V13-FE-011: finalize search list layout slice

This commit is contained in:
2026-08-09 02:57:26 +09:00
parent 9efd202e76
commit 6422cb2b13
984 changed files with 120811 additions and 1498 deletions
@@ -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;
}
@@ -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"));
}