docs: add XML doc comments across src + Sister Projects section in CLAUDE.md

Bulk CommentChecker pass: fills in <param>/<inheritdoc> tags on public
APIs across all 23 src/ projects so the doc-coverage gate is green. Also
adds a Sister Projects section to CLAUDE.md pointing at the MxAccess
Gateway and OtOpcUa sibling repos, and gitignores local credential
captures (*login*.txt) and the wonder-app-vd03 deploy/ artifacts.
This commit is contained in:
Joseph Doherty
2026-05-28 01:55:24 -04:00
parent 6731845473
commit 1eb6e972b0
381 changed files with 5788 additions and 532 deletions
@@ -6,6 +6,10 @@ namespace ScadaLink.ConfigurationDatabase.Configurations;
public class AuditLogEntryConfiguration : IEntityTypeConfiguration<AuditLogEntry>
{
/// <summary>
/// Configures the EF Core entity mapping for <see cref="AuditLogEntry"/>.
/// </summary>
/// <param name="builder">The entity type builder for <see cref="AuditLogEntry"/>.</param>
public void Configure(EntityTypeBuilder<AuditLogEntry> builder)
{
builder.HasKey(a => a.Id);
@@ -11,6 +11,8 @@ namespace ScadaLink.ConfigurationDatabase.Configurations;
/// </summary>
public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration<AuditEvent>
{
/// <summary>Applies the EF Core type configuration for <see cref="AuditEvent"/> to the model builder.</summary>
/// <param name="builder">The entity type builder to configure.</param>
public void Configure(EntityTypeBuilder<AuditEvent> builder)
{
builder.ToTable("AuditLog");
@@ -7,6 +7,8 @@ namespace ScadaLink.ConfigurationDatabase.Configurations;
public class DeploymentRecordConfiguration : IEntityTypeConfiguration<DeploymentRecord>
{
/// <summary>Configures the EF Core mapping for <see cref="DeploymentRecord"/>.</summary>
/// <param name="builder">The entity type builder.</param>
public void Configure(EntityTypeBuilder<DeploymentRecord> builder)
{
builder.HasKey(d => d.Id);
@@ -43,6 +45,8 @@ public class DeploymentRecordConfiguration : IEntityTypeConfiguration<Deployment
public class DeployedConfigSnapshotConfiguration : IEntityTypeConfiguration<DeployedConfigSnapshot>
{
/// <summary>Configures the EF Core mapping for <see cref="DeployedConfigSnapshot"/>.</summary>
/// <param name="builder">The entity type builder.</param>
public void Configure(EntityTypeBuilder<DeployedConfigSnapshot> builder)
{
builder.HasKey(s => s.Id);
@@ -70,6 +74,8 @@ public class DeployedConfigSnapshotConfiguration : IEntityTypeConfiguration<Depl
public class SystemArtifactDeploymentRecordConfiguration : IEntityTypeConfiguration<SystemArtifactDeploymentRecord>
{
/// <summary>Configures the EF Core mapping for <see cref="SystemArtifactDeploymentRecord"/>.</summary>
/// <param name="builder">The entity type builder.</param>
public void Configure(EntityTypeBuilder<SystemArtifactDeploymentRecord> builder)
{
builder.HasKey(d => d.Id);
@@ -6,6 +6,8 @@ namespace ScadaLink.ConfigurationDatabase.Configurations;
public class ExternalSystemDefinitionConfiguration : IEntityTypeConfiguration<ExternalSystemDefinition>
{
/// <summary>Applies the EF Core entity type configuration for <see cref="ExternalSystemDefinition"/>.</summary>
/// <param name="builder">The entity type builder to configure.</param>
public void Configure(EntityTypeBuilder<ExternalSystemDefinition> builder)
{
builder.HasKey(e => e.Id);
@@ -38,6 +40,8 @@ public class ExternalSystemDefinitionConfiguration : IEntityTypeConfiguration<Ex
public class ExternalSystemMethodConfiguration : IEntityTypeConfiguration<ExternalSystemMethod>
{
/// <summary>Applies the EF Core entity type configuration for <see cref="ExternalSystemMethod"/>.</summary>
/// <param name="builder">The entity type builder to configure.</param>
public void Configure(EntityTypeBuilder<ExternalSystemMethod> builder)
{
builder.HasKey(m => m.Id);
@@ -66,6 +70,8 @@ public class ExternalSystemMethodConfiguration : IEntityTypeConfiguration<Extern
public class DatabaseConnectionDefinitionConfiguration : IEntityTypeConfiguration<DatabaseConnectionDefinition>
{
/// <summary>Applies the EF Core entity type configuration for <see cref="DatabaseConnectionDefinition"/>.</summary>
/// <param name="builder">The entity type builder to configure.</param>
public void Configure(EntityTypeBuilder<DatabaseConnectionDefinition> builder)
{
builder.HasKey(d => d.Id);
@@ -6,6 +6,8 @@ namespace ScadaLink.ConfigurationDatabase.Configurations;
public class ApiKeyConfiguration : IEntityTypeConfiguration<ApiKey>
{
/// <summary>Configures the EF Core mapping for the <see cref="ApiKey"/> entity.</summary>
/// <param name="builder">Entity type builder used to apply the configuration.</param>
public void Configure(EntityTypeBuilder<ApiKey> builder)
{
builder.HasKey(k => k.Id);
@@ -28,6 +30,8 @@ public class ApiKeyConfiguration : IEntityTypeConfiguration<ApiKey>
public class ApiMethodConfiguration : IEntityTypeConfiguration<ApiMethod>
{
/// <summary>Configures the EF Core mapping for the <see cref="ApiMethod"/> entity.</summary>
/// <param name="builder">Entity type builder used to apply the configuration.</param>
public void Configure(EntityTypeBuilder<ApiMethod> builder)
{
builder.HasKey(m => m.Id);
@@ -8,6 +8,8 @@ namespace ScadaLink.ConfigurationDatabase.Configurations;
public class InstanceConfiguration : IEntityTypeConfiguration<Instance>
{
/// <summary>Configures the EF Core mapping for <see cref="Instance"/>.</summary>
/// <param name="builder">The entity type builder.</param>
public void Configure(EntityTypeBuilder<Instance> builder)
{
builder.HasKey(i => i.Id);
@@ -57,6 +59,8 @@ public class InstanceConfiguration : IEntityTypeConfiguration<Instance>
public class InstanceAttributeOverrideConfiguration : IEntityTypeConfiguration<InstanceAttributeOverride>
{
/// <summary>Configures the EF Core mapping for <see cref="InstanceAttributeOverride"/>.</summary>
/// <param name="builder">The entity type builder.</param>
public void Configure(EntityTypeBuilder<InstanceAttributeOverride> builder)
{
builder.HasKey(o => o.Id);
@@ -74,6 +78,8 @@ public class InstanceAttributeOverrideConfiguration : IEntityTypeConfiguration<I
public class InstanceAlarmOverrideConfiguration : IEntityTypeConfiguration<InstanceAlarmOverride>
{
/// <summary>Configures the EF Core mapping for <see cref="InstanceAlarmOverride"/>.</summary>
/// <param name="builder">The entity type builder.</param>
public void Configure(EntityTypeBuilder<InstanceAlarmOverride> builder)
{
builder.HasKey(o => o.Id);
@@ -91,6 +97,8 @@ public class InstanceAlarmOverrideConfiguration : IEntityTypeConfiguration<Insta
public class InstanceConnectionBindingConfiguration : IEntityTypeConfiguration<InstanceConnectionBinding>
{
/// <summary>Configures the EF Core mapping for <see cref="InstanceConnectionBinding"/>.</summary>
/// <param name="builder">The entity type builder.</param>
public void Configure(EntityTypeBuilder<InstanceConnectionBinding> builder)
{
builder.HasKey(b => b.Id);
@@ -110,6 +118,8 @@ public class InstanceConnectionBindingConfiguration : IEntityTypeConfiguration<I
public class AreaConfiguration : IEntityTypeConfiguration<Area>
{
/// <summary>Configures the EF Core mapping for <see cref="Area"/>.</summary>
/// <param name="builder">The entity type builder.</param>
public void Configure(EntityTypeBuilder<Area> builder)
{
builder.HasKey(a => a.Id);
@@ -6,6 +6,8 @@ namespace ScadaLink.ConfigurationDatabase.Configurations;
public class NotificationListConfiguration : IEntityTypeConfiguration<NotificationList>
{
/// <summary>Configures the EF Core mapping for <see cref="NotificationList"/>.</summary>
/// <param name="builder">The entity type builder.</param>
public void Configure(EntityTypeBuilder<NotificationList> builder)
{
builder.HasKey(n => n.Id);
@@ -30,6 +32,8 @@ public class NotificationListConfiguration : IEntityTypeConfiguration<Notificati
public class NotificationRecipientConfiguration : IEntityTypeConfiguration<NotificationRecipient>
{
/// <summary>Configures the EF Core mapping for <see cref="NotificationRecipient"/>.</summary>
/// <param name="builder">The entity type builder.</param>
public void Configure(EntityTypeBuilder<NotificationRecipient> builder)
{
builder.HasKey(r => r.Id);
@@ -46,6 +50,8 @@ public class NotificationRecipientConfiguration : IEntityTypeConfiguration<Notif
public class SmtpConfigurationConfiguration : IEntityTypeConfiguration<SmtpConfiguration>
{
/// <summary>Configures the EF Core mapping for <see cref="SmtpConfiguration"/>.</summary>
/// <param name="builder">The entity type builder.</param>
public void Configure(EntityTypeBuilder<SmtpConfiguration> builder)
{
builder.HasKey(s => s.Id);
@@ -11,6 +11,8 @@ namespace ScadaLink.ConfigurationDatabase.Configurations;
/// </summary>
public class NotificationOutboxConfiguration : IEntityTypeConfiguration<Notification>
{
/// <summary>Configures the EF Core entity type mapping for <see cref="Notification"/>.</summary>
/// <param name="builder">The entity type builder to configure.</param>
public void Configure(EntityTypeBuilder<Notification> builder)
{
builder.HasKey(n => n.NotificationId);
@@ -6,6 +6,8 @@ namespace ScadaLink.ConfigurationDatabase.Configurations;
public class SharedScriptConfiguration : IEntityTypeConfiguration<SharedScript>
{
/// <summary>Configures the EF Core mapping for the <see cref="SharedScript"/> entity.</summary>
/// <param name="builder">Entity type builder used to apply the configuration.</param>
public void Configure(EntityTypeBuilder<SharedScript> builder)
{
builder.HasKey(s => s.Id);
@@ -7,6 +7,10 @@ namespace ScadaLink.ConfigurationDatabase.Configurations;
public class LdapGroupMappingConfiguration : IEntityTypeConfiguration<LdapGroupMapping>
{
/// <summary>
/// Configures the EF Core entity type mapping for <see cref="LdapGroupMapping"/>.
/// </summary>
/// <param name="builder">The entity type builder to configure.</param>
public void Configure(EntityTypeBuilder<LdapGroupMapping> builder)
{
builder.HasKey(m => m.Id);
@@ -32,6 +36,10 @@ public class LdapGroupMappingConfiguration : IEntityTypeConfiguration<LdapGroupM
public class SiteScopeRuleConfiguration : IEntityTypeConfiguration<SiteScopeRule>
{
/// <summary>
/// Configures the EF Core entity type mapping for <see cref="SiteScopeRule"/>.
/// </summary>
/// <param name="builder">The entity type builder to configure.</param>
public void Configure(EntityTypeBuilder<SiteScopeRule> builder)
{
builder.HasKey(r => r.Id);
@@ -14,6 +14,10 @@ namespace ScadaLink.ConfigurationDatabase.Configurations;
/// </summary>
public class SiteCallEntityTypeConfiguration : IEntityTypeConfiguration<SiteCall>
{
/// <summary>
/// Configures the EF Core entity type mapping for <see cref="SiteCall"/>.
/// </summary>
/// <param name="builder">The entity type builder to configure.</param>
public void Configure(EntityTypeBuilder<SiteCall> builder)
{
builder.ToTable("SiteCalls");
@@ -6,6 +6,8 @@ namespace ScadaLink.ConfigurationDatabase.Configurations;
public class SiteConfiguration : IEntityTypeConfiguration<Site>
{
/// <summary>Configures the EF Core mapping for <see cref="Site"/>.</summary>
/// <param name="builder">The entity type builder.</param>
public void Configure(EntityTypeBuilder<Site> builder)
{
builder.HasKey(s => s.Id);
@@ -33,6 +35,8 @@ public class SiteConfiguration : IEntityTypeConfiguration<Site>
public class DataConnectionConfiguration : IEntityTypeConfiguration<DataConnection>
{
/// <summary>Configures the EF Core mapping for <see cref="DataConnection"/>.</summary>
/// <param name="builder">The entity type builder.</param>
public void Configure(EntityTypeBuilder<DataConnection> builder)
{
builder.HasKey(d => d.Id);
@@ -6,6 +6,8 @@ namespace ScadaLink.ConfigurationDatabase.Configurations;
public class TemplateConfiguration : IEntityTypeConfiguration<Template>
{
/// <summary>Configures the EF Core mapping for <see cref="Template"/>.</summary>
/// <param name="builder">The entity type builder.</param>
public void Configure(EntityTypeBuilder<Template> builder)
{
builder.HasKey(t => t.Id);
@@ -61,6 +63,8 @@ public class TemplateConfiguration : IEntityTypeConfiguration<Template>
public class TemplateAttributeConfiguration : IEntityTypeConfiguration<TemplateAttribute>
{
/// <summary>Configures the EF Core mapping for <see cref="TemplateAttribute"/>.</summary>
/// <param name="builder">The entity type builder.</param>
public void Configure(EntityTypeBuilder<TemplateAttribute> builder)
{
builder.HasKey(a => a.Id);
@@ -88,6 +92,8 @@ public class TemplateAttributeConfiguration : IEntityTypeConfiguration<TemplateA
public class TemplateAlarmConfiguration : IEntityTypeConfiguration<TemplateAlarm>
{
/// <summary>Configures the EF Core mapping for <see cref="TemplateAlarm"/>.</summary>
/// <param name="builder">The entity type builder.</param>
public void Configure(EntityTypeBuilder<TemplateAlarm> builder)
{
builder.HasKey(a => a.Id);
@@ -112,6 +118,8 @@ public class TemplateAlarmConfiguration : IEntityTypeConfiguration<TemplateAlarm
public class TemplateScriptConfiguration : IEntityTypeConfiguration<TemplateScript>
{
/// <summary>Configures the EF Core mapping for <see cref="TemplateScript"/>.</summary>
/// <param name="builder">The entity type builder.</param>
public void Configure(EntityTypeBuilder<TemplateScript> builder)
{
builder.HasKey(s => s.Id);
@@ -141,6 +149,8 @@ public class TemplateScriptConfiguration : IEntityTypeConfiguration<TemplateScri
public class TemplateCompositionConfiguration : IEntityTypeConfiguration<TemplateComposition>
{
/// <summary>Configures the EF Core mapping for <see cref="TemplateComposition"/>.</summary>
/// <param name="builder">The entity type builder.</param>
public void Configure(EntityTypeBuilder<TemplateComposition> builder)
{
builder.HasKey(c => c.Id);
@@ -161,6 +171,8 @@ public class TemplateCompositionConfiguration : IEntityTypeConfiguration<Templat
public class TemplateFolderConfiguration : IEntityTypeConfiguration<TemplateFolder>
{
/// <summary>Configures the EF Core mapping for <see cref="TemplateFolder"/>.</summary>
/// <param name="builder">The entity type builder.</param>
public void Configure(EntityTypeBuilder<TemplateFolder> builder)
{
builder.HasKey(f => f.Id);
@@ -22,6 +22,10 @@ public class DesignTimeDbContextFactory : IDesignTimeDbContextFactory<ScadaLinkD
private const string EnvironmentVariableName = "SCADALINK_DESIGNTIME_CONNECTIONSTRING";
private const string ConfigurationKey = "ScadaLink:Database:ConfigurationDb";
/// <summary>
/// Creates a <see cref="ScadaLinkDbContext"/> for design-time EF Core tooling.
/// </summary>
/// <param name="args">Arguments passed by the EF tooling (unused).</param>
public ScadaLinkDbContext CreateDbContext(string[] args)
{
var configurationBuilder = new ConfigurationBuilder();
@@ -22,6 +22,10 @@ public sealed class EncryptedStringConverter : ValueConverter<string?, string?>
/// <summary>The Data Protection purpose string shared by all encrypted configuration columns.</summary>
public const string ProtectorPurpose = "ScadaLink.ConfigurationDatabase.EncryptedColumn";
/// <summary>
/// Initializes the converter with the data protector used to encrypt and decrypt column values.
/// </summary>
/// <param name="protector">The data protector scoped to the encrypted column purpose.</param>
public EncryptedStringConverter(IDataProtector protector)
: base(
plaintext => plaintext == null ? null : protector.Protect(plaintext),
@@ -54,6 +54,9 @@ public sealed class AuditLogPartitionMaintenance : IPartitionMaintenance
private readonly ScadaLinkDbContext _context;
private readonly ILogger<AuditLogPartitionMaintenance> _logger;
/// <summary>Initializes the maintenance implementation with the database context and optional logger.</summary>
/// <param name="context">The EF Core database context used to execute partition-management SQL.</param>
/// <param name="logger">Optional logger; defaults to <see cref="NullLogger{T}"/> when null.</param>
public AuditLogPartitionMaintenance(
ScadaLinkDbContext context,
ILogger<AuditLogPartitionMaintenance>? logger = null)
@@ -28,20 +28,16 @@ public class AuditLogRepository : IAuditLogRepository
private readonly ScadaLinkDbContext _context;
private readonly ILogger<AuditLogRepository> _logger;
/// <summary>Initializes a new instance of the AuditLogRepository class.</summary>
/// <param name="context">The database context.</param>
/// <param name="logger">Optional logger instance.</param>
public AuditLogRepository(ScadaLinkDbContext context, ILogger<AuditLogRepository>? logger = null)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
_logger = logger ?? NullLogger<AuditLogRepository>.Instance;
}
/// <summary>
/// Issues a single <c>IF NOT EXISTS … INSERT INTO dbo.AuditLog (…) VALUES (…)</c>
/// via <see cref="Microsoft.EntityFrameworkCore.RelationalDatabaseFacadeExtensions.ExecuteSqlInterpolatedAsync"/>.
/// Bypasses the EF change tracker so the row never enters a tracked state and
/// the enum-as-string conversion is done explicitly in C# (the columns are
/// declared <c>varchar(32)</c> via <c>HasConversion&lt;string&gt;()</c> in
/// <see cref="ScadaLink.ConfigurationDatabase.Configurations.AuditLogEntityTypeConfiguration"/>).
/// </summary>
/// <inheritdoc />
public async Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default)
{
if (evt is null)
@@ -93,14 +89,7 @@ VALUES
}
}
/// <summary>
/// Builds an <c>AsNoTracking</c> queryable over <see cref="AuditEvent"/>, applies
/// every non-null filter predicate, and pages by keyset on
/// <c>(OccurredAtUtc DESC, EventId DESC)</c>. The keyset clause is expressed
/// directly (<c>occurred &lt; after || (occurred == after &amp;&amp; eventId.CompareTo(afterId) &lt; 0)</c>)
/// — EF Core 10 translates <see cref="Guid.CompareTo(Guid)"/> against SQL Server's
/// <c>uniqueidentifier</c> sort order.
/// </summary>
/// <inheritdoc />
public async Task<IReadOnlyList<AuditEvent>> QueryAsync(
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default)
{
@@ -199,30 +188,7 @@ VALUES
.ToListAsync(ct);
}
/// <summary>
/// M6-T4 production implementation of the drop-and-rebuild dance documented
/// on <see cref="IAuditLogRepository.SwitchOutPartitionAsync"/>.
/// </summary>
/// <remarks>
/// <para>
/// The staging table name is GUID-suffixed so concurrent purge attempts on
/// different boundaries cannot collide. The staging schema is byte-identical
/// to the live <c>AuditLog</c> table (same column types, lengths,
/// nullability, and clustered-key shape) — SQL Server's
/// <c>ALTER TABLE … SWITCH PARTITION</c> rejects any drift. Keep this CREATE
/// in sync with both the migration that ships the live table
/// (<c>20260520142214_AddAuditLogTable</c>) and
/// <c>AuditLogEntityTypeConfiguration</c>.
/// </para>
/// <para>
/// All five steps run inside an explicit transaction so the SWITCH +
/// staging-DROP are atomic from the perspective of a consumer reading via
/// snapshot isolation; the CATCH rolls back and runs an idempotent
/// "rebuild UX_AuditLog_EventId if it doesn't exist" so a partial failure
/// never leaves the live table without its idempotency-supporting unique
/// index.
/// </para>
/// </remarks>
/// <inheritdoc />
public async Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, CancellationToken ct = default)
{
// GUID-suffixed staging name: prevents collision with any concurrent
@@ -371,27 +337,7 @@ VALUES
return rowsDeleted;
}
/// <summary>
/// Returns the set of <c>pf_AuditLog_Month</c> boundaries whose partition's
/// <c>MAX(OccurredAtUtc)</c> is strictly older than <paramref name="threshold"/>.
/// Boundaries with empty partitions are excluded — purging an empty
/// partition is wasted I/O.
/// </summary>
/// <remarks>
/// <para>
/// The CTE pulls every boundary value defined by the partition function and
/// joins it (via <c>$PARTITION.pf_AuditLog_Month</c>) to the live AuditLog
/// to compute per-partition <c>MAX(OccurredAtUtc)</c>. The outer filter
/// keeps only those whose MAX is non-NULL (partition has rows) AND strictly
/// less than the threshold (every row is past retention).
/// </para>
/// <para>
/// Note: the query scans the live <c>OccurredAtUtc</c> column to compute
/// the MAX per partition. With <c>IX_AuditLog_OccurredAtUtc</c> on the
/// partition-aligned scheme this is a single index seek per partition; for
/// 24 partitions and a daily purge cadence the cost is negligible.
/// </para>
/// </remarks>
/// <inheritdoc />
public async Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
DateTime threshold,
CancellationToken ct = default)
@@ -451,31 +397,7 @@ VALUES
return results;
}
/// <summary>
/// M7-T13 Bundle E — Health-dashboard Audit KPI tiles aggregate query.
/// Single round-trip
/// (<c>SELECT COUNT_BIG(*) AS Total, SUM(CASE WHEN Status IN (...) THEN 1 ELSE 0 END) AS Errors</c>)
/// over the trailing <paramref name="window"/> anchored at
/// <paramref name="nowUtc"/>. Returns a snapshot with
/// <see cref="AuditLogKpiSnapshot.BacklogTotal"/> left at zero — the service
/// layer composes that in from
/// <see cref="ScadaLink.HealthMonitoring.ICentralHealthAggregator"/>.
/// </summary>
/// <remarks>
/// <para>
/// Why one query, not two: keeping the numerator + denominator in the same
/// scan means the error rate the UI displays is computed from a consistent
/// snapshot. With two separate queries a row could be inserted between
/// them, inflating the denominator past the numerator (or vice-versa) and
/// briefly producing a misleading percentage.
/// </para>
/// <para>
/// "Error" rows are <c>Failed</c>, <c>Parked</c>, or <c>Discarded</c> — see
/// <see cref="IAuditLogRepository.GetKpiSnapshotAsync"/> for the rationale.
/// We pass the three discriminator strings as separate parameters rather
/// than building an IN-list to keep the prepared statement cache-friendly.
/// </para>
/// </remarks>
/// <inheritdoc />
public async Task<AuditLogKpiSnapshot> GetKpiSnapshotAsync(
TimeSpan window,
DateTime? nowUtc = null,
@@ -573,37 +495,7 @@ VALUES
// practice.
private const int ExecutionChainMaxDepth = 32;
/// <summary>
/// Audit Log ParentExecutionId (Task 8) — returns the whole execution chain
/// containing <paramref name="executionId"/>, regardless of entry point.
/// </summary>
/// <remarks>
/// <para>
/// Two phases. <b>Walk up:</b> an iterative
/// <c>SELECT TOP 1 ParentExecutionId … WHERE ExecutionId = @cur AND ParentExecutionId IS NOT NULL</c>
/// climbs from the supplied node to the root — the last execution id with no
/// parent. The loop is capped at <see cref="ExecutionChainMaxDepth"/>
/// iterations; a purged/missing parent simply ends the climb early. <b>Walk
/// down:</b> a recursive CTE over a DISTINCT
/// <c>(ExecutionId, ParentExecutionId)</c> edge set, seeded at the root edge
/// and joining <c>edge.ParentExecutionId = chain.ExecutionId</c> to
/// enumerate every descendant. Recursing over edges rather than raw rows
/// keeps the recursion one path wide per execution. It is bounded by
/// <c>OPTION (MAXRECURSION ...)</c> at <see cref="ExecutionChainMaxDepth"/>
/// — corrupt cyclic data raises a <see cref="SqlException"/> (msg 530)
/// rather than spinning.
/// </para>
/// <para>
/// The chain's full execution-id set is every edge's <c>ExecutionId</c>
/// unioned with its non-null <c>ParentExecutionId</c>, so an execution
/// referenced only as a parent — a "stub" that emitted no rows of its own,
/// and therefore owns no edge of its own — is still included. The final
/// projection LEFT JOINs that id set back to <c>AuditLog</c> and
/// <c>GROUP BY</c>s, so a stub yields a node with <c>RowCount = 0</c> and
/// empty/null aggregates. The query is SELECT-only
/// (the audit writer role grants no UPDATE/DELETE — reads are unrestricted).
/// </para>
/// </remarks>
/// <inheritdoc />
public async Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId,
CancellationToken ct = default)
@@ -768,14 +660,7 @@ VALUES
}
}
/// <summary>
/// Distinct non-null <c>SourceNode</c> values for the Audit Log page's
/// Node filter dropdown. EF Core translates this to
/// <c>SELECT DISTINCT SourceNode FROM AuditLog WHERE SourceNode IS NOT NULL ORDER BY SourceNode</c>
/// — a single index-less scan, but the column is bounded (one entry per
/// node in the cluster, currently &lt;10) and the Central UI caches the
/// result for ~60s, so a periodic scan is acceptable.
/// </summary>
/// <inheritdoc />
public async Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default)
{
return await _context.Set<AuditEvent>()
@@ -12,11 +12,16 @@ public class CentralUiRepository : ICentralUiRepository
{
private readonly ScadaLinkDbContext _context;
/// <summary>
/// Initializes a new instance of the <see cref="CentralUiRepository"/> class.
/// </summary>
/// <param name="context">The EF Core database context.</param>
public CentralUiRepository(ScadaLinkDbContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
/// <inheritdoc />
public async Task<IReadOnlyList<Site>> GetAllSitesAsync(CancellationToken cancellationToken = default)
{
return await _context.Sites
@@ -25,6 +30,7 @@ public class CentralUiRepository : ICentralUiRepository
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<DataConnection>> GetDataConnectionsBySiteIdAsync(int siteId, CancellationToken cancellationToken = default)
{
return await _context.DataConnections
@@ -34,6 +40,7 @@ public class CentralUiRepository : ICentralUiRepository
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<DataConnection>> GetAllDataConnectionsAsync(CancellationToken cancellationToken = default)
{
return await _context.DataConnections
@@ -42,6 +49,7 @@ public class CentralUiRepository : ICentralUiRepository
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<Template>> GetTemplateTreeAsync(CancellationToken cancellationToken = default)
{
return await _context.Templates
@@ -55,6 +63,7 @@ public class CentralUiRepository : ICentralUiRepository
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<Instance>> GetInstancesFilteredAsync(
int? siteId = null,
int? templateId = null,
@@ -77,6 +86,7 @@ public class CentralUiRepository : ICentralUiRepository
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<DeploymentRecord>> GetRecentDeploymentsAsync(int count, CancellationToken cancellationToken = default)
{
return await _context.DeploymentRecords
@@ -86,6 +96,7 @@ public class CentralUiRepository : ICentralUiRepository
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<Area>> GetAreaTreeBySiteIdAsync(int siteId, CancellationToken cancellationToken = default)
{
return await _context.Areas
@@ -96,6 +107,7 @@ public class CentralUiRepository : ICentralUiRepository
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<(IReadOnlyList<AuditLogEntry> Entries, int TotalCount)> GetAuditLogEntriesAsync(
string? user = null,
string? entityType = null,
@@ -146,6 +158,7 @@ public class CentralUiRepository : ICentralUiRepository
return (entries, totalCount);
}
/// <inheritdoc />
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
return await _context.SaveChangesAsync(cancellationToken);
@@ -16,6 +16,10 @@ public class DeploymentManagerRepository : IDeploymentManagerRepository
{
private readonly ScadaLinkDbContext _dbContext;
/// <summary>
/// Initializes a new instance of the DeploymentManagerRepository class.
/// </summary>
/// <param name="dbContext">The database context for accessing deployment data.</param>
public DeploymentManagerRepository(ScadaLinkDbContext dbContext)
{
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
@@ -23,11 +27,13 @@ public class DeploymentManagerRepository : IDeploymentManagerRepository
// --- DeploymentRecord ---
/// <inheritdoc />
public async Task<DeploymentRecord?> GetDeploymentRecordByIdAsync(int id, CancellationToken cancellationToken = default)
{
return await _dbContext.DeploymentRecords.FindAsync([id], cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<DeploymentRecord>> GetAllDeploymentRecordsAsync(CancellationToken cancellationToken = default)
{
return await _dbContext.DeploymentRecords
@@ -35,6 +41,7 @@ public class DeploymentManagerRepository : IDeploymentManagerRepository
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<DeploymentRecord>> GetDeploymentsByInstanceIdAsync(int instanceId, CancellationToken cancellationToken = default)
{
return await _dbContext.DeploymentRecords
@@ -43,10 +50,7 @@ public class DeploymentManagerRepository : IDeploymentManagerRepository
.ToListAsync(cancellationToken);
}
/// <summary>
/// Gets the most recent deployment record for an instance (current deployment status).
/// Used for staleness detection by comparing revision hashes.
/// </summary>
/// <inheritdoc />
public async Task<DeploymentRecord?> GetCurrentDeploymentStatusAsync(int instanceId, CancellationToken cancellationToken = default)
{
return await _dbContext.DeploymentRecords
@@ -55,28 +59,27 @@ public class DeploymentManagerRepository : IDeploymentManagerRepository
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<DeploymentRecord?> GetDeploymentByDeploymentIdAsync(string deploymentId, CancellationToken cancellationToken = default)
{
return await _dbContext.DeploymentRecords
.FirstOrDefaultAsync(d => d.DeploymentId == deploymentId, cancellationToken);
}
/// <inheritdoc />
public async Task AddDeploymentRecordAsync(DeploymentRecord record, CancellationToken cancellationToken = default)
{
await _dbContext.DeploymentRecords.AddAsync(record, cancellationToken);
}
/// <summary>
/// Updates a deployment record. Uses optimistic concurrency on deployment status records —
/// EF Core's change tracking will detect concurrent modifications via the row version/concurrency token
/// configured in the entity configuration.
/// </summary>
/// <inheritdoc />
public Task UpdateDeploymentRecordAsync(DeploymentRecord record, CancellationToken cancellationToken = default)
{
_dbContext.DeploymentRecords.Update(record);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task DeleteDeploymentRecordAsync(int id, CancellationToken cancellationToken = default)
{
var record = _dbContext.DeploymentRecords.Local.FirstOrDefault(d => d.Id == id);
@@ -95,11 +98,13 @@ public class DeploymentManagerRepository : IDeploymentManagerRepository
// --- SystemArtifactDeploymentRecord ---
/// <inheritdoc />
public async Task<SystemArtifactDeploymentRecord?> GetSystemArtifactDeploymentByIdAsync(int id, CancellationToken cancellationToken = default)
{
return await _dbContext.SystemArtifactDeploymentRecords.FindAsync([id], cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<SystemArtifactDeploymentRecord>> GetAllSystemArtifactDeploymentsAsync(CancellationToken cancellationToken = default)
{
return await _dbContext.SystemArtifactDeploymentRecords
@@ -107,17 +112,20 @@ public class DeploymentManagerRepository : IDeploymentManagerRepository
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task AddSystemArtifactDeploymentAsync(SystemArtifactDeploymentRecord record, CancellationToken cancellationToken = default)
{
await _dbContext.SystemArtifactDeploymentRecords.AddAsync(record, cancellationToken);
}
/// <inheritdoc />
public Task UpdateSystemArtifactDeploymentAsync(SystemArtifactDeploymentRecord record, CancellationToken cancellationToken = default)
{
_dbContext.SystemArtifactDeploymentRecords.Update(record);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task DeleteSystemArtifactDeploymentAsync(int id, CancellationToken cancellationToken = default)
{
var record = _dbContext.SystemArtifactDeploymentRecords.Local.FirstOrDefault(d => d.Id == id);
@@ -136,23 +144,27 @@ public class DeploymentManagerRepository : IDeploymentManagerRepository
// --- WP-8: DeployedConfigSnapshot ---
/// <inheritdoc />
public async Task<DeployedConfigSnapshot?> GetDeployedSnapshotByInstanceIdAsync(int instanceId, CancellationToken cancellationToken = default)
{
return await _dbContext.Set<DeployedConfigSnapshot>()
.FirstOrDefaultAsync(s => s.InstanceId == instanceId, cancellationToken);
}
/// <inheritdoc />
public async Task AddDeployedSnapshotAsync(DeployedConfigSnapshot snapshot, CancellationToken cancellationToken = default)
{
await _dbContext.Set<DeployedConfigSnapshot>().AddAsync(snapshot, cancellationToken);
}
/// <inheritdoc />
public Task UpdateDeployedSnapshotAsync(DeployedConfigSnapshot snapshot, CancellationToken cancellationToken = default)
{
_dbContext.Set<DeployedConfigSnapshot>().Update(snapshot);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task DeleteDeployedSnapshotAsync(int instanceId, CancellationToken cancellationToken = default)
{
var snapshot = await _dbContext.Set<DeployedConfigSnapshot>()
@@ -165,6 +177,7 @@ public class DeploymentManagerRepository : IDeploymentManagerRepository
// --- Instance lookups for deployment pipeline ---
/// <inheritdoc />
public async Task<Instance?> GetInstanceByIdAsync(int instanceId, CancellationToken cancellationToken = default)
{
return await _dbContext.Set<Instance>()
@@ -175,6 +188,7 @@ public class DeploymentManagerRepository : IDeploymentManagerRepository
.FirstOrDefaultAsync(i => i.Id == instanceId, cancellationToken);
}
/// <inheritdoc />
public async Task<Instance?> GetInstanceByUniqueNameAsync(string uniqueName, CancellationToken cancellationToken = default)
{
return await _dbContext.Set<Instance>()
@@ -185,12 +199,14 @@ public class DeploymentManagerRepository : IDeploymentManagerRepository
.FirstOrDefaultAsync(i => i.UniqueName == uniqueName, cancellationToken);
}
/// <inheritdoc />
public Task UpdateInstanceAsync(Instance instance, CancellationToken cancellationToken = default)
{
_dbContext.Set<Instance>().Update(instance);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task DeleteInstanceAsync(int instanceId, CancellationToken cancellationToken = default)
{
// DeploymentRecords have a Restrict FK to Instance — remove them
@@ -212,6 +228,7 @@ public class DeploymentManagerRepository : IDeploymentManagerRepository
}
}
/// <inheritdoc />
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
return await _dbContext.SaveChangesAsync(cancellationToken);
@@ -8,38 +8,50 @@ public class ExternalSystemRepository : IExternalSystemRepository
{
private readonly ScadaLinkDbContext _context;
/// <summary>
/// Initializes a new instance of the ExternalSystemRepository class.
/// </summary>
/// <param name="context">The database context for accessing external system data.</param>
public ExternalSystemRepository(ScadaLinkDbContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
/// <inheritdoc />
public async Task<ExternalSystemDefinition?> GetExternalSystemByIdAsync(int id, CancellationToken cancellationToken = default)
=> await _context.Set<ExternalSystemDefinition>().FindAsync(new object[] { id }, cancellationToken);
/// <inheritdoc />
// ExternalSystemGateway-011: genuine name-keyed query (server-side WHERE) so the
// gateway's hot-path resolution does not fetch every system and filter in memory.
public async Task<ExternalSystemDefinition?> GetExternalSystemByNameAsync(string name, CancellationToken cancellationToken = default)
=> await _context.Set<ExternalSystemDefinition>()
.FirstOrDefaultAsync(s => s.Name == name, cancellationToken);
/// <inheritdoc />
public async Task<IReadOnlyList<ExternalSystemDefinition>> GetAllExternalSystemsAsync(CancellationToken cancellationToken = default)
=> await _context.Set<ExternalSystemDefinition>().ToListAsync(cancellationToken);
/// <inheritdoc />
public async Task AddExternalSystemAsync(ExternalSystemDefinition definition, CancellationToken cancellationToken = default)
=> await _context.Set<ExternalSystemDefinition>().AddAsync(definition, cancellationToken);
/// <inheritdoc />
public Task UpdateExternalSystemAsync(ExternalSystemDefinition definition, CancellationToken cancellationToken = default)
{ _context.Set<ExternalSystemDefinition>().Update(definition); return Task.CompletedTask; }
/// <inheritdoc />
public async Task DeleteExternalSystemAsync(int id, CancellationToken cancellationToken = default)
{
var entity = await GetExternalSystemByIdAsync(id, cancellationToken);
if (entity != null) _context.Set<ExternalSystemDefinition>().Remove(entity);
}
/// <inheritdoc />
public async Task<ExternalSystemMethod?> GetExternalSystemMethodByIdAsync(int id, CancellationToken cancellationToken = default)
=> await _context.Set<ExternalSystemMethod>().FindAsync(new object[] { id }, cancellationToken);
/// <inheritdoc />
// ExternalSystemGateway-011: genuine name-keyed query scoped to the parent system.
public async Task<ExternalSystemMethod?> GetMethodByNameAsync(int externalSystemId, string methodName, CancellationToken cancellationToken = default)
=> await _context.Set<ExternalSystemMethod>()
@@ -47,44 +59,55 @@ public class ExternalSystemRepository : IExternalSystemRepository
m => m.ExternalSystemDefinitionId == externalSystemId && m.Name == methodName,
cancellationToken);
/// <inheritdoc />
public async Task<IReadOnlyList<ExternalSystemMethod>> GetMethodsByExternalSystemIdAsync(int externalSystemId, CancellationToken cancellationToken = default)
=> await _context.Set<ExternalSystemMethod>().Where(m => m.ExternalSystemDefinitionId == externalSystemId).ToListAsync(cancellationToken);
/// <inheritdoc />
public async Task AddExternalSystemMethodAsync(ExternalSystemMethod method, CancellationToken cancellationToken = default)
=> await _context.Set<ExternalSystemMethod>().AddAsync(method, cancellationToken);
/// <inheritdoc />
public Task UpdateExternalSystemMethodAsync(ExternalSystemMethod method, CancellationToken cancellationToken = default)
{ _context.Set<ExternalSystemMethod>().Update(method); return Task.CompletedTask; }
/// <inheritdoc />
public async Task DeleteExternalSystemMethodAsync(int id, CancellationToken cancellationToken = default)
{
var entity = await GetExternalSystemMethodByIdAsync(id, cancellationToken);
if (entity != null) _context.Set<ExternalSystemMethod>().Remove(entity);
}
/// <inheritdoc />
public async Task<DatabaseConnectionDefinition?> GetDatabaseConnectionByIdAsync(int id, CancellationToken cancellationToken = default)
=> await _context.Set<DatabaseConnectionDefinition>().FindAsync(new object[] { id }, cancellationToken);
/// <inheritdoc />
// ExternalSystemGateway-011: genuine name-keyed query (server-side WHERE).
public async Task<DatabaseConnectionDefinition?> GetDatabaseConnectionByNameAsync(string name, CancellationToken cancellationToken = default)
=> await _context.Set<DatabaseConnectionDefinition>()
.FirstOrDefaultAsync(c => c.Name == name, cancellationToken);
/// <inheritdoc />
public async Task<IReadOnlyList<DatabaseConnectionDefinition>> GetAllDatabaseConnectionsAsync(CancellationToken cancellationToken = default)
=> await _context.Set<DatabaseConnectionDefinition>().ToListAsync(cancellationToken);
/// <inheritdoc />
public async Task AddDatabaseConnectionAsync(DatabaseConnectionDefinition definition, CancellationToken cancellationToken = default)
=> await _context.Set<DatabaseConnectionDefinition>().AddAsync(definition, cancellationToken);
/// <inheritdoc />
public Task UpdateDatabaseConnectionAsync(DatabaseConnectionDefinition definition, CancellationToken cancellationToken = default)
{ _context.Set<DatabaseConnectionDefinition>().Update(definition); return Task.CompletedTask; }
/// <inheritdoc />
public async Task DeleteDatabaseConnectionAsync(int id, CancellationToken cancellationToken = default)
{
var entity = await GetDatabaseConnectionByIdAsync(id, cancellationToken);
if (entity != null) _context.Set<DatabaseConnectionDefinition>().Remove(entity);
}
/// <inheritdoc />
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
=> await _context.SaveChangesAsync(cancellationToken);
}
@@ -12,54 +12,60 @@ public class InboundApiRepository : IInboundApiRepository
private readonly ScadaLinkDbContext _context;
private readonly ILogger<InboundApiRepository> _logger;
/// <summary>
/// Initializes a new instance of the InboundApiRepository class.
/// </summary>
/// <param name="context">The database context for accessing inbound API data.</param>
/// <param name="logger">Optional logger instance for warnings and diagnostics.</param>
public InboundApiRepository(ScadaLinkDbContext context, ILogger<InboundApiRepository>? logger = null)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
_logger = logger ?? NullLogger<InboundApiRepository>.Instance;
}
/// <inheritdoc />
public async Task<ApiKey?> GetApiKeyByIdAsync(int id, CancellationToken cancellationToken = default)
=> await _context.Set<ApiKey>().FindAsync(new object[] { id }, cancellationToken);
/// <inheritdoc />
public async Task<IReadOnlyList<ApiKey>> GetAllApiKeysAsync(CancellationToken cancellationToken = default)
=> await _context.Set<ApiKey>().ToListAsync(cancellationToken);
/// <summary>
/// ConfigurationDatabase-012: API keys are persisted only as a deterministic hash,
/// never as plaintext, so this lookup hashes the supplied plaintext value and
/// matches it against the stored <see cref="ApiKey.KeyHash"/> column. The
/// unpeppered default hasher is used here because the repository has no access to
/// the deployment pepper; the inbound-API authentication path does not use this
/// method — it loads all keys and compares hashes constant-time in
/// <c>ApiKeyValidator</c> with the configured (peppered) hasher.
/// </summary>
/// <inheritdoc />
public async Task<ApiKey?> GetApiKeyByValueAsync(string keyValue, CancellationToken cancellationToken = default)
{
var keyHash = ApiKeyHasher.Default.Hash(keyValue);
return await _context.Set<ApiKey>().FirstOrDefaultAsync(k => k.KeyHash == keyHash, cancellationToken);
}
/// <inheritdoc />
public async Task AddApiKeyAsync(ApiKey apiKey, CancellationToken cancellationToken = default)
=> await _context.Set<ApiKey>().AddAsync(apiKey, cancellationToken);
/// <inheritdoc />
public Task UpdateApiKeyAsync(ApiKey apiKey, CancellationToken cancellationToken = default)
{ _context.Set<ApiKey>().Update(apiKey); return Task.CompletedTask; }
/// <inheritdoc />
public async Task DeleteApiKeyAsync(int id, CancellationToken cancellationToken = default)
{
var entity = await GetApiKeyByIdAsync(id, cancellationToken);
if (entity != null) _context.Set<ApiKey>().Remove(entity);
}
/// <inheritdoc />
public async Task<ApiMethod?> GetApiMethodByIdAsync(int id, CancellationToken cancellationToken = default)
=> await _context.Set<ApiMethod>().FindAsync(new object[] { id }, cancellationToken);
/// <inheritdoc />
public async Task<IReadOnlyList<ApiMethod>> GetAllApiMethodsAsync(CancellationToken cancellationToken = default)
=> await _context.Set<ApiMethod>().ToListAsync(cancellationToken);
/// <inheritdoc />
public async Task<ApiMethod?> GetMethodByNameAsync(string name, CancellationToken cancellationToken = default)
=> await _context.Set<ApiMethod>().FirstOrDefaultAsync(m => m.Name == name, cancellationToken);
/// <inheritdoc />
public async Task<IReadOnlyList<ApiKey>> GetApprovedKeysForMethodAsync(int methodId, CancellationToken cancellationToken = default)
{
var method = await _context.Set<ApiMethod>().FindAsync(new object[] { methodId }, cancellationToken);
@@ -90,18 +96,22 @@ public class InboundApiRepository : IInboundApiRepository
return await _context.Set<ApiKey>().Where(k => keyIds.Contains(k.Id)).ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task AddApiMethodAsync(ApiMethod method, CancellationToken cancellationToken = default)
=> await _context.Set<ApiMethod>().AddAsync(method, cancellationToken);
/// <inheritdoc />
public Task UpdateApiMethodAsync(ApiMethod method, CancellationToken cancellationToken = default)
{ _context.Set<ApiMethod>().Update(method); return Task.CompletedTask; }
/// <inheritdoc />
public async Task DeleteApiMethodAsync(int id, CancellationToken cancellationToken = default)
{
var entity = await GetApiMethodByIdAsync(id, cancellationToken);
if (entity != null) _context.Set<ApiMethod>().Remove(entity);
}
/// <inheritdoc />
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
=> await _context.SaveChangesAsync(cancellationToken);
}
@@ -22,11 +22,14 @@ public class NotificationOutboxRepository : INotificationOutboxRepository
NotificationStatus.Discarded,
};
/// <summary>Initializes a new instance of <see cref="NotificationOutboxRepository"/> with the given EF Core context.</summary>
/// <param name="context">The EF Core database context.</param>
public NotificationOutboxRepository(ScadaLinkDbContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
/// <inheritdoc />
public async Task<bool> InsertIfNotExistsAsync(Notification n, CancellationToken cancellationToken = default)
{
var exists = await _context.Notifications
@@ -41,6 +44,7 @@ public class NotificationOutboxRepository : INotificationOutboxRepository
return true;
}
/// <inheritdoc />
public async Task<IReadOnlyList<Notification>> GetDueAsync(
DateTimeOffset now, int batchSize, CancellationToken cancellationToken = default)
{
@@ -54,15 +58,18 @@ public class NotificationOutboxRepository : INotificationOutboxRepository
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<Notification?> GetByIdAsync(string notificationId, CancellationToken cancellationToken = default)
=> await _context.Notifications.FindAsync(new object[] { notificationId }, cancellationToken);
/// <inheritdoc />
public async Task UpdateAsync(Notification n, CancellationToken cancellationToken = default)
{
_context.Notifications.Update(n);
await _context.SaveChangesAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<(IReadOnlyList<Notification> Rows, int TotalCount)> QueryAsync(
NotificationOutboxFilter filter, int pageNumber, int pageSize, CancellationToken cancellationToken = default)
{
@@ -128,6 +135,7 @@ public class NotificationOutboxRepository : INotificationOutboxRepository
return (rows, totalCount);
}
/// <inheritdoc />
public async Task<int> DeleteTerminalOlderThanAsync(DateTimeOffset cutoff, CancellationToken cancellationToken = default)
{
return await _context.Notifications
@@ -135,6 +143,7 @@ public class NotificationOutboxRepository : INotificationOutboxRepository
.ExecuteDeleteAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<NotificationKpiSnapshot> ComputeKpisAsync(
DateTimeOffset stuckCutoff, DateTimeOffset deliveredSince, CancellationToken cancellationToken = default)
{
@@ -181,6 +190,7 @@ public class NotificationOutboxRepository : INotificationOutboxRepository
OldestPendingAge: oldestPendingAge);
}
/// <inheritdoc />
public async Task<IReadOnlyList<SiteNotificationKpiSnapshot>> ComputePerSiteKpisAsync(
DateTimeOffset stuckCutoff, DateTimeOffset deliveredSince, CancellationToken cancellationToken = default)
{
@@ -243,6 +253,7 @@ public class NotificationOutboxRepository : INotificationOutboxRepository
.ToDictionaryAsync(x => x.Site, x => x.Count, cancellationToken);
}
/// <inheritdoc />
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
=> await _context.SaveChangesAsync(cancellationToken);
}
@@ -8,68 +8,87 @@ public class NotificationRepository : INotificationRepository
{
private readonly ScadaLinkDbContext _context;
/// <summary>Initializes a new instance of the NotificationRepository class.</summary>
/// <param name="context">The database context.</param>
public NotificationRepository(ScadaLinkDbContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
/// <inheritdoc />
public async Task<NotificationList?> GetNotificationListByIdAsync(int id, CancellationToken cancellationToken = default)
=> await _context.Set<NotificationList>().FindAsync(new object[] { id }, cancellationToken);
/// <inheritdoc />
public async Task<IReadOnlyList<NotificationList>> GetAllNotificationListsAsync(CancellationToken cancellationToken = default)
=> await _context.Set<NotificationList>().Include(n => n.Recipients).ToListAsync(cancellationToken);
/// <inheritdoc />
public async Task<NotificationList?> GetListByNameAsync(string name, CancellationToken cancellationToken = default)
=> await _context.Set<NotificationList>().FirstOrDefaultAsync(l => l.Name == name, cancellationToken);
/// <inheritdoc />
public async Task AddNotificationListAsync(NotificationList list, CancellationToken cancellationToken = default)
=> await _context.Set<NotificationList>().AddAsync(list, cancellationToken);
/// <inheritdoc />
public Task UpdateNotificationListAsync(NotificationList list, CancellationToken cancellationToken = default)
{ _context.Set<NotificationList>().Update(list); return Task.CompletedTask; }
/// <inheritdoc />
public async Task DeleteNotificationListAsync(int id, CancellationToken cancellationToken = default)
{
var entity = await GetNotificationListByIdAsync(id, cancellationToken);
if (entity != null) _context.Set<NotificationList>().Remove(entity);
}
/// <inheritdoc />
public async Task<NotificationRecipient?> GetRecipientByIdAsync(int id, CancellationToken cancellationToken = default)
=> await _context.Set<NotificationRecipient>().FindAsync(new object[] { id }, cancellationToken);
/// <inheritdoc />
public async Task<IReadOnlyList<NotificationRecipient>> GetRecipientsByListIdAsync(int notificationListId, CancellationToken cancellationToken = default)
=> await _context.Set<NotificationRecipient>().Where(r => r.NotificationListId == notificationListId).ToListAsync(cancellationToken);
/// <inheritdoc />
public async Task AddRecipientAsync(NotificationRecipient recipient, CancellationToken cancellationToken = default)
=> await _context.Set<NotificationRecipient>().AddAsync(recipient, cancellationToken);
/// <inheritdoc />
public Task UpdateRecipientAsync(NotificationRecipient recipient, CancellationToken cancellationToken = default)
{ _context.Set<NotificationRecipient>().Update(recipient); return Task.CompletedTask; }
/// <inheritdoc />
public async Task DeleteRecipientAsync(int id, CancellationToken cancellationToken = default)
{
var entity = await GetRecipientByIdAsync(id, cancellationToken);
if (entity != null) _context.Set<NotificationRecipient>().Remove(entity);
}
/// <inheritdoc />
public async Task<SmtpConfiguration?> GetSmtpConfigurationByIdAsync(int id, CancellationToken cancellationToken = default)
=> await _context.Set<SmtpConfiguration>().FindAsync(new object[] { id }, cancellationToken);
/// <inheritdoc />
public async Task<IReadOnlyList<SmtpConfiguration>> GetAllSmtpConfigurationsAsync(CancellationToken cancellationToken = default)
=> await _context.Set<SmtpConfiguration>().ToListAsync(cancellationToken);
/// <inheritdoc />
public async Task AddSmtpConfigurationAsync(SmtpConfiguration configuration, CancellationToken cancellationToken = default)
=> await _context.Set<SmtpConfiguration>().AddAsync(configuration, cancellationToken);
/// <inheritdoc />
public Task UpdateSmtpConfigurationAsync(SmtpConfiguration configuration, CancellationToken cancellationToken = default)
{ _context.Set<SmtpConfiguration>().Update(configuration); return Task.CompletedTask; }
/// <inheritdoc />
public async Task DeleteSmtpConfigurationAsync(int id, CancellationToken cancellationToken = default)
{
var entity = await GetSmtpConfigurationByIdAsync(id, cancellationToken);
if (entity != null) _context.Set<SmtpConfiguration>().Remove(entity);
}
/// <inheritdoc />
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
=> await _context.SaveChangesAsync(cancellationToken);
}
@@ -8,6 +8,10 @@ public class SecurityRepository : ISecurityRepository
{
private readonly ScadaLinkDbContext _context;
/// <summary>
/// Initializes a new instance of the SecurityRepository.
/// </summary>
/// <param name="context">The database context.</param>
public SecurityRepository(ScadaLinkDbContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
@@ -15,16 +19,19 @@ public class SecurityRepository : ISecurityRepository
// LdapGroupMapping
/// <inheritdoc />
public async Task<LdapGroupMapping?> GetMappingByIdAsync(int id, CancellationToken cancellationToken = default)
{
return await _context.LdapGroupMappings.FindAsync(new object[] { id }, cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<LdapGroupMapping>> GetAllMappingsAsync(CancellationToken cancellationToken = default)
{
return await _context.LdapGroupMappings.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<LdapGroupMapping>> GetMappingsByRoleAsync(string role, CancellationToken cancellationToken = default)
{
return await _context.LdapGroupMappings
@@ -32,17 +39,20 @@ public class SecurityRepository : ISecurityRepository
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task AddMappingAsync(LdapGroupMapping mapping, CancellationToken cancellationToken = default)
{
await _context.LdapGroupMappings.AddAsync(mapping, cancellationToken);
}
/// <inheritdoc />
public Task UpdateMappingAsync(LdapGroupMapping mapping, CancellationToken cancellationToken = default)
{
_context.LdapGroupMappings.Update(mapping);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task DeleteMappingAsync(int id, CancellationToken cancellationToken = default)
{
var mapping = await _context.LdapGroupMappings.FindAsync(new object[] { id }, cancellationToken);
@@ -54,11 +64,13 @@ public class SecurityRepository : ISecurityRepository
// SiteScopeRule
/// <inheritdoc />
public async Task<SiteScopeRule?> GetScopeRuleByIdAsync(int id, CancellationToken cancellationToken = default)
{
return await _context.SiteScopeRules.FindAsync(new object[] { id }, cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<SiteScopeRule>> GetScopeRulesForMappingAsync(int ldapGroupMappingId, CancellationToken cancellationToken = default)
{
return await _context.SiteScopeRules
@@ -66,17 +78,20 @@ public class SecurityRepository : ISecurityRepository
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task AddScopeRuleAsync(SiteScopeRule rule, CancellationToken cancellationToken = default)
{
await _context.SiteScopeRules.AddAsync(rule, cancellationToken);
}
/// <inheritdoc />
public Task UpdateScopeRuleAsync(SiteScopeRule rule, CancellationToken cancellationToken = default)
{
_context.SiteScopeRules.Update(rule);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task DeleteScopeRuleAsync(int id, CancellationToken cancellationToken = default)
{
var rule = await _context.SiteScopeRules.FindAsync(new object[] { id }, cancellationToken);
@@ -86,6 +101,7 @@ public class SecurityRepository : ISecurityRepository
}
}
/// <inheritdoc />
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
return await _context.SaveChangesAsync(cancellationToken);
@@ -42,20 +42,18 @@ public class SiteCallAuditRepository : ISiteCallAuditRepository
private readonly ScadaLinkDbContext _context;
private readonly ILogger<SiteCallAuditRepository> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="SiteCallAuditRepository"/> class.
/// </summary>
/// <param name="context">The EF Core database context.</param>
/// <param name="logger">Optional logger for diagnostic information.</param>
public SiteCallAuditRepository(ScadaLinkDbContext context, ILogger<SiteCallAuditRepository>? logger = null)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
_logger = logger ?? NullLogger<SiteCallAuditRepository>.Instance;
}
/// <summary>
/// Two-step: <c>IF NOT EXISTS INSERT</c> then conditional <c>UPDATE</c> with
/// an inline <c>CASE</c> rank comparison. Both go through
/// <see cref="Microsoft.EntityFrameworkCore.RelationalDatabaseFacadeExtensions.ExecuteSqlInterpolatedAsync"/>
/// so the change tracker is bypassed and the value-converted PK column is
/// written as the canonical "D"-format GUID string. Duplicate-key violations
/// from the insert race are swallowed.
/// </summary>
/// <inheritdoc />
public async Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default)
{
if (siteCall is null)
@@ -141,25 +139,13 @@ WHERE TrackedOperationId = {idText}
ct);
}
/// <summary>
/// Single <c>FindAsync</c> against the PK. Returns <c>null</c> for unknown ids.
/// </summary>
/// <inheritdoc />
public async Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default)
{
return await _context.Set<SiteCall>().FindAsync(new object?[] { id }, ct);
}
/// <summary>
/// Builds a parameterised SQL query against <c>dbo.SiteCalls</c> ordered by
/// <c>(CreatedAtUtc DESC, TrackedOperationId DESC)</c>, with keyset paging.
/// Raw SQL is used here (rather than LINQ) because EF Core 10 cannot
/// translate the lexicographic string comparison against the value-converted
/// <see cref="TrackedOperationId"/> column inside an expression tree — the
/// converter is applied to equality but not to inequality comparisons
/// against the underlying Guid. The keyset tiebreaker is varchar lex order,
/// which is deterministic and gives "no overlap, every row exactly once"
/// paging without depending on Guid byte ordering.
/// </summary>
/// <inheritdoc />
public async Task<IReadOnlyList<SiteCall>> QueryAsync(
SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default)
{
@@ -223,11 +209,7 @@ ORDER BY CreatedAtUtc DESC, TrackedOperationId DESC;";
return rows;
}
/// <summary>
/// Deletes rows whose <see cref="SiteCall.TerminalAtUtc"/> is non-null AND
/// strictly less than <paramref name="olderThanUtc"/>. Non-terminal rows are
/// never touched. Returns the number of rows removed.
/// </summary>
/// <inheritdoc />
public async Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default)
{
return await _context.Database.ExecuteSqlInterpolatedAsync(
@@ -252,16 +234,7 @@ ORDER BY CreatedAtUtc DESC, TrackedOperationId DESC;";
private const string StatusDelivered = "Delivered";
private const string StatusFailed = "Failed";
/// <summary>
/// Computes the global KPI snapshot with five server-side aggregate queries
/// against <c>dbo.SiteCalls</c>. No rows are materialised — every count is a
/// translated <c>COUNT</c> and the oldest-pending age is a translated
/// <c>MIN(CreatedAtUtc)</c>. The <c>Status</c> and <c>CreatedAtUtc</c>/<c>TerminalAtUtc</c>
/// columns have no value converter, so the aggregates translate cleanly to
/// SQL Server (unlike the NotificationOutbox's <c>DateTimeOffset</c>-converted
/// column, which forces an order-and-take). "Buffered" / "stuck" key off
/// <c>TerminalAtUtc IS NULL</c> — see the field comments above.
/// </summary>
/// <inheritdoc />
public async Task<SiteCallKpiSnapshot> ComputeKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default)
{
@@ -304,14 +277,7 @@ ORDER BY CreatedAtUtc DESC, TrackedOperationId DESC;";
StuckCount: stuckCount);
}
/// <summary>
/// Computes the per-source-site KPI breakdown. The five counts are
/// <c>GROUP BY SourceSite</c> aggregates; the oldest-pending age is a
/// per-site <c>MIN(CreatedAtUtc)</c> over the (bounded) non-terminal set —
/// all run server-side. A site appears in the result only if it has at
/// least one row matched by one of the count queries. "Buffered" / "stuck"
/// key off <c>TerminalAtUtc IS NULL</c> — see <see cref="ComputeKpisAsync"/>.
/// </summary>
/// <inheritdoc />
public async Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default)
{
@@ -12,6 +12,10 @@ public class SiteRepository : ISiteRepository
{
private readonly ScadaLinkDbContext _dbContext;
/// <summary>
/// Initializes a new instance of the SiteRepository.
/// </summary>
/// <param name="dbContext">The database context.</param>
public SiteRepository(ScadaLinkDbContext dbContext)
{
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
@@ -19,33 +23,39 @@ public class SiteRepository : ISiteRepository
// --- Sites ---
/// <inheritdoc />
public async Task<Site?> GetSiteByIdAsync(int id, CancellationToken cancellationToken = default)
{
return await _dbContext.Sites.FindAsync([id], cancellationToken);
}
/// <inheritdoc />
public async Task<Site?> GetSiteByIdentifierAsync(string siteIdentifier, CancellationToken cancellationToken = default)
{
return await _dbContext.Sites
.FirstOrDefaultAsync(s => s.SiteIdentifier == siteIdentifier, cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<Site>> GetAllSitesAsync(CancellationToken cancellationToken = default)
{
return await _dbContext.Sites.OrderBy(s => s.Name).ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task AddSiteAsync(Site site, CancellationToken cancellationToken = default)
{
await _dbContext.Sites.AddAsync(site, cancellationToken);
}
/// <inheritdoc />
public Task UpdateSiteAsync(Site site, CancellationToken cancellationToken = default)
{
_dbContext.Sites.Update(site);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task DeleteSiteAsync(int id, CancellationToken cancellationToken = default)
{
var entity = _dbContext.Sites.Local.FirstOrDefault(s => s.Id == id);
@@ -64,16 +74,19 @@ public class SiteRepository : ISiteRepository
// --- Data Connections ---
/// <inheritdoc />
public async Task<DataConnection?> GetDataConnectionByIdAsync(int id, CancellationToken cancellationToken = default)
{
return await _dbContext.DataConnections.FindAsync([id], cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<DataConnection>> GetAllDataConnectionsAsync(CancellationToken cancellationToken = default)
{
return await _dbContext.DataConnections.OrderBy(c => c.Name).ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<DataConnection>> GetDataConnectionsBySiteIdAsync(int siteId, CancellationToken cancellationToken = default)
{
return await _dbContext.DataConnections
@@ -82,17 +95,20 @@ public class SiteRepository : ISiteRepository
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task AddDataConnectionAsync(DataConnection connection, CancellationToken cancellationToken = default)
{
await _dbContext.DataConnections.AddAsync(connection, cancellationToken);
}
/// <inheritdoc />
public Task UpdateDataConnectionAsync(DataConnection connection, CancellationToken cancellationToken = default)
{
_dbContext.DataConnections.Update(connection);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task DeleteDataConnectionAsync(int id, CancellationToken cancellationToken = default)
{
var entity = _dbContext.DataConnections.Local.FirstOrDefault(c => c.Id == id);
@@ -111,6 +127,7 @@ public class SiteRepository : ISiteRepository
// --- Instances (for deletion constraint checks) ---
/// <inheritdoc />
public async Task<IReadOnlyList<Instance>> GetInstancesBySiteIdAsync(int siteId, CancellationToken cancellationToken = default)
{
return await _dbContext.Instances
@@ -118,6 +135,7 @@ public class SiteRepository : ISiteRepository
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
return await _dbContext.SaveChangesAsync(cancellationToken);
@@ -10,6 +10,10 @@ public class TemplateEngineRepository : ITemplateEngineRepository
{
private readonly ScadaLinkDbContext _context;
/// <summary>
/// Initializes a new instance of the TemplateEngineRepository class.
/// </summary>
/// <param name="context">The database context used to access template and instance data.</param>
public TemplateEngineRepository(ScadaLinkDbContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
@@ -17,6 +21,7 @@ public class TemplateEngineRepository : ITemplateEngineRepository
// Template
/// <inheritdoc />
public async Task<Template?> GetTemplateByIdAsync(int id, CancellationToken cancellationToken = default)
{
return await _context.Templates
@@ -28,17 +33,13 @@ public class TemplateEngineRepository : ITemplateEngineRepository
.FirstOrDefaultAsync(t => t.Id == id, cancellationToken);
}
/// <summary>
/// Loads a template together with its child members — Attributes, Alarms,
/// Scripts and Compositions — eager-loaded so callers get the full template
/// aggregate in a single round-trip. "Children" here refers to the template's
/// member collections, not derived/sub templates.
/// </summary>
/// <inheritdoc />
public async Task<Template?> GetTemplateWithChildrenAsync(int id, CancellationToken cancellationToken = default)
{
return await GetTemplateByIdAsync(id, cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<Template>> GetAllTemplatesAsync(CancellationToken cancellationToken = default)
{
return await _context.Templates
@@ -50,6 +51,7 @@ public class TemplateEngineRepository : ITemplateEngineRepository
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<Template>> GetTemplatesComposingAsync(int composedTemplateId, CancellationToken cancellationToken = default)
{
return await _context.Templates
@@ -61,17 +63,20 @@ public class TemplateEngineRepository : ITemplateEngineRepository
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task AddTemplateAsync(Template template, CancellationToken cancellationToken = default)
{
await _context.Templates.AddAsync(template, cancellationToken);
}
/// <inheritdoc />
public Task UpdateTemplateAsync(Template template, CancellationToken cancellationToken = default)
{
_context.Templates.Update(template);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task DeleteTemplateAsync(int id, CancellationToken cancellationToken = default)
{
var template = await _context.Templates.FindAsync(new object[] { id }, cancellationToken);
@@ -83,11 +88,13 @@ public class TemplateEngineRepository : ITemplateEngineRepository
// TemplateAttribute
/// <inheritdoc />
public async Task<TemplateAttribute?> GetTemplateAttributeByIdAsync(int id, CancellationToken cancellationToken = default)
{
return await _context.TemplateAttributes.FindAsync(new object[] { id }, cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<TemplateAttribute>> GetAttributesByTemplateIdAsync(int templateId, CancellationToken cancellationToken = default)
{
return await _context.TemplateAttributes
@@ -95,17 +102,20 @@ public class TemplateEngineRepository : ITemplateEngineRepository
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task AddTemplateAttributeAsync(TemplateAttribute attribute, CancellationToken cancellationToken = default)
{
await _context.TemplateAttributes.AddAsync(attribute, cancellationToken);
}
/// <inheritdoc />
public Task UpdateTemplateAttributeAsync(TemplateAttribute attribute, CancellationToken cancellationToken = default)
{
_context.TemplateAttributes.Update(attribute);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task DeleteTemplateAttributeAsync(int id, CancellationToken cancellationToken = default)
{
var attribute = await _context.TemplateAttributes.FindAsync(new object[] { id }, cancellationToken);
@@ -117,11 +127,13 @@ public class TemplateEngineRepository : ITemplateEngineRepository
// TemplateAlarm
/// <inheritdoc />
public async Task<TemplateAlarm?> GetTemplateAlarmByIdAsync(int id, CancellationToken cancellationToken = default)
{
return await _context.TemplateAlarms.FindAsync(new object[] { id }, cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<TemplateAlarm>> GetAlarmsByTemplateIdAsync(int templateId, CancellationToken cancellationToken = default)
{
return await _context.TemplateAlarms
@@ -129,17 +141,20 @@ public class TemplateEngineRepository : ITemplateEngineRepository
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task AddTemplateAlarmAsync(TemplateAlarm alarm, CancellationToken cancellationToken = default)
{
await _context.TemplateAlarms.AddAsync(alarm, cancellationToken);
}
/// <inheritdoc />
public Task UpdateTemplateAlarmAsync(TemplateAlarm alarm, CancellationToken cancellationToken = default)
{
_context.TemplateAlarms.Update(alarm);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task DeleteTemplateAlarmAsync(int id, CancellationToken cancellationToken = default)
{
var alarm = await _context.TemplateAlarms.FindAsync(new object[] { id }, cancellationToken);
@@ -151,11 +166,13 @@ public class TemplateEngineRepository : ITemplateEngineRepository
// TemplateScript
/// <inheritdoc />
public async Task<TemplateScript?> GetTemplateScriptByIdAsync(int id, CancellationToken cancellationToken = default)
{
return await _context.TemplateScripts.FindAsync(new object[] { id }, cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<TemplateScript>> GetScriptsByTemplateIdAsync(int templateId, CancellationToken cancellationToken = default)
{
return await _context.TemplateScripts
@@ -163,17 +180,20 @@ public class TemplateEngineRepository : ITemplateEngineRepository
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task AddTemplateScriptAsync(TemplateScript script, CancellationToken cancellationToken = default)
{
await _context.TemplateScripts.AddAsync(script, cancellationToken);
}
/// <inheritdoc />
public Task UpdateTemplateScriptAsync(TemplateScript script, CancellationToken cancellationToken = default)
{
_context.TemplateScripts.Update(script);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task DeleteTemplateScriptAsync(int id, CancellationToken cancellationToken = default)
{
var script = await _context.TemplateScripts.FindAsync(new object[] { id }, cancellationToken);
@@ -185,11 +205,13 @@ public class TemplateEngineRepository : ITemplateEngineRepository
// TemplateComposition
/// <inheritdoc />
public async Task<TemplateComposition?> GetTemplateCompositionByIdAsync(int id, CancellationToken cancellationToken = default)
{
return await _context.TemplateCompositions.FindAsync(new object[] { id }, cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<TemplateComposition>> GetCompositionsByTemplateIdAsync(int templateId, CancellationToken cancellationToken = default)
{
return await _context.TemplateCompositions
@@ -197,17 +219,20 @@ public class TemplateEngineRepository : ITemplateEngineRepository
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task AddTemplateCompositionAsync(TemplateComposition composition, CancellationToken cancellationToken = default)
{
await _context.TemplateCompositions.AddAsync(composition, cancellationToken);
}
/// <inheritdoc />
public Task UpdateTemplateCompositionAsync(TemplateComposition composition, CancellationToken cancellationToken = default)
{
_context.TemplateCompositions.Update(composition);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task DeleteTemplateCompositionAsync(int id, CancellationToken cancellationToken = default)
{
var composition = await _context.TemplateCompositions.FindAsync(new object[] { id }, cancellationToken);
@@ -219,6 +244,7 @@ public class TemplateEngineRepository : ITemplateEngineRepository
// Instance
/// <inheritdoc />
public async Task<Instance?> GetInstanceByIdAsync(int id, CancellationToken cancellationToken = default)
{
return await _context.Instances
@@ -229,6 +255,7 @@ public class TemplateEngineRepository : ITemplateEngineRepository
.FirstOrDefaultAsync(i => i.Id == id, cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<Instance>> GetAllInstancesAsync(CancellationToken cancellationToken = default)
{
return await _context.Instances
@@ -239,6 +266,7 @@ public class TemplateEngineRepository : ITemplateEngineRepository
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<Instance>> GetInstancesByTemplateIdAsync(int templateId, CancellationToken cancellationToken = default)
{
return await _context.Instances
@@ -246,6 +274,7 @@ public class TemplateEngineRepository : ITemplateEngineRepository
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<Instance>> GetInstancesBySiteIdAsync(int siteId, CancellationToken cancellationToken = default)
{
return await _context.Instances
@@ -257,6 +286,7 @@ public class TemplateEngineRepository : ITemplateEngineRepository
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<Instance?> GetInstanceByUniqueNameAsync(string uniqueName, CancellationToken cancellationToken = default)
{
return await _context.Instances
@@ -267,17 +297,20 @@ public class TemplateEngineRepository : ITemplateEngineRepository
.FirstOrDefaultAsync(i => i.UniqueName == uniqueName, cancellationToken);
}
/// <inheritdoc />
public async Task AddInstanceAsync(Instance instance, CancellationToken cancellationToken = default)
{
await _context.Instances.AddAsync(instance, cancellationToken);
}
/// <inheritdoc />
public Task UpdateInstanceAsync(Instance instance, CancellationToken cancellationToken = default)
{
_context.Instances.Update(instance);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task DeleteInstanceAsync(int id, CancellationToken cancellationToken = default)
{
var instance = await _context.Instances.FindAsync(new object[] { id }, cancellationToken);
@@ -289,6 +322,7 @@ public class TemplateEngineRepository : ITemplateEngineRepository
// InstanceAttributeOverride
/// <inheritdoc />
public async Task<IReadOnlyList<InstanceAttributeOverride>> GetOverridesByInstanceIdAsync(int instanceId, CancellationToken cancellationToken = default)
{
return await _context.InstanceAttributeOverrides
@@ -296,17 +330,20 @@ public class TemplateEngineRepository : ITemplateEngineRepository
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task AddInstanceAttributeOverrideAsync(InstanceAttributeOverride attributeOverride, CancellationToken cancellationToken = default)
{
await _context.InstanceAttributeOverrides.AddAsync(attributeOverride, cancellationToken);
}
/// <inheritdoc />
public Task UpdateInstanceAttributeOverrideAsync(InstanceAttributeOverride attributeOverride, CancellationToken cancellationToken = default)
{
_context.InstanceAttributeOverrides.Update(attributeOverride);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task DeleteInstanceAttributeOverrideAsync(int id, CancellationToken cancellationToken = default)
{
var attributeOverride = await _context.InstanceAttributeOverrides.FindAsync(new object[] { id }, cancellationToken);
@@ -318,6 +355,7 @@ public class TemplateEngineRepository : ITemplateEngineRepository
// InstanceAlarmOverride
/// <inheritdoc />
public async Task<IReadOnlyList<InstanceAlarmOverride>> GetAlarmOverridesByInstanceIdAsync(int instanceId, CancellationToken cancellationToken = default)
{
return await _context.InstanceAlarmOverrides
@@ -325,6 +363,7 @@ public class TemplateEngineRepository : ITemplateEngineRepository
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<InstanceAlarmOverride?> GetAlarmOverrideAsync(int instanceId, string alarmCanonicalName, CancellationToken cancellationToken = default)
{
return await _context.InstanceAlarmOverrides
@@ -333,17 +372,20 @@ public class TemplateEngineRepository : ITemplateEngineRepository
cancellationToken);
}
/// <inheritdoc />
public async Task AddInstanceAlarmOverrideAsync(InstanceAlarmOverride alarmOverride, CancellationToken cancellationToken = default)
{
await _context.InstanceAlarmOverrides.AddAsync(alarmOverride, cancellationToken);
}
/// <inheritdoc />
public Task UpdateInstanceAlarmOverrideAsync(InstanceAlarmOverride alarmOverride, CancellationToken cancellationToken = default)
{
_context.InstanceAlarmOverrides.Update(alarmOverride);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task DeleteInstanceAlarmOverrideAsync(int id, CancellationToken cancellationToken = default)
{
var alarmOverride = await _context.InstanceAlarmOverrides.FindAsync(new object[] { id }, cancellationToken);
@@ -355,6 +397,7 @@ public class TemplateEngineRepository : ITemplateEngineRepository
// InstanceConnectionBinding
/// <inheritdoc />
public async Task<IReadOnlyList<InstanceConnectionBinding>> GetBindingsByInstanceIdAsync(int instanceId, CancellationToken cancellationToken = default)
{
return await _context.InstanceConnectionBindings
@@ -362,17 +405,20 @@ public class TemplateEngineRepository : ITemplateEngineRepository
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task AddInstanceConnectionBindingAsync(InstanceConnectionBinding binding, CancellationToken cancellationToken = default)
{
await _context.InstanceConnectionBindings.AddAsync(binding, cancellationToken);
}
/// <inheritdoc />
public Task UpdateInstanceConnectionBindingAsync(InstanceConnectionBinding binding, CancellationToken cancellationToken = default)
{
_context.InstanceConnectionBindings.Update(binding);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task DeleteInstanceConnectionBindingAsync(int id, CancellationToken cancellationToken = default)
{
var binding = await _context.InstanceConnectionBindings.FindAsync(new object[] { id }, cancellationToken);
@@ -384,6 +430,7 @@ public class TemplateEngineRepository : ITemplateEngineRepository
// Area
/// <inheritdoc />
public async Task<Area?> GetAreaByIdAsync(int id, CancellationToken cancellationToken = default)
{
return await _context.Areas
@@ -391,6 +438,7 @@ public class TemplateEngineRepository : ITemplateEngineRepository
.FirstOrDefaultAsync(a => a.Id == id, cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<Area>> GetAreasBySiteIdAsync(int siteId, CancellationToken cancellationToken = default)
{
return await _context.Areas
@@ -399,17 +447,20 @@ public class TemplateEngineRepository : ITemplateEngineRepository
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task AddAreaAsync(Area area, CancellationToken cancellationToken = default)
{
await _context.Areas.AddAsync(area, cancellationToken);
}
/// <inheritdoc />
public Task UpdateAreaAsync(Area area, CancellationToken cancellationToken = default)
{
_context.Areas.Update(area);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task DeleteAreaAsync(int id, CancellationToken cancellationToken = default)
{
var area = await _context.Areas.FindAsync(new object[] { id }, cancellationToken);
@@ -421,33 +472,39 @@ public class TemplateEngineRepository : ITemplateEngineRepository
// SharedScript
/// <inheritdoc />
public async Task<SharedScript?> GetSharedScriptByIdAsync(int id, CancellationToken cancellationToken = default)
{
return await _context.SharedScripts.FindAsync(new object[] { id }, cancellationToken);
}
/// <inheritdoc />
public async Task<SharedScript?> GetSharedScriptByNameAsync(string name, CancellationToken cancellationToken = default)
{
return await _context.SharedScripts
.FirstOrDefaultAsync(s => s.Name == name, cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<SharedScript>> GetAllSharedScriptsAsync(CancellationToken cancellationToken = default)
{
return await _context.SharedScripts.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task AddSharedScriptAsync(SharedScript sharedScript, CancellationToken cancellationToken = default)
{
await _context.SharedScripts.AddAsync(sharedScript, cancellationToken);
}
/// <inheritdoc />
public Task UpdateSharedScriptAsync(SharedScript sharedScript, CancellationToken cancellationToken = default)
{
_context.SharedScripts.Update(sharedScript);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task DeleteSharedScriptAsync(int id, CancellationToken cancellationToken = default)
{
var sharedScript = await _context.SharedScripts.FindAsync(new object[] { id }, cancellationToken);
@@ -459,21 +516,26 @@ public class TemplateEngineRepository : ITemplateEngineRepository
// TemplateFolder
/// <inheritdoc />
public async Task<TemplateFolder?> GetFolderByIdAsync(int id, CancellationToken cancellationToken = default)
=> await _context.TemplateFolders.FindAsync(new object[] { id }, cancellationToken);
/// <inheritdoc />
public async Task<IReadOnlyList<TemplateFolder>> GetAllFoldersAsync(CancellationToken cancellationToken = default)
=> await _context.TemplateFolders.ToListAsync(cancellationToken);
/// <inheritdoc />
public async Task AddFolderAsync(TemplateFolder folder, CancellationToken cancellationToken = default)
=> await _context.TemplateFolders.AddAsync(folder, cancellationToken);
/// <inheritdoc />
public Task UpdateFolderAsync(TemplateFolder folder, CancellationToken cancellationToken = default)
{
_context.TemplateFolders.Update(folder);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task DeleteFolderAsync(int id, CancellationToken cancellationToken = default)
{
var folder = await _context.TemplateFolders.FindAsync(new object[] { id }, cancellationToken);
@@ -483,6 +545,7 @@ public class TemplateEngineRepository : ITemplateEngineRepository
}
}
/// <inheritdoc />
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
return await _context.SaveChangesAsync(cancellationToken);
@@ -20,6 +20,10 @@ public class ScadaLinkDbContext : DbContext, IDataProtectionKeyContext
{
private readonly IDataProtectionProvider? _dataProtectionProvider;
/// <summary>
/// Initializes a new instance of the <see cref="ScadaLinkDbContext"/> class for schema-only access (design-time).
/// </summary>
/// <param name="options">Database context options.</param>
public ScadaLinkDbContext(DbContextOptions<ScadaLinkDbContext> options) : base(options)
{
}
@@ -29,6 +33,8 @@ public class ScadaLinkDbContext : DbContext, IDataProtectionKeyContext
/// secret-bearing configuration columns at rest. The runtime resolves this overload
/// via DI; design-time tooling uses the single-argument overload.
/// </summary>
/// <param name="options">Database context options.</param>
/// <param name="dataProtectionProvider">Data Protection provider for encrypting secrets at rest.</param>
public ScadaLinkDbContext(DbContextOptions<ScadaLinkDbContext> options, IDataProtectionProvider dataProtectionProvider)
: base(options)
{
@@ -37,59 +43,92 @@ public class ScadaLinkDbContext : DbContext, IDataProtectionKeyContext
}
// Templates
/// <summary>Gets the set of templates.</summary>
public DbSet<Template> Templates => Set<Template>();
/// <summary>Gets the set of template attributes.</summary>
public DbSet<TemplateAttribute> TemplateAttributes => Set<TemplateAttribute>();
/// <summary>Gets the set of template alarms.</summary>
public DbSet<TemplateAlarm> TemplateAlarms => Set<TemplateAlarm>();
/// <summary>Gets the set of template scripts.</summary>
public DbSet<TemplateScript> TemplateScripts => Set<TemplateScript>();
/// <summary>Gets the set of template compositions.</summary>
public DbSet<TemplateComposition> TemplateCompositions => Set<TemplateComposition>();
/// <summary>Gets the set of template folders.</summary>
public DbSet<TemplateFolder> TemplateFolders => Set<TemplateFolder>();
// Instances
/// <summary>Gets the set of instances.</summary>
public DbSet<Instance> Instances => Set<Instance>();
/// <summary>Gets the set of instance attribute overrides.</summary>
public DbSet<InstanceAttributeOverride> InstanceAttributeOverrides => Set<InstanceAttributeOverride>();
/// <summary>Gets the set of instance alarm overrides.</summary>
public DbSet<InstanceAlarmOverride> InstanceAlarmOverrides => Set<InstanceAlarmOverride>();
/// <summary>Gets the set of instance connection bindings.</summary>
public DbSet<InstanceConnectionBinding> InstanceConnectionBindings => Set<InstanceConnectionBinding>();
/// <summary>Gets the set of areas.</summary>
public DbSet<Area> Areas => Set<Area>();
// Sites
/// <summary>Gets the set of sites.</summary>
public DbSet<Site> Sites => Set<Site>();
/// <summary>Gets the set of data connections.</summary>
public DbSet<DataConnection> DataConnections => Set<DataConnection>();
// Deployment
/// <summary>Gets the set of deployment records.</summary>
public DbSet<DeploymentRecord> DeploymentRecords => Set<DeploymentRecord>();
/// <summary>Gets the set of system artifact deployment records.</summary>
public DbSet<SystemArtifactDeploymentRecord> SystemArtifactDeploymentRecords => Set<SystemArtifactDeploymentRecord>();
/// <summary>Gets the set of deployed configuration snapshots.</summary>
public DbSet<DeployedConfigSnapshot> DeployedConfigSnapshots => Set<DeployedConfigSnapshot>();
// External Systems
/// <summary>Gets the set of external system definitions.</summary>
public DbSet<ExternalSystemDefinition> ExternalSystemDefinitions => Set<ExternalSystemDefinition>();
/// <summary>Gets the set of external system methods.</summary>
public DbSet<ExternalSystemMethod> ExternalSystemMethods => Set<ExternalSystemMethod>();
/// <summary>Gets the set of database connection definitions.</summary>
public DbSet<DatabaseConnectionDefinition> DatabaseConnectionDefinitions => Set<DatabaseConnectionDefinition>();
// Notifications
/// <summary>Gets the set of notification lists.</summary>
public DbSet<NotificationList> NotificationLists => Set<NotificationList>();
/// <summary>Gets the set of notification recipients.</summary>
public DbSet<NotificationRecipient> NotificationRecipients => Set<NotificationRecipient>();
/// <summary>Gets the set of SMTP configurations.</summary>
public DbSet<SmtpConfiguration> SmtpConfigurations => Set<SmtpConfiguration>();
/// <summary>Gets the set of notifications.</summary>
public DbSet<Notification> Notifications => Set<Notification>();
// Scripts
/// <summary>Gets the set of shared scripts.</summary>
public DbSet<SharedScript> SharedScripts => Set<SharedScript>();
// Security
/// <summary>Gets the set of LDAP group mappings.</summary>
public DbSet<LdapGroupMapping> LdapGroupMappings => Set<LdapGroupMapping>();
/// <summary>Gets the set of site scope rules.</summary>
public DbSet<SiteScopeRule> SiteScopeRules => Set<SiteScopeRule>();
// Inbound API
/// <summary>Gets the set of API keys.</summary>
public DbSet<ApiKey> ApiKeys => Set<ApiKey>();
/// <summary>Gets the set of API methods.</summary>
public DbSet<ApiMethod> ApiMethods => Set<ApiMethod>();
// Audit
/// <summary>Gets the set of audit log entries.</summary>
public DbSet<AuditLogEntry> AuditLogEntries => Set<AuditLogEntry>();
/// <summary>Gets the set of audit logs.</summary>
public DbSet<AuditEvent> AuditLogs => Set<AuditEvent>();
/// <summary>Gets the set of site calls.</summary>
public DbSet<SiteCall> SiteCalls => Set<SiteCall>();
// Data Protection Keys (for shared ASP.NET Data Protection across nodes)
/// <summary>Gets the set of data protection keys.</summary>
public DbSet<DataProtectionKey> DataProtectionKeys => Set<DataProtectionKey>();
/// <inheritdoc />
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
base.OnConfiguring(optionsBuilder);
@@ -103,6 +142,7 @@ public class ScadaLinkDbContext : DbContext, IDataProtectionKeyContext
optionsBuilder.ReplaceService<IModelCacheKeyFactory, SecretAwareModelCacheKeyFactory>();
}
/// <inheritdoc />
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(ScadaLinkDbContext).Assembly);
@@ -110,12 +150,14 @@ public class ScadaLinkDbContext : DbContext, IDataProtectionKeyContext
ApplySecretColumnEncryption(modelBuilder);
}
/// <inheritdoc />
public override int SaveChanges(bool acceptAllChangesOnSuccess)
{
GuardSecretWritesHaveAKeyRing();
return base.SaveChanges(acceptAllChangesOnSuccess);
}
/// <inheritdoc />
public override Task<int> SaveChangesAsync(
bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default)
{
@@ -173,11 +215,22 @@ public class ScadaLinkDbContext : DbContext, IDataProtectionKeyContext
/// </summary>
private sealed class SecretAwareModelCacheKeyFactory : IModelCacheKeyFactory
{
/// <summary>
/// Creates a model cache key that includes the Data Protection provider state.
/// </summary>
/// <param name="context">The database context.</param>
/// <param name="designTime">Whether the model is being created at design-time.</param>
/// <returns>A cache key tuple.</returns>
public object Create(DbContext context, bool designTime)
=> (context.GetType(),
designTime,
(context as ScadaLinkDbContext)?.HasSecretEncryptionProvider ?? false);
/// <summary>
/// Creates a model cache key for run-time contexts.
/// </summary>
/// <param name="context">The database context.</param>
/// <returns>A cache key tuple.</returns>
public object Create(DbContext context) => Create(context, false);
}
@@ -240,10 +293,25 @@ public class ScadaLinkDbContext : DbContext, IDataProtectionKeyContext
"columns cannot be read or written through it. Construct the context with the " +
"DI-registered IDataProtectionProvider (AddConfigurationDatabase wires this up).";
/// <summary>
/// Creates a schema-only protector that raises when called.
/// </summary>
/// <param name="purpose">The protector purpose string.</param>
/// <returns>This instance.</returns>
public IDataProtector CreateProtector(string purpose) => this;
/// <summary>
/// Protects plaintext (schema-only version always throws).
/// </summary>
/// <param name="plaintext">The data to protect.</param>
/// <returns>Never returns.</returns>
public byte[] Protect(byte[] plaintext) => throw new InvalidOperationException(Message);
/// <summary>
/// Unprotects ciphertext (schema-only version always throws).
/// </summary>
/// <param name="protectedData">The protected data.</param>
/// <returns>Never returns.</returns>
public byte[] Unprotect(byte[] protectedData) => throw new InvalidOperationException(Message);
}
}
@@ -16,6 +16,8 @@ public static class ServiceCollectionExtensions
/// <summary>
/// Registers the ScadaLinkDbContext with the provided SQL Server connection string.
/// </summary>
/// <param name="services">The service collection to register into.</param>
/// <param name="connectionString">SQL Server connection string for the central configuration database.</param>
public static IServiceCollection AddConfigurationDatabase(this IServiceCollection services, string connectionString)
{
// The DbContext is constructed via the (options, IDataProtectionProvider) overload so
@@ -76,6 +78,7 @@ public static class ServiceCollectionExtensions
/// <see cref="AddConfigurationDatabase(IServiceCollection, string)"/> and pass the
/// configured connection string.
/// </summary>
/// <param name="services">The service collection (unused; this overload always throws).</param>
/// <exception cref="InvalidOperationException">
/// Always thrown. The connection string is required; there is no valid no-op registration.
/// </exception>
@@ -10,5 +10,6 @@ namespace ScadaLink.ConfigurationDatabase.Services;
/// </summary>
public sealed class AuditCorrelationContext : IAuditCorrelationContext
{
/// <inheritdoc />
public Guid? BundleImportId { get; set; }
}
@@ -23,12 +23,18 @@ public class AuditService : IAuditService
MaxDepth = 32
};
/// <summary>
/// Initializes the audit service with the EF Core context and correlation context.
/// </summary>
/// <param name="context">The EF Core database context used to stage audit entries.</param>
/// <param name="correlationContext">Provides the active bundle import id for audit row stamping.</param>
public AuditService(ScadaLinkDbContext context, IAuditCorrelationContext correlationContext)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
_correlationContext = correlationContext ?? throw new ArgumentNullException(nameof(correlationContext));
}
/// <inheritdoc />
public async Task LogAsync(
string user,
string action,
@@ -10,11 +10,14 @@ public class InstanceLocator : IInstanceLocator
{
private readonly ScadaLinkDbContext _context;
/// <summary>Initializes the locator with the EF Core database context.</summary>
/// <param name="context">The database context used to look up instances and sites.</param>
public InstanceLocator(ScadaLinkDbContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
/// <inheritdoc />
public async Task<string?> GetSiteIdForInstanceAsync(
string instanceUniqueName,
CancellationToken cancellationToken = default)