feat(sms): CLI list --type/--phones + notification sms group + channel-aware recipients (S6)

This commit is contained in:
Joseph Doherty
2026-06-19 10:40:09 -04:00
parent cdfd0ffbd2
commit 73df322a66
7 changed files with 638 additions and 37 deletions
@@ -1,6 +1,7 @@
using System.CommandLine;
using System.CommandLine.Parsing;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
@@ -24,10 +25,43 @@ public static class NotificationCommands
command.Add(BuildUpdate(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildDelete(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildSmtp(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildSms(urlOption, formatOption, usernameOption, passwordOption));
return command;
}
// ------------------------------------------------------------------------
// Notification list create/update options (static so the parsed values can
// be read back both from the SetAction and from the testable Build* helpers).
// ------------------------------------------------------------------------
private static readonly Option<string> ListCreateNameOption =
new("--name") { Description = "Notification list name", Required = true };
private static readonly Option<string?> ListCreateEmailsOption =
new("--emails") { Description = "Comma-separated recipient emails (required for --type email; rejected for --type sms)" };
private static readonly Option<string?> ListCreatePhonesOption =
new("--phones") { Description = "Comma-separated recipient phone numbers in E.164 (required for --type sms; rejected for --type email)" };
private static readonly Option<string> ListCreateTypeOption = CreateListTypeOption();
private static readonly Option<int> ListUpdateIdOption =
new("--id") { Description = "Notification list ID", Required = true };
private static readonly Option<string> ListUpdateNameOption =
new("--name") { Description = "List name", Required = true };
private static readonly Option<string?> ListUpdateEmailsOption =
new("--emails") { Description = "Comma-separated recipient emails (required for --type email; rejected for --type sms)" };
private static readonly Option<string?> ListUpdatePhonesOption =
new("--phones") { Description = "Comma-separated recipient phone numbers in E.164 (required for --type sms; rejected for --type email)" };
private static readonly Option<string> ListUpdateTypeOption = CreateListTypeOption();
private static Option<string> CreateListTypeOption()
{
var option = new Option<string>("--type")
{
Description = "Delivery channel: email or sms (case-insensitive; default email)",
DefaultValueFactory = _ => "email",
};
return option;
}
private static Command BuildGet(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var idOption = new Option<int>("--id") { Description = "Notification list ID", Required = true };
@@ -44,27 +78,50 @@ public static class NotificationCommands
private static Command BuildUpdate(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var idOption = new Option<int>("--id") { Description = "Notification list ID", Required = true };
var nameOption = new Option<string>("--name") { Description = "List name", Required = true };
var emailsOption = new Option<string>("--emails") { Description = "Comma-separated recipient emails", Required = true };
var cmd = new Command("update") { Description = "Update a notification list" };
cmd.Add(idOption);
cmd.Add(nameOption);
cmd.Add(emailsOption);
cmd.Add(ListUpdateIdOption);
cmd.Add(ListUpdateNameOption);
cmd.Add(ListUpdateTypeOption);
cmd.Add(ListUpdateEmailsOption);
cmd.Add(ListUpdatePhonesOption);
cmd.SetAction(async (ParseResult result) =>
{
var id = result.GetValue(idOption);
var name = result.GetValue(nameOption)!;
var emailsRaw = result.GetValue(emailsOption)!;
var emails = emailsRaw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
UpdateNotificationListCommand command;
try
{
command = BuildUpdateNotificationListCommand(result);
}
catch (ArgumentException ex)
{
OutputFormatter.WriteError(ex.Message, "INVALID_ARGUMENT");
return 1;
}
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new UpdateNotificationListCommand(id, name, emails));
result, urlOption, formatOption, usernameOption, passwordOption, command);
});
return cmd;
}
/// <summary>
/// Builds the <see cref="UpdateNotificationListCommand"/> from a parsed
/// <c>notification list update</c> invocation, applying the channel-aware
/// recipient validation. Throws <see cref="ArgumentException"/> when the
/// channel and the supplied recipient flags are inconsistent.
/// </summary>
/// <param name="result">The parsed command-line result.</param>
/// <returns>A validated <see cref="UpdateNotificationListCommand"/>.</returns>
internal static UpdateNotificationListCommand BuildUpdateNotificationListCommand(ParseResult result)
{
var id = result.GetValue(ListUpdateIdOption);
var name = result.GetValue(ListUpdateNameOption)!;
var (type, emails, phones) = ResolveListChannel(
result.GetValue(ListUpdateTypeOption)!,
result.GetValue(ListUpdateEmailsOption),
result.GetValue(ListUpdatePhonesOption));
return new UpdateNotificationListCommand(id, name, emails, type, phones);
}
private static Command BuildSmtp(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var group = new Command("smtp") { Description = "Manage SMTP configuration" };
@@ -145,6 +202,80 @@ public static class NotificationCommands
return new UpdateSmtpConfigCommand(id, server, port, authMode, from, tlsMode, credentials);
}
private static Command BuildSms(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var group = new Command("sms") { Description = "Manage SMS (Twilio) configuration" };
var listCmd = new Command("list") { Description = "List SMS configurations (Auth Token shown as a presence flag only)" };
listCmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new ListSmsConfigsCommand());
});
group.Add(listCmd);
var updateCmd = new Command("update") { Description = "Update SMS configuration" };
updateCmd.Add(SmsIdOption);
updateCmd.Add(SmsAccountSidOption);
updateCmd.Add(SmsFromNumberOption);
updateCmd.Add(SmsMessagingServiceSidOption);
updateCmd.Add(SmsApiBaseUrlOption);
updateCmd.Add(SmsAuthTokenOption);
updateCmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
BuildUpdateSmsConfigCommand(result));
});
group.Add(updateCmd);
return group;
}
// SMS update options are static so the parsed values can be read back both
// from the SetAction and from BuildUpdateSmsConfigCommand (used by tests).
private static readonly Option<int> SmsIdOption =
new("--id") { Description = "SMS config ID", Required = true };
private static readonly Option<string> SmsAccountSidOption =
new("--account-sid") { Description = "Twilio Account SID", Required = true };
private static readonly Option<string> SmsFromNumberOption =
new("--from-number") { Description = "Sender phone number (E.164)", Required = true };
private static readonly Option<string?> SmsMessagingServiceSidOption =
new("--messaging-service-sid")
{
Description = "Twilio Messaging Service SID (optional; OMITTING IT CLEARS the stored value)",
};
private static readonly Option<string?> SmsApiBaseUrlOption =
new("--api-base-url")
{
Description = "API base URL override (optional; preserves existing if omitted)",
};
private static readonly Option<string?> SmsAuthTokenOption =
new("--auth-token")
{
Description = "Twilio Auth Token (optional; PRESERVES the stored token if omitted; never printed back)",
};
/// <summary>
/// Builds the <see cref="UpdateSmsConfigCommand"/> from a parsed <c>sms update</c>
/// invocation. Note the asymmetric preserve-vs-clear semantics enforced server-side:
/// omitting <c>--auth-token</c> / <c>--api-base-url</c> maps to null so the handler
/// PRESERVES the existing values, whereas omitting <c>--messaging-service-sid</c> also
/// maps to null but the handler OVERWRITES (clears) the stored value.
/// </summary>
/// <param name="result">The parsed command-line result from the <c>sms update</c> invocation.</param>
/// <returns>An <see cref="UpdateSmsConfigCommand"/> populated from the parsed result.</returns>
internal static UpdateSmsConfigCommand BuildUpdateSmsConfigCommand(ParseResult result)
{
var id = result.GetValue(SmsIdOption);
var accountSid = result.GetValue(SmsAccountSidOption)!;
var fromNumber = result.GetValue(SmsFromNumberOption)!;
var messagingServiceSid = result.GetValue(SmsMessagingServiceSidOption);
var apiBaseUrl = result.GetValue(SmsApiBaseUrlOption);
var authToken = result.GetValue(SmsAuthTokenOption);
return new UpdateSmsConfigCommand(id, accountSid, fromNumber, messagingServiceSid, apiBaseUrl, authToken);
}
private static Command BuildList(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var cmd = new Command("list") { Description = "List all notification lists" };
@@ -158,24 +289,103 @@ public static class NotificationCommands
private static Command BuildCreate(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var nameOption = new Option<string>("--name") { Description = "Notification list name", Required = true };
var emailsOption = new Option<string>("--emails") { Description = "Comma-separated recipient emails", Required = true };
var cmd = new Command("create") { Description = "Create a notification list" };
cmd.Add(nameOption);
cmd.Add(emailsOption);
cmd.Add(ListCreateNameOption);
cmd.Add(ListCreateTypeOption);
cmd.Add(ListCreateEmailsOption);
cmd.Add(ListCreatePhonesOption);
cmd.SetAction(async (ParseResult result) =>
{
var name = result.GetValue(nameOption)!;
var emailsRaw = result.GetValue(emailsOption)!;
var emails = emailsRaw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
CreateNotificationListCommand command;
try
{
command = BuildCreateNotificationListCommand(result);
}
catch (ArgumentException ex)
{
OutputFormatter.WriteError(ex.Message, "INVALID_ARGUMENT");
return 1;
}
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new CreateNotificationListCommand(name, emails));
result, urlOption, formatOption, usernameOption, passwordOption, command);
});
return cmd;
}
/// <summary>
/// Builds the <see cref="CreateNotificationListCommand"/> from a parsed
/// <c>notification list create</c> invocation, applying the channel-aware
/// recipient validation. Throws <see cref="ArgumentException"/> when the
/// channel and the supplied recipient flags are inconsistent.
/// </summary>
/// <param name="result">The parsed command-line result.</param>
/// <returns>A validated <see cref="CreateNotificationListCommand"/>.</returns>
internal static CreateNotificationListCommand BuildCreateNotificationListCommand(ParseResult result)
{
var name = result.GetValue(ListCreateNameOption)!;
var (type, emails, phones) = ResolveListChannel(
result.GetValue(ListCreateTypeOption)!,
result.GetValue(ListCreateEmailsOption),
result.GetValue(ListCreatePhonesOption));
return new CreateNotificationListCommand(name, emails, type, phones);
}
/// <summary>
/// Parses the <c>--type</c> value (case-insensitive) and validates that the supplied
/// recipient flags match the channel: <c>email</c> requires <c>--emails</c> and rejects
/// <c>--phones</c>; <c>sms</c> requires <c>--phones</c> and rejects <c>--emails</c>.
/// </summary>
/// <param name="typeRaw">The raw <c>--type</c> value.</param>
/// <param name="emailsRaw">The raw <c>--emails</c> value, or null when omitted.</param>
/// <param name="phonesRaw">The raw <c>--phones</c> value, or null when omitted.</param>
/// <returns>The resolved channel and recipient lists (the off-channel list is empty).</returns>
/// <exception cref="ArgumentException">Thrown when the type is unknown or the recipient flags are inconsistent with the channel.</exception>
private static (NotificationType Type, IReadOnlyList<string> Emails, IReadOnlyList<string>? Phones) ResolveListChannel(
string typeRaw, string? emailsRaw, string? phonesRaw)
{
if (!Enum.TryParse<NotificationType>(typeRaw, ignoreCase: true, out var type))
{
throw new ArgumentException($"Invalid --type '{typeRaw}'. Expected 'email' or 'sms'.");
}
var emails = SplitRecipients(emailsRaw);
var phones = SplitRecipients(phonesRaw);
if (type == NotificationType.Email)
{
if (phones.Count > 0)
{
throw new ArgumentException("--phones is not valid for --type email; use --emails.");
}
if (emails.Count == 0)
{
throw new ArgumentException("--emails is required for --type email.");
}
return (type, emails, null);
}
// Sms
if (emails.Count > 0)
{
throw new ArgumentException("--emails is not valid for --type sms; use --phones.");
}
if (phones.Count == 0)
{
throw new ArgumentException("--phones is required for --type sms.");
}
return (type, Array.Empty<string>(), phones);
}
private static IReadOnlyList<string> SplitRecipients(string? raw) =>
string.IsNullOrWhiteSpace(raw)
? Array.Empty<string>()
: raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
private static Command BuildDelete(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var idOption = new Option<int>("--id") { Description = "Notification list ID", Required = true };
+46 -4
View File
@@ -985,31 +985,47 @@ scadabridge --url <url> notification list
#### `notification create`
Create a notification list with one or more recipients.
Create a notification list with one or more recipients. The `--type` flag selects the
delivery channel and decides which recipient flag is required: `email` (the default)
uses `--emails`; `sms` uses `--phones`. Supplying the wrong recipient flag for the
channel is rejected before the command is sent.
```sh
# Email list (default channel)
scadabridge --url <url> notification create --name <string> --emails <email1,email2,...>
# SMS list
scadabridge --url <url> notification create --name <string> --type sms --phones <e164a,e164b,...>
```
| Option | Required | Description |
|--------|----------|-------------|
| `--name` | yes | Notification list name |
| `--emails` | yes | Comma-separated list of recipient email addresses |
| `--type` | no | Delivery channel: `email` or `sms` (case-insensitive; default `email`) |
| `--emails` | conditional | Comma-separated recipient email addresses. **Required for `--type email`; rejected for `--type sms`.** |
| `--phones` | conditional | Comma-separated recipient phone numbers in E.164. **Required for `--type sms`; rejected for `--type email`.** |
#### `notification update`
Update a notification list. An update **replaces** the whole entity — every required
field below must be supplied, even if unchanged.
field below must be supplied, even if unchanged. As with `create`, `--type` selects the
channel and decides whether `--emails` or `--phones` is required.
```sh
# Email list
scadabridge --url <url> notification update --id <int> --name <string> --emails <email1,email2,...>
# SMS list
scadabridge --url <url> notification update --id <int> --name <string> --type sms --phones <e164a,e164b,...>
```
| Option | Required | Description |
|--------|----------|-------------|
| `--id` | yes | Notification list ID |
| `--name` | yes | List name |
| `--emails` | yes | Comma-separated list of recipient email addresses |
| `--type` | no | Delivery channel: `email` or `sms` (case-insensitive; default `email`) |
| `--emails` | conditional | Comma-separated recipient email addresses. **Required for `--type email`; rejected for `--type sms`.** |
| `--phones` | conditional | Comma-separated recipient phone numbers in E.164. **Required for `--type sms`; rejected for `--type email`.** |
#### `notification delete`
@@ -1049,6 +1065,32 @@ scadabridge --url <url> notification smtp update --id <int> --server <string> --
| `--tls-mode` | no | TLS mode: `None`, `StartTLS`, or `SSL` (preserves existing if omitted) |
| `--credentials` | no | SMTP credentials — `username:password` for Basic, or client secret for OAuth2 (preserves existing if omitted) |
#### `notification sms list`
Show the current SMS (Twilio) configuration. The Twilio **Auth Token is never returned**
— the listing reports it only as a `hasAuthToken` presence flag.
```sh
scadabridge --url <url> notification sms list
```
#### `notification sms update`
Update the SMS (Twilio) configuration.
```sh
scadabridge --url <url> notification sms update --id <int> --account-sid <string> --from-number <string> [--messaging-service-sid <string>] [--api-base-url <string>] [--auth-token <string>]
```
| Option | Required | Description |
|--------|----------|-------------|
| `--id` | yes | SMS config ID |
| `--account-sid` | yes | Twilio Account SID |
| `--from-number` | yes | Sender phone number (E.164) |
| `--messaging-service-sid` | no | Twilio Messaging Service SID. **Omitting it CLEARS the stored value** (the update overwrites it). |
| `--api-base-url` | no | API base URL override (preserves existing if omitted) |
| `--auth-token` | no | Twilio Auth Token. **Omitting it PRESERVES the stored token.** Never printed back. |
---
### `security` — Security settings
@@ -4,8 +4,8 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
public record ListNotificationListsCommand;
public record GetNotificationListCommand(int NotificationListId);
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 CreateNotificationListCommand(string Name, IReadOnlyList<string> RecipientEmails, NotificationType Type = NotificationType.Email, IReadOnlyList<string>? RecipientPhones = null);
public record UpdateNotificationListCommand(int NotificationListId, string Name, IReadOnlyList<string> RecipientEmails, NotificationType Type = NotificationType.Email, IReadOnlyList<string>? RecipientPhones = null);
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);
@@ -1686,9 +1686,9 @@ public class ManagementActor : ReceiveActor
{
var repo = sp.GetRequiredService<INotificationRepository>();
var list = new NotificationList(cmd.Name) { Type = cmd.Type };
foreach (var email in cmd.RecipientEmails)
foreach (var recipient in BuildRecipients(cmd.Type, cmd.RecipientEmails, cmd.RecipientPhones))
{
list.Recipients.Add(new NotificationRecipient(email, email));
list.Recipients.Add(recipient);
}
await repo.AddNotificationListAsync(list);
await repo.SaveChangesAsync();
@@ -1710,12 +1710,10 @@ public class ManagementActor : ReceiveActor
await repo.DeleteRecipientAsync(r.Id);
}
foreach (var email in cmd.RecipientEmails)
foreach (var recipient in BuildRecipients(cmd.Type, cmd.RecipientEmails, cmd.RecipientPhones))
{
await repo.AddRecipientAsync(new NotificationRecipient(email, email)
{
NotificationListId = cmd.NotificationListId
});
recipient.NotificationListId = cmd.NotificationListId;
await repo.AddRecipientAsync(recipient);
}
await repo.UpdateNotificationListAsync(list);
@@ -1733,6 +1731,33 @@ public class ManagementActor : ReceiveActor
return true;
}
/// <summary>
/// SMS Notifications (S6): build the recipient set for a notification list according
/// to its delivery channel. Email lists map each <paramref name="recipientEmails"/>
/// entry to an <see cref="NotificationRecipient.ForEmail"/> recipient; SMS lists map
/// each <paramref name="recipientPhones"/> entry to an <see cref="NotificationRecipient.ForSms"/>
/// recipient. The off-channel source list is ignored so an Email list never stores a
/// phone in EmailAddress (and vice versa).
/// </summary>
private static IEnumerable<NotificationRecipient> BuildRecipients(
NotificationType type, IReadOnlyList<string> recipientEmails, IReadOnlyList<string>? recipientPhones)
{
if (type == NotificationType.Sms)
{
foreach (var phone in recipientPhones ?? Array.Empty<string>())
{
yield return NotificationRecipient.ForSms(phone, phone);
}
}
else
{
foreach (var email in recipientEmails)
{
yield return NotificationRecipient.ForEmail(email, email);
}
}
}
/// <summary>
/// MgmtSvc-020: project an SmtpConfiguration to a credential-free shape so the
/// stored Credentials (SMTP password / OAuth2 client secret) never leaves this