Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.
This commit is contained in:
@@ -0,0 +1,552 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// © StellaOps Contributors. See LICENSE and NOTICE.md in the repository root.
|
||||
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.VexLens.Consensus;
|
||||
using StellaOps.VexLens.Models;
|
||||
using StellaOps.VexLens.Options;
|
||||
|
||||
namespace StellaOps.VexLens.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="IConsensusProjectionStore"/>.
|
||||
/// This proxy implementation lives in the core VexLens project to enable DI registration.
|
||||
/// Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii (LIN-BE-022)
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// For full-featured implementation with advanced queries, use PostgresConsensusProjectionStore
|
||||
/// from StellaOps.VexLens.Persistence package.
|
||||
/// </remarks>
|
||||
public sealed class PostgresConsensusProjectionStoreProxy : IConsensusProjectionStore
|
||||
{
|
||||
private static readonly ActivitySource ActivitySource = new("StellaOps.VexLens.Storage.PostgresConsensusProjectionStoreProxy");
|
||||
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private readonly IConsensusEventEmitter? _eventEmitter;
|
||||
private readonly ILogger<PostgresConsensusProjectionStoreProxy> _logger;
|
||||
private readonly VexLensStorageOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public PostgresConsensusProjectionStoreProxy(
|
||||
NpgsqlDataSource dataSource,
|
||||
ILogger<PostgresConsensusProjectionStoreProxy> logger,
|
||||
IConsensusEventEmitter? eventEmitter = null,
|
||||
VexLensStorageOptions? options = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_eventEmitter = eventEmitter;
|
||||
_options = options ?? new VexLensStorageOptions();
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
private const string Schema = "vexlens";
|
||||
|
||||
private const string InsertProjectionSql = $"""
|
||||
INSERT INTO {Schema}.consensus_projections (
|
||||
id, vulnerability_id, product_key, tenant_id, status, justification,
|
||||
confidence_score, outcome, statement_count, conflict_count,
|
||||
rationale_summary, computed_at, stored_at, previous_projection_id, status_changed
|
||||
) VALUES (
|
||||
@id, @vulnerability_id, @product_key, @tenant_id, @status, @justification,
|
||||
@confidence_score, @outcome, @statement_count, @conflict_count,
|
||||
@rationale_summary, @computed_at, @stored_at, @previous_projection_id, @status_changed
|
||||
)
|
||||
""";
|
||||
|
||||
private const string SelectByIdSql = $"""
|
||||
SELECT id, vulnerability_id, product_key, tenant_id, status, justification,
|
||||
confidence_score, outcome, statement_count, conflict_count,
|
||||
rationale_summary, computed_at, stored_at, previous_projection_id, status_changed
|
||||
FROM {Schema}.consensus_projections
|
||||
WHERE id = @id
|
||||
""";
|
||||
|
||||
private const string SelectLatestSql = $"""
|
||||
SELECT id, vulnerability_id, product_key, tenant_id, status, justification,
|
||||
confidence_score, outcome, statement_count, conflict_count,
|
||||
rationale_summary, computed_at, stored_at, previous_projection_id, status_changed
|
||||
FROM {Schema}.consensus_projections
|
||||
WHERE vulnerability_id = @vulnerability_id
|
||||
AND product_key = @product_key
|
||||
AND (@tenant_id IS NULL OR tenant_id = @tenant_id)
|
||||
ORDER BY computed_at DESC
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
private const string SelectHistorySql = $"""
|
||||
SELECT id, vulnerability_id, product_key, tenant_id, status, justification,
|
||||
confidence_score, outcome, statement_count, conflict_count,
|
||||
rationale_summary, computed_at, stored_at, previous_projection_id, status_changed
|
||||
FROM {Schema}.consensus_projections
|
||||
WHERE vulnerability_id = @vulnerability_id
|
||||
AND product_key = @product_key
|
||||
AND (@tenant_id IS NULL OR tenant_id = @tenant_id)
|
||||
ORDER BY computed_at DESC
|
||||
LIMIT @limit
|
||||
""";
|
||||
|
||||
private const string PurgeSql = $"""
|
||||
DELETE FROM {Schema}.consensus_projections
|
||||
WHERE computed_at < @older_than
|
||||
AND (@tenant_id IS NULL OR tenant_id = @tenant_id)
|
||||
""";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ConsensusProjection> StoreAsync(
|
||||
VexConsensusResult result,
|
||||
StoreProjectionOptions options,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
using var activity = ActivitySource.StartActivity("StoreAsync");
|
||||
activity?.SetTag("vulnerabilityId", result.VulnerabilityId);
|
||||
activity?.SetTag("productKey", result.ProductKey);
|
||||
|
||||
var projectionId = Guid.NewGuid();
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Check for previous projection to track history
|
||||
ConsensusProjection? previous = null;
|
||||
if (options.TrackHistory)
|
||||
{
|
||||
previous = await GetLatestAsync(
|
||||
result.VulnerabilityId,
|
||||
result.ProductKey,
|
||||
options.TenantId,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
var statusChanged = previous is not null && previous.Status != result.ConsensusStatus;
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
await using var cmd = new NpgsqlCommand(InsertProjectionSql, connection, transaction);
|
||||
cmd.Parameters.AddWithValue("id", projectionId);
|
||||
cmd.Parameters.AddWithValue("vulnerability_id", result.VulnerabilityId);
|
||||
cmd.Parameters.AddWithValue("product_key", result.ProductKey);
|
||||
cmd.Parameters.AddWithValue("tenant_id", options.TenantId ?? (object)DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("status", MapStatus(result.ConsensusStatus));
|
||||
cmd.Parameters.AddWithValue("justification", result.ConsensusJustification.HasValue ? MapJustification(result.ConsensusJustification.Value) : DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("confidence_score", result.ConfidenceScore);
|
||||
cmd.Parameters.AddWithValue("outcome", MapOutcome(result.Outcome));
|
||||
cmd.Parameters.AddWithValue("statement_count", result.Contributions.Count);
|
||||
cmd.Parameters.AddWithValue("conflict_count", result.Conflicts?.Count ?? 0);
|
||||
cmd.Parameters.AddWithValue("rationale_summary", result.Rationale.Summary ?? (object)DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("computed_at", result.ComputedAt);
|
||||
cmd.Parameters.AddWithValue("stored_at", now);
|
||||
cmd.Parameters.AddWithValue("previous_projection_id", previous?.ProjectionId is not null ? Guid.Parse(previous.ProjectionId) : DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("status_changed", statusChanged);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
|
||||
var projection = new ConsensusProjection(
|
||||
ProjectionId: projectionId.ToString(),
|
||||
VulnerabilityId: result.VulnerabilityId,
|
||||
ProductKey: result.ProductKey,
|
||||
TenantId: options.TenantId,
|
||||
Status: result.ConsensusStatus,
|
||||
Justification: result.ConsensusJustification,
|
||||
ConfidenceScore: result.ConfidenceScore,
|
||||
Outcome: result.Outcome,
|
||||
StatementCount: result.Contributions.Count,
|
||||
ConflictCount: result.Conflicts?.Count ?? 0,
|
||||
RationaleSummary: result.Rationale.Summary ?? string.Empty,
|
||||
ComputedAt: result.ComputedAt,
|
||||
StoredAt: now,
|
||||
PreviousProjectionId: previous?.ProjectionId,
|
||||
StatusChanged: statusChanged);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Stored consensus projection {ProjectionId} for {VulnerabilityId}/{ProductKey}",
|
||||
projectionId, result.VulnerabilityId, result.ProductKey);
|
||||
|
||||
// Emit events
|
||||
if (options.EmitEvent && _eventEmitter is not null)
|
||||
{
|
||||
await EmitEventsAsync(projection, previous, cancellationToken);
|
||||
}
|
||||
|
||||
return projection;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ConsensusProjection?> GetAsync(
|
||||
string projectionId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(projectionId);
|
||||
|
||||
if (!Guid.TryParse(projectionId, out var id))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using var activity = ActivitySource.StartActivity("GetAsync");
|
||||
activity?.SetTag("projectionId", projectionId);
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
||||
await using var cmd = new NpgsqlCommand(SelectByIdSql, connection);
|
||||
cmd.Parameters.AddWithValue("id", id);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
||||
if (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
return MapProjection(reader);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ConsensusProjection?> GetLatestAsync(
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
string? tenantId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(vulnerabilityId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(productKey);
|
||||
|
||||
using var activity = ActivitySource.StartActivity("GetLatestAsync");
|
||||
activity?.SetTag("vulnerabilityId", vulnerabilityId);
|
||||
activity?.SetTag("productKey", productKey);
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
||||
await using var cmd = new NpgsqlCommand(SelectLatestSql, connection);
|
||||
cmd.Parameters.AddWithValue("vulnerability_id", vulnerabilityId);
|
||||
cmd.Parameters.AddWithValue("product_key", productKey);
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId ?? (object)DBNull.Value);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
||||
if (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
return MapProjection(reader);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ProjectionListResult> ListAsync(
|
||||
ProjectionQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
using var activity = ActivitySource.StartActivity("ListAsync");
|
||||
|
||||
var (sql, countSql, parameters) = BuildListQuery(query);
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
||||
|
||||
// Get total count
|
||||
await using var countCmd = new NpgsqlCommand(countSql, connection);
|
||||
foreach (var (name, value) in parameters)
|
||||
{
|
||||
countCmd.Parameters.AddWithValue(name, value);
|
||||
}
|
||||
var totalCount = Convert.ToInt32(await countCmd.ExecuteScalarAsync(cancellationToken));
|
||||
|
||||
// Get results
|
||||
await using var cmd = new NpgsqlCommand(sql, connection);
|
||||
foreach (var (name, value) in parameters)
|
||||
{
|
||||
cmd.Parameters.AddWithValue(name, value);
|
||||
}
|
||||
cmd.Parameters.AddWithValue("limit", query.Limit);
|
||||
cmd.Parameters.AddWithValue("offset", query.Offset);
|
||||
|
||||
var projections = new List<ConsensusProjection>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
projections.Add(MapProjection(reader));
|
||||
}
|
||||
|
||||
return new ProjectionListResult(projections, totalCount, query.Offset, query.Limit);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ConsensusProjection>> GetHistoryAsync(
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
string? tenantId = null,
|
||||
int? limit = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(vulnerabilityId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(productKey);
|
||||
|
||||
using var activity = ActivitySource.StartActivity("GetHistoryAsync");
|
||||
activity?.SetTag("vulnerabilityId", vulnerabilityId);
|
||||
activity?.SetTag("productKey", productKey);
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
||||
await using var cmd = new NpgsqlCommand(SelectHistorySql, connection);
|
||||
cmd.Parameters.AddWithValue("vulnerability_id", vulnerabilityId);
|
||||
cmd.Parameters.AddWithValue("product_key", productKey);
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId ?? (object)DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("limit", limit ?? _options.MaxHistoryEntries);
|
||||
|
||||
var projections = new List<ConsensusProjection>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
projections.Add(MapProjection(reader));
|
||||
}
|
||||
|
||||
return projections;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> PurgeAsync(
|
||||
DateTimeOffset olderThan,
|
||||
string? tenantId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var activity = ActivitySource.StartActivity("PurgeAsync");
|
||||
activity?.SetTag("olderThan", olderThan.ToString("O"));
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
||||
await using var cmd = new NpgsqlCommand(PurgeSql, connection);
|
||||
cmd.Parameters.AddWithValue("older_than", olderThan);
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId ?? (object)DBNull.Value);
|
||||
|
||||
var deleted = await cmd.ExecuteNonQueryAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation("Purged {Count} consensus projections older than {OlderThan}", deleted, olderThan);
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
private static ConsensusProjection MapProjection(NpgsqlDataReader reader)
|
||||
{
|
||||
return new ConsensusProjection(
|
||||
ProjectionId: reader.GetGuid(0).ToString(),
|
||||
VulnerabilityId: reader.GetString(1),
|
||||
ProductKey: reader.GetString(2),
|
||||
TenantId: reader.IsDBNull(3) ? null : reader.GetString(3),
|
||||
Status: ParseStatus(reader.GetString(4)),
|
||||
Justification: reader.IsDBNull(5) ? null : ParseJustification(reader.GetString(5)),
|
||||
ConfidenceScore: reader.GetDouble(6),
|
||||
Outcome: ParseOutcome(reader.GetString(7)),
|
||||
StatementCount: reader.GetInt32(8),
|
||||
ConflictCount: reader.GetInt32(9),
|
||||
RationaleSummary: reader.IsDBNull(10) ? string.Empty : reader.GetString(10),
|
||||
ComputedAt: reader.GetDateTime(11),
|
||||
StoredAt: reader.GetDateTime(12),
|
||||
PreviousProjectionId: reader.IsDBNull(13) ? null : reader.GetGuid(13).ToString(),
|
||||
StatusChanged: reader.GetBoolean(14));
|
||||
}
|
||||
|
||||
private static string MapStatus(VexStatus status) => status switch
|
||||
{
|
||||
VexStatus.NotAffected => "not_affected",
|
||||
VexStatus.Affected => "affected",
|
||||
VexStatus.Fixed => "fixed",
|
||||
VexStatus.UnderInvestigation => "under_investigation",
|
||||
_ => "unknown"
|
||||
};
|
||||
|
||||
private static VexStatus ParseStatus(string status) => status switch
|
||||
{
|
||||
"not_affected" => VexStatus.NotAffected,
|
||||
"affected" => VexStatus.Affected,
|
||||
"fixed" => VexStatus.Fixed,
|
||||
"under_investigation" => VexStatus.UnderInvestigation,
|
||||
_ => VexStatus.UnderInvestigation
|
||||
};
|
||||
|
||||
private static string MapJustification(VexJustification justification) => justification switch
|
||||
{
|
||||
VexJustification.ComponentNotPresent => "component_not_present",
|
||||
VexJustification.VulnerableCodeNotPresent => "vulnerable_code_not_present",
|
||||
VexJustification.VulnerableCodeCannotBeControlledByAdversary => "vulnerable_code_cannot_be_controlled_by_adversary",
|
||||
VexJustification.VulnerableCodeNotInExecutePath => "vulnerable_code_not_in_execute_path",
|
||||
VexJustification.InlineMitigationsAlreadyExist => "inline_mitigations_already_exist",
|
||||
_ => "unknown"
|
||||
};
|
||||
|
||||
private static VexJustification ParseJustification(string justification) => justification switch
|
||||
{
|
||||
"component_not_present" => VexJustification.ComponentNotPresent,
|
||||
"vulnerable_code_not_present" => VexJustification.VulnerableCodeNotPresent,
|
||||
"vulnerable_code_cannot_be_controlled_by_adversary" => VexJustification.VulnerableCodeCannotBeControlledByAdversary,
|
||||
"vulnerable_code_not_in_execute_path" => VexJustification.VulnerableCodeNotInExecutePath,
|
||||
"inline_mitigations_already_exist" => VexJustification.InlineMitigationsAlreadyExist,
|
||||
_ => VexJustification.VulnerableCodeNotPresent
|
||||
};
|
||||
|
||||
private static string MapOutcome(ConsensusOutcome outcome) => outcome switch
|
||||
{
|
||||
ConsensusOutcome.Unanimous => "unanimous",
|
||||
ConsensusOutcome.Majority => "majority",
|
||||
ConsensusOutcome.Plurality => "plurality",
|
||||
ConsensusOutcome.ConflictResolved => "conflict_resolved",
|
||||
ConsensusOutcome.NoData => "no_data",
|
||||
ConsensusOutcome.Indeterminate => "indeterminate",
|
||||
_ => "indeterminate"
|
||||
};
|
||||
|
||||
private static ConsensusOutcome ParseOutcome(string outcome) => outcome switch
|
||||
{
|
||||
"unanimous" => ConsensusOutcome.Unanimous,
|
||||
"majority" => ConsensusOutcome.Majority,
|
||||
"plurality" => ConsensusOutcome.Plurality,
|
||||
"conflict_resolved" => ConsensusOutcome.ConflictResolved,
|
||||
"no_data" => ConsensusOutcome.NoData,
|
||||
"indeterminate" => ConsensusOutcome.Indeterminate,
|
||||
_ => ConsensusOutcome.Indeterminate
|
||||
};
|
||||
|
||||
private static (string sql, string countSql, List<(string name, object value)> parameters) BuildListQuery(ProjectionQuery query)
|
||||
{
|
||||
var parameters = new List<(string name, object value)>();
|
||||
var conditions = new List<string>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.TenantId))
|
||||
{
|
||||
conditions.Add("tenant_id = @tenant_id");
|
||||
parameters.Add(("tenant_id", query.TenantId));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.VulnerabilityId))
|
||||
{
|
||||
conditions.Add("vulnerability_id ILIKE @vulnerability_id");
|
||||
parameters.Add(("vulnerability_id", $"%{query.VulnerabilityId}%"));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.ProductKey))
|
||||
{
|
||||
conditions.Add("product_key ILIKE @product_key");
|
||||
parameters.Add(("product_key", $"%{query.ProductKey}%"));
|
||||
}
|
||||
|
||||
if (query.Status.HasValue)
|
||||
{
|
||||
conditions.Add("status = @status");
|
||||
parameters.Add(("status", MapStatus(query.Status.Value)));
|
||||
}
|
||||
|
||||
if (query.Outcome.HasValue)
|
||||
{
|
||||
conditions.Add("outcome = @outcome");
|
||||
parameters.Add(("outcome", MapOutcome(query.Outcome.Value)));
|
||||
}
|
||||
|
||||
if (query.MinimumConfidence.HasValue)
|
||||
{
|
||||
conditions.Add("confidence_score >= @min_confidence");
|
||||
parameters.Add(("min_confidence", query.MinimumConfidence.Value));
|
||||
}
|
||||
|
||||
if (query.ComputedAfter.HasValue)
|
||||
{
|
||||
conditions.Add("computed_at >= @computed_after");
|
||||
parameters.Add(("computed_after", query.ComputedAfter.Value));
|
||||
}
|
||||
|
||||
if (query.ComputedBefore.HasValue)
|
||||
{
|
||||
conditions.Add("computed_at <= @computed_before");
|
||||
parameters.Add(("computed_before", query.ComputedBefore.Value));
|
||||
}
|
||||
|
||||
if (query.StatusChanged.HasValue)
|
||||
{
|
||||
conditions.Add("status_changed = @status_changed");
|
||||
parameters.Add(("status_changed", query.StatusChanged.Value));
|
||||
}
|
||||
|
||||
var whereClause = conditions.Count > 0
|
||||
? $"WHERE {string.Join(" AND ", conditions)}"
|
||||
: string.Empty;
|
||||
|
||||
var orderColumn = query.SortBy switch
|
||||
{
|
||||
ProjectionSortField.ComputedAt => "computed_at",
|
||||
ProjectionSortField.StoredAt => "stored_at",
|
||||
ProjectionSortField.VulnerabilityId => "vulnerability_id",
|
||||
ProjectionSortField.ProductKey => "product_key",
|
||||
ProjectionSortField.ConfidenceScore => "confidence_score",
|
||||
_ => "computed_at"
|
||||
};
|
||||
|
||||
var orderDirection = query.SortDescending ? "DESC" : "ASC";
|
||||
|
||||
var sql = $"""
|
||||
SELECT id, vulnerability_id, product_key, tenant_id, status, justification,
|
||||
confidence_score, outcome, statement_count, conflict_count,
|
||||
rationale_summary, computed_at, stored_at, previous_projection_id, status_changed
|
||||
FROM {Schema}.consensus_projections
|
||||
{whereClause}
|
||||
ORDER BY {orderColumn} {orderDirection}
|
||||
LIMIT @limit OFFSET @offset
|
||||
""";
|
||||
|
||||
var countSql = $"SELECT COUNT(*) FROM {Schema}.consensus_projections {whereClause}";
|
||||
|
||||
return (sql, countSql, parameters);
|
||||
}
|
||||
|
||||
private async Task EmitEventsAsync(
|
||||
ConsensusProjection projection,
|
||||
ConsensusProjection? previous,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (_eventEmitter is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var computedEvent = new ConsensusComputedEvent(
|
||||
EventId: $"evt-{Guid.NewGuid():N}",
|
||||
ProjectionId: projection.ProjectionId,
|
||||
VulnerabilityId: projection.VulnerabilityId,
|
||||
ProductKey: projection.ProductKey,
|
||||
TenantId: projection.TenantId,
|
||||
Status: projection.Status,
|
||||
Justification: projection.Justification,
|
||||
ConfidenceScore: projection.ConfidenceScore,
|
||||
Outcome: projection.Outcome,
|
||||
StatementCount: projection.StatementCount,
|
||||
ComputedAt: projection.ComputedAt,
|
||||
EmittedAt: now);
|
||||
|
||||
await _eventEmitter.EmitConsensusComputedAsync(computedEvent, cancellationToken);
|
||||
|
||||
if (projection.StatusChanged && previous is not null)
|
||||
{
|
||||
var changedEvent = new ConsensusStatusChangedEvent(
|
||||
EventId: $"evt-{Guid.NewGuid():N}",
|
||||
ProjectionId: projection.ProjectionId,
|
||||
VulnerabilityId: projection.VulnerabilityId,
|
||||
ProductKey: projection.ProductKey,
|
||||
TenantId: projection.TenantId,
|
||||
PreviousStatus: previous.Status,
|
||||
NewStatus: projection.Status,
|
||||
ChangeReason: $"Consensus status changed from {previous.Status} to {projection.Status}",
|
||||
ComputedAt: projection.ComputedAt,
|
||||
EmittedAt: now);
|
||||
|
||||
await _eventEmitter.EmitStatusChangedAsync(changedEvent, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user