330 lines
9.5 KiB
C#
330 lines
9.5 KiB
C#
// <copyright file="IFacetDriftVexDraftStore.cs" company="StellaOps">
|
|
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
|
|
// </copyright>
|
|
// Sprint: SPRINT_20260105_002_003_FACET (QTA-018)
|
|
|
|
using System.Collections.Concurrent;
|
|
using System.Collections.Immutable;
|
|
|
|
namespace StellaOps.Facet;
|
|
|
|
/// <summary>
|
|
/// Query parameters for listing VEX drafts.
|
|
/// </summary>
|
|
public sealed record FacetDriftVexDraftQuery
|
|
{
|
|
/// <summary>
|
|
/// Filter by image digest.
|
|
/// </summary>
|
|
public string? ImageDigest { get; init; }
|
|
|
|
/// <summary>
|
|
/// Filter by facet ID.
|
|
/// </summary>
|
|
public string? FacetId { get; init; }
|
|
|
|
/// <summary>
|
|
/// Filter by review status.
|
|
/// </summary>
|
|
public FacetDriftVexReviewStatus? ReviewStatus { get; init; }
|
|
|
|
/// <summary>
|
|
/// Include only drafts created since this time.
|
|
/// </summary>
|
|
public DateTimeOffset? Since { get; init; }
|
|
|
|
/// <summary>
|
|
/// Include only drafts created until this time.
|
|
/// </summary>
|
|
public DateTimeOffset? Until { get; init; }
|
|
|
|
/// <summary>
|
|
/// Maximum number of results to return.
|
|
/// </summary>
|
|
public int Limit { get; init; } = 100;
|
|
|
|
/// <summary>
|
|
/// Offset for pagination.
|
|
/// </summary>
|
|
public int Offset { get; init; } = 0;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Review status for facet drift VEX drafts.
|
|
/// </summary>
|
|
public enum FacetDriftVexReviewStatus
|
|
{
|
|
/// <summary>
|
|
/// Draft is pending review.
|
|
/// </summary>
|
|
Pending,
|
|
|
|
/// <summary>
|
|
/// Draft has been approved.
|
|
/// </summary>
|
|
Approved,
|
|
|
|
/// <summary>
|
|
/// Draft has been rejected.
|
|
/// </summary>
|
|
Rejected,
|
|
|
|
/// <summary>
|
|
/// Draft has expired without review.
|
|
/// </summary>
|
|
Expired
|
|
}
|
|
|
|
/// <summary>
|
|
/// Storage abstraction for facet drift VEX drafts.
|
|
/// </summary>
|
|
public interface IFacetDriftVexDraftStore
|
|
{
|
|
/// <summary>
|
|
/// Saves a new draft. Throws if a draft with the same ID already exists.
|
|
/// </summary>
|
|
Task SaveAsync(FacetDriftVexDraft draft, CancellationToken ct = default);
|
|
|
|
/// <summary>
|
|
/// Saves multiple drafts atomically.
|
|
/// </summary>
|
|
Task SaveBatchAsync(IEnumerable<FacetDriftVexDraft> drafts, CancellationToken ct = default);
|
|
|
|
/// <summary>
|
|
/// Finds a draft by its unique ID.
|
|
/// </summary>
|
|
Task<FacetDriftVexDraft?> FindByIdAsync(string draftId, CancellationToken ct = default);
|
|
|
|
/// <summary>
|
|
/// Finds drafts matching the query parameters.
|
|
/// </summary>
|
|
Task<ImmutableArray<FacetDriftVexDraft>> QueryAsync(FacetDriftVexDraftQuery query, CancellationToken ct = default);
|
|
|
|
/// <summary>
|
|
/// Updates a draft's review status.
|
|
/// </summary>
|
|
Task UpdateReviewStatusAsync(
|
|
string draftId,
|
|
FacetDriftVexReviewStatus status,
|
|
string? reviewedBy = null,
|
|
string? reviewNotes = null,
|
|
CancellationToken ct = default);
|
|
|
|
/// <summary>
|
|
/// Gets pending drafts that have passed their review deadline.
|
|
/// </summary>
|
|
Task<ImmutableArray<FacetDriftVexDraft>> GetOverdueAsync(DateTimeOffset asOf, CancellationToken ct = default);
|
|
|
|
/// <summary>
|
|
/// Deletes expired drafts older than the retention period.
|
|
/// </summary>
|
|
Task<int> PurgeExpiredAsync(DateTimeOffset asOf, CancellationToken ct = default);
|
|
|
|
/// <summary>
|
|
/// Checks if a draft exists for the given image/facet combination.
|
|
/// </summary>
|
|
Task<bool> ExistsAsync(string imageDigest, string facetId, CancellationToken ct = default);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Extended draft record with review tracking.
|
|
/// </summary>
|
|
public sealed record FacetDriftVexDraftWithReview
|
|
{
|
|
/// <summary>
|
|
/// The original draft.
|
|
/// </summary>
|
|
public required FacetDriftVexDraft Draft { get; init; }
|
|
|
|
/// <summary>
|
|
/// Current review status.
|
|
/// </summary>
|
|
public FacetDriftVexReviewStatus ReviewStatus { get; init; } = FacetDriftVexReviewStatus.Pending;
|
|
|
|
/// <summary>
|
|
/// Who reviewed the draft.
|
|
/// </summary>
|
|
public string? ReviewedBy { get; init; }
|
|
|
|
/// <summary>
|
|
/// When the draft was reviewed.
|
|
/// </summary>
|
|
public DateTimeOffset? ReviewedAt { get; init; }
|
|
|
|
/// <summary>
|
|
/// Notes from the reviewer.
|
|
/// </summary>
|
|
public string? ReviewNotes { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// In-memory implementation of <see cref="IFacetDriftVexDraftStore"/> for testing.
|
|
/// </summary>
|
|
public sealed class InMemoryFacetDriftVexDraftStore : IFacetDriftVexDraftStore
|
|
{
|
|
private readonly ConcurrentDictionary<string, FacetDriftVexDraftWithReview> _drafts = new(StringComparer.Ordinal);
|
|
private readonly TimeProvider _timeProvider;
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="InMemoryFacetDriftVexDraftStore"/> class.
|
|
/// </summary>
|
|
public InMemoryFacetDriftVexDraftStore(TimeProvider? timeProvider = null)
|
|
{
|
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task SaveBatchAsync(IEnumerable<FacetDriftVexDraft> 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;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<FacetDriftVexDraft?> FindByIdAsync(string draftId, CancellationToken ct = default)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(draftId);
|
|
|
|
_drafts.TryGetValue(draftId, out var wrapper);
|
|
return Task.FromResult<FacetDriftVexDraft?>(wrapper?.Draft);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<ImmutableArray<FacetDriftVexDraft>> 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);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<ImmutableArray<FacetDriftVexDraft>> 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);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<int> 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);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<bool> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets all drafts for testing purposes.
|
|
/// </summary>
|
|
public IReadOnlyCollection<FacetDriftVexDraftWithReview> GetAllForTesting()
|
|
=> _drafts.Values.ToList();
|
|
}
|