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 { 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(new CommandDefinition(rowsSql, args, cancellationToken: ct))).AsList(); var total = await connection.ExecuteScalarAsync(new CommandDefinition(countSql, args, cancellationToken: ct)); var counters = (await connection.QueryAsync(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 { 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(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); } }