using Dapper; using Npgsql; using System.Text.Json; namespace Shared.Operations; public sealed record UpsertWorkItem( string TenantId, string SourceModule, string SourceType, string SourceId, string ReferenceNo, string? SourceScreenId, long? SourceVersion, string Code, string Title, string? Detail, string Severity, DateTimeOffset OccurredAt, DateTimeOffset? DueAt = null, string? RetryActionKey = null, bool AllowManualResolution = false, object? Context = null); public sealed class OperationsProjectionWriter(NpgsqlDataSource dataSource) { public async Task UpsertAsync(UpsertWorkItem item, CancellationToken ct) { await using var connection = await dataSource.OpenConnectionAsync(ct); var id = Guid.NewGuid(); const string sql = """ insert into kbx.work_items( id, tenant_id, source_module, source_type, source_id, reference_no, source_screen_id, source_version, code, title, detail, severity, status, retry_action_key, allow_manual_resolution, context, occurred_at, due_at) values( @Id, @TenantId, @SourceModule, @SourceType, @SourceId, @ReferenceNo, @SourceScreenId, @SourceVersion, @Code, @Title, @Detail, @Severity, 'open', @RetryActionKey, @AllowManualResolution, cast(@Context as jsonb), @OccurredAt, @DueAt) on conflict(tenant_id, source_module, source_type, source_id, code) do update set source_version = excluded.source_version, reference_no = excluded.reference_no, source_screen_id = excluded.source_screen_id, title = excluded.title, detail = excluded.detail, severity = excluded.severity, status = case when kbx.work_items.status = 'resolved' then 'open' else kbx.work_items.status end, retry_action_key = excluded.retry_action_key, allow_manual_resolution = excluded.allow_manual_resolution, context = excluded.context, occurred_at = excluded.occurred_at, due_at = excluded.due_at, resolved_at = null, resolution_reason = null, version = kbx.work_items.version + 1, updated_at = now() where excluded.source_version is null or kbx.work_items.source_version is null or excluded.source_version > kbx.work_items.source_version returning id; """; var args = new { Id = id, item.TenantId, item.SourceModule, item.SourceType, item.SourceId, item.ReferenceNo, item.SourceScreenId, item.SourceVersion, item.Code, item.Title, item.Detail, item.Severity, item.RetryActionKey, item.AllowManualResolution, Context = JsonSerializer.Serialize(item.Context ?? new { }), item.OccurredAt, item.DueAt }; var changedId = await connection.ExecuteScalarAsync(new CommandDefinition(sql, args, cancellationToken: ct)); if (changedId is not null) return changedId.Value; return await connection.ExecuteScalarAsync(new CommandDefinition(""" select id from kbx.work_items where tenant_id=@TenantId and source_module=@SourceModule and source_type=@SourceType and source_id=@SourceId and code=@Code; """, args, cancellationToken: ct)); } public async Task ResolveBySourceAsync(string tenantId, string sourceModule, string sourceType, string sourceId, string code, string reason, long? sourceVersion, CancellationToken ct) { await using var connection = await dataSource.OpenConnectionAsync(ct); await connection.ExecuteAsync(new CommandDefinition(""" update kbx.work_items set status = 'resolved', resolved_at = now(), resolution_reason = @Reason, source_version = coalesce(@SourceVersion, source_version), version = version + 1, updated_at = now() where tenant_id = @TenantId and source_module = @SourceModule and source_type = @SourceType and source_id = @SourceId and code = @Code and status <> 'resolved' and (@SourceVersion is null or source_version is null or @SourceVersion >= source_version); """, new { TenantId = tenantId, SourceModule = sourceModule, SourceType = sourceType, SourceId = sourceId, Code = code, Reason = reason, SourceVersion = sourceVersion }, cancellationToken: ct)); } }