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,55 @@
using Dapper;
using FastEndpoints;
using KBX.Shared.Experience;
using Npgsql;
using System.Diagnostics;
namespace KBX.Modules.Common.Ai.Ask;
public sealed record Request(string Question, AiScreenContext Context);
public sealed class Endpoint(IKbxAiAssistantProvider assistant, NpgsqlDataSource dataSource) : Endpoint<Request, AiAnswer>
{
public override void Configure()
{
Post("/api/common/ai/ask");
Permissions("common.ai.use");
}
public override async Task HandleAsync(Request req, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(req.Question) || req.Question.Length > 2000)
{
AddError(r => r.Question, "질문을 1~2000자로 입력하세요.");
await Send.ErrorsAsync(cancellation: ct);
return;
}
// Host application replaces this with authenticated claims resolution.
var tenantId = Guid.Empty;
var userId = Guid.Empty;
var sw = Stopwatch.StartNew();
var answer = await assistant.AskAsync(tenantId, userId, new AiAskCommand(req.Question.Trim(), req.Context), ct);
sw.Stop();
await using var connection = await dataSource.OpenConnectionAsync(ct);
await connection.ExecuteAsync(new CommandDefinition("""
insert into kbx.ai_interactions
(id, tenant_id, user_id, screen_id, screen_version, capability, result_kind, provider, duration_ms)
values
(@Id, @TenantId, @UserId, @ScreenId, @ScreenVersion, 'explain', @ResultKind, @Provider, @DurationMs)
""", new
{
Id = Guid.NewGuid(),
TenantId = tenantId,
UserId = userId,
req.Context.ScreenId,
req.Context.ScreenVersion,
ResultKind = answer.Proposal is null ? "answer" : "proposal",
Provider = assistant.GetType().Name,
DurationMs = (int)sw.ElapsedMilliseconds,
}, cancellationToken: ct));
await Send.OkAsync(answer, ct);
}
}
@@ -0,0 +1,4 @@
using FastEndpoints;using Kbx.Shared.Experiments;using Kbx.Shared.Runtime;
namespace Kbx.Modules.Common.Experiments.Assignments;
public sealed record Request(string ScreenId);
public sealed class Endpoint(KbxExperimentAssignmentService service):Endpoint<Request,KbxExperimentAssignmentsResponse>{public override void Configure(){Get("/api/kbx/experiments/assignments");Permissions("common.experiment.evaluate");}public override async Task HandleAsync(Request req,CancellationToken ct)=>await Send.OkAsync(await service.EvaluateAsync(RuntimeIdentity.TenantId(User),RuntimeIdentity.UserId(User),req.ScreenId,ct),ct);}
@@ -0,0 +1,3 @@
using FastEndpoints;using Kbx.Shared.Experiments;using Kbx.Shared.Runtime;
namespace Kbx.Modules.Common.Experiments.Overview;
public sealed class Endpoint(KbxExperimentOverviewQuery query):EndpointWithoutRequest<KbxExperimentOverviewResponse>{public override void Configure(){Get("/api/kbx/experiments");Permissions("common.experiment.read");}public override async Task HandleAsync(CancellationToken ct)=>await Send.OkAsync(await query.ExecuteAsync(RuntimeIdentity.TenantId(User),ct),ct);}
@@ -0,0 +1,4 @@
using FastEndpoints;using Kbx.Shared.Experiments;using Kbx.Shared.Runtime;
namespace Kbx.Modules.Common.Experiments.Rollback;
public sealed record Request(string ExperimentId,string Reason);
public sealed class Endpoint(KbxExperimentRuntimeStore store):Endpoint<Request>{public override void Configure(){Post("/api/kbx/experiments/{experimentId}/rollback");Permissions("common.experiment.manage");}public override async Task HandleAsync(Request req,CancellationToken ct){await store.RollbackAsync(RuntimeIdentity.TenantId(User),RuntimeIdentity.UserId(User),req.ExperimentId,req.Reason,HttpContext.TraceIdentifier,ct);await Send.OkAsync(ct);}}
@@ -0,0 +1,4 @@
using FastEndpoints;using Kbx.Shared.Experiments;using Kbx.Shared.Runtime;
namespace Kbx.Modules.Common.Experiments.Rollout;
public sealed record Request(string ExperimentId,int RolloutPercent,string State,string Reason);
public sealed class Endpoint(KbxExperimentRuntimeStore store):Endpoint<Request>{public override void Configure(){Post("/api/kbx/experiments/{experimentId}/rollout");Permissions("common.experiment.manage");}public override async Task HandleAsync(Request req,CancellationToken ct){await store.UpdateRolloutAsync(RuntimeIdentity.TenantId(User),RuntimeIdentity.UserId(User),req.ExperimentId,req.RolloutPercent,req.State,req.Reason,HttpContext.TraceIdentifier,ct);await Send.OkAsync(ct);}}
@@ -0,0 +1,23 @@
using FastEndpoints;
using Kbx.Shared.ExternalData;
using Kbx.Shared.ExternalData.Generated;
using Kbx.Shared.Runtime;
using Shared.Problems;
namespace Kbx.Modules.Common.ExternalData.Refresh;
public sealed record Response(string DatasetId,string Status);
public sealed class Endpoint(IKbxExternalDataRefreshScheduler scheduler):EndpointWithoutRequest<Response>
{
public override void Configure(){Post("/api/kbx/external-data/{datasetId}/refresh");Permissions("common.external-data.refresh");}
public override async Task HandleAsync(CancellationToken ct)
{
var datasetId=Route<string>("datasetId")!;
if(!KbxExternalDataCatalog.All.TryGetValue(datasetId,out var dataset))
{ await Send.ResponseAsync(KbxNotFoundProblem.Create("EXTERNAL_DATASET_NOT_FOUND","외부 데이터셋을 찾을 수 없습니다."),404,cancellation:ct); return; }
if(dataset.FreshnessMode=="provider-defined" && dataset.FreshForSeconds is null)
{ await Send.ResponseAsync(KbxBusinessProblem.Create("EXTERNAL_DATA_POLICY_REQUIRED","승인된 서비스별 신선도 정책이 먼저 필요합니다.","KRX 범용 데이터셋에는 임의 TTL을 적용하지 않습니다."),422,cancellation:ct); return; }
await scheduler.ScheduleDatasetAsync(RuntimeIdentity.TenantId(User),datasetId,ct);
await Send.ResponseAsync(new Response(datasetId,"queued"),202,cancellation:ct);
}
}
@@ -0,0 +1,44 @@
using Dapper;
using FastEndpoints;
using Npgsql;
using Kbx.Shared.ExternalData.Generated;
using Kbx.Shared.Runtime;
namespace Kbx.Modules.Common.ExternalData.Status;
public sealed record Row(string DatasetId,string ProviderId,string SourceLabel,string State,long CacheEntries,DateTimeOffset? LastReceivedAt,DateTimeOffset? OldestFreshUntil,long StaleEntries,long UnavailableEntries);
public sealed record Response(IReadOnlyList<Row> Items);
file sealed record DbRow(string DatasetId,string ProviderId,string State,long CacheEntries,DateTimeOffset? LastReceivedAt,DateTimeOffset? OldestFreshUntil,long StaleEntries,long UnavailableEntries);
public sealed class Endpoint(NpgsqlDataSource dataSource):EndpointWithoutRequest<Response>
{
public override void Configure(){Get("/api/kbx/external-data/status");Permissions("common.external-data.read");}
public override async Task HandleAsync(CancellationToken ct)
{
const string sql="""
select dataset_id as DatasetId, provider_id as ProviderId,
case
when bool_or(fresh_until is not null and now() <= fresh_until) then 'fresh'
when bool_or(usable_until is not null and now() <= usable_until) then 'stale'
when bool_or(state='unavailable') then 'unavailable'
else 'expired'
end as State,
count(*) as CacheEntries,
max(received_at) as LastReceivedAt,
min(fresh_until) as OldestFreshUntil,
count(*) filter(where fresh_until is not null and now()>fresh_until and usable_until is not null and now()<=usable_until) as StaleEntries,
count(*) filter(where state='unavailable') as UnavailableEntries
from kbx.external_data_cache
where tenant_id=@TenantId
group by dataset_id,provider_id
""";
await using var connection=await dataSource.OpenConnectionAsync(ct);
var db=(await connection.QueryAsync<DbRow>(new CommandDefinition(sql,new{TenantId=RuntimeIdentity.TenantId(User)},cancellationToken:ct))).ToDictionary(x=>x.DatasetId,StringComparer.Ordinal);
var rows=KbxExternalDataCatalog.All.Values.OrderBy(x=>x.Id,StringComparer.Ordinal).Select(dataset=>{
if(db.TryGetValue(dataset.Id,out var current))return new Row(dataset.Id,dataset.ProviderId,dataset.SourceLabel,current.State,current.CacheEntries,current.LastReceivedAt,current.OldestFreshUntil,current.StaleEntries,current.UnavailableEntries);
var state=dataset.FreshnessMode=="provider-defined" && dataset.FreshForSeconds is null ? "unavailable" : "expired";
return new Row(dataset.Id,dataset.ProviderId,dataset.SourceLabel,state,0,null,null,0,state=="unavailable"?1:0);
}).ToArray();
await Send.OkAsync(new Response(rows),ct);
}
}
@@ -0,0 +1,39 @@
using FastEndpoints;
using Hangfire;
using Modules.Common.Imports.Jobs;
using Shared.Excel;
namespace Modules.Common.Imports.Endpoints;
public sealed class CommitEndpoint(ImportRepository repository, IBackgroundJobClient jobs)
: EndpointWithoutRequest<ImportSessionDto>
{
public override void Configure()
{
Post("/api/imports/sessions/{sessionId:guid}/commit");
Permissions("imports.execute");
}
public override async Task HandleAsync(CancellationToken ct)
{
var id = Route<Guid>("sessionId");
var tenant = ImportIdentity.TenantId(User);
var actor = ImportIdentity.UserId(User);
var session = await repository.GetAsync(id, tenant, actor, false, ct);
if (session is null) { await Send.NotFoundAsync(ct); return; }
if (session.Status != ImportStatuses.Validated)
{
AddError("검증 완료된 Import만 반영할 수 있습니다.");
await Send.ErrorsAsync(cancellation: ct);
return;
}
var acquired = await repository.TryTransitionAsync(id, [ImportStatuses.Validated], ImportStatuses.Committing, 1, ct);
if (acquired)
{
try { jobs.Enqueue<CommitImportJob>(job => job.RunAsync(id, actor, CancellationToken.None)); }
catch { await repository.SetStatusAsync(id, ImportStatuses.Validated, 100, ct); throw; }
}
var current = await repository.GetAsync(id, tenant, actor, false, ct);
await Send.OkAsync(current!, ct);
}
}
@@ -0,0 +1,93 @@
using FastEndpoints;
using Shared.Excel;
namespace Modules.Common.Imports.Endpoints;
public sealed class CreateSessionRequest
{
public string ImportType { get; init; } = string.Empty;
}
public sealed class CreateSessionEndpoint(
ImportDefinitionRegistry registry,
ImportRepository repository,
ClosedXmlWorkbookService workbook,
ImportMappingEngine mappingEngine,
IImportMappingSuggester mappingSuggester,
XlsxSafetyInspector safety)
: Endpoint<CreateSessionRequest, ImportSessionDto>
{
public override void Configure()
{
Post("/api/imports/sessions");
AllowFileUploads();
Permissions("imports.execute");
}
public override async Task HandleAsync(CreateSessionRequest req, CancellationToken ct)
{
if (!registry.TryGet(req.ImportType, out var importDefinition) || importDefinition is null)
{
await Send.NotFoundAsync(ct);
return;
}
var file = Files.FirstOrDefault();
if (file is null || file.Length == 0)
{
AddError("Excel 파일을 선택하세요.");
await Send.ErrorsAsync(cancellation: ct);
return;
}
var definition = importDefinition.Definition;
if (file.Length > definition.MaxFileSizeBytes)
{
AddError($"파일 크기는 최대 {definition.MaxFileSizeBytes / 1024 / 1024}MB입니다.");
await Send.ErrorsAsync(cancellation: ct);
return;
}
if (!Path.GetExtension(file.FileName).Equals(".xlsx", StringComparison.OrdinalIgnoreCase))
{
AddError(".xlsx 파일만 업로드할 수 있습니다.");
await Send.ErrorsAsync(cancellation: ct);
return;
}
await using var source = file.OpenReadStream();
using var buffer = new MemoryStream();
await source.CopyToAsync(buffer, ct);
var bytes = buffer.ToArray();
try { safety.EnsureSafe(bytes); }
catch (InvalidDataException ex)
{
AddError(ex.Message);
await Send.ErrorsAsync(cancellation: ct);
return;
}
await using var read = new MemoryStream(bytes, writable: false);
var columns = workbook.ReadHeaders(read);
var tenantId = ImportIdentity.TenantId(User);
var userId = ImportIdentity.UserId(User);
var saved = await repository.GetSavedMappingAsync(tenantId, userId, definition.Id, columns, ct);
var mapping = mappingEngine.Map(columns, definition, saved).ToList();
var unresolved = mapping.Where(x => x.TargetField is null).Select(x => x.SourceColumn).ToArray();
if (unresolved.Length > 0)
{
var suggestions = await mappingSuggester.SuggestAsync(unresolved, definition, ct);
var allowed = definition.Fields.Select(x => x.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var suggestion in suggestions.Where(x => x.TargetField is not null && allowed.Contains(x.TargetField)))
{
var index = mapping.FindIndex(x => x.SourceColumn.Equals(suggestion.SourceColumn, StringComparison.OrdinalIgnoreCase) && x.TargetField is null);
if (index >= 0) mapping[index] = suggestion with { Source = "ai" };
}
}
var sessionId = await repository.CreateSessionAsync(
tenantId, userId, definition, file.FileName,
file.ContentType ?? "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
bytes, columns, mapping, ct);
var session = await repository.GetAsync(sessionId, tenantId, userId, includeErrors: false, ct);
await Send.OkAsync(session!, ct);
}
}
@@ -0,0 +1,30 @@
using FastEndpoints;
using Shared.Excel;
namespace Modules.Common.Imports.Endpoints;
public sealed class ErrorWorkbookEndpoint(ImportRepository repository, ClosedXmlWorkbookService workbook)
: EndpointWithoutRequest
{
public override void Configure()
{
Get("/api/imports/sessions/{sessionId:guid}/errors.xlsx");
Permissions("imports.execute");
}
public override async Task HandleAsync(CancellationToken ct)
{
var id = Route<Guid>("sessionId");
var session = await repository.GetAsync(id, ImportIdentity.TenantId(User), ImportIdentity.UserId(User), false, ct);
if (session is null) { await Send.NotFoundAsync(ct); return; }
var (_, bytes) = await repository.GetFileAsync(id, ct);
await using var source = new MemoryStream(bytes, writable: false);
var errors = await repository.GetErrorsAsync(id, ct);
var output = workbook.CreateErrorWorkbook(source, errors);
await Send.StreamAsync(output,
fileName: $"{Path.GetFileNameWithoutExtension(session.FileName)}_errors.xlsx",
fileLengthBytes: output.Length,
contentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
cancellation: ct);
}
}
@@ -0,0 +1,22 @@
using FastEndpoints;
using Shared.Excel;
namespace Modules.Common.Imports.Endpoints;
public sealed class GetSessionEndpoint(ImportRepository repository)
: EndpointWithoutRequest<ImportSessionDto>
{
public override void Configure()
{
Get("/api/imports/sessions/{sessionId:guid}");
Permissions("imports.execute");
}
public override async Task HandleAsync(CancellationToken ct)
{
var id = Route<Guid>("sessionId");
var session = await repository.GetAsync(id, ImportIdentity.TenantId(User), ImportIdentity.UserId(User), includeErrors: true, ct);
if (session is null) { await Send.NotFoundAsync(ct); return; }
await Send.OkAsync(session, ct);
}
}
@@ -0,0 +1,27 @@
using FastEndpoints;
using Shared.Excel;
namespace Modules.Common.Imports.Endpoints;
public sealed record SaveMappingRequest(IReadOnlyList<ImportMapping> Mappings);
public sealed class SaveMappingEndpoint(ImportRepository repository)
: Endpoint<SaveMappingRequest, ImportSessionDto>
{
public override void Configure()
{
Put("/api/imports/sessions/{sessionId:guid}/mapping");
Permissions("imports.execute");
}
public override async Task HandleAsync(SaveMappingRequest req, CancellationToken ct)
{
var id = Route<Guid>("sessionId");
var tenant = ImportIdentity.TenantId(User);
var user = ImportIdentity.UserId(User);
if (await repository.GetAsync(id, tenant, user, false, ct) is null) { await Send.NotFoundAsync(ct); return; }
await repository.SaveMappingAsync(id, req.Mappings, ct);
var session = await repository.GetAsync(id, tenant, user, false, ct);
await Send.OkAsync(session!, ct);
}
}
@@ -0,0 +1,27 @@
using FastEndpoints;
using Shared.Excel;
namespace Modules.Common.Imports.Endpoints;
public sealed record SaveNamedMappingRequest(string Name, IReadOnlyList<ImportMapping> Mappings);
public sealed class SaveNamedMappingEndpoint(ImportRepository repository)
: Endpoint<SaveNamedMappingRequest>
{
public override void Configure()
{
Post("/api/imports/sessions/{sessionId:guid}/saved-mappings");
Permissions("imports.execute");
}
public override async Task HandleAsync(SaveNamedMappingRequest req, CancellationToken ct)
{
var id = Route<Guid>("sessionId");
var tenant = ImportIdentity.TenantId(User);
var user = ImportIdentity.UserId(User);
var session = await repository.GetAsync(id, tenant, user, false, ct);
if (session is null) { await Send.NotFoundAsync(ct); return; }
await repository.SaveNamedMappingAsync(tenant, user, session.ImportType, req.Name, session.SourceColumns, req.Mappings, ct);
await Send.NoContentAsync(ct);
}
}
@@ -0,0 +1,26 @@
using FastEndpoints;
using Shared.Excel;
namespace Modules.Common.Imports.Endpoints;
public sealed class TemplateEndpoint(ImportDefinitionRegistry registry, ClosedXmlWorkbookService workbook)
: EndpointWithoutRequest
{
public override void Configure()
{
Get("/api/imports/{importType}/template");
Permissions("imports.execute");
}
public override async Task HandleAsync(CancellationToken ct)
{
var importType = Route<string>("importType")!;
if (!registry.TryGet(importType, out var definition) || definition is null) { await Send.NotFoundAsync(ct); return; }
var stream = workbook.CreateTemplate(definition.Definition);
await Send.StreamAsync(stream,
fileName: $"{definition.Definition.Entity}_import_template.xlsx",
fileLengthBytes: stream.Length,
contentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
cancellation: ct);
}
}
@@ -0,0 +1,36 @@
using FastEndpoints;
using Hangfire;
using Modules.Common.Imports.Jobs;
using Shared.Excel;
namespace Modules.Common.Imports.Endpoints;
public sealed class ValidateEndpoint(ImportRepository repository, IBackgroundJobClient jobs)
: EndpointWithoutRequest<ImportSessionDto>
{
public override void Configure()
{
Post("/api/imports/sessions/{sessionId:guid}/validate");
Permissions("imports.execute");
}
public override async Task HandleAsync(CancellationToken ct)
{
var id = Route<Guid>("sessionId");
var tenant = ImportIdentity.TenantId(User);
var user = ImportIdentity.UserId(User);
var session = await repository.GetAsync(id, tenant, user, false, ct);
if (session is null) { await Send.NotFoundAsync(ct); return; }
var acquired = await repository.TryTransitionAsync(id,
[ImportStatuses.MappingRequired, ImportStatuses.Uploaded, ImportStatuses.Failed],
ImportStatuses.Validating, 1, ct);
if (acquired)
{
try { jobs.Enqueue<ValidateImportJob>(job => job.RunAsync(id, CancellationToken.None)); }
catch { await repository.SetStatusAsync(id, ImportStatuses.MappingRequired, 0, ct); throw; }
}
var current = await repository.GetAsync(id, tenant, user, false, ct);
await Send.OkAsync(current!, ct);
}
}
@@ -0,0 +1,39 @@
using Shared.Excel;
namespace Modules.Common.Imports.Jobs;
public sealed class CommitImportJob(
ImportRepository repository,
ImportDefinitionRegistry registry,
ImportProgressPublisher progress)
{
public async Task RunAsync(Guid sessionId, string actor, CancellationToken ct)
{
try
{
var (importType, _) = await repository.GetWorkDefinitionAsync(sessionId, ct);
var definition = registry.Get(importType);
await progress.PublishAsync(new ImportProgressEvent(
sessionId, ImportStatuses.Committing, 1, 0, 0, 0, 0, 0,
"검증된 데이터를 반영하고 있습니다."), ct);
var result = await definition.CommitAsync(sessionId, actor, ct);
await repository.CompleteAsync(sessionId, result, ct);
await progress.PublishAsync(new ImportProgressEvent(
sessionId,
result.Failed > 0 ? ImportStatuses.PartiallyCompleted : ImportStatuses.Completed,
100,
result.Created + result.Updated + result.Failed,
result.Created + result.Updated + result.Failed,
result.Created + result.Updated,
result.Failed,
0,
result.Failed > 0 ? "일부 데이터가 오류로 제외되었습니다." : "반영이 완료되었습니다."), ct);
}
catch (Exception)
{
await repository.SetStatusAsync(sessionId, ImportStatuses.Failed, 100, ct);
throw;
}
}
}
@@ -0,0 +1,52 @@
using Npgsql;
using Shared.Excel;
namespace Modules.Common.Imports.Jobs;
public sealed class ValidateImportJob(
ImportRepository repository,
ImportDefinitionRegistry registry,
ClosedXmlWorkbookService workbook,
ImportProgressPublisher progress,
NpgsqlDataSource dataSource)
{
public async Task RunAsync(Guid sessionId, CancellationToken ct)
{
try
{
var (importType, mapping) = await repository.GetWorkDefinitionAsync(sessionId, ct);
var definition = registry.Get(importType);
var targetToSource = mapping
.Where(x => !string.IsNullOrWhiteSpace(x.TargetField))
.ToDictionary(x => x.TargetField!, x => x.SourceColumn, StringComparer.OrdinalIgnoreCase);
var (contentType, bytes) = await repository.GetFileAsync(sessionId, ct);
await using var stream = new MemoryStream(bytes, writable: false);
var rows = workbook.ReadRows(stream, definition.Definition.MaxRows).ToArray();
await progress.PublishAsync(new ImportProgressEvent(
sessionId, ImportStatuses.Validating, 15, rows.Length, 0, 0, 0, 0,
"참조 데이터를 확인하고 있습니다."), ct);
await using var connection = await dataSource.OpenConnectionAsync(ct);
var results = await definition.ValidateAsync(rows, targetToSource, connection, ct);
var valid = results.Count(x => x.IsValid);
var invalid = results.Count - valid;
var warnings = results.Count(x => x.Warnings.Count > 0);
await progress.PublishAsync(new ImportProgressEvent(
sessionId, ImportStatuses.Validating, 80, rows.Length, rows.Length,
valid, invalid, warnings, "검증 결과를 저장하고 있습니다."), ct);
await repository.ReplaceRowsAsync(sessionId, results, ct);
await progress.PublishAsync(new ImportProgressEvent(
sessionId, ImportStatuses.Validated, 100, rows.Length, rows.Length,
valid, invalid, warnings, "검증이 완료되었습니다."), ct);
}
catch (Exception)
{
await repository.SetStatusAsync(sessionId, ImportStatuses.Failed, 100, ct);
throw;
}
}
}
@@ -0,0 +1,8 @@
using Dapper;using FastEndpoints;using Npgsql;using Kbx.Shared.Runtime;using Shared.Problems;
namespace Kbx.Modules.Common.Integrations.Attempts.Get;
public sealed record Response(Guid Id,string IntegrationId,string MessageId,string AggregateType,string AggregateId,string State,int AttemptNo,string? FailureCode,string? Detail,DateTimeOffset StartedAt,DateTimeOffset? CompletedAt,DateTimeOffset? NextRetryAt,string CorrelationId);
public sealed class Endpoint(NpgsqlDataSource dataSource):EndpointWithoutRequest<object>
{
public override void Configure(){Get("/api/integrations/attempts/{attemptId:guid}");Permissions("common.integration.read");}
public override async Task HandleAsync(CancellationToken ct){var attemptId=Route<Guid>("attemptId");await using var c=await dataSource.OpenConnectionAsync(ct);const string sql="""select id, integration_id as IntegrationId, message_id::text as MessageId, aggregate_type as AggregateType, aggregate_id as AggregateId, state, attempt_no as AttemptNo, failure_code as FailureCode, detail, started_at as StartedAt, completed_at as CompletedAt, next_retry_at as NextRetryAt, correlation_id as CorrelationId from kbx.integration_attempts where id=@Id and tenant_id=@TenantId""";var row=await c.QuerySingleOrDefaultAsync<Response>(new CommandDefinition(sql,new{Id=attemptId,TenantId=RuntimeIdentity.TenantId(User)},cancellationToken:ct));if(row is null){await Send.ResponseAsync(KbxNotFoundProblem.Create("INTEGRATION_ATTEMPT_NOT_FOUND","연계 이력을 찾을 수 없습니다."),404,cancellation:ct);return;}await Send.OkAsync(row,ct);}
}
@@ -0,0 +1,47 @@
using System.Text.Json;
using Dapper;
using FastEndpoints;
using Npgsql;
using Kbx.Shared.Runtime;
using Shared.Problems;
namespace Kbx.Modules.Common.Integrations.Attempts.Retry;
public sealed record Request(string? IdempotencyKey = null);
public sealed record Response(Guid AttemptId,string State,string Message);
public sealed class Endpoint(NpgsqlDataSource dataSource):Endpoint<Request,object>
{
private const string OperationId = "common.integrations.retryAttempt";
public override void Configure(){Post("/api/integrations/attempts/{attemptId:guid}/retry");Permissions("common.integration.retry");}
public override async Task HandleAsync(Request req,CancellationToken ct)
{
var attemptId=Route<Guid>("attemptId");
var key=HttpContext.Request.Headers["Idempotency-Key"].FirstOrDefault()??req.IdempotencyKey;
if(string.IsNullOrWhiteSpace(key)){
await Send.ResponseAsync(KbxValidationProblem.Create(new KbxValidationError(null,null,"IDEMPOTENCY_KEY_REQUIRED","안전한 재처리를 위해 Idempotency-Key가 필요합니다.")),400,cancellation:ct);return;
}
await using var c=await dataSource.OpenConnectionAsync(ct);
await using var tx=await c.BeginTransactionAsync(ct);
var replay=await c.QuerySingleOrDefaultAsync<string?>(new CommandDefinition(
"select response_json::text from kbx.command_receipts where operation_id=@OperationId and idempotency_key=@Key",
new { OperationId, Key=key },tx,cancellationToken:ct));
if(replay is not null){await tx.RollbackAsync(ct);await Send.OkAsync(JsonSerializer.Deserialize<Response>(replay)!,ct);return;}
const string sql="""
update kbx.integration_attempts
set state='retrying', next_retry_at=now(), manual_retry_key=@Key
where id=@Id and tenant_id=@TenantId and state='failed'
returning id;
""";
var id=await c.ExecuteScalarAsync<Guid?>(new CommandDefinition(sql,new{Id=attemptId,TenantId=RuntimeIdentity.TenantId(User),Key=key},tx,cancellationToken:ct));
if(id is null){await tx.RollbackAsync(ct);await Send.ResponseAsync(KbxNotFoundProblem.Create("INTEGRATION_ATTEMPT_NOT_RETRYABLE","재처리할 수 있는 연계 실패를 찾지 못했습니다."),404,cancellation:ct);return;}
var response=new Response(id.Value,"retrying","재처리를 예약했습니다.");
await c.ExecuteAsync(new CommandDefinition("insert into kbx.command_receipts(operation_id,idempotency_key,response_json) values(@OperationId,@Key,cast(@Json as jsonb))",
new { OperationId, Key=key, Json=JsonSerializer.Serialize(response) },tx,cancellationToken:ct));
await tx.CommitAsync(ct);
await Send.OkAsync(response,ct);
}
}
@@ -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);
}
}
@@ -0,0 +1,49 @@
using Dapper;
using FastEndpoints;
using Npgsql;
using Shared.Operations;
using System.Text.Json;
namespace Modules.Common.Reconcile.CreateExceptions;
public sealed record CreateReconcileExceptionsRequest(IReadOnlyList<Guid> Ids);
public sealed record CreateReconcileExceptionsResponse(int CreatedOrUpdatedCount, int SkippedCount);
public sealed class Endpoint(NpgsqlDataSource dataSource, OperationsProjectionWriter projection)
: Endpoint<CreateReconcileExceptionsRequest, CreateReconcileExceptionsResponse>
{
public override void Configure() { Post("/api/reconcile/items/create-exceptions"); Permissions("common.operations.create"); }
public override async Task HandleAsync(CreateReconcileExceptionsRequest req, CancellationToken ct)
{
var tenant = OperationsIdentity.TenantId(User);
var ids = req.Ids.Distinct().Take(500).ToArray();
if (ids.Length == 0) { await Send.OkAsync(new(0, 0), ct); return; }
await using var connection = await dataSource.OpenConnectionAsync(ct);
var rows = (await connection.QueryAsync<Row>(new CommandDefinition("""
select id, reconcile_type as ReconcileType, reference_no as ReferenceNo,
expected_value as ExpectedValue, actual_value as ActualValue,
difference_value as DifferenceValue, reason_code as ReasonCode, reason_text as ReasonText,
occurred_at as OccurredAt, version
from kbx.reconcile_items
where tenant_id=@Tenant and id=any(@Ids) and status in ('mismatch','pending');
""", new { Tenant = tenant, Ids = ids }, cancellationToken: ct))).AsList();
foreach (var row in rows)
{
await projection.UpsertAsync(new UpsertWorkItem(
tenant, "COMMON", "reconcile", row.Id.ToString(), row.ReferenceNo, "COMMON-REC-001", row.Version,
"RECONCILE_MISMATCH", $"대사 불일치 · {row.ReferenceNo}", row.ReasonText,
"warning", row.OccurredAt, Context: new {
row.ReconcileType, row.ReferenceNo, row.ExpectedValue, row.ActualValue,
row.DifferenceValue, row.ReasonCode
}), ct);
}
await Send.OkAsync(new(rows.Count, ids.Length - rows.Count), ct);
}
private sealed record Row(Guid Id, string ReconcileType, string ReferenceNo, string ExpectedValue,
string ActualValue, string? DifferenceValue, string? ReasonCode, string? ReasonText,
DateTimeOffset OccurredAt, long Version);
}
@@ -0,0 +1,69 @@
using Dapper;
using FastEndpoints;
using Npgsql;
using Shared.Operations;
namespace Modules.Common.Reconcile.Search;
public sealed class SearchReconcileRequest
{
public string? ReconcileType { get; init; }
public string? Status { 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<SearchReconcileRequest, ReconcileResponse>
{
public override void Configure() { Get("/api/reconcile/items"); Permissions("common.reconcile.read"); }
public override async Task HandleAsync(SearchReconcileRequest req, CancellationToken ct)
{
var tenant = OperationsIdentity.TenantId(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, reconcile_type as ReconcileType, reference_no as ReferenceNo,
source_label as SourceLabel, target_label as TargetLabel,
expected_value as ExpectedValue, actual_value as ActualValue,
difference_value as DifferenceValue, reason_code as ReasonCode, reason_text as ReasonText,
status, occurred_at as OccurredAt, source_id as SourceId, target_id as TargetId, version
from kbx.reconcile_items
where tenant_id=@Tenant
and (@ReconcileType is null or reconcile_type=@ReconcileType)
and (@Status is null or status=@Status)
and (@Keyword is null or reference_no ilike '%' || @Keyword || '%' or reason_text ilike '%' || @Keyword || '%')
order by case status when 'mismatch' then 0 when 'pending' then 1 when 'resolved' then 2 else 3 end,
occurred_at desc
limit @PageSize offset @Offset;
""";
const string summarySql = """
select count(*)::int as TotalCount,
count(*) filter(where status='matched')::int as MatchedCount,
count(*) filter(where status='mismatch')::int as MismatchCount,
count(*) filter(where status='pending')::int as PendingCount,
count(*) filter(where status='resolved')::int as ResolvedCount
from kbx.reconcile_items
where tenant_id=@Tenant
and (@ReconcileType is null or reconcile_type=@ReconcileType)
and (@Keyword is null or reference_no ilike '%' || @Keyword || '%' or reason_text ilike '%' || @Keyword || '%');
""";
var args = new {
Tenant = tenant,
ReconcileType = Empty(req.ReconcileType),
Status = Empty(req.Status),
Keyword = Empty(req.Keyword),
PageSize = pageSize,
Offset = offset,
};
var rows = (await connection.QueryAsync<ReconcileItemDto>(new CommandDefinition(rowsSql, args, cancellationToken: ct))).AsList();
var summary = await connection.QuerySingleAsync<ReconcileSummary>(new CommandDefinition(summarySql, args, cancellationToken: ct));
await Send.OkAsync(new ReconcileResponse(rows, summary), ct);
}
private static string? Empty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
@@ -0,0 +1,11 @@
using FastEndpoints;
using Kbx.Shared.Runtime;
namespace Kbx.Modules.Common.Runtime.Health;
public sealed class Endpoint(KbxRuntimeNoticeRepository repository) : EndpointWithoutRequest<KbxRuntimeNotice?>
{
public override void Configure() { Get("/api/kbx/runtime/notice"); Permissions("common.runtime.read"); }
public override async Task HandleAsync(CancellationToken ct)
=> await Send.OkAsync(await repository.GetActiveAsync(RuntimeIdentity.TenantId(User), ct), ct);
}
@@ -0,0 +1,23 @@
using FastEndpoints;
using Kbx.Shared.Runtime;
namespace Kbx.Modules.Common.Runtime.Notifications;
public sealed class Request { public int Limit { get; init; } = 30; }
public sealed class Endpoint(KbxNotificationRepository repository) : Endpoint<Request, IReadOnlyList<KbxUserNotificationDto>>
{
public override void Configure() { Get("/api/kbx/runtime/notifications"); Permissions("common.runtime.read"); }
public override async Task HandleAsync(Request req, CancellationToken ct)
=> await Send.OkAsync(await repository.GetRecentAsync(RuntimeIdentity.TenantId(User), RuntimeIdentity.UserId(User), req.Limit, ct), ct);
}
public sealed class MarkReadRequest { public Guid Id { get; init; } }
public sealed class MarkReadEndpoint(KbxNotificationRepository repository) : Endpoint<MarkReadRequest>
{
public override void Configure() { Post("/api/kbx/runtime/notifications/{Id}/read"); Permissions("common.runtime.read"); }
public override async Task HandleAsync(MarkReadRequest req, CancellationToken ct)
{
await repository.MarkReadAsync(RuntimeIdentity.TenantId(User), RuntimeIdentity.UserId(User), req.Id, ct);
await Send.NoContentAsync(ct);
}
}
@@ -0,0 +1,12 @@
using FastEndpoints;
using Kbx.Shared.Runtime;
namespace Kbx.Modules.Common.Runtime.Operations;
public sealed class Request { public int Limit { get; init; } = 20; }
public sealed class Endpoint(KbxOperationRunRepository repository) : Endpoint<Request, IReadOnlyList<KbxOperationRunDto>>
{
public override void Configure() { Get("/api/kbx/runtime/operations"); Permissions("common.runtime.read"); }
public override async Task HandleAsync(Request req, CancellationToken ct)
=> await Send.OkAsync(await repository.GetRecentAsync(RuntimeIdentity.TenantId(User), RuntimeIdentity.UserId(User), req.Limit, ct), ct);
}
@@ -0,0 +1,83 @@
using Dapper;
using FastEndpoints;
using KBX.Shared.Experience;
using Npgsql;
namespace KBX.Modules.Common.Suggestions.Submit;
public sealed record Request(
string Category,
string Message,
bool IncludeScreenContext,
SuggestionContext Context);
public sealed record Response(Guid SuggestionId, DateTimeOffset ReceivedAt);
public sealed class Endpoint(NpgsqlDataSource dataSource) : Endpoint<Request, Response>
{
public override void Configure()
{
Post("/api/common/suggestions");
Permissions("common.suggestion.create");
}
public override async Task HandleAsync(Request req, CancellationToken ct)
{
if (req.Message.Trim().Length is < 3 or > 2000)
{
AddError(r => r.Message, "의견 내용은 3~2000자로 입력하세요.");
await Send.ErrorsAsync(cancellation: ct);
return;
}
if (req.Category is not ("inconvenience" or "bug" or "improvement"))
{
AddError(r => r.Category, "지원하지 않는 의견 유형입니다.");
await Send.ErrorsAsync(cancellation: ct);
return;
}
// Host application must replace these claims adapters with its authenticated tenant/user resolver.
var tenantId = Guid.Empty;
var userId = Guid.Empty;
var id = Guid.NewGuid();
var now = DateTimeOffset.UtcNow;
object? context = null;
if (req.IncludeScreenContext)
{
// Deliberately persist diagnostic keys only, not arbitrary screen/business values.
context = new
{
req.Context.ActiveFilters,
req.Context.GridLayoutVersion,
};
}
await using var connection = await dataSource.OpenConnectionAsync(ct);
await connection.ExecuteAsync(new CommandDefinition("""
insert into kbx.user_suggestions
(id, tenant_id, user_id, category, message, screen_id, screen_version,
route, app_version, user_role, context, status, created_at)
values
(@Id, @TenantId, @UserId, @Category, @Message, @ScreenId, @ScreenVersion,
@Route, @AppVersion, @UserRole, cast(@Context as jsonb), 'NEW', @CreatedAt)
""", new
{
Id = id,
TenantId = tenantId,
UserId = userId,
req.Category,
Message = req.Message.Trim(),
req.Context.ScreenId,
req.Context.ScreenVersion,
req.Context.Route,
req.Context.AppVersion,
req.Context.UserRole,
Context = context is null ? null : System.Text.Json.JsonSerializer.Serialize(context),
CreatedAt = now,
}, cancellationToken: ct));
await Send.OkAsync(new Response(id, now), ct);
}
}
@@ -0,0 +1,13 @@
using FastEndpoints;
using Kbx.Shared.Telemetry;
namespace Kbx.Modules.Common.UxTelemetry.Ingest;
public sealed class Endpoint(KbxUxTelemetryRepository repository) : Endpoint<KbxUxEventBatchRequest>
{
public override void Configure(){Post("/api/kbx/ux/events");Permissions("common.telemetry.write");}
public override async Task HandleAsync(KbxUxEventBatchRequest req,CancellationToken ct)
{
// Replace with the deployment's authenticated tenant accessor.
var tenantId=Kbx.Shared.Runtime.RuntimeIdentity.TenantId(User);
await repository.AppendAsync(tenantId,Kbx.Shared.Runtime.RuntimeIdentity.UserId(User),req.Events,ct);await Send.NoContentAsync(ct);
}
}
@@ -0,0 +1,12 @@
using FastEndpoints;using Kbx.Shared.Telemetry;
namespace Kbx.Modules.Common.UxTelemetry.Metrics;
public sealed record Request(DateOnly From,DateOnly To,string? ScreenId);
public sealed class Endpoint(KbxUxMetricsQuery query):Endpoint<Request,KbxUxMetricsResponse>
{
public override void Configure(){Get("/api/kbx/ux/metrics");Permissions("common.ux.read");}
public override async Task HandleAsync(Request req,CancellationToken ct)
{
var tenantId=Kbx.Shared.Runtime.RuntimeIdentity.TenantId(User);
await Send.OkAsync(await query.ExecuteAsync(tenantId,req.From,req.To,req.ScreenId,ct),ct);
}
}