V13-FE-011: finalize search list layout slice
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Shared.Operations;
|
||||
|
||||
public static class KbxOperationsRegistration
|
||||
{
|
||||
public static IServiceCollection AddKbxOperations(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<OperationsProjectionWriter>();
|
||||
services.AddSingleton<ReconcileProjectionWriter>();
|
||||
services.AddSingleton<WorkItemActionRegistry>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
namespace Shared.Operations;
|
||||
|
||||
public sealed record WorkItemProjection(
|
||||
Guid Id,
|
||||
string SourceModule,
|
||||
string SourceType,
|
||||
string SourceId,
|
||||
string ReferenceNo,
|
||||
string? SourceScreenId,
|
||||
string Code,
|
||||
string Title,
|
||||
string? Detail,
|
||||
string Severity,
|
||||
string Status,
|
||||
string? OwnerId,
|
||||
string? OwnerName,
|
||||
DateTimeOffset OccurredAt,
|
||||
DateTimeOffset? DueAt,
|
||||
int AgeMinutes,
|
||||
long Version,
|
||||
string ContextJson,
|
||||
string? RetryActionKey,
|
||||
bool AllowManualResolution);
|
||||
|
||||
public sealed record WorkQueueCounter(string Key, string Label, int Count, string Severity);
|
||||
public sealed record WorkItemActionDto(string Id, string Label, string Kind, string? Permission = null, bool Danger = false);
|
||||
public sealed record WorkItemDto(
|
||||
Guid Id, string SourceModule, string SourceType, string SourceId, string ReferenceNo, string? SourceScreenId, string Code, string Title, string? Detail,
|
||||
string Severity, string Status, string? OwnerId, string? OwnerName, DateTimeOffset OccurredAt,
|
||||
DateTimeOffset? DueAt, int AgeMinutes, long Version, object Context, IReadOnlyList<WorkItemActionDto> Actions);
|
||||
public sealed record WorkQueueResponse(IReadOnlyList<WorkItemDto> Items, int TotalCount, IReadOnlyList<WorkQueueCounter> Counters);
|
||||
|
||||
public sealed record ReconcileItemDto(
|
||||
Guid Id, string ReconcileType, string ReferenceNo, string SourceLabel, string TargetLabel,
|
||||
string ExpectedValue, string ActualValue, string? DifferenceValue, string? ReasonCode, string? ReasonText,
|
||||
string Status, DateTimeOffset OccurredAt, string? SourceId, string? TargetId, long Version);
|
||||
public sealed record ReconcileSummary(int TotalCount, int MatchedCount, int MismatchCount, int PendingCount, int ResolvedCount);
|
||||
public sealed record ReconcileResponse(IReadOnlyList<ReconcileItemDto> Items, ReconcileSummary Summary);
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Shared.Operations;
|
||||
|
||||
public static class OperationsIdentity
|
||||
{
|
||||
public static string TenantId(ClaimsPrincipal user) =>
|
||||
user.FindFirst("tenant_id")?.Value ?? user.FindFirst("tenant")?.Value ?? "default";
|
||||
|
||||
public static string UserId(ClaimsPrincipal user) =>
|
||||
user.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? user.Identity?.Name ?? "unknown";
|
||||
|
||||
public static string UserName(ClaimsPrincipal user) => user.Identity?.Name ?? UserId(user);
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
using Dapper;
|
||||
using Npgsql;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Shared.Operations;
|
||||
|
||||
public sealed record UpsertWorkItem(
|
||||
string TenantId,
|
||||
string SourceModule,
|
||||
string SourceType,
|
||||
string SourceId,
|
||||
string ReferenceNo,
|
||||
string? SourceScreenId,
|
||||
long? SourceVersion,
|
||||
string Code,
|
||||
string Title,
|
||||
string? Detail,
|
||||
string Severity,
|
||||
DateTimeOffset OccurredAt,
|
||||
DateTimeOffset? DueAt = null,
|
||||
string? RetryActionKey = null,
|
||||
bool AllowManualResolution = false,
|
||||
object? Context = null);
|
||||
|
||||
public sealed class OperationsProjectionWriter(NpgsqlDataSource dataSource)
|
||||
{
|
||||
public async Task<Guid> UpsertAsync(UpsertWorkItem item, CancellationToken ct)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
var id = Guid.NewGuid();
|
||||
const string sql = """
|
||||
insert into kbx.work_items(
|
||||
id, tenant_id, source_module, source_type, source_id, reference_no, source_screen_id, source_version,
|
||||
code, title, detail, severity, status, retry_action_key, allow_manual_resolution,
|
||||
context, occurred_at, due_at)
|
||||
values(
|
||||
@Id, @TenantId, @SourceModule, @SourceType, @SourceId, @ReferenceNo, @SourceScreenId, @SourceVersion,
|
||||
@Code, @Title, @Detail, @Severity, 'open', @RetryActionKey, @AllowManualResolution,
|
||||
cast(@Context as jsonb), @OccurredAt, @DueAt)
|
||||
on conflict(tenant_id, source_module, source_type, source_id, code)
|
||||
do update set
|
||||
source_version = excluded.source_version,
|
||||
reference_no = excluded.reference_no,
|
||||
source_screen_id = excluded.source_screen_id,
|
||||
title = excluded.title,
|
||||
detail = excluded.detail,
|
||||
severity = excluded.severity,
|
||||
status = case when kbx.work_items.status = 'resolved' then 'open' else kbx.work_items.status end,
|
||||
retry_action_key = excluded.retry_action_key,
|
||||
allow_manual_resolution = excluded.allow_manual_resolution,
|
||||
context = excluded.context,
|
||||
occurred_at = excluded.occurred_at,
|
||||
due_at = excluded.due_at,
|
||||
resolved_at = null,
|
||||
resolution_reason = null,
|
||||
version = kbx.work_items.version + 1,
|
||||
updated_at = now()
|
||||
where excluded.source_version is null
|
||||
or kbx.work_items.source_version is null
|
||||
or excluded.source_version > kbx.work_items.source_version
|
||||
returning id;
|
||||
""";
|
||||
var args = new {
|
||||
Id = id,
|
||||
item.TenantId, item.SourceModule, item.SourceType, item.SourceId, item.ReferenceNo, item.SourceScreenId, item.SourceVersion,
|
||||
item.Code, item.Title, item.Detail, item.Severity, item.RetryActionKey, item.AllowManualResolution,
|
||||
Context = JsonSerializer.Serialize(item.Context ?? new { }), item.OccurredAt, item.DueAt
|
||||
};
|
||||
var changedId = await connection.ExecuteScalarAsync<Guid?>(new CommandDefinition(sql, args, cancellationToken: ct));
|
||||
if (changedId is not null) return changedId.Value;
|
||||
return await connection.ExecuteScalarAsync<Guid>(new CommandDefinition("""
|
||||
select id from kbx.work_items
|
||||
where tenant_id=@TenantId and source_module=@SourceModule and source_type=@SourceType
|
||||
and source_id=@SourceId and code=@Code;
|
||||
""", args, cancellationToken: ct));
|
||||
}
|
||||
|
||||
public async Task ResolveBySourceAsync(string tenantId, string sourceModule, string sourceType, string sourceId, string code, string reason, long? sourceVersion, CancellationToken ct)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await connection.ExecuteAsync(new CommandDefinition("""
|
||||
update kbx.work_items
|
||||
set status = 'resolved', resolved_at = now(), resolution_reason = @Reason,
|
||||
source_version = coalesce(@SourceVersion, source_version),
|
||||
version = version + 1, updated_at = now()
|
||||
where tenant_id = @TenantId and source_module = @SourceModule and source_type = @SourceType
|
||||
and source_id = @SourceId and code = @Code and status <> 'resolved'
|
||||
and (@SourceVersion is null or source_version is null or @SourceVersion >= source_version);
|
||||
""", new { TenantId = tenantId, SourceModule = sourceModule, SourceType = sourceType, SourceId = sourceId, Code = code, Reason = reason, SourceVersion = sourceVersion }, cancellationToken: ct));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace Shared.Operations;
|
||||
|
||||
public static class ReconcilePolicy
|
||||
{
|
||||
public static string DecideStatus(
|
||||
bool valuesMatch,
|
||||
DateTimeOffset sourceChangedAt,
|
||||
DateTimeOffset observedAt,
|
||||
TimeSpan gracePeriod)
|
||||
{
|
||||
if (valuesMatch) return "matched";
|
||||
if (observedAt < sourceChangedAt) throw new ArgumentOutOfRangeException(nameof(observedAt));
|
||||
return observedAt - sourceChangedAt < gracePeriod ? "pending" : "mismatch";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using Dapper;
|
||||
using Npgsql;
|
||||
|
||||
namespace Shared.Operations;
|
||||
|
||||
public sealed record UpsertReconcileItem(
|
||||
string TenantId,
|
||||
string ReconcileType,
|
||||
string ReferenceNo,
|
||||
string SourceLabel,
|
||||
string TargetLabel,
|
||||
string ExpectedValue,
|
||||
string ActualValue,
|
||||
string? DifferenceValue,
|
||||
string? ReasonCode,
|
||||
string? ReasonText,
|
||||
string Status,
|
||||
string? SourceId,
|
||||
string? TargetId,
|
||||
DateTimeOffset OccurredAt);
|
||||
|
||||
public sealed class ReconcileProjectionWriter(NpgsqlDataSource dataSource)
|
||||
{
|
||||
public async Task<Guid> UpsertAsync(UpsertReconcileItem item, CancellationToken ct)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
var id = Guid.NewGuid();
|
||||
const string sql = """
|
||||
insert into kbx.reconcile_items(
|
||||
id, tenant_id, reconcile_type, reference_no, source_label, target_label,
|
||||
expected_value, actual_value, difference_value, reason_code, reason_text, status,
|
||||
source_id, target_id, identity_key, occurred_at)
|
||||
values(
|
||||
@Id, @TenantId, @ReconcileType, @ReferenceNo, @SourceLabel, @TargetLabel,
|
||||
@ExpectedValue, @ActualValue, @DifferenceValue, @ReasonCode, @ReasonText, @Status,
|
||||
@SourceId, @TargetId, @IdentityKey, @OccurredAt)
|
||||
on conflict(tenant_id, reconcile_type, reference_no, identity_key)
|
||||
do update set
|
||||
source_label=excluded.source_label, target_label=excluded.target_label,
|
||||
expected_value=excluded.expected_value, actual_value=excluded.actual_value,
|
||||
difference_value=excluded.difference_value, reason_code=excluded.reason_code,
|
||||
reason_text=excluded.reason_text, status=excluded.status,
|
||||
occurred_at=excluded.occurred_at, version=kbx.reconcile_items.version+1, updated_at=now()
|
||||
where excluded.occurred_at >= kbx.reconcile_items.occurred_at
|
||||
returning id;
|
||||
""";
|
||||
var args = new {
|
||||
Id = id,
|
||||
item.TenantId, item.ReconcileType, item.ReferenceNo, item.SourceLabel, item.TargetLabel,
|
||||
item.ExpectedValue, item.ActualValue, item.DifferenceValue, item.ReasonCode, item.ReasonText,
|
||||
item.Status, item.SourceId, item.TargetId, IdentityKey = $"{item.SourceId ?? "-"}|{item.TargetId ?? "-"}", item.OccurredAt,
|
||||
};
|
||||
var changedId = await connection.ExecuteScalarAsync<Guid?>(new CommandDefinition(sql, args, cancellationToken: ct));
|
||||
if (changedId is not null) return changedId.Value;
|
||||
return await connection.ExecuteScalarAsync<Guid>(new CommandDefinition("""
|
||||
select id from kbx.reconcile_items
|
||||
where tenant_id=@TenantId and reconcile_type=@ReconcileType and reference_no=@ReferenceNo and identity_key=@IdentityKey;
|
||||
""", args, cancellationToken: ct));
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
using Shared.Operations;
|
||||
using Xunit;
|
||||
|
||||
namespace Shared.Operations.Tests;
|
||||
|
||||
public sealed class ReconcilePolicyTests
|
||||
{
|
||||
[Fact]
|
||||
public void Equal_values_are_matched_immediately()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
Assert.Equal("matched", ReconcilePolicy.DecideStatus(true, now, now, TimeSpan.FromSeconds(30)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mismatch_inside_eventual_consistency_window_is_pending()
|
||||
{
|
||||
var changed = DateTimeOffset.UtcNow;
|
||||
Assert.Equal("pending", ReconcilePolicy.DecideStatus(false, changed, changed.AddSeconds(10), TimeSpan.FromSeconds(30)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mismatch_after_grace_period_is_actionable()
|
||||
{
|
||||
var changed = DateTimeOffset.UtcNow;
|
||||
Assert.Equal("mismatch", ReconcilePolicy.DecideStatus(false, changed, changed.AddMinutes(2), TimeSpan.FromSeconds(30)));
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
using Shared.Operations;
|
||||
using Xunit;
|
||||
|
||||
namespace Shared.Operations.Tests;
|
||||
|
||||
public sealed class WorkItemActionPolicyTests
|
||||
{
|
||||
[Fact]
|
||||
public void Domain_exception_is_not_manually_resolvable_by_default() =>
|
||||
Assert.False(WorkItemActionPolicy.CanManualResolve("open", false));
|
||||
|
||||
[Fact]
|
||||
public void Explicit_operational_item_can_be_manually_resolved() =>
|
||||
Assert.True(WorkItemActionPolicy.CanManualResolve("claimed", true));
|
||||
|
||||
[Fact]
|
||||
public void Retry_requires_registered_action_key() =>
|
||||
Assert.False(WorkItemActionPolicy.CanRetry("open", null));
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Shared.Operations;
|
||||
|
||||
public static class WorkItemActionPolicy
|
||||
{
|
||||
public static bool CanClaim(string status) => status == "open";
|
||||
public static bool CanManualResolve(string status, bool allowManualResolution) =>
|
||||
allowManualResolution && status is "open" or "claimed";
|
||||
public static bool CanRetry(string status, string? retryActionKey) =>
|
||||
!string.IsNullOrWhiteSpace(retryActionKey) && status is "open" or "claimed";
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace Shared.Operations;
|
||||
|
||||
public interface IWorkItemActionHandler
|
||||
{
|
||||
string Key { get; }
|
||||
Task ExecuteAsync(string tenantId, string actorId, Guid workItemId, CancellationToken ct);
|
||||
}
|
||||
|
||||
public sealed class WorkItemActionRegistry(IEnumerable<IWorkItemActionHandler> handlers)
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, IWorkItemActionHandler> _handlers =
|
||||
handlers.ToDictionary(x => x.Key, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public bool TryGet(string key, out IWorkItemActionHandler? handler) => _handlers.TryGetValue(key, out handler);
|
||||
}
|
||||
Reference in New Issue
Block a user