feat(sms): complete SmsConfig bundle export/import wiring + GetSmsConfigurationByIdAsync (S10b)

This commit is contained in:
Joseph Doherty
2026-06-19 11:10:39 -04:00
parent 78fadb82d2
commit c3501ecd72
19 changed files with 586 additions and 6 deletions
@@ -61,6 +61,9 @@ public static class BundleCommands
var dbConnectionsOption = NameListOption("--db-connections", "Comma-separated database-connection names");
var notificationListsOption = NameListOption("--notification-lists", "Comma-separated notification-list names");
var smtpConfigsOption = NameListOption("--smtp-configs", "Comma-separated SMTP host names");
// SMS (S10b): SmsConfiguration is keyed by AccountSid (no Name column), so
// tokens are AccountSid values — mirrors --smtp-configs using Host.
var smsConfigsOption = NameListOption("--sms-configs", "Comma-separated SMS provider account SIDs");
// Inbound API keys are not transported between environments (re-arch C4) — no
// --api-keys option. Re-create keys and re-grant their method scopes on the
// destination via the admin UI/CLI.
@@ -91,6 +94,7 @@ public static class BundleCommands
cmd.Add(dbConnectionsOption);
cmd.Add(notificationListsOption);
cmd.Add(smtpConfigsOption);
cmd.Add(smsConfigsOption);
cmd.Add(apiMethodsOption);
cmd.Add(sitesOption);
cmd.Add(instancesOption);
@@ -118,7 +122,8 @@ public static class BundleCommands
Passphrase: passphrase,
SourceEnvironment: sourceEnv,
SiteNames: result.GetValue(sitesOption),
InstanceNames: result.GetValue(instancesOption));
InstanceNames: result.GetValue(instancesOption),
SmsConfigurationNames: result.GetValue(smsConfigsOption));
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
+1
View File
@@ -1744,6 +1744,7 @@ scadabridge --url <url> bundle export --output <path> [--passphrase <string>] [-
| `--db-connections` | no | Comma-separated database-connection names to include |
| `--notification-lists` | no | Comma-separated notification-list names to include |
| `--smtp-configs` | no | Comma-separated SMTP host names to include |
| `--sms-configs` | no | Comma-separated SMS provider account SIDs to include |
| `--api-methods` | no | Comma-separated API-method names to include |
| `--sites` | no | Comma-separated site identifiers (preferred) or friendly names to include |
| `--instances` | no | Comma-separated instance unique-names to include (also pulls their site + bound data connections) |
@@ -107,6 +107,12 @@ public interface INotificationRepository
/// <returns>The SMS configuration, or null if not found.</returns>
Task<SmsConfiguration?> GetSmsConfigurationAsync(CancellationToken cancellationToken = default);
/// <summary>Gets an SMS configuration by ID.</summary>
/// <param name="id">The SMS configuration ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The SMS configuration, or null if not found.</returns>
Task<SmsConfiguration?> GetSmsConfigurationByIdAsync(int id, CancellationToken cancellationToken = default);
/// <summary>Gets all SMS configurations.</summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A read-only list of SMS configurations.</returns>
@@ -30,7 +30,12 @@ public sealed record ExportBundleCommand(
// Defaulted null so every existing positional caller keeps compiling; the
// handler normalizes null to "select nothing" (or everything under All=true).
IReadOnlyList<string>? SiteNames = null,
IReadOnlyList<string>? InstanceNames = null);
IReadOnlyList<string>? InstanceNames = null,
// Additive (S10b): SMS provider config selection, mirroring
// SmtpConfigurationNames. SmsConfiguration is keyed by AccountSid (no Name
// column), so tokens are AccountSid values. Defaulted null so every existing
// positional caller keeps compiling.
IReadOnlyList<string>? SmsConfigurationNames = null);
/// <summary>
/// Bundle body returned as base64-encoded ZIP. <see cref="ByteCount"/> is the
@@ -15,4 +15,8 @@ public sealed record BundleSummary(
// written by older exporters (which omit these JSON properties) deserialize fine.
int Sites = 0,
int DataConnections = 0,
int Instances = 0);
int Instances = 0,
// Additive (S10b): SMS provider config count, mirroring SmtpConfigs. Defaulted to
// 0 so manifests written by older exporters (which omit this JSON property)
// deserialize fine.
int SmsConfigs = 0);
@@ -17,11 +17,18 @@ public sealed record ExportSelection(
// Additive (M8 A1): site/instance-scoped export. Defaulted to empty so every
// existing positional caller keeps compiling; older callers select no sites/instances.
IReadOnlyList<int>? SiteIds = null,
IReadOnlyList<int>? InstanceIds = null)
IReadOnlyList<int>? InstanceIds = null,
// Additive (S10b): SMS provider config export, mirroring SmtpConfigurationIds.
// Defaulted null (trailing) so every existing positional caller keeps compiling;
// older callers select no SMS configs.
IReadOnlyList<int>? SmsConfigurationIds = null)
{
/// <summary>Sites selected for site/instance-scoped export (M8). Never null.</summary>
public IReadOnlyList<int> SiteIds { get; init; } = SiteIds ?? Array.Empty<int>();
/// <summary>Instances selected for site/instance-scoped export (M8). Never null.</summary>
public IReadOnlyList<int> InstanceIds { get; init; } = InstanceIds ?? Array.Empty<int>();
/// <summary>SMS provider configurations selected for export (S10b). Never null.</summary>
public IReadOnlyList<int> SmsConfigurationIds { get; init; } = SmsConfigurationIds ?? Array.Empty<int>();
}
@@ -92,6 +92,10 @@ public class NotificationRepository : INotificationRepository
public async Task<SmsConfiguration?> GetSmsConfigurationAsync(CancellationToken cancellationToken = default)
=> await _context.Set<SmsConfiguration>().FirstOrDefaultAsync(cancellationToken);
/// <inheritdoc />
public async Task<SmsConfiguration?> GetSmsConfigurationByIdAsync(int id, CancellationToken cancellationToken = default)
=> await _context.Set<SmsConfiguration>().FindAsync(new object[] { id }, cancellationToken);
/// <inheritdoc />
public async Task<IReadOnlyList<SmsConfiguration>> GetAllSmsConfigurationsAsync(CancellationToken cancellationToken = default)
=> await _context.Set<SmsConfiguration>().ToListAsync(cancellationToken);
@@ -2721,6 +2721,8 @@ public class ManagementActor : ReceiveActor
var dbConnections = await externalRepo.GetAllDatabaseConnectionsAsync();
var notificationLists = await notifRepo.GetAllNotificationListsAsync();
var smtpConfigs = await notifRepo.GetAllSmtpConfigurationsAsync();
// SMS (S10b): central-only SMS provider configs, mirroring smtpConfigs.
var smsConfigs = await notifRepo.GetAllSmsConfigurationsAsync();
// Inbound API keys are not transported between environments (re-arch C4); only methods.
var apiMethods = await inboundRepo.GetAllApiMethodsAsync();
// M8 (B4): site/instance-scoped selection. Sites match on SiteIdentifier
@@ -2787,7 +2789,10 @@ public class ManagementActor : ReceiveActor
// M8 (B4): site/instance-scoped selection. Under All=true include every
// site + instance, mirroring how All includes every template/etc.
SiteIds: ResolveSiteIds(),
InstanceIds: ResolveIds(instances, cmd.InstanceNames, i => i.UniqueName, i => i.Id, "instance"));
InstanceIds: ResolveIds(instances, cmd.InstanceNames, i => i.UniqueName, i => i.Id, "instance"),
// SMS (S10b): SmsConfiguration is keyed by AccountSid (no Name column);
// the bundle preview row shows AccountSid, so the CLI uses AccountSid too.
SmsConfigurationIds: ResolveIds(smsConfigs, cmd.SmsConfigurationNames, s => s.AccountSid, s => s.Id, "SMS configuration"));
var exporter = sp.GetRequiredService<IBundleExporter>();
await using var stream = await exporter.ExportAsync(
@@ -199,6 +199,10 @@ public class SiteNotificationRepository : INotificationRepository
public Task<SmsConfiguration?> GetSmsConfigurationAsync(CancellationToken cancellationToken = default)
=> throw new NotSupportedException("Managed via artifact deployment from Central");
/// <inheritdoc />
public Task<SmsConfiguration?> GetSmsConfigurationByIdAsync(int id, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("Managed via artifact deployment from Central");
/// <inheritdoc />
public Task<IReadOnlyList<SmsConfiguration>> GetAllSmsConfigurationsAsync(CancellationToken cancellationToken = default)
=> throw new NotSupportedException("Managed via artifact deployment from Central");
@@ -92,6 +92,11 @@ public sealed class BundleExporter : IBundleExporter
Sites = resolved.Sites,
DataConnections = resolved.DataConnections,
Instances = resolved.Instances,
// SMS (S10b): wire the resolver's SMS config closure through the
// aggregate. Without this the aggregate carries the empty default and the
// bundle ships an empty smsConfigs array even when SMS configs were
// selected — mirrors the M8 site/instance fix (review item I3).
SmsConfigurations = resolved.SmsConfigs,
};
var contentDto = _entitySerializer.ToBundleContent(aggregate);
@@ -112,7 +117,11 @@ public sealed class BundleExporter : IBundleExporter
// are 0 today and become correct once those waves land.
Sites: contentDto.Sites.Count,
DataConnections: contentDto.DataConnections.Count,
Instances: contentDto.Instances.Count);
Instances: contentDto.Instances.Count,
// SMS (S10b): additive count sourced from the serialized content DTO — the
// same array the importer reads — so the manifest summary always matches
// the packed payload.
SmsConfigs: contentDto.SmsConfigs.Count);
// 4. Build a TEMPLATE manifest. BundleSerializer.Pack re-stamps both
// ContentHash and EncryptionMetadata against the bytes it actually
@@ -111,6 +111,15 @@ public sealed class DependencyResolver
if (sm is not null) smtpConfigs[sm.Id] = sm;
}
// SMS provider configs (S10b): mirror the SMTP seed exactly — resolve each
// selected id via the by-id repository accessor; missing ids are skipped.
var smsConfigs = new Dictionary<int, SmsConfiguration>();
foreach (var id in selection.SmsConfigurationIds.Distinct())
{
var sms = await _notifications.GetSmsConfigurationByIdAsync(id, ct).ConfigureAwait(false);
if (sms is not null) smsConfigs[sms.Id] = sms;
}
// Inbound API keys are intentionally NOT resolved into the bundle: per the
// inbound-API-key re-architecture (C4) keys are not transported between
// environments. Only API methods travel.
@@ -234,6 +243,7 @@ public sealed class DependencyResolver
dbConnections.Values,
notificationLists.Values,
smtpConfigs.Values,
smsConfigs.Values,
apiMethods.Values,
orderedSites,
orderedDataConnections,
@@ -256,6 +266,9 @@ public sealed class DependencyResolver
Sites = orderedSites,
DataConnections = orderedDataConnections,
Instances = orderedInstances,
// SMS (S10b): ordered by AccountSid — the natural key the importer matches
// on, mirroring how SMTP is ordered by Host.
SmsConfigs = smsConfigs.Values.OrderBy(s => s.AccountSid, StringComparer.Ordinal).ToList(),
};
}
@@ -549,6 +562,7 @@ public sealed class DependencyResolver
IEnumerable<DatabaseConnectionDefinition> dbConnections,
IEnumerable<NotificationList> notificationLists,
IEnumerable<SmtpConfiguration> smtpConfigs,
IEnumerable<SmsConfiguration> smsConfigs,
IEnumerable<ApiMethod> apiMethods,
IReadOnlyList<Site> sites,
IReadOnlyList<DataConnection> dataConnections,
@@ -604,6 +618,12 @@ public sealed class DependencyResolver
{
entries.Add(new ManifestContentEntry("SmtpConfiguration", s.Host, 1, Array.Empty<string>()));
}
// SMS (S10b): mirror SMTP — one manifest row per config, keyed by AccountSid
// (the natural key the importer matches on).
foreach (var s in smsConfigs.OrderBy(x => x.AccountSid, StringComparer.Ordinal))
{
entries.Add(new ManifestContentEntry("SmsConfiguration", s.AccountSid, 1, Array.Empty<string>()));
}
// Inbound API keys are not transported (re-arch C4) — no ApiKey manifest entries.
foreach (var m in apiMethods.OrderBy(x => x.Name, StringComparer.Ordinal))
{
@@ -56,4 +56,12 @@ public sealed record ResolvedExport(
/// properties.
/// </summary>
public IReadOnlyList<Instance> Instances { get; init; } = Array.Empty<Instance>();
/// <summary>
/// SMS provider configurations in the closure, ordered by
/// <see cref="SmsConfiguration.AccountSid"/> (S10b). Mirrors
/// <see cref="SmtpConfigs"/>; init-only with an empty default so callers that
/// only resolve other selections keep compiling.
/// </summary>
public IReadOnlyList<SmsConfiguration> SmsConfigs { get; init; } = Array.Empty<SmsConfiguration>();
}
@@ -252,6 +252,40 @@ public sealed class ArtifactDiff
return BuildItem("SmtpConfiguration", incoming.Host, changes);
}
/// <summary>
/// Compares an incoming SMS provider configuration against the existing one in
/// the database (S10b). Mirrors <see cref="CompareSmtpConfiguration"/>: keyed by
/// the natural key <c>AccountSid</c>, with the auth token diffed presence-only
/// (it lives in <see cref="SecretsBlock"/>, never compared by value).
/// </summary>
/// <param name="incoming">The incoming SMS configuration from the bundle.</param>
/// <param name="existing">The existing SMS configuration in the database, or null if new.</param>
/// <returns>An import preview item describing the conflict type and differences.</returns>
public ImportPreviewItem CompareSmsConfiguration(SmsConfigDto incoming, SmsConfiguration? existing)
{
ArgumentNullException.ThrowIfNull(incoming);
if (existing is null) return New("SmsConfiguration", incoming.AccountSid);
var changes = new List<FieldChange>();
AddIfDifferent(changes, "FromNumber", existing.FromNumber, incoming.FromNumber);
AddIfDifferent(changes, "MessagingServiceSid", existing.MessagingServiceSid, incoming.MessagingServiceSid);
AddIfDifferent(changes, "ApiBaseUrl", existing.ApiBaseUrl, incoming.ApiBaseUrl);
AddIfDifferent(changes, "ConnectionTimeoutSeconds", existing.ConnectionTimeoutSeconds, incoming.ConnectionTimeoutSeconds);
AddIfDifferent(changes, "MaxRetries", existing.MaxRetries, incoming.MaxRetries);
AddIfDifferent(changes, "RetryDelay", existing.RetryDelay.ToString(), incoming.RetryDelay.ToString());
var existingHasSecret = !string.IsNullOrEmpty(existing.AuthToken);
var incomingHasSecret = incoming.Secrets is not null && incoming.Secrets.Values.ContainsKey("AuthToken");
if (existingHasSecret != incomingHasSecret)
{
changes.Add(new FieldChange("Secrets.AuthToken",
existingHasSecret ? "<present>" : null,
incomingHasSecret ? "<present>" : null));
}
return BuildItem("SmsConfiguration", incoming.AccountSid, changes);
}
// CompareApiKey was removed in re-arch C4: inbound API keys are not transported
// between environments, so the import preview never diffs keys.
@@ -429,6 +429,15 @@ public sealed class BundleImporter : IBundleImporter
items.Add(_diff.CompareSmtpConfiguration(sm, existing));
}
// ---- SmsConfigurations (S10b; no by-AccountSid lookup — scan GetAll) ----
var allSms = await _notificationRepo.GetAllSmsConfigurationsAsync(ct).ConfigureAwait(false);
var smsBySid = allSms.ToDictionary(s => s.AccountSid, s => s, StringComparer.Ordinal);
foreach (var sms in content.SmsConfigs)
{
smsBySid.TryGetValue(sms.AccountSid, out var existing);
items.Add(_diff.CompareSmsConfiguration(sms, existing));
}
// ---- ApiKeys ----
// Inbound API keys are not transported between environments (re-arch C4).
// New bundles never carry a keys section. A pre-C4 bundle may still contain
@@ -1010,6 +1019,7 @@ public sealed class BundleImporter : IBundleImporter
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 ApplySmsConfigsAsync(content.SmsConfigs, resolutionMap, user, summary, ct).ConfigureAwait(false);
// Inbound API keys are NOT applied from a bundle (re-arch C4) — any keys
// in a legacy bundle were counted above (apiKeysIgnored) and are skipped.
await ApplyApiMethodsAsync(content.ApiMethods, resolutionMap, user, summary, ct).ConfigureAwait(false);
@@ -2534,6 +2544,80 @@ public sealed class BundleImporter : IBundleImporter
target.Credentials = dto.Secrets?.Values.TryGetValue("Credentials", out var cred) == true ? cred : null;
}
// SMS (S10b): mirrors ApplySmtpConfigsAsync exactly. SmsConfiguration is keyed by
// AccountSid (the diff engine's natural key — analogous to SMTP's Host), so a
// Rename targets AccountSid and Overwrite matches an existing config by AccountSid.
// The provider auth token is decrypted out of the SecretsBlock via the same path
// SMTP uses for its Credentials secret.
private async Task ApplySmsConfigsAsync(
IReadOnlyList<SmsConfigDto> dtos,
Dictionary<(string, string), ImportResolution> map,
string user,
ImportSummary summary,
CancellationToken ct)
{
if (dtos.Count == 0) return;
var all = await _notificationRepo.GetAllSmsConfigurationsAsync(ct).ConfigureAwait(false);
var bySid = all.ToDictionary(s => s.AccountSid, s => s, StringComparer.Ordinal);
foreach (var dto in dtos)
{
var resolution = ResolveOrDefault(map, "SmsConfiguration", dto.AccountSid);
switch (resolution.Action)
{
case ResolutionAction.Skip:
summary.Skipped++;
break;
case ResolutionAction.Rename:
{
var sid = resolution.RenameTo ?? dto.AccountSid;
var sms = BuildSms(dto, overrideAccountSid: sid);
await _notificationRepo.AddSmsConfigurationAsync(sms, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Create", "SmsConfiguration", "0", sid,
new { sms.AccountSid, RenamedFrom = dto.AccountSid }, ct).ConfigureAwait(false);
summary.Renamed++;
break;
}
case ResolutionAction.Overwrite when bySid.TryGetValue(dto.AccountSid, out var ex):
ApplySmsFields(ex, dto);
await _notificationRepo.UpdateSmsConfigurationAsync(ex, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Update", "SmsConfiguration", ex.Id.ToString(), ex.AccountSid,
new { ex.AccountSid }, ct).ConfigureAwait(false);
summary.Overwritten++;
break;
case ResolutionAction.Add:
case ResolutionAction.Overwrite:
default:
{
var sms = BuildSms(dto, overrideAccountSid: null);
await _notificationRepo.AddSmsConfigurationAsync(sms, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Create", "SmsConfiguration", "0", sms.AccountSid,
new { sms.AccountSid }, ct).ConfigureAwait(false);
summary.Added++;
break;
}
}
}
}
private static SmsConfiguration BuildSms(SmsConfigDto dto, string? overrideAccountSid)
{
var sms = new SmsConfiguration(overrideAccountSid ?? dto.AccountSid, dto.FromNumber);
ApplySmsFields(sms, dto);
return sms;
}
private static void ApplySmsFields(SmsConfiguration target, SmsConfigDto dto)
{
target.FromNumber = dto.FromNumber;
target.MessagingServiceSid = dto.MessagingServiceSid;
target.ApiBaseUrl = dto.ApiBaseUrl;
target.ConnectionTimeoutSeconds = dto.ConnectionTimeoutSeconds;
target.MaxRetries = dto.MaxRetries;
target.RetryDelay = dto.RetryDelay;
target.AuthToken = dto.Secrets?.Values.TryGetValue("AuthToken", out var token) == true ? token : null;
}
// ApplyApiKeysAsync was removed in re-arch C4: inbound API keys are not
// transported between environments, so a bundle never re-creates keys. Any keys
// present in a legacy (pre-C4) bundle are counted and ignored in ApplyAsync.