namespace KArtSell.Modules.ModelOperations.ApprovalWorkflow; using System; using System.Collections.Generic; using System.Data; using System.Linq; using System.Text.Json; using System.Threading.Tasks; using Dapper; using KArtSell.BuildingBlocks.Time; using Npgsql; public class ApprovalSql { private readonly string _connectionString; private readonly IClock _clock; public ApprovalSql(string connectionString, IClock clock) { _connectionString = connectionString; _clock = clock; } public async Task GetProposalByIdAsync(Guid id, DateTimeOffset cutoff) { using var conn = new NpgsqlConnection(_connectionString); const string sql = """ SELECT id, model_id, status, created_by, created_at, justification, effective_at, proposed_at, approved_by, approved_at, approval_notes, activated_by, activated_at, published_at, revision, correlation_id FROM model_operations.approval_proposals WHERE id = @id AND published_at <= @cutoff ORDER BY published_at DESC LIMIT 1 """; var proposal = await conn.QueryFirstOrDefaultAsync(sql, new { id, cutoff }); if (proposal == null) return null; return MapFromRaw(proposal); } public async Task> GetProposalsByStatusAsync(string status, DateTimeOffset cutoff, int pageSize = 100) { using var conn = new NpgsqlConnection(_connectionString); const string sql = """ SELECT id, model_id, status, created_by, created_at, justification, effective_at, proposed_at, approved_by, approved_at, approval_notes, activated_by, activated_at, published_at, revision, correlation_id FROM model_operations.approval_proposals WHERE status = @status AND published_at <= @cutoff ORDER BY created_at DESC LIMIT @pageSize """; var proposals = await conn.QueryAsync(sql, new { status, cutoff, pageSize }); return proposals.Select(MapFromRaw).ToList(); } public async Task InsertProposalAsync( Guid id, Guid modelId, string status, string createdBy, string justification, DateOnly effectiveAt, DateTimeOffset publishedAt, Guid correlationId) { using var conn = new NpgsqlConnection(_connectionString); const string sql = """ INSERT INTO model_operations.approval_proposals (id, model_id, status, created_by, created_at, justification, effective_at, published_at, revision, correlation_id) VALUES (@id, @modelId, @status, @createdBy, @createdAt, @justification, @effectiveAt::date, @publishedAt, 1, @correlationId) """; await conn.ExecuteAsync(sql, new { id, modelId, status, createdBy, createdAt = _clock.UtcNow, justification, effectiveAt = effectiveAt.ToString("yyyy-MM-dd"), // Dapper: DateOnly cannot be used as a parameter value directly publishedAt, correlationId }); } public async Task UpdateProposalStatusAsync(Guid id, string newStatus, string approvedBy, string? approvalNotes, DateTimeOffset publishedAt) { using var conn = new NpgsqlConnection(_connectionString); const string sql = """ INSERT INTO model_operations.approval_proposals (id, model_id, status, created_by, created_at, justification, effective_at, approved_by, approved_at, approval_notes, published_at, revision, correlation_id) SELECT id, model_id, @newStatus, created_by, created_at, justification, effective_at, @approvedBy, @approvedAt, @approvalNotes, @publishedAt, revision + 1, correlation_id FROM model_operations.approval_proposals WHERE id = @id ORDER BY published_at DESC LIMIT 1 """; await conn.ExecuteAsync(sql, new { id, newStatus, approvedBy, approvedAt = _clock.UtcNow, approvalNotes, publishedAt }); } public async Task InsertEvidenceAsync(Guid id, Guid proposalId, string evidenceType, string evidenceUrl, string? comment, Guid correlationId) { using var conn = new NpgsqlConnection(_connectionString); const string sql = """ INSERT INTO model_operations.approval_evidence (id, approval_proposal_id, evidence_type, evidence_url, reviewer_comment, published_at, correlation_id) VALUES (@id, @proposalId, @evidenceType, @evidenceUrl, @comment, @publishedAt, @correlationId) """; await conn.ExecuteAsync(sql, new { id, proposalId, evidenceType, evidenceUrl, comment, publishedAt = _clock.UtcNow, correlationId }); } public async Task InsertEventAsync(Guid id, Guid proposalId, string eventType, string actorEmail, Dictionary? details, Guid correlationId) { using var conn = new NpgsqlConnection(_connectionString); const string sql = """ INSERT INTO model_operations.approval_events (id, approval_proposal_id, event_type, actor_email, event_at, details, published_at, correlation_id) VALUES (@id, @proposalId, @eventType, @actorEmail, @eventAt, @details::jsonb, @publishedAt, @correlationId) """; var detailsJson = details != null ? JsonSerializer.Serialize(details) : null; await conn.ExecuteAsync(sql, new { id, proposalId, eventType, actorEmail, eventAt = _clock.UtcNow, details = detailsJson, publishedAt = _clock.UtcNow, correlationId }); } public async Task> GetEvidenceByProposalAsync(Guid proposalId, DateTimeOffset cutoff) { using var conn = new NpgsqlConnection(_connectionString); const string sql = """ SELECT id, approval_proposal_id, evidence_type, evidence_url, reviewer_comment, published_at, correlation_id FROM model_operations.approval_evidence WHERE approval_proposal_id = @proposalId AND published_at <= @cutoff ORDER BY published_at DESC """; var results = await conn.QueryAsync(sql, new { proposalId, cutoff }); return results.ToList(); } private ApprovalProposal MapFromRaw(ApprovalProposalRaw raw) { return new ApprovalProposal { Id = raw.Id, ModelId = raw.ModelId, Status = Enum.Parse(raw.Status), CreatedBy = raw.CreatedBy, CreatedAt = raw.CreatedAt, Justification = raw.Justification, EffectiveAt = raw.EffectiveAt, ProposedAt = raw.ProposedAt, ApprovedBy = raw.ApprovedBy, ApprovedAt = raw.ApprovedAt, ApprovalNotes = raw.ApprovalNotes, ActivatedBy = raw.ActivatedBy, ActivatedAt = raw.ActivatedAt, PublishedAt = raw.PublishedAt, Revision = raw.Revision, CorrelationId = raw.CorrelationId }; } private sealed class ApprovalProposalRaw { public Guid Id { get; set; } public Guid ModelId { get; set; } public string Status { get; set; } = null!; public string CreatedBy { get; set; } = null!; public DateTimeOffset CreatedAt { get; set; } public string Justification { get; set; } = null!; public DateOnly EffectiveAt { get; set; } public DateTimeOffset? ProposedAt { get; set; } public string? ApprovedBy { get; set; } public DateTimeOffset? ApprovedAt { get; set; } public string? ApprovalNotes { get; set; } public string? ActivatedBy { get; set; } public DateTimeOffset? ActivatedAt { get; set; } public DateTimeOffset PublishedAt { get; set; } public int Revision { get; set; } public Guid CorrelationId { get; set; } } }