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,41 @@
using Dapper;
using FastEndpoints;
using Npgsql;
using Shared.Operations;
namespace Modules.Common.Operations.Claim;
public sealed record ClaimWorkItemsRequest(IReadOnlyList<Guid> Ids);
public sealed record ClaimWorkItemsResponse(int ClaimedCount, int SkippedCount);
public sealed class Endpoint(NpgsqlDataSource dataSource) : Endpoint<ClaimWorkItemsRequest, ClaimWorkItemsResponse>
{
public override void Configure() { Post("/api/operations/work-items/claim"); Permissions("common.operations.claim"); }
public override async Task HandleAsync(ClaimWorkItemsRequest req, CancellationToken ct)
{
var ids = req.Ids.Distinct().Take(500).ToArray();
if (ids.Length == 0) { await Send.OkAsync(new(0, 0), ct); return; }
var tenant = OperationsIdentity.TenantId(User);
var actorId = OperationsIdentity.UserId(User);
var actorName = OperationsIdentity.UserName(User);
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var tx = await connection.BeginTransactionAsync(ct);
var changed = (await connection.QueryAsync<(Guid Id, string Status)>(new CommandDefinition("""
update kbx.work_items
set status='claimed', owner_id=@ActorId, owner_name=@ActorName, version=version+1, updated_at=now()
where tenant_id=@Tenant and id=any(@Ids) and status='open'
returning id, 'open'::text as Status;
""", new { Tenant = tenant, Ids = ids, ActorId = actorId, ActorName = actorName }, tx, cancellationToken: ct))).AsList();
foreach (var row in changed)
await connection.ExecuteAsync(new CommandDefinition("""
insert into kbx.work_item_audit(id, work_item_id, tenant_id, action, actor_id, actor_name, before_status, after_status)
values(@Id, @WorkItemId, @Tenant, 'claim', @ActorId, @ActorName, 'open', 'claimed');
""", new { Id = Guid.NewGuid(), WorkItemId = row.Id, Tenant = tenant, ActorId = actorId, ActorName = actorName }, tx, cancellationToken: ct));
await tx.CommitAsync(ct);
await Send.OkAsync(new(changed.Count, ids.Length - changed.Count), ct);
}
}
@@ -0,0 +1,42 @@
using Dapper;
using FastEndpoints;
using Npgsql;
using Shared.Operations;
namespace Modules.Common.Operations.Resolve;
public sealed record ResolveWorkItemsRequest(IReadOnlyList<Guid> Ids, string Reason);
public sealed record ResolveWorkItemsResponse(int ResolvedCount, int RejectedCount);
public sealed class Endpoint(NpgsqlDataSource dataSource) : Endpoint<ResolveWorkItemsRequest, ResolveWorkItemsResponse>
{
public override void Configure() { Post("/api/operations/work-items/resolve"); Permissions("common.operations.resolve"); }
public override async Task HandleAsync(ResolveWorkItemsRequest req, CancellationToken ct)
{
var ids = req.Ids.Distinct().Take(500).ToArray();
var tenant = OperationsIdentity.TenantId(User);
var actorId = OperationsIdentity.UserId(User);
var actorName = OperationsIdentity.UserName(User);
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var tx = await connection.BeginTransactionAsync(ct);
// Manual resolution is opt-in. Domain-originated exceptions should normally be resolved
// by source-module events through OperationsProjectionWriter.ResolveBySourceAsync().
var changed = (await connection.QueryAsync<Guid>(new CommandDefinition("""
update kbx.work_items
set status='resolved', resolution_reason=@Reason, resolved_at=now(), version=version+1, updated_at=now()
where tenant_id=@Tenant and id=any(@Ids) and allow_manual_resolution=true and status in ('open','claimed')
returning id;
""", new { Tenant = tenant, Ids = ids, Reason = req.Reason }, tx, cancellationToken: ct))).AsList();
foreach (var id in changed)
await connection.ExecuteAsync(new CommandDefinition("""
insert into kbx.work_item_audit(id, work_item_id, tenant_id, action, actor_id, actor_name, reason, after_status)
values(@Id, @WorkItemId, @Tenant, 'manual-resolve', @ActorId, @ActorName, @Reason, 'resolved');
""", new { Id = Guid.NewGuid(), WorkItemId = id, Tenant = tenant, ActorId = actorId, ActorName = actorName, Reason = req.Reason }, tx, cancellationToken: ct));
await tx.CommitAsync(ct);
await Send.OkAsync(new(changed.Count, ids.Length - changed.Count), ct);
}
}
@@ -0,0 +1,33 @@
using Dapper;
using FastEndpoints;
using Npgsql;
using Shared.Operations;
namespace Modules.Common.Operations.Retry;
public sealed class Endpoint(NpgsqlDataSource dataSource, WorkItemActionRegistry registry) : EndpointWithoutRequest
{
public override void Configure() { Post("/api/operations/work-items/{id:guid}/retry"); Permissions("common.operations.retry"); }
public override async Task HandleAsync(CancellationToken ct)
{
var id = Route<Guid>("id");
var tenant = OperationsIdentity.TenantId(User);
var actor = OperationsIdentity.UserId(User);
await using var connection = await dataSource.OpenConnectionAsync(ct);
var key = await connection.QuerySingleOrDefaultAsync<string?>(new CommandDefinition("""
select retry_action_key from kbx.work_items
where tenant_id=@Tenant and id=@Id and status in ('open','claimed');
""", new { Tenant = tenant, Id = id }, cancellationToken: ct));
if (string.IsNullOrWhiteSpace(key) || !registry.TryGet(key, out var handler) || handler is null)
{
AddError("이 예외에는 안전한 재처리 동작이 등록되어 있지 않습니다.");
await Send.ErrorsAsync(cancellation: ct);
return;
}
await handler.ExecuteAsync(tenant, actor, id, ct);
await Send.OkAsync(ct);
}
}
@@ -0,0 +1,121 @@
using Dapper;
using FastEndpoints;
using Npgsql;
using Shared.Operations;
using System.Text.Json;
namespace Modules.Common.Operations.Search;
public sealed class SearchWorkItemsRequest
{
public string? Module { get; init; }
public string? Severity { get; init; }
public string? Status { get; init; }
public string? Owner { get; init; }
public string? Code { get; init; }
public string? Keyword { get; init; }
public int Page { get; init; } = 1;
public int PageSize { get; init; } = 200;
}
public sealed class Endpoint(NpgsqlDataSource dataSource) : Endpoint<SearchWorkItemsRequest, WorkQueueResponse>
{
public override void Configure()
{
Get("/api/operations/work-items");
Permissions("common.operations.read");
}
public override async Task HandleAsync(SearchWorkItemsRequest req, CancellationToken ct)
{
var tenantId = OperationsIdentity.TenantId(User);
var userId = OperationsIdentity.UserId(User);
var page = Math.Max(1, req.Page);
var pageSize = Math.Clamp(req.PageSize, 1, 500);
var offset = (page - 1) * pageSize;
await using var connection = await dataSource.OpenConnectionAsync(ct);
const string rowsSql = """
select id, source_module as SourceModule, source_type as SourceType, source_id as SourceId,
reference_no as ReferenceNo, source_screen_id as SourceScreenId, code, title, detail, severity, status, owner_id as OwnerId, owner_name as OwnerName,
occurred_at as OccurredAt, due_at as DueAt,
greatest(0, floor(extract(epoch from (now() - occurred_at)) / 60))::int as AgeMinutes,
version, context::text as ContextJson, retry_action_key as RetryActionKey,
allow_manual_resolution as AllowManualResolution
from kbx.work_items
where tenant_id = @TenantId
and (@Module is null or source_module = @Module)
and (@Severity is null or severity = @Severity)
and (@Status is null or status = @Status)
and (@Code is null or code = @Code)
and (@Keyword is null or title ilike '%' || @Keyword || '%' or detail ilike '%' || @Keyword || '%' or reference_no ilike '%' || @Keyword || '%' or source_id ilike '%' || @Keyword || '%')
and (@Owner is null
or (@Owner = 'mine' and owner_id = @UserId)
or (@Owner = 'unassigned' and owner_id is null))
order by
case severity when 'critical' then 0 when 'warning' then 1 else 2 end,
coalesce(due_at, occurred_at), occurred_at
limit @PageSize offset @Offset;
""";
const string countSql = """
select count(*)::int
from kbx.work_items
where tenant_id = @TenantId
and (@Module is null or source_module = @Module)
and (@Severity is null or severity = @Severity)
and (@Status is null or status = @Status)
and (@Code is null or code = @Code)
and (@Keyword is null or title ilike '%' || @Keyword || '%' or detail ilike '%' || @Keyword || '%' or reference_no ilike '%' || @Keyword || '%' or source_id ilike '%' || @Keyword || '%')
and (@Owner is null
or (@Owner = 'mine' and owner_id = @UserId)
or (@Owner = 'unassigned' and owner_id is null));
""";
const string countersSql = """
select code as Key, min(title) as Label, count(*)::int as Count,
case max(case severity when 'critical' then 3 when 'warning' then 2 else 1 end)
when 3 then 'critical' when 2 then 'warning' else 'info' end as Severity
from kbx.work_items
where tenant_id = @TenantId and status in ('open','claimed')
group by code
order by max(case severity when 'critical' then 3 when 'warning' then 2 else 1 end) desc, count(*) desc
limit 12;
""";
var args = new {
TenantId = tenantId,
UserId = userId,
Module = EmptyToNull(req.Module),
Severity = EmptyToNull(req.Severity),
Status = EmptyToNull(req.Status),
Owner = EmptyToNull(req.Owner),
Code = EmptyToNull(req.Code),
Keyword = EmptyToNull(req.Keyword),
PageSize = pageSize,
Offset = offset,
};
var rows = (await connection.QueryAsync<WorkItemProjection>(new CommandDefinition(rowsSql, args, cancellationToken: ct))).AsList();
var total = await connection.ExecuteScalarAsync<int>(new CommandDefinition(countSql, args, cancellationToken: ct));
var counters = (await connection.QueryAsync<WorkQueueCounter>(new CommandDefinition(countersSql, new { TenantId = tenantId }, cancellationToken: ct))).AsList();
var items = rows.Select(ToDto).ToList();
await Send.OkAsync(new WorkQueueResponse(items, total, counters), ct);
}
private static string? EmptyToNull(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static WorkItemDto ToDto(WorkItemProjection row)
{
var actions = new List<WorkItemActionDto> {
new("navigate", "원 업무 보기", "navigate"),
};
if (WorkItemActionPolicy.CanClaim(row.Status)) actions.Add(new("claim", "내가 처리", "claim", "common.operations.claim"));
if (WorkItemActionPolicy.CanRetry(row.Status, row.RetryActionKey)) actions.Add(new("retry", "재처리", "retry", "common.operations.retry"));
if (WorkItemActionPolicy.CanManualResolve(row.Status, row.AllowManualResolution)) actions.Add(new("resolve", "해결 처리", "resolve", "common.operations.resolve"));
var context = JsonSerializer.Deserialize<JsonElement>(row.ContextJson);
return new(row.Id, row.SourceModule, row.SourceType, row.SourceId, row.ReferenceNo, row.SourceScreenId, row.Code, row.Title, row.Detail,
row.Severity, row.Status, row.OwnerId, row.OwnerName, row.OccurredAt, row.DueAt, row.AgeMinutes,
row.Version, context, actions);
}
}