feat(notification-outbox): add NotificationOutbox repository

This commit is contained in:
Joseph Doherty
2026-05-19 01:02:06 -04:00
parent 3022aa8379
commit 2c59d59b61
6 changed files with 630 additions and 0 deletions

View File

@@ -0,0 +1,57 @@
using ScadaLink.Commons.Entities.Notifications;
using ScadaLink.Commons.Types.Notifications;
namespace ScadaLink.Commons.Interfaces.Repositories;
/// <summary>
/// Data access for the central notification outbox — the queue of <see cref="Notification"/>
/// rows the outbox actor drains, retries, and audits. Distinct from
/// <see cref="INotificationRepository"/>, which manages notification list configuration.
/// </summary>
public interface INotificationOutboxRepository
{
/// <summary>
/// Inserts <paramref name="n"/> only if no row with the same
/// <see cref="Notification.NotificationId"/> exists. Returns <c>true</c> when a new
/// row was inserted, <c>false</c> when an existing row was left untouched.
/// </summary>
Task<bool> InsertIfNotExistsAsync(Notification n, CancellationToken ct = default);
/// <summary>
/// Returns notifications ready for a delivery attempt: <c>Pending</c> rows, plus
/// <c>Retrying</c> rows whose <c>NextAttemptAt</c> is at or before <paramref name="now"/>.
/// Terminal rows are excluded. Ordered by <c>CreatedAt</c> ascending, capped at
/// <paramref name="batchSize"/>.
/// </summary>
Task<IReadOnlyList<Notification>> GetDueAsync(DateTimeOffset now, int batchSize, CancellationToken ct = default);
/// <summary>Returns the notification with the given id, or <c>null</c>.</summary>
Task<Notification?> GetByIdAsync(string notificationId, CancellationToken ct = default);
/// <summary>Marks <paramref name="n"/> modified and persists it (status transitions).</summary>
Task UpdateAsync(Notification n, CancellationToken ct = default);
/// <summary>
/// Returns a page of notifications matching <paramref name="filter"/>, ordered by
/// <c>CreatedAt</c> descending, together with the total matching count.
/// </summary>
Task<(IReadOnlyList<Notification> Rows, int TotalCount)> QueryAsync(
NotificationOutboxFilter filter, int pageNumber, int pageSize, CancellationToken ct = default);
/// <summary>
/// Bulk-deletes terminal rows (Delivered/Parked/Discarded) whose <c>CreatedAt</c> is
/// older than <paramref name="cutoff"/>. Returns the number of rows deleted.
/// </summary>
Task<int> DeleteTerminalOlderThanAsync(DateTimeOffset cutoff, CancellationToken ct = default);
/// <summary>
/// Computes a point-in-time <see cref="NotificationKpiSnapshot"/>. The stuck and
/// delivered cutoffs are supplied by the caller; the current time used for
/// <c>OldestPendingAge</c> is captured inside the method.
/// </summary>
Task<NotificationKpiSnapshot> ComputeKpisAsync(
DateTimeOffset stuckCutoff, DateTimeOffset deliveredSince, CancellationToken ct = default);
/// <summary>Persists pending changes tracked on the underlying context.</summary>
Task<int> SaveChangesAsync(CancellationToken ct = default);
}

View File

@@ -0,0 +1,26 @@
namespace ScadaLink.Commons.Types.Notifications;
/// <summary>
/// Point-in-time operational metrics for the central notification outbox,
/// surfaced on the health dashboard.
/// </summary>
/// <param name="QueueDepth">Count of non-terminal rows (Pending + Retrying).</param>
/// <param name="StuckCount">
/// Count of non-terminal rows (Pending/Retrying) whose <c>CreatedAt</c> is older
/// than the supplied stuck cutoff.
/// </param>
/// <param name="ParkedCount">Count of rows in the Parked status.</param>
/// <param name="DeliveredLastInterval">
/// Count of Delivered rows whose <c>DeliveredAt</c> is at or after the supplied
/// "delivered since" timestamp.
/// </param>
/// <param name="OldestPendingAge">
/// Age of the oldest non-terminal row (<c>now - min(CreatedAt)</c>), or <c>null</c>
/// when there are no non-terminal rows.
/// </param>
public record NotificationKpiSnapshot(
int QueueDepth,
int StuckCount,
int ParkedCount,
int DeliveredLastInterval,
TimeSpan? OldestPendingAge);

View File

@@ -0,0 +1,30 @@
using ScadaLink.Commons.Types.Enums;
namespace ScadaLink.Commons.Types.Notifications;
/// <summary>
/// Query filter for the central notification outbox. All members are optional;
/// an unset member means "no constraint on that dimension".
/// </summary>
/// <param name="Status">Restrict to a single lifecycle status.</param>
/// <param name="Type">Restrict to a single delivery channel.</param>
/// <param name="SourceSiteId">Restrict to notifications originating at a given site.</param>
/// <param name="ListName">Restrict to a single notification list.</param>
/// <param name="SubjectKeyword">Substring matched against <c>Subject</c>.</param>
/// <param name="StuckOnly">
/// When <c>true</c>, restrict to non-terminal rows (Pending/Retrying) whose
/// <c>CreatedAt</c> is older than <see cref="StuckCutoff"/>.
/// </param>
/// <param name="StuckCutoff">Rows with <c>CreatedAt</c> older than this count as stuck.</param>
/// <param name="From">Inclusive lower bound on <c>CreatedAt</c>.</param>
/// <param name="To">Inclusive upper bound on <c>CreatedAt</c>.</param>
public record NotificationOutboxFilter(
NotificationStatus? Status = null,
NotificationType? Type = null,
string? SourceSiteId = null,
string? ListName = null,
string? SubjectKeyword = null,
bool StuckOnly = false,
DateTimeOffset? StuckCutoff = null,
DateTimeOffset? From = null,
DateTimeOffset? To = null);