using System.IO.Compression;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
using ZB.MOM.WW.ScadaBridge.Transport.Encryption;
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
namespace ZB.MOM.WW.ScadaBridge.Transport.Import;
///
/// Three-phase bundle importer: validates the
/// bundle envelope (manifest + content hash + decryption) and opens a
/// session; diffs the bundle's DTOs against the
/// current target database; writes the chosen
/// resolutions through the audited repositories. Only LoadAsync is
/// implemented in this slice — the other two are wired into DI now so
/// follow-up tasks can fill them in without churning the constructor.
///
/// Audit-row responsibility: repository mutation methods in
/// ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories are thin EF wrappers
/// and do NOT emit audit rows. therefore writes
/// each per-entity audit row explicitly via ;
/// the scoped is
/// automatically stamped on each row by the audit service.
///
///
/// If repository methods are ever changed to emit audit rows themselves,
/// the explicit LogAsync calls in this class must be removed to
/// avoid double-logging.
///
///
public sealed class BundleImporter : IBundleImporter
{
private static readonly JsonSerializerOptions ContentJsonOptions = new()
{
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() },
};
private readonly BundleSerializer _bundleSerializer;
private readonly ManifestValidator _manifestValidator;
private readonly BundleSecretEncryptor _encryptor;
private readonly ArtifactDiff _diff = new();
private readonly EntitySerializer _entitySerializer;
private readonly IAuditService _auditService;
private readonly IAuditCorrelationContext _correlationContext;
private readonly ScadaBridgeDbContext _dbContext;
private readonly ITemplateEngineRepository _templateRepo;
private readonly IExternalSystemRepository _externalRepo;
private readonly INotificationRepository _notificationRepo;
private readonly IInboundApiRepository _inboundApiRepo;
private readonly IBundleSessionStore _sessionStore;
private readonly BundleUnlockRateLimiter _unlockRateLimiter;
private readonly IOptions _options;
private readonly TimeProvider _timeProvider;
private readonly SemanticValidator _semanticValidator;
///
/// Initializes a new with all required dependencies.
///
/// Serializer for reading bundle zip archives.
/// Validates the bundle manifest on load.
/// Handles AES-256-GCM decryption of encrypted bundles.
/// Deserializes entity DTOs from bundle content.
/// In-memory session store for loaded bundle state.
/// Transport configuration options.
/// Abstracted time provider for testability.
/// Template engine repository for diff and apply.
/// External system repository for diff and apply.
/// Notification repository for diff and apply.
/// Inbound API repository for diff and apply.
/// Audit service for writing per-entity import audit rows.
/// Correlation context that carries the active BundleImportId.
/// EF Core context used to commit the import transaction.
/// Validates template references before applying.
public BundleImporter(
BundleSerializer bundleSerializer,
ManifestValidator manifestValidator,
BundleSecretEncryptor encryptor,
EntitySerializer entitySerializer,
IBundleSessionStore sessionStore,
BundleUnlockRateLimiter unlockRateLimiter,
IOptions options,
TimeProvider timeProvider,
ITemplateEngineRepository templateRepo,
IExternalSystemRepository externalRepo,
INotificationRepository notificationRepo,
IInboundApiRepository inboundApiRepo,
IAuditService auditService,
IAuditCorrelationContext correlationContext,
ScadaBridgeDbContext dbContext,
SemanticValidator semanticValidator)
{
_bundleSerializer = bundleSerializer ?? throw new ArgumentNullException(nameof(bundleSerializer));
_manifestValidator = manifestValidator ?? throw new ArgumentNullException(nameof(manifestValidator));
_encryptor = encryptor ?? throw new ArgumentNullException(nameof(encryptor));
_entitySerializer = entitySerializer ?? throw new ArgumentNullException(nameof(entitySerializer));
_sessionStore = sessionStore ?? throw new ArgumentNullException(nameof(sessionStore));
_unlockRateLimiter = unlockRateLimiter ?? throw new ArgumentNullException(nameof(unlockRateLimiter));
_options = options ?? throw new ArgumentNullException(nameof(options));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_templateRepo = templateRepo ?? throw new ArgumentNullException(nameof(templateRepo));
_externalRepo = externalRepo ?? throw new ArgumentNullException(nameof(externalRepo));
_notificationRepo = notificationRepo ?? throw new ArgumentNullException(nameof(notificationRepo));
_inboundApiRepo = inboundApiRepo ?? throw new ArgumentNullException(nameof(inboundApiRepo));
_auditService = auditService ?? throw new ArgumentNullException(nameof(auditService));
_correlationContext = correlationContext ?? throw new ArgumentNullException(nameof(correlationContext));
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
_semanticValidator = semanticValidator ?? throw new ArgumentNullException(nameof(semanticValidator));
}
///
public async Task LoadAsync(Stream bundleStream, string? passphrase, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(bundleStream);
// Copy to a seekable buffer — manifest + content readers each open a
// fresh ZipArchive over the same bytes, so the upstream stream needs to
// be seekable. A caller-supplied FileStream is seekable but a Kestrel
// request stream is not, so we always normalise to MemoryStream.
var ms = new MemoryStream();
await bundleStream.CopyToAsync(ms, ct).ConfigureAwait(false);
ms.Position = 0;
// Size cap is in MB; multiply in long arithmetic so the comparison
// doesn't overflow at the int boundary for large MaxBundleSizeMb.
var maxBytes = _options.Value.MaxBundleSizeMb * 1024L * 1024L;
if (ms.Length > maxBytes)
{
throw new InvalidOperationException(
$"Bundle exceeds maximum allowed size of {_options.Value.MaxBundleSizeMb} MB.");
}
// T-006: zip-bomb / decompression-bomb defences. We enforce three caps
// BEFORE any entry is decompressed by ReadManifest / ReadContentBytes:
// 1) total entry count
// 2) per-entry decompressed length
// 3) per-entry compression ratio (Length / CompressedLength)
// Each cap is configurable via TransportOptions so an operator can tune
// for an environment with legitimately large or unusually compressible
// bundles. Using ZipArchiveEntry.Length / .CompressedLength avoids
// reading the entry payload at all.
ms.Position = 0;
ValidateArchiveEnvelope(ms);
BundleManifest manifest;
try
{
ms.Position = 0;
manifest = _bundleSerializer.ReadManifest(ms);
}
catch (InvalidDataException)
{
// Preserve the serializer's specific "manifest missing/null" message
// — the caller wants to surface a precise diagnostic to the operator.
throw;
}
catch (Exception ex)
{
throw new InvalidDataException("Bundle is missing or has a malformed manifest.json.", ex);
}
ms.Position = 0;
var contentBytes = _bundleSerializer.ReadContentBytes(ms, manifest);
// Validate format version + content-hash + manifest shape. Reject paths
// surface as distinct exceptions so the UI can disambiguate the cause.
var validation = _manifestValidator.Validate(manifest, contentBytes);
switch (validation)
{
case ManifestValidationResult.UnsupportedFormatVersion:
throw new NotSupportedException(
$"Bundle format version {manifest.BundleFormatVersion} is not supported by this cluster.");
case ManifestValidationResult.ContentHashMismatch:
throw new InvalidDataException(
"Bundle content hash does not match manifest — file may be corrupt.");
case ManifestValidationResult.MalformedManifest:
throw new InvalidDataException("Bundle manifest is malformed.");
case ManifestValidationResult.Ok:
break;
default:
throw new InvalidDataException($"Unrecognised manifest validation result: {validation}.");
}
// Decrypt when the manifest carries EncryptionMetadata.
//
// T-003: lockout enforcement is server-side and keyed by ContentHash so a
// second tab / CLI caller re-uploading the same bundle bytes cannot
// side-step the limit by skipping the Razor page. The counter is
// consulted BEFORE attempting decrypt (rejects further attempts on an
// already-locked bundle) and incremented on a CryptographicException
// from _encryptor.Decrypt; a successful decrypt clears the counter so a
// legitimate operator who eventually types the right passphrase is not
// penalised for earlier typos.
byte[] decryptedContent;
if (manifest.Encryption is not null)
{
if (string.IsNullOrEmpty(passphrase))
{
throw new ArgumentException(
"Passphrase required for encrypted bundle.", nameof(passphrase));
}
var maxAttempts = _options.Value.MaxUnlockAttemptsPerSession;
var priorFailures = _sessionStore.GetUnlockFailureCount(manifest.ContentHash);
if (priorFailures >= maxAttempts)
{
throw new BundleLockedException(manifest.ContentHash, priorFailures);
}
// T-005: bind the manifest's non-derivative fields into AES-GCM AAD so
// a tampered SourceEnvironment / ExportedBy / etc. yields an
// authentication-tag mismatch (surfaced as CryptographicException) on
// decrypt — preventing a forged origin label from slipping past the
// Step-4 typo-resistant confirmation gate.
var aad = Encryption.BundleManifestAad.Compute(manifest);
try
{
decryptedContent = _encryptor.Decrypt(contentBytes, manifest.Encryption, passphrase, aad);
}
catch (CryptographicException)
{
var newCount = _sessionStore.IncrementUnlockFailureCount(manifest.ContentHash);
if (newCount >= maxAttempts)
{
// Surface the lockout as the typed exception so the caller can
// distinguish "wrong passphrase, try again" from "no more attempts".
throw new BundleLockedException(manifest.ContentHash, newCount);
}
// Otherwise rebubble the CryptographicException so the UI's
// wrong-passphrase audit + retry path continues to work unchanged.
throw;
}
_sessionStore.ClearUnlockFailures(manifest.ContentHash);
}
else
{
decryptedContent = contentBytes;
}
var ttl = TimeSpan.FromMinutes(_options.Value.BundleSessionTtlMinutes);
var session = new BundleSession
{
SessionId = Guid.NewGuid(),
Manifest = manifest,
DecryptedContent = decryptedContent,
ExpiresAt = _timeProvider.GetUtcNow() + ttl,
};
return _sessionStore.Open(session);
}
///
/// T-006: validates the zip envelope against the configured caps BEFORE any
/// entry payload is decompressed. Reads only the central-directory headers
/// ( / )
/// so a hostile bundle can't OOM the central node through this method itself.
///
/// Buffered bundle bytes. Position is preserved.
private void ValidateArchiveEnvelope(MemoryStream bundleBytes)
{
var opts = _options.Value;
var maxEntries = opts.MaxBundleEntryCount;
var maxEntryDecompressed = opts.MaxBundleEntryDecompressedMb * 1024L * 1024L;
var maxRatio = opts.MaxBundleEntryCompressionRatio;
var savedPosition = bundleBytes.Position;
try
{
bundleBytes.Position = 0;
using var archive = new ZipArchive(bundleBytes, ZipArchiveMode.Read, leaveOpen: true);
if (archive.Entries.Count > maxEntries)
{
throw new InvalidDataException(
$"Bundle contains {archive.Entries.Count} zip entries; the configured maximum is {maxEntries}.");
}
foreach (var entry in archive.Entries)
{
if (entry.Length > maxEntryDecompressed)
{
throw new InvalidDataException(
$"Bundle entry '{entry.FullName}' declares a decompressed size of {entry.Length} bytes; "
+ $"the configured maximum is {maxEntryDecompressed} bytes "
+ $"({opts.MaxBundleEntryDecompressedMb} MB).");
}
// CompressedLength of 0 means store-only or empty — skip ratio check.
if (entry.CompressedLength > 0 && entry.Length / entry.CompressedLength > maxRatio)
{
throw new InvalidDataException(
$"Bundle entry '{entry.FullName}' has compression ratio "
+ $"{entry.Length / entry.CompressedLength}x; the configured maximum is {maxRatio}x.");
}
}
}
finally
{
bundleBytes.Position = savedPosition;
}
}
///
public async Task PreviewAsync(Guid sessionId, CancellationToken ct = default)
{
var session = _sessionStore.Get(sessionId)
?? throw new InvalidOperationException($"Bundle session {sessionId} not found or expired.");
if (session.Locked)
{
throw new InvalidOperationException($"Bundle session {sessionId} is locked.");
}
BundleContentDto content;
try
{
content = JsonSerializer.Deserialize(session.DecryptedContent, ContentJsonOptions)
?? throw new InvalidDataException("Session content deserialized to null.");
}
catch (JsonException ex)
{
throw new InvalidDataException("Session content is not a valid BundleContentDto.", ex);
}
var items = new List();
// ---- TemplateFolders ----
var allFolders = await _templateRepo.GetAllFoldersAsync(ct).ConfigureAwait(false);
var folderByName = allFolders.ToDictionary(f => f.Name, f => f, StringComparer.Ordinal);
var folderNameById = allFolders.ToDictionary(f => f.Id, f => f.Name);
foreach (var fDto in content.TemplateFolders)
{
folderByName.TryGetValue(fDto.Name, out var existing);
items.Add(_diff.CompareTemplateFolder(fDto, existing, folderNameById));
}
// ---- Templates ----
// Transport-008: previously this loop iterated GetAllTemplatesAsync()
// and called GetTemplateWithChildrenAsync(stub.Id) once per matching
// name (classic N+1). The bulk variant fetches every matching template
// with children eager-loaded in a single round-trip.
var bundleTemplateNames = content.Templates.Select(t => t.Name);
var hydratedTemplates = await _templateRepo
.GetTemplatesWithChildrenAsync(bundleTemplateNames, ct)
.ConfigureAwait(false);
var hydratedByName = hydratedTemplates
.ToDictionary(t => t.Name, t => t, StringComparer.Ordinal);
foreach (var tDto in content.Templates)
{
hydratedByName.TryGetValue(tDto.Name, out var existing);
items.Add(_diff.CompareTemplate(tDto, existing));
}
// ---- SharedScripts ----
foreach (var s in content.SharedScripts)
{
var existing = await _templateRepo.GetSharedScriptByNameAsync(s.Name, ct).ConfigureAwait(false);
items.Add(_diff.CompareSharedScript(s, existing));
}
// ---- ExternalSystems (+ their methods) ----
foreach (var es in content.ExternalSystems)
{
var existing = await _externalRepo.GetExternalSystemByNameAsync(es.Name, ct).ConfigureAwait(false);
IReadOnlyList? methods = null;
if (existing is not null)
{
methods = await _externalRepo.GetMethodsByExternalSystemIdAsync(existing.Id, ct).ConfigureAwait(false);
}
items.Add(_diff.CompareExternalSystem(es, existing, methods));
}
// ---- DatabaseConnections ----
foreach (var db in content.DatabaseConnections)
{
var existing = await _externalRepo.GetDatabaseConnectionByNameAsync(db.Name, ct).ConfigureAwait(false);
items.Add(_diff.CompareDatabaseConnection(db, existing));
}
// ---- NotificationLists ----
foreach (var nl in content.NotificationLists)
{
var existing = await _notificationRepo.GetListByNameAsync(nl.Name, ct).ConfigureAwait(false);
items.Add(_diff.CompareNotificationList(nl, existing));
}
// ---- SmtpConfigurations (no by-host lookup — scan GetAll) ----
var allSmtp = await _notificationRepo.GetAllSmtpConfigurationsAsync(ct).ConfigureAwait(false);
var smtpByHost = allSmtp.ToDictionary(s => s.Host, s => s, StringComparer.Ordinal);
foreach (var sm in content.SmtpConfigs)
{
smtpByHost.TryGetValue(sm.Host, out var existing);
items.Add(_diff.CompareSmtpConfiguration(sm, existing));
}
// ---- ApiKeys (no by-name lookup — scan GetAll) ----
var allApiKeys = await _inboundApiRepo.GetAllApiKeysAsync(ct).ConfigureAwait(false);
var apiKeyByName = allApiKeys.ToDictionary(k => k.Name, k => k, StringComparer.Ordinal);
foreach (var k in content.ApiKeys)
{
apiKeyByName.TryGetValue(k.Name, out var existing);
items.Add(_diff.CompareApiKey(k, existing));
}
// ---- ApiMethods ----
foreach (var m in content.ApiMethods)
{
var existing = await _inboundApiRepo.GetMethodByNameAsync(m.Name, ct).ConfigureAwait(false);
items.Add(_diff.CompareApiMethod(m, existing));
}
// ---- Blocker detection ----
items.AddRange(await DetectBlockersAsync(content, ct).ConfigureAwait(false));
return new ImportPreview(sessionId, items);
}
///
/// Surfaces unresolved cross-entity references: a TemplateScript or
/// ApiMethod body that name-mentions a SharedScript or ExternalSystem that
/// is in neither the bundle nor the target database. We reuse the same
/// substring-with-word-boundary scan as DependencyResolver; the
/// implementations are kept in lockstep but not factored out yet because
/// the resolver's scan operates on entity Code while the importer's scan
/// operates on DTO Code — same algorithm, different inputs.
///
private async Task> DetectBlockersAsync(BundleContentDto content, CancellationToken ct)
{
var blockers = new List();
// Known-resolvable names = (in-bundle) ∪ (already-in-target).
var allSharedScripts = await _templateRepo.GetAllSharedScriptsAsync(ct).ConfigureAwait(false);
var allExternalSystems = await _externalRepo.GetAllExternalSystemsAsync(ct).ConfigureAwait(false);
var sharedScriptNames = new HashSet(StringComparer.Ordinal);
foreach (var s in content.SharedScripts) sharedScriptNames.Add(s.Name);
foreach (var s in allSharedScripts) sharedScriptNames.Add(s.Name);
var externalSystemNames = new HashSet(StringComparer.Ordinal);
foreach (var e in content.ExternalSystems) externalSystemNames.Add(e.Name);
foreach (var e in allExternalSystems) externalSystemNames.Add(e.Name);
// Heuristic: collect a small candidate vocabulary of identifiers used
// by the bundle's scripts that are NOT one of the known-good names, and
// check whether each one was previously a SharedScript or ExternalSystem
// (i.e. matches the naming-convention shape of an identifier reference).
// For v1, we walk the SharedScripts the bundle *expects* — by scanning
// bodies and reporting any identifier-shaped token that resolves to a
// SharedScript by historical record... but that's overreach.
//
// Simpler + sufficient v1: scan every script body in the bundle's
// templates + ApiMethods, and for each occurrence of "Name(" where
// Name is a valid identifier, if Name appears in NEITHER set, surface
// it as a Blocker. This catches the documented use-case
// (HelperFn() / ErpSystem.Call()) without combinatorial blowup.
var referencedFromBundle = new HashSet(StringComparer.Ordinal);
foreach (var t in content.Templates)
{
foreach (var s in t.Scripts) CollectCallIdentifiers(s.Code, referencedFromBundle);
foreach (var a in t.Attributes)
{
// Attribute.Value carries the design-time default expression, which
// can be script-callable. DataSourceReference is an OPC UA node
// address path (e.g. "ns=3;s=Tank.Level") owned by the device --
// it's never script source and must NOT be scanned, or the dot
// delimiter trips the heuristic into flagging the address segments
// as missing SharedScript/ExternalSystem references.
CollectCallIdentifiers(a.Value, referencedFromBundle);
}
}
foreach (var m in content.ApiMethods)
{
CollectCallIdentifiers(m.Script, referencedFromBundle);
}
// For each candidate, only report it as a blocker if it looks like a
// resource reference (PascalCase, length > 1), isn't a well-known
// language / runtime / SQL token, AND isn't present anywhere we can
// satisfy it. The denylist is the noise filter that keeps the
// heuristic usable on real script bodies — without it, every member
// access (`obj.ToString()`) and stdlib type (`DateTimeOffset`) gets
// flagged.
foreach (var candidate in referencedFromBundle.OrderBy(n => n, StringComparer.Ordinal))
{
if (!LooksLikeResourceName(candidate)) continue;
if (KnownNonReferenceNames.Contains(candidate)) continue;
var isShared = sharedScriptNames.Contains(candidate);
var isExternal = externalSystemNames.Contains(candidate);
if (isShared || isExternal) continue;
blockers.Add(new ImportPreviewItem(
EntityType: "Reference",
Name: candidate,
ExistingVersion: null,
IncomingVersion: null,
Kind: ConflictKind.Blocker,
FieldDiffJson: null,
BlockerReason: $"References SharedScript or ExternalSystem '{candidate}' not present in bundle or target."));
}
return blockers;
}
private static void CollectCallIdentifiers(string? body, HashSet sink)
{
if (string.IsNullOrEmpty(body)) return;
// Find every "Identifier(" or "Identifier." occurrence. The boundary
// before the identifier must NOT be an identifier char so we don't
// match the trailing portion of a longer token, AND must not be a
// dot — otherwise `obj.Method()` would flag `Method` as a top-level
// reference. Member-access trailing identifiers are skipped.
for (var i = 0; i < body.Length; i++)
{
if (!IsIdentifierStart(body[i])) continue;
if (i > 0 && (IsIdentifierChar(body[i - 1]) || body[i - 1] == '.')) continue;
var start = i;
while (i < body.Length && IsIdentifierChar(body[i])) i++;
if (i >= body.Length) break;
var trailing = body[i];
if (trailing == '(' || trailing == '.')
{
sink.Add(body[start..i]);
}
}
}
///
/// Names that look like PascalCase references but are never user-defined
/// SharedScripts or ExternalSystems. Filters the false-positive noise the
/// identifier scan produces against real script bodies: .NET stdlib types
/// and helpers, ScadaBridge runtime API roots, and common SQL keywords that
/// appear inside string literals. Match is case-sensitive (Ordinal).
///
private static readonly HashSet KnownNonReferenceNames = new(StringComparer.Ordinal)
{
// .NET / C# stdlib
"Boolean", "Byte", "Char", "Console", "Convert", "DateTime",
"DateTimeOffset", "Decimal", "Dispose", "Double", "Enumerable",
"Exception", "Guid", "Int16", "Int32", "Int64", "List", "Math",
"Now", "Object", "Single", "String", "Task", "TimeSpan", "ToBoolean",
"ToDateTime", "ToDecimal", "ToDouble", "ToInt16", "ToInt32", "ToInt64",
"ToList", "ToSingle", "ToString", "UtcNow",
// ScadaBridge script runtime API roots and well-known members
"Attribute", "Attributes", "Call", "CallScript", "CallShared",
"Connection", "CreateCommand", "Database", "ExecuteAsync",
"ExecuteNonQueryAsync", "ExecuteReaderAsync", "ExecuteScalarAsync",
"ExternalSystem", "GetAsync", "GetAttribute", "Instance", "Notify",
"Parameters", "Request", "Response", "Route", "Scheduler", "Scripts",
"Send", "SetAsync", "SetAttribute",
// SQL keywords commonly seen inside string literals
"COUNT", "FROM", "GROUP", "INSERT", "JOIN", "ORDER", "SELECT",
"UPDATE", "WHERE",
};
private static bool LooksLikeResourceName(string name)
{
if (name.Length < 2) return false;
if (!char.IsUpper(name[0])) return false;
for (var i = 1; i < name.Length; i++)
{
if (!IsIdentifierChar(name[i])) return false;
}
return true;
}
private static bool IsIdentifierStart(char c) => c == '_' || char.IsLetter(c);
private static bool IsIdentifierChar(char c) => c == '_' || char.IsLetterOrDigit(c);
///
public async Task ApplyAsync(
Guid sessionId,
IReadOnlyList resolutions,
string user,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(resolutions);
ArgumentNullException.ThrowIfNull(user);
var session = _sessionStore.Get(sessionId)
?? throw new InvalidOperationException($"Bundle session {sessionId} not found or expired.");
if (session.Locked)
{
throw new InvalidOperationException($"Bundle session {sessionId} is locked.");
}
BundleContentDto content;
try
{
content = JsonSerializer.Deserialize(session.DecryptedContent, ContentJsonOptions)
?? throw new InvalidDataException("Session content deserialized to null.");
}
catch (JsonException ex)
{
throw new InvalidDataException("Session content is not a valid BundleContentDto.", ex);
}
var bundleImportId = Guid.NewGuid();
var resolutionMap = resolutions.ToDictionary(
r => (r.EntityType, r.Name),
r => r);
var summary = new ImportSummary();
// Set the correlation BEFORE the transaction so any audit writes
// triggered during the apply pick up the BundleImportId — AuditService
// reads the scoped context at the moment LogAsync is called.
_correlationContext.BundleImportId = bundleImportId;
// BeginTransactionAsync is a no-op on the in-memory EF provider (which
// logs an InMemoryEventId.TransactionIgnoredWarning by default). To keep
// rollback semantics testable on in-memory AND correct on relational
// providers, we defer the SINGLE SaveChangesAsync call until just before
// CommitAsync — every Add*Async + LogAsync call only stages on the
// change tracker, so throwing before SaveChangesAsync naturally undoes
// the entire apply on both providers.
await using var tx = await _dbContext.Database.BeginTransactionAsync(ct).ConfigureAwait(false);
try
{
// Run semantic validation FIRST — before any writes are staged.
// This is purely a name-resolution scan over the in-memory DTOs +
// pre-existing target reads, so it has no ordering dependency on
// the Apply* helpers. Failing here means the change tracker is
// still empty, which keeps the rollback contract simple on both
// the in-memory and relational providers (FU-B: intermediate
// SaveChangesAsync between Apply* and the second-pass rewire
// would otherwise prevent the in-memory provider from undoing
// already-flushed template rows on validation failure — the
// in-memory transaction is a no-op, so the only safe pattern is
// ONE deferred SaveChangesAsync at the very end of try-block).
//
// Skip-resolved DTOs are excluded from the in-bundle name set so
// a Skip on a dependency surfaces as a missing-reference error
// rather than silently passing.
var validationErrors = await RunSemanticValidationAsync(content, resolutionMap, ct).ConfigureAwait(false);
if (validationErrors.Count > 0)
{
throw new SemanticValidationException(validationErrors);
}
await ApplyTemplateFoldersAsync(content.TemplateFolders, resolutionMap, user, summary, ct).ConfigureAwait(false);
await ApplyTemplatesAsync(content.Templates, resolutionMap, user, summary, ct).ConfigureAwait(false);
await ApplySharedScriptsAsync(content.SharedScripts, resolutionMap, user, summary, ct).ConfigureAwait(false);
await ApplyExternalSystemsAsync(content.ExternalSystems, resolutionMap, user, summary, ct).ConfigureAwait(false);
await ApplyDatabaseConnectionsAsync(content.DatabaseConnections, resolutionMap, user, summary, ct).ConfigureAwait(false);
await ApplyNotificationListsAsync(content.NotificationLists, resolutionMap, user, summary, ct).ConfigureAwait(false);
await ApplySmtpConfigsAsync(content.SmtpConfigs, resolutionMap, user, summary, ct).ConfigureAwait(false);
await ApplyApiKeysAsync(content.ApiKeys, resolutionMap, user, summary, ct).ConfigureAwait(false);
await ApplyApiMethodsAsync(content.ApiMethods, resolutionMap, user, summary, ct).ConfigureAwait(false);
// FU-B / #39 + remainder of #37 — second-pass rewire of name-keyed
// FKs that can only be resolved AFTER every template's scripts and
// child rows have been staged. We flush here so Pass A can look up
// the just-persisted scripts by name and Pass B can look up the
// just-persisted templates by name; both passes stage further
// mutations that ride the SAME outer transaction (committed below).
//
// EF tracking note: AddAsync on the in-memory provider assigns
// synthetic ids eagerly, so this intermediate flush mostly
// materialises identity values on a relational provider. The
// rollback contract is preserved because semantic validation
// already ran above — any throw from this point onward represents
// a successful merge that the user wants to keep, and the only
// remaining failure surface is the final BundleImported audit
// write itself.
await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
await ResolveAlarmScriptLinksAsync(content.Templates, resolutionMap, user, ct).ConfigureAwait(false);
await ResolveCompositionEdgesAsync(content.Templates, resolutionMap, user, ct).ConfigureAwait(false);
await _auditService.LogAsync(
user: user,
action: "BundleImported",
entityType: "Bundle",
entityId: bundleImportId.ToString(),
entityName: session.Manifest.SourceEnvironment,
afterState: new
{
BundleImportId = bundleImportId,
session.Manifest.SourceEnvironment,
session.Manifest.ContentHash,
Summary = new
{
summary.Added,
summary.Overwritten,
summary.Skipped,
summary.Renamed,
},
},
cancellationToken: ct).ConfigureAwait(false);
await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
await tx.CommitAsync(ct).ConfigureAwait(false);
// T-007: zero out the decrypted plaintext BEFORE remove so any
// caller-held reference (e.g. the Razor page that built the
// ImportPreview) sees the cleared buffer too. Remove drops the
// dictionary entry; together they release the secrets immediately
// instead of leaving them in process memory for the full TTL.
ZeroDecryptedContent(session);
_sessionStore.Remove(sessionId);
return new ImportResult(
BundleImportId: bundleImportId,
Added: summary.Added,
Overwritten: summary.Overwritten,
Skipped: summary.Skipped,
Renamed: summary.Renamed,
StaleInstanceIds: Array.Empty(),
AuditEventCorrelation: bundleImportId.ToString());
}
catch (Exception ex)
{
// Rollback can itself throw (connection drop mid-rollback, provider
// bug, etc). If it does, we must STILL write the BundleImportFailed
// audit row — otherwise a rollback-failure path silently swallows
// the import's audit trail. Capture the rollback exception (if any)
// and surface it on the failure row alongside the original cause.
Exception? rollbackFailure = null;
try
{
await tx.RollbackAsync(ct).ConfigureAwait(false);
}
catch (Exception rbEx)
{
rollbackFailure = rbEx;
}
// If rollback threw the IDbContextTransaction is in an indeterminate
// state and still associated with the DbContext — a subsequent
// SaveChangesAsync would attempt to enlist in (or commit to) that
// broken transaction, and the failure-row would itself be rolled
// back when the transaction is finally disposed. Dispose it now so
// the audit-row write below uses a fresh implicit transaction. On
// the happy rollback path Dispose is a benign no-op (the using
// would call it on scope exit anyway).
if (rollbackFailure is not null)
{
try { await tx.DisposeAsync().ConfigureAwait(false); }
catch { /* dispose-after-throw must not mask the original cause */ }
}
// Clear the change tracker before writing the failure row — on the
// in-memory provider the rollback is a no-op and the staged adds
// would otherwise persist when the next SaveChangesAsync runs. This
// also matters when rollback threw: the change tracker is in an
// ambiguous state and we don't want the failure-write to sweep up
// any of the staged apply mutations.
_dbContext.ChangeTracker.Clear();
// Clear correlation FIRST so the failure row doesn't carry the now-
// rolled-back BundleImportId. The contract is: BundleImportFailed
// exists at top level (no correlation) so audit consumers can see
// imports that aborted before any rows landed.
_correlationContext.BundleImportId = null;
try
{
await _auditService.LogAsync(
user: user,
action: "BundleImportFailed",
entityType: "Bundle",
entityId: bundleImportId.ToString(),
entityName: session.Manifest.SourceEnvironment,
afterState: new
{
BundleImportId = bundleImportId,
Reason = ex.Message,
ExceptionType = ex.GetType().FullName,
RollbackException = rollbackFailure?.Message,
},
cancellationToken: ct).ConfigureAwait(false);
await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
}
catch
{
// Audit-write is best-effort per design §10 ("Audit-write failure
// NEVER aborts the user-facing action — audit is best-effort, the
// action's own success/failure path is authoritative"). Swallow
// any failure here so the original exception below propagates
// unchanged rather than being masked by an audit-layer fault.
}
// T-007: a failed apply used to leave the BundleSession (with its
// decrypted secrets) in the in-memory store for the full 30-minute
// TTL — 10 failed 100 MB imports = 1 GB of plaintext still rooted.
// Drop the session here too so the secrets are released as soon as
// the failure surfaces, not when the next Get() happens to evict.
ZeroDecryptedContent(session);
_sessionStore.Remove(sessionId);
throw;
}
finally
{
// Always clear — even on the success path the correlation only
// applies to the apply we just finished. Subsequent operations on
// this scope (e.g. a second concurrent apply on a circuit) must
// not inherit the import id.
_correlationContext.BundleImportId = null;
}
}
///
/// T-007: zeros the session's
/// buffer in place so any caller still holding a reference observes the
/// cleared bytes. Best-effort — a null/empty buffer is a no-op.
///
private static void ZeroDecryptedContent(BundleSession session)
{
var buf = session.DecryptedContent;
if (buf is { Length: > 0 })
{
Array.Clear(buf, 0, buf.Length);
}
}
/// Mutable per-apply counter struct, accumulated through every helper.
private sealed class ImportSummary
{
/// Number of artifacts inserted as new entries.
public int Added { get; set; }
/// Number of artifacts overwritten with the bundle version.
public int Overwritten { get; set; }
/// Number of artifacts skipped (left unchanged).
public int Skipped { get; set; }
/// Number of artifacts renamed to avoid a collision.
public int Renamed { get; set; }
}
///
/// Returns the resolution for the given (entityType, name) tuple, defaulting to
/// when no explicit resolution was supplied —
/// the diff engine surfaces every artifact in the preview so any missing
/// entry means the UI didn't override the default, which for a New artifact
/// is Add. This keeps the apply tolerant of partial-resolution payloads.
///
private static ImportResolution ResolveOrDefault(
Dictionary<(string EntityType, string Name), ImportResolution> map,
string entityType,
string name)
{
return map.TryGetValue((entityType, name), out var r)
? r
: new ImportResolution(entityType, name, ResolutionAction.Add, RenameTo: null);
}
private async Task ApplyTemplateFoldersAsync(
IReadOnlyList dtos,
Dictionary<(string, string), ImportResolution> map,
string user,
ImportSummary summary,
CancellationToken ct)
{
if (dtos.Count == 0) return;
var existing = await _templateRepo.GetAllFoldersAsync(ct).ConfigureAwait(false);
var byName = existing.ToDictionary(f => f.Name, f => f, StringComparer.Ordinal);
foreach (var dto in dtos)
{
var resolution = ResolveOrDefault(map, "TemplateFolder", dto.Name);
switch (resolution.Action)
{
case ResolutionAction.Skip:
summary.Skipped++;
break;
case ResolutionAction.Rename:
{
var name = resolution.RenameTo ?? dto.Name;
var folder = new TemplateFolder(name) { SortOrder = dto.SortOrder };
await _templateRepo.AddFolderAsync(folder, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Create", "TemplateFolder", "0", name,
new { folder.Name, folder.SortOrder, RenamedFrom = dto.Name }, ct).ConfigureAwait(false);
summary.Renamed++;
break;
}
case ResolutionAction.Overwrite when byName.TryGetValue(dto.Name, out var ex):
ex.SortOrder = dto.SortOrder;
await _templateRepo.UpdateFolderAsync(ex, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Update", "TemplateFolder", ex.Id.ToString(), ex.Name,
new { ex.Name, ex.SortOrder }, ct).ConfigureAwait(false);
summary.Overwritten++;
break;
case ResolutionAction.Add:
case ResolutionAction.Overwrite:
default:
{
var folder = new TemplateFolder(dto.Name) { SortOrder = dto.SortOrder };
await _templateRepo.AddFolderAsync(folder, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Create", "TemplateFolder", "0", folder.Name,
new { folder.Name, folder.SortOrder }, ct).ConfigureAwait(false);
summary.Added++;
break;
}
}
}
}
private async Task ApplyTemplatesAsync(
IReadOnlyList dtos,
Dictionary<(string, string), ImportResolution> map,
string user,
ImportSummary summary,
CancellationToken ct)
{
if (dtos.Count == 0) return;
var stubs = await _templateRepo.GetAllTemplatesAsync(ct).ConfigureAwait(false);
var byName = stubs.ToDictionary(t => t.Name, t => t, StringComparer.Ordinal);
// Folder ids must be materialised before we wire templates' FolderId
// FKs. ApplyTemplateFoldersAsync staged the rows via AddFolderAsync
// but did NOT call SaveChanges -- on a relational provider that means
// every new folder still has Id=0 in the change tracker. Flushing
// here is safe: we're inside the outer import transaction (begun in
// ApplyAsync), so any later throw still rolls everything back as one
// unit. The cost is one extra round-trip per import, negligible.
await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
var folders = await _templateRepo.GetAllFoldersAsync(ct).ConfigureAwait(false);
var folderIdByName = folders.ToDictionary(f => f.Name, f => f.Id, StringComparer.Ordinal);
// Honour folder renames -- a TemplateFolder resolved as Rename was
// written under its new name; templates that reference the old name in
// the bundle DTO must map to the renamed folder, not be orphaned.
int? ResolveFolderId(string? folderName)
{
if (folderName is null) return null;
var folderResolution = ResolveOrDefault(map, "TemplateFolder", folderName);
var lookupName = (folderResolution.Action == ResolutionAction.Rename
&& !string.IsNullOrEmpty(folderResolution.RenameTo))
? folderResolution.RenameTo
: folderName;
return folderIdByName.TryGetValue(lookupName, out var fid) ? fid : (int?)null;
}
foreach (var dto in dtos)
{
var resolution = ResolveOrDefault(map, "Template", dto.Name);
var folderId = ResolveFolderId(dto.FolderName);
switch (resolution.Action)
{
case ResolutionAction.Skip:
summary.Skipped++;
break;
case ResolutionAction.Rename:
{
var name = resolution.RenameTo ?? dto.Name;
var t = BuildTemplate(dto, overrideName: name);
t.FolderId = folderId;
await _templateRepo.AddTemplateAsync(t, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Create", "Template", "0", name,
new { Name = name, dto.Description, FolderId = folderId, RenamedFrom = dto.Name }, ct).ConfigureAwait(false);
summary.Renamed++;
break;
}
case ResolutionAction.Overwrite when byName.TryGetValue(dto.Name, out var ex):
ex.Description = dto.Description;
ex.FolderId = folderId;
await _templateRepo.UpdateTemplateAsync(ex, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Update", "Template", ex.Id.ToString(), ex.Name,
new { ex.Name, ex.Description, ex.FolderId }, ct).ConfigureAwait(false);
// T-001: Overwrite must also synchronise child collections —
// attributes / alarms / scripts diverging between the bundle
// and the target must round-trip. Composition rewire is
// handled by ResolveCompositionEdgesAsync after the global
// flush; alarm→script FKs are rewired by
// ResolveAlarmScriptLinksAsync. The helpers below stage the
// child diffs (add / update / delete) onto the tracked
// entity and emit one audit row per detected change.
await SyncTemplateAttributesAsync(ex, dto, user, ct).ConfigureAwait(false);
await SyncTemplateAlarmsAsync(ex, dto, user, ct).ConfigureAwait(false);
await SyncTemplateScriptsAsync(ex, dto, user, ct).ConfigureAwait(false);
summary.Overwritten++;
break;
case ResolutionAction.Add:
case ResolutionAction.Overwrite:
default:
{
var t = BuildTemplate(dto, overrideName: null);
t.FolderId = folderId;
await _templateRepo.AddTemplateAsync(t, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Create", "Template", "0", t.Name,
new { t.Name, t.Description, t.FolderId }, ct).ConfigureAwait(false);
summary.Added++;
break;
}
}
}
}
///
/// Builds a from a bundle DTO, copying attributes /
/// alarms / scripts. Two name-keyed FKs are NOT wired here because they
/// require post-flush identity values:
///
/// - TemplateAlarm.OnTriggerScriptId — points at a sibling
/// TemplateScript; resolved by
/// once SaveChangesAsync has assigned script ids.
/// - TemplateComposition.ComposedTemplateId — points at any
/// other persisted Template; resolved by
/// once all bundled templates
/// have been flushed and any pre-existing target templates can be joined
/// in by name.
///
/// Both resolution passes run inside the same outer import transaction.
/// supports the Rename resolution; pass
/// null to keep the DTO's original name. Renamed templates are
/// looked up by their imported name (i.e. RenameTo) when
/// the second pass resolves their alarm/composition FKs; however, bundle
/// DTOs that reference a renamed template by its original name
/// will still fall through to the unresolved-audit path — call sites are
/// not rewritten in v1.
///
private static Template BuildTemplate(TemplateDto dto, string? overrideName)
{
var t = new Template(overrideName ?? dto.Name) { Description = dto.Description };
foreach (var a in dto.Attributes)
{
t.Attributes.Add(new TemplateAttribute(a.Name)
{
Value = a.Value,
DataType = a.DataType,
IsLocked = a.IsLocked,
Description = a.Description,
DataSourceReference = a.DataSourceReference,
});
}
foreach (var al in dto.Alarms)
{
t.Alarms.Add(new TemplateAlarm(al.Name)
{
Description = al.Description,
PriorityLevel = al.PriorityLevel,
TriggerType = al.TriggerType,
TriggerConfiguration = al.TriggerConfiguration,
IsLocked = al.IsLocked,
});
}
foreach (var s in dto.Scripts)
{
t.Scripts.Add(new TemplateScript(s.Name, s.Code)
{
TriggerType = s.TriggerType,
TriggerConfiguration = s.TriggerConfiguration,
ParameterDefinitions = s.ParameterDefinitions,
ReturnDefinition = s.ReturnDefinition,
IsLocked = s.IsLocked,
});
}
return t;
}
///
/// T-001 — Overwrite child sync (attributes). Diffs the DTO's
/// Attributes against the existing template's attribute collection
/// by name and stages add / update / delete on the tracked entity. Emits
/// one TemplateAttributeAdded / TemplateAttributeUpdated /
/// TemplateAttributeDeleted audit row per detected change — the
/// per-field rows the design doc's Configuration Audit Trail table
/// enumerates for the "Template overwritten" action.
///
/// Update detection compares every scalar field (Value, DataType,
/// IsLocked, Description, DataSourceReference) — no field change → no
/// audit row, so an idempotent overwrite produces no noise.
///
///
private async Task SyncTemplateAttributesAsync(
Template ex,
TemplateDto dto,
string user,
CancellationToken ct)
{
var existingByName = ex.Attributes.ToDictionary(a => a.Name, a => a, StringComparer.Ordinal);
var dtoByName = dto.Attributes.ToDictionary(a => a.Name, a => a, StringComparer.Ordinal);
// Deletes — attributes present on the target but not in the bundle.
foreach (var existing in existingByName.Values.ToList())
{
if (dtoByName.ContainsKey(existing.Name)) continue;
await _templateRepo.DeleteTemplateAttributeAsync(existing.Id, ct).ConfigureAwait(false);
ex.Attributes.Remove(existing);
await _auditService.LogAsync(
user,
"TemplateAttributeDeleted",
"TemplateAttribute",
existing.Id.ToString(),
$"{ex.Name}.{existing.Name}",
new { TemplateName = ex.Name, AttributeName = existing.Name },
ct).ConfigureAwait(false);
}
// Adds + Updates.
foreach (var attrDto in dto.Attributes)
{
if (existingByName.TryGetValue(attrDto.Name, out var current))
{
// Update only if any field actually changed.
bool changed =
!string.Equals(current.Value, attrDto.Value, StringComparison.Ordinal) ||
current.DataType != attrDto.DataType ||
current.IsLocked != attrDto.IsLocked ||
!string.Equals(current.Description, attrDto.Description, StringComparison.Ordinal) ||
!string.Equals(current.DataSourceReference, attrDto.DataSourceReference, StringComparison.Ordinal);
if (!changed) continue;
current.Value = attrDto.Value;
current.DataType = attrDto.DataType;
current.IsLocked = attrDto.IsLocked;
current.Description = attrDto.Description;
current.DataSourceReference = attrDto.DataSourceReference;
await _templateRepo.UpdateTemplateAttributeAsync(current, ct).ConfigureAwait(false);
await _auditService.LogAsync(
user,
"TemplateAttributeUpdated",
"TemplateAttribute",
current.Id.ToString(),
$"{ex.Name}.{current.Name}",
new
{
TemplateName = ex.Name,
AttributeName = current.Name,
current.Value,
current.DataType,
current.IsLocked,
current.Description,
current.DataSourceReference,
},
ct).ConfigureAwait(false);
}
else
{
var newAttr = new TemplateAttribute(attrDto.Name)
{
Value = attrDto.Value,
DataType = attrDto.DataType,
IsLocked = attrDto.IsLocked,
Description = attrDto.Description,
DataSourceReference = attrDto.DataSourceReference,
};
ex.Attributes.Add(newAttr);
await _auditService.LogAsync(
user,
"TemplateAttributeAdded",
"TemplateAttribute",
"0",
$"{ex.Name}.{newAttr.Name}",
new
{
TemplateName = ex.Name,
AttributeName = newAttr.Name,
newAttr.Value,
newAttr.DataType,
newAttr.IsLocked,
newAttr.Description,
newAttr.DataSourceReference,
},
ct).ConfigureAwait(false);
}
}
}
///
/// T-001 — Overwrite child sync (alarms). Mirrors
/// for the alarm collection.
/// Updated / added alarms have their OnTriggerScriptId cleared so
/// the post-flush pass re-binds
/// the FK from the DTO's OnTriggerScriptName against the synced
/// script collection. Audit rows: TemplateAlarmAdded /
/// TemplateAlarmUpdated / TemplateAlarmDeleted.
///
private async Task SyncTemplateAlarmsAsync(
Template ex,
TemplateDto dto,
string user,
CancellationToken ct)
{
var existingByName = ex.Alarms.ToDictionary(a => a.Name, a => a, StringComparer.Ordinal);
var dtoByName = dto.Alarms.ToDictionary(a => a.Name, a => a, StringComparer.Ordinal);
foreach (var existing in existingByName.Values.ToList())
{
if (dtoByName.ContainsKey(existing.Name)) continue;
await _templateRepo.DeleteTemplateAlarmAsync(existing.Id, ct).ConfigureAwait(false);
ex.Alarms.Remove(existing);
await _auditService.LogAsync(
user,
"TemplateAlarmDeleted",
"TemplateAlarm",
existing.Id.ToString(),
$"{ex.Name}.{existing.Name}",
new { TemplateName = ex.Name, AlarmName = existing.Name },
ct).ConfigureAwait(false);
}
foreach (var alarmDto in dto.Alarms)
{
if (existingByName.TryGetValue(alarmDto.Name, out var current))
{
bool changed =
!string.Equals(current.Description, alarmDto.Description, StringComparison.Ordinal) ||
current.PriorityLevel != alarmDto.PriorityLevel ||
current.TriggerType != alarmDto.TriggerType ||
!string.Equals(current.TriggerConfiguration, alarmDto.TriggerConfiguration, StringComparison.Ordinal) ||
current.IsLocked != alarmDto.IsLocked;
if (!changed)
{
// Always reset the script FK on Overwrite so the post-flush
// resolve pass owns the binding (the DTO's script name is
// the authoritative reference); leaving a stale FK would
// silently survive Overwrite when the user expected the
// bundle to be the source of truth.
if ((current.OnTriggerScriptId is not null) ||
!string.IsNullOrEmpty(alarmDto.OnTriggerScriptName))
{
current.OnTriggerScriptId = null;
await _templateRepo.UpdateTemplateAlarmAsync(current, ct).ConfigureAwait(false);
}
continue;
}
current.Description = alarmDto.Description;
current.PriorityLevel = alarmDto.PriorityLevel;
current.TriggerType = alarmDto.TriggerType;
current.TriggerConfiguration = alarmDto.TriggerConfiguration;
current.IsLocked = alarmDto.IsLocked;
current.OnTriggerScriptId = null; // re-resolved post-flush.
await _templateRepo.UpdateTemplateAlarmAsync(current, ct).ConfigureAwait(false);
await _auditService.LogAsync(
user,
"TemplateAlarmUpdated",
"TemplateAlarm",
current.Id.ToString(),
$"{ex.Name}.{current.Name}",
new
{
TemplateName = ex.Name,
AlarmName = current.Name,
current.Description,
current.PriorityLevel,
current.TriggerType,
current.TriggerConfiguration,
current.IsLocked,
OnTriggerScriptName = alarmDto.OnTriggerScriptName,
},
ct).ConfigureAwait(false);
}
else
{
var newAlarm = new TemplateAlarm(alarmDto.Name)
{
Description = alarmDto.Description,
PriorityLevel = alarmDto.PriorityLevel,
TriggerType = alarmDto.TriggerType,
TriggerConfiguration = alarmDto.TriggerConfiguration,
IsLocked = alarmDto.IsLocked,
};
ex.Alarms.Add(newAlarm);
await _auditService.LogAsync(
user,
"TemplateAlarmAdded",
"TemplateAlarm",
"0",
$"{ex.Name}.{newAlarm.Name}",
new
{
TemplateName = ex.Name,
AlarmName = newAlarm.Name,
newAlarm.Description,
newAlarm.PriorityLevel,
newAlarm.TriggerType,
newAlarm.TriggerConfiguration,
newAlarm.IsLocked,
OnTriggerScriptName = alarmDto.OnTriggerScriptName,
},
ct).ConfigureAwait(false);
}
}
}
///
/// T-001 — Overwrite child sync (scripts). Mirrors
/// for the script collection.
/// Audit rows: TemplateScriptAdded / TemplateScriptUpdated /
/// TemplateScriptDeleted.
///
private async Task SyncTemplateScriptsAsync(
Template ex,
TemplateDto dto,
string user,
CancellationToken ct)
{
var existingByName = ex.Scripts.ToDictionary(s => s.Name, s => s, StringComparer.Ordinal);
var dtoByName = dto.Scripts.ToDictionary(s => s.Name, s => s, StringComparer.Ordinal);
foreach (var existing in existingByName.Values.ToList())
{
if (dtoByName.ContainsKey(existing.Name)) continue;
await _templateRepo.DeleteTemplateScriptAsync(existing.Id, ct).ConfigureAwait(false);
ex.Scripts.Remove(existing);
await _auditService.LogAsync(
user,
"TemplateScriptDeleted",
"TemplateScript",
existing.Id.ToString(),
$"{ex.Name}.{existing.Name}",
new { TemplateName = ex.Name, ScriptName = existing.Name },
ct).ConfigureAwait(false);
}
foreach (var scriptDto in dto.Scripts)
{
if (existingByName.TryGetValue(scriptDto.Name, out var current))
{
bool changed =
!string.Equals(current.Code, scriptDto.Code, StringComparison.Ordinal) ||
!string.Equals(current.TriggerType, scriptDto.TriggerType, StringComparison.Ordinal) ||
!string.Equals(current.TriggerConfiguration, scriptDto.TriggerConfiguration, StringComparison.Ordinal) ||
!string.Equals(current.ParameterDefinitions, scriptDto.ParameterDefinitions, StringComparison.Ordinal) ||
!string.Equals(current.ReturnDefinition, scriptDto.ReturnDefinition, StringComparison.Ordinal) ||
current.IsLocked != scriptDto.IsLocked;
if (!changed) continue;
current.Code = scriptDto.Code;
current.TriggerType = scriptDto.TriggerType;
current.TriggerConfiguration = scriptDto.TriggerConfiguration;
current.ParameterDefinitions = scriptDto.ParameterDefinitions;
current.ReturnDefinition = scriptDto.ReturnDefinition;
current.IsLocked = scriptDto.IsLocked;
await _templateRepo.UpdateTemplateScriptAsync(current, ct).ConfigureAwait(false);
await _auditService.LogAsync(
user,
"TemplateScriptUpdated",
"TemplateScript",
current.Id.ToString(),
$"{ex.Name}.{current.Name}",
new
{
TemplateName = ex.Name,
ScriptName = current.Name,
current.TriggerType,
current.TriggerConfiguration,
current.IsLocked,
},
ct).ConfigureAwait(false);
}
else
{
var newScript = new TemplateScript(scriptDto.Name, scriptDto.Code)
{
TriggerType = scriptDto.TriggerType,
TriggerConfiguration = scriptDto.TriggerConfiguration,
ParameterDefinitions = scriptDto.ParameterDefinitions,
ReturnDefinition = scriptDto.ReturnDefinition,
IsLocked = scriptDto.IsLocked,
};
ex.Scripts.Add(newScript);
await _auditService.LogAsync(
user,
"TemplateScriptAdded",
"TemplateScript",
"0",
$"{ex.Name}.{newScript.Name}",
new
{
TemplateName = ex.Name,
ScriptName = newScript.Name,
newScript.TriggerType,
newScript.TriggerConfiguration,
newScript.IsLocked,
},
ct).ConfigureAwait(false);
}
}
}
///
/// FU-B / remainder of #37 — Pass A of the post-template-flush rewire.
/// For every imported template (Add / Overwrite / Rename) whose bundle DTO
/// carries any alarm with OnTriggerScriptName, look up the
/// persisted alarm + script by name on the same template and set the FK.
/// If the named script is missing from the imported template, leave the
/// FK null and emit a BundleImportAlarmScriptUnresolved audit row
/// (correlation context still carries BundleImportId).
///
private async Task ResolveAlarmScriptLinksAsync(
IReadOnlyList dtos,
Dictionary<(string, string), ImportResolution> resolutionMap,
string user,
CancellationToken ct)
{
if (dtos.Count == 0) return;
foreach (var dto in dtos)
{
var resolution = ResolveOrDefault(resolutionMap, "Template", dto.Name);
if (resolution.Action == ResolutionAction.Skip) continue;
// Resolve the imported template by its post-rename name. Skip-
// resolved DTOs were already filtered out above; everything else
// landed under either the DTO name or RenameTo.
var importedName = resolution.Action == ResolutionAction.Rename
? (resolution.RenameTo ?? dto.Name)
: dto.Name;
// Use the children-loaded variant so the alarm + script collections
// are populated — the unfiltered GetAllTemplatesAsync returns the
// tracked entities EF assigned ids to during the flush, but the
// navigation collections aren't necessarily included.
var stub = _dbContext.Templates.Local.FirstOrDefault(t =>
string.Equals(t.Name, importedName, StringComparison.Ordinal));
if (stub is null) continue;
var template = await _templateRepo.GetTemplateWithChildrenAsync(stub.Id, ct).ConfigureAwait(false);
if (template is null) continue;
foreach (var alarmDto in dto.Alarms)
{
if (string.IsNullOrEmpty(alarmDto.OnTriggerScriptName)) continue;
var alarm = template.Alarms.FirstOrDefault(a =>
string.Equals(a.Name, alarmDto.Name, StringComparison.Ordinal));
if (alarm is null) continue;
var script = template.Scripts.FirstOrDefault(s =>
string.Equals(s.Name, alarmDto.OnTriggerScriptName, StringComparison.Ordinal));
if (script is null)
{
// Unresolved — emit warning audit row, leave FK null.
await _auditService.LogAsync(
user,
"BundleImportAlarmScriptUnresolved",
"TemplateAlarm",
alarm.Id.ToString(),
$"{template.Name}.{alarm.Name}",
new
{
TemplateName = template.Name,
AlarmName = alarm.Name,
UnresolvedScriptName = alarmDto.OnTriggerScriptName,
},
ct).ConfigureAwait(false);
continue;
}
alarm.OnTriggerScriptId = script.Id;
await _templateRepo.UpdateTemplateAlarmAsync(alarm, ct).ConfigureAwait(false);
}
}
}
///
/// FU-B / #39 — Pass B of the post-template-flush rewire. For every
/// imported template (Add / Overwrite / Rename) whose bundle DTO carries
/// any Compositions, replace the persisted template's existing
/// composition rows with new ones whose ComposedTemplateId is
/// resolved from ComposedTemplateName by looking up the now-
/// persisted template (just-imported set first, then pre-existing target).
///
/// Overwrite semantics: the persisted template's existing composition
/// rows are CLEARED before re-adding from the bundle so an Overwrite
/// truly overwrites the composition graph and doesn't silently retain
/// stale edges that aren't in the bundle anymore.
///
///
/// When ComposedTemplateName cannot be resolved — most commonly
/// because the user chose Skip on the referenced template — we emit a
/// BundleImportCompositionUnresolved audit row and skip the edge.
/// We deliberately do NOT throw: a Skip-resolved dependency is a
/// legitimate operator choice, not an import-killing error.
///
///
private async Task ResolveCompositionEdgesAsync(
IReadOnlyList dtos,
Dictionary<(string, string), ImportResolution> resolutionMap,
string user,
CancellationToken ct)
{
if (dtos.Count == 0) return;
foreach (var dto in dtos)
{
var resolution = ResolveOrDefault(resolutionMap, "Template", dto.Name);
if (resolution.Action == ResolutionAction.Skip) continue;
if (dto.Compositions.Count == 0) continue;
var importedName = resolution.Action == ResolutionAction.Rename
? (resolution.RenameTo ?? dto.Name)
: dto.Name;
var parentStub = _dbContext.Templates.Local.FirstOrDefault(t =>
string.Equals(t.Name, importedName, StringComparison.Ordinal));
if (parentStub is null) continue;
var parent = await _templateRepo.GetTemplateWithChildrenAsync(parentStub.Id, ct).ConfigureAwait(false);
if (parent is null) continue;
// Clear existing rows (Overwrite-truly-overwrites). EF Core tracks
// the deletes via the navigation; explicit DeleteAsync ensures the
// change is staged even when the relational provider doesn't
// detect orphans automatically.
if (parent.Compositions.Count > 0)
{
var existingComps = parent.Compositions.ToList();
foreach (var existing in existingComps)
{
await _templateRepo.DeleteTemplateCompositionAsync(existing.Id, ct).ConfigureAwait(false);
}
parent.Compositions.Clear();
}
foreach (var compDto in dto.Compositions)
{
if (string.IsNullOrEmpty(compDto.ComposedTemplateName))
{
await _auditService.LogAsync(
user,
"BundleImportCompositionUnresolved",
"TemplateComposition",
"0",
$"{parent.Name}.{compDto.InstanceName}",
new
{
OwnerTemplateName = parent.Name,
compDto.InstanceName,
UnresolvedComposedTemplateName = (string?)null,
Reason = "Composition DTO carried empty ComposedTemplateName.",
},
ct).ConfigureAwait(false);
continue;
}
// Resolve the composed template by name — first against the
// tracked Local set (which includes anything just imported,
// pre-flush or post-flush), then against the wider target DB
// (pre-existing rows not staged in this transaction).
var composedStub = _dbContext.Templates.Local.FirstOrDefault(t =>
string.Equals(t.Name, compDto.ComposedTemplateName, StringComparison.Ordinal));
int? composedId = composedStub?.Id;
if (composedId is null)
{
var allTargets = await _templateRepo.GetAllTemplatesAsync(ct).ConfigureAwait(false);
composedId = allTargets
.FirstOrDefault(t => string.Equals(t.Name, compDto.ComposedTemplateName, StringComparison.Ordinal))?.Id;
}
if (composedId is null)
{
await _auditService.LogAsync(
user,
"BundleImportCompositionUnresolved",
"TemplateComposition",
"0",
$"{parent.Name}.{compDto.InstanceName}",
new
{
OwnerTemplateName = parent.Name,
compDto.InstanceName,
UnresolvedComposedTemplateName = compDto.ComposedTemplateName,
Reason = "Composed template name not present in bundle or target.",
},
ct).ConfigureAwait(false);
continue;
}
var comp = new TemplateComposition(compDto.InstanceName)
{
TemplateId = parent.Id,
ComposedTemplateId = composedId.Value,
};
await _templateRepo.AddTemplateCompositionAsync(comp, ct).ConfigureAwait(false);
}
}
}
private async Task ApplySharedScriptsAsync(
IReadOnlyList dtos,
Dictionary<(string, string), ImportResolution> map,
string user,
ImportSummary summary,
CancellationToken ct)
{
if (dtos.Count == 0) return;
foreach (var dto in dtos)
{
var resolution = ResolveOrDefault(map, "SharedScript", dto.Name);
var existing = await _templateRepo.GetSharedScriptByNameAsync(dto.Name, ct).ConfigureAwait(false);
switch (resolution.Action)
{
case ResolutionAction.Skip:
summary.Skipped++;
break;
case ResolutionAction.Rename:
{
var name = resolution.RenameTo ?? dto.Name;
var s = new SharedScript(name, dto.Code)
{
ParameterDefinitions = dto.ParameterDefinitions,
ReturnDefinition = dto.ReturnDefinition,
};
await _templateRepo.AddSharedScriptAsync(s, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Create", "SharedScript", "0", name,
new { s.Name, RenamedFrom = dto.Name }, ct).ConfigureAwait(false);
summary.Renamed++;
break;
}
case ResolutionAction.Overwrite when existing is not null:
existing.Code = dto.Code;
existing.ParameterDefinitions = dto.ParameterDefinitions;
existing.ReturnDefinition = dto.ReturnDefinition;
await _templateRepo.UpdateSharedScriptAsync(existing, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Update", "SharedScript", existing.Id.ToString(), existing.Name,
new { existing.Name }, ct).ConfigureAwait(false);
summary.Overwritten++;
break;
case ResolutionAction.Add:
case ResolutionAction.Overwrite:
default:
{
var s = new SharedScript(dto.Name, dto.Code)
{
ParameterDefinitions = dto.ParameterDefinitions,
ReturnDefinition = dto.ReturnDefinition,
};
await _templateRepo.AddSharedScriptAsync(s, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Create", "SharedScript", "0", s.Name,
new { s.Name }, ct).ConfigureAwait(false);
summary.Added++;
break;
}
}
}
}
private async Task ApplyExternalSystemsAsync(
IReadOnlyList dtos,
Dictionary<(string, string), ImportResolution> map,
string user,
ImportSummary summary,
CancellationToken ct)
{
if (dtos.Count == 0) return;
foreach (var dto in dtos)
{
var resolution = ResolveOrDefault(map, "ExternalSystem", dto.Name);
var existing = await _externalRepo.GetExternalSystemByNameAsync(dto.Name, ct).ConfigureAwait(false);
switch (resolution.Action)
{
case ResolutionAction.Skip:
summary.Skipped++;
break;
case ResolutionAction.Rename:
{
var name = resolution.RenameTo ?? dto.Name;
var sys = BuildExternalSystem(dto, overrideName: name);
await _externalRepo.AddExternalSystemAsync(sys, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Create", "ExternalSystem", "0", name,
new { sys.Name, sys.EndpointUrl, RenamedFrom = dto.Name }, ct).ConfigureAwait(false);
summary.Renamed++;
break;
}
case ResolutionAction.Overwrite when existing is not null:
existing.EndpointUrl = dto.BaseUrl;
existing.AuthType = dto.AuthType;
existing.AuthConfiguration = dto.Secrets?.Values.TryGetValue("AuthConfiguration", out var auth) == true ? auth : null;
await _externalRepo.UpdateExternalSystemAsync(existing, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Update", "ExternalSystem", existing.Id.ToString(), existing.Name,
new { existing.Name, existing.EndpointUrl }, ct).ConfigureAwait(false);
// T-002: Overwrite must also synchronise the Methods child
// collection — added / removed / modified methods on the
// bundle DTO must round-trip. Mirrors the T-001 template
// child-sync helpers (attributes / alarms / scripts): each
// helper emits one audit row per detected change.
await SyncExternalSystemMethodsAsync(existing, dto, user, ct).ConfigureAwait(false);
summary.Overwritten++;
break;
case ResolutionAction.Add:
case ResolutionAction.Overwrite:
default:
{
var sys = BuildExternalSystem(dto, overrideName: null);
await _externalRepo.AddExternalSystemAsync(sys, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Create", "ExternalSystem", "0", sys.Name,
new { sys.Name, sys.EndpointUrl }, ct).ConfigureAwait(false);
summary.Added++;
break;
}
}
}
}
private static ExternalSystemDefinition BuildExternalSystem(ExternalSystemDto dto, string? overrideName)
{
var sys = new ExternalSystemDefinition(overrideName ?? dto.Name, dto.BaseUrl, dto.AuthType)
{
AuthConfiguration = dto.Secrets?.Values.TryGetValue("AuthConfiguration", out var auth) == true ? auth : null,
};
return sys;
}
///
/// T-002 — Overwrite child sync (ExternalSystem methods). Mirrors the
/// T-001 SyncTemplate*Async helpers: name-keyed diff between the
/// bundle DTO and the persisted children, then add / update / delete via
/// the repository with one audit row per detected change. Methods are
/// NOT a navigation on (the FK
/// runs from
/// to the parent) so the helper drives the repo directly rather than
/// mutating a tracked collection like the template helpers do.
///
/// Audit rows: ExternalSystemMethodAdded /
/// ExternalSystemMethodUpdated / ExternalSystemMethodDeleted.
/// Idempotent: scalar-field comparison gates the Update audit row, so an
/// Overwrite against an already-matching method produces no noise.
///
///
private async Task SyncExternalSystemMethodsAsync(
ExternalSystemDefinition ex,
ExternalSystemDto dto,
string user,
CancellationToken ct)
{
var existingMethods = await _externalRepo
.GetMethodsByExternalSystemIdAsync(ex.Id, ct)
.ConfigureAwait(false);
var existingByName = existingMethods.ToDictionary(m => m.Name, m => m, StringComparer.Ordinal);
var dtoByName = dto.Methods.ToDictionary(m => m.Name, m => m, StringComparer.Ordinal);
// Deletes — methods present on the target but not in the bundle.
foreach (var existing in existingByName.Values.ToList())
{
if (dtoByName.ContainsKey(existing.Name)) continue;
await _externalRepo.DeleteExternalSystemMethodAsync(existing.Id, ct).ConfigureAwait(false);
await _auditService.LogAsync(
user,
"ExternalSystemMethodDeleted",
"ExternalSystemMethod",
existing.Id.ToString(),
$"{ex.Name}.{existing.Name}",
new { ExternalSystemName = ex.Name, MethodName = existing.Name },
ct).ConfigureAwait(false);
}
// Adds + Updates.
foreach (var methodDto in dto.Methods)
{
if (existingByName.TryGetValue(methodDto.Name, out var current))
{
// Update only if any field actually changed — mirrors the
// ArtifactDiff.ExternalSystemMethodsEqual comparator.
bool changed =
!string.Equals(current.HttpMethod, methodDto.HttpMethod, StringComparison.Ordinal) ||
!string.Equals(current.Path, methodDto.Path, StringComparison.Ordinal) ||
!string.Equals(current.ParameterDefinitions, methodDto.ParameterDefinitions, StringComparison.Ordinal) ||
!string.Equals(current.ReturnDefinition, methodDto.ReturnDefinition, StringComparison.Ordinal);
if (!changed) continue;
current.HttpMethod = methodDto.HttpMethod;
current.Path = methodDto.Path;
current.ParameterDefinitions = methodDto.ParameterDefinitions;
current.ReturnDefinition = methodDto.ReturnDefinition;
await _externalRepo.UpdateExternalSystemMethodAsync(current, ct).ConfigureAwait(false);
await _auditService.LogAsync(
user,
"ExternalSystemMethodUpdated",
"ExternalSystemMethod",
current.Id.ToString(),
$"{ex.Name}.{current.Name}",
new
{
ExternalSystemName = ex.Name,
MethodName = current.Name,
current.HttpMethod,
current.Path,
current.ParameterDefinitions,
current.ReturnDefinition,
},
ct).ConfigureAwait(false);
}
else
{
var newMethod = new ExternalSystemMethod(methodDto.Name, methodDto.HttpMethod, methodDto.Path)
{
ExternalSystemDefinitionId = ex.Id,
ParameterDefinitions = methodDto.ParameterDefinitions,
ReturnDefinition = methodDto.ReturnDefinition,
};
await _externalRepo.AddExternalSystemMethodAsync(newMethod, ct).ConfigureAwait(false);
await _auditService.LogAsync(
user,
"ExternalSystemMethodAdded",
"ExternalSystemMethod",
"0",
$"{ex.Name}.{newMethod.Name}",
new
{
ExternalSystemName = ex.Name,
MethodName = newMethod.Name,
newMethod.HttpMethod,
newMethod.Path,
newMethod.ParameterDefinitions,
newMethod.ReturnDefinition,
},
ct).ConfigureAwait(false);
}
}
}
private async Task ApplyDatabaseConnectionsAsync(
IReadOnlyList dtos,
Dictionary<(string, string), ImportResolution> map,
string user,
ImportSummary summary,
CancellationToken ct)
{
if (dtos.Count == 0) return;
foreach (var dto in dtos)
{
var resolution = ResolveOrDefault(map, "DatabaseConnection", dto.Name);
var existing = await _externalRepo.GetDatabaseConnectionByNameAsync(dto.Name, ct).ConfigureAwait(false);
var connStr = dto.Secrets?.Values.TryGetValue("ConnectionString", out var cs) == true ? cs : string.Empty;
switch (resolution.Action)
{
case ResolutionAction.Skip:
summary.Skipped++;
break;
case ResolutionAction.Rename:
{
var name = resolution.RenameTo ?? dto.Name;
var c = new DatabaseConnectionDefinition(name, connStr)
{
MaxRetries = dto.MaxRetries,
RetryDelay = dto.RetryDelay,
};
await _externalRepo.AddDatabaseConnectionAsync(c, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Create", "DatabaseConnection", "0", name,
new { c.Name, RenamedFrom = dto.Name }, ct).ConfigureAwait(false);
summary.Renamed++;
break;
}
case ResolutionAction.Overwrite when existing is not null:
existing.ConnectionString = connStr;
existing.MaxRetries = dto.MaxRetries;
existing.RetryDelay = dto.RetryDelay;
await _externalRepo.UpdateDatabaseConnectionAsync(existing, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Update", "DatabaseConnection", existing.Id.ToString(), existing.Name,
new { existing.Name }, ct).ConfigureAwait(false);
summary.Overwritten++;
break;
case ResolutionAction.Add:
case ResolutionAction.Overwrite:
default:
{
var c = new DatabaseConnectionDefinition(dto.Name, connStr)
{
MaxRetries = dto.MaxRetries,
RetryDelay = dto.RetryDelay,
};
await _externalRepo.AddDatabaseConnectionAsync(c, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Create", "DatabaseConnection", "0", c.Name,
new { c.Name }, ct).ConfigureAwait(false);
summary.Added++;
break;
}
}
}
}
private async Task ApplyNotificationListsAsync(
IReadOnlyList dtos,
Dictionary<(string, string), ImportResolution> map,
string user,
ImportSummary summary,
CancellationToken ct)
{
if (dtos.Count == 0) return;
foreach (var dto in dtos)
{
var resolution = ResolveOrDefault(map, "NotificationList", dto.Name);
var existing = await _notificationRepo.GetListByNameAsync(dto.Name, ct).ConfigureAwait(false);
switch (resolution.Action)
{
case ResolutionAction.Skip:
summary.Skipped++;
break;
case ResolutionAction.Rename:
{
var name = resolution.RenameTo ?? dto.Name;
var list = BuildNotificationList(dto, overrideName: name);
await _notificationRepo.AddNotificationListAsync(list, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Create", "NotificationList", "0", name,
new { list.Name, RenamedFrom = dto.Name }, ct).ConfigureAwait(false);
summary.Renamed++;
break;
}
case ResolutionAction.Overwrite when existing is not null:
existing.Type = dto.Type;
// Recipient sync is structural — clear + re-add. The repo
// mutates the navigation collection, EF tracks the delete.
existing.Recipients.Clear();
foreach (var r in dto.Recipients)
{
existing.Recipients.Add(new NotificationRecipient(r.Name, r.EmailAddress));
}
await _notificationRepo.UpdateNotificationListAsync(existing, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Update", "NotificationList", existing.Id.ToString(), existing.Name,
new { existing.Name, RecipientCount = existing.Recipients.Count }, ct).ConfigureAwait(false);
summary.Overwritten++;
break;
case ResolutionAction.Add:
case ResolutionAction.Overwrite:
default:
{
var list = BuildNotificationList(dto, overrideName: null);
await _notificationRepo.AddNotificationListAsync(list, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Create", "NotificationList", "0", list.Name,
new { list.Name, RecipientCount = list.Recipients.Count }, ct).ConfigureAwait(false);
summary.Added++;
break;
}
}
}
}
private static NotificationList BuildNotificationList(NotificationListDto dto, string? overrideName)
{
var list = new NotificationList(overrideName ?? dto.Name) { Type = dto.Type };
foreach (var r in dto.Recipients)
{
list.Recipients.Add(new NotificationRecipient(r.Name, r.EmailAddress));
}
return list;
}
private async Task ApplySmtpConfigsAsync(
IReadOnlyList dtos,
Dictionary<(string, string), ImportResolution> map,
string user,
ImportSummary summary,
CancellationToken ct)
{
if (dtos.Count == 0) return;
var all = await _notificationRepo.GetAllSmtpConfigurationsAsync(ct).ConfigureAwait(false);
var byHost = all.ToDictionary(s => s.Host, s => s, StringComparer.Ordinal);
foreach (var dto in dtos)
{
// SmtpConfiguration is keyed by Host in the diff engine — mirror
// that here so a Rename targets Host, not an arbitrary "name".
var resolution = ResolveOrDefault(map, "SmtpConfiguration", dto.Host);
switch (resolution.Action)
{
case ResolutionAction.Skip:
summary.Skipped++;
break;
case ResolutionAction.Rename:
{
var host = resolution.RenameTo ?? dto.Host;
var smtp = BuildSmtp(dto, overrideHost: host);
await _notificationRepo.AddSmtpConfigurationAsync(smtp, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Create", "SmtpConfiguration", "0", host,
new { smtp.Host, RenamedFrom = dto.Host }, ct).ConfigureAwait(false);
summary.Renamed++;
break;
}
case ResolutionAction.Overwrite when byHost.TryGetValue(dto.Host, out var ex):
ApplySmtpFields(ex, dto);
await _notificationRepo.UpdateSmtpConfigurationAsync(ex, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Update", "SmtpConfiguration", ex.Id.ToString(), ex.Host,
new { ex.Host }, ct).ConfigureAwait(false);
summary.Overwritten++;
break;
case ResolutionAction.Add:
case ResolutionAction.Overwrite:
default:
{
var smtp = BuildSmtp(dto, overrideHost: null);
await _notificationRepo.AddSmtpConfigurationAsync(smtp, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Create", "SmtpConfiguration", "0", smtp.Host,
new { smtp.Host }, ct).ConfigureAwait(false);
summary.Added++;
break;
}
}
}
}
private static SmtpConfiguration BuildSmtp(SmtpConfigDto dto, string? overrideHost)
{
var smtp = new SmtpConfiguration(overrideHost ?? dto.Host, dto.AuthType, dto.FromAddress);
ApplySmtpFields(smtp, dto);
return smtp;
}
private static void ApplySmtpFields(SmtpConfiguration target, SmtpConfigDto dto)
{
target.Port = dto.Port;
target.AuthType = dto.AuthType;
target.FromAddress = dto.FromAddress;
target.TlsMode = dto.TlsMode;
target.ConnectionTimeoutSeconds = dto.ConnectionTimeoutSeconds;
target.MaxConcurrentConnections = dto.MaxConcurrentConnections;
target.MaxRetries = dto.MaxRetries;
target.RetryDelay = dto.RetryDelay;
target.Credentials = dto.Secrets?.Values.TryGetValue("Credentials", out var cred) == true ? cred : null;
}
private async Task ApplyApiKeysAsync(
IReadOnlyList dtos,
Dictionary<(string, string), ImportResolution> map,
string user,
ImportSummary summary,
CancellationToken ct)
{
if (dtos.Count == 0) return;
var all = await _inboundApiRepo.GetAllApiKeysAsync(ct).ConfigureAwait(false);
var byName = all.ToDictionary(k => k.Name, k => k, StringComparer.Ordinal);
foreach (var dto in dtos)
{
var resolution = ResolveOrDefault(map, "ApiKey", dto.Name);
switch (resolution.Action)
{
case ResolutionAction.Skip:
summary.Skipped++;
break;
case ResolutionAction.Rename:
{
var name = resolution.RenameTo ?? dto.Name;
var key = ApiKey.FromHash(name, dto.KeyHash);
key.IsEnabled = dto.IsEnabled;
await _inboundApiRepo.AddApiKeyAsync(key, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Create", "ApiKey", "0", name,
new { key.Name, RenamedFrom = dto.Name }, ct).ConfigureAwait(false);
summary.Renamed++;
break;
}
case ResolutionAction.Overwrite when byName.TryGetValue(dto.Name, out var ex):
ex.KeyHash = dto.KeyHash;
ex.IsEnabled = dto.IsEnabled;
await _inboundApiRepo.UpdateApiKeyAsync(ex, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Update", "ApiKey", ex.Id.ToString(), ex.Name,
new { ex.Name, ex.IsEnabled }, ct).ConfigureAwait(false);
summary.Overwritten++;
break;
case ResolutionAction.Add:
case ResolutionAction.Overwrite:
default:
{
var key = ApiKey.FromHash(dto.Name, dto.KeyHash);
key.IsEnabled = dto.IsEnabled;
await _inboundApiRepo.AddApiKeyAsync(key, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Create", "ApiKey", "0", key.Name,
new { key.Name, key.IsEnabled }, ct).ConfigureAwait(false);
summary.Added++;
break;
}
}
}
}
private async Task ApplyApiMethodsAsync(
IReadOnlyList dtos,
Dictionary<(string, string), ImportResolution> map,
string user,
ImportSummary summary,
CancellationToken ct)
{
if (dtos.Count == 0) return;
foreach (var dto in dtos)
{
var resolution = ResolveOrDefault(map, "ApiMethod", dto.Name);
var existing = await _inboundApiRepo.GetMethodByNameAsync(dto.Name, ct).ConfigureAwait(false);
switch (resolution.Action)
{
case ResolutionAction.Skip:
summary.Skipped++;
break;
case ResolutionAction.Rename:
{
var name = resolution.RenameTo ?? dto.Name;
var m = BuildApiMethod(dto, overrideName: name);
await _inboundApiRepo.AddApiMethodAsync(m, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Create", "ApiMethod", "0", name,
new { m.Name, RenamedFrom = dto.Name }, ct).ConfigureAwait(false);
summary.Renamed++;
break;
}
case ResolutionAction.Overwrite when existing is not null:
existing.Script = dto.Script;
existing.ApprovedApiKeyIds = dto.ApprovedApiKeyIds;
existing.ParameterDefinitions = dto.ParameterDefinitions;
existing.ReturnDefinition = dto.ReturnDefinition;
existing.TimeoutSeconds = dto.TimeoutSeconds;
await _inboundApiRepo.UpdateApiMethodAsync(existing, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Update", "ApiMethod", existing.Id.ToString(), existing.Name,
new { existing.Name }, ct).ConfigureAwait(false);
summary.Overwritten++;
break;
case ResolutionAction.Add:
case ResolutionAction.Overwrite:
default:
{
var m = BuildApiMethod(dto, overrideName: null);
await _inboundApiRepo.AddApiMethodAsync(m, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Create", "ApiMethod", "0", m.Name,
new { m.Name }, ct).ConfigureAwait(false);
summary.Added++;
break;
}
}
}
}
private static ApiMethod BuildApiMethod(ApiMethodDto dto, string? overrideName)
{
return new ApiMethod(overrideName ?? dto.Name, dto.Script)
{
ApprovedApiKeyIds = dto.ApprovedApiKeyIds,
ParameterDefinitions = dto.ParameterDefinitions,
ReturnDefinition = dto.ReturnDefinition,
TimeoutSeconds = dto.TimeoutSeconds,
};
}
///
/// Two-tier semantic validation run before any rows are flushed:
///
/// - Pass 1 — minimal name-resolution scan. Catches the
/// import-specific crash surface that the full SemanticValidator
/// can't see: identifier-shaped call targets in
/// TemplateScript / ApiMethod bodies that resolve to neither
/// an in-bundle nor a pre-existing target SharedScript /
/// ExternalSystem. Skip-resolved DTOs are excluded from the
/// in-bundle name set so a Skip that would have provided a dependency
/// surfaces here. Fails fast: if Pass 1 finds errors, Pass 2 is not run.
/// - Pass 2 — full . For each
/// template being imported (Add / Overwrite / Rename — not Skip), build a
/// per-template directly from the DTO
/// (single-template scope — no inheritance / composition resolution, since
/// the inheritance chain is reconstructed only at deploy time) and invoke
/// the same validator the deployment pipeline uses. Errors from every
/// template are aggregated into one list so the operator sees the full
/// surface at once. SharedScripts are passed as
/// values combining bundle + target so call-target checks resolve in either
/// direction.
///
///
/// Per-template scoping is intentional: pre-existing target templates that
/// haven't been touched by this bundle aren't run through the validator —
/// otherwise a latent validation issue on an unrelated template (one the
/// operator isn't trying to import) would block the import.
///
///
private async Task> RunSemanticValidationAsync(
BundleContentDto content,
Dictionary<(string, string), ImportResolution> resolutionMap,
CancellationToken ct)
{
var errors = new List();
// ---- Pass 1: minimal name-resolution scan ----
// Build the known-resolvable set. For in-bundle entries, EXCLUDE the
// Skip-resolved names — those aren't being written, so they can't
// satisfy a downstream reference. Renamed entries register under both
// their original DTO name (so the script body in the bundle still
// resolves) AND the new name; the v1 import doesn't rewrite call sites.
var sharedScriptNames = new HashSet(StringComparer.Ordinal);
foreach (var s in content.SharedScripts)
{
var resolution = ResolveOrDefault(resolutionMap, "SharedScript", s.Name);
if (resolution.Action == ResolutionAction.Skip) continue;
sharedScriptNames.Add(s.Name);
if (resolution.Action == ResolutionAction.Rename && !string.IsNullOrEmpty(resolution.RenameTo))
{
sharedScriptNames.Add(resolution.RenameTo);
}
}
var externalSystemNames = new HashSet(StringComparer.Ordinal);
foreach (var e in content.ExternalSystems)
{
var resolution = ResolveOrDefault(resolutionMap, "ExternalSystem", e.Name);
if (resolution.Action == ResolutionAction.Skip) continue;
externalSystemNames.Add(e.Name);
if (resolution.Action == ResolutionAction.Rename && !string.IsNullOrEmpty(resolution.RenameTo))
{
externalSystemNames.Add(resolution.RenameTo);
}
}
// Pre-existing target entries always count as resolvable.
var preExistingSharedScripts = await _templateRepo.GetAllSharedScriptsAsync(ct).ConfigureAwait(false);
foreach (var s in preExistingSharedScripts)
{
sharedScriptNames.Add(s.Name);
}
foreach (var e in await _externalRepo.GetAllExternalSystemsAsync(ct).ConfigureAwait(false))
{
externalSystemNames.Add(e.Name);
}
// Collect every identifier-shaped call target from the bundle's
// templates + api methods. We only check the bundle's bodies here
// (matching PreviewAsync's blocker scan); pre-existing target rows are
// assumed already validated when they were originally written.
var referenced = new HashSet(StringComparer.Ordinal);
foreach (var t in content.Templates)
{
// Skip-resolved templates aren't being written, so their script
// references don't need to resolve.
var resolution = ResolveOrDefault(resolutionMap, "Template", t.Name);
if (resolution.Action == ResolutionAction.Skip) continue;
foreach (var s in t.Scripts) CollectCallIdentifiers(s.Code, referenced);
foreach (var a in t.Attributes)
{
// Value can hold script-callable design-time expressions;
// DataSourceReference is an OPC UA address-space path (e.g.
// "ns=3;s=Tank.Level") and must NOT be scanned, or the dot
// delimiter will flag tag-path segments as missing references.
// Symmetric with DetectBlockersAsync.
CollectCallIdentifiers(a.Value, referenced);
}
}
foreach (var m in content.ApiMethods)
{
var resolution = ResolveOrDefault(resolutionMap, "ApiMethod", m.Name);
if (resolution.Action == ResolutionAction.Skip) continue;
CollectCallIdentifiers(m.Script, referenced);
}
foreach (var candidate in referenced.OrderBy(n => n, StringComparer.Ordinal))
{
if (!LooksLikeResourceName(candidate)) continue;
if (KnownNonReferenceNames.Contains(candidate)) continue;
if (sharedScriptNames.Contains(candidate) || externalSystemNames.Contains(candidate)) continue;
errors.Add(
$"Script references SharedScript or ExternalSystem '{candidate}' not present in bundle or target.");
}
// Fail fast — running the full validator over templates that already
// failed name resolution would produce duplicate / lower-quality errors
// (the missing identifier shows up there as "callee not found" too).
if (errors.Count > 0) return errors;
// ---- Pass 2: full SemanticValidator over imported templates ----
// Build the shared-script catalog the validator uses to resolve
// CallShared targets. Combine in-bundle (non-Skip) + pre-existing
// target — same resolution model as Pass 1's name set.
var sharedScripts = new List();
foreach (var s in content.SharedScripts)
{
var resolution = ResolveOrDefault(resolutionMap, "SharedScript", s.Name);
if (resolution.Action == ResolutionAction.Skip) continue;
var name = resolution.Action == ResolutionAction.Rename && !string.IsNullOrEmpty(resolution.RenameTo)
? resolution.RenameTo
: s.Name;
sharedScripts.Add(new ResolvedScript
{
CanonicalName = name,
Code = s.Code,
ParameterDefinitions = s.ParameterDefinitions,
ReturnDefinition = s.ReturnDefinition,
});
}
foreach (var s in preExistingSharedScripts)
{
// Pre-existing target wins on duplicate name only when the bundle
// didn't supply it; otherwise the bundle's version (the one about
// to be written) is the right signature surface to validate against.
if (sharedScripts.Any(rs => string.Equals(rs.CanonicalName, s.Name, StringComparison.Ordinal))) continue;
sharedScripts.Add(new ResolvedScript
{
CanonicalName = s.Name,
Code = s.Code,
ParameterDefinitions = s.ParameterDefinitions,
ReturnDefinition = s.ReturnDefinition,
});
}
foreach (var dto in content.Templates)
{
var resolution = ResolveOrDefault(resolutionMap, "Template", dto.Name);
if (resolution.Action == ResolutionAction.Skip) continue;
var importedName = resolution.Action == ResolutionAction.Rename && !string.IsNullOrEmpty(resolution.RenameTo)
? resolution.RenameTo
: dto.Name;
var config = BuildFlattenedConfigForValidation(dto, importedName);
var result = _semanticValidator.Validate(config, sharedScripts);
foreach (var entry in result.Errors)
{
errors.Add($"Template '{importedName}': {entry.Message}");
}
}
return errors;
}
///
/// Builds a for a single template DTO
/// — the validator's input contract. The bundle DTO carries only the
/// template's own attributes / alarms / scripts (no inheritance / no
/// composition resolution at design time), so the flattening here is a
/// straight 1:1 copy with the alarm on-trigger-script name carried through
/// as the canonical name (the same script's bare name, since composed
/// modules aren't expanded at import time). This is intentionally narrower
/// than the production FlatteningService pipeline, which needs a
/// concrete Instance plus site / connection context that doesn't
/// exist yet at design time. The deployment-time flatten will revalidate
/// against the full graph; this pass catches the same-template-scope
/// errors that operators would otherwise only hit at deploy time.
///
private static FlattenedConfiguration BuildFlattenedConfigForValidation(TemplateDto dto, string templateName)
{
var attributes = new List(dto.Attributes.Count);
foreach (var a in dto.Attributes)
{
attributes.Add(new ResolvedAttribute
{
CanonicalName = a.Name,
Value = a.Value,
DataType = a.DataType.ToString(),
IsLocked = a.IsLocked,
Description = a.Description,
DataSourceReference = a.DataSourceReference,
Source = "Template",
});
}
var alarms = new List(dto.Alarms.Count);
foreach (var al in dto.Alarms)
{
alarms.Add(new ResolvedAlarm
{
CanonicalName = al.Name,
Description = al.Description,
PriorityLevel = al.PriorityLevel,
IsLocked = al.IsLocked,
TriggerType = al.TriggerType.ToString(),
TriggerConfiguration = al.TriggerConfiguration,
// The bundle carries the on-trigger script by NAME (not id);
// at this single-template-scope validation step the bare name
// IS the canonical name, so just pass it through.
OnTriggerScriptCanonicalName = string.IsNullOrEmpty(al.OnTriggerScriptName) ? null : al.OnTriggerScriptName,
Source = "Template",
});
}
var scripts = new List(dto.Scripts.Count);
foreach (var s in dto.Scripts)
{
scripts.Add(new ResolvedScript
{
CanonicalName = s.Name,
Code = s.Code,
IsLocked = s.IsLocked,
TriggerType = s.TriggerType,
TriggerConfiguration = s.TriggerConfiguration,
ParameterDefinitions = s.ParameterDefinitions,
ReturnDefinition = s.ReturnDefinition,
MinTimeBetweenRuns = s.MinTimeBetweenRuns,
Source = "Template",
});
}
return new FlattenedConfiguration
{
InstanceUniqueName = templateName,
TemplateId = 0,
SiteId = 0,
AreaId = null,
Attributes = attributes,
Alarms = alarms,
Scripts = scripts,
Connections = null,
GeneratedAtUtc = DateTimeOffset.UtcNow,
};
}
}