using System.Security.Cryptography; using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.Options; using ScadaLink.Commons.Entities.ExternalSystems; using ScadaLink.Commons.Entities.InboundApi; using ScadaLink.Commons.Entities.Notifications; using ScadaLink.Commons.Entities.Scripts; using ScadaLink.Commons.Entities.Templates; using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.Commons.Interfaces.Services; using ScadaLink.Commons.Interfaces.Transport; using ScadaLink.Commons.Types.Transport; using ScadaLink.Commons.Types.Enums; using ScadaLink.Commons.Types.Flattening; using ScadaLink.ConfigurationDatabase; using ScadaLink.TemplateEngine.Validation; using ScadaLink.Transport.Encryption; using ScadaLink.Transport.Serialization; namespace ScadaLink.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 /// ScadaLink.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 ScadaLinkDbContext _dbContext; private readonly ITemplateEngineRepository _templateRepo; private readonly IExternalSystemRepository _externalRepo; private readonly INotificationRepository _notificationRepo; private readonly IInboundApiRepository _inboundApiRepo; private readonly IBundleSessionStore _sessionStore; private readonly IOptions _options; private readonly TimeProvider _timeProvider; private readonly SemanticValidator _semanticValidator; public BundleImporter( BundleSerializer bundleSerializer, ManifestValidator manifestValidator, BundleSecretEncryptor encryptor, EntitySerializer entitySerializer, IBundleSessionStore sessionStore, IOptions options, TimeProvider timeProvider, ITemplateEngineRepository templateRepo, IExternalSystemRepository externalRepo, INotificationRepository notificationRepo, IInboundApiRepository inboundApiRepo, IAuditService auditService, IAuditCorrelationContext correlationContext, ScadaLinkDbContext 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)); _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)); } /// /// Validates the bundle envelope and opens a session keyed by a fresh GUID. /// Wrong-passphrase failures surface as /// so the caller (UI / API endpoint) can increment the lockout counter on /// the returned session — this method does not mutate FailedUnlockAttempts /// itself because the session does not exist yet at the point of failure. /// 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."); } 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. AES-GCM tag // mismatch surfaces as a CryptographicException (or its // AuthenticationTagMismatchException subclass on .NET 10+) — bubble it // unchanged so the caller can detect wrong-passphrase via type check // and increment the lockout counter on the (about-to-be-rejected) // session reference. The session is not opened on the failure path. byte[] decryptedContent; if (manifest.Encryption is not null) { if (string.IsNullOrEmpty(passphrase)) { throw new ArgumentException( "Passphrase required for encrypted bundle.", nameof(passphrase)); } decryptedContent = _encryptor.Decrypt(contentBytes, manifest.Encryption, passphrase); } 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); } /// /// Diffs every artifact in the loaded bundle against the current target /// database. Lookups are name-keyed (the bundle is portable across /// environments so FK ids never line up). Emits /// items when a bundled template references a SharedScript or ExternalSystem /// that is in neither the bundle nor the target — that import would crash at /// runtime, so we surface it in the preview UI before Apply. /// 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 ---- // Repos only expose GetTemplateByIdAsync / GetAllTemplatesAsync — no // by-name lookup. Pull all once and index by name for the diff loop. var allTemplates = await _templateRepo.GetAllTemplatesAsync(ct).ConfigureAwait(false); var hydratedByName = new Dictionary(StringComparer.Ordinal); foreach (var stub in allTemplates) { // GetAllTemplatesAsync may not eager-load children — fetch the // children-loaded variant for any name that matches an incoming DTO // so the per-child diff loop sees the full collection. if (content.Templates.Any(t => string.Equals(t.Name, stub.Name, StringComparison.Ordinal))) { var hydrated = await _templateRepo.GetTemplateWithChildrenAsync(stub.Id, ct).ConfigureAwait(false); if (hydrated is not null) { hydratedByName[stub.Name] = hydrated; } } } 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) { CollectCallIdentifiers(a.Value, referencedFromBundle); CollectCallIdentifiers(a.DataSourceReference, 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) AND it's not present // anywhere we can satisfy it. We deliberately do not look at language // keywords or stdlib helpers — the test surface only ever uses // well-named identifiers. foreach (var candidate in referencedFromBundle.OrderBy(n => n, StringComparer.Ordinal)) { if (!LooksLikeResourceName(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. for (var i = 0; i < body.Length; i++) { if (!IsIdentifierStart(body[i])) continue; if (i > 0 && IsIdentifierChar(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]); } } } 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); /// /// Writes the bundle's artifacts to the central DB inside a single /// transaction, threading BundleImportId through every audit row via /// the scoped . /// /// Apply ordering — folders → templates → shared scripts → external systems /// → database connections → notification lists → SMTP configs → API keys → /// API methods — matches the dependency edges in the design doc so each /// later category can resolve name-keyed references to earlier ones. /// /// /// Semantic validation is two-tier: a minimal name-resolution scan first /// (every script-callable identifier referenced by the merged target must /// resolve to either a pre-existing or in-bundle SharedScript / /// ExternalSystem), then — on Pass 1 success — the full /// over each /// imported template scoped to its own single-template /// FlattenedConfiguration. The minimal pass is run AGAINST the /// merged target (incoming-bundle DTOs in memory plus the target DB read /// inside the transaction) so a Skip resolution can legitimately fail /// validation if it would have provided a missing dependency. The full /// pass scopes to imported templates only — pre-existing untouched /// templates aren't revalidated so a latent issue elsewhere in the /// catalog doesn't block this import. See /// for the per-pass contract. /// /// /// Audit-row contract: every per-entity write goes through /// with the correlation context set to /// 's import id, plus a summary /// BundleImported row inside the transaction. On failure the /// transaction rolls back and a single BundleImportFailed row is /// written OUTSIDE the rolled-back scope (correlation cleared first so the /// row doesn't carry a non-existent import id). /// /// 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); _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. } 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; } } /// Mutable per-apply counter struct, accumulated through every helper. private sealed class ImportSummary { public int Added { get; set; } public int Overwritten { get; set; } public int Skipped { get; set; } 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); foreach (var dto in dtos) { var resolution = ResolveOrDefault(map, "Template", dto.Name); switch (resolution.Action) { case ResolutionAction.Skip: summary.Skipped++; break; case ResolutionAction.Rename: { var name = resolution.RenameTo ?? dto.Name; var t = BuildTemplate(dto, overrideName: name); await _templateRepo.AddTemplateAsync(t, ct).ConfigureAwait(false); await _auditService.LogAsync(user, "Create", "Template", "0", name, new { Name = name, dto.Description, RenamedFrom = dto.Name }, ct).ConfigureAwait(false); summary.Renamed++; break; } case ResolutionAction.Overwrite when byName.TryGetValue(dto.Name, out var ex): ex.Description = dto.Description; await _templateRepo.UpdateTemplateAsync(ex, ct).ConfigureAwait(false); await _auditService.LogAsync(user, "Update", "Template", ex.Id.ToString(), ex.Name, new { ex.Name, ex.Description }, ct).ConfigureAwait(false); summary.Overwritten++; break; case ResolutionAction.Add: case ResolutionAction.Overwrite: default: { var t = BuildTemplate(dto, overrideName: null); await _templateRepo.AddTemplateAsync(t, ct).ConfigureAwait(false); await _auditService.LogAsync(user, "Create", "Template", "0", t.Name, new { t.Name, t.Description }, 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; } /// /// 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); 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; } 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) { CollectCallIdentifiers(a.Value, referenced); CollectCallIdentifiers(a.DataSourceReference, 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 (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, }; } }