From 17facba3b9b3c79c50ef2d257d63ee6d0f365b14 Mon Sep 17 00:00:00 2001 From: James Wampler Date: Mon, 17 Aug 2026 22:45:07 -0700 Subject: [PATCH] Group character beats into arc-stage sections; reciprocal relationship types Arc stages now group the beats that establish or pay off that stage of a character's arc (many-to-many via ArcStageBeats), and carry a Result field (renamed from Description) describing what the stage results in for the character. Assigning a beat to a stage moves it out of any other stage of the same character. New endpoint POST /api/arc-stages/{id}/beats, MCP tool set_arc_stage_beats, and frontend grouping UI in CharacterArc/CharacterBeats. Also records a relationship's reciprocal type so both characters' dossiers show the correct direction (e.g. "sister" / "brother") instead of mirroring the same label. --- .editorconfig | 3 + src/Novelly.Api/Agent/NovelAgentToolset.cs | 2 +- src/Novelly.Api/Beats/Beat.cs | 2 + src/Novelly.Api/Beats/BeatContracts.cs | 8 +- src/Novelly.Api/Beats/BeatEndpoints.cs | 2 +- src/Novelly.Api/Beats/BeatService.cs | 1 + .../Characters/CharacterArcService.cs | 73 +- .../Characters/CharacterArcStage.cs | 8 +- .../Characters/CharacterContracts.cs | 43 +- .../Characters/CharacterEndpoints.cs | 6 + .../Characters/CharacterService.cs | 16 + ...2141_AddArcStageResultAndBeats.Designer.cs | 1169 +++++++++++++++++ ...0260818022141_AddArcStageResultAndBeats.cs | 61 + .../Migrations/NovelDbContextModelSnapshot.cs | 32 +- src/Novelly.Api/Imports/ImportAgentToolset.cs | 2 +- src/Novelly.Api/Projects/ProjectContracts.cs | 6 +- src/Novelly.Api/Projects/ProjectService.cs | 15 +- src/Novelly.Api/Users/NovellyUser.cs | 4 +- src/Novelly.Mcp/Tools/CharacterTools.cs | 31 +- src/Novelly.Web/src/api/hooks.ts | 50 +- src/Novelly.Web/src/api/types.ts | 4 +- .../src/components/CharacterArc.tsx | 82 +- .../src/components/CharacterBeats.tsx | 8 + .../src/pages/CharacterDetailPage.tsx | 191 ++- tests/Novelly.Api.Tests/CharacterArcTests.cs | 71 +- .../CharacterServiceTests.cs | 52 + tests/Novelly.Api.Tests/ServiceTestFixture.cs | 3 +- 27 files changed, 1871 insertions(+), 74 deletions(-) create mode 100644 src/Novelly.Api/Data/Migrations/20260818022141_AddArcStageResultAndBeats.Designer.cs create mode 100644 src/Novelly.Api/Data/Migrations/20260818022141_AddArcStageResultAndBeats.cs diff --git a/.editorconfig b/.editorconfig index 0ab5841..8dc507d 100644 --- a/.editorconfig +++ b/.editorconfig @@ -104,6 +104,9 @@ dotnet_style_qualification_for_property = false:suggestion dotnet_style_qualification_for_method = false:warning dotnet_style_qualification_for_event = false:warning + +dotnet_diagnostic.CA1873.severity = silent + [*.cs] csharp_using_directive_placement = outside_namespace:silent csharp_prefer_simple_using_statement = true:suggestion diff --git a/src/Novelly.Api/Agent/NovelAgentToolset.cs b/src/Novelly.Api/Agent/NovelAgentToolset.cs index 0bb01e9..e9b59df 100644 --- a/src/Novelly.Api/Agent/NovelAgentToolset.cs +++ b/src/Novelly.Api/Agent/NovelAgentToolset.cs @@ -456,7 +456,7 @@ public class NovelAgentToolset( var characterId = JsonInput.RequiredGuid(input, "character_id"); return await OrNotFound( beats.ListForCharacterAsync(characterId, ct), - list => list.Select(b => b.ToCharacterBeatResponse()), + list => list.Select(b => b.ToCharacterBeatResponse(characterId)), "Character", characterId); }); diff --git a/src/Novelly.Api/Beats/Beat.cs b/src/Novelly.Api/Beats/Beat.cs index aba1489..a4cb383 100644 --- a/src/Novelly.Api/Beats/Beat.cs +++ b/src/Novelly.Api/Beats/Beat.cs @@ -19,6 +19,8 @@ public class Beat public List Characters { get; set; } = []; + public List ArcStages { get; set; } = []; + public string? WhatHappened { get; set; } public string? WhatsNext { get; set; } diff --git a/src/Novelly.Api/Beats/BeatContracts.cs b/src/Novelly.Api/Beats/BeatContracts.cs index d1cd444..2d7b21b 100644 --- a/src/Novelly.Api/Beats/BeatContracts.cs +++ b/src/Novelly.Api/Beats/BeatContracts.cs @@ -84,7 +84,8 @@ public record CharacterBeatResponse( int SortOrder, string Title, string? WhatHappened, - string? WhatsNext); + string? WhatsNext, + Guid? ArcStageId); public record ReorderBeatsRequest(IReadOnlyList BeatIds); @@ -150,7 +151,7 @@ public static class BeatMapping [.. b.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())], b.UpdatedAt); - public static CharacterBeatResponse ToCharacterBeatResponse(this Beat b) => new( + public static CharacterBeatResponse ToCharacterBeatResponse(this Beat b, Guid characterId) => new( b.Id, b.ChapterId, b.Chapter?.Number ?? 0, @@ -158,5 +159,6 @@ public static class BeatMapping b.SortOrder, b.Title, b.WhatHappened, - b.WhatsNext); + b.WhatsNext, + b.ArcStages.FirstOrDefault(s => s.CharacterId == characterId)?.Id); } diff --git a/src/Novelly.Api/Beats/BeatEndpoints.cs b/src/Novelly.Api/Beats/BeatEndpoints.cs index 4528fd9..8f3d3af 100644 --- a/src/Novelly.Api/Beats/BeatEndpoints.cs +++ b/src/Novelly.Api/Beats/BeatEndpoints.cs @@ -46,7 +46,7 @@ public static class BeatEndpoints 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()) + (await service.ListForCharacterAsync(characterId, ct))?.Select(b => b.ToCharacterBeatResponse(characterId)).ToList().ToApiResult()) .WithTags("Beats") .WithSummary("Every beat this character appears in, in manuscript order."); diff --git a/src/Novelly.Api/Beats/BeatService.cs b/src/Novelly.Api/Beats/BeatService.cs index b86dff3..d6a16e3 100644 --- a/src/Novelly.Api/Beats/BeatService.cs +++ b/src/Novelly.Api/Beats/BeatService.cs @@ -68,6 +68,7 @@ public class BeatService( var beats = await db.Beats .Include(b => b.Chapter) + .Include(b => b.ArcStages) .Where(b => b.Characters.Any(c => c.Id == characterId)) .ToListAsync(ct); diff --git a/src/Novelly.Api/Characters/CharacterArcService.cs b/src/Novelly.Api/Characters/CharacterArcService.cs index 98843e8..1c85277 100644 --- a/src/Novelly.Api/Characters/CharacterArcService.cs +++ b/src/Novelly.Api/Characters/CharacterArcService.cs @@ -12,7 +12,8 @@ public class CharacterArcService( ILogger logger, IModelValidator createValidator, IModelValidator updateValidator, - IModelValidator reorderValidator) + IModelValidator reorderValidator, + IModelValidator setBeatsValidator) { public async Task> ListAsync(Guid characterId, CancellationToken ct = default) { @@ -70,7 +71,7 @@ public class CharacterArcService( CharacterId = characterId, Title = request.Title, SortOrder = request.SortOrder ?? await NextSortOrderAsync(characterId, ct), - Description = request.Description, + Result = request.Result, ChapterId = request.ChapterId }; @@ -107,7 +108,7 @@ public class CharacterArcService( stage.Title = Patch.Apply(stage.Title, request.Title) ?? stage.Title; stage.SortOrder = request.SortOrder ?? stage.SortOrder; - stage.Description = Patch.Apply(stage.Description, request.Description); + stage.Result = Patch.Apply(stage.Result, request.Result); stage.ChapterId = request.ChapterId ?? stage.ChapterId; stage.UpdatedAt = DateTimeOffset.UtcNow; @@ -171,6 +172,67 @@ public class CharacterArcService( return await ListAsync(characterId, ct); } + public async Task SetBeatsAsync( + Guid stageId, SetArcStageBeatsRequest request, CancellationToken ct = default) + { + Guard.Default(stageId, nameof(stageId)); + Guard.Null(request, nameof(request)); + setBeatsValidator.Validate(request).ThrowIfInvalid(logger); + + logger.LogInformation("Setting {Count} beats for arc stage {ArcStageId}", request.BeatIds.Count, stageId); + + var stage = await FindAsync(stageId, ct); + if (stage is null) + { + return null; + } + + var character = await db.Characters.FirstOrDefaultAsync(c => c.Id == stage.CharacterId, ct); + if (character is null) + { + logger.LogError("Arc stage {ArcStageId} references character {CharacterId} which does not exist", stageId, stage.CharacterId); + return null; + } + + await access.RequireAsync(character.ProjectId, ProjectPermission.Write, ct); + + var beats = await db.Beats + .Include(b => b.Characters) + .Include(b => b.ArcStages) + .Where(b => 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 arc stage beat assignment: arc stage {ArcStageId} referenced missing beat {BeatId}", stageId, missing[0]); + return null; + } + + var unrelated = beats.Where(b => b.Characters.All(c => c.Id != stage.CharacterId)).ToList(); + if (unrelated.Count > 0) + { + logger.LogWarning( + "Rejected arc stage beat assignment: beat {BeatId} does not include character {CharacterId}", + unrelated[0].Id, stage.CharacterId); + throw new InvalidOperationException("A beat can only be grouped into an arc stage for a character who appears in it."); + } + + foreach (var beat in beats) + { + foreach (var sibling in beat.ArcStages.Where(s => s.CharacterId == stage.CharacterId && s.Id != stageId).ToList()) + { + beat.ArcStages.Remove(sibling); + } + } + + stage.Beats = beats; + stage.UpdatedAt = DateTimeOffset.UtcNow; + + await db.SaveChangesAsync(ct); + return (await FindAsync(stageId, ct))!; + } + private async Task EnsureChapterIsInSameProjectAsync( Character character, Guid? chapterId, CancellationToken ct) { @@ -212,7 +274,10 @@ public class CharacterArcService( await access.RequireAsync(projectId, permission, ct); } - private IQueryable Query() => db.CharacterArcStages.Include(s => s.Chapter); + private IQueryable Query() => + db.CharacterArcStages + .Include(s => s.Chapter) + .Include(s => s.Beats).ThenInclude(b => b.Chapter); private async Task FindAsync(Guid id, CancellationToken ct) { diff --git a/src/Novelly.Api/Characters/CharacterArcStage.cs b/src/Novelly.Api/Characters/CharacterArcStage.cs index 059d4a3..6ab3d4f 100644 --- a/src/Novelly.Api/Characters/CharacterArcStage.cs +++ b/src/Novelly.Api/Characters/CharacterArcStage.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Novelly.Api.Beats; using Novelly.Api.Chapters; namespace Novelly.Api.Characters; @@ -15,11 +16,13 @@ public class CharacterArcStage public string Title { get; set; } = string.Empty; - public string? Description { get; set; } + public string? Result { get; set; } public Guid? ChapterId { get; set; } public Chapter? Chapter { get; init; } + public List Beats { get; set; } = []; + public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow; public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; } @@ -33,5 +36,8 @@ public class CharacterArcStageEntityTypeConfiguration : IEntityTypeConfiguration entity.HasOne(s => s.Chapter).WithMany() .HasForeignKey(s => s.ChapterId).OnDelete(DeleteBehavior.SetNull); + + entity.HasMany(s => s.Beats).WithMany(b => b.ArcStages) + .UsingEntity(join => join.ToTable("ArcStageBeats")); } } diff --git a/src/Novelly.Api/Characters/CharacterContracts.cs b/src/Novelly.Api/Characters/CharacterContracts.cs index 9b7b1df..7becdf9 100644 --- a/src/Novelly.Api/Characters/CharacterContracts.cs +++ b/src/Novelly.Api/Characters/CharacterContracts.cs @@ -1,3 +1,4 @@ +using Novelly.Api.Beats; using Novelly.Api.Common.Validation; using Novelly.Api.Tags; @@ -162,7 +163,8 @@ file static class CharacterValidation public record CreateRelationshipRequest( Guid RelatedCharacterId, string RelationshipType, - string? Description = null); + string? Description = null, + string? ReciprocalRelationshipType = null); public class CreateRelationshipRequestValidator : IModelValidator { @@ -175,6 +177,7 @@ public class CreateRelationshipRequestValidator : IModelValidator Beats, DateTimeOffset UpdatedAt); public record CreateArcStageRequest( string Title, int? SortOrder = null, - string? Description = null, + string? Result = null, Guid? ChapterId = null); public class CreateArcStageRequestValidator : IModelValidator @@ -224,7 +228,7 @@ public class CreateArcStageRequestValidator : IModelValidator @@ -243,7 +247,7 @@ public class UpdateArcStageRequestValidator : IModelValidator 20000 }) - result.AddError("Description", "'Description' must be 20,000 characters or fewer."); + if (result_ is { Length: > 20000 }) + result.AddError("Result", "'Result' must be 20,000 characters or fewer."); } } @@ -276,6 +280,21 @@ public class ReorderArcStagesRequestValidator : IModelValidator BeatIds); + +public class SetArcStageBeatsRequestValidator : IModelValidator +{ + public ValidationResult Validate(SetArcStageBeatsRequest model) + { + var result = new ValidationResult(); + + if (model.BeatIds is null) + result.AddError("BeatIds", "'Beat Ids' must not be null."); + + return result; + } +} + public static class CharacterMapping { @@ -305,9 +324,13 @@ public static class CharacterMapping s.CharacterId, s.SortOrder, s.Title, - s.Description, + s.Result, s.ChapterId, s.Chapter?.Number, s.Chapter?.Title, + [.. s.Beats + .OrderBy(b => b.Chapter?.Number ?? 0) + .ThenBy(b => b.SortOrder) + .Select(b => b.ToCharacterBeatResponse(s.CharacterId))], s.UpdatedAt); } diff --git a/src/Novelly.Api/Characters/CharacterEndpoints.cs b/src/Novelly.Api/Characters/CharacterEndpoints.cs index 82d0d70..0d93c26 100644 --- a/src/Novelly.Api/Characters/CharacterEndpoints.cs +++ b/src/Novelly.Api/Characters/CharacterEndpoints.cs @@ -107,6 +107,12 @@ public static class CharacterEndpoints await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound()) .WithSummary("Delete an arc stage."); + arcStages.MapPost("/{id:guid}/beats", async ( + Guid id, SetArcStageBeatsRequest request, CharacterArcService service, CancellationToken ct) => + (await service.SetBeatsAsync(id, request, ct))?.ToResponse().ToApiResult()) + .WithSummary("Set which beats belong to this arc stage, replacing its current set. " + + "A beat moved into this stage leaves any other stage of the same character it was in."); + return app; } } diff --git a/src/Novelly.Api/Characters/CharacterService.cs b/src/Novelly.Api/Characters/CharacterService.cs index f01ff8d..6eac946 100644 --- a/src/Novelly.Api/Characters/CharacterService.cs +++ b/src/Novelly.Api/Characters/CharacterService.cs @@ -214,6 +214,14 @@ public class CharacterService( Description = request.Description }); + db.CharacterRelationships.Add(new CharacterRelationship + { + CharacterId = request.RelatedCharacterId, + RelatedCharacterId = characterId, + RelationshipType = request.ReciprocalRelationshipType ?? request.RelationshipType, + Description = request.Description + }); + await db.SaveChangesAsync(ct); return (await FindAsync(characterId, ct))!; } @@ -235,7 +243,12 @@ public class CharacterService( await access.RequireAsync(relationship.Character!.ProjectId, ProjectPermission.Write, ct); + var reciprocals = await db.CharacterRelationships + .Where(r => r.CharacterId == relationship.RelatedCharacterId && r.RelatedCharacterId == relationship.CharacterId) + .ToListAsync(ct); + db.CharacterRelationships.Remove(relationship); + db.CharacterRelationships.RemoveRange(reciprocals); await db.SaveChangesAsync(ct); return true; } @@ -338,6 +351,9 @@ public class CharacterService( .Include(c => c.Tags) .Include(c => c.ArcStages) .ThenInclude(s => s.Chapter) + .Include(c => c.ArcStages) + .ThenInclude(s => s.Beats) + .ThenInclude(b => b.Chapter) .Include(c => c.SameCharacterAs) .Include(c => c.OtherIdentities) .Include(c => c.RevealedInChapter); diff --git a/src/Novelly.Api/Data/Migrations/20260818022141_AddArcStageResultAndBeats.Designer.cs b/src/Novelly.Api/Data/Migrations/20260818022141_AddArcStageResultAndBeats.Designer.cs new file mode 100644 index 0000000..461a581 --- /dev/null +++ b/src/Novelly.Api/Data/Migrations/20260818022141_AddArcStageResultAndBeats.Designer.cs @@ -0,0 +1,1169 @@ +// +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("20260818022141_AddArcStageResultAndBeats")] + partial class AddArcStageResultAndBeats + { + /// + 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("BeatCharacterArcStage", b => + { + b.Property("ArcStagesId") + .HasColumnType("TEXT"); + + b.Property("BeatsId") + .HasColumnType("TEXT"); + + b.HasKey("ArcStagesId", "BeatsId"); + + b.HasIndex("BeatsId"); + + b.ToTable("ArcStageBeats", (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("Result") + .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("BeatCharacterArcStage", b => + { + b.HasOne("Novelly.Api.Characters.CharacterArcStage", null) + .WithMany() + .HasForeignKey("ArcStagesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Beats.Beat", null) + .WithMany() + .HasForeignKey("BeatsId") + .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/20260818022141_AddArcStageResultAndBeats.cs b/src/Novelly.Api/Data/Migrations/20260818022141_AddArcStageResultAndBeats.cs new file mode 100644 index 0000000..602a5fa --- /dev/null +++ b/src/Novelly.Api/Data/Migrations/20260818022141_AddArcStageResultAndBeats.cs @@ -0,0 +1,61 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Novelly.Api.Data.Migrations +{ + /// + public partial class AddArcStageResultAndBeats : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "Description", + table: "CharacterArcStages", + newName: "Result"); + + migrationBuilder.CreateTable( + name: "ArcStageBeats", + columns: table => new + { + ArcStagesId = table.Column(type: "TEXT", nullable: false), + BeatsId = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ArcStageBeats", x => new { x.ArcStagesId, x.BeatsId }); + table.ForeignKey( + name: "FK_ArcStageBeats_Beats_BeatsId", + column: x => x.BeatsId, + principalTable: "Beats", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_ArcStageBeats_CharacterArcStages_ArcStagesId", + column: x => x.ArcStagesId, + principalTable: "CharacterArcStages", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ArcStageBeats_BeatsId", + table: "ArcStageBeats", + column: "BeatsId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ArcStageBeats"); + + migrationBuilder.RenameColumn( + name: "Result", + table: "CharacterArcStages", + newName: "Description"); + } + } +} diff --git a/src/Novelly.Api/Data/Migrations/NovelDbContextModelSnapshot.cs b/src/Novelly.Api/Data/Migrations/NovelDbContextModelSnapshot.cs index 0cad14d..ca3a072 100644 --- a/src/Novelly.Api/Data/Migrations/NovelDbContextModelSnapshot.cs +++ b/src/Novelly.Api/Data/Migrations/NovelDbContextModelSnapshot.cs @@ -32,6 +32,21 @@ namespace Novelly.Api.Data.Migrations b.ToTable("BeatCharacters", (string)null); }); + modelBuilder.Entity("BeatCharacterArcStage", b => + { + b.Property("ArcStagesId") + .HasColumnType("TEXT"); + + b.Property("BeatsId") + .HasColumnType("TEXT"); + + b.HasKey("ArcStagesId", "BeatsId"); + + b.HasIndex("BeatsId"); + + b.ToTable("ArcStageBeats", (string)null); + }); + modelBuilder.Entity("BeatTag", b => { b.Property("BeatsId") @@ -398,7 +413,7 @@ namespace Novelly.Api.Data.Migrations b.Property("CreatedAt") .HasColumnType("INTEGER"); - b.Property("Description") + b.Property("Result") .HasColumnType("TEXT"); b.Property("SortOrder") @@ -856,6 +871,21 @@ namespace Novelly.Api.Data.Migrations .IsRequired(); }); + modelBuilder.Entity("BeatCharacterArcStage", b => + { + b.HasOne("Novelly.Api.Characters.CharacterArcStage", null) + .WithMany() + .HasForeignKey("ArcStagesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Beats.Beat", null) + .WithMany() + .HasForeignKey("BeatsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("BeatTag", b => { b.HasOne("Novelly.Api.Beats.Beat", null) diff --git a/src/Novelly.Api/Imports/ImportAgentToolset.cs b/src/Novelly.Api/Imports/ImportAgentToolset.cs index 675f689..f00d60a 100644 --- a/src/Novelly.Api/Imports/ImportAgentToolset.cs +++ b/src/Novelly.Api/Imports/ImportAgentToolset.cs @@ -359,7 +359,7 @@ public class ImportAgentToolset( characterId, new CreateArcStageRequest( JsonInput.RequiredString(input, "title"), - Description: JsonInput.String(input, "description"), + Result: JsonInput.String(input, "description"), ChapterId: JsonInput.Guid(input, "chapter_id")), ct); return created is null diff --git a/src/Novelly.Api/Projects/ProjectContracts.cs b/src/Novelly.Api/Projects/ProjectContracts.cs index 695f9e4..f0f996f 100644 --- a/src/Novelly.Api/Projects/ProjectContracts.cs +++ b/src/Novelly.Api/Projects/ProjectContracts.cs @@ -77,11 +77,9 @@ public class UpdateProjectRequestValidator : IModelValidator - result.AddRequiredTextErrors("Title", "Title", title, 200); + public static void Title(string title, ValidationResult result) => result.AddRequiredTextErrors("Title", "Title", title, 200); - public static void OptionalFields( - string? author, string? genre, string? logline, string? synopsis, string? notes, int? targetWordCount, ValidationResult result) + public static void OptionalFields(string? author, string? genre, string? logline, string? synopsis, string? notes, int? targetWordCount, ValidationResult result) { if (author is { Length: > 200 }) result.AddError("Author", "'Author' must be 200 characters or fewer."); diff --git a/src/Novelly.Api/Projects/ProjectService.cs b/src/Novelly.Api/Projects/ProjectService.cs index c53ec42..fbce64a 100644 --- a/src/Novelly.Api/Projects/ProjectService.cs +++ b/src/Novelly.Api/Projects/ProjectService.cs @@ -42,10 +42,7 @@ public class ProjectService( logger.LogInformation("Getting project {ProjectId}", id); var project = await FindAsync(id, ct); - if (project is null) - { - return null; - } + if (project is null) return null; await access.RequireAsync(id, ProjectPermission.Read, ct); return project; @@ -85,10 +82,7 @@ public class ProjectService( logger.LogInformation("Updating project {ProjectId}", id); var project = await FindAsync(id, ct); - if (project is null) - { - return null; - } + if (project is null) return null; await access.RequireAsync(id, ProjectPermission.Write, ct); @@ -113,10 +107,7 @@ public class ProjectService( logger.LogInformation("Deleting project {ProjectId}", id); var project = await FindAsync(id, ct); - if (project is null) - { - return false; - } + if (project is null) return false; await access.RequireAsync(id, ProjectPermission.DeleteContent, ct); diff --git a/src/Novelly.Api/Users/NovellyUser.cs b/src/Novelly.Api/Users/NovellyUser.cs index 32ba5b8..dbff4c7 100644 --- a/src/Novelly.Api/Users/NovellyUser.cs +++ b/src/Novelly.Api/Users/NovellyUser.cs @@ -6,9 +6,9 @@ namespace Novelly.Api.Users; public class NovellyUser : IdentityUser { - public string DisplayName { get; set; } = string.Empty; + public string DisplayName { get; init; } = string.Empty; public GlobalRole GlobalRole { get; set; } = GlobalRole.Reviewer; - public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow; } public class NovellyUserEntityTypeConfiguration : IEntityTypeConfiguration diff --git a/src/Novelly.Mcp/Tools/CharacterTools.cs b/src/Novelly.Mcp/Tools/CharacterTools.cs index 6fe42a5..96b2eb4 100644 --- a/src/Novelly.Mcp/Tools/CharacterTools.cs +++ b/src/Novelly.Mcp/Tools/CharacterTools.cs @@ -147,11 +147,11 @@ public static class CharacterTools [Description("Id of the character whose arc to add to.")] Guid characterId, [Description("A short handle for the change, three to five words.")] string title, CancellationToken ct, - [Description("What shifts in the character here, and what it costs them.")] string? description = null, + [Description("What this stage of the arc results in for the character — what shifts, and what it costs them.")] string? result = null, [Description("Id of the chapter where this stage lands, if it is pinned to one.")] Guid? chapterId = null, [Description("Position in the arc. Appended to the end when omitted.")] int? sortOrder = null) => api.PostAsync($"/api/characters/{characterId}/arc", - new { title, sortOrder, description, chapterId }, ct); + new { title, sortOrder, result, chapterId }, ct); [McpServerTool(Name = "update_arc_stage")] [Description("Revise a stage of a character's arc. Only the fields you supply change.")] @@ -160,11 +160,11 @@ public static class CharacterTools [Description("The arc stage's id.")] Guid arcStageId, CancellationToken ct, [Description("New title for the stage.")] string? title = null, - [Description("What shifts in the character here.")] string? description = null, + [Description("What this stage of the arc results in for the character.")] string? result = null, [Description("Id of the chapter where this stage lands.")] Guid? chapterId = null, [Description("Position in the arc.")] int? sortOrder = null) => api.PatchAsync($"/api/arc-stages/{arcStageId}", - new { title, sortOrder, description, chapterId }, ct); + new { title, sortOrder, result, chapterId }, ct); [McpServerTool(Name = "delete_arc_stage")] [Description("Remove a stage from a character's arc.")] @@ -184,17 +184,34 @@ public static class CharacterTools CancellationToken ct) => api.PostAsync($"/api/characters/{characterId}/arc/reorder", new { stageIds }, ct); + [McpServerTool(Name = "set_arc_stage_beats")] + [Description("Set which beats belong to an arc stage, replacing its current set. This groups the " + + "chapter-level beats that establish or pay off this stage of the character's arc. A " + + "beat moved into this stage leaves any other stage of the same character it was in. " + + "Each beat must already include this character.")] + public static Task SetArcStageBeats( + NovelApiClient api, + [Description("The arc stage's id.")] Guid arcStageId, + [Description("Beat ids that belong to this stage, replacing whatever was there before.")] string[] beatIds, + CancellationToken ct) => + api.PostAsync($"/api/arc-stages/{arcStageId}/beats", new { beatIds }, ct); + [McpServerTool(Name = "relate_characters")] - [Description("Record a relationship from one character to another in the same project.")] + [Description("Record a relationship between two characters in the same project. Creates both directions " + + "at once — characterId's side and relatedCharacterId's side — so the pair always shows up " + + "on both dossiers.")] public static Task RelateCharacters( NovelApiClient api, [Description("Id of the character the relationship belongs to.")] Guid characterId, [Description("Id of the character they are related to.")] Guid relatedCharacterId, - [Description("How they are related, e.g. 'sister', 'rival', 'former mentor'.")] string relationshipType, + [Description("How characterId is related to relatedCharacterId, e.g. 'sister', 'rival', 'former mentor'.")] string relationshipType, CancellationToken ct, + [Description("How relatedCharacterId is related back to characterId, if different — e.g. 'brother' for " + + "'sister'. Defaults to relationshipType when the relation is symmetric, like 'rival'.")] + string? reciprocalRelationshipType = null, [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); + new { relatedCharacterId, relationshipType, reciprocalRelationshipType, description }, ct); [McpServerTool(Name = "link_character_identity")] [Description("Record that this character is really another character — e.g. a character introduced " diff --git a/src/Novelly.Web/src/api/hooks.ts b/src/Novelly.Web/src/api/hooks.ts index 8251ed2..c047da3 100644 --- a/src/Novelly.Web/src/api/hooks.ts +++ b/src/Novelly.Web/src/api/hooks.ts @@ -204,6 +204,40 @@ export function useUnlinkCharacterIdentity(projectId: string) { }) } +export function useAddRelationship(projectId: string) { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ + id, + relatedCharacterId, + relationshipType, + reciprocalRelationshipType, + description, + }: { + id: string + relatedCharacterId: string + relationshipType: string + reciprocalRelationshipType?: string | null + description?: string | null + }) => + api.post(`/api/characters/${id}/relationships`, { + relatedCharacterId, + relationshipType, + reciprocalRelationshipType, + description, + }), + onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }), + }) +} + +export function useRemoveRelationship(projectId: string) { + const qc = useQueryClient() + return useMutation({ + mutationFn: (relationshipId: string) => api.delete(`/api/characters/relationships/${relationshipId}`), + onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }), + }) +} + export const useCharacterBeats = (characterId: string | undefined) => useQuery({ queryKey: keys.characterBeats(characterId ?? ''), @@ -214,7 +248,7 @@ export const useCharacterBeats = (characterId: string | undefined) => export function useCreateArcStage(projectId: string) { const qc = useQueryClient() return useMutation({ - mutationFn: ({ characterId, ...body }: { characterId: string; title: string; description?: string; chapterId?: string }) => + mutationFn: ({ characterId, ...body }: { characterId: string; title: string; result?: string; chapterId?: string }) => api.post(`/api/characters/${characterId}/arc`, body), onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }), }) @@ -223,12 +257,24 @@ export function useCreateArcStage(projectId: string) { export function useUpdateArcStage(projectId: string) { const qc = useQueryClient() return useMutation({ - mutationFn: ({ id, ...body }: { id: string; title?: string; description?: string; chapterId?: string }) => + mutationFn: ({ id, ...body }: { id: string; title?: string; result?: string; chapterId?: string }) => api.patch(`/api/arc-stages/${id}`, body), onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }), }) } +export function useSetArcStageBeats(projectId: string, characterId: string | undefined) { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ id, beatIds }: { id: string; beatIds: string[] }) => + api.post(`/api/arc-stages/${id}/beats`, { beatIds }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: keys.characters(projectId) }) + qc.invalidateQueries({ queryKey: keys.characterBeats(characterId ?? '') }) + }, + }) +} + export function useDeleteArcStage(projectId: string) { const qc = useQueryClient() return useMutation({ diff --git a/src/Novelly.Web/src/api/types.ts b/src/Novelly.Web/src/api/types.ts index 82fb27e..32efb4e 100644 --- a/src/Novelly.Web/src/api/types.ts +++ b/src/Novelly.Web/src/api/types.ts @@ -151,10 +151,11 @@ export interface ArcStage { characterId: string sortOrder: number title: string - description: string | null + result: string | null chapterId: string | null chapterNumber: number | null chapterTitle: string | null + beats: CharacterBeat[] updatedAt: string } @@ -167,6 +168,7 @@ export interface CharacterBeat { title: string whatHappened: string | null whatsNext: string | null + arcStageId: string | null } export interface Character { diff --git a/src/Novelly.Web/src/components/CharacterArc.tsx b/src/Novelly.Web/src/components/CharacterArc.tsx index af78153..1314635 100644 --- a/src/Novelly.Web/src/components/CharacterArc.tsx +++ b/src/Novelly.Web/src/components/CharacterArc.tsx @@ -1,10 +1,12 @@ import { useState } from 'react' import { Link } from 'react-router-dom' import { + useCharacterBeats, useChapters, useCreateArcStage, useDeleteArcStage, useReorderArcStages, + useSetArcStageBeats, useUpdateArcStage, } from '../api/hooks' import type { ArcStage, Character } from '../api/types' @@ -24,12 +26,14 @@ export function CharacterArc({ canDelete: boolean }) { const { data: chapters } = useChapters(projectId) + const { data: beats } = useCharacterBeats(character.id) const create = useCreateArcStage(projectId) const reorder = useReorderArcStages(projectId) const [title, setTitle] = useState('') const stages = character.arcStages + const unassignedBeats = (beats ?? []).filter((b) => b.arcStageId === null) const submit = (e: React.FormEvent) => { e.preventDefault() @@ -56,8 +60,9 @@ export function CharacterArc({ )}

- The changes {character.name} goes through, in order. Pin a stage to the chapter it - lands in and it links into that outline. + The sections {character.name}’s arc breaks into, in order — each one a short span of + beats and what it results in for them. Pin a section to the chapter it lands in and it + links into that outline.

{stages.length > 0 && ( @@ -68,6 +73,7 @@ export function CharacterArc({ projectId={projectId} stage={stage} chapters={chapters ?? []} + unassignedBeats={unassignedBeats} canMoveUp={index > 0} canMoveDown={index < stages.length - 1} onMove={(delta) => move(index, delta)} @@ -78,11 +84,18 @@ export function CharacterArc({ )} + {unassignedBeats.length > 0 && ( +

+ {unassignedBeats.length} beat{unassignedBeats.length === 1 ? '' : 's'} not yet grouped + into a section — add {character.name} to a section above, or check the Beats list below. +

+ )} + {canCreate && (
setTitle(e.target.value)} /> @@ -105,6 +118,7 @@ function ArcStageRow({ projectId, stage, chapters, + unassignedBeats, canMoveUp, canMoveDown, onMove, @@ -114,6 +128,7 @@ function ArcStageRow({ projectId: string stage: ArcStage chapters: { id: string; number: number; title: string }[] + unassignedBeats: { id: string; chapterNumber: number; sortOrder: number; title: string }[] canMoveUp: boolean canMoveDown: boolean onMove: (delta: number) => void @@ -122,6 +137,16 @@ function ArcStageRow({ }) { const update = useUpdateArcStage(projectId) const remove = useDeleteArcStage(projectId) + const setBeats = useSetArcStageBeats(projectId, stage.characterId) + + const addBeat = (beatId: string) => { + if (!beatId) return + setBeats.mutate({ id: stage.id, beatIds: [...stage.beats.map((b) => b.id), beatId] }) + } + + const removeBeat = (beatId: string) => { + setBeats.mutate({ id: stage.id, beatIds: stage.beats.filter((b) => b.id !== beatId).map((b) => b.id) }) + } return (
  • update.mutate({ id: stage.id, description })} + placeholder="What this results in for them — what shifts, and what it costs." + onCommit={(result) => update.mutate({ id: stage.id, result })} readOnly={!canWrite} /> + {stage.beats.length > 0 && ( +
      + {stage.beats.map((beat) => ( +
    • + + {beat.chapterNumber}.{beat.sortOrder} + + {beat.title} + {canWrite && ( + + )} +
    • + ))} +
    + )} + + {canWrite && unassignedBeats.length > 0 && ( + + )} +
    setTargetId(e.target.value)} + autoFocus + > + + {candidates.map((c) => ( + + ))} + + + + + +
    + + +
    + + )} + + )} + + {error !== null && error !== undefined && ( +
    + +
    + )} + + ) +} + function IdentitySection({ character, allCharacters, diff --git a/tests/Novelly.Api.Tests/CharacterArcTests.cs b/tests/Novelly.Api.Tests/CharacterArcTests.cs index 4df53c3..65cc977 100644 --- a/tests/Novelly.Api.Tests/CharacterArcTests.cs +++ b/tests/Novelly.Api.Tests/CharacterArcTests.cs @@ -98,14 +98,14 @@ public class CharacterArcTests : ServiceTestFixture public async Task A_character_dossier_carries_its_arc() { await Arcs.CreateAsync(_characterId, new CreateArcStageRequest( - "She trusts the map", Description: "Because her mother drew it.")); + "She trusts the map", Result: "Because her mother drew it.")); var character = (await Characters.GetAsync(_characterId))!; Assert.Multiple(() => { Assert.That(character.ArcStages, Has.Count.EqualTo(1)); - Assert.That(character.ArcStages[0].Description, Does.Contain("her mother drew it")); + Assert.That(character.ArcStages[0].Result, Does.Contain("her mother drew it")); }); } @@ -211,4 +211,71 @@ public class CharacterArcTests : ServiceTestFixture [Test] public async Task Asking_for_the_beats_of_a_character_who_does_not_exist_returns_null_rather_than_throwing() => Assert.That(await Beats.ListForCharacterAsync(Guid.NewGuid()), Is.Null); + + [Test] + public async Task An_arc_stage_groups_the_beats_assigned_to_it() + { + var chapter = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Landfall")); + var spoiled = await Beats.CreateAsync(chapter.Id, new CreateBeatRequest("Snaps at the crew", CharacterIds: [_characterId])); + var humbled = await Beats.CreateAsync(chapter.Id, new CreateBeatRequest("Learns to swab a deck", CharacterIds: [_characterId])); + var stage = await Arcs.CreateAsync(_characterId, new CreateArcStageRequest("Spoiled noble")); + + var updated = await Arcs.SetBeatsAsync(stage.Id, new SetArcStageBeatsRequest([spoiled!.Id])); + + Assert.That(updated!.Beats.Select(b => b.Id), Is.EqualTo(new[] { spoiled.Id })); + Assert.That(humbled, Is.Not.Null); + } + + [Test] + public async Task Assigning_a_beat_to_a_stage_moves_it_out_of_the_characters_other_stage() + { + var chapter = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Landfall")); + var beat = await Beats.CreateAsync(chapter.Id, new CreateBeatRequest("Gets hurt", CharacterIds: [_characterId])); + var early = await Arcs.CreateAsync(_characterId, new CreateArcStageRequest("Spoiled noble")); + var later = await Arcs.CreateAsync(_characterId, new CreateArcStageRequest("Humbled")); + + await Arcs.SetBeatsAsync(early.Id, new SetArcStageBeatsRequest([beat!.Id])); + await Arcs.SetBeatsAsync(later.Id, new SetArcStageBeatsRequest([beat.Id])); + + var earlyAfter = (await Arcs.GetAsync(early.Id))!; + var laterAfter = (await Arcs.GetAsync(later.Id))!; + + Assert.Multiple(() => + { + Assert.That(earlyAfter.Beats, Is.Empty); + Assert.That(laterAfter.Beats.Select(b => b.Id), Is.EqualTo(new[] { beat.Id })); + }); + } + + [Test] + public async Task A_beat_can_only_be_grouped_into_a_stage_for_a_character_who_appears_in_it() + { + var chapter = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Landfall")); + var mara = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Mara")); + var beat = await Beats.CreateAsync(chapter.Id, new CreateBeatRequest("Mara alone", CharacterIds: [mara.Id])); + var stage = await Arcs.CreateAsync(_characterId, new CreateArcStageRequest("Spoiled noble")); + + Assert.That( + async () => await Arcs.SetBeatsAsync(stage.Id, new SetArcStageBeatsRequest([beat!.Id])), + Throws.TypeOf().With.Message.Contains("appears in it")); + } + + [Test] + public async Task Setting_beats_on_an_unknown_stage_returns_null_rather_than_throwing() => + Assert.That( + await Arcs.SetBeatsAsync(Guid.NewGuid(), new SetArcStageBeatsRequest([])), + Is.Null); + + [Test] + public async Task Clearing_a_stages_beats_with_an_empty_list_ungroups_them() + { + var chapter = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Landfall")); + var beat = await Beats.CreateAsync(chapter.Id, new CreateBeatRequest("Snaps at the crew", CharacterIds: [_characterId])); + var stage = await Arcs.CreateAsync(_characterId, new CreateArcStageRequest("Spoiled noble")); + await Arcs.SetBeatsAsync(stage.Id, new SetArcStageBeatsRequest([beat!.Id])); + + var cleared = await Arcs.SetBeatsAsync(stage.Id, new SetArcStageBeatsRequest([])); + + Assert.That(cleared!.Beats, Is.Empty); + } } diff --git a/tests/Novelly.Api.Tests/CharacterServiceTests.cs b/tests/Novelly.Api.Tests/CharacterServiceTests.cs index 1870875..4326329 100644 --- a/tests/Novelly.Api.Tests/CharacterServiceTests.cs +++ b/tests/Novelly.Api.Tests/CharacterServiceTests.cs @@ -101,6 +101,58 @@ public class CharacterServiceTests : ServiceTestFixture }); } + [Test] + public async Task Adding_a_relationship_records_it_on_both_characters() + { + var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines")); + var mara = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Mara")); + + await Characters.AddRelationshipAsync( + ines.Id, new CreateRelationshipRequest(mara.Id, "sister", ReciprocalRelationshipType: "brother")); + + var inesAfter = (await Characters.GetAsync(ines.Id))!; + var maraAfter = (await Characters.GetAsync(mara.Id))!; + + Assert.Multiple(() => + { + Assert.That(inesAfter.Relationships, Has.Count.EqualTo(1)); + Assert.That(inesAfter.Relationships[0].RelationshipType, Is.EqualTo("sister")); + Assert.That(inesAfter.Relationships[0].RelatedCharacterId, Is.EqualTo(mara.Id)); + Assert.That(maraAfter.Relationships, Has.Count.EqualTo(1)); + Assert.That(maraAfter.Relationships[0].RelationshipType, Is.EqualTo("brother")); + Assert.That(maraAfter.Relationships[0].RelatedCharacterId, Is.EqualTo(ines.Id)); + }); + } + + [Test] + public async Task A_relationship_with_no_reciprocal_type_mirrors_the_same_type_both_ways() + { + var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines")); + var mara = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Mara")); + + await Characters.AddRelationshipAsync(ines.Id, new CreateRelationshipRequest(mara.Id, "rival")); + + var maraAfter = (await Characters.GetAsync(mara.Id))!; + + Assert.That(maraAfter.Relationships[0].RelationshipType, Is.EqualTo("rival")); + } + + [Test] + public async Task Removing_a_relationship_removes_the_reciprocal_side_too() + { + var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines")); + var mara = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Mara")); + + var withRelationship = (await Characters.AddRelationshipAsync( + ines.Id, new CreateRelationshipRequest(mara.Id, "sister", ReciprocalRelationshipType: "brother")))!; + var relationshipId = withRelationship.Relationships[0].Id; + + await Characters.RemoveRelationshipAsync(relationshipId); + var maraAfter = (await Characters.GetAsync(mara.Id))!; + + Assert.That(maraAfter.Relationships, Is.Empty); + } + [Test] public async Task Deleting_a_character_detaches_it_from_beats_rather_than_deleting_them() { diff --git a/tests/Novelly.Api.Tests/ServiceTestFixture.cs b/tests/Novelly.Api.Tests/ServiceTestFixture.cs index dfe99dc..48c76d3 100644 --- a/tests/Novelly.Api.Tests/ServiceTestFixture.cs +++ b/tests/Novelly.Api.Tests/ServiceTestFixture.cs @@ -72,7 +72,8 @@ public abstract class ServiceTestFixture new AssignCharacterToBeatsRequestValidator(), new MoveBeatsRequestValidator()); Arcs = new CharacterArcService( Db.Context, Access, ArcLogs, - new CreateArcStageRequestValidator(), new UpdateArcStageRequestValidator(), new ReorderArcStagesRequestValidator()); + new CreateArcStageRequestValidator(), new UpdateArcStageRequestValidator(), new ReorderArcStagesRequestValidator(), + new SetArcStageBeatsRequestValidator()); Questions = new OpenQuestionService( Db.Context, Access, QuestionLogs, new CreateOpenQuestionRequestValidator(), new UpdateOpenQuestionRequestValidator(), new ResolveOpenQuestionRequestValidator());