refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)

Solution + 23 src projects + 26 test projects renamed; folders, csproj,
namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated.
ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated.
SQL roles/logins, LDAP domains, CLI command name, and CLI config dir
(~/.scadalink → ~/.scadabridge) also renamed.

Build green; 5 Host.Tests fail awaiting SQL login rename in next commit.
Pre-existing StaleTagMonitor timing flakes unchanged.

Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
Joseph Doherty
2026-05-28 09:37:45 -04:00
parent 6d87ee3c3b
commit 7b0b9c7365
1531 changed files with 11180 additions and 11054 deletions
@@ -0,0 +1,137 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
/// <summary>
/// Single source of truth for AuditLog (#23) rows. Central rows leave ForwardState null;
/// site rows leave IngestedAtUtc null until ingest. Append-only.
/// </summary>
/// <remarks>
/// All <c>*Utc</c>-suffixed <see cref="DateTime"/> properties on this record are
/// invariantly UTC ("All timestamps are UTC throughout the system." — CLAUDE.md).
/// Their init-setters call <see cref="DateTime.SpecifyKind(DateTime, DateTimeKind)"/>
/// to force <see cref="DateTimeKind.Utc"/> on assignment, so a value built from a
/// <c>DateTime</c> literal or re-hydrated from a SQL Server <c>datetime2</c> column
/// (which strips the <c>Kind</c> flag on the wire) cannot leak downstream as
/// <see cref="DateTimeKind.Unspecified"/> or be silently re-interpreted as local
/// time. The unrelated <see cref="ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications"/>
/// surface uses <see cref="DateTimeOffset"/> for the same UTC guarantee; this
/// entity stays on <see cref="DateTime"/> to match the partitioned SQL Server
/// <c>datetime2</c> column shape required by the AuditLog table.
/// </remarks>
public sealed record AuditEvent
{
/// <summary>Idempotency key; uniquely identifies one audit lifecycle event.</summary>
public Guid EventId { get; init; }
/// <summary>
/// UTC timestamp when the audited action occurred at its source. The value
/// MUST be in UTC ("All timestamps are UTC throughout the system." — CLAUDE.md).
/// The init-setter forces <see cref="DateTimeKind.Utc"/> on assignment via
/// <see cref="DateTime.SpecifyKind(DateTime, DateTimeKind)"/>, so any
/// construction path that supplies a value with <see cref="DateTimeKind.Unspecified"/>
/// (e.g. a <c>DateTime</c> literal, JSON deserialisation, or a SQL Server
/// <c>datetime2</c> read where the value bypassed the EF converter) is
/// re-tagged as UTC rather than treated as local time downstream. Producers
/// are still expected to supply values that ARE genuinely UTC — the setter
/// only fixes the <c>Kind</c> flag, it cannot re-interpret a local-time value.
/// </summary>
public DateTime OccurredAtUtc
{
get => _occurredAtUtc;
init => _occurredAtUtc = DateTime.SpecifyKind(value, DateTimeKind.Utc);
}
private readonly DateTime _occurredAtUtc;
/// <summary>
/// UTC timestamp when the row was ingested at central; null on the site hot-path.
/// The value MUST be in UTC when non-null; the init-setter forces
/// <see cref="DateTimeKind.Utc"/> on assignment, matching
/// <see cref="OccurredAtUtc"/>'s contract.
/// </summary>
public DateTime? IngestedAtUtc
{
get => _ingestedAtUtc;
init => _ingestedAtUtc = value.HasValue
? DateTime.SpecifyKind(value.Value, DateTimeKind.Utc)
: null;
}
private readonly DateTime? _ingestedAtUtc;
/// <summary>Trust-boundary channel the audited action crossed.</summary>
public AuditChannel Channel { get; init; }
/// <summary>Specific event kind within the channel (see alog.md §4).</summary>
public AuditKind Kind { get; init; }
/// <summary>Correlation id linking related audit rows (e.g. the cached-op lifecycle).</summary>
public Guid? CorrelationId { get; init; }
/// <summary>
/// Id of the originating script execution / inbound request — the universal
/// per-run correlation value, distinct from <see cref="CorrelationId"/> (which
/// is the per-operation lifecycle id).
/// </summary>
public Guid? ExecutionId { get; init; }
/// <summary>
/// <see cref="ExecutionId"/> of the execution that spawned this run, when this
/// run was spawned by another; null for top-level runs. Lets a spawned
/// execution point back at its spawner for cross-run correlation.
/// </summary>
public Guid? ParentExecutionId { get; init; }
/// <summary>Site id where the action originated; null for central-direct events.</summary>
public string? SourceSiteId { get; init; }
/// <summary>
/// The cluster node on which the event was emitted — `node-a` / `node-b` for
/// site rows (qualified by <see cref="SourceSiteId"/>), `central-a` / `central-b`
/// for central-originated rows. Stamped by the writing node from
/// <c>INodeIdentityProvider</c>; nullable so reconciled rows from a node that
/// has since been retired don't block ingest.
/// </summary>
public string? SourceNode { get; init; }
/// <summary>Instance id where the action originated, when applicable.</summary>
public string? SourceInstanceId { get; init; }
/// <summary>Script that initiated the action (script trust boundary), when applicable.</summary>
public string? SourceScript { get; init; }
/// <summary>Authenticated actor for inbound paths (API key name, user, etc.).</summary>
public string? Actor { get; init; }
/// <summary>Target of the action: external system name, db connection name, list name, or inbound method.</summary>
public string? Target { get; init; }
/// <summary>Lifecycle status of this row.</summary>
public AuditStatus Status { get; init; }
/// <summary>HTTP status code where applicable (outbound API + inbound API).</summary>
public int? HttpStatus { get; init; }
/// <summary>Duration of the audited action in milliseconds, when measurable.</summary>
public int? DurationMs { get; init; }
/// <summary>Human-readable error summary on failure rows.</summary>
public string? ErrorMessage { get; init; }
/// <summary>Verbose error detail (stack/exception) on failure rows.</summary>
public string? ErrorDetail { get; init; }
/// <summary>Truncated/redacted request summary; capped per AuditLogOptions.</summary>
public string? RequestSummary { get; init; }
/// <summary>Truncated/redacted response summary; capped per AuditLogOptions.</summary>
public string? ResponseSummary { get; init; }
/// <summary>True when Request/Response summaries were truncated to the payload cap.</summary>
public bool PayloadTruncated { get; init; }
/// <summary>Free-form JSON extension column for channel-specific extras.</summary>
public string? Extra { get; init; }
/// <summary>Site-local forwarding state; null on central rows.</summary>
public AuditForwardState? ForwardState { get; init; }
}
@@ -0,0 +1,40 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
public class AuditLogEntry
{
/// <summary>Auto-incremented primary key.</summary>
public int Id { get; set; }
/// <summary>Username of the actor who performed the action.</summary>
public string User { get; set; }
/// <summary>Action performed (e.g. Created, Updated, Deleted).</summary>
public string Action { get; set; }
/// <summary>Entity type name (e.g. Template, ExternalSystem).</summary>
public string EntityType { get; set; }
/// <summary>String representation of the entity's primary key.</summary>
public string EntityId { get; set; }
/// <summary>Human-readable name of the affected entity.</summary>
public string EntityName { get; set; }
/// <summary>JSON snapshot of the entity's state after the action; null for deletes.</summary>
public string? AfterStateJson { get; set; }
/// <summary>UTC timestamp when the audit entry was recorded.</summary>
public DateTimeOffset Timestamp { get; set; }
/// <summary>Bundle import session id when this entry was created during a bundle import; otherwise null.</summary>
public Guid? BundleImportId { get; set; }
/// <summary>
/// Creates an audit log entry for the specified user action on a named entity.
/// </summary>
/// <param name="user">Username of the actor performing the action.</param>
/// <param name="action">Action name (e.g. Created, Updated, Deleted).</param>
/// <param name="entityType">Entity type name.</param>
/// <param name="entityId">String primary key of the affected entity.</param>
/// <param name="entityName">Human-readable name of the affected entity.</param>
public AuditLogEntry(string user, string action, string entityType, string entityId, string entityName)
{
User = user ?? throw new ArgumentNullException(nameof(user));
Action = action ?? throw new ArgumentNullException(nameof(action));
EntityType = entityType ?? throw new ArgumentNullException(nameof(entityType));
EntityId = entityId ?? throw new ArgumentNullException(nameof(entityId));
EntityName = entityName ?? throw new ArgumentNullException(nameof(entityName));
}
}
@@ -0,0 +1,69 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types;
namespace ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
/// <summary>
/// Central operational state row for a cached call (Site Call Audit #22, Audit Log #23 M3).
/// One row per <see cref="TrackedOperationId"/> in the <c>SiteCalls</c> table — append-once
/// then monotonic status update. Status transitions are forward-only
/// (<c>Submitted → Forwarded → Attempted → Delivered|Failed|Parked|Discarded</c>); an
/// out-of-order or duplicate upsert is a silent no-op so duplicate gRPC packets and
/// reconciliation pulls can both feed the same writer without rolling state back.
/// </summary>
/// <remarks>
/// Sites remain the source of truth — this row is the eventually-consistent mirror the
/// Central UI's Site Calls page reads. Unlike the partitioned <c>AuditLog</c> table this
/// entity backs operational (mutable) state on a standard non-partitioned table on
/// <c>[PRIMARY]</c>; no DB-role restriction applies.
/// </remarks>
public sealed record SiteCall
{
/// <summary>Strong-typed idempotency key shared with every audit row for the operation.</summary>
public required TrackedOperationId TrackedOperationId { get; init; }
/// <summary>Trust-boundary channel — <c>"ApiOutbound"</c> or <c>"DbOutbound"</c>.</summary>
public required string Channel { get; init; }
/// <summary>Human-readable target (e.g. <c>"ERP.GetOrder"</c>).</summary>
public required string Target { get; init; }
/// <summary>Site id that submitted the cached call.</summary>
public required string SourceSite { get; init; }
/// <summary>
/// The cluster node on which the cached call was emitted — <c>node-a</c> /
/// <c>node-b</c> for site rows (qualified by <see cref="SourceSite"/>),
/// <c>central-a</c> / <c>central-b</c> for central-originated rows. Stamped
/// by the emitting node from <c>INodeIdentityProvider</c>; nullable so
/// reconciled rows from a node that has since been retired don't block ingest.
/// </summary>
public string? SourceNode { get; init; }
/// <summary>
/// Lifecycle status — string form of
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditStatus"/>. Monotonic: later rank
/// wins, earlier rank is a no-op.
/// </summary>
public required string Status { get; init; }
/// <summary>Number of dispatch attempts so far; 0 prior to first attempt.</summary>
public required int RetryCount { get; init; }
/// <summary>Most recent error message; null when no failures have occurred.</summary>
public string? LastError { get; init; }
/// <summary>Most recent HTTP status code (API calls only); null otherwise.</summary>
public int? HttpStatus { get; init; }
/// <summary>UTC timestamp the cached call was first submitted at the site.</summary>
public required DateTime CreatedAtUtc { get; init; }
/// <summary>UTC timestamp of the latest status mutation at the site.</summary>
public required DateTime UpdatedAtUtc { get; init; }
/// <summary>UTC timestamp the row reached a terminal status; null while still active.</summary>
public DateTime? TerminalAtUtc { get; init; }
/// <summary>UTC timestamp central ingested (or last refreshed) this row.</summary>
public required DateTime IngestedAtUtc { get; init; }
}
@@ -0,0 +1,36 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
/// <summary>
/// WP-8: Stores the deployed configuration snapshot for an instance.
/// Captured at deploy time; compared against template-derived (live flattened) config for staleness detection.
/// </summary>
public class DeployedConfigSnapshot
{
/// <summary>Primary key.</summary>
public int Id { get; set; }
/// <summary>Foreign key to the owning <c>Instance</c> entity.</summary>
public int InstanceId { get; set; }
/// <summary>Unique deployment identifier assigned at deploy time for idempotency.</summary>
public string DeploymentId { get; set; }
/// <summary>Revision hash of the flattened configuration at deploy time, used for staleness detection.</summary>
public string RevisionHash { get; set; }
/// <summary>
/// JSON-serialized FlattenedConfiguration captured at deploy time.
/// </summary>
public string ConfigurationJson { get; set; }
/// <summary>UTC timestamp when this snapshot was persisted.</summary>
public DateTimeOffset DeployedAt { get; set; }
/// <summary>Initializes a new snapshot with the deployment identity, revision hash, and serialized configuration.</summary>
/// <param name="deploymentId">Unique deployment identifier.</param>
/// <param name="revisionHash">Revision hash of the flattened configuration.</param>
/// <param name="configurationJson">JSON-serialized flattened configuration.</param>
public DeployedConfigSnapshot(string deploymentId, string revisionHash, string configurationJson)
{
DeploymentId = deploymentId ?? throw new ArgumentNullException(nameof(deploymentId));
RevisionHash = revisionHash ?? throw new ArgumentNullException(nameof(revisionHash));
ConfigurationJson = configurationJson ?? throw new ArgumentNullException(nameof(configurationJson));
}
}
@@ -0,0 +1,67 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
public class DeploymentRecord
{
/// <summary>
/// The deployment record identifier.
/// </summary>
public int Id { get; set; }
/// <summary>
/// The instance identifier being deployed.
/// </summary>
public int InstanceId { get; set; }
/// <summary>
/// The current deployment status.
/// </summary>
public DeploymentStatus Status { get; set; }
/// <summary>
/// The deployment identifier.
/// </summary>
public string DeploymentId { get; set; }
/// <summary>
/// The revision hash of the deployed configuration, or null.
/// </summary>
public string? RevisionHash { get; set; }
/// <summary>
/// The user who initiated the deployment.
/// </summary>
public string DeployedBy { get; set; }
/// <summary>
/// The time when the deployment was initiated.
/// </summary>
public DateTimeOffset DeployedAt { get; set; }
/// <summary>
/// The time when the deployment completed, or null if still in progress.
/// </summary>
public DateTimeOffset? CompletedAt { get; set; }
/// <summary>
/// Error message if the deployment failed, or null.
/// </summary>
public string? ErrorMessage { get; set; }
/// <summary>
/// WP-4: Optimistic concurrency token for deployment status updates.
/// </summary>
public byte[] RowVersion { get; set; } = [];
/// <summary>
/// Initializes a new instance of the DeploymentRecord.
/// </summary>
/// <param name="deploymentId">The deployment identifier.</param>
/// <param name="deployedBy">The user initiating the deployment.</param>
public DeploymentRecord(string deploymentId, string deployedBy)
{
DeploymentId = deploymentId ?? throw new ArgumentNullException(nameof(deploymentId));
DeployedBy = deployedBy ?? throw new ArgumentNullException(nameof(deployedBy));
}
}
@@ -0,0 +1,27 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
/// <summary>
/// Records a system-wide artifact deployment operation, tracking status per site.
/// </summary>
public class SystemArtifactDeploymentRecord
{
/// <summary>Primary key.</summary>
public int Id { get; set; }
/// <summary>Type identifier for the deployed artifact (e.g. the artifact category name).</summary>
public string ArtifactType { get; set; }
/// <summary>Username of the operator who initiated the deployment.</summary>
public string DeployedBy { get; set; }
/// <summary>UTC timestamp when the deployment was initiated.</summary>
public DateTimeOffset DeployedAt { get; set; }
/// <summary>JSON-serialized per-site deployment status map, or null if not yet computed.</summary>
public string? PerSiteStatus { get; set; }
/// <summary>Initializes a new <see cref="SystemArtifactDeploymentRecord"/> with required fields.</summary>
/// <param name="artifactType">The artifact type being deployed.</param>
/// <param name="deployedBy">The username of the initiating operator.</param>
public SystemArtifactDeploymentRecord(string artifactType, string deployedBy)
{
ArtifactType = artifactType ?? throw new ArgumentNullException(nameof(artifactType));
DeployedBy = deployedBy ?? throw new ArgumentNullException(nameof(deployedBy));
}
}
@@ -0,0 +1,24 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
public class DatabaseConnectionDefinition
{
/// <summary>Gets or sets the primary key.</summary>
public int Id { get; set; }
/// <summary>Gets or sets the human-readable connection name.</summary>
public string Name { get; set; }
/// <summary>Gets or sets the ADO.NET connection string for this database.</summary>
public string ConnectionString { get; set; }
/// <summary>Gets or sets the maximum number of retry attempts for transient failures.</summary>
public int MaxRetries { get; set; }
/// <summary>Gets or sets the delay between retry attempts.</summary>
public TimeSpan RetryDelay { get; set; }
/// <summary>Initializes a new <see cref="DatabaseConnectionDefinition"/> with the required name and connection string.</summary>
/// <param name="name">The human-readable connection name.</param>
/// <param name="connectionString">The ADO.NET connection string.</param>
public DatabaseConnectionDefinition(string name, string connectionString)
{
Name = name ?? throw new ArgumentNullException(nameof(name));
ConnectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString));
}
}
@@ -0,0 +1,32 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
public class ExternalSystemDefinition
{
/// <summary>Database primary key.</summary>
public int Id { get; set; }
/// <summary>Display name for the external system.</summary>
public string Name { get; set; }
/// <summary>Base URL of the external system's HTTP endpoint.</summary>
public string EndpointUrl { get; set; }
/// <summary>Authentication type identifier (e.g., "ApiKey", "Basic").</summary>
public string AuthType { get; set; }
/// <summary>JSON-serialized authentication configuration for the selected <see cref="AuthType"/>.</summary>
public string? AuthConfiguration { get; set; }
/// <summary>Maximum number of retry attempts for transient failures.</summary>
public int MaxRetries { get; set; }
/// <summary>Fixed delay between retry attempts.</summary>
public TimeSpan RetryDelay { get; set; }
/// <summary>
/// Initializes a new <see cref="ExternalSystemDefinition"/>.
/// </summary>
/// <param name="name">Display name for the external system.</param>
/// <param name="endpointUrl">Base URL of the external system's HTTP endpoint.</param>
/// <param name="authType">Authentication type identifier.</param>
public ExternalSystemDefinition(string name, string endpointUrl, string authType)
{
Name = name ?? throw new ArgumentNullException(nameof(name));
EndpointUrl = endpointUrl ?? throw new ArgumentNullException(nameof(endpointUrl));
AuthType = authType ?? throw new ArgumentNullException(nameof(authType));
}
}
@@ -0,0 +1,33 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
/// <summary>
/// Defines a callable HTTP method on an external system definition.
/// </summary>
public class ExternalSystemMethod
{
/// <summary>Primary key.</summary>
public int Id { get; set; }
/// <summary>Foreign key referencing the owning <c>ExternalSystemDefinition</c>.</summary>
public int ExternalSystemDefinitionId { get; set; }
/// <summary>Name of the method as referenced in scripts.</summary>
public string Name { get; set; }
/// <summary>HTTP method (GET, POST, PUT, DELETE, etc.).</summary>
public string HttpMethod { get; set; }
/// <summary>URL path relative to the external system's base URL.</summary>
public string Path { get; set; }
/// <summary>JSON-serialized parameter definitions for this method, or null if there are none.</summary>
public string? ParameterDefinitions { get; set; }
/// <summary>JSON-serialized return type definition for this method, or null if void.</summary>
public string? ReturnDefinition { get; set; }
/// <summary>Initializes a new instance of <see cref="ExternalSystemMethod"/> with the required fields.</summary>
/// <param name="name">The method name.</param>
/// <param name="httpMethod">The HTTP method verb.</param>
/// <param name="path">The URL path.</param>
public ExternalSystemMethod(string name, string httpMethod, string path)
{
Name = name ?? throw new ArgumentNullException(nameof(name));
HttpMethod = httpMethod ?? throw new ArgumentNullException(nameof(httpMethod));
Path = path ?? throw new ArgumentNullException(nameof(path));
}
}
@@ -0,0 +1,68 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
namespace ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
/// <summary>
/// An inbound-API bearer credential. Per ConfigurationDatabase-012 the plaintext key
/// is never persisted: the entity stores only <see cref="KeyHash"/>, a deterministic
/// keyed hash of the key (HMAC-SHA256 with a server-side pepper). The plaintext is
/// generated at creation, shown to the operator exactly once, and then discarded.
/// </summary>
public class ApiKey
{
/// <summary>Database primary key.</summary>
public int Id { get; set; }
/// <summary>Display name for the API key.</summary>
public string Name { get; set; }
/// <summary>
/// Deterministic keyed hash of the API key value. This is the only form of the
/// credential persisted; the plaintext key is never stored. Authentication hashes
/// the presented candidate with the same scheme and compares against this value.
/// </summary>
public string KeyHash { get; set; }
/// <summary>When false, the key is rejected even if the hash matches.</summary>
public bool IsEnabled { get; set; }
/// <summary>
/// Creates an API key from a plaintext value, immediately hashing it with the
/// unpeppered default hasher (<see cref="ApiKeyHasher.Default"/>) so the entity
/// never holds the plaintext. Production code paths that have a configured pepper
/// should use <see cref="FromHash(string, string)"/> with a peppered hash instead.
/// </summary>
/// <param name="name">Display name for the API key.</param>
/// <param name="keyValue">Plaintext key value; hashed immediately and never stored.</param>
public ApiKey(string name, string keyValue)
{
Name = name ?? throw new ArgumentNullException(nameof(name));
if (keyValue is null) throw new ArgumentNullException(nameof(keyValue));
KeyHash = ApiKeyHasher.Default.Hash(keyValue);
}
/// <summary>
/// Parameterless constructor for the EF Core materializer. Application code uses
/// <see cref="ApiKey(string, string)"/> or <see cref="FromHash(string, string)"/>.
/// </summary>
private ApiKey()
{
Name = string.Empty;
KeyHash = string.Empty;
}
/// <summary>
/// Creates an API key from an already-computed key hash. Used by the creation
/// path, which generates a random key, hashes it with the configured (peppered)
/// <see cref="IApiKeyHasher"/>, and stores only the resulting hash.
/// </summary>
/// <param name="name">Display name for the API key.</param>
/// <param name="keyHash">Pre-computed keyed hash of the API key value.</param>
public static ApiKey FromHash(string name, string keyHash)
{
return new ApiKey
{
Name = name ?? throw new ArgumentNullException(nameof(name)),
KeyHash = keyHash ?? throw new ArgumentNullException(nameof(keyHash)),
};
}
}
@@ -0,0 +1,28 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
public class ApiMethod
{
/// <summary>Gets or sets the primary key.</summary>
public int Id { get; set; }
/// <summary>Gets or sets the method name used in the route (<c>/api/{Name}</c>).</summary>
public string Name { get; set; }
/// <summary>Gets or sets the C# script body executed when the method is invoked.</summary>
public string Script { get; set; }
/// <summary>Gets or sets the JSON-serialised list of API key IDs approved for this method, or <c>null</c> for unrestricted.</summary>
public string? ApprovedApiKeyIds { get; set; }
/// <summary>Gets or sets the JSON Schema describing the accepted parameters, or <c>null</c> if the method takes no parameters.</summary>
public string? ParameterDefinitions { get; set; }
/// <summary>Gets or sets the JSON Schema describing the return type, or <c>null</c> if the method returns nothing.</summary>
public string? ReturnDefinition { get; set; }
/// <summary>Gets or sets the script execution timeout in seconds.</summary>
public int TimeoutSeconds { get; set; }
/// <summary>Initializes a new <see cref="ApiMethod"/> with the required name and script.</summary>
/// <param name="name">The method name (used as the route segment).</param>
/// <param name="script">The C# script body to execute.</param>
public ApiMethod(string name, string script)
{
Name = name ?? throw new ArgumentNullException(nameof(name));
Script = script ?? throw new ArgumentNullException(nameof(script));
}
}
@@ -0,0 +1,24 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
public class Area
{
/// <summary>Gets or sets the database primary key.</summary>
public int Id { get; set; }
/// <summary>Gets or sets the id of the site this area belongs to.</summary>
public int SiteId { get; set; }
/// <summary>Gets or sets the display name of the area.</summary>
public string Name { get; set; }
/// <summary>Gets or sets the id of the parent area, or null for a root area.</summary>
public int? ParentAreaId { get; set; }
/// <summary>Gets or sets the child areas nested under this area.</summary>
public ICollection<Area> Children { get; set; } = new List<Area>();
/// <summary>
/// Initializes a new <see cref="Area"/> with the given name.
/// </summary>
/// <param name="name">Display name for the area.</param>
public Area(string name)
{
Name = name ?? throw new ArgumentNullException(nameof(name));
}
}
@@ -0,0 +1,34 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
public class Instance
{
/// <summary>Primary key.</summary>
public int Id { get; set; }
/// <summary>Foreign key to the template this instance is based on.</summary>
public int TemplateId { get; set; }
/// <summary>Foreign key to the site where this instance is deployed.</summary>
public int SiteId { get; set; }
/// <summary>Optional foreign key to the organisational area this instance belongs to.</summary>
public int? AreaId { get; set; }
/// <summary>System-wide unique name that identifies this instance.</summary>
public string UniqueName { get; set; }
/// <summary>Current lifecycle state of the instance.</summary>
public InstanceState State { get; set; }
/// <summary>Per-attribute value overrides applied on top of the template defaults.</summary>
public ICollection<InstanceAttributeOverride> AttributeOverrides { get; set; } = new List<InstanceAttributeOverride>();
/// <summary>Per-alarm configuration overrides applied on top of the template defaults.</summary>
public ICollection<InstanceAlarmOverride> AlarmOverrides { get; set; } = new List<InstanceAlarmOverride>();
/// <summary>Data-connection bindings that map template tags to site data sources.</summary>
public ICollection<InstanceConnectionBinding> ConnectionBindings { get; set; } = new List<InstanceConnectionBinding>();
/// <summary>
/// Initializes a new instance with the required unique name.
/// </summary>
/// <param name="uniqueName">System-wide unique name for this instance.</param>
public Instance(string uniqueName)
{
UniqueName = uniqueName ?? throw new ArgumentNullException(nameof(uniqueName));
}
}
@@ -0,0 +1,54 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
/// <summary>
/// Per-instance override for a template-defined alarm. Lets a deployed
/// instance tweak setpoints, priority, or per-band messages without forking
/// the template. Locked alarms (TemplateAlarm.IsLocked) cannot be overridden
/// — LockEnforcer rejects the change at write time.
///
/// Merge semantics (applied during flattening, after template inheritance and
/// composition):
/// • <see cref="TriggerConfigurationOverride"/> with a HiLo trigger merges
/// into the inherited JSON setpoint-by-setpoint (derived keys win,
/// inherited keys survive for unset derived keys). Same logic as
/// template-to-template HiLo override, just one layer deeper.
/// • For ValueMatch / RangeViolation / RateOfChange, the override replaces
/// the whole TriggerConfiguration JSON (existing whole-replace semantics).
/// • <see cref="PriorityLevelOverride"/> replaces the alarm's PriorityLevel
/// when set.
/// </summary>
public class InstanceAlarmOverride
{
/// <summary>Primary key.</summary>
public int Id { get; set; }
/// <summary>Foreign key to the instance this override belongs to.</summary>
public int InstanceId { get; set; }
/// <summary>
/// Canonical name of the alarm being overridden — matches
/// <c>ResolvedAlarm.CanonicalName</c> after flattening, so composed-member
/// alarms are referenced as <c>[CompositionInstance].[AlarmName]</c>.
/// </summary>
public string AlarmCanonicalName { get; set; }
/// <summary>
/// Partial JSON (for HiLo) or full JSON (for binary trigger types) to
/// override the inherited TriggerConfiguration. <c>null</c> means
/// "leave inherited as-is".
/// </summary>
public string? TriggerConfigurationOverride { get; set; }
/// <summary>
/// Replaces the alarm's PriorityLevel when set. <c>null</c> = keep inherited.
/// </summary>
public int? PriorityLevelOverride { get; set; }
/// <summary>
/// Initializes a new alarm override for the specified alarm.
/// </summary>
/// <param name="alarmCanonicalName">Canonical name of the alarm to override.</param>
public InstanceAlarmOverride(string alarmCanonicalName)
{
AlarmCanonicalName = alarmCanonicalName ?? throw new ArgumentNullException(nameof(alarmCanonicalName));
}
}
@@ -0,0 +1,20 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
public class InstanceAttributeOverride
{
/// <summary>Gets or sets the primary key.</summary>
public int Id { get; set; }
/// <summary>Gets or sets the foreign key of the owning instance.</summary>
public int InstanceId { get; set; }
/// <summary>Gets or sets the attribute name this override targets.</summary>
public string AttributeName { get; set; }
/// <summary>Gets or sets the override value, or <c>null</c> to clear a previous override.</summary>
public string? OverrideValue { get; set; }
/// <summary>Initializes a new <see cref="InstanceAttributeOverride"/> for the given attribute name.</summary>
/// <param name="attributeName">The name of the attribute to override.</param>
public InstanceAttributeOverride(string attributeName)
{
AttributeName = attributeName ?? throw new ArgumentNullException(nameof(attributeName));
}
}
@@ -0,0 +1,22 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
public class InstanceConnectionBinding
{
/// <summary>Auto-incremented primary key.</summary>
public int Id { get; set; }
/// <summary>Foreign key to the owning instance.</summary>
public int InstanceId { get; set; }
/// <summary>Name of the attribute on the instance that this binding maps to a data connection tag.</summary>
public string AttributeName { get; set; }
/// <summary>Foreign key to the data connection that provides values for this attribute.</summary>
public int DataConnectionId { get; set; }
/// <summary>
/// Creates a binding for the specified attribute name.
/// </summary>
/// <param name="attributeName">Name of the attribute being bound to a data connection.</param>
public InstanceConnectionBinding(string attributeName)
{
AttributeName = attributeName ?? throw new ArgumentNullException(nameof(attributeName));
}
}
@@ -0,0 +1,98 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
/// <summary>
/// A single notification queued in the central outbox. Created at a site (where the
/// <see cref="NotificationId"/> GUID is generated) and forwarded to the central cluster
/// for delivery, retry, and audit. The lifecycle is tracked by <see cref="Status"/>.
/// </summary>
public class Notification
{
/// <summary>GUID primary key, generated at the originating site.</summary>
public string NotificationId { get; set; }
/// <summary>Gets or sets the notification type.</summary>
public NotificationType Type { get; set; }
/// <summary>Gets or sets the notification list name.</summary>
public string ListName { get; set; }
/// <summary>Gets or sets the notification subject.</summary>
public string Subject { get; set; }
/// <summary>Gets or sets the notification body.</summary>
public string Body { get; set; }
/// <summary>JSON extensibility hook for channel-specific payload data.</summary>
public string? TypeData { get; set; }
/// <summary>Gets or sets the notification delivery status.</summary>
public NotificationStatus Status { get; set; } = NotificationStatus.Pending;
/// <summary>Gets or sets the delivery retry count.</summary>
public int RetryCount { get; set; }
/// <summary>Gets or sets the last error message, if any.</summary>
public string? LastError { get; set; }
/// <summary>Resolved delivery targets snapshotted at delivery time, for audit.</summary>
public string? ResolvedTargets { get; set; }
/// <summary>Gets or sets the originating site ID.</summary>
public string SourceSiteId { get; set; }
/// <summary>
/// The cluster node on which the notification was emitted — `node-a` / `node-b`
/// for site rows (qualified by <see cref="SourceSiteId"/>), `central-a` / `central-b`
/// for central-originated rows. Carried from the site on the
/// <see cref="Commons.Messages.Notification.NotificationSubmit"/> and persisted at
/// central; nullable so rows submitted before the column existed don't block ingest.
/// </summary>
public string? SourceNode { get; set; }
/// <summary>Gets or sets the originating instance ID, if any.</summary>
public string? SourceInstanceId { get; set; }
/// <summary>Gets or sets the originating script name, if any.</summary>
public string? SourceScript { get; set; }
/// <summary>
/// The originating script execution's <c>ExecutionId</c> (Audit Log #23). Carried from
/// the site on the <see cref="Commons.Messages.Notification.NotificationSubmit"/> so the
/// central dispatcher can stamp the same id onto its <c>NotifyDeliver</c> audit rows,
/// correlating them with the site-emitted <c>NotifySend</c> row. Null for notifications
/// submitted before the column existed, or raised outside a script-execution context.
/// </summary>
public Guid? OriginExecutionId { get; set; }
/// <summary>
/// The originating routed script execution's <c>ParentExecutionId</c> (Audit Log #23).
/// Carried from the site on the <see cref="Commons.Messages.Notification.NotificationSubmit"/>
/// so the central dispatcher can stamp the same parent id onto its <c>NotifyDeliver</c>
/// audit rows, correlating them with the site-emitted <c>NotifySend</c> row. Null for
/// non-routed runs, or for notifications submitted before the column existed.
/// </summary>
public Guid? OriginParentExecutionId { get; set; }
/// <summary>Gets or sets the time when the notification was enqueued at the site.</summary>
public DateTimeOffset SiteEnqueuedAt { get; set; }
/// <summary>Central ingest time.</summary>
public DateTimeOffset CreatedAt { get; set; }
/// <summary>Gets or sets the time of the last delivery attempt, if any.</summary>
public DateTimeOffset? LastAttemptAt { get; set; }
/// <summary>Gets or sets the time of the next scheduled delivery attempt, if any.</summary>
public DateTimeOffset? NextAttemptAt { get; set; }
/// <summary>Gets or sets the time the notification was delivered, if any.</summary>
public DateTimeOffset? DeliveredAt { get; set; }
/// <summary>
/// Initializes a new instance of the Notification class.
/// </summary>
/// <param name="notificationId">The notification ID (GUID).</param>
/// <param name="type">The notification type.</param>
/// <param name="listName">The notification list name.</param>
/// <param name="subject">The notification subject.</param>
/// <param name="body">The notification body text.</param>
/// <param name="sourceSiteId">The originating site ID.</param>
public Notification(string notificationId, NotificationType type, string listName,
string subject, string body, string sourceSiteId)
{
NotificationId = notificationId ?? throw new ArgumentNullException(nameof(notificationId));
Type = type;
ListName = listName ?? throw new ArgumentNullException(nameof(listName));
Subject = subject ?? throw new ArgumentNullException(nameof(subject));
Body = body ?? throw new ArgumentNullException(nameof(body));
SourceSiteId = sourceSiteId ?? throw new ArgumentNullException(nameof(sourceSiteId));
}
}
@@ -0,0 +1,22 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
public class NotificationList
{
/// <summary>Primary key.</summary>
public int Id { get; set; }
/// <summary>Display name of the notification list.</summary>
public string Name { get; set; }
/// <summary>Delivery type discriminator (e.g., Email).</summary>
public NotificationType Type { get; set; } = NotificationType.Email;
/// <summary>Recipients belonging to this list.</summary>
public ICollection<NotificationRecipient> Recipients { get; set; } = new List<NotificationRecipient>();
/// <summary>Initializes the notification list with the given name.</summary>
/// <param name="name">Display name of the notification list.</param>
public NotificationList(string name)
{
Name = name ?? throw new ArgumentNullException(nameof(name));
}
}
@@ -0,0 +1,24 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
public class NotificationRecipient
{
/// <summary>Gets or sets the database primary key.</summary>
public int Id { get; set; }
/// <summary>Gets or sets the id of the parent notification list.</summary>
public int NotificationListId { get; set; }
/// <summary>Gets or sets the display name of the recipient.</summary>
public string Name { get; set; }
/// <summary>Gets or sets the recipient's email address.</summary>
public string EmailAddress { get; set; }
/// <summary>
/// Initializes a new <see cref="NotificationRecipient"/> with the required fields.
/// </summary>
/// <param name="name">Display name of the recipient.</param>
/// <param name="emailAddress">Email address of the recipient.</param>
public NotificationRecipient(string name, string emailAddress)
{
Name = name ?? throw new ArgumentNullException(nameof(name));
EmailAddress = emailAddress ?? throw new ArgumentNullException(nameof(emailAddress));
}
}
@@ -0,0 +1,40 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
public class SmtpConfiguration
{
/// <summary>Gets or sets the primary key.</summary>
public int Id { get; set; }
/// <summary>Gets or sets the SMTP server hostname or IP address.</summary>
public string Host { get; set; }
/// <summary>Gets or sets the SMTP server port number.</summary>
public int Port { get; set; }
/// <summary>Gets or sets the authentication type (e.g. Basic, OAuth2ClientCredentials).</summary>
public string AuthType { get; set; }
/// <summary>Gets or sets the serialized credentials (password or OAuth2 client secret), or null when not applicable.</summary>
public string? Credentials { get; set; }
/// <summary>Gets or sets the TLS mode (None, StartTLS, or SSL), or null to use the provider default.</summary>
public string? TlsMode { get; set; }
/// <summary>Gets or sets the sender address placed in the From header.</summary>
public string FromAddress { get; set; }
/// <summary>Gets or sets the connection timeout in seconds.</summary>
public int ConnectionTimeoutSeconds { get; set; }
/// <summary>Gets or sets the maximum number of concurrent SMTP connections.</summary>
public int MaxConcurrentConnections { get; set; }
/// <summary>Gets or sets the maximum number of delivery retries before parking.</summary>
public int MaxRetries { get; set; }
/// <summary>Gets or sets the delay between retry attempts.</summary>
public TimeSpan RetryDelay { get; set; }
/// <summary>
/// Initializes a new <see cref="SmtpConfiguration"/> with required fields.
/// </summary>
/// <param name="host">SMTP server hostname or IP address.</param>
/// <param name="authType">Authentication type string (e.g. Basic, OAuth2ClientCredentials).</param>
/// <param name="fromAddress">Sender address for the From header.</param>
public SmtpConfiguration(string host, string authType, string fromAddress)
{
Host = host ?? throw new ArgumentNullException(nameof(host));
AuthType = authType ?? throw new ArgumentNullException(nameof(authType));
FromAddress = fromAddress ?? throw new ArgumentNullException(nameof(fromAddress));
}
}
@@ -0,0 +1,26 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
public class SharedScript
{
/// <summary>Primary key.</summary>
public int Id { get; set; }
/// <summary>Unique script name used to reference this script from templates.</summary>
public string Name { get; set; }
/// <summary>C# script source code.</summary>
public string Code { get; set; }
/// <summary>JSON-serialized parameter definitions, or null when the script takes no parameters.</summary>
public string? ParameterDefinitions { get; set; }
/// <summary>JSON-serialized return type definition, or null when the script has no return value.</summary>
public string? ReturnDefinition { get; set; }
/// <summary>
/// Initializes a new shared script with the required name and code.
/// </summary>
/// <param name="name">Unique script name.</param>
/// <param name="code">C# script source code.</param>
public SharedScript(string name, string code)
{
Name = name ?? throw new ArgumentNullException(nameof(name));
Code = code ?? throw new ArgumentNullException(nameof(code));
}
}
@@ -0,0 +1,23 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Entities.Security;
public class LdapGroupMapping
{
/// <summary>Gets or sets the primary key.</summary>
public int Id { get; set; }
/// <summary>Gets or sets the LDAP/AD group CN that this mapping targets.</summary>
public string LdapGroupName { get; set; }
/// <summary>Gets or sets the ScadaBridge role name this group maps to.</summary>
public string Role { get; set; }
// Parameterless constructor for EF Core seed data
private LdapGroupMapping() { LdapGroupName = null!; Role = null!; }
/// <summary>Initializes a new <see cref="LdapGroupMapping"/> linking an LDAP group to a ScadaBridge role.</summary>
/// <param name="ldapGroupName">The LDAP group name (CN).</param>
/// <param name="role">The ScadaBridge role name to assign.</param>
public LdapGroupMapping(string ldapGroupName, string role)
{
LdapGroupName = ldapGroupName ?? throw new ArgumentNullException(nameof(ldapGroupName));
Role = role ?? throw new ArgumentNullException(nameof(role));
}
}
@@ -0,0 +1,11 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Entities.Security;
public class SiteScopeRule
{
/// <summary>Database primary key.</summary>
public int Id { get; set; }
/// <summary>Foreign key to the <see cref="LdapGroupMapping"/> this rule restricts.</summary>
public int LdapGroupMappingId { get; set; }
/// <summary>Foreign key to the site this rule limits the mapping to.</summary>
public int SiteId { get; set; }
}
@@ -0,0 +1,32 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
public class DataConnection
{
/// <summary>Gets or sets the database primary key.</summary>
public int Id { get; set; }
/// <summary>Gets or sets the owning site's id.</summary>
public int SiteId { get; set; }
/// <summary>Gets or sets the unique name of this data connection within the site.</summary>
public string Name { get; set; }
/// <summary>Gets or sets the protocol type string (e.g. "OpcUa").</summary>
public string Protocol { get; set; }
/// <summary>Gets or sets the primary protocol-specific configuration JSON.</summary>
public string? PrimaryConfiguration { get; set; }
/// <summary>Gets or sets the backup protocol-specific configuration JSON used on failover.</summary>
public string? BackupConfiguration { get; set; }
/// <summary>Gets or sets the number of failover retry attempts before the connection is marked failed.</summary>
public int FailoverRetryCount { get; set; } = 3;
/// <summary>
/// Initializes a new <see cref="DataConnection"/> with the required fields.
/// </summary>
/// <param name="name">Unique name of the connection within the site.</param>
/// <param name="protocol">Protocol type string (e.g. "OpcUa").</param>
/// <param name="siteId">Id of the owning site.</param>
public DataConnection(string name, string protocol, int siteId)
{
Name = name ?? throw new ArgumentNullException(nameof(name));
Protocol = protocol ?? throw new ArgumentNullException(nameof(protocol));
SiteId = siteId;
}
}
@@ -0,0 +1,32 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
public class Site
{
/// <summary>Primary key.</summary>
public int Id { get; set; }
/// <summary>Human-readable display name for the site.</summary>
public string Name { get; set; }
/// <summary>Machine-readable identifier used in Akka addresses and API routing.</summary>
public string SiteIdentifier { get; set; }
/// <summary>Optional description of the site.</summary>
public string? Description { get; set; }
/// <summary>Akka remote address for site node A (ClusterClient contact point).</summary>
public string? NodeAAddress { get; set; }
/// <summary>Akka remote address for site node B (ClusterClient contact point).</summary>
public string? NodeBAddress { get; set; }
/// <summary>gRPC endpoint for site node A used by the central SiteStreamGrpcClient.</summary>
public string? GrpcNodeAAddress { get; set; }
/// <summary>gRPC endpoint for site node B used by the central SiteStreamGrpcClient.</summary>
public string? GrpcNodeBAddress { get; set; }
/// <summary>
/// Initializes a new site with the required name and identifier.
/// </summary>
/// <param name="name">Human-readable display name.</param>
/// <param name="siteIdentifier">Machine-readable identifier used in Akka addresses.</param>
public Site(string name, string siteIdentifier)
{
Name = name ?? throw new ArgumentNullException(nameof(name));
SiteIdentifier = siteIdentifier ?? throw new ArgumentNullException(nameof(siteIdentifier));
}
}
@@ -0,0 +1,65 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
public class Template
{
/// <summary>
/// The unique identifier for the template.
/// </summary>
public int Id { get; set; }
/// <summary>
/// The name of the template.
/// </summary>
public string Name { get; set; }
/// <summary>
/// Optional description of the template.
/// </summary>
public string? Description { get; set; }
/// <summary>
/// The identifier of the parent template, if this template inherits from another.
/// </summary>
public int? ParentTemplateId { get; set; }
/// <summary>
/// The identifier of the folder containing this template.
/// </summary>
public int? FolderId { get; set; }
/// <summary>
/// Collection of attributes defined in this template.
/// </summary>
public ICollection<TemplateAttribute> Attributes { get; set; } = new List<TemplateAttribute>();
/// <summary>
/// Collection of alarms defined in this template.
/// </summary>
public ICollection<TemplateAlarm> Alarms { get; set; } = new List<TemplateAlarm>();
/// <summary>
/// Collection of scripts defined in this template.
/// </summary>
public ICollection<TemplateScript> Scripts { get; set; } = new List<TemplateScript>();
/// <summary>
/// Collection of compositions defined in this template.
/// </summary>
public ICollection<TemplateComposition> Compositions { get; set; } = new List<TemplateComposition>();
/// <summary>
/// True when this template was auto-derived to back a TemplateComposition
/// slot. Derived templates inherit from a base (see <see cref="ParentTemplateId"/>),
/// are owned by their composition row (see <see cref="OwnerCompositionId"/>),
/// and are hidden from the main template tree by default.
/// </summary>
public bool IsDerived { get; set; }
/// <summary>
/// Back-reference to the <see cref="TemplateComposition"/> that owns this
/// derived template. Non-null only when <see cref="IsDerived"/>; cascade-
/// delete when the composition is removed. Always null on base templates.
/// </summary>
public int? OwnerCompositionId { get; set; }
/// <summary>
/// Initializes a new instance of the Template with the specified name.
/// </summary>
/// <param name="name">The name of the template.</param>
public Template(string name)
{
Name = name ?? throw new ArgumentNullException(nameof(name));
}
}
@@ -0,0 +1,49 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
public class TemplateAlarm
{
/// <summary>Database primary key.</summary>
public int Id { get; set; }
/// <summary>Foreign key to the owning <see cref="Template"/>.</summary>
public int TemplateId { get; set; }
/// <summary>Unique alarm name within the template.</summary>
public string Name { get; set; }
/// <summary>Optional human-readable description of the alarm.</summary>
public string? Description { get; set; }
/// <summary>Alarm priority level; lower values indicate higher priority.</summary>
public int PriorityLevel { get; set; }
/// <summary>When true, this alarm is locked and cannot be overridden in derived templates.</summary>
public bool IsLocked { get; set; }
/// <summary>Type of trigger condition that activates this alarm.</summary>
public AlarmTriggerType TriggerType { get; set; }
/// <summary>JSON-serialized trigger configuration specific to the <see cref="TriggerType"/>.</summary>
public string? TriggerConfiguration { get; set; }
/// <summary>Optional ID of the script to execute when the alarm triggers.</summary>
public int? OnTriggerScriptId { get; set; }
/// <summary>
/// True when this row was copied from the base template and has not been
/// overridden on the derived template. Changes to the base flow downward
/// for inherited rows; an explicit override flips this to false.
/// Always false on base (non-derived) templates.
/// </summary>
public bool IsInherited { get; set; }
/// <summary>
/// Set on a base alarm. When true, derived templates may not override the
/// alarm — the row is rendered readonly with a 🔒 in the derived UI, and
/// any attempt to update it through the API is rejected.
/// </summary>
public bool LockedInDerived { get; set; }
/// <summary>
/// Initializes a new <see cref="TemplateAlarm"/> with the specified name.
/// </summary>
/// <param name="name">The unique alarm name within the template.</param>
public TemplateAlarm(string name)
{
Name = name ?? throw new ArgumentNullException(nameof(name));
}
}
@@ -0,0 +1,63 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
public class TemplateAttribute
{
/// <summary>
/// Gets or sets the attribute ID.
/// </summary>
public int Id { get; set; }
/// <summary>
/// Gets or sets the template ID that owns this attribute.
/// </summary>
public int TemplateId { get; set; }
/// <summary>
/// Gets or sets the attribute name.
/// </summary>
public string Name { get; set; }
/// <summary>
/// Gets or sets the attribute value.
/// </summary>
public string? Value { get; set; }
/// <summary>
/// Gets or sets the data type of the attribute.
/// </summary>
public DataType DataType { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the attribute is locked from override.
/// </summary>
public bool IsLocked { get; set; }
/// <summary>
/// Gets or sets the attribute description.
/// </summary>
public string? Description { get; set; }
/// <summary>
/// Gets or sets the data source reference for this attribute.
/// </summary>
public string? DataSourceReference { get; set; }
/// <summary>
/// True when this row was copied from the base template and has not been
/// overridden on the derived template. Changes to the base flow downward
/// for inherited rows; an explicit override flips this to false.
/// Always false on base (non-derived) templates.
/// </summary>
public bool IsInherited { get; set; }
/// <summary>
/// Set on a base attribute. When true, derived templates may not override
/// the value — the row is rendered readonly with a 🔒 in the derived UI,
/// and any attempt to update it through the API is rejected.
/// </summary>
public bool LockedInDerived { get; set; }
/// <summary>
/// Initializes a new instance of the <see cref="TemplateAttribute"/> class with the specified name.
/// </summary>
/// <param name="name">The attribute name.</param>
public TemplateAttribute(string name)
{
Name = name ?? throw new ArgumentNullException(nameof(name));
}
}
@@ -0,0 +1,22 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
public class TemplateComposition
{
/// <summary>Primary key.</summary>
public int Id { get; set; }
/// <summary>Foreign key to the parent template that includes this composition.</summary>
public int TemplateId { get; set; }
/// <summary>Foreign key to the template being composed into the parent.</summary>
public int ComposedTemplateId { get; set; }
/// <summary>Name of the composition instance within the parent template's namespace.</summary>
public string InstanceName { get; set; }
/// <summary>
/// Initializes a new template composition with the required instance name.
/// </summary>
/// <param name="instanceName">Name of this composition slot within the parent template.</param>
public TemplateComposition(string instanceName)
{
InstanceName = instanceName ?? throw new ArgumentNullException(nameof(instanceName));
}
}
@@ -0,0 +1,22 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
public class TemplateFolder
{
/// <summary>Database primary key.</summary>
public int Id { get; set; }
/// <summary>Display name for the folder.</summary>
public string Name { get; set; }
/// <summary>ID of the parent folder, or null for root-level folders.</summary>
public int? ParentFolderId { get; set; }
/// <summary>Display ordering position within the parent folder.</summary>
public int SortOrder { get; set; }
/// <summary>
/// Initializes a new <see cref="TemplateFolder"/> with the specified name.
/// </summary>
/// <param name="name">The display name for the folder.</param>
public TemplateFolder(string name)
{
Name = name ?? throw new ArgumentNullException(nameof(name));
}
}
@@ -0,0 +1,80 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
public class TemplateScript
{
/// <summary>
/// The script identifier.
/// </summary>
public int Id { get; set; }
/// <summary>
/// The template identifier this script belongs to.
/// </summary>
public int TemplateId { get; set; }
/// <summary>
/// The script name.
/// </summary>
public string Name { get; set; }
/// <summary>
/// Whether the script is locked for editing.
/// </summary>
public bool IsLocked { get; set; }
/// <summary>
/// The script code.
/// </summary>
public string Code { get; set; }
/// <summary>
/// The trigger type for the script, or null.
/// </summary>
public string? TriggerType { get; set; }
/// <summary>
/// The trigger configuration, or null.
/// </summary>
public string? TriggerConfiguration { get; set; }
/// <summary>
/// The parameter definitions in JSON format, or null.
/// </summary>
public string? ParameterDefinitions { get; set; }
/// <summary>
/// The return type definition in JSON format, or null.
/// </summary>
public string? ReturnDefinition { get; set; }
/// <summary>
/// The minimum time between script runs, or null.
/// </summary>
public TimeSpan? MinTimeBetweenRuns { get; set; }
/// <summary>
/// True when this row was copied from the base template and has not been
/// overridden on the derived template. Changes to the base flow downward
/// for inherited rows; an explicit override flips this to false.
/// Always false on base (non-derived) templates.
/// </summary>
public bool IsInherited { get; set; }
/// <summary>
/// Set on a base script. When true, derived templates may not override
/// the script body — the row is rendered readonly in the derived
/// UI, and any attempt to update it through the API is rejected.
/// </summary>
public bool LockedInDerived { get; set; }
/// <summary>
/// Initializes a new instance of the TemplateScript.
/// </summary>
/// <param name="name">The script name.</param>
/// <param name="code">The script code.</param>
public TemplateScript(string name, string code)
{
Name = name ?? throw new ArgumentNullException(nameof(name));
Code = code ?? throw new ArgumentNullException(nameof(code));
}
}
@@ -0,0 +1,75 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
public enum QualityCode { Good, Bad, Uncertain }
public record TagValue(object? Value, QualityCode Quality, DateTimeOffset Timestamp);
public record ReadResult(bool Success, TagValue? Value, string? ErrorMessage);
public record WriteResult(bool Success, string? ErrorMessage);
/// <summary>Callback invoked when a subscribed tag value changes.</summary>
/// <param name="tagPath">The tag path whose value has changed.</param>
/// <param name="value">The new tag value including quality and timestamp.</param>
public delegate void SubscriptionCallback(string tagPath, TagValue value);
public interface IDataConnection : IAsyncDisposable
{
/// <summary>Establishes the protocol connection using the provided connection details.</summary>
/// <param name="connectionDetails">Protocol-specific key-value configuration pairs.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task ConnectAsync(IDictionary<string, string> connectionDetails, CancellationToken cancellationToken = default);
/// <summary>Gracefully terminates the protocol connection.</summary>
/// <param name="cancellationToken">Cancellation token.</param>
Task DisconnectAsync(CancellationToken cancellationToken = default);
/// <summary>Subscribes to value-change notifications for a tag path; returns a subscription ID.</summary>
/// <param name="tagPath">The tag path to subscribe to.</param>
/// <param name="callback">Callback invoked on each value change.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A subscription ID that can be passed to <see cref="UnsubscribeAsync"/>.</returns>
Task<string> SubscribeAsync(string tagPath, SubscriptionCallback callback, CancellationToken cancellationToken = default);
/// <summary>Cancels an active subscription by its ID.</summary>
/// <param name="subscriptionId">The subscription ID returned by <see cref="SubscribeAsync"/>.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task UnsubscribeAsync(string subscriptionId, CancellationToken cancellationToken = default);
/// <summary>Reads the current value of a single tag.</summary>
/// <param name="tagPath">The tag path to read.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The read result containing the value or an error.</returns>
Task<ReadResult> ReadAsync(string tagPath, CancellationToken cancellationToken = default);
/// <summary>Reads the current values of multiple tags in a single round-trip.</summary>
/// <param name="tagPaths">The tag paths to read.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A dictionary of tag paths to their read results.</returns>
Task<IReadOnlyDictionary<string, ReadResult>> ReadBatchAsync(IEnumerable<string> tagPaths, CancellationToken cancellationToken = default);
/// <summary>Writes a value to a single tag.</summary>
/// <param name="tagPath">The tag path to write.</param>
/// <param name="value">The value to write.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The write result indicating success or failure.</returns>
Task<WriteResult> WriteAsync(string tagPath, object? value, CancellationToken cancellationToken = default);
/// <summary>Writes values to multiple tags in a single round-trip.</summary>
/// <param name="values">A dictionary of tag paths to values.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A dictionary of tag paths to their write results.</returns>
Task<IReadOnlyDictionary<string, WriteResult>> WriteBatchAsync(IDictionary<string, object?> values, CancellationToken cancellationToken = default);
/// <summary>Writes a batch of values, then writes a flag and waits for a specific response value within the timeout.</summary>
/// <param name="values">Tag values to write before the flag.</param>
/// <param name="flagPath">Tag path of the trigger flag.</param>
/// <param name="flagValue">Value to write to the flag tag.</param>
/// <param name="responsePath">Tag path to monitor for the expected response value.</param>
/// <param name="responseValue">The response value that indicates completion.</param>
/// <param name="timeout">Maximum time to wait for the response.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns><c>true</c> if the response value was observed within the timeout; otherwise <c>false</c>.</returns>
Task<bool> WriteBatchAndWaitAsync(IDictionary<string, object?> values, string flagPath, object? flagValue, string responsePath, object? responseValue, TimeSpan timeout, CancellationToken cancellationToken = default);
/// <summary>Current connection health status.</summary>
ConnectionHealth Status { get; }
/// <summary>
/// Raised when the adapter detects an unexpected connection loss (e.g., gRPC stream error,
/// network timeout). The DataConnectionActor listens for this to trigger reconnection
/// and push bad quality to all subscribed tags.
/// </summary>
event Action? Disconnected;
}
@@ -0,0 +1,198 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
/// <summary>
/// Append-only data access for the central <c>AuditLog</c> table (Audit Log #23).
/// </summary>
/// <remarks>
/// <para>
/// The append-only invariant is enforced both at the SQL level (the
/// <c>scadabridge_audit_writer</c> role has only INSERT + SELECT — UPDATE and DELETE
/// are not granted) and at the API level: this interface deliberately exposes no
/// Update and no single-row Delete. Bulk purge is performed exclusively via
/// monthly partition switch-out (<see cref="SwitchOutPartitionAsync"/>).
/// </para>
/// <para>
/// Ingest is idempotent on <c>EventId</c>: <see cref="InsertIfNotExistsAsync"/> is
/// first-write-wins, so retrying telemetry and reconciliation pulls can both feed
/// the same writer without producing duplicates.
/// </para>
/// </remarks>
public interface IAuditLogRepository
{
/// <summary>
/// Inserts <paramref name="evt"/> if no row with the same
/// <see cref="AuditEvent.EventId"/> exists; otherwise silently leaves the
/// stored row untouched (first-write-wins). Bypasses the EF change tracker
/// so the row never enters a tracked state.
/// </summary>
/// <param name="evt">The audit event to insert.</param>
/// <param name="ct">Cancellation token.</param>
Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default);
/// <summary>
/// Returns up to <see cref="AuditLogPaging.PageSize"/> rows matching
/// <paramref name="filter"/>, ordered by <c>(OccurredAtUtc DESC, EventId DESC)</c>.
/// Use keyset paging by passing the last returned row's
/// <c>OccurredAtUtc</c> + <c>EventId</c> back via
/// <see cref="AuditLogPaging.AfterOccurredAtUtc"/> +
/// <see cref="AuditLogPaging.AfterEventId"/> to fetch the next page.
/// </summary>
/// <param name="filter">Filter criteria to apply to the query.</param>
/// <param name="paging">Paging cursor and page size.</param>
/// <param name="ct">Cancellation token.</param>
Task<IReadOnlyList<AuditEvent>> QueryAsync(
AuditLogQueryFilter filter,
AuditLogPaging paging,
CancellationToken ct = default);
/// <summary>
/// Switches out (purges) the monthly partition whose lower bound is
/// <paramref name="monthBoundary"/> and returns the approximate number
/// of rows discarded — sampled inside the transaction BEFORE the switch
/// so the row count reflects what the switch removed, not a post-purge
/// scan of a table that no longer exists.
/// </summary>
/// <remarks>
/// <para>
/// <b>Drop-and-rebuild dance.</b> <c>UX_AuditLog_EventId</c> is intentionally
/// non-partition-aligned (it lives on <c>[PRIMARY]</c> so single-column
/// EventId uniqueness — required by <see cref="InsertIfNotExistsAsync"/> —
/// can be enforced cheaply). SQL Server rejects
/// <c>ALTER TABLE … SWITCH PARTITION</c> while a non-aligned unique index
/// is present, so the M6 implementation drops the index, creates a staging
/// table with byte-identical schema, switches the partition's data into
/// staging, drops staging (discarding the rows), and rebuilds the unique
/// index. The CATCH branch guarantees the index is rebuilt even on partial
/// failure so the table never returns to live traffic without its
/// idempotency-supporting index.
/// </para>
/// <para>
/// <b>Outage window.</b> The dance briefly removes the unique index, so
/// concurrent <see cref="InsertIfNotExistsAsync"/> calls during the switch
/// could in principle race past the IF NOT EXISTS check without the index
/// catching the duplicate. This is acceptable for the daily purge cadence
/// — the inserts that the IF NOT EXISTS check guards are themselves rare
/// enough that a sub-second collision window is operationally negligible,
/// and the composite PK still rejects same-(EventId, OccurredAtUtc) rows.
/// </para>
/// </remarks>
/// <param name="monthBoundary">Lower-bound datetime of the monthly partition to switch out.</param>
/// <param name="ct">Cancellation token.</param>
Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, CancellationToken ct = default);
/// <summary>
/// Returns the set of <c>pf_AuditLog_Month</c> partition lower-bound
/// boundaries whose partitions contain only rows with
/// <see cref="AuditEvent.OccurredAtUtc"/> strictly older than
/// <paramref name="threshold"/>. Boundaries whose partition is empty are
/// excluded (a no-op switch is wasted work). Used by the M6 purge actor
/// to enumerate retention-eligible months on every tick.
/// </summary>
/// <param name="threshold">Only partitions whose data is entirely older than this UTC datetime are returned.</param>
/// <param name="ct">Cancellation token.</param>
Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
DateTime threshold,
CancellationToken ct = default);
/// <summary>
/// Audit Log (#23) M7 Bundle E (T13) — returns aggregate counts over the
/// trailing <paramref name="window"/> driving the central Health
/// dashboard's Audit KPI tiles.
/// </summary>
/// <param name="window">
/// Trailing time window (e.g. <c>TimeSpan.FromHours(1)</c>). Rows whose
/// <c>OccurredAtUtc &gt;= nowUtc - window</c> are counted; the upper
/// bound is <paramref name="nowUtc"/>.
/// </param>
/// <param name="nowUtc">
/// Optional explicit "now" timestamp used to anchor the trailing window.
/// Defaults to <see cref="DateTime.UtcNow"/> at call time when null —
/// production callers should leave this null; tests pin a deterministic
/// value so the window is reproducible across runs.
/// </param>
/// <param name="ct">Cancellation token.</param>
/// <returns>
/// A snapshot with <c>TotalEventsLastHour</c> + <c>ErrorEventsLastHour</c>
/// populated; <c>BacklogTotal</c> is left at zero (this method has no
/// visibility into per-site backlogs — the service layer composes it in
/// from <see cref="ZB.MOM.WW.ScadaBridge.HealthMonitoring.ICentralHealthAggregator"/>).
/// <c>AsOfUtc</c> is set to the server-side <c>UtcNow</c> at the time of
/// the query.
/// </returns>
/// <remarks>
/// <para>
/// Implemented as a single aggregate query
/// (<c>SELECT COUNT_BIG(*) AS Total, SUM(CASE …) AS Errors</c>) rather than
/// two round trips so the volume + error rate tiles read a consistent
/// snapshot — the denominator and numerator come from the same scan.
/// </para>
/// <para>
/// Errors are defined as <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditStatus.Failed"/>,
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditStatus.Parked"/>, or
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditStatus.Discarded"/>
/// — every non-success terminal lifecycle state. <c>Submitted</c>,
/// <c>Forwarded</c>, <c>Attempted</c> are in-flight and are NOT errors;
/// <c>Delivered</c> is success; <c>Skipped</c> is an intentional no-op.
/// </para>
/// </remarks>
Task<AuditLogKpiSnapshot> GetKpiSnapshotAsync(
TimeSpan window,
DateTime? nowUtc = null,
CancellationToken ct = default);
/// <summary>
/// Audit Log ParentExecutionId feature (Task 8) — given any
/// <paramref name="executionId"/> in an execution chain, returns the whole
/// chain rooted at the topmost ancestor: one <see cref="ExecutionTreeNode"/>
/// per distinct execution, summarising its <c>AuditLog</c> rows. The Central
/// UI renders the result as a tree.
/// </summary>
/// <remarks>
/// <para>
/// The input id may be any node in the chain — a leaf, the root, or a middle
/// node. The implementation first walks <em>up</em> via
/// <c>ParentExecutionId</c> to find the root, then walks <em>down</em> from
/// the root via a recursive CTE, so the full chain is returned regardless of
/// entry point.
/// </para>
/// <para>
/// The <c>ParentExecutionId</c> graph is a tree (acyclic by construction —
/// each execution is minted fresh and its parent always pre-exists). Both
/// the upward walk and the downward CTE are nonetheless bounded at 32 levels
/// as a guard against corrupt/pathological data: a depth that exceeds the
/// guard raises an error rather than hanging the server. Chains are shallow
/// (1-2 levels typical) so the guard is never reached in practice.
/// </para>
/// <para>
/// A "stub" node — an execution that emitted no rows of its own yet is
/// referenced by a child via <c>ParentExecutionId</c>, or whose rows have
/// been purged — still appears, with <see cref="ExecutionTreeNode.RowCount"/>
/// = 0. A purged/missing parent simply ends the upward walk.
/// </para>
/// <para>
/// When no <c>AuditLog</c> row carries <paramref name="executionId"/> in
/// either <c>ExecutionId</c> or <c>ParentExecutionId</c>, the result is a
/// single stub node for <paramref name="executionId"/> itself
/// (<see cref="ExecutionTreeNode.RowCount"/> = 0) — consistent with the
/// stub-node treatment of any other row-less execution.
/// </para>
/// </remarks>
/// <param name="executionId">Any execution id in the chain; the implementation walks to the root and back down.</param>
/// <param name="ct">Cancellation token.</param>
Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId,
CancellationToken ct = default);
/// <summary>
/// Returns the distinct, non-null <c>SourceNode</c> values present in the
/// <c>AuditLog</c> table, in ascending order. Backs the Audit Log page's
/// "Node" multi-select filter dropdown — the Central UI caches the result
/// for ~60s so the repository is hit at most once per minute per circuit.
/// </summary>
/// <param name="ct">Cancellation token.</param>
Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default);
}
@@ -0,0 +1,70 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
public interface ICentralUiRepository
{
/// <summary>Returns all configured sites.</summary>
/// <param name="cancellationToken">Cancellation token.</param>
Task<IReadOnlyList<Site>> GetAllSitesAsync(CancellationToken cancellationToken = default);
/// <summary>Returns all data connections for the specified site.</summary>
/// <param name="siteId">The site database ID to filter by.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<IReadOnlyList<DataConnection>> GetDataConnectionsBySiteIdAsync(int siteId, CancellationToken cancellationToken = default);
/// <summary>Returns all data connections across all sites.</summary>
/// <param name="cancellationToken">Cancellation token.</param>
Task<IReadOnlyList<DataConnection>> GetAllDataConnectionsAsync(CancellationToken cancellationToken = default);
/// <summary>Returns the full template tree including folders and templates.</summary>
/// <param name="cancellationToken">Cancellation token.</param>
Task<IReadOnlyList<Template>> GetTemplateTreeAsync(CancellationToken cancellationToken = default);
/// <summary>Returns instances filtered by optional site, template, or search term.</summary>
/// <param name="siteId">Optional site ID to filter by.</param>
/// <param name="templateId">Optional template ID to filter by.</param>
/// <param name="searchTerm">Optional keyword to filter instance names.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<IReadOnlyList<Instance>> GetInstancesFilteredAsync(int? siteId = null, int? templateId = null, string? searchTerm = null, CancellationToken cancellationToken = default);
/// <summary>Returns the most recent deployment records up to the specified count.</summary>
/// <param name="count">Maximum number of records to return.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<IReadOnlyList<DeploymentRecord>> GetRecentDeploymentsAsync(int count, CancellationToken cancellationToken = default);
/// <summary>Returns the area tree for the specified site.</summary>
/// <param name="siteId">The site database ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<IReadOnlyList<Area>> GetAreaTreeBySiteIdAsync(int siteId, CancellationToken cancellationToken = default);
// Audit log queries
/// <summary>
/// Queries audit log entries with optional filters, returning a page of results and the total matching count.
/// </summary>
/// <param name="user">Optional user filter.</param>
/// <param name="entityType">Optional entity type filter.</param>
/// <param name="action">Optional action filter.</param>
/// <param name="from">Optional start of date range filter.</param>
/// <param name="to">Optional end of date range filter.</param>
/// <param name="entityId">Optional entity ID filter.</param>
/// <param name="entityName">Optional entity name filter.</param>
/// <param name="bundleImportId">Optional bundle import correlation ID filter.</param>
/// <param name="page">One-based page number.</param>
/// <param name="pageSize">Number of entries per page.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<(IReadOnlyList<AuditLogEntry> Entries, int TotalCount)> GetAuditLogEntriesAsync(
string? user = null,
string? entityType = null,
string? action = null,
DateTimeOffset? from = null,
DateTimeOffset? to = null,
string? entityId = null,
string? entityName = null,
Guid? bundleImportId = null,
int page = 1,
int pageSize = 50,
CancellationToken cancellationToken = default);
/// <summary>Persists pending changes to the underlying store.</summary>
/// <param name="cancellationToken">Cancellation token.</param>
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}
@@ -0,0 +1,178 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
public interface IDeploymentManagerRepository
{
// DeploymentRecord
/// <summary>
/// Gets a deployment record by its ID.
/// </summary>
/// <param name="id">The deployment record ID.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>The deployment record, or null if not found.</returns>
Task<DeploymentRecord?> GetDeploymentRecordByIdAsync(int id, CancellationToken cancellationToken = default);
/// <summary>
/// Gets all deployment records.
/// </summary>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A read-only list of all deployment records.</returns>
Task<IReadOnlyList<DeploymentRecord>> GetAllDeploymentRecordsAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Gets all deployment records for a specific instance.
/// </summary>
/// <param name="instanceId">The instance ID.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A read-only list of deployment records for the instance.</returns>
Task<IReadOnlyList<DeploymentRecord>> GetDeploymentsByInstanceIdAsync(int instanceId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets the current deployment status for an instance.
/// </summary>
/// <param name="instanceId">The instance ID.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>The current deployment record, or null if no deployment exists.</returns>
Task<DeploymentRecord?> GetCurrentDeploymentStatusAsync(int instanceId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a deployment record by deployment ID.
/// </summary>
/// <param name="deploymentId">The deployment ID.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>The deployment record, or null if not found.</returns>
Task<DeploymentRecord?> GetDeploymentByDeploymentIdAsync(string deploymentId, CancellationToken cancellationToken = default);
/// <summary>
/// Adds a new deployment record.
/// </summary>
/// <param name="record">The deployment record to add.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task AddDeploymentRecordAsync(DeploymentRecord record, CancellationToken cancellationToken = default);
/// <summary>
/// Updates an existing deployment record.
/// </summary>
/// <param name="record">The deployment record to update.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task UpdateDeploymentRecordAsync(DeploymentRecord record, CancellationToken cancellationToken = default);
/// <summary>
/// Deletes a deployment record by ID, enforcing optimistic concurrency against the
/// supplied <paramref name="expectedRowVersion"/>. The caller MUST pass the
/// <c>RowVersion</c> it last observed on the record so EF emits
/// <c>DELETE ... WHERE Id = @id AND RowVersion = @prior</c>. A concurrent edit
/// surfaces as <see cref="Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException"/>
/// on <see cref="SaveChangesAsync(CancellationToken)"/>, matching the documented
/// "Optimistic concurrency is used on deployment status records" design rule.
/// </summary>
/// <param name="id">The deployment record ID to delete.</param>
/// <param name="expectedRowVersion">The RowVersion the caller observed; used as the optimistic-concurrency token.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task DeleteDeploymentRecordAsync(int id, byte[] expectedRowVersion, CancellationToken cancellationToken = default);
// SystemArtifactDeploymentRecord
/// <summary>
/// Gets a system artifact deployment record by ID.
/// </summary>
/// <param name="id">The system artifact deployment record ID.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>The system artifact deployment record, or null if not found.</returns>
Task<SystemArtifactDeploymentRecord?> GetSystemArtifactDeploymentByIdAsync(int id, CancellationToken cancellationToken = default);
/// <summary>
/// Gets all system artifact deployment records.
/// </summary>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A read-only list of all system artifact deployment records.</returns>
Task<IReadOnlyList<SystemArtifactDeploymentRecord>> GetAllSystemArtifactDeploymentsAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Adds a new system artifact deployment record.
/// </summary>
/// <param name="record">The system artifact deployment record to add.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task AddSystemArtifactDeploymentAsync(SystemArtifactDeploymentRecord record, CancellationToken cancellationToken = default);
/// <summary>
/// Updates an existing system artifact deployment record.
/// </summary>
/// <param name="record">The system artifact deployment record to update.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task UpdateSystemArtifactDeploymentAsync(SystemArtifactDeploymentRecord record, CancellationToken cancellationToken = default);
/// <summary>
/// Deletes a system artifact deployment record by ID.
/// </summary>
/// <param name="id">The system artifact deployment record ID to delete.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task DeleteSystemArtifactDeploymentAsync(int id, CancellationToken cancellationToken = default);
// WP-8: DeployedConfigSnapshot
/// <summary>
/// Gets the deployed config snapshot for an instance.
/// </summary>
/// <param name="instanceId">The instance ID.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>The deployed config snapshot, or null if not found.</returns>
Task<DeployedConfigSnapshot?> GetDeployedSnapshotByInstanceIdAsync(int instanceId, CancellationToken cancellationToken = default);
/// <summary>
/// Adds a new deployed config snapshot.
/// </summary>
/// <param name="snapshot">The deployed config snapshot to add.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task AddDeployedSnapshotAsync(DeployedConfigSnapshot snapshot, CancellationToken cancellationToken = default);
/// <summary>
/// Updates an existing deployed config snapshot.
/// </summary>
/// <param name="snapshot">The deployed config snapshot to update.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task UpdateDeployedSnapshotAsync(DeployedConfigSnapshot snapshot, CancellationToken cancellationToken = default);
/// <summary>
/// Deletes the deployed config snapshot for an instance.
/// </summary>
/// <param name="instanceId">The instance ID.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task DeleteDeployedSnapshotAsync(int instanceId, CancellationToken cancellationToken = default);
// Instance lookups for deployment pipeline
/// <summary>
/// Gets an instance by ID.
/// </summary>
/// <param name="instanceId">The instance ID.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>The instance, or null if not found.</returns>
Task<Instance?> GetInstanceByIdAsync(int instanceId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets an instance by unique name.
/// </summary>
/// <param name="uniqueName">The unique instance name.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>The instance, or null if not found.</returns>
Task<Instance?> GetInstanceByUniqueNameAsync(string uniqueName, CancellationToken cancellationToken = default);
/// <summary>
/// Updates an instance.
/// </summary>
/// <param name="instance">The instance to update.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task UpdateInstanceAsync(Instance instance, CancellationToken cancellationToken = default);
/// <summary>
/// Removes an instance and everything that depends on it: deployment
/// records, deployed config snapshot, attribute/alarm overrides, and
/// connection bindings.
/// </summary>
/// <param name="instanceId">The instance ID to delete.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task DeleteInstanceAsync(int instanceId, CancellationToken cancellationToken = default);
/// <summary>
/// Saves all pending changes to the database.
/// </summary>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A task representing the number of entities saved.</returns>
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}
@@ -0,0 +1,160 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
public interface IExternalSystemRepository
{
// ExternalSystemDefinition
/// <summary>
/// Gets an external system definition by ID.
/// </summary>
/// <param name="id">The external system ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The external system definition, or null if not found.</returns>
Task<ExternalSystemDefinition?> GetExternalSystemByIdAsync(int id, CancellationToken cancellationToken = default);
/// <summary>
/// Returns the external system with the given name, or <c>null</c> if no such
/// system exists. A name-keyed lookup so hot-path resolution (e.g. a script's
/// <c>ExternalSystem.Call()</c>) does not have to fetch every system and filter
/// in memory on each call (ExternalSystemGateway-011).
/// </summary>
/// <param name="name">The external system name.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The external system definition, or null if not found.</returns>
Task<ExternalSystemDefinition?> GetExternalSystemByNameAsync(string name, CancellationToken cancellationToken = default);
/// <summary>
/// Gets all external system definitions.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A read-only list of external system definitions.</returns>
Task<IReadOnlyList<ExternalSystemDefinition>> GetAllExternalSystemsAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Adds a new external system definition.
/// </summary>
/// <param name="definition">The external system definition to add.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task AddExternalSystemAsync(ExternalSystemDefinition definition, CancellationToken cancellationToken = default);
/// <summary>
/// Updates an existing external system definition.
/// </summary>
/// <param name="definition">The external system definition to update.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task UpdateExternalSystemAsync(ExternalSystemDefinition definition, CancellationToken cancellationToken = default);
/// <summary>
/// Deletes an external system definition by ID.
/// </summary>
/// <param name="id">The external system ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task DeleteExternalSystemAsync(int id, CancellationToken cancellationToken = default);
// ExternalSystemMethod
/// <summary>
/// Gets an external system method by ID.
/// </summary>
/// <param name="id">The method ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The external system method, or null if not found.</returns>
Task<ExternalSystemMethod?> GetExternalSystemMethodByIdAsync(int id, CancellationToken cancellationToken = default);
/// <summary>
/// Returns the method with the given name belonging to the given external system,
/// or <c>null</c> if no such method exists. A name-keyed lookup so hot-path
/// resolution does not have to fetch every method of the system and filter in
/// memory on each call (ExternalSystemGateway-011).
/// </summary>
/// <param name="externalSystemId">The external system ID.</param>
/// <param name="methodName">The method name.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The external system method, or null if not found.</returns>
Task<ExternalSystemMethod?> GetMethodByNameAsync(int externalSystemId, string methodName, CancellationToken cancellationToken = default);
/// <summary>
/// Gets all methods for a given external system.
/// </summary>
/// <param name="externalSystemId">The external system ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A read-only list of external system methods.</returns>
Task<IReadOnlyList<ExternalSystemMethod>> GetMethodsByExternalSystemIdAsync(int externalSystemId, CancellationToken cancellationToken = default);
/// <summary>
/// Adds a new external system method.
/// </summary>
/// <param name="method">The external system method to add.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task AddExternalSystemMethodAsync(ExternalSystemMethod method, CancellationToken cancellationToken = default);
/// <summary>
/// Updates an existing external system method.
/// </summary>
/// <param name="method">The external system method to update.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task UpdateExternalSystemMethodAsync(ExternalSystemMethod method, CancellationToken cancellationToken = default);
/// <summary>
/// Deletes an external system method by ID.
/// </summary>
/// <param name="id">The method ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task DeleteExternalSystemMethodAsync(int id, CancellationToken cancellationToken = default);
// DatabaseConnectionDefinition
/// <summary>
/// Gets a database connection definition by ID.
/// </summary>
/// <param name="id">The database connection ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The database connection definition, or null if not found.</returns>
Task<DatabaseConnectionDefinition?> GetDatabaseConnectionByIdAsync(int id, CancellationToken cancellationToken = default);
/// <summary>
/// Returns the database connection definition with the given name, or <c>null</c>
/// if no such connection exists. A name-keyed lookup so hot-path resolution (e.g.
/// a script's <c>Database.Connection()</c> / <c>Database.CachedWrite()</c>) does
/// not have to fetch every connection and filter in memory on each call
/// (ExternalSystemGateway-011).
/// </summary>
/// <param name="name">The database connection name.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The database connection definition, or null if not found.</returns>
Task<DatabaseConnectionDefinition?> GetDatabaseConnectionByNameAsync(string name, CancellationToken cancellationToken = default);
/// <summary>
/// Gets all database connection definitions.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A read-only list of database connection definitions.</returns>
Task<IReadOnlyList<DatabaseConnectionDefinition>> GetAllDatabaseConnectionsAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Adds a new database connection definition.
/// </summary>
/// <param name="definition">The database connection definition to add.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task AddDatabaseConnectionAsync(DatabaseConnectionDefinition definition, CancellationToken cancellationToken = default);
/// <summary>
/// Updates an existing database connection definition.
/// </summary>
/// <param name="definition">The database connection definition to update.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task UpdateDatabaseConnectionAsync(DatabaseConnectionDefinition definition, CancellationToken cancellationToken = default);
/// <summary>
/// Deletes a database connection definition by ID.
/// </summary>
/// <param name="id">The database connection ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task DeleteDatabaseConnectionAsync(int id, CancellationToken cancellationToken = default);
/// <summary>
/// Saves pending changes to the repository.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The number of entities saved.</returns>
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}
@@ -0,0 +1,64 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
public interface IInboundApiRepository
{
// ApiKey
/// <summary>Retrieves an API key by ID.</summary>
/// <param name="id">The API key ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<ApiKey?> GetApiKeyByIdAsync(int id, CancellationToken cancellationToken = default);
/// <summary>Retrieves all API keys.</summary>
/// <param name="cancellationToken">Cancellation token.</param>
Task<IReadOnlyList<ApiKey>> GetAllApiKeysAsync(CancellationToken cancellationToken = default);
/// <summary>Retrieves an API key by value.</summary>
/// <param name="keyValue">The API key value.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<ApiKey?> GetApiKeyByValueAsync(string keyValue, CancellationToken cancellationToken = default);
/// <summary>Adds a new API key.</summary>
/// <param name="apiKey">The API key to add.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task AddApiKeyAsync(ApiKey apiKey, CancellationToken cancellationToken = default);
/// <summary>Updates an existing API key.</summary>
/// <param name="apiKey">The API key to update.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task UpdateApiKeyAsync(ApiKey apiKey, CancellationToken cancellationToken = default);
/// <summary>Deletes an API key by ID.</summary>
/// <param name="id">The API key ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task DeleteApiKeyAsync(int id, CancellationToken cancellationToken = default);
// ApiMethod
/// <summary>Retrieves an API method by ID.</summary>
/// <param name="id">The API method ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<ApiMethod?> GetApiMethodByIdAsync(int id, CancellationToken cancellationToken = default);
/// <summary>Retrieves all API methods.</summary>
/// <param name="cancellationToken">Cancellation token.</param>
Task<IReadOnlyList<ApiMethod>> GetAllApiMethodsAsync(CancellationToken cancellationToken = default);
/// <summary>Retrieves an API method by name.</summary>
/// <param name="name">The API method name.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<ApiMethod?> GetMethodByNameAsync(string name, CancellationToken cancellationToken = default);
/// <summary>Retrieves API keys approved for a method.</summary>
/// <param name="methodId">The API method ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<IReadOnlyList<ApiKey>> GetApprovedKeysForMethodAsync(int methodId, CancellationToken cancellationToken = default);
/// <summary>Adds a new API method.</summary>
/// <param name="method">The API method to add.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task AddApiMethodAsync(ApiMethod method, CancellationToken cancellationToken = default);
/// <summary>Updates an existing API method.</summary>
/// <param name="method">The API method to update.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task UpdateApiMethodAsync(ApiMethod method, CancellationToken cancellationToken = default);
/// <summary>Deletes an API method by ID.</summary>
/// <param name="id">The API method ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task DeleteApiMethodAsync(int id, CancellationToken cancellationToken = default);
/// <summary>Saves pending changes to the database.</summary>
/// <param name="cancellationToken">Cancellation token.</param>
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}
@@ -0,0 +1,110 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Notifications;
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
/// <summary>
/// Data access for the central notification outbox — the queue of <see cref="Notification"/>
/// rows the outbox actor drains, retries, and audits. Distinct from
/// <see cref="INotificationRepository"/>, which manages notification list configuration.
/// </summary>
/// <remarks>
/// Persistence model: <see cref="InsertIfNotExistsAsync"/> and <see cref="UpdateAsync"/> commit
/// internally, so each call is its own transaction — suited to the outbox actor committing one
/// row's status transition at a time. The standalone <see cref="SaveChangesAsync"/> is available
/// for callers that stage multiple changes and want to flush them together.
/// </remarks>
public interface INotificationOutboxRepository
{
/// <summary>
/// Inserts <paramref name="n"/> only if no row with the same
/// <see cref="Notification.NotificationId"/> exists. Returns <c>true</c> when a new
/// row was inserted, <c>false</c> when an existing row was left untouched.
/// Commits internally — this call is its own transaction.
/// </summary>
/// <param name="n">The notification to insert.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if inserted, false if already exists.</returns>
Task<bool> InsertIfNotExistsAsync(Notification n, CancellationToken cancellationToken = default);
/// <summary>
/// Returns notifications ready for a delivery attempt: <c>Pending</c> rows, plus
/// <c>Retrying</c> rows whose <c>NextAttemptAt</c> is at or before <paramref name="now"/>.
/// Terminal rows are excluded. Ordered by <c>CreatedAt</c> ascending, capped at
/// <paramref name="batchSize"/>.
/// </summary>
/// <param name="now">The current time for evaluating due retries.</param>
/// <param name="batchSize">Maximum number of rows to return.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A list of notifications ready for delivery.</returns>
Task<IReadOnlyList<Notification>> GetDueAsync(DateTimeOffset now, int batchSize, CancellationToken cancellationToken = default);
/// <summary>Returns the notification with the given id, or <c>null</c>.</summary>
/// <param name="notificationId">The notification identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The notification, or null if not found.</returns>
Task<Notification?> GetByIdAsync(string notificationId, CancellationToken cancellationToken = default);
/// <summary>
/// Marks <paramref name="n"/> modified and persists it (status transitions).
/// Commits internally — this call is its own transaction.
/// </summary>
/// <param name="n">The notification to update.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task UpdateAsync(Notification n, CancellationToken cancellationToken = default);
/// <summary>
/// Returns a page of notifications matching <paramref name="filter"/>, ordered by
/// <c>CreatedAt</c> descending, together with the total matching count.
/// </summary>
/// <param name="filter">The query filter.</param>
/// <param name="pageNumber">The page number (1-based).</param>
/// <param name="pageSize">The page size.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A tuple of rows and total count.</returns>
Task<(IReadOnlyList<Notification> Rows, int TotalCount)> QueryAsync(
NotificationOutboxFilter filter, int pageNumber, int pageSize, CancellationToken cancellationToken = default);
/// <summary>
/// Bulk-deletes terminal rows (Delivered/Parked/Discarded) whose <c>CreatedAt</c> is
/// older than <paramref name="cutoff"/>. Returns the number of rows deleted.
/// </summary>
/// <param name="cutoff">The cutoff time for deletion.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The number of rows deleted.</returns>
Task<int> DeleteTerminalOlderThanAsync(DateTimeOffset cutoff, CancellationToken cancellationToken = default);
/// <summary>
/// Computes a point-in-time <see cref="NotificationKpiSnapshot"/>. The stuck and
/// delivered cutoffs are supplied by the caller; the current time used for
/// <c>OldestPendingAge</c> is captured inside the method.
/// </summary>
/// <param name="stuckCutoff">The time threshold for marking notifications as stuck.</param>
/// <param name="deliveredSince">The time threshold for counting delivered notifications.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A KPI snapshot.</returns>
Task<NotificationKpiSnapshot> ComputeKpisAsync(
DateTimeOffset stuckCutoff, DateTimeOffset deliveredSince, CancellationToken cancellationToken = default);
/// <summary>
/// Computes a point-in-time <see cref="SiteNotificationKpiSnapshot"/> per source site.
/// Sites with no notification rows at all are omitted. The stuck and delivered cutoffs
/// are supplied by the caller; the current time used for <c>OldestPendingAge</c> is
/// captured inside the method.
/// </summary>
/// <param name="stuckCutoff">The time threshold for marking notifications as stuck.</param>
/// <param name="deliveredSince">The time threshold for counting delivered notifications.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A list of per-site KPI snapshots.</returns>
Task<IReadOnlyList<SiteNotificationKpiSnapshot>> ComputePerSiteKpisAsync(
DateTimeOffset stuckCutoff, DateTimeOffset deliveredSince, CancellationToken cancellationToken = default);
/// <summary>
/// Persists pending changes tracked on the underlying context. Use this when staging
/// multiple changes for a single commit; the individual mutating methods on this
/// interface already commit on their own.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The number of changes persisted.</returns>
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}
@@ -0,0 +1,99 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
public interface INotificationRepository
{
// NotificationList
/// <summary>Gets a notification list by ID.</summary>
/// <param name="id">The notification list ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The notification list, or null if not found.</returns>
Task<NotificationList?> GetNotificationListByIdAsync(int id, CancellationToken cancellationToken = default);
/// <summary>Gets all notification lists.</summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A read-only list of notification lists.</returns>
Task<IReadOnlyList<NotificationList>> GetAllNotificationListsAsync(CancellationToken cancellationToken = default);
/// <summary>Gets a notification list by name.</summary>
/// <param name="name">The notification list name.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The notification list, or null if not found.</returns>
Task<NotificationList?> GetListByNameAsync(string name, CancellationToken cancellationToken = default);
/// <summary>Adds a new notification list.</summary>
/// <param name="list">The notification list to add.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task AddNotificationListAsync(NotificationList list, CancellationToken cancellationToken = default);
/// <summary>Updates an existing notification list.</summary>
/// <param name="list">The notification list to update.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task UpdateNotificationListAsync(NotificationList list, CancellationToken cancellationToken = default);
/// <summary>Deletes a notification list by ID.</summary>
/// <param name="id">The notification list ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task DeleteNotificationListAsync(int id, CancellationToken cancellationToken = default);
// NotificationRecipient
/// <summary>Gets a notification recipient by ID.</summary>
/// <param name="id">The recipient ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The notification recipient, or null if not found.</returns>
Task<NotificationRecipient?> GetRecipientByIdAsync(int id, CancellationToken cancellationToken = default);
/// <summary>Gets all recipients in a notification list.</summary>
/// <param name="notificationListId">The notification list ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A read-only list of recipients.</returns>
Task<IReadOnlyList<NotificationRecipient>> GetRecipientsByListIdAsync(int notificationListId, CancellationToken cancellationToken = default);
/// <summary>Adds a new notification recipient.</summary>
/// <param name="recipient">The recipient to add.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task AddRecipientAsync(NotificationRecipient recipient, CancellationToken cancellationToken = default);
/// <summary>Updates an existing notification recipient.</summary>
/// <param name="recipient">The recipient to update.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task UpdateRecipientAsync(NotificationRecipient recipient, CancellationToken cancellationToken = default);
/// <summary>Deletes a notification recipient by ID.</summary>
/// <param name="id">The recipient ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task DeleteRecipientAsync(int id, CancellationToken cancellationToken = default);
// SmtpConfiguration
/// <summary>Gets an SMTP configuration by ID.</summary>
/// <param name="id">The SMTP configuration ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The SMTP configuration, or null if not found.</returns>
Task<SmtpConfiguration?> GetSmtpConfigurationByIdAsync(int id, CancellationToken cancellationToken = default);
/// <summary>Gets all SMTP configurations.</summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A read-only list of SMTP configurations.</returns>
Task<IReadOnlyList<SmtpConfiguration>> GetAllSmtpConfigurationsAsync(CancellationToken cancellationToken = default);
/// <summary>Adds a new SMTP configuration.</summary>
/// <param name="configuration">The SMTP configuration to add.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task AddSmtpConfigurationAsync(SmtpConfiguration configuration, CancellationToken cancellationToken = default);
/// <summary>Updates an existing SMTP configuration.</summary>
/// <param name="configuration">The SMTP configuration to update.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task UpdateSmtpConfigurationAsync(SmtpConfiguration configuration, CancellationToken cancellationToken = default);
/// <summary>Deletes an SMTP configuration by ID.</summary>
/// <param name="id">The SMTP configuration ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task DeleteSmtpConfigurationAsync(int id, CancellationToken cancellationToken = default);
/// <summary>Saves pending changes to the repository.</summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The number of entities saved.</returns>
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}
@@ -0,0 +1,93 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Security;
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
public interface ISecurityRepository
{
// LdapGroupMapping
/// <summary>
/// Gets an LDAP group mapping by ID.
/// </summary>
/// <param name="id">The mapping ID.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>The LDAP group mapping, or null if not found.</returns>
Task<LdapGroupMapping?> GetMappingByIdAsync(int id, CancellationToken cancellationToken = default);
/// <summary>
/// Gets all LDAP group mappings.
/// </summary>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A read-only list of all LDAP group mappings.</returns>
Task<IReadOnlyList<LdapGroupMapping>> GetAllMappingsAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Gets all LDAP group mappings for a specific role.
/// </summary>
/// <param name="role">The role name.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A read-only list of LDAP group mappings for the role.</returns>
Task<IReadOnlyList<LdapGroupMapping>> GetMappingsByRoleAsync(string role, CancellationToken cancellationToken = default);
/// <summary>
/// Adds a new LDAP group mapping.
/// </summary>
/// <param name="mapping">The LDAP group mapping to add.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task AddMappingAsync(LdapGroupMapping mapping, CancellationToken cancellationToken = default);
/// <summary>
/// Updates an existing LDAP group mapping.
/// </summary>
/// <param name="mapping">The LDAP group mapping to update.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task UpdateMappingAsync(LdapGroupMapping mapping, CancellationToken cancellationToken = default);
/// <summary>
/// Deletes an LDAP group mapping by ID.
/// </summary>
/// <param name="id">The mapping ID to delete.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task DeleteMappingAsync(int id, CancellationToken cancellationToken = default);
// SiteScopeRule
/// <summary>
/// Gets a site scope rule by ID.
/// </summary>
/// <param name="id">The scope rule ID.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>The site scope rule, or null if not found.</returns>
Task<SiteScopeRule?> GetScopeRuleByIdAsync(int id, CancellationToken cancellationToken = default);
/// <summary>
/// Gets all site scope rules for an LDAP group mapping.
/// </summary>
/// <param name="ldapGroupMappingId">The LDAP group mapping ID.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A read-only list of scope rules for the mapping.</returns>
Task<IReadOnlyList<SiteScopeRule>> GetScopeRulesForMappingAsync(int ldapGroupMappingId, CancellationToken cancellationToken = default);
/// <summary>
/// Adds a new site scope rule.
/// </summary>
/// <param name="rule">The site scope rule to add.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task AddScopeRuleAsync(SiteScopeRule rule, CancellationToken cancellationToken = default);
/// <summary>
/// Updates an existing site scope rule.
/// </summary>
/// <param name="rule">The site scope rule to update.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task UpdateScopeRuleAsync(SiteScopeRule rule, CancellationToken cancellationToken = default);
/// <summary>
/// Deletes a site scope rule by ID.
/// </summary>
/// <param name="id">The scope rule ID to delete.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task DeleteScopeRuleAsync(int id, CancellationToken cancellationToken = default);
/// <summary>
/// Saves all pending changes to the database.
/// </summary>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A task representing the number of entities saved.</returns>
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}
@@ -0,0 +1,104 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
/// <summary>
/// Operational-state data access for the central <c>SiteCalls</c> table
/// (Site Call Audit #22, Audit Log #23 M3 Bundle B). One row per
/// <see cref="TrackedOperationId"/>; sites remain the source of truth and this
/// table is an eventually-consistent mirror fed by best-effort gRPC telemetry
/// plus periodic reconciliation pulls.
/// </summary>
/// <remarks>
/// <para>
/// Unlike the partitioned append-only <c>AuditLog</c> (M1), this table holds
/// mutable operational state. <see cref="UpsertAsync"/> is insert-if-not-exists
/// then monotonic update — a status update with rank less than or equal to the
/// stored status is a silent no-op so out-of-order telemetry, duplicate gRPC
/// packets, and reconciliation pulls can all feed the same writer without
/// rolling state backward.
/// </para>
/// <para>
/// Status rank for monotonic comparison (lower wins): <c>Submitted=0,
/// Forwarded=1, Attempted=2, Skipped=2, Delivered=3, Failed=3, Parked=3,
/// Discarded=3</c>. Terminal statuses share rank 3 and are mutually exclusive
/// — an attempt to upsert e.g. <c>Delivered</c> over an existing <c>Parked</c>
/// row is a no-op.
/// </para>
/// </remarks>
public interface ISiteCallAuditRepository
{
/// <summary>
/// Inserts <paramref name="siteCall"/> if no row with the same
/// <see cref="SiteCall.TrackedOperationId"/> exists; otherwise updates the
/// existing row IF AND ONLY IF the incoming status' rank strictly exceeds
/// the stored status' rank. Out-of-order / duplicate updates are silently
/// dropped (monotonic forward-only progression).
/// </summary>
/// <param name="siteCall">The site call row to insert or monotonically update.</param>
/// <param name="ct">Cancellation token.</param>
Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default);
/// <summary>
/// Returns the row for the given id, or <c>null</c> if none exists.
/// </summary>
/// <param name="id">The tracked operation id to look up.</param>
/// <param name="ct">Cancellation token.</param>
Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default);
/// <summary>
/// Returns up to <see cref="SiteCallPaging.PageSize"/> rows matching
/// <paramref name="filter"/>, ordered by <c>(CreatedAtUtc DESC,
/// TrackedOperationId DESC)</c>. Use keyset paging via
/// <see cref="SiteCallPaging.AfterCreatedAtUtc"/> + <see cref="SiteCallPaging.AfterId"/>
/// to fetch subsequent pages.
/// </summary>
/// <param name="filter">Filter criteria for the query.</param>
/// <param name="paging">Keyset paging parameters.</param>
/// <param name="ct">Cancellation token.</param>
Task<IReadOnlyList<SiteCall>> QueryAsync(
SiteCallQueryFilter filter,
SiteCallPaging paging,
CancellationToken ct = default);
/// <summary>
/// Deletes terminal rows whose <see cref="SiteCall.TerminalAtUtc"/> is
/// strictly older than <paramref name="olderThanUtc"/>. Non-terminal rows
/// (TerminalAtUtc IS NULL) are NEVER purged. Returns the number of rows
/// deleted.
/// </summary>
/// <param name="olderThanUtc">UTC cutoff; terminal rows older than this are deleted.</param>
/// <param name="ct">Cancellation token.</param>
Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default);
/// <summary>
/// Computes a point-in-time global <see cref="SiteCallKpiSnapshot"/> from the
/// <c>SiteCalls</c> table. Counts are aggregated server-side (no row
/// materialisation): <c>StuckCount</c> uses <paramref name="stuckCutoff"/>;
/// <c>FailedLastInterval</c> / <c>DeliveredLastInterval</c> use
/// <paramref name="intervalSince"/>; the current time for <c>OldestPendingAge</c>
/// is captured inside the method.
/// </summary>
/// <param name="stuckCutoff">UTC threshold for classifying a row as stuck.</param>
/// <param name="intervalSince">UTC start of the delivered/failed interval window.</param>
/// <param name="ct">Cancellation token.</param>
Task<SiteCallKpiSnapshot> ComputeKpisAsync(
DateTime stuckCutoff,
DateTime intervalSince,
CancellationToken ct = default);
/// <summary>
/// Computes a point-in-time <see cref="SiteCallSiteKpiSnapshot"/> per source
/// site. Sites with no <c>SiteCalls</c> rows at all are omitted. The stuck
/// cutoff and interval bounds are interpreted as in <see cref="ComputeKpisAsync"/>.
/// </summary>
/// <param name="stuckCutoff">UTC threshold for classifying a row as stuck.</param>
/// <param name="intervalSince">UTC start of the delivered/failed interval window.</param>
/// <param name="ct">Cancellation token.</param>
Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
DateTime stuckCutoff,
DateTime intervalSince,
CancellationToken ct = default);
}
@@ -0,0 +1,70 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
/// <summary>
/// Repository interface for site and data connection management.
/// </summary>
public interface ISiteRepository
{
// Sites
/// <summary>Retrieves a site by its ID.</summary>
/// <param name="id">The site primary key.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<Site?> GetSiteByIdAsync(int id, CancellationToken cancellationToken = default);
/// <summary>Retrieves a site by its identifier.</summary>
/// <param name="siteIdentifier">The unique site identifier string.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<Site?> GetSiteByIdentifierAsync(string siteIdentifier, CancellationToken cancellationToken = default);
/// <summary>Retrieves all sites.</summary>
/// <param name="cancellationToken">Cancellation token.</param>
Task<IReadOnlyList<Site>> GetAllSitesAsync(CancellationToken cancellationToken = default);
/// <summary>Adds a new site.</summary>
/// <param name="site">The site entity to add.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task AddSiteAsync(Site site, CancellationToken cancellationToken = default);
/// <summary>Updates an existing site.</summary>
/// <param name="site">The site entity with updated values.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task UpdateSiteAsync(Site site, CancellationToken cancellationToken = default);
/// <summary>Deletes a site.</summary>
/// <param name="id">The site primary key.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task DeleteSiteAsync(int id, CancellationToken cancellationToken = default);
// Data Connections
/// <summary>Retrieves a data connection by its ID.</summary>
/// <param name="id">The data connection primary key.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<DataConnection?> GetDataConnectionByIdAsync(int id, CancellationToken cancellationToken = default);
/// <summary>Retrieves all data connections.</summary>
/// <param name="cancellationToken">Cancellation token.</param>
Task<IReadOnlyList<DataConnection>> GetAllDataConnectionsAsync(CancellationToken cancellationToken = default);
/// <summary>Retrieves all data connections for a site.</summary>
/// <param name="siteId">The site primary key to filter by.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<IReadOnlyList<DataConnection>> GetDataConnectionsBySiteIdAsync(int siteId, CancellationToken cancellationToken = default);
/// <summary>Adds a new data connection.</summary>
/// <param name="connection">The data connection entity to add.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task AddDataConnectionAsync(DataConnection connection, CancellationToken cancellationToken = default);
/// <summary>Updates an existing data connection.</summary>
/// <param name="connection">The data connection entity with updated values.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task UpdateDataConnectionAsync(DataConnection connection, CancellationToken cancellationToken = default);
/// <summary>Deletes a data connection.</summary>
/// <param name="id">The data connection primary key.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task DeleteDataConnectionAsync(int id, CancellationToken cancellationToken = default);
// Instances (for deletion constraint checks)
/// <summary>Retrieves all instances deployed to a site.</summary>
/// <param name="siteId">The site primary key to filter by.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<IReadOnlyList<Instance>> GetInstancesBySiteIdAsync(int siteId, CancellationToken cancellationToken = default);
/// <summary>Saves all pending changes to the database.</summary>
/// <param name="cancellationToken">Cancellation token.</param>
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}
@@ -0,0 +1,306 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
public interface ITemplateEngineRepository
{
// Template
/// <summary>Retrieves a template by ID.</summary>
/// <param name="id">The template ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<Template?> GetTemplateByIdAsync(int id, CancellationToken cancellationToken = default);
/// <summary>Retrieves a template with its child entities by ID.</summary>
/// <param name="id">The template ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<Template?> GetTemplateWithChildrenAsync(int id, CancellationToken cancellationToken = default);
/// <summary>
/// Bulk variant of <see cref="GetTemplateWithChildrenAsync(int, CancellationToken)"/>
/// that fetches every template whose <see cref="Template.Name"/> matches one of
/// <paramref name="names"/> in a single SQL/EF query, eager-loading
/// Attributes / Alarms / Scripts / Compositions. Resolves the Transport-008
/// N+1 in <c>BundleImporter.PreviewAsync</c> — names that don't match an
/// existing template are omitted from the result rather than producing a
/// null entry, so callers should look up by name into the returned list.
/// </summary>
/// <param name="names">Template names to load. Duplicate / null / empty names are filtered out.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<IReadOnlyList<Template>> GetTemplatesWithChildrenAsync(IEnumerable<string> names, CancellationToken cancellationToken = default);
/// <summary>Retrieves all templates.</summary>
/// <param name="cancellationToken">Cancellation token.</param>
Task<IReadOnlyList<Template>> GetAllTemplatesAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Returns every template that contains a composition referencing
/// <paramref name="composedTemplateId"/>. Each result is eager-loaded with
/// its Attributes / Scripts / Compositions so the caller can build a
/// CompositionContext without a follow-up round-trip per parent.
/// </summary>
/// <param name="composedTemplateId">The composed template ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<IReadOnlyList<Template>> GetTemplatesComposingAsync(int composedTemplateId, CancellationToken cancellationToken = default);
/// <summary>Adds a new template.</summary>
/// <param name="template">The template to add.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task AddTemplateAsync(Template template, CancellationToken cancellationToken = default);
/// <summary>Updates an existing template.</summary>
/// <param name="template">The template to update.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task UpdateTemplateAsync(Template template, CancellationToken cancellationToken = default);
/// <summary>Deletes a template by ID.</summary>
/// <param name="id">The template ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task DeleteTemplateAsync(int id, CancellationToken cancellationToken = default);
// TemplateAttribute
/// <summary>Retrieves a template attribute by ID.</summary>
/// <param name="id">The attribute ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<TemplateAttribute?> GetTemplateAttributeByIdAsync(int id, CancellationToken cancellationToken = default);
/// <summary>Retrieves attributes for a template.</summary>
/// <param name="templateId">The template ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<IReadOnlyList<TemplateAttribute>> GetAttributesByTemplateIdAsync(int templateId, CancellationToken cancellationToken = default);
/// <summary>Adds a new template attribute.</summary>
/// <param name="attribute">The attribute to add.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task AddTemplateAttributeAsync(TemplateAttribute attribute, CancellationToken cancellationToken = default);
/// <summary>Updates an existing template attribute.</summary>
/// <param name="attribute">The attribute to update.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task UpdateTemplateAttributeAsync(TemplateAttribute attribute, CancellationToken cancellationToken = default);
/// <summary>Deletes a template attribute by ID.</summary>
/// <param name="id">The attribute ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task DeleteTemplateAttributeAsync(int id, CancellationToken cancellationToken = default);
// TemplateAlarm
/// <summary>Retrieves a template alarm by ID.</summary>
/// <param name="id">The alarm ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<TemplateAlarm?> GetTemplateAlarmByIdAsync(int id, CancellationToken cancellationToken = default);
/// <summary>Retrieves alarms for a template.</summary>
/// <param name="templateId">The template ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<IReadOnlyList<TemplateAlarm>> GetAlarmsByTemplateIdAsync(int templateId, CancellationToken cancellationToken = default);
/// <summary>Adds a new template alarm.</summary>
/// <param name="alarm">The alarm to add.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task AddTemplateAlarmAsync(TemplateAlarm alarm, CancellationToken cancellationToken = default);
/// <summary>Updates an existing template alarm.</summary>
/// <param name="alarm">The alarm to update.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task UpdateTemplateAlarmAsync(TemplateAlarm alarm, CancellationToken cancellationToken = default);
/// <summary>Deletes a template alarm by ID.</summary>
/// <param name="id">The alarm ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task DeleteTemplateAlarmAsync(int id, CancellationToken cancellationToken = default);
// TemplateScript
/// <summary>Retrieves a template script by ID.</summary>
/// <param name="id">The script ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<TemplateScript?> GetTemplateScriptByIdAsync(int id, CancellationToken cancellationToken = default);
/// <summary>Retrieves scripts for a template.</summary>
/// <param name="templateId">The template ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<IReadOnlyList<TemplateScript>> GetScriptsByTemplateIdAsync(int templateId, CancellationToken cancellationToken = default);
/// <summary>Adds a new template script.</summary>
/// <param name="script">The script to add.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task AddTemplateScriptAsync(TemplateScript script, CancellationToken cancellationToken = default);
/// <summary>Updates an existing template script.</summary>
/// <param name="script">The script to update.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task UpdateTemplateScriptAsync(TemplateScript script, CancellationToken cancellationToken = default);
/// <summary>Deletes a template script by ID.</summary>
/// <param name="id">The script ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task DeleteTemplateScriptAsync(int id, CancellationToken cancellationToken = default);
// TemplateComposition
/// <summary>Retrieves a template composition by ID.</summary>
/// <param name="id">The composition ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<TemplateComposition?> GetTemplateCompositionByIdAsync(int id, CancellationToken cancellationToken = default);
/// <summary>Retrieves compositions for a template.</summary>
/// <param name="templateId">The template ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<IReadOnlyList<TemplateComposition>> GetCompositionsByTemplateIdAsync(int templateId, CancellationToken cancellationToken = default);
/// <summary>Adds a new template composition.</summary>
/// <param name="composition">The composition to add.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task AddTemplateCompositionAsync(TemplateComposition composition, CancellationToken cancellationToken = default);
/// <summary>Updates an existing template composition.</summary>
/// <param name="composition">The composition to update.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task UpdateTemplateCompositionAsync(TemplateComposition composition, CancellationToken cancellationToken = default);
/// <summary>Deletes a template composition by ID.</summary>
/// <param name="id">The composition ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task DeleteTemplateCompositionAsync(int id, CancellationToken cancellationToken = default);
// Instance
/// <summary>Retrieves an instance by ID.</summary>
/// <param name="id">The instance ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<Instance?> GetInstanceByIdAsync(int id, CancellationToken cancellationToken = default);
/// <summary>Retrieves all instances.</summary>
/// <param name="cancellationToken">Cancellation token.</param>
Task<IReadOnlyList<Instance>> GetAllInstancesAsync(CancellationToken cancellationToken = default);
/// <summary>Retrieves instances for a template.</summary>
/// <param name="templateId">The template ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<IReadOnlyList<Instance>> GetInstancesByTemplateIdAsync(int templateId, CancellationToken cancellationToken = default);
/// <summary>Retrieves instances for a site.</summary>
/// <param name="siteId">The site ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<IReadOnlyList<Instance>> GetInstancesBySiteIdAsync(int siteId, CancellationToken cancellationToken = default);
/// <summary>Retrieves an instance by unique name.</summary>
/// <param name="uniqueName">The unique instance name.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<Instance?> GetInstanceByUniqueNameAsync(string uniqueName, CancellationToken cancellationToken = default);
/// <summary>Adds a new instance.</summary>
/// <param name="instance">The instance to add.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task AddInstanceAsync(Instance instance, CancellationToken cancellationToken = default);
/// <summary>Updates an existing instance.</summary>
/// <param name="instance">The instance to update.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task UpdateInstanceAsync(Instance instance, CancellationToken cancellationToken = default);
/// <summary>Deletes an instance by ID.</summary>
/// <param name="id">The instance ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task DeleteInstanceAsync(int id, CancellationToken cancellationToken = default);
// InstanceAttributeOverride
/// <summary>Retrieves attribute overrides for an instance.</summary>
/// <param name="instanceId">The instance ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<IReadOnlyList<InstanceAttributeOverride>> GetOverridesByInstanceIdAsync(int instanceId, CancellationToken cancellationToken = default);
/// <summary>Adds a new instance attribute override.</summary>
/// <param name="attributeOverride">The override to add.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task AddInstanceAttributeOverrideAsync(InstanceAttributeOverride attributeOverride, CancellationToken cancellationToken = default);
/// <summary>Updates an existing instance attribute override.</summary>
/// <param name="attributeOverride">The override to update.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task UpdateInstanceAttributeOverrideAsync(InstanceAttributeOverride attributeOverride, CancellationToken cancellationToken = default);
/// <summary>Deletes an instance attribute override by ID.</summary>
/// <param name="id">The override ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task DeleteInstanceAttributeOverrideAsync(int id, CancellationToken cancellationToken = default);
// InstanceAlarmOverride
/// <summary>Retrieves alarm overrides for an instance.</summary>
/// <param name="instanceId">The instance ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<IReadOnlyList<InstanceAlarmOverride>> GetAlarmOverridesByInstanceIdAsync(int instanceId, CancellationToken cancellationToken = default);
/// <summary>Retrieves an alarm override by instance and alarm name.</summary>
/// <param name="instanceId">The instance ID.</param>
/// <param name="alarmCanonicalName">The alarm canonical name.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<InstanceAlarmOverride?> GetAlarmOverrideAsync(int instanceId, string alarmCanonicalName, CancellationToken cancellationToken = default);
/// <summary>Adds a new instance alarm override.</summary>
/// <param name="alarmOverride">The override to add.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task AddInstanceAlarmOverrideAsync(InstanceAlarmOverride alarmOverride, CancellationToken cancellationToken = default);
/// <summary>Updates an existing instance alarm override.</summary>
/// <param name="alarmOverride">The override to update.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task UpdateInstanceAlarmOverrideAsync(InstanceAlarmOverride alarmOverride, CancellationToken cancellationToken = default);
/// <summary>Deletes an instance alarm override by ID.</summary>
/// <param name="id">The override ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task DeleteInstanceAlarmOverrideAsync(int id, CancellationToken cancellationToken = default);
// InstanceConnectionBinding
/// <summary>Retrieves connection bindings for an instance.</summary>
/// <param name="instanceId">The instance ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<IReadOnlyList<InstanceConnectionBinding>> GetBindingsByInstanceIdAsync(int instanceId, CancellationToken cancellationToken = default);
/// <summary>Adds a new instance connection binding.</summary>
/// <param name="binding">The binding to add.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task AddInstanceConnectionBindingAsync(InstanceConnectionBinding binding, CancellationToken cancellationToken = default);
/// <summary>Updates an existing instance connection binding.</summary>
/// <param name="binding">The binding to update.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task UpdateInstanceConnectionBindingAsync(InstanceConnectionBinding binding, CancellationToken cancellationToken = default);
/// <summary>Deletes an instance connection binding by ID.</summary>
/// <param name="id">The binding ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task DeleteInstanceConnectionBindingAsync(int id, CancellationToken cancellationToken = default);
// Area
/// <summary>Retrieves an area by ID.</summary>
/// <param name="id">The area ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<Area?> GetAreaByIdAsync(int id, CancellationToken cancellationToken = default);
/// <summary>Retrieves areas for a site.</summary>
/// <param name="siteId">The site ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<IReadOnlyList<Area>> GetAreasBySiteIdAsync(int siteId, CancellationToken cancellationToken = default);
/// <summary>Adds a new area.</summary>
/// <param name="area">The area to add.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task AddAreaAsync(Area area, CancellationToken cancellationToken = default);
/// <summary>Updates an existing area.</summary>
/// <param name="area">The area to update.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task UpdateAreaAsync(Area area, CancellationToken cancellationToken = default);
/// <summary>Deletes an area by ID.</summary>
/// <param name="id">The area ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task DeleteAreaAsync(int id, CancellationToken cancellationToken = default);
// SharedScript
/// <summary>Retrieves a shared script by ID.</summary>
/// <param name="id">The script ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<SharedScript?> GetSharedScriptByIdAsync(int id, CancellationToken cancellationToken = default);
/// <summary>Retrieves a shared script by name.</summary>
/// <param name="name">The script name.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<SharedScript?> GetSharedScriptByNameAsync(string name, CancellationToken cancellationToken = default);
/// <summary>Retrieves all shared scripts.</summary>
/// <param name="cancellationToken">Cancellation token.</param>
Task<IReadOnlyList<SharedScript>> GetAllSharedScriptsAsync(CancellationToken cancellationToken = default);
/// <summary>Adds a new shared script.</summary>
/// <param name="sharedScript">The script to add.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task AddSharedScriptAsync(SharedScript sharedScript, CancellationToken cancellationToken = default);
/// <summary>Updates an existing shared script.</summary>
/// <param name="sharedScript">The script to update.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task UpdateSharedScriptAsync(SharedScript sharedScript, CancellationToken cancellationToken = default);
/// <summary>Deletes a shared script by ID.</summary>
/// <param name="id">The script ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task DeleteSharedScriptAsync(int id, CancellationToken cancellationToken = default);
// TemplateFolder
/// <summary>Retrieves a template folder by ID.</summary>
/// <param name="id">The folder ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<TemplateFolder?> GetFolderByIdAsync(int id, CancellationToken cancellationToken = default);
/// <summary>Retrieves all template folders.</summary>
/// <param name="cancellationToken">Cancellation token.</param>
Task<IReadOnlyList<TemplateFolder>> GetAllFoldersAsync(CancellationToken cancellationToken = default);
/// <summary>Adds a new template folder.</summary>
/// <param name="folder">The folder to add.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task AddFolderAsync(TemplateFolder folder, CancellationToken cancellationToken = default);
/// <summary>Updates an existing template folder.</summary>
/// <param name="folder">The folder to update.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task UpdateFolderAsync(TemplateFolder folder, CancellationToken cancellationToken = default);
/// <summary>Deletes a template folder by ID.</summary>
/// <param name="id">The folder ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task DeleteFolderAsync(int id, CancellationToken cancellationToken = default);
/// <summary>Saves pending changes to the database.</summary>
/// <param name="cancellationToken">Cancellation token.</param>
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}
@@ -0,0 +1,16 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
public interface IAuditService
{
/// <summary>
/// Appends an audit log entry recording a user action on an entity.
/// </summary>
/// <param name="user">The authenticated username performing the action.</param>
/// <param name="action">The action performed (e.g., "Create", "Update", "Delete").</param>
/// <param name="entityType">The type name of the affected entity.</param>
/// <param name="entityId">The string representation of the entity's primary key.</param>
/// <param name="entityName">The display name of the affected entity.</param>
/// <param name="afterState">The entity state after the action; may be null for deletes.</param>
/// <param name="cancellationToken">Cancellation token for the log write.</param>
Task LogAsync(string user, string action, string entityType, string entityId, string entityName, object? afterState, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,19 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
/// <summary>
/// Boundary-side abstraction for emitting Audit Log (#23) events.
/// Implementations on the site write to local SQLite hot-path; on central they write to MS SQL directly.
/// Failures must NEVER abort the user-facing action.
/// </summary>
public interface IAuditWriter
{
/// <summary>
/// Persist an audit event. Best-effort: implementations must swallow/log internal failures
/// rather than propagating them to the calling boundary code.
/// </summary>
/// <param name="evt">The audit event to persist.</param>
/// <param name="ct">Cancellation token.</param>
Task WriteAsync(AuditEvent evt, CancellationToken ct = default);
}
@@ -0,0 +1,122 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types;
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
/// <summary>
/// Audit Log #23 (M3 Bundle E — Tasks E4/E5): site-side hook the
/// store-and-forward retry loop invokes after every cached-call attempt and
/// at terminal-state transitions, so the audit pipeline can emit
/// <c>ApiCallCached</c>/<c>DbWriteCached</c> per-attempt rows and the
/// <c>CachedResolve</c> terminal row under the original
/// <see cref="TrackedOperationId"/>.
/// </summary>
/// <remarks>
/// <para>
/// The interface deliberately uses <see cref="CachedCallAttemptOutcome"/>
/// rather than <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditStatus"/> so the
/// S&amp;F project does not need to depend on the audit vocabulary — the
/// bridge living in <c>ZB.MOM.WW.ScadaBridge.AuditLog</c> maps the outcome to the right
/// audit kind + status when materialising the <c>CachedCallTelemetry</c>
/// packet.
/// </para>
/// <para>
/// <b>Best-effort contract (alog.md §7):</b> implementations MUST swallow
/// internal failures rather than propagating to the S&amp;F service — a
/// thrown observer must not be misclassified as a transient delivery
/// failure and must not corrupt the retry-count bookkeeping.
/// </para>
/// </remarks>
public interface ICachedCallLifecycleObserver
{
/// <summary>
/// Called by the store-and-forward retry loop after every cached-call
/// delivery attempt. Receives the message's TrackedOperationId-bearing id,
/// the per-category channel discriminator, retry-count + last-error
/// context, and whether the outcome reached a terminal state.
/// </summary>
/// <param name="context">Per-attempt context including the tracking id, outcome, and audit provenance fields.</param>
/// <param name="ct">Cancellation token for the observation operation.</param>
Task OnAttemptCompletedAsync(CachedCallAttemptContext context, CancellationToken ct = default);
}
/// <summary>
/// Per-attempt context handed to <see cref="ICachedCallLifecycleObserver"/>.
/// </summary>
/// <param name="TrackedOperationId">
/// Tracking id parsed from the underlying <c>StoreAndForwardMessage.Id</c>.
/// </param>
/// <param name="Channel">
/// Trust-boundary channel string — <c>"ApiOutbound"</c> for ExternalSystem
/// cached calls, <c>"DbOutbound"</c> for cached DB writes.
/// </param>
/// <param name="Target">Human-readable target (system name / DB connection).</param>
/// <param name="SourceSite">Site id that submitted the cached call.</param>
/// <param name="Outcome">Per-attempt outcome.</param>
/// <param name="RetryCount">Number of retries performed so far (S&amp;F bookkeeping).</param>
/// <param name="LastError">Most recent error message (null on success).</param>
/// <param name="HttpStatus">Most recent HTTP status (null when not applicable).</param>
/// <param name="CreatedAtUtc">When the underlying S&amp;F message was first enqueued.</param>
/// <param name="OccurredAtUtc">When this attempt completed.</param>
/// <param name="DurationMs">Duration of the attempt in milliseconds (null when not measured).</param>
/// <param name="SourceInstanceId">Originating instance, when known.</param>
/// <param name="ExecutionId">
/// Audit Log #23 (ExecutionId Task 4): the originating script execution's
/// per-run correlation id, threaded through the store-and-forward buffer from
/// the cached-call enqueue path. The audit bridge stamps it onto the
/// retry-loop <c>ApiCallCached</c>/<c>DbWriteCached</c> Attempted and
/// <c>CachedResolve</c> rows so they correlate with the rest of the run.
/// <c>null</c> for rows buffered before Task 4 (back-compat).
/// </param>
/// <param name="SourceScript">
/// Audit Log #23 (ExecutionId Task 4): the originating script identifier,
/// threaded alongside <paramref name="ExecutionId"/> so the retry-loop audit
/// rows carry the same <c>SourceScript</c> provenance the script-side cached
/// rows already do. <c>null</c> when not known.
/// </param>
/// <param name="ParentExecutionId">
/// Audit Log #23 (ParentExecutionId Task 6): the <c>ExecutionId</c> of the
/// inbound-API request that spawned the originating script execution,
/// threaded through the store-and-forward buffer alongside
/// <paramref name="ExecutionId"/>. The audit bridge stamps it onto the
/// retry-loop <c>ApiCallCached</c>/<c>DbWriteCached</c> Attempted and
/// <c>CachedResolve</c> rows so they correlate back to the spawning run.
/// <c>null</c> for a non-routed run and for rows buffered before Task 6
/// (back-compat).
/// </param>
public sealed record CachedCallAttemptContext(
TrackedOperationId TrackedOperationId,
string Channel,
string Target,
string SourceSite,
CachedCallAttemptOutcome Outcome,
int RetryCount,
string? LastError,
int? HttpStatus,
DateTime CreatedAtUtc,
DateTime OccurredAtUtc,
int? DurationMs,
string? SourceInstanceId,
Guid? ExecutionId = null,
string? SourceScript = null,
Guid? ParentExecutionId = null);
/// <summary>
/// Coarse outcome of one cached-call delivery attempt, observed from inside
/// the store-and-forward retry loop. The audit bridge maps this to the
/// <c>ApiCallCached</c>/<c>DbWriteCached</c> Attempted row and, when terminal,
/// the corresponding <c>CachedResolve</c> row.
/// </summary>
public enum CachedCallAttemptOutcome
{
/// <summary>Attempt delivered successfully — terminal Delivered state.</summary>
Delivered,
/// <summary>Attempt failed transiently; another retry will follow.</summary>
TransientFailure,
/// <summary>Attempt returned permanent failure — terminal Parked state (S&amp;F semantics).</summary>
PermanentFailure,
/// <summary>Retry budget exhausted — terminal Parked state.</summary>
ParkedMaxRetries,
}
@@ -0,0 +1,36 @@
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
/// <summary>
/// Site-side fan-out abstraction for cached-call lifecycle telemetry
/// (Audit Log #23 / M3). One <see cref="CachedCallTelemetry"/> packet carries
/// both an audit row and an operational <c>SiteCalls</c> upsert; the
/// implementation routes the audit half through <see cref="IAuditWriter"/>
/// and the operational half through the site-local tracking SQLite store.
/// </summary>
/// <remarks>
/// <para>
/// Defined in Commons so the script runtime (and the StoreAndForward retry
/// loop, Bundle E4) can take a dependency on the abstraction rather than on
/// the concrete forwarder living inside <c>ZB.MOM.WW.ScadaBridge.AuditLog</c> — the
/// existing dependency arrow runs from <c>SiteRuntime</c> to Commons, not to
/// AuditLog.
/// </para>
/// <para>
/// <b>Best-effort contract (alog.md §7):</b> implementations MUST swallow
/// internal failures rather than propagating to the calling script.
/// </para>
/// </remarks>
public interface ICachedCallTelemetryForwarder
{
/// <summary>
/// Fan one combined-telemetry packet out to the audit writer and the
/// tracking store. Best-effort — failures on either half are logged and
/// swallowed; the returned Task completes when both halves have been
/// attempted.
/// </summary>
/// <param name="telemetry">The combined-telemetry packet to fan out.</param>
/// <param name="ct">Cancellation token.</param>
Task ForwardAsync(CachedCallTelemetry telemetry, CancellationToken ct = default);
}
@@ -0,0 +1,18 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
/// <summary>
/// Central-only audit writer for the direct-write path (Notification Outbox dispatch, Inbound API).
/// Distinct from <see cref="IAuditWriter"/> so DI binding can differ between site and central hosts.
/// </summary>
public interface ICentralAuditWriter
{
/// <summary>
/// Persist an audit event into the central AuditLog table directly (bypassing site telemetry).
/// Best-effort: implementations must swallow/log internal failures rather than propagating them.
/// </summary>
/// <param name="evt">The audit event to persist.</param>
/// <param name="ct">Cancellation token.</param>
Task WriteAsync(AuditEvent evt, CancellationToken ct = default);
}
@@ -0,0 +1,68 @@
using System.Data.Common;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
/// <summary>
/// Interface for database access from scripts.
/// Implemented by ExternalSystemGateway, consumed by ScriptRuntimeContext.
/// </summary>
public interface IDatabaseGateway
{
/// <summary>
/// Returns an ADO.NET DbConnection (typically SqlConnection) from the named connection.
/// Connection pooling is managed by the underlying provider.
/// Caller is responsible for disposing.
/// </summary>
/// <param name="connectionName">Name of the configured database connection to open.</param>
/// <param name="cancellationToken">Cancellation token for the async open operation.</param>
Task<DbConnection> GetConnectionAsync(
string connectionName,
CancellationToken cancellationToken = default);
/// <summary>
/// Submits a SQL write to the store-and-forward engine for reliable delivery.
/// </summary>
/// <param name="trackedOperationId">
/// Audit Log #23 (M3): caller-supplied tracking id used as the
/// store-and-forward message id so the S&amp;F retry loop can read it
/// back via <c>StoreAndForwardMessage.Id</c> and emit per-attempt /
/// terminal cached-write telemetry under the same id. Defaults to
/// <c>null</c> — when omitted the S&amp;F engine mints a fresh GUID and no
/// M3 telemetry is correlated (pre-M3 caller behaviour).
/// </param>
/// <param name="executionId">
/// Audit Log #23 (ExecutionId Task 4): the originating script execution's
/// per-run correlation id. When the write is buffered on a transient
/// failure this is threaded onto the S&amp;F message so the retry-loop
/// cached-write audit rows carry it. <c>null</c> when not threaded.
/// </param>
/// <param name="sourceScript">
/// Audit Log #23 (ExecutionId Task 4): the originating script identifier,
/// threaded onto the buffered S&amp;F message alongside
/// <paramref name="executionId"/>. <c>null</c> when not known.
/// </param>
/// <param name="parentExecutionId">
/// Audit Log #23 (ParentExecutionId Task 6): the <c>ExecutionId</c> of the
/// inbound-API request that spawned the originating script execution.
/// When the write is buffered on a transient failure this is threaded onto
/// the S&amp;F message alongside <paramref name="executionId"/> so the
/// retry-loop cached-write audit rows carry it. <c>null</c> for a
/// non-routed run.
/// </param>
/// <param name="connectionName">Name of the configured database connection to write to.</param>
/// <param name="sql">SQL statement to execute as a store-and-forward write.</param>
/// <param name="parameters">Optional SQL parameters for the statement.</param>
/// <param name="originInstanceName">Optional name of the instance that originated the write.</param>
/// <param name="cancellationToken">Cancellation token for the buffering operation.</param>
Task CachedWriteAsync(
string connectionName,
string sql,
IReadOnlyDictionary<string, object?>? parameters = null,
string? originInstanceName = null,
CancellationToken cancellationToken = default,
TrackedOperationId? trackedOperationId = null,
Guid? executionId = null,
string? sourceScript = null,
Guid? parentExecutionId = null);
}
@@ -0,0 +1,103 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types;
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
/// <summary>
/// Interface for invoking external system HTTP APIs.
/// Implemented by ExternalSystemGateway, consumed by ScriptRuntimeContext.
/// </summary>
public interface IExternalSystemClient
{
/// <summary>
/// Synchronous call to an external system. All failures returned to caller.
/// </summary>
/// <param name="systemName">The name of the external system.</param>
/// <param name="methodName">The name of the method to invoke.</param>
/// <param name="parameters">Method parameters as a dictionary, or null if none.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The result of the external call.</returns>
Task<ExternalCallResult> CallAsync(
string systemName,
string methodName,
IReadOnlyDictionary<string, object?>? parameters = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Attempt immediate delivery; on transient failure, hand to S&amp;F engine.
/// Permanent failures returned to caller.
/// </summary>
/// <param name="systemName">The name of the external system.</param>
/// <param name="methodName">The name of the method to invoke.</param>
/// <param name="parameters">Method parameters as a dictionary, or null if none.</param>
/// <param name="originInstanceName">The instance name originating the call, or null.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <param name="trackedOperationId">
/// Audit Log #23 (M3): caller-supplied tracking id used as the
/// store-and-forward message id so the S&amp;F retry loop can read it
/// back via <c>StoreAndForwardMessage.Id</c> and emit per-attempt /
/// terminal cached-call telemetry under the same id. Defaults to
/// <c>null</c> — when omitted the S&amp;F engine mints a fresh GUID and no
/// M3 telemetry is correlated (the legacy behaviour pre-M3 callers rely
/// on).
/// </param>
/// <param name="executionId">
/// Audit Log #23 (ExecutionId Task 4): the originating script execution's
/// per-run correlation id. When the call is buffered on a transient
/// failure this is threaded onto the S&amp;F message so the retry-loop
/// cached-call audit rows carry it. <c>null</c> when not threaded.
/// </param>
/// <param name="sourceScript">
/// Audit Log #23 (ExecutionId Task 4): the originating script identifier,
/// threaded onto the buffered S&amp;F message alongside
/// <paramref name="executionId"/>. <c>null</c> when not known.
/// </param>
/// <param name="parentExecutionId">
/// Audit Log #23 (ParentExecutionId Task 6): the <c>ExecutionId</c> of the
/// inbound-API request that spawned the originating script execution.
/// When the call is buffered on a transient failure this is threaded onto
/// the S&amp;F message alongside <paramref name="executionId"/> so the
/// retry-loop cached-call audit rows carry it. <c>null</c> for a non-routed
/// run.
/// </param>
/// <returns>The result of the external call.</returns>
Task<ExternalCallResult> CachedCallAsync(
string systemName,
string methodName,
IReadOnlyDictionary<string, object?>? parameters = null,
string? originInstanceName = null,
CancellationToken cancellationToken = default,
TrackedOperationId? trackedOperationId = null,
Guid? executionId = null,
string? sourceScript = null,
Guid? parentExecutionId = null);
}
/// <summary>
/// Result of an external system call.
/// </summary>
public record ExternalCallResult(
bool Success,
string? ResponseJson,
string? ErrorMessage,
bool WasBuffered = false)
{
// Commons-021: thread-safe lazy parse — `Lazy<T>` with the default
// `LazyThreadSafetyMode.ExecutionAndPublication` guarantees that two
// concurrent readers see the same `DynamicJsonElement` instance, the
// `JsonDocument.Parse` runs at most once, and the published value is
// safe under .NET's memory model. The closure captures `ResponseJson`
// by reference to the property — the record's positional property is
// an init-only field set in the constructor, so the snapshot read at
// first-access time is stable for the lifetime of the result.
private readonly Lazy<dynamic?> _response = new(() =>
string.IsNullOrEmpty(ResponseJson)
? null
: new DynamicJsonElement(System.Text.Json.JsonDocument.Parse(ResponseJson).RootElement));
/// <summary>
/// Parsed response as a dynamic object. Returns null if ResponseJson is null or empty.
/// Access properties directly: result.Response.result, result.Response.items[0].name, etc.
/// Thread-safe: concurrent readers share a single parsed instance (Commons-021).
/// </summary>
public dynamic? Response => _response.Value;
}
@@ -0,0 +1,18 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
/// <summary>
/// Resolves an instance unique name to its site identifier.
/// Used by Inbound API's Route.To() to determine which site to route requests to.
/// </summary>
public interface IInstanceLocator
{
/// <summary>
/// Resolves the site identifier for a given instance unique name.
/// Returns null if the instance is not found.
/// </summary>
/// <param name="instanceUniqueName">System-wide unique name of the instance to look up.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<string?> GetSiteIdForInstanceAsync(
string instanceUniqueName,
CancellationToken cancellationToken = default);
}
@@ -0,0 +1,22 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
/// <summary>
/// Surfaces the local node's semantic role-within-cluster name so downstream
/// audit writers can stamp it on the SourceNode column.
/// </summary>
/// <remarks>
/// Conventional values follow the pattern <c>node-a</c>/<c>node-b</c> on site
/// nodes and <c>central-a</c>/<c>central-b</c> on central nodes. The value is
/// a free-form operator-supplied label — there is no enforced format. When the
/// configuration value is missing, empty, or whitespace, implementations
/// return <c>null</c> so audit writers can persist NULL rather than an empty
/// string.
/// </remarks>
public interface INodeIdentityProvider
{
/// <summary>
/// The configured semantic node name, trimmed of surrounding whitespace.
/// <c>null</c> when unconfigured.
/// </summary>
string? NodeName { get; }
}
@@ -0,0 +1,117 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types;
// Commons-018: physically lives under Interfaces/Services/ to match the
// established subfolder convention (REQ-COM-5b), but the namespace stays
// `ZB.MOM.WW.ScadaBridge.Commons.Interfaces` to avoid a cascading update to 9+ consumer
// files across ZB.MOM.WW.ScadaBridge.SiteRuntime, ZB.MOM.WW.ScadaBridge.AuditLog and ZB.MOM.WW.ScadaBridge.Host.
// Adopting the canonical `ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services` namespace
// can be picked up alongside any future Commons-wide namespace tidy-up.
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces;
/// <summary>
/// Site-local source of truth for cached-operation tracking
/// (<c>ExternalSystem.CachedCall</c> / <c>Database.CachedWrite</c>) — alongside the
/// Store-and-Forward buffer, this is the row that <c>Tracking.Status(id)</c>
/// reads (Audit Log #23 / M3). One row per <see cref="TrackedOperationId"/>;
/// terminal rows are purged after a configurable retention window
/// (default 7 days).
/// </summary>
/// <remarks>
/// <para>
/// The store is intentionally a thin write-API on top of SQLite — not a
/// dispatcher. Status transitions follow
/// <c>Submitted → Retrying → Delivered / Parked / Failed / Discarded</c>; rows
/// in a terminal state never roll back. Implementations must:
/// <list type="bullet">
/// <item><description><see cref="RecordEnqueueAsync"/> is insert-if-not-exists
/// (caller-supplied id is the idempotency key — duplicate enqueues are no-ops).</description></item>
/// <item><description><see cref="RecordAttemptAsync"/> only updates non-terminal rows.</description></item>
/// <item><description><see cref="RecordTerminalAsync"/> only flips a non-terminal row to terminal.</description></item>
/// <item><description><see cref="PurgeTerminalAsync"/> deletes terminal rows whose
/// <c>TerminalAtUtc</c> is strictly older than the supplied threshold.</description></item>
/// </list>
/// </para>
/// </remarks>
public interface IOperationTrackingStore
{
/// <summary>
/// Insert a new tracking row in <c>Submitted</c> state with <c>RetryCount = 0</c>.
/// Idempotent — a duplicate id is silently ignored (the existing row is left
/// untouched), matching the at-least-once semantics of the calling site
/// store-and-forward path.
/// </summary>
/// <param name="id">Unique operation ID (idempotency key).</param>
/// <param name="kind">Kind of operation (e.g., cached call type).</param>
/// <param name="targetSummary">Optional summary of the operation target.</param>
/// <param name="sourceInstanceId">Optional ID of the source instance.</param>
/// <param name="sourceScript">Optional name of the source script.</param>
/// <param name="sourceNode">Optional source node identifier.</param>
/// <param name="ct">Cancellation token.</param>
Task RecordEnqueueAsync(
TrackedOperationId id,
string kind,
string? targetSummary,
string? sourceInstanceId,
string? sourceScript,
string? sourceNode,
CancellationToken ct = default);
/// <summary>
/// Advance an in-flight tracking row's status, retry counter, and most-
/// recent error/HTTP-status. Terminal rows (<see cref="RecordTerminalAsync"/>
/// already applied) are NOT mutated — the operation has reached its final
/// outcome and any late-arriving attempt telemetry is dropped on the floor.
/// </summary>
/// <param name="id">Operation ID to update.</param>
/// <param name="status">Current operation status.</param>
/// <param name="retryCount">Number of retry attempts.</param>
/// <param name="lastError">Optional error message from the last attempt.</param>
/// <param name="httpStatus">Optional HTTP status code from the last attempt.</param>
/// <param name="ct">Cancellation token.</param>
Task RecordAttemptAsync(
TrackedOperationId id,
string status,
int retryCount,
string? lastError,
int? httpStatus,
CancellationToken ct = default);
/// <summary>
/// Flip a non-terminal tracking row to terminal — sets
/// <c>TerminalAtUtc = now</c> and writes the final status / error. A row
/// already in terminal state is left untouched (first-write-wins).
/// </summary>
/// <param name="id">Operation ID to mark as terminal.</param>
/// <param name="status">Final operation status.</param>
/// <param name="lastError">Optional final error message.</param>
/// <param name="httpStatus">Optional final HTTP status code.</param>
/// <param name="ct">Cancellation token.</param>
Task RecordTerminalAsync(
TrackedOperationId id,
string status,
string? lastError,
int? httpStatus,
CancellationToken ct = default);
/// <summary>
/// Return the latest snapshot for the supplied id, or <c>null</c> when no
/// tracking row exists (purged or never recorded).
/// </summary>
/// <param name="id">Operation ID to fetch status for.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Tracking status snapshot, or null if not found.</returns>
Task<TrackingStatusSnapshot?> GetStatusAsync(
TrackedOperationId id,
CancellationToken ct = default);
/// <summary>
/// Delete terminal rows whose <c>TerminalAtUtc</c> is strictly older than
/// <paramref name="olderThanUtc"/>. Non-terminal rows are kept regardless
/// of age (the operation is still in flight).
/// </summary>
/// <param name="olderThanUtc">Cutoff timestamp; rows terminal before this are deleted.</param>
/// <param name="ct">Cancellation token.</param>
Task PurgeTerminalAsync(
DateTime olderThanUtc,
CancellationToken ct = default);
}
@@ -0,0 +1,57 @@
// Commons-018: physically lives under Interfaces/Services/ to match the
// established subfolder convention (REQ-COM-5b), but the namespace stays
// `ZB.MOM.WW.ScadaBridge.Commons.Interfaces` to avoid a cascading update to consumers
// across ZB.MOM.WW.ScadaBridge.AuditLog and ZB.MOM.WW.ScadaBridge.ConfigurationDatabase. Adopting
// the canonical `ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services` namespace can be
// picked up alongside any future Commons-wide namespace tidy-up.
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces;
/// <summary>
/// Abstraction over the central AuditLog partition-function roll-forward
/// operation. M6-T5 introduces a daily-cadence hosted service
/// (<c>AuditLogPartitionMaintenanceService</c>) that calls
/// <see cref="EnsureLookaheadAsync"/> to make sure
/// <c>pf_AuditLog_Month</c> always has at least <c>LookaheadMonths</c> of
/// future boundaries available — otherwise inserts past the highest
/// boundary land in a single ever-growing tail partition that
/// <c>SwitchOutPartitionAsync</c> cannot purge cleanly.
/// </summary>
/// <remarks>
/// <para>
/// The interface lives in <c>ZB.MOM.WW.ScadaBridge.Commons</c> so the central hosted
/// service in <c>ZB.MOM.WW.ScadaBridge.AuditLog</c> can depend on it without taking a
/// reference on <c>ZB.MOM.WW.ScadaBridge.ConfigurationDatabase</c>; the EF-based
/// implementation ships in
/// <c>ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Maintenance.AuditLogPartitionMaintenance</c>
/// and is registered by <c>AddConfigurationDatabase</c>.
/// </para>
/// <para>
/// Both methods read <c>sys.partition_range_values</c> / mutate
/// <c>pf_AuditLog_Month</c> via raw SQL — there is no EF model for a
/// partition function. The interface deliberately exposes only the two
/// operations the hosted service needs; it is not a general partition-DDL
/// surface.
/// </para>
/// </remarks>
public interface IPartitionMaintenance
{
/// <summary>
/// Splits new monthly boundaries on <c>pf_AuditLog_Month</c> so the
/// function covers at least <paramref name="lookaheadMonths"/> future
/// months relative to <see cref="DateTime.UtcNow"/>. Idempotent — a
/// boundary that already exists is skipped rather than re-issued.
/// Returns the boundaries actually added, in chronological order.
/// </summary>
/// <param name="lookaheadMonths">Number of future monthly boundaries to ensure exist.</param>
/// <param name="ct">Cancellation token for the SQL operation.</param>
Task<IReadOnlyList<DateTime>> EnsureLookaheadAsync(int lookaheadMonths, CancellationToken ct = default);
/// <summary>
/// Reads the current maximum boundary value from
/// <c>sys.partition_range_values</c> for <c>pf_AuditLog_Month</c>.
/// Returns <c>null</c> when the partition function does not exist or
/// has no boundaries.
/// </summary>
/// <param name="ct">Cancellation token for the SQL operation.</param>
Task<DateTime?> GetMaxBoundaryAsync(CancellationToken ct = default);
}
@@ -0,0 +1,139 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
/// <summary>
/// Site-local audit-log queue surface consumed by the site
/// <c>SiteAuditTelemetryActor</c> drain loop and the M6
/// <c>SiteStreamGrpcServer.PullAuditEvents</c> reconciliation handler.
/// Extracted from <c>SqliteAuditWriter</c> so both consumers can be
/// unit-tested against a stub without touching SQLite; the
/// <c>SqliteAuditWriter</c> production type implements this interface
/// and DI wires the same singleton instance to every consumer.
/// </summary>
/// <remarks>
/// Lives in Commons (rather than alongside <c>SqliteAuditWriter</c> in
/// <c>ZB.MOM.WW.ScadaBridge.AuditLog</c>) because <c>ZB.MOM.WW.ScadaBridge.Communication</c> — which
/// hosts the M6 gRPC pull handler — must depend on this interface and
/// <c>ZB.MOM.WW.ScadaBridge.AuditLog</c> already depends on <c>ZB.MOM.WW.ScadaBridge.Communication</c>.
/// Pulling the interface up to Commons breaks the would-be cycle while
/// keeping the implementation in the AuditLog component.
///
/// Only the methods the drain and pull paths need are exposed — the
/// hot-path <c>WriteAsync</c> stays on <see cref="IAuditWriter"/>
/// (script-thread surface), separated by concern so each side can be
/// mocked independently.
/// </remarks>
public interface ISiteAuditQueue
{
/// <summary>
/// Returns up to <paramref name="limit"/> rows currently in
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditForwardState.Pending"/>,
/// oldest first. Idempotent — repeated calls before
/// <see cref="MarkForwardedAsync"/> will yield the same rows again.
/// </summary>
/// <remarks>
/// AuditLog-001: cached-lifecycle <see cref="AuditEvent.Kind"/>s
/// (<see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditKind.CachedSubmit"/>,
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditKind.ApiCallCached"/>,
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditKind.DbWriteCached"/>,
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditKind.CachedResolve"/>) are
/// EXCLUDED from this result — they ride the combined-telemetry drain via
/// <see cref="ReadPendingCachedTelemetryAsync"/> + the central
/// <c>OnCachedTelemetryAsync</c> dual-write transaction. The audit-only
/// drain handled by this method covers everything else (sync ApiCall /
/// DbWrite, NotifySend, InboundRequest, etc.).
/// </remarks>
/// <param name="limit">Maximum number of rows to return.</param>
/// <param name="ct">Cancellation token.</param>
Task<IReadOnlyList<AuditEvent>> ReadPendingAsync(int limit, CancellationToken ct = default);
/// <summary>
/// AuditLog-001: returns up to <paramref name="limit"/> rows in
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditForwardState.Pending"/>
/// whose <see cref="AuditEvent.Kind"/> belongs to the cached-call lifecycle
/// vocabulary (<see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditKind.CachedSubmit"/>,
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditKind.ApiCallCached"/>,
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditKind.DbWriteCached"/>,
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditKind.CachedResolve"/>),
/// oldest first. The site-side <c>SiteAuditTelemetryActor</c> drains these
/// rows separately, joining each with the matching operational tracking row
/// (<c>IOperationTrackingStore.GetStatusAsync</c>) before pushing the
/// combined <c>CachedTelemetryBatch</c> via
/// <c>ISiteStreamAuditClient.IngestCachedTelemetryAsync</c>. Idempotent —
/// repeated calls before <see cref="MarkForwardedAsync"/> yield the same
/// rows again.
/// </summary>
/// <remarks>
/// The two-drain partition is the production wiring of the combined-telemetry
/// transport specified in Component-AuditLog.md §"Cached Operations —
/// Combined Telemetry": cached rows MUST flow with their matching
/// <c>SiteCalls</c> upsert through one MS SQL transaction at central. The
/// pre-AuditLog-001 implementation drained cached rows through the
/// audit-only path, leaving the operational half unsent and the central
/// dual-write handler unreachable. Returning them via this dedicated read
/// surface lets the new drain join with the tracking store before push.
/// </remarks>
/// <param name="limit">Maximum number of rows to return.</param>
/// <param name="ct">Cancellation token.</param>
Task<IReadOnlyList<AuditEvent>> ReadPendingCachedTelemetryAsync(int limit, CancellationToken ct = default);
/// <summary>
/// Flips the supplied EventIds from
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditForwardState.Pending"/> to
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditForwardState.Forwarded"/>.
/// Non-existent or already-forwarded ids are silent no-ops.
/// </summary>
/// <param name="eventIds">Event IDs to mark as forwarded.</param>
/// <param name="ct">Cancellation token.</param>
Task MarkForwardedAsync(IReadOnlyList<Guid> eventIds, CancellationToken ct = default);
/// <summary>
/// M6 reconciliation-pull read surface: returns up to <paramref name="batchSize"/>
/// rows whose <see cref="AuditEvent.OccurredAtUtc"/> &gt;= <paramref name="sinceUtc"/>
/// and whose <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditForwardState"/> is still
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditForwardState.Pending"/> or
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditForwardState.Forwarded"/>.
/// </summary>
/// <remarks>
/// Rows in the brief race window between site-Forwarded and central-ingest are
/// intentionally included: the central reconciliation puller dedups on
/// <see cref="AuditEvent.EventId"/>, so re-shipping is safe and avoids losing rows
/// whose telemetry ack was acted on locally but never landed centrally. Ordering
/// is oldest <see cref="AuditEvent.OccurredAtUtc"/> first with
/// <see cref="AuditEvent.EventId"/> as the deterministic tiebreaker.
/// </remarks>
/// <param name="sinceUtc">Lower bound timestamp (UTC).</param>
/// <param name="batchSize">Maximum number of rows to return.</param>
/// <param name="ct">Cancellation token.</param>
Task<IReadOnlyList<AuditEvent>> ReadPendingSinceAsync(
DateTime sinceUtc, int batchSize, CancellationToken ct = default);
/// <summary>
/// M6 reconciliation-pull commit surface: flips the supplied EventIds to
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditForwardState.Reconciled"/>,
/// but ONLY for rows currently in
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditForwardState.Pending"/> or
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditForwardState.Forwarded"/>.
/// Rows already in <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditForwardState.Reconciled"/>
/// are left untouched (idempotent re-call). Non-existent ids are silent no-ops.
/// </summary>
/// <param name="eventIds">Event IDs to mark as reconciled.</param>
/// <param name="ct">Cancellation token.</param>
Task MarkReconciledAsync(IReadOnlyList<Guid> eventIds, CancellationToken ct = default);
/// <summary>
/// M6 Bundle E (T6) health-metric surface: returns a point-in-time snapshot
/// of the site queue's pending count + oldest pending timestamp + on-disk
/// SQLite file size. Surfaced on
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Messages.Health.SiteHealthReport"/> as
/// <c>SiteAuditBacklog</c> by the periodic <c>SiteAuditBacklogReporter</c>
/// hosted service so a stuck site→central drain is visible on the central
/// health dashboard. Safe to call concurrently with hot-path writes —
/// implementations are expected to take the same connection lock used by
/// the hot-path INSERT batch and the drain queries.
/// </summary>
/// <param name="ct">Cancellation token.</param>
Task<SiteAuditBacklogSnapshot> GetBacklogStatsAsync(CancellationToken ct = default);
}
@@ -0,0 +1,27 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
/// <summary>
/// Service the bundle importer sets to thread a BundleImportId through to the
/// audit log entries emitted by the audited repository methods invoked during
/// ApplyAsync. AuditService reads this and stamps every AuditLogEntry it writes.
/// <para>
/// Thread-safety / concurrency contract (Transport-009): the in-tree
/// implementation backs <see cref="BundleImportId"/> with an
/// <see cref="System.Threading.AsyncLocal{T}"/> so each logical asynchronous
/// call chain — every distinct <c>BundleImporter.ApplyAsync</c> invocation —
/// observes its own value, even when two imports share the same DI scope (e.g.
/// awaited via <c>Task.WhenAll</c> on a single Blazor circuit, or driven by a
/// misconfigured singleton registration). The value flows through every
/// <c>await</c> naturally; no cross-contamination of BundleImportIds between
/// concurrent imports.
/// </para>
/// <para>
/// Alternative implementations (e.g. ambient-context-free explicit-parameter
/// threading) MUST preserve the same per-call-context isolation guarantee.
/// </para>
/// </summary>
public interface IAuditCorrelationContext
{
/// <summary>Gets or sets the bundle import id used to correlate audit rows written during a bundle apply operation. Implementations MUST isolate the value per-logical-call-context to prevent concurrent imports from cross-contaminating audit rows.</summary>
Guid? BundleImportId { get; set; }
}
@@ -0,0 +1,21 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
public interface IBundleExporter
{
/// <summary>
/// Exports the selected artifacts as an encrypted or plain bundle stream.
/// </summary>
/// <param name="selection">Specifies which artifact types and ids to include in the bundle.</param>
/// <param name="user">Username of the operator performing the export, stamped in the manifest.</param>
/// <param name="sourceEnvironment">Environment label stamped in the bundle manifest.</param>
/// <param name="passphrase">Optional passphrase to encrypt the bundle; null produces an unencrypted bundle.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<Stream> ExportAsync(
ExportSelection selection,
string user,
string sourceEnvironment,
string? passphrase,
CancellationToken cancellationToken = default);
}
@@ -0,0 +1,34 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
public interface IBundleImporter
{
/// <summary>
/// Validates and decrypts the bundle stream, opens a session, and returns session metadata.
/// </summary>
/// <param name="bundleStream">Stream containing the bundle zip archive.</param>
/// <param name="passphrase">Optional passphrase for decrypting an encrypted bundle.</param>
/// <param name="ct">Cancellation token.</param>
Task<BundleSession> LoadAsync(Stream bundleStream, string? passphrase, CancellationToken ct = default);
/// <summary>
/// Diffs the loaded bundle against the target database and returns a per-artifact preview.
/// </summary>
/// <param name="sessionId">Session id returned by <see cref="LoadAsync"/>.</param>
/// <param name="ct">Cancellation token.</param>
Task<ImportPreview> PreviewAsync(Guid sessionId, CancellationToken ct = default);
/// <summary>
/// Applies the chosen conflict resolutions and commits the import transaction.
/// </summary>
/// <param name="sessionId">Session id returned by <see cref="LoadAsync"/>.</param>
/// <param name="resolutions">Per-artifact conflict resolutions from the preview step.</param>
/// <param name="user">Username of the operator performing the import, stamped in audit rows.</param>
/// <param name="ct">Cancellation token.</param>
Task<ImportResult> ApplyAsync(
Guid sessionId,
IReadOnlyList<ImportResolution> resolutions,
string user,
CancellationToken ct = default);
}
@@ -0,0 +1,43 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
public interface IBundleSessionStore
{
/// <summary>Stores the session and returns it; overwrites any existing session with the same id.</summary>
/// <param name="session">The session to store.</param>
BundleSession Open(BundleSession session);
/// <summary>Returns the session for the given id, or null if not found or expired.</summary>
/// <param name="sessionId">The session identifier to look up.</param>
BundleSession? Get(Guid sessionId);
/// <summary>Removes the session for the given id, if present.</summary>
/// <param name="sessionId">The session identifier to remove.</param>
void Remove(Guid sessionId);
/// <summary>Removes all sessions whose expiry has passed.</summary>
void EvictExpired();
/// <summary>
/// T-003: returns the current unlock-failure count for a bundle keyed by its
/// content hash. The counter is server-owned so a second tab / CLI caller
/// cannot side-step the lockout by re-uploading the same bytes.
/// </summary>
/// <param name="bundleContentHash">SHA-256 hex from <c>BundleManifest.ContentHash</c>.</param>
/// <returns>Number of recorded failures for this bundle (0 if none, or if any record has expired).</returns>
int GetUnlockFailureCount(string bundleContentHash);
/// <summary>
/// T-003: atomically increments the unlock-failure counter for a bundle and
/// returns the new count. Tracking is scoped by content hash so retries
/// against identical bundle bytes are throttled regardless of client.
/// </summary>
/// <param name="bundleContentHash">SHA-256 hex from <c>BundleManifest.ContentHash</c>.</param>
int IncrementUnlockFailureCount(string bundleContentHash);
/// <summary>
/// T-003: clears the unlock-failure counter for a bundle (called on a
/// successful unlock so a legitimate operator who eventually types the
/// right passphrase is not penalised for earlier typos).
/// </summary>
/// <param name="bundleContentHash">SHA-256 hex from <c>BundleManifest.ContentHash</c>.</param>
void ClearUnlockFailures(string bundleContentHash);
}
@@ -0,0 +1,8 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts;
public record ArtifactDeploymentResponse(
string DeploymentId,
string SiteId,
bool Success,
string? ErrorMessage,
DateTimeOffset Timestamp);
@@ -0,0 +1,8 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts;
public record DataConnectionArtifact(
string Name,
string Protocol,
string? PrimaryConfigurationJson,
string? BackupConfigurationJson,
int FailoverRetryCount = 3);
@@ -0,0 +1,7 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts;
public record DatabaseConnectionArtifact(
string Name,
string ConnectionString,
int MaxRetries,
TimeSpan RetryDelay);
@@ -0,0 +1,11 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts;
public record DeployArtifactsCommand(
string DeploymentId,
IReadOnlyList<SharedScriptArtifact>? SharedScripts,
IReadOnlyList<ExternalSystemArtifact>? ExternalSystems,
IReadOnlyList<DatabaseConnectionArtifact>? DatabaseConnections,
IReadOnlyList<NotificationListArtifact>? NotificationLists,
IReadOnlyList<DataConnectionArtifact>? DataConnections,
IReadOnlyList<SmtpConfigurationArtifact>? SmtpConfigurations,
DateTimeOffset Timestamp);
@@ -0,0 +1,8 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts;
public record ExternalSystemArtifact(
string Name,
string EndpointUrl,
string AuthType,
string? AuthConfiguration,
string? MethodDefinitionsJson);
@@ -0,0 +1,5 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts;
public record NotificationListArtifact(
string Name,
IReadOnlyList<string> RecipientEmails);
@@ -0,0 +1,7 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts;
public record SharedScriptArtifact(
string Name,
string Code,
string? ParameterDefinitions,
string? ReturnDefinition);
@@ -0,0 +1,11 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts;
public record SmtpConfigurationArtifact(
string Name,
string Server,
int Port,
string AuthMode,
string FromAddress,
string? Username,
string? Password,
string? OAuthConfig);
@@ -0,0 +1,20 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
/// <summary>
/// Akka message sent to the central <c>AuditLogIngestActor</c> (Audit Log #23,
/// M2 site-sync pipeline) carrying a batch of <see cref="AuditEvent"/> rows
/// decoded by the <c>SiteStreamGrpcServer</c> from a site's
/// <c>IngestAuditEvents</c> gRPC RPC. The actor stamps
/// <see cref="AuditEvent.IngestedAtUtc"/> and writes the rows idempotently to
/// the central <c>AuditLog</c> table.
/// </summary>
/// <remarks>
/// Lives in <c>ZB.MOM.WW.ScadaBridge.Commons</c> rather than <c>ZB.MOM.WW.ScadaBridge.AuditLog</c>
/// because the gRPC server in <c>ZB.MOM.WW.ScadaBridge.Communication</c> needs to construct
/// it, and <c>ZB.MOM.WW.ScadaBridge.AuditLog</c> already references
/// <c>ZB.MOM.WW.ScadaBridge.Communication</c> (the proto DTOs live there). Putting the
/// message in Commons avoids a project-reference cycle.
/// </remarks>
public sealed record IngestAuditEventsCommand(IReadOnlyList<AuditEvent> Events);
@@ -0,0 +1,11 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
/// <summary>
/// Reply from the central <c>AuditLogIngestActor</c> for an
/// <see cref="IngestAuditEventsCommand"/>. <see cref="AcceptedEventIds"/> lists
/// every row the actor considers durably persisted at central — including ids
/// that were already present before the call (first-write-wins idempotency).
/// The gRPC handler echoes these ids back over the wire as the <c>IngestAck</c>
/// the site uses to flip rows to <c>Forwarded</c>.
/// </summary>
public sealed record IngestAuditEventsReply(IReadOnlyList<Guid> AcceptedEventIds);
@@ -0,0 +1,30 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
/// <summary>
/// Akka message sent to the central <c>AuditLogIngestActor</c> (Audit Log #23 M3
/// Bundle D dual-write transaction) carrying a batch of combined audit +
/// site-call telemetry packets decoded by the <c>SiteStreamGrpcServer</c> from a
/// site's <c>IngestCachedTelemetry</c> gRPC RPC. For each entry the actor writes
/// the <see cref="AuditEvent"/> row AND the <see cref="SiteCall"/> upsert inside
/// a single MS SQL transaction — both succeed or both roll back, so the audit
/// and operational mirrors never drift mid-row.
/// </summary>
/// <remarks>
/// Lives in <c>ZB.MOM.WW.ScadaBridge.Commons</c> for the same reason as
/// <c>IngestAuditEventsCommand</c>: the gRPC server in
/// <c>ZB.MOM.WW.ScadaBridge.Communication</c> constructs it and <c>ZB.MOM.WW.ScadaBridge.AuditLog</c>
/// already references Communication. Putting the message in Commons avoids a
/// project-reference cycle.
/// </remarks>
public sealed record IngestCachedTelemetryCommand(IReadOnlyList<CachedTelemetryEntry> Entries);
/// <summary>
/// One lifecycle event of a cached call: the <see cref="AuditEvent"/> to insert
/// (idempotent on <see cref="AuditEvent.EventId"/>) plus the
/// <see cref="SiteCall"/> to upsert (monotonic on
/// <see cref="SiteCall.TrackedOperationId"/>). The two rows are paired so the
/// central dual-write transaction can commit them atomically.
/// </summary>
public sealed record CachedTelemetryEntry(AuditEvent Audit, SiteCall SiteCall);
@@ -0,0 +1,10 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
/// <summary>
/// Reply from the central <c>AuditLogIngestActor</c> for an
/// <see cref="IngestCachedTelemetryCommand"/>. <see cref="AcceptedEventIds"/>
/// lists every entry whose dual-write transaction (AuditLog INSERT + SiteCalls
/// UPSERT) committed; entries whose transaction rolled back are absent so the
/// site can leave the row Pending and retry on the next drain.
/// </summary>
public sealed record IngestCachedTelemetryReply(IReadOnlyList<Guid> AcceptedEventIds);
@@ -0,0 +1,166 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
/// <summary>
/// Site Calls UI -> Central: paginated, filtered query over the central
/// <c>SiteCalls</c> table (Site Call Audit #22). All filter fields are optional;
/// <see cref="StuckOnly"/> restricts results to stuck cached calls. Mirrors
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification.NotificationOutboxQueryRequest"/>
/// but uses keyset paging (<see cref="AfterCreatedAtUtc"/> + <see cref="AfterId"/>)
/// to match the repository's <c>(CreatedAtUtc DESC, TrackedOperationId DESC)</c>
/// cursor, rather than page numbers.
/// </summary>
/// <remarks>
/// <see cref="ChannelFilter"/> matches the <c>SiteCall.Channel</c> column —
/// <c>"ApiOutbound"</c> or <c>"DbOutbound"</c> (the spec's <c>Kind</c> notion;
/// the entity exposes it as <c>Channel</c>). <see cref="TargetKeyword"/> is an
/// exact-match target filter, consistent with the repository's
/// <see cref="SiteCallQueryFilter.Target"/> predicate.
/// </remarks>
/// <param name="PageSize">
/// Requested page size. The actor clamps this to the <c>[1, 200]</c> range, so
/// the effective ceiling is 200 rows per page regardless of the value sent.
/// </param>
public sealed record SiteCallQueryRequest(
string CorrelationId,
string? StatusFilter,
string? SourceSiteFilter,
string? ChannelFilter,
string? TargetKeyword,
bool StuckOnly,
DateTime? FromUtc,
DateTime? ToUtc,
DateTime? AfterCreatedAtUtc,
Guid? AfterId,
int PageSize,
string? SourceNodeFilter = null);
/// <summary>
/// A single <c>SiteCalls</c> row summarised for the Site Calls UI grid. Carries
/// only the columns the <see cref="ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit.SiteCall"/>
/// entity genuinely exposes — there are no source-instance/script provenance
/// columns on that entity, so unlike
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification.NotificationSummary"/>
/// none are surfaced here.
/// </summary>
/// <remarks>
/// <see cref="HttpStatus"/> is not called out in the Site Call Audit plan, but
/// it is a real (nullable) <see cref="ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit.SiteCall"/>
/// column — the last HTTP status code observed for the call — so it is surfaced
/// here for the grid; <c>null</c> for non-HTTP channels or before a first attempt.
/// </remarks>
public sealed record SiteCallSummary(
Guid TrackedOperationId,
string SourceSite,
string Channel,
string Target,
string Status,
int RetryCount,
string? LastError,
int? HttpStatus,
DateTime CreatedAtUtc,
DateTime UpdatedAtUtc,
DateTime? TerminalAtUtc,
bool IsStuck,
string? SourceNode = null);
/// <summary>
/// Central -> Site Calls UI: paginated response for a <see cref="SiteCallQueryRequest"/>.
/// The keyset cursor of the last row is echoed back as
/// <see cref="NextAfterCreatedAtUtc"/> + <see cref="NextAfterId"/> for the caller
/// to request the following page; both are <c>null</c> when the page was empty.
/// On a repository fault <see cref="Success"/> is <c>false</c>,
/// <see cref="ErrorMessage"/> carries the cause and <see cref="SiteCalls"/> is empty.
/// </summary>
public sealed record SiteCallQueryResponse(
string CorrelationId,
bool Success,
string? ErrorMessage,
IReadOnlyList<SiteCallSummary> SiteCalls,
DateTime? NextAfterCreatedAtUtc,
Guid? NextAfterId);
/// <summary>
/// Site Calls UI -> Central: request for the full detail of a single cached call,
/// for the report detail modal.
/// </summary>
public sealed record SiteCallDetailRequest(
string CorrelationId,
Guid TrackedOperationId);
/// <summary>
/// Central -> Site Calls UI: full detail for one cached call. On a repository
/// fault or missing row, <see cref="Success"/> is <c>false</c> /
/// <see cref="Detail"/> is <c>null</c> and <see cref="ErrorMessage"/> carries
/// the cause.
/// </summary>
public sealed record SiteCallDetailResponse(
string CorrelationId,
bool Success,
string? ErrorMessage,
SiteCallDetail? Detail);
/// <summary>
/// Full <c>SiteCalls</c> row detail for the report detail modal — every field
/// on the <see cref="ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit.SiteCall"/> entity,
/// including <see cref="LastError"/> and the <see cref="IngestedAtUtc"/>
/// timestamp the grid summary omits.
/// </summary>
public sealed record SiteCallDetail(
Guid TrackedOperationId,
string SourceSite,
string Channel,
string Target,
string Status,
int RetryCount,
string? LastError,
int? HttpStatus,
DateTime CreatedAtUtc,
DateTime UpdatedAtUtc,
DateTime? TerminalAtUtc,
DateTime IngestedAtUtc,
string? SourceNode = null);
/// <summary>
/// Site Calls UI -> Central: request for the global <c>SiteCalls</c> KPI summary.
/// Mirrors <see cref="ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification.NotificationKpiRequest"/>.
/// </summary>
public sealed record SiteCallKpiRequest(
string CorrelationId);
/// <summary>
/// Central -> Site Calls UI: KPI summary for the Site Calls dashboard. On a
/// repository fault <see cref="Success"/> is <c>false</c>,
/// <see cref="ErrorMessage"/> carries the cause, and the KPI fields are
/// zeroed/<c>null</c>.
/// </summary>
public sealed record SiteCallKpiResponse(
string CorrelationId,
bool Success,
string? ErrorMessage,
int BufferedCount,
int ParkedCount,
int FailedLastInterval,
int DeliveredLastInterval,
TimeSpan? OldestPendingAge,
int StuckCount);
/// <summary>
/// Site Calls UI -> Central: request for the per-source-site <c>SiteCalls</c>
/// KPI breakdown. Mirrors
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification.PerSiteNotificationKpiRequest"/>.
/// </summary>
public sealed record PerSiteSiteCallKpiRequest(
string CorrelationId);
/// <summary>
/// Central -> Site Calls UI: per-site KPI breakdown for the Site Calls KPIs
/// page. On a repository fault <see cref="Success"/> is <c>false</c>,
/// <see cref="ErrorMessage"/> carries the cause, and <see cref="Sites"/> is empty.
/// </summary>
public sealed record PerSiteSiteCallKpiResponse(
string CorrelationId,
bool Success,
string? ErrorMessage,
IReadOnlyList<SiteCallSiteKpiSnapshot> Sites);
@@ -0,0 +1,113 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
/// <summary>
/// Outcome of a Site Call Audit (#22) Retry/Discard relay — distinguishes the
/// three cases the Central UI Site Calls page must surface differently.
/// </summary>
/// <remarks>
/// The "site unreachable" case is deliberately separate from
/// <see cref="OperationFailed"/>: central is an eventually-consistent mirror,
/// not the source of truth, so a relay that never reaches the owning site is a
/// transient transport condition the operator can retry — not a failed
/// operation. The UI shows "site unreachable" rather than a generic error.
/// </remarks>
public enum SiteCallRelayOutcome
{
/// <summary>
/// The owning site received the relay command and applied the action to its
/// Store-and-Forward buffer (the parked cached call was reset to retry, or
/// discarded). The corrected state reaches central later via telemetry.
/// </summary>
Applied,
/// <summary>
/// The owning site received the relay command but found nothing to do — no
/// parked row matched the tracked id (already delivered/discarded, or no
/// longer <c>Parked</c>). A definitive answer from the site, not a failure.
/// </summary>
NotParked,
/// <summary>
/// The owning site could not be reached (offline / no ClusterClient route /
/// relay timed out). The action was NOT applied; the operator may retry once
/// the site is back online.
/// </summary>
SiteUnreachable,
/// <summary>
/// The owning site was reached but reported it could not apply the action
/// (its parked-message handler was unavailable or its store faulted).
/// </summary>
OperationFailed,
}
/// <summary>
/// Central UI → Site Call Audit: relay a Retry of a parked cached call to its
/// owning site. The owning site performs the actual retry on its
/// Store-and-Forward buffer — central never mutates the central <c>SiteCalls</c>
/// mirror row. Mirrors
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification.RetryNotificationRequest"/>
/// but carries <see cref="SourceSite"/> (the relay target) and answers with a
/// distinct site-unreachable outcome.
/// </summary>
/// <param name="CorrelationId">Request correlation id, echoed on the response.</param>
/// <param name="TrackedOperationId">
/// The cached operation to retry — the PK of the central <c>SiteCalls</c> row
/// and the S&amp;F buffer message id at the owning site.
/// </param>
/// <param name="SourceSite">
/// The owning site (<c>SiteCall.SourceSite</c>) the relay is routed to.
/// </param>
public sealed record RetrySiteCallRequest(
string CorrelationId,
Guid TrackedOperationId,
string SourceSite);
/// <summary>
/// Site Call Audit → Central UI: result of a <see cref="RetrySiteCallRequest"/>.
/// </summary>
/// <param name="CorrelationId">Echoed request correlation id.</param>
/// <param name="Outcome">
/// The relay outcome — <see cref="SiteCallRelayOutcome.Applied"/>,
/// <see cref="SiteCallRelayOutcome.NotParked"/>,
/// <see cref="SiteCallRelayOutcome.SiteUnreachable"/> or
/// <see cref="SiteCallRelayOutcome.OperationFailed"/>.
/// </param>
/// <param name="Success">
/// Convenience flag — <c>true</c> only for <see cref="SiteCallRelayOutcome.Applied"/>.
/// </param>
/// <param name="SiteReachable">
/// <c>false</c> only for <see cref="SiteCallRelayOutcome.SiteUnreachable"/>; lets
/// the UI distinguish "site offline" from "operation failed" without switching
/// on the enum.
/// </param>
/// <param name="ErrorMessage">
/// Human-readable detail for a non-applied outcome; <c>null</c> on success.
/// </param>
public sealed record RetrySiteCallResponse(
string CorrelationId,
SiteCallRelayOutcome Outcome,
bool Success,
bool SiteReachable,
string? ErrorMessage);
/// <summary>
/// Central UI → Site Call Audit: relay a Discard of a parked cached call to its
/// owning site. See <see cref="RetrySiteCallRequest"/> for the source-of-truth
/// and routing rationale.
/// </summary>
public sealed record DiscardSiteCallRequest(
string CorrelationId,
Guid TrackedOperationId,
string SourceSite);
/// <summary>
/// Site Call Audit → Central UI: result of a <see cref="DiscardSiteCallRequest"/>.
/// Same shape as <see cref="RetrySiteCallResponse"/>.
/// </summary>
public sealed record DiscardSiteCallResponse(
string CorrelationId,
SiteCallRelayOutcome Outcome,
bool Success,
bool SiteReachable,
string? ErrorMessage);
@@ -0,0 +1,19 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
/// <summary>
/// Akka message sent to the central <c>SiteCallAuditActor</c> (Site Call Audit
/// #22, Audit Log #23 M3 Bundle C) carrying one <see cref="SiteCall"/> row to
/// be persisted via <c>ISiteCallAuditRepository.UpsertAsync</c>. The repository
/// performs an insert-if-not-exists then monotonic update — duplicate gRPC
/// packets and reconciliation pulls can both feed the actor without rolling
/// state back.
/// </summary>
/// <remarks>
/// Lives in <c>ZB.MOM.WW.ScadaBridge.Commons</c> rather than <c>ZB.MOM.WW.ScadaBridge.SiteCallAudit</c>
/// so the gRPC server in <c>ZB.MOM.WW.ScadaBridge.Communication</c> can construct it
/// without taking a project reference on the actor's host project (Bundle D
/// adds the IngestCachedTelemetry RPC that will Tell this command).
/// </remarks>
public sealed record UpsertSiteCallCommand(SiteCall SiteCall);
@@ -0,0 +1,14 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types;
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
/// <summary>
/// Reply from the central <c>SiteCallAuditActor</c> for an
/// <see cref="UpsertSiteCallCommand"/>. <see cref="Accepted"/> is <c>true</c>
/// when the upsert reached the repository without throwing (including the
/// monotonic-no-op case where the stored status' rank wins) and <c>false</c>
/// when persistence raised an exception. The actor itself stays alive in
/// either case — audit-write failures must NEVER abort the user-facing action
/// (Audit Log #23 §13).
/// </summary>
public sealed record UpsertSiteCallReply(TrackedOperationId TrackedOperationId, bool Accepted);
@@ -0,0 +1,6 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Communication;
public record RoutingMetadata(
string TargetSiteId,
string? TargetInstanceUniqueName,
string CorrelationId);
@@ -0,0 +1,6 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Communication;
public record SiteIdentity(
string SiteId,
string NodeHostname,
bool IsActive);
@@ -0,0 +1,12 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
/// <summary>
/// Command to create a new data connection actor for a specific protocol.
/// Sent from DeploymentManagerActor to DCL Manager Actor.
/// </summary>
public record CreateConnectionCommand(
string ConnectionName,
string ProtocolType,
IDictionary<string, string> PrimaryConnectionDetails,
IDictionary<string, string>? BackupConnectionDetails = null,
int FailoverRetryCount = 3);
@@ -0,0 +1,14 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
/// <summary>
/// Health metrics for a single data connection, contributed to the site health report.
/// </summary>
public record DataConnectionHealthReport(
string ConnectionName,
ConnectionHealth Status,
int TotalSubscribedTags,
int ResolvedTags,
string ActiveEndpoint,
DateTimeOffset Timestamp);
@@ -0,0 +1,11 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
/// <summary>
/// Request from an Instance Actor to subscribe to tag values through the DCL.
/// </summary>
public record SubscribeTagsRequest(
string CorrelationId,
string InstanceUniqueName,
string ConnectionName,
IReadOnlyList<string> TagPaths,
DateTimeOffset Timestamp);
@@ -0,0 +1,11 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
/// <summary>
/// Response confirming tag subscription registration.
/// </summary>
public record SubscribeTagsResponse(
string CorrelationId,
string InstanceUniqueName,
bool Success,
string? ErrorMessage,
DateTimeOffset Timestamp);
@@ -0,0 +1,21 @@
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
/// <summary>
/// Published by DCL to an Instance Actor when a subscribed tag value changes.
/// </summary>
public record TagValueUpdate(
string ConnectionName,
string TagPath,
object? Value,
QualityCode Quality,
DateTimeOffset Timestamp);
/// <summary>
/// Published by DCL when connection state changes, causing bulk quality updates.
/// </summary>
public record ConnectionQualityChanged(
string ConnectionName,
QualityCode Quality,
DateTimeOffset Timestamp);
@@ -0,0 +1,10 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
/// <summary>
/// Request from an Instance Actor to unsubscribe from all its tags when stopping.
/// </summary>
public record UnsubscribeTagsRequest(
string CorrelationId,
string InstanceUniqueName,
string ConnectionName,
DateTimeOffset Timestamp);
@@ -0,0 +1,21 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
/// <summary>
/// Request to write a value to a device tag through the DCL.
/// Write failures are returned synchronously to the calling script.
/// </summary>
public record WriteTagRequest(
string CorrelationId,
string ConnectionName,
string TagPath,
object? Value,
DateTimeOffset Timestamp);
/// <summary>
/// Response for a device tag write operation.
/// </summary>
public record WriteTagResponse(
string CorrelationId,
bool Success,
string? ErrorMessage,
DateTimeOffset Timestamp);
@@ -0,0 +1,5 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView;
public record DebugSnapshotRequest(
string InstanceUniqueName,
string CorrelationId);
@@ -0,0 +1,9 @@
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView;
public record DebugViewSnapshot(
string InstanceUniqueName,
IReadOnlyList<AttributeValueChanged> AttributeValues,
IReadOnlyList<AlarmStateChanged> AlarmStates,
DateTimeOffset SnapshotTimestamp);
@@ -0,0 +1,5 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView;
public record SubscribeDebugViewRequest(
string InstanceUniqueName,
string CorrelationId);
@@ -0,0 +1,5 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView;
public record UnsubscribeDebugViewRequest(
string InstanceUniqueName,
string CorrelationId);
@@ -0,0 +1,9 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment;
public record DeployInstanceCommand(
string DeploymentId,
string InstanceUniqueName,
string RevisionHash,
string FlattenedConfigurationJson,
string DeployedBy,
DateTimeOffset Timestamp);
@@ -0,0 +1,13 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment;
/// <summary>
/// Central→site query for the currently-applied deployment state of a single
/// instance. Issued by the Deployment Manager before a re-deploy when a prior
/// deployment record is stuck <c>InProgress</c> or <c>Failed</c> due to a
/// timeout, so the site's actual state can be reconciled against the target
/// revision before re-sending a deployment ("Deployment Identity &amp; Idempotency").
/// </summary>
public record DeploymentStateQueryRequest(
string CorrelationId,
string InstanceUniqueName,
DateTimeOffset Timestamp);
@@ -0,0 +1,15 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment;
/// <summary>
/// Site→central response carrying the instance's currently-applied deployment
/// state. If <see cref="IsDeployed"/> is <c>false</c> the instance has no
/// deployed configuration at the site and <see cref="AppliedDeploymentId"/> /
/// <see cref="AppliedRevisionHash"/> are <c>null</c>.
/// </summary>
public record DeploymentStateQueryResponse(
string CorrelationId,
string InstanceUniqueName,
bool IsDeployed,
string? AppliedDeploymentId,
string? AppliedRevisionHash,
DateTimeOffset Timestamp);
@@ -0,0 +1,10 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment;
public record DeploymentStatusResponse(
string DeploymentId,
string InstanceUniqueName,
DeploymentStatus Status,
string? ErrorMessage,
DateTimeOffset Timestamp);
@@ -0,0 +1,6 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment;
public record DeploymentValidationResult(
bool IsValid,
IReadOnlyList<string> Errors,
IReadOnlyList<string> Warnings);
@@ -0,0 +1,7 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment;
public record FlattenedConfigurationSnapshot(
string InstanceUniqueName,
string RevisionHash,
string ConfigurationJson,
DateTimeOffset GeneratedAt);
@@ -0,0 +1,7 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Health;
public record HeartbeatMessage(
string SiteId,
string NodeHostname,
bool IsActive,
DateTimeOffset Timestamp);
@@ -0,0 +1,3 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Health;
public record NodeStatus(string Hostname, bool IsOnline, string Role);
@@ -0,0 +1,53 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Health;
public record SiteHealthReport(
string SiteId,
long SequenceNumber,
DateTimeOffset ReportTimestamp,
IReadOnlyDictionary<string, ConnectionHealth> DataConnectionStatuses,
IReadOnlyDictionary<string, TagResolutionStatus> TagResolutionCounts,
int ScriptErrorCount,
int AlarmEvaluationErrorCount,
IReadOnlyDictionary<string, int> StoreAndForwardBufferDepths,
int DeadLetterCount,
int DeployedInstanceCount,
int EnabledInstanceCount,
int DisabledInstanceCount,
string NodeRole = "Unknown",
string NodeHostname = "",
IReadOnlyDictionary<string, string>? DataConnectionEndpoints = null,
IReadOnlyDictionary<string, TagQualityCounts>? DataConnectionTagQuality = null,
int ParkedMessageCount = 0,
IReadOnlyList<NodeStatus>? ClusterNodes = null,
// Audit Log (#23) M2 Bundle G: per-interval count of FallbackAuditWriter
// primary failures (SQLite throws routed to the drop-oldest ring). Surfaces
// a sustained audit-write outage on /monitoring/health. Defaults to 0 so
// existing producers / tests that don't construct the field stay valid.
int SiteAuditWriteFailures = 0,
// Audit Log (#23) M5 Bundle C: per-interval count of payload-filter
// redactor over-redactions (header / body / SQL parameter stages all
// throwing → field replaced with the "<redacted: redactor error>"
// marker). Surfaces a misconfigured / catastrophic regex on
// /monitoring/health. Defaults to 0 for back-compat with existing
// producers and tests that don't construct the field.
int AuditRedactionFailure = 0,
// Audit Log (#23) M6 Bundle E (T6): point-in-time snapshot of the
// site-local SQLite audit-log queue (pending count, oldest pending row,
// on-disk bytes). Populated by the site-side SiteAuditBacklogReporter
// hosted service every 30 s. Defaults to null so existing producers /
// tests that don't refresh the snapshot stay valid; the central health
// surface treats null as "no data yet" rather than a zeroed queue.
SiteAuditBacklogSnapshot? SiteAuditBacklog = null);
/// <summary>
/// Broadcast wrapper used between central nodes to keep per-node
/// CentralHealthAggregator state in sync. ClusterClient load-balances each
/// incoming SiteHealthReport to one central node; that node re-publishes
/// this wrapper on a DistributedPubSub topic so the peer node's aggregator
/// also processes the report (idempotently — sequence numbers guard against
/// double-counting).
/// </summary>
public record SiteHealthReportReplica(SiteHealthReport Report);
@@ -0,0 +1,3 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Health;
public record TagQualityCounts(int Good, int Bad, int Uncertain);
@@ -0,0 +1,3 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Health;
public record TagResolutionStatus(int TotalSubscribed, int SuccessfullyResolved);
@@ -0,0 +1,85 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi;
/// <summary>
/// Request routed from Inbound API to a site to invoke a script on an instance.
/// Used by Route.To("instanceCode").Call("scriptName", params).
/// </summary>
/// <param name="ParentExecutionId">
/// Audit Log #23 (ParentExecutionId): the spawning execution's <c>ExecutionId</c>
/// — for an inbound-API-routed call this is the inbound request's per-request
/// execution id. The site records it as the routed script execution's
/// <c>ParentExecutionId</c> so a spawned execution points back at its spawner.
/// Additive trailing member — null for requests built before the field existed
/// or for routed calls with no spawning execution (e.g. the Central UI sandbox).
/// </param>
public record RouteToCallRequest(
string CorrelationId,
string InstanceUniqueName,
string ScriptName,
IReadOnlyDictionary<string, object?>? Parameters,
DateTimeOffset Timestamp,
Guid? ParentExecutionId = null);
/// <summary>
/// Response from a Route.To() call.
/// </summary>
public record RouteToCallResponse(
string CorrelationId,
bool Success,
object? ReturnValue,
string? ErrorMessage,
DateTimeOffset Timestamp);
/// <summary>
/// Request to read attribute(s) from a remote instance.
/// </summary>
/// <param name="ParentExecutionId">
/// Audit Log #23 (ParentExecutionId): mirrors <see cref="RouteToCallRequest.ParentExecutionId"/>.
/// For an inbound-API-routed read this is the inbound request's per-request execution id;
/// future site-side audit emission for routed reads can stamp it as <c>ParentExecutionId</c>
/// so the inbound→site execution-tree link survives the read path. Additive trailing
/// member — null for the Central UI sandbox path or for callers built before the field existed.
/// </param>
public record RouteToGetAttributesRequest(
string CorrelationId,
string InstanceUniqueName,
IReadOnlyList<string> AttributeNames,
DateTimeOffset Timestamp,
Guid? ParentExecutionId = null);
/// <summary>
/// Response containing attribute values from a remote instance.
/// </summary>
public record RouteToGetAttributesResponse(
string CorrelationId,
IReadOnlyDictionary<string, object?> Values,
bool Success,
string? ErrorMessage,
DateTimeOffset Timestamp);
/// <summary>
/// Request to write attribute(s) on a remote instance.
/// </summary>
/// <param name="ParentExecutionId">
/// Audit Log #23 (ParentExecutionId): mirrors <see cref="RouteToCallRequest.ParentExecutionId"/>.
/// For an inbound-API-routed write this is the inbound request's per-request execution id;
/// site-side audit emission for the underlying device / static-attribute write can stamp
/// it as <c>ParentExecutionId</c> so the inbound→site execution-tree link survives the
/// write path. Additive trailing member — null for the Central UI sandbox path or for
/// callers built before the field existed.
/// </param>
public record RouteToSetAttributesRequest(
string CorrelationId,
string InstanceUniqueName,
IReadOnlyDictionary<string, string> AttributeValues,
DateTimeOffset Timestamp,
Guid? ParentExecutionId = null);
/// <summary>
/// Response confirming attribute writes on a remote instance.
/// </summary>
public record RouteToSetAttributesResponse(
string CorrelationId,
bool Success,
string? ErrorMessage,
DateTimeOffset Timestamp);

Some files were not shown because too many files have changed in this diff Show More