From 9a4bb3996ecdddca479534068b1c59b4a135e00a Mon Sep 17 00:00:00 2001 From: Robert Good Date: Fri, 13 Feb 2026 21:57:58 -0800 Subject: [PATCH 1/2] Invariant state protection --- .../Actor/CreateActorCommand.cs | 2 +- .../Actor/SaveMyActorCommand.cs | 22 ++++- .../Actor/SaveMyActorCommandValidator.cs | 2 +- .../CreateMyChatMessageCommand.cs | 8 +- .../CreateMyChatSessionCommand.cs | 10 ++- src/Core.Application/Core.Application.csproj | 6 +- src/Core.Domain/Actor/ActorEntity.cs | 36 ++++----- .../ChatCompletion/ChatMessageEntity.cs | 10 +-- .../ChatCompletion/ChatSessionEntity.cs | 12 ++- src/Core.Domain/Core.Domain.csproj | 2 +- .../Infrastructure.AgentFramework.csproj | 10 +-- .../Infrastructure.SqlServer.csproj | 17 ++-- ... 20260214043857_InitialCreate.Designer.cs} | 29 ++++++- ...ate.cs => 20260214043857_InitialCreate.cs} | 15 +++- .../AgentFrameworkContextModelSnapshot.cs | 27 +++++++ .../Persistence/AgentFrameworkContext.cs | 80 ++++++------------- .../Clients/BackendApiClient.g.cs | 3 - .../Presentation.Blazor.csproj | 12 +-- .../Services/UserSyncService.cs | 1 - .../Presentation.WebApi.csproj | 12 +-- .../CreateActorCommandStepDefinitions.cs | 2 +- .../DeleteActorCommandStepDefinitions.cs | 2 +- .../GetActorByOwnerIdQueryStepDefinitions.cs | 2 +- .../Actor/GetActorQueryStepDefinitions.cs | 2 +- .../Actor/SaveActorCommandStepDefinitions.cs | 3 +- .../UpdateActorCommandStepDefinitions.cs | 2 +- ...CreateChatMessageCommandStepDefinitions.cs | 2 +- ...CreateChatSessionCommandStepDefinitions.cs | 4 +- ...DeleteChatSessionCommandStepDefinitions.cs | 2 +- .../GetChatMessageQueryStepDefinitions.cs | 4 +- ...atMessagesPaginatedQueryStepDefinitions.cs | 2 +- .../GetMyChatSessionQueryStepDefinitions.cs | 2 +- ...atSessionsPaginatedQueryStepDefinitions.cs | 2 +- .../GetMyChatSessionsQueryStepDefinitions.cs | 2 +- .../PatchChatSessionCommandStepDefinitions.cs | 2 +- .../Tests.Integration.csproj | 4 +- 36 files changed, 200 insertions(+), 155 deletions(-) rename src/Infrastructure.SqlServer/Migrations/{20260208060746_InitialCreate.Designer.cs => 20260214043857_InitialCreate.Designer.cs} (87%) rename src/Infrastructure.SqlServer/Migrations/{20260208060746_InitialCreate.cs => 20260214043857_InitialCreate.cs} (87%) diff --git a/src/Core.Application/Actor/CreateActorCommand.cs b/src/Core.Application/Actor/CreateActorCommand.cs index de69dc6..4aac01a 100644 --- a/src/Core.Application/Actor/CreateActorCommand.cs +++ b/src/Core.Application/Actor/CreateActorCommand.cs @@ -23,7 +23,7 @@ public async Task Handle(CreateActorCommand request, CancellationToken GuardAgainstEmptyOwnerId(request?.OwnerId); GuardAgainstIdExists(_context.Actors, request!.Id); - var Actor = ActorEntity.Create(request!.Id == Guid.Empty ? Guid.NewGuid() : request!.Id, request.FirstName, request.LastName, request.Email); + var Actor = ActorEntity.Create(request!.Id == Guid.Empty ? Guid.NewGuid() : request!.Id, request.FirstName, request.LastName, request.Email, request.OwnerId, request.TenantId); _context.Actors.Add(Actor); try { diff --git a/src/Core.Application/Actor/SaveMyActorCommand.cs b/src/Core.Application/Actor/SaveMyActorCommand.cs index e42180d..f4f4de5 100644 --- a/src/Core.Application/Actor/SaveMyActorCommand.cs +++ b/src/Core.Application/Actor/SaveMyActorCommand.cs @@ -9,7 +9,6 @@ public class SaveMyActorCommand : IRequest, IRequiresUserContext public string? FirstName { get; set; } public string? LastName { get; set; } public string? Email { get; set; } - public Guid TenantId { get; set; } public IUserContext? UserContext { get; set; } } @@ -19,9 +18,9 @@ public class SaveActorCommandHandler(IAgentFrameworkContext context) : IRequestH public async Task Handle(SaveMyActorCommand request, CancellationToken cancellationToken) { - GuardAgainstEmptyTenantId(request?.TenantId); + GuardAgainstInvalidUserContext(request?.UserContext); - var actor = await _context.Actors.Where(x => x.OwnerId == request!.UserContext!.OwnerId && x.TenantId == request.TenantId).FirstOrDefaultAsync(cancellationToken); + var actor = await _context.Actors.Where(x => x.OwnerId == request!.UserContext!.OwnerId && x.TenantId == request.UserContext.TenantId).FirstOrDefaultAsync(cancellationToken); if (actor is not null) { actor.Update(request?.FirstName, request?.LastName ?? actor.LastName, request?.Email); @@ -29,7 +28,7 @@ public async Task Handle(SaveMyActorCommand request, CancellationToken } else { - actor = ActorEntity.Create(Guid.NewGuid(), request?.FirstName, request?.LastName, request?.Email); + actor = ActorEntity.Create(Guid.NewGuid(), request?.FirstName, request?.LastName, request?.Email, request!.UserContext!.OwnerId, request.UserContext.TenantId); _context.Actors.Add(actor); } @@ -38,6 +37,21 @@ public async Task Handle(SaveMyActorCommand request, CancellationToken return ActorDto.CreateFrom(actor); } + private static void GuardAgainstInvalidUserContext(IUserContext? userContext) + { + if (userContext is null) + throw new CustomValidationException( + [ + new("UserContext", "UserContext is required to save an actor") + ]); + + if (userContext.OwnerId == Guid.Empty) + throw new CustomValidationException( + [ + new("OwnerId", "A valid OwnerId is required in UserContext to save an actor") + ]); + } + private static void GuardAgainstEmptyTenantId(Guid? tenantId) { if (tenantId == Guid.Empty) diff --git a/src/Core.Application/Actor/SaveMyActorCommandValidator.cs b/src/Core.Application/Actor/SaveMyActorCommandValidator.cs index 2d19fb9..d913173 100644 --- a/src/Core.Application/Actor/SaveMyActorCommandValidator.cs +++ b/src/Core.Application/Actor/SaveMyActorCommandValidator.cs @@ -5,6 +5,6 @@ public class SaveMyActorCommandValidator : Validator public SaveMyActorCommandValidator() { RuleFor(x => x.UserContext).NotEmpty(); - RuleFor(x => x.TenantId).NotEmpty(); + RuleFor(x => x.UserContext!.TenantId).NotEmpty(); } } \ No newline at end of file diff --git a/src/Core.Application/ChatCompletion/CreateMyChatMessageCommand.cs b/src/Core.Application/ChatCompletion/CreateMyChatMessageCommand.cs index 1670758..6a48868 100644 --- a/src/Core.Application/ChatCompletion/CreateMyChatMessageCommand.cs +++ b/src/Core.Application/ChatCompletion/CreateMyChatMessageCommand.cs @@ -48,7 +48,9 @@ public async Task Handle(CreateMyChatMessageCommand request, Can request.Id, chatSession.Id, ChatMessageRole.user, - request.Message! + request.Message!, + request!.UserContext!.OwnerId, + request.UserContext.TenantId ); chatSession.Messages.Add(chatMessage); _context.ChatMessages.Add(chatMessage); @@ -59,7 +61,9 @@ public async Task Handle(CreateMyChatMessageCommand request, Can Guid.NewGuid(), chatSession.Id, ChatMessageRole.assistant, - agentReply + agentReply, + request!.UserContext!.OwnerId, + request.UserContext.TenantId ); chatSession.Messages.Add(chatMessageResponse); _context.ChatMessages.Add(chatMessageResponse); diff --git a/src/Core.Application/ChatCompletion/CreateMyChatSessionCommand.cs b/src/Core.Application/ChatCompletion/CreateMyChatSessionCommand.cs index 395ba31..513faa7 100644 --- a/src/Core.Application/ChatCompletion/CreateMyChatSessionCommand.cs +++ b/src/Core.Application/ChatCompletion/CreateMyChatSessionCommand.cs @@ -3,9 +3,7 @@ using Goodtocode.AgentFramework.Core.Domain.Actor; using Goodtocode.AgentFramework.Core.Domain.Auth; using Goodtocode.AgentFramework.Core.Domain.ChatCompletion; -using Goodtocode.Domain.Entities; using Microsoft.Agents.AI; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.AI; namespace Goodtocode.AgentFramework.Core.Application.ChatCompletion; @@ -39,7 +37,9 @@ public async Task Handle(CreateMyChatSessionCommand request, Can Guid.NewGuid(), request?.UserContext?.FirstName, request?.UserContext?.LastName, - request?.UserContext?.Email + request?.UserContext?.Email, + request!.UserContext!.OwnerId, + request.UserContext.TenantId ); _context.Actors.Add(actor); await _context.SaveChangesAsync(cancellationToken); @@ -62,7 +62,9 @@ public async Task Handle(CreateMyChatSessionCommand request, Can title, Enum.TryParse(response!.Role.ToString().ToLowerInvariant(), out var role) ? role : ChatMessageRole.assistant, request.Message!, - response.ToString() + response.ToString(), + request!.UserContext!.OwnerId, + request.UserContext.TenantId ); _context.ChatSessions.Add(chatSession); await _context.SaveChangesAsync(cancellationToken); diff --git a/src/Core.Application/Core.Application.csproj b/src/Core.Application/Core.Application.csproj index 57821d1..e1b922e 100644 --- a/src/Core.Application/Core.Application.csproj +++ b/src/Core.Application/Core.Application.csproj @@ -10,10 +10,10 @@ enable - - + + - + diff --git a/src/Core.Domain/Actor/ActorEntity.cs b/src/Core.Domain/Actor/ActorEntity.cs index 65daa24..5ca73ea 100644 --- a/src/Core.Domain/Actor/ActorEntity.cs +++ b/src/Core.Domain/Actor/ActorEntity.cs @@ -5,34 +5,32 @@ namespace Goodtocode.AgentFramework.Core.Domain.Actor; public class ActorEntity : SecuredEntity { - protected ActorEntity() { } - public string? FirstName { get; private set; } = string.Empty; public string? LastName { get; private set; } = string.Empty; public string? Email { get; private set; } = string.Empty; - public static ActorEntity Create(Guid id, string? firstName, string? lastName, string? email) + public static ActorEntity Create(Guid id, string? firstName, string? lastName, string? email, Guid ownerId, Guid tenantId) + { + return new ActorEntity(id, firstName, lastName, email, ownerId, tenantId); + } + + private ActorEntity(Guid id, string? firstName, string? lastName, string? email, Guid ownerId, Guid tenantId) : base(id, ownerId, tenantId) { - return new ActorEntity - { - Id = id == Guid.Empty ? Guid.NewGuid() : id, - FirstName = firstName, - LastName = lastName, - Email = email - }; + FirstName = firstName; + LastName = lastName; + Email = email; } public static ActorEntity Create(IUserContext userInfo) { - return new ActorEntity - { - Id = Guid.NewGuid(), - OwnerId = userInfo.OwnerId, - TenantId = userInfo.TenantId, - FirstName = userInfo.FirstName, - LastName = userInfo.LastName, - Email = userInfo.Email - }; + return new ActorEntity( + Guid.NewGuid(), + userInfo.FirstName, + userInfo.LastName, + userInfo.Email, + userInfo.OwnerId, + userInfo.TenantId + ); } public void Update(string? firstName, string? lastName, string? email) diff --git a/src/Core.Domain/ChatCompletion/ChatMessageEntity.cs b/src/Core.Domain/ChatCompletion/ChatMessageEntity.cs index 9c433a3..2b431aa 100644 --- a/src/Core.Domain/ChatCompletion/ChatMessageEntity.cs +++ b/src/Core.Domain/ChatCompletion/ChatMessageEntity.cs @@ -4,20 +4,18 @@ namespace Goodtocode.AgentFramework.Core.Domain.ChatCompletion; public class ChatMessageEntity : SecuredEntity, IDomainEntity { - protected ChatMessageEntity() { } + protected ChatMessageEntity(Guid id, Guid ownerId, Guid tenantId) : base(id, ownerId, tenantId) { } public Guid ChatSessionId { get; private set; } public ChatMessageRole Role { get; private set; } public string Content { get; private set; } = string.Empty; public virtual ChatSessionEntity? ChatSession { get; private set; } - public static ChatMessageEntity Create(Guid id, Guid chatSessionId, ChatMessageRole role, string content) + public static ChatMessageEntity Create(Guid id, Guid chatSessionId, ChatMessageRole role, string content, Guid ownerId, Guid tenantId) { - return new ChatMessageEntity + return new ChatMessageEntity(id, ownerId, tenantId) { - Id = id == Guid.Empty ? Guid.NewGuid() : id, ChatSessionId = chatSessionId, Role = role, - Content = content, - Timestamp = DateTime.UtcNow + Content = content }; } } diff --git a/src/Core.Domain/ChatCompletion/ChatSessionEntity.cs b/src/Core.Domain/ChatCompletion/ChatSessionEntity.cs index a78642a..4cabd0b 100644 --- a/src/Core.Domain/ChatCompletion/ChatSessionEntity.cs +++ b/src/Core.Domain/ChatCompletion/ChatSessionEntity.cs @@ -5,23 +5,21 @@ namespace Goodtocode.AgentFramework.Core.Domain.ChatCompletion; public class ChatSessionEntity : SecuredEntity { - protected ChatSessionEntity() { } + protected ChatSessionEntity(Guid id, Guid ownerId, Guid tenantId) : base(id, ownerId, tenantId) { } public Guid ActorId { get; private set; } public string? Title { get; private set; } = string.Empty; public virtual ICollection Messages { get; private set; } = []; - public static ChatSessionEntity Create(Guid id, Guid actorId, string? title, ChatMessageRole responseRole, string initialMessage, string responseMessage) + public static ChatSessionEntity Create(Guid id, Guid actorId, string? title, ChatMessageRole responseRole, string initialMessage, string responseMessage, Guid ownerId, Guid tenantId) { - var session = new ChatSessionEntity + var session = new ChatSessionEntity(id, ownerId, tenantId) { - Id = id == Guid.Empty ? Guid.NewGuid() : id, ActorId = actorId, Title = title, - Timestamp = DateTime.UtcNow }; - session.Messages.Add(ChatMessageEntity.Create(Guid.NewGuid(), session.Id, ChatMessageRole.user, initialMessage)); - session.Messages.Add(ChatMessageEntity.Create(Guid.NewGuid(), session.Id, responseRole, responseMessage)); + session.Messages.Add(ChatMessageEntity.Create(Guid.NewGuid(), session.Id, ChatMessageRole.user, initialMessage, ownerId, tenantId)); + session.Messages.Add(ChatMessageEntity.Create(Guid.NewGuid(), session.Id, responseRole, responseMessage, ownerId, tenantId)); return session; } diff --git a/src/Core.Domain/Core.Domain.csproj b/src/Core.Domain/Core.Domain.csproj index e54147d..5cca5d0 100644 --- a/src/Core.Domain/Core.Domain.csproj +++ b/src/Core.Domain/Core.Domain.csproj @@ -12,6 +12,6 @@ - + diff --git a/src/Infrastructure.AgentFramework/Infrastructure.AgentFramework.csproj b/src/Infrastructure.AgentFramework/Infrastructure.AgentFramework.csproj index 799d99a..df70372 100644 --- a/src/Infrastructure.AgentFramework/Infrastructure.AgentFramework.csproj +++ b/src/Infrastructure.AgentFramework/Infrastructure.AgentFramework.csproj @@ -12,11 +12,11 @@ - - - - - + + + + + diff --git a/src/Infrastructure.SqlServer/Infrastructure.SqlServer.csproj b/src/Infrastructure.SqlServer/Infrastructure.SqlServer.csproj index 9f7ef2e..82eeabe 100644 --- a/src/Infrastructure.SqlServer/Infrastructure.SqlServer.csproj +++ b/src/Infrastructure.SqlServer/Infrastructure.SqlServer.csproj @@ -10,13 +10,16 @@ - - - - - - - + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + diff --git a/src/Infrastructure.SqlServer/Migrations/20260208060746_InitialCreate.Designer.cs b/src/Infrastructure.SqlServer/Migrations/20260214043857_InitialCreate.Designer.cs similarity index 87% rename from src/Infrastructure.SqlServer/Migrations/20260208060746_InitialCreate.Designer.cs rename to src/Infrastructure.SqlServer/Migrations/20260214043857_InitialCreate.Designer.cs index 94032b3..ee03af8 100644 --- a/src/Infrastructure.SqlServer/Migrations/20260208060746_InitialCreate.Designer.cs +++ b/src/Infrastructure.SqlServer/Migrations/20260214043857_InitialCreate.Designer.cs @@ -12,7 +12,7 @@ namespace Goodtocode.AgentFramework.Infrastructure.SqlServer.Migrations { [DbContext(typeof(AgentFrameworkContext))] - [Migration("20260208060746_InitialCreate")] + [Migration("20260214043857_InitialCreate")] partial class InitialCreate { /// @@ -34,9 +34,15 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("uniqueidentifier"); + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + b.Property("CreatedOn") .HasColumnType("datetime2"); + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + b.Property("DeletedOn") .HasColumnType("datetime2"); @@ -49,6 +55,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("LastName") .HasColumnType("NVARCHAR(200)"); + b.Property("ModifiedBy") + .HasColumnType("uniqueidentifier"); + b.Property("ModifiedOn") .HasColumnType("datetime2"); @@ -94,12 +103,21 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("nvarchar(max)"); + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + b.Property("CreatedOn") .HasColumnType("datetime2"); + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + b.Property("DeletedOn") .HasColumnType("datetime2"); + b.Property("ModifiedBy") + .HasColumnType("uniqueidentifier"); + b.Property("ModifiedOn") .HasColumnType("datetime2"); @@ -143,12 +161,21 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("ActorId") .HasColumnType("uniqueidentifier"); + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + b.Property("CreatedOn") .HasColumnType("datetime2"); + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + b.Property("DeletedOn") .HasColumnType("datetime2"); + b.Property("ModifiedBy") + .HasColumnType("uniqueidentifier"); + b.Property("ModifiedOn") .HasColumnType("datetime2"); diff --git a/src/Infrastructure.SqlServer/Migrations/20260208060746_InitialCreate.cs b/src/Infrastructure.SqlServer/Migrations/20260214043857_InitialCreate.cs similarity index 87% rename from src/Infrastructure.SqlServer/Migrations/20260208060746_InitialCreate.cs rename to src/Infrastructure.SqlServer/Migrations/20260214043857_InitialCreate.cs index 89c8dc5..dbb3e29 100644 --- a/src/Infrastructure.SqlServer/Migrations/20260208060746_InitialCreate.cs +++ b/src/Infrastructure.SqlServer/Migrations/20260214043857_InitialCreate.cs @@ -24,7 +24,10 @@ protected override void Up(MigrationBuilder migrationBuilder) DeletedOn = table.Column(type: "datetime2", nullable: true), Timestamp = table.Column(type: "datetimeoffset", nullable: false), OwnerId = table.Column(type: "UNIQUEIDENTIFIER", nullable: false), - TenantId = table.Column(type: "UNIQUEIDENTIFIER", nullable: false) + TenantId = table.Column(type: "UNIQUEIDENTIFIER", nullable: false), + CreatedBy = table.Column(type: "uniqueidentifier", nullable: false), + ModifiedBy = table.Column(type: "uniqueidentifier", nullable: true), + DeletedBy = table.Column(type: "uniqueidentifier", nullable: true) }, constraints: table => { @@ -44,7 +47,10 @@ protected override void Up(MigrationBuilder migrationBuilder) DeletedOn = table.Column(type: "datetime2", nullable: true), Timestamp = table.Column(type: "datetimeoffset", nullable: false), OwnerId = table.Column(type: "uniqueidentifier", nullable: false), - TenantId = table.Column(type: "uniqueidentifier", nullable: false) + TenantId = table.Column(type: "uniqueidentifier", nullable: false), + CreatedBy = table.Column(type: "uniqueidentifier", nullable: false), + ModifiedBy = table.Column(type: "uniqueidentifier", nullable: true), + DeletedBy = table.Column(type: "uniqueidentifier", nullable: true) }, constraints: table => { @@ -65,7 +71,10 @@ protected override void Up(MigrationBuilder migrationBuilder) DeletedOn = table.Column(type: "datetime2", nullable: true), Timestamp = table.Column(type: "datetimeoffset", nullable: false), OwnerId = table.Column(type: "uniqueidentifier", nullable: false), - TenantId = table.Column(type: "uniqueidentifier", nullable: false) + TenantId = table.Column(type: "uniqueidentifier", nullable: false), + CreatedBy = table.Column(type: "uniqueidentifier", nullable: false), + ModifiedBy = table.Column(type: "uniqueidentifier", nullable: true), + DeletedBy = table.Column(type: "uniqueidentifier", nullable: true) }, constraints: table => { diff --git a/src/Infrastructure.SqlServer/Migrations/AgentFrameworkContextModelSnapshot.cs b/src/Infrastructure.SqlServer/Migrations/AgentFrameworkContextModelSnapshot.cs index 4b73088..228daee 100644 --- a/src/Infrastructure.SqlServer/Migrations/AgentFrameworkContextModelSnapshot.cs +++ b/src/Infrastructure.SqlServer/Migrations/AgentFrameworkContextModelSnapshot.cs @@ -31,9 +31,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("uniqueidentifier"); + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + b.Property("CreatedOn") .HasColumnType("datetime2"); + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + b.Property("DeletedOn") .HasColumnType("datetime2"); @@ -46,6 +52,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("LastName") .HasColumnType("NVARCHAR(200)"); + b.Property("ModifiedBy") + .HasColumnType("uniqueidentifier"); + b.Property("ModifiedOn") .HasColumnType("datetime2"); @@ -91,12 +100,21 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("nvarchar(max)"); + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + b.Property("CreatedOn") .HasColumnType("datetime2"); + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + b.Property("DeletedOn") .HasColumnType("datetime2"); + b.Property("ModifiedBy") + .HasColumnType("uniqueidentifier"); + b.Property("ModifiedOn") .HasColumnType("datetime2"); @@ -140,12 +158,21 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("ActorId") .HasColumnType("uniqueidentifier"); + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + b.Property("CreatedOn") .HasColumnType("datetime2"); + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + b.Property("DeletedOn") .HasColumnType("datetime2"); + b.Property("ModifiedBy") + .HasColumnType("uniqueidentifier"); + b.Property("ModifiedOn") .HasColumnType("datetime2"); diff --git a/src/Infrastructure.SqlServer/Persistence/AgentFrameworkContext.cs b/src/Infrastructure.SqlServer/Persistence/AgentFrameworkContext.cs index c2923bb..0c8219c 100644 --- a/src/Infrastructure.SqlServer/Persistence/AgentFrameworkContext.cs +++ b/src/Infrastructure.SqlServer/Persistence/AgentFrameworkContext.cs @@ -1,9 +1,9 @@ -using System.Reflection; -using Goodtocode.AgentFramework.Core.Application.Abstractions; +using Goodtocode.AgentFramework.Core.Application.Abstractions; using Goodtocode.AgentFramework.Core.Domain.Actor; using Goodtocode.AgentFramework.Core.Domain.ChatCompletion; using Goodtocode.Domain.Entities; using Microsoft.EntityFrameworkCore.ChangeTracking; +using System.Reflection; namespace Goodtocode.AgentFramework.Infrastructure.SqlServer.Persistence; @@ -34,67 +34,37 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) public override Task SaveChangesAsync(CancellationToken cancellationToken = default) { - SetSecurityFields(); - SetAuditFields(); + SetAuditAndSecurityFields(); return base.SaveChangesAsync(cancellationToken); } - private void SetSecurityFields() + private void SetAuditAndSecurityFields() { - if (_currentUserContext is null || - _currentUserContext.OwnerId == Guid.Empty || - _currentUserContext.TenantId == Guid.Empty) + foreach (var entry in ChangeTracker.Entries()) { - return; - } - - var entries = ChangeTracker.Entries() - .Where(e => e.State == EntityState.Added && HasSecurityFields(e)); - - foreach (var entry in entries) - { - SetIfEmpty(entry, "OwnerId", _currentUserContext.OwnerId); - SetIfEmpty(entry, "TenantId", _currentUserContext.TenantId); - } - } - - private static bool HasSecurityFields(EntityEntry entry) => - entry.Metadata.FindProperty("OwnerId") != null && - entry.Metadata.FindProperty("TenantId") != null; - - private static void SetIfEmpty(EntityEntry entry, string propertyName, Guid value) - { - var property = entry.Property(propertyName); - if (property.CurrentValue is Guid guid && guid == Guid.Empty) - { - property.CurrentValue = value; - } - } - - private void SetAuditFields() - { - var entries = ChangeTracker.Entries() - .Where(e => IsDomainEntity(e.Entity) && - (e.State == EntityState.Modified || e.State == EntityState.Added || e.State == EntityState.Deleted)); - - foreach (var entry in entries) - { - dynamic entity = entry.Entity; - if (entry.State == EntityState.Added) - { - entity.SetCreatedOn(DateTime.UtcNow); - entity.SetModifiedOn(null); - entity.SetDeletedOn(null); - } - else if (entry.State == EntityState.Modified) + if (entry.Entity is IAuditable auditable) { - entity.SetModifiedOn(DateTime.UtcNow); - entity.SetDeletedOn(null); + if (entry.State == EntityState.Modified) + { + auditable.MarkModified(); + auditable.MarkDeleted(); + } + else if (entry.State == EntityState.Deleted) + { + auditable.MarkDeleted(); + entry.State = EntityState.Modified; + } } - else if (entry.State == EntityState.Deleted) + + if (entry.Entity is ISecurable securable && _currentUserContext is not null) { - entity.SetDeletedOn(DateTime.UtcNow); - entry.State = EntityState.Modified; + if (entry.State == EntityState.Added) + { + if (securable.OwnerId == Guid.Empty) + securable.ChangeOwner(_currentUserContext.OwnerId); + if (securable.TenantId == Guid.Empty) + securable.ChangeTenant(_currentUserContext.TenantId); + } } } } diff --git a/src/Presentation.Blazor/Clients/BackendApiClient.g.cs b/src/Presentation.Blazor/Clients/BackendApiClient.g.cs index c9de742..0740262 100644 --- a/src/Presentation.Blazor/Clients/BackendApiClient.g.cs +++ b/src/Presentation.Blazor/Clients/BackendApiClient.g.cs @@ -1896,9 +1896,6 @@ public partial class SaveMyActorCommand [System.Text.Json.Serialization.JsonPropertyName("Email")] public string Email { get; set; } - [System.Text.Json.Serialization.JsonPropertyName("TenantId")] - public System.Guid TenantId { get; set; } - [System.Text.Json.Serialization.JsonPropertyName("UserContext")] public IUserContext UserContext { get; set; } diff --git a/src/Presentation.Blazor/Presentation.Blazor.csproj b/src/Presentation.Blazor/Presentation.Blazor.csproj index 6af47fe..5290a7d 100644 --- a/src/Presentation.Blazor/Presentation.Blazor.csproj +++ b/src/Presentation.Blazor/Presentation.Blazor.csproj @@ -14,13 +14,13 @@ - + - - - - - + + + + + diff --git a/src/Presentation.Blazor/Services/UserSyncService.cs b/src/Presentation.Blazor/Services/UserSyncService.cs index 3862cce..2d7e78e 100644 --- a/src/Presentation.Blazor/Services/UserSyncService.cs +++ b/src/Presentation.Blazor/Services/UserSyncService.cs @@ -40,7 +40,6 @@ public async Task SyncUserAsync(ClaimsPrincipal? user) { await HandleApiException(() => _apiClient.SaveMyActorAsync(new SaveMyActorCommand { - TenantId = _userContext.TenantId, FirstName = _userContext.Givenname, LastName = _userContext.Surname, Email = _userContext.Email diff --git a/src/Presentation.WebApi/Presentation.WebApi.csproj b/src/Presentation.WebApi/Presentation.WebApi.csproj index 213d0f7..19199df 100644 --- a/src/Presentation.WebApi/Presentation.WebApi.csproj +++ b/src/Presentation.WebApi/Presentation.WebApi.csproj @@ -18,18 +18,18 @@ - - - - - + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/src/Tests.Integration/Actor/CreateActorCommandStepDefinitions.cs b/src/Tests.Integration/Actor/CreateActorCommandStepDefinitions.cs index 6d5151d..dd5142c 100644 --- a/src/Tests.Integration/Actor/CreateActorCommandStepDefinitions.cs +++ b/src/Tests.Integration/Actor/CreateActorCommandStepDefinitions.cs @@ -55,7 +55,7 @@ public async Task WhenICreateAAuthor() { if (_exists) { - var actor = ActorEntity.Create(_id, "John", "Doe", "jdoe@goodtocode.com"); + var actor = ActorEntity.Create(_id, "John", "Doe", "jdoe@goodtocode.com", _ownerId, _tenantId); context.Actors.Add(actor); await context.SaveChangesAsync(CancellationToken.None); } diff --git a/src/Tests.Integration/Actor/DeleteActorCommandStepDefinitions.cs b/src/Tests.Integration/Actor/DeleteActorCommandStepDefinitions.cs index d1ea451..242cd36 100644 --- a/src/Tests.Integration/Actor/DeleteActorCommandStepDefinitions.cs +++ b/src/Tests.Integration/Actor/DeleteActorCommandStepDefinitions.cs @@ -34,7 +34,7 @@ public async Task WhenIDeleteTheAuthor() { if (_exists) { - var actor = ActorEntity.Create(_id, "John", "Doe", "jdoe@goodtocode.com"); + var actor = ActorEntity.Create(_id, "John", "Doe", "jdoe@goodtocode.com", userContext.OwnerId, userContext.TenantId); context.Actors.Add(actor); await context.SaveChangesAsync(CancellationToken.None); } diff --git a/src/Tests.Integration/Actor/GetActorByOwnerIdQueryStepDefinitions.cs b/src/Tests.Integration/Actor/GetActorByOwnerIdQueryStepDefinitions.cs index a5c1eff..4fa3438 100644 --- a/src/Tests.Integration/Actor/GetActorByOwnerIdQueryStepDefinitions.cs +++ b/src/Tests.Integration/Actor/GetActorByOwnerIdQueryStepDefinitions.cs @@ -33,7 +33,7 @@ public async Task WhenIGetAAuthor() { if (_exists) { - var actor = ActorEntity.Create(userContext.OwnerId, "John", "Doe", "jdoe@goodtocode.com"); + var actor = ActorEntity.Create(userContext.OwnerId, "John", "Doe", "jdoe@goodtocode.com", userContext.OwnerId, userContext.TenantId); context.Actors.Add(actor); await context.SaveChangesAsync(CancellationToken.None); } diff --git a/src/Tests.Integration/Actor/GetActorQueryStepDefinitions.cs b/src/Tests.Integration/Actor/GetActorQueryStepDefinitions.cs index 50eb78a..5ef8da4 100644 --- a/src/Tests.Integration/Actor/GetActorQueryStepDefinitions.cs +++ b/src/Tests.Integration/Actor/GetActorQueryStepDefinitions.cs @@ -35,7 +35,7 @@ public async Task WhenIGetAAuthor() { if (_exists) { - var actor = ActorEntity.Create(_id, "John", "Doe", "jdoe@goodtocode.com"); + var actor = ActorEntity.Create(_id, "John", "Doe", "jdoe@goodtocode.com", userContext.OwnerId, userContext.TenantId); context.Actors.Add(actor); await context.SaveChangesAsync(CancellationToken.None); } diff --git a/src/Tests.Integration/Actor/SaveActorCommandStepDefinitions.cs b/src/Tests.Integration/Actor/SaveActorCommandStepDefinitions.cs index d2d9b45..6a7cb3c 100644 --- a/src/Tests.Integration/Actor/SaveActorCommandStepDefinitions.cs +++ b/src/Tests.Integration/Actor/SaveActorCommandStepDefinitions.cs @@ -55,14 +55,13 @@ public async Task WhenICreateAAuthor() { if (_exists) { - var actor = ActorEntity.Create(_id, "John", "Doe", "jdoe@goodtocode.com"); + var actor = ActorEntity.Create(_id, "John", "Doe", "jdoe@goodtocode.com", userContext.OwnerId, userContext.TenantId); context.Actors.Add(actor); await context.SaveChangesAsync(CancellationToken.None); } var request = new SaveMyActorCommand() { - TenantId = _tenantId, FirstName = _name.Split(" ").FirstOrDefault(), LastName = _name.Split(" ").LastOrDefault(), Email = _email, diff --git a/src/Tests.Integration/Actor/UpdateActorCommandStepDefinitions.cs b/src/Tests.Integration/Actor/UpdateActorCommandStepDefinitions.cs index 54b7c8b..08d6e39 100644 --- a/src/Tests.Integration/Actor/UpdateActorCommandStepDefinitions.cs +++ b/src/Tests.Integration/Actor/UpdateActorCommandStepDefinitions.cs @@ -33,7 +33,7 @@ public async Task WhenIUpdateTheAuthor() { if (_exists) { - var actor = ActorEntity.Create(_id, "John", "Doe", "jdoe@goodtocode.com"); + var actor = ActorEntity.Create(_id, "John", "Doe", "jdoe@goodtocode.com", userContext.OwnerId, userContext.TenantId); context.Actors.Add(actor); await context.SaveChangesAsync(CancellationToken.None); } diff --git a/src/Tests.Integration/ChatCompletion/CreateChatMessageCommandStepDefinitions.cs b/src/Tests.Integration/ChatCompletion/CreateChatMessageCommandStepDefinitions.cs index 8e329f5..4883f47 100644 --- a/src/Tests.Integration/ChatCompletion/CreateChatMessageCommandStepDefinitions.cs +++ b/src/Tests.Integration/ChatCompletion/CreateChatMessageCommandStepDefinitions.cs @@ -46,7 +46,7 @@ public async Task WhenICreateAChatMessageWithTheMessage() var actor = ActorEntity.Create(userContext); context.Actors.Add(actor); await context.SaveChangesAsync(CancellationToken.None); - var chatSession = ChatSessionEntity.Create(_chatSessionId, actor.Id, "Test Session", ChatMessageRole.assistant, "First Message", "First Response"); + var chatSession = ChatSessionEntity.Create(_chatSessionId, actor.Id, "Test Session", ChatMessageRole.assistant, "First Message", "First Response", userContext.OwnerId, userContext.TenantId); context.ChatSessions.Add(chatSession); await context.SaveChangesAsync(CancellationToken.None); } diff --git a/src/Tests.Integration/ChatCompletion/CreateChatSessionCommandStepDefinitions.cs b/src/Tests.Integration/ChatCompletion/CreateChatSessionCommandStepDefinitions.cs index 9ae5034..a35365b 100644 --- a/src/Tests.Integration/ChatCompletion/CreateChatSessionCommandStepDefinitions.cs +++ b/src/Tests.Integration/ChatCompletion/CreateChatSessionCommandStepDefinitions.cs @@ -41,11 +41,11 @@ public void GivenTheChatSessionExists(string exists) public async Task WhenICreateAChatSessionWithTheMessage() { // Setup the database if want to test existing records - var actor = ActorEntity.Create(_actorId, "Test", "Actor", "actor@goodtocode.com"); + var actor = ActorEntity.Create(_actorId, "Test", "Actor", "actor@goodtocode.com", userContext.OwnerId, userContext.TenantId); context.Actors.Add(actor); if (_exists) { - var chatSession = ChatSessionEntity.Create(_id, _actorId, "Test Session", ChatMessageRole.assistant, _message, "First Response"); + var chatSession = ChatSessionEntity.Create(_id, _actorId, "Test Session", ChatMessageRole.assistant, _message, "First Response", userContext.OwnerId, userContext.TenantId); context.ChatSessions.Add(chatSession); } await context.SaveChangesAsync(CancellationToken.None); diff --git a/src/Tests.Integration/ChatCompletion/DeleteChatSessionCommandStepDefinitions.cs b/src/Tests.Integration/ChatCompletion/DeleteChatSessionCommandStepDefinitions.cs index 4f8415e..3ca4c4a 100644 --- a/src/Tests.Integration/ChatCompletion/DeleteChatSessionCommandStepDefinitions.cs +++ b/src/Tests.Integration/ChatCompletion/DeleteChatSessionCommandStepDefinitions.cs @@ -39,7 +39,7 @@ public async Task WhenIDeleteTheChatSession() if (_exists) { - var chatSession = ChatSessionEntity.Create(_id, Guid.NewGuid(), "Test Session", ChatMessageRole.assistant, "First Message", "First Response"); + var chatSession = ChatSessionEntity.Create(_id, Guid.NewGuid(), "Test Session", ChatMessageRole.assistant, "First Message", "First Response", userContext.OwnerId, userContext.TenantId); context.ChatSessions.Add(chatSession); await context.SaveChangesAsync(CancellationToken.None); } diff --git a/src/Tests.Integration/ChatCompletion/GetChatMessageQueryStepDefinitions.cs b/src/Tests.Integration/ChatCompletion/GetChatMessageQueryStepDefinitions.cs index 27c3ff4..cc081a8 100644 --- a/src/Tests.Integration/ChatCompletion/GetChatMessageQueryStepDefinitions.cs +++ b/src/Tests.Integration/ChatCompletion/GetChatMessageQueryStepDefinitions.cs @@ -36,8 +36,8 @@ public async Task WhenIGetAChatMessage() { if (_exists) { - var chatSession = ChatSessionEntity.Create(_chatSessionId, Guid.NewGuid(), "Test Session", ChatMessageRole.assistant, "First Message", "First Response"); - chatSession.Messages.Add(ChatMessageEntity.Create(_id, _chatSessionId, ChatMessageRole.user, "Test Message Content")); + var chatSession = ChatSessionEntity.Create(_chatSessionId, Guid.NewGuid(), "Test Session", ChatMessageRole.assistant, "First Message", "First Response", userContext.OwnerId, userContext.TenantId); + chatSession.Messages.Add(ChatMessageEntity.Create(_id, _chatSessionId, ChatMessageRole.user, "Test Message Content", userContext.OwnerId, userContext.TenantId)); context.ChatSessions.Add(chatSession); await context.SaveChangesAsync(CancellationToken.None); } diff --git a/src/Tests.Integration/ChatCompletion/GetChatMessagesPaginatedQueryStepDefinitions.cs b/src/Tests.Integration/ChatCompletion/GetChatMessagesPaginatedQueryStepDefinitions.cs index b32c635..6a99393 100644 --- a/src/Tests.Integration/ChatCompletion/GetChatMessagesPaginatedQueryStepDefinitions.cs +++ b/src/Tests.Integration/ChatCompletion/GetChatMessagesPaginatedQueryStepDefinitions.cs @@ -66,7 +66,7 @@ public async Task WhenIGetTheChatMessagesPaginated() { if (_exists) { - var chatSession = ChatSessionEntity.Create(_chatSessionId, Guid.NewGuid(), "Test Session", ChatMessageRole.assistant, "First Message", "First Response"); + var chatSession = ChatSessionEntity.Create(_chatSessionId, Guid.NewGuid(), "Test Session", ChatMessageRole.assistant, "First Message", "First Response", userContext.OwnerId, userContext.TenantId); context.ChatSessions.Add(chatSession); await context.SaveChangesAsync(CancellationToken.None); } diff --git a/src/Tests.Integration/ChatCompletion/GetMyChatSessionQueryStepDefinitions.cs b/src/Tests.Integration/ChatCompletion/GetMyChatSessionQueryStepDefinitions.cs index 1135fa8..9ad0fa7 100644 --- a/src/Tests.Integration/ChatCompletion/GetMyChatSessionQueryStepDefinitions.cs +++ b/src/Tests.Integration/ChatCompletion/GetMyChatSessionQueryStepDefinitions.cs @@ -43,7 +43,7 @@ public async Task WhenIGetAChatSession() { if (_exists) { - var chatSession = ChatSessionEntity.Create(_id, Guid.NewGuid(), "Test Session", ChatMessageRole.assistant, "First Message", "First Response"); + var chatSession = ChatSessionEntity.Create(_id, Guid.NewGuid(), "Test Session", ChatMessageRole.assistant, "First Message", "First Response", userContext.OwnerId, userContext.TenantId); context.ChatSessions.Add(chatSession); await context.SaveChangesAsync(CancellationToken.None); } diff --git a/src/Tests.Integration/ChatCompletion/GetMyChatSessionsPaginatedQueryStepDefinitions.cs b/src/Tests.Integration/ChatCompletion/GetMyChatSessionsPaginatedQueryStepDefinitions.cs index d2e9120..6e2ff11 100644 --- a/src/Tests.Integration/ChatCompletion/GetMyChatSessionsPaginatedQueryStepDefinitions.cs +++ b/src/Tests.Integration/ChatCompletion/GetMyChatSessionsPaginatedQueryStepDefinitions.cs @@ -65,7 +65,7 @@ public async Task WhenIGetTheChatSessionsPaginated() { if (_exists) { - var chatSession = ChatSessionEntity.Create(Guid.NewGuid(), Guid.NewGuid(), "Test Session", ChatMessageRole.assistant, "First Message", "First Response"); + var chatSession = ChatSessionEntity.Create(Guid.NewGuid(), Guid.NewGuid(), "Test Session", ChatMessageRole.assistant, "First Message", "First Response", userContext.OwnerId, userContext.TenantId); context.ChatSessions.Add(chatSession); await context.SaveChangesAsync(CancellationToken.None); } diff --git a/src/Tests.Integration/ChatCompletion/GetMyChatSessionsQueryStepDefinitions.cs b/src/Tests.Integration/ChatCompletion/GetMyChatSessionsQueryStepDefinitions.cs index d19e028..93a1479 100644 --- a/src/Tests.Integration/ChatCompletion/GetMyChatSessionsQueryStepDefinitions.cs +++ b/src/Tests.Integration/ChatCompletion/GetMyChatSessionsQueryStepDefinitions.cs @@ -51,7 +51,7 @@ public async Task WhenIGetTheChatSessions() { if (_exists) { - var chatSession = ChatSessionEntity.Create(Guid.NewGuid(), Guid.NewGuid(), "Test Session", ChatMessageRole.assistant, "First Message", "First Response"); + var chatSession = ChatSessionEntity.Create(Guid.NewGuid(), Guid.NewGuid(), "Test Session", ChatMessageRole.assistant, "First Message", "First Response", userContext.OwnerId, userContext.TenantId); context.ChatSessions.Add(chatSession); await context.SaveChangesAsync(CancellationToken.None); } diff --git a/src/Tests.Integration/ChatCompletion/PatchChatSessionCommandStepDefinitions.cs b/src/Tests.Integration/ChatCompletion/PatchChatSessionCommandStepDefinitions.cs index 6c8ad07..2e89765 100644 --- a/src/Tests.Integration/ChatCompletion/PatchChatSessionCommandStepDefinitions.cs +++ b/src/Tests.Integration/ChatCompletion/PatchChatSessionCommandStepDefinitions.cs @@ -47,7 +47,7 @@ public async Task WhenIPatchTheChatSession() if (_exists) { - var chatSession = ChatSessionEntity.Create(_id, Guid.NewGuid(), "Test Session", ChatMessageRole.assistant, "First Message", "First Response"); + var chatSession = ChatSessionEntity.Create(_id, Guid.NewGuid(), "Test Session", ChatMessageRole.assistant, "First Message", "First Response", userContext.OwnerId, userContext.TenantId); context.ChatSessions.Add(chatSession); await context.SaveChangesAsync(CancellationToken.None); } diff --git a/src/Tests.Integration/Tests.Integration.csproj b/src/Tests.Integration/Tests.Integration.csproj index b94e24a..4707cb1 100644 --- a/src/Tests.Integration/Tests.Integration.csproj +++ b/src/Tests.Integration/Tests.Integration.csproj @@ -18,8 +18,8 @@ - - + + From 45517227b9a6c8e1e95b6b259b8190085960745c Mon Sep 17 00:00:00 2001 From: Robert Good Date: Fri, 13 Feb 2026 22:10:50 -0800 Subject: [PATCH 2/2] parameterless constructor for EF --- src/Core.Domain/Actor/ActorEntity.cs | 1 + src/Core.Domain/ChatCompletion/ChatMessageEntity.cs | 3 ++- src/Core.Domain/ChatCompletion/ChatSessionEntity.cs | 3 ++- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Core.Domain/Actor/ActorEntity.cs b/src/Core.Domain/Actor/ActorEntity.cs index 5ca73ea..af06319 100644 --- a/src/Core.Domain/Actor/ActorEntity.cs +++ b/src/Core.Domain/Actor/ActorEntity.cs @@ -5,6 +5,7 @@ namespace Goodtocode.AgentFramework.Core.Domain.Actor; public class ActorEntity : SecuredEntity { + protected ActorEntity() : base(Guid.Empty, Guid.Empty, Guid.Empty) { } public string? FirstName { get; private set; } = string.Empty; public string? LastName { get; private set; } = string.Empty; public string? Email { get; private set; } = string.Empty; diff --git a/src/Core.Domain/ChatCompletion/ChatMessageEntity.cs b/src/Core.Domain/ChatCompletion/ChatMessageEntity.cs index 2b431aa..13ce4b3 100644 --- a/src/Core.Domain/ChatCompletion/ChatMessageEntity.cs +++ b/src/Core.Domain/ChatCompletion/ChatMessageEntity.cs @@ -4,7 +4,8 @@ namespace Goodtocode.AgentFramework.Core.Domain.ChatCompletion; public class ChatMessageEntity : SecuredEntity, IDomainEntity { - protected ChatMessageEntity(Guid id, Guid ownerId, Guid tenantId) : base(id, ownerId, tenantId) { } + protected ChatMessageEntity() : base(Guid.Empty, Guid.Empty, Guid.Empty) { } + private ChatMessageEntity(Guid id, Guid ownerId, Guid tenantId) : base(id, ownerId, tenantId) { } public Guid ChatSessionId { get; private set; } public ChatMessageRole Role { get; private set; } public string Content { get; private set; } = string.Empty; diff --git a/src/Core.Domain/ChatCompletion/ChatSessionEntity.cs b/src/Core.Domain/ChatCompletion/ChatSessionEntity.cs index 4cabd0b..0287e9d 100644 --- a/src/Core.Domain/ChatCompletion/ChatSessionEntity.cs +++ b/src/Core.Domain/ChatCompletion/ChatSessionEntity.cs @@ -5,7 +5,8 @@ namespace Goodtocode.AgentFramework.Core.Domain.ChatCompletion; public class ChatSessionEntity : SecuredEntity { - protected ChatSessionEntity(Guid id, Guid ownerId, Guid tenantId) : base(id, ownerId, tenantId) { } + protected ChatSessionEntity() : base(Guid.Empty, Guid.Empty, Guid.Empty) { } + private ChatSessionEntity(Guid id, Guid ownerId, Guid tenantId) : base(id, ownerId, tenantId) { } public Guid ActorId { get; private set; } public string? Title { get; private set; } = string.Empty;