diff --git a/src/ScadaLink.Commons/Entities/Notifications/Notification.cs b/src/ScadaLink.Commons/Entities/Notifications/Notification.cs index ab94f56..916ea02 100644 --- a/src/ScadaLink.Commons/Entities/Notifications/Notification.cs +++ b/src/ScadaLink.Commons/Entities/Notifications/Notification.cs @@ -27,6 +27,15 @@ public class Notification public string SourceSiteId { get; set; } public string? SourceInstanceId { get; set; } public string? SourceScript { get; set; } + + /// + /// The originating script execution's ExecutionId (Audit Log #23). Carried from + /// the site on the so the + /// central dispatcher can stamp the same id onto its NotifyDeliver audit rows, + /// correlating them with the site-emitted NotifySend row. Null for notifications + /// submitted before the column existed, or raised outside a script-execution context. + /// + public Guid? OriginExecutionId { 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 718e1b6..ecfb1d6 100644 --- a/src/ScadaLink.Commons/Messages/Notification/NotificationMessages.cs +++ b/src/ScadaLink.Commons/Messages/Notification/NotificationMessages.cs @@ -4,6 +4,13 @@ namespace ScadaLink.Commons.Messages.Notification; /// Site -> Central: submit a notification for central delivery. /// Fire-and-forget with ack; the site retries until a is received. /// +/// +/// The originating script execution's ExecutionId (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 notifications raised outside a script execution. +/// public record NotificationSubmit( string NotificationId, string ListName, @@ -12,7 +19,8 @@ public record NotificationSubmit( string SourceSiteId, string? SourceInstanceId, string? SourceScript, - DateTimeOffset SiteEnqueuedAt); + DateTimeOffset SiteEnqueuedAt, + Guid? OriginExecutionId = 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 4009c56..a30859b 100644 --- a/src/ScadaLink.ConfigurationDatabase/Configurations/NotificationOutboxConfiguration.cs +++ b/src/ScadaLink.ConfigurationDatabase/Configurations/NotificationOutboxConfiguration.cs @@ -47,6 +47,10 @@ public class NotificationOutboxConfiguration : IEntityTypeConfiguration n.SourceScript).HasMaxLength(200); + // OriginExecutionId (Audit Log #23): nullable uniqueidentifier carried from the + // site so the dispatcher can echo it onto NotifyDeliver audit rows. No index — + // it is never a query predicate on this table, only copied onto audit events. + builder.HasIndex(n => new { n.Status, n.NextAttemptAt }); builder.HasIndex(n => new { n.SourceSiteId, n.CreatedAt }); diff --git a/src/ScadaLink.ConfigurationDatabase/Migrations/20260521193048_AddNotificationOriginExecutionId.Designer.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/20260521193048_AddNotificationOriginExecutionId.Designer.cs new file mode 100644 index 0000000..618ca79 --- /dev/null +++ b/src/ScadaLink.ConfigurationDatabase/Migrations/20260521193048_AddNotificationOriginExecutionId.Designer.cs @@ -0,0 +1,1629 @@ +// +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("20260521193048_AddNotificationOriginExecutionId")] + partial class AddNotificationOriginExecutionId + { + /// + 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("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("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("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/20260521193048_AddNotificationOriginExecutionId.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/20260521193048_AddNotificationOriginExecutionId.cs new file mode 100644 index 0000000..5174a57 --- /dev/null +++ b/src/ScadaLink.ConfigurationDatabase/Migrations/20260521193048_AddNotificationOriginExecutionId.cs @@ -0,0 +1,41 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ScadaLink.ConfigurationDatabase.Migrations +{ + /// + /// Adds the OriginExecutionId correlation column to the central + /// Notifications table (#21). It carries the originating script execution's + /// ExecutionId from the site so the dispatcher can echo it onto the + /// NotifyDeliver audit rows (#23), linking them to the site's NotifySend + /// row for the same run. + /// + /// The change is purely additive: OriginExecutionId 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 AddNotificationOriginExecutionId : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "OriginExecutionId", + table: "Notifications", + type: "uniqueidentifier", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "OriginExecutionId", + table: "Notifications"); + } + } +} diff --git a/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs index f69c4a4..ba22cb1 100644 --- a/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs +++ b/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs @@ -787,6 +787,9 @@ namespace ScadaLink.ConfigurationDatabase.Migrations b.Property("NextAttemptAt") .HasColumnType("datetimeoffset"); + b.Property("OriginExecutionId") + .HasColumnType("uniqueidentifier"); + b.Property("ResolvedTargets") .HasColumnType("nvarchar(max)"); diff --git a/src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs b/src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs index fa61574..2c2af88 100644 --- a/src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs +++ b/src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs @@ -489,6 +489,10 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers /// parses the notification's id as a Guid; sites generate the id with /// Guid.NewGuid().ToString("N") so the parse always succeeds, but /// a non-Guid id is recorded as null rather than crashing the dispatcher. + /// 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). /// private static AuditEvent BuildNotifyDeliverEvent( Notification notification, @@ -515,6 +519,12 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers SourceSiteId = notification.SourceSiteId, SourceInstanceId = notification.SourceInstanceId, SourceScript = notification.SourceScript, + // ExecutionId (Audit Log #23): the originating script execution's id, + // carried from the site on NotificationSubmit and persisted on the + // Notification row. Echoing it here links the central NotifyDeliver + // rows to the site-emitted NotifySend row for the same run. Null when + // the notification was raised outside a script execution. + ExecutionId = notification.OriginExecutionId, Target = notification.ListName, Status = status, ErrorMessage = errorMessage, @@ -941,6 +951,9 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers { SourceInstanceId = msg.SourceInstanceId, SourceScript = msg.SourceScript, + // 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, 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 1d085f3..2b5df67 100644 --- a/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs +++ b/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs @@ -1407,7 +1407,12 @@ public class ScriptRuntimeContext // notification, threaded down from the script-execution context for the // central audit trail. Null when no single script owns the context. SourceScript: _sourceScript, - SiteEnqueuedAt: DateTimeOffset.UtcNow); + SiteEnqueuedAt: DateTimeOffset.UtcNow, + // OriginExecutionId (Audit Log #23): the SAME per-execution id stamped + // 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); var payloadJson = JsonSerializer.Serialize(payload); diff --git a/tests/ScadaLink.Commons.Tests/Entities/NotificationEntityTests.cs b/tests/ScadaLink.Commons.Tests/Entities/NotificationEntityTests.cs index a0f593f..d3679da 100644 --- a/tests/ScadaLink.Commons.Tests/Entities/NotificationEntityTests.cs +++ b/tests/ScadaLink.Commons.Tests/Entities/NotificationEntityTests.cs @@ -21,6 +21,21 @@ public class NotificationEntityTests Assert.Equal("SiteA", n.SourceSiteId); } + [Fact] + public void OriginExecutionId_DefaultsToNull_AndIsSettable() + { + // Audit Log #23: OriginExecutionId carries the originating script + // execution's id from the site so the dispatcher can echo it onto + // NotifyDeliver rows. Null for notifications submitted before the + // column existed; settable from the NotificationSubmit message. + var n = new Notification("id-1", NotificationType.Email, "ops-team", "subj", "body", "SiteA"); + Assert.Null(n.OriginExecutionId); + + var executionId = Guid.NewGuid(); + n.OriginExecutionId = executionId; + Assert.Equal(executionId, n.OriginExecutionId); + } + [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 f68996f..53b5a15 100644 --- a/tests/ScadaLink.Commons.Tests/Messages/NotificationMessagesTests.cs +++ b/tests/ScadaLink.Commons.Tests/Messages/NotificationMessagesTests.cs @@ -40,6 +40,47 @@ public class NotificationMessagesTests Assert.Null(msg.SourceScript); } + [Fact] + public void NotificationSubmit_OriginExecutionId_DefaultsToNull() + { + // Audit Log #23: OriginExecutionId is an additive trailing member — a + // submit built without it (old call sites / old serialized payloads) + // leaves the id null. + var msg = new NotificationSubmit( + "notif-3", "Operators", "Subject", "Body", + "site-01", "inst-1", "OnAlarm", DateTimeOffset.UtcNow); + + Assert.Null(msg.OriginExecutionId); + } + + [Fact] + public void NotificationSubmit_OriginExecutionId_RoundTripsWhenSupplied() + { + var executionId = Guid.NewGuid(); + var msg = new NotificationSubmit( + "notif-4", "Operators", "Subject", "Body", + "site-01", "inst-1", "OnAlarm", DateTimeOffset.UtcNow, executionId); + + Assert.Equal(executionId, msg.OriginExecutionId); + } + + [Fact] + public void NotificationSubmit_OriginExecutionId_SurvivesJsonRoundTrip() + { + // The buffered S&F payload IS a serialized NotificationSubmit; the + // forwarder deserializes it, so OriginExecutionId must survive JSON. + var executionId = Guid.NewGuid(); + var msg = new NotificationSubmit( + "notif-5", "Operators", "Subject", "Body", + "site-01", "inst-1", "OnAlarm", DateTimeOffset.UtcNow, executionId); + + var json = System.Text.Json.JsonSerializer.Serialize(msg); + var roundTripped = System.Text.Json.JsonSerializer.Deserialize(json); + + Assert.NotNull(roundTripped); + Assert.Equal(executionId, roundTripped!.OriginExecutionId); + } + [Fact] public void NotificationSubmit_ValueEquality_EqualWhenAllFieldsMatch() { diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/Migrations/AddNotificationOriginExecutionIdMigrationTests.cs b/tests/ScadaLink.ConfigurationDatabase.Tests/Migrations/AddNotificationOriginExecutionIdMigrationTests.cs new file mode 100644 index 0000000..d7ce92b --- /dev/null +++ b/tests/ScadaLink.ConfigurationDatabase.Tests/Migrations/AddNotificationOriginExecutionIdMigrationTests.cs @@ -0,0 +1,70 @@ +using Xunit; + +namespace ScadaLink.ConfigurationDatabase.Tests.Migrations; + +/// +/// Audit Log #23 (ExecutionId Task 5) integration test for the +/// AddNotificationOriginExecutionId 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 +/// OriginExecutionId 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 AddNotificationOriginExecutionIdMigrationTests : IClassFixture +{ + private readonly MsSqlMigrationFixture _fixture; + + public AddNotificationOriginExecutionIdMigrationTests(MsSqlMigrationFixture fixture) + { + _fixture = fixture; + } + + [SkippableFact] + public async Task AppliesMigration_AddsOriginExecutionIdColumn_ToNotifications() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + var present = await ScalarAsync( + "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS " + + "WHERE TABLE_NAME = 'Notifications' AND COLUMN_NAME = 'OriginExecutionId' " + + "AND TABLE_SCHEMA = 'dbo';"); + Assert.Equal(1, present); + } + + [SkippableFact] + public async Task OriginExecutionIdColumn_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 = 'OriginExecutionId';"); + Assert.Equal("uniqueidentifier", dataType); + + var isNullable = await ScalarAsync( + "SELECT IS_NULLABLE FROM INFORMATION_SCHEMA.COLUMNS " + + "WHERE TABLE_NAME = 'Notifications' AND COLUMN_NAME = 'OriginExecutionId';"); + 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 6f0d11b..20fdd50 100644 --- a/tests/ScadaLink.ConfigurationDatabase.Tests/RepositoryCoverageTests.cs +++ b/tests/ScadaLink.ConfigurationDatabase.Tests/RepositoryCoverageTests.cs @@ -268,6 +268,7 @@ public class NotificationOutboxConfigurationTests : IDisposable var lastAttemptAt = new DateTimeOffset(2026, 5, 19, 8, 1, 0, TimeSpan.Zero); 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 notification = new Notification(id, NotificationType.Email, "Ops List", "High Tank Level", "Tank 4 exceeded the high level threshold.", "site-north") @@ -279,6 +280,7 @@ public class NotificationOutboxConfigurationTests : IDisposable ResolvedTargets = "ops@example.test;duty@example.test", SourceInstanceId = "instance-42", SourceScript = "TankLevelAlarm", + OriginExecutionId = originExecutionId, SiteEnqueuedAt = siteEnqueuedAt, CreatedAt = createdAt, LastAttemptAt = lastAttemptAt, @@ -311,6 +313,27 @@ public class NotificationOutboxConfigurationTests : IDisposable Assert.Equal(lastAttemptAt, loaded.LastAttemptAt); Assert.Equal(nextAttemptAt, loaded.NextAttemptAt); Assert.Equal(deliveredAt, loaded.DeliveredAt); + Assert.Equal(originExecutionId, loaded.OriginExecutionId); + } + + [Fact] + public async Task Notification_NullOriginExecutionId_RoundTripsAsNull() + { + // Audit Log #23: OriginExecutionId is an additive nullable column — + // notifications raised outside a script execution (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!.OriginExecutionId); } [Fact] diff --git a/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorAttemptEmissionTests.cs b/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorAttemptEmissionTests.cs index 8780d59..f594d88 100644 --- a/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorAttemptEmissionTests.cs +++ b/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorAttemptEmissionTests.cs @@ -94,7 +94,8 @@ public class NotificationOutboxActorAttemptEmissionTests : TestKit private static Notification MakeNotification( Guid? notificationId = null, string sourceSite = "site-1", - int retryCount = 0) + int retryCount = 0, + Guid? originExecutionId = null) { return new Notification( (notificationId ?? Guid.NewGuid()).ToString("D"), @@ -108,6 +109,7 @@ public class NotificationOutboxActorAttemptEmissionTests : TestKit CreatedAt = DateTimeOffset.UtcNow, SourceInstanceId = "instance-42", SourceScript = "AlarmScript", + OriginExecutionId = originExecutionId, }; } @@ -162,6 +164,49 @@ public class NotificationOutboxActorAttemptEmissionTests : TestKit }); } + [Fact] + public void Attempt_CarriesOriginExecutionId_AsExecutionId() + { + // Audit Log #23: the Attempted NotifyDeliver row must echo the + // notification's OriginExecutionId so all rows for one run share an id. + SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1)); + var executionId = Guid.NewGuid(); + var notification = MakeNotification(originExecutionId: executionId); + _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(executionId, attempted[0].ExecutionId); + }); + } + + [Fact] + public void Attempt_NullOriginExecutionId_HasNullExecutionId() + { + SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1)); + var notification = MakeNotification(originExecutionId: 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].ExecutionId); + }); + } + [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 123711d..1b8cbab 100644 --- a/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorIngestTests.cs +++ b/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorIngestTests.cs @@ -39,7 +39,8 @@ public class NotificationOutboxActorIngestTests : TestKit NullLogger.Instance))); } - private static NotificationSubmit MakeSubmit(string? notificationId = null) + private static NotificationSubmit MakeSubmit( + string? notificationId = null, Guid? originExecutionId = null) { return new NotificationSubmit( NotificationId: notificationId ?? Guid.NewGuid().ToString(), @@ -49,7 +50,8 @@ public class NotificationOutboxActorIngestTests : TestKit SourceSiteId: "site-1", SourceInstanceId: "instance-42", SourceScript: "AlarmScript", - SiteEnqueuedAt: new DateTimeOffset(2026, 5, 19, 8, 30, 0, TimeSpan.Zero)); + SiteEnqueuedAt: new DateTimeOffset(2026, 5, 19, 8, 30, 0, TimeSpan.Zero), + OriginExecutionId: originExecutionId); } [Fact] @@ -83,6 +85,42 @@ public class NotificationOutboxActorIngestTests : TestKit Arg.Any()); } + [Fact] + public void NotificationSubmit_CopiesOriginExecutionId_OntoPersistedNotification() + { + // Audit Log #23: the originating script execution's id 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 executionId = Guid.NewGuid(); + var submit = MakeSubmit(originExecutionId: executionId); + var actor = CreateActor(); + + actor.Tell(submit, TestActor); + + ExpectMsg(); + _repository.Received(1).InsertIfNotExistsAsync( + Arg.Is(n => n.OriginExecutionId == executionId), + Arg.Any()); + } + + [Fact] + public void NotificationSubmit_NullOriginExecutionId_PersistsNull() + { + _repository.InsertIfNotExistsAsync(Arg.Any(), Arg.Any()) + .Returns(true); + var submit = MakeSubmit(originExecutionId: null); + var actor = CreateActor(); + + actor.Tell(submit, TestActor); + + ExpectMsg(); + _repository.Received(1).InsertIfNotExistsAsync( + Arg.Is(n => n.OriginExecutionId == 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 eab39ac..74e0df8 100644 --- a/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorTerminalEmissionTests.cs +++ b/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorTerminalEmissionTests.cs @@ -87,7 +87,8 @@ public class NotificationOutboxActorTerminalEmissionTests : TestKit private static Notification MakeNotification( NotificationStatus status = NotificationStatus.Pending, int retryCount = 0, - Guid? notificationId = null) + Guid? notificationId = null, + Guid? originExecutionId = null) { return new Notification( (notificationId ?? Guid.NewGuid()).ToString("D"), @@ -100,6 +101,7 @@ public class NotificationOutboxActorTerminalEmissionTests : TestKit Status = status, RetryCount = retryCount, CreatedAt = DateTimeOffset.UtcNow, + OriginExecutionId = originExecutionId, }; } @@ -145,6 +147,50 @@ public class NotificationOutboxActorTerminalEmissionTests : TestKit }); } + [Fact] + public void Terminal_Delivered_CarriesOriginExecutionId_AsExecutionId() + { + // Audit Log #23: the terminal NotifyDeliver row must echo the + // notification's OriginExecutionId so it shares the per-run id with + // the site-emitted NotifySend row. + SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1)); + var executionId = Guid.NewGuid(); + var notification = MakeNotification(originExecutionId: executionId); + _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(executionId, delivered[0].ExecutionId); + }); + } + + [Fact] + public void Terminal_Delivered_NullOriginExecutionId_HasNullExecutionId() + { + SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1)); + var notification = MakeNotification(originExecutionId: 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].ExecutionId); + }); + } + [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 adfec68..bfb66f6 100644 --- a/tests/ScadaLink.SiteRuntime.Tests/Scripts/NotifyHelperTests.cs +++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/NotifyHelperTests.cs @@ -60,7 +60,8 @@ public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable private ScriptRuntimeContext.NotifyHelper CreateHelper( IActorRef siteCommunicationActor, - string? sourceScript = null) + string? sourceScript = null, + Guid? executionId = null) { return new ScriptRuntimeContext.NotifyHelper( _saf, @@ -70,7 +71,7 @@ public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable sourceScript, TimeSpan.FromSeconds(3), NullLogger.Instance, - Guid.NewGuid()); + executionId ?? Guid.NewGuid()); } [Fact] @@ -134,6 +135,27 @@ public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable Assert.Equal("ScriptActor:MonitorSpeed", payload!.SourceScript); } + [Fact] + public async Task Send_StampsExecutionId_OnTheNotificationSubmitPayload() + { + // Audit Log #23 (ExecutionId Task 5): Notify.Send must stamp the + // script run's 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 id stamped + // onto the site-emitted NotifySend audit row. + var executionId = Guid.NewGuid(); + var commProbe = CreateTestProbe(); + var notify = CreateHelper(commProbe.Ref, executionId: executionId); + + 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(executionId, payload!.OriginExecutionId); + } + [Fact] public async Task Send_WhenHelperHasNoSourceScript_LeavesSourceScriptNull() {