Rename Project concept to Novel across the stack

Renames the domain concept from Project to Novel throughout the backend
(entities, DTOs, services, endpoints, ProjectAccessService/Permission,
ProjectId foreign keys), MCP server (tool names and routes), and the
React/Vite frontend (types, hooks, routes, components). Adds a new EF
Core migration (RenameProjectToNovel) using RenameTable/RenameColumn to
preserve existing data instead of dropping/recreating tables. Updates
CLAUDE.md's structure section to reference Novels/ instead of Projects/.
This commit is contained in:
James Wampler
2026-08-17 23:03:09 -07:00
parent 0ab4f568b5
commit 4313c8f206
95 changed files with 3192 additions and 1660 deletions
+3 -3
View File
@@ -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";
+8 -8
View File
@@ -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()));
+3 -3
View File
@@ -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);
+31 -31
View File
@@ -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.
+32 -32
View File
@@ -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",
+32 -32
View File
@@ -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() =>
+4 -4
View File
@@ -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 });
}
}
+4 -4
View File
@@ -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 -7
View File
@@ -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();
+23 -23
View File
@@ -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;
}
+4 -4
View File
@@ -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) =>
+32 -32
View File
@@ -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>();
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");
+5 -5
View File
@@ -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);
}
+13 -13
View File
@@ -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
+23 -23
View File
@@ -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();
});
+3 -3
View File
@@ -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,
+1 -1
View File
@@ -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; }
+3 -3
View File
@@ -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;
+2 -2
View File
@@ -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);
+6 -6
View File
@@ -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);
}
@@ -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);
}
+57
View File
@@ -0,0 +1,57 @@
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Users;
namespace Novelly.Api.Novels;
public static class NovelEndpoints
{
public static IEndpointRouteBuilder MapNovelEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/novels").WithTags("Novels")
.AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>();
group.MapGet("/", async (NovelService service, CancellationToken ct) =>
Results.Ok(await service.ListAsync(ct)))
.WithSummary("List all novels.");
group.MapGet("/{id:guid}", async (Guid id, NovelService service, NovelAccessService access, CancellationToken ct) =>
{
var novel = await service.GetAsync(id, ct);
if (novel is null)
return Results.NotFound();
var myRole = await access.GetMyRoleAsync(novel, ct);
return Results.Ok(novel.ToResponse(myRole));
})
.WithSummary("Read a novel's brief.");
group.MapPost("/", async (CreateNovelRequest request, NovelService service, NovelAccessService access, CancellationToken ct) =>
{
var novel = await service.CreateAsync(request, ct);
var myRole = await access.GetMyRoleAsync(novel, ct);
var created = novel.ToResponse(myRole);
return Results.Created($"/api/novels/{created.Id}", created);
})
.WithSummary("Create a novel.");
group.MapPatch("/{id:guid}", async (
Guid id, UpdateNovelRequest request, NovelService service, NovelAccessService access, CancellationToken ct) =>
{
var novel = await service.UpdateAsync(id, request, ct);
if (novel is null)
return Results.NotFound();
var myRole = await access.GetMyRoleAsync(novel, ct);
return Results.Ok(novel.ToResponse(myRole));
})
.WithSummary("Update a novel's brief.");
group.MapDelete("/{id:guid}", async (Guid id, NovelService service, CancellationToken ct) =>
await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
.WithSummary("Delete a novel and everything in it.");
return app;
}
}
@@ -1,6 +1,6 @@
namespace Novelly.Api.Projects;
namespace Novelly.Api.Novels;
public enum ProjectPhase
public enum NovelPhase
{
Brainstorming,
Outlining,
+133
View File
@@ -0,0 +1,133 @@
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Data;
using Novelly.Api.Users;
namespace Novelly.Api.Novels;
public class NovelService(
INovelDbContext db,
NovelAccessService access,
INovelUserContext userContext,
ILogger<NovelService> logger,
IModelValidator<CreateNovelRequest> createValidator,
IModelValidator<UpdateNovelRequest> updateValidator)
{
public async Task<IReadOnlyList<NovelSummaryResponse>> ListAsync(CancellationToken ct = default)
{
logger.LogInformation("Listing novels");
return await access.VisibleNovels()
.OrderByDescending(p => p.UpdatedAt)
.Select(p => new NovelSummaryResponse(
p.Id,
p.Title,
p.Author,
p.Genre,
p.Logline,
p.TargetWordCount,
p.Phase,
p.Characters.Count,
p.Chapters.Count,
p.Chapters.Sum(c => (int?)c.WordCount) ?? 0,
p.UpdatedAt))
.ToListAsync(ct);
}
public async Task<Novel?> GetAsync(Guid id, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
logger.LogInformation("Getting novel {NovelId}", id);
var novel = await FindAsync(id, ct);
if (novel is null) return null;
await access.RequireAsync(id, NovelPermission.Read, ct);
return novel;
}
public async Task<Novel> CreateAsync(CreateNovelRequest request, CancellationToken ct = default)
{
Guard.Null(request, nameof(request));
createValidator.Validate(request).ThrowIfInvalid(logger);
access.RequireCanCreateNovel();
logger.LogInformation("Creating novel {Title}", request.Title);
var novel = new Novel
{
Title = request.Title,
Author = request.Author,
Genre = request.Genre,
Logline = request.Logline,
Synopsis = request.Synopsis,
Notes = request.Notes,
TargetWordCount = request.TargetWordCount,
OwnerId = userContext.UserId
};
db.Novels.Add(novel);
await db.SaveChangesAsync(ct);
return novel;
}
public async Task<Novel?> UpdateAsync(Guid id, UpdateNovelRequest request, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
Guard.Null(request, nameof(request));
updateValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation("Updating novel {NovelId}", id);
var novel = await FindAsync(id, ct);
if (novel is null) return null;
await access.RequireAsync(id, NovelPermission.Write, ct);
novel.Title = Patch.Apply(novel.Title, request.Title) ?? novel.Title;
novel.Author = Patch.Apply(novel.Author, request.Author);
novel.Genre = Patch.Apply(novel.Genre, request.Genre);
novel.Logline = Patch.Apply(novel.Logline, request.Logline);
novel.Synopsis = Patch.Apply(novel.Synopsis, request.Synopsis);
novel.Notes = Patch.Apply(novel.Notes, request.Notes);
novel.TargetWordCount = request.TargetWordCount ?? novel.TargetWordCount;
novel.Phase = request.Phase ?? novel.Phase;
novel.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(ct);
return novel;
}
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
logger.LogInformation("Deleting novel {NovelId}", id);
var novel = await FindAsync(id, ct);
if (novel is null) return false;
await access.RequireAsync(id, NovelPermission.DeleteContent, ct);
db.Novels.Remove(novel);
await db.SaveChangesAsync(ct);
return true;
}
private async Task<Novel?> FindAsync(Guid id, CancellationToken ct)
{
logger.LogDebug("Finding novel {NovelId}", id);
var novel = await db.Novels.FirstOrDefaultAsync(p => p.Id == id, ct);
if (novel is null)
{
logger.LogWarning("Novel {NovelId} not found", id);
return novel;
}
logger.LogDebug("Found novel {NovelId}", id);
return novel;
}
}
+3 -3
View File
@@ -10,7 +10,7 @@ using Novelly.Api.Common;
using Novelly.Api.Data;
using Novelly.Api.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;
}
}
-133
View File
@@ -1,133 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Data;
using Novelly.Api.Users;
namespace Novelly.Api.Projects;
public class ProjectService(
INovelDbContext db,
ProjectAccessService access,
INovelUserContext userContext,
ILogger<ProjectService> logger,
IModelValidator<CreateProjectRequest> createValidator,
IModelValidator<UpdateProjectRequest> updateValidator)
{
public async Task<IReadOnlyList<ProjectSummaryResponse>> ListAsync(CancellationToken ct = default)
{
logger.LogInformation("Listing projects");
return await access.VisibleProjects()
.OrderByDescending(p => p.UpdatedAt)
.Select(p => new ProjectSummaryResponse(
p.Id,
p.Title,
p.Author,
p.Genre,
p.Logline,
p.TargetWordCount,
p.Phase,
p.Characters.Count,
p.Chapters.Count,
p.Chapters.Sum(c => (int?)c.WordCount) ?? 0,
p.UpdatedAt))
.ToListAsync(ct);
}
public async Task<Project?> GetAsync(Guid id, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
logger.LogInformation("Getting project {ProjectId}", id);
var project = await FindAsync(id, ct);
if (project is null) return null;
await access.RequireAsync(id, ProjectPermission.Read, ct);
return project;
}
public async Task<Project> CreateAsync(CreateProjectRequest request, CancellationToken ct = default)
{
Guard.Null(request, nameof(request));
createValidator.Validate(request).ThrowIfInvalid(logger);
access.RequireCanCreateProject();
logger.LogInformation("Creating project {Title}", request.Title);
var project = new Project
{
Title = request.Title,
Author = request.Author,
Genre = request.Genre,
Logline = request.Logline,
Synopsis = request.Synopsis,
Notes = request.Notes,
TargetWordCount = request.TargetWordCount,
OwnerId = userContext.UserId
};
db.Projects.Add(project);
await db.SaveChangesAsync(ct);
return project;
}
public async Task<Project?> UpdateAsync(Guid id, UpdateProjectRequest request, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
Guard.Null(request, nameof(request));
updateValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation("Updating project {ProjectId}", id);
var project = await FindAsync(id, ct);
if (project is null) return null;
await access.RequireAsync(id, ProjectPermission.Write, ct);
project.Title = Patch.Apply(project.Title, request.Title) ?? project.Title;
project.Author = Patch.Apply(project.Author, request.Author);
project.Genre = Patch.Apply(project.Genre, request.Genre);
project.Logline = Patch.Apply(project.Logline, request.Logline);
project.Synopsis = Patch.Apply(project.Synopsis, request.Synopsis);
project.Notes = Patch.Apply(project.Notes, request.Notes);
project.TargetWordCount = request.TargetWordCount ?? project.TargetWordCount;
project.Phase = request.Phase ?? project.Phase;
project.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(ct);
return project;
}
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
logger.LogInformation("Deleting project {ProjectId}", id);
var project = await FindAsync(id, ct);
if (project is null) return false;
await access.RequireAsync(id, ProjectPermission.DeleteContent, ct);
db.Projects.Remove(project);
await db.SaveChangesAsync(ct);
return true;
}
private async Task<Project?> FindAsync(Guid id, CancellationToken ct)
{
logger.LogDebug("Finding project {ProjectId}", id);
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == id, ct);
if (project is null)
{
logger.LogWarning("Project {ProjectId} not found", id);
return project;
}
logger.LogDebug("Found project {ProjectId}", id);
return project;
}
}
+6 -6
View File
@@ -2,7 +2,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using 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() =>
+4 -4
View File
@@ -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 -7
View File
@@ -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();
+30 -30
View File
@@ -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);
@@ -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;
}
}
+107
View File
@@ -0,0 +1,107 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Data;
namespace Novelly.Api.Users;
public class NovelMemberService(
INovelDbContext db,
NovelAccessService access,
UserManager<NovellyUser> userManager,
INovelUserContext userContext,
ILogger<NovelMemberService> logger,
IModelValidator<GrantAccessRequest> grantValidator)
{
public async Task<IReadOnlyList<NovelMemberResponse>?> ListAsync(Guid novelId, CancellationToken ct = default)
{
Guard.Default(novelId, nameof(novelId));
logger.LogInformation("Listing members for novel {NovelId}", novelId);
if (!await db.Novels.AnyAsync(p => p.Id == novelId, ct))
{
logger.LogWarning("Rejected member listing: novel {NovelId} not found", novelId);
return null;
}
await access.RequireAsync(novelId, NovelPermission.ManageAccess, ct);
var members = await db.NovelMembers
.Include(m => m.User)
.Where(m => m.NovelId == novelId)
.ToListAsync(ct);
return [.. members.Select(m => m.ToResponse())];
}
public async Task<NovelMemberResponse?> GrantAsync(Guid novelId, GrantAccessRequest request, CancellationToken ct = default)
{
Guard.Default(novelId, nameof(novelId));
Guard.Null(request, nameof(request));
grantValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation("Granting {NovelRole} on novel {NovelId}", request.NovelRole, novelId);
if (!await db.Novels.AnyAsync(p => p.Id == novelId, ct))
{
logger.LogWarning("Rejected access grant: novel {NovelId} not found", novelId);
return null;
}
await access.RequireAsync(novelId, NovelPermission.ManageAccess, ct);
var user = await userManager.FindByEmailAsync(request.Email);
if (user is null)
{
logger.LogWarning("Rejected access grant: no account for the given email");
throw new ArgumentException("No account exists with that email.");
}
var member = await db.NovelMembers.FirstOrDefaultAsync(m => m.NovelId == novelId && m.UserId == user.Id, ct);
if (member is null)
{
member = new NovelMember
{
NovelId = novelId,
UserId = user.Id,
NovelRole = request.NovelRole,
GrantedByUserId = userContext.UserId ?? Guid.Empty
};
db.NovelMembers.Add(member);
}
else
{
member.NovelRole = request.NovelRole;
member.GrantedByUserId = userContext.UserId ?? Guid.Empty;
member.GrantedAt = DateTimeOffset.UtcNow;
}
await db.SaveChangesAsync(ct);
member.User = user;
return member.ToResponse();
}
public async Task<bool> RevokeAsync(Guid novelId, Guid userId, CancellationToken ct = default)
{
Guard.Default(novelId, nameof(novelId));
Guard.Default(userId, nameof(userId));
logger.LogInformation("Revoking access on novel {NovelId} for user {UserId}", novelId, userId);
var member = await db.NovelMembers.FirstOrDefaultAsync(m => m.NovelId == novelId && m.UserId == userId, ct);
if (member is null)
{
logger.LogWarning("No membership found for user {UserId} on novel {NovelId}", userId, novelId);
return false;
}
await access.RequireAsync(novelId, NovelPermission.ManageAccess, ct);
db.NovelMembers.Remove(member);
await db.SaveChangesAsync(ct);
return true;
}
}
@@ -1,6 +1,6 @@
namespace Novelly.Api.Users;
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;
}
}
+1 -1
View File
@@ -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);