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,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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1103
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1106
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
|
||||
|
||||
@@ -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