fix(sms): code-review fixes — Admin-gate provider-config updates, guard secret-clear/data-loss, type-aware UI

Findings from the per-module code review of the SMS feature (code-reviews/):

- ManagementService (High): UpdateSmsConfig + UpdateSmtpConfig were Designer-gated
  while both /notifications/{sms,smtp} pages enforce RequireAdmin — a Designer
  blocked in the UI could still rotate a production credential via CLI. Moved both
  to the Administrator arm so the actor gate matches the UI.
- ManagementService (Medium): UpdateSmsConfig treated --auth-token "" as a value,
  silently clearing the stored Twilio token. Guard on IsNullOrWhiteSpace so empty ==
  omitted (SMTP Credentials keeps its null-only guard — empty is valid for no-auth).
- CentralUI (Medium): NotificationLists recipient badge rendered Name <Email>
  unconditionally, showing "Name <>" for SMS lists. Now type-aware (phone for SMS).
- ConfigurationDatabase (Medium): AddSmsNotifications.Down() backfilled NULL emails
  to '' — silent data loss for SMS-only recipients. Added a pre-drop guard that
  refuses rollback while such rows exist.
- NotificationOutbox (Low): SMS body truncation could split a surrogate pair at the
  cap boundary; back off one code unit to stay well-formed.
- Commons (Low): NotificationRecipient public ctor name-guard now matches the
  ForEmail factory (IsNullOrWhiteSpace). Documented SmsConfiguration.MaxRetries/
  RetryDelay as RESERVED (dispatcher reuses the shared SMTP-derived retry policy).
This commit is contained in:
Joseph Doherty
2026-06-19 15:02:02 -04:00
parent d6ead8ae62
commit cd8e4872f6
6 changed files with 77 additions and 11 deletions
@@ -364,7 +364,18 @@ public sealed class SmsNotificationDeliveryAdapter : INotificationDeliveryAdapte
return composed[..max];
}
return string.Concat(composed.AsSpan(0, max - ellipsis.Length), ellipsis);
// Cut at (max - ellipsis) UTF-16 code units, backing off by one if that boundary
// would split a surrogate pair (e.g. an emoji in the alarm subject/body). `cut`
// is the index of the first dropped char, so a split is signalled by a high
// surrogate immediately before it; dropping that lone surrogate keeps the body
// well-formed and still within the cap.
var cut = max - ellipsis.Length;
if (char.IsHighSurrogate(composed[cut - 1]))
{
cut--;
}
return string.Concat(composed.AsSpan(0, cut), ellipsis);
}
/// <summary>The classified outcome of a single per-recipient Twilio attempt.</summary>