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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user