feat(sms): list Type + SMS-config management commands/handlers (S5)

This commit is contained in:
Joseph Doherty
2026-06-19 10:12:55 -04:00
parent bffbb0c2da
commit 609bdb37ef
3 changed files with 283 additions and 3 deletions
@@ -1,9 +1,13 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
public record ListNotificationListsCommand;
public record GetNotificationListCommand(int NotificationListId);
public record CreateNotificationListCommand(string Name, IReadOnlyList<string> RecipientEmails);
public record UpdateNotificationListCommand(int NotificationListId, string Name, IReadOnlyList<string> RecipientEmails);
public record CreateNotificationListCommand(string Name, IReadOnlyList<string> RecipientEmails, NotificationType Type = NotificationType.Email);
public record UpdateNotificationListCommand(int NotificationListId, string Name, IReadOnlyList<string> RecipientEmails, NotificationType Type = NotificationType.Email);
public record DeleteNotificationListCommand(int NotificationListId);
public record ListSmtpConfigsCommand;
public record UpdateSmtpConfigCommand(int SmtpConfigId, string Server, int Port, string AuthMode, string FromAddress, string? TlsMode = null, string? Credentials = null);
public record ListSmsConfigsCommand;
public record UpdateSmsConfigCommand(int SmsConfigId, string AccountSid, string FromNumber, string? MessagingServiceSid = null, string? ApiBaseUrl = null, string? AuthToken = null);
@@ -187,6 +187,7 @@ public class ManagementActor : ReceiveActor
or CreateNotificationListCommand or UpdateNotificationListCommand
or DeleteNotificationListCommand
or UpdateSmtpConfigCommand
or UpdateSmsConfigCommand
or CreateDataConnectionCommand or UpdateDataConnectionCommand
or DeleteDataConnectionCommand or MoveDataConnectionCommand
or AddTemplateAttributeCommand or UpdateTemplateAttributeCommand or DeleteTemplateAttributeCommand
@@ -331,6 +332,8 @@ public class ManagementActor : ReceiveActor
DeleteNotificationListCommand cmd => await HandleDeleteNotificationList(sp, cmd, user.Username),
ListSmtpConfigsCommand => await HandleListSmtpConfigs(sp),
UpdateSmtpConfigCommand cmd => await HandleUpdateSmtpConfig(sp, cmd, user.Username),
ListSmsConfigsCommand => await HandleListSmsConfigs(sp),
UpdateSmsConfigCommand cmd => await HandleUpdateSmsConfig(sp, cmd, user.Username),
// Shared Scripts
ListSharedScriptsCommand => await HandleListSharedScripts(sp),
@@ -1682,7 +1685,7 @@ public class ManagementActor : ReceiveActor
private static async Task<object?> HandleCreateNotificationList(IServiceProvider sp, CreateNotificationListCommand cmd, string user)
{
var repo = sp.GetRequiredService<INotificationRepository>();
var list = new NotificationList(cmd.Name);
var list = new NotificationList(cmd.Name) { Type = cmd.Type };
foreach (var email in cmd.RecipientEmails)
{
list.Recipients.Add(new NotificationRecipient(email, email));
@@ -1699,6 +1702,7 @@ public class ManagementActor : ReceiveActor
var list = await repo.GetNotificationListByIdAsync(cmd.NotificationListId)
?? throw new ManagementCommandException($"NotificationList with ID {cmd.NotificationListId} not found.");
list.Name = cmd.Name;
list.Type = cmd.Type;
var existingRecipients = await repo.GetRecipientsByListIdAsync(cmd.NotificationListId);
foreach (var r in existingRecipients)
@@ -1784,6 +1788,58 @@ public class ManagementActor : ReceiveActor
return publicShape;
}
/// <summary>
/// MgmtSvc-020 (SMS): project an SmsConfiguration to a credential-free shape so the
/// stored AuthToken (Twilio Auth Token secret) never leaves this boundary via
/// response payloads or audit afterState. Mirrors <see cref="SmtpConfigPublicShape"/>.
/// </summary>
private static object SmsConfigPublicShape(Commons.Entities.Notifications.SmsConfiguration c) =>
new
{
c.Id,
c.AccountSid,
c.FromNumber,
c.MessagingServiceSid,
c.ApiBaseUrl,
c.ConnectionTimeoutSeconds,
c.MaxRetries,
c.RetryDelay,
HasAuthToken = !string.IsNullOrEmpty(c.AuthToken),
};
private static async Task<object?> HandleListSmsConfigs(IServiceProvider sp)
{
var repo = sp.GetRequiredService<INotificationRepository>();
var configs = await repo.GetAllSmsConfigurationsAsync();
// MgmtSvc-020: project away the AuthToken field — read access to this
// list is broader than the Designer-gated UpdateSmsConfig path that owns
// the secret.
return configs.Select(SmsConfigPublicShape).ToList();
}
private static async Task<object?> HandleUpdateSmsConfig(IServiceProvider sp, UpdateSmsConfigCommand cmd, string user)
{
var repo = sp.GetRequiredService<INotificationRepository>();
var configs = await repo.GetAllSmsConfigurationsAsync();
var config = configs.FirstOrDefault(c => c.Id == cmd.SmsConfigId)
?? throw new ManagementCommandException($"SmsConfiguration with ID {cmd.SmsConfigId} not found.");
config.AccountSid = cmd.AccountSid;
config.FromNumber = cmd.FromNumber;
config.MessagingServiceSid = cmd.MessagingServiceSid;
// Preserve-if-null: an update that omits ApiBaseUrl/AuthToken leaves the
// existing values intact (non-breaking for callers that do not send them,
// and so the secret AuthToken survives a config edit that does not rotate it).
if (cmd.ApiBaseUrl is not null) config.ApiBaseUrl = cmd.ApiBaseUrl;
if (cmd.AuthToken is not null) config.AuthToken = cmd.AuthToken;
await repo.UpdateSmsConfigurationAsync(config);
await repo.SaveChangesAsync();
// MgmtSvc-020: audit the credential-free shape — the AuthToken secret is
// not persisted to the audit log where OperationalAuditRoles can read it.
var publicShape = SmsConfigPublicShape(config);
await AuditAsync(sp, user, "Update", "SmsConfiguration", config.Id.ToString(), config.AccountSid, publicShape);
return publicShape;
}
// ========================================================================
// Security handlers
// ========================================================================