diff --git a/CLAUDE.md b/CLAUDE.md index a5d8886..0320cb5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,6 +58,7 @@ Serilog console via `AddSerilog` (not `UseSerilog` — keeps OTel provider for A - DTOs are records; entities are classes. DO NOT use Dto in names. - `PATCH` requests partial: null field = leave alone, empty string = clear. Keep new update endpoints consistent with `Patch.Apply`. - Enums cross wire as names, never ordinals +- All frontend components should have an id attribute that identifies them uniquely. ## Testing diff --git a/src/Novelly.Api/Agent/NovelAgentToolset.cs b/src/Novelly.Api/Agent/NovelAgentToolset.cs index 8aba0bd..0bb01e9 100644 --- a/src/Novelly.Api/Agent/NovelAgentToolset.cs +++ b/src/Novelly.Api/Agent/NovelAgentToolset.cs @@ -151,7 +151,8 @@ public class NovelAgentToolset( JsonInput.String(input, "arc_summary"), JsonInput.String(input, "voice"), JsonInput.String(input, "notes"), - JsonInput.Strings(input, "tags")), ct), c => c.ToResponse(), "Project", projectId)); + JsonInput.Strings(input, "tags"), + JsonInput.Strings(input, "aliases")), ct), c => c.ToResponse(), "Project", projectId)); yield return new AgentTool( "update_character", @@ -181,7 +182,42 @@ public class NovelAgentToolset( JsonInput.String(input, "arc_summary"), JsonInput.String(input, "voice"), JsonInput.String(input, "notes"), - JsonInput.Strings(input, "tags")), ct), c => c.ToResponse(), "Character", characterId); + JsonInput.Strings(input, "tags"), + JsonInput.Strings(input, "aliases")), ct), c => c.ToResponse(), "Character", characterId); + }); + + yield return new AgentTool( + "link_character_identity", + "Record that a character is really another character — e.g. one introduced under one name " + + "who is later revealed to be a character already in the project under another name. Both " + + "keep their own dossier and beats; the canonical identity is whichever character you link to.", + new JsonSchemaBuilder() + .Str("character_id", "Id of the character being revealed as someone else.", required: true) + .Str("same_character_as_id", "Id of the character this one really is.", required: true) + .Str("revealed_in_chapter_id", "Id of the chapter where the reveal happens, if any.") + .Str("note", "Context on the reveal, e.g. how and why the disguise held.") + .Build(), + async (_, input, ct) => + { + var characterId = JsonInput.RequiredGuid(input, "character_id"); + return await OrNotFound(characters.LinkIdentityAsync( + characterId, + new LinkCharacterIdentityRequest( + JsonInput.RequiredGuid(input, "same_character_as_id"), + JsonInput.Guid(input, "revealed_in_chapter_id"), + JsonInput.String(input, "note")), ct), c => c.ToResponse(), "Character", characterId); + }); + + yield return new AgentTool( + "unlink_character_identity", + "Remove a character's identity link, restoring it to its own separate identity.", + new JsonSchemaBuilder() + .Str("character_id", "Id of the character to unlink.", required: true) + .Build(), + async (_, input, ct) => + { + var characterId = JsonInput.RequiredGuid(input, "character_id"); + return await DeletedOrNotFound(characters.UnlinkIdentityAsync(characterId, ct), "Character", characterId); }); yield return new AgentTool( @@ -289,6 +325,27 @@ public class NovelAgentToolset( .Where(g => g != Guid.Empty)]), ct), list => list.Select(b => b.ToResponse()), "Chapter", chapterId); }); + yield return new AgentTool( + "move_beats", + "Move one or more beats from one chapter to another, appending them to the target " + + "chapter's end in the order given.", + new JsonSchemaBuilder() + .Str("chapter_id", "Id of the beats' current chapter.", required: true) + .Str("target_chapter_id", "Id of the chapter to move the beats into.", required: true) + .StringArray("beat_ids", "Ids of the beats to move.", required: true) + .Build(), + async (_, input, ct) => + { + var chapterId = JsonInput.RequiredGuid(input, "chapter_id"); + return await OrNotFound(beats.MoveAsync( + chapterId, + new MoveBeatsRequest( + JsonInput.RequiredGuid(input, "target_chapter_id"), + [.. (JsonInput.Strings(input, "beat_ids") ?? []) + .Select(id => Guid.TryParse(id, out var g) ? g : Guid.Empty) + .Where(g => g != Guid.Empty)]), ct), list => list.Select(b => b.ToResponse()), "Chapter", chapterId); + }); + yield return new AgentTool( "list_tags", "List the project's tags with how many characters, chapters and beats carry each. " @@ -596,7 +653,8 @@ public class NovelAgentToolset( .Str("arc_summary", "How they change over the course of the book.") .Str("voice", "Speech patterns and register that make their dialogue theirs.") .Str("notes", "Anything else worth recording.") - .StringArray("tags", "Tags for cross-referencing. Replaces the existing tags."); + .StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.") + .StringArray("aliases", "Other names this character is known by. Replaces the existing aliases."); } private static JsonSchemaBuilder BeatSchema() => diff --git a/src/Novelly.Api/Beats/BeatContracts.cs b/src/Novelly.Api/Beats/BeatContracts.cs index 2825374..d1cd444 100644 --- a/src/Novelly.Api/Beats/BeatContracts.cs +++ b/src/Novelly.Api/Beats/BeatContracts.cs @@ -119,6 +119,24 @@ public class AssignCharacterToBeatsRequestValidator : IModelValidator BeatIds); + +public class MoveBeatsRequestValidator : IModelValidator +{ + public ValidationResult Validate(MoveBeatsRequest model) + { + var result = new ValidationResult(); + + if (model.TargetChapterId == Guid.Empty) + result.AddError("TargetChapterId", "'Target Chapter Id' must not be empty."); + + if (model.BeatIds is null || model.BeatIds.Count == 0) + result.AddError("BeatIds", "'Beat Ids' must not be empty."); + + return result; + } +} + public static class BeatMapping { public static BeatResponse ToResponse(this Beat b) => new( diff --git a/src/Novelly.Api/Beats/BeatEndpoints.cs b/src/Novelly.Api/Beats/BeatEndpoints.cs index 5f8aac3..4528fd9 100644 --- a/src/Novelly.Api/Beats/BeatEndpoints.cs +++ b/src/Novelly.Api/Beats/BeatEndpoints.cs @@ -39,6 +39,11 @@ public static class BeatEndpoints (await service.AssignCharacterAsync(chapterId, request, ct))?.Select(b => b.ToResponse()).ToList().ToApiResult()) .WithSummary("Add a character to several beats at once, leaving each beat's existing characters alone."); + chapterScoped.MapPost("/move", async ( + Guid chapterId, MoveBeatsRequest request, BeatService service, CancellationToken ct) => + (await service.MoveAsync(chapterId, request, ct))?.Select(b => b.ToResponse()).ToList().ToApiResult()) + .WithSummary("Move one or more beats to another chapter, appending them to its end."); + app.MapGet("/api/characters/{characterId:guid}/beats", async ( Guid characterId, BeatService service, CancellationToken ct) => (await service.ListForCharacterAsync(characterId, ct))?.Select(b => b.ToCharacterBeatResponse()).ToList().ToApiResult()) diff --git a/src/Novelly.Api/Beats/BeatService.cs b/src/Novelly.Api/Beats/BeatService.cs index b34a89d..b86dff3 100644 --- a/src/Novelly.Api/Beats/BeatService.cs +++ b/src/Novelly.Api/Beats/BeatService.cs @@ -17,7 +17,8 @@ public class BeatService( IModelValidator createValidator, IModelValidator updateValidator, IModelValidator reorderValidator, - IModelValidator assignCharacterValidator) + IModelValidator assignCharacterValidator, + IModelValidator moveValidator) { public async Task> ListAsync(Guid chapterId, CancellationToken ct = default) { @@ -266,6 +267,62 @@ public class BeatService( return await ListAsync(chapterId, ct); } + public async Task?> MoveAsync( + Guid chapterId, MoveBeatsRequest request, CancellationToken ct = default) + { + Guard.Default(chapterId, nameof(chapterId)); + Guard.Null(request, nameof(request)); + moveValidator.Validate(request).ThrowIfInvalid(logger); + + logger.LogInformation( + "Moving {Count} beats from chapter {ChapterId} to chapter {TargetChapterId}", + request.BeatIds.Count, chapterId, request.TargetChapterId); + + var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == chapterId, ct); + if (chapter is null) + { + logger.LogWarning("Rejected beat move: chapter {ChapterId} not found", chapterId); + return null; + } + + var targetChapter = await db.Chapters.FirstOrDefaultAsync( + c => c.Id == request.TargetChapterId && c.ProjectId == chapter.ProjectId, ct); + if (targetChapter is null) + { + logger.LogWarning( + "Rejected beat move: target chapter {TargetChapterId} not found in project {ProjectId}", + request.TargetChapterId, chapter.ProjectId); + return null; + } + + await access.RequireAsync(chapter.ProjectId, ProjectPermission.Write, ct); + + var beats = await Query().Where(b => b.ChapterId == chapterId && request.BeatIds.Contains(b.Id)).ToListAsync(ct); + var missing = request.BeatIds.Where(id => beats.All(b => b.Id != id)).ToList(); + if (missing.Count > 0) + { + logger.LogWarning("Rejected beat move: chapter {ChapterId} referenced missing beat {BeatId}", chapterId, missing[0]); + return null; + } + + if (chapterId == request.TargetChapterId) + { + return beats; + } + + var nextSortOrder = await NextSortOrderAsync(request.TargetChapterId, ct); + foreach (var beatId in request.BeatIds) + { + var beat = beats.Single(b => b.Id == beatId); + beat.ChapterId = request.TargetChapterId; + beat.SortOrder = nextSortOrder++; + beat.UpdatedAt = DateTimeOffset.UtcNow; + } + + await db.SaveChangesAsync(ct); + return beats; + } + private async Task> ResolveCharactersAsync(Guid projectId, IReadOnlyList characterIds, CancellationToken ct) { logger.LogDebug("Resolving {Count} characters for project {ProjectId}", characterIds.Count, projectId); diff --git a/src/Novelly.Api/Characters/Character.cs b/src/Novelly.Api/Characters/Character.cs index 8605573..29bffbe 100644 --- a/src/Novelly.Api/Characters/Character.cs +++ b/src/Novelly.Api/Characters/Character.cs @@ -1,6 +1,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; using Novelly.Api.Beats; +using Novelly.Api.Chapters; using Novelly.Api.Projects; using Novelly.Api.Tags; @@ -38,6 +39,16 @@ public class Character public string? Notes { get; set; } + public List Aliases { get; set; } = []; + + public Guid? SameCharacterAsId { get; set; } + public Character? SameCharacterAs { get; set; } + public List OtherIdentities { get; set; } = []; + + public Guid? RevealedInChapterId { get; set; } + public Chapter? RevealedInChapter { get; set; } + public string? IdentityNote { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; @@ -72,12 +83,19 @@ public class CharacterEntityTypeConfiguration : IEntityTypeConfiguration c.Role).HasConversion().HasMaxLength(32); entity.Property(c => c.Importance).HasConversion().HasMaxLength(32); entity.HasIndex(c => c.ProjectId); + entity.HasIndex(c => c.SameCharacterAsId); 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); + + entity.HasOne(c => c.SameCharacterAs).WithMany(c => c.OtherIdentities) + .HasForeignKey(c => c.SameCharacterAsId).OnDelete(DeleteBehavior.SetNull); + + entity.HasOne(c => c.RevealedInChapter).WithMany() + .HasForeignKey(c => c.RevealedInChapterId).OnDelete(DeleteBehavior.SetNull); } } diff --git a/src/Novelly.Api/Characters/CharacterContracts.cs b/src/Novelly.Api/Characters/CharacterContracts.cs index 65a8a9e..9b7b1df 100644 --- a/src/Novelly.Api/Characters/CharacterContracts.cs +++ b/src/Novelly.Api/Characters/CharacterContracts.cs @@ -22,11 +22,20 @@ public record CharacterResponse( string? ArcSummary, string? Voice, string? Notes, + IReadOnlyList Aliases, + Guid? SameCharacterAsId, + string? SameCharacterAsName, + Guid? RevealedInChapterId, + int? RevealedInChapterNumber, + string? IdentityNote, + IReadOnlyList OtherIdentities, IReadOnlyList Relationships, IReadOnlyList Tags, IReadOnlyList ArcStages, DateTimeOffset UpdatedAt); +public record CharacterIdentityResponse(Guid Id, string Name); + public record RelationshipResponse( Guid Id, Guid RelatedCharacterId, @@ -51,7 +60,8 @@ public record CreateCharacterRequest( string? ArcSummary = null, string? Voice = null, string? Notes = null, - IReadOnlyList? Tags = null); + IReadOnlyList? Tags = null, + IReadOnlyList? Aliases = null); public class CreateCharacterRequestValidator : IModelValidator { @@ -63,7 +73,7 @@ public class CreateCharacterRequestValidator : IModelValidator? Tags = null); + IReadOnlyList? Tags = null, + IReadOnlyList? Aliases = null); public class UpdateCharacterRequestValidator : IModelValidator { @@ -98,7 +109,7 @@ public class UpdateCharacterRequestValidator : IModelValidator? tags, ValidationResult result) + string? notes, IReadOnlyList? tags, IReadOnlyList? aliases, ValidationResult result) { Cap(age, "Age", 100, result); Cap(pronouns, "Pronouns", 100, result); @@ -127,6 +138,18 @@ file static class CharacterValidation if (tags is not null && tags.Any(string.IsNullOrWhiteSpace)) result.AddError("Tags", "'Tags' must not contain blank entries."); + + if (aliases is not null) + { + if (aliases.Any(string.IsNullOrWhiteSpace)) + result.AddError("Aliases", "'Aliases' must not contain blank entries."); + + if (aliases.Any(a => a.Length > 200)) + result.AddError("Aliases", "'Aliases' entries must be 200 characters or fewer."); + + if (aliases.Count > 25) + result.AddError("Aliases", "'Aliases' must contain 25 entries or fewer."); + } } private static void Cap(string? value, string field, int max, ValidationResult result) @@ -157,6 +180,26 @@ public class CreateRelationshipRequestValidator : IModelValidator +{ + public ValidationResult Validate(LinkCharacterIdentityRequest model) + { + var result = new ValidationResult(); + + if (model.SameCharacterAsId == Guid.Empty) + result.AddError("SameCharacterAsId", "'Same Character As Id' must not be empty."); + + result.AddOptionalTextErrors("Note", "Note", model.Note, 2000); + + return result; + } +} + public record ArcStageResponse( Guid Id, Guid CharacterId, @@ -240,6 +283,13 @@ public static class CharacterMapping c.Id, c.ProjectId, c.Name, c.Role, c.Importance, c.Age, c.Pronouns, c.Occupation, c.Appearance, c.Personality, c.Backstory, c.Want, c.Need, c.InternalConflict, c.ExternalConflict, c.ArcSummary, c.Voice, c.Notes, + [.. c.Aliases], + c.SameCharacterAsId, + c.SameCharacterAs?.Name, + c.RevealedInChapterId, + c.RevealedInChapter?.Number, + c.IdentityNote, + [.. c.OtherIdentities.OrderBy(o => o.Name).Select(o => new CharacterIdentityResponse(o.Id, o.Name))], [.. c.Relationships.Select(r => new RelationshipResponse( r.Id, r.RelatedCharacterId, diff --git a/src/Novelly.Api/Characters/CharacterEndpoints.cs b/src/Novelly.Api/Characters/CharacterEndpoints.cs index e718f9f..82d0d70 100644 --- a/src/Novelly.Api/Characters/CharacterEndpoints.cs +++ b/src/Novelly.Api/Characters/CharacterEndpoints.cs @@ -56,6 +56,16 @@ public static class CharacterEndpoints await service.RemoveRelationshipAsync(relationshipId, ct) ? Results.NoContent() : Results.NotFound()) .WithSummary("Remove a relationship."); + characters.MapPut("/{id:guid}/identity", async ( + Guid id, LinkCharacterIdentityRequest request, CharacterService service, CancellationToken ct) => + (await service.LinkIdentityAsync(id, request, ct))?.ToResponse().ToApiResult()) + .WithSummary("Link this character as another identity of a character in the same project."); + + characters.MapDelete("/{id:guid}/identity", async ( + Guid id, CharacterService service, CancellationToken ct) => + await service.UnlinkIdentityAsync(id, ct) ? Results.NoContent() : Results.NotFound()) + .WithSummary("Remove this character's identity link."); + characters.MapGet("/{id:guid}/arc", async ( Guid id, CharacterArcService service, CancellationToken ct) => Results.Ok((await service.ListAsync(id, ct)).Select(s => s.ToResponse()))) diff --git a/src/Novelly.Api/Characters/CharacterService.cs b/src/Novelly.Api/Characters/CharacterService.cs index f971273..f01ff8d 100644 --- a/src/Novelly.Api/Characters/CharacterService.cs +++ b/src/Novelly.Api/Characters/CharacterService.cs @@ -14,7 +14,8 @@ public class CharacterService( ILogger logger, IModelValidator createValidator, IModelValidator updateValidator, - IModelValidator relationshipValidator) + IModelValidator relationshipValidator, + IModelValidator identityValidator) { public async Task> ListAsync(Guid projectId, CancellationToken ct = default) { @@ -97,6 +98,11 @@ public class CharacterService( character.Tags = await tags.ResolveAsync(projectId, names, ct); } + if (request.Aliases is { } aliases) + { + character.Aliases = [.. aliases]; + } + db.Characters.Add(character); await db.SaveChangesAsync(ct); @@ -142,6 +148,11 @@ public class CharacterService( character.Tags = await tags.ResolveAsync(character.ProjectId, names, ct); } + if (request.Aliases is { } aliases) + { + character.Aliases = [.. aliases]; + } + await db.SaveChangesAsync(ct); return (await FindAsync(id, ct))!; } @@ -229,13 +240,107 @@ public class CharacterService( return true; } + public async Task LinkIdentityAsync( + Guid characterId, LinkCharacterIdentityRequest request, CancellationToken ct = default) + { + Guard.Default(characterId, nameof(characterId)); + Guard.Null(request, nameof(request)); + identityValidator.Validate(request).ThrowIfInvalid(logger); + + logger.LogInformation( + "Linking character {CharacterId} to identity {SameCharacterAsId}", characterId, request.SameCharacterAsId); + + var character = await FindAsync(characterId, ct); + if (character is null) + { + return null; + } + + await access.RequireAsync(character.ProjectId, ProjectPermission.Write, ct); + + if (request.SameCharacterAsId == characterId) + { + logger.LogWarning("Rejected identity link: character {CharacterId} cannot be linked to itself", characterId); + throw new InvalidOperationException("A character cannot be linked to itself."); + } + + var target = await db.Characters + .Include(c => c.SameCharacterAs) + .FirstOrDefaultAsync(c => c.Id == request.SameCharacterAsId, ct); + if (target is null) + { + logger.LogWarning("Rejected identity link: target character {SameCharacterAsId} not found", request.SameCharacterAsId); + return null; + } + + if (target.ProjectId != character.ProjectId) + { + logger.LogWarning( + "Rejected identity link: character {CharacterId} and {SameCharacterAsId} belong to different projects", + characterId, request.SameCharacterAsId); + throw new InvalidOperationException("Characters must belong to the same project to be linked."); + } + + if (await db.Characters.AnyAsync(c => c.SameCharacterAsId == characterId, ct)) + { + logger.LogWarning("Rejected identity link: character {CharacterId} already has other identities pointing to it", characterId); + throw new InvalidOperationException("This character already has other identities linked to it and cannot itself be an alias."); + } + + var canonical = target.SameCharacterAsId ?? target.Id; + + if (request.RevealedInChapterId is { } chapterId) + { + var chapterInProject = await db.Chapters.AnyAsync(c => c.Id == chapterId && c.ProjectId == character.ProjectId, ct); + if (!chapterInProject) + { + logger.LogWarning("Rejected identity link: chapter {ChapterId} not in project {ProjectId}", chapterId, character.ProjectId); + throw new InvalidOperationException("The reveal chapter must belong to the same project."); + } + } + + character.SameCharacterAsId = canonical; + character.RevealedInChapterId = request.RevealedInChapterId; + character.IdentityNote = request.Note; + character.UpdatedAt = DateTimeOffset.UtcNow; + + await db.SaveChangesAsync(ct); + return (await FindAsync(characterId, ct))!; + } + + public async Task UnlinkIdentityAsync(Guid characterId, CancellationToken ct = default) + { + Guard.Default(characterId, nameof(characterId)); + + logger.LogInformation("Unlinking identity for character {CharacterId}", characterId); + + var character = await FindAsync(characterId, ct); + if (character is null) + { + return false; + } + + await access.RequireAsync(character.ProjectId, ProjectPermission.Write, ct); + + character.SameCharacterAsId = null; + character.RevealedInChapterId = null; + character.IdentityNote = null; + character.UpdatedAt = DateTimeOffset.UtcNow; + + await db.SaveChangesAsync(ct); + return true; + } + private IQueryable Query() => db.Characters .Include(c => c.Relationships) .ThenInclude(r => r.RelatedCharacter) .Include(c => c.Tags) .Include(c => c.ArcStages) - .ThenInclude(s => s.Chapter); + .ThenInclude(s => s.Chapter) + .Include(c => c.SameCharacterAs) + .Include(c => c.OtherIdentities) + .Include(c => c.RevealedInChapter); private async Task FindAsync(Guid id, CancellationToken ct) { diff --git a/src/Novelly.Api/Data/Migrations/20260817235523_AddCharacterAliasesAndIdentityLinks.Designer.cs b/src/Novelly.Api/Data/Migrations/20260817235523_AddCharacterAliasesAndIdentityLinks.Designer.cs new file mode 100644 index 0000000..06a012c --- /dev/null +++ b/src/Novelly.Api/Data/Migrations/20260817235523_AddCharacterAliasesAndIdentityLinks.Designer.cs @@ -0,0 +1,1139 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Novelly.Api.Data; + +#nullable disable + +namespace Novelly.Api.Data.Migrations +{ + [DbContext(typeof(NovelDbContext))] + [Migration("20260817235523_AddCharacterAliasesAndIdentityLinks")] + partial class AddCharacterAliasesAndIdentityLinks + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.10"); + + modelBuilder.Entity("BeatCharacter", b => + { + b.Property("BeatsId") + .HasColumnType("TEXT"); + + b.Property("CharactersId") + .HasColumnType("TEXT"); + + b.HasKey("BeatsId", "CharactersId"); + + b.HasIndex("CharactersId"); + + b.ToTable("BeatCharacters", (string)null); + }); + + modelBuilder.Entity("BeatTag", b => + { + b.Property("BeatsId") + .HasColumnType("TEXT"); + + b.Property("TagsId") + .HasColumnType("TEXT"); + + b.HasKey("BeatsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("BeatTags", (string)null); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("TEXT"); + + b.Property("TagsId") + .HasColumnType("TEXT"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTags", (string)null); + }); + + modelBuilder.Entity("CharacterTag", b => + { + b.Property("CharactersId") + .HasColumnType("TEXT"); + + b.Property("TagsId") + .HasColumnType("TEXT"); + + b.HasKey("CharactersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("CharacterTags", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.ToTable("Conversations"); + }); + + modelBuilder.Entity("Novelly.Api.Agent.AgentMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ConversationId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Sequence") + .HasColumnType("INTEGER"); + + b.Property("ToolCallsJson") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ConversationId", "Sequence") + .IsUnique(); + + b.ToTable("AgentMessages"); + }); + + modelBuilder.Entity("Novelly.Api.Beats.Beat", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.Property("WhatHappened") + .HasColumnType("TEXT"); + + b.Property("WhatsNext") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId", "SortOrder"); + + b.ToTable("Beats"); + }); + + modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("Prose") + .HasColumnType("TEXT"); + + b.Property("Setting") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("TargetWordCount") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId", "Number"); + + b.ToTable("Chapters"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.Character", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Age") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Aliases") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Appearance") + .HasColumnType("TEXT"); + + b.Property("ArcSummary") + .HasColumnType("TEXT"); + + b.Property("Backstory") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("ExternalConflict") + .HasColumnType("TEXT"); + + b.Property("IdentityNote") + .HasColumnType("TEXT"); + + b.Property("Importance") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("InternalConflict") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Need") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("Occupation") + .HasColumnType("TEXT"); + + b.Property("Personality") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("Pronouns") + .HasColumnType("TEXT"); + + b.Property("RevealedInChapterId") + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("SameCharacterAsId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.Property("Voice") + .HasColumnType("TEXT"); + + b.Property("Want") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.HasIndex("RevealedInChapterId"); + + b.HasIndex("SameCharacterAsId"); + + b.ToTable("Characters"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.CharacterArcStage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("TEXT"); + + b.Property("CharacterId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("CharacterId", "SortOrder"); + + b.ToTable("CharacterArcStages"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.CharacterRelationship", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CharacterId") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("RelatedCharacterId") + .HasColumnType("TEXT"); + + b.Property("RelationshipType") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CharacterId"); + + b.HasIndex("RelatedCharacterId"); + + b.ToTable("CharacterRelationships"); + }); + + modelBuilder.Entity("Novelly.Api.Genres.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Genres"); + + b.HasData( + new + { + Id = new Guid("b89aadb3-ee96-5a33-897d-94946b037f96"), + Name = "Adventure" + }, + new + { + Id = new Guid("1295b746-5de1-5724-aab8-186d4220c84f"), + Name = "Contemporary Fiction" + }, + new + { + Id = new Guid("786d6d01-be6c-5dff-ab53-17081d2979ed"), + Name = "Crime" + }, + new + { + Id = new Guid("800eea0a-52cb-5e03-8b6f-5e1ceaec8554"), + Name = "Dystopian" + }, + new + { + Id = new Guid("8dbe0291-1ab6-5045-b327-00f2025a7b0a"), + Name = "Fantasy" + }, + new + { + Id = new Guid("93face5a-9a61-5d63-9a8d-7fd5d49eab7d"), + Name = "Historical Fiction" + }, + new + { + Id = new Guid("4eba456f-b706-5f1f-bfc9-5d32cab0da62"), + Name = "Horror" + }, + new + { + Id = new Guid("d49c5adf-3ed9-5bc9-8652-1f7a9a098ecb"), + Name = "Literary Fiction" + }, + new + { + Id = new Guid("f72c6437-c8e7-519f-8d35-5aefeebbff9e"), + Name = "Magical Realism" + }, + new + { + Id = new Guid("1b670010-b4cc-5b22-a879-d36eb1bf3429"), + Name = "Memoir" + }, + new + { + Id = new Guid("03063bbf-de5d-5dd0-af06-0ee939de58bc"), + Name = "Middle Grade" + }, + new + { + Id = new Guid("c22ed045-52e5-54b0-8cdd-cd1d6a699c19"), + Name = "Mystery" + }, + new + { + Id = new Guid("abe2e8bc-a35e-5a30-a07f-7ae30a00d838"), + Name = "Non-Fiction" + }, + new + { + Id = new Guid("f8543db0-c519-56a0-996a-c6028176e57e"), + Name = "Poetry" + }, + new + { + Id = new Guid("b6251b9e-63a1-563f-94c0-834162fb580b"), + Name = "Romance" + }, + new + { + Id = new Guid("4f188842-488e-567a-b31d-831e0c551fa5"), + Name = "Science Fiction" + }, + new + { + Id = new Guid("ae67fc84-1ed9-55ae-8c9f-8a37adb52b57"), + Name = "Thriller" + }, + new + { + Id = new Guid("37956a94-e9c4-5d29-abbc-f121d687f997"), + Name = "Young Adult" + }); + }); + + modelBuilder.Entity("Novelly.Api.Imports.ImportJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ChaptersCompleted") + .HasColumnType("INTEGER"); + + b.Property("ChaptersTotal") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("RequestedByUserId") + .HasColumnType("TEXT"); + + b.Property("SourceRoot") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("StatusMessage") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SourceRoot"); + + b.ToTable("ImportJobs"); + }); + + modelBuilder.Entity("Novelly.Api.Projects.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Genre") + .HasColumnType("TEXT"); + + b.Property("Logline") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("OwnerId") + .HasColumnType("TEXT"); + + b.Property("Phase") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Synopsis") + .HasColumnType("TEXT"); + + b.Property("TargetWordCount") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId"); + + b.ToTable("Projects"); + }); + + modelBuilder.Entity("Novelly.Api.Questions.OpenQuestion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("TEXT"); + + b.Property("CharacterId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Detail") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Resolution") + .HasColumnType("TEXT"); + + b.Property("ResolvedAt") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("CharacterId"); + + b.HasIndex("ProjectId"); + + b.ToTable("OpenQuestions"); + }); + + modelBuilder.Entity("Novelly.Api.Tags.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Color") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId", "Name") + .IsUnique(); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("Novelly.Api.Users.NovellyUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("GlobalRole") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("INTEGER"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("GrantedAt") + .HasColumnType("INTEGER"); + + b.Property("GrantedByUserId") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("ProjectRole") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("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) + .WithMany() + .HasForeignKey("BeatsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Characters.Character", null) + .WithMany() + .HasForeignKey("CharactersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("BeatTag", b => + { + b.HasOne("Novelly.Api.Beats.Beat", null) + .WithMany() + .HasForeignKey("BeatsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Tags.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("Novelly.Api.Chapters.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Tags.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CharacterTag", b => + { + b.HasOne("Novelly.Api.Characters.Character", null) + .WithMany() + .HasForeignKey("CharactersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Tags.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Novelly.Api.Users.NovellyUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Novelly.Api.Users.NovellyUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", 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") + .WithMany("Conversations") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Novelly.Api.Agent.AgentMessage", b => + { + b.HasOne("Novelly.Api.Agent.AgentConversation", "Conversation") + .WithMany("Messages") + .HasForeignKey("ConversationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Conversation"); + }); + + modelBuilder.Entity("Novelly.Api.Beats.Beat", b => + { + b.HasOne("Novelly.Api.Chapters.Chapter", "Chapter") + .WithMany("Beats") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b => + { + b.HasOne("Novelly.Api.Projects.Project", "Project") + .WithMany("Chapters") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.Character", b => + { + b.HasOne("Novelly.Api.Projects.Project", "Project") + .WithMany("Characters") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Chapters.Chapter", "RevealedInChapter") + .WithMany() + .HasForeignKey("RevealedInChapterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Novelly.Api.Characters.Character", "SameCharacterAs") + .WithMany("OtherIdentities") + .HasForeignKey("SameCharacterAsId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Project"); + + b.Navigation("RevealedInChapter"); + + b.Navigation("SameCharacterAs"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.CharacterArcStage", b => + { + b.HasOne("Novelly.Api.Chapters.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Novelly.Api.Characters.Character", "Character") + .WithMany("ArcStages") + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Character"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.CharacterRelationship", b => + { + b.HasOne("Novelly.Api.Characters.Character", "Character") + .WithMany("Relationships") + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Characters.Character", "RelatedCharacter") + .WithMany() + .HasForeignKey("RelatedCharacterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Character"); + + 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") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Novelly.Api.Characters.Character", "Character") + .WithMany() + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Novelly.Api.Projects.Project", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Character"); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Novelly.Api.Tags.Tag", b => + { + b.HasOne("Novelly.Api.Projects.Project", "Project") + .WithMany("Tags") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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"); + }); + + modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b => + { + b.Navigation("Beats"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.Character", b => + { + b.Navigation("ArcStages"); + + b.Navigation("OtherIdentities"); + + b.Navigation("Relationships"); + }); + + modelBuilder.Entity("Novelly.Api.Projects.Project", b => + { + b.Navigation("Chapters"); + + b.Navigation("Characters"); + + b.Navigation("Conversations"); + + b.Navigation("Members"); + + b.Navigation("Tags"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Novelly.Api/Data/Migrations/20260817235523_AddCharacterAliasesAndIdentityLinks.cs b/src/Novelly.Api/Data/Migrations/20260817235523_AddCharacterAliasesAndIdentityLinks.cs new file mode 100644 index 0000000..90a2152 --- /dev/null +++ b/src/Novelly.Api/Data/Migrations/20260817235523_AddCharacterAliasesAndIdentityLinks.cs @@ -0,0 +1,102 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Novelly.Api.Data.Migrations +{ + /// + public partial class AddCharacterAliasesAndIdentityLinks : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Aliases", + table: "Characters", + type: "TEXT", + nullable: false, + defaultValue: "[]"); + + migrationBuilder.AddColumn( + name: "IdentityNote", + table: "Characters", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "RevealedInChapterId", + table: "Characters", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "SameCharacterAsId", + table: "Characters", + type: "TEXT", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_Characters_RevealedInChapterId", + table: "Characters", + column: "RevealedInChapterId"); + + migrationBuilder.CreateIndex( + name: "IX_Characters_SameCharacterAsId", + table: "Characters", + column: "SameCharacterAsId"); + + migrationBuilder.AddForeignKey( + name: "FK_Characters_Chapters_RevealedInChapterId", + table: "Characters", + column: "RevealedInChapterId", + principalTable: "Chapters", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + + migrationBuilder.AddForeignKey( + name: "FK_Characters_Characters_SameCharacterAsId", + table: "Characters", + column: "SameCharacterAsId", + principalTable: "Characters", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Characters_Chapters_RevealedInChapterId", + table: "Characters"); + + migrationBuilder.DropForeignKey( + name: "FK_Characters_Characters_SameCharacterAsId", + table: "Characters"); + + migrationBuilder.DropIndex( + name: "IX_Characters_RevealedInChapterId", + table: "Characters"); + + migrationBuilder.DropIndex( + name: "IX_Characters_SameCharacterAsId", + table: "Characters"); + + migrationBuilder.DropColumn( + name: "Aliases", + table: "Characters"); + + migrationBuilder.DropColumn( + name: "IdentityNote", + table: "Characters"); + + migrationBuilder.DropColumn( + name: "RevealedInChapterId", + table: "Characters"); + + migrationBuilder.DropColumn( + name: "SameCharacterAsId", + table: "Characters"); + } + } +} diff --git a/src/Novelly.Api/Data/Migrations/NovelDbContextModelSnapshot.cs b/src/Novelly.Api/Data/Migrations/NovelDbContextModelSnapshot.cs index 8f25010..0cad14d 100644 --- a/src/Novelly.Api/Data/Migrations/NovelDbContextModelSnapshot.cs +++ b/src/Novelly.Api/Data/Migrations/NovelDbContextModelSnapshot.cs @@ -299,6 +299,10 @@ namespace Novelly.Api.Data.Migrations b.Property("Age") .HasColumnType("TEXT"); + b.PrimitiveCollection("Aliases") + .IsRequired() + .HasColumnType("TEXT"); + b.Property("Appearance") .HasColumnType("TEXT"); @@ -314,6 +318,9 @@ namespace Novelly.Api.Data.Migrations b.Property("ExternalConflict") .HasColumnType("TEXT"); + b.Property("IdentityNote") + .HasColumnType("TEXT"); + b.Property("Importance") .IsRequired() .HasMaxLength(32) @@ -345,11 +352,17 @@ namespace Novelly.Api.Data.Migrations b.Property("Pronouns") .HasColumnType("TEXT"); + b.Property("RevealedInChapterId") + .HasColumnType("TEXT"); + b.Property("Role") .IsRequired() .HasMaxLength(32) .HasColumnType("TEXT"); + b.Property("SameCharacterAsId") + .HasColumnType("TEXT"); + b.Property("UpdatedAt") .HasColumnType("INTEGER"); @@ -363,6 +376,10 @@ namespace Novelly.Api.Data.Migrations b.HasIndex("ProjectId"); + b.HasIndex("RevealedInChapterId"); + + b.HasIndex("SameCharacterAsId"); + b.ToTable("Characters"); }); @@ -963,7 +980,21 @@ namespace Novelly.Api.Data.Migrations .OnDelete(DeleteBehavior.Cascade) .IsRequired(); + b.HasOne("Novelly.Api.Chapters.Chapter", "RevealedInChapter") + .WithMany() + .HasForeignKey("RevealedInChapterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Novelly.Api.Characters.Character", "SameCharacterAs") + .WithMany("OtherIdentities") + .HasForeignKey("SameCharacterAsId") + .OnDelete(DeleteBehavior.SetNull); + b.Navigation("Project"); + + b.Navigation("RevealedInChapter"); + + b.Navigation("SameCharacterAs"); }); modelBuilder.Entity("Novelly.Api.Characters.CharacterArcStage", b => @@ -1082,6 +1113,8 @@ namespace Novelly.Api.Data.Migrations { b.Navigation("ArcStages"); + b.Navigation("OtherIdentities"); + b.Navigation("Relationships"); }); diff --git a/src/Novelly.Mcp/NovelApiClient.cs b/src/Novelly.Mcp/NovelApiClient.cs index e77c685..2f45e01 100644 --- a/src/Novelly.Mcp/NovelApiClient.cs +++ b/src/Novelly.Mcp/NovelApiClient.cs @@ -27,6 +27,12 @@ public class NovelApiClient(HttpClient http, ILogger logger) Content = JsonContent.Create(body, options: Options) }, ct); + public Task PutAsync(string path, object body, CancellationToken ct = default) => + SendAsync(new HttpRequestMessage(HttpMethod.Put, path) + { + Content = JsonContent.Create(body, options: Options) + }, ct); + public Task DeleteAsync(string path, CancellationToken ct = default) => SendAsync(new HttpRequestMessage(HttpMethod.Delete, path), ct); private async Task SendAsync(HttpRequestMessage request, CancellationToken ct) diff --git a/src/Novelly.Mcp/Tools/BeatTools.cs b/src/Novelly.Mcp/Tools/BeatTools.cs index 383a15a..ad67317 100644 --- a/src/Novelly.Mcp/Tools/BeatTools.cs +++ b/src/Novelly.Mcp/Tools/BeatTools.cs @@ -78,4 +78,15 @@ public static class BeatTools [Description("Beat ids in their new order.")] Guid[] beatIds, CancellationToken ct) => api.PostAsync($"/api/chapters/{chapterId}/beats/reorder", new { beatIds }, ct); + + [McpServerTool(Name = "move_beats")] + [Description("Move one or more beats from one chapter to another, appending them to the " + + "target chapter's end in the order given.")] + public static Task MoveBeats( + NovelApiClient api, + [Description("The beats' current chapter id.")] Guid chapterId, + [Description("Id of the chapter to move the beats into.")] Guid targetChapterId, + [Description("Ids of the beats to move.")] Guid[] beatIds, + CancellationToken ct) => + api.PostAsync($"/api/chapters/{chapterId}/beats/move", new { targetChapterId, beatIds }, ct); } diff --git a/src/Novelly.Mcp/Tools/CharacterTools.cs b/src/Novelly.Mcp/Tools/CharacterTools.cs index 7b9aad6..6fe42a5 100644 --- a/src/Novelly.Mcp/Tools/CharacterTools.cs +++ b/src/Novelly.Mcp/Tools/CharacterTools.cs @@ -48,7 +48,8 @@ public static class CharacterTools [Description("How they change over the course of the book.")] string? arcSummary = null, [Description("Speech patterns and register that make their dialogue theirs.")] string? voice = null, [Description("Anything else worth recording.")] string? notes = null, - [Description("Tags for cross-referencing. Unknown tags are created.")] string[]? tags = null) => + [Description("Tags for cross-referencing. Unknown tags are created.")] string[]? tags = null, + [Description("Other names this character is known by.")] string[]? aliases = null) => api.PostAsync($"/api/projects/{projectId}/characters", new { name, @@ -67,7 +68,8 @@ public static class CharacterTools arcSummary, voice, notes, - tags + tags, + aliases }, ct); [McpServerTool(Name = "update_character")] @@ -94,7 +96,8 @@ public static class CharacterTools [Description("How they change over the course of the book.")] string? arcSummary = null, [Description("Speech patterns and register.")] string? voice = null, [Description("Anything else worth recording.")] string? notes = null, - [Description("Tags for cross-referencing. Replaces the existing tags.")] string[]? tags = null) => + [Description("Tags for cross-referencing. Replaces the existing tags.")] string[]? tags = null, + [Description("Other names this character is known by. Replaces the existing aliases.")] string[]? aliases = null) => api.PatchAsync($"/api/characters/{characterId}", new { name, @@ -113,7 +116,8 @@ public static class CharacterTools arcSummary, voice, notes, - tags + tags, + aliases }, ct); [McpServerTool(Name = "get_character_beats")] @@ -191,4 +195,27 @@ public static class CharacterTools [Description("What the relationship is like, and where it is headed.")] string? description = null) => api.PostAsync($"/api/characters/{characterId}/relationships", new { relatedCharacterId, relationshipType, description }, ct); + + [McpServerTool(Name = "link_character_identity")] + [Description("Record that this character is really another character — e.g. a character introduced " + + "under one name who is later revealed to be a character already in the project under " + + "another name. Both characters keep their own dossier and beats; the canonical identity " + + "is whichever character you link to.")] + public static Task LinkCharacterIdentity( + NovelApiClient api, + [Description("Id of the character being revealed as someone else.")] Guid characterId, + [Description("Id of the character this one really is.")] Guid sameCharacterAsId, + CancellationToken ct, + [Description("Id of the chapter where the reveal happens, if any.")] Guid? revealedInChapterId = null, + [Description("Context on the reveal, e.g. how and why the disguise held.")] string? note = null) => + api.PutAsync($"/api/characters/{characterId}/identity", + new { sameCharacterAsId, revealedInChapterId, note }, ct); + + [McpServerTool(Name = "unlink_character_identity")] + [Description("Remove a character's identity link, restoring it to its own separate identity.")] + public static Task UnlinkCharacterIdentity( + NovelApiClient api, + [Description("The character's id.")] Guid characterId, + CancellationToken ct) => + api.DeleteAsync($"/api/characters/{characterId}/identity", ct); } diff --git a/src/Novelly.Web/src/App.tsx b/src/Novelly.Web/src/App.tsx index 80f756b..393352e 100644 --- a/src/Novelly.Web/src/App.tsx +++ b/src/Novelly.Web/src/App.tsx @@ -13,6 +13,7 @@ import { AuthProvider, useAuth } from './auth/AuthContext' import { Spinner } from './components/ui' import { HotkeysProvider } from './keyboard/HotkeysContext' import { HelpOverlay } from './keyboard/HelpOverlay' +import { HelpOverlayProvider } from './keyboard/HelpOverlayContext' function RequireAuth() { const { user, isPending } = useAuth() @@ -27,23 +28,25 @@ export default function App() { return ( - - - } /> - }> - } /> - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + + + + } /> + }> + } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> - } /> - - + + ) diff --git a/src/Novelly.Web/src/api/client.ts b/src/Novelly.Web/src/api/client.ts index 5799fe8..a489926 100644 --- a/src/Novelly.Web/src/api/client.ts +++ b/src/Novelly.Web/src/api/client.ts @@ -39,5 +39,7 @@ export const api = { request(path, { method: 'POST', body: JSON.stringify(body ?? {}) }), patch: (path: string, body: unknown) => request(path, { method: 'PATCH', body: JSON.stringify(body) }), + put: (path: string, body: unknown) => + request(path, { method: 'PUT', body: JSON.stringify(body) }), delete: (path: string) => request(path, { method: 'DELETE' }), } diff --git a/src/Novelly.Web/src/api/hooks.ts b/src/Novelly.Web/src/api/hooks.ts index 9894771..3377d78 100644 --- a/src/Novelly.Web/src/api/hooks.ts +++ b/src/Novelly.Web/src/api/hooks.ts @@ -178,6 +178,32 @@ export function useDeleteCharacter(projectId: string) { }) } +export function useLinkCharacterIdentity(projectId: string) { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ + id, + sameCharacterAsId, + revealedInChapterId, + note, + }: { + id: string + sameCharacterAsId: string + revealedInChapterId?: string | null + note?: string | null + }) => api.put(`/api/characters/${id}/identity`, { sameCharacterAsId, revealedInChapterId, note }), + onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }), + }) +} + +export function useUnlinkCharacterIdentity(projectId: string) { + const qc = useQueryClient() + return useMutation({ + mutationFn: (id: string) => api.delete(`/api/characters/${id}/identity`), + onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }), + }) +} + export const useCharacterBeats = (characterId: string | undefined) => useQuery({ queryKey: keys.characterBeats(characterId ?? ''), @@ -371,6 +397,18 @@ export function useAssignCharacterToBeats(chapterId: string) { }) } +export function useMoveBeats(chapterId: string) { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ targetChapterId, beatIds }: { targetChapterId: string; beatIds: string[] }) => + api.post(`/api/chapters/${chapterId}/beats/move`, { targetChapterId, beatIds }), + onSuccess: (_, { targetChapterId }) => { + qc.invalidateQueries({ queryKey: keys.chapter(chapterId) }) + qc.invalidateQueries({ queryKey: keys.chapter(targetChapterId) }) + }, + }) +} + export const useChapters = (projectId: string) => useQuery({ queryKey: keys.chapters(projectId), diff --git a/src/Novelly.Web/src/api/types.ts b/src/Novelly.Web/src/api/types.ts index db5cc7f..82fb27e 100644 --- a/src/Novelly.Web/src/api/types.ts +++ b/src/Novelly.Web/src/api/types.ts @@ -188,12 +188,24 @@ export interface Character { arcSummary: string | null voice: string | null notes: string | null + aliases: string[] + sameCharacterAsId: string | null + sameCharacterAsName: string | null + revealedInChapterId: string | null + revealedInChapterNumber: number | null + identityNote: string | null + otherIdentities: CharacterIdentity[] relationships: Relationship[] tags: Tag[] arcStages: ArcStage[] updatedAt: string } +export interface CharacterIdentity { + id: string + name: string +} + export interface ChapterSummary { id: string projectId: string diff --git a/src/Novelly.Web/src/components/AliasEditor.tsx b/src/Novelly.Web/src/components/AliasEditor.tsx new file mode 100644 index 0000000..3a249e8 --- /dev/null +++ b/src/Novelly.Web/src/components/AliasEditor.tsx @@ -0,0 +1,69 @@ +import { useState } from 'react' + +export function AliasEditor({ + aliases, + onChange, + label, + readOnly, +}: { + aliases: string[] + onChange: (aliases: string[]) => void + label?: string + readOnly?: boolean +}) { + const [draft, setDraft] = useState('') + + const add = () => { + const name = draft.trim() + if (!name) return + if (!aliases.some((a) => a.toLowerCase() === name.toLowerCase())) { + onChange([...aliases, name]) + } + setDraft('') + } + + const remove = (name: string) => onChange(aliases.filter((a) => a !== name)) + + return ( +
+ {label && {label}} +
+ {aliases.map((alias) => ( + + {alias} + {!readOnly && ( + + )} + + ))} + {!readOnly && ( + setDraft(e.target.value)} + onBlur={add} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ',') { + e.preventDefault() + add() + } + }} + /> + )} +
+
+ ) +} diff --git a/src/Novelly.Web/src/components/CharacterMultiSelect.tsx b/src/Novelly.Web/src/components/CharacterMultiSelect.tsx index d826c4f..73f8296 100644 --- a/src/Novelly.Web/src/components/CharacterMultiSelect.tsx +++ b/src/Novelly.Web/src/components/CharacterMultiSelect.tsx @@ -1,13 +1,33 @@ import { useState } from 'react' +import { Link } from 'react-router-dom' +import { useCreateCharacter } from '../api/hooks' import type { BeatCharacter } from '../api/types' -export function CharacterChip({ character, onRemove }: { character: BeatCharacter; onRemove?: () => void }) { +export function CharacterChip({ + character, + projectId, + onRemove, +}: { + character: BeatCharacter + projectId?: string + onRemove?: () => void +}) { return ( - {character.name} + {projectId ? ( + e.stopPropagation()} + > + {character.name} + + ) : ( + character.name + )} {onRemove && ( + {mode === 'write' ? (