using System.Text.Json.Nodes; using Microsoft.Extensions.Logging; using Npgsql; using NpgsqlTypes; using StellaOps.Findings.Ledger.Domain; using StellaOps.Findings.Ledger.Hashing; namespace StellaOps.Findings.Ledger.Infrastructure.Postgres; public sealed class PostgresFindingProjectionRepository : IFindingProjectionRepository { private const string GetProjectionSql = """ SELECT status, severity, risk_score, risk_severity, risk_profile_version, risk_explanation_id, risk_event_sequence, labels, current_event_id, explain_ref, policy_rationale, updated_at, cycle_hash FROM findings_projection WHERE tenant_id = @tenant_id AND finding_id = @finding_id AND policy_version = @policy_version """; private const string UpsertProjectionSql = """ INSERT INTO findings_projection ( tenant_id, finding_id, policy_version, status, severity, risk_score, risk_severity, risk_profile_version, risk_explanation_id, risk_event_sequence, labels, current_event_id, explain_ref, policy_rationale, updated_at, cycle_hash) VALUES ( @tenant_id, @finding_id, @policy_version, @status, @severity, @risk_score, @risk_severity, @risk_profile_version, @risk_explanation_id, @risk_event_sequence, @labels, @current_event_id, @explain_ref, @policy_rationale, @updated_at, @cycle_hash) ON CONFLICT (tenant_id, finding_id, policy_version) DO UPDATE SET status = EXCLUDED.status, severity = EXCLUDED.severity, risk_score = EXCLUDED.risk_score, risk_severity = EXCLUDED.risk_severity, risk_profile_version = EXCLUDED.risk_profile_version, risk_explanation_id = EXCLUDED.risk_explanation_id, risk_event_sequence = EXCLUDED.risk_event_sequence, labels = EXCLUDED.labels, current_event_id = EXCLUDED.current_event_id, explain_ref = EXCLUDED.explain_ref, policy_rationale = EXCLUDED.policy_rationale, updated_at = EXCLUDED.updated_at, cycle_hash = EXCLUDED.cycle_hash; """; private const string InsertHistorySql = """ INSERT INTO finding_history ( tenant_id, finding_id, policy_version, event_id, status, severity, actor_id, comment, occurred_at) VALUES ( @tenant_id, @finding_id, @policy_version, @event_id, @status, @severity, @actor_id, @comment, @occurred_at) ON CONFLICT (tenant_id, finding_id, event_id) DO NOTHING; """; private const string InsertActionSql = """ INSERT INTO triage_actions ( tenant_id, action_id, event_id, finding_id, action_type, payload, created_at, created_by) VALUES ( @tenant_id, @action_id, @event_id, @finding_id, @action_type, @payload, @created_at, @created_by) ON CONFLICT (tenant_id, action_id) DO NOTHING; """; private const string SelectCheckpointSql = """ SELECT last_recorded_at, last_event_id, updated_at FROM ledger_projection_offsets WHERE worker_id = @worker_id """; private const string UpsertCheckpointSql = """ INSERT INTO ledger_projection_offsets ( worker_id, last_recorded_at, last_event_id, updated_at) VALUES ( @worker_id, @last_recorded_at, @last_event_id, @updated_at) ON CONFLICT (worker_id) DO UPDATE SET last_recorded_at = EXCLUDED.last_recorded_at, last_event_id = EXCLUDED.last_event_id, updated_at = EXCLUDED.updated_at; """; private const string DefaultWorkerId = "default"; private readonly LedgerDataSource _dataSource; private readonly TimeProvider _timeProvider; private readonly ILogger _logger; public PostgresFindingProjectionRepository( LedgerDataSource dataSource, TimeProvider timeProvider, ILogger logger) { _dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource)); _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task GetAsync(string tenantId, string findingId, string policyVersion, CancellationToken cancellationToken) { await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "projector", cancellationToken).ConfigureAwait(false); await using var command = new NpgsqlCommand(GetProjectionSql, connection); command.CommandTimeout = _dataSource.CommandTimeoutSeconds; command.Parameters.AddWithValue("tenant_id", tenantId); command.Parameters.AddWithValue("finding_id", findingId); command.Parameters.AddWithValue("policy_version", policyVersion); await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) { return null; } var status = reader.GetString(0); var severity = reader.IsDBNull(1) ? (decimal?)null : reader.GetDecimal(1); var riskScore = reader.IsDBNull(2) ? (decimal?)null : reader.GetDecimal(2); var riskSeverity = reader.IsDBNull(3) ? null : reader.GetString(3); var riskProfileVersion = reader.IsDBNull(4) ? null : reader.GetString(4); var riskExplanationId = reader.IsDBNull(5) ? (Guid?)null : reader.GetGuid(5); var riskEventSequence = reader.IsDBNull(6) ? (long?)null : reader.GetInt64(6); var labelsJson = reader.GetFieldValue(7); var labels = JsonNode.Parse(labelsJson)?.AsObject() ?? new JsonObject(); var currentEventId = reader.GetGuid(8); var explainRef = reader.IsDBNull(9) ? null : reader.GetString(9); var rationaleJson = reader.IsDBNull(10) ? string.Empty : reader.GetFieldValue(10); JsonArray rationale; if (string.IsNullOrWhiteSpace(rationaleJson)) { rationale = new JsonArray(); } else { rationale = JsonNode.Parse(rationaleJson) as JsonArray ?? new JsonArray(); } var updatedAt = reader.GetFieldValue(11); var cycleHash = reader.GetString(12); return new FindingProjection( tenantId, findingId, policyVersion, status, severity, riskScore, riskSeverity, riskProfileVersion, riskExplanationId, riskEventSequence, labels, currentEventId, explainRef, rationale, updatedAt, cycleHash); } public async Task UpsertAsync(FindingProjection projection, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(projection); await using var connection = await _dataSource.OpenConnectionAsync(projection.TenantId, "projector", cancellationToken).ConfigureAwait(false); await using var command = new NpgsqlCommand(UpsertProjectionSql, connection); command.CommandTimeout = _dataSource.CommandTimeoutSeconds; command.Parameters.AddWithValue("tenant_id", projection.TenantId); command.Parameters.AddWithValue("finding_id", projection.FindingId); command.Parameters.AddWithValue("policy_version", projection.PolicyVersion); command.Parameters.AddWithValue("status", projection.Status); command.Parameters.AddWithValue("severity", projection.Severity.HasValue ? projection.Severity.Value : (object)DBNull.Value); command.Parameters.AddWithValue("risk_score", projection.RiskScore.HasValue ? projection.RiskScore.Value : (object)DBNull.Value); command.Parameters.AddWithValue("risk_severity", projection.RiskSeverity ?? (object)DBNull.Value); command.Parameters.AddWithValue("risk_profile_version", projection.RiskProfileVersion ?? (object)DBNull.Value); command.Parameters.AddWithValue("risk_explanation_id", projection.RiskExplanationId ?? (object)DBNull.Value); command.Parameters.AddWithValue("risk_event_sequence", projection.RiskEventSequence.HasValue ? projection.RiskEventSequence.Value : (object)DBNull.Value); var labelsCanonical = LedgerCanonicalJsonSerializer.Canonicalize(projection.Labels); var labelsJson = labelsCanonical.ToJsonString(); command.Parameters.Add(new NpgsqlParameter("labels", NpgsqlDbType.Jsonb) { TypedValue = labelsJson }); command.Parameters.AddWithValue("current_event_id", projection.CurrentEventId); command.Parameters.AddWithValue("explain_ref", projection.ExplainRef ?? (object)DBNull.Value); var rationaleCanonical = LedgerCanonicalJsonSerializer.Canonicalize(projection.PolicyRationale); var rationaleJson = rationaleCanonical.ToJsonString(); command.Parameters.Add(new NpgsqlParameter("policy_rationale", NpgsqlDbType.Jsonb) { TypedValue = rationaleJson }); command.Parameters.AddWithValue("updated_at", projection.UpdatedAt); command.Parameters.AddWithValue("cycle_hash", projection.CycleHash); await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); } public async Task InsertHistoryAsync(FindingHistoryEntry entry, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(entry); await using var connection = await _dataSource.OpenConnectionAsync(entry.TenantId, "projector", cancellationToken).ConfigureAwait(false); await using var command = new NpgsqlCommand(InsertHistorySql, connection); command.CommandTimeout = _dataSource.CommandTimeoutSeconds; command.Parameters.AddWithValue("tenant_id", entry.TenantId); command.Parameters.AddWithValue("finding_id", entry.FindingId); command.Parameters.AddWithValue("policy_version", entry.PolicyVersion); command.Parameters.AddWithValue("event_id", entry.EventId); command.Parameters.AddWithValue("status", entry.Status); command.Parameters.AddWithValue("severity", entry.Severity.HasValue ? entry.Severity.Value : (object)DBNull.Value); command.Parameters.AddWithValue("actor_id", entry.ActorId); command.Parameters.AddWithValue("comment", entry.Comment ?? (object)DBNull.Value); command.Parameters.AddWithValue("occurred_at", entry.OccurredAt); await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); } public async Task InsertActionAsync(TriageActionEntry entry, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(entry); await using var connection = await _dataSource.OpenConnectionAsync(entry.TenantId, "projector", cancellationToken).ConfigureAwait(false); await using var command = new NpgsqlCommand(InsertActionSql, connection); command.CommandTimeout = _dataSource.CommandTimeoutSeconds; command.Parameters.AddWithValue("tenant_id", entry.TenantId); command.Parameters.AddWithValue("action_id", entry.ActionId); command.Parameters.AddWithValue("event_id", entry.EventId); command.Parameters.AddWithValue("finding_id", entry.FindingId); command.Parameters.AddWithValue("action_type", entry.ActionType); var payloadJson = entry.Payload.ToJsonString(); command.Parameters.Add(new NpgsqlParameter("payload", NpgsqlDbType.Jsonb) { TypedValue = payloadJson }); command.Parameters.AddWithValue("created_at", entry.CreatedAt); command.Parameters.AddWithValue("created_by", entry.CreatedBy); await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); } public async Task GetCheckpointAsync(CancellationToken cancellationToken) { await using var connection = await _dataSource.OpenConnectionAsync(string.Empty, "projector", cancellationToken).ConfigureAwait(false); await using var command = new NpgsqlCommand(SelectCheckpointSql, connection); command.CommandTimeout = _dataSource.CommandTimeoutSeconds; command.Parameters.AddWithValue("worker_id", DefaultWorkerId); await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) { return ProjectionCheckpoint.Initial(_timeProvider); } var lastRecordedAt = reader.GetFieldValue(0); var lastEventId = reader.GetGuid(1); var updatedAt = reader.GetFieldValue(2); return new ProjectionCheckpoint(lastRecordedAt, lastEventId, updatedAt); } public async Task SaveCheckpointAsync(ProjectionCheckpoint checkpoint, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(checkpoint); await using var connection = await _dataSource.OpenConnectionAsync(string.Empty, "projector", cancellationToken).ConfigureAwait(false); await using var command = new NpgsqlCommand(UpsertCheckpointSql, connection); command.CommandTimeout = _dataSource.CommandTimeoutSeconds; command.Parameters.AddWithValue("worker_id", DefaultWorkerId); command.Parameters.AddWithValue("last_recorded_at", checkpoint.LastRecordedAt); command.Parameters.AddWithValue("last_event_id", checkpoint.LastEventId); command.Parameters.AddWithValue("updated_at", checkpoint.UpdatedAt); try { await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); } catch (PostgresException ex) { _logger.LogError(ex, "Failed to persist projection checkpoint."); throw; } } }