docs(plans): code implementation plan for the notification outbox
26 TDD tasks across six phases — data layer, message contracts, the new ScadaLink.NotificationOutbox project (actor, dispatcher, Email adapter), site S&F retarget and central wiring, Central UI, and verification.
This commit is contained in:
710
docs/plans/2026-05-19-notification-outbox-implementation.md
Normal file
710
docs/plans/2026-05-19-notification-outbox-implementation.md
Normal file
@@ -0,0 +1,710 @@
|
||||
# Notification Outbox — Code Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Build the central Notification Outbox feature in the ScadaLink `src/` codebase — sites store-and-forward notifications to the central cluster, which logs each to a `Notifications` table and delivers it via per-type adapters with retry, parking, status handles, and KPIs.
|
||||
|
||||
**Architecture:** A new `ScadaLink.NotificationOutbox` project hosts a `NotificationOutboxActor` cluster singleton on the active central node. Sites enqueue notifications into the existing site Store-and-Forward Engine (notification category, retargeted from SMTP to "central"); the S&F engine forwards them to central via `ClusterClient`; the `CentralCommunicationActor` routes each `NotificationSubmit` to the outbox singleton, which inserts a row into the central MS SQL `Notifications` table (insert-if-not-exists on a site-generated `NotificationId` GUID) and acks. A timer-driven dispatcher polls due rows and delivers them through an `INotificationDeliveryAdapter` (Email adapter now; Teams later). A Blazor page surfaces KPIs and a queryable list with Retry/Discard.
|
||||
|
||||
**Tech Stack:** .NET 10, Akka.NET (cluster singletons, ClusterClient, TestKit), EF Core (MS SQL; SQLite in-memory for tests), Blazor Server + Bootstrap, xUnit + NSubstitute + bUnit. Solution: `ScadaLink.slnx`.
|
||||
|
||||
**Authoritative design:** `docs/plans/notif.md` and `docs/requirements/Component-NotificationOutbox.md`. Read both before starting.
|
||||
|
||||
---
|
||||
|
||||
## Conventions (read once, applies to every task)
|
||||
|
||||
These were confirmed by exploring the existing codebase. Follow them in every task.
|
||||
|
||||
- **Entities (Commons):** POCOs in `src/ScadaLink.Commons/Entities/<Area>/`. Auto-properties, parameterized constructor with null checks, navigation collections initialised to `new List<T>()`. No data annotations.
|
||||
- **EF mapping (ConfigurationDatabase):** Fluent `IEntityTypeConfiguration<T>` classes in `src/ScadaLink.ConfigurationDatabase/Configurations/`, auto-applied by `ApplyConfigurationsFromAssembly`. Enums stored as strings via `.HasConversion<string>()`. Add a `DbSet<T>` to `ScadaLinkDbContext`.
|
||||
- **Repositories:** Interface in `src/ScadaLink.Commons/Interfaces/Repositories/`, implementation in `src/ScadaLink.ConfigurationDatabase/Repositories/`. Inject `ScadaLinkDbContext`, use `_context.Set<T>()`, expose explicit `SaveChangesAsync`. Register in `ConfigurationDatabase/ServiceCollectionExtensions.cs` with `AddScoped`.
|
||||
- **Migrations:** `dotnet ef migrations add <Name> --project src/ScadaLink.ConfigurationDatabase` — timestamp-named. Applied via `MigrationHelper.ApplyOrValidateMigrationsAsync` (auto in dev).
|
||||
- **Message contracts (Commons):** `record` types in `src/ScadaLink.Commons/Messages/<Area>/`, named positional params, additive-only evolution.
|
||||
- **Options pattern:** `<Component>Options` class owned by the component project; component's `ServiceCollectionExtensions.Add<Component>()` calls `services.AddOptions<T>().BindConfiguration("ScadaLink:<Section>")`; Host also `services.Configure<T>(...)`. Config lives in `appsettings.Central.json` / `appsettings.Site.json`.
|
||||
- **Actors:** No Akka.DI framework. Dependencies passed via `Props.Create(() => new XActor(...))`. Actors that need scoped services take `IServiceProvider` and call `CreateScope()`. Cluster singletons use `ClusterSingletonManager.Props` + `ClusterSingletonProxy.Props`, created in `src/ScadaLink.Host/Actors/AkkaHostedService.cs`.
|
||||
- **Tests:** xUnit, NSubstitute, built-in `Assert`. One `tests/ScadaLink.<Component>.Tests/` project per `src/` project. Actor tests inherit `Akka.TestKit.Xunit2.TestKit`. Repository tests use SQLite in-memory (`DataSource=:memory:`, `OpenConnection()` + `EnsureCreated()`, `IDisposable`). Blazor tests inherit bUnit `BunitContext`. Test naming: `Method_Scenario_Result`.
|
||||
- **Run tests:** whole suite `dotnet test ScadaLink.slnx`; single project `dotnet test tests/ScadaLink.<X>.Tests/ScadaLink.<X>.Tests.csproj`; single test `--filter "FullyQualifiedName~<Class>.<Method>"`.
|
||||
- **Build:** `dotnet build ScadaLink.slnx`.
|
||||
- **TDD:** every task writes the failing test first, runs it red, implements, runs it green, commits. Use the superpowers-extended-cc:test-driven-development discipline.
|
||||
- **Commits:** one per task, message `feat(notification-outbox): <task summary>`.
|
||||
|
||||
**Status lifecycle** (central `Notifications` table — `Forwarding` is site-local, never stored centrally):
|
||||
`Pending → Retrying → Delivered | Parked`, plus `Discarded` (operator action only).
|
||||
|
||||
---
|
||||
|
||||
## Phase A — Data layer (Commons + ConfigurationDatabase)
|
||||
|
||||
### Task 1: Notification enums
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ScadaLink.Commons/Types/Enums/NotificationType.cs`
|
||||
- Create: `src/ScadaLink.Commons/Types/Enums/NotificationStatus.cs`
|
||||
- Test: `tests/ScadaLink.Commons.Tests/Types/NotificationEnumTests.cs` (create if the test project lacks a `Types/` folder)
|
||||
|
||||
**Step 1 — failing test.** Assert the enums expose exactly the expected members:
|
||||
```csharp
|
||||
[Fact]
|
||||
public void NotificationStatus_HasExactlyTheCentralStates()
|
||||
{
|
||||
var names = Enum.GetNames<NotificationStatus>();
|
||||
Assert.Equal(
|
||||
new[] { "Pending", "Retrying", "Delivered", "Parked", "Discarded" },
|
||||
names);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationType_HasEmail()
|
||||
{
|
||||
Assert.True(Enum.IsDefined(NotificationType.Email));
|
||||
}
|
||||
```
|
||||
Note: `Forwarding` is intentionally NOT a `NotificationStatus` member — it is a site-local concept (Task 19), never persisted centrally.
|
||||
|
||||
**Step 2 — run red:** `dotnet test tests/ScadaLink.Commons.Tests/ScadaLink.Commons.Tests.csproj --filter "FullyQualifiedName~NotificationEnumTests"` → FAIL (types don't exist).
|
||||
|
||||
**Step 3 — implement.**
|
||||
```csharp
|
||||
// NotificationType.cs — namespace ScadaLink.Commons.Types.Enums
|
||||
public enum NotificationType { Email } // Teams and others added later
|
||||
|
||||
// NotificationStatus.cs — namespace ScadaLink.Commons.Types.Enums
|
||||
public enum NotificationStatus { Pending, Retrying, Delivered, Parked, Discarded }
|
||||
```
|
||||
|
||||
**Step 4 — run green.** Same filter → PASS.
|
||||
|
||||
**Step 5 — commit:**
|
||||
```bash
|
||||
git add src/ScadaLink.Commons/Types/Enums/NotificationType.cs src/ScadaLink.Commons/Types/Enums/NotificationStatus.cs tests/ScadaLink.Commons.Tests/Types/NotificationEnumTests.cs
|
||||
git commit -m "feat(notification-outbox): add NotificationType and NotificationStatus enums"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: `Notification` entity POCO
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ScadaLink.Commons/Entities/Notifications/Notification.cs`
|
||||
- Test: `tests/ScadaLink.Commons.Tests/Entities/NotificationEntityTests.cs`
|
||||
|
||||
**Step 1 — failing test.** Verify the constructor sets required fields, defaults `Status` to `Pending` and `RetryCount` to 0, and rejects nulls:
|
||||
```csharp
|
||||
[Fact]
|
||||
public void Constructor_SetsDefaults()
|
||||
{
|
||||
var n = new Notification("id-1", NotificationType.Email, "ops-team", "subj", "body", "SiteA");
|
||||
Assert.Equal(NotificationStatus.Pending, n.Status);
|
||||
Assert.Equal(0, n.RetryCount);
|
||||
Assert.Equal("id-1", n.NotificationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullListName_Throws()
|
||||
=> Assert.Throws<ArgumentNullException>(
|
||||
() => new Notification("id", NotificationType.Email, null!, "s", "b", "SiteA"));
|
||||
```
|
||||
|
||||
**Step 2 — run red.**
|
||||
|
||||
**Step 3 — implement.** Match the `Notifications` table schema in `notif.md`:
|
||||
```csharp
|
||||
namespace ScadaLink.Commons.Entities.Notifications;
|
||||
|
||||
public class Notification
|
||||
{
|
||||
public string NotificationId { get; set; } // GUID PK, generated at site
|
||||
public NotificationType Type { get; set; }
|
||||
public string ListName { get; set; }
|
||||
public string Subject { get; set; }
|
||||
public string Body { get; set; }
|
||||
public string? TypeData { get; set; } // JSON extensibility hook
|
||||
public NotificationStatus Status { get; set; } = NotificationStatus.Pending;
|
||||
public int RetryCount { get; set; }
|
||||
public string? LastError { get; set; }
|
||||
public string? ResolvedTargets { get; set; } // snapshotted at delivery, for audit
|
||||
public string SourceSiteId { get; set; }
|
||||
public string? SourceInstanceId { get; set; }
|
||||
public string? SourceScript { get; set; }
|
||||
public DateTimeOffset SiteEnqueuedAt { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; } // central ingest time
|
||||
public DateTimeOffset? LastAttemptAt { get; set; }
|
||||
public DateTimeOffset? NextAttemptAt { get; set; }
|
||||
public DateTimeOffset? DeliveredAt { get; set; }
|
||||
|
||||
public Notification(string notificationId, NotificationType type, string listName,
|
||||
string subject, string body, string sourceSiteId)
|
||||
{
|
||||
NotificationId = notificationId ?? throw new ArgumentNullException(nameof(notificationId));
|
||||
Type = type;
|
||||
ListName = listName ?? throw new ArgumentNullException(nameof(listName));
|
||||
Subject = subject ?? throw new ArgumentNullException(nameof(subject));
|
||||
Body = body ?? throw new ArgumentNullException(nameof(body));
|
||||
SourceSiteId = sourceSiteId ?? throw new ArgumentNullException(nameof(sourceSiteId));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4 — run green. Step 5 — commit** (`feat(notification-outbox): add Notification entity`).
|
||||
|
||||
---
|
||||
|
||||
### Task 3: `Type` field on `NotificationList`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ScadaLink.Commons/Entities/Notifications/NotificationList.cs`
|
||||
- Modify: `src/ScadaLink.ConfigurationDatabase/Configurations/NotificationConfiguration.cs` (`NotificationListConfiguration`)
|
||||
- Test: `tests/ScadaLink.ConfigurationDatabase.Tests/RepositoryTests.cs` (add a test to the notification repository tests)
|
||||
|
||||
**Step 1 — failing test.** A `NotificationList` round-trips its `Type` through the repository:
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task NotificationList_PersistsType()
|
||||
{
|
||||
var list = new NotificationList("ops") { Type = NotificationType.Email };
|
||||
await _notificationRepo.AddNotificationListAsync(list);
|
||||
await _notificationRepo.SaveChangesAsync();
|
||||
_context.ChangeTracker.Clear();
|
||||
var loaded = await _notificationRepo.GetListByNameAsync("ops");
|
||||
Assert.Equal(NotificationType.Email, loaded!.Type);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2 — run red.**
|
||||
|
||||
**Step 3 — implement.** Add to `NotificationList`: `public NotificationType Type { get; set; } = NotificationType.Email;`. In `NotificationListConfiguration.Configure`, add `builder.Property(n => n.Type).HasConversion<string>().HasMaxLength(32).IsRequired();`.
|
||||
|
||||
**Step 4 — run green. Step 5 — commit** (`feat(notification-outbox): add Type field to NotificationList`).
|
||||
|
||||
---
|
||||
|
||||
### Task 4: `Notification` EF configuration + DbSet
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ScadaLink.ConfigurationDatabase/Configurations/NotificationOutboxConfiguration.cs`
|
||||
- Modify: `src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs` (add `DbSet<Notification>`)
|
||||
- Test: `tests/ScadaLink.ConfigurationDatabase.Tests/RepositoryTests.cs`
|
||||
|
||||
**Step 1 — failing test.** A `Notification` round-trips all fields through the `DbContext` (use the SQLite in-memory fixture pattern). Assert the `Status`/`Type` enums persist as strings and the row is found by `NotificationId`.
|
||||
|
||||
**Step 2 — run red.**
|
||||
|
||||
**Step 3 — implement.** Configuration:
|
||||
```csharp
|
||||
public class NotificationOutboxConfiguration : IEntityTypeConfiguration<Notification>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Notification> builder)
|
||||
{
|
||||
builder.ToTable("Notifications");
|
||||
builder.HasKey(n => n.NotificationId);
|
||||
builder.Property(n => n.NotificationId).HasMaxLength(64);
|
||||
builder.Property(n => n.Type).HasConversion<string>().HasMaxLength(32).IsRequired();
|
||||
builder.Property(n => n.Status).HasConversion<string>().HasMaxLength(32).IsRequired();
|
||||
builder.Property(n => n.ListName).HasMaxLength(200).IsRequired();
|
||||
builder.Property(n => n.Subject).HasMaxLength(1000).IsRequired();
|
||||
builder.Property(n => n.Body).IsRequired(); // nvarchar(max)
|
||||
builder.Property(n => n.TypeData); // nvarchar(max), nullable
|
||||
builder.Property(n => n.ResolvedTargets); // nvarchar(max), nullable
|
||||
builder.Property(n => n.LastError).HasMaxLength(4000);
|
||||
builder.Property(n => n.SourceSiteId).HasMaxLength(100).IsRequired();
|
||||
builder.Property(n => n.SourceInstanceId).HasMaxLength(200);
|
||||
builder.Property(n => n.SourceScript).HasMaxLength(200);
|
||||
builder.HasIndex(n => new { n.Status, n.NextAttemptAt }); // dispatcher polling
|
||||
builder.HasIndex(n => new { n.SourceSiteId, n.CreatedAt }); // KPIs / UI query
|
||||
}
|
||||
}
|
||||
```
|
||||
Add `public DbSet<Notification> Notifications => Set<Notification>();` to `ScadaLinkDbContext`.
|
||||
|
||||
**Step 4 — run green. Step 5 — commit** (`feat(notification-outbox): add Notification EF configuration and DbSet`).
|
||||
|
||||
---
|
||||
|
||||
### Task 5: `INotificationOutboxRepository` + implementation
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ScadaLink.Commons/Interfaces/Repositories/INotificationOutboxRepository.cs`
|
||||
- Create: `src/ScadaLink.ConfigurationDatabase/Repositories/NotificationOutboxRepository.cs`
|
||||
- Modify: `src/ScadaLink.ConfigurationDatabase/ServiceCollectionExtensions.cs` (register `AddScoped`)
|
||||
- Test: `tests/ScadaLink.ConfigurationDatabase.Tests/RepositoryTests.cs`
|
||||
|
||||
**Step 1 — failing tests.** Cover the operations the outbox actor needs:
|
||||
- `InsertIfNotExistsAsync` inserts a new row and returns `true`; a second call with the same `NotificationId` returns `false` and does not duplicate (idempotency key).
|
||||
- `GetDueAsync(now, batchSize)` returns `Pending` rows and `Retrying` rows with `NextAttemptAt <= now`, ordered by `CreatedAt`, capped at `batchSize`.
|
||||
- `UpdateAsync` persists status transitions.
|
||||
- `GetByIdAsync` returns a row or null.
|
||||
- `QueryAsync(filter, page, pageSize)` filters by status/type/source site and paginates.
|
||||
- `DeleteTerminalOlderThanAsync(cutoff)` bulk-deletes `Delivered`/`Parked`/`Discarded` rows older than `cutoff` and returns the count; leaves non-terminal rows.
|
||||
- `ComputeKpisAsync` returns queue depth, stuck count, parked count, delivered-last-window, oldest-pending age.
|
||||
|
||||
**Step 2 — run red.**
|
||||
|
||||
**Step 3 — implement.** Interface:
|
||||
```csharp
|
||||
public interface INotificationOutboxRepository
|
||||
{
|
||||
Task<bool> InsertIfNotExistsAsync(Notification n, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<Notification>> GetDueAsync(DateTimeOffset now, int batchSize, CancellationToken ct = default);
|
||||
Task<Notification?> GetByIdAsync(string notificationId, CancellationToken ct = default);
|
||||
Task UpdateAsync(Notification n, CancellationToken ct = default);
|
||||
Task<(IReadOnlyList<Notification> Rows, int TotalCount)> QueryAsync(
|
||||
NotificationOutboxFilter filter, int pageNumber, int pageSize, CancellationToken ct = default);
|
||||
Task<int> DeleteTerminalOlderThanAsync(DateTimeOffset cutoff, CancellationToken ct = default);
|
||||
Task<NotificationKpiSnapshot> ComputeKpisAsync(DateTimeOffset stuckCutoff, DateTimeOffset deliveredSince, CancellationToken ct = default);
|
||||
Task<int> SaveChangesAsync(CancellationToken ct = default);
|
||||
}
|
||||
```
|
||||
`NotificationOutboxFilter` (a `record` in Commons `Types/`) and `NotificationKpiSnapshot` (a `record`) are created in this task alongside the interface. `InsertIfNotExistsAsync`: check `await _context.Notifications.FindAsync(...)`, if present return false, else `AddAsync` + `SaveChangesAsync`, return true. `DeleteTerminalOlderThanAsync`: use `ExecuteDeleteAsync` with a `Where` on terminal statuses and `CreatedAt < cutoff`. Register in `ServiceCollectionExtensions.AddConfigurationDatabase`.
|
||||
|
||||
**Step 4 — run green. Step 5 — commit** (`feat(notification-outbox): add NotificationOutbox repository`).
|
||||
|
||||
---
|
||||
|
||||
### Task 6: EF migration
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ScadaLink.ConfigurationDatabase/Migrations/<timestamp>_AddNotificationsTable.cs` (generated)
|
||||
|
||||
**Step 1 — generate:**
|
||||
```bash
|
||||
dotnet ef migrations add AddNotificationsTable --project src/ScadaLink.ConfigurationDatabase
|
||||
```
|
||||
This also picks up the `NotificationList.Type` column from Task 3.
|
||||
|
||||
**Step 2 — verify.** Inspect the generated migration: confirm a `Notifications` table with the columns and two indexes from Task 4, and an `AlterColumn`/`AddColumn` for `NotificationLists.Type`. Run the ConfigurationDatabase test project — the SQLite `EnsureCreated()` fixture builds from the model, and `dotnet build ScadaLink.slnx` must succeed.
|
||||
|
||||
**Step 3 — run:** `dotnet test tests/ScadaLink.ConfigurationDatabase.Tests/ScadaLink.ConfigurationDatabase.Tests.csproj` → PASS.
|
||||
|
||||
**Step 4 — commit** (`feat(notification-outbox): add Notifications table migration`).
|
||||
|
||||
---
|
||||
|
||||
## Phase B — Message contracts (Commons)
|
||||
|
||||
### Task 7: Site↔central notification message contracts
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ScadaLink.Commons/Messages/Notification/NotificationMessages.cs`
|
||||
- Test: `tests/ScadaLink.Commons.Tests/Messages/NotificationMessagesTests.cs`
|
||||
|
||||
**Step 1 — failing test.** A trivial construction/round-trip test (these are records — assert positional construction and value equality; if the project has a serialization test helper, round-trip through it).
|
||||
|
||||
**Step 2 — run red.**
|
||||
|
||||
**Step 3 — implement.** Namespace `ScadaLink.Commons.Messages.Notification`:
|
||||
```csharp
|
||||
// Site → Central: submit a notification for central delivery (fire-and-forget with ack).
|
||||
public record NotificationSubmit(
|
||||
string NotificationId, string ListName, string Subject, string Body,
|
||||
string SourceSiteId, string? SourceInstanceId, string? SourceScript,
|
||||
DateTimeOffset SiteEnqueuedAt);
|
||||
|
||||
// Central → Site: ack after the row is persisted (idempotent — safe to re-send).
|
||||
public record NotificationSubmitAck(string NotificationId, bool Accepted, string? Error);
|
||||
|
||||
// Site → Central: query delivery status for a NotificationId.
|
||||
public record NotificationStatusQuery(string CorrelationId, string NotificationId);
|
||||
|
||||
public record NotificationStatusResponse(
|
||||
string CorrelationId, bool Found, string Status,
|
||||
int RetryCount, string? LastError,
|
||||
DateTimeOffset? DeliveredAt);
|
||||
```
|
||||
|
||||
**Step 4 — run green. Step 5 — commit** (`feat(notification-outbox): add site/central notification message contracts`).
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Outbox UI query/action contracts
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ScadaLink.Commons/Messages/Notification/NotificationOutboxQueries.cs`
|
||||
- Test: `tests/ScadaLink.Commons.Tests/Messages/NotificationOutboxQueriesTests.cs`
|
||||
|
||||
**Step 1 — failing test.** Construction test as in Task 7.
|
||||
|
||||
**Step 2 — run red.**
|
||||
|
||||
**Step 3 — implement.** Records the Central UI / `CommunicationService` use to talk to the outbox actor:
|
||||
```csharp
|
||||
public record NotificationOutboxQueryRequest(
|
||||
string CorrelationId, string? StatusFilter, string? TypeFilter, string? SourceSiteFilter,
|
||||
string? ListNameFilter, bool StuckOnly, string? SubjectKeyword,
|
||||
DateTimeOffset? From, DateTimeOffset? To, int PageNumber, int PageSize);
|
||||
|
||||
public record NotificationSummary(
|
||||
string NotificationId, string Type, string ListName, string Subject, string Status,
|
||||
int RetryCount, string? LastError, string SourceSiteId, string? SourceInstanceId,
|
||||
DateTimeOffset CreatedAt, DateTimeOffset? DeliveredAt, bool IsStuck);
|
||||
|
||||
public record NotificationOutboxQueryResponse(
|
||||
string CorrelationId, bool Success, string? ErrorMessage,
|
||||
IReadOnlyList<NotificationSummary> Notifications, int TotalCount);
|
||||
|
||||
public record RetryNotificationRequest(string CorrelationId, string NotificationId);
|
||||
public record RetryNotificationResponse(string CorrelationId, bool Success, string? ErrorMessage);
|
||||
public record DiscardNotificationRequest(string CorrelationId, string NotificationId);
|
||||
public record DiscardNotificationResponse(string CorrelationId, bool Success, string? ErrorMessage);
|
||||
|
||||
public record NotificationKpiRequest(string CorrelationId);
|
||||
public record NotificationKpiResponse(
|
||||
string CorrelationId, int QueueDepth, int StuckCount, int ParkedCount,
|
||||
int DeliveredLastInterval, TimeSpan? OldestPendingAge);
|
||||
```
|
||||
|
||||
**Step 4 — run green. Step 5 — commit** (`feat(notification-outbox): add outbox query and action contracts`).
|
||||
|
||||
---
|
||||
|
||||
## Phase C — NotificationOutbox project + delivery
|
||||
|
||||
### Task 9: Create the `ScadaLink.NotificationOutbox` project
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ScadaLink.NotificationOutbox/ScadaLink.NotificationOutbox.csproj`
|
||||
- Create: `tests/ScadaLink.NotificationOutbox.Tests/ScadaLink.NotificationOutbox.Tests.csproj`
|
||||
- Modify: `ScadaLink.slnx` (add both projects)
|
||||
|
||||
**Step 1 — create the projects.** Copy the `.csproj` shape from `src/ScadaLink.NotificationService/ScadaLink.NotificationService.csproj` (same `TargetFramework`, central-managed package versions via `Directory.Packages.props`). The src project references `ScadaLink.Commons` and Akka packages (`Akka`, `Akka.Cluster.Tools`). The test project mirrors `tests/ScadaLink.NotificationService.Tests/` (xUnit, NSubstitute, `Akka.TestKit.Xunit2`) and references the new src project. Add both `<Project>` entries to `ScadaLink.slnx`.
|
||||
|
||||
**Step 2 — add a placeholder test** so the test project is non-empty:
|
||||
```csharp
|
||||
public class ProjectSmokeTest { [Fact] public void ProjectCompiles() => Assert.True(true); }
|
||||
```
|
||||
|
||||
**Step 3 — verify:** `dotnet build ScadaLink.slnx` succeeds; `dotnet test tests/ScadaLink.NotificationOutbox.Tests/ScadaLink.NotificationOutbox.Tests.csproj` → PASS.
|
||||
|
||||
**Step 4 — commit** (`feat(notification-outbox): scaffold ScadaLink.NotificationOutbox project`).
|
||||
|
||||
---
|
||||
|
||||
### Task 10: `NotificationOutboxOptions`
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ScadaLink.NotificationOutbox/NotificationOutboxOptions.cs`
|
||||
- Test: `tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxOptionsTests.cs`
|
||||
|
||||
**Step 1 — failing test.** Assert the defaults.
|
||||
|
||||
**Step 2 — run red.**
|
||||
|
||||
**Step 3 — implement.**
|
||||
```csharp
|
||||
public class NotificationOutboxOptions
|
||||
{
|
||||
public TimeSpan DispatchInterval { get; set; } = TimeSpan.FromSeconds(10);
|
||||
public int DispatchBatchSize { get; set; } = 100;
|
||||
public TimeSpan StuckAgeThreshold { get; set; } = TimeSpan.FromMinutes(10);
|
||||
public TimeSpan TerminalRetention { get; set; } = TimeSpan.FromDays(365);
|
||||
public TimeSpan PurgeInterval { get; set; } = TimeSpan.FromDays(1);
|
||||
public TimeSpan DeliveredKpiWindow { get; set; } = TimeSpan.FromMinutes(1);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4 — run green. Step 5 — commit** (`feat(notification-outbox): add NotificationOutboxOptions`).
|
||||
|
||||
---
|
||||
|
||||
### Task 11: `INotificationDeliveryAdapter` + `DeliveryOutcome`
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ScadaLink.NotificationOutbox/Delivery/INotificationDeliveryAdapter.cs`
|
||||
- Create: `src/ScadaLink.NotificationOutbox/Delivery/DeliveryOutcome.cs`
|
||||
- Test: `tests/ScadaLink.NotificationOutbox.Tests/Delivery/DeliveryOutcomeTests.cs`
|
||||
|
||||
**Step 1 — failing test.** Assert `DeliveryOutcome` factory methods produce the right classification.
|
||||
|
||||
**Step 2 — run red.**
|
||||
|
||||
**Step 3 — implement.** Mirror the External System Gateway error-classification pattern:
|
||||
```csharp
|
||||
public enum DeliveryResult { Success, TransientFailure, PermanentFailure }
|
||||
|
||||
public record DeliveryOutcome(DeliveryResult Result, string? ResolvedTargets, string? Error)
|
||||
{
|
||||
public static DeliveryOutcome Success(string resolvedTargets) => new(DeliveryResult.Success, resolvedTargets, null);
|
||||
public static DeliveryOutcome Transient(string error) => new(DeliveryResult.TransientFailure, null, error);
|
||||
public static DeliveryOutcome Permanent(string error) => new(DeliveryResult.PermanentFailure, null, error);
|
||||
}
|
||||
|
||||
public interface INotificationDeliveryAdapter
|
||||
{
|
||||
NotificationType Type { get; }
|
||||
Task<DeliveryOutcome> DeliverAsync(Notification notification, CancellationToken ct = default);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4 — run green. Step 5 — commit** (`feat(notification-outbox): add delivery adapter abstraction`).
|
||||
|
||||
---
|
||||
|
||||
### Task 12: `EmailNotificationDeliveryAdapter`
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ScadaLink.NotificationOutbox/Delivery/EmailNotificationDeliveryAdapter.cs`
|
||||
- Modify: `src/ScadaLink.NotificationOutbox/ScadaLink.NotificationOutbox.csproj` (reference `ScadaLink.NotificationService` for `ISmtpClientWrapper`)
|
||||
- Test: `tests/ScadaLink.NotificationOutbox.Tests/Delivery/EmailNotificationDeliveryAdapterTests.cs`
|
||||
|
||||
**Step 1 — failing tests.** Using NSubstitute mocks of `INotificationOutboxRepository`-resolved data and a substituted `ISmtpClientWrapper`:
|
||||
- list resolved + send succeeds → `DeliveryResult.Success`, `ResolvedTargets` lists the recipient addresses.
|
||||
- list not found / no recipients → `PermanentFailure`.
|
||||
- SMTP throws `SmtpPermanentException` → `PermanentFailure`.
|
||||
- SMTP throws a transient error (socket/timeout) → `TransientFailure`.
|
||||
|
||||
**Step 2 — run red.**
|
||||
|
||||
**Step 3 — implement.** The adapter resolves the list + recipients + SMTP config from `INotificationRepository` (the existing notification-list repo — recipients are resolved centrally at delivery time), composes and sends via the existing `ISmtpClientWrapper` (`Func<ISmtpClientWrapper>` injected, same as `NotificationService`), classifies errors identically to `NotificationDeliveryService`. Reuse the SMTP composition logic from `src/ScadaLink.NotificationService/NotificationDeliveryService.cs` (BCC delivery, plain text, address validation, the `SmtpPermanentException` → permanent mapping). On success return `DeliveryOutcome.Success(<comma-joined recipient addresses>)`. `Type => NotificationType.Email`.
|
||||
|
||||
**Step 4 — run green. Step 5 — commit** (`feat(notification-outbox): add Email delivery adapter`).
|
||||
|
||||
---
|
||||
|
||||
### Task 13: `NotificationOutboxActor` — ingest
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs`
|
||||
- Create: `src/ScadaLink.NotificationOutbox/Messages/InternalMessages.cs` (actor-internal tick messages)
|
||||
- Test: `tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorIngestTests.cs`
|
||||
|
||||
**Step 1 — failing tests** (TestKit). The actor takes `IServiceProvider`, `NotificationOutboxOptions`, `ILogger`. Use a mocked `INotificationOutboxRepository` registered in the test `ServiceProvider`:
|
||||
- Send `NotificationSubmit` → actor calls `InsertIfNotExistsAsync` with a `Notification` whose fields map from the message, `Status = Pending`, `CreatedAt` set; replies `NotificationSubmitAck(NotificationId, Accepted: true, null)` to `Sender`.
|
||||
- Send the same `NotificationSubmit` twice → second `InsertIfNotExistsAsync` returns false; actor still replies `Accepted: true` (idempotent — the row already exists, ack so the site clears its buffer).
|
||||
- Repository throws → actor replies `Accepted: false` with the error (site will retry the forward).
|
||||
|
||||
**Step 2 — run red.**
|
||||
|
||||
**Step 3 — implement.** `ReceiveActor`. On `NotificationSubmit`: build a `Notification`, `CreateScope()` to resolve `INotificationOutboxRepository`, call `InsertIfNotExistsAsync`, `PipeTo` the result back so the reply preserves `Sender`. Reply `NotificationSubmitAck`. Keep dispatch (Task 14) out of this task — ingest only.
|
||||
|
||||
**Step 4 — run green. Step 5 — commit** (`feat(notification-outbox): add NotificationOutboxActor ingest`).
|
||||
|
||||
---
|
||||
|
||||
### Task 14: `NotificationOutboxActor` — dispatcher loop
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs`
|
||||
- Test: `tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorDispatchTests.cs`
|
||||
|
||||
**Step 1 — failing tests** (TestKit, with a registered set of `INotificationDeliveryAdapter` and a mocked repo):
|
||||
- On a `DispatchTick`, the actor calls `GetDueAsync`, and for each row invokes the adapter for its `Type`.
|
||||
- adapter `Success` → row updated to `Delivered`, `DeliveredAt`/`ResolvedTargets`/`LastAttemptAt` set, `UpdateAsync` called.
|
||||
- adapter `TransientFailure` → `Retrying`, `RetryCount` incremented, `NextAttemptAt = now + retry interval`, `LastError` set.
|
||||
- adapter `TransientFailure` when `RetryCount` already at the SMTP-config max → `Parked`.
|
||||
- adapter `PermanentFailure` → `Parked` immediately, `LastError` set.
|
||||
- no adapter for the row's `Type` → `Parked` with an explanatory error.
|
||||
|
||||
**Step 2 — run red.**
|
||||
|
||||
**Step 3 — implement.** `IWithTimers`; in `PreStart` start a periodic `DispatchTick` every `options.DispatchInterval`. On `DispatchTick`: scope-resolve the repo, `GetDueAsync(now, options.DispatchBatchSize)`, and for each notification resolve the adapter from a `Dictionary<NotificationType, INotificationDeliveryAdapter>` (injected), `await DeliverAsync`, apply the status transition, `UpdateAsync`. Retry count/interval come from the central SMTP config (`SmtpConfiguration.MaxRetries` / `RetryDelay` via `INotificationRepository`). Run delivery on a blocking-safe path (the actor `PipeTo`s the async work; do not block the actor thread). Guard against overlapping ticks (ignore a new tick while one is in flight).
|
||||
|
||||
**Step 4 — run green. Step 5 — commit** (`feat(notification-outbox): add dispatcher loop to NotificationOutboxActor`).
|
||||
|
||||
---
|
||||
|
||||
### Task 15: `NotificationOutboxActor` — query, retry, discard, KPIs
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs`
|
||||
- Test: `tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorQueryTests.cs`
|
||||
|
||||
**Step 1 — failing tests** (TestKit):
|
||||
- `NotificationOutboxQueryRequest` → actor calls `QueryAsync`, replies `NotificationOutboxQueryResponse` with mapped `NotificationSummary` rows; `IsStuck` true when `Status` is `Pending`/`Retrying` and `CreatedAt` older than `options.StuckAgeThreshold`.
|
||||
- `NotificationStatusQuery` → replies `NotificationStatusResponse` (`Found:false` when the id is unknown).
|
||||
- `RetryNotificationRequest` on a `Parked` row → row reset to `Pending`, `RetryCount` 0, `NextAttemptAt` cleared; replies success. On a non-`Parked` row → `Success:false`.
|
||||
- `DiscardNotificationRequest` on a `Parked` row → `Status = Discarded`; replies success.
|
||||
- `NotificationKpiRequest` → replies `NotificationKpiResponse` from `ComputeKpisAsync` (stuck cutoff = now − `StuckAgeThreshold`; delivered window = now − `DeliveredKpiWindow`).
|
||||
|
||||
**Step 2 — run red.**
|
||||
|
||||
**Step 3 — implement** the additional `Receive<>` handlers, each scope-resolving the repo and `PipeTo`-ing the reply.
|
||||
|
||||
**Step 4 — run green. Step 5 — commit** (`feat(notification-outbox): add query, retry, discard, and KPI handlers`).
|
||||
|
||||
---
|
||||
|
||||
### Task 16: Daily purge job
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs`
|
||||
- Test: `tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorPurgeTests.cs`
|
||||
|
||||
**Step 1 — failing test.** On a `PurgeTick`, the actor calls `DeleteTerminalOlderThanAsync(now − options.TerminalRetention)`.
|
||||
|
||||
**Step 2 — run red.**
|
||||
|
||||
**Step 3 — implement.** In `PreStart` start a second periodic timer `PurgeTick` every `options.PurgeInterval`. Handler scope-resolves the repo and calls `DeleteTerminalOlderThanAsync`; log the deleted count.
|
||||
|
||||
**Step 4 — run green. Step 5 — commit** (`feat(notification-outbox): add daily terminal-row purge`).
|
||||
|
||||
---
|
||||
|
||||
### Task 17: `AddNotificationOutbox` DI extension
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ScadaLink.NotificationOutbox/ServiceCollectionExtensions.cs`
|
||||
- Test: `tests/ScadaLink.NotificationOutbox.Tests/ServiceRegistrationTests.cs`
|
||||
|
||||
**Step 1 — failing test.** Build a `ServiceCollection`, call `AddNotificationOutbox`, and assert `NotificationOutboxOptions`, the `EmailNotificationDeliveryAdapter`, and the adapter dictionary resolve.
|
||||
|
||||
**Step 2 — run red.**
|
||||
|
||||
**Step 3 — implement.** `public const string OptionsSection = "ScadaLink:NotificationOutbox";` plus `AddNotificationOutbox(this IServiceCollection)` registering `AddOptions<NotificationOutboxOptions>().BindConfiguration(OptionsSection)`, the SMTP client `Func<ISmtpClientWrapper>` (reuse `NotificationService`'s registration or register here), `EmailNotificationDeliveryAdapter`, and a registration that exposes `IReadOnlyDictionary<NotificationType, INotificationDeliveryAdapter>` built from all registered adapters.
|
||||
|
||||
**Step 4 — run green. Step 5 — commit** (`feat(notification-outbox): add AddNotificationOutbox DI registration`).
|
||||
|
||||
---
|
||||
|
||||
## Phase D — Site retarget + central wiring
|
||||
|
||||
### Task 18: Retarget the site S&F notification handler to forward to central
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ScadaLink.StoreAndForward/StoreAndForwardService.cs` and/or the site registration that wires the `Notification` category delivery handler
|
||||
- Modify: `src/ScadaLink.Host/SiteServiceRegistration.cs` (where the notification handler is registered)
|
||||
- Test: `tests/ScadaLink.StoreAndForward.Tests/` (a test that the registered notification handler forwards to the communication actor and treats an ack as success)
|
||||
|
||||
**Step 1 — investigate + failing test.** Currently the `Notification` category handler calls `NotificationDeliveryService.DeliverBufferedAsync`. The new handler must instead send a `NotificationSubmit` to central via the site's communication actor (`ClusterClient.Send("/user/central-communication", submit)`) and treat a `NotificationSubmitAck(Accepted:true)` as delivered (`true`), a non-ack/timeout as transient (throw), so S&F retries the forward. Write a test with a `TestProbe` standing in for the central client: handler invoked → probe receives `NotificationSubmit`; reply `NotificationSubmitAck(Accepted:true)` → handler result `true`; timeout → handler throws (transient).
|
||||
|
||||
**Step 2 — run red.**
|
||||
|
||||
**Step 3 — implement.** Add a `NotificationForwarder` (small class or the handler lambda) that holds the site communication actor ref and does `Ask<NotificationSubmitAck>` with the host-configured forward-retry timeout. Register it as the `StoreAndForwardCategory.Notification` delivery handler in `SiteServiceRegistration`, replacing the `NotificationDeliveryService` handler. The S&F engine already buffers/retries on a thrown (transient) result — no S&F core change needed.
|
||||
|
||||
**Step 4 — run green. Step 5 — commit** (`feat(notification-outbox): forward site S&F notifications to central`).
|
||||
|
||||
---
|
||||
|
||||
### Task 19: `Notify.Send` async + `Notify.Status` (SiteRuntime)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs` (`NotifyHelper`, `NotifyTarget`)
|
||||
- Test: `tests/ScadaLink.SiteRuntime.Tests/` (Notify API tests)
|
||||
|
||||
**Step 1 — failing tests.**
|
||||
- `Notify.To("list").Send("subj","body")` generates a GUID `NotificationId`, enqueues a `StoreAndForwardCategory.Notification` message into `StoreAndForwardService` (target `"central"`, payload = serialized `NotificationSubmit`), and returns the `NotificationId` string immediately.
|
||||
- `Notify.Status(id)` issues a `NotificationStatusQuery` to central and returns the mapped status record; while the notification is still in the site S&F buffer (central has no row / query says `Found:false` but the S&F buffer still holds the id) it reports `Forwarding`.
|
||||
|
||||
**Step 2 — run red.**
|
||||
|
||||
**Step 3 — implement.** Change `NotifyTarget.Send` to return `Task<string>` (the `NotificationId`): create the GUID, build a `NotificationSubmit` (with `SourceSiteId`, `SourceInstanceId = _instanceName`, `SiteEnqueuedAt = UtcNow`), `EnqueueAsync(Notification, "central", payloadJson)`. Add `NotifyHelper.Status(string notificationId)` returning a status record: query central via the site communication actor; if central returns `Found:false` and the id is still buffered in S&F, return status `Forwarding`. Keep the script-facing surface minimal (`Send`, `Status`).
|
||||
|
||||
**Step 2 note:** the `Notify` API is consumed by compiled scripts — confirm the script trust model / compilation still accepts the changed signature; update any script-API surface tests.
|
||||
|
||||
**Step 4 — run green. Step 5 — commit** (`feat(notification-outbox): async Notify.Send with status handle`).
|
||||
|
||||
---
|
||||
|
||||
### Task 20: Central ingest routing
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ScadaLink.Communication/Actors/CentralCommunicationActor.cs`
|
||||
- Test: `tests/ScadaLink.Communication.Tests/CentralCommunicationActorTests.cs`
|
||||
|
||||
**Step 1 — failing test.** When `CentralCommunicationActor` receives a `NotificationSubmit` (sent site→central via ClusterClient to `/user/central-communication`), it forwards it to the notification-outbox singleton proxy and the ack flows back to the original `Sender`. Use a `TestProbe` for the outbox proxy.
|
||||
|
||||
**Step 2 — run red.**
|
||||
|
||||
**Step 3 — implement.** `CentralCommunicationActor` takes an optional outbox-proxy `IActorRef` (passed at construction by the Host, Task 21). `Receive<NotificationSubmit>(m => _outboxProxy.Forward(m))` — `Forward` preserves the original sender so the `NotificationSubmitAck` returns to the site's ClusterClient.
|
||||
|
||||
**Step 4 — run green. Step 5 — commit** (`feat(notification-outbox): route NotificationSubmit to the outbox actor`).
|
||||
|
||||
---
|
||||
|
||||
### Task 21: Host registration + appsettings
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ScadaLink.Host/Actors/AkkaHostedService.cs` (`RegisterCentralActors`)
|
||||
- Modify: `src/ScadaLink.Host/Program.cs` (call `AddNotificationOutbox`; `Configure<NotificationOutboxOptions>`)
|
||||
- Modify: `src/ScadaLink.Host/appsettings.Central.json` (`ScadaLink:NotificationOutbox` section)
|
||||
- Modify: `src/ScadaLink.Host/appsettings.Site.json` (site→central notification forward-retry interval, if not already covered by S&F config)
|
||||
- Test: `tests/ScadaLink.Host.Tests/` if present, else verify via build + the integration test in Task 25
|
||||
|
||||
**Step 1 — implement.** In `RegisterCentralActors`: create the `NotificationOutboxActor` as a **cluster singleton** (`ClusterSingletonManager.Props` + `ClusterSingletonProxy.Props`, singleton name `"notification-outbox"`, no explicit role — central nodes only run this role), passing `IServiceProvider`, `NotificationOutboxOptions`, the adapter dictionary, and a logger. Pass the singleton **proxy** ref into `CentralCommunicationActor`'s `Props.Create`. In `Program.cs` central path, call `builder.Services.AddNotificationOutbox()` and `services.Configure<NotificationOutboxOptions>(...GetSection(ServiceCollectionExtensions.OptionsSection))`. Add the `ScadaLink:NotificationOutbox` block to `appsettings.Central.json` with the Task 10 defaults.
|
||||
|
||||
**Step 2 — verify:** `dotnet build ScadaLink.slnx` succeeds.
|
||||
|
||||
**Step 3 — commit** (`feat(notification-outbox): register NotificationOutbox singleton in Host`).
|
||||
|
||||
---
|
||||
|
||||
## Phase E — Central UI
|
||||
|
||||
### Task 22: `CommunicationService` outbox methods
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ScadaLink.Communication/CommunicationService.cs`
|
||||
- Test: `tests/ScadaLink.Communication.Tests/CommunicationServiceTests.cs` (or the existing service test file)
|
||||
|
||||
**Step 1 — failing tests.** New methods `QueryNotificationOutboxAsync`, `RetryNotificationAsync`, `DiscardNotificationAsync`, `GetNotificationKpisAsync` each `Ask` the central outbox proxy and return the typed response. (These are central-side and do not go through `SiteEnvelope` — they talk to the local outbox proxy directly.) Test with a `TestProbe` for the proxy.
|
||||
|
||||
**Step 2 — run red.**
|
||||
|
||||
**Step 3 — implement.** Add an outbox-proxy `IActorRef` to `CommunicationService` (set by the Host like `SetCommunicationActor`). Each method `Ask<TResponse>(request, _options.QueryTimeout)`.
|
||||
|
||||
**Step 4 — run green. Step 5 — commit** (`feat(notification-outbox): add CommunicationService outbox methods`).
|
||||
|
||||
---
|
||||
|
||||
### Task 23: Notification Outbox Blazor page + nav entry
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ScadaLink.CentralUI/Components/Pages/Monitoring/NotificationOutbox.razor`
|
||||
- Modify: `src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor`
|
||||
- Test: `tests/ScadaLink.CentralUI.Tests/Pages/NotificationOutboxPageTests.cs` (bUnit)
|
||||
|
||||
**Step 1 — failing test** (bUnit). Render the page with a substituted `CommunicationService` returning a fixed KPI response and a page of `NotificationSummary` rows; assert the KPI tiles show the values and the table renders the rows; assert clicking Retry on a `Parked` row calls `RetryNotificationAsync`.
|
||||
|
||||
**Step 2 — run red.**
|
||||
|
||||
**Step 3 — implement.** Model the page on `Components/Pages/Monitoring/ParkedMessages.razor`: `@page "/monitoring/notification-outbox"`, `@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)]`. KPI tile row (Bootstrap `card` tiles like `Health.razor`) bound to `GetNotificationKpisAsync`; a filter card (status, type, source site, list, time range, stuck-only toggle, subject keyword); a table of `NotificationSummary` with stuck rows badged; Retry/Discard buttons on `Parked` rows using `IDialogService.ConfirmAsync` + `ToastNotification`. Add a `NavLink` to `NavMenu.razor` under the Deployment-role Monitoring section (`href="/monitoring/notification-outbox"`).
|
||||
|
||||
**Step 4 — run green. Step 5 — commit** (`feat(notification-outbox): add Notification Outbox UI page`).
|
||||
|
||||
---
|
||||
|
||||
### Task 24: Health dashboard outbox KPI tiles
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ScadaLink.CentralUI/Components/Pages/Monitoring/Health.razor`
|
||||
- Test: `tests/ScadaLink.CentralUI.Tests/Pages/HealthPageTests.cs` (extend if present)
|
||||
|
||||
**Step 1 — failing test** (bUnit). With a substituted `CommunicationService.GetNotificationKpisAsync`, the Health page renders three headline outbox tiles: queue depth, stuck count, parked count.
|
||||
|
||||
**Step 2 — run red.**
|
||||
|
||||
**Step 3 — implement.** Add a "Notification Outbox" tile row to `Health.razor`, fetched on init / on the existing 10s polling timer, styled like the existing overview cards.
|
||||
|
||||
**Step 4 — run green. Step 5 — commit** (`feat(notification-outbox): add outbox KPI tiles to Health dashboard`).
|
||||
|
||||
---
|
||||
|
||||
## Phase F — Integration & verification
|
||||
|
||||
### Task 25: End-to-end integration test
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/ScadaLink.IntegrationTests/NotificationOutboxFlowTests.cs`
|
||||
|
||||
**Step 1 — failing test.** Following the patterns in `tests/ScadaLink.IntegrationTests/`, exercise the flow with an in-memory/SQLite-backed `ScadaLinkDbContext` and a real `NotificationOutboxActor`: submit a `NotificationSubmit` → assert a `Notifications` row exists (`Pending`) → trigger a `DispatchTick` with a stub adapter that returns `Success` → assert the row is `Delivered`. Add a second case: stub adapter returns `PermanentFailure` → row `Parked`; then a `RetryNotificationRequest` → row back to `Pending`.
|
||||
|
||||
**Step 2 — run red. Step 3 — make it pass** (it should, if Phases A–D are correct; fix any wiring gaps found). **Step 4 — commit** (`test(notification-outbox): end-to-end outbox flow integration test`).
|
||||
|
||||
---
|
||||
|
||||
### Task 26: Full build + suite verification
|
||||
|
||||
**Files:** none (verification only).
|
||||
|
||||
**Step 1:** `dotnet build ScadaLink.slnx` → must succeed with no errors.
|
||||
|
||||
**Step 2:** `dotnet test ScadaLink.slnx` → the whole suite must pass. Investigate and fix any regressions (notably in `ScadaLink.NotificationService.Tests`, `ScadaLink.StoreAndForward.Tests`, `ScadaLink.SiteRuntime.Tests`, `ScadaLink.Communication.Tests` — the docs/design changed the notification path and existing tests may assert old behavior; update them to the new design).
|
||||
|
||||
**Step 3:** If the docker cluster is used for smoke testing, note that `bash docker/deploy.sh` rebuilds the image — out of scope for this plan unless the user asks.
|
||||
|
||||
**Step 4 — commit** any test fixes (`test(notification-outbox): update existing tests for the central-delivery model`).
|
||||
|
||||
---
|
||||
|
||||
## Done
|
||||
|
||||
The Notification Outbox feature is implemented end to end: site scripts enqueue notifications that store-and-forward to central, the `NotificationOutboxActor` singleton ingests them into the `Notifications` table and delivers them via the Email adapter with retry/parking, operators see KPIs and manage notifications from the Central UI, and the full test suite passes. Teams and other delivery adapters can be added later by implementing `INotificationDeliveryAdapter` and registering it — no other change required.
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-05-19-notification-outbox-implementation.md",
|
||||
"tasks": [
|
||||
{"id": 18, "subject": "Task 1: Notification enums", "status": "pending"},
|
||||
{"id": 19, "subject": "Task 2: Notification entity POCO", "status": "pending", "blockedBy": [18]},
|
||||
{"id": 20, "subject": "Task 3: Type field on NotificationList", "status": "pending", "blockedBy": [19]},
|
||||
{"id": 21, "subject": "Task 4: Notification EF configuration + DbSet", "status": "pending", "blockedBy": [20]},
|
||||
{"id": 22, "subject": "Task 5: NotificationOutbox repository", "status": "pending", "blockedBy": [21]},
|
||||
{"id": 23, "subject": "Task 6: EF migration AddNotificationsTable", "status": "pending", "blockedBy": [22]},
|
||||
{"id": 24, "subject": "Task 7: Site/central notification message contracts", "status": "pending", "blockedBy": [23]},
|
||||
{"id": 25, "subject": "Task 8: Outbox query/action contracts", "status": "pending", "blockedBy": [24]},
|
||||
{"id": 26, "subject": "Task 9: Scaffold ScadaLink.NotificationOutbox project", "status": "pending", "blockedBy": [25]},
|
||||
{"id": 27, "subject": "Task 10: NotificationOutboxOptions", "status": "pending", "blockedBy": [26]},
|
||||
{"id": 28, "subject": "Task 11: Delivery adapter abstraction", "status": "pending", "blockedBy": [27]},
|
||||
{"id": 29, "subject": "Task 12: Email delivery adapter", "status": "pending", "blockedBy": [28]},
|
||||
{"id": 30, "subject": "Task 13: NotificationOutboxActor ingest", "status": "pending", "blockedBy": [29]},
|
||||
{"id": 31, "subject": "Task 14: Dispatcher loop", "status": "pending", "blockedBy": [30]},
|
||||
{"id": 32, "subject": "Task 15: Query, retry, discard, KPI handlers", "status": "pending", "blockedBy": [31]},
|
||||
{"id": 33, "subject": "Task 16: Daily purge job", "status": "pending", "blockedBy": [32]},
|
||||
{"id": 34, "subject": "Task 17: AddNotificationOutbox DI extension", "status": "pending", "blockedBy": [33]},
|
||||
{"id": 35, "subject": "Task 18: Retarget site S&F notification handler to central", "status": "pending", "blockedBy": [34]},
|
||||
{"id": 36, "subject": "Task 19: Async Notify.Send + Notify.Status", "status": "pending", "blockedBy": [35]},
|
||||
{"id": 37, "subject": "Task 20: Central ingest routing", "status": "pending", "blockedBy": [36]},
|
||||
{"id": 38, "subject": "Task 21: Host registration + appsettings", "status": "pending", "blockedBy": [37]},
|
||||
{"id": 39, "subject": "Task 22: CommunicationService outbox methods", "status": "pending", "blockedBy": [38]},
|
||||
{"id": 40, "subject": "Task 23: Notification Outbox Blazor page", "status": "pending", "blockedBy": [39]},
|
||||
{"id": 41, "subject": "Task 24: Health dashboard outbox KPI tiles", "status": "pending", "blockedBy": [40]},
|
||||
{"id": 42, "subject": "Task 25: End-to-end integration test", "status": "pending", "blockedBy": [41]},
|
||||
{"id": 43, "subject": "Task 26: Full build + suite verification", "status": "pending", "blockedBy": [42]}
|
||||
],
|
||||
"lastUpdated": "2026-05-19"
|
||||
}
|
||||
Reference in New Issue
Block a user