Skip to content
37 changes: 34 additions & 3 deletions src/FluentCMS.Admin/Components/Pages/ChangeSetView.razor
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
@inject IDeskQueues Queues
@inject FluentCMS.Core.Checks.IChangeSetChecks Checks
@inject IAuditReader Audit
@inject ICurrentInitiator Initiator
@inject NavigationManager Navigation
@inject IStringLocalizer<ChangeSetView> L

Expand Down Expand Up @@ -85,8 +86,23 @@
{
<p role="alert" class="rounded-md border border-destructive/50 px-3 py-2 text-sm text-destructive" data-testid="review-refusal">@_refusal</p>
}
@if (set.State == ChangeSetState.AwaitingReview || set.Approvals.Count > 0)
{
<div class="text-sm" data-testid="change-set-approvals">
<p>@L["{0} of {1} approvals", set.Approvals.Count, set.RequiredApprovals]</p>
@if (set.Approvals.Count > 0)
{
<ul class="text-muted-foreground">
@foreach (var approval in set.Approvals)
{
<li>@L["Approved by {0}", approval.Approver.DisplayName]</li>
}
</ul>
}
</div>
}
<div class="flex flex-wrap gap-2">
@if (set.State == ChangeSetState.AwaitingReview)
@if (set.State == ChangeSetState.AwaitingReview && !ApprovedByMe(set))
{
<form method="post" @onsubmit="ApproveAsync" @formname="approve">
<AntiforgeryToken />
Expand Down Expand Up @@ -155,7 +171,7 @@
{
<form method="post" @onsubmit="SubmitAsync" @formname="submit" hidden></form>
}
@if (set.State != ChangeSetState.AwaitingReview)
@if (set.State != ChangeSetState.AwaitingReview || ApprovedByMe(set))
{
<form method="post" @onsubmit="ApproveAsync" @formname="approve" hidden></form>
}
Expand Down Expand Up @@ -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()
Expand Down
13 changes: 10 additions & 3 deletions src/FluentCMS.Core/Builtin/Tools/DeskTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -166,6 +167,12 @@ private static async Task<ToolResult> 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..],
Expand Down
76 changes: 75 additions & 1 deletion src/FluentCMS.Core/ChangeSets/ChangeSet.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@ public sealed class ChangeSet
{
public const int MaxSessionIdLength = 128;

public const int MaxApprovals = 10;

private readonly List<string> _sources;
private readonly List<ChangeOperation> _operations = [];
private readonly List<ChangeSetApproval> _approvals = [];

private ChangeSet(Guid id, Guid siteId, string rationale, IEnumerable<string> sources, Initiator initiator, string? sessionId)
{
Expand Down Expand Up @@ -45,6 +48,12 @@ private ChangeSet(Guid id, Guid siteId, string rationale, IEnumerable<string> 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<ChangeSetApproval> 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<ChangeSet> Open(Guid siteId, string rationale, IEnumerable<string> sources, Initiator initiator, string? sessionId = null)
Expand Down Expand Up @@ -99,7 +108,9 @@ public static ChangeSet Restore(
IEnumerable<ChangeOperation> operations,
ChangeSetState state,
string? rejectionReason,
Guid? revertsChangeSetId = null)
Guid? revertsChangeSetId = null,
int requiredApprovals = 1,
IEnumerable<ChangeSetApproval>? approvals = null)
{
ArgumentNullException.ThrowIfNull(rationale);
ArgumentNullException.ThrowIfNull(sources);
Expand All @@ -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;
}
Expand Down Expand Up @@ -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);
Expand All @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions src/FluentCMS.Core/ChangeSets/ChangeSetApproval.cs
Original file line number Diff line number Diff line change
@@ -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);
3 changes: 3 additions & 0 deletions src/FluentCMS.Core/ChangeSets/ChangeSetErrorCodes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
4 changes: 4 additions & 0 deletions src/FluentCMS.Core/ChangeSets/IChangeSetDirectory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,8 @@ Task<Result<ChangeSet>> AddToSessionAsync(
Task<Result<ChangeSet>> RevertAsync(Guid changeSetId, string rationale, CancellationToken cancellationToken = default);

Task<Result<ChangeSet>> 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<Result<ChangeSet>> ApproveAsync(Guid changeSetId, CancellationToken cancellationToken = default);
}
22 changes: 22 additions & 0 deletions src/FluentCMS.Infrastructure/ChangeSets/ApprovalOptions.cs
Original file line number Diff line number Diff line change
@@ -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<ApprovalOptions>
{
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}.");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;

namespace FluentCMS.Infrastructure.ChangeSets;

internal sealed class ChangeSetApprovalConfiguration : IEntityTypeConfiguration<ChangeSetApprovalRow>
{
public void Configure(EntityTypeBuilder<ChangeSetApprovalRow> 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<ChangeSetRow>().WithMany().HasForeignKey(approval => approval.ChangeSetId).OnDelete(DeleteBehavior.Restrict);
}
}
13 changes: 13 additions & 0 deletions src/FluentCMS.Infrastructure/ChangeSets/ChangeSetApprovalRow.cs
Original file line number Diff line number Diff line change
@@ -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; }
}
13 changes: 10 additions & 3 deletions src/FluentCMS.Infrastructure/ChangeSets/ChangeSetConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,15 @@ internal sealed class ChangeSetConfiguration : IEntityTypeConfiguration<ChangeSe
public void Configure(EntityTypeBuilder<ChangeSetRow> 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();
Expand All @@ -21,6 +27,7 @@ public void Configure(EntityTypeBuilder<ChangeSetRow> row)
row.Property(set => set.InitiatorName).HasMaxLength(256).IsRequired();
row.Property(set => set.SessionId).HasMaxLength(ChangeSet.MaxSessionIdLength);
row.Property(set => set.State).HasConversion<string>().HasMaxLength(32);
row.Property(set => set.RequiredApprovals).HasDefaultValue(1);

row.HasOne<SiteRow>().WithMany().HasForeignKey(set => set.SiteId).OnDelete(DeleteBehavior.Restrict);
row.HasOne<ChangeSetRow>().WithMany().HasForeignKey(set => set.RevertsChangeSetId).OnDelete(DeleteBehavior.Restrict);
Expand Down
Loading
Loading