Add users, roles, and per-novel permissions

Introduces accounts (ASP.NET Identity + cookie auth), four global
roles (Admin/Writer/Editor/Reviewer), per-novel ownership and grants
via ProjectMember, and a service-API-key principal for the MCP server
and background import jobs. Enforcement lives in the application
services (not endpoint filters) so the embedded agent and MCP tools,
which call the same services directly, can't bypass it. Web client
gets a login page, session-aware routing, and a People section for
managing per-novel access.

Also includes prior in-flight changes from this branch (CLAUDE.md
compliance pass, dev-deploy docker-compose setup) that were
uncommitted when this feature work started.
This commit is contained in:
James Wampler
2026-08-15 22:29:33 -07:00
parent 7d8dd0c4fd
commit e598c18d67
111 changed files with 6562 additions and 797 deletions
-26
View File
@@ -2,10 +2,8 @@ using System.Text.Json;
namespace Novelly.Api.Agent;
/// <summary>A tool the model may call, described in the shape the Messages API expects.</summary>
public record AgentToolDefinition(string Name, string Description, JsonElement InputSchema);
/// <summary>One content block in a model turn.</summary>
public abstract record AgentContentBlock;
public record AgentTextBlock(string Text) : AgentContentBlock;
@@ -14,7 +12,6 @@ public record AgentToolUseBlock(string Id, string Name, JsonElement Input) : Age
public record AgentToolResultBlock(string ToolUseId, string Content, bool IsError = false) : AgentContentBlock;
/// <summary>A full turn in the conversation sent to or received from the model.</summary>
public record AgentChatMessage(string Role, IReadOnlyList<AgentContentBlock> Content)
{
public static AgentChatMessage User(params AgentContentBlock[] content) => new("user", content);
@@ -23,10 +20,6 @@ public record AgentChatMessage(string Role, IReadOnlyList<AgentContentBlock> Con
public record AgentModelResponse(IReadOnlyList<AgentContentBlock> Content, string? StopReason);
/// <summary>
/// The model-facing seam. Infrastructure implements this against the Anthropic SDK;
/// tests substitute a scripted stand-in so the agent loop can be exercised offline.
/// </summary>
public interface IAgentModelClient
{
Task<AgentModelResponse> CompleteAsync(
@@ -36,40 +29,21 @@ public interface IAgentModelClient
CancellationToken ct = default);
}
/// <summary>Configuration for the embedded writing agent.</summary>
public class AgentOptions
{
public const string SectionName = "Agent";
/// <summary>Anthropic model id. Defaults to the current Opus.</summary>
public string Model { get; set; } = "claude-opus-5";
public int MaxTokens { get; set; } = 16000;
/// <summary>Thinking depth: low | medium | high | xhigh | max.</summary>
public string Effort { get; set; } = "high";
/// <summary>
/// Ceiling on model round-trips per user turn. Each tool call costs one; without a
/// cap a confused model could loop indefinitely.
/// </summary>
public int MaxIterations { get; set; } = 12;
/// <summary>Falls back to the ANTHROPIC_API_KEY environment variable when unset.</summary>
public string? ApiKey { get; set; }
/// <summary>
/// Ceiling on model round-trips per <em>turn</em> of an outline import — higher than
/// <see cref="MaxIterations"/> because a batch of chapters needs far more tool calls
/// than a chat reply, but still bounded so a confused run can't spin forever.
/// </summary>
public int ImportMaxIterationsPerTurn { get; set; } = 40;
/// <summary>
/// Ceiling on synthetic "continue" turns per import run. The run driver — not the
/// model — decides whether to keep going, by re-reading the ledger after each turn; this
/// is the safety net if it never reports done. Hitting it pauses the job rather than
/// failing it: re-starting the same source root resumes from the ledger.
/// </summary>
public int ImportMaxTurns { get; set; } = 8;
}
+21 -15
View File
@@ -1,8 +1,9 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Novelly.Api.Projects;
namespace Novelly.Api.Agent;
/// <summary>A chat thread between the writer and the embedded agent, scoped to one project.</summary>
public class AgentConversation
{
public Guid Id { get; init; } = Guid.NewGuid();
@@ -17,11 +18,6 @@ public class AgentConversation
public List<AgentMessage> Messages { get; init; } = [];
}
/// <summary>
/// One turn in an agent conversation. Assistant turns may carry a record of the tools
/// the agent called, so the UI can show what it changed and the next request can replay
/// the turn back to the model.
/// </summary>
public class AgentMessage
{
public Guid Id { get; init; } = Guid.NewGuid();
@@ -30,20 +26,30 @@ public class AgentMessage
public AgentRole Role { get; init; }
/// <summary>
/// Position in the conversation, 0-based. Timestamps are not enough to order a
/// transcript: a fast turn can produce two messages inside the same tick.
/// </summary>
public int Sequence { get; init; }
/// <summary>The visible text of the turn.</summary>
public string Content { get; init; } = string.Empty;
/// <summary>
/// JSON array of <c>{ name, input, result }</c> objects describing tool calls made
/// during this turn. Null on user turns and on assistant turns that used no tools.
/// </summary>
public string? ToolCallsJson { get; init; }
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
}
public class AgentConversationEntityTypeConfiguration : IEntityTypeConfiguration<AgentConversation>
{
public void Configure(EntityTypeBuilder<AgentConversation> entity)
{
entity.Property(c => c.Title).IsRequired().HasMaxLength(200);
entity.HasMany(c => c.Messages).WithOne(m => m.Conversation!)
.HasForeignKey(m => m.ConversationId).OnDelete(DeleteBehavior.Cascade);
}
}
public class AgentMessageEntityTypeConfiguration : IEntityTypeConfiguration<AgentMessage>
{
public void Configure(EntityTypeBuilder<AgentMessage> entity)
{
entity.Property(m => m.Role).HasConversion<string>().HasMaxLength(16);
entity.HasIndex(m => new { m.ConversationId, m.Sequence }).IsUnique();
}
}
+1 -4
View File
@@ -20,10 +20,7 @@ public class SendAgentMessageRequestValidator : IModelValidator<SendAgentMessage
{
var result = new ValidationResult();
if (string.IsNullOrWhiteSpace(model.Message))
result.AddError("Message", "'Message' must not be empty.");
else if (model.Message.Length > 20000)
result.AddError("Message", "'Message' must be 20,000 characters or fewer.");
result.AddRequiredTextErrors("Message", "Message", model.Message, 20000);
return result;
}
-1
View File
@@ -1,6 +1,5 @@
namespace Novelly.Api.Agent;
/// <summary>Who produced a message in an agent conversation.</summary>
public enum AgentRole
{
User,
@@ -6,22 +6,12 @@ using Novelly.Api.Common;
namespace Novelly.Api.Agent;
/// <summary>
/// Talks to the Anthropic Messages API. Translates between the application's
/// model-agnostic block types and the SDK's request/response shapes; the tool-use loop
/// itself lives in <see cref="NovelAgentService"/>.
/// </summary>
public class AnthropicAgentModelClient(IOptions<AgentOptions> options, ILogger<AnthropicAgentModelClient> logger) : IAgentModelClient
{
private readonly AgentOptions _options = options.Value;
private AnthropicClient? _client;
/// <summary>
/// Built on first use rather than at construction. This type is injected into the
/// agent service, which also serves read-only endpoints like listing conversations —
/// those should keep working on an install that has not set up a key yet.
/// </summary>
private AnthropicClient Client => _client ??= new AnthropicClient
private AnthropicClient LazilyConfiguredClient => _client ??= new AnthropicClient
{
ApiKey = _options.ApiKey
?? Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY")
@@ -46,8 +36,6 @@ public class AnthropicAgentModelClient(IOptions<AgentOptions> options, ILogger<A
MaxTokens = _options.MaxTokens,
System = new List<TextBlockParam>
{
// The system prompt is stable across a conversation, so cache it: every
// turn after the first reads it back at a tenth of the input price.
new() { Text = systemPrompt, CacheControl = new CacheControlEphemeral() }
},
OutputConfig = new OutputConfig { Effort = ParseEffort(_options.Effort) },
@@ -55,7 +43,7 @@ public class AnthropicAgentModelClient(IOptions<AgentOptions> options, ILogger<A
Messages = [.. messages.Select(ToSdkMessage)]
};
var response = await Client.Messages.Create(parameters, cancellationToken: ct);
var response = await LazilyConfiguredClient.Messages.Create(parameters, cancellationToken: ct);
logger.LogInformation(
"Model {Model} responded with stop reason {StopReason}, input tokens {InputTokens}, output tokens {OutputTokens}",
@@ -148,7 +136,6 @@ public class AnthropicAgentModelClient(IOptions<AgentOptions> options, ILogger<A
JsonSerializer.SerializeToElement(toolUse.Input));
}
// Thinking blocks and any future block types carry nothing the loop acts on.
return null;
}
+23 -43
View File
@@ -1,7 +1,7 @@
using System.Text.Json;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
@@ -20,7 +20,7 @@ public class NovelAgentService(
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }
Converters = { new JsonStringEnumConverter() }
};
private readonly AgentOptions _options = options.Value;
@@ -63,12 +63,11 @@ public class NovelAgentService(
return true;
}
public async Task<AgentMessage?> SendMessageAsync(
Guid projectId, SendAgentMessageRequest request, CancellationToken ct = default)
public async Task<AgentMessage?> SendMessageAsync(Guid projectId, SendAgentMessageRequest request, CancellationToken ct = default)
{
Guard.Default(projectId, nameof(projectId));
Guard.Null(request, nameof(request));
sendMessageValidator.Validate(request).ThrowIfInvalid();
sendMessageValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation(
"Sending agent message for project {ProjectId}, conversation {ConversationId}, message length {MessageLength}",
@@ -77,25 +76,15 @@ public class NovelAgentService(
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == projectId, ct);
if (project is null)
{
logger.LogInformation("Project {ProjectId} not found", projectId);
logger.LogWarning("Project {ProjectId} not found", projectId);
return null;
}
AgentConversation conversation;
if (request.ConversationId is { } id)
{
var found = await FindConversationAsync(id, ct);
if (found is null)
{
return null;
}
var conversation = request.ConversationId is { } id
? await FindConversationAsync(id, ct)
: StartConversation(projectId, request.Message);
conversation = found;
}
else
{
conversation = StartConversation(projectId, request.Message);
}
if (conversation is null) return null;
await AppendMessageAsync(conversation, AgentRole.User, request.Message, null, ct);
@@ -113,16 +102,11 @@ public class NovelAgentService(
foreach (var block in response.Content.OfType<AgentTextBlock>())
{
if (!string.IsNullOrWhiteSpace(block.Text))
{
text.AppendLine(block.Text.Trim());
}
}
var requestedTools = response.Content.OfType<AgentToolUseBlock>().ToList();
if (requestedTools.Count == 0)
{
break;
}
if (requestedTools.Count == 0) break;
transcript.Add(AgentChatMessage.Assistant(response.Content));
@@ -131,9 +115,7 @@ public class NovelAgentService(
{
var outcome = await toolset.ExecuteAsync(call.Name, projectId, call.Input, ct);
logger.LogInformation(
"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 project {ProjectId} {Outcome}", call.Name, projectId, 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));
@@ -141,15 +123,11 @@ public class NovelAgentService(
transcript.Add(AgentChatMessage.User([.. results]));
if (iteration == _options.MaxIterations - 1)
{
logger.LogWarning(
"Agent hit the {Max}-iteration ceiling on project {ProjectId}",
_options.MaxIterations, projectId);
if (iteration != _options.MaxIterations - 1) continue;
text.AppendLine(
"_I reached my tool-call limit for this turn. Ask me to continue if there's more to do._");
}
logger.LogWarning("Agent hit the {Max}-iteration ceiling on project {ProjectId}", _options.MaxIterations, projectId);
text.AppendLine("_I reached my tool-call limit for this turn. Ask me to continue if there's more to do._");
}
var reply = await AppendMessageAsync(
@@ -162,8 +140,7 @@ public class NovelAgentService(
return reply;
}
private async Task<AgentMessage> AppendMessageAsync(
AgentConversation conversation, AgentRole role, string content, string? toolCallsJson, CancellationToken ct)
private async Task<AgentMessage> AppendMessageAsync(AgentConversation conversation, AgentRole role, string content, string? toolCallsJson, CancellationToken ct)
{
logger.LogDebug("Appending {Role} message to conversation {ConversationId}, content length {ContentLength}", role, conversation.Id, content.Length);
@@ -182,10 +159,9 @@ public class NovelAgentService(
await db.SaveChangesAsync(ct);
if (!conversation.Messages.Contains(message))
{
conversation.Messages.Add(message);
}
logger.LogDebug("Appended {Role} message {MessageId} to conversation {ConversationId}", role, message.Id, conversation.Id);
return message;
}
@@ -200,6 +176,8 @@ public class NovelAgentService(
};
db.Conversations.Add(conversation);
logger.LogDebug("Started agent conversation {ConversationId} for project {ProjectId}", conversation.Id, projectId);
return conversation;
}
@@ -213,9 +191,11 @@ public class NovelAgentService(
if (conversation is null)
{
logger.LogInformation("AgentConversation {ConversationId} not found", conversationId);
logger.LogWarning("AgentConversation {ConversationId} not found", conversationId);
return conversation;
}
logger.LogDebug("Found agent conversation {ConversationId}", conversationId);
return conversation;
}
+17
View File
@@ -1,3 +1,5 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Novelly.Api.Chapters;
using Novelly.Api.Characters;
using Novelly.Api.Tags;
@@ -26,3 +28,18 @@ public class Beat
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
}
public class BeatEntityTypeConfiguration : IEntityTypeConfiguration<Beat>
{
public void Configure(EntityTypeBuilder<Beat> entity)
{
entity.Property(b => b.Title).IsRequired().HasMaxLength(200);
entity.HasIndex(b => new { b.ChapterId, b.SortOrder });
entity.HasOne(b => b.Chapter).WithMany(c => c.Beats)
.HasForeignKey(b => b.ChapterId).OnDelete(DeleteBehavior.Cascade);
entity.HasMany(b => b.Characters).WithMany(c => c.Beats)
.UsingEntity(join => join.ToTable("BeatCharacters"));
}
}
+36 -2
View File
@@ -5,11 +5,13 @@ using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Data;
using Novelly.Api.Tags;
using Novelly.Api.Users;
namespace Novelly.Api.Beats;
public class BeatService(
INovelDbContext db,
ProjectAccessService access,
TagService tags,
ILogger<BeatService> logger,
IModelValidator<CreateBeatRequest> createValidator,
@@ -23,6 +25,8 @@ public class BeatService(
logger.LogInformation("Listing beats for chapter {ChapterId}", chapterId);
await RequireChapterAccessAsync(chapterId, ProjectPermission.Read, ct);
return await Query()
.Where(b => b.ChapterId == chapterId)
.OrderBy(b => b.SortOrder)
@@ -34,7 +38,15 @@ public class BeatService(
Guard.Default(id, nameof(id));
logger.LogInformation("Getting beat {BeatId}", id);
return await FindAsync(id, ct);
var beat = await FindAsync(id, ct);
if (beat is null)
{
return null;
}
await RequireBeatAccessAsync(beat, ProjectPermission.Read, ct);
return beat;
}
public async Task<IReadOnlyList<Beat>?> ListForCharacterAsync(
@@ -44,12 +56,15 @@ public class BeatService(
logger.LogInformation("Listing beats for character {CharacterId}", characterId);
if (!await db.Characters.AnyAsync(c => c.Id == characterId, ct))
var characterProjectId = await db.Characters.Where(c => c.Id == characterId).Select(c => (Guid?)c.ProjectId).FirstOrDefaultAsync(ct);
if (characterProjectId is null)
{
logger.LogWarning("Character {CharacterId} not found", characterId);
return null;
}
await access.RequireAsync(characterProjectId.Value, ProjectPermission.Read, ct);
var beats = await db.Beats
.Include(b => b.Chapter)
.Where(b => b.Characters.Any(c => c.Id == characterId))
@@ -78,6 +93,8 @@ public class BeatService(
return null;
}
await access.RequireAsync(chapter.ProjectId, ProjectPermission.CreateContent, ct);
var beat = new Beat
{
ChapterId = chapterId,
@@ -124,6 +141,8 @@ public class BeatService(
return null;
}
await access.RequireAsync(chapter.ProjectId, ProjectPermission.Write, ct);
beat.Title = Patch.Apply(beat.Title, request.Title) ?? beat.Title;
beat.SortOrder = request.SortOrder ?? beat.SortOrder;
beat.WhatHappened = Patch.Apply(beat.WhatHappened, request.WhatHappened);
@@ -156,6 +175,8 @@ public class BeatService(
return false;
}
await RequireBeatAccessAsync(beat, ProjectPermission.DeleteContent, ct);
db.Beats.Remove(beat);
await db.SaveChangesAsync(ct);
return true;
@@ -170,6 +191,8 @@ public class BeatService(
logger.LogInformation("Reordering {Count} beats for chapter {ChapterId}", request.BeatIds.Count, chapterId);
await RequireChapterAccessAsync(chapterId, ProjectPermission.Write, ct);
var beats = await db.Beats.Where(b => b.ChapterId == chapterId).ToListAsync(ct);
var missing = request.BeatIds.Where(id => beats.All(b => b.Id != id)).ToList();
@@ -212,6 +235,8 @@ public class BeatService(
return null;
}
await access.RequireAsync(chapter.ProjectId, ProjectPermission.Write, ct);
var character = await db.Characters
.FirstOrDefaultAsync(c => c.Id == request.CharacterId && c.ProjectId == chapter.ProjectId, ct);
if (character is null)
@@ -279,6 +304,15 @@ public class BeatService(
return next;
}
private async Task RequireChapterAccessAsync(Guid chapterId, ProjectPermission 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);
}
private Task RequireBeatAccessAsync(Beat beat, ProjectPermission permission, CancellationToken ct) =>
RequireChapterAccessAsync(beat.ChapterId, permission, ct);
private IQueryable<Beat> Query() =>
db.Beats
.Include(b => b.Characters)
+19 -1
View File
@@ -3,11 +3,13 @@ using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Data;
using Novelly.Api.Tags;
using Novelly.Api.Users;
namespace Novelly.Api.Chapters;
public class ChapterService(
INovelDbContext db,
ProjectAccessService access,
TagService tags,
ILogger<ChapterService> logger,
IModelValidator<CreateChapterRequest> createValidator,
@@ -19,6 +21,8 @@ public class ChapterService(
logger.LogInformation("Listing chapters for project {ProjectId}", projectId);
await access.RequireAsync(projectId, ProjectPermission.Read, ct);
return await db.Chapters
.Include(c => c.Beats)
.Include(c => c.Tags)
@@ -32,7 +36,15 @@ public class ChapterService(
Guard.Default(id, nameof(id));
logger.LogInformation("Getting chapter {ChapterId}", id);
return await FindAsync(id, ct);
var chapter = await FindAsync(id, ct);
if (chapter is null)
{
return null;
}
await access.RequireAsync(chapter.ProjectId, ProjectPermission.Read, ct);
return chapter;
}
public async Task<Chapter?> CreateAsync(Guid projectId, CreateChapterRequest request, CancellationToken ct = default)
@@ -49,6 +61,8 @@ public class ChapterService(
return null;
}
await access.RequireAsync(projectId, ProjectPermission.CreateContent, ct);
var chapter = new Chapter
{
ProjectId = projectId,
@@ -88,6 +102,8 @@ public class ChapterService(
return null;
}
await access.RequireAsync(chapter.ProjectId, ProjectPermission.Write, ct);
chapter.Title = Patch.Apply(chapter.Title, request.Title) ?? chapter.Title;
chapter.Number = request.Number ?? chapter.Number;
chapter.Summary = Patch.Apply(chapter.Summary, request.Summary);
@@ -125,6 +141,8 @@ public class ChapterService(
return false;
}
await access.RequireAsync(chapter.ProjectId, ProjectPermission.DeleteContent, ct);
db.Chapters.Remove(chapter);
await db.SaveChangesAsync(ct);
return true;
+30
View File
@@ -1,3 +1,5 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Novelly.Api.Beats;
using Novelly.Api.Projects;
using Novelly.Api.Tags;
@@ -61,3 +63,31 @@ public class CharacterRelationship
public string? Description { get; set; }
}
public class CharacterEntityTypeConfiguration : IEntityTypeConfiguration<Character>
{
public void Configure(EntityTypeBuilder<Character> entity)
{
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.HasMany(c => c.Relationships).WithOne(r => r.Character!)
.HasForeignKey(r => r.CharacterId).OnDelete(DeleteBehavior.Cascade);
entity.HasMany(c => c.ArcStages).WithOne(s => s.Character!)
.HasForeignKey(s => s.CharacterId).OnDelete(DeleteBehavior.Cascade);
}
}
public class CharacterRelationshipEntityTypeConfiguration : IEntityTypeConfiguration<CharacterRelationship>
{
public void Configure(EntityTypeBuilder<CharacterRelationship> entity)
{
entity.Property(r => r.RelationshipType).IsRequired().HasMaxLength(120);
entity.HasOne(r => r.RelatedCharacter).WithMany()
.HasForeignKey(r => r.RelatedCharacterId).OnDelete(DeleteBehavior.Restrict);
}
}
@@ -2,20 +2,13 @@ using Microsoft.EntityFrameworkCore;
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Data;
using Novelly.Api.Users;
namespace Novelly.Api.Characters;
/// <summary>
/// A character's arc: a flat, ordered list of the changes they go through. Same shape as
/// a chapter's beats, and for the same reason — an arc is a sequence, not a tree.
/// </summary>
/// <remarks>
/// Arcs are only really worth keeping for main characters, but nothing here refuses one
/// on a supporting character. Demoting someone should not delete work, and a character
/// who turns out to matter gets promoted after the arc is already sketched.
/// </remarks>
public class CharacterArcService(
INovelDbContext db,
ProjectAccessService access,
ILogger<CharacterArcService> logger,
IModelValidator<CreateArcStageRequest> createValidator,
IModelValidator<UpdateArcStageRequest> updateValidator,
@@ -27,6 +20,8 @@ public class CharacterArcService(
logger.LogInformation("Listing arc stages for character {CharacterId}", characterId);
await RequireCharacterAccessAsync(characterId, ProjectPermission.Read, ct);
var stages = await Query()
.Where(s => s.CharacterId == characterId)
.OrderBy(s => s.SortOrder)
@@ -35,32 +30,39 @@ public class CharacterArcService(
return stages;
}
/// <summary>Null when no arc stage has this id — a lookup miss is expected, not exceptional.</summary>
public async Task<CharacterArcStage?> GetAsync(Guid id, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
logger.LogInformation("Getting arc stage {ArcStageId}", id);
return await FindAsync(id, ct);
var stage = await FindAsync(id, ct);
if (stage is null)
{
return null;
}
await RequireCharacterAccessAsync(stage.CharacterId, ProjectPermission.Read, ct);
return stage;
}
/// <summary>Null when no character has this id — a lookup miss is expected, not exceptional.</summary>
public async Task<CharacterArcStage?> CreateAsync(
Guid characterId, CreateArcStageRequest request, CancellationToken ct = default)
{
Guard.Default(characterId, nameof(characterId));
Guard.Null(request, nameof(request));
createValidator.Validate(request).ThrowIfInvalid();
createValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation("Creating arc stage {Title} for character {CharacterId}", request.Title, characterId);
var character = await db.Characters.FirstOrDefaultAsync(c => c.Id == characterId, ct);
if (character is null)
{
logger.LogInformation("Rejected arc stage creation: character {CharacterId} not found", characterId);
logger.LogWarning("Rejected arc stage creation: character {CharacterId} not found", characterId);
return null;
}
await access.RequireAsync(character.ProjectId, ProjectPermission.CreateContent, ct);
await EnsureChapterIsInSameProjectAsync(character, request.ChapterId, ct);
var stage = new CharacterArcStage
@@ -75,7 +77,6 @@ public class CharacterArcService(
db.CharacterArcStages.Add(stage);
await db.SaveChangesAsync(ct);
// Just created it — the reload is only to pick up includes, not to check existence.
return (await FindAsync(stage.Id, ct))!;
}
@@ -84,7 +85,7 @@ public class CharacterArcService(
{
Guard.Default(id, nameof(id));
Guard.Null(request, nameof(request));
updateValidator.Validate(request).ThrowIfInvalid();
updateValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation("Updating arc stage {ArcStageId}", id);
@@ -97,12 +98,11 @@ public class CharacterArcService(
var character = await db.Characters.FirstOrDefaultAsync(c => c.Id == stage.CharacterId, ct);
if (character is null)
{
// The stage's own character should always exist via the FK — an invariant
// failing, not a caller mistake, but still not found so still just null.
logger.LogError("Arc stage {ArcStageId} references character {CharacterId} which does not exist", id, stage.CharacterId);
return null;
}
await access.RequireAsync(character.ProjectId, ProjectPermission.Write, ct);
await EnsureChapterIsInSameProjectAsync(character, request.ChapterId, ct);
stage.Title = Patch.Apply(stage.Title, request.Title) ?? stage.Title;
@@ -115,7 +115,6 @@ public class CharacterArcService(
return (await FindAsync(id, ct))!;
}
/// <summary>True if an arc stage was deleted; false if no stage had this id.</summary>
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
@@ -128,25 +127,24 @@ public class CharacterArcService(
return false;
}
await RequireCharacterAccessAsync(stage.CharacterId, ProjectPermission.DeleteContent, ct);
db.CharacterArcStages.Remove(stage);
await db.SaveChangesAsync(ct);
return true;
}
/// <summary>
/// Renumbers a character's arc to match the order given. Stages left out keep their
/// relative position after the ones listed, exactly as beat reordering works.
/// </summary>
/// <summary>Null when the character carries a stage id it does not own — a lookup miss is expected, not exceptional.</summary>
public async Task<IReadOnlyList<CharacterArcStage>?> ReorderAsync(
Guid characterId, ReorderArcStagesRequest request, CancellationToken ct = default)
{
Guard.Default(characterId, nameof(characterId));
Guard.Null(request, nameof(request));
reorderValidator.Validate(request).ThrowIfInvalid();
reorderValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation("Reordering {Count} arc stages for character {CharacterId}", request.StageIds.Count, characterId);
await RequireCharacterAccessAsync(characterId, ProjectPermission.Write, ct);
var stages = await db.CharacterArcStages
.Where(s => s.CharacterId == characterId)
.ToListAsync(ct);
@@ -154,7 +152,7 @@ public class CharacterArcService(
var missing = request.StageIds.Where(id => stages.All(s => s.Id != id)).ToList();
if (missing.Count > 0)
{
logger.LogInformation("Reorder for character {CharacterId} referenced missing arc stage {ArcStageId}", characterId, missing[0]);
logger.LogWarning("Reorder for character {CharacterId} referenced missing arc stage {ArcStageId}", characterId, missing[0]);
return null;
}
@@ -191,6 +189,8 @@ public class CharacterArcService(
throw new InvalidOperationException(
"An arc stage can only point at a chapter in the same project as its character.");
}
logger.LogDebug("Chapter {ChapterId} belongs to project {ProjectId}", id, character.ProjectId);
}
private async Task<int> NextSortOrderAsync(Guid characterId, CancellationToken ct)
@@ -201,7 +201,15 @@ public class CharacterArcService(
.Where(s => s.CharacterId == characterId)
.MaxAsync(s => (int?)s.SortOrder, ct);
return (max ?? 0) + 1;
var next = (max ?? 0) + 1;
logger.LogDebug("Next sort order for character {CharacterId} is {SortOrder}", characterId, next);
return next;
}
private async Task RequireCharacterAccessAsync(Guid characterId, ProjectPermission 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);
}
private IQueryable<CharacterArcStage> Query() => db.CharacterArcStages.Include(s => s.Chapter);
@@ -213,13 +221,11 @@ public class CharacterArcService(
var stage = await Query().FirstOrDefaultAsync(s => s.Id == id, ct);
if (stage is null)
{
logger.LogInformation("CharacterArcStage {ArcStageId} not found", id);
}
else
{
logger.LogDebug("Found arc stage {ArcStageId}", id);
logger.LogWarning("CharacterArcStage {ArcStageId} not found", id);
return stage;
}
logger.LogDebug("Found arc stage {ArcStageId}", id);
return stage;
}
}
+19 -13
View File
@@ -1,31 +1,37 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Novelly.Api.Chapters;
namespace Novelly.Api.Characters;
/// <summary>
/// One step in a main character's arc. Flat and ordered by <see cref="SortOrder"/>, the
/// same shape as a chapter's beats — an arc is a sequence of changes, not a tree.
/// </summary>
public class CharacterArcStage
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid Id { get; init; } = Guid.NewGuid();
public Guid CharacterId { get; set; }
public Character? Character { get; set; }
public Guid CharacterId { get; init; }
public Character? Character { get; init; }
/// <summary>Position in the arc, 1-based.</summary>
public int SortOrder { get; set; }
/// <summary>A short handle for the change — "stops covering for her brother".</summary>
public string Title { get; set; } = string.Empty;
/// <summary>What shifts in the character here, and what it costs them.</summary>
public string? Description { get; set; }
/// <summary>Optionally, where in the manuscript this stage lands.</summary>
public Guid? ChapterId { get; set; }
public Chapter? Chapter { get; set; }
public Chapter? Chapter { get; init; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
}
public class CharacterArcStageEntityTypeConfiguration : IEntityTypeConfiguration<CharacterArcStage>
{
public void Configure(EntityTypeBuilder<CharacterArcStage> entity)
{
entity.Property(s => s.Title).IsRequired().HasMaxLength(200);
entity.HasIndex(s => new { s.CharacterId, s.SortOrder });
entity.HasOne(s => s.Chapter).WithMany()
.HasForeignKey(s => s.ChapterId).OnDelete(DeleteBehavior.SetNull);
}
}
@@ -59,11 +59,7 @@ public class CreateCharacterRequestValidator : IModelValidator<CreateCharacterRe
{
var result = new ValidationResult();
if (string.IsNullOrWhiteSpace(model.Name))
result.AddError("Name", "'Name' must not be empty.");
else if (model.Name.Length > 200)
result.AddError("Name", "'Name' must be 200 characters or fewer.");
result.AddRequiredTextErrors("Name", "Name", model.Name, 200);
CharacterValidation.OptionalFields(
model.Age, model.Pronouns, model.Occupation, model.Appearance, model.Personality, model.Backstory,
model.Want, model.Need, model.InternalConflict, model.ExternalConflict, model.ArcSummary, model.Voice,
@@ -73,10 +69,6 @@ public class CreateCharacterRequestValidator : IModelValidator<CreateCharacterRe
}
}
/// <summary>
/// Patch-style update. A null field is left alone; an empty string clears it. Passing a
/// <see cref="Tags"/> list replaces the character's tags outright.
/// </summary>
public record UpdateCharacterRequest(
string? Name = null,
CharacterRole? Role = null,
@@ -102,14 +94,7 @@ public class UpdateCharacterRequestValidator : IModelValidator<UpdateCharacterRe
{
var result = new ValidationResult();
if (model.Name is not null)
{
if (model.Name.Length == 0)
result.AddError("Name", "'Name' can not be cleared — a character always needs one.");
else if (model.Name.Length > 200)
result.AddError("Name", "'Name' must be 200 characters or fewer.");
}
result.AddUnclearableTextErrors("Name", "Name", model.Name, "a character", 200);
CharacterValidation.OptionalFields(
model.Age, model.Pronouns, model.Occupation, model.Appearance, model.Personality, model.Backstory,
model.Want, model.Need, model.InternalConflict, model.ExternalConflict, model.ArcSummary, model.Voice,
@@ -165,13 +150,8 @@ public class CreateRelationshipRequestValidator : IModelValidator<CreateRelation
if (model.RelatedCharacterId == Guid.Empty)
result.AddError("RelatedCharacterId", "'Related Character Id' must not be empty.");
if (string.IsNullOrWhiteSpace(model.RelationshipType))
result.AddError("RelationshipType", "'Relationship Type' must not be empty.");
else if (model.RelationshipType.Length > 100)
result.AddError("RelationshipType", "'Relationship Type' must be 100 characters or fewer.");
if (model.Description is { Length: > 2000 })
result.AddError("Description", "'Description' must be 2,000 characters or fewer.");
result.AddRequiredTextErrors("RelationshipType", "Relationship Type", model.RelationshipType, 100);
result.AddOptionalTextErrors("Description", "Description", model.Description, 2000);
return result;
}
@@ -200,18 +180,13 @@ public class CreateArcStageRequestValidator : IModelValidator<CreateArcStageRequ
{
var result = new ValidationResult();
if (string.IsNullOrWhiteSpace(model.Title))
result.AddError("Title", "'Title' must not be empty.");
else if (model.Title.Length > 200)
result.AddError("Title", "'Title' must be 200 characters or fewer.");
result.AddRequiredTextErrors("Title", "Title", model.Title, 200);
ArcStageValidation.OptionalFields(model.SortOrder, model.Description, result);
return result;
}
}
/// <summary>Patch-style update. A null field is left alone; an empty string clears it.</summary>
public record UpdateArcStageRequest(
string? Title = null,
int? SortOrder = null,
@@ -224,14 +199,7 @@ public class UpdateArcStageRequestValidator : IModelValidator<UpdateArcStageRequ
{
var result = new ValidationResult();
if (model.Title is not null)
{
if (model.Title.Length == 0)
result.AddError("Title", "'Title' can not be cleared — an arc stage always needs one.");
else if (model.Title.Length > 200)
result.AddError("Title", "'Title' must be 200 characters or fewer.");
}
result.AddUnclearableTextErrors("Title", "Title", model.Title, "an arc stage", 200);
ArcStageValidation.OptionalFields(model.SortOrder, model.Description, result);
return result;
@@ -250,7 +218,6 @@ file static class ArcStageValidation
}
}
/// <summary>Reorders a character's arc in one call, by listing the stage ids in the order wanted.</summary>
public record ReorderArcStagesRequest(IReadOnlyList<Guid> StageIds);
public class ReorderArcStagesRequestValidator : IModelValidator<ReorderArcStagesRequest>
@@ -1,11 +1,5 @@
namespace Novelly.Api.Characters;
/// <summary>
/// How much of the book a character carries. This is separate from
/// <see cref="CharacterRole"/>: role is the part they play in the story (protagonist,
/// mentor, foil), importance is how much weight they take. A mentor can be either.
/// Main characters are the ones worth tracking an arc for.
/// </summary>
public enum CharacterImportance
{
Main,
@@ -1,6 +1,5 @@
namespace Novelly.Api.Characters;
/// <summary>The role a character plays in the story.</summary>
public enum CharacterRole
{
Protagonist,
+44 -36
View File
@@ -3,70 +3,74 @@ using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Data;
using Novelly.Api.Tags;
using Novelly.Api.Users;
namespace Novelly.Api.Characters;
public class CharacterService(
INovelDbContext db,
ProjectAccessService access,
TagService tags,
ILogger<CharacterService> logger,
IModelValidator<CreateCharacterRequest> createValidator,
IModelValidator<UpdateCharacterRequest> updateValidator,
IModelValidator<CreateRelationshipRequest> relationshipValidator)
{
/// <summary>
/// Main characters first, then by the part they play, then by name.
/// </summary>
/// <remarks>
/// The ordering is done in memory on purpose. Both enums are stored as text, so sorting
/// them in SQL sorts the spelling — which puts Deuteragonist above Protagonist and buries
/// the character the book is about. Sorting after materialising uses the declaration
/// order, which is the significance order these enums are written in. A project's cast is
/// small enough that this costs nothing.
/// </remarks>
public async Task<IReadOnlyList<Character>> ListAsync(Guid projectId, CancellationToken ct = default)
{
Guard.Default(projectId, nameof(projectId));
logger.LogInformation("Listing characters for project {ProjectId}", projectId);
await access.RequireAsync(projectId, ProjectPermission.Read, ct);
var characters = await Query()
.Where(c => c.ProjectId == projectId)
.ToListAsync(ct);
return
[
.. characters
.OrderBy(c => c.Importance)
.ThenBy(c => c.Role)
.ThenBy(c => c.Name)
];
return OrderedInMemoryBySignificanceThenName(characters);
}
/// <summary>Null when no character has this id — a lookup miss is expected, not exceptional.</summary>
private static IReadOnlyList<Character> OrderedInMemoryBySignificanceThenName(IReadOnlyList<Character> characters) =>
[
.. characters
.OrderBy(c => c.Importance)
.ThenBy(c => c.Role)
.ThenBy(c => c.Name)
];
public async Task<Character?> GetAsync(Guid id, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
logger.LogInformation("Getting character {CharacterId}", id);
return await FindAsync(id, ct);
var character = await FindAsync(id, ct);
if (character is null)
{
return null;
}
await access.RequireAsync(character.ProjectId, ProjectPermission.Read, ct);
return character;
}
/// <summary>Null when no project has this id — a lookup miss is expected, not exceptional.</summary>
public async Task<Character?> CreateAsync(Guid projectId, CreateCharacterRequest request, CancellationToken ct = default)
{
Guard.Default(projectId, nameof(projectId));
Guard.Null(request, nameof(request));
createValidator.Validate(request).ThrowIfInvalid();
createValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation("Creating character {Name} for project {ProjectId}, role {Role}, importance {Importance}", request.Name, projectId, request.Role, request.Importance);
if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct))
{
logger.LogInformation("Rejected character creation: project {ProjectId} not found", projectId);
logger.LogWarning("Rejected character creation: project {ProjectId} not found", projectId);
return null;
}
await access.RequireAsync(projectId, ProjectPermission.CreateContent, ct);
var character = new Character
{
ProjectId = projectId,
@@ -96,7 +100,6 @@ public class CharacterService(
db.Characters.Add(character);
await db.SaveChangesAsync(ct);
// Just created it — the reload is only to pick up includes, not to check existence.
return (await FindAsync(character.Id, ct))!;
}
@@ -104,7 +107,7 @@ public class CharacterService(
{
Guard.Default(id, nameof(id));
Guard.Null(request, nameof(request));
updateValidator.Validate(request).ThrowIfInvalid();
updateValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation("Updating character {CharacterId}", id);
@@ -114,6 +117,8 @@ public class CharacterService(
return null;
}
await access.RequireAsync(character.ProjectId, ProjectPermission.Write, ct);
character.Name = Patch.Apply(character.Name, request.Name) ?? character.Name;
character.Role = request.Role ?? character.Role;
character.Importance = request.Importance ?? character.Importance;
@@ -141,7 +146,6 @@ public class CharacterService(
return (await FindAsync(id, ct))!;
}
/// <summary>True if a character was deleted; false if no character had this id.</summary>
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
@@ -154,18 +158,19 @@ public class CharacterService(
return false;
}
await access.RequireAsync(character.ProjectId, ProjectPermission.DeleteContent, ct);
db.Characters.Remove(character);
await db.SaveChangesAsync(ct);
return true;
}
/// <summary>Null when the subject character (<paramref name="characterId"/>) doesn't exist.</summary>
public async Task<Character?> AddRelationshipAsync(
Guid characterId, CreateRelationshipRequest request, CancellationToken ct = default)
{
Guard.Default(characterId, nameof(characterId));
Guard.Null(request, nameof(request));
relationshipValidator.Validate(request).ThrowIfInvalid();
relationshipValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation("Adding relationship {RelationshipType} from character {CharacterId} to {RelatedCharacterId}", request.RelationshipType, characterId, request.RelatedCharacterId);
@@ -175,10 +180,12 @@ public class CharacterService(
return null;
}
await access.RequireAsync(character.ProjectId, ProjectPermission.Write, ct);
var related = await db.Characters.FirstOrDefaultAsync(c => c.Id == request.RelatedCharacterId, ct);
if (related is null)
{
logger.LogInformation("Rejected relationship: related character {RelatedCharacterId} not found", request.RelatedCharacterId);
logger.LogWarning("Rejected relationship: related character {RelatedCharacterId} not found", request.RelatedCharacterId);
return null;
}
@@ -200,20 +207,23 @@ public class CharacterService(
return (await FindAsync(characterId, ct))!;
}
/// <summary>True if a relationship was removed; false if no relationship had this id.</summary>
public async Task<bool> RemoveRelationshipAsync(Guid relationshipId, CancellationToken ct = default)
{
Guard.Default(relationshipId, nameof(relationshipId));
logger.LogInformation("Removing relationship {RelationshipId}", relationshipId);
var relationship = await db.CharacterRelationships.FirstOrDefaultAsync(r => r.Id == relationshipId, ct);
var relationship = await db.CharacterRelationships
.Include(r => r.Character)
.FirstOrDefaultAsync(r => r.Id == relationshipId, ct);
if (relationship is null)
{
logger.LogInformation("CharacterRelationship {RelationshipId} not found", relationshipId);
logger.LogWarning("CharacterRelationship {RelationshipId} not found", relationshipId);
return false;
}
await access.RequireAsync(relationship.Character!.ProjectId, ProjectPermission.Write, ct);
db.CharacterRelationships.Remove(relationship);
await db.SaveChangesAsync(ct);
return true;
@@ -234,13 +244,11 @@ public class CharacterService(
var character = await Query().FirstOrDefaultAsync(c => c.Id == id, ct);
if (character is null)
{
logger.LogInformation("Character {CharacterId} not found", id);
}
else
{
logger.LogDebug("Found character {CharacterId}", id);
logger.LogWarning("Character {CharacterId} not found", id);
return character;
}
logger.LogDebug("Found character {CharacterId}", id);
return character;
}
}
@@ -0,0 +1,3 @@
namespace Novelly.Api.Common;
public class NotAuthorizedException(string message) : Exception(message);
@@ -1,4 +1,9 @@
using System.Diagnostics.CodeAnalysis;
using System.Threading.Channels;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Agent;
using Novelly.Api.Beats;
@@ -11,9 +16,11 @@ using Novelly.Api.Imports;
using Novelly.Api.Projects;
using Novelly.Api.Questions;
using Novelly.Api.Tags;
using Novelly.Api.Users;
namespace Novelly.Api.Common;
[ExcludeFromCodeCoverage]
public static class NovellyServiceRegistration
{
public static IServiceCollection AddNovelly(this IServiceCollection services, IConfiguration configuration)
@@ -24,6 +31,47 @@ public static class NovellyServiceRegistration
services.AddDbContext<NovelDbContext>(options => options.UseSqlite(connectionString));
services.AddScoped<INovelDbContext>(sp => sp.GetRequiredService<NovelDbContext>());
var authentication = services.AddAuthentication(IdentityConstants.ApplicationScheme);
authentication.AddIdentityCookies();
authentication.AddScheme<AuthenticationSchemeOptions, ServiceApiKeyAuthenticationHandler>(ServiceApiKeyAuthenticationHandler.SchemeName, null);
services.AddAuthorizationBuilder()
.SetFallbackPolicy(new AuthorizationPolicyBuilder()
.AddAuthenticationSchemes(IdentityConstants.ApplicationScheme, ServiceApiKeyAuthenticationHandler.SchemeName)
.RequireAuthenticatedUser()
.Build());
services.AddIdentityCore<NovellyUser>(options => options.User.RequireUniqueEmail = true)
.AddEntityFrameworkStores<NovelDbContext>()
.AddSignInManager();
services.AddScoped<IUserClaimsPrincipalFactory<NovellyUser>, NovellyUserClaimsPrincipalFactory>();
services.AddHttpContextAccessor();
services.AddScoped<INovelUserContext, NovelUserContext>();
services.AddScoped<ProjectAccessService>();
services.ConfigureApplicationCookie(options =>
{
options.ExpireTimeSpan = TimeSpan.FromHours(24);
options.SlidingExpiration = false;
options.Cookie.HttpOnly = true;
options.Cookie.SameSite = SameSiteMode.Lax;
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
options.Events.OnRedirectToLogin = context =>
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return Task.CompletedTask;
};
options.Events.OnRedirectToAccessDenied = context =>
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
return Task.CompletedTask;
};
});
services.AddScoped<UserAccountService>();
services.AddScoped<ProjectMemberService>();
services.AddScoped<ProjectService>();
services.AddScoped<CharacterService>();
services.AddScoped<CharacterArcService>();
-4
View File
@@ -1,9 +1,5 @@
namespace Novelly.Api.Common;
/// <summary>
/// Patch semantics shared by every update endpoint: a null value leaves the field
/// untouched, an empty string clears it.
/// </summary>
internal static class Patch
{
public static string? Apply(string? current, string? incoming) => incoming switch
@@ -1,10 +1,5 @@
namespace Novelly.Api.Common;
/// <summary>
/// Logs every request an endpoint group handles: Information on entry with the route's
/// name and values, Debug on exit with the resulting status. Applied per <c>MapGroup</c>
/// rather than inside each handler, so no endpoint lambda needs to know about logging.
/// </summary>
public class RequestLoggingEndpointFilter(ILogger<RequestLoggingEndpointFilter> logger) : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
@@ -5,7 +5,7 @@ public static class ModelValidatorServiceCollectionExtensions
public static IServiceCollection AddModelValidatorsFromAssemblyContaining<TMarker>(this IServiceCollection services)
{
var registrations = typeof(TMarker).Assembly.GetTypes()
.Where(type => !type.IsAbstract && !type.IsInterface)
.Where(type => type is { IsAbstract: false, IsInterface: false })
.SelectMany(type => type.GetInterfaces()
.Where(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IModelValidator<>))
.Select(i => (Interface: i, Implementation: type)));
@@ -1,10 +1,5 @@
namespace Novelly.Api.Common.Validation;
/// <summary>
/// Minimal-API equivalent of mic-check's MVC <c>ModelValidationActionFilter</c>. Runs every
/// endpoint argument that has a registered <see cref="IModelValidator{T}"/> through it and,
/// if any fail, short-circuits with a 400 naming every field and message a caller can act on.
/// </summary>
public class ValidationEndpointFilter : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
@@ -1,12 +1,9 @@
using Microsoft.Extensions.Logging;
namespace Novelly.Api.Common.Validation;
public static class ValidationResultExtensions
{
/// <summary>
/// The service-level half of "validate again and throw if invalid": callers that reach
/// a service directly (agent tools, MCP, tests) skip the API's <see cref="ValidationEndpointFilter"/>,
/// so services re-run the same validator and throw rather than act on bad data.
/// </summary>
public static void ThrowIfInvalid(this ValidationResult result)
{
if (result.IsInvalid)
@@ -14,4 +11,47 @@ public static class ValidationResultExtensions
throw new ArgumentException(string.Join("; ", result.Errors.Select(e => $"{e.PropertyName}: {e.Message}")));
}
}
public static void ThrowIfInvalid(this ValidationResult result, ILogger logger)
{
if (result.IsInvalid)
{
logger.LogWarning("Rejected request: {Errors}", string.Join("; ", result.Errors.Select(e => $"{e.PropertyName}: {e.Message}")));
}
result.ThrowIfInvalid();
}
public static void AddRequiredTextErrors(this ValidationResult result, string field, string label, string value, int maxLength)
{
if (string.IsNullOrWhiteSpace(value))
{
result.AddError(field, $"'{label}' must not be empty.");
return;
}
if (value.Length > maxLength)
result.AddError(field, $"'{label}' must be {maxLength:N0} characters or fewer.");
}
public static void AddOptionalTextErrors(this ValidationResult result, string field, string label, string? value, int maxLength)
{
if (value is { Length: var length } && length > maxLength)
result.AddError(field, $"'{label}' must be {maxLength:N0} characters or fewer.");
}
public static void AddUnclearableTextErrors(this ValidationResult result, string field, string label, string? value, string entityArticleAndName, int maxLength)
{
if (value is null)
return;
if (value.Length == 0)
{
result.AddError(field, $"'{label}' can not be cleared — {entityArticleAndName} always needs one.");
return;
}
if (value.Length > maxLength)
result.AddError(field, $"'{label}' must be {maxLength:N0} characters or fewer.");
}
}
-30
View File
@@ -1,30 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Agent;
using Novelly.Api.Beats;
using Novelly.Api.Chapters;
using Novelly.Api.Characters;
using Novelly.Api.Genres;
using Novelly.Api.Imports;
using Novelly.Api.Projects;
using Novelly.Api.Questions;
using Novelly.Api.Tags;
namespace Novelly.Api.Data;
public interface INovelDbContext
{
DbSet<Project> Projects { get; }
DbSet<Character> Characters { get; }
DbSet<CharacterRelationship> CharacterRelationships { get; }
DbSet<CharacterArcStage> CharacterArcStages { get; }
DbSet<Beat> Beats { get; }
DbSet<Tag> Tags { get; }
DbSet<Chapter> Chapters { get; }
DbSet<OpenQuestion> OpenQuestions { get; }
DbSet<AgentConversation> Conversations { get; }
DbSet<AgentMessage> AgentMessages { get; }
DbSet<ImportJob> ImportJobs { get; }
DbSet<Genre> Genres { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,141 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Novelly.Api.Data.Migrations
{
/// <inheritdoc />
public partial class AddUsers : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AspNetUsers",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
DisplayName = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
GlobalRole = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
CreatedAt = table.Column<long>(type: "INTEGER", nullable: false),
UserName = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
NormalizedUserName = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
Email = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
NormalizedEmail = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
EmailConfirmed = table.Column<bool>(type: "INTEGER", nullable: false),
PasswordHash = table.Column<string>(type: "TEXT", nullable: true),
SecurityStamp = table.Column<string>(type: "TEXT", nullable: true),
ConcurrencyStamp = table.Column<string>(type: "TEXT", nullable: true),
PhoneNumber = table.Column<string>(type: "TEXT", nullable: true),
PhoneNumberConfirmed = table.Column<bool>(type: "INTEGER", nullable: false),
TwoFactorEnabled = table.Column<bool>(type: "INTEGER", nullable: false),
LockoutEnd = table.Column<long>(type: "INTEGER", nullable: true),
LockoutEnabled = table.Column<bool>(type: "INTEGER", nullable: false),
AccessFailedCount = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUsers", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AspNetUserClaims",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
UserId = table.Column<Guid>(type: "TEXT", nullable: false),
ClaimType = table.Column<string>(type: "TEXT", nullable: true),
ClaimValue = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserClaims", x => x.Id);
table.ForeignKey(
name: "FK_AspNetUserClaims_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserLogins",
columns: table => new
{
LoginProvider = table.Column<string>(type: "TEXT", nullable: false),
ProviderKey = table.Column<string>(type: "TEXT", nullable: false),
ProviderDisplayName = table.Column<string>(type: "TEXT", nullable: true),
UserId = table.Column<Guid>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey });
table.ForeignKey(
name: "FK_AspNetUserLogins_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserTokens",
columns: table => new
{
UserId = table.Column<Guid>(type: "TEXT", nullable: false),
LoginProvider = table.Column<string>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", nullable: false),
Value = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name });
table.ForeignKey(
name: "FK_AspNetUserTokens_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_AspNetUserClaims_UserId",
table: "AspNetUserClaims",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_AspNetUserLogins_UserId",
table: "AspNetUserLogins",
column: "UserId");
migrationBuilder.CreateIndex(
name: "EmailIndex",
table: "AspNetUsers",
column: "NormalizedEmail");
migrationBuilder.CreateIndex(
name: "UserNameIndex",
table: "AspNetUsers",
column: "NormalizedUserName",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AspNetUserClaims");
migrationBuilder.DropTable(
name: "AspNetUserLogins");
migrationBuilder.DropTable(
name: "AspNetUserTokens");
migrationBuilder.DropTable(
name: "AspNetUsers");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,92 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Novelly.Api.Data.Migrations
{
/// <inheritdoc />
public partial class AddProjectOwnershipAndMembers : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "OwnerId",
table: "Projects",
type: "TEXT",
nullable: true);
migrationBuilder.CreateTable(
name: "ProjectMembers",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
ProjectId = table.Column<Guid>(type: "TEXT", nullable: false),
UserId = table.Column<Guid>(type: "TEXT", nullable: false),
ProjectRole = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
GrantedAt = table.Column<long>(type: "INTEGER", nullable: false),
GrantedByUserId = table.Column<Guid>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ProjectMembers", x => x.Id);
table.ForeignKey(
name: "FK_ProjectMembers_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ProjectMembers_Projects_ProjectId",
column: x => x.ProjectId,
principalTable: "Projects",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Projects_OwnerId",
table: "Projects",
column: "OwnerId");
migrationBuilder.CreateIndex(
name: "IX_ProjectMembers_ProjectId_UserId",
table: "ProjectMembers",
columns: new[] { "ProjectId", "UserId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ProjectMembers_UserId",
table: "ProjectMembers",
column: "UserId");
migrationBuilder.AddForeignKey(
name: "FK_Projects_AspNetUsers_OwnerId",
table: "Projects",
column: "OwnerId",
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Projects_AspNetUsers_OwnerId",
table: "Projects");
migrationBuilder.DropTable(
name: "ProjectMembers");
migrationBuilder.DropIndex(
name: "IX_Projects_OwnerId",
table: "Projects");
migrationBuilder.DropColumn(
name: "OwnerId",
table: "Projects");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,29 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Novelly.Api.Data.Migrations
{
/// <inheritdoc />
public partial class AddImportJobRequestedBy : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "RequestedByUserId",
table: "ImportJobs",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "RequestedByUserId",
table: "ImportJobs");
}
}
}
@@ -77,6 +77,68 @@ namespace Novelly.Api.Data.Migrations
b.ToTable("CharacterTags", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("ProviderKey")
.HasColumnType("TEXT");
b.Property<string>("ProviderDisplayName")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b =>
{
b.Property<Guid>("Id")
@@ -500,6 +562,9 @@ namespace Novelly.Api.Data.Migrations
b.Property<Guid?>("ProjectId")
.HasColumnType("TEXT");
b.Property<Guid?>("RequestedByUserId")
.HasColumnType("TEXT");
b.Property<string>("SourceRoot")
.IsRequired()
.HasMaxLength(1000)
@@ -544,6 +609,9 @@ namespace Novelly.Api.Data.Migrations
b.Property<string>("Notes")
.HasColumnType("TEXT");
b.Property<Guid?>("OwnerId")
.HasColumnType("TEXT");
b.Property<string>("Phase")
.IsRequired()
.HasMaxLength(32)
@@ -565,6 +633,8 @@ namespace Novelly.Api.Data.Migrations
b.HasKey("Id");
b.HasIndex("OwnerId");
b.ToTable("Projects");
});
@@ -643,6 +713,117 @@ namespace Novelly.Api.Data.Migrations
b.ToTable("Tags");
});
modelBuilder.Entity("Novelly.Api.Users.NovellyUser", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("GlobalRole")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER");
b.Property<long?>("LockoutEnd")
.HasColumnType("INTEGER");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("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)
@@ -703,6 +884,33 @@ namespace Novelly.Api.Data.Migrations
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.HasOne("Novelly.Api.Users.NovellyUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.HasOne("Novelly.Api.Users.NovellyUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.HasOne("Novelly.Api.Users.NovellyUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b =>
{
b.HasOne("Novelly.Api.Projects.Project", "Project")
@@ -795,6 +1003,16 @@ namespace Novelly.Api.Data.Migrations
b.Navigation("RelatedCharacter");
});
modelBuilder.Entity("Novelly.Api.Projects.Project", b =>
{
b.HasOne("Novelly.Api.Users.NovellyUser", "Owner")
.WithMany()
.HasForeignKey("OwnerId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("Owner");
});
modelBuilder.Entity("Novelly.Api.Questions.OpenQuestion", b =>
{
b.HasOne("Novelly.Api.Chapters.Chapter", "Chapter")
@@ -831,6 +1049,25 @@ namespace Novelly.Api.Data.Migrations
b.Navigation("Project");
});
modelBuilder.Entity("Novelly.Api.Users.ProjectMember", b =>
{
b.HasOne("Novelly.Api.Projects.Project", "Project")
.WithMany("Members")
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Novelly.Api.Users.NovellyUser", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Project");
b.Navigation("User");
});
modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b =>
{
b.Navigation("Messages");
@@ -856,6 +1093,8 @@ namespace Novelly.Api.Data.Migrations
b.Navigation("Conversations");
b.Navigation("Members");
b.Navigation("Tags");
});
#pragma warning restore 612, 618
+29 -135
View File
@@ -1,3 +1,4 @@
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Agent;
@@ -9,16 +10,13 @@ using Novelly.Api.Imports;
using Novelly.Api.Projects;
using Novelly.Api.Questions;
using Novelly.Api.Tags;
using Novelly.Api.Users;
namespace Novelly.Api.Data;
internal class UtcTicksConverter()
: ValueConverter<DateTimeOffset, long>(
value => value.UtcTicks,
ticks => new DateTimeOffset(ticks, TimeSpan.Zero));
internal class UtcTicksConverter() : ValueConverter<DateTimeOffset, long>(value => value.UtcTicks, ticks => new DateTimeOffset(ticks, TimeSpan.Zero));
public class NovelDbContext(DbContextOptions<NovelDbContext> options)
: DbContext(options), INovelDbContext
public class NovelDbContext(DbContextOptions<NovelDbContext> options) : IdentityUserContext<NovellyUser, Guid>(options), INovelDbContext
{
public DbSet<Project> Projects => Set<Project>();
public DbSet<Character> Characters => Set<Character>();
@@ -32,139 +30,35 @@ public class NovelDbContext(DbContextOptions<NovelDbContext> options)
public DbSet<AgentMessage> AgentMessages => Set<AgentMessage>();
public DbSet<ImportJob> ImportJobs => Set<ImportJob>();
public DbSet<Genre> Genres => Set<Genre>();
public DbSet<ProjectMember> ProjectMembers => Set<ProjectMember>();
Task<int> INovelDbContext.SaveChangesAsync(CancellationToken cancellationToken) =>
base.SaveChangesAsync(cancellationToken);
Task<int> INovelDbContext.SaveChangesAsync(CancellationToken cancellationToken) => base.SaveChangesAsync(cancellationToken);
protected override void ConfigureConventions(ModelConfigurationBuilder builder) =>
builder.Properties<DateTimeOffset>().HaveConversion<UtcTicksConverter>();
protected override void ConfigureConventions(ModelConfigurationBuilder builder) => builder.Properties<DateTimeOffset>().HaveConversion<UtcTicksConverter>();
protected override void OnModelCreating(ModelBuilder builder)
{
builder.Entity<Project>(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);
});
builder.Entity<Character>(entity =>
{
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.HasMany(c => c.Relationships).WithOne(r => r.Character!)
.HasForeignKey(r => r.CharacterId).OnDelete(DeleteBehavior.Cascade);
entity.HasMany(c => c.ArcStages).WithOne(s => s.Character!)
.HasForeignKey(s => s.CharacterId).OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<CharacterArcStage>(entity =>
{
entity.Property(s => s.Title).IsRequired().HasMaxLength(200);
entity.HasIndex(s => new { s.CharacterId, s.SortOrder });
entity.HasOne(s => s.Chapter).WithMany()
.HasForeignKey(s => s.ChapterId).OnDelete(DeleteBehavior.SetNull);
});
builder.Entity<CharacterRelationship>(entity =>
{
entity.Property(r => r.RelationshipType).IsRequired().HasMaxLength(120);
entity.HasOne(r => r.RelatedCharacter).WithMany()
.HasForeignKey(r => r.RelatedCharacterId).OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<Beat>(entity =>
{
entity.Property(b => b.Title).IsRequired().HasMaxLength(200);
entity.HasIndex(b => new { b.ChapterId, b.SortOrder });
entity.HasOne(b => b.Chapter).WithMany(c => c.Beats)
.HasForeignKey(b => b.ChapterId).OnDelete(DeleteBehavior.Cascade);
entity.HasMany(b => b.Characters).WithMany(c => c.Beats)
.UsingEntity(join => join.ToTable("BeatCharacters"));
});
builder.Entity<Tag>(entity =>
{
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.HasMany(t => t.Characters).WithMany(c => c.Tags)
.UsingEntity(join => join.ToTable("CharacterTags"));
entity.HasMany(t => t.Chapters).WithMany(c => c.Tags)
.UsingEntity(join => join.ToTable("ChapterTags"));
entity.HasMany(t => t.Beats).WithMany(b => b.Tags)
.UsingEntity(join => join.ToTable("BeatTags"));
});
builder.Entity<Chapter>(entity =>
{
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.HasOne(c => c.PovCharacter).WithMany()
.HasForeignKey(c => c.PovCharacterId).OnDelete(DeleteBehavior.SetNull);
});
builder.Entity<OpenQuestion>(entity =>
{
entity.Property(q => q.Question).IsRequired().HasMaxLength(500);
entity.Ignore(q => q.IsResolved);
entity.HasIndex(q => q.ProjectId);
entity.HasOne(q => q.Project).WithMany()
.HasForeignKey(q => q.ProjectId).OnDelete(DeleteBehavior.Cascade);
entity.HasOne(q => q.Chapter).WithMany()
.HasForeignKey(q => q.ChapterId).OnDelete(DeleteBehavior.SetNull);
entity.HasOne(q => q.Character).WithMany()
.HasForeignKey(q => q.CharacterId).OnDelete(DeleteBehavior.SetNull);
});
builder.Entity<AgentConversation>(entity =>
{
entity.Property(c => c.Title).IsRequired().HasMaxLength(200);
entity.HasMany(c => c.Messages).WithOne(m => m.Conversation!)
.HasForeignKey(m => m.ConversationId).OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<AgentMessage>(entity =>
{
entity.Property(m => m.Role).HasConversion<string>().HasMaxLength(16);
entity.HasIndex(m => new { m.ConversationId, m.Sequence }).IsUnique();
});
builder.Entity<Genre>(entity =>
{
entity.Property(g => g.Name).IsRequired().HasMaxLength(100);
entity.HasIndex(g => g.Name).IsUnique();
entity.HasData(SeededGenres.All);
});
builder.Entity<ImportJob>(entity =>
{
entity.Property(j => j.SourceRoot).IsRequired().HasMaxLength(1000);
entity.Property(j => j.Status).HasConversion<string>().HasMaxLength(16);
entity.HasIndex(j => j.SourceRoot);
});
base.OnModelCreating(builder);
builder.ApplyConfigurationsFromAssembly(typeof(NovelDbContext).Assembly);
}
}
public interface INovelDbContext
{
DbSet<Project> Projects { get; }
DbSet<Character> Characters { get; }
DbSet<CharacterRelationship> CharacterRelationships { get; }
DbSet<CharacterArcStage> CharacterArcStages { get; }
DbSet<Beat> Beats { get; }
DbSet<Tag> Tags { get; }
DbSet<Chapter> Chapters { get; }
DbSet<OpenQuestion> OpenQuestions { get; }
DbSet<AgentConversation> Conversations { get; }
DbSet<AgentMessage> AgentMessages { get; }
DbSet<ImportJob> ImportJobs { get; }
DbSet<Genre> Genres { get; }
DbSet<NovellyUser> Users { get; }
DbSet<ProjectMember> ProjectMembers { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}
+17
View File
@@ -0,0 +1,17 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY src/Novelly.ServiceDefaults/Novelly.ServiceDefaults.csproj src/Novelly.ServiceDefaults/
COPY src/Novelly.Api/Novelly.Api.csproj src/Novelly.Api/
COPY src/Novelly.ServiceDefaults/ src/Novelly.ServiceDefaults/
COPY src/Novelly.Api/ src/Novelly.Api/
RUN dotnet publish src/Novelly.Api/Novelly.Api.csproj -c Release -o /app
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app
COPY --from=build /app .
ENV ASPNETCORE_URLS=http://0.0.0.0:8080
EXPOSE 8080
ENTRYPOINT ["dotnet", "Novelly.Api.dll"]
+15 -2
View File
@@ -1,8 +1,21 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Novelly.Api.Genres;
public class Genre
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid Id { get; init; } = Guid.NewGuid();
public string Name { get; set; } = string.Empty;
public string Name { get; init; } = string.Empty;
}
public class GenreEntityTypeConfiguration : IEntityTypeConfiguration<Genre>
{
public void Configure(EntityTypeBuilder<Genre> entity)
{
entity.Property(g => g.Name).IsRequired().HasMaxLength(100);
entity.HasIndex(g => g.Name).IsUnique();
entity.HasData(SeededGenres.All);
}
}
+2 -4
View File
@@ -6,11 +6,9 @@ public static class GenreEndpoints
{
public static IEndpointRouteBuilder MapGenreEndpoints(this IEndpointRouteBuilder app)
{
var genres = app.MapGroup("/api/genres").WithTags("Genres")
.AddEndpointFilter<RequestLoggingEndpointFilter>();
var genres = app.MapGroup("/api/genres").WithTags("Genres").AddEndpointFilter<RequestLoggingEndpointFilter>();
genres.MapGet("/", async (GenreService service, CancellationToken ct) =>
Results.Ok(await service.ListAsync(ct)))
genres.MapGet("/", async (GenreService service, CancellationToken ct) => Results.Ok(await service.ListAsync(ct)))
.WithSummary("List the suggested genres a novel can be filed under.");
return app;
@@ -13,7 +13,6 @@ public record ImportJobResponse(
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt);
/// <summary>Whether a source folder is ready for a fresh import, has one to resume, or is already done.</summary>
public enum ImportReadiness
{
Fresh,
@@ -43,11 +42,6 @@ public class InspectImportRequestValidator : IModelValidator<InspectImportReques
}
}
/// <summary>
/// Starts a fresh import, resumes an incomplete one, or — with <see cref="ForceRestart"/> —
/// deletes the ledger and the project it points at before starting clean. Resuming needs no
/// flag: the importer always continues from the ledger it finds unless told to wipe it.
/// </summary>
public record StartImportRequest(string SourceRoot, bool ForceRestart = false);
public class StartImportRequestValidator : IModelValidator<StartImportRequest>
+16 -12
View File
@@ -1,10 +1,8 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Novelly.Api.Imports;
/// <summary>
/// Where an import run stands. <see cref="Paused"/> means it hit its safety limit for a
/// single run without finishing — not an error, just more work than fit in one pass —
/// and re-starting the same source root resumes it from the ledger.
/// </summary>
public enum ImportJobStatus
{
Pending,
@@ -14,23 +12,18 @@ public enum ImportJobStatus
Paused
}
/// <summary>
/// One run of the outline importer against a source folder, tracked so the web client can
/// poll progress while the embedded agent works through it in the background.
/// </summary>
public class ImportJob
{
public Guid Id { get; init; } = Guid.NewGuid();
/// <summary>Absolute, canonicalised path to the outline folder this job reads from.</summary>
public string SourceRoot { get; init; } = string.Empty;
/// <summary>Set once the import creates (or resumes) the project it's populating.</summary>
public Guid? ProjectId { get; set; }
public Guid? RequestedByUserId { get; init; }
public ImportJobStatus Status { get; set; } = ImportJobStatus.Pending;
/// <summary>Human-readable detail for <see cref="Paused"/> or <see cref="Failed"/> — null otherwise.</summary>
public string? StatusMessage { get; set; }
public int ChaptersCompleted { get; set; }
@@ -39,3 +32,14 @@ public class ImportJob
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
}
public class ImportJobEntityTypeConfiguration : IEntityTypeConfiguration<ImportJob>
{
public void Configure(EntityTypeBuilder<ImportJob> entity)
{
entity.Property(j => j.SourceRoot).IsRequired().HasMaxLength(1000);
entity.Property(j => j.Status).HasConversion<string>().HasMaxLength(16);
entity.HasIndex(j => j.SourceRoot);
}
}
+35 -10
View File
@@ -1,16 +1,10 @@
using System.Security.Claims;
using System.Threading.Channels;
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Data;
namespace Novelly.Api.Imports;
/// <summary>
/// The only background-job infrastructure in the app. Drains import job ids off a queue
/// and runs each one to completion (or its safety limit) in its own DI scope, persisting
/// progress and the terminal status onto the <see cref="ImportJob"/> row the web client
/// polls. Everything else in Novelly runs synchronously on the request thread; imports are
/// the first thing long enough that it can't.
/// </summary>
public class ImportJobRunner(
Channel<Guid> queue,
IServiceScopeFactory scopeFactory,
@@ -26,9 +20,6 @@ public class ImportJobRunner(
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
// A failure here means the job row itself couldn't be updated (e.g. the
// scope's DbContext failed) — RunJobAsync already turns ordinary import
// failures into a Failed status rather than throwing.
logger.LogError(ex, "Import job {JobId} runner failed unexpectedly", jobId);
}
}
@@ -47,6 +38,13 @@ public class ImportJobRunner(
return;
}
var requester = await LoadRequestingUserAsync(db, job, ct);
if (requester is not null)
{
scope.ServiceProvider.GetRequiredService<IHttpContextAccessor>().HttpContext =
new DefaultHttpContext { RequestServices = scope.ServiceProvider, User = requester };
}
logger.LogInformation("Import job {JobId} starting for {SourceRoot}", job.Id, job.SourceRoot);
job.Status = ImportJobStatus.Running;
@@ -76,4 +74,31 @@ public class ImportJobRunner(
logger.LogInformation("Import job {JobId} finished as {Status}", job.Id, job.Status);
}
private async Task<ClaimsPrincipal?> LoadRequestingUserAsync(INovelDbContext db, ImportJob job, CancellationToken ct)
{
logger.LogDebug("Resolving the requesting user for import job {JobId}", job.Id);
if (job.RequestedByUserId is not { } userId)
{
logger.LogWarning("Import job {JobId} has no requesting user; it will run without permissions", job.Id);
return null;
}
var user = await db.Users.AsNoTracking().FirstOrDefaultAsync(u => u.Id == userId, ct);
if (user is null)
{
logger.LogWarning("Import job {JobId} was requested by missing user {UserId}", job.Id, userId);
return null;
}
logger.LogDebug("Import job {JobId} will run as user {UserId} with global role {GlobalRole}", job.Id, user.Id, user.GlobalRole);
return new ClaimsPrincipal(new ClaimsIdentity(
[
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Name, user.DisplayName),
new Claim(ClaimTypes.Role, user.GlobalRole.ToString())
], "ImportJob"));
}
}
+1 -29
View File
@@ -3,11 +3,6 @@ using System.Text.Json.Serialization;
namespace Novelly.Api.Imports;
/// <summary>
/// The resume ledger an import run writes to <c>&lt;sourceRoot&gt;/.novelly-import.json</c>.
/// Shape matches the one the <c>outline-importer</c> Claude Code subagent already writes,
/// so a partially-completed CLI import can be finished from the web app and vice versa.
/// </summary>
public record ImportLedger(
Guid? ProjectId,
Dictionary<string, Guid>? Characters,
@@ -15,13 +10,6 @@ public record ImportLedger(
List<string>? CompletedPasses,
List<int>? CompletedChapters);
/// <summary>
/// Path resolution and ledger I/O shared by <see cref="ImportService"/> (which only ever
/// peeks at the ledger to report status) and <see cref="ImportAgentToolset"/> (which reads
/// and writes it as the agent's only file-write capability). Centralising the containment
/// check here means there is exactly one place that decides whether a path is inside the
/// import root, rather than one per caller.
/// </summary>
internal static class ImportPaths
{
private const string LedgerFileName = ".novelly-import.json";
@@ -32,11 +20,6 @@ internal static class ImportPaths
WriteIndented = true
};
/// <summary>
/// Canonicalises a source root and confirms it's a directory that exists. Throws
/// <see cref="ArgumentException"/> on anything else — bad input from the request, not
/// an exceptional server condition.
/// </summary>
public static string ResolveRoot(string sourceRoot)
{
if (string.IsNullOrWhiteSpace(sourceRoot))
@@ -49,7 +32,7 @@ internal static class ImportPaths
}
catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException)
{
throw new ArgumentException($"'{sourceRoot}' is not a valid path.", nameof(sourceRoot));
throw new ArgumentException($"'{sourceRoot}' is not a valid path.", nameof(sourceRoot), ex);
}
if (!Directory.Exists(full))
@@ -58,11 +41,6 @@ internal static class ImportPaths
return full;
}
/// <summary>
/// Resolves a path the agent supplied relative to the import root, rejecting anything
/// that would escape it (`..`, absolute paths, symlink traversal). This is the tool
/// layer's actual security boundary — the system prompt asking nicely is not.
/// </summary>
public static string ResolveWithin(string root, string relativePath)
{
if (string.IsNullOrWhiteSpace(relativePath))
@@ -79,7 +57,6 @@ internal static class ImportPaths
public static string LedgerPath(string root) => Path.Combine(root, LedgerFileName);
/// <summary>Null when no ledger exists yet — a fresh import, not an error.</summary>
public static ImportLedger? ReadLedger(string root)
{
var path = LedgerPath(root);
@@ -101,11 +78,6 @@ internal static class ImportPaths
}
}
/// <summary>
/// Counts chapter source files as a stand-in for "how many chapters does this outline
/// have" — good enough to drive a progress bar without parsing <c>outline.md</c>'s
/// chapter table in C#.
/// </summary>
public static int CountChapterFiles(string root)
{
foreach (var folder in new[] { "outlines", "chapters" })
+10 -20
View File
@@ -4,31 +4,23 @@ using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Data;
using Novelly.Api.Projects;
using Novelly.Api.Users;
namespace Novelly.Api.Imports;
/// <summary>
/// Read-only inspection and job creation for outline imports. The actual import — reading
/// source files, calling the model, writing project data — runs in <see cref="ImportAgentService"/>,
/// driven off the request thread by <see cref="ImportJobRunner"/>; this service only ever
/// touches the filesystem to peek at a ledger, never to import anything itself.
/// </summary>
public class ImportService(
INovelDbContext db,
ProjectService projects,
Channel<Guid> queue,
INovelUserContext userContext,
ILogger<ImportService> logger,
IModelValidator<InspectImportRequest> inspectValidator,
IModelValidator<StartImportRequest> startValidator)
{
/// <summary>
/// Reports whether a folder is a fresh import, one to resume, or already complete —
/// so the UI can offer the right action before committing to anything.
/// </summary>
public Task<ImportInspectionResponse> InspectAsync(InspectImportRequest request, CancellationToken ct = default)
{
Guard.Null(request, nameof(request));
inspectValidator.Validate(request).ThrowIfInvalid();
inspectValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation("Inspecting import source {SourceRoot}", request.SourceRoot);
@@ -48,16 +40,10 @@ public class ImportService(
readiness, ledger.ProjectId, chaptersDone, total, ledger.CompletedPasses ?? []));
}
/// <summary>
/// Creates (or reuses) an <see cref="ImportJob"/> for this source root and enqueues it
/// for the background runner. <see cref="StartImportRequest.ForceRestart"/> deletes the
/// ledger and the project it points at first — the "complete, delete and reimport" path —
/// so make sure the caller has confirmed with the writer before setting it.
/// </summary>
public async Task<ImportJob> StartOrResumeAsync(StartImportRequest request, CancellationToken ct = default)
{
Guard.Null(request, nameof(request));
startValidator.Validate(request).ThrowIfInvalid();
startValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation(
"Starting import for {SourceRoot}, forceRestart {ForceRestart}", request.SourceRoot, request.ForceRestart);
@@ -88,7 +74,12 @@ public class ImportService(
return existing;
}
var job = new ImportJob { SourceRoot = root, ChaptersTotal = ImportPaths.CountChapterFiles(root) };
var job = new ImportJob
{
SourceRoot = root,
ChaptersTotal = ImportPaths.CountChapterFiles(root),
RequestedByUserId = userContext.UserId
};
db.ImportJobs.Add(job);
await db.SaveChangesAsync(ct);
@@ -97,7 +88,6 @@ public class ImportService(
return job;
}
/// <summary>Null when no job has this id — a lookup miss is expected, not exceptional.</summary>
public async Task<ImportJob?> GetStatusAsync(Guid id, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
+1
View File
@@ -6,6 +6,7 @@
<ItemGroup>
<PackageReference Include="Anthropic" Version="12.39.0" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.10" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+20 -11
View File
@@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.EntityFrameworkCore;
@@ -12,6 +13,7 @@ using Novelly.Api.Imports;
using Novelly.Api.Projects;
using Novelly.Api.Questions;
using Novelly.Api.Tags;
using Novelly.Api.Users;
using Serilog;
var builder = WebApplication.CreateBuilder(args);
@@ -35,13 +37,16 @@ var corsOrigins = builder.Configuration.GetSection("Cors:Origins").Get<string[]>
builder.Services.AddCors(options => options.AddDefaultPolicy(policy => policy
.WithOrigins(corsOrigins)
.AllowAnyHeader()
.AllowAnyMethod()));
.AllowAnyMethod()
.AllowCredentials()));
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
await scope.ServiceProvider.GetRequiredService<NovelDbContext>().Database.MigrateAsync();
var db = scope.ServiceProvider.GetRequiredService<NovelDbContext>();
await db.Database.MigrateAsync();
await ServiceUser.EnsureSeededAsync(db, builder.Configuration[ServiceApiKeyAuthenticationHandler.ConfigurationKey], app.Logger);
}
app.UseSerilogRequestLogging();
@@ -53,18 +58,15 @@ app.UseExceptionHandler(handler => handler.Run(async context =>
var (status, title) = exception switch
{
AgentNotConfiguredException => (StatusCodes.Status503ServiceUnavailable, "Agent unavailable"),
NotAuthorizedException => (StatusCodes.Status403Forbidden, "Forbidden"),
ArgumentException or InvalidOperationException => (StatusCodes.Status400BadRequest, "Invalid request"),
_ => (StatusCodes.Status500InternalServerError, "Unexpected error")
};
if (status == StatusCodes.Status500InternalServerError)
{
app.Logger.LogError(exception, "Unhandled exception on {Path}", context.Request.Path);
}
else
{
app.Logger.LogWarning(exception, "Handled {StatusCode} on {Path}: {Title}", status, context.Request.Path, title);
}
app.Logger.Log(
status == StatusCodes.Status500InternalServerError ? LogLevel.Error : LogLevel.Warning,
exception,
"Handled {StatusCode} on {Path}: {Title}", status, context.Request.Path, title);
await Results
.Problem(title: title, detail: exception?.Message, statusCode: status)
@@ -73,6 +75,9 @@ app.UseExceptionHandler(handler => handler.Run(async context =>
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
@@ -80,7 +85,10 @@ if (app.Environment.IsDevelopment())
app.MapDefaultEndpoints();
app.MapGet("/api/health", () => Results.Ok(new { status = "ok" })).WithTags("Health");
app.MapGet("/api/health", () => Results.Ok(new { status = "ok" })).WithTags("Health").AllowAnonymous();
app.MapUserEndpoints();
app.MapProjectMemberEndpoints();
app.MapProjectEndpoints()
.MapCharacterEndpoints()
@@ -94,4 +102,5 @@ app.MapProjectEndpoints()
app.Run();
[ExcludeFromCodeCoverage]
public partial class Program;
+28 -4
View File
@@ -1,11 +1,13 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Novelly.Api.Agent;
using Novelly.Api.Chapters;
using Novelly.Api.Characters;
using Novelly.Api.Tags;
using Novelly.Api.Users;
namespace Novelly.Api.Projects;
/// <summary>A single novel and everything that belongs to it.</summary>
public class Project
{
public Guid Id { get; set; } = Guid.NewGuid();
@@ -14,19 +16,19 @@ public class Project
public string? Author { get; set; }
public string? Genre { get; set; }
/// <summary>One-sentence pitch.</summary>
public string? Logline { get; set; }
/// <summary>Paragraph-length summary of the whole book.</summary>
public string? Synopsis { get; set; }
/// <summary>Free-form notes on theme, tone, comparable titles, etc.</summary>
public string? Notes { get; set; }
public int? TargetWordCount { get; set; }
public ProjectPhase Phase { get; set; } = ProjectPhase.Brainstorming;
public Guid? OwnerId { get; set; }
public NovellyUser? Owner { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
@@ -34,4 +36,26 @@ 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 class ProjectEntityTypeConfiguration : IEntityTypeConfiguration<Project>
{
public void Configure(EntityTypeBuilder<Project> 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.HasOne(p => p.Owner).WithMany()
.HasForeignKey(p => p.OwnerId).OnDelete(DeleteBehavior.Restrict);
}
}
+3 -19
View File
@@ -50,10 +50,6 @@ public class CreateProjectRequestValidator : IModelValidator<CreateProjectReques
}
}
/// <summary>
/// Patch-style update: every field is optional and null means "leave alone".
/// Clearing a field is done by sending an empty string.
/// </summary>
public record UpdateProjectRequest(
string? Title = null,
string? Author = null,
@@ -70,14 +66,7 @@ public class UpdateProjectRequestValidator : IModelValidator<UpdateProjectReques
{
var result = new ValidationResult();
if (model.Title is not null)
{
if (model.Title.Length == 0)
result.AddError("Title", "'Title' can not be cleared — a project always needs one.");
else if (model.Title.Length > 200)
result.AddError("Title", "'Title' must be 200 characters or fewer.");
}
result.AddUnclearableTextErrors("Title", "Title", model.Title, "a project", 200);
ProjectValidation.OptionalFields(model.Author, model.Genre, model.Logline, model.Synopsis, model.Notes, model.TargetWordCount, result);
return result;
@@ -86,13 +75,8 @@ public class UpdateProjectRequestValidator : IModelValidator<UpdateProjectReques
file static class ProjectValidation
{
public static void Title(string title, ValidationResult result)
{
if (string.IsNullOrWhiteSpace(title))
result.AddError("Title", "'Title' must not be empty.");
else if (title.Length > 200)
result.AddError("Title", "'Title' must be 200 characters or fewer.");
}
public static void Title(string title, ValidationResult result) =>
result.AddRequiredTextErrors("Title", "Title", title, 200);
public static void OptionalFields(
string? author, string? genre, string? logline, string? synopsis, string? notes, int? targetWordCount, ValidationResult result)
-1
View File
@@ -1,6 +1,5 @@
namespace Novelly.Api.Projects;
/// <summary>Where a novel is in its lifecycle, from first notes to a finished manuscript.</summary>
public enum ProjectPhase
{
Brainstorming,
+25 -10
View File
@@ -2,11 +2,14 @@ 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)
@@ -15,7 +18,7 @@ public class ProjectService(
{
logger.LogInformation("Listing projects");
return await db.Projects
return await access.VisibleProjects()
.OrderByDescending(p => p.UpdatedAt)
.Select(p => new ProjectSummaryResponse(
p.Id,
@@ -37,13 +40,22 @@ public class ProjectService(
Guard.Default(id, nameof(id));
logger.LogInformation("Getting project {ProjectId}", id);
return await FindAsync(id, ct);
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();
createValidator.Validate(request).ThrowIfInvalid(logger);
access.RequireCanCreateProject();
logger.LogInformation("Creating project {Title}", request.Title);
@@ -55,7 +67,8 @@ public class ProjectService(
Logline = request.Logline,
Synopsis = request.Synopsis,
Notes = request.Notes,
TargetWordCount = request.TargetWordCount
TargetWordCount = request.TargetWordCount,
OwnerId = userContext.UserId
};
db.Projects.Add(project);
@@ -67,7 +80,7 @@ public class ProjectService(
{
Guard.Default(id, nameof(id));
Guard.Null(request, nameof(request));
updateValidator.Validate(request).ThrowIfInvalid();
updateValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation("Updating project {ProjectId}", id);
@@ -77,6 +90,8 @@ public class ProjectService(
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);
@@ -103,6 +118,8 @@ public class ProjectService(
return false;
}
await access.RequireAsync(id, ProjectPermission.DeleteContent, ct);
db.Projects.Remove(project);
await db.SaveChangesAsync(ct);
return true;
@@ -115,13 +132,11 @@ public class ProjectService(
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == id, ct);
if (project is null)
{
logger.LogInformation("Project {ProjectId} not found", id);
}
else
{
logger.LogDebug("Found project {ProjectId}", id);
logger.LogWarning("Project {ProjectId} not found", id);
return project;
}
logger.LogDebug("Found project {ProjectId}", id);
return project;
}
}
+21 -11
View File
@@ -1,14 +1,11 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Novelly.Api.Chapters;
using Novelly.Api.Characters;
using Novelly.Api.Projects;
namespace Novelly.Api.Questions;
/// <summary>
/// Something the writer has not decided yet — "does she know about the letter before the
/// harbour?". Questions hang off the chapter outline or the character they belong to, or
/// both, or neither when they are about the book as a whole.
/// </summary>
public class OpenQuestion
{
public Guid Id { get; set; } = Guid.NewGuid();
@@ -16,24 +13,18 @@ public class OpenQuestion
public Guid ProjectId { get; set; }
public Project? Project { get; set; }
/// <summary>The question itself, in one line.</summary>
public string Question { get; set; } = string.Empty;
/// <summary>Room for the thinking around it — options considered, what each costs.</summary>
public string? Detail { get; set; }
/// <summary>The chapter outline this question is about, if it is about one.</summary>
public Guid? ChapterId { get; set; }
public Chapter? Chapter { get; set; }
/// <summary>The character this question is about, if it is about one.</summary>
public Guid? CharacterId { get; set; }
public Character? Character { get; set; }
/// <summary>What was decided. Set when the question is resolved, cleared when reopened.</summary>
public string? Resolution { get; set; }
/// <summary>When it was decided. Null while the question is still open.</summary>
public DateTimeOffset? ResolvedAt { get; set; }
public bool IsResolved => ResolvedAt is not null;
@@ -41,3 +32,22 @@ public class OpenQuestion
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
}
public class OpenQuestionEntityTypeConfiguration : IEntityTypeConfiguration<OpenQuestion>
{
public void Configure(EntityTypeBuilder<OpenQuestion> entity)
{
entity.Property(q => q.Question).IsRequired().HasMaxLength(500);
entity.Ignore(q => q.IsResolved);
entity.HasIndex(q => q.ProjectId);
entity.HasOne(q => q.Project).WithMany()
.HasForeignKey(q => q.ProjectId).OnDelete(DeleteBehavior.Cascade);
entity.HasOne(q => q.Chapter).WithMany()
.HasForeignKey(q => q.ChapterId).OnDelete(DeleteBehavior.SetNull);
entity.HasOne(q => q.Character).WithMany()
.HasForeignKey(q => q.CharacterId).OnDelete(DeleteBehavior.SetNull);
}
}
@@ -30,23 +30,13 @@ public class CreateOpenQuestionRequestValidator : IModelValidator<CreateOpenQues
{
var result = new ValidationResult();
if (string.IsNullOrWhiteSpace(model.Question))
result.AddError("Question", "'Question' must not be empty.");
else if (model.Question.Length > 1000)
result.AddError("Question", "'Question' must be 1,000 characters or fewer.");
if (model.Detail is { Length: > 20000 })
result.AddError("Detail", "'Detail' must be 20,000 characters or fewer.");
result.AddRequiredTextErrors("Question", "Question", model.Question, 1000);
result.AddOptionalTextErrors("Detail", "Detail", model.Detail, 20000);
return result;
}
}
/// <summary>
/// Patch-style update. A null field is left alone; an empty string clears it. Use
/// <see cref="ClearChapter"/> / <see cref="ClearCharacter"/> to detach a question, since a
/// null id already means "leave the association alone".
/// </summary>
public record UpdateOpenQuestionRequest(
string? Question = null,
string? Detail = null,
@@ -61,26 +51,13 @@ public class UpdateOpenQuestionRequestValidator : IModelValidator<UpdateOpenQues
{
var result = new ValidationResult();
if (model.Question is not null)
{
if (model.Question.Length == 0)
result.AddError("Question", "'Question' can not be cleared — a question always needs one.");
else if (model.Question.Length > 1000)
result.AddError("Question", "'Question' must be 1,000 characters or fewer.");
}
if (model.Detail is { Length: > 20000 })
result.AddError("Detail", "'Detail' must be 20,000 characters or fewer.");
result.AddUnclearableTextErrors("Question", "Question", model.Question, "a question", 1000);
result.AddOptionalTextErrors("Detail", "Detail", model.Detail, 20000);
return result;
}
}
/// <summary>
/// Settles a question. The resolution is kept on the question itself; setting
/// <see cref="AppendToNotes"/> also appends it to the notes of whatever the question is
/// attached to, so the decision lands where the writer will actually re-read it.
/// </summary>
public record ResolveOpenQuestionRequest(string Resolution, bool AppendToNotes = false);
public class ResolveOpenQuestionRequestValidator : IModelValidator<ResolveOpenQuestionRequest>
@@ -89,10 +66,7 @@ public class ResolveOpenQuestionRequestValidator : IModelValidator<ResolveOpenQu
{
var result = new ValidationResult();
if (string.IsNullOrWhiteSpace(model.Resolution))
result.AddError("Resolution", "'Resolution' must not be empty.");
else if (model.Resolution.Length > 20000)
result.AddError("Resolution", "'Resolution' must be 20,000 characters or fewer.");
result.AddRequiredTextErrors("Resolution", "Resolution", model.Resolution, 20000);
return result;
}
@@ -4,25 +4,18 @@ using Novelly.Api.Characters;
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Data;
using Novelly.Api.Users;
namespace Novelly.Api.Questions;
/// <summary>
/// The project's open questions — the decisions still outstanding. A question can be
/// attached to a chapter outline, a character, both, or neither.
/// </summary>
public class OpenQuestionService(
INovelDbContext db,
ProjectAccessService access,
ILogger<OpenQuestionService> logger,
IModelValidator<CreateOpenQuestionRequest> createValidator,
IModelValidator<UpdateOpenQuestionRequest> updateValidator,
IModelValidator<ResolveOpenQuestionRequest> resolveValidator)
{
/// <summary>
/// Lists a project's questions, open ones first and newest first within each group.
/// Filters narrow to what one page cares about; resolved questions are left out
/// unless asked for, since the point of the list is what is still undecided.
/// </summary>
public async Task<IReadOnlyList<OpenQuestion>> ListAsync(
Guid projectId,
Guid? chapterId = null,
@@ -36,6 +29,8 @@ public class OpenQuestionService(
"Listing open questions for project {ProjectId}, chapter {ChapterId}, character {CharacterId}, includeResolved {IncludeResolved}",
projectId, chapterId, characterId, includeResolved);
await access.RequireAsync(projectId, ProjectPermission.Read, ct);
var query = Query().Where(q => q.ProjectId == projectId);
if (chapterId is { } cid)
@@ -63,31 +58,38 @@ public class OpenQuestionService(
];
}
/// <summary>Null when no open question has this id — a lookup miss is expected, not exceptional.</summary>
public async Task<OpenQuestion?> GetAsync(Guid id, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
logger.LogInformation("Getting open question {QuestionId}", id);
return await FindAsync(id, ct);
var question = await FindAsync(id, ct);
if (question is null)
{
return null;
}
await access.RequireAsync(question.ProjectId, ProjectPermission.Read, ct);
return question;
}
/// <summary>Null when no project has this id — a lookup miss is expected, not exceptional.</summary>
public async Task<OpenQuestion?> CreateAsync(
Guid projectId, CreateOpenQuestionRequest request, CancellationToken ct = default)
{
Guard.Default(projectId, nameof(projectId));
Guard.Null(request, nameof(request));
createValidator.Validate(request).ThrowIfInvalid();
createValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation("Creating open question for project {ProjectId}", projectId);
if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct))
{
logger.LogInformation("Rejected open question creation: project {ProjectId} not found", projectId);
logger.LogWarning("Rejected open question creation: project {ProjectId} not found", projectId);
return null;
}
await access.RequireAsync(projectId, ProjectPermission.CreateContent, ct);
await ValidateAssociationsAsync(projectId, request.ChapterId, request.CharacterId, ct);
var question = new OpenQuestion
@@ -102,7 +104,6 @@ public class OpenQuestionService(
db.OpenQuestions.Add(question);
await db.SaveChangesAsync(ct);
// Just created it — the reload is only to pick up includes, not to check existence.
return (await FindAsync(question.Id, ct))!;
}
@@ -111,7 +112,7 @@ public class OpenQuestionService(
{
Guard.Default(id, nameof(id));
Guard.Null(request, nameof(request));
updateValidator.Validate(request).ThrowIfInvalid();
updateValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation("Updating open question {QuestionId}", id);
@@ -121,6 +122,7 @@ public class OpenQuestionService(
return null;
}
await access.RequireAsync(question.ProjectId, ProjectPermission.Write, ct);
await ValidateAssociationsAsync(question.ProjectId, request.ChapterId, request.CharacterId, ct);
question.Question = Patch.Apply(question.Question, request.Question) ?? question.Question;
@@ -133,18 +135,12 @@ public class OpenQuestionService(
return (await FindAsync(id, ct))!;
}
/// <summary>
/// Settles a question. With <c>AppendToNotes</c> the resolution is also appended to the
/// notes of the chapter and character it hangs off, so the decision ends up where the
/// writer reads rather than only in a list they have stopped looking at. Null when no
/// open question has this id.
/// </summary>
public async Task<OpenQuestion?> ResolveAsync(
Guid id, ResolveOpenQuestionRequest request, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
Guard.Null(request, nameof(request));
resolveValidator.Validate(request).ThrowIfInvalid();
resolveValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation("Resolving open question {QuestionId}, appendToNotes {AppendToNotes}", id, request.AppendToNotes);
@@ -154,6 +150,8 @@ public class OpenQuestionService(
return null;
}
await access.RequireAsync(question.ProjectId, ProjectPermission.Write, ct);
question.Resolution = request.Resolution.Trim();
question.ResolvedAt = DateTimeOffset.UtcNow;
question.UpdatedAt = question.ResolvedAt.Value;
@@ -191,7 +189,6 @@ public class OpenQuestionService(
return (await FindAsync(id, ct))!;
}
/// <summary>Puts a question back on the list. The resolution goes; anything already appended to notes stays. Null when no open question has this id.</summary>
public async Task<OpenQuestion?> ReopenAsync(Guid id, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
@@ -204,6 +201,8 @@ public class OpenQuestionService(
return null;
}
await access.RequireAsync(question.ProjectId, ProjectPermission.Write, ct);
question.Resolution = null;
question.ResolvedAt = null;
question.UpdatedAt = DateTimeOffset.UtcNow;
@@ -212,7 +211,6 @@ public class OpenQuestionService(
return (await FindAsync(id, ct))!;
}
/// <summary>True if an open question was deleted; false if no question had this id.</summary>
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
@@ -225,12 +223,13 @@ public class OpenQuestionService(
return false;
}
await access.RequireAsync(question.ProjectId, ProjectPermission.DeleteContent, ct);
db.OpenQuestions.Remove(question);
await db.SaveChangesAsync(ct);
return true;
}
/// <summary>Blank line between entries, so appended resolutions stay readable as notes accumulate.</summary>
private static string AppendNote(string? existing, string note) =>
string.IsNullOrWhiteSpace(existing) ? note : $"{existing.TrimEnd()}\n\n{note}";
@@ -254,6 +253,8 @@ public class OpenQuestionService(
throw new InvalidOperationException(
"A question can only be attached to a character in the same project.");
}
logger.LogDebug("Associations valid for project {ProjectId}: chapter {ChapterId}, character {CharacterId}", projectId, chapterId, characterId);
}
private IQueryable<OpenQuestion> Query() =>
@@ -266,13 +267,11 @@ public class OpenQuestionService(
var question = await Query().FirstOrDefaultAsync(q => q.Id == id, ct);
if (question is null)
{
logger.LogInformation("OpenQuestion {QuestionId} not found", id);
}
else
{
logger.LogDebug("Found open question {QuestionId}", id);
logger.LogWarning("OpenQuestion {QuestionId} not found", id);
return question;
}
logger.LogDebug("Found open question {QuestionId}", id);
return question;
}
}
+20 -6
View File
@@ -1,3 +1,5 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Novelly.Api.Beats;
using Novelly.Api.Chapters;
using Novelly.Api.Characters;
@@ -5,11 +7,6 @@ using Novelly.Api.Projects;
namespace Novelly.Api.Tags;
/// <summary>
/// A free-form label scoped to one project. Tags are the cross-reference mechanism:
/// attach the same tag to a character, a chapter and a beat, then ask what else carries it.
/// Names are unique within a project so "betrayal" always means the same tag.
/// </summary>
public class Tag
{
public Guid Id { get; set; } = Guid.NewGuid();
@@ -19,7 +16,6 @@ public class Tag
public string Name { get; set; } = string.Empty;
/// <summary>Optional hex colour for the UI, e.g. "#9a4a2f".</summary>
public string? Color { get; set; }
public List<Character> Characters { get; set; } = [];
@@ -28,3 +24,21 @@ public class Tag
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
public class TagEntityTypeConfiguration : IEntityTypeConfiguration<Tag>
{
public void Configure(EntityTypeBuilder<Tag> entity)
{
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.HasMany(t => t.Characters).WithMany(c => c.Tags)
.UsingEntity(join => join.ToTable("CharacterTags"));
entity.HasMany(t => t.Chapters).WithMany(c => c.Tags)
.UsingEntity(join => join.ToTable("ChapterTags"));
entity.HasMany(t => t.Beats).WithMany(b => b.Tags)
.UsingEntity(join => join.ToTable("BeatTags"));
}
}
+4 -17
View File
@@ -23,13 +23,8 @@ public class CreateTagRequestValidator : IModelValidator<CreateTagRequest>
{
var result = new ValidationResult();
if (string.IsNullOrWhiteSpace(model.Name))
result.AddError("Name", "'Name' must not be empty.");
else if (model.Name.Length > 100)
result.AddError("Name", "'Name' must be 100 characters or fewer.");
if (model.Color is { Length: > 50 })
result.AddError("Color", "'Color' must be 50 characters or fewer.");
result.AddRequiredTextErrors("Name", "Name", model.Name, 100);
result.AddOptionalTextErrors("Color", "Color", model.Color, 50);
return result;
}
@@ -43,16 +38,8 @@ public class UpdateTagRequestValidator : IModelValidator<UpdateTagRequest>
{
var result = new ValidationResult();
if (model.Name is not null)
{
if (model.Name.Length == 0)
result.AddError("Name", "'Name' can not be cleared — a tag always needs one.");
else if (model.Name.Length > 100)
result.AddError("Name", "'Name' must be 100 characters or fewer.");
}
if (model.Color is { Length: > 50 })
result.AddError("Color", "'Color' must be 50 characters or fewer.");
result.AddUnclearableTextErrors("Name", "Name", model.Name, "a tag", 100);
result.AddOptionalTextErrors("Color", "Color", model.Color, 50);
return result;
}
+20 -6
View File
@@ -2,11 +2,13 @@ using Microsoft.EntityFrameworkCore;
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Data;
using Novelly.Api.Users;
namespace Novelly.Api.Tags;
public class TagService(
INovelDbContext db,
ProjectAccessService access,
ILogger<TagService> logger,
IModelValidator<CreateTagRequest> createValidator,
IModelValidator<UpdateTagRequest> updateValidator)
@@ -17,6 +19,8 @@ public class TagService(
logger.LogInformation("Listing tags for project {ProjectId}", projectId);
await access.RequireAsync(projectId, ProjectPermission.Read, ct);
return await db.Tags
.Where(t => t.ProjectId == projectId)
.OrderBy(t => t.Name)
@@ -40,8 +44,12 @@ public class TagService(
.FirstOrDefaultAsync(t => t.Id == tagId, ct);
if (tag is null)
logger.LogInformation("Tag {TagId} not found", tagId);
{
logger.LogWarning("Tag {TagId} not found", tagId);
return tag;
}
await access.RequireAsync(tag.ProjectId, ProjectPermission.Read, ct);
return tag;
}
@@ -49,16 +57,18 @@ public class TagService(
{
Guard.Default(projectId, nameof(projectId));
Guard.Null(request, nameof(request));
createValidator.Validate(request).ThrowIfInvalid();
createValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation("Creating tag {Name} for project {ProjectId}", request.Name, projectId);
if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct))
{
logger.LogInformation("Rejected tag creation: project {ProjectId} not found", projectId);
logger.LogWarning("Rejected tag creation: project {ProjectId} not found", projectId);
return null;
}
await access.RequireAsync(projectId, ProjectPermission.CreateContent, ct);
var name = TagMapping.Normalise(request.Name);
var existing = await FindByNameAsync(projectId, name, ct);
@@ -78,17 +88,19 @@ public class TagService(
{
Guard.Default(tagId, nameof(tagId));
Guard.Null(request, nameof(request));
updateValidator.Validate(request).ThrowIfInvalid();
updateValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation("Updating tag {TagId}", tagId);
var tag = await db.Tags.FirstOrDefaultAsync(t => t.Id == tagId, ct);
if (tag is null)
{
logger.LogInformation("Tag {TagId} not found", tagId);
logger.LogWarning("Tag {TagId} not found", tagId);
return null;
}
await access.RequireAsync(tag.ProjectId, ProjectPermission.Write, ct);
if (request.Name is not null)
{
var name = TagMapping.Normalise(request.Name);
@@ -117,10 +129,12 @@ public class TagService(
var tag = await db.Tags.FirstOrDefaultAsync(t => t.Id == tagId, ct);
if (tag is null)
{
logger.LogInformation("Tag {TagId} not found", tagId);
logger.LogWarning("Tag {TagId} not found", tagId);
return false;
}
await access.RequireAsync(tag.ProjectId, ProjectPermission.DeleteContent, ct);
db.Tags.Remove(tag);
await db.SaveChangesAsync(ct);
return true;
+9
View File
@@ -0,0 +1,9 @@
namespace Novelly.Api.Users;
public enum GlobalRole
{
Admin,
Writer,
Editor,
Reviewer
}
+33
View File
@@ -0,0 +1,33 @@
using System.Security.Claims;
namespace Novelly.Api.Users;
public class NovelUserContext(IHttpContextAccessor httpContextAccessor) : INovelUserContext
{
public bool IsAuthenticated => httpContextAccessor.HttpContext?.User.Identity?.IsAuthenticated ?? false;
public Guid? UserId
{
get
{
var value = httpContextAccessor.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier);
return Guid.TryParse(value, out var id) ? id : null;
}
}
public GlobalRole? GlobalRole
{
get
{
var value = httpContextAccessor.HttpContext?.User.FindFirstValue(ClaimTypes.Role);
return Enum.TryParse<GlobalRole>(value, out var role) ? role : null;
}
}
}
public interface INovelUserContext
{
bool IsAuthenticated { get; }
Guid? UserId { get; }
GlobalRole? GlobalRole { get; }
}
+21
View File
@@ -0,0 +1,21 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Novelly.Api.Users;
public class NovellyUser : IdentityUser<Guid>
{
public string DisplayName { get; set; } = string.Empty;
public GlobalRole GlobalRole { get; set; } = GlobalRole.Reviewer;
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
public class NovellyUserEntityTypeConfiguration : IEntityTypeConfiguration<NovellyUser>
{
public void Configure(EntityTypeBuilder<NovellyUser> entity)
{
entity.Property(u => u.DisplayName).IsRequired().HasMaxLength(200);
entity.Property(u => u.GlobalRole).HasConversion<string>().HasMaxLength(32);
}
}
@@ -0,0 +1,16 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
namespace Novelly.Api.Users;
public class NovellyUserClaimsPrincipalFactory(UserManager<NovellyUser> userManager, IOptions<IdentityOptions> optionsAccessor)
: UserClaimsPrincipalFactory<NovellyUser>(userManager, optionsAccessor)
{
public override async Task<ClaimsPrincipal> CreateAsync(NovellyUser user)
{
var principal = await base.CreateAsync(user);
((ClaimsIdentity)principal.Identity!).AddClaim(new Claim(ClaimTypes.Role, user.GlobalRole.ToString()));
return principal;
}
}
@@ -0,0 +1,72 @@
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 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
};
}
+33
View File
@@ -0,0 +1,33 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Novelly.Api.Projects;
namespace Novelly.Api.Users;
public class ProjectMember
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid ProjectId { get; set; }
public Project? Project { get; set; }
public Guid UserId { get; set; }
public NovellyUser? User { get; set; }
public ProjectRole ProjectRole { get; set; }
public DateTimeOffset GrantedAt { get; set; } = DateTimeOffset.UtcNow;
public Guid GrantedByUserId { get; set; }
}
public class ProjectMemberEntityTypeConfiguration : IEntityTypeConfiguration<ProjectMember>
{
public void Configure(EntityTypeBuilder<ProjectMember> entity)
{
entity.Property(m => m.ProjectRole).HasConversion<string>().HasMaxLength(32);
entity.HasIndex(m => new { m.ProjectId, m.UserId }).IsUnique();
entity.HasOne(m => m.User).WithMany()
.HasForeignKey(m => m.UserId).OnDelete(DeleteBehavior.Cascade);
}
}
@@ -0,0 +1,23 @@
using Novelly.Api.Common.Validation;
namespace Novelly.Api.Users;
public record GrantAccessRequest(string Email, ProjectRole ProjectRole);
public record ProjectMemberResponse(Guid UserId, string Email, string DisplayName, ProjectRole ProjectRole, DateTimeOffset GrantedAt);
public class GrantAccessRequestValidator : IModelValidator<GrantAccessRequest>
{
public ValidationResult Validate(GrantAccessRequest model)
{
var result = new ValidationResult();
result.AddRequiredTextErrors("Email", "Email", model.Email, 256);
return result;
}
}
public static class ProjectMemberMapping
{
public static ProjectMemberResponse ToResponse(this ProjectMember m) =>
new(m.UserId, m.User!.Email ?? string.Empty, m.User.DisplayName, m.ProjectRole, m.GrantedAt);
}
@@ -0,0 +1,28 @@
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;
}
}
@@ -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 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;
}
}
+8
View File
@@ -0,0 +1,8 @@
namespace Novelly.Api.Users;
public enum ProjectRole
{
Writer,
Editor,
Reviewer
}
@@ -0,0 +1,60 @@
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Novelly.Api.Data;
namespace Novelly.Api.Users;
public class ServiceApiKeyAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory loggerFactory,
UrlEncoder encoder,
IConfiguration configuration,
INovelDbContext db) : AuthenticationHandler<AuthenticationSchemeOptions>(options, loggerFactory, encoder)
{
public const string SchemeName = "ServiceApiKey";
public const string HeaderName = "X-Novelly-Api-Key";
public const string ConfigurationKey = "Auth:ServiceApiKey";
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Request.Headers.TryGetValue(HeaderName, out var presented) || string.IsNullOrWhiteSpace(presented))
return AuthenticateResult.NoResult();
var configured = configuration[ConfigurationKey];
if (string.IsNullOrWhiteSpace(configured))
{
Logger.LogWarning("A service api key was presented but no key is configured");
return AuthenticateResult.Fail("Service api key authentication is not configured.");
}
if (!MatchesConfiguredKey(presented.ToString(), configured))
{
Logger.LogWarning("A service api key was presented that does not match the configured key");
return AuthenticateResult.Fail("The service api key is not valid.");
}
var user = await db.Users.AsNoTracking().FirstOrDefaultAsync(u => u.Id == ServiceUser.Id, Context.RequestAborted);
if (user is null)
{
Logger.LogWarning("The service api key matched but the service user {UserId} is missing", ServiceUser.Id);
return AuthenticateResult.Fail("The service user does not exist.");
}
var identity = new ClaimsIdentity(
[
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Name, user.DisplayName),
new Claim(ClaimTypes.Role, user.GlobalRole.ToString())
], SchemeName);
return AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(identity), SchemeName));
}
private static bool MatchesConfiguredKey(string presented, string configured) =>
CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(presented), Encoding.UTF8.GetBytes(configured));
}
+46
View File
@@ -0,0 +1,46 @@
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Data;
namespace Novelly.Api.Users;
public static class ServiceUser
{
public static readonly Guid Id = new("9f1d6f2c-6d1b-4d3e-9a54-0f2b6f8a7c11");
public const string Email = "service@novelly.local";
public const string DisplayName = "Novelly Service";
public static async Task<NovellyUser?> EnsureSeededAsync(INovelDbContext db, string? serviceApiKey, ILogger logger, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(serviceApiKey))
{
logger.LogInformation("No service api key configured; the service user {UserId} was not seeded", Id);
return null;
}
var existing = await db.Users.FirstOrDefaultAsync(u => u.Id == Id, ct);
if (existing is not null)
return existing;
var user = new NovellyUser
{
Id = Id,
UserName = Email,
NormalizedUserName = Email.ToUpperInvariant(),
Email = Email,
NormalizedEmail = Email.ToUpperInvariant(),
EmailConfirmed = true,
DisplayName = DisplayName,
GlobalRole = GlobalRole.Admin,
SecurityStamp = Guid.NewGuid().ToString("N"),
ConcurrencyStamp = Guid.NewGuid().ToString("N")
};
db.Users.Add(user);
await db.SaveChangesAsync(ct);
logger.LogInformation("Seeded the service user {UserId} with global role {GlobalRole}", user.Id, user.GlobalRole);
return user;
}
}
+110
View File
@@ -0,0 +1,110 @@
using System.Security.Claims;
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 UserAccountService(
UserManager<NovellyUser> userManager,
SignInManager<NovellyUser> signInManager,
INovelDbContext db,
ILogger<UserAccountService> logger,
IModelValidator<RegisterRequest> registerValidator,
IModelValidator<LoginRequest> loginValidator)
{
public async Task<NovellyUser> RegisterAsync(RegisterRequest request, CancellationToken ct = default)
{
Guard.Null(request, nameof(request));
registerValidator.Validate(request).ThrowIfInvalid(logger);
var isFirstAccount = !userManager.Users.Any(u => u.Id != ServiceUser.Id);
logger.LogInformation("Registering account, first account: {IsFirstAccount}", isFirstAccount);
var user = new NovellyUser
{
UserName = request.Email,
Email = request.Email,
DisplayName = request.DisplayName,
GlobalRole = isFirstAccount ? GlobalRole.Admin : GlobalRole.Reviewer
};
var created = await userManager.CreateAsync(user, request.Password);
if (!created.Succeeded)
{
var errors = string.Join("; ", created.Errors.Select(e => e.Description));
logger.LogWarning("Registration rejected: {Errors}", errors);
throw new ArgumentException(errors);
}
logger.LogInformation("Registered account {UserId} with role {GlobalRole}", user.Id, user.GlobalRole);
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 signInManager.SignInAsync(user, isPersistent: true);
return user;
}
public async Task<NovellyUser?> LoginAsync(LoginRequest request, CancellationToken ct = default)
{
Guard.Null(request, nameof(request));
loginValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation("Signing in");
var user = await userManager.FindByEmailAsync(request.Email);
if (user is null)
{
logger.LogWarning("Sign-in rejected, no account for the given email");
return null;
}
var result = await signInManager.PasswordSignInAsync(user, request.Password, isPersistent: true, lockoutOnFailure: false);
if (!result.Succeeded)
{
logger.LogWarning("Sign-in rejected for {UserId}", user.Id);
return null;
}
logger.LogInformation("Signed in {UserId}", user.Id);
return user;
}
public Task LogoutAsync()
{
logger.LogInformation("Signing out");
return signInManager.SignOutAsync();
}
public Task<NovellyUser?> GetCurrentUserAsync(ClaimsPrincipal principal) => userManager.GetUserAsync(principal);
public Task<List<NovellyUser>> ListAsync(CancellationToken ct = default)
{
logger.LogInformation("Listing accounts");
return db.Users.OrderBy(u => u.DisplayName).ToListAsync(ct);
}
public async Task<NovellyUser?> SetGlobalRoleAsync(Guid userId, GlobalRole globalRole, CancellationToken ct = default)
{
Guard.Default(userId, nameof(userId));
logger.LogInformation("Setting global role for account {UserId} to {GlobalRole}", userId, globalRole);
var user = await userManager.FindByIdAsync(userId.ToString());
if (user is null)
{
logger.LogWarning("Account {UserId} not found", userId);
return null;
}
user.GlobalRole = globalRole;
await userManager.UpdateAsync(user);
return user;
}
}
+47
View File
@@ -0,0 +1,47 @@
using Novelly.Api.Common.Validation;
namespace Novelly.Api.Users;
public record RegisterRequest(string Email, string Password, string DisplayName);
public record LoginRequest(string Email, string Password);
public record UserResponse(Guid Id, string Email, string DisplayName, GlobalRole GlobalRole);
public record SetGlobalRoleRequest(GlobalRole GlobalRole);
public class RegisterRequestValidator : IModelValidator<RegisterRequest>
{
public ValidationResult Validate(RegisterRequest model)
{
var result = new ValidationResult();
result.AddRequiredTextErrors("Email", "Email", model.Email, 256);
result.AddRequiredTextErrors("DisplayName", "Display name", model.DisplayName, 200);
if (string.IsNullOrEmpty(model.Password) || model.Password.Length < 8)
result.AddError("Password", "'Password' must be at least 8 characters.");
return result;
}
}
public class LoginRequestValidator : IModelValidator<LoginRequest>
{
public ValidationResult Validate(LoginRequest model)
{
var result = new ValidationResult();
result.AddRequiredTextErrors("Email", "Email", model.Email, 256);
if (string.IsNullOrEmpty(model.Password))
result.AddError("Password", "'Password' must not be empty.");
return result;
}
}
public static class UserMapping
{
public static UserResponse ToResponse(this NovellyUser u) => new(u.Id, u.Email ?? string.Empty, u.DisplayName, u.GlobalRole);
}
+57
View File
@@ -0,0 +1,57 @@
using System.Security.Claims;
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
namespace Novelly.Api.Users;
public static class UserEndpoints
{
public static IEndpointRouteBuilder MapUserEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/auth").WithTags("Auth")
.AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>();
group.MapPost("/register", async (RegisterRequest request, UserAccountService service, CancellationToken ct) =>
Results.Ok((await service.RegisterAsync(request, ct)).ToResponse()))
.AllowAnonymous()
.WithSummary("Create an account. The first account created becomes an admin.");
group.MapPost("/login", async (LoginRequest request, UserAccountService service, CancellationToken ct) =>
{
var user = await service.LoginAsync(request, ct);
return user is null ? Results.Unauthorized() : Results.Ok(user.ToResponse());
})
.AllowAnonymous()
.WithSummary("Sign in.");
group.MapPost("/logout", async (UserAccountService service) =>
{
await service.LogoutAsync();
return Results.NoContent();
})
.WithSummary("Sign out.");
group.MapGet("/me", async (ClaimsPrincipal principal, UserAccountService service) =>
{
var user = await service.GetCurrentUserAsync(principal);
return user is null ? Results.Unauthorized() : Results.Ok(user.ToResponse());
})
.WithSummary("Read the signed-in account.");
var admin = app.MapGroup("/api/users").WithTags("Auth")
.AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>()
.RequireAuthorization(policy => policy.RequireRole(nameof(GlobalRole.Admin)));
admin.MapGet("/", async (UserAccountService service, CancellationToken ct) =>
Results.Ok((await service.ListAsync(ct)).Select(u => u.ToResponse())))
.WithSummary("List every account. Admin only.");
admin.MapPatch("/{id:guid}/role", async (Guid id, SetGlobalRoleRequest request, UserAccountService service, CancellationToken ct) =>
(await service.SetGlobalRoleAsync(id, request.GlobalRole, ct))?.ToResponse().ToApiResult())
.WithSummary("Change an account's global role. Admin only.");
return app;
}
}
+3
View File
@@ -21,6 +21,9 @@
"ConnectionStrings": {
"Novel": "Data Source=novel.db"
},
"Auth": {
"ServiceApiKey": ""
},
"Cors": {
"Origins": [ "http://localhost:5173" ]
},
-3
View File
@@ -1,8 +1,5 @@
var builder = DistributedApplication.CreateBuilder(args);
// Port 5080 is pinned to match src/Novelly.Web's Vite proxy default and the curl-based
// smoke checks in CLAUDE.md, so the API sits at the same address whether it is started
// on its own with `dotnet run` or through this AppHost.
var api = builder.AddProject<Projects.Novelly_Api>("api").WithHttpEndpoint(port: 5080, name: "http");
builder.AddViteApp("web", "../Novelly.Web", "dev")
+11 -18
View File
@@ -1,16 +1,12 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Protocol;
namespace Novelly.Mcp;
/// <summary>
/// Thin wrapper over the Novelly REST API. The MCP server deliberately owns no
/// domain logic of its own — it is a second front end onto the same API the web client
/// uses, so an edit made from Claude Code and one made in the browser are the same edit.
/// </summary>
public class NovelApiClient(HttpClient http)
public class NovelApiClient(HttpClient http, ILogger<NovelApiClient> logger)
{
private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
{
@@ -33,11 +29,6 @@ public class NovelApiClient(HttpClient http)
public Task<CallToolResult> DeleteAsync(string path, CancellationToken ct = default) => SendAsync(new HttpRequestMessage(HttpMethod.Delete, path), ct);
/// <summary>
/// Sends the request and shapes the outcome as a tool result. Failures come back as
/// `isError` results carrying the API's own message, rather than as exceptions the
/// SDK would flatten into "an error occurred" — the model can act on the former.
/// </summary>
private async Task<CallToolResult> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
HttpResponseMessage response;
@@ -47,8 +38,7 @@ public class NovelApiClient(HttpClient http)
}
catch (HttpRequestException ex)
{
// The API not being up is the most common failure here, and a bare connection
// exception tells the model nothing actionable.
logger.LogError(ex, "Could not reach the Novelly API at {BaseAddress}", http.BaseAddress);
return Error($"Could not reach the Novelly API at {http.BaseAddress}. Is it running? ({ex.Message})");
}
@@ -62,6 +52,8 @@ public class NovelApiClient(HttpClient http)
var detail = TryReadProblemDetail(body) ?? body;
return Error(response.StatusCode switch
{
HttpStatusCode.Unauthorized => $"Not permitted: the Novelly API rejected the service api key. Set NOVELLY_API_KEY to match the API's Auth:ServiceApiKey. ({detail})",
HttpStatusCode.Forbidden => $"Not permitted: {detail}",
HttpStatusCode.NotFound => $"Not found: {detail}",
HttpStatusCode.BadRequest => $"Rejected: {detail}",
_ => $"API returned {(int)response.StatusCode}: {detail}"
@@ -74,28 +66,29 @@ public class NovelApiClient(HttpClient http)
private static CallToolResult Error(string message) =>
new() { Content = [new TextContentBlock { Text = message }], IsError = true };
/// <summary>Reformats the API's compact JSON so tool output reads well in a transcript.</summary>
private static string Prettify(string json)
private string Prettify(string json)
{
try
{
return JsonSerializer.Serialize(JsonSerializer.Deserialize<JsonElement>(json), Options);
}
catch (JsonException)
catch (JsonException ex)
{
logger.LogWarning(ex, "Response body was not valid JSON; returning it unformatted");
return json;
}
}
private static string? TryReadProblemDetail(string body)
private string? TryReadProblemDetail(string body)
{
try
{
var problem = JsonSerializer.Deserialize<JsonElement>(body);
return problem.TryGetProperty("detail", out var detail) ? detail.GetString() : null;
}
catch (JsonException)
catch (JsonException ex)
{
logger.LogWarning(ex, "Error response body was not valid JSON problem details");
return null;
}
}
+6 -2
View File
@@ -5,18 +5,22 @@ using Novelly.Mcp;
var builder = Host.CreateApplicationBuilder(args);
// stdout is the MCP transport. Anything written there that is not a JSON-RPC frame
// corrupts the stream, so every log line goes to stderr instead.
builder.Logging.ClearProviders();
builder.Logging.AddConsole(options => options.LogToStandardErrorThreshold = LogLevel.Trace);
builder.Logging.SetMinimumLevel(LogLevel.Warning);
var apiBaseUrl = builder.Configuration["NOVELLY_API_URL"] ?? "http://localhost:5080";
var apiKey = builder.Configuration["NOVELLY_API_KEY"];
builder.Services.AddHttpClient<NovelApiClient>(client =>
{
client.BaseAddress = new Uri(apiBaseUrl);
client.Timeout = TimeSpan.FromSeconds(30);
if (!string.IsNullOrWhiteSpace(apiKey))
{
client.DefaultRequestHeaders.Add("X-Novelly-Api-Key", apiKey);
}
});
builder.Services
+11
View File
@@ -0,0 +1,11 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.27-alpine AS runtime
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Novel Software</title>
<title>Novelly</title>
</head>
<body>
<div id="root"></div>
+14
View File
@@ -0,0 +1,14 @@
server {
listen 80;
location / {
root /usr/share/nginx/html;
try_files $uri /index.html;
}
location /api/ {
proxy_pass http://api:8080/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
+34 -17
View File
@@ -1,4 +1,4 @@
import { Route, Routes } from 'react-router-dom'
import { Navigate, Outlet, Route, Routes } from 'react-router-dom'
import ProjectsPage from './pages/ProjectsPage'
import ProjectLayout from './pages/ProjectLayout'
import DashboardPage from './pages/DashboardPage'
@@ -8,26 +8,43 @@ import ChaptersPage from './pages/ChaptersPage'
import ChapterPage from './pages/ChapterPage'
import AgentPage from './pages/AgentPage'
import SettingsPage from './pages/SettingsPage'
import LoginPage from './pages/LoginPage'
import { AuthProvider, useAuth } from './auth/AuthContext'
import { Spinner } from './components/ui'
import { HotkeysProvider } from './keyboard/HotkeysContext'
import { HelpOverlay } from './keyboard/HelpOverlay'
function RequireAuth() {
const { user, isPending } = useAuth()
if (isPending) return <Spinner label="Checking your session" />
if (!user) return <Navigate to="/login" replace />
return <Outlet />
}
export default function App() {
return (
<HotkeysProvider>
<HelpOverlay />
<Routes>
<Route path="/" element={<ProjectsPage />} />
<Route path="/projects/:projectId" element={<ProjectLayout />}>
<Route index element={<DashboardPage />} />
<Route path="characters" element={<CharactersPage />} />
<Route path="chapters" element={<ChaptersPage />} />
<Route path="chapters/:chapterId" element={<ChapterPage />} />
<Route path="tags" element={<TagsPage />} />
<Route path="agent" element={<AgentPage />} />
<Route path="settings" element={<SettingsPage />} />
</Route>
<Route path="*" element={<ProjectsPage />} />
</Routes>
</HotkeysProvider>
<AuthProvider>
<HotkeysProvider>
<HelpOverlay />
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route element={<RequireAuth />}>
<Route path="/" element={<ProjectsPage />} />
<Route path="/projects/:projectId" element={<ProjectLayout />}>
<Route index element={<DashboardPage />} />
<Route path="characters" element={<CharactersPage />} />
<Route path="chapters" element={<ChaptersPage />} />
<Route path="chapters/:chapterId" element={<ChapterPage />} />
<Route path="tags" element={<TagsPage />} />
<Route path="agent" element={<AgentPage />} />
<Route path="settings" element={<SettingsPage />} />
</Route>
<Route path="*" element={<ProjectsPage />} />
</Route>
</Routes>
</HotkeysProvider>
</AuthProvider>
)
}
+3 -8
View File
@@ -1,6 +1,5 @@
const BASE = import.meta.env.VITE_API_BASE ?? ''
/** An API error carrying the ProblemDetails message so the UI can show something useful. */
export class ApiError extends Error {
readonly status: number
@@ -14,6 +13,7 @@ export class ApiError extends Error {
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${BASE}${path}`, {
...init,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...init?.headers,
@@ -21,13 +21,8 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
})
if (!response.ok) {
let detail = response.statusText
try {
const problem = await response.json()
detail = problem.detail ?? problem.title ?? detail
} catch {
// Non-JSON error body — the status text is the best we have.
}
const problem = await response.json().catch(() => null)
const detail = problem?.detail ?? problem?.title ?? response.statusText
throw new ApiError(detail, response.status)
}
+70 -1
View File
@@ -1,5 +1,5 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { api } from './client'
import { api, ApiError } from './client'
import type {
AgentTurn,
ArcStage,
@@ -16,12 +16,17 @@ import type {
ImportJobStatus,
OpenQuestion,
Project,
ProjectMember,
ProjectRole,
ProjectSummary,
TagReferences,
TagSummary,
User,
} from './types'
export const keys = {
me: ['me'] as const,
members: (projectId: string) => ['projects', projectId, 'members'] as const,
projects: ['projects'] as const,
genres: ['genres'] as const,
project: (id: string) => ['projects', id] as const,
@@ -37,6 +42,70 @@ export const keys = {
importJob: (id: string) => ['imports', id] as const,
}
export const useMe = () =>
useQuery({
queryKey: keys.me,
queryFn: () =>
api.get<User>('/api/auth/me').catch((error) => {
if (error instanceof ApiError && error.status === 401) return null
throw error
}),
retry: false,
staleTime: Infinity,
})
export function useRegister() {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: { email: string; password: string; displayName: string }) =>
api.post<User>('/api/auth/register', body),
onSuccess: (user) => qc.setQueryData(keys.me, user),
})
}
export function useLogin() {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: { email: string; password: string }) => api.post<User>('/api/auth/login', body),
onSuccess: (user) => qc.setQueryData(keys.me, user),
})
}
export function useLogout() {
const qc = useQueryClient()
return useMutation({
mutationFn: () => api.post<void>('/api/auth/logout'),
onSuccess: () => {
qc.setQueryData(keys.me, null)
qc.removeQueries({ predicate: (query) => query.queryKey[0] !== keys.me[0] })
},
})
}
export const useProjectMembers = (projectId: string) =>
useQuery({
queryKey: keys.members(projectId),
queryFn: () => api.get<ProjectMember[]>(`/api/projects/${projectId}/members`),
retry: false,
})
export function useGrantAccess(projectId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: { email: string; projectRole: ProjectRole }) =>
api.post<ProjectMember>(`/api/projects/${projectId}/members`, body),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.members(projectId) }),
})
}
export function useRevokeAccess(projectId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (userId: string) => api.delete(`/api/projects/${projectId}/members/${userId}`),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.members(projectId) }),
})
}
export const useProjects = () =>
useQuery({ queryKey: keys.projects, queryFn: () => api.get<ProjectSummary[]>('/api/projects') })
+23
View File
@@ -32,6 +32,29 @@ export type ProjectPhase = 'Brainstorming' | 'Outlining' | 'Writing' | 'Editing'
export const projectPhases: ProjectPhase[] = ['Brainstorming', 'Outlining', 'Writing', 'Editing', 'Complete']
export type GlobalRole = 'Admin' | 'Writer' | 'Editor' | 'Reviewer'
export const globalRoles: GlobalRole[] = ['Admin', 'Writer', 'Editor', 'Reviewer']
export type ProjectRole = 'Writer' | 'Editor' | 'Reviewer'
export const projectRoles: ProjectRole[] = ['Writer', 'Editor', 'Reviewer']
export interface User {
id: string
email: string
displayName: string
globalRole: GlobalRole
}
export interface ProjectMember {
userId: string
email: string
displayName: string
projectRole: ProjectRole
grantedAt: string
}
export interface Genre {
id: string
name: string
+32
View File
@@ -0,0 +1,32 @@
import { createContext, useContext, useMemo, type ReactNode } from 'react'
import { useMe } from '../api/hooks'
import type { User } from '../api/types'
export type AuthPermission = 'CreateNovel'
interface AuthValue {
user: User | null
isPending: boolean
can: (permission: AuthPermission) => boolean
}
const AuthContext = createContext<AuthValue>({ user: null, isPending: true, can: () => false })
export function AuthProvider({ children }: { children: ReactNode }) {
const { data, isPending } = useMe()
const user = data ?? null
const value = useMemo<AuthValue>(
() => ({
user,
isPending,
can: (permission) =>
permission === 'CreateNovel' && (user?.globalRole === 'Admin' || user?.globalRole === 'Writer'),
}),
[user, isPending],
)
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}
export const useAuth = () => useContext(AuthContext)
@@ -10,10 +10,6 @@ import {
import type { ArcStage, Character } from '../api/types'
import { AutoField, ErrorNote } from './ui'
/**
* A main character's arc: a flat ordered list of the changes they go through, the same
* shape as a chapter's beat table. Each stage can be pinned to the chapter it lands in.
*/
export function CharacterArc({
projectId,
character,
@@ -2,11 +2,6 @@ import { Link } from 'react-router-dom'
import { useCharacterBeats } from '../api/hooks'
import { ErrorNote, Spinner } from './ui'
/**
* Every beat this character appears in, in manuscript order. This is the dossier's
* reality check: what they actually do on the page, as opposed to what the sheet claims
* about them. Each row links into the beat's chapter outline.
*/
export function CharacterBeats({
projectId,
characterId,
@@ -4,14 +4,6 @@ import { useImportJob, useInspectImport, useStartImport } from '../api/hooks'
import type { ImportInspection, ImportJob } from '../api/types'
import { ErrorNote, Modal, Spinner } from './ui'
/**
* Kicks off (or resumes) an outline import against an absolute folder path. The app runs
* locally with the API and browser on the same machine, so a pasted path is meaningful —
* there's no browser folder picker that can hand back one instead.
*
* State machine: type a path → Check (inspects the folder without starting anything) →
* Start/Resume/Delete-and-reimport → poll until the background job finishes.
*/
export function ImportDialog({
onClose,
onImported,
@@ -31,8 +23,6 @@ export function ImportDialog({
useEffect(() => {
if (job.data?.status !== 'Completed') return
// The import writes project data through the same services the UI uses to edit it —
// everything on screen may be stale once it finishes.
qc.invalidateQueries()
if (job.data.projectId) onImported?.(job.data.projectId)
}, [job.data?.status, job.data?.projectId, qc, onImported])
@@ -9,11 +9,6 @@ import {
import type { OpenQuestion } from '../api/types'
import { ErrorNote, Spinner } from './ui'
/**
* The list of decisions still outstanding. The same section serves a chapter outline and
* a character page — `scope` decides both what it shows and what a new question is
* attached to, so raising one from the outline lands on that chapter without asking.
*/
export function OpenQuestions({
projectId,
scope,
@@ -148,8 +143,6 @@ function QuestionRow({
)
}
// Only show an association the page is not already scoped to — on a chapter outline,
// "Landfall" on every row is noise.
const showsChapter = question.chapterId && !scope.chapterId
const showsCharacter = question.characterName && !scope.characterId
@@ -24,11 +24,6 @@ export function TagChip({ tag, onRemove }: { tag: Tag; onRemove?: () => void })
)
}
/**
* Shows a set of tags and lets you add or remove them by name. The API creates unknown
* tags on the fly, so typing a new one is a single action rather than "create the tag,
* then apply it".
*/
export function TagEditor({
tags,
suggestions = [],
@@ -46,7 +41,6 @@ export function TagEditor({
const add = () => {
const name = draft.trim()
if (!name) return
// Case-insensitive, matching how the API resolves tag names.
if (!tags.some((t) => t.name.toLowerCase() === name.toLowerCase())) {
onChange([...tags.map((t) => t.name), name])
}
-6
View File
@@ -55,10 +55,6 @@ export function StatusBadge({ status }: { status: DraftStatus }) {
)
}
/**
* A field that saves when it loses focus. Writing tools live or die on not making the
* user hunt for a save button, so every editable field here commits on blur.
*/
export function AutoField({
label,
value,
@@ -84,8 +80,6 @@ export function AutoField({
const committed = useRef(value ?? '')
const suggestionsId = useId()
// Adopt changes that arrive from elsewhere (the agent, another tab) unless the user
// is mid-edit, which would yank text out from under them.
useEffect(() => {
const incoming = value ?? ''
if (incoming !== committed.current) {
@@ -15,8 +15,6 @@ interface HotkeysActions {
unregister: (id: string) => void
}
// Split so registering a hotkey (stable actions) never invalidates every other
// hotkey's effect just because the entries list (read only by the help sidebar) changed.
const HotkeysActionsContext = createContext<HotkeysActions | null>(null)
const HotkeysEntriesContext = createContext<HotkeyEntry[]>([])
@@ -114,12 +112,6 @@ export function HotkeysProvider({ children }: { children: ReactNode }) {
)
}
/** Registers a keyboard shortcut and (while mounted) lists it in the help sidebar.
*
* `keys` is either a single token ("n", "?", "Escape", "mod+Enter") or a two-key
* chord ("g d"). Chords never fire while a text field is focused; single keys are
* ignored while typing unless `allowInInputs` is set.
*/
export function useHotkey(
keys: string,
description: string,
@@ -238,9 +238,6 @@ function CharacterSheet({ projectId, character }: { projectId: string; character
)}
</div>
{/* The arc is what a main character is for. Supporting characters keep the section —
hidden only when there is nothing in it — so promoting someone does not surprise
them with work they thought they had lost. */}
{(character.importance === 'Main' || character.arcStages.length > 0) && (
<CharacterArc projectId={projectId} character={character} />
)}
@@ -19,7 +19,6 @@ export default function DashboardPage() {
)
}
/** The only thing the writer needs before there's a shape to the book: a place to dump notes. */
function BrainstormingDashboard({ project }: { project: Project }) {
const update = useUpdateProject(project.id)
@@ -42,7 +41,6 @@ function BrainstormingDashboard({ project }: { project: Project }) {
)
}
/** Chapter outlines and character development — where most of the outlining phase happens. */
function OutliningDashboard({ projectId }: { projectId: string }) {
const { data: characters, isPending: charactersPending, error: charactersError } = useCharacters(projectId)
const { data: chapters, isPending: chaptersPending, error: chaptersError } = useChapters(projectId)
+103
View File
@@ -0,0 +1,103 @@
import { useState } from 'react'
import { Navigate, useNavigate } from 'react-router-dom'
import { useLogin, useRegister } from '../api/hooks'
import { useAuth } from '../auth/AuthContext'
import { ErrorNote, Spinner } from '../components/ui'
export default function LoginPage() {
const { user, isPending } = useAuth()
const navigate = useNavigate()
const login = useLogin()
const register = useRegister()
const [registering, setRegistering] = useState(false)
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [displayName, setDisplayName] = useState('')
if (isPending) return <Spinner label="Checking your session" />
if (user) return <Navigate to="/" replace />
const active = registering ? register : login
const canSubmit = email.trim() && password && (!registering || displayName.trim())
const submit = (e: React.FormEvent) => {
e.preventDefault()
if (!canSubmit) return
const onSuccess = () => navigate('/')
if (registering) {
register.mutate({ email: email.trim(), password, displayName: displayName.trim() }, { onSuccess })
return
}
login.mutate({ email: email.trim(), password }, { onSuccess })
}
return (
<div className="mx-auto max-w-md px-6 py-16">
<header className="mb-6">
<h1 className="text-3xl font-semibold tracking-tight">Novelly</h1>
<p className="mt-1 text-sm muted">
{registering
? 'Create an account to get started.'
: 'Sign in to your outlines, dossiers and drafts.'}
</p>
</header>
<form onSubmit={submit} className="card grid gap-3 p-5">
{registering && (
<label className="block">
<span className="label">Display name</span>
<input
className="input"
autoFocus
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="How your name appears on a novel"
/>
</label>
)}
<label className="block">
<span className="label">Email</span>
<input
className="input"
type="email"
autoComplete="username"
autoFocus={!registering}
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</label>
<label className="block">
<span className="label">Password</span>
<input
className="input"
type="password"
autoComplete={registering ? 'new-password' : 'current-password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</label>
{registering && (
<p className="text-sm muted">
New accounts start as reviewers, which is read-only. Ask an admin to promote you to writer
to create novels of your own. The very first account on a fresh instance becomes the admin.
</p>
)}
{active.error && <ErrorNote error={active.error} />}
<button type="submit" className="btn btn-primary mt-1" disabled={!canSubmit || active.isPending}>
{active.isPending ? 'Working…' : registering ? 'Create account' : 'Sign in'}
</button>
<button
type="button"
className="text-sm muted hover:underline"
onClick={() => setRegistering(!registering)}
>
{registering ? 'Already have an account? Sign in' : 'No account yet? Create one'}
</button>
</form>
</div>
)
}
+33 -15
View File
@@ -1,6 +1,7 @@
import { Outlet, useParams, Link, NavLink, useNavigate } from 'react-router-dom'
import { useProject, useUpdateProject } from '../api/hooks'
import { useLogout, useProject, useUpdateProject } from '../api/hooks'
import { projectPhases } from '../api/types'
import { useAuth } from '../auth/AuthContext'
import { ErrorNote, Spinner } from '../components/ui'
import { useHotkey } from '../keyboard/HotkeysContext'
@@ -18,6 +19,8 @@ export default function ProjectLayout() {
const navigate = useNavigate()
const { data: project, isPending, error } = useProject(projectId)
const update = useUpdateProject(projectId)
const { user } = useAuth()
const logout = useLogout()
const goTo = (path: string) => navigate(path ? `/projects/${projectId}/${path}` : `/projects/${projectId}`)
@@ -38,20 +41,35 @@ export default function ProjectLayout() {
<Link to={`/projects/${projectId}`} className="truncate text-base font-semibold hover:underline">
{project?.title ?? '…'}
</Link>
{project && (
<select
className="input ml-auto w-auto"
value={project.phase}
onChange={(e) => update.mutate({ phase: e.target.value as (typeof projectPhases)[number] })}
aria-label="Novel phase"
>
{projectPhases.map((phase) => (
<option key={phase} value={phase}>
{phase}
</option>
))}
</select>
)}
<div className="ml-auto flex items-center gap-3">
{project && (
<select
className="input w-auto"
value={project.phase}
onChange={(e) => update.mutate({ phase: e.target.value as (typeof projectPhases)[number] })}
aria-label="Novel phase"
>
{projectPhases.map((phase) => (
<option key={phase} value={phase}>
{phase}
</option>
))}
</select>
)}
{user && (
<>
<span className="truncate text-sm muted" title={user.email}>
{user.displayName} · {user.globalRole}
</span>
<button
className="btn"
onClick={() => logout.mutate(undefined, { onSuccess: () => navigate('/login') })}
>
Sign out
</button>
</>
)}
</div>
</div>
<nav className="mx-auto flex max-w-[100rem] gap-1 px-6 pb-2 text-sm">
{sections.map(({ to, label, end }) => (
+47 -12
View File
@@ -1,18 +1,22 @@
import { useState } from 'react'
import { useId, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useCreateProject, useProjects } from '../api/hooks'
import { useCreateProject, useGenres, useLogout, useProjects } from '../api/hooks'
import { useAuth } from '../auth/AuthContext'
import { ImportDialog } from '../components/ImportDialog'
import { EmptyState, ErrorNote, Modal, Spinner } from '../components/ui'
import { useHotkey } from '../keyboard/HotkeysContext'
export default function ProjectsPage() {
const { data: projects, isPending, error } = useProjects()
const { user, can } = useAuth()
const logout = useLogout()
const [creating, setCreating] = useState(false)
const [importing, setImporting] = useState(false)
const navigate = useNavigate()
const canCreate = can('CreateNovel')
useHotkey('n', 'New novel', () => setCreating(true), { group: 'Novels' })
useHotkey('i', 'Import from outline', () => setImporting(true), { group: 'Novels' })
useHotkey('n', 'New novel', () => canCreate && setCreating(true), { group: 'Novels' })
useHotkey('i', 'Import from outline', () => canCreate && setImporting(true), { group: 'Novels' })
return (
<div className="mx-auto max-w-4xl px-6 py-12">
@@ -23,13 +27,30 @@ export default function ProjectsPage() {
Outlines, character dossiers, and a writing partner that knows the book.
</p>
</div>
<div className="flex gap-2">
<button className="btn" onClick={() => setImporting(true)}>
Import from outline
</button>
<button className="btn btn-primary" onClick={() => setCreating(true)}>
New novel
</button>
<div className="flex shrink-0 items-center gap-2 whitespace-nowrap">
{canCreate && (
<>
<button className="btn" onClick={() => setImporting(true)}>
Import from outline
</button>
<button className="btn btn-primary" onClick={() => setCreating(true)}>
New novel
</button>
</>
)}
{user && (
<>
<span className="ml-2 text-sm muted" title={user.email}>
{user.displayName} · {user.globalRole}
</span>
<button
className="btn"
onClick={() => logout.mutate(undefined, { onSuccess: () => navigate('/login') })}
>
Sign out
</button>
</>
)}
</div>
</header>
@@ -85,6 +106,8 @@ export default function ProjectsPage() {
function CreateProjectModal({ onClose }: { onClose: () => void }) {
const create = useCreateProject()
const { data: genres } = useGenres()
const genreListId = useId()
const [title, setTitle] = useState('')
const [author, setAuthor] = useState('')
const [genre, setGenre] = useState('')
@@ -124,7 +147,19 @@ function CreateProjectModal({ onClose }: { onClose: () => void }) {
</label>
<label className="block">
<span className="label">Genre</span>
<input className="input" value={genre} onChange={(e) => setGenre(e.target.value)} />
<input
className="input"
value={genre}
list={genres?.length ? genreListId : undefined}
onChange={(e) => setGenre(e.target.value)}
/>
{genres?.length ? (
<datalist id={genreListId}>
{genres.map((g) => (
<option key={g.id} value={g.name} />
))}
</datalist>
) : null}
</label>
</div>
<label className="block">
+105 -1
View File
@@ -5,11 +5,16 @@ import {
useCharacters,
useDeleteProject,
useGenres,
useGrantAccess,
useProject,
useProjectMembers,
useRevokeAccess,
useUpdateProject,
} from '../api/hooks'
import { ApiError } from '../api/client'
import { projectRoles, type ProjectMember, type ProjectRole } from '../api/types'
import { ImportDialog } from '../components/ImportDialog'
import { AutoField, ErrorNote, Spinner } from '../components/ui'
import { AutoField, ErrorNote, Select, Spinner } from '../components/ui'
import { ConfirmModal } from '../components/ConfirmModal'
export default function SettingsPage() {
@@ -156,6 +161,8 @@ export default function SettingsPage() {
</div>
</aside>
<ProjectPeople projectId={projectId} />
{importing && (
<ImportDialog
onClose={() => setImporting(false)}
@@ -174,3 +181,100 @@ export default function SettingsPage() {
</div>
)
}
function ProjectPeople({ projectId }: { projectId: string }) {
const { data: members, isPending, error } = useProjectMembers(projectId)
const grant = useGrantAccess(projectId)
const revoke = useRevokeAccess(projectId)
const [email, setEmail] = useState('')
const [projectRole, setProjectRole] = useState<ProjectRole>('Reviewer')
const [revoking, setRevoking] = useState<ProjectMember | null>(null)
if (isPending) return null
if (error instanceof ApiError && (error.status === 403 || error.status === 401)) return null
const submit = (e: React.FormEvent) => {
e.preventDefault()
if (!email.trim()) return
grant.mutate({ email: email.trim(), projectRole }, { onSuccess: () => setEmail('') })
}
return (
<section className="card p-5">
<h2 className="mb-1 text-sm font-semibold tracking-wide uppercase muted">People</h2>
<p className="mb-4 text-sm muted">
Writers can add and delete anything in this novel, editors can change what is already here, and
reviewers can only read.
</p>
{error && <ErrorNote error={error} />}
{members?.length === 0 && <p className="mb-4 text-sm muted">Nobody else has access yet.</p>}
{members && members.length > 0 && (
<ul className="mb-4 grid gap-2">
{members.map((member) => (
<li
key={member.userId}
className="flex items-center justify-between gap-4 border-b pb-2 last:border-b-0"
style={{ borderColor: 'var(--line)' }}
>
<div className="min-w-0">
<p className="truncate font-medium">{member.displayName}</p>
<p className="truncate text-xs muted">{member.email}</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<Select
value={member.projectRole}
options={projectRoles}
onChange={(next) => grant.mutate({ email: member.email, projectRole: next })}
/>
<button className="btn btn-danger" onClick={() => setRevoking(member)}>
Remove
</button>
</div>
</li>
))}
</ul>
)}
<form onSubmit={submit} className="flex items-end gap-2">
<label className="block flex-1">
<span className="label">Grant access by email</span>
<input
className="input"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="someone@example.com"
/>
</label>
<Select value={projectRole} options={projectRoles} onChange={setProjectRole} />
<button type="submit" className="btn btn-primary" disabled={!email.trim() || grant.isPending}>
{grant.isPending ? 'Granting' : 'Grant'}
</button>
</form>
{grant.error && (
<div className="mt-3">
<ErrorNote error={grant.error} />
</div>
)}
{revoke.error && (
<div className="mt-3">
<ErrorNote error={revoke.error} />
</div>
)}
{revoking && (
<ConfirmModal
title="Remove access"
message={`Remove ${revoking.displayName}'s access to this novel?`}
confirmLabel="Remove"
onConfirm={() => revoke.mutate(revoking.userId)}
onClose={() => setRevoking(null)}
/>
)}
</section>
)
}
-2
View File
@@ -6,8 +6,6 @@ export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
port: 5173,
// Proxy the API in dev so the browser sees a single origin and CORS never enters
// the picture during local development.
proxy: {
'/api': {
target: process.env.VITE_API_URL ?? 'http://localhost:5080',