651 lines
34 KiB
C#
651 lines
34 KiB
C#
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.Transport.Serialization;
|
|
|
|
/// <summary>
|
|
/// Converts between the persistence-shaped <see cref="EntityAggregate"/> and
|
|
/// the wire-shaped <see cref="BundleContentDto"/>. The conversion strips EF
|
|
/// identity (FK ints) and references inter-entity links by name so bundles
|
|
/// remain portable across environments. Secrets are carved into a per-entity
|
|
/// <see cref="SecretsBlock"/> so the bundle's secret-free public surface can
|
|
/// be inspected (or, in future, exported without secrets) without touching the
|
|
/// rest of the payload.
|
|
/// </summary>
|
|
public sealed class EntitySerializer
|
|
{
|
|
/// <summary>Converts a persistence-shaped <see cref="EntityAggregate"/> to the portable wire-shaped <see cref="BundleContentDto"/>.</summary>
|
|
/// <param name="aggregate">The in-memory aggregate to serialize.</param>
|
|
/// <returns>A <see cref="BundleContentDto"/> with identity-stripped, name-linked entity representations.</returns>
|
|
public BundleContentDto ToBundleContent(EntityAggregate aggregate)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(aggregate);
|
|
|
|
var folderNameById = aggregate.TemplateFolders.ToDictionary(f => f.Id, f => f.Name);
|
|
var templateNameById = aggregate.Templates.ToDictionary(t => t.Id, t => t.Name);
|
|
|
|
// Name-link lookups. Instances/data-connections reference their owning
|
|
// site by the portable SiteIdentifier (not the EF surrogate SiteId); a
|
|
// connection binding references its DataConnection by Name. Build both
|
|
// id→name maps once so the projections below resolve in O(1).
|
|
var siteIdentifierBySiteId = aggregate.Sites.ToDictionary(s => s.Id, s => s.SiteIdentifier);
|
|
var connectionNameByConnectionId = aggregate.DataConnections.ToDictionary(c => c.Id, c => c.Name);
|
|
|
|
return new BundleContentDto(
|
|
TemplateFolders: aggregate.TemplateFolders.Select(f => new TemplateFolderDto(
|
|
Name: f.Name,
|
|
ParentName: f.ParentFolderId is { } pid && folderNameById.TryGetValue(pid, out var pname) ? pname : null,
|
|
SortOrder: f.SortOrder)).ToList(),
|
|
Templates: aggregate.Templates.Select(t =>
|
|
{
|
|
// Build per-template script-id → name lookup once so the alarm
|
|
// projection below resolves OnTriggerScriptId by name in O(1).
|
|
// Scripts can only target sibling scripts on the same template
|
|
// (TemplateAlarm.OnTriggerScriptId FK is scoped to TemplateId),
|
|
// so we don't need a global script index.
|
|
var scriptNameById = t.Scripts.ToDictionary(s => s.Id, s => s.Name);
|
|
return new TemplateDto(
|
|
Name: t.Name,
|
|
FolderName: t.FolderId is { } fid && folderNameById.TryGetValue(fid, out var fname) ? fname : null,
|
|
BaseTemplateName: t.ParentTemplateId is { } btid && templateNameById.TryGetValue(btid, out var bname) ? bname : null,
|
|
Description: t.Description,
|
|
Attributes: t.Attributes.Select(a => new TemplateAttributeDto(
|
|
Name: a.Name,
|
|
Value: a.Value,
|
|
DataType: a.DataType,
|
|
IsLocked: a.IsLocked,
|
|
Description: a.Description,
|
|
DataSourceReference: a.DataSourceReference,
|
|
ElementDataType: a.ElementDataType,
|
|
LockedInDerived: a.LockedInDerived)).ToList(),
|
|
Alarms: t.Alarms.Select(a => new TemplateAlarmDto(
|
|
Name: a.Name,
|
|
Description: a.Description,
|
|
PriorityLevel: a.PriorityLevel,
|
|
TriggerType: a.TriggerType,
|
|
TriggerConfiguration: a.TriggerConfiguration,
|
|
IsLocked: a.IsLocked,
|
|
// Carry the on-trigger script by NAME — the importer resolves
|
|
// this back to a script id once the parent template's scripts
|
|
// have been persisted and assigned ids. If the FK doesn't
|
|
// resolve in this aggregate (e.g. corrupt/orphan row), the
|
|
// name comes through as null and the importer leaves the
|
|
// FK null on the imported alarm.
|
|
OnTriggerScriptName: a.OnTriggerScriptId is { } sid && scriptNameById.TryGetValue(sid, out var sn) ? sn : null,
|
|
LockedInDerived: a.LockedInDerived)).ToList(),
|
|
Scripts: t.Scripts.Select(s => new TemplateScriptDto(
|
|
Name: s.Name,
|
|
Code: s.Code,
|
|
TriggerType: s.TriggerType,
|
|
TriggerConfiguration: s.TriggerConfiguration,
|
|
ParameterDefinitions: s.ParameterDefinitions,
|
|
ReturnDefinition: s.ReturnDefinition,
|
|
IsLocked: s.IsLocked,
|
|
MinTimeBetweenRuns: s.MinTimeBetweenRuns,
|
|
ExecutionTimeoutSeconds: s.ExecutionTimeoutSeconds,
|
|
LockedInDerived: s.LockedInDerived)).ToList(),
|
|
Compositions: t.Compositions.Select(c => new TemplateCompositionDto(
|
|
InstanceName: c.InstanceName,
|
|
ComposedTemplateName: templateNameById.TryGetValue(c.ComposedTemplateId, out var cn) ? cn : string.Empty)).ToList())
|
|
{
|
|
// Native alarm sources carry ALL rows including IsInherited
|
|
// placeholders — the collision detector depends on them.
|
|
NativeAlarmSources = t.NativeAlarmSources.Select(n => new TemplateNativeAlarmSourceDto(
|
|
Name: n.Name,
|
|
Description: n.Description,
|
|
ConnectionName: n.ConnectionName,
|
|
SourceReference: n.SourceReference,
|
|
ConditionFilter: n.ConditionFilter,
|
|
IsLocked: n.IsLocked,
|
|
IsInherited: n.IsInherited,
|
|
LockedInDerived: n.LockedInDerived)).ToList(),
|
|
};
|
|
}).ToList(),
|
|
SharedScripts: aggregate.SharedScripts.Select(s => new SharedScriptDto(
|
|
Name: s.Name,
|
|
Code: s.Code,
|
|
ParameterDefinitions: s.ParameterDefinitions,
|
|
ReturnDefinition: s.ReturnDefinition)).ToList(),
|
|
ExternalSystems: aggregate.ExternalSystems.Select(sys =>
|
|
{
|
|
var methods = aggregate.ExternalSystemMethods
|
|
.Where(m => m.ExternalSystemDefinitionId == sys.Id)
|
|
.Select(m => new ExternalSystemMethodDto(
|
|
Name: m.Name,
|
|
HttpMethod: m.HttpMethod,
|
|
Path: m.Path,
|
|
ParameterDefinitions: m.ParameterDefinitions,
|
|
ReturnDefinition: m.ReturnDefinition))
|
|
.ToList();
|
|
|
|
SecretsBlock? secrets = null;
|
|
if (!string.IsNullOrEmpty(sys.AuthConfiguration))
|
|
{
|
|
secrets = new SecretsBlock(new Dictionary<string, string>(StringComparer.Ordinal)
|
|
{
|
|
["AuthConfiguration"] = sys.AuthConfiguration,
|
|
});
|
|
}
|
|
|
|
return new ExternalSystemDto(
|
|
Name: sys.Name,
|
|
BaseUrl: sys.EndpointUrl,
|
|
AuthType: sys.AuthType,
|
|
MaxRetries: sys.MaxRetries,
|
|
RetryDelay: sys.RetryDelay,
|
|
Methods: methods,
|
|
Secrets: secrets);
|
|
}).ToList(),
|
|
DatabaseConnections: aggregate.DatabaseConnections.Select(dc => new DatabaseConnectionDto(
|
|
Name: dc.Name,
|
|
MaxRetries: dc.MaxRetries,
|
|
RetryDelay: dc.RetryDelay,
|
|
// Connection strings typically embed credentials inline; treat the entire
|
|
// string as secret rather than try to parse out the password fragment.
|
|
Secrets: new SecretsBlock(new Dictionary<string, string>(StringComparer.Ordinal)
|
|
{
|
|
["ConnectionString"] = dc.ConnectionString,
|
|
}))).ToList(),
|
|
NotificationLists: aggregate.NotificationLists.Select(nl => new NotificationListDto(
|
|
Name: nl.Name,
|
|
Type: nl.Type,
|
|
// Carry BOTH contacts. Keep any recipient that has at least
|
|
// one contact (email OR phone) — SMS-only recipients have a null
|
|
// EmailAddress and would have been dropped by the old email-only
|
|
// filter. EmailAddress/PhoneNumber are nullable on the DTO so a
|
|
// recipient round-trips exactly the contact(s) it holds.
|
|
Recipients: nl.Recipients
|
|
.Where(r => r.EmailAddress is not null || r.PhoneNumber is not null)
|
|
.Select(r => new NotificationRecipientDto(
|
|
Name: r.Name,
|
|
EmailAddress: r.EmailAddress,
|
|
PhoneNumber: r.PhoneNumber)).ToList())).ToList(),
|
|
SmtpConfigs: aggregate.SmtpConfigurations.Select(smtp =>
|
|
{
|
|
SecretsBlock? secrets = null;
|
|
if (!string.IsNullOrEmpty(smtp.Credentials))
|
|
{
|
|
secrets = new SecretsBlock(new Dictionary<string, string>(StringComparer.Ordinal)
|
|
{
|
|
["Credentials"] = smtp.Credentials,
|
|
});
|
|
}
|
|
|
|
return new SmtpConfigDto(
|
|
Host: smtp.Host,
|
|
Port: smtp.Port,
|
|
AuthType: smtp.AuthType,
|
|
FromAddress: smtp.FromAddress,
|
|
TlsMode: smtp.TlsMode,
|
|
ConnectionTimeoutSeconds: smtp.ConnectionTimeoutSeconds,
|
|
MaxConcurrentConnections: smtp.MaxConcurrentConnections,
|
|
MaxRetries: smtp.MaxRetries,
|
|
RetryDelay: smtp.RetryDelay,
|
|
Secrets: secrets,
|
|
// Non-sensitive delivery metadata: the OAuth2 endpoint pair and
|
|
// the Smtp/Ews transport selector travel as plain DTO fields.
|
|
OAuth2Authority: smtp.OAuth2Authority,
|
|
OAuth2Scope: smtp.OAuth2Scope,
|
|
Transport: smtp.Transport);
|
|
}).ToList(),
|
|
// Inbound API keys are not transported between environments:
|
|
// the bundle carries API methods only. ApiMethod.ApprovedApiKeyIds is also
|
|
// excluded — it references keys that aren't in the bundle; method→key scopes
|
|
// are re-granted per environment via the admin UI/CLI. The legacy ApiKeys
|
|
// field on the DTO stays null (and is dropped by WhenWritingNull).
|
|
ApiMethods: aggregate.ApiMethods.Select(m => new ApiMethodDto(
|
|
Name: m.Name,
|
|
Script: m.Script,
|
|
ParameterDefinitions: m.ParameterDefinitions,
|
|
ReturnDefinition: m.ReturnDefinition,
|
|
TimeoutSeconds: m.TimeoutSeconds)).ToList())
|
|
{
|
|
// Site/instance-scoped payloads. These are init-only (non-positional)
|
|
// on BundleContentDto, so they're set via object-initializer here.
|
|
Sites = aggregate.Sites.Select(s => new SiteDto(
|
|
SiteIdentifier: s.SiteIdentifier,
|
|
Name: s.Name,
|
|
Description: s.Description,
|
|
NodeAAddress: s.NodeAAddress,
|
|
NodeBAddress: s.NodeBAddress,
|
|
GrpcNodeAAddress: s.GrpcNodeAAddress,
|
|
GrpcNodeBAddress: s.GrpcNodeBAddress)).ToList(),
|
|
DataConnections = aggregate.DataConnections.Select(c =>
|
|
{
|
|
// The protocol-specific Primary/Backup configuration JSON typically
|
|
// embeds endpoints + credentials, so the whole blob rides inside the
|
|
// SecretsBlock (a "share without secrets" export drops it as a unit).
|
|
// Omit a key entirely when its source value is null/empty so the
|
|
// importer's TryGetValue cleanly yields "no value" rather than "".
|
|
var secretValues = new Dictionary<string, string>(StringComparer.Ordinal);
|
|
if (!string.IsNullOrEmpty(c.PrimaryConfiguration))
|
|
{
|
|
secretValues["PrimaryConfiguration"] = c.PrimaryConfiguration;
|
|
}
|
|
if (!string.IsNullOrEmpty(c.BackupConfiguration))
|
|
{
|
|
secretValues["BackupConfiguration"] = c.BackupConfiguration;
|
|
}
|
|
|
|
return new DataConnectionDto(
|
|
SiteIdentifier: siteIdentifierBySiteId.TryGetValue(c.SiteId, out var sid) ? sid : string.Empty,
|
|
Name: c.Name,
|
|
Protocol: c.Protocol,
|
|
FailoverRetryCount: c.FailoverRetryCount,
|
|
Secrets: secretValues.Count > 0 ? new SecretsBlock(secretValues) : null);
|
|
}).ToList(),
|
|
// SMS: mirror the SMTP secret handling exactly — the provider
|
|
// auth token (never a public field) rides inside the SecretsBlock, so a
|
|
// "share without secrets" export can drop it as a unit. Omit the secret
|
|
// entirely when the token is null/empty so the importer's TryGetValue
|
|
// cleanly yields "no value".
|
|
SmsConfigs = aggregate.SmsConfigurations.Select(sms =>
|
|
{
|
|
SecretsBlock? secrets = null;
|
|
if (!string.IsNullOrEmpty(sms.AuthToken))
|
|
{
|
|
secrets = new SecretsBlock(new Dictionary<string, string>(StringComparer.Ordinal)
|
|
{
|
|
["AuthToken"] = sms.AuthToken,
|
|
});
|
|
}
|
|
|
|
return new SmsConfigDto(
|
|
AccountSid: sms.AccountSid,
|
|
FromNumber: sms.FromNumber,
|
|
MessagingServiceSid: sms.MessagingServiceSid,
|
|
ApiBaseUrl: sms.ApiBaseUrl,
|
|
ConnectionTimeoutSeconds: sms.ConnectionTimeoutSeconds,
|
|
MaxRetries: sms.MaxRetries,
|
|
RetryDelay: sms.RetryDelay,
|
|
Secrets: secrets);
|
|
}).ToList(),
|
|
Instances = aggregate.Instances.Select(inst => new InstanceDto(
|
|
UniqueName: inst.UniqueName,
|
|
TemplateName: templateNameById.TryGetValue(inst.TemplateId, out var tn) ? tn : string.Empty,
|
|
SiteIdentifier: siteIdentifierBySiteId.TryGetValue(inst.SiteId, out var isid) ? isid : string.Empty,
|
|
// Area travels by NAME — the importer resolves-or-creates the
|
|
// equivalent area under the target site (ResolveOrCreateAreaIdAsync).
|
|
// Resolve the instance's AreaId FK against the aggregate's id→name
|
|
// map; if the FK is null (no area) or unresolved (orphan/absent from
|
|
// the map), the name comes through as null and the importer leaves
|
|
// AreaId null on the imported instance.
|
|
AreaName: inst.AreaId is { } aid && aggregate.AreaNameById.TryGetValue(aid, out var an) ? an : null,
|
|
State: inst.State,
|
|
AttributeOverrides: inst.AttributeOverrides.Select(o => new InstanceAttributeOverrideDto(
|
|
AttributeName: o.AttributeName,
|
|
OverrideValue: o.OverrideValue,
|
|
ElementDataType: o.ElementDataType)).ToList(),
|
|
AlarmOverrides: inst.AlarmOverrides.Select(o => new InstanceAlarmOverrideDto(
|
|
AlarmCanonicalName: o.AlarmCanonicalName,
|
|
TriggerConfigurationOverride: o.TriggerConfigurationOverride,
|
|
PriorityLevelOverride: o.PriorityLevelOverride)).ToList(),
|
|
NativeAlarmSourceOverrides: inst.NativeAlarmSourceOverrides.Select(o => new InstanceNativeAlarmSourceOverrideDto(
|
|
SourceCanonicalName: o.SourceCanonicalName,
|
|
ConnectionNameOverride: o.ConnectionNameOverride,
|
|
SourceReferenceOverride: o.SourceReferenceOverride,
|
|
ConditionFilterOverride: o.ConditionFilterOverride)).ToList(),
|
|
// Connection bindings reference their DataConnection by NAME — the
|
|
// numeric DataConnectionId FK can't survive a cross-environment
|
|
// bundle, so the importer resolves ConnectionName→target FK at
|
|
// apply time. If the FK doesn't resolve in this aggregate (orphan
|
|
// row), the name comes through empty.
|
|
ConnectionBindings: inst.ConnectionBindings.Select(b => new InstanceConnectionBindingDto(
|
|
AttributeName: b.AttributeName,
|
|
ConnectionName: connectionNameByConnectionId.TryGetValue(b.DataConnectionId, out var cn) ? cn : string.Empty,
|
|
DataSourceReferenceOverride: b.DataSourceReferenceOverride)).ToList())).ToList(),
|
|
};
|
|
}
|
|
|
|
/// <summary>Reconstructs a persistence-shaped <see cref="EntityAggregate"/> from a wire-shaped <see cref="BundleContentDto"/>.</summary>
|
|
/// <param name="content">The bundle content DTO to deserialize.</param>
|
|
/// <returns>An <see cref="EntityAggregate"/> with entities linked by name, ready for import conflict resolution.</returns>
|
|
public EntityAggregate FromBundleContent(BundleContentDto content)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(content);
|
|
|
|
// Folders: assign synthetic ids by ordinal, resolve parent pointers by name.
|
|
var folders = content.TemplateFolders
|
|
.Select((dto, ix) => new TemplateFolder(dto.Name) { Id = ix + 1, SortOrder = dto.SortOrder })
|
|
.ToList();
|
|
var folderIdByName = folders.ToDictionary(f => f.Name, f => f.Id, StringComparer.Ordinal);
|
|
for (var i = 0; i < folders.Count; i++)
|
|
{
|
|
var parentName = content.TemplateFolders[i].ParentName;
|
|
if (parentName is not null && folderIdByName.TryGetValue(parentName, out var parentId))
|
|
{
|
|
folders[i].ParentFolderId = parentId;
|
|
}
|
|
}
|
|
|
|
// Templates: same pattern — assign ids, second pass resolves base + folder.
|
|
var templates = content.Templates
|
|
.Select((dto, ix) =>
|
|
{
|
|
var t = new Template(dto.Name) { Id = ix + 1, Description = dto.Description };
|
|
foreach (var a in dto.Attributes)
|
|
{
|
|
t.Attributes.Add(new TemplateAttribute(a.Name)
|
|
{
|
|
TemplateId = t.Id,
|
|
Value = ImportValueNormalizer.NormalizeListValue(a.Value, a.DataType, a.ElementDataType),
|
|
DataType = a.DataType,
|
|
IsLocked = a.IsLocked,
|
|
Description = a.Description,
|
|
DataSourceReference = a.DataSourceReference,
|
|
ElementDataType = a.ElementDataType,
|
|
LockedInDerived = a.LockedInDerived,
|
|
});
|
|
}
|
|
foreach (var al in dto.Alarms)
|
|
{
|
|
t.Alarms.Add(new TemplateAlarm(al.Name)
|
|
{
|
|
TemplateId = t.Id,
|
|
Description = al.Description,
|
|
PriorityLevel = al.PriorityLevel,
|
|
TriggerType = al.TriggerType,
|
|
TriggerConfiguration = al.TriggerConfiguration,
|
|
IsLocked = al.IsLocked,
|
|
LockedInDerived = al.LockedInDerived,
|
|
});
|
|
}
|
|
foreach (var s in dto.Scripts)
|
|
{
|
|
t.Scripts.Add(new TemplateScript(s.Name, s.Code)
|
|
{
|
|
TemplateId = t.Id,
|
|
TriggerType = s.TriggerType,
|
|
TriggerConfiguration = s.TriggerConfiguration,
|
|
ParameterDefinitions = s.ParameterDefinitions,
|
|
ReturnDefinition = s.ReturnDefinition,
|
|
IsLocked = s.IsLocked,
|
|
MinTimeBetweenRuns = s.MinTimeBetweenRuns,
|
|
ExecutionTimeoutSeconds = s.ExecutionTimeoutSeconds,
|
|
LockedInDerived = s.LockedInDerived,
|
|
});
|
|
}
|
|
foreach (var n in dto.NativeAlarmSources)
|
|
{
|
|
t.NativeAlarmSources.Add(new TemplateNativeAlarmSource(n.Name)
|
|
{
|
|
TemplateId = t.Id,
|
|
Description = n.Description,
|
|
ConnectionName = n.ConnectionName,
|
|
SourceReference = n.SourceReference,
|
|
ConditionFilter = n.ConditionFilter,
|
|
IsLocked = n.IsLocked,
|
|
IsInherited = n.IsInherited,
|
|
LockedInDerived = n.LockedInDerived,
|
|
});
|
|
}
|
|
return t;
|
|
})
|
|
.ToList();
|
|
var templateIdByName = templates.ToDictionary(t => t.Name, t => t.Id, StringComparer.Ordinal);
|
|
for (var i = 0; i < templates.Count; i++)
|
|
{
|
|
var dto = content.Templates[i];
|
|
if (dto.FolderName is not null && folderIdByName.TryGetValue(dto.FolderName, out var fid))
|
|
{
|
|
templates[i].FolderId = fid;
|
|
}
|
|
if (dto.BaseTemplateName is not null && templateIdByName.TryGetValue(dto.BaseTemplateName, out var btid))
|
|
{
|
|
templates[i].ParentTemplateId = btid;
|
|
}
|
|
foreach (var compDto in dto.Compositions)
|
|
{
|
|
var comp = new TemplateComposition(compDto.InstanceName)
|
|
{
|
|
TemplateId = templates[i].Id,
|
|
};
|
|
if (templateIdByName.TryGetValue(compDto.ComposedTemplateName, out var ctid))
|
|
{
|
|
comp.ComposedTemplateId = ctid;
|
|
}
|
|
templates[i].Compositions.Add(comp);
|
|
}
|
|
}
|
|
|
|
var sharedScripts = content.SharedScripts
|
|
.Select((dto, ix) => new SharedScript(dto.Name, dto.Code)
|
|
{
|
|
Id = ix + 1,
|
|
ParameterDefinitions = dto.ParameterDefinitions,
|
|
ReturnDefinition = dto.ReturnDefinition,
|
|
})
|
|
.ToList();
|
|
|
|
var externalSystems = new List<ExternalSystemDefinition>();
|
|
var externalSystemMethods = new List<ExternalSystemMethod>();
|
|
for (var ix = 0; ix < content.ExternalSystems.Count; ix++)
|
|
{
|
|
var dto = content.ExternalSystems[ix];
|
|
var sys = new ExternalSystemDefinition(dto.Name, dto.BaseUrl, dto.AuthType)
|
|
{
|
|
Id = ix + 1,
|
|
AuthConfiguration = dto.Secrets?.Values.TryGetValue("AuthConfiguration", out var auth) == true ? auth : null,
|
|
MaxRetries = dto.MaxRetries,
|
|
RetryDelay = dto.RetryDelay,
|
|
};
|
|
externalSystems.Add(sys);
|
|
foreach (var m in dto.Methods)
|
|
{
|
|
externalSystemMethods.Add(new ExternalSystemMethod(m.Name, m.HttpMethod, m.Path)
|
|
{
|
|
ExternalSystemDefinitionId = sys.Id,
|
|
ParameterDefinitions = m.ParameterDefinitions,
|
|
ReturnDefinition = m.ReturnDefinition,
|
|
});
|
|
}
|
|
}
|
|
|
|
var databaseConnections = content.DatabaseConnections
|
|
.Select((dto, ix) =>
|
|
{
|
|
var connStr = dto.Secrets?.Values.TryGetValue("ConnectionString", out var cs) == true ? cs : string.Empty;
|
|
return new DatabaseConnectionDefinition(dto.Name, connStr)
|
|
{
|
|
Id = ix + 1,
|
|
MaxRetries = dto.MaxRetries,
|
|
RetryDelay = dto.RetryDelay,
|
|
};
|
|
})
|
|
.ToList();
|
|
|
|
var notificationLists = content.NotificationLists
|
|
.Select((dto, ix) =>
|
|
{
|
|
var list = new NotificationList(dto.Name) { Id = ix + 1, Type = dto.Type };
|
|
foreach (var r in dto.Recipients)
|
|
{
|
|
// Reconstruct whichever contact(s) the bundle carries.
|
|
// Email-present recipients use the email ctor (unchanged
|
|
// historical path) and additively restore the phone number;
|
|
// phone-only recipients (null email) take the SMS factory so we
|
|
// never pass null to the non-null email ctor.
|
|
var recipient = r.EmailAddress is not null
|
|
? new NotificationRecipient(r.Name, r.EmailAddress) { PhoneNumber = r.PhoneNumber }
|
|
: NotificationRecipient.ForSms(r.Name, r.PhoneNumber ?? string.Empty);
|
|
recipient.NotificationListId = list.Id;
|
|
list.Recipients.Add(recipient);
|
|
}
|
|
return list;
|
|
})
|
|
.ToList();
|
|
|
|
var smtpConfigurations = content.SmtpConfigs
|
|
.Select((dto, ix) => new SmtpConfiguration(dto.Host, dto.AuthType, dto.FromAddress)
|
|
{
|
|
Id = ix + 1,
|
|
Port = dto.Port,
|
|
Credentials = dto.Secrets?.Values.TryGetValue("Credentials", out var cred) == true ? cred : null,
|
|
TlsMode = dto.TlsMode,
|
|
ConnectionTimeoutSeconds = dto.ConnectionTimeoutSeconds,
|
|
MaxConcurrentConnections = dto.MaxConcurrentConnections,
|
|
MaxRetries = dto.MaxRetries,
|
|
RetryDelay = dto.RetryDelay,
|
|
// Null on a bundle written before these fields were carried, which
|
|
// is exactly the entity's "use the provider defaults / Smtp" state.
|
|
OAuth2Authority = dto.OAuth2Authority,
|
|
OAuth2Scope = dto.OAuth2Scope,
|
|
Transport = dto.Transport,
|
|
})
|
|
.ToList();
|
|
|
|
// SMS: mirror the SMTP path — restore AccountSid/FromNumber and decrypt
|
|
// the auth token back out of the SecretsBlock (null when the key was omitted
|
|
// because the source token was null/empty).
|
|
var smsConfigurations = content.SmsConfigs
|
|
.Select((dto, ix) => new SmsConfiguration(dto.AccountSid, dto.FromNumber)
|
|
{
|
|
Id = ix + 1,
|
|
AuthToken = dto.Secrets?.Values.TryGetValue("AuthToken", out var token) == true ? token : null,
|
|
MessagingServiceSid = dto.MessagingServiceSid,
|
|
ApiBaseUrl = dto.ApiBaseUrl,
|
|
ConnectionTimeoutSeconds = dto.ConnectionTimeoutSeconds,
|
|
MaxRetries = dto.MaxRetries,
|
|
RetryDelay = dto.RetryDelay,
|
|
})
|
|
.ToList();
|
|
|
|
// Inbound API keys are not transported — content.ApiKeys is a
|
|
// legacy field only present on pre-C4 bundles; it is ignored here. The
|
|
// BundleImporter is responsible for counting and reporting any such keys.
|
|
// ApiMethod.ApprovedApiKeyIds is likewise not reconstructed: scopes are
|
|
// re-granted per environment.
|
|
var apiMethods = content.ApiMethods
|
|
.Select((dto, ix) => new ApiMethod(dto.Name, dto.Script)
|
|
{
|
|
Id = ix + 1,
|
|
ParameterDefinitions = dto.ParameterDefinitions,
|
|
ReturnDefinition = dto.ReturnDefinition,
|
|
TimeoutSeconds = dto.TimeoutSeconds,
|
|
})
|
|
.ToList();
|
|
|
|
// Sites first — assign synthetic ids by ordinal. Addresses are carried
|
|
// verbatim; the importer decides whether to keep or rewrite them.
|
|
var sites = content.Sites
|
|
.Select((dto, ix) => new Site(dto.Name, dto.SiteIdentifier)
|
|
{
|
|
Id = ix + 1,
|
|
Description = dto.Description,
|
|
NodeAAddress = dto.NodeAAddress,
|
|
NodeBAddress = dto.NodeBAddress,
|
|
GrpcNodeAAddress = dto.GrpcNodeAAddress,
|
|
GrpcNodeBAddress = dto.GrpcNodeBAddress,
|
|
})
|
|
.ToList();
|
|
var siteIdByIdentifier = sites.ToDictionary(s => s.SiteIdentifier, s => s.Id, StringComparer.Ordinal);
|
|
|
|
// Data connections: resolve owning site by SiteIdentifier, restore the
|
|
// protocol config from the SecretsBlock (null when the key was omitted).
|
|
var dataConnections = content.DataConnections
|
|
.Select((dto, ix) => new DataConnection(
|
|
dto.Name,
|
|
dto.Protocol,
|
|
siteIdByIdentifier.TryGetValue(dto.SiteIdentifier, out var sid) ? sid : 0)
|
|
{
|
|
Id = ix + 1,
|
|
FailoverRetryCount = dto.FailoverRetryCount,
|
|
PrimaryConfiguration = dto.Secrets?.Values.TryGetValue("PrimaryConfiguration", out var pc) == true ? pc : null,
|
|
BackupConfiguration = dto.Secrets?.Values.TryGetValue("BackupConfiguration", out var bc) == true ? bc : null,
|
|
})
|
|
.ToList();
|
|
|
|
// Instances: resolve template + site by name/identifier. Areas DO travel —
|
|
// by NAME on InstanceDto.AreaName (export resolves AreaId→AreaNameById; the
|
|
// importer resolves-or-creates the area under the target site). This helper
|
|
// still materializes AreaId = null for the same reason the connection-binding
|
|
// FK below is a placeholder: FromBundleContent is NOT on the importer's path
|
|
// (BundleImporter walks the raw DTO) and has no target-DB name→id map to
|
|
// resolve against. Round-trip fidelity for the area lives on the DTO, not on
|
|
// this materialized entity. Connection bindings keep their
|
|
// resolved DataConnection NAME on the wire DTO — the importer reads
|
|
// bindings from BundleContentDto.Instances[].ConnectionBindings directly and
|
|
// resolves ConnectionName→the target environment's DataConnectionId at apply
|
|
// time. FromBundleContent is NOT on the importer's path (BundleImporter walks
|
|
// the raw DTO), so here we materialize the binding entity with a placeholder
|
|
// DataConnectionId = 0; it carries AttributeName + DataSourceReferenceOverride
|
|
// so a round-trip preserves the binding shape, but the FK is intentionally
|
|
// unresolved (there is no DataConnection-name→id map valid for the target DB).
|
|
var instances = content.Instances
|
|
.Select((dto, ix) =>
|
|
{
|
|
var inst = new Instance(dto.UniqueName)
|
|
{
|
|
Id = ix + 1,
|
|
State = dto.State,
|
|
TemplateId = templateIdByName.TryGetValue(dto.TemplateName, out var tid) ? tid : 0,
|
|
SiteId = siteIdByIdentifier.TryGetValue(dto.SiteIdentifier, out var isid) ? isid : 0,
|
|
AreaId = null,
|
|
};
|
|
foreach (var o in dto.AttributeOverrides)
|
|
{
|
|
inst.AttributeOverrides.Add(new InstanceAttributeOverride(o.AttributeName)
|
|
{
|
|
InstanceId = inst.Id,
|
|
OverrideValue = o.OverrideValue,
|
|
ElementDataType = o.ElementDataType,
|
|
});
|
|
}
|
|
foreach (var o in dto.AlarmOverrides)
|
|
{
|
|
inst.AlarmOverrides.Add(new InstanceAlarmOverride(o.AlarmCanonicalName)
|
|
{
|
|
InstanceId = inst.Id,
|
|
TriggerConfigurationOverride = o.TriggerConfigurationOverride,
|
|
PriorityLevelOverride = o.PriorityLevelOverride,
|
|
});
|
|
}
|
|
foreach (var o in dto.NativeAlarmSourceOverrides)
|
|
{
|
|
inst.NativeAlarmSourceOverrides.Add(new InstanceNativeAlarmSourceOverride(o.SourceCanonicalName)
|
|
{
|
|
InstanceId = inst.Id,
|
|
ConnectionNameOverride = o.ConnectionNameOverride,
|
|
SourceReferenceOverride = o.SourceReferenceOverride,
|
|
ConditionFilterOverride = o.ConditionFilterOverride,
|
|
});
|
|
}
|
|
foreach (var b in dto.ConnectionBindings)
|
|
{
|
|
inst.ConnectionBindings.Add(new InstanceConnectionBinding(b.AttributeName)
|
|
{
|
|
InstanceId = inst.Id,
|
|
// FK left at 0 on purpose — the importer resolves it from the
|
|
// DTO's ConnectionName against target-DB DataConnection ids.
|
|
DataConnectionId = 0,
|
|
DataSourceReferenceOverride = b.DataSourceReferenceOverride,
|
|
});
|
|
}
|
|
return inst;
|
|
})
|
|
.ToList();
|
|
|
|
return new EntityAggregate(
|
|
TemplateFolders: folders,
|
|
Templates: templates,
|
|
SharedScripts: sharedScripts,
|
|
ExternalSystems: externalSystems,
|
|
ExternalSystemMethods: externalSystemMethods,
|
|
DatabaseConnections: databaseConnections,
|
|
NotificationLists: notificationLists,
|
|
SmtpConfigurations: smtpConfigurations,
|
|
ApiMethods: apiMethods)
|
|
{
|
|
Sites = sites,
|
|
DataConnections = dataConnections,
|
|
Instances = instances,
|
|
SmsConfigurations = smsConfigurations,
|
|
};
|
|
}
|
|
}
|