Rename Project concept to Novel across the stack
Renames the domain concept from Project to Novel throughout the backend (entities, DTOs, services, endpoints, ProjectAccessService/Permission, ProjectId foreign keys), MCP server (tool names and routes), and the React/Vite frontend (types, hooks, routes, components). Adds a new EF Core migration (RenameProjectToNovel) using RenameTable/RenameColumn to preserve existing data instead of dropping/recreating tables. Updates CLAUDE.md's structure section to reference Novels/ instead of Projects/.
This commit is contained in:
@@ -1,14 +1,14 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Novels;
|
||||
|
||||
namespace Novelly.Api.Agent;
|
||||
|
||||
public class AgentConversation
|
||||
{
|
||||
public Guid Id { get; init; } = Guid.NewGuid();
|
||||
public Guid ProjectId { get; init; }
|
||||
public Project? Project { get; init; }
|
||||
public Guid NovelId { get; init; }
|
||||
public Novel? Novel { get; init; }
|
||||
|
||||
public string Title { get; init; } = "New conversation";
|
||||
|
||||
|
||||
@@ -7,22 +7,22 @@ public static class AgentEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapAgentEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/agent").WithTags("Agent")
|
||||
var novelScoped = app.MapGroup("/api/novels/{novelId:guid}/agent").WithTags("Agent")
|
||||
.AddEndpointFilter<RequestLoggingEndpointFilter>()
|
||||
.AddEndpointFilter<ValidationEndpointFilter>();
|
||||
|
||||
projectScoped.MapGet("/conversations", async (
|
||||
Guid projectId, NovelAgentService agent, CancellationToken ct) =>
|
||||
Results.Ok(await agent.ListConversationsAsync(projectId, ct)))
|
||||
.WithSummary("List the project's agent conversations.");
|
||||
novelScoped.MapGet("/conversations", async (
|
||||
Guid novelId, NovelAgentService agent, CancellationToken ct) =>
|
||||
Results.Ok(await agent.ListConversationsAsync(novelId, ct)))
|
||||
.WithSummary("List the novel's agent conversations.");
|
||||
|
||||
projectScoped.MapPost("/messages", async (
|
||||
Guid projectId,
|
||||
novelScoped.MapPost("/messages", async (
|
||||
Guid novelId,
|
||||
SendAgentMessageRequest request,
|
||||
NovelAgentService agent,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var reply = await agent.SendMessageAsync(projectId, request, ct);
|
||||
var reply = await agent.SendMessageAsync(novelId, request, ct);
|
||||
return reply is null
|
||||
? Results.NotFound()
|
||||
: Results.Ok(new AgentTurnResponse(reply.ConversationId, reply.ToResponse()));
|
||||
|
||||
@@ -4,9 +4,9 @@ using Novelly.Api.Common.Validation;
|
||||
|
||||
namespace Novelly.Api.Agent;
|
||||
|
||||
public record ConversationSummaryResponse(Guid Id, Guid ProjectId, string Title, int MessageCount, DateTimeOffset UpdatedAt);
|
||||
public record ConversationSummaryResponse(Guid Id, Guid NovelId, string Title, int MessageCount, DateTimeOffset UpdatedAt);
|
||||
|
||||
public record ConversationResponse(Guid Id, Guid ProjectId, string Title, IReadOnlyList<AgentMessageResponse> Messages, DateTimeOffset UpdatedAt);
|
||||
public record ConversationResponse(Guid Id, Guid NovelId, string Title, IReadOnlyList<AgentMessageResponse> Messages, DateTimeOffset UpdatedAt);
|
||||
|
||||
public record AgentMessageResponse(Guid Id, AgentRole Role, string Content, IReadOnlyList<ToolCallResponse> ToolCalls, DateTimeOffset CreatedAt);
|
||||
|
||||
@@ -46,7 +46,7 @@ public static class AgentMapping
|
||||
|
||||
public static ConversationResponse ToResponse(this AgentConversation conversation) => new(
|
||||
conversation.Id,
|
||||
conversation.ProjectId,
|
||||
conversation.NovelId,
|
||||
conversation.Title,
|
||||
[.. conversation.Messages.OrderBy(m => m.Sequence).Select(m => m.ToResponse())],
|
||||
conversation.UpdatedAt);
|
||||
|
||||
@@ -6,7 +6,7 @@ using Microsoft.Extensions.Options;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Common.Validation;
|
||||
using Novelly.Api.Data;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Novels;
|
||||
|
||||
namespace Novelly.Api.Agent;
|
||||
|
||||
@@ -26,14 +26,14 @@ public class NovelAgentService(
|
||||
private readonly AgentOptions _options = options.Value;
|
||||
|
||||
public async Task<IReadOnlyList<ConversationSummaryResponse>> ListConversationsAsync(
|
||||
Guid projectId, CancellationToken ct = default)
|
||||
Guid novelId, CancellationToken ct = default)
|
||||
{
|
||||
logger.LogInformation("Listing agent conversations for project {ProjectId}", projectId);
|
||||
logger.LogInformation("Listing agent conversations for novel {NovelId}", novelId);
|
||||
|
||||
return await db.Conversations
|
||||
.Where(c => c.ProjectId == projectId)
|
||||
.Where(c => c.NovelId == novelId)
|
||||
.OrderByDescending(c => c.UpdatedAt)
|
||||
.Select(c => new ConversationSummaryResponse(c.Id, c.ProjectId, c.Title, c.Messages.Count, c.UpdatedAt))
|
||||
.Select(c => new ConversationSummaryResponse(c.Id, c.NovelId, c.Title, c.Messages.Count, c.UpdatedAt))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
@@ -63,39 +63,39 @@ public class NovelAgentService(
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<AgentMessage?> SendMessageAsync(Guid projectId, SendAgentMessageRequest request, CancellationToken ct = default)
|
||||
public async Task<AgentMessage?> SendMessageAsync(Guid novelId, SendAgentMessageRequest request, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(projectId, nameof(projectId));
|
||||
Guard.Default(novelId, nameof(novelId));
|
||||
Guard.Null(request, nameof(request));
|
||||
sendMessageValidator.Validate(request).ThrowIfInvalid(logger);
|
||||
|
||||
logger.LogInformation(
|
||||
"Sending agent message for project {ProjectId}, conversation {ConversationId}, message length {MessageLength}",
|
||||
projectId, request.ConversationId, request.Message.Length);
|
||||
"Sending agent message for novel {NovelId}, conversation {ConversationId}, message length {MessageLength}",
|
||||
novelId, request.ConversationId, request.Message.Length);
|
||||
|
||||
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == projectId, ct);
|
||||
if (project is null)
|
||||
var novel = await db.Novels.FirstOrDefaultAsync(p => p.Id == novelId, ct);
|
||||
if (novel is null)
|
||||
{
|
||||
logger.LogWarning("Project {ProjectId} not found", projectId);
|
||||
logger.LogWarning("Novel {NovelId} not found", novelId);
|
||||
return null;
|
||||
}
|
||||
|
||||
var conversation = request.ConversationId is { } id
|
||||
? await FindConversationAsync(id, ct)
|
||||
: StartConversation(projectId, request.Message);
|
||||
: StartConversation(novelId, request.Message);
|
||||
|
||||
if (conversation is null) return null;
|
||||
|
||||
await AppendMessageAsync(conversation, AgentRole.User, request.Message, null, ct);
|
||||
|
||||
var systemPrompt = BuildSystemPrompt(project);
|
||||
var systemPrompt = BuildSystemPrompt(novel);
|
||||
var transcript = BuildTranscript(conversation);
|
||||
var toolCalls = new List<ToolCallResponse>();
|
||||
var text = new StringBuilder();
|
||||
|
||||
for (var iteration = 0; iteration < _options.MaxIterations; iteration++)
|
||||
{
|
||||
logger.LogDebug("Agent iteration {Iteration} for project {ProjectId}", iteration, projectId);
|
||||
logger.LogDebug("Agent iteration {Iteration} for novel {NovelId}", iteration, novelId);
|
||||
|
||||
var response = await model.CompleteAsync(systemPrompt, transcript, toolset.Definitions, ct);
|
||||
|
||||
@@ -113,9 +113,9 @@ public class NovelAgentService(
|
||||
var results = new List<AgentContentBlock>();
|
||||
foreach (var call in requestedTools)
|
||||
{
|
||||
var outcome = await toolset.ExecuteAsync(call.Name, projectId, call.Input, ct);
|
||||
var outcome = await toolset.ExecuteAsync(call.Name, novelId, call.Input, ct);
|
||||
|
||||
logger.Log(outcome.IsError ? LogLevel.Warning : LogLevel.Information, "Agent tool {Tool} on project {ProjectId} {Outcome}", call.Name, projectId, outcome.IsError ? "failed" : "succeeded");
|
||||
logger.Log(outcome.IsError ? LogLevel.Warning : LogLevel.Information, "Agent tool {Tool} on novel {NovelId} {Outcome}", call.Name, novelId, outcome.IsError ? "failed" : "succeeded");
|
||||
|
||||
toolCalls.Add(new ToolCallResponse(call.Name, call.Input.ToString(), outcome.Content));
|
||||
results.Add(new AgentToolResultBlock(call.Id, outcome.Content, outcome.IsError));
|
||||
@@ -125,7 +125,7 @@ public class NovelAgentService(
|
||||
|
||||
if (iteration != _options.MaxIterations - 1) continue;
|
||||
|
||||
logger.LogWarning("Agent hit the {Max}-iteration ceiling on project {ProjectId}", _options.MaxIterations, projectId);
|
||||
logger.LogWarning("Agent hit the {Max}-iteration ceiling on novel {NovelId}", _options.MaxIterations, novelId);
|
||||
|
||||
text.AppendLine("_I reached my tool-call limit for this turn. Ask me to continue if there's more to do._");
|
||||
}
|
||||
@@ -165,19 +165,19 @@ public class NovelAgentService(
|
||||
return message;
|
||||
}
|
||||
|
||||
private AgentConversation StartConversation(Guid projectId, string firstMessage)
|
||||
private AgentConversation StartConversation(Guid novelId, string firstMessage)
|
||||
{
|
||||
logger.LogDebug("Starting new agent conversation for project {ProjectId}", projectId);
|
||||
logger.LogDebug("Starting new agent conversation for novel {NovelId}", novelId);
|
||||
|
||||
var conversation = new AgentConversation
|
||||
{
|
||||
ProjectId = projectId,
|
||||
NovelId = novelId,
|
||||
Title = Summarise(firstMessage)
|
||||
};
|
||||
|
||||
db.Conversations.Add(conversation);
|
||||
|
||||
logger.LogDebug("Started agent conversation {ConversationId} for project {ProjectId}", conversation.Id, projectId);
|
||||
logger.LogDebug("Started agent conversation {ConversationId} for novel {NovelId}", conversation.Id, novelId);
|
||||
return conversation;
|
||||
}
|
||||
|
||||
@@ -209,25 +209,25 @@ public class NovelAgentService(
|
||||
[new AgentTextBlock(m.Content)]))
|
||||
];
|
||||
|
||||
private static string BuildSystemPrompt(Project project)
|
||||
private static string BuildSystemPrompt(Novel novel)
|
||||
{
|
||||
var brief = new StringBuilder();
|
||||
brief.AppendLine($"Title: {project.Title}");
|
||||
if (!string.IsNullOrWhiteSpace(project.Genre)) brief.AppendLine($"Genre: {project.Genre}");
|
||||
if (!string.IsNullOrWhiteSpace(project.Logline)) brief.AppendLine($"Logline: {project.Logline}");
|
||||
if (project.TargetWordCount is { } target) brief.AppendLine($"Target length: {target:N0} words");
|
||||
brief.AppendLine($"Title: {novel.Title}");
|
||||
if (!string.IsNullOrWhiteSpace(novel.Genre)) brief.AppendLine($"Genre: {novel.Genre}");
|
||||
if (!string.IsNullOrWhiteSpace(novel.Logline)) brief.AppendLine($"Logline: {novel.Logline}");
|
||||
if (novel.TargetWordCount is { } target) brief.AppendLine($"Target length: {target:N0} words");
|
||||
|
||||
return $"""
|
||||
You are a developmental editor and writing partner embedded in the software the
|
||||
writer is using to plan their novel. You have tools that read and write the
|
||||
project's real data: the brief, character dossiers, the outline (beats) and each
|
||||
novel's real data: the brief, character dossiers, the outline (beats) and each
|
||||
chapter's drafted prose.
|
||||
|
||||
The project you are working on:
|
||||
The novel you are working on:
|
||||
{brief}
|
||||
Working principles:
|
||||
|
||||
- Read before you write. Call get_project_brief, get_outline, or list_characters
|
||||
- Read before you write. Call get_novel_brief, get_outline, or list_characters
|
||||
to ground yourself rather than assuming what is already there.
|
||||
- The book is the writer's. Ask about the choices that define the story — what a
|
||||
character wants, what the ending costs them — instead of deciding for them.
|
||||
@@ -239,7 +239,7 @@ public class NovelAgentService(
|
||||
genuinely in tension, what the outline is missing — over line-level polish,
|
||||
unless the writer asks for prose.
|
||||
- When drafting a chapter's prose, match the voice already established in the
|
||||
project. Write the chapter, then stop; do not append notes about your choices.
|
||||
novel. Write the chapter, then stop; do not append notes about your choices.
|
||||
- Destructive operations (deleting outline nodes) need the writer's explicit
|
||||
go-ahead first.
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ using Novelly.Api.Beats;
|
||||
using Novelly.Api.Chapters;
|
||||
using Novelly.Api.Characters;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Novels;
|
||||
using Novelly.Api.Questions;
|
||||
using Novelly.Api.Tags;
|
||||
|
||||
@@ -23,7 +23,7 @@ public record AgentTool(
|
||||
Func<Guid, JsonElement, CancellationToken, Task<object?>> Handler);
|
||||
|
||||
public class NovelAgentToolset(
|
||||
ProjectService projects,
|
||||
NovelService novels,
|
||||
CharacterService characters,
|
||||
CharacterArcService arcs,
|
||||
ChapterService chapters,
|
||||
@@ -45,7 +45,7 @@ public class NovelAgentToolset(
|
||||
public IReadOnlyList<AgentToolDefinition> Definitions =>
|
||||
[.. Tools.Select(t => new AgentToolDefinition(t.Name, t.Description, t.InputSchema))];
|
||||
|
||||
public async Task<AgentToolResult> ExecuteAsync(string name, Guid projectId, JsonElement input, CancellationToken ct = default)
|
||||
public async Task<AgentToolResult> ExecuteAsync(string name, Guid novelId, JsonElement input, CancellationToken ct = default)
|
||||
{
|
||||
if (!ByName.TryGetValue(name, out var tool))
|
||||
{
|
||||
@@ -53,29 +53,29 @@ public class NovelAgentToolset(
|
||||
return new AgentToolResult($"No such tool: '{name}'.", true);
|
||||
}
|
||||
|
||||
logger.LogDebug("Running tool {Tool} for project {ProjectId}", name, projectId);
|
||||
logger.LogDebug("Running tool {Tool} for novel {NovelId}", name, novelId);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await tool.Handler(projectId, input, ct);
|
||||
var result = await tool.Handler(novelId, input, ct);
|
||||
|
||||
if (result is ToolNotFound notFound)
|
||||
{
|
||||
logger.LogWarning("Tool {Tool} for project {ProjectId} found no {Entity} {EntityId}", name, projectId, notFound.Entity, notFound.Id);
|
||||
logger.LogWarning("Tool {Tool} for novel {NovelId} found no {Entity} {EntityId}", name, novelId, notFound.Entity, notFound.Id);
|
||||
return new AgentToolResult(notFound.Message, true);
|
||||
}
|
||||
|
||||
logger.LogDebug("Tool {Tool} for project {ProjectId} succeeded", name, projectId);
|
||||
logger.LogDebug("Tool {Tool} for novel {NovelId} succeeded", name, novelId);
|
||||
return new AgentToolResult(JsonSerializer.Serialize(result, SerializerOptions), false);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Tool {Tool} for project {ProjectId} failed: invalid argument", name, projectId);
|
||||
logger.LogWarning(ex, "Tool {Tool} for novel {NovelId} failed: invalid argument", name, novelId);
|
||||
return new AgentToolResult(ex.Message, true);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Tool {Tool} for project {ProjectId} failed: invalid operation", name, projectId);
|
||||
logger.LogWarning(ex, "Tool {Tool} for novel {NovelId} failed: invalid operation", name, novelId);
|
||||
return new AgentToolResult(ex.Message, true);
|
||||
}
|
||||
}
|
||||
@@ -95,15 +95,15 @@ public class NovelAgentToolset(
|
||||
private IEnumerable<AgentTool> Build()
|
||||
{
|
||||
yield return new AgentTool(
|
||||
"get_project_brief",
|
||||
"Read the project's title, logline, synopsis, genre, notes and word-count target. "
|
||||
"get_novel_brief",
|
||||
"Read the novel's title, logline, synopsis, genre, notes and word-count target. "
|
||||
+ "Call this first in a conversation to ground yourself in what the book is.",
|
||||
new JsonSchemaBuilder().Build(),
|
||||
async (projectId, _, ct) => await OrNotFound(projects.GetAsync(projectId, ct), p => p.ToResponse(null), "Project", projectId));
|
||||
async (novelId, _, ct) => await OrNotFound(novels.GetAsync(novelId, ct), p => p.ToResponse(null), "Novel", novelId));
|
||||
|
||||
yield return new AgentTool(
|
||||
"update_project_brief",
|
||||
"Revise the project's top-level fields. Only the fields you supply change; "
|
||||
"update_novel_brief",
|
||||
"Revise the novel's top-level fields. Only the fields you supply change; "
|
||||
+ "pass an empty string to clear a field.",
|
||||
new JsonSchemaBuilder()
|
||||
.Str("title", "New title.")
|
||||
@@ -114,27 +114,27 @@ public class NovelAgentToolset(
|
||||
.Str("notes", "Free-form notes on theme, tone, comparable titles.")
|
||||
.Int("target_word_count", "Target manuscript length in words.")
|
||||
.Build(),
|
||||
async (projectId, input, ct) => await OrNotFound(projects.UpdateAsync(projectId, new UpdateProjectRequest(
|
||||
async (novelId, input, ct) => await OrNotFound(novels.UpdateAsync(novelId, new UpdateNovelRequest(
|
||||
JsonInput.String(input, "title"),
|
||||
JsonInput.String(input, "author"),
|
||||
JsonInput.String(input, "genre"),
|
||||
JsonInput.String(input, "logline"),
|
||||
JsonInput.String(input, "synopsis"),
|
||||
JsonInput.String(input, "notes"),
|
||||
JsonInput.Int(input, "target_word_count")), ct), p => p.ToResponse(null), "Project", projectId));
|
||||
JsonInput.Int(input, "target_word_count")), ct), p => p.ToResponse(null), "Novel", novelId));
|
||||
|
||||
yield return new AgentTool(
|
||||
"list_characters",
|
||||
"List every character in the project with their full dossiers.",
|
||||
"List every character in the novel with their full dossiers.",
|
||||
new JsonSchemaBuilder().Build(),
|
||||
async (projectId, _, ct) => (await characters.ListAsync(projectId, ct)).Select(c => c.ToResponse()));
|
||||
async (novelId, _, ct) => (await characters.ListAsync(novelId, ct)).Select(c => c.ToResponse()));
|
||||
|
||||
yield return new AgentTool(
|
||||
"create_character",
|
||||
"Add a character dossier. Name is the only requirement — leave fields blank when "
|
||||
+ "the writer has not decided them yet rather than inventing detail.",
|
||||
CharacterSchema(includeName: true, nameRequired: true).Build(),
|
||||
async (projectId, input, ct) => await OrNotFound(characters.CreateAsync(projectId, new CreateCharacterRequest(
|
||||
async (novelId, input, ct) => await OrNotFound(characters.CreateAsync(novelId, new CreateCharacterRequest(
|
||||
JsonInput.RequiredString(input, "name"),
|
||||
JsonInput.Enum<CharacterRole>(input, "role") ?? CharacterRole.Supporting,
|
||||
JsonInput.Enum<CharacterImportance>(input, "importance") ?? CharacterImportance.Supporting,
|
||||
@@ -152,7 +152,7 @@ public class NovelAgentToolset(
|
||||
JsonInput.String(input, "voice"),
|
||||
JsonInput.String(input, "notes"),
|
||||
JsonInput.Strings(input, "tags"),
|
||||
JsonInput.Strings(input, "aliases")), ct), c => c.ToResponse(), "Project", projectId));
|
||||
JsonInput.Strings(input, "aliases")), ct), c => c.ToResponse(), "Novel", novelId));
|
||||
|
||||
yield return new AgentTool(
|
||||
"update_character",
|
||||
@@ -189,7 +189,7 @@ public class NovelAgentToolset(
|
||||
yield return new AgentTool(
|
||||
"link_character_identity",
|
||||
"Record that a character is really another character — e.g. one introduced under one name "
|
||||
+ "who is later revealed to be a character already in the project under another name. Both "
|
||||
+ "who is later revealed to be a character already in the novel under another name. Both "
|
||||
+ "keep their own dossier and beats; the canonical identity is whichever character you link to.",
|
||||
new JsonSchemaBuilder()
|
||||
.Str("character_id", "Id of the character being revealed as someone else.", required: true)
|
||||
@@ -348,10 +348,10 @@ public class NovelAgentToolset(
|
||||
|
||||
yield return new AgentTool(
|
||||
"list_tags",
|
||||
"List the project's tags with how many characters, chapters and beats carry each. "
|
||||
"List the novel's tags with how many characters, chapters and beats carry each. "
|
||||
+ "Read this before inventing a new tag so you reuse the writer's vocabulary.",
|
||||
new JsonSchemaBuilder().Build(),
|
||||
async (projectId, _, ct) => await tags.ListAsync(projectId, ct));
|
||||
async (novelId, _, ct) => await tags.ListAsync(novelId, ct));
|
||||
|
||||
yield return new AgentTool(
|
||||
"get_tag_references",
|
||||
@@ -368,9 +368,9 @@ public class NovelAgentToolset(
|
||||
|
||||
yield return new AgentTool(
|
||||
"list_chapters",
|
||||
"List the project's chapters in manuscript order with beat and word counts.",
|
||||
"List the novel's chapters in manuscript order with beat and word counts.",
|
||||
new JsonSchemaBuilder().Build(),
|
||||
async (projectId, _, ct) => (await chapters.ListAsync(projectId, ct)).Select(c => c.ToSummaryResponse()));
|
||||
async (novelId, _, ct) => (await chapters.ListAsync(novelId, ct)).Select(c => c.ToSummaryResponse()));
|
||||
|
||||
yield return new AgentTool(
|
||||
"get_chapter",
|
||||
@@ -398,7 +398,7 @@ public class NovelAgentToolset(
|
||||
.Str("prose", "The chapter's drafted text, in markdown, if you are writing it now.")
|
||||
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.")
|
||||
.Build(),
|
||||
async (projectId, input, ct) => await OrNotFound(chapters.CreateAsync(projectId, new CreateChapterRequest(
|
||||
async (novelId, input, ct) => await OrNotFound(chapters.CreateAsync(novelId, new CreateChapterRequest(
|
||||
JsonInput.RequiredString(input, "title"),
|
||||
JsonInput.Int(input, "number"),
|
||||
JsonInput.String(input, "summary"),
|
||||
@@ -407,7 +407,7 @@ public class NovelAgentToolset(
|
||||
JsonInput.Enum<DraftStatus>(input, "status") ?? DraftStatus.Planned,
|
||||
JsonInput.Int(input, "target_word_count"),
|
||||
JsonInput.String(input, "prose"),
|
||||
JsonInput.Strings(input, "tags")), ct), c => c.ToResponse(), "Project", projectId));
|
||||
JsonInput.Strings(input, "tags")), ct), c => c.ToResponse(), "Novel", novelId));
|
||||
|
||||
yield return new AgentTool(
|
||||
"update_chapter",
|
||||
@@ -548,8 +548,8 @@ public class NovelAgentToolset(
|
||||
.Str("character_id", "Narrow to questions about one character.")
|
||||
.Bool("include_resolved", "Include questions already settled. Defaults to false.")
|
||||
.Build(),
|
||||
async (projectId, input, ct) => (await questions.ListAsync(
|
||||
projectId,
|
||||
async (novelId, input, ct) => (await questions.ListAsync(
|
||||
novelId,
|
||||
JsonInput.Guid(input, "chapter_id"),
|
||||
JsonInput.Guid(input, "character_id"),
|
||||
JsonInput.Bool(input, "include_resolved") ?? false,
|
||||
@@ -566,13 +566,13 @@ public class NovelAgentToolset(
|
||||
.Str("chapter_id", "The chapter outline this is about, if any.")
|
||||
.Str("character_id", "The character this is about, if any.")
|
||||
.Build(),
|
||||
async (projectId, input, ct) => await OrNotFound(questions.CreateAsync(
|
||||
projectId,
|
||||
async (novelId, input, ct) => await OrNotFound(questions.CreateAsync(
|
||||
novelId,
|
||||
new CreateOpenQuestionRequest(
|
||||
JsonInput.RequiredString(input, "question"),
|
||||
JsonInput.String(input, "detail"),
|
||||
JsonInput.Guid(input, "chapter_id"),
|
||||
JsonInput.Guid(input, "character_id")), ct), q => q.ToResponse(), "Project", projectId));
|
||||
JsonInput.Guid(input, "character_id")), ct), q => q.ToResponse(), "Novel", novelId));
|
||||
|
||||
yield return new AgentTool(
|
||||
"resolve_open_question",
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace Novelly.Api.Beats;
|
||||
|
||||
public class BeatService(
|
||||
INovelDbContext db,
|
||||
ProjectAccessService access,
|
||||
NovelAccessService access,
|
||||
TagService tags,
|
||||
ILogger<BeatService> logger,
|
||||
IModelValidator<CreateBeatRequest> createValidator,
|
||||
@@ -26,7 +26,7 @@ public class BeatService(
|
||||
|
||||
logger.LogInformation("Listing beats for chapter {ChapterId}", chapterId);
|
||||
|
||||
await RequireChapterAccessAsync(chapterId, ProjectPermission.Read, ct);
|
||||
await RequireChapterAccessAsync(chapterId, NovelPermission.Read, ct);
|
||||
|
||||
return await Query()
|
||||
.Where(b => b.ChapterId == chapterId)
|
||||
@@ -46,7 +46,7 @@ public class BeatService(
|
||||
return null;
|
||||
}
|
||||
|
||||
await RequireBeatAccessAsync(beat, ProjectPermission.Read, ct);
|
||||
await RequireBeatAccessAsync(beat, NovelPermission.Read, ct);
|
||||
return beat;
|
||||
}
|
||||
|
||||
@@ -57,14 +57,14 @@ public class BeatService(
|
||||
|
||||
logger.LogInformation("Listing beats for character {CharacterId}", characterId);
|
||||
|
||||
var characterProjectId = await db.Characters.Where(c => c.Id == characterId).Select(c => (Guid?)c.ProjectId).FirstOrDefaultAsync(ct);
|
||||
if (characterProjectId is null)
|
||||
var characterNovelId = await db.Characters.Where(c => c.Id == characterId).Select(c => (Guid?)c.NovelId).FirstOrDefaultAsync(ct);
|
||||
if (characterNovelId is null)
|
||||
{
|
||||
logger.LogWarning("Character {CharacterId} not found", characterId);
|
||||
return null;
|
||||
}
|
||||
|
||||
await access.RequireAsync(characterProjectId.Value, ProjectPermission.Read, ct);
|
||||
await access.RequireAsync(characterNovelId.Value, NovelPermission.Read, ct);
|
||||
|
||||
var beats = await db.Beats
|
||||
.Include(b => b.Chapter)
|
||||
@@ -95,7 +95,7 @@ public class BeatService(
|
||||
return null;
|
||||
}
|
||||
|
||||
await access.RequireAsync(chapter.ProjectId, ProjectPermission.CreateContent, ct);
|
||||
await access.RequireAsync(chapter.NovelId, NovelPermission.CreateContent, ct);
|
||||
|
||||
var beat = new Beat
|
||||
{
|
||||
@@ -108,12 +108,12 @@ public class BeatService(
|
||||
|
||||
if (request.CharacterIds is { } characterIds)
|
||||
{
|
||||
beat.Characters = await ResolveCharactersAsync(chapter.ProjectId, characterIds, ct);
|
||||
beat.Characters = await ResolveCharactersAsync(chapter.NovelId, characterIds, ct);
|
||||
}
|
||||
|
||||
if (request.Tags is { } names)
|
||||
{
|
||||
beat.Tags = await tags.ResolveAsync(chapter.ProjectId, names, ct);
|
||||
beat.Tags = await tags.ResolveAsync(chapter.NovelId, names, ct);
|
||||
}
|
||||
|
||||
chapter.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
@@ -145,7 +145,7 @@ public class BeatService(
|
||||
return null;
|
||||
}
|
||||
|
||||
await access.RequireAsync(chapter.ProjectId, ProjectPermission.Write, ct);
|
||||
await access.RequireAsync(chapter.NovelId, NovelPermission.Write, ct);
|
||||
|
||||
beat.Title = Patch.Apply(beat.Title, request.Title) ?? beat.Title;
|
||||
beat.SortOrder = request.SortOrder ?? beat.SortOrder;
|
||||
@@ -156,12 +156,12 @@ public class BeatService(
|
||||
|
||||
if (request.CharacterIds is { } characterIds)
|
||||
{
|
||||
beat.Characters = await ResolveCharactersAsync(chapter.ProjectId, characterIds, ct);
|
||||
beat.Characters = await ResolveCharactersAsync(chapter.NovelId, characterIds, ct);
|
||||
}
|
||||
|
||||
if (request.Tags is { } names)
|
||||
{
|
||||
beat.Tags = await tags.ResolveAsync(chapter.ProjectId, names, ct);
|
||||
beat.Tags = await tags.ResolveAsync(chapter.NovelId, names, ct);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
@@ -180,7 +180,7 @@ public class BeatService(
|
||||
return false;
|
||||
}
|
||||
|
||||
await RequireBeatAccessAsync(beat, ProjectPermission.DeleteContent, ct);
|
||||
await RequireBeatAccessAsync(beat, NovelPermission.DeleteContent, ct);
|
||||
|
||||
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == beat.ChapterId, ct);
|
||||
if (chapter is not null) chapter.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
@@ -199,7 +199,7 @@ public class BeatService(
|
||||
|
||||
logger.LogInformation("Reordering {Count} beats for chapter {ChapterId}", request.BeatIds.Count, chapterId);
|
||||
|
||||
await RequireChapterAccessAsync(chapterId, ProjectPermission.Write, ct);
|
||||
await RequireChapterAccessAsync(chapterId, NovelPermission.Write, ct);
|
||||
|
||||
var beats = await db.Beats.Where(b => b.ChapterId == chapterId).ToListAsync(ct);
|
||||
|
||||
@@ -246,15 +246,15 @@ public class BeatService(
|
||||
return null;
|
||||
}
|
||||
|
||||
await access.RequireAsync(chapter.ProjectId, ProjectPermission.Write, ct);
|
||||
await access.RequireAsync(chapter.NovelId, NovelPermission.Write, ct);
|
||||
|
||||
var character = await db.Characters
|
||||
.FirstOrDefaultAsync(c => c.Id == request.CharacterId && c.ProjectId == chapter.ProjectId, ct);
|
||||
.FirstOrDefaultAsync(c => c.Id == request.CharacterId && c.NovelId == chapter.NovelId, ct);
|
||||
if (character is null)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Rejected character assignment: character {CharacterId} not found in project {ProjectId}",
|
||||
request.CharacterId, chapter.ProjectId);
|
||||
"Rejected character assignment: character {CharacterId} not found in novel {NovelId}",
|
||||
request.CharacterId, chapter.NovelId);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -297,16 +297,16 @@ public class BeatService(
|
||||
}
|
||||
|
||||
var targetChapter = await db.Chapters.FirstOrDefaultAsync(
|
||||
c => c.Id == request.TargetChapterId && c.ProjectId == chapter.ProjectId, ct);
|
||||
c => c.Id == request.TargetChapterId && c.NovelId == chapter.NovelId, ct);
|
||||
if (targetChapter is null)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Rejected beat move: target chapter {TargetChapterId} not found in project {ProjectId}",
|
||||
request.TargetChapterId, chapter.ProjectId);
|
||||
"Rejected beat move: target chapter {TargetChapterId} not found in novel {NovelId}",
|
||||
request.TargetChapterId, chapter.NovelId);
|
||||
return null;
|
||||
}
|
||||
|
||||
await access.RequireAsync(chapter.ProjectId, ProjectPermission.Write, ct);
|
||||
await access.RequireAsync(chapter.NovelId, NovelPermission.Write, ct);
|
||||
|
||||
var beats = await Query().Where(b => b.ChapterId == chapterId && request.BeatIds.Contains(b.Id)).ToListAsync(ct);
|
||||
var missing = request.BeatIds.Where(id => beats.All(b => b.Id != id)).ToList();
|
||||
@@ -338,9 +338,9 @@ public class BeatService(
|
||||
return beats;
|
||||
}
|
||||
|
||||
private async Task<List<Character>> ResolveCharactersAsync(Guid projectId, IReadOnlyList<Guid> characterIds, CancellationToken ct)
|
||||
private async Task<List<Character>> ResolveCharactersAsync(Guid novelId, IReadOnlyList<Guid> characterIds, CancellationToken ct)
|
||||
{
|
||||
logger.LogDebug("Resolving {Count} characters for project {ProjectId}", characterIds.Count, projectId);
|
||||
logger.LogDebug("Resolving {Count} characters for novel {NovelId}", characterIds.Count, novelId);
|
||||
|
||||
var distinct = characterIds.Distinct().ToList();
|
||||
if (distinct.Count == 0)
|
||||
@@ -349,17 +349,17 @@ public class BeatService(
|
||||
}
|
||||
|
||||
var found = await db.Characters
|
||||
.Where(c => c.ProjectId == projectId && distinct.Contains(c.Id))
|
||||
.Where(c => c.NovelId == novelId && distinct.Contains(c.Id))
|
||||
.ToListAsync(ct);
|
||||
|
||||
if (found.Count != distinct.Count)
|
||||
{
|
||||
logger.LogWarning("Rejected beat reference: one or more characters do not belong to project {ProjectId}", projectId);
|
||||
logger.LogWarning("Rejected beat reference: one or more characters do not belong to novel {NovelId}", novelId);
|
||||
throw new InvalidOperationException(
|
||||
"A beat's characters must belong to the same project as its chapter.");
|
||||
"A beat's characters must belong to the same novel as its chapter.");
|
||||
}
|
||||
|
||||
logger.LogDebug("Resolved {Count} characters for project {ProjectId}", found.Count, projectId);
|
||||
logger.LogDebug("Resolved {Count} characters for novel {NovelId}", found.Count, novelId);
|
||||
return found;
|
||||
}
|
||||
|
||||
@@ -376,13 +376,13 @@ public class BeatService(
|
||||
return next;
|
||||
}
|
||||
|
||||
private async Task RequireChapterAccessAsync(Guid chapterId, ProjectPermission permission, CancellationToken ct)
|
||||
private async Task RequireChapterAccessAsync(Guid chapterId, NovelPermission permission, CancellationToken ct)
|
||||
{
|
||||
var projectId = await db.Chapters.Where(c => c.Id == chapterId).Select(c => c.ProjectId).FirstOrDefaultAsync(ct);
|
||||
await access.RequireAsync(projectId, permission, ct);
|
||||
var novelId = await db.Chapters.Where(c => c.Id == chapterId).Select(c => c.NovelId).FirstOrDefaultAsync(ct);
|
||||
await access.RequireAsync(novelId, permission, ct);
|
||||
}
|
||||
|
||||
private Task RequireBeatAccessAsync(Beat beat, ProjectPermission permission, CancellationToken ct) =>
|
||||
private Task RequireBeatAccessAsync(Beat beat, NovelPermission permission, CancellationToken ct) =>
|
||||
RequireChapterAccessAsync(beat.ChapterId, permission, ct);
|
||||
|
||||
private IQueryable<Beat> Query() =>
|
||||
|
||||
@@ -2,7 +2,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using Novelly.Api.Beats;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Novels;
|
||||
using Novelly.Api.Tags;
|
||||
|
||||
namespace Novelly.Api.Chapters;
|
||||
@@ -10,8 +10,8 @@ namespace Novelly.Api.Chapters;
|
||||
public class Chapter
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public Guid ProjectId { get; set; }
|
||||
public Project? Project { get; set; }
|
||||
public Guid NovelId { get; set; }
|
||||
public Novel? Novel { get; set; }
|
||||
|
||||
public int Number { get; set; }
|
||||
|
||||
@@ -43,6 +43,6 @@ public class ChapterEntityTypeConfiguration : IEntityTypeConfiguration<Chapter>
|
||||
{
|
||||
entity.Property(c => c.Title).IsRequired().HasMaxLength(300);
|
||||
entity.Property(c => c.Status).HasConversion<string>().HasMaxLength(32);
|
||||
entity.HasIndex(c => new { c.ProjectId, c.Number });
|
||||
entity.HasIndex(c => new { c.NovelId, c.Number });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace Novelly.Api.Chapters;
|
||||
|
||||
public record ChapterSummaryResponse(
|
||||
Guid Id,
|
||||
Guid ProjectId,
|
||||
Guid NovelId,
|
||||
int Number,
|
||||
string Title,
|
||||
string? Summary,
|
||||
@@ -21,7 +21,7 @@ public record ChapterSummaryResponse(
|
||||
|
||||
public record ChapterResponse(
|
||||
Guid Id,
|
||||
Guid ProjectId,
|
||||
Guid NovelId,
|
||||
int Number,
|
||||
string Title,
|
||||
string? Summary,
|
||||
@@ -115,7 +115,7 @@ file static class ChapterValidation
|
||||
public static class ChapterMapping
|
||||
{
|
||||
public static ChapterResponse ToResponse(this Chapter c) => new(
|
||||
c.Id, c.ProjectId, c.Number, c.Title, c.Summary,
|
||||
c.Id, c.NovelId, c.Number, c.Title, c.Summary,
|
||||
c.Setting, c.Notes,
|
||||
c.Status, c.TargetWordCount,
|
||||
[.. c.Beats.OrderBy(b => b.SortOrder).Select(b => b.ToResponse())],
|
||||
@@ -124,7 +124,7 @@ public static class ChapterMapping
|
||||
c.UpdatedAt);
|
||||
|
||||
public static ChapterSummaryResponse ToSummaryResponse(this Chapter c) => new(
|
||||
c.Id, c.ProjectId, c.Number, c.Title, c.Summary,
|
||||
c.Id, c.NovelId, c.Number, c.Title, c.Summary,
|
||||
c.Setting, c.Status, c.TargetWordCount,
|
||||
c.Beats.Count, c.WordCount,
|
||||
[.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())],
|
||||
|
||||
@@ -7,18 +7,18 @@ public static class ChapterEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapChapterEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/chapters").WithTags("Chapters")
|
||||
var novelScoped = app.MapGroup("/api/novels/{novelId:guid}/chapters").WithTags("Chapters")
|
||||
.AddEndpointFilter<RequestLoggingEndpointFilter>()
|
||||
.AddEndpointFilter<ValidationEndpointFilter>();
|
||||
|
||||
projectScoped.MapGet("/", async (Guid projectId, ChapterService service, CancellationToken ct) =>
|
||||
Results.Ok((await service.ListAsync(projectId, ct)).Select(c => c.ToSummaryResponse())))
|
||||
.WithSummary("List a project's chapters in manuscript order.");
|
||||
novelScoped.MapGet("/", async (Guid novelId, ChapterService service, CancellationToken ct) =>
|
||||
Results.Ok((await service.ListAsync(novelId, ct)).Select(c => c.ToSummaryResponse())))
|
||||
.WithSummary("List a novel's chapters in manuscript order.");
|
||||
|
||||
projectScoped.MapPost("/", async (
|
||||
Guid projectId, CreateChapterRequest request, ChapterService service, CancellationToken ct) =>
|
||||
novelScoped.MapPost("/", async (
|
||||
Guid novelId, CreateChapterRequest request, ChapterService service, CancellationToken ct) =>
|
||||
{
|
||||
var chapter = await service.CreateAsync(projectId, request, ct);
|
||||
var chapter = await service.CreateAsync(novelId, request, ct);
|
||||
if (chapter is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
|
||||
@@ -9,24 +9,24 @@ namespace Novelly.Api.Chapters;
|
||||
|
||||
public class ChapterService(
|
||||
INovelDbContext db,
|
||||
ProjectAccessService access,
|
||||
NovelAccessService access,
|
||||
TagService tags,
|
||||
ILogger<ChapterService> logger,
|
||||
IModelValidator<CreateChapterRequest> createValidator,
|
||||
IModelValidator<UpdateChapterRequest> updateValidator)
|
||||
{
|
||||
public async Task<IReadOnlyList<Chapter>> ListAsync(Guid projectId, CancellationToken ct = default)
|
||||
public async Task<IReadOnlyList<Chapter>> ListAsync(Guid novelId, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(projectId, nameof(projectId));
|
||||
Guard.Default(novelId, nameof(novelId));
|
||||
|
||||
logger.LogInformation("Listing chapters for project {ProjectId}", projectId);
|
||||
logger.LogInformation("Listing chapters for novel {NovelId}", novelId);
|
||||
|
||||
await access.RequireAsync(projectId, ProjectPermission.Read, ct);
|
||||
await access.RequireAsync(novelId, NovelPermission.Read, ct);
|
||||
|
||||
return await db.Chapters
|
||||
.Include(c => c.Beats)
|
||||
.Include(c => c.Tags)
|
||||
.Where(c => c.ProjectId == projectId)
|
||||
.Where(c => c.NovelId == novelId)
|
||||
.OrderBy(c => c.Number)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
@@ -43,31 +43,31 @@ public class ChapterService(
|
||||
return null;
|
||||
}
|
||||
|
||||
await access.RequireAsync(chapter.ProjectId, ProjectPermission.Read, ct);
|
||||
await access.RequireAsync(chapter.NovelId, NovelPermission.Read, ct);
|
||||
return chapter;
|
||||
}
|
||||
|
||||
public async Task<Chapter?> CreateAsync(Guid projectId, CreateChapterRequest request, CancellationToken ct = default)
|
||||
public async Task<Chapter?> CreateAsync(Guid novelId, CreateChapterRequest request, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(projectId, nameof(projectId));
|
||||
Guard.Default(novelId, nameof(novelId));
|
||||
Guard.Null(request, nameof(request));
|
||||
createValidator.Validate(request).ThrowIfInvalid(logger);
|
||||
|
||||
logger.LogInformation("Creating chapter {Title} for project {ProjectId}", request.Title, projectId);
|
||||
logger.LogInformation("Creating chapter {Title} for novel {NovelId}", request.Title, novelId);
|
||||
|
||||
if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct))
|
||||
if (!await db.Novels.AnyAsync(p => p.Id == novelId, ct))
|
||||
{
|
||||
logger.LogWarning("Rejected chapter creation: project {ProjectId} not found", projectId);
|
||||
logger.LogWarning("Rejected chapter creation: novel {NovelId} not found", novelId);
|
||||
return null;
|
||||
}
|
||||
|
||||
await access.RequireAsync(projectId, ProjectPermission.CreateContent, ct);
|
||||
await access.RequireAsync(novelId, NovelPermission.CreateContent, ct);
|
||||
|
||||
var chapter = new Chapter
|
||||
{
|
||||
ProjectId = projectId,
|
||||
NovelId = novelId,
|
||||
Title = request.Title,
|
||||
Number = request.Number ?? await NextChapterNumberAsync(projectId, ct),
|
||||
Number = request.Number ?? await NextChapterNumberAsync(novelId, ct),
|
||||
Summary = request.Summary,
|
||||
Setting = request.Setting,
|
||||
Notes = request.Notes,
|
||||
@@ -79,7 +79,7 @@ public class ChapterService(
|
||||
|
||||
if (request.Tags is { } names)
|
||||
{
|
||||
chapter.Tags = await tags.ResolveAsync(projectId, names, ct);
|
||||
chapter.Tags = await tags.ResolveAsync(novelId, names, ct);
|
||||
}
|
||||
|
||||
db.Chapters.Add(chapter);
|
||||
@@ -102,7 +102,7 @@ public class ChapterService(
|
||||
return null;
|
||||
}
|
||||
|
||||
await access.RequireAsync(chapter.ProjectId, ProjectPermission.Write, ct);
|
||||
await access.RequireAsync(chapter.NovelId, NovelPermission.Write, ct);
|
||||
|
||||
chapter.Title = Patch.Apply(chapter.Title, request.Title) ?? chapter.Title;
|
||||
chapter.Number = request.Number ?? chapter.Number;
|
||||
@@ -122,7 +122,7 @@ public class ChapterService(
|
||||
|
||||
if (request.Tags is { } names)
|
||||
{
|
||||
chapter.Tags = await tags.ResolveAsync(chapter.ProjectId, names, ct);
|
||||
chapter.Tags = await tags.ResolveAsync(chapter.NovelId, names, ct);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
@@ -141,23 +141,23 @@ public class ChapterService(
|
||||
return false;
|
||||
}
|
||||
|
||||
await access.RequireAsync(chapter.ProjectId, ProjectPermission.DeleteContent, ct);
|
||||
await access.RequireAsync(chapter.NovelId, NovelPermission.DeleteContent, ct);
|
||||
|
||||
db.Chapters.Remove(chapter);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<int> NextChapterNumberAsync(Guid projectId, CancellationToken ct)
|
||||
private async Task<int> NextChapterNumberAsync(Guid novelId, CancellationToken ct)
|
||||
{
|
||||
logger.LogDebug("Computing next chapter number for project {ProjectId}", projectId);
|
||||
logger.LogDebug("Computing next chapter number for novel {NovelId}", novelId);
|
||||
|
||||
var max = await db.Chapters
|
||||
.Where(c => c.ProjectId == projectId)
|
||||
.Where(c => c.NovelId == novelId)
|
||||
.MaxAsync(c => (int?)c.Number, ct);
|
||||
|
||||
var next = (max ?? 0) + 1;
|
||||
logger.LogDebug("Next chapter number for project {ProjectId} is {Number}", projectId, next);
|
||||
logger.LogDebug("Next chapter number for novel {NovelId} is {Number}", novelId, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using Novelly.Api.Beats;
|
||||
using Novelly.Api.Chapters;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Novels;
|
||||
using Novelly.Api.Tags;
|
||||
|
||||
namespace Novelly.Api.Characters;
|
||||
@@ -10,8 +10,8 @@ namespace Novelly.Api.Characters;
|
||||
public class Character
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public Guid ProjectId { get; set; }
|
||||
public Project? Project { get; set; }
|
||||
public Guid NovelId { get; set; }
|
||||
public Novel? Novel { get; set; }
|
||||
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public CharacterRole Role { get; set; } = CharacterRole.Supporting;
|
||||
@@ -82,7 +82,7 @@ public class CharacterEntityTypeConfiguration : IEntityTypeConfiguration<Charact
|
||||
entity.Property(c => c.Name).IsRequired().HasMaxLength(200);
|
||||
entity.Property(c => c.Role).HasConversion<string>().HasMaxLength(32);
|
||||
entity.Property(c => c.Importance).HasConversion<string>().HasMaxLength(32);
|
||||
entity.HasIndex(c => c.ProjectId);
|
||||
entity.HasIndex(c => c.NovelId);
|
||||
entity.HasIndex(c => c.SameCharacterAsId);
|
||||
|
||||
entity.HasMany(c => c.Relationships).WithOne(r => r.Character!)
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace Novelly.Api.Characters;
|
||||
|
||||
public class CharacterArcService(
|
||||
INovelDbContext db,
|
||||
ProjectAccessService access,
|
||||
NovelAccessService access,
|
||||
ILogger<CharacterArcService> logger,
|
||||
IModelValidator<CreateArcStageRequest> createValidator,
|
||||
IModelValidator<UpdateArcStageRequest> updateValidator,
|
||||
@@ -21,7 +21,7 @@ public class CharacterArcService(
|
||||
|
||||
logger.LogInformation("Listing arc stages for character {CharacterId}", characterId);
|
||||
|
||||
await RequireCharacterAccessAsync(characterId, ProjectPermission.Read, ct);
|
||||
await RequireCharacterAccessAsync(characterId, NovelPermission.Read, ct);
|
||||
|
||||
var stages = await Query()
|
||||
.Where(s => s.CharacterId == characterId)
|
||||
@@ -43,7 +43,7 @@ public class CharacterArcService(
|
||||
return null;
|
||||
}
|
||||
|
||||
await RequireCharacterAccessAsync(stage.CharacterId, ProjectPermission.Read, ct);
|
||||
await RequireCharacterAccessAsync(stage.CharacterId, NovelPermission.Read, ct);
|
||||
return stage;
|
||||
}
|
||||
|
||||
@@ -63,8 +63,8 @@ public class CharacterArcService(
|
||||
return null;
|
||||
}
|
||||
|
||||
await access.RequireAsync(character.ProjectId, ProjectPermission.CreateContent, ct);
|
||||
await EnsureChapterIsInSameProjectAsync(character, request.ChapterId, ct);
|
||||
await access.RequireAsync(character.NovelId, NovelPermission.CreateContent, ct);
|
||||
await EnsureChapterIsInSameNovelAsync(character, request.ChapterId, ct);
|
||||
|
||||
var stage = new CharacterArcStage
|
||||
{
|
||||
@@ -103,8 +103,8 @@ public class CharacterArcService(
|
||||
return null;
|
||||
}
|
||||
|
||||
await access.RequireAsync(character.ProjectId, ProjectPermission.Write, ct);
|
||||
await EnsureChapterIsInSameProjectAsync(character, request.ChapterId, ct);
|
||||
await access.RequireAsync(character.NovelId, NovelPermission.Write, ct);
|
||||
await EnsureChapterIsInSameNovelAsync(character, request.ChapterId, ct);
|
||||
|
||||
stage.Title = Patch.Apply(stage.Title, request.Title) ?? stage.Title;
|
||||
stage.SortOrder = request.SortOrder ?? stage.SortOrder;
|
||||
@@ -128,7 +128,7 @@ public class CharacterArcService(
|
||||
return false;
|
||||
}
|
||||
|
||||
await RequireCharacterAccessAsync(stage.CharacterId, ProjectPermission.DeleteContent, ct);
|
||||
await RequireCharacterAccessAsync(stage.CharacterId, NovelPermission.DeleteContent, ct);
|
||||
|
||||
db.CharacterArcStages.Remove(stage);
|
||||
await db.SaveChangesAsync(ct);
|
||||
@@ -144,7 +144,7 @@ public class CharacterArcService(
|
||||
|
||||
logger.LogInformation("Reordering {Count} arc stages for character {CharacterId}", request.StageIds.Count, characterId);
|
||||
|
||||
await RequireCharacterAccessAsync(characterId, ProjectPermission.Write, ct);
|
||||
await RequireCharacterAccessAsync(characterId, NovelPermission.Write, ct);
|
||||
|
||||
var stages = await db.CharacterArcStages
|
||||
.Where(s => s.CharacterId == characterId)
|
||||
@@ -194,7 +194,7 @@ public class CharacterArcService(
|
||||
return null;
|
||||
}
|
||||
|
||||
await access.RequireAsync(character.ProjectId, ProjectPermission.Write, ct);
|
||||
await access.RequireAsync(character.NovelId, NovelPermission.Write, ct);
|
||||
|
||||
var beats = await db.Beats
|
||||
.Include(b => b.Characters)
|
||||
@@ -233,7 +233,7 @@ public class CharacterArcService(
|
||||
return (await FindAsync(stageId, ct))!;
|
||||
}
|
||||
|
||||
private async Task EnsureChapterIsInSameProjectAsync(
|
||||
private async Task EnsureChapterIsInSameNovelAsync(
|
||||
Character character, Guid? chapterId, CancellationToken ct)
|
||||
{
|
||||
if (chapterId is not { } id)
|
||||
@@ -241,18 +241,18 @@ public class CharacterArcService(
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogDebug("Checking chapter {ChapterId} belongs to project {ProjectId}", id, character.ProjectId);
|
||||
logger.LogDebug("Checking chapter {ChapterId} belongs to novel {NovelId}", id, character.NovelId);
|
||||
|
||||
var belongs = await db.Chapters.AnyAsync(c => c.Id == id && c.ProjectId == character.ProjectId, ct);
|
||||
var belongs = await db.Chapters.AnyAsync(c => c.Id == id && c.NovelId == character.NovelId, ct);
|
||||
|
||||
if (!belongs)
|
||||
{
|
||||
logger.LogWarning("Rejected arc stage: chapter {ChapterId} does not belong to project {ProjectId}", id, character.ProjectId);
|
||||
logger.LogWarning("Rejected arc stage: chapter {ChapterId} does not belong to novel {NovelId}", id, character.NovelId);
|
||||
throw new InvalidOperationException(
|
||||
"An arc stage can only point at a chapter in the same project as its character.");
|
||||
"An arc stage can only point at a chapter in the same novel as its character.");
|
||||
}
|
||||
|
||||
logger.LogDebug("Chapter {ChapterId} belongs to project {ProjectId}", id, character.ProjectId);
|
||||
logger.LogDebug("Chapter {ChapterId} belongs to novel {NovelId}", id, character.NovelId);
|
||||
}
|
||||
|
||||
private async Task<int> NextSortOrderAsync(Guid characterId, CancellationToken ct)
|
||||
@@ -268,10 +268,10 @@ public class CharacterArcService(
|
||||
return next;
|
||||
}
|
||||
|
||||
private async Task RequireCharacterAccessAsync(Guid characterId, ProjectPermission permission, CancellationToken ct)
|
||||
private async Task RequireCharacterAccessAsync(Guid characterId, NovelPermission permission, CancellationToken ct)
|
||||
{
|
||||
var projectId = await db.Characters.Where(c => c.Id == characterId).Select(c => c.ProjectId).FirstOrDefaultAsync(ct);
|
||||
await access.RequireAsync(projectId, permission, ct);
|
||||
var novelId = await db.Characters.Where(c => c.Id == characterId).Select(c => c.NovelId).FirstOrDefaultAsync(ct);
|
||||
await access.RequireAsync(novelId, permission, ct);
|
||||
}
|
||||
|
||||
private IQueryable<CharacterArcStage> Query() =>
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace Novelly.Api.Characters;
|
||||
|
||||
public record CharacterResponse(
|
||||
Guid Id,
|
||||
Guid ProjectId,
|
||||
Guid NovelId,
|
||||
string Name,
|
||||
CharacterRole Role,
|
||||
CharacterImportance Importance,
|
||||
@@ -299,7 +299,7 @@ public class SetArcStageBeatsRequestValidator : IModelValidator<SetArcStageBeats
|
||||
public static class CharacterMapping
|
||||
{
|
||||
public static CharacterResponse ToResponse(this Character c) => new(
|
||||
c.Id, c.ProjectId, c.Name, c.Role, c.Importance, c.Age, c.Pronouns, c.Occupation,
|
||||
c.Id, c.NovelId, c.Name, c.Role, c.Importance, c.Age, c.Pronouns, c.Occupation,
|
||||
c.Appearance, c.Personality, c.Backstory, c.Want, c.Need,
|
||||
c.InternalConflict, c.ExternalConflict, c.ArcSummary, c.Voice, c.Notes,
|
||||
[.. c.Aliases],
|
||||
|
||||
@@ -7,18 +7,18 @@ public static class CharacterEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapCharacterEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/characters").WithTags("Characters")
|
||||
var novelScoped = app.MapGroup("/api/novels/{novelId:guid}/characters").WithTags("Characters")
|
||||
.AddEndpointFilter<RequestLoggingEndpointFilter>()
|
||||
.AddEndpointFilter<ValidationEndpointFilter>();
|
||||
|
||||
projectScoped.MapGet("/", async (Guid projectId, CharacterService service, CancellationToken ct) =>
|
||||
Results.Ok((await service.ListAsync(projectId, ct)).Select(c => c.ToResponse())))
|
||||
.WithSummary("List a project's character dossiers.");
|
||||
novelScoped.MapGet("/", async (Guid novelId, CharacterService service, CancellationToken ct) =>
|
||||
Results.Ok((await service.ListAsync(novelId, ct)).Select(c => c.ToResponse())))
|
||||
.WithSummary("List a novel's character dossiers.");
|
||||
|
||||
projectScoped.MapPost("/", async (
|
||||
Guid projectId, CreateCharacterRequest request, CharacterService service, CancellationToken ct) =>
|
||||
novelScoped.MapPost("/", async (
|
||||
Guid novelId, CreateCharacterRequest request, CharacterService service, CancellationToken ct) =>
|
||||
{
|
||||
var character = await service.CreateAsync(projectId, request, ct);
|
||||
var character = await service.CreateAsync(novelId, request, ct);
|
||||
if (character is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
@@ -49,7 +49,7 @@ public static class CharacterEndpoints
|
||||
characters.MapPost("/{id:guid}/relationships", async (
|
||||
Guid id, CreateRelationshipRequest request, CharacterService service, CancellationToken ct) =>
|
||||
(await service.AddRelationshipAsync(id, request, ct))?.ToResponse().ToApiResult())
|
||||
.WithSummary("Relate this character to another in the same project.");
|
||||
.WithSummary("Relate this character to another in the same novel.");
|
||||
|
||||
characters.MapDelete("/relationships/{relationshipId:guid}", async (
|
||||
Guid relationshipId, CharacterService service, CancellationToken ct) =>
|
||||
@@ -59,7 +59,7 @@ public static class CharacterEndpoints
|
||||
characters.MapPut("/{id:guid}/identity", async (
|
||||
Guid id, LinkCharacterIdentityRequest request, CharacterService service, CancellationToken ct) =>
|
||||
(await service.LinkIdentityAsync(id, request, ct))?.ToResponse().ToApiResult())
|
||||
.WithSummary("Link this character as another identity of a character in the same project.");
|
||||
.WithSummary("Link this character as another identity of a character in the same novel.");
|
||||
|
||||
characters.MapDelete("/{id:guid}/identity", async (
|
||||
Guid id, CharacterService service, CancellationToken ct) =>
|
||||
|
||||
@@ -9,7 +9,7 @@ namespace Novelly.Api.Characters;
|
||||
|
||||
public class CharacterService(
|
||||
INovelDbContext db,
|
||||
ProjectAccessService access,
|
||||
NovelAccessService access,
|
||||
TagService tags,
|
||||
ILogger<CharacterService> logger,
|
||||
IModelValidator<CreateCharacterRequest> createValidator,
|
||||
@@ -17,16 +17,16 @@ public class CharacterService(
|
||||
IModelValidator<CreateRelationshipRequest> relationshipValidator,
|
||||
IModelValidator<LinkCharacterIdentityRequest> identityValidator)
|
||||
{
|
||||
public async Task<IReadOnlyList<Character>> ListAsync(Guid projectId, CancellationToken ct = default)
|
||||
public async Task<IReadOnlyList<Character>> ListAsync(Guid novelId, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(projectId, nameof(projectId));
|
||||
Guard.Default(novelId, nameof(novelId));
|
||||
|
||||
logger.LogInformation("Listing characters for project {ProjectId}", projectId);
|
||||
logger.LogInformation("Listing characters for novel {NovelId}", novelId);
|
||||
|
||||
await access.RequireAsync(projectId, ProjectPermission.Read, ct);
|
||||
await access.RequireAsync(novelId, NovelPermission.Read, ct);
|
||||
|
||||
var characters = await Query()
|
||||
.Where(c => c.ProjectId == projectId)
|
||||
.Where(c => c.NovelId == novelId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return OrderedInMemoryBySignificanceThenName(characters);
|
||||
@@ -52,29 +52,29 @@ public class CharacterService(
|
||||
return null;
|
||||
}
|
||||
|
||||
await access.RequireAsync(character.ProjectId, ProjectPermission.Read, ct);
|
||||
await access.RequireAsync(character.NovelId, NovelPermission.Read, ct);
|
||||
return character;
|
||||
}
|
||||
|
||||
public async Task<Character?> CreateAsync(Guid projectId, CreateCharacterRequest request, CancellationToken ct = default)
|
||||
public async Task<Character?> CreateAsync(Guid novelId, CreateCharacterRequest request, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(projectId, nameof(projectId));
|
||||
Guard.Default(novelId, nameof(novelId));
|
||||
Guard.Null(request, nameof(request));
|
||||
createValidator.Validate(request).ThrowIfInvalid(logger);
|
||||
|
||||
logger.LogInformation("Creating character {Name} for project {ProjectId}, role {Role}, importance {Importance}", request.Name, projectId, request.Role, request.Importance);
|
||||
logger.LogInformation("Creating character {Name} for novel {NovelId}, role {Role}, importance {Importance}", request.Name, novelId, request.Role, request.Importance);
|
||||
|
||||
if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct))
|
||||
if (!await db.Novels.AnyAsync(p => p.Id == novelId, ct))
|
||||
{
|
||||
logger.LogWarning("Rejected character creation: project {ProjectId} not found", projectId);
|
||||
logger.LogWarning("Rejected character creation: novel {NovelId} not found", novelId);
|
||||
return null;
|
||||
}
|
||||
|
||||
await access.RequireAsync(projectId, ProjectPermission.CreateContent, ct);
|
||||
await access.RequireAsync(novelId, NovelPermission.CreateContent, ct);
|
||||
|
||||
var character = new Character
|
||||
{
|
||||
ProjectId = projectId,
|
||||
NovelId = novelId,
|
||||
Name = request.Name,
|
||||
Role = request.Role,
|
||||
Importance = request.Importance,
|
||||
@@ -95,7 +95,7 @@ public class CharacterService(
|
||||
|
||||
if (request.Tags is { } names)
|
||||
{
|
||||
character.Tags = await tags.ResolveAsync(projectId, names, ct);
|
||||
character.Tags = await tags.ResolveAsync(novelId, names, ct);
|
||||
}
|
||||
|
||||
if (request.Aliases is { } aliases)
|
||||
@@ -123,7 +123,7 @@ public class CharacterService(
|
||||
return null;
|
||||
}
|
||||
|
||||
await access.RequireAsync(character.ProjectId, ProjectPermission.Write, ct);
|
||||
await access.RequireAsync(character.NovelId, NovelPermission.Write, ct);
|
||||
|
||||
character.Name = Patch.Apply(character.Name, request.Name) ?? character.Name;
|
||||
character.Role = request.Role ?? character.Role;
|
||||
@@ -145,7 +145,7 @@ public class CharacterService(
|
||||
|
||||
if (request.Tags is { } names)
|
||||
{
|
||||
character.Tags = await tags.ResolveAsync(character.ProjectId, names, ct);
|
||||
character.Tags = await tags.ResolveAsync(character.NovelId, names, ct);
|
||||
}
|
||||
|
||||
if (request.Aliases is { } aliases)
|
||||
@@ -169,7 +169,7 @@ public class CharacterService(
|
||||
return false;
|
||||
}
|
||||
|
||||
await access.RequireAsync(character.ProjectId, ProjectPermission.DeleteContent, ct);
|
||||
await access.RequireAsync(character.NovelId, NovelPermission.DeleteContent, ct);
|
||||
|
||||
db.Characters.Remove(character);
|
||||
await db.SaveChangesAsync(ct);
|
||||
@@ -191,7 +191,7 @@ public class CharacterService(
|
||||
return null;
|
||||
}
|
||||
|
||||
await access.RequireAsync(character.ProjectId, ProjectPermission.Write, ct);
|
||||
await access.RequireAsync(character.NovelId, NovelPermission.Write, ct);
|
||||
|
||||
var related = await db.Characters.FirstOrDefaultAsync(c => c.Id == request.RelatedCharacterId, ct);
|
||||
if (related is null)
|
||||
@@ -200,10 +200,10 @@ public class CharacterService(
|
||||
return null;
|
||||
}
|
||||
|
||||
if (related.ProjectId != character.ProjectId)
|
||||
if (related.NovelId != character.NovelId)
|
||||
{
|
||||
logger.LogWarning("Rejected relationship: character {CharacterId} and {RelatedCharacterId} belong to different projects", characterId, request.RelatedCharacterId);
|
||||
throw new InvalidOperationException("Characters must belong to the same project to be related.");
|
||||
logger.LogWarning("Rejected relationship: character {CharacterId} and {RelatedCharacterId} belong to different novels", characterId, request.RelatedCharacterId);
|
||||
throw new InvalidOperationException("Characters must belong to the same novel to be related.");
|
||||
}
|
||||
|
||||
db.CharacterRelationships.Add(new CharacterRelationship
|
||||
@@ -241,7 +241,7 @@ public class CharacterService(
|
||||
return false;
|
||||
}
|
||||
|
||||
await access.RequireAsync(relationship.Character!.ProjectId, ProjectPermission.Write, ct);
|
||||
await access.RequireAsync(relationship.Character!.NovelId, NovelPermission.Write, ct);
|
||||
|
||||
var reciprocals = await db.CharacterRelationships
|
||||
.Where(r => r.CharacterId == relationship.RelatedCharacterId && r.RelatedCharacterId == relationship.CharacterId)
|
||||
@@ -269,7 +269,7 @@ public class CharacterService(
|
||||
return null;
|
||||
}
|
||||
|
||||
await access.RequireAsync(character.ProjectId, ProjectPermission.Write, ct);
|
||||
await access.RequireAsync(character.NovelId, NovelPermission.Write, ct);
|
||||
|
||||
if (request.SameCharacterAsId == characterId)
|
||||
{
|
||||
@@ -286,12 +286,12 @@ public class CharacterService(
|
||||
return null;
|
||||
}
|
||||
|
||||
if (target.ProjectId != character.ProjectId)
|
||||
if (target.NovelId != character.NovelId)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Rejected identity link: character {CharacterId} and {SameCharacterAsId} belong to different projects",
|
||||
"Rejected identity link: character {CharacterId} and {SameCharacterAsId} belong to different novels",
|
||||
characterId, request.SameCharacterAsId);
|
||||
throw new InvalidOperationException("Characters must belong to the same project to be linked.");
|
||||
throw new InvalidOperationException("Characters must belong to the same novel to be linked.");
|
||||
}
|
||||
|
||||
if (await db.Characters.AnyAsync(c => c.SameCharacterAsId == characterId, ct))
|
||||
@@ -304,11 +304,11 @@ public class CharacterService(
|
||||
|
||||
if (request.RevealedInChapterId is { } chapterId)
|
||||
{
|
||||
var chapterInProject = await db.Chapters.AnyAsync(c => c.Id == chapterId && c.ProjectId == character.ProjectId, ct);
|
||||
if (!chapterInProject)
|
||||
var chapterInNovel = await db.Chapters.AnyAsync(c => c.Id == chapterId && c.NovelId == character.NovelId, ct);
|
||||
if (!chapterInNovel)
|
||||
{
|
||||
logger.LogWarning("Rejected identity link: chapter {ChapterId} not in project {ProjectId}", chapterId, character.ProjectId);
|
||||
throw new InvalidOperationException("The reveal chapter must belong to the same project.");
|
||||
logger.LogWarning("Rejected identity link: chapter {ChapterId} not in novel {NovelId}", chapterId, character.NovelId);
|
||||
throw new InvalidOperationException("The reveal chapter must belong to the same novel.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,7 +333,7 @@ public class CharacterService(
|
||||
return false;
|
||||
}
|
||||
|
||||
await access.RequireAsync(character.ProjectId, ProjectPermission.Write, ct);
|
||||
await access.RequireAsync(character.NovelId, NovelPermission.Write, ct);
|
||||
|
||||
character.SameCharacterAsId = null;
|
||||
character.RevealedInChapterId = null;
|
||||
|
||||
@@ -13,7 +13,7 @@ using Novelly.Api.Common.Validation;
|
||||
using Novelly.Api.Data;
|
||||
using Novelly.Api.Genres;
|
||||
using Novelly.Api.Imports;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Novels;
|
||||
using Novelly.Api.Questions;
|
||||
using Novelly.Api.Tags;
|
||||
using Novelly.Api.Users;
|
||||
@@ -48,7 +48,7 @@ public static class NovellyServiceRegistration
|
||||
services.AddScoped<IUserClaimsPrincipalFactory<NovellyUser>, NovellyUserClaimsPrincipalFactory>();
|
||||
services.AddHttpContextAccessor();
|
||||
services.AddScoped<INovelUserContext, NovelUserContext>();
|
||||
services.AddScoped<ProjectAccessService>();
|
||||
services.AddScoped<NovelAccessService>();
|
||||
|
||||
services.ConfigureApplicationCookie(options =>
|
||||
{
|
||||
@@ -70,9 +70,9 @@ public static class NovellyServiceRegistration
|
||||
});
|
||||
|
||||
services.AddScoped<UserAccountService>();
|
||||
services.AddScoped<ProjectMemberService>();
|
||||
services.AddScoped<NovelMemberService>();
|
||||
|
||||
services.AddScoped<ProjectService>();
|
||||
services.AddScoped<NovelService>();
|
||||
services.AddScoped<CharacterService>();
|
||||
services.AddScoped<CharacterArcService>();
|
||||
services.AddScoped<BeatService>();
|
||||
|
||||
+1169
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,363 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Novelly.Api.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class RenameProjectToNovel : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Chapters_Projects_ProjectId",
|
||||
table: "Chapters");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Characters_Projects_ProjectId",
|
||||
table: "Characters");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Conversations_Projects_ProjectId",
|
||||
table: "Conversations");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_OpenQuestions_Projects_ProjectId",
|
||||
table: "OpenQuestions");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Tags_Projects_ProjectId",
|
||||
table: "Tags");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_ProjectMembers_Projects_ProjectId",
|
||||
table: "ProjectMembers");
|
||||
|
||||
migrationBuilder.RenameTable(
|
||||
name: "Projects",
|
||||
newName: "Novels");
|
||||
|
||||
migrationBuilder.RenameTable(
|
||||
name: "ProjectMembers",
|
||||
newName: "NovelMembers");
|
||||
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "ProjectId",
|
||||
table: "Tags",
|
||||
newName: "NovelId");
|
||||
|
||||
migrationBuilder.RenameIndex(
|
||||
name: "IX_Tags_ProjectId_Name",
|
||||
table: "Tags",
|
||||
newName: "IX_Tags_NovelId_Name");
|
||||
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "ProjectId",
|
||||
table: "OpenQuestions",
|
||||
newName: "NovelId");
|
||||
|
||||
migrationBuilder.RenameIndex(
|
||||
name: "IX_OpenQuestions_ProjectId",
|
||||
table: "OpenQuestions",
|
||||
newName: "IX_OpenQuestions_NovelId");
|
||||
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "ProjectId",
|
||||
table: "ImportJobs",
|
||||
newName: "NovelId");
|
||||
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "ProjectId",
|
||||
table: "Conversations",
|
||||
newName: "NovelId");
|
||||
|
||||
migrationBuilder.RenameIndex(
|
||||
name: "IX_Conversations_ProjectId",
|
||||
table: "Conversations",
|
||||
newName: "IX_Conversations_NovelId");
|
||||
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "ProjectId",
|
||||
table: "Characters",
|
||||
newName: "NovelId");
|
||||
|
||||
migrationBuilder.RenameIndex(
|
||||
name: "IX_Characters_ProjectId",
|
||||
table: "Characters",
|
||||
newName: "IX_Characters_NovelId");
|
||||
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "ProjectId",
|
||||
table: "Chapters",
|
||||
newName: "NovelId");
|
||||
|
||||
migrationBuilder.RenameIndex(
|
||||
name: "IX_Chapters_ProjectId_Number",
|
||||
table: "Chapters",
|
||||
newName: "IX_Chapters_NovelId_Number");
|
||||
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "ProjectId",
|
||||
table: "NovelMembers",
|
||||
newName: "NovelId");
|
||||
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "ProjectRole",
|
||||
table: "NovelMembers",
|
||||
newName: "NovelRole");
|
||||
|
||||
migrationBuilder.RenameIndex(
|
||||
name: "IX_ProjectMembers_ProjectId_UserId",
|
||||
table: "NovelMembers",
|
||||
newName: "IX_NovelMembers_NovelId_UserId");
|
||||
|
||||
migrationBuilder.RenameIndex(
|
||||
name: "IX_ProjectMembers_UserId",
|
||||
table: "NovelMembers",
|
||||
newName: "IX_NovelMembers_UserId");
|
||||
|
||||
migrationBuilder.RenameIndex(
|
||||
name: "IX_Projects_OwnerId",
|
||||
table: "Novels",
|
||||
newName: "IX_Novels_OwnerId");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_ProjectMembers_AspNetUsers_UserId",
|
||||
table: "NovelMembers");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_NovelMembers_AspNetUsers_UserId",
|
||||
table: "NovelMembers",
|
||||
column: "UserId",
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_NovelMembers_Novels_NovelId",
|
||||
table: "NovelMembers",
|
||||
column: "NovelId",
|
||||
principalTable: "Novels",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Chapters_Novels_NovelId",
|
||||
table: "Chapters",
|
||||
column: "NovelId",
|
||||
principalTable: "Novels",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Characters_Novels_NovelId",
|
||||
table: "Characters",
|
||||
column: "NovelId",
|
||||
principalTable: "Novels",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Conversations_Novels_NovelId",
|
||||
table: "Conversations",
|
||||
column: "NovelId",
|
||||
principalTable: "Novels",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_OpenQuestions_Novels_NovelId",
|
||||
table: "OpenQuestions",
|
||||
column: "NovelId",
|
||||
principalTable: "Novels",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Tags_Novels_NovelId",
|
||||
table: "Tags",
|
||||
column: "NovelId",
|
||||
principalTable: "Novels",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Chapters_Novels_NovelId",
|
||||
table: "Chapters");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Characters_Novels_NovelId",
|
||||
table: "Characters");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Conversations_Novels_NovelId",
|
||||
table: "Conversations");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_OpenQuestions_Novels_NovelId",
|
||||
table: "OpenQuestions");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Tags_Novels_NovelId",
|
||||
table: "Tags");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_NovelMembers_Novels_NovelId",
|
||||
table: "NovelMembers");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_NovelMembers_AspNetUsers_UserId",
|
||||
table: "NovelMembers");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_ProjectMembers_AspNetUsers_UserId",
|
||||
table: "NovelMembers",
|
||||
column: "UserId",
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
|
||||
migrationBuilder.RenameIndex(
|
||||
name: "IX_Novels_OwnerId",
|
||||
table: "Novels",
|
||||
newName: "IX_Projects_OwnerId");
|
||||
|
||||
migrationBuilder.RenameIndex(
|
||||
name: "IX_NovelMembers_UserId",
|
||||
table: "NovelMembers",
|
||||
newName: "IX_ProjectMembers_UserId");
|
||||
|
||||
migrationBuilder.RenameIndex(
|
||||
name: "IX_NovelMembers_NovelId_UserId",
|
||||
table: "NovelMembers",
|
||||
newName: "IX_ProjectMembers_ProjectId_UserId");
|
||||
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "NovelRole",
|
||||
table: "NovelMembers",
|
||||
newName: "ProjectRole");
|
||||
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "NovelId",
|
||||
table: "NovelMembers",
|
||||
newName: "ProjectId");
|
||||
|
||||
migrationBuilder.RenameIndex(
|
||||
name: "IX_Chapters_NovelId_Number",
|
||||
table: "Chapters",
|
||||
newName: "IX_Chapters_ProjectId_Number");
|
||||
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "NovelId",
|
||||
table: "Chapters",
|
||||
newName: "ProjectId");
|
||||
|
||||
migrationBuilder.RenameIndex(
|
||||
name: "IX_Characters_NovelId",
|
||||
table: "Characters",
|
||||
newName: "IX_Characters_ProjectId");
|
||||
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "NovelId",
|
||||
table: "Characters",
|
||||
newName: "ProjectId");
|
||||
|
||||
migrationBuilder.RenameIndex(
|
||||
name: "IX_Conversations_NovelId",
|
||||
table: "Conversations",
|
||||
newName: "IX_Conversations_ProjectId");
|
||||
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "NovelId",
|
||||
table: "Conversations",
|
||||
newName: "ProjectId");
|
||||
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "NovelId",
|
||||
table: "ImportJobs",
|
||||
newName: "ProjectId");
|
||||
|
||||
migrationBuilder.RenameIndex(
|
||||
name: "IX_OpenQuestions_NovelId",
|
||||
table: "OpenQuestions",
|
||||
newName: "IX_OpenQuestions_ProjectId");
|
||||
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "NovelId",
|
||||
table: "OpenQuestions",
|
||||
newName: "ProjectId");
|
||||
|
||||
migrationBuilder.RenameIndex(
|
||||
name: "IX_Tags_NovelId_Name",
|
||||
table: "Tags",
|
||||
newName: "IX_Tags_ProjectId_Name");
|
||||
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "NovelId",
|
||||
table: "Tags",
|
||||
newName: "ProjectId");
|
||||
|
||||
migrationBuilder.RenameTable(
|
||||
name: "NovelMembers",
|
||||
newName: "ProjectMembers");
|
||||
|
||||
migrationBuilder.RenameTable(
|
||||
name: "Novels",
|
||||
newName: "Projects");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_ProjectMembers_Projects_ProjectId",
|
||||
table: "ProjectMembers",
|
||||
column: "ProjectId",
|
||||
principalTable: "Projects",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Chapters_Projects_ProjectId",
|
||||
table: "Chapters",
|
||||
column: "ProjectId",
|
||||
principalTable: "Projects",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Characters_Projects_ProjectId",
|
||||
table: "Characters",
|
||||
column: "ProjectId",
|
||||
principalTable: "Projects",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Conversations_Projects_ProjectId",
|
||||
table: "Conversations",
|
||||
column: "ProjectId",
|
||||
principalTable: "Projects",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_OpenQuestions_Projects_ProjectId",
|
||||
table: "OpenQuestions",
|
||||
column: "ProjectId",
|
||||
principalTable: "Projects",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Tags_Projects_ProjectId",
|
||||
table: "Tags",
|
||||
column: "ProjectId",
|
||||
principalTable: "Projects",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -163,7 +163,7 @@ namespace Novelly.Api.Data.Migrations
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
b.Property<Guid>("NovelId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
@@ -176,7 +176,7 @@ namespace Novelly.Api.Data.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProjectId");
|
||||
b.HasIndex("NovelId");
|
||||
|
||||
b.ToTable("Conversations");
|
||||
});
|
||||
@@ -264,12 +264,12 @@ namespace Novelly.Api.Data.Migrations
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("NovelId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Number")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Prose")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
@@ -300,7 +300,7 @@ namespace Novelly.Api.Data.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProjectId", "Number");
|
||||
b.HasIndex("NovelId", "Number");
|
||||
|
||||
b.ToTable("Chapters");
|
||||
});
|
||||
@@ -355,15 +355,15 @@ namespace Novelly.Api.Data.Migrations
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("NovelId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Occupation")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Personality")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Pronouns")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
@@ -389,7 +389,7 @@ namespace Novelly.Api.Data.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProjectId");
|
||||
b.HasIndex("NovelId");
|
||||
|
||||
b.HasIndex("RevealedInChapterId");
|
||||
|
||||
@@ -591,7 +591,7 @@ namespace Novelly.Api.Data.Migrations
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid?>("ProjectId")
|
||||
b.Property<Guid?>("NovelId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("RequestedByUserId")
|
||||
@@ -620,7 +620,7 @@ namespace Novelly.Api.Data.Migrations
|
||||
b.ToTable("ImportJobs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Projects.Project", b =>
|
||||
modelBuilder.Entity("Novelly.Api.Novels.Novel", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
@@ -667,7 +667,7 @@ namespace Novelly.Api.Data.Migrations
|
||||
|
||||
b.HasIndex("OwnerId");
|
||||
|
||||
b.ToTable("Projects");
|
||||
b.ToTable("Novels");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Questions.OpenQuestion", b =>
|
||||
@@ -688,7 +688,7 @@ namespace Novelly.Api.Data.Migrations
|
||||
b.Property<string>("Detail")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
b.Property<Guid>("NovelId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Question")
|
||||
@@ -711,7 +711,7 @@ namespace Novelly.Api.Data.Migrations
|
||||
|
||||
b.HasIndex("CharacterId");
|
||||
|
||||
b.HasIndex("ProjectId");
|
||||
b.HasIndex("NovelId");
|
||||
|
||||
b.ToTable("OpenQuestions");
|
||||
});
|
||||
@@ -734,17 +734,50 @@ namespace Novelly.Api.Data.Migrations
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
b.Property<Guid>("NovelId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProjectId", "Name")
|
||||
b.HasIndex("NovelId", "Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Tags");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Users.NovelMember", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("GrantedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("GrantedByUserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("NovelId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NovelRole")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.HasIndex("NovelId", "UserId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("NovelMembers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Users.NovellyUser", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -823,39 +856,6 @@ namespace Novelly.Api.Data.Migrations
|
||||
b.ToTable("AspNetUsers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Users.ProjectMember", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("GrantedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("GrantedByUserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ProjectRole")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.HasIndex("ProjectId", "UserId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ProjectMembers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BeatCharacter", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Beats.Beat", null)
|
||||
@@ -960,13 +960,13 @@ namespace Novelly.Api.Data.Migrations
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Projects.Project", "Project")
|
||||
b.HasOne("Novelly.Api.Novels.Novel", "Novel")
|
||||
.WithMany("Conversations")
|
||||
.HasForeignKey("ProjectId")
|
||||
.HasForeignKey("NovelId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Project");
|
||||
b.Navigation("Novel");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Agent.AgentMessage", b =>
|
||||
@@ -993,20 +993,20 @@ namespace Novelly.Api.Data.Migrations
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Projects.Project", "Project")
|
||||
b.HasOne("Novelly.Api.Novels.Novel", "Novel")
|
||||
.WithMany("Chapters")
|
||||
.HasForeignKey("ProjectId")
|
||||
.HasForeignKey("NovelId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Project");
|
||||
b.Navigation("Novel");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Characters.Character", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Projects.Project", "Project")
|
||||
b.HasOne("Novelly.Api.Novels.Novel", "Novel")
|
||||
.WithMany("Characters")
|
||||
.HasForeignKey("ProjectId")
|
||||
.HasForeignKey("NovelId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
@@ -1020,7 +1020,7 @@ namespace Novelly.Api.Data.Migrations
|
||||
.HasForeignKey("SameCharacterAsId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Project");
|
||||
b.Navigation("Novel");
|
||||
|
||||
b.Navigation("RevealedInChapter");
|
||||
|
||||
@@ -1064,7 +1064,7 @@ namespace Novelly.Api.Data.Migrations
|
||||
b.Navigation("RelatedCharacter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Projects.Project", b =>
|
||||
modelBuilder.Entity("Novelly.Api.Novels.Novel", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Users.NovellyUser", "Owner")
|
||||
.WithMany()
|
||||
@@ -1086,9 +1086,9 @@ namespace Novelly.Api.Data.Migrations
|
||||
.HasForeignKey("CharacterId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("Novelly.Api.Projects.Project", "Project")
|
||||
b.HasOne("Novelly.Api.Novels.Novel", "Novel")
|
||||
.WithMany()
|
||||
.HasForeignKey("ProjectId")
|
||||
.HasForeignKey("NovelId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
@@ -1096,25 +1096,25 @@ namespace Novelly.Api.Data.Migrations
|
||||
|
||||
b.Navigation("Character");
|
||||
|
||||
b.Navigation("Project");
|
||||
b.Navigation("Novel");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Tags.Tag", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Projects.Project", "Project")
|
||||
b.HasOne("Novelly.Api.Novels.Novel", "Novel")
|
||||
.WithMany("Tags")
|
||||
.HasForeignKey("ProjectId")
|
||||
.HasForeignKey("NovelId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Project");
|
||||
b.Navigation("Novel");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Users.ProjectMember", b =>
|
||||
modelBuilder.Entity("Novelly.Api.Users.NovelMember", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Projects.Project", "Project")
|
||||
b.HasOne("Novelly.Api.Novels.Novel", "Novel")
|
||||
.WithMany("Members")
|
||||
.HasForeignKey("ProjectId")
|
||||
.HasForeignKey("NovelId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
@@ -1124,7 +1124,7 @@ namespace Novelly.Api.Data.Migrations
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Project");
|
||||
b.Navigation("Novel");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
@@ -1148,7 +1148,7 @@ namespace Novelly.Api.Data.Migrations
|
||||
b.Navigation("Relationships");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Projects.Project", b =>
|
||||
modelBuilder.Entity("Novelly.Api.Novels.Novel", b =>
|
||||
{
|
||||
b.Navigation("Chapters");
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ using Novelly.Api.Chapters;
|
||||
using Novelly.Api.Characters;
|
||||
using Novelly.Api.Genres;
|
||||
using Novelly.Api.Imports;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Novels;
|
||||
using Novelly.Api.Questions;
|
||||
using Novelly.Api.Tags;
|
||||
using Novelly.Api.Users;
|
||||
@@ -18,7 +18,7 @@ internal class UtcTicksConverter() : ValueConverter<DateTimeOffset, long>(value
|
||||
|
||||
public class NovelDbContext(DbContextOptions<NovelDbContext> options) : IdentityUserContext<NovellyUser, Guid>(options), INovelDbContext
|
||||
{
|
||||
public DbSet<Project> Projects => Set<Project>();
|
||||
public DbSet<Novel> Novels => Set<Novel>();
|
||||
public DbSet<Character> Characters => Set<Character>();
|
||||
public DbSet<CharacterRelationship> CharacterRelationships => Set<CharacterRelationship>();
|
||||
public DbSet<CharacterArcStage> CharacterArcStages => Set<CharacterArcStage>();
|
||||
@@ -30,7 +30,7 @@ public class NovelDbContext(DbContextOptions<NovelDbContext> options) : Identity
|
||||
public DbSet<AgentMessage> AgentMessages => Set<AgentMessage>();
|
||||
public DbSet<ImportJob> ImportJobs => Set<ImportJob>();
|
||||
public DbSet<Genre> Genres => Set<Genre>();
|
||||
public DbSet<ProjectMember> ProjectMembers => Set<ProjectMember>();
|
||||
public DbSet<NovelMember> NovelMembers => Set<NovelMember>();
|
||||
|
||||
Task<int> INovelDbContext.SaveChangesAsync(CancellationToken cancellationToken) => base.SaveChangesAsync(cancellationToken);
|
||||
|
||||
@@ -45,7 +45,7 @@ public class NovelDbContext(DbContextOptions<NovelDbContext> options) : Identity
|
||||
|
||||
public interface INovelDbContext
|
||||
{
|
||||
DbSet<Project> Projects { get; }
|
||||
DbSet<Novel> Novels { get; }
|
||||
DbSet<Character> Characters { get; }
|
||||
DbSet<CharacterRelationship> CharacterRelationships { get; }
|
||||
DbSet<CharacterArcStage> CharacterArcStages { get; }
|
||||
@@ -58,7 +58,7 @@ public interface INovelDbContext
|
||||
DbSet<ImportJob> ImportJobs { get; }
|
||||
DbSet<Genre> Genres { get; }
|
||||
DbSet<NovellyUser> Users { get; }
|
||||
DbSet<ProjectMember> ProjectMembers { get; }
|
||||
DbSet<NovelMember> NovelMembers { get; }
|
||||
|
||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ using Novelly.Api.Agent;
|
||||
|
||||
namespace Novelly.Api.Imports;
|
||||
|
||||
public record ImportRunResult(bool Completed, Guid? ProjectId, int ChaptersCompleted, string? Message);
|
||||
public record ImportRunResult(bool Completed, Guid? NovelId, int ChaptersCompleted, string? Message);
|
||||
|
||||
public class ImportAgentService(
|
||||
IAgentModelClient model,
|
||||
@@ -14,13 +14,13 @@ public class ImportAgentService(
|
||||
private readonly AgentOptions _options = options.Value;
|
||||
|
||||
public async Task<ImportRunResult> RunAsync(
|
||||
string sourceRoot, Guid? existingProjectId, int chaptersTotal, CancellationToken ct = default)
|
||||
string sourceRoot, Guid? existingNovelId, int chaptersTotal, CancellationToken ct = default)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Running import for {SourceRoot}, existing project {ExistingProjectId}, {ChaptersTotal} chapters total",
|
||||
sourceRoot, existingProjectId, chaptersTotal);
|
||||
"Running import for {SourceRoot}, existing novel {ExistingNovelId}, {ChaptersTotal} chapters total",
|
||||
sourceRoot, existingNovelId, chaptersTotal);
|
||||
|
||||
toolset.Initialize(sourceRoot, existingProjectId);
|
||||
toolset.Initialize(sourceRoot, existingNovelId);
|
||||
|
||||
var startingLedger = toolset.ReadLedgerOrNull();
|
||||
var systemPrompt = BuildSystemPrompt(sourceRoot);
|
||||
@@ -46,7 +46,7 @@ public class ImportAgentService(
|
||||
logger.LogInformation("Import for {SourceRoot} completed after {Turns} turns", sourceRoot, turn + 1);
|
||||
return new ImportRunResult(
|
||||
Completed: true,
|
||||
toolset.ProjectId,
|
||||
toolset.NovelId,
|
||||
ledger?.CompletedChapters?.Count ?? 0,
|
||||
null);
|
||||
}
|
||||
@@ -60,7 +60,7 @@ public class ImportAgentService(
|
||||
|
||||
return new ImportRunResult(
|
||||
Completed: false,
|
||||
toolset.ProjectId,
|
||||
toolset.NovelId,
|
||||
finalLedger?.CompletedChapters?.Count ?? 0,
|
||||
"Reached the safety limit for this run without finishing. Starting the import "
|
||||
+ "again for the same folder will resume from the ledger.");
|
||||
@@ -107,12 +107,12 @@ public class ImportAgentService(
|
||||
|
||||
private const string SystemPromptTemplate = """
|
||||
You import a novel outline that already exists as markdown files on disk into this
|
||||
app's project data. You are running unattended — nobody will read your replies or
|
||||
app's novel data. You are running unattended — nobody will read your replies or
|
||||
answer questions mid-run, so make the judgment calls the instructions below call
|
||||
for yourself and record anything genuinely ambiguous rather than stalling on it.
|
||||
|
||||
Your tools give you exactly two things: read-only access to files under the import
|
||||
source folder, and application tools that create the project's chapters, characters,
|
||||
source folder, and application tools that create the novel's chapters, characters,
|
||||
beats and arcs — the same ones the writer's own UI uses. You cannot write or edit
|
||||
anything on disk except the resume ledger, and you cannot read anything outside the
|
||||
source folder.
|
||||
@@ -143,10 +143,10 @@ public class ImportAgentService(
|
||||
|
||||
```json
|
||||
{{
|
||||
"projectId": "guid",
|
||||
"novelId": "guid",
|
||||
"characters": {{ "Name": "guid", "Alias": "guid" }},
|
||||
"chapters": {{ "1": "guid" }},
|
||||
"completedPasses": ["project", "characters"],
|
||||
"completedPasses": ["novel", "characters"],
|
||||
"completedChapters": [1, 2, 3]
|
||||
}}
|
||||
```
|
||||
@@ -160,9 +160,9 @@ public class ImportAgentService(
|
||||
Skip a pass whose completion is already recorded. Jump straight to the first
|
||||
incomplete one.
|
||||
|
||||
1. **Project** — skip if `completedPasses` has "project". Parse title and author from
|
||||
1. **Novel** — skip if `completedPasses` has "novel". Parse title and author from
|
||||
`outline.md`'s heading. The paragraph(s) before the chapter table are the blurb —
|
||||
pass them as `notes` to create_project. Record `projectId`, mark "project" done.
|
||||
pass them as `notes` to create_novel. Record `novelId`, mark "novel" done.
|
||||
2. **Characters (dossiers)** — skip if "characters" is complete. For each
|
||||
`characters/*.md` not already in the ledger's `characters` map: name from the `#`
|
||||
heading, occupation from the tagline, appearance/backstory/want from
|
||||
|
||||
@@ -4,7 +4,7 @@ using Novelly.Api.Beats;
|
||||
using Novelly.Api.Chapters;
|
||||
using Novelly.Api.Characters;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Novels;
|
||||
|
||||
namespace Novelly.Api.Imports;
|
||||
|
||||
@@ -20,7 +20,7 @@ internal record ImportAgentTool(
|
||||
Func<JsonElement, CancellationToken, Task<object?>> Handler);
|
||||
|
||||
public class ImportAgentToolset(
|
||||
ProjectService projects,
|
||||
NovelService novels,
|
||||
CharacterService characters,
|
||||
CharacterArcService arcs,
|
||||
ChapterService chapters,
|
||||
@@ -36,15 +36,15 @@ public class ImportAgentToolset(
|
||||
private string _sourceRoot = string.Empty;
|
||||
private Dictionary<string, ImportAgentTool>? _byName;
|
||||
|
||||
public Guid? ProjectId { get; private set; }
|
||||
public Guid? NovelId { get; private set; }
|
||||
|
||||
public IReadOnlyList<AgentToolDefinition> Definitions =>
|
||||
[.. ByName.Values.Select(t => new AgentToolDefinition(t.Name, t.Description, t.InputSchema))];
|
||||
|
||||
public void Initialize(string sourceRoot, Guid? existingProjectId)
|
||||
public void Initialize(string sourceRoot, Guid? existingNovelId)
|
||||
{
|
||||
_sourceRoot = sourceRoot;
|
||||
ProjectId = existingProjectId;
|
||||
NovelId = existingNovelId;
|
||||
}
|
||||
|
||||
public ImportLedger? ReadLedgerOrNull() => ImportPaths.ReadLedger(_sourceRoot);
|
||||
@@ -99,9 +99,9 @@ public class ImportAgentToolset(
|
||||
}
|
||||
}
|
||||
|
||||
private Guid RequireProjectId() =>
|
||||
ProjectId ?? throw new InvalidOperationException(
|
||||
"No project exists yet for this import — call create_project first.");
|
||||
private Guid RequireNovelId() =>
|
||||
NovelId ?? throw new InvalidOperationException(
|
||||
"No novel exists yet for this import — call create_novel first.");
|
||||
|
||||
private Dictionary<string, ImportAgentTool> ByName => _byName ??= Build().ToDictionary(t => t.Name);
|
||||
|
||||
@@ -187,8 +187,8 @@ public class ImportAgentToolset(
|
||||
});
|
||||
|
||||
yield return new ImportAgentTool(
|
||||
"create_project",
|
||||
"Create the novel project this import populates. Call once, in the first pass.",
|
||||
"create_novel",
|
||||
"Create the novel this import populates. Call once, in the first pass.",
|
||||
new JsonSchemaBuilder()
|
||||
.Str("title", "The book's title.", required: true)
|
||||
.Str("author", "Author name, if known.")
|
||||
@@ -196,18 +196,18 @@ public class ImportAgentToolset(
|
||||
.Build(),
|
||||
async (input, ct) =>
|
||||
{
|
||||
var created = await projects.CreateAsync(new CreateProjectRequest(
|
||||
var created = await novels.CreateAsync(new CreateNovelRequest(
|
||||
JsonInput.RequiredString(input, "title"),
|
||||
JsonInput.String(input, "author"),
|
||||
Notes: JsonInput.String(input, "notes")), ct);
|
||||
|
||||
ProjectId = created.Id;
|
||||
NovelId = created.Id;
|
||||
return created.ToResponse(null);
|
||||
});
|
||||
|
||||
yield return new ImportAgentTool(
|
||||
"update_project_brief",
|
||||
"Revise the project's top-level fields. Only the fields you supply change.",
|
||||
"update_novel_brief",
|
||||
"Revise the novel's top-level fields. Only the fields you supply change.",
|
||||
new JsonSchemaBuilder()
|
||||
.Str("title", "New title.")
|
||||
.Str("author", "Author name.")
|
||||
@@ -216,15 +216,15 @@ public class ImportAgentToolset(
|
||||
.Build(),
|
||||
async (input, ct) =>
|
||||
{
|
||||
var projectId = RequireProjectId();
|
||||
var updated = await projects.UpdateAsync(projectId, new UpdateProjectRequest(
|
||||
var novelId = RequireNovelId();
|
||||
var updated = await novels.UpdateAsync(novelId, new UpdateNovelRequest(
|
||||
JsonInput.String(input, "title"),
|
||||
JsonInput.String(input, "author"),
|
||||
JsonInput.String(input, "genre"),
|
||||
Notes: JsonInput.String(input, "notes")), ct);
|
||||
|
||||
return updated is null
|
||||
? new ImportToolNotFound("Project", projectId)
|
||||
? new ImportToolNotFound("Novel", novelId)
|
||||
: updated.ToResponse(null);
|
||||
});
|
||||
|
||||
@@ -234,8 +234,8 @@ public class ImportAgentToolset(
|
||||
CharacterSchema(nameRequired: true).Build(),
|
||||
async (input, ct) =>
|
||||
{
|
||||
var projectId = RequireProjectId();
|
||||
var created = await characters.CreateAsync(projectId, new CreateCharacterRequest(
|
||||
var novelId = RequireNovelId();
|
||||
var created = await characters.CreateAsync(novelId, new CreateCharacterRequest(
|
||||
JsonInput.RequiredString(input, "name"),
|
||||
Importance: JsonInput.Enum<CharacterImportance>(input, "importance") ?? CharacterImportance.Supporting,
|
||||
Occupation: JsonInput.String(input, "occupation"),
|
||||
@@ -245,7 +245,7 @@ public class ImportAgentToolset(
|
||||
Notes: JsonInput.String(input, "notes")), ct);
|
||||
|
||||
return created is null
|
||||
? new ImportToolNotFound("Project", projectId)
|
||||
? new ImportToolNotFound("Novel", novelId)
|
||||
: created.ToResponse();
|
||||
});
|
||||
|
||||
@@ -284,8 +284,8 @@ public class ImportAgentToolset(
|
||||
.Build(),
|
||||
async (input, ct) =>
|
||||
{
|
||||
var projectId = RequireProjectId();
|
||||
var created = await chapters.CreateAsync(projectId, new CreateChapterRequest(
|
||||
var novelId = RequireNovelId();
|
||||
var created = await chapters.CreateAsync(novelId, new CreateChapterRequest(
|
||||
JsonInput.RequiredString(input, "title"),
|
||||
JsonInput.Int(input, "number"),
|
||||
JsonInput.String(input, "summary"),
|
||||
@@ -293,7 +293,7 @@ public class ImportAgentToolset(
|
||||
Tags: JsonInput.Strings(input, "tags")), ct);
|
||||
|
||||
return created is null
|
||||
? new ImportToolNotFound("Project", projectId)
|
||||
? new ImportToolNotFound("Novel", novelId)
|
||||
: created.ToResponse();
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace Novelly.Api.Imports;
|
||||
public record ImportJobResponse(
|
||||
Guid Id,
|
||||
string SourceRoot,
|
||||
Guid? ProjectId,
|
||||
Guid? NovelId,
|
||||
ImportJobStatus Status,
|
||||
string? StatusMessage,
|
||||
int ChaptersCompleted,
|
||||
@@ -22,7 +22,7 @@ public enum ImportReadiness
|
||||
|
||||
public record ImportInspectionResponse(
|
||||
ImportReadiness Readiness,
|
||||
Guid? ProjectId,
|
||||
Guid? NovelId,
|
||||
int ChaptersCompleted,
|
||||
int ChaptersTotal,
|
||||
IReadOnlyList<string> CompletedPasses);
|
||||
@@ -62,7 +62,7 @@ public static class ImportMapping
|
||||
public static ImportJobResponse ToResponse(this ImportJob job) => new(
|
||||
job.Id,
|
||||
job.SourceRoot,
|
||||
job.ProjectId,
|
||||
job.NovelId,
|
||||
job.Status,
|
||||
job.StatusMessage,
|
||||
job.ChaptersCompleted,
|
||||
|
||||
@@ -18,7 +18,7 @@ public class ImportJob
|
||||
|
||||
public string SourceRoot { get; init; } = string.Empty;
|
||||
|
||||
public Guid? ProjectId { get; set; }
|
||||
public Guid? NovelId { get; set; }
|
||||
|
||||
public Guid? RequestedByUserId { get; init; }
|
||||
|
||||
|
||||
@@ -53,11 +53,11 @@ public class ImportJobRunner(
|
||||
|
||||
try
|
||||
{
|
||||
var existingProjectId = ImportPaths.ReadLedger(job.SourceRoot)?.ProjectId;
|
||||
var existingNovelId = ImportPaths.ReadLedger(job.SourceRoot)?.NovelId;
|
||||
|
||||
var result = await agent.RunAsync(job.SourceRoot, existingProjectId, job.ChaptersTotal, ct);
|
||||
var result = await agent.RunAsync(job.SourceRoot, existingNovelId, job.ChaptersTotal, ct);
|
||||
|
||||
job.ProjectId = result.ProjectId;
|
||||
job.NovelId = result.NovelId;
|
||||
job.ChaptersCompleted = result.ChaptersCompleted;
|
||||
job.Status = result.Completed ? ImportJobStatus.Completed : ImportJobStatus.Paused;
|
||||
job.StatusMessage = result.Message;
|
||||
|
||||
@@ -4,7 +4,7 @@ using System.Text.Json.Serialization;
|
||||
namespace Novelly.Api.Imports;
|
||||
|
||||
public record ImportLedger(
|
||||
Guid? ProjectId,
|
||||
Guid? NovelId,
|
||||
Dictionary<string, Guid>? Characters,
|
||||
Dictionary<string, Guid>? Chapters,
|
||||
List<string>? CompletedPasses,
|
||||
@@ -100,7 +100,7 @@ internal static class ImportPaths
|
||||
}
|
||||
|
||||
var passes = ledger.CompletedPasses ?? [];
|
||||
var requiredPasses = new[] { "project", "characters", "chapters", "arcs" };
|
||||
var requiredPasses = new[] { "novel", "characters", "chapters", "arcs" };
|
||||
var chaptersDone = ledger.CompletedChapters?.Count ?? 0;
|
||||
|
||||
return requiredPasses.All(passes.Contains) && (chaptersTotal == 0 || chaptersDone >= chaptersTotal);
|
||||
|
||||
@@ -3,14 +3,14 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Common.Validation;
|
||||
using Novelly.Api.Data;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Novels;
|
||||
using Novelly.Api.Users;
|
||||
|
||||
namespace Novelly.Api.Imports;
|
||||
|
||||
public class ImportService(
|
||||
INovelDbContext db,
|
||||
ProjectService projects,
|
||||
NovelService novels,
|
||||
Channel<Guid> queue,
|
||||
INovelUserContext userContext,
|
||||
ILogger<ImportService> logger,
|
||||
@@ -37,7 +37,7 @@ public class ImportService(
|
||||
var readiness = ImportPaths.IsComplete(ledger, total) ? ImportReadiness.Complete : ImportReadiness.Resumable;
|
||||
|
||||
return Task.FromResult(new ImportInspectionResponse(
|
||||
readiness, ledger.ProjectId, chaptersDone, total, ledger.CompletedPasses ?? []));
|
||||
readiness, ledger.NovelId, chaptersDone, total, ledger.CompletedPasses ?? []));
|
||||
}
|
||||
|
||||
public async Task<ImportJob> StartOrResumeAsync(StartImportRequest request, CancellationToken ct = default)
|
||||
@@ -53,11 +53,11 @@ public class ImportService(
|
||||
if (request.ForceRestart)
|
||||
{
|
||||
var ledger = ImportPaths.ReadLedger(root);
|
||||
if (ledger?.ProjectId is { } existingProjectId)
|
||||
if (ledger?.NovelId is { } existingNovelId)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Force-restarting import for {SourceRoot}: deleting project {ProjectId}", root, existingProjectId);
|
||||
await projects.DeleteAsync(existingProjectId, ct);
|
||||
"Force-restarting import for {SourceRoot}: deleting novel {NovelId}", root, existingNovelId);
|
||||
await novels.DeleteAsync(existingNovelId, ct);
|
||||
}
|
||||
|
||||
ImportPaths.DeleteLedger(root);
|
||||
|
||||
@@ -6,9 +6,9 @@ using Novelly.Api.Characters;
|
||||
using Novelly.Api.Tags;
|
||||
using Novelly.Api.Users;
|
||||
|
||||
namespace Novelly.Api.Projects;
|
||||
namespace Novelly.Api.Novels;
|
||||
|
||||
public class Project
|
||||
public class Novel
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
@@ -24,7 +24,7 @@ public class Project
|
||||
|
||||
public int? TargetWordCount { get; set; }
|
||||
|
||||
public ProjectPhase Phase { get; set; } = ProjectPhase.Brainstorming;
|
||||
public NovelPhase Phase { get; set; } = NovelPhase.Brainstorming;
|
||||
|
||||
public Guid? OwnerId { get; set; }
|
||||
public NovellyUser? Owner { get; set; }
|
||||
@@ -36,25 +36,25 @@ public class Project
|
||||
public List<Chapter> Chapters { get; set; } = [];
|
||||
public List<Tag> Tags { get; set; } = [];
|
||||
public List<AgentConversation> Conversations { get; set; } = [];
|
||||
public List<ProjectMember> Members { get; set; } = [];
|
||||
public List<NovelMember> Members { get; set; } = [];
|
||||
}
|
||||
|
||||
public class ProjectEntityTypeConfiguration : IEntityTypeConfiguration<Project>
|
||||
public class NovelEntityTypeConfiguration : IEntityTypeConfiguration<Novel>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Project> entity)
|
||||
public void Configure(EntityTypeBuilder<Novel> entity)
|
||||
{
|
||||
entity.Property(p => p.Title).IsRequired().HasMaxLength(300);
|
||||
entity.Property(p => p.Phase).HasConversion<string>().HasMaxLength(32);
|
||||
entity.HasMany(p => p.Characters).WithOne(c => c.Project!)
|
||||
.HasForeignKey(c => c.ProjectId).OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasMany(p => p.Chapters).WithOne(c => c.Project!)
|
||||
.HasForeignKey(c => c.ProjectId).OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasMany(p => p.Tags).WithOne(t => t.Project!)
|
||||
.HasForeignKey(t => t.ProjectId).OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasMany(p => p.Conversations).WithOne(c => c.Project!)
|
||||
.HasForeignKey(c => c.ProjectId).OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasMany(p => p.Members).WithOne(m => m.Project!)
|
||||
.HasForeignKey(m => m.ProjectId).OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasMany(p => p.Characters).WithOne(c => c.Novel!)
|
||||
.HasForeignKey(c => c.NovelId).OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasMany(p => p.Chapters).WithOne(c => c.Novel!)
|
||||
.HasForeignKey(c => c.NovelId).OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasMany(p => p.Tags).WithOne(t => t.Novel!)
|
||||
.HasForeignKey(t => t.NovelId).OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasMany(p => p.Conversations).WithOne(c => c.Novel!)
|
||||
.HasForeignKey(c => c.NovelId).OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasMany(p => p.Members).WithOne(m => m.Novel!)
|
||||
.HasForeignKey(m => m.NovelId).OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne(p => p.Owner).WithMany()
|
||||
.HasForeignKey(p => p.OwnerId).OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
+19
-19
@@ -1,21 +1,21 @@
|
||||
using Novelly.Api.Common.Validation;
|
||||
|
||||
namespace Novelly.Api.Projects;
|
||||
namespace Novelly.Api.Novels;
|
||||
|
||||
public record ProjectSummaryResponse(
|
||||
public record NovelSummaryResponse(
|
||||
Guid Id,
|
||||
string Title,
|
||||
string? Author,
|
||||
string? Genre,
|
||||
string? Logline,
|
||||
int? TargetWordCount,
|
||||
ProjectPhase Phase,
|
||||
NovelPhase Phase,
|
||||
int CharacterCount,
|
||||
int ChapterCount,
|
||||
int WordCount,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
public record ProjectResponse(
|
||||
public record NovelResponse(
|
||||
Guid Id,
|
||||
string Title,
|
||||
string? Author,
|
||||
@@ -24,13 +24,13 @@ public record ProjectResponse(
|
||||
string? Synopsis,
|
||||
string? Notes,
|
||||
int? TargetWordCount,
|
||||
ProjectPhase Phase,
|
||||
NovelPhase Phase,
|
||||
Guid? OwnerId,
|
||||
string? MyRole,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
public record CreateProjectRequest(
|
||||
public record CreateNovelRequest(
|
||||
string Title,
|
||||
string? Author = null,
|
||||
string? Genre = null,
|
||||
@@ -39,20 +39,20 @@ public record CreateProjectRequest(
|
||||
string? Notes = null,
|
||||
int? TargetWordCount = null);
|
||||
|
||||
public class CreateProjectRequestValidator : IModelValidator<CreateProjectRequest>
|
||||
public class CreateNovelRequestValidator : IModelValidator<CreateNovelRequest>
|
||||
{
|
||||
public ValidationResult Validate(CreateProjectRequest model)
|
||||
public ValidationResult Validate(CreateNovelRequest model)
|
||||
{
|
||||
var result = new ValidationResult();
|
||||
|
||||
ProjectValidation.Title(model.Title, result);
|
||||
ProjectValidation.OptionalFields(model.Author, model.Genre, model.Logline, model.Synopsis, model.Notes, model.TargetWordCount, result);
|
||||
NovelValidation.Title(model.Title, result);
|
||||
NovelValidation.OptionalFields(model.Author, model.Genre, model.Logline, model.Synopsis, model.Notes, model.TargetWordCount, result);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
public record UpdateProjectRequest(
|
||||
public record UpdateNovelRequest(
|
||||
string? Title = null,
|
||||
string? Author = null,
|
||||
string? Genre = null,
|
||||
@@ -60,22 +60,22 @@ public record UpdateProjectRequest(
|
||||
string? Synopsis = null,
|
||||
string? Notes = null,
|
||||
int? TargetWordCount = null,
|
||||
ProjectPhase? Phase = null);
|
||||
NovelPhase? Phase = null);
|
||||
|
||||
public class UpdateProjectRequestValidator : IModelValidator<UpdateProjectRequest>
|
||||
public class UpdateNovelRequestValidator : IModelValidator<UpdateNovelRequest>
|
||||
{
|
||||
public ValidationResult Validate(UpdateProjectRequest model)
|
||||
public ValidationResult Validate(UpdateNovelRequest model)
|
||||
{
|
||||
var result = new ValidationResult();
|
||||
|
||||
result.AddUnclearableTextErrors("Title", "Title", model.Title, "a project", 200);
|
||||
ProjectValidation.OptionalFields(model.Author, model.Genre, model.Logline, model.Synopsis, model.Notes, model.TargetWordCount, result);
|
||||
result.AddUnclearableTextErrors("Title", "Title", model.Title, "a novel", 200);
|
||||
NovelValidation.OptionalFields(model.Author, model.Genre, model.Logline, model.Synopsis, model.Notes, model.TargetWordCount, result);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
file static class ProjectValidation
|
||||
file static class NovelValidation
|
||||
{
|
||||
public static void Title(string title, ValidationResult result) => result.AddRequiredTextErrors("Title", "Title", title, 200);
|
||||
|
||||
@@ -101,9 +101,9 @@ file static class ProjectValidation
|
||||
}
|
||||
}
|
||||
|
||||
public static class ProjectMapping
|
||||
public static class NovelMapping
|
||||
{
|
||||
public static ProjectResponse ToResponse(this Project p, string? myRole) => new(
|
||||
public static NovelResponse ToResponse(this Novel p, string? myRole) => new(
|
||||
p.Id, p.Title, p.Author, p.Genre, p.Logline, p.Synopsis, p.Notes,
|
||||
p.TargetWordCount, p.Phase, p.OwnerId, myRole, p.CreatedAt, p.UpdatedAt);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Common.Validation;
|
||||
using Novelly.Api.Users;
|
||||
|
||||
namespace Novelly.Api.Novels;
|
||||
|
||||
public static class NovelEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapNovelEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/novels").WithTags("Novels")
|
||||
.AddEndpointFilter<RequestLoggingEndpointFilter>()
|
||||
.AddEndpointFilter<ValidationEndpointFilter>();
|
||||
|
||||
group.MapGet("/", async (NovelService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.ListAsync(ct)))
|
||||
.WithSummary("List all novels.");
|
||||
|
||||
group.MapGet("/{id:guid}", async (Guid id, NovelService service, NovelAccessService access, CancellationToken ct) =>
|
||||
{
|
||||
var novel = await service.GetAsync(id, ct);
|
||||
if (novel is null)
|
||||
return Results.NotFound();
|
||||
|
||||
var myRole = await access.GetMyRoleAsync(novel, ct);
|
||||
return Results.Ok(novel.ToResponse(myRole));
|
||||
})
|
||||
.WithSummary("Read a novel's brief.");
|
||||
|
||||
group.MapPost("/", async (CreateNovelRequest request, NovelService service, NovelAccessService access, CancellationToken ct) =>
|
||||
{
|
||||
var novel = await service.CreateAsync(request, ct);
|
||||
var myRole = await access.GetMyRoleAsync(novel, ct);
|
||||
var created = novel.ToResponse(myRole);
|
||||
return Results.Created($"/api/novels/{created.Id}", created);
|
||||
})
|
||||
.WithSummary("Create a novel.");
|
||||
|
||||
group.MapPatch("/{id:guid}", async (
|
||||
Guid id, UpdateNovelRequest request, NovelService service, NovelAccessService access, CancellationToken ct) =>
|
||||
{
|
||||
var novel = await service.UpdateAsync(id, request, ct);
|
||||
if (novel is null)
|
||||
return Results.NotFound();
|
||||
|
||||
var myRole = await access.GetMyRoleAsync(novel, ct);
|
||||
return Results.Ok(novel.ToResponse(myRole));
|
||||
})
|
||||
.WithSummary("Update a novel's brief.");
|
||||
|
||||
group.MapDelete("/{id:guid}", async (Guid id, NovelService service, CancellationToken ct) =>
|
||||
await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
|
||||
.WithSummary("Delete a novel and everything in it.");
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace Novelly.Api.Projects;
|
||||
namespace Novelly.Api.Novels;
|
||||
|
||||
public enum ProjectPhase
|
||||
public enum NovelPhase
|
||||
{
|
||||
Brainstorming,
|
||||
Outlining,
|
||||
@@ -0,0 +1,133 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Common.Validation;
|
||||
using Novelly.Api.Data;
|
||||
using Novelly.Api.Users;
|
||||
|
||||
namespace Novelly.Api.Novels;
|
||||
|
||||
public class NovelService(
|
||||
INovelDbContext db,
|
||||
NovelAccessService access,
|
||||
INovelUserContext userContext,
|
||||
ILogger<NovelService> logger,
|
||||
IModelValidator<CreateNovelRequest> createValidator,
|
||||
IModelValidator<UpdateNovelRequest> updateValidator)
|
||||
{
|
||||
public async Task<IReadOnlyList<NovelSummaryResponse>> ListAsync(CancellationToken ct = default)
|
||||
{
|
||||
logger.LogInformation("Listing novels");
|
||||
|
||||
return await access.VisibleNovels()
|
||||
.OrderByDescending(p => p.UpdatedAt)
|
||||
.Select(p => new NovelSummaryResponse(
|
||||
p.Id,
|
||||
p.Title,
|
||||
p.Author,
|
||||
p.Genre,
|
||||
p.Logline,
|
||||
p.TargetWordCount,
|
||||
p.Phase,
|
||||
p.Characters.Count,
|
||||
p.Chapters.Count,
|
||||
p.Chapters.Sum(c => (int?)c.WordCount) ?? 0,
|
||||
p.UpdatedAt))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<Novel?> GetAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(id, nameof(id));
|
||||
|
||||
logger.LogInformation("Getting novel {NovelId}", id);
|
||||
|
||||
var novel = await FindAsync(id, ct);
|
||||
if (novel is null) return null;
|
||||
|
||||
await access.RequireAsync(id, NovelPermission.Read, ct);
|
||||
return novel;
|
||||
}
|
||||
|
||||
public async Task<Novel> CreateAsync(CreateNovelRequest request, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Null(request, nameof(request));
|
||||
createValidator.Validate(request).ThrowIfInvalid(logger);
|
||||
access.RequireCanCreateNovel();
|
||||
|
||||
logger.LogInformation("Creating novel {Title}", request.Title);
|
||||
|
||||
var novel = new Novel
|
||||
{
|
||||
Title = request.Title,
|
||||
Author = request.Author,
|
||||
Genre = request.Genre,
|
||||
Logline = request.Logline,
|
||||
Synopsis = request.Synopsis,
|
||||
Notes = request.Notes,
|
||||
TargetWordCount = request.TargetWordCount,
|
||||
OwnerId = userContext.UserId
|
||||
};
|
||||
|
||||
db.Novels.Add(novel);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return novel;
|
||||
}
|
||||
|
||||
public async Task<Novel?> UpdateAsync(Guid id, UpdateNovelRequest request, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(id, nameof(id));
|
||||
Guard.Null(request, nameof(request));
|
||||
updateValidator.Validate(request).ThrowIfInvalid(logger);
|
||||
|
||||
logger.LogInformation("Updating novel {NovelId}", id);
|
||||
|
||||
var novel = await FindAsync(id, ct);
|
||||
if (novel is null) return null;
|
||||
|
||||
await access.RequireAsync(id, NovelPermission.Write, ct);
|
||||
|
||||
novel.Title = Patch.Apply(novel.Title, request.Title) ?? novel.Title;
|
||||
novel.Author = Patch.Apply(novel.Author, request.Author);
|
||||
novel.Genre = Patch.Apply(novel.Genre, request.Genre);
|
||||
novel.Logline = Patch.Apply(novel.Logline, request.Logline);
|
||||
novel.Synopsis = Patch.Apply(novel.Synopsis, request.Synopsis);
|
||||
novel.Notes = Patch.Apply(novel.Notes, request.Notes);
|
||||
novel.TargetWordCount = request.TargetWordCount ?? novel.TargetWordCount;
|
||||
novel.Phase = request.Phase ?? novel.Phase;
|
||||
novel.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
return novel;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(id, nameof(id));
|
||||
|
||||
logger.LogInformation("Deleting novel {NovelId}", id);
|
||||
|
||||
var novel = await FindAsync(id, ct);
|
||||
if (novel is null) return false;
|
||||
|
||||
await access.RequireAsync(id, NovelPermission.DeleteContent, ct);
|
||||
|
||||
db.Novels.Remove(novel);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<Novel?> FindAsync(Guid id, CancellationToken ct)
|
||||
{
|
||||
logger.LogDebug("Finding novel {NovelId}", id);
|
||||
|
||||
var novel = await db.Novels.FirstOrDefaultAsync(p => p.Id == id, ct);
|
||||
if (novel is null)
|
||||
{
|
||||
logger.LogWarning("Novel {NovelId} not found", id);
|
||||
return novel;
|
||||
}
|
||||
|
||||
logger.LogDebug("Found novel {NovelId}", id);
|
||||
return novel;
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ using Novelly.Api.Common;
|
||||
using Novelly.Api.Data;
|
||||
using Novelly.Api.Genres;
|
||||
using Novelly.Api.Imports;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Novels;
|
||||
using Novelly.Api.Questions;
|
||||
using Novelly.Api.Tags;
|
||||
using Novelly.Api.Users;
|
||||
@@ -88,9 +88,9 @@ app.MapDefaultEndpoints();
|
||||
app.MapGet("/api/health", () => Results.Ok(new { status = "ok" })).WithTags("Health").AllowAnonymous();
|
||||
|
||||
app.MapUserEndpoints();
|
||||
app.MapProjectMemberEndpoints();
|
||||
app.MapNovelMemberEndpoints();
|
||||
|
||||
app.MapProjectEndpoints()
|
||||
app.MapNovelEndpoints()
|
||||
.MapCharacterEndpoints()
|
||||
.MapChapterEndpoints()
|
||||
.MapBeatEndpoints()
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Common.Validation;
|
||||
using Novelly.Api.Users;
|
||||
|
||||
namespace Novelly.Api.Projects;
|
||||
|
||||
public static class ProjectEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapProjectEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/projects").WithTags("Projects")
|
||||
.AddEndpointFilter<RequestLoggingEndpointFilter>()
|
||||
.AddEndpointFilter<ValidationEndpointFilter>();
|
||||
|
||||
group.MapGet("/", async (ProjectService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.ListAsync(ct)))
|
||||
.WithSummary("List all novel projects.");
|
||||
|
||||
group.MapGet("/{id:guid}", async (Guid id, ProjectService service, ProjectAccessService access, CancellationToken ct) =>
|
||||
{
|
||||
var project = await service.GetAsync(id, ct);
|
||||
if (project is null)
|
||||
return Results.NotFound();
|
||||
|
||||
var myRole = await access.GetMyRoleAsync(project, ct);
|
||||
return Results.Ok(project.ToResponse(myRole));
|
||||
})
|
||||
.WithSummary("Read a project's brief.");
|
||||
|
||||
group.MapPost("/", async (CreateProjectRequest request, ProjectService service, ProjectAccessService access, CancellationToken ct) =>
|
||||
{
|
||||
var project = await service.CreateAsync(request, ct);
|
||||
var myRole = await access.GetMyRoleAsync(project, ct);
|
||||
var created = project.ToResponse(myRole);
|
||||
return Results.Created($"/api/projects/{created.Id}", created);
|
||||
})
|
||||
.WithSummary("Create a novel project.");
|
||||
|
||||
group.MapPatch("/{id:guid}", async (
|
||||
Guid id, UpdateProjectRequest request, ProjectService service, ProjectAccessService access, CancellationToken ct) =>
|
||||
{
|
||||
var project = await service.UpdateAsync(id, request, ct);
|
||||
if (project is null)
|
||||
return Results.NotFound();
|
||||
|
||||
var myRole = await access.GetMyRoleAsync(project, ct);
|
||||
return Results.Ok(project.ToResponse(myRole));
|
||||
})
|
||||
.WithSummary("Update a project's brief.");
|
||||
|
||||
group.MapDelete("/{id:guid}", async (Guid id, ProjectService service, CancellationToken ct) =>
|
||||
await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
|
||||
.WithSummary("Delete a project and everything in it.");
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Common.Validation;
|
||||
using Novelly.Api.Data;
|
||||
using Novelly.Api.Users;
|
||||
|
||||
namespace Novelly.Api.Projects;
|
||||
|
||||
public class ProjectService(
|
||||
INovelDbContext db,
|
||||
ProjectAccessService access,
|
||||
INovelUserContext userContext,
|
||||
ILogger<ProjectService> logger,
|
||||
IModelValidator<CreateProjectRequest> createValidator,
|
||||
IModelValidator<UpdateProjectRequest> updateValidator)
|
||||
{
|
||||
public async Task<IReadOnlyList<ProjectSummaryResponse>> ListAsync(CancellationToken ct = default)
|
||||
{
|
||||
logger.LogInformation("Listing projects");
|
||||
|
||||
return await access.VisibleProjects()
|
||||
.OrderByDescending(p => p.UpdatedAt)
|
||||
.Select(p => new ProjectSummaryResponse(
|
||||
p.Id,
|
||||
p.Title,
|
||||
p.Author,
|
||||
p.Genre,
|
||||
p.Logline,
|
||||
p.TargetWordCount,
|
||||
p.Phase,
|
||||
p.Characters.Count,
|
||||
p.Chapters.Count,
|
||||
p.Chapters.Sum(c => (int?)c.WordCount) ?? 0,
|
||||
p.UpdatedAt))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<Project?> GetAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(id, nameof(id));
|
||||
|
||||
logger.LogInformation("Getting project {ProjectId}", id);
|
||||
|
||||
var project = await FindAsync(id, ct);
|
||||
if (project is null) return null;
|
||||
|
||||
await access.RequireAsync(id, ProjectPermission.Read, ct);
|
||||
return project;
|
||||
}
|
||||
|
||||
public async Task<Project> CreateAsync(CreateProjectRequest request, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Null(request, nameof(request));
|
||||
createValidator.Validate(request).ThrowIfInvalid(logger);
|
||||
access.RequireCanCreateProject();
|
||||
|
||||
logger.LogInformation("Creating project {Title}", request.Title);
|
||||
|
||||
var project = new Project
|
||||
{
|
||||
Title = request.Title,
|
||||
Author = request.Author,
|
||||
Genre = request.Genre,
|
||||
Logline = request.Logline,
|
||||
Synopsis = request.Synopsis,
|
||||
Notes = request.Notes,
|
||||
TargetWordCount = request.TargetWordCount,
|
||||
OwnerId = userContext.UserId
|
||||
};
|
||||
|
||||
db.Projects.Add(project);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return project;
|
||||
}
|
||||
|
||||
public async Task<Project?> UpdateAsync(Guid id, UpdateProjectRequest request, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(id, nameof(id));
|
||||
Guard.Null(request, nameof(request));
|
||||
updateValidator.Validate(request).ThrowIfInvalid(logger);
|
||||
|
||||
logger.LogInformation("Updating project {ProjectId}", id);
|
||||
|
||||
var project = await FindAsync(id, ct);
|
||||
if (project is null) return null;
|
||||
|
||||
await access.RequireAsync(id, ProjectPermission.Write, ct);
|
||||
|
||||
project.Title = Patch.Apply(project.Title, request.Title) ?? project.Title;
|
||||
project.Author = Patch.Apply(project.Author, request.Author);
|
||||
project.Genre = Patch.Apply(project.Genre, request.Genre);
|
||||
project.Logline = Patch.Apply(project.Logline, request.Logline);
|
||||
project.Synopsis = Patch.Apply(project.Synopsis, request.Synopsis);
|
||||
project.Notes = Patch.Apply(project.Notes, request.Notes);
|
||||
project.TargetWordCount = request.TargetWordCount ?? project.TargetWordCount;
|
||||
project.Phase = request.Phase ?? project.Phase;
|
||||
project.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
return project;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(id, nameof(id));
|
||||
|
||||
logger.LogInformation("Deleting project {ProjectId}", id);
|
||||
|
||||
var project = await FindAsync(id, ct);
|
||||
if (project is null) return false;
|
||||
|
||||
await access.RequireAsync(id, ProjectPermission.DeleteContent, ct);
|
||||
|
||||
db.Projects.Remove(project);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<Project?> FindAsync(Guid id, CancellationToken ct)
|
||||
{
|
||||
logger.LogDebug("Finding project {ProjectId}", id);
|
||||
|
||||
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == id, ct);
|
||||
if (project is null)
|
||||
{
|
||||
logger.LogWarning("Project {ProjectId} not found", id);
|
||||
return project;
|
||||
}
|
||||
|
||||
logger.LogDebug("Found project {ProjectId}", id);
|
||||
return project;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using Novelly.Api.Chapters;
|
||||
using Novelly.Api.Characters;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Novels;
|
||||
|
||||
namespace Novelly.Api.Questions;
|
||||
|
||||
@@ -10,8 +10,8 @@ public class OpenQuestion
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
public Guid ProjectId { get; set; }
|
||||
public Project? Project { get; set; }
|
||||
public Guid NovelId { get; set; }
|
||||
public Novel? Novel { get; set; }
|
||||
|
||||
public string Question { get; set; } = string.Empty;
|
||||
|
||||
@@ -40,10 +40,10 @@ public class OpenQuestionEntityTypeConfiguration : IEntityTypeConfiguration<Open
|
||||
entity.Property(q => q.Question).IsRequired().HasMaxLength(500);
|
||||
entity.Ignore(q => q.IsResolved);
|
||||
|
||||
entity.HasIndex(q => q.ProjectId);
|
||||
entity.HasIndex(q => q.NovelId);
|
||||
|
||||
entity.HasOne(q => q.Project).WithMany()
|
||||
.HasForeignKey(q => q.ProjectId).OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne(q => q.Novel).WithMany()
|
||||
.HasForeignKey(q => q.NovelId).OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasOne(q => q.Chapter).WithMany()
|
||||
.HasForeignKey(q => q.ChapterId).OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace Novelly.Api.Questions;
|
||||
|
||||
public record OpenQuestionResponse(
|
||||
Guid Id,
|
||||
Guid ProjectId,
|
||||
Guid NovelId,
|
||||
string Question,
|
||||
string? Detail,
|
||||
Guid? ChapterId,
|
||||
@@ -76,7 +76,7 @@ public static class OpenQuestionMapping
|
||||
{
|
||||
public static OpenQuestionResponse ToResponse(this OpenQuestion q) => new(
|
||||
q.Id,
|
||||
q.ProjectId,
|
||||
q.NovelId,
|
||||
q.Question,
|
||||
q.Detail,
|
||||
q.ChapterId,
|
||||
|
||||
@@ -7,24 +7,24 @@ public static class OpenQuestionEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapOpenQuestionEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/questions").WithTags("Questions")
|
||||
var novelScoped = app.MapGroup("/api/novels/{novelId:guid}/questions").WithTags("Questions")
|
||||
.AddEndpointFilter<RequestLoggingEndpointFilter>()
|
||||
.AddEndpointFilter<ValidationEndpointFilter>();
|
||||
|
||||
projectScoped.MapGet("/", async (
|
||||
Guid projectId,
|
||||
novelScoped.MapGet("/", async (
|
||||
Guid novelId,
|
||||
OpenQuestionService service,
|
||||
CancellationToken ct,
|
||||
Guid? chapterId = null,
|
||||
Guid? characterId = null,
|
||||
bool includeResolved = false) =>
|
||||
Results.Ok((await service.ListAsync(projectId, chapterId, characterId, includeResolved, ct)).Select(q => q.ToResponse())))
|
||||
.WithSummary("List a project's open questions, optionally narrowed to one chapter or character.");
|
||||
Results.Ok((await service.ListAsync(novelId, chapterId, characterId, includeResolved, ct)).Select(q => q.ToResponse())))
|
||||
.WithSummary("List a novel's open questions, optionally narrowed to one chapter or character.");
|
||||
|
||||
projectScoped.MapPost("/", async (
|
||||
Guid projectId, CreateOpenQuestionRequest request, OpenQuestionService service, CancellationToken ct) =>
|
||||
novelScoped.MapPost("/", async (
|
||||
Guid novelId, CreateOpenQuestionRequest request, OpenQuestionService service, CancellationToken ct) =>
|
||||
{
|
||||
var question = await service.CreateAsync(projectId, request, ct);
|
||||
var question = await service.CreateAsync(novelId, request, ct);
|
||||
if (question is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
|
||||
@@ -10,28 +10,28 @@ namespace Novelly.Api.Questions;
|
||||
|
||||
public class OpenQuestionService(
|
||||
INovelDbContext db,
|
||||
ProjectAccessService access,
|
||||
NovelAccessService access,
|
||||
ILogger<OpenQuestionService> logger,
|
||||
IModelValidator<CreateOpenQuestionRequest> createValidator,
|
||||
IModelValidator<UpdateOpenQuestionRequest> updateValidator,
|
||||
IModelValidator<ResolveOpenQuestionRequest> resolveValidator)
|
||||
{
|
||||
public async Task<IReadOnlyList<OpenQuestion>> ListAsync(
|
||||
Guid projectId,
|
||||
Guid novelId,
|
||||
Guid? chapterId = null,
|
||||
Guid? characterId = null,
|
||||
bool includeResolved = false,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(projectId, nameof(projectId));
|
||||
Guard.Default(novelId, nameof(novelId));
|
||||
|
||||
logger.LogInformation(
|
||||
"Listing open questions for project {ProjectId}, chapter {ChapterId}, character {CharacterId}, includeResolved {IncludeResolved}",
|
||||
projectId, chapterId, characterId, includeResolved);
|
||||
"Listing open questions for novel {NovelId}, chapter {ChapterId}, character {CharacterId}, includeResolved {IncludeResolved}",
|
||||
novelId, chapterId, characterId, includeResolved);
|
||||
|
||||
await access.RequireAsync(projectId, ProjectPermission.Read, ct);
|
||||
await access.RequireAsync(novelId, NovelPermission.Read, ct);
|
||||
|
||||
var query = Query().Where(q => q.ProjectId == projectId);
|
||||
var query = Query().Where(q => q.NovelId == novelId);
|
||||
|
||||
if (chapterId is { } cid)
|
||||
{
|
||||
@@ -70,31 +70,31 @@ public class OpenQuestionService(
|
||||
return null;
|
||||
}
|
||||
|
||||
await access.RequireAsync(question.ProjectId, ProjectPermission.Read, ct);
|
||||
await access.RequireAsync(question.NovelId, NovelPermission.Read, ct);
|
||||
return question;
|
||||
}
|
||||
|
||||
public async Task<OpenQuestion?> CreateAsync(
|
||||
Guid projectId, CreateOpenQuestionRequest request, CancellationToken ct = default)
|
||||
Guid novelId, CreateOpenQuestionRequest request, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(projectId, nameof(projectId));
|
||||
Guard.Default(novelId, nameof(novelId));
|
||||
Guard.Null(request, nameof(request));
|
||||
createValidator.Validate(request).ThrowIfInvalid(logger);
|
||||
|
||||
logger.LogInformation("Creating open question for project {ProjectId}", projectId);
|
||||
logger.LogInformation("Creating open question for novel {NovelId}", novelId);
|
||||
|
||||
if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct))
|
||||
if (!await db.Novels.AnyAsync(p => p.Id == novelId, ct))
|
||||
{
|
||||
logger.LogWarning("Rejected open question creation: project {ProjectId} not found", projectId);
|
||||
logger.LogWarning("Rejected open question creation: novel {NovelId} not found", novelId);
|
||||
return null;
|
||||
}
|
||||
|
||||
await access.RequireAsync(projectId, ProjectPermission.CreateContent, ct);
|
||||
await ValidateAssociationsAsync(projectId, request.ChapterId, request.CharacterId, ct);
|
||||
await access.RequireAsync(novelId, NovelPermission.CreateContent, ct);
|
||||
await ValidateAssociationsAsync(novelId, request.ChapterId, request.CharacterId, ct);
|
||||
|
||||
var question = new OpenQuestion
|
||||
{
|
||||
ProjectId = projectId,
|
||||
NovelId = novelId,
|
||||
Question = request.Question.Trim(),
|
||||
Detail = request.Detail,
|
||||
ChapterId = request.ChapterId,
|
||||
@@ -122,8 +122,8 @@ public class OpenQuestionService(
|
||||
return null;
|
||||
}
|
||||
|
||||
await access.RequireAsync(question.ProjectId, ProjectPermission.Write, ct);
|
||||
await ValidateAssociationsAsync(question.ProjectId, request.ChapterId, request.CharacterId, ct);
|
||||
await access.RequireAsync(question.NovelId, NovelPermission.Write, ct);
|
||||
await ValidateAssociationsAsync(question.NovelId, request.ChapterId, request.CharacterId, ct);
|
||||
|
||||
question.Question = Patch.Apply(question.Question, request.Question) ?? question.Question;
|
||||
question.Detail = Patch.Apply(question.Detail, request.Detail);
|
||||
@@ -150,7 +150,7 @@ public class OpenQuestionService(
|
||||
return null;
|
||||
}
|
||||
|
||||
await access.RequireAsync(question.ProjectId, ProjectPermission.Write, ct);
|
||||
await access.RequireAsync(question.NovelId, NovelPermission.Write, ct);
|
||||
|
||||
question.Resolution = request.Resolution.Trim();
|
||||
question.ResolvedAt = DateTimeOffset.UtcNow;
|
||||
@@ -201,7 +201,7 @@ public class OpenQuestionService(
|
||||
return null;
|
||||
}
|
||||
|
||||
await access.RequireAsync(question.ProjectId, ProjectPermission.Write, ct);
|
||||
await access.RequireAsync(question.NovelId, NovelPermission.Write, ct);
|
||||
|
||||
question.Resolution = null;
|
||||
question.ResolvedAt = null;
|
||||
@@ -223,7 +223,7 @@ public class OpenQuestionService(
|
||||
return false;
|
||||
}
|
||||
|
||||
await access.RequireAsync(question.ProjectId, ProjectPermission.DeleteContent, ct);
|
||||
await access.RequireAsync(question.NovelId, NovelPermission.DeleteContent, ct);
|
||||
|
||||
db.OpenQuestions.Remove(question);
|
||||
await db.SaveChangesAsync(ct);
|
||||
@@ -234,27 +234,27 @@ public class OpenQuestionService(
|
||||
string.IsNullOrWhiteSpace(existing) ? note : $"{existing.TrimEnd()}\n\n{note}";
|
||||
|
||||
private async Task ValidateAssociationsAsync(
|
||||
Guid projectId, Guid? chapterId, Guid? characterId, CancellationToken ct)
|
||||
Guid novelId, Guid? chapterId, Guid? characterId, CancellationToken ct)
|
||||
{
|
||||
logger.LogDebug("Validating associations for project {ProjectId}: chapter {ChapterId}, character {CharacterId}", projectId, chapterId, characterId);
|
||||
logger.LogDebug("Validating associations for novel {NovelId}: chapter {ChapterId}, character {CharacterId}", novelId, chapterId, characterId);
|
||||
|
||||
if (chapterId is { } cid
|
||||
&& !await db.Chapters.AnyAsync(c => c.Id == cid && c.ProjectId == projectId, ct))
|
||||
&& !await db.Chapters.AnyAsync(c => c.Id == cid && c.NovelId == novelId, ct))
|
||||
{
|
||||
logger.LogWarning("Rejected question association: chapter {ChapterId} does not belong to project {ProjectId}", cid, projectId);
|
||||
logger.LogWarning("Rejected question association: chapter {ChapterId} does not belong to novel {NovelId}", cid, novelId);
|
||||
throw new InvalidOperationException(
|
||||
"A question can only be attached to a chapter in the same project.");
|
||||
"A question can only be attached to a chapter in the same novel.");
|
||||
}
|
||||
|
||||
if (characterId is { } chid
|
||||
&& !await db.Characters.AnyAsync(c => c.Id == chid && c.ProjectId == projectId, ct))
|
||||
&& !await db.Characters.AnyAsync(c => c.Id == chid && c.NovelId == novelId, ct))
|
||||
{
|
||||
logger.LogWarning("Rejected question association: character {CharacterId} does not belong to project {ProjectId}", chid, projectId);
|
||||
logger.LogWarning("Rejected question association: character {CharacterId} does not belong to novel {NovelId}", chid, novelId);
|
||||
throw new InvalidOperationException(
|
||||
"A question can only be attached to a character in the same project.");
|
||||
"A question can only be attached to a character in the same novel.");
|
||||
}
|
||||
|
||||
logger.LogDebug("Associations valid for project {ProjectId}: chapter {ChapterId}, character {CharacterId}", projectId, chapterId, characterId);
|
||||
logger.LogDebug("Associations valid for novel {NovelId}: chapter {ChapterId}, character {CharacterId}", novelId, chapterId, characterId);
|
||||
}
|
||||
|
||||
private IQueryable<OpenQuestion> Query() =>
|
||||
|
||||
@@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using Novelly.Api.Beats;
|
||||
using Novelly.Api.Chapters;
|
||||
using Novelly.Api.Characters;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Novels;
|
||||
|
||||
namespace Novelly.Api.Tags;
|
||||
|
||||
@@ -11,8 +11,8 @@ public class Tag
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
public Guid ProjectId { get; set; }
|
||||
public Project? Project { get; set; }
|
||||
public Guid NovelId { get; set; }
|
||||
public Novel? Novel { get; set; }
|
||||
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
@@ -32,7 +32,7 @@ public class TagEntityTypeConfiguration : IEntityTypeConfiguration<Tag>
|
||||
entity.Property(t => t.Name).IsRequired().HasMaxLength(64);
|
||||
entity.Property(t => t.Color).HasMaxLength(16);
|
||||
|
||||
entity.HasIndex(t => new { t.ProjectId, t.Name }).IsUnique();
|
||||
entity.HasIndex(t => new { t.NovelId, t.Name }).IsUnique();
|
||||
|
||||
entity.HasMany(t => t.Characters).WithMany(c => c.Tags)
|
||||
.UsingEntity(join => join.ToTable("CharacterTags"));
|
||||
|
||||
@@ -7,18 +7,18 @@ public static class TagEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapTagEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/tags").WithTags("Tags")
|
||||
var novelScoped = app.MapGroup("/api/novels/{novelId:guid}/tags").WithTags("Tags")
|
||||
.AddEndpointFilter<RequestLoggingEndpointFilter>()
|
||||
.AddEndpointFilter<ValidationEndpointFilter>();
|
||||
|
||||
projectScoped.MapGet("/", async (Guid projectId, TagService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.ListAsync(projectId, ct)))
|
||||
.WithSummary("List a project's tags with usage counts.");
|
||||
novelScoped.MapGet("/", async (Guid novelId, TagService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.ListAsync(novelId, ct)))
|
||||
.WithSummary("List a novel's tags with usage counts.");
|
||||
|
||||
projectScoped.MapPost("/", async (
|
||||
Guid projectId, CreateTagRequest request, TagService service, CancellationToken ct) =>
|
||||
novelScoped.MapPost("/", async (
|
||||
Guid novelId, CreateTagRequest request, TagService service, CancellationToken ct) =>
|
||||
{
|
||||
var tag = await service.CreateAsync(projectId, request, ct);
|
||||
var tag = await service.CreateAsync(novelId, request, ct);
|
||||
if (tag is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
|
||||
@@ -8,21 +8,21 @@ namespace Novelly.Api.Tags;
|
||||
|
||||
public class TagService(
|
||||
INovelDbContext db,
|
||||
ProjectAccessService access,
|
||||
NovelAccessService access,
|
||||
ILogger<TagService> logger,
|
||||
IModelValidator<CreateTagRequest> createValidator,
|
||||
IModelValidator<UpdateTagRequest> updateValidator)
|
||||
{
|
||||
public async Task<IReadOnlyList<TagSummaryResponse>> ListAsync(Guid projectId, CancellationToken ct = default)
|
||||
public async Task<IReadOnlyList<TagSummaryResponse>> ListAsync(Guid novelId, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(projectId, nameof(projectId));
|
||||
Guard.Default(novelId, nameof(novelId));
|
||||
|
||||
logger.LogInformation("Listing tags for project {ProjectId}", projectId);
|
||||
logger.LogInformation("Listing tags for novel {NovelId}", novelId);
|
||||
|
||||
await access.RequireAsync(projectId, ProjectPermission.Read, ct);
|
||||
await access.RequireAsync(novelId, NovelPermission.Read, ct);
|
||||
|
||||
return await db.Tags
|
||||
.Where(t => t.ProjectId == projectId)
|
||||
.Where(t => t.NovelId == novelId)
|
||||
.OrderBy(t => t.Name)
|
||||
.Select(t => new TagSummaryResponse(
|
||||
t.Id, t.Name, t.Color,
|
||||
@@ -49,36 +49,36 @@ public class TagService(
|
||||
return tag;
|
||||
}
|
||||
|
||||
await access.RequireAsync(tag.ProjectId, ProjectPermission.Read, ct);
|
||||
await access.RequireAsync(tag.NovelId, NovelPermission.Read, ct);
|
||||
return tag;
|
||||
}
|
||||
|
||||
public async Task<Tag?> CreateAsync(Guid projectId, CreateTagRequest request, CancellationToken ct = default)
|
||||
public async Task<Tag?> CreateAsync(Guid novelId, CreateTagRequest request, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(projectId, nameof(projectId));
|
||||
Guard.Default(novelId, nameof(novelId));
|
||||
Guard.Null(request, nameof(request));
|
||||
createValidator.Validate(request).ThrowIfInvalid(logger);
|
||||
|
||||
logger.LogInformation("Creating tag {Name} for project {ProjectId}", request.Name, projectId);
|
||||
logger.LogInformation("Creating tag {Name} for novel {NovelId}", request.Name, novelId);
|
||||
|
||||
if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct))
|
||||
if (!await db.Novels.AnyAsync(p => p.Id == novelId, ct))
|
||||
{
|
||||
logger.LogWarning("Rejected tag creation: project {ProjectId} not found", projectId);
|
||||
logger.LogWarning("Rejected tag creation: novel {NovelId} not found", novelId);
|
||||
return null;
|
||||
}
|
||||
|
||||
await access.RequireAsync(projectId, ProjectPermission.CreateContent, ct);
|
||||
await access.RequireAsync(novelId, NovelPermission.CreateContent, ct);
|
||||
|
||||
var name = TagMapping.Normalise(request.Name);
|
||||
|
||||
var existing = await FindByNameAsync(projectId, name, ct);
|
||||
var existing = await FindByNameAsync(novelId, name, ct);
|
||||
if (existing is not null)
|
||||
{
|
||||
logger.LogWarning("Rejected tag creation for project {ProjectId}: '{Name}' already exists", projectId, existing.Name);
|
||||
throw new InvalidOperationException($"The project already has a tag called '{existing.Name}'.");
|
||||
logger.LogWarning("Rejected tag creation for novel {NovelId}: '{Name}' already exists", novelId, existing.Name);
|
||||
throw new InvalidOperationException($"The novel already has a tag called '{existing.Name}'.");
|
||||
}
|
||||
|
||||
var tag = new Tag { ProjectId = projectId, Name = name, Color = request.Color };
|
||||
var tag = new Tag { NovelId = novelId, Name = name, Color = request.Color };
|
||||
db.Tags.Add(tag);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return tag;
|
||||
@@ -99,17 +99,17 @@ public class TagService(
|
||||
return null;
|
||||
}
|
||||
|
||||
await access.RequireAsync(tag.ProjectId, ProjectPermission.Write, ct);
|
||||
await access.RequireAsync(tag.NovelId, NovelPermission.Write, ct);
|
||||
|
||||
if (request.Name is not null)
|
||||
{
|
||||
var name = TagMapping.Normalise(request.Name);
|
||||
|
||||
var clash = await FindByNameAsync(tag.ProjectId, name, ct);
|
||||
var clash = await FindByNameAsync(tag.NovelId, name, ct);
|
||||
if (clash is not null && clash.Id != tag.Id)
|
||||
{
|
||||
logger.LogWarning("Rejected update for tag {TagId}: '{Name}' already exists as {ClashTagId}", tagId, clash.Name, clash.Id);
|
||||
throw new InvalidOperationException($"The project already has a tag called '{clash.Name}'.");
|
||||
throw new InvalidOperationException($"The novel already has a tag called '{clash.Name}'.");
|
||||
}
|
||||
|
||||
tag.Name = name;
|
||||
@@ -133,7 +133,7 @@ public class TagService(
|
||||
return false;
|
||||
}
|
||||
|
||||
await access.RequireAsync(tag.ProjectId, ProjectPermission.DeleteContent, ct);
|
||||
await access.RequireAsync(tag.NovelId, NovelPermission.DeleteContent, ct);
|
||||
|
||||
db.Tags.Remove(tag);
|
||||
await db.SaveChangesAsync(ct);
|
||||
@@ -141,12 +141,12 @@ public class TagService(
|
||||
}
|
||||
|
||||
internal async Task<List<Tag>> ResolveAsync(
|
||||
Guid projectId, IReadOnlyList<string> names, CancellationToken ct)
|
||||
Guid novelId, IReadOnlyList<string> names, CancellationToken ct)
|
||||
{
|
||||
Guard.Default(projectId, nameof(projectId));
|
||||
Guard.Default(novelId, nameof(novelId));
|
||||
Guard.Null(names, nameof(names));
|
||||
|
||||
logger.LogDebug("Resolving {Count} tag names for project {ProjectId}", names.Count, projectId);
|
||||
logger.LogDebug("Resolving {Count} tag names for novel {NovelId}", names.Count, novelId);
|
||||
|
||||
var wanted = names
|
||||
.Select(TagMapping.Normalise)
|
||||
@@ -156,12 +156,12 @@ public class TagService(
|
||||
|
||||
if (wanted.Count == 0)
|
||||
{
|
||||
logger.LogDebug("No usable tag names for project {ProjectId}", projectId);
|
||||
logger.LogDebug("No usable tag names for novel {NovelId}", novelId);
|
||||
return [];
|
||||
}
|
||||
|
||||
var existing = await db.Tags
|
||||
.Where(t => t.ProjectId == projectId)
|
||||
.Where(t => t.NovelId == novelId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var resolved = new List<Tag>();
|
||||
@@ -172,7 +172,7 @@ public class TagService(
|
||||
|
||||
if (match is null)
|
||||
{
|
||||
match = new Tag { ProjectId = projectId, Name = name };
|
||||
match = new Tag { NovelId = novelId, Name = name };
|
||||
db.Tags.Add(match);
|
||||
existing.Add(match);
|
||||
}
|
||||
@@ -180,11 +180,11 @@ public class TagService(
|
||||
resolved.Add(match);
|
||||
}
|
||||
|
||||
logger.LogDebug("Resolved {Count} tags for project {ProjectId}", resolved.Count, projectId);
|
||||
logger.LogDebug("Resolved {Count} tags for novel {NovelId}", resolved.Count, novelId);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private async Task<Tag?> FindByNameAsync(Guid projectId, string name, CancellationToken ct) =>
|
||||
private async Task<Tag?> FindByNameAsync(Guid novelId, string name, CancellationToken ct) =>
|
||||
await db.Tags.FirstOrDefaultAsync(
|
||||
t => t.ProjectId == projectId && EF.Functions.Like(t.Name, name), ct);
|
||||
t => t.NovelId == novelId && EF.Functions.Like(t.Name, name), ct);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Data;
|
||||
using Novelly.Api.Novels;
|
||||
|
||||
namespace Novelly.Api.Users;
|
||||
|
||||
public enum NovelPermission
|
||||
{
|
||||
Read,
|
||||
Write,
|
||||
CreateContent,
|
||||
DeleteContent,
|
||||
ManageAccess
|
||||
}
|
||||
|
||||
public class NovelAccessService(INovelDbContext db, INovelUserContext userContext, ILogger<NovelAccessService> logger)
|
||||
{
|
||||
public void RequireCanCreateNovel()
|
||||
{
|
||||
if (userContext.GlobalRole is GlobalRole.Admin or GlobalRole.Writer)
|
||||
return;
|
||||
|
||||
logger.LogWarning("User {UserId} denied novel creation, global role {GlobalRole}", userContext.UserId, userContext.GlobalRole);
|
||||
throw new NotAuthorizedException("Only writers and admins can create novels.");
|
||||
}
|
||||
|
||||
public async Task RequireAsync(Guid novelId, NovelPermission permission, CancellationToken ct = default)
|
||||
{
|
||||
if (userContext.GlobalRole == GlobalRole.Admin)
|
||||
return;
|
||||
|
||||
var novel = await db.Novels.AsNoTracking().Select(p => new { p.Id, p.OwnerId }).FirstOrDefaultAsync(p => p.Id == novelId, ct);
|
||||
if (novel is null)
|
||||
{
|
||||
logger.LogWarning("Access check against missing novel {NovelId}", novelId);
|
||||
throw new NotAuthorizedException("Not permitted.");
|
||||
}
|
||||
|
||||
if (novel.OwnerId is not null && novel.OwnerId == userContext.UserId)
|
||||
return;
|
||||
|
||||
var member = userContext.UserId is null
|
||||
? null
|
||||
: await db.NovelMembers.AsNoTracking().FirstOrDefaultAsync(m => m.NovelId == novelId && m.UserId == userContext.UserId, ct);
|
||||
|
||||
if (!IsAllowed(permission, member?.NovelRole))
|
||||
{
|
||||
logger.LogWarning("User {UserId} denied {Permission} on novel {NovelId}", userContext.UserId, permission, novelId);
|
||||
throw new NotAuthorizedException($"Not permitted to {permission} on this novel.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string?> GetMyRoleAsync(Novel novel, CancellationToken ct = default)
|
||||
{
|
||||
if (userContext.GlobalRole == GlobalRole.Admin)
|
||||
return "Admin";
|
||||
|
||||
if (novel.OwnerId is not null && novel.OwnerId == userContext.UserId)
|
||||
return "Owner";
|
||||
|
||||
if (userContext.UserId is null)
|
||||
return null;
|
||||
|
||||
var member = await db.NovelMembers.AsNoTracking()
|
||||
.FirstOrDefaultAsync(m => m.NovelId == novel.Id && m.UserId == userContext.UserId, ct);
|
||||
|
||||
return member?.NovelRole.ToString();
|
||||
}
|
||||
|
||||
public IQueryable<Novel> VisibleNovels()
|
||||
{
|
||||
if (userContext.GlobalRole == GlobalRole.Admin)
|
||||
return db.Novels;
|
||||
|
||||
var userId = userContext.UserId;
|
||||
return db.Novels.Where(p => p.OwnerId == userId || p.Members.Any(m => m.UserId == userId));
|
||||
}
|
||||
|
||||
private static bool IsAllowed(NovelPermission permission, NovelRole? role) => permission switch
|
||||
{
|
||||
NovelPermission.Read => role is not null,
|
||||
NovelPermission.Write => role is NovelRole.Writer or NovelRole.Editor,
|
||||
NovelPermission.CreateContent => role is NovelRole.Writer,
|
||||
NovelPermission.DeleteContent => role is NovelRole.Writer,
|
||||
NovelPermission.ManageAccess => false,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
@@ -1,31 +1,31 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Novels;
|
||||
|
||||
namespace Novelly.Api.Users;
|
||||
|
||||
public class ProjectMember
|
||||
public class NovelMember
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
public Guid ProjectId { get; set; }
|
||||
public Project? Project { get; set; }
|
||||
public Guid NovelId { get; set; }
|
||||
public Novel? Novel { get; set; }
|
||||
|
||||
public Guid UserId { get; set; }
|
||||
public NovellyUser? User { get; set; }
|
||||
|
||||
public ProjectRole ProjectRole { get; set; }
|
||||
public NovelRole NovelRole { get; set; }
|
||||
|
||||
public DateTimeOffset GrantedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public Guid GrantedByUserId { get; set; }
|
||||
}
|
||||
|
||||
public class ProjectMemberEntityTypeConfiguration : IEntityTypeConfiguration<ProjectMember>
|
||||
public class NovelMemberEntityTypeConfiguration : IEntityTypeConfiguration<NovelMember>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ProjectMember> entity)
|
||||
public void Configure(EntityTypeBuilder<NovelMember> entity)
|
||||
{
|
||||
entity.Property(m => m.ProjectRole).HasConversion<string>().HasMaxLength(32);
|
||||
entity.HasIndex(m => new { m.ProjectId, m.UserId }).IsUnique();
|
||||
entity.Property(m => m.NovelRole).HasConversion<string>().HasMaxLength(32);
|
||||
entity.HasIndex(m => new { m.NovelId, m.UserId }).IsUnique();
|
||||
|
||||
entity.HasOne(m => m.User).WithMany()
|
||||
.HasForeignKey(m => m.UserId).OnDelete(DeleteBehavior.Cascade);
|
||||
+5
-5
@@ -2,9 +2,9 @@ using Novelly.Api.Common.Validation;
|
||||
|
||||
namespace Novelly.Api.Users;
|
||||
|
||||
public record GrantAccessRequest(string Email, ProjectRole ProjectRole);
|
||||
public record GrantAccessRequest(string Email, NovelRole NovelRole);
|
||||
|
||||
public record ProjectMemberResponse(Guid UserId, string Email, string DisplayName, ProjectRole ProjectRole, DateTimeOffset GrantedAt);
|
||||
public record NovelMemberResponse(Guid UserId, string Email, string DisplayName, NovelRole NovelRole, DateTimeOffset GrantedAt);
|
||||
|
||||
public class GrantAccessRequestValidator : IModelValidator<GrantAccessRequest>
|
||||
{
|
||||
@@ -16,8 +16,8 @@ public class GrantAccessRequestValidator : IModelValidator<GrantAccessRequest>
|
||||
}
|
||||
}
|
||||
|
||||
public static class ProjectMemberMapping
|
||||
public static class NovelMemberMapping
|
||||
{
|
||||
public static ProjectMemberResponse ToResponse(this ProjectMember m) =>
|
||||
new(m.UserId, m.User!.Email ?? string.Empty, m.User.DisplayName, m.ProjectRole, m.GrantedAt);
|
||||
public static NovelMemberResponse ToResponse(this NovelMember m) =>
|
||||
new(m.UserId, m.User!.Email ?? string.Empty, m.User.DisplayName, m.NovelRole, m.GrantedAt);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Common.Validation;
|
||||
|
||||
namespace Novelly.Api.Users;
|
||||
|
||||
public static class NovelMemberEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapNovelMemberEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/novels/{novelId:guid}/members").WithTags("NovelMembers")
|
||||
.AddEndpointFilter<RequestLoggingEndpointFilter>()
|
||||
.AddEndpointFilter<ValidationEndpointFilter>();
|
||||
|
||||
group.MapGet("/", async (Guid novelId, NovelMemberService service, CancellationToken ct) =>
|
||||
(await service.ListAsync(novelId, ct))?.ToApiResult())
|
||||
.WithSummary("List everyone granted access to a novel.");
|
||||
|
||||
group.MapPost("/", async (Guid novelId, GrantAccessRequest request, NovelMemberService service, CancellationToken ct) =>
|
||||
(await service.GrantAsync(novelId, request, ct))?.ToApiResult())
|
||||
.WithSummary("Grant a role on a novel to another account.");
|
||||
|
||||
group.MapDelete("/{userId:guid}", async (Guid novelId, Guid userId, NovelMemberService service, CancellationToken ct) =>
|
||||
await service.RevokeAsync(novelId, userId, ct) ? Results.NoContent() : Results.NotFound())
|
||||
.WithSummary("Revoke an account's access to a novel.");
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Common.Validation;
|
||||
using Novelly.Api.Data;
|
||||
|
||||
namespace Novelly.Api.Users;
|
||||
|
||||
public class NovelMemberService(
|
||||
INovelDbContext db,
|
||||
NovelAccessService access,
|
||||
UserManager<NovellyUser> userManager,
|
||||
INovelUserContext userContext,
|
||||
ILogger<NovelMemberService> logger,
|
||||
IModelValidator<GrantAccessRequest> grantValidator)
|
||||
{
|
||||
public async Task<IReadOnlyList<NovelMemberResponse>?> ListAsync(Guid novelId, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(novelId, nameof(novelId));
|
||||
|
||||
logger.LogInformation("Listing members for novel {NovelId}", novelId);
|
||||
|
||||
if (!await db.Novels.AnyAsync(p => p.Id == novelId, ct))
|
||||
{
|
||||
logger.LogWarning("Rejected member listing: novel {NovelId} not found", novelId);
|
||||
return null;
|
||||
}
|
||||
|
||||
await access.RequireAsync(novelId, NovelPermission.ManageAccess, ct);
|
||||
|
||||
var members = await db.NovelMembers
|
||||
.Include(m => m.User)
|
||||
.Where(m => m.NovelId == novelId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return [.. members.Select(m => m.ToResponse())];
|
||||
}
|
||||
|
||||
public async Task<NovelMemberResponse?> GrantAsync(Guid novelId, GrantAccessRequest request, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(novelId, nameof(novelId));
|
||||
Guard.Null(request, nameof(request));
|
||||
grantValidator.Validate(request).ThrowIfInvalid(logger);
|
||||
|
||||
logger.LogInformation("Granting {NovelRole} on novel {NovelId}", request.NovelRole, novelId);
|
||||
|
||||
if (!await db.Novels.AnyAsync(p => p.Id == novelId, ct))
|
||||
{
|
||||
logger.LogWarning("Rejected access grant: novel {NovelId} not found", novelId);
|
||||
return null;
|
||||
}
|
||||
|
||||
await access.RequireAsync(novelId, NovelPermission.ManageAccess, ct);
|
||||
|
||||
var user = await userManager.FindByEmailAsync(request.Email);
|
||||
if (user is null)
|
||||
{
|
||||
logger.LogWarning("Rejected access grant: no account for the given email");
|
||||
throw new ArgumentException("No account exists with that email.");
|
||||
}
|
||||
|
||||
var member = await db.NovelMembers.FirstOrDefaultAsync(m => m.NovelId == novelId && m.UserId == user.Id, ct);
|
||||
if (member is null)
|
||||
{
|
||||
member = new NovelMember
|
||||
{
|
||||
NovelId = novelId,
|
||||
UserId = user.Id,
|
||||
NovelRole = request.NovelRole,
|
||||
GrantedByUserId = userContext.UserId ?? Guid.Empty
|
||||
};
|
||||
db.NovelMembers.Add(member);
|
||||
}
|
||||
else
|
||||
{
|
||||
member.NovelRole = request.NovelRole;
|
||||
member.GrantedByUserId = userContext.UserId ?? Guid.Empty;
|
||||
member.GrantedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
member.User = user;
|
||||
return member.ToResponse();
|
||||
}
|
||||
|
||||
public async Task<bool> RevokeAsync(Guid novelId, Guid userId, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(novelId, nameof(novelId));
|
||||
Guard.Default(userId, nameof(userId));
|
||||
|
||||
logger.LogInformation("Revoking access on novel {NovelId} for user {UserId}", novelId, userId);
|
||||
|
||||
var member = await db.NovelMembers.FirstOrDefaultAsync(m => m.NovelId == novelId && m.UserId == userId, ct);
|
||||
if (member is null)
|
||||
{
|
||||
logger.LogWarning("No membership found for user {UserId} on novel {NovelId}", userId, novelId);
|
||||
return false;
|
||||
}
|
||||
|
||||
await access.RequireAsync(novelId, NovelPermission.ManageAccess, ct);
|
||||
|
||||
db.NovelMembers.Remove(member);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace Novelly.Api.Users;
|
||||
|
||||
public enum ProjectRole
|
||||
public enum NovelRole
|
||||
{
|
||||
Writer,
|
||||
Editor,
|
||||
@@ -1,89 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Data;
|
||||
using Novelly.Api.Projects;
|
||||
|
||||
namespace Novelly.Api.Users;
|
||||
|
||||
public enum ProjectPermission
|
||||
{
|
||||
Read,
|
||||
Write,
|
||||
CreateContent,
|
||||
DeleteContent,
|
||||
ManageAccess
|
||||
}
|
||||
|
||||
public class ProjectAccessService(INovelDbContext db, INovelUserContext userContext, ILogger<ProjectAccessService> logger)
|
||||
{
|
||||
public void RequireCanCreateProject()
|
||||
{
|
||||
if (userContext.GlobalRole is GlobalRole.Admin or GlobalRole.Writer)
|
||||
return;
|
||||
|
||||
logger.LogWarning("User {UserId} denied novel creation, global role {GlobalRole}", userContext.UserId, userContext.GlobalRole);
|
||||
throw new NotAuthorizedException("Only writers and admins can create novels.");
|
||||
}
|
||||
|
||||
public async Task RequireAsync(Guid projectId, ProjectPermission permission, CancellationToken ct = default)
|
||||
{
|
||||
if (userContext.GlobalRole == GlobalRole.Admin)
|
||||
return;
|
||||
|
||||
var project = await db.Projects.AsNoTracking().Select(p => new { p.Id, p.OwnerId }).FirstOrDefaultAsync(p => p.Id == projectId, ct);
|
||||
if (project is null)
|
||||
{
|
||||
logger.LogWarning("Access check against missing project {ProjectId}", projectId);
|
||||
throw new NotAuthorizedException("Not permitted.");
|
||||
}
|
||||
|
||||
if (project.OwnerId is not null && project.OwnerId == userContext.UserId)
|
||||
return;
|
||||
|
||||
var member = userContext.UserId is null
|
||||
? null
|
||||
: await db.ProjectMembers.AsNoTracking().FirstOrDefaultAsync(m => m.ProjectId == projectId && m.UserId == userContext.UserId, ct);
|
||||
|
||||
if (!IsAllowed(permission, member?.ProjectRole))
|
||||
{
|
||||
logger.LogWarning("User {UserId} denied {Permission} on project {ProjectId}", userContext.UserId, permission, projectId);
|
||||
throw new NotAuthorizedException($"Not permitted to {permission} on this novel.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string?> GetMyRoleAsync(Project project, CancellationToken ct = default)
|
||||
{
|
||||
if (userContext.GlobalRole == GlobalRole.Admin)
|
||||
return "Admin";
|
||||
|
||||
if (project.OwnerId is not null && project.OwnerId == userContext.UserId)
|
||||
return "Owner";
|
||||
|
||||
if (userContext.UserId is null)
|
||||
return null;
|
||||
|
||||
var member = await db.ProjectMembers.AsNoTracking()
|
||||
.FirstOrDefaultAsync(m => m.ProjectId == project.Id && m.UserId == userContext.UserId, ct);
|
||||
|
||||
return member?.ProjectRole.ToString();
|
||||
}
|
||||
|
||||
public IQueryable<Project> VisibleProjects()
|
||||
{
|
||||
if (userContext.GlobalRole == GlobalRole.Admin)
|
||||
return db.Projects;
|
||||
|
||||
var userId = userContext.UserId;
|
||||
return db.Projects.Where(p => p.OwnerId == userId || p.Members.Any(m => m.UserId == userId));
|
||||
}
|
||||
|
||||
private static bool IsAllowed(ProjectPermission permission, ProjectRole? role) => permission switch
|
||||
{
|
||||
ProjectPermission.Read => role is not null,
|
||||
ProjectPermission.Write => role is ProjectRole.Writer or ProjectRole.Editor,
|
||||
ProjectPermission.CreateContent => role is ProjectRole.Writer,
|
||||
ProjectPermission.DeleteContent => role is ProjectRole.Writer,
|
||||
ProjectPermission.ManageAccess => false,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Common.Validation;
|
||||
|
||||
namespace Novelly.Api.Users;
|
||||
|
||||
public static class ProjectMemberEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapProjectMemberEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/projects/{projectId:guid}/members").WithTags("ProjectMembers")
|
||||
.AddEndpointFilter<RequestLoggingEndpointFilter>()
|
||||
.AddEndpointFilter<ValidationEndpointFilter>();
|
||||
|
||||
group.MapGet("/", async (Guid projectId, ProjectMemberService service, CancellationToken ct) =>
|
||||
(await service.ListAsync(projectId, ct))?.ToApiResult())
|
||||
.WithSummary("List everyone granted access to a novel.");
|
||||
|
||||
group.MapPost("/", async (Guid projectId, GrantAccessRequest request, ProjectMemberService service, CancellationToken ct) =>
|
||||
(await service.GrantAsync(projectId, request, ct))?.ToApiResult())
|
||||
.WithSummary("Grant a role on a novel to another account.");
|
||||
|
||||
group.MapDelete("/{userId:guid}", async (Guid projectId, Guid userId, ProjectMemberService service, CancellationToken ct) =>
|
||||
await service.RevokeAsync(projectId, userId, ct) ? Results.NoContent() : Results.NotFound())
|
||||
.WithSummary("Revoke an account's access to a novel.");
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Common.Validation;
|
||||
using Novelly.Api.Data;
|
||||
|
||||
namespace Novelly.Api.Users;
|
||||
|
||||
public class ProjectMemberService(
|
||||
INovelDbContext db,
|
||||
ProjectAccessService access,
|
||||
UserManager<NovellyUser> userManager,
|
||||
INovelUserContext userContext,
|
||||
ILogger<ProjectMemberService> logger,
|
||||
IModelValidator<GrantAccessRequest> grantValidator)
|
||||
{
|
||||
public async Task<IReadOnlyList<ProjectMemberResponse>?> ListAsync(Guid projectId, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(projectId, nameof(projectId));
|
||||
|
||||
logger.LogInformation("Listing members for project {ProjectId}", projectId);
|
||||
|
||||
if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct))
|
||||
{
|
||||
logger.LogWarning("Rejected member listing: project {ProjectId} not found", projectId);
|
||||
return null;
|
||||
}
|
||||
|
||||
await access.RequireAsync(projectId, ProjectPermission.ManageAccess, ct);
|
||||
|
||||
var members = await db.ProjectMembers
|
||||
.Include(m => m.User)
|
||||
.Where(m => m.ProjectId == projectId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return [.. members.Select(m => m.ToResponse())];
|
||||
}
|
||||
|
||||
public async Task<ProjectMemberResponse?> GrantAsync(Guid projectId, GrantAccessRequest request, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(projectId, nameof(projectId));
|
||||
Guard.Null(request, nameof(request));
|
||||
grantValidator.Validate(request).ThrowIfInvalid(logger);
|
||||
|
||||
logger.LogInformation("Granting {ProjectRole} on project {ProjectId}", request.ProjectRole, projectId);
|
||||
|
||||
if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct))
|
||||
{
|
||||
logger.LogWarning("Rejected access grant: project {ProjectId} not found", projectId);
|
||||
return null;
|
||||
}
|
||||
|
||||
await access.RequireAsync(projectId, ProjectPermission.ManageAccess, ct);
|
||||
|
||||
var user = await userManager.FindByEmailAsync(request.Email);
|
||||
if (user is null)
|
||||
{
|
||||
logger.LogWarning("Rejected access grant: no account for the given email");
|
||||
throw new ArgumentException("No account exists with that email.");
|
||||
}
|
||||
|
||||
var member = await db.ProjectMembers.FirstOrDefaultAsync(m => m.ProjectId == projectId && m.UserId == user.Id, ct);
|
||||
if (member is null)
|
||||
{
|
||||
member = new ProjectMember
|
||||
{
|
||||
ProjectId = projectId,
|
||||
UserId = user.Id,
|
||||
ProjectRole = request.ProjectRole,
|
||||
GrantedByUserId = userContext.UserId ?? Guid.Empty
|
||||
};
|
||||
db.ProjectMembers.Add(member);
|
||||
}
|
||||
else
|
||||
{
|
||||
member.ProjectRole = request.ProjectRole;
|
||||
member.GrantedByUserId = userContext.UserId ?? Guid.Empty;
|
||||
member.GrantedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
member.User = user;
|
||||
return member.ToResponse();
|
||||
}
|
||||
|
||||
public async Task<bool> RevokeAsync(Guid projectId, Guid userId, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(projectId, nameof(projectId));
|
||||
Guard.Default(userId, nameof(userId));
|
||||
|
||||
logger.LogInformation("Revoking access on project {ProjectId} for user {UserId}", projectId, userId);
|
||||
|
||||
var member = await db.ProjectMembers.FirstOrDefaultAsync(m => m.ProjectId == projectId && m.UserId == userId, ct);
|
||||
if (member is null)
|
||||
{
|
||||
logger.LogWarning("No membership found for user {UserId} on project {ProjectId}", userId, projectId);
|
||||
return false;
|
||||
}
|
||||
|
||||
await access.RequireAsync(projectId, ProjectPermission.ManageAccess, ct);
|
||||
|
||||
db.ProjectMembers.Remove(member);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,7 @@ public class UserAccountService(
|
||||
if (isFirstAccount)
|
||||
{
|
||||
logger.LogInformation("Adopting orphaned novels under first account {UserId}", user.Id);
|
||||
await db.Projects.Where(p => p.OwnerId == null).ExecuteUpdateAsync(set => set.SetProperty(p => p.OwnerId, user.Id), ct);
|
||||
await db.Novels.Where(p => p.OwnerId == null).ExecuteUpdateAsync(set => set.SetProperty(p => p.OwnerId, user.Id), ct);
|
||||
}
|
||||
|
||||
await signInManager.SignInAsync(user, isPersistent: true);
|
||||
|
||||
Reference in New Issue
Block a user