From d35551efc28df05eb1f5264a6eeb781cf5824645 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 21 May 2026 18:11:04 -0400 Subject: [PATCH] feat(auditlog): NotifyDeliver rows carry the originating ParentExecutionId --- .../Entities/Notifications/Notification.cs | 9 + .../Notification/NotificationMessages.cs | 10 +- .../NotificationOutboxConfiguration.cs | 4 + ...icationOriginParentExecutionId.Designer.cs | 1639 +++++++++++++++++ ..._AddNotificationOriginParentExecutionId.cs | 42 + .../ScadaLinkDbContextModelSnapshot.cs | 3 + .../NotificationOutboxActor.cs | 12 +- .../Scripts/ScriptRuntimeContext.cs | 8 +- .../Entities/NotificationEntityTests.cs | 15 + .../Messages/NotificationMessagesTests.cs | 45 + ...onOriginParentExecutionIdMigrationTests.cs | 71 + .../RepositoryCoverageTests.cs | 23 + ...ficationOutboxActorAttemptEmissionTests.cs | 48 +- .../NotificationOutboxActorIngestTests.cs | 43 +- ...icationOutboxActorTerminalEmissionTests.cs | 48 +- .../Scripts/NotifyHelperTests.cs | 45 +- 16 files changed, 2056 insertions(+), 9 deletions(-) create mode 100644 src/ScadaLink.ConfigurationDatabase/Migrations/20260521220924_AddNotificationOriginParentExecutionId.Designer.cs create mode 100644 src/ScadaLink.ConfigurationDatabase/Migrations/20260521220924_AddNotificationOriginParentExecutionId.cs create mode 100644 tests/ScadaLink.ConfigurationDatabase.Tests/Migrations/AddNotificationOriginParentExecutionIdMigrationTests.cs diff --git a/src/ScadaLink.Commons/Entities/Notifications/Notification.cs b/src/ScadaLink.Commons/Entities/Notifications/Notification.cs index 916ea02..f9305d0 100644 --- a/src/ScadaLink.Commons/Entities/Notifications/Notification.cs +++ b/src/ScadaLink.Commons/Entities/Notifications/Notification.cs @@ -36,6 +36,15 @@ public class Notification /// submitted before the column existed, or raised outside a script-execution context. /// public Guid? OriginExecutionId { get; set; } + + /// + /// The originating routed script execution's ParentExecutionId (Audit Log #23). + /// Carried from the site on the + /// so the central dispatcher can stamp the same parent id onto its NotifyDeliver + /// audit rows, correlating them with the site-emitted NotifySend row. Null for + /// non-routed runs, or for notifications submitted before the column existed. + /// + public Guid? OriginParentExecutionId { get; set; } public DateTimeOffset SiteEnqueuedAt { get; set; } /// Central ingest time. diff --git a/src/ScadaLink.Commons/Messages/Notification/NotificationMessages.cs b/src/ScadaLink.Commons/Messages/Notification/NotificationMessages.cs index ecfb1d6..1c5a868 100644 --- a/src/ScadaLink.Commons/Messages/Notification/NotificationMessages.cs +++ b/src/ScadaLink.Commons/Messages/Notification/NotificationMessages.cs @@ -11,6 +11,13 @@ namespace ScadaLink.Commons.Messages.Notification; /// NotifyDeliver audit rows. Additive trailing member — null for messages built /// before the field existed, or for notifications raised outside a script execution. /// +/// +/// The originating routed script execution's ParentExecutionId (Audit Log #23). +/// Stamped at Notify.Send time and carried, inside the serialized payload, through +/// the site store-and-forward buffer so the central dispatcher can echo it onto the +/// NotifyDeliver audit rows. Additive trailing member — null for messages built +/// before the field existed, or for non-routed runs. +/// public record NotificationSubmit( string NotificationId, string ListName, @@ -20,7 +27,8 @@ public record NotificationSubmit( string? SourceInstanceId, string? SourceScript, DateTimeOffset SiteEnqueuedAt, - Guid? OriginExecutionId = null); + Guid? OriginExecutionId = null, + Guid? OriginParentExecutionId = null); /// /// Central -> Site: ack sent after the notification row is persisted. diff --git a/src/ScadaLink.ConfigurationDatabase/Configurations/NotificationOutboxConfiguration.cs b/src/ScadaLink.ConfigurationDatabase/Configurations/NotificationOutboxConfiguration.cs index a30859b..cb6e4c2 100644 --- a/src/ScadaLink.ConfigurationDatabase/Configurations/NotificationOutboxConfiguration.cs +++ b/src/ScadaLink.ConfigurationDatabase/Configurations/NotificationOutboxConfiguration.cs @@ -51,6 +51,10 @@ public class NotificationOutboxConfiguration : IEntityTypeConfiguration new { n.Status, n.NextAttemptAt }); builder.HasIndex(n => new { n.SourceSiteId, n.CreatedAt }); diff --git a/src/ScadaLink.ConfigurationDatabase/Migrations/20260521220924_AddNotificationOriginParentExecutionId.Designer.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/20260521220924_AddNotificationOriginParentExecutionId.Designer.cs new file mode 100644 index 0000000..ce3e031 --- /dev/null +++ b/src/ScadaLink.ConfigurationDatabase/Migrations/20260521220924_AddNotificationOriginParentExecutionId.Designer.cs @@ -0,0 +1,1639 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using ScadaLink.ConfigurationDatabase; + +#nullable disable + +namespace ScadaLink.ConfigurationDatabase.Migrations +{ + [DbContext(typeof(ScadaLinkDbContext))] + [Migration("20260521220924_AddNotificationOriginParentExecutionId")] + partial class AddNotificationOriginParentExecutionId + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Xml") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Audit.AuditEvent", b => + { + b.Property("EventId") + .HasColumnType("uniqueidentifier"); + + b.Property("OccurredAtUtc") + .HasColumnType("datetime2"); + + b.Property("Actor") + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("CorrelationId") + .HasColumnType("uniqueidentifier"); + + b.Property("DurationMs") + .HasColumnType("int"); + + b.Property("ErrorDetail") + .HasColumnType("nvarchar(max)"); + + b.Property("ErrorMessage") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("ExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("Extra") + .HasColumnType("nvarchar(max)"); + + b.Property("ForwardState") + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("HttpStatus") + .HasColumnType("int"); + + b.Property("IngestedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("ParentExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("PayloadTruncated") + .HasColumnType("bit"); + + b.Property("RequestSummary") + .HasColumnType("nvarchar(max)"); + + b.Property("ResponseSummary") + .HasColumnType("nvarchar(max)"); + + b.Property("SourceInstanceId") + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("SourceScript") + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("SourceSiteId") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("Target") + .HasMaxLength(256) + .IsUnicode(false) + .HasColumnType("varchar(256)"); + + b.HasKey("EventId", "OccurredAtUtc"); + + b.HasIndex("CorrelationId") + .HasDatabaseName("IX_AuditLog_CorrelationId") + .HasFilter("[CorrelationId] IS NOT NULL"); + + b.HasIndex("EventId") + .IsUnique() + .HasDatabaseName("UX_AuditLog_EventId"); + + b.HasIndex("ExecutionId") + .HasDatabaseName("IX_AuditLog_Execution") + .HasFilter("[ExecutionId] IS NOT NULL"); + + b.HasIndex("OccurredAtUtc") + .IsDescending() + .HasDatabaseName("IX_AuditLog_OccurredAtUtc"); + + b.HasIndex("ParentExecutionId") + .HasDatabaseName("IX_AuditLog_ParentExecution") + .HasFilter("[ParentExecutionId] IS NOT NULL"); + + b.HasIndex("SourceSiteId", "OccurredAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_AuditLog_Site_Occurred"); + + b.HasIndex("Target", "OccurredAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_AuditLog_Target_Occurred") + .HasFilter("[Target] IS NOT NULL"); + + b.HasIndex("Channel", "Status", "OccurredAtUtc") + .IsDescending(false, false, true) + .HasDatabaseName("IX_AuditLog_Channel_Status_Occurred"); + + b.ToTable("AuditLog", (string)null); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Audit.AuditLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("AfterStateJson") + .HasColumnType("nvarchar(max)"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("EntityName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("User") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("Action"); + + b.HasIndex("EntityId"); + + b.HasIndex("EntityType"); + + b.HasIndex("Timestamp"); + + b.HasIndex("User"); + + b.ToTable("AuditLogEntries"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Audit.SiteCall", b => + { + b.Property("TrackedOperationId") + .HasMaxLength(36) + .IsUnicode(false) + .HasColumnType("varchar(36)"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("HttpStatus") + .HasColumnType("int"); + + b.Property("IngestedAtUtc") + .HasColumnType("datetime2"); + + b.Property("LastError") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("RetryCount") + .HasColumnType("int"); + + b.Property("SourceSite") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("Target") + .IsRequired() + .HasMaxLength(256) + .IsUnicode(false) + .HasColumnType("varchar(256)"); + + b.Property("TerminalAtUtc") + .HasColumnType("datetime2"); + + b.Property("UpdatedAtUtc") + .HasColumnType("datetime2"); + + b.HasKey("TrackedOperationId"); + + b.HasIndex("SourceSite", "CreatedAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_SiteCalls_Source_Created"); + + b.HasIndex("Status", "UpdatedAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_SiteCalls_Status_Updated"); + + b.ToTable("SiteCalls", (string)null); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeployedConfigSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConfigurationJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeploymentId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("RevisionHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DeployedConfigSnapshots"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeploymentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("DeploymentId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ErrorMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("RevisionHash") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("DeployedAt"); + + b.HasIndex("DeploymentId") + .IsUnique(); + + b.HasIndex("InstanceId"); + + b.ToTable("DeploymentRecords"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.SystemArtifactDeploymentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ArtifactType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PerSiteStatus") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("DeployedAt"); + + b.ToTable("SystemArtifactDeploymentRecords"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.DatabaseConnectionDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConnectionString") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("DatabaseConnectionDefinitions"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AuthConfiguration") + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("AuthType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("EndpointUrl") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ExternalSystemDefinitions"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemMethod", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ExternalSystemDefinitionId") + .HasColumnType("int"); + + b.Property("HttpMethod") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("ExternalSystemDefinitionId", "Name") + .IsUnique(); + + b.ToTable("ExternalSystemMethods"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.InboundApi.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("IsEnabled") + .HasColumnType("bit"); + + b.Property("KeyHash") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("KeyHash") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.InboundApi.ApiMethod", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ApprovedApiKeyIds") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Script") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TimeoutSeconds") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ApiMethods"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Area", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParentAreaId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ParentAreaId"); + + b.HasIndex("SiteId", "ParentAreaId", "Name") + .IsUnique() + .HasFilter("[ParentAreaId] IS NOT NULL"); + + b.ToTable("Areas"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Instance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AreaId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.Property("State") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("UniqueName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("AreaId"); + + b.HasIndex("TemplateId"); + + b.HasIndex("SiteId", "UniqueName") + .IsUnique(); + + b.ToTable("Instances"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAlarmOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlarmCanonicalName") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("PriorityLevelOverride") + .HasColumnType("int"); + + b.Property("TriggerConfigurationOverride") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "AlarmCanonicalName") + .IsUnique(); + + b.ToTable("InstanceAlarmOverrides"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAttributeOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttributeName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("OverrideValue") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "AttributeName") + .IsUnique(); + + b.ToTable("InstanceAttributeOverrides"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceConnectionBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttributeName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("DataConnectionId") + .HasColumnType("int"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DataConnectionId"); + + b.HasIndex("InstanceId", "AttributeName") + .IsUnique(); + + b.ToTable("InstanceConnectionBindings"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.Notification", b => + { + b.Property("NotificationId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Body") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeliveredAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastAttemptAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastError") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ListName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NextAttemptAt") + .HasColumnType("datetimeoffset"); + + b.Property("OriginExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("OriginParentExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("ResolvedTargets") + .HasColumnType("nvarchar(max)"); + + b.Property("RetryCount") + .HasColumnType("int"); + + b.Property("SiteEnqueuedAt") + .HasColumnType("datetimeoffset"); + + b.Property("SourceInstanceId") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SourceScript") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SourceSiteId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("TypeData") + .HasColumnType("nvarchar(max)"); + + b.HasKey("NotificationId"); + + b.HasIndex("SourceSiteId", "CreatedAt"); + + b.HasIndex("Status", "NextAttemptAt"); + + b.ToTable("Notifications"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("NotificationLists"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationRecipient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("EmailAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NotificationListId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("NotificationListId"); + + b.ToTable("NotificationRecipients"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.SmtpConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AuthType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ConnectionTimeoutSeconds") + .HasColumnType("int"); + + b.Property("Credentials") + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("FromAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("MaxConcurrentConnections") + .HasColumnType("int"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Port") + .HasColumnType("int"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.Property("TlsMode") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.ToTable("SmtpConfigurations"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Scripts.SharedScript", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("SharedScripts"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Security.LdapGroupMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("LdapGroupName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("LdapGroupName") + .IsUnique(); + + b.ToTable("LdapGroupMappings"); + + b.HasData( + new + { + Id = 1, + LdapGroupName = "SCADA-Admins", + Role = "Admin" + }, + new + { + Id = 2, + LdapGroupName = "SCADA-Designers", + Role = "Design" + }, + new + { + Id = 3, + LdapGroupName = "SCADA-Deploy-All", + Role = "Deployment" + }, + new + { + Id = 4, + LdapGroupName = "SCADA-Deploy-SiteA", + Role = "Deployment" + }); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Security.SiteScopeRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("LdapGroupMappingId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SiteId"); + + b.HasIndex("LdapGroupMappingId", "SiteId") + .IsUnique(); + + b.ToTable("SiteScopeRules"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Sites.DataConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BackupConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("FailoverRetryCount") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(3); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PrimaryConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Name") + .IsUnique(); + + b.ToTable("DataConnections"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Sites.Site", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("GrpcNodeAAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("GrpcNodeBAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NodeAAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("NodeBAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("SiteIdentifier") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("SiteIdentifier") + .IsUnique(); + + b.ToTable("Sites"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.Template", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("FolderId") + .HasColumnType("int"); + + b.Property("IsDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OwnerCompositionId") + .HasColumnType("int"); + + b.Property("ParentTemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FolderId"); + + b.HasIndex("Name") + .IsUnique() + .HasFilter("[IsDerived] = 0"); + + b.HasIndex("ParentTemplateId"); + + b.ToTable("Templates"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAlarm", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OnTriggerScriptId") + .HasColumnType("int"); + + b.Property("PriorityLevel") + .HasColumnType("int"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("TriggerConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TriggerType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateAlarms"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAttribute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DataSourceReference") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("Value") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateAttributes"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateComposition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ComposedTemplateId") + .HasColumnType("int"); + + b.Property("InstanceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ComposedTemplateId"); + + b.HasIndex("TemplateId", "InstanceName") + .IsUnique(); + + b.ToTable("TemplateCompositions"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateFolder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParentFolderId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ParentFolderId", "Name") + .IsUnique() + .HasFilter("[ParentFolderId] IS NOT NULL"); + + b.ToTable("TemplateFolders"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateScript", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("MinTimeBetweenRuns") + .HasColumnType("time"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("TriggerConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TriggerType") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateScripts"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeployedConfigSnapshot", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany() + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeploymentRecord", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany() + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemMethod", b => + { + b.HasOne("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemDefinition", null) + .WithMany() + .HasForeignKey("ExternalSystemDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Area", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Area", null) + .WithMany("Children") + .HasForeignKey("ParentAreaId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Instance", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Area", null) + .WithMany() + .HasForeignKey("AreaId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAlarmOverride", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany("AlarmOverrides") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAttributeOverride", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany("AttributeOverrides") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceConnectionBinding", b => + { + b.HasOne("ScadaLink.Commons.Entities.Sites.DataConnection", null) + .WithMany() + .HasForeignKey("DataConnectionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany("ConnectionBindings") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationRecipient", b => + { + b.HasOne("ScadaLink.Commons.Entities.Notifications.NotificationList", null) + .WithMany("Recipients") + .HasForeignKey("NotificationListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Security.SiteScopeRule", b => + { + b.HasOne("ScadaLink.Commons.Entities.Security.LdapGroupMapping", null) + .WithMany() + .HasForeignKey("LdapGroupMappingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Sites.DataConnection", b => + { + b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.Template", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.TemplateFolder", null) + .WithMany() + .HasForeignKey("FolderId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("ParentTemplateId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAlarm", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany("Alarms") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAttribute", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany("Attributes") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateComposition", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("ComposedTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany("Compositions") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateFolder", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.TemplateFolder", null) + .WithMany() + .HasForeignKey("ParentFolderId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateScript", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany("Scripts") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Area", b => + { + b.Navigation("Children"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Instance", b => + { + b.Navigation("AlarmOverrides"); + + b.Navigation("AttributeOverrides"); + + b.Navigation("ConnectionBindings"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationList", b => + { + b.Navigation("Recipients"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.Template", b => + { + b.Navigation("Alarms"); + + b.Navigation("Attributes"); + + b.Navigation("Compositions"); + + b.Navigation("Scripts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ScadaLink.ConfigurationDatabase/Migrations/20260521220924_AddNotificationOriginParentExecutionId.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/20260521220924_AddNotificationOriginParentExecutionId.cs new file mode 100644 index 0000000..245e14e --- /dev/null +++ b/src/ScadaLink.ConfigurationDatabase/Migrations/20260521220924_AddNotificationOriginParentExecutionId.cs @@ -0,0 +1,42 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ScadaLink.ConfigurationDatabase.Migrations +{ + /// + /// Adds the OriginParentExecutionId correlation column to the central + /// Notifications table (#21). It carries the originating routed script + /// execution's ParentExecutionId from the site so the dispatcher can echo it + /// onto the NotifyDeliver audit rows (#23), linking them to the routed run's + /// parent. Sibling of OriginExecutionId. + /// + /// The change is purely additive: OriginParentExecutionId uniqueidentifier NULL + /// is added with no default, so the operation is a metadata-only + /// ALTER TABLE … ADD. Unlike AuditLog, the Notifications table is + /// NOT partitioned, so a plain ADD is fine. No index is created — the column is + /// never a query predicate, only copied onto audit events. Historical rows stay + /// NULL. + /// + public partial class AddNotificationOriginParentExecutionId : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "OriginParentExecutionId", + table: "Notifications", + type: "uniqueidentifier", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "OriginParentExecutionId", + table: "Notifications"); + } + } +} diff --git a/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs index 47d71da..1f5a5e3 100644 --- a/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs +++ b/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs @@ -797,6 +797,9 @@ namespace ScadaLink.ConfigurationDatabase.Migrations b.Property("OriginExecutionId") .HasColumnType("uniqueidentifier"); + b.Property("OriginParentExecutionId") + .HasColumnType("uniqueidentifier"); + b.Property("ResolvedTargets") .HasColumnType("nvarchar(max)"); diff --git a/src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs b/src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs index 2c2af88..2c3284d 100644 --- a/src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs +++ b/src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs @@ -492,7 +492,8 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers /// is copied straight from /// so the dispatcher's /// NotifyDeliver rows carry the same per-run id as the site's - /// NotifySend row (Audit Log #23). + /// NotifySend row (Audit Log #23); + /// is likewise copied from . /// private static AuditEvent BuildNotifyDeliverEvent( Notification notification, @@ -525,6 +526,11 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers // rows to the site-emitted NotifySend row for the same run. Null when // the notification was raised outside a script execution. ExecutionId = notification.OriginExecutionId, + // ParentExecutionId (Audit Log #23): the originating routed run's + // parent ExecutionId, carried from the site on NotificationSubmit and + // persisted on the Notification row. Echoing it here links the central + // NotifyDeliver rows to the routed run's parent. Null for non-routed runs. + ParentExecutionId = notification.OriginParentExecutionId, Target = notification.ListName, Status = status, ErrorMessage = errorMessage, @@ -954,6 +960,10 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers // OriginExecutionId (Audit Log #23): the originating script execution's id, // carried from the site so the dispatcher can echo it onto NotifyDeliver rows. OriginExecutionId = msg.OriginExecutionId, + // OriginParentExecutionId (Audit Log #23): the originating routed run's parent + // ExecutionId, carried from the site so the dispatcher can echo it onto + // NotifyDeliver rows. + OriginParentExecutionId = msg.OriginParentExecutionId, SiteEnqueuedAt = msg.SiteEnqueuedAt, CreatedAt = DateTimeOffset.UtcNow, // Status stays at its Pending default for the dispatch sweep to claim. diff --git a/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs b/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs index f581645..a8c68fb 100644 --- a/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs +++ b/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs @@ -1521,7 +1521,13 @@ public class ScriptRuntimeContext // onto this run's NotifySend audit row. It rides inside the serialized // payload through the S&F buffer to central, where the dispatcher echoes // it onto the NotifyDeliver rows so all rows for one run share an id. - OriginExecutionId: _executionId); + OriginExecutionId: _executionId, + // OriginParentExecutionId (Audit Log #23): the SAME parent-execution id + // stamped onto this run's NotifySend audit row — the spawning run's id + // for an inbound-API-routed execution, null otherwise. It rides through + // the S&F buffer to central, where the dispatcher echoes it onto the + // NotifyDeliver rows so the central rows carry the routed run's parent id. + OriginParentExecutionId: _parentExecutionId); var payloadJson = JsonSerializer.Serialize(payload); diff --git a/tests/ScadaLink.Commons.Tests/Entities/NotificationEntityTests.cs b/tests/ScadaLink.Commons.Tests/Entities/NotificationEntityTests.cs index d3679da..bb872cd 100644 --- a/tests/ScadaLink.Commons.Tests/Entities/NotificationEntityTests.cs +++ b/tests/ScadaLink.Commons.Tests/Entities/NotificationEntityTests.cs @@ -36,6 +36,21 @@ public class NotificationEntityTests Assert.Equal(executionId, n.OriginExecutionId); } + [Fact] + public void OriginParentExecutionId_DefaultsToNull_AndIsSettable() + { + // Audit Log ParentExecutionId: OriginParentExecutionId carries the + // routed run's parent ExecutionId from the site so the dispatcher can + // echo it onto NotifyDeliver rows. Null for non-routed runs, or for + // notifications submitted before the column existed. + var n = new Notification("id-1", NotificationType.Email, "ops-team", "subj", "body", "SiteA"); + Assert.Null(n.OriginParentExecutionId); + + var parentExecutionId = Guid.NewGuid(); + n.OriginParentExecutionId = parentExecutionId; + Assert.Equal(parentExecutionId, n.OriginParentExecutionId); + } + [Fact] public void Constructor_NullArguments_Throw() { diff --git a/tests/ScadaLink.Commons.Tests/Messages/NotificationMessagesTests.cs b/tests/ScadaLink.Commons.Tests/Messages/NotificationMessagesTests.cs index 53b5a15..20c090a 100644 --- a/tests/ScadaLink.Commons.Tests/Messages/NotificationMessagesTests.cs +++ b/tests/ScadaLink.Commons.Tests/Messages/NotificationMessagesTests.cs @@ -81,6 +81,51 @@ public class NotificationMessagesTests Assert.Equal(executionId, roundTripped!.OriginExecutionId); } + [Fact] + public void NotificationSubmit_OriginParentExecutionId_DefaultsToNull() + { + // Audit Log ParentExecutionId: OriginParentExecutionId is an additive + // trailing member — a submit built without it (old call sites / old + // serialized payloads, or non-routed runs) leaves the id null. + var msg = new NotificationSubmit( + "notif-6", "Operators", "Subject", "Body", + "site-01", "inst-1", "OnAlarm", DateTimeOffset.UtcNow); + + Assert.Null(msg.OriginParentExecutionId); + } + + [Fact] + public void NotificationSubmit_OriginParentExecutionId_RoundTripsWhenSupplied() + { + var executionId = Guid.NewGuid(); + var parentExecutionId = Guid.NewGuid(); + var msg = new NotificationSubmit( + "notif-7", "Operators", "Subject", "Body", + "site-01", "inst-1", "OnAlarm", DateTimeOffset.UtcNow, + executionId, parentExecutionId); + + Assert.Equal(parentExecutionId, msg.OriginParentExecutionId); + } + + [Fact] + public void NotificationSubmit_OriginParentExecutionId_SurvivesJsonRoundTrip() + { + // The buffered S&F payload IS a serialized NotificationSubmit; the + // forwarder deserializes it, so OriginParentExecutionId must survive JSON. + var executionId = Guid.NewGuid(); + var parentExecutionId = Guid.NewGuid(); + var msg = new NotificationSubmit( + "notif-8", "Operators", "Subject", "Body", + "site-01", "inst-1", "OnAlarm", DateTimeOffset.UtcNow, + executionId, parentExecutionId); + + var json = System.Text.Json.JsonSerializer.Serialize(msg); + var roundTripped = System.Text.Json.JsonSerializer.Deserialize(json); + + Assert.NotNull(roundTripped); + Assert.Equal(parentExecutionId, roundTripped!.OriginParentExecutionId); + } + [Fact] public void NotificationSubmit_ValueEquality_EqualWhenAllFieldsMatch() { diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/Migrations/AddNotificationOriginParentExecutionIdMigrationTests.cs b/tests/ScadaLink.ConfigurationDatabase.Tests/Migrations/AddNotificationOriginParentExecutionIdMigrationTests.cs new file mode 100644 index 0000000..8a7e390 --- /dev/null +++ b/tests/ScadaLink.ConfigurationDatabase.Tests/Migrations/AddNotificationOriginParentExecutionIdMigrationTests.cs @@ -0,0 +1,71 @@ +using Xunit; + +namespace ScadaLink.ConfigurationDatabase.Tests.Migrations; + +/// +/// Audit Log ParentExecutionId integration test for the +/// AddNotificationOriginParentExecutionId migration: applies the EF +/// migrations to a freshly-created MSSQL test database on the running +/// infra/mssql container and asserts that the Notifications table carries +/// the new OriginParentExecutionId column as a nullable +/// uniqueidentifier. +/// +/// +/// Unlike AuditLog, the Notifications table is not partitioned, so +/// the column is a plain metadata-only ALTER TABLE … ADD with no index. +/// Tests pair with Skip.IfNot(...) so +/// the runner reports them as Skipped (not Passed) when MSSQL is unreachable. The +/// fixture applies the migrations once at construction time. +/// +public class AddNotificationOriginParentExecutionIdMigrationTests : IClassFixture +{ + private readonly MsSqlMigrationFixture _fixture; + + public AddNotificationOriginParentExecutionIdMigrationTests(MsSqlMigrationFixture fixture) + { + _fixture = fixture; + } + + [SkippableFact] + public async Task AppliesMigration_AddsOriginParentExecutionIdColumn_ToNotifications() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + var present = await ScalarAsync( + "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS " + + "WHERE TABLE_NAME = 'Notifications' AND COLUMN_NAME = 'OriginParentExecutionId' " + + "AND TABLE_SCHEMA = 'dbo';"); + Assert.Equal(1, present); + } + + [SkippableFact] + public async Task OriginParentExecutionIdColumn_IsNullableUniqueIdentifier() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + var dataType = await ScalarAsync( + "SELECT DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS " + + "WHERE TABLE_NAME = 'Notifications' AND COLUMN_NAME = 'OriginParentExecutionId';"); + Assert.Equal("uniqueidentifier", dataType); + + var isNullable = await ScalarAsync( + "SELECT IS_NULLABLE FROM INFORMATION_SCHEMA.COLUMNS " + + "WHERE TABLE_NAME = 'Notifications' AND COLUMN_NAME = 'OriginParentExecutionId';"); + Assert.Equal("YES", isNullable); + } + + // --- helpers ------------------------------------------------------------ + + private async Task ScalarAsync(string sql) + { + await using var conn = _fixture.OpenConnection(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = sql; + var result = await cmd.ExecuteScalarAsync(); + if (result is null || result is DBNull) + { + return default!; + } + return (T)Convert.ChangeType(result, typeof(T) == typeof(string) ? typeof(string) : Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T))!; + } +} diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/RepositoryCoverageTests.cs b/tests/ScadaLink.ConfigurationDatabase.Tests/RepositoryCoverageTests.cs index 20fdd50..573b959 100644 --- a/tests/ScadaLink.ConfigurationDatabase.Tests/RepositoryCoverageTests.cs +++ b/tests/ScadaLink.ConfigurationDatabase.Tests/RepositoryCoverageTests.cs @@ -269,6 +269,7 @@ public class NotificationOutboxConfigurationTests : IDisposable var nextAttemptAt = new DateTimeOffset(2026, 5, 19, 8, 2, 0, TimeSpan.Zero); var deliveredAt = new DateTimeOffset(2026, 5, 19, 8, 3, 0, TimeSpan.Zero); var originExecutionId = Guid.NewGuid(); + var originParentExecutionId = Guid.NewGuid(); var notification = new Notification(id, NotificationType.Email, "Ops List", "High Tank Level", "Tank 4 exceeded the high level threshold.", "site-north") @@ -281,6 +282,7 @@ public class NotificationOutboxConfigurationTests : IDisposable SourceInstanceId = "instance-42", SourceScript = "TankLevelAlarm", OriginExecutionId = originExecutionId, + OriginParentExecutionId = originParentExecutionId, SiteEnqueuedAt = siteEnqueuedAt, CreatedAt = createdAt, LastAttemptAt = lastAttemptAt, @@ -314,6 +316,7 @@ public class NotificationOutboxConfigurationTests : IDisposable Assert.Equal(nextAttemptAt, loaded.NextAttemptAt); Assert.Equal(deliveredAt, loaded.DeliveredAt); Assert.Equal(originExecutionId, loaded.OriginExecutionId); + Assert.Equal(originParentExecutionId, loaded.OriginParentExecutionId); } [Fact] @@ -336,6 +339,26 @@ public class NotificationOutboxConfigurationTests : IDisposable Assert.Null(loaded!.OriginExecutionId); } + [Fact] + public async Task Notification_NullOriginParentExecutionId_RoundTripsAsNull() + { + // Audit Log ParentExecutionId: OriginParentExecutionId is an additive + // nullable column — notifications from non-routed runs (or submitted + // before the column existed) persist and reload it as null. + var id = Guid.NewGuid().ToString(); + var notification = new Notification(id, NotificationType.Email, "Ops List", + "Subject", "Body", "site-north"); + + _context.Notifications.Add(notification); + await _context.SaveChangesAsync(); + _context.ChangeTracker.Clear(); + + var loaded = await _context.Notifications.FindAsync(id); + + Assert.NotNull(loaded); + Assert.Null(loaded!.OriginParentExecutionId); + } + [Fact] public async Task Notification_StatusPersistsAsString() { diff --git a/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorAttemptEmissionTests.cs b/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorAttemptEmissionTests.cs index f594d88..27ebd18 100644 --- a/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorAttemptEmissionTests.cs +++ b/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorAttemptEmissionTests.cs @@ -95,7 +95,8 @@ public class NotificationOutboxActorAttemptEmissionTests : TestKit Guid? notificationId = null, string sourceSite = "site-1", int retryCount = 0, - Guid? originExecutionId = null) + Guid? originExecutionId = null, + Guid? originParentExecutionId = null) { return new Notification( (notificationId ?? Guid.NewGuid()).ToString("D"), @@ -110,6 +111,7 @@ public class NotificationOutboxActorAttemptEmissionTests : TestKit SourceInstanceId = "instance-42", SourceScript = "AlarmScript", OriginExecutionId = originExecutionId, + OriginParentExecutionId = originParentExecutionId, }; } @@ -207,6 +209,50 @@ public class NotificationOutboxActorAttemptEmissionTests : TestKit }); } + [Fact] + public void Attempt_CarriesOriginParentExecutionId_AsParentExecutionId() + { + // Audit Log ParentExecutionId: the Attempted NotifyDeliver row must echo + // the notification's OriginParentExecutionId so the central dispatcher's + // rows carry the routed run's parent id. + SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1)); + var parentExecutionId = Guid.NewGuid(); + var notification = MakeNotification(originParentExecutionId: parentExecutionId); + _outboxRepository.GetDueAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new[] { notification }); + var adapter = new StubAdapter(() => DeliveryOutcome.Success("ops@example.com")); + var actor = CreateActor([adapter]); + + actor.Tell(InternalMessages.DispatchTick.Instance); + + AwaitAssert(() => + { + var attempted = EventsByStatus(AuditStatus.Attempted); + Assert.Single(attempted); + Assert.Equal(parentExecutionId, attempted[0].ParentExecutionId); + }); + } + + [Fact] + public void Attempt_NullOriginParentExecutionId_HasNullParentExecutionId() + { + SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1)); + var notification = MakeNotification(originParentExecutionId: null); + _outboxRepository.GetDueAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new[] { notification }); + var adapter = new StubAdapter(() => DeliveryOutcome.Success("ops@example.com")); + var actor = CreateActor([adapter]); + + actor.Tell(InternalMessages.DispatchTick.Instance); + + AwaitAssert(() => + { + var attempted = EventsByStatus(AuditStatus.Attempted); + Assert.Single(attempted); + Assert.Null(attempted[0].ParentExecutionId); + }); + } + [Fact] public void Attempt_TransientFailure_EmitsEvent_StatusAttempted_ErrorMessageSet() { diff --git a/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorIngestTests.cs b/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorIngestTests.cs index 1b8cbab..37812d9 100644 --- a/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorIngestTests.cs +++ b/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorIngestTests.cs @@ -40,7 +40,9 @@ public class NotificationOutboxActorIngestTests : TestKit } private static NotificationSubmit MakeSubmit( - string? notificationId = null, Guid? originExecutionId = null) + string? notificationId = null, + Guid? originExecutionId = null, + Guid? originParentExecutionId = null) { return new NotificationSubmit( NotificationId: notificationId ?? Guid.NewGuid().ToString(), @@ -51,7 +53,8 @@ public class NotificationOutboxActorIngestTests : TestKit SourceInstanceId: "instance-42", SourceScript: "AlarmScript", SiteEnqueuedAt: new DateTimeOffset(2026, 5, 19, 8, 30, 0, TimeSpan.Zero), - OriginExecutionId: originExecutionId); + OriginExecutionId: originExecutionId, + OriginParentExecutionId: originParentExecutionId); } [Fact] @@ -121,6 +124,42 @@ public class NotificationOutboxActorIngestTests : TestKit Arg.Any()); } + [Fact] + public void NotificationSubmit_CopiesOriginParentExecutionId_OntoPersistedNotification() + { + // Audit Log ParentExecutionId: the routed run's parent ExecutionId rides + // on the NotificationSubmit and must be persisted on the Notification row + // so the dispatcher can later echo it onto NotifyDeliver audit rows. + _repository.InsertIfNotExistsAsync(Arg.Any(), Arg.Any()) + .Returns(true); + var parentExecutionId = Guid.NewGuid(); + var submit = MakeSubmit(originParentExecutionId: parentExecutionId); + var actor = CreateActor(); + + actor.Tell(submit, TestActor); + + ExpectMsg(); + _repository.Received(1).InsertIfNotExistsAsync( + Arg.Is(n => n.OriginParentExecutionId == parentExecutionId), + Arg.Any()); + } + + [Fact] + public void NotificationSubmit_NullOriginParentExecutionId_PersistsNull() + { + _repository.InsertIfNotExistsAsync(Arg.Any(), Arg.Any()) + .Returns(true); + var submit = MakeSubmit(originParentExecutionId: null); + var actor = CreateActor(); + + actor.Tell(submit, TestActor); + + ExpectMsg(); + _repository.Received(1).InsertIfNotExistsAsync( + Arg.Is(n => n.OriginParentExecutionId == null), + Arg.Any()); + } + [Fact] public void DuplicateSubmit_RepositoryReturnsFalse_StillAcksAccepted() { diff --git a/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorTerminalEmissionTests.cs b/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorTerminalEmissionTests.cs index 08c8cd1..024b803 100644 --- a/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorTerminalEmissionTests.cs +++ b/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorTerminalEmissionTests.cs @@ -88,7 +88,8 @@ public class NotificationOutboxActorTerminalEmissionTests : TestKit NotificationStatus status = NotificationStatus.Pending, int retryCount = 0, Guid? notificationId = null, - Guid? originExecutionId = null) + Guid? originExecutionId = null, + Guid? originParentExecutionId = null) { return new Notification( (notificationId ?? Guid.NewGuid()).ToString("D"), @@ -102,6 +103,7 @@ public class NotificationOutboxActorTerminalEmissionTests : TestKit RetryCount = retryCount, CreatedAt = DateTimeOffset.UtcNow, OriginExecutionId = originExecutionId, + OriginParentExecutionId = originParentExecutionId, }; } @@ -191,6 +193,50 @@ public class NotificationOutboxActorTerminalEmissionTests : TestKit }); } + [Fact] + public void Terminal_Delivered_CarriesOriginParentExecutionId_AsParentExecutionId() + { + // Audit Log ParentExecutionId: the terminal NotifyDeliver row must echo + // the notification's OriginParentExecutionId so the central dispatcher's + // rows carry the routed run's parent id. + SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1)); + var parentExecutionId = Guid.NewGuid(); + var notification = MakeNotification(originParentExecutionId: parentExecutionId); + _outboxRepository.GetDueAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new[] { notification }); + var adapter = new StubAdapter(() => DeliveryOutcome.Success("ops@example.com")); + var actor = CreateActor([adapter]); + + actor.Tell(InternalMessages.DispatchTick.Instance); + + AwaitAssert(() => + { + var delivered = EventsByStatus(AuditStatus.Delivered); + Assert.Single(delivered); + Assert.Equal(parentExecutionId, delivered[0].ParentExecutionId); + }); + } + + [Fact] + public void Terminal_Delivered_NullOriginParentExecutionId_HasNullParentExecutionId() + { + SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1)); + var notification = MakeNotification(originParentExecutionId: null); + _outboxRepository.GetDueAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new[] { notification }); + var adapter = new StubAdapter(() => DeliveryOutcome.Success("ops@example.com")); + var actor = CreateActor([adapter]); + + actor.Tell(InternalMessages.DispatchTick.Instance); + + AwaitAssert(() => + { + var delivered = EventsByStatus(AuditStatus.Delivered); + Assert.Single(delivered); + Assert.Null(delivered[0].ParentExecutionId); + }); + } + [Fact] public void Terminal_Parked_OnPermanentFailure_EmitsEvent_StatusParked() { diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/NotifyHelperTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/NotifyHelperTests.cs index bfb66f6..d4f5eca 100644 --- a/tests/ScadaLink.SiteRuntime.Tests/Scripts/NotifyHelperTests.cs +++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/NotifyHelperTests.cs @@ -61,7 +61,8 @@ public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable private ScriptRuntimeContext.NotifyHelper CreateHelper( IActorRef siteCommunicationActor, string? sourceScript = null, - Guid? executionId = null) + Guid? executionId = null, + Guid? parentExecutionId = null) { return new ScriptRuntimeContext.NotifyHelper( _saf, @@ -71,7 +72,9 @@ public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable sourceScript, TimeSpan.FromSeconds(3), NullLogger.Instance, - executionId ?? Guid.NewGuid()); + executionId ?? Guid.NewGuid(), + auditWriter: null, + parentExecutionId: parentExecutionId); } [Fact] @@ -156,6 +159,44 @@ public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable Assert.Equal(executionId, payload!.OriginExecutionId); } + [Fact] + public async Task Send_StampsParentExecutionId_OnTheNotificationSubmitPayload() + { + // Audit Log ParentExecutionId (Task 7): for an inbound-API-routed run, + // Notify.Send must stamp the routed run's parent ExecutionId onto the + // NotificationSubmit so it rides inside the serialized S&F payload to + // central, where the dispatcher echoes it onto the NotifyDeliver rows. + // This is the SAME parent id stamped onto the site-emitted NotifySend row. + var parentExecutionId = Guid.NewGuid(); + var commProbe = CreateTestProbe(); + var notify = CreateHelper(commProbe.Ref, parentExecutionId: parentExecutionId); + + var notificationId = await notify.To("Operators").Send("Pump alarm", "Pump 3 tripped"); + + var buffered = await _saf.GetMessageByIdAsync(notificationId); + Assert.NotNull(buffered); + var payload = JsonSerializer.Deserialize(buffered!.PayloadJson); + Assert.NotNull(payload); + Assert.Equal(parentExecutionId, payload!.OriginParentExecutionId); + } + + [Fact] + public async Task Send_NonRoutedRun_LeavesOriginParentExecutionIdNull() + { + // Non-routed runs have no parent execution — OriginParentExecutionId + // stays null on the NotificationSubmit payload. + var commProbe = CreateTestProbe(); + var notify = CreateHelper(commProbe.Ref, parentExecutionId: null); + + var notificationId = await notify.To("Operators").Send("Pump alarm", "Pump 3 tripped"); + + var buffered = await _saf.GetMessageByIdAsync(notificationId); + Assert.NotNull(buffered); + var payload = JsonSerializer.Deserialize(buffered!.PayloadJson); + Assert.NotNull(payload); + Assert.Null(payload!.OriginParentExecutionId); + } + [Fact] public async Task Send_WhenHelperHasNoSourceScript_LeavesSourceScriptNull() {