diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/NotificationRepository.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/NotificationRepository.cs index fde0bc97..cca96c99 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/NotificationRepository.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/NotificationRepository.cs @@ -17,7 +17,12 @@ public class NotificationRepository : INotificationRepository /// public async Task GetNotificationListByIdAsync(int id, CancellationToken cancellationToken = default) - => await _context.Set().FindAsync(new object[] { id }, cancellationToken); + // Eager-load Recipients: single-list selection feeds the Transport exporter, + // which serializes the recipient collection — a FindAsync without the Include + // exported a recipient-less list (silent data loss). + => await _context.Set() + .Include(n => n.Recipients) + .FirstOrDefaultAsync(n => n.Id == id, cancellationToken); /// public async Task> GetAllNotificationListsAsync(CancellationToken cancellationToken = default) diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs index 1bab567a..4b864a0c 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs @@ -1050,6 +1050,16 @@ public sealed class BundleImporter : IBundleImporter await ResolveAlarmScriptLinksAsync(content.Templates, resolutionMap, user, ct).ConfigureAwait(false); await ResolveCompositionEdgesAsync(content.Templates, resolutionMap, user, ct).ConfigureAwait(false); await ResolveInheritanceEdgesAsync(content.Templates, resolutionMap, user, ct).ConfigureAwait(false); + // Folder parent edges are name-keyed too — resolve them AFTER the flush + // above so every imported folder has a materialised id to point at + // (the Add pass created the folders parent-less). Mirrors the template + // inheritance/composition rewires. + await ResolveFolderParentEdgesAsync(content.TemplateFolders, resolutionMap, user, ct).ConfigureAwait(false); + // External-system methods have no navigation on the parent definition, + // so an Add can't cascade them — persist them here, once the parent + // system has a real id from the flush above. (Overwrite already syncs + // its methods inline against the existing parent id.) + await PersistAddedExternalSystemMethodsAsync(content.ExternalSystems, resolutionMap, user, ct).ConfigureAwait(false); // ---- Import-time acyclicity guard over the merged template graph ---- // The two rewire passes above have STAGED (not yet saved) the bundle's @@ -2544,6 +2554,8 @@ public sealed class BundleImporter : IBundleImporter existing.EndpointUrl = dto.BaseUrl; existing.AuthType = dto.AuthType; existing.AuthConfiguration = dto.Secrets?.Values.TryGetValue("AuthConfiguration", out var auth) == true ? auth : null; + existing.MaxRetries = dto.MaxRetries; + existing.RetryDelay = dto.RetryDelay; 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); @@ -2575,6 +2587,8 @@ public sealed class BundleImporter : IBundleImporter var sys = new ExternalSystemDefinition(overrideName ?? dto.Name, dto.BaseUrl, dto.AuthType) { AuthConfiguration = dto.Secrets?.Values.TryGetValue("AuthConfiguration", out var auth) == true ? auth : null, + MaxRetries = dto.MaxRetries, + RetryDelay = dto.RetryDelay, }; return sys; } @@ -2687,6 +2701,116 @@ public sealed class BundleImporter : IBundleImporter } } + /// + /// Second-pass rewire of the name-keyed + /// edge. The Add pass creates folders parent-less (parent may be later in the + /// bundle and have no id yet); after the folder flush, resolve each folder's + /// bundle ParentName (through the rename resolution map) to a persisted + /// folder id and set the FK. Mirrors . + /// + private async Task ResolveFolderParentEdgesAsync( + IReadOnlyList dtos, + Dictionary<(string, string), ImportResolution> map, + string user, + CancellationToken ct) + { + if (dtos.Count == 0) return; + var allFolders = await _templateRepo.GetAllFoldersAsync(ct).ConfigureAwait(false); + var byName = allFolders.ToDictionary(f => f.Name, f => f, StringComparer.Ordinal); + + foreach (var dto in dtos) + { + var resolution = ResolveOrDefault(map, "TemplateFolder", dto.Name); + if (resolution.Action == ResolutionAction.Skip) continue; + + var childName = resolution.Action == ResolutionAction.Rename + ? resolution.RenameTo ?? dto.Name + : dto.Name; + if (!byName.TryGetValue(childName, out var child)) continue; + + int? parentId = null; + if (dto.ParentName is not null) + { + // A renamed parent is persisted under its RenameTo name. + var parentResolution = ResolveOrDefault(map, "TemplateFolder", dto.ParentName); + var parentName = parentResolution.Action == ResolutionAction.Rename + ? parentResolution.RenameTo ?? dto.ParentName + : dto.ParentName; + if (byName.TryGetValue(parentName, out var parent)) + { + parentId = parent.Id; + } + } + + if (child.ParentFolderId != parentId) + { + child.ParentFolderId = parentId; + await _templateRepo.UpdateFolderAsync(child, ct).ConfigureAwait(false); + } + } + } + + /// + /// Persists an added external system's method rows. + /// has no navigation on its parent definition, so an Add can't cascade its + /// methods the way template children do — they are added here after the parent + /// system has a materialised id from the central-config flush. Idempotent: a + /// method already present (e.g. a genuine Overwrite that synced its methods + /// inline) is skipped, so re-processing every non-Skip system is safe. + /// + private async Task PersistAddedExternalSystemMethodsAsync( + IReadOnlyList dtos, + Dictionary<(string, string), ImportResolution> map, + string user, + CancellationToken ct) + { + if (dtos.Count == 0) return; + + foreach (var dto in dtos) + { + if (dto.Methods.Count == 0) continue; + var resolution = ResolveOrDefault(map, "ExternalSystem", dto.Name); + if (resolution.Action == ResolutionAction.Skip) continue; + + var persistedName = resolution.Action == ResolutionAction.Rename + ? resolution.RenameTo ?? dto.Name + : dto.Name; + var sys = await _externalRepo.GetExternalSystemByNameAsync(persistedName, ct).ConfigureAwait(false); + if (sys is null) continue; + + var existing = await _externalRepo.GetMethodsByExternalSystemIdAsync(sys.Id, ct).ConfigureAwait(false); + var existingNames = existing.Select(m => m.Name).ToHashSet(StringComparer.Ordinal); + + foreach (var methodDto in dto.Methods) + { + if (existingNames.Contains(methodDto.Name)) continue; // already synced (Overwrite) or already added. + var method = new ExternalSystemMethod(methodDto.Name, methodDto.HttpMethod, methodDto.Path) + { + ExternalSystemDefinitionId = sys.Id, + ParameterDefinitions = methodDto.ParameterDefinitions, + ReturnDefinition = methodDto.ReturnDefinition, + }; + await _externalRepo.AddExternalSystemMethodAsync(method, ct).ConfigureAwait(false); + await _auditService.LogAsync( + user, + "ExternalSystemMethodAdded", + "ExternalSystemMethod", + "0", + $"{sys.Name}.{method.Name}", + new + { + ExternalSystemName = sys.Name, + MethodName = method.Name, + method.HttpMethod, + method.Path, + method.ParameterDefinitions, + method.ReturnDefinition, + }, + ct).ConfigureAwait(false); + } + } + } + private async Task ApplyDatabaseConnectionsAsync( IReadOnlyList dtos, Dictionary<(string, string), ImportResolution> map,