// // Copyright (c) StellaOps. Licensed under BUSL-1.1. // // Sprint: SPRINT_20260105_002_003_FACET (QTA-018) using System.Collections.Concurrent; using System.Collections.Immutable; namespace StellaOps.Facet; /// /// Query parameters for listing VEX drafts. /// public sealed record FacetDriftVexDraftQuery { /// /// Filter by image digest. /// public string? ImageDigest { get; init; } /// /// Filter by facet ID. /// public string? FacetId { get; init; } /// /// Filter by review status. /// public FacetDriftVexReviewStatus? ReviewStatus { get; init; } /// /// Include only drafts created since this time. /// public DateTimeOffset? Since { get; init; } /// /// Include only drafts created until this time. /// public DateTimeOffset? Until { get; init; } /// /// Maximum number of results to return. /// public int Limit { get; init; } = 100; /// /// Offset for pagination. /// public int Offset { get; init; } = 0; } /// /// Review status for facet drift VEX drafts. /// public enum FacetDriftVexReviewStatus { /// /// Draft is pending review. /// Pending, /// /// Draft has been approved. /// Approved, /// /// Draft has been rejected. /// Rejected, /// /// Draft has expired without review. /// Expired } /// /// Storage abstraction for facet drift VEX drafts. /// public interface IFacetDriftVexDraftStore { /// /// Saves a new draft. Throws if a draft with the same ID already exists. /// Task SaveAsync(FacetDriftVexDraft draft, CancellationToken ct = default); /// /// Saves multiple drafts atomically. /// Task SaveBatchAsync(IEnumerable drafts, CancellationToken ct = default); /// /// Finds a draft by its unique ID. /// Task FindByIdAsync(string draftId, CancellationToken ct = default); /// /// Finds drafts matching the query parameters. /// Task> QueryAsync(FacetDriftVexDraftQuery query, CancellationToken ct = default); /// /// Updates a draft's review status. /// Task UpdateReviewStatusAsync( string draftId, FacetDriftVexReviewStatus status, string? reviewedBy = null, string? reviewNotes = null, CancellationToken ct = default); /// /// Gets pending drafts that have passed their review deadline. /// Task> GetOverdueAsync(DateTimeOffset asOf, CancellationToken ct = default); /// /// Deletes expired drafts older than the retention period. /// Task PurgeExpiredAsync(DateTimeOffset asOf, CancellationToken ct = default); /// /// Checks if a draft exists for the given image/facet combination. /// Task ExistsAsync(string imageDigest, string facetId, CancellationToken ct = default); } /// /// Extended draft record with review tracking. /// public sealed record FacetDriftVexDraftWithReview { /// /// The original draft. /// public required FacetDriftVexDraft Draft { get; init; } /// /// Current review status. /// public FacetDriftVexReviewStatus ReviewStatus { get; init; } = FacetDriftVexReviewStatus.Pending; /// /// Who reviewed the draft. /// public string? ReviewedBy { get; init; } /// /// When the draft was reviewed. /// public DateTimeOffset? ReviewedAt { get; init; } /// /// Notes from the reviewer. /// public string? ReviewNotes { get; init; } } /// /// In-memory implementation of for testing. /// public sealed class InMemoryFacetDriftVexDraftStore : IFacetDriftVexDraftStore { private readonly ConcurrentDictionary _drafts = new(StringComparer.Ordinal); private readonly TimeProvider _timeProvider; /// /// Initializes a new instance of the class. /// public InMemoryFacetDriftVexDraftStore(TimeProvider? timeProvider = null) { _timeProvider = timeProvider ?? TimeProvider.System; } /// public Task SaveAsync(FacetDriftVexDraft draft, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(draft); var wrapper = new FacetDriftVexDraftWithReview { Draft = draft }; if (!_drafts.TryAdd(draft.DraftId, wrapper)) { throw new InvalidOperationException($"Draft with ID '{draft.DraftId}' already exists."); } return Task.CompletedTask; } /// public Task SaveBatchAsync(IEnumerable drafts, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(drafts); foreach (var draft in drafts) { var wrapper = new FacetDriftVexDraftWithReview { Draft = draft }; if (!_drafts.TryAdd(draft.DraftId, wrapper)) { throw new InvalidOperationException($"Draft with ID '{draft.DraftId}' already exists."); } } return Task.CompletedTask; } /// public Task FindByIdAsync(string draftId, CancellationToken ct = default) { ArgumentException.ThrowIfNullOrWhiteSpace(draftId); _drafts.TryGetValue(draftId, out var wrapper); return Task.FromResult(wrapper?.Draft); } /// public Task> QueryAsync(FacetDriftVexDraftQuery query, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(query); var results = _drafts.Values.AsEnumerable(); if (!string.IsNullOrEmpty(query.ImageDigest)) { results = results.Where(w => w.Draft.ImageDigest == query.ImageDigest); } if (!string.IsNullOrEmpty(query.FacetId)) { results = results.Where(w => w.Draft.FacetId == query.FacetId); } if (query.ReviewStatus.HasValue) { results = results.Where(w => w.ReviewStatus == query.ReviewStatus.Value); } if (query.Since.HasValue) { results = results.Where(w => w.Draft.GeneratedAt >= query.Since.Value); } if (query.Until.HasValue) { results = results.Where(w => w.Draft.GeneratedAt <= query.Until.Value); } var paged = results .OrderByDescending(w => w.Draft.GeneratedAt) .Skip(query.Offset) .Take(query.Limit) .Select(w => w.Draft) .ToImmutableArray(); return Task.FromResult(paged); } /// public Task UpdateReviewStatusAsync( string draftId, FacetDriftVexReviewStatus status, string? reviewedBy = null, string? reviewNotes = null, CancellationToken ct = default) { ArgumentException.ThrowIfNullOrWhiteSpace(draftId); if (!_drafts.TryGetValue(draftId, out var wrapper)) { throw new KeyNotFoundException($"Draft with ID '{draftId}' not found."); } var updated = wrapper with { ReviewStatus = status, ReviewedBy = reviewedBy, ReviewedAt = _timeProvider.GetUtcNow(), ReviewNotes = reviewNotes }; _drafts[draftId] = updated; return Task.CompletedTask; } /// public Task> GetOverdueAsync(DateTimeOffset asOf, CancellationToken ct = default) { var overdue = _drafts.Values .Where(w => w.ReviewStatus == FacetDriftVexReviewStatus.Pending) .Where(w => w.Draft.ReviewDeadline < asOf) .Select(w => w.Draft) .ToImmutableArray(); return Task.FromResult(overdue); } /// public Task PurgeExpiredAsync(DateTimeOffset asOf, CancellationToken ct = default) { var expiredIds = _drafts .Where(kvp => kvp.Value.Draft.ExpiresAt < asOf) .Select(kvp => kvp.Key) .ToList(); foreach (var id in expiredIds) { _drafts.TryRemove(id, out _); } return Task.FromResult(expiredIds.Count); } /// public Task ExistsAsync(string imageDigest, string facetId, CancellationToken ct = default) { var exists = _drafts.Values.Any(w => w.Draft.ImageDigest == imageDigest && w.Draft.FacetId == facetId && w.ReviewStatus == FacetDriftVexReviewStatus.Pending); return Task.FromResult(exists); } /// /// Gets all drafts for testing purposes. /// public IReadOnlyCollection GetAllForTesting() => _drafts.Values.ToList(); }