diff --git a/src/FluentCMS.Admin/Components/Pages/ChangeSetView.razor b/src/FluentCMS.Admin/Components/Pages/ChangeSetView.razor index f3665f282..1416da24b 100644 --- a/src/FluentCMS.Admin/Components/Pages/ChangeSetView.razor +++ b/src/FluentCMS.Admin/Components/Pages/ChangeSetView.razor @@ -11,6 +11,7 @@ @inject IDeskQueues Queues @inject FluentCMS.Core.Checks.IChangeSetChecks Checks @inject IAuditReader Audit +@inject ICurrentInitiator Initiator @inject NavigationManager Navigation @inject IStringLocalizer L @@ -85,8 +86,23 @@ { } + @if (set.State == ChangeSetState.AwaitingReview || set.Approvals.Count > 0) + { +
+

@L["{0} of {1} approvals", set.Approvals.Count, set.RequiredApprovals]

+ @if (set.Approvals.Count > 0) + { + + } +
+ }
- @if (set.State == ChangeSetState.AwaitingReview) + @if (set.State == ChangeSetState.AwaitingReview && !ApprovedByMe(set)) {
@@ -155,7 +171,7 @@ { } - @if (set.State != ChangeSetState.AwaitingReview) + @if (set.State != ChangeSetState.AwaitingReview || ApprovedByMe(set)) { } @@ -501,10 +517,25 @@ private async Task ApproveAsync() { - if (await ActAsync(Approve, [ChangeSetState.AwaitingReview], () => Directory.TransitionAsync(Id, ChangeSetState.Approved), L["Approved. Publish the set to make its items live."]) is not null) + if (await ActAsync(Approve, [ChangeSetState.AwaitingReview], () => Directory.ApproveAsync(Id), null) is not { } approved) + { + return; + } + + if (approved.State == ChangeSetState.Approved) { + _message = L["Approved. Publish the set to make its items live."]; MoveOn("approved"); + return; } + + // The set stays in review until enough other people approve it (RTE-05). + _message = L["Your approval is recorded. The set needs {0} more, from other people.", approved.RequiredApprovals - approved.Approvals.Count]; + } + + private bool ApprovedByMe(ChangeSet set) + { + return Initiator.Current is UserInitiator person && set.Approvals.Any(approval => approval.Approver.UserId == person.UserId); } private Task PublishAsync() diff --git a/src/FluentCMS.Core/Builtin/Tools/DeskTools.cs b/src/FluentCMS.Core/Builtin/Tools/DeskTools.cs index eb73a543c..bb777a017 100644 --- a/src/FluentCMS.Core/Builtin/Tools/DeskTools.cs +++ b/src/FluentCMS.Core/Builtin/Tools/DeskTools.cs @@ -61,9 +61,10 @@ public sealed class DeskTools : IToolProvider new AgentTool( "get_change_set_review", "Get a change set's review status", - "Returns where a change set this agent opened stands in review: its state and since when, the reason if a person " - + "rejected it, the set it reverts, and every move it made, in order, with who made it and when. Use it to learn whether " - + "your set was approved, published or rejected and why; a rejected set's reason says what to change in a new set.", + "Returns where a change set this agent opened stands in review: its state and since when, the approvals it has and how " + + "many it needs, the reason if a person rejected it, the set it reverts, and every move it made, in order, with who made " + + "it and when. Use it to learn whether your set was approved, published or rejected and why; a rejected set's reason " + + "says what to change in a new set.", ToolSchemas.Object(ToolSchemas.Uuid("id", "The change set's id.")), readOnly: true, ReviewAsync), @@ -166,6 +167,12 @@ private static async Task ReviewAsync(ToolInvocation call, Cancellat ["stateSince"] = moves.Count > 0 ? moves[^1].Timestamp.ToUniversalTime().ToString("O") : null, ["rejectionReason"] = set.RejectionReason, ["revertsChangeSetId"] = set.RevertsChangeSetId?.ToString(), + ["requiredApprovals"] = set.RequiredApprovals, + ["approvals"] = new JsonArray([.. set.Approvals.Select(approval => (JsonNode)new JsonObject + { + ["by"] = Initiator(approval.Approver), + ["at"] = approval.ApprovedAt.ToUniversalTime().ToString("O"), + })]), ["moves"] = new JsonArray([.. moves.Select(move => (JsonNode)new JsonObject { ["move"] = move.Operation["change_set.".Length..], diff --git a/src/FluentCMS.Core/ChangeSets/ChangeSet.cs b/src/FluentCMS.Core/ChangeSets/ChangeSet.cs index 5838744d3..26c752e6f 100644 --- a/src/FluentCMS.Core/ChangeSets/ChangeSet.cs +++ b/src/FluentCMS.Core/ChangeSets/ChangeSet.cs @@ -9,8 +9,11 @@ public sealed class ChangeSet { public const int MaxSessionIdLength = 128; + public const int MaxApprovals = 10; + private readonly List _sources; private readonly List _operations = []; + private readonly List _approvals = []; private ChangeSet(Guid id, Guid siteId, string rationale, IEnumerable sources, Initiator initiator, string? sessionId) { @@ -45,6 +48,12 @@ private ChangeSet(Guid id, Guid siteId, string rationale, IEnumerable so // The published set this one undoes (CHG-06); null for every other set. public Guid? RevertsChangeSetId { get; private set; } + // How many people must approve the set before it is approved, set as it enters review (RTE-05). + public int RequiredApprovals { get; private set; } = 1; + + // A set reaches review once, and leaves it only for approved or rejected, so these all belong to its one review. + public IReadOnlyList Approvals => _approvals.AsReadOnly(); + // Operations come later, while the set is open, so an agent can open a set before its first write (CHG-02). An empty // list of sources is allowed here: requiring one is a check (CHK-01), not a rule of the model. public static Result Open(Guid siteId, string rationale, IEnumerable sources, Initiator initiator, string? sessionId = null) @@ -99,7 +108,9 @@ public static ChangeSet Restore( IEnumerable operations, ChangeSetState state, string? rejectionReason, - Guid? revertsChangeSetId = null) + Guid? revertsChangeSetId = null, + int requiredApprovals = 1, + IEnumerable? approvals = null) { ArgumentNullException.ThrowIfNull(rationale); ArgumentNullException.ThrowIfNull(sources); @@ -111,8 +122,10 @@ public static ChangeSet Restore( State = state, RejectionReason = rejectionReason, RevertsChangeSetId = revertsChangeSetId, + RequiredApprovals = requiredApprovals, }; changeSet._operations.AddRange(operations); + changeSet._approvals.AddRange(approvals ?? []); return changeSet; } @@ -247,10 +260,65 @@ public Result TransitionTo(ChangeSetState target) "A rejection needs a reason, which the change set stores; reject it with one."); } + // A set in review is approved by its approvals, never moved there directly. + if (State == ChangeSetState.AwaitingReview && target == ChangeSetState.Approved) + { + return Result.Failure( + ChangeSetErrorCodes.ApprovalsNeeded, + $"A change set in review is approved by its approvals. {ApprovalCount()}; approve it to add one."); + } + State = target; return Result.Success(); } + // Moves the set into review, needing this many approvals by as many people. + public Result EnterReview(int requiredApprovals) + { + if (requiredApprovals is < 1 or > MaxApprovals) + { + return Result.Failure( + ChangeSetErrorCodes.RequiredApprovalsInvalid, + $"A set in review needs from 1 to {MaxApprovals} approvals, and {requiredApprovals} were asked for."); + } + + var moved = TransitionTo(ChangeSetState.AwaitingReview); + if (moved.IsSuccess) + { + RequiredApprovals = requiredApprovals; + } + + return moved; + } + + // Records a person's approval, and approves the set once enough different people have. + public Result Approve(UserInitiator person, DateTimeOffset at) + { + ArgumentNullException.ThrowIfNull(person); + + if (State != ChangeSetState.AwaitingReview) + { + return CanMoveTo(ChangeSetState.Approved) is { IsSuccess: false } refused + ? refused + : Result.Failure(ChangeSetErrorCodes.TransitionNotAllowed, $"A change set is approved while it awaits review, and this one is {Describe(State)}."); + } + + if (_approvals.Any(approval => approval.Approver.UserId == person.UserId)) + { + return Result.Failure( + ChangeSetErrorCodes.ApprovalDuplicate, + $"{person.DisplayName} has already approved this change set. {ApprovalCount()}, so another person approves it next."); + } + + _approvals.Add(new ChangeSetApproval(person, at)); + if (_approvals.Count >= RequiredApprovals) + { + State = ChangeSetState.Approved; + } + + return Result.Success(); + } + public Result Reject(string reason) { var allowed = CanMoveTo(ChangeSetState.Rejected); @@ -272,6 +340,12 @@ public Result Reject(string reason) return Result.Success(); } + private string ApprovalCount() + { + var needed = RequiredApprovals == 1 ? "1 approval" : $"{RequiredApprovals} approvals, each by a different person,"; + return $"It needs {needed} and has {_approvals.Count}"; + } + private Result CanTakeOperations() { if (State != ChangeSetState.Open) diff --git a/src/FluentCMS.Core/ChangeSets/ChangeSetApproval.cs b/src/FluentCMS.Core/ChangeSets/ChangeSetApproval.cs new file mode 100644 index 000000000..f0310d883 --- /dev/null +++ b/src/FluentCMS.Core/ChangeSets/ChangeSetApproval.cs @@ -0,0 +1,6 @@ +using FluentCMS.Core.Identity; + +namespace FluentCMS.Core.ChangeSets; + +// One person's approval of a set in review (RTE-05). +public sealed record ChangeSetApproval(UserInitiator Approver, DateTimeOffset ApprovedAt); diff --git a/src/FluentCMS.Core/ChangeSets/ChangeSetErrorCodes.cs b/src/FluentCMS.Core/ChangeSets/ChangeSetErrorCodes.cs index 5205fa362..5a4f62b1f 100644 --- a/src/FluentCMS.Core/ChangeSets/ChangeSetErrorCodes.cs +++ b/src/FluentCMS.Core/ChangeSets/ChangeSetErrorCodes.cs @@ -30,4 +30,7 @@ public static class ChangeSetErrorCodes public const string ListStatesMissing = "list_states_missing"; public const string OffsetInvalid = "offset_invalid"; public const string LimitInvalid = "limit_invalid"; + public const string ApprovalDuplicate = "approval_duplicate"; + public const string ApprovalsNeeded = "approvals_needed"; + public const string RequiredApprovalsInvalid = "required_approvals_invalid"; } diff --git a/src/FluentCMS.Core/ChangeSets/IChangeSetDirectory.cs b/src/FluentCMS.Core/ChangeSets/IChangeSetDirectory.cs index 69bf68aef..9e0bfb42a 100644 --- a/src/FluentCMS.Core/ChangeSets/IChangeSetDirectory.cs +++ b/src/FluentCMS.Core/ChangeSets/IChangeSetDirectory.cs @@ -40,4 +40,8 @@ Task> AddToSessionAsync( Task> RevertAsync(Guid changeSetId, string rationale, CancellationToken cancellationToken = default); Task> RejectAsync(Guid changeSetId, string reason, CancellationToken cancellationToken = default); + + // A person's approval of a set in review, which approves the set once it has as many as it needs (RTE-05). Moving a set in + // review to approved through TransitionAsync approves it the same way. + Task> ApproveAsync(Guid changeSetId, CancellationToken cancellationToken = default); } diff --git a/src/FluentCMS.Infrastructure/ChangeSets/ApprovalOptions.cs b/src/FluentCMS.Infrastructure/ChangeSets/ApprovalOptions.cs new file mode 100644 index 000000000..8347ed3e0 --- /dev/null +++ b/src/FluentCMS.Infrastructure/ChangeSets/ApprovalOptions.cs @@ -0,0 +1,22 @@ +using FluentCMS.Core.ChangeSets; +using Microsoft.Extensions.Options; + +namespace FluentCMS.Infrastructure.ChangeSets; + +public sealed class ApprovalOptions +{ + public const string SectionName = "Approvals"; + + // How many people approve a set in review when nothing asks for more (RTE-05). + public int Required { get; set; } = 1; +} + +internal sealed class ApprovalOptionsValidation : IValidateOptions +{ + public ValidateOptionsResult Validate(string? name, ApprovalOptions options) + { + return options.Required is >= 1 and <= ChangeSet.MaxApprovals + ? ValidateOptionsResult.Success + : ValidateOptionsResult.Fail($"Approvals__Required is {options.Required}; it must be from 1 to {ChangeSet.MaxApprovals}."); + } +} diff --git a/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetApprovalConfiguration.cs b/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetApprovalConfiguration.cs new file mode 100644 index 000000000..a1dba95b2 --- /dev/null +++ b/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetApprovalConfiguration.cs @@ -0,0 +1,18 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FluentCMS.Infrastructure.ChangeSets; + +internal sealed class ChangeSetApprovalConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder row) + { + row.ToTable("ChangeSetApprovals"); + + // One approval per person, also when the same person approves twice at once. + row.HasKey(approval => new { approval.ChangeSetId, approval.UserId }); + row.Property(approval => approval.ApproverName).HasMaxLength(256).IsRequired(); + + row.HasOne().WithMany().HasForeignKey(approval => approval.ChangeSetId).OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetApprovalRow.cs b/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetApprovalRow.cs new file mode 100644 index 000000000..16a9674fe --- /dev/null +++ b/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetApprovalRow.cs @@ -0,0 +1,13 @@ +namespace FluentCMS.Infrastructure.ChangeSets; + +// One person's approval of a set in review; inserted once and never changed. +public sealed class ChangeSetApprovalRow +{ + public Guid ChangeSetId { get; set; } + + public Guid UserId { get; set; } + + public string ApproverName { get; set; } = string.Empty; + + public DateTimeOffset ApprovedAt { get; set; } +} diff --git a/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetConfiguration.cs b/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetConfiguration.cs index 475df94d3..06b5795f8 100644 --- a/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetConfiguration.cs +++ b/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetConfiguration.cs @@ -10,9 +10,15 @@ internal sealed class ChangeSetConfiguration : IEntityTypeConfiguration row) { // A set carries a rejection reason exactly when it is rejected, whatever the mapping code does (CHG-08). - row.ToTable("ChangeSets", table => table.HasCheckConstraint( - "CK_ChangeSets_RejectionReason", - $"(\"State\" = '{ChangeSetState.Rejected}') = (\"RejectionReason\" IS NOT NULL)")); + row.ToTable("ChangeSets", table => + { + table.HasCheckConstraint( + "CK_ChangeSets_RejectionReason", + $"(\"State\" = '{ChangeSetState.Rejected}') = (\"RejectionReason\" IS NOT NULL)"); + table.HasCheckConstraint( + "CK_ChangeSets_RequiredApprovals", + $"\"RequiredApprovals\" BETWEEN 1 AND {ChangeSet.MaxApprovals}"); + }); row.HasKey(set => set.Id); row.Property(set => set.Id).ValueGeneratedNever(); @@ -21,6 +27,7 @@ public void Configure(EntityTypeBuilder row) row.Property(set => set.InitiatorName).HasMaxLength(256).IsRequired(); row.Property(set => set.SessionId).HasMaxLength(ChangeSet.MaxSessionIdLength); row.Property(set => set.State).HasConversion().HasMaxLength(32); + row.Property(set => set.RequiredApprovals).HasDefaultValue(1); row.HasOne().WithMany().HasForeignKey(set => set.SiteId).OnDelete(DeleteBehavior.Restrict); row.HasOne().WithMany().HasForeignKey(set => set.RevertsChangeSetId).OnDelete(DeleteBehavior.Restrict); diff --git a/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs b/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs index 335b94fd3..2f1c5eed8 100644 --- a/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs +++ b/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs @@ -13,6 +13,7 @@ using FluentCMS.Infrastructure.Media; using FluentCMS.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; namespace FluentCMS.Infrastructure.ChangeSets; @@ -25,7 +26,9 @@ internal sealed class ChangeSetDirectory( IAuditLog auditLog, ICurrentInitiator currentInitiator, IAgentPermissions permissions, - IEnumerable events) : IChangeSetDirectory + IEnumerable events, + IOptions approvalOptions, + TimeProvider clock) : IChangeSetDirectory { // AgentActivity counts these events for the change-sets-per-day cap. internal const string OpenedOperation = "change_set.opened"; @@ -191,10 +194,68 @@ public Task> TransitionAsync(Guid changeSetId, ChangeSetState ChangeSetState.Reverted => MoveAsync(changeSetId, target, _ => Result.Failure( ChangeSetErrorCodes.TransitionNotAllowed, "A change set is reverted by publishing the change set that reverting it opens; revert it instead."), cancellationToken), + ChangeSetState.AwaitingReview => MoveAsync(changeSetId, target, set => set.EnterReview(approvalOptions.Value.Required), cancellationToken), + ChangeSetState.Approved => ApproveAsync(changeSetId, cancellationToken), _ => MoveAsync(changeSetId, target, set => set.TransitionTo(target), cancellationToken), }; } + // A person's approval of a set in review counts toward the approvals it needs, and the one that completes them approves the + // set in the same unit of work (RTE-05). A set that skips review, as routing's auto-publish does, moves to approved directly. + public async Task> ApproveAsync(Guid changeSetId, CancellationToken cancellationToken = default) + { + var stored = await ReadAsync(changeSetId, cancellationToken); + if (stored is null) + { + return NotFound(changeSetId); + } + + if (stored.ChangeSet.State != ChangeSetState.AwaitingReview) + { + return await MoveAsync(changeSetId, ChangeSetState.Approved, set => set.TransitionTo(ChangeSetState.Approved), cancellationToken); + } + + var allowed = await permissions.CheckTransitionAsync(stored.ChangeSet, ChangeSetState.Approved, cancellationToken); + if (!allowed.IsSuccess) + { + return Result.Failure(allowed.Errors); + } + + // Agents and engines are refused above, so whoever is left is a person. + var person = Initiator() as UserInitiator + ?? throw new InvalidOperationException("Only a person approves a change set in review (MCP-06)."); + var set = stored.ChangeSet; + var approved = set.Approve(person, clock.GetUtcNow()); + if (!approved.IsSuccess) + { + return Result.Failure(approved.Errors); + } + + var approval = set.Approvals[^1]; + await using var scope = await unitOfWork.BeginAsync(cancellationToken); + if (!await UpdateAsync(stored.Revision, set, cancellationToken)) + { + return Conflict(set.Id); + } + + context.ChangeSetApprovals.Add(new ChangeSetApprovalRow + { + ChangeSetId = set.Id, + UserId = person.UserId, + ApproverName = person.DisplayName, + ApprovedAt = approval.ApprovedAt, + }); + await auditLog.AppendAsync( + new NewAuditEvent("change_set.approval_recorded") { SubjectType = SubjectType, SubjectId = set.Id, ChangeSetId = set.Id }, + cancellationToken); + if (set.State == ChangeSetState.Approved) + { + await AppendTransitionAsync(set, cancellationToken); + } + + return await CommitAsync(scope, set, Conflict(set.Id), cancellationToken); + } + public async Task> PublishAsync(Guid changeSetId, CancellationToken cancellationToken = default) { var stored = await ReadAsync(changeSetId, cancellationToken); @@ -630,6 +691,7 @@ private async Task InsertAsync(ChangeSet set, CancellationToken cancellationToke SessionId = set.SessionId, State = set.State, RevertsChangeSetId = set.RevertsChangeSetId, + RequiredApprovals = set.RequiredApprovals, }); await auditLog.AppendAsync( @@ -717,6 +779,7 @@ private async Task UpdateAsync(int revision, ChangeSet set, CancellationTo .SetProperty(row => row.Revision, revision + 1) .SetProperty(row => row.State, set.State) .SetProperty(row => row.RejectionReason, set.RejectionReason) + .SetProperty(row => row.RequiredApprovals, set.RequiredApprovals) .SetProperty(row => row.Sources, sources), cancellationToken); @@ -1401,6 +1464,13 @@ private static ContentTypeDefinition ReadDefinition(string subject, string json) .Where(operation => operation.ChangeSetId == id) .OrderBy(operation => operation.Position) .ToListAsync(cancellationToken); + // Ordered here, since SQLite cannot order by a DateTimeOffset; a set has at most ten. + var approvals = (await context.ChangeSetApprovals + .AsNoTracking() + .Where(approval => approval.ChangeSetId == id) + .ToListAsync(cancellationToken)) + .OrderBy(approval => approval.ApprovedAt) + .ThenBy(approval => approval.UserId); var set = ChangeSet.Restore( row.Id, @@ -1417,7 +1487,9 @@ private static ContentTypeDefinition ReadDefinition(string subject, string json) operation.ProposedVersion)), row.State, row.RejectionReason, - row.RevertsChangeSetId); + row.RevertsChangeSetId, + row.RequiredApprovals, + approvals.Select(approval => new ChangeSetApproval(new UserInitiator(approval.UserId, approval.ApproverName), approval.ApprovedAt))); return new StoredChangeSet(set, row.Revision); } diff --git a/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetRow.cs b/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetRow.cs index 6ef704c70..4e86288dc 100644 --- a/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetRow.cs +++ b/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetRow.cs @@ -27,6 +27,8 @@ public sealed class ChangeSetRow public Guid? RevertsChangeSetId { get; set; } + public int RequiredApprovals { get; set; } = 1; + // Every write moves it on and only applies to the revision it read, so of two concurrent writers the second changes nothing. public int Revision { get; set; } } diff --git a/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs b/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs index 1232a1133..ca02f135e 100644 --- a/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs +++ b/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs @@ -54,7 +54,7 @@ public static IServiceCollection AddFluentCmsInfrastructure(this IServiceCollect AddIdentity(services); AddAudit(services); AddSchema(services); - AddChangeSets(services); + AddChangeSets(services, configuration); AddAgents(services); AddContent(services, configuration); AddMedia(services, configuration); @@ -120,8 +120,11 @@ private static void AddSchema(IServiceCollection services) services.AddScoped(); } - private static void AddChangeSets(IServiceCollection services) + private static void AddChangeSets(IServiceCollection services, IConfiguration configuration) { + services.AddOptions().Bind(configuration.GetSection(ApprovalOptions.SectionName)).ValidateOnStart(); + services.AddSingleton, ApprovalOptionsValidation>(); + // The content type directory writes its operations through the same instance (SCH-05). services.AddScoped(); services.AddScoped(provider => provider.GetRequiredService()); diff --git a/src/FluentCMS.Infrastructure/Persistence/FluentCmsDbContext.cs b/src/FluentCMS.Infrastructure/Persistence/FluentCmsDbContext.cs index a32f0b998..727f0c677 100644 --- a/src/FluentCMS.Infrastructure/Persistence/FluentCmsDbContext.cs +++ b/src/FluentCMS.Infrastructure/Persistence/FluentCmsDbContext.cs @@ -34,6 +34,8 @@ public abstract class FluentCmsDbContext(DbContextOptions options) : IdentityDbC public DbSet ChangeSetOperations => Set(); + public DbSet ChangeSetApprovals => Set(); + public DbSet AgentCredentials => Set(); public DbSet ToolCalls => Set(); @@ -88,6 +90,7 @@ protected override void OnModelCreating(ModelBuilder builder) builder.ApplyConfiguration(new ContentTypeVersionConfiguration()); builder.ApplyConfiguration(new ChangeSetConfiguration()); builder.ApplyConfiguration(new ChangeSetOperationConfiguration()); + builder.ApplyConfiguration(new ChangeSetApprovalConfiguration()); builder.ApplyConfiguration(new AgentCredentialConfiguration()); builder.ApplyConfiguration(new ToolCallConfiguration()); builder.ApplyConfiguration(new EntryConfiguration()); diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261026124757_ChangeSetApprovals.Designer.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261026124757_ChangeSetApprovals.Designer.cs new file mode 100644 index 000000000..41f74e4cd --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261026124757_ChangeSetApprovals.Designer.cs @@ -0,0 +1,1842 @@ +// +using System; +using System.Collections.Generic; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FluentCMS.Infrastructure.Persistence.Migrations.PostgreSql +{ + [DbContext(typeof(PostgreSqlFluentCmsDbContext))] + [Migration("20261026124757_ChangeSetApprovals")] + partial class ChangeSetApprovals + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.13") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.AgentCredentialRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CallPeriod") + .HasColumnType("interval"); + + b.Property("CallsPerPeriod") + .HasColumnType("integer"); + + b.Property("ChangeSetsPerDay") + .HasColumnType("integer"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.PrimitiveCollection>("Fields") + .HasColumnType("text[]"); + + b.Property("IssuedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ItemsPerChangeSet") + .HasColumnType("integer"); + + b.Property("KeyHash") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("LlmSpendPerPeriod") + .HasColumnType("numeric"); + + b.Property("LlmSpendPeriod") + .HasColumnType("interval"); + + b.PrimitiveCollection("Operations") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.PrimitiveCollection>("Sites") + .IsRequired() + .HasColumnType("uuid[]"); + + b.PrimitiveCollection>("Types") + .HasColumnType("text[]"); + + b.HasKey("Id"); + + b.HasIndex("KeyHash") + .IsUnique(); + + b.ToTable("AgentCredentials", null, t => + { + t.HasCheckConstraint("CK_AgentCredentials_Caps", "\"CallsPerPeriod\" >= 1 AND \"ItemsPerChangeSet\" >= 1 AND \"ChangeSetsPerDay\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.ToolCallRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AgentCredentialId") + .HasColumnType("uuid"); + + b.Property("AgentName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("TaskId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("Tool") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("AgentCredentialId", "Timestamp"); + + b.ToTable("ToolCalls", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Audit.AuditEventRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AgentCredentialId") + .HasColumnType("uuid"); + + b.Property("ChangeSetId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("InitiatorKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("InitiatorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Operation") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("PreviousVersionId") + .HasColumnType("uuid"); + + b.Property("PromptHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.PrimitiveCollection>("Sources") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("SubjectId") + .HasColumnType("uuid"); + + b.Property("SubjectType") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AgentCredentialId"); + + b.HasIndex("ChangeSetId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("Operation", "Timestamp"); + + b.HasIndex("SubjectType", "SubjectId"); + + b.ToTable("AuditEvents", null, t => + { + t.HasCheckConstraint("CK_AuditEvents_AgentCredentialId", "(\"InitiatorKind\" = 'agent') = (\"AgentCredentialId\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetApprovalRow", b => + { + b.Property("ChangeSetId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("ApprovedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ApproverName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("ChangeSetId", "UserId"); + + b.ToTable("ChangeSetApprovals", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetOperationRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BaseVersion") + .HasColumnType("integer"); + + b.Property("ChangeSetId") + .HasColumnType("uuid"); + + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("ItemKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("ProposedVersion") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId", "Position") + .IsUnique(); + + b.HasIndex("ItemKind", "ItemId"); + + b.HasIndex("ChangeSetId", "ItemKind", "ItemId") + .IsUnique(); + + b.ToTable("ChangeSetOperations", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("InitiatorKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("InitiatorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Rationale") + .IsRequired() + .HasColumnType("text"); + + b.Property("RejectionReason") + .HasColumnType("text"); + + b.Property("RequiredApprovals") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.Property("RevertsChangeSetId") + .HasColumnType("uuid"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("SessionId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.PrimitiveCollection>("Sources") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("State") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("RevertsChangeSetId"); + + b.HasIndex("SiteId"); + + b.HasIndex("State"); + + b.HasIndex("InitiatorKind", "InitiatorId", "SessionId") + .IsUnique() + .HasFilter("\"State\" = 'Open' AND \"SessionId\" IS NOT NULL"); + + b.ToTable("ChangeSets", null, t => + { + t.HasCheckConstraint("CK_ChangeSets_RejectionReason", "(\"State\" = 'Rejected') = (\"RejectionReason\" IS NOT NULL)"); + + t.HasCheckConstraint("CK_ChangeSets_RequiredApprovals", "\"RequiredApprovals\" BETWEEN 1 AND 10"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckResultRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Check") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CheckId") + .HasColumnType("uuid"); + + b.Property("CheckType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CheckVersion") + .HasColumnType("integer"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Cost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("Findings") + .IsRequired() + .HasColumnType("text"); + + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("ItemKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("ItemVersion") + .HasColumnType("integer"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ModelCallId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("PromptHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Provider") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("RunId") + .HasColumnType("uuid"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.HasKey("Id"); + + b.HasIndex("RunId", "Position") + .IsUnique(); + + b.ToTable("CheckResults", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ActiveVersion") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Name") + .IsUnique(); + + b.ToTable("Checks", null, t => + { + t.HasCheckConstraint("CK_Checks_ActiveVersion", "\"ActiveVersion\" IS NULL OR (\"ActiveVersion\" >= 1 AND \"ActiveVersion\" <= \"Version\")"); + + t.HasCheckConstraint("CK_Checks_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRunRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChangeSetId") + .HasColumnType("uuid"); + + b.Property("Cost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("JobId") + .HasColumnType("uuid"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Number") + .HasColumnType("integer"); + + b.Property("Problems") + .IsRequired() + .HasColumnType("text"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId", "Number") + .IsUnique(); + + b.ToTable("CheckRuns", null, t => + { + t.HasCheckConstraint("CK_CheckRuns_Number", "\"Number\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckVersionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CheckId") + .HasColumnType("uuid"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CheckId", "Version") + .IsUnique(); + + b.ToTable("CheckVersions", null, t => + { + t.HasCheckConstraint("CK_CheckVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryReferenceRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("EntryId") + .HasColumnType("uuid"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("TargetField") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("EntryId", "Version"); + + b.HasIndex("TargetKind", "TargetId", "TargetField"); + + b.ToTable("EntryReferences", null, t => + { + t.HasCheckConstraint("CK_EntryReferences_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ContentTypeId") + .HasColumnType("uuid"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LatestVersion") + .HasColumnType("integer"); + + b.Property("PublishedVersion") + .HasColumnType("integer"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.HasKey("Id"); + + b.HasIndex("ContentTypeId"); + + b.HasIndex("SiteId", "ContentTypeId"); + + b.ToTable("Entries", null, t => + { + t.HasCheckConstraint("CK_Entries_LatestVersion", "\"LatestVersion\" >= 1"); + + t.HasCheckConstraint("CK_Entries_PublishedVersion", "\"PublishedVersion\" IS NULL OR (\"PublishedVersion\" >= 1 AND \"PublishedVersion\" <= \"LatestVersion\")"); + + t.HasCheckConstraint("CK_Entries_State", "\"State\" IN ('Draft', 'Published', 'Reverted', 'Archived')"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryVersionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ContentTypeVersion") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EntryId") + .HasColumnType("uuid"); + + b.Property("Fields") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("EntryId", "Version") + .IsUnique(); + + b.ToTable("EntryVersions", null, t => + { + t.HasTrigger("TR_EntryVersions_NoUpdate"); + + t.HasCheckConstraint("CK_EntryVersions_ContentTypeVersion", "\"ContentTypeVersion\" >= 1"); + + t.HasCheckConstraint("CK_EntryVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Delivery.PreviewTokenRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChangeSetId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("bytea"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId"); + + b.HasIndex("SiteId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("PreviewTokens", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Entities.EntityFactRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("EntryId") + .HasColumnType("uuid"); + + b.Property("Field") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LastVerified") + .HasColumnType("timestamp with time zone"); + + b.Property("Owner") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("SourceKind") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("SourceReference") + .HasColumnType("text"); + + b.Property("Unsourced") + .HasColumnType("boolean"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("LastVerified"); + + b.HasIndex("Unsourced"); + + b.HasIndex("ValidUntil"); + + b.HasIndex("EntryId", "Version", "Field") + .IsUnique(); + + b.ToTable("EntityFacts", null, t => + { + t.HasCheckConstraint("CK_EntityFacts_Unsourced", "\"Unsourced\" = (\"SourceKind\" IS NULL)"); + + t.HasCheckConstraint("CK_EntityFacts_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Jobs.JobRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Attempts") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("InitiatorKind") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("InitiatorName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("LeaseOwner") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LeaseUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("MaxAttempts") + .HasColumnType("integer"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProgressDone") + .HasColumnType("integer"); + + b.Property("ProgressMessage") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ProgressTotal") + .HasColumnType("integer"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("ScheduledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("SubjectId") + .HasColumnType("uuid"); + + b.Property("SubjectType") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("State", "ScheduledAt"); + + b.HasIndex("SubjectType", "SubjectId"); + + b.ToTable("Jobs", null, t => + { + t.HasCheckConstraint("CK_Jobs_Attempts", "\"Attempts\" >= 0 AND \"MaxAttempts\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LatestVersion") + .HasColumnType("integer"); + + b.Property("PublishedVersion") + .HasColumnType("integer"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SiteId"); + + b.ToTable("MediaAssets", null, t => + { + t.HasCheckConstraint("CK_MediaAssets_LatestVersion", "\"LatestVersion\" >= 1"); + + t.HasCheckConstraint("CK_MediaAssets_PublishedVersion", "\"PublishedVersion\" IS NULL OR (\"PublishedVersion\" >= 1 AND \"PublishedVersion\" <= \"LatestVersion\")"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetVersionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AltText") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("AssetId") + .HasColumnType("uuid"); + + b.Property("Caption") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(127) + .HasColumnType("character varying(127)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Folder") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Length") + .HasColumnType("bigint"); + + b.Property("Sha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Storage") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("StorageKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.PrimitiveCollection>("Tags") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Folder"); + + b.HasIndex("AssetId", "Version") + .IsUnique(); + + b.ToTable("MediaAssetVersions", null, t => + { + t.HasTrigger("TR_MediaAssetVersions_NoUpdate"); + + t.HasCheckConstraint("CK_MediaAssetVersions_Length", "\"Length\" > 0"); + + t.HasCheckConstraint("CK_MediaAssetVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.ProposedMediaVersionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AssetId") + .HasColumnType("uuid"); + + b.Property("ChangeSetId") + .HasColumnType("uuid"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId"); + + b.HasIndex("AssetId", "Version", "ChangeSetId") + .IsUnique(); + + b.ToTable("ProposedMediaVersions", null, t => + { + t.HasCheckConstraint("CK_ProposedMediaVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Models.ModelCallRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AgentCredentialId") + .HasColumnType("uuid"); + + b.Property("Cost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("InitiatorKind") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("InputTokens") + .HasColumnType("bigint"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ModelVersion") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OutputTokens") + .HasColumnType("bigint"); + + b.Property("Profile") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Prompt") + .HasColumnType("text"); + + b.Property("PromptHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("PurgedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Purpose") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Response") + .HasColumnType("text"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("PromptHash"); + + b.HasIndex("Timestamp"); + + b.HasIndex("AgentCredentialId", "Timestamp"); + + b.ToTable("ModelCalls", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Persistence.FluentCmsRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("Roles", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Persistence.FluentCmsUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Routing.RoutingRuleRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ActiveVersion") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Name") + .IsUnique(); + + b.ToTable("RoutingRules", null, t => + { + t.HasCheckConstraint("CK_RoutingRules_ActiveVersion", "\"ActiveVersion\" IS NULL OR (\"ActiveVersion\" >= 1 AND \"ActiveVersion\" <= \"Version\")"); + + t.HasCheckConstraint("CK_RoutingRules_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Routing.RoutingRuleVersionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("RoutingRuleId") + .HasColumnType("uuid"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("RoutingRuleId", "Version") + .IsUnique(); + + b.ToTable("RoutingRuleVersions", null, t => + { + t.HasCheckConstraint("CK_RoutingRuleVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Rules.RuleRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ActiveVersion") + .HasColumnType("integer"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Slug") + .IsUnique(); + + b.ToTable("Rules", null, t => + { + t.HasCheckConstraint("CK_Rules_ActiveVersion", "\"ActiveVersion\" IS NULL OR \"ActiveVersion\" >= 1"); + + t.HasCheckConstraint("CK_Rules_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Rules.RuleVersionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("RuleId") + .HasColumnType("uuid"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("RuleId", "Version") + .IsUnique(); + + b.ToTable("RuleVersions", null, t => + { + t.HasCheckConstraint("CK_RuleVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("PublishedVersion") + .HasColumnType("integer"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Slug") + .IsUnique(); + + b.ToTable("ContentTypes", null, t => + { + t.HasCheckConstraint("CK_ContentTypes_PublishedVersion", "\"PublishedVersion\" IS NULL OR \"PublishedVersion\" >= 1"); + + t.HasCheckConstraint("CK_ContentTypes_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeVersionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ContentTypeId") + .HasColumnType("uuid"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ContentTypeId", "Version") + .IsUnique(); + + b.ToTable("ContentTypeVersions", null, t => + { + t.HasCheckConstraint("CK_ContentTypeVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Sites.SiteRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.ToTable("Sites", (string)null); + + b.HasData( + new + { + Id = new Guid("00000000-0000-0000-0000-000000000001"), + Name = "Default" + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Webhooks.WebhookDeliveryRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Attempt") + .HasColumnType("integer"); + + b.Property("AttemptedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ChangeSetId") + .HasColumnType("uuid"); + + b.Property("DeliveryId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("Error") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Event") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("StatusCode") + .HasColumnType("integer"); + + b.Property("SubscriptionId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SubscriptionId", "Id"); + + b.ToTable("WebhookDeliveries", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Webhooks.WebhookSubscriptionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AddedById") + .HasColumnType("uuid"); + + b.Property("AddedByName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("DisabledAt") + .HasColumnType("timestamp with time zone"); + + b.PrimitiveCollection>("Events") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("ProtectedSecret") + .IsRequired() + .HasColumnType("text"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.HasKey("Id"); + + b.ToTable("WebhookSubscriptions", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", (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("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.ToolCallRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Agents.AgentCredentialRow", null) + .WithMany() + .HasForeignKey("AgentCredentialId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetApprovalRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetOperationRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("RevertsChangeSetId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckResultRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Checks.CheckRunRow", null) + .WithMany() + .HasForeignKey("RunId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRunRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Checks.CheckRow", null) + .WithMany() + .HasForeignKey("CheckId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryReferenceRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Schema.ContentTypeRow", null) + .WithMany() + .HasForeignKey("ContentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Delivery.PreviewTokenRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Entities.EntityFactRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Media.MediaAssetRow", null) + .WithMany() + .HasForeignKey("AssetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.ProposedMediaVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Media.MediaAssetRow", null) + .WithMany() + .HasForeignKey("AssetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Models.ModelCallRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Agents.AgentCredentialRow", null) + .WithMany() + .HasForeignKey("AgentCredentialId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Routing.RoutingRuleRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Routing.RoutingRuleVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Routing.RoutingRuleRow", null) + .WithMany() + .HasForeignKey("RoutingRuleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Rules.RuleRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Rules.RuleVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Rules.RuleRow", null) + .WithMany() + .HasForeignKey("RuleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Schema.ContentTypeRow", null) + .WithMany() + .HasForeignKey("ContentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261026124757_ChangeSetApprovals.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261026124757_ChangeSetApprovals.cs new file mode 100644 index 000000000..e41d97e18 --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261026124757_ChangeSetApprovals.cs @@ -0,0 +1,62 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FluentCMS.Infrastructure.Persistence.Migrations.PostgreSql +{ + /// + public partial class ChangeSetApprovals : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "RequiredApprovals", + table: "ChangeSets", + type: "integer", + nullable: false, + defaultValue: 1); + + migrationBuilder.CreateTable( + name: "ChangeSetApprovals", + columns: table => new + { + ChangeSetId = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + ApproverName = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + ApprovedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ChangeSetApprovals", x => new { x.ChangeSetId, x.UserId }); + table.ForeignKey( + name: "FK_ChangeSetApprovals_ChangeSets_ChangeSetId", + column: x => x.ChangeSetId, + principalTable: "ChangeSets", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.AddCheckConstraint( + name: "CK_ChangeSets_RequiredApprovals", + table: "ChangeSets", + sql: "\"RequiredApprovals\" BETWEEN 1 AND 10"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ChangeSetApprovals"); + + migrationBuilder.DropCheckConstraint( + name: "CK_ChangeSets_RequiredApprovals", + table: "ChangeSets"); + + migrationBuilder.DropColumn( + name: "RequiredApprovals", + table: "ChangeSets"); + } + } +} diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs index 52b50dc34..5107220b8 100644 --- a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs @@ -205,6 +205,27 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetApprovalRow", b => + { + b.Property("ChangeSetId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("ApprovedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ApproverName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("ChangeSetId", "UserId"); + + b.ToTable("ChangeSetApprovals", (string)null); + }); + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetOperationRow", b => { b.Property("Id") @@ -273,6 +294,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("RejectionReason") .HasColumnType("text"); + b.Property("RequiredApprovals") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + b.Property("RevertsChangeSetId") .HasColumnType("uuid"); @@ -310,6 +336,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("ChangeSets", null, t => { t.HasCheckConstraint("CK_ChangeSets_RejectionReason", "(\"State\" = 'Rejected') = (\"RejectionReason\" IS NOT NULL)"); + + t.HasCheckConstraint("CK_ChangeSets_RequiredApprovals", "\"RequiredApprovals\" BETWEEN 1 AND 10"); }); }); @@ -1535,6 +1563,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetApprovalRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetOperationRow", b => { b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261026124751_ChangeSetApprovals.Designer.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261026124751_ChangeSetApprovals.Designer.cs new file mode 100644 index 000000000..b8a81b819 --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261026124751_ChangeSetApprovals.Designer.cs @@ -0,0 +1,1832 @@ +// +using System; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace FluentCMS.Infrastructure.Persistence.Migrations.Sqlite +{ + [DbContext(typeof(SqliteFluentCmsDbContext))] + [Migration("20261026124751_ChangeSetApprovals")] + partial class ChangeSetApprovals + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.13"); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.AgentCredentialRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CallPeriod") + .HasColumnType("TEXT"); + + b.Property("CallsPerPeriod") + .HasColumnType("INTEGER"); + + b.Property("ChangeSetsPerDay") + .HasColumnType("INTEGER"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Fields") + .HasColumnType("TEXT"); + + b.Property("IssuedAt") + .HasColumnType("TEXT"); + + b.Property("ItemsPerChangeSet") + .HasColumnType("INTEGER"); + + b.Property("KeyHash") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("LlmSpendPerPeriod") + .HasColumnType("TEXT"); + + b.Property("LlmSpendPeriod") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Operations") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RevokedAt") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Sites") + .IsRequired() + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Types") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("KeyHash") + .IsUnique(); + + b.ToTable("AgentCredentials", null, t => + { + t.HasCheckConstraint("CK_AgentCredentials_Caps", "\"CallsPerPeriod\" >= 1 AND \"ItemsPerChangeSet\" >= 1 AND \"ChangeSetsPerDay\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.ToolCallRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AgentCredentialId") + .HasColumnType("TEXT"); + + b.Property("AgentName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Duration") + .HasColumnType("TEXT"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("TaskId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("Tool") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AgentCredentialId", "Timestamp"); + + b.ToTable("ToolCalls", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Audit.AuditEventRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AgentCredentialId") + .HasColumnType("TEXT"); + + b.Property("ChangeSetId") + .HasColumnType("TEXT"); + + b.Property("InitiatorId") + .HasColumnType("TEXT"); + + b.Property("InitiatorKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("InitiatorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Operation") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PreviousVersionId") + .HasColumnType("TEXT"); + + b.Property("PromptHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Sources") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SubjectId") + .HasColumnType("TEXT"); + + b.Property("SubjectType") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AgentCredentialId"); + + b.HasIndex("ChangeSetId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("Operation", "Timestamp"); + + b.HasIndex("SubjectType", "SubjectId"); + + b.ToTable("AuditEvents", null, t => + { + t.HasCheckConstraint("CK_AuditEvents_AgentCredentialId", "(\"InitiatorKind\" = 'agent') = (\"AgentCredentialId\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetApprovalRow", b => + { + b.Property("ChangeSetId") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("ApprovedAt") + .HasColumnType("TEXT"); + + b.Property("ApproverName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("ChangeSetId", "UserId"); + + b.ToTable("ChangeSetApprovals", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetOperationRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("BaseVersion") + .HasColumnType("INTEGER"); + + b.Property("ChangeSetId") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ItemKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("ProposedVersion") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId", "Position") + .IsUnique(); + + b.HasIndex("ItemKind", "ItemId"); + + b.HasIndex("ChangeSetId", "ItemKind", "ItemId") + .IsUnique(); + + b.ToTable("ChangeSetOperations", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("InitiatorId") + .HasColumnType("TEXT"); + + b.Property("InitiatorKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("InitiatorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Rationale") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RejectionReason") + .HasColumnType("TEXT"); + + b.Property("RequiredApprovals") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1); + + b.Property("RevertsChangeSetId") + .HasColumnType("TEXT"); + + b.Property("Revision") + .HasColumnType("INTEGER"); + + b.Property("SessionId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Sources") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("State") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RevertsChangeSetId"); + + b.HasIndex("SiteId"); + + b.HasIndex("State"); + + b.HasIndex("InitiatorKind", "InitiatorId", "SessionId") + .IsUnique() + .HasFilter("\"State\" = 'Open' AND \"SessionId\" IS NOT NULL"); + + b.ToTable("ChangeSets", null, t => + { + t.HasCheckConstraint("CK_ChangeSets_RejectionReason", "(\"State\" = 'Rejected') = (\"RejectionReason\" IS NOT NULL)"); + + t.HasCheckConstraint("CK_ChangeSets_RequiredApprovals", "\"RequiredApprovals\" BETWEEN 1 AND 10"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckResultRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Check") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CheckId") + .HasColumnType("TEXT"); + + b.Property("CheckType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CheckVersion") + .HasColumnType("INTEGER"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Cost") + .HasPrecision(18, 6) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Findings") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ItemKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("ItemVersion") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ModelCallId") + .HasColumnType("TEXT"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("PromptHash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("RunId") + .HasColumnType("TEXT"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RunId", "Position") + .IsUnique(); + + b.ToTable("CheckResults", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ActiveVersion") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Name") + .IsUnique(); + + b.ToTable("Checks", null, t => + { + t.HasCheckConstraint("CK_Checks_ActiveVersion", "\"ActiveVersion\" IS NULL OR (\"ActiveVersion\" >= 1 AND \"ActiveVersion\" <= \"Version\")"); + + t.HasCheckConstraint("CK_Checks_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRunRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ChangeSetId") + .HasColumnType("TEXT"); + + b.Property("Cost") + .HasPrecision(18, 6) + .HasColumnType("TEXT"); + + b.Property("FinishedAt") + .HasColumnType("TEXT"); + + b.Property("JobId") + .HasColumnType("TEXT"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Problems") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId", "Number") + .IsUnique(); + + b.ToTable("CheckRuns", null, t => + { + t.HasCheckConstraint("CK_CheckRuns_Number", "\"Number\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckVersionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CheckId") + .HasColumnType("TEXT"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CheckId", "Version") + .IsUnique(); + + b.ToTable("CheckVersions", null, t => + { + t.HasCheckConstraint("CK_CheckVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryReferenceRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("EntryId") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TargetField") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("TargetId") + .HasColumnType("TEXT"); + + b.Property("TargetKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EntryId", "Version"); + + b.HasIndex("TargetKind", "TargetId", "TargetField"); + + b.ToTable("EntryReferences", null, t => + { + t.HasCheckConstraint("CK_EntryReferences_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ContentTypeId") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("LatestVersion") + .HasColumnType("INTEGER"); + + b.Property("PublishedVersion") + .HasColumnType("INTEGER"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ContentTypeId"); + + b.HasIndex("SiteId", "ContentTypeId"); + + b.ToTable("Entries", null, t => + { + t.HasCheckConstraint("CK_Entries_LatestVersion", "\"LatestVersion\" >= 1"); + + t.HasCheckConstraint("CK_Entries_PublishedVersion", "\"PublishedVersion\" IS NULL OR (\"PublishedVersion\" >= 1 AND \"PublishedVersion\" <= \"LatestVersion\")"); + + t.HasCheckConstraint("CK_Entries_State", "\"State\" IN ('Draft', 'Published', 'Reverted', 'Archived')"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryVersionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ContentTypeVersion") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EntryId") + .HasColumnType("TEXT"); + + b.Property("Fields") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EntryId", "Version") + .IsUnique(); + + b.ToTable("EntryVersions", null, t => + { + t.HasTrigger("TR_EntryVersions_NoUpdate"); + + t.HasCheckConstraint("CK_EntryVersions_ContentTypeVersion", "\"ContentTypeVersion\" >= 1"); + + t.HasCheckConstraint("CK_EntryVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Delivery.PreviewTokenRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ChangeSetId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("RevokedAt") + .HasColumnType("TEXT"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("BLOB"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId"); + + b.HasIndex("SiteId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("PreviewTokens", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Entities.EntityFactRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("EntryId") + .HasColumnType("TEXT"); + + b.Property("Field") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastVerified") + .HasColumnType("TEXT"); + + b.Property("Owner") + .HasColumnType("TEXT"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("SourceKind") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SourceReference") + .HasColumnType("TEXT"); + + b.Property("Unsourced") + .HasColumnType("INTEGER"); + + b.Property("ValidFrom") + .HasColumnType("TEXT"); + + b.Property("ValidUntil") + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LastVerified"); + + b.HasIndex("Unsourced"); + + b.HasIndex("ValidUntil"); + + b.HasIndex("EntryId", "Version", "Field") + .IsUnique(); + + b.ToTable("EntityFacts", null, t => + { + t.HasCheckConstraint("CK_EntityFacts_Unsourced", "\"Unsourced\" = (\"SourceKind\" IS NULL)"); + + t.HasCheckConstraint("CK_EntityFacts_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Jobs.JobRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Attempts") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("FinishedAt") + .HasColumnType("TEXT"); + + b.Property("InitiatorId") + .HasColumnType("TEXT"); + + b.Property("InitiatorKind") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("InitiatorName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasColumnType("TEXT"); + + b.Property("LeaseOwner") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("LeaseUntil") + .HasColumnType("TEXT"); + + b.Property("MaxAttempts") + .HasColumnType("INTEGER"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ProgressDone") + .HasColumnType("INTEGER"); + + b.Property("ProgressMessage") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("ProgressTotal") + .HasColumnType("INTEGER"); + + b.Property("Revision") + .HasColumnType("INTEGER"); + + b.Property("ScheduledAt") + .HasColumnType("TEXT"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SubjectId") + .HasColumnType("TEXT"); + + b.Property("SubjectType") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("State", "ScheduledAt"); + + b.HasIndex("SubjectType", "SubjectId"); + + b.ToTable("Jobs", null, t => + { + t.HasCheckConstraint("CK_Jobs_Attempts", "\"Attempts\" >= 0 AND \"MaxAttempts\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("LatestVersion") + .HasColumnType("INTEGER"); + + b.Property("PublishedVersion") + .HasColumnType("INTEGER"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SiteId"); + + b.ToTable("MediaAssets", null, t => + { + t.HasCheckConstraint("CK_MediaAssets_LatestVersion", "\"LatestVersion\" >= 1"); + + t.HasCheckConstraint("CK_MediaAssets_PublishedVersion", "\"PublishedVersion\" IS NULL OR (\"PublishedVersion\" >= 1 AND \"PublishedVersion\" <= \"LatestVersion\")"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetVersionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AltText") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("AssetId") + .HasColumnType("TEXT"); + + b.Property("Caption") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(127) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Folder") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Length") + .HasColumnType("INTEGER"); + + b.Property("Sha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Storage") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("StorageKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Tags") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Folder"); + + b.HasIndex("AssetId", "Version") + .IsUnique(); + + b.ToTable("MediaAssetVersions", null, t => + { + t.HasTrigger("TR_MediaAssetVersions_NoUpdate"); + + t.HasCheckConstraint("CK_MediaAssetVersions_Length", "\"Length\" > 0"); + + t.HasCheckConstraint("CK_MediaAssetVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.ProposedMediaVersionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AssetId") + .HasColumnType("TEXT"); + + b.Property("ChangeSetId") + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId"); + + b.HasIndex("AssetId", "Version", "ChangeSetId") + .IsUnique(); + + b.ToTable("ProposedMediaVersions", null, t => + { + t.HasCheckConstraint("CK_ProposedMediaVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Models.ModelCallRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AgentCredentialId") + .HasColumnType("TEXT"); + + b.Property("Cost") + .HasPrecision(18, 6) + .HasColumnType("TEXT"); + + b.Property("Duration") + .HasColumnType("TEXT"); + + b.Property("Error") + .HasColumnType("TEXT"); + + b.Property("InitiatorId") + .HasColumnType("TEXT"); + + b.Property("InitiatorKind") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("InputTokens") + .HasColumnType("INTEGER"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ModelVersion") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("OutputTokens") + .HasColumnType("INTEGER"); + + b.Property("Profile") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Prompt") + .HasColumnType("TEXT"); + + b.Property("PromptHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("PurgedAt") + .HasColumnType("TEXT"); + + b.Property("Purpose") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Response") + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PromptHash"); + + b.HasIndex("Timestamp"); + + b.HasIndex("AgentCredentialId", "Timestamp"); + + b.ToTable("ModelCalls", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Persistence.FluentCmsRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("Roles", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Persistence.FluentCmsUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + 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("Users", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Routing.RoutingRuleRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ActiveVersion") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Name") + .IsUnique(); + + b.ToTable("RoutingRules", null, t => + { + t.HasCheckConstraint("CK_RoutingRules_ActiveVersion", "\"ActiveVersion\" IS NULL OR (\"ActiveVersion\" >= 1 AND \"ActiveVersion\" <= \"Version\")"); + + t.HasCheckConstraint("CK_RoutingRules_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Routing.RoutingRuleVersionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RoutingRuleId") + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoutingRuleId", "Version") + .IsUnique(); + + b.ToTable("RoutingRuleVersions", null, t => + { + t.HasCheckConstraint("CK_RoutingRuleVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Rules.RuleRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ActiveVersion") + .HasColumnType("INTEGER"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Slug") + .IsUnique(); + + b.ToTable("Rules", null, t => + { + t.HasCheckConstraint("CK_Rules_ActiveVersion", "\"ActiveVersion\" IS NULL OR \"ActiveVersion\" >= 1"); + + t.HasCheckConstraint("CK_Rules_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Rules.RuleVersionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RuleId") + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RuleId", "Version") + .IsUnique(); + + b.ToTable("RuleVersions", null, t => + { + t.HasCheckConstraint("CK_RuleVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PublishedVersion") + .HasColumnType("INTEGER"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Slug") + .IsUnique(); + + b.ToTable("ContentTypes", null, t => + { + t.HasCheckConstraint("CK_ContentTypes_PublishedVersion", "\"PublishedVersion\" IS NULL OR \"PublishedVersion\" >= 1"); + + t.HasCheckConstraint("CK_ContentTypes_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeVersionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ContentTypeId") + .HasColumnType("TEXT"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ContentTypeId", "Version") + .IsUnique(); + + b.ToTable("ContentTypeVersions", null, t => + { + t.HasCheckConstraint("CK_ContentTypeVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Sites.SiteRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Sites", (string)null); + + b.HasData( + new + { + Id = new Guid("00000000-0000-0000-0000-000000000001"), + Name = "Default" + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Webhooks.WebhookDeliveryRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Attempt") + .HasColumnType("INTEGER"); + + b.Property("AttemptedAt") + .HasColumnType("TEXT"); + + b.Property("ChangeSetId") + .HasColumnType("TEXT"); + + b.Property("DeliveryId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Duration") + .HasColumnType("TEXT"); + + b.Property("Error") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Event") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("StatusCode") + .HasColumnType("INTEGER"); + + b.Property("SubscriptionId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SubscriptionId", "Id"); + + b.ToTable("WebhookDeliveries", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Webhooks.WebhookSubscriptionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AddedAt") + .HasColumnType("TEXT"); + + b.Property("AddedById") + .HasColumnType("TEXT"); + + b.Property("AddedByName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("DisabledAt") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Events") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ProtectedSecret") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("WebhookSubscriptions", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", (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("UserClaims", (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("UserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", (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("UserTokens", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.ToolCallRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Agents.AgentCredentialRow", null) + .WithMany() + .HasForeignKey("AgentCredentialId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetApprovalRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetOperationRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("RevertsChangeSetId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckResultRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Checks.CheckRunRow", null) + .WithMany() + .HasForeignKey("RunId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRunRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Checks.CheckRow", null) + .WithMany() + .HasForeignKey("CheckId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryReferenceRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Schema.ContentTypeRow", null) + .WithMany() + .HasForeignKey("ContentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Delivery.PreviewTokenRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Entities.EntityFactRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Media.MediaAssetRow", null) + .WithMany() + .HasForeignKey("AssetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.ProposedMediaVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Media.MediaAssetRow", null) + .WithMany() + .HasForeignKey("AssetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Models.ModelCallRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Agents.AgentCredentialRow", null) + .WithMany() + .HasForeignKey("AgentCredentialId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Routing.RoutingRuleRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Routing.RoutingRuleVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Routing.RoutingRuleRow", null) + .WithMany() + .HasForeignKey("RoutingRuleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Rules.RuleRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Rules.RuleVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Rules.RuleRow", null) + .WithMany() + .HasForeignKey("RuleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Schema.ContentTypeRow", null) + .WithMany() + .HasForeignKey("ContentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261026124751_ChangeSetApprovals.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261026124751_ChangeSetApprovals.cs new file mode 100644 index 000000000..39628cf7a --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261026124751_ChangeSetApprovals.cs @@ -0,0 +1,62 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FluentCMS.Infrastructure.Persistence.Migrations.Sqlite +{ + /// + public partial class ChangeSetApprovals : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "RequiredApprovals", + table: "ChangeSets", + type: "INTEGER", + nullable: false, + defaultValue: 1); + + migrationBuilder.CreateTable( + name: "ChangeSetApprovals", + columns: table => new + { + ChangeSetId = table.Column(type: "TEXT", nullable: false), + UserId = table.Column(type: "TEXT", nullable: false), + ApproverName = table.Column(type: "TEXT", maxLength: 256, nullable: false), + ApprovedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ChangeSetApprovals", x => new { x.ChangeSetId, x.UserId }); + table.ForeignKey( + name: "FK_ChangeSetApprovals_ChangeSets_ChangeSetId", + column: x => x.ChangeSetId, + principalTable: "ChangeSets", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.AddCheckConstraint( + name: "CK_ChangeSets_RequiredApprovals", + table: "ChangeSets", + sql: "\"RequiredApprovals\" BETWEEN 1 AND 10"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ChangeSetApprovals"); + + migrationBuilder.DropCheckConstraint( + name: "CK_ChangeSets_RequiredApprovals", + table: "ChangeSets"); + + migrationBuilder.DropColumn( + name: "RequiredApprovals", + table: "ChangeSets"); + } + } +} diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs index 14b9de3b0..7a6c2d9b9 100644 --- a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs @@ -199,6 +199,27 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetApprovalRow", b => + { + b.Property("ChangeSetId") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("ApprovedAt") + .HasColumnType("TEXT"); + + b.Property("ApproverName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("ChangeSetId", "UserId"); + + b.ToTable("ChangeSetApprovals", (string)null); + }); + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetOperationRow", b => { b.Property("Id") @@ -267,6 +288,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("RejectionReason") .HasColumnType("TEXT"); + b.Property("RequiredApprovals") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1); + b.Property("RevertsChangeSetId") .HasColumnType("TEXT"); @@ -304,6 +330,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("ChangeSets", null, t => { t.HasCheckConstraint("CK_ChangeSets_RejectionReason", "(\"State\" = 'Rejected') = (\"RejectionReason\" IS NOT NULL)"); + + t.HasCheckConstraint("CK_ChangeSets_RequiredApprovals", "\"RequiredApprovals\" BETWEEN 1 AND 10"); }); }); @@ -1525,6 +1553,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetApprovalRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetOperationRow", b => { b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) diff --git a/tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetApprovalTests.cs b/tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetApprovalTests.cs new file mode 100644 index 000000000..5c949f179 --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetApprovalTests.cs @@ -0,0 +1,99 @@ +using FluentCMS.Core.ChangeSets; +using FluentCMS.Core.Identity; +using FluentCMS.Infrastructure.ChangeSets; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using static FluentCMS.IntegrationTests.ChangeSets.ChangeSetScenario; + +namespace FluentCMS.IntegrationTests.ChangeSets; + +// A set in review needs as many approvals as it asks for, each by a different person (RTE-05). +public sealed class ChangeSetApprovalTests +{ + private static readonly UserInitiator Dana = new(Guid.NewGuid(), "Dana"); + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task With_the_default_of_one_an_approval_approves_the_set_and_is_audited(string provider) + { + await using var scenario = await ScenarioAsync(provider); + var set = await InReviewAsync(scenario); + + var approved = await scenario.RunAsync(directory => directory.ApproveAsync(set.Id)); + + Assert.Equal(ChangeSetState.Approved, approved.Value.State); + Assert.Equal(1, approved.Value.RequiredApprovals); + Assert.Equal(Amir.UserId, Assert.Single(approved.Value.Approvals).Approver.UserId); + Assert.Equal(["change_set.approval_recorded", "change_set.approved"], (await EventsAsync(scenario, set.Id))[^2..]); + Assert.Equal(ChangeSetState.Approved, (await scenario.RunAsync(directory => directory.GetAsync(set.Id))).Value.State); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_set_that_needs_two_approvals_waits_for_a_second_person(string provider) + { + await using var scenario = await ScenarioAsync(provider, required: 2); + var set = await InReviewAsync(scenario); + var events = (await EventsAsync(scenario, set.Id)).Count; + + var first = await scenario.RunAsync(directory => directory.ApproveAsync(set.Id)); + var again = await scenario.RunAsync(directory => directory.TransitionAsync(set.Id, ChangeSetState.Approved)); + scenario.Initiator = Dana; + var second = await scenario.RunAsync(directory => directory.ApproveAsync(set.Id)); + + Assert.Equal((ChangeSetState.AwaitingReview, 2), (first.Value.State, first.Value.RequiredApprovals)); + Assert.Equal(ChangeSetErrorCodes.ApprovalDuplicate, Assert.Single(again.Errors).Code); + Assert.Equal(ChangeSetState.Approved, second.Value.State); + Assert.Equal([Amir.UserId, Dana.UserId], second.Value.Approvals.Select(approval => approval.Approver.UserId)); + Assert.Equal(2, await scenario.QueryAsync(context => context.ChangeSetApprovals.CountAsync(row => row.ChangeSetId == set.Id))); + Assert.Equal( + ["change_set.approval_recorded", "change_set.approval_recorded", "change_set.approved"], + (await EventsAsync(scenario, set.Id)).Skip(events)); + } + + // An approval of a set that was already approved, or of an unknown set, is refused and stores nothing. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task Only_a_set_in_review_takes_approvals(string provider) + { + await using var scenario = await ScenarioAsync(provider, required: 2); + var set = await InReviewAsync(scenario); + scenario.Initiator = Dana; + await scenario.RunAsync(directory => directory.ApproveAsync(set.Id)); + scenario.Initiator = Amir; + await scenario.RunAsync(directory => directory.ApproveAsync(set.Id)); + var events = (await EventsAsync(scenario, set.Id)).Count; + + var late = await scenario.RunAsync(directory => directory.ApproveAsync(set.Id)); + var unknown = await scenario.RunAsync(directory => directory.ApproveAsync(Guid.NewGuid())); + + Assert.Equal(ChangeSetErrorCodes.TransitionNotAllowed, Assert.Single(late.Errors).Code); + Assert.Equal(ChangeSetErrorCodes.ChangeSetNotFound, Assert.Single(unknown.Errors).Code); + Assert.Equal(events, (await EventsAsync(scenario, set.Id)).Count); + } + + private static async Task ScenarioAsync(string provider, int? required = null) + { + var scenario = await ChangeSetScenario.CreateAsync( + provider, + services => services.Configure(options => options.Required = required ?? options.Required)); + scenario.Initiator = Amir; + return scenario; + } + + private static async Task InReviewAsync(ChangeSetScenario scenario) + { + var set = await scenario.OpenAsync(Create(await scenario.ContentTypeAsync("product"))); + await scenario.RunAsync(directory => directory.TransitionAsync(set.Id, ChangeSetState.ChecksRunning)); + return (await scenario.RunAsync(directory => directory.TransitionAsync(set.Id, ChangeSetState.AwaitingReview))).Value; + } + + private static async Task> EventsAsync(ChangeSetScenario scenario, Guid changeSetId) + { + return [.. (await scenario.ChangeSetEventsAsync(changeSetId)).Select(row => row.Operation)]; + } +} diff --git a/tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetScenario.cs b/tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetScenario.cs index fc115b682..625134874 100644 --- a/tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetScenario.cs +++ b/tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetScenario.cs @@ -54,7 +54,7 @@ public Initiator? Initiator set => _initiator.Current = value; } - public static async Task CreateAsync(string provider) + public static async Task CreateAsync(string provider, Action? configure = null) { var database = await TemporaryDatabase.CreateAsync(provider); var initiator = new SwitchableInitiator { Current = Agent }; @@ -62,9 +62,13 @@ public static async Task CreateAsync(string provider) var auditFailure = new AuditFailure(); var services = TestServices.Build( database.Configuration, - collection => collection - .AddSingleton(initiator) - .AddSingleton(clock), + collection => + { + collection + .AddSingleton(initiator) + .AddSingleton(clock); + configure?.Invoke(collection); + }, collection => FailingAuditLog.Wrap(collection, auditFailure)); await TestServices.MigrateAsync(services); diff --git a/tests/FluentCMS.IntegrationTests/Desk/DeskQueueTests.cs b/tests/FluentCMS.IntegrationTests/Desk/DeskQueueTests.cs index 2acc796bb..ee95e2453 100644 --- a/tests/FluentCMS.IntegrationTests/Desk/DeskQueueTests.cs +++ b/tests/FluentCMS.IntegrationTests/Desk/DeskQueueTests.cs @@ -235,7 +235,9 @@ private static async Task ExpectMoveAsync(ChangeSetScenario scenario, Guid id, C .AsNoTracking() .Where(row => row.SubjectType == "change_set" && row.SubjectId == id && row.Operation.StartsWith("change_set.")) .ToListAsync())) - .Where(row => !before.Contains(row.Id) && !row.Operation.StartsWith("change_set.operation_", StringComparison.Ordinal)) + .Where(row => !before.Contains(row.Id) + && !row.Operation.StartsWith("change_set.operation_", StringComparison.Ordinal) + && row.Operation != "change_set.approval_recorded") .ToList(); var move = Assert.Single(appended); diff --git a/tests/FluentCMS.IntegrationTests/Mcp/DeskToolsTests.cs b/tests/FluentCMS.IntegrationTests/Mcp/DeskToolsTests.cs index ed5a8b9a8..bb7929420 100644 --- a/tests/FluentCMS.IntegrationTests/Mcp/DeskToolsTests.cs +++ b/tests/FluentCMS.IntegrationTests/Mcp/DeskToolsTests.cs @@ -147,6 +147,22 @@ public async Task Every_queue_lists_through_the_tool_in_its_order_and_window_and Assert.Equal(events, await agent.QueryAsync(context => Task.FromResult(context.AuditEvents.Count()))); } + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task An_agent_reads_who_approved_its_set_and_how_many_approvals_it_needs(string provider) + { + await using var agent = await ConnectedAgent.StartAsync(provider); + var set = await SubmitAsync(agent, "product"); + await agent.AsPersonAsync(directory => directory.ApproveAsync(set)); + + var review = await agent.CallAsync("get_change_set_review", new() { ["id"] = set.ToString() }); + + Assert.Equal(("approved", 1), ((string?)review["state"], (int)review["requiredApprovals"]!)); + var approval = Assert.Single(review["approvals"]!.AsArray()); + Assert.Equal(("user", "Test administrator"), ((string?)approval!["by"]!["kind"], (string?)approval["by"]!["name"])); + } + [Theory] [InlineData(DatabaseProviders.Sqlite)] [PostgresData] diff --git a/tests/FluentCMS.Tests/Admin/ChangeSetViewTests.cs b/tests/FluentCMS.Tests/Admin/ChangeSetViewTests.cs index 5ef6c7c70..d04908c1a 100644 --- a/tests/FluentCMS.Tests/Admin/ChangeSetViewTests.cs +++ b/tests/FluentCMS.Tests/Admin/ChangeSetViewTests.cs @@ -339,6 +339,28 @@ public void The_items_island_reports_a_refused_diff() Assert.Empty(cut.FindAll("[data-slot=diff-view]")); } + // A set that needs more than one approval shows who has approved, and offers Approve only to someone who has not. + [Fact] + public void The_review_panel_counts_approvals_and_offers_approve_only_to_someone_who_has_not_approved() + { + var dana = new ChangeSetApproval(new UserInitiator(Guid.NewGuid(), "Dana"), DateTimeOffset.UnixEpoch); + var mine = new ChangeSetApproval(Reviewer, DateTimeOffset.UnixEpoch); + var others = new FakeSets { RequiredApprovals = 2, Approvals = [dana] }; + var own = new FakeSets { RequiredApprovals = 3, Approvals = [dana, mine] }; + using var othersContext = Context(others); + using var ownContext = Context(own); + + var awaiting = othersContext.Render(page => page.Add(p => p.Id, others.Set.Id)); + var approved = ownContext.Render(page => page.Add(p => p.Id, own.Set.Id)); + + Assert.Contains("1 of 2 approvals", awaiting.Find("[data-testid=change-set-approvals]").TextContent); + Assert.Contains("Approved by Dana", awaiting.Find("[data-testid=change-set-approvals]").TextContent); + Assert.Equal(["approve", "reject"], awaiting.FindAll("[data-testid=change-set-review] [data-action]").Select(action => action.GetAttribute("data-action"))); + Assert.Contains("2 of 3 approvals", approved.Find("[data-testid=change-set-approvals]").TextContent); + Assert.Equal(["reject"], approved.FindAll("[data-testid=change-set-review] [data-action]").Select(action => action.GetAttribute("data-action"))); + Assert.Equal(5, approved.FindAll("[data-testid=change-set-review] form").Count); + } + [Fact] public void Uses_logical_utilities_only() { @@ -357,11 +379,19 @@ private static AdminTestContext Context(FakeSets sets) context.Services.AddSingleton(new FakeHistory(sets)); context.Services.AddSingleton(new QueueAfter(sets)); context.Services.AddSingleton(new FakeChecks(sets)); + context.Services.AddSingleton(new Signedin()); context.Services.AddSingleton(new NoReverts()); context.Renderer.SetRendererInfo(new RendererInfo("Static", isInteractive: false)); return context; } + private static readonly UserInitiator Reviewer = new(Guid.NewGuid(), "Reviewer"); + + private sealed class Signedin : ICurrentInitiator + { + public Initiator? Current => Reviewer; + } + private sealed class FakeSets : IChangeSetDirectory { private readonly Guid _id = Guid.CreateVersion7(); @@ -379,6 +409,10 @@ private sealed class FakeSets : IChangeSetDirectory public string? RejectionReason { get; init; } + public int RequiredApprovals { get; init; } = 1; + + public IReadOnlyList Approvals { get; init; } = []; + public List Diffed { get; } = []; // A diff larger than the island keeps for its circuit. @@ -394,7 +428,9 @@ private sealed class FakeSets : IChangeSetDirectory Operations, State, RejectionReason, - RevertsChangeSetId); + RevertsChangeSetId, + RequiredApprovals, + Approvals); private static ChangeOperation[] Operations => [ @@ -445,6 +481,8 @@ public Task> DiffAsync(Guid id, CancellationToken cancella public Task> RevertAsync(Guid changeSetId, string rationale, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task> RejectAsync(Guid changeSetId, string reason, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + + public Task> ApproveAsync(Guid changeSetId, CancellationToken cancellationToken = default) => throw new NotSupportedException(); } // A queue holding the set and one more after it. diff --git a/tests/FluentCMS.Tests/ChangeSets/ChangeSetTests.cs b/tests/FluentCMS.Tests/ChangeSets/ChangeSetTests.cs index 6dc004700..d86f570cd 100644 --- a/tests/FluentCMS.Tests/ChangeSets/ChangeSetTests.cs +++ b/tests/FluentCMS.Tests/ChangeSets/ChangeSetTests.cs @@ -410,4 +410,43 @@ private static ChangeOperation Operation(OperationKind kind, Guid item, int? bas { return ChangeOperation.Create(kind, ItemKind.Schema, item, baseVersion, proposedVersion).Value; } + + [Fact] + public void A_set_in_review_is_approved_once_as_many_people_as_it_needs_have_approved_it() + { + var changeSet = TestChangeSets.InState(ChangeSetState.ChecksRunning); + var dana = new UserInitiator(Guid.NewGuid(), "Dana"); + var amir = new UserInitiator(Guid.NewGuid(), "Amir"); + Assert.True(changeSet.EnterReview(2).IsSuccess); + + var first = changeSet.Approve(dana, DateTimeOffset.UnixEpoch); + var again = changeSet.Approve(dana, DateTimeOffset.UnixEpoch.AddMinutes(1)); + var direct = changeSet.TransitionTo(ChangeSetState.Approved); + + Assert.True(first.IsSuccess); + Assert.Equal(ChangeSetErrorCodes.ApprovalDuplicate, Assert.Single(again.Errors).Code); + Assert.Equal(ChangeSetErrorCodes.ApprovalsNeeded, Assert.Single(direct.Errors).Code); + Assert.Contains("needs 2 approvals, each by a different person, and has 1; approve it", direct.Errors[0].Message); + Assert.EndsWith("Dana has already approved this change set. It needs 2 approvals, each by a different person, and has 1, so another person approves it next.", again.Errors[0].Message); + Assert.Equal(ChangeSetState.AwaitingReview, changeSet.State); + + Assert.True(changeSet.Approve(amir, DateTimeOffset.UnixEpoch.AddMinutes(2)).IsSuccess); + Assert.Equal(ChangeSetState.Approved, changeSet.State); + Assert.Equal([dana, amir], changeSet.Approvals.Select(approval => approval.Approver)); + Assert.Equal(ChangeSetErrorCodes.TransitionNotAllowed, Assert.Single(changeSet.Approve(new UserInitiator(Guid.NewGuid(), "Lee"), DateTimeOffset.UnixEpoch).Errors).Code); + } + + [Theory] + [InlineData(0)] + [InlineData(11)] + public void A_set_enters_review_needing_from_one_to_ten_approvals(int required) + { + var changeSet = TestChangeSets.InState(ChangeSetState.ChecksRunning); + + var result = changeSet.EnterReview(required); + + Assert.Equal(ChangeSetErrorCodes.RequiredApprovalsInvalid, Assert.Single(result.Errors).Code); + Assert.Equal(ChangeSetState.ChecksRunning, changeSet.State); + Assert.Equal(1, changeSet.RequiredApprovals); + } } diff --git a/tests/FluentCMS.Tests/ChangeSets/ChangeSetTransitionTests.cs b/tests/FluentCMS.Tests/ChangeSets/ChangeSetTransitionTests.cs index 516e9d19e..4065833bd 100644 --- a/tests/FluentCMS.Tests/ChangeSets/ChangeSetTransitionTests.cs +++ b/tests/FluentCMS.Tests/ChangeSets/ChangeSetTransitionTests.cs @@ -1,4 +1,5 @@ using FluentCMS.Core.ChangeSets; +using FluentCMS.Core.Identity; namespace FluentCMS.Tests.ChangeSets; @@ -80,7 +81,13 @@ public void Every_transition_in_the_table_succeeds(ChangeSetState from, ChangeSe { var changeSet = TestChangeSets.InState(from); - var result = to == ChangeSetState.Rejected ? changeSet.Reject("The price has no source.") : changeSet.TransitionTo(to); + // A set in review is approved by its approvals (RTE-05). + var result = (from, to) switch + { + (_, ChangeSetState.Rejected) => changeSet.Reject("The price has no source."), + (ChangeSetState.AwaitingReview, ChangeSetState.Approved) => changeSet.Approve(new UserInitiator(Guid.NewGuid(), "Amir"), DateTimeOffset.UnixEpoch), + _ => changeSet.TransitionTo(to), + }; Assert.True(result.IsSuccess); Assert.Equal(to, changeSet.State);