V13-FE-011: finalize search list layout slice
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Kbx.Shared.Runtime;
|
||||
|
||||
public sealed class KbxCorrelationMiddleware(RequestDelegate next, ILogger<KbxCorrelationMiddleware> logger)
|
||||
{
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
var correlationId = context.Request.Headers["X-Correlation-Id"].FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(correlationId)) correlationId = Guid.NewGuid().ToString("N");
|
||||
|
||||
context.TraceIdentifier = correlationId;
|
||||
context.Response.Headers["X-Correlation-Id"] = correlationId;
|
||||
Activity.Current?.SetTag("kbx.correlation.id", correlationId);
|
||||
|
||||
using (logger.BeginScope(new Dictionary<string, object?> { ["CorrelationId"] = correlationId }))
|
||||
{
|
||||
await next(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using Dapper;
|
||||
using Npgsql;
|
||||
|
||||
namespace Kbx.Shared.Runtime;
|
||||
|
||||
public sealed class KbxNotificationRepository(NpgsqlDataSource dataSource)
|
||||
{
|
||||
public async Task<IReadOnlyList<KbxUserNotificationDto>> GetRecentAsync(Guid tenantId, Guid userId, int limit, CancellationToken ct)
|
||||
{
|
||||
const string sql = """
|
||||
select id, severity, title, message, created_at as CreatedAt, read_at as ReadAt,
|
||||
screen_id as ScreenId, entity_type as EntityType, entity_id as EntityId,
|
||||
action::text as ActionJson
|
||||
from kbx.user_notifications
|
||||
where tenant_id=@tenantId and user_id=@userId
|
||||
and (expires_at is null or expires_at > now())
|
||||
order by created_at desc
|
||||
limit @limit;
|
||||
""";
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
var rows = await connection.QueryAsync<KbxUserNotificationDto>(new CommandDefinition(sql, new { tenantId, userId, limit = Math.Clamp(limit, 1, 100) }, cancellationToken: ct));
|
||||
return rows.AsList();
|
||||
}
|
||||
|
||||
public async Task MarkReadAsync(Guid tenantId, Guid userId, Guid id, CancellationToken ct)
|
||||
{
|
||||
const string sql = "update kbx.user_notifications set read_at=coalesce(read_at, now()) where tenant_id=@tenantId and user_id=@userId and id=@id";
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await connection.ExecuteAsync(new CommandDefinition(sql, new { tenantId, userId, id }, cancellationToken: ct));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Kbx.Shared.Runtime;
|
||||
|
||||
public static class KbxOperationPolicy
|
||||
{
|
||||
public static bool IsTerminal(string status) => status is "completed" or "partially-completed" or "failed" or "cancelled";
|
||||
|
||||
public static bool CanRetryTransport(string httpMethod, bool hasIdempotencyKey)
|
||||
{
|
||||
if (httpMethod is "GET" or "HEAD") return true;
|
||||
return hasIdempotencyKey;
|
||||
}
|
||||
|
||||
public static bool CanStartCommit(string currentStatus) => currentStatus is "validated" or "retryable";
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using Dapper;
|
||||
using Npgsql;
|
||||
|
||||
namespace Kbx.Shared.Runtime;
|
||||
|
||||
public sealed class KbxOperationRunRepository(NpgsqlDataSource dataSource)
|
||||
{
|
||||
public async Task<IReadOnlyList<KbxOperationRunDto>> GetRecentAsync(Guid tenantId, Guid userId, int limit, CancellationToken ct)
|
||||
{
|
||||
const string sql = """
|
||||
select id, operation_type as Type, title, source_screen_id as SourceScreenId,
|
||||
status, requested_at as RequestedAt, started_at as StartedAt,
|
||||
completed_at as CompletedAt, processed, total, succeeded, failed,
|
||||
correlation_id as CorrelationId, result_message as ResultMessage
|
||||
from kbx.operation_runs
|
||||
where tenant_id=@tenantId and user_id=@userId
|
||||
order by requested_at desc
|
||||
limit @limit;
|
||||
""";
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
var rows = await connection.QueryAsync<KbxOperationRunDto>(new CommandDefinition(sql, new { tenantId, userId, limit = Math.Clamp(limit, 1, 50) }, cancellationToken: ct));
|
||||
return rows.AsList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
namespace Kbx.Shared.Runtime;
|
||||
|
||||
public enum KbxRuntimeMode { Normal, Degraded, ReadOnly, Offline }
|
||||
public enum KbxOperationStatus { Queued, Running, Completed, PartiallyCompleted, Failed, Cancelled }
|
||||
|
||||
public sealed record KbxRequestContext(
|
||||
Guid? TenantId,
|
||||
Guid? UserId,
|
||||
string? ScreenId,
|
||||
string CorrelationId,
|
||||
string? RequestId);
|
||||
|
||||
public sealed record KbxRuntimeNotice(
|
||||
KbxRuntimeMode Mode,
|
||||
string Title,
|
||||
string? Message,
|
||||
DateTimeOffset? Since,
|
||||
string? CorrelationId,
|
||||
bool RetryAllowed);
|
||||
|
||||
public sealed record KbxOperationRunDto(
|
||||
Guid Id,
|
||||
string Type,
|
||||
string Title,
|
||||
string? SourceScreenId,
|
||||
string Status,
|
||||
DateTimeOffset RequestedAt,
|
||||
DateTimeOffset? StartedAt,
|
||||
DateTimeOffset? CompletedAt,
|
||||
long Processed,
|
||||
long? Total,
|
||||
long Succeeded,
|
||||
long Failed,
|
||||
string? CorrelationId,
|
||||
string? ResultMessage);
|
||||
|
||||
public sealed record KbxUserNotificationDto(
|
||||
Guid Id,
|
||||
string Severity,
|
||||
string Title,
|
||||
string? Message,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? ReadAt,
|
||||
string? ScreenId,
|
||||
string? EntityType,
|
||||
string? EntityId,
|
||||
string? ActionJson);
|
||||
@@ -0,0 +1,32 @@
|
||||
using Dapper;
|
||||
using Npgsql;
|
||||
|
||||
namespace Kbx.Shared.Runtime;
|
||||
|
||||
public sealed class KbxRuntimeNoticeRepository(NpgsqlDataSource dataSource)
|
||||
{
|
||||
public async Task<KbxRuntimeNotice?> GetActiveAsync(Guid tenantId, CancellationToken ct)
|
||||
{
|
||||
const string sql = """
|
||||
select mode as Mode, title, message, started_at as Since,
|
||||
correlation_id as CorrelationId
|
||||
from kbx.runtime_incidents
|
||||
where ended_at is null
|
||||
and (tenant_id is null or tenant_id=@tenantId)
|
||||
order by case mode when 'read-only' then 0 when 'offline' then 1 else 2 end,
|
||||
started_at desc
|
||||
limit 1;
|
||||
""";
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
var row = await connection.QuerySingleOrDefaultAsync<RuntimeNoticeRow>(new CommandDefinition(sql, new { tenantId }, cancellationToken: ct));
|
||||
if (row is null) return null;
|
||||
var mode = row.Mode switch {
|
||||
"read-only" => KbxRuntimeMode.ReadOnly,
|
||||
"offline" => KbxRuntimeMode.Offline,
|
||||
_ => KbxRuntimeMode.Degraded,
|
||||
};
|
||||
return new KbxRuntimeNotice(mode, row.Title, row.Message, row.Since, row.CorrelationId, mode is KbxRuntimeMode.Degraded or KbxRuntimeMode.Offline);
|
||||
}
|
||||
|
||||
private sealed record RuntimeNoticeRow(string Mode, string Title, string? Message, DateTimeOffset Since, string? CorrelationId);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Kbx.Shared.Runtime;
|
||||
|
||||
public static class KbxRuntimeRegistration
|
||||
{
|
||||
public static IServiceCollection AddKbxRuntime(this IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<KbxOperationRunRepository>();
|
||||
services.AddScoped<KbxNotificationRepository>();
|
||||
services.AddScoped<KbxRuntimeNoticeRepository>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Kbx.Shared.Runtime;
|
||||
|
||||
public static class KbxTelemetry
|
||||
{
|
||||
public static readonly ActivitySource ActivitySource = new("KBX.BusinessRuntime");
|
||||
|
||||
public static Activity? StartOperation(
|
||||
string operationName,
|
||||
KbxRequestContext context,
|
||||
string? entityType = null,
|
||||
string? entityId = null)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity(operationName, ActivityKind.Internal);
|
||||
if (activity is null) return null;
|
||||
|
||||
activity.SetTag("kbx.screen.id", context.ScreenId);
|
||||
activity.SetTag("kbx.correlation.id", context.CorrelationId);
|
||||
activity.SetTag("kbx.tenant.id", context.TenantId?.ToString());
|
||||
activity.SetTag("kbx.entity.type", entityType);
|
||||
activity.SetTag("kbx.entity.id", entityId);
|
||||
return activity;
|
||||
}
|
||||
|
||||
public static void LogFailure(
|
||||
ILogger logger,
|
||||
Exception exception,
|
||||
KbxRequestContext context,
|
||||
string operation)
|
||||
{
|
||||
logger.LogError(exception,
|
||||
"KBX operation failed. Operation={Operation} ScreenId={ScreenId} CorrelationId={CorrelationId}",
|
||||
operation, context.ScreenId, context.CorrelationId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Kbx.Shared.Runtime;
|
||||
|
||||
public static class RuntimeIdentity
|
||||
{
|
||||
public static Guid TenantId(ClaimsPrincipal user) => Parse(user.FindFirst("tenant_id")?.Value ?? user.FindFirst("tenant")?.Value);
|
||||
public static Guid UserId(ClaimsPrincipal user) => Parse(user.FindFirst(ClaimTypes.NameIdentifier)?.Value);
|
||||
|
||||
private static Guid Parse(string? value) => Guid.TryParse(value, out var id) ? id : Guid.Empty;
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
using Kbx.Shared.Runtime;
|
||||
using Xunit;
|
||||
|
||||
public sealed class KbxOperationPolicyTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("GET", false, true)]
|
||||
[InlineData("POST", false, false)]
|
||||
[InlineData("POST", true, true)]
|
||||
public void Mutation_retry_requires_idempotency(string method, bool key, bool expected)
|
||||
=> Assert.Equal(expected, KbxOperationPolicy.CanRetryTransport(method, key));
|
||||
|
||||
[Fact]
|
||||
public void Terminal_operation_is_not_treated_as_running()
|
||||
=> Assert.True(KbxOperationPolicy.IsTerminal("partially-completed"));
|
||||
}
|
||||
Reference in New Issue
Block a user