feat(notifications): central SMS config + nullable recipient contact (S2)
Implement the central ConfigurationDatabase side of SMS notifications: - NotificationConfiguration: EmailAddress now nullable (SMS-only recipients carry a PhoneNumber, no email); add PhoneNumber nvarchar(32); add SmsConfigurationConfiguration (AuthToken sized as the encrypted column, mirroring SmtpConfiguration.Credentials; timeout/retry mapped REQUIRED for ctor-default round-trip fidelity). - ScadaBridgeDbContext: add SmsConfigurations DbSet, encrypt AuthToken at rest via EncryptedStringConverter, and cover SmsConfiguration in the schema-only secret-write guard. - NotificationRepository: implement the four INotificationRepository SMS-config methods (resolves the 4x CS0535), mirroring the SMTP methods' stage-only / separate-SaveChangesAsync discipline. - Migration AddSmsNotifications: idempotent (guarded) ALTER EmailAddress nullable, ADD PhoneNumber, CREATE SmsConfigurations; Down reverses cleanly (backfills NULL emails before restoring NOT NULL).
This commit is contained in:
+51
-1
@@ -42,9 +42,59 @@ public class NotificationRecipientConfiguration : IEntityTypeConfiguration<Notif
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
// EmailAddress is now nullable — an SMS-only recipient carries a PhoneNumber and no
|
||||
// email. The max length is unchanged from the original email-only mapping.
|
||||
builder.Property(r => r.EmailAddress)
|
||||
.IsRequired()
|
||||
.IsRequired(false)
|
||||
.HasMaxLength(500);
|
||||
|
||||
// PhoneNumber (E.164, e.g. +14155552671) — nullable, present only for SMS recipients.
|
||||
builder.Property(r => r.PhoneNumber)
|
||||
.IsRequired(false)
|
||||
.HasMaxLength(32);
|
||||
}
|
||||
}
|
||||
|
||||
public class SmsConfigurationConfiguration : IEntityTypeConfiguration<SmsConfiguration>
|
||||
{
|
||||
/// <summary>Configures the EF Core mapping for <see cref="SmsConfiguration"/>.</summary>
|
||||
/// <param name="builder">The entity type builder.</param>
|
||||
public void Configure(EntityTypeBuilder<SmsConfiguration> builder)
|
||||
{
|
||||
builder.HasKey(s => s.Id);
|
||||
|
||||
builder.Property(s => s.AccountSid)
|
||||
.IsRequired()
|
||||
.HasMaxLength(100);
|
||||
|
||||
// Stored encrypted at rest (EncryptedStringConverter, wired in
|
||||
// ScadaBridgeDbContext.ApplySecretColumnEncryption). Ciphertext is larger than the
|
||||
// plaintext, so the column is sized generously to avoid truncation — mirrors
|
||||
// SmtpConfiguration.Credentials.
|
||||
builder.Property(s => s.AuthToken)
|
||||
.HasMaxLength(8000);
|
||||
|
||||
builder.Property(s => s.FromNumber)
|
||||
.IsRequired()
|
||||
.HasMaxLength(32);
|
||||
|
||||
builder.Property(s => s.MessagingServiceSid)
|
||||
.HasMaxLength(100);
|
||||
|
||||
builder.Property(s => s.ApiBaseUrl)
|
||||
.HasMaxLength(500);
|
||||
|
||||
// The non-parameter constructor seeds ConnectionTimeoutSeconds/MaxRetries/RetryDelay
|
||||
// with sensible defaults; mapping them as REQUIRED columns preserves round-trip
|
||||
// fidelity for those values (S1 review note).
|
||||
builder.Property(s => s.ConnectionTimeoutSeconds)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(s => s.MaxRetries)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(s => s.RetryDelay)
|
||||
.IsRequired();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1953
File diff suppressed because it is too large
Load Diff
+68
@@ -0,0 +1,68 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
||||
{
|
||||
/// <summary>
|
||||
/// SMS Notifications (S2): makes a notification recipient's contact path optional and adds
|
||||
/// the central SMS-provider (Twilio) configuration table.
|
||||
///
|
||||
/// 1. <c>NotificationRecipients.EmailAddress</c> becomes nullable — an SMS-only recipient
|
||||
/// carries a <c>PhoneNumber</c> and no email. (ALTER COLUMN, naturally idempotent.)
|
||||
/// 2. <c>NotificationRecipients.PhoneNumber nvarchar(32) NULL</c> — E.164 phone for SMS
|
||||
/// recipients (guarded ADD).
|
||||
/// 3. <c>SmsConfigurations</c> table — central-only provider config; <c>AuthToken</c> is
|
||||
/// stored encrypted at rest (EncryptedStringConverter → <c>nvarchar(max)</c>, mirroring
|
||||
/// <c>SmtpConfigurations.Credentials</c>) (guarded CREATE TABLE).
|
||||
///
|
||||
/// Every DDL statement is guarded so the migration is safe to re-run against a
|
||||
/// partially-migrated database (the repo's idempotent-migration convention; see
|
||||
/// AddListAttributeElementType).
|
||||
/// </summary>
|
||||
public partial class AddSmsNotifications : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// ALTER COLUMN is naturally idempotent: re-running re-applies the same nullable shape.
|
||||
migrationBuilder.Sql("ALTER TABLE [NotificationRecipients] ALTER COLUMN [EmailAddress] nvarchar(500) NULL;");
|
||||
|
||||
migrationBuilder.Sql(@"
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE Name='PhoneNumber' AND Object_ID=Object_ID('NotificationRecipients'))
|
||||
ALTER TABLE [NotificationRecipients] ADD [PhoneNumber] nvarchar(32) NULL;");
|
||||
|
||||
migrationBuilder.Sql(@"
|
||||
IF OBJECT_ID(N'dbo.SmsConfigurations', N'U') IS NULL
|
||||
CREATE TABLE [SmsConfigurations] (
|
||||
[Id] int NOT NULL IDENTITY(1, 1),
|
||||
[AccountSid] nvarchar(100) NOT NULL,
|
||||
[AuthToken] nvarchar(max) NULL,
|
||||
[FromNumber] nvarchar(32) NOT NULL,
|
||||
[MessagingServiceSid] nvarchar(100) NULL,
|
||||
[ApiBaseUrl] nvarchar(500) NULL,
|
||||
[ConnectionTimeoutSeconds] int NOT NULL,
|
||||
[MaxRetries] int NOT NULL,
|
||||
[RetryDelay] time NOT NULL,
|
||||
CONSTRAINT [PK_SmsConfigurations] PRIMARY KEY ([Id])
|
||||
);");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(@"
|
||||
IF OBJECT_ID(N'dbo.SmsConfigurations', N'U') IS NOT NULL
|
||||
DROP TABLE [SmsConfigurations];");
|
||||
|
||||
migrationBuilder.Sql(@"
|
||||
IF EXISTS (SELECT 1 FROM sys.columns WHERE Name='PhoneNumber' AND Object_ID=Object_ID('NotificationRecipients'))
|
||||
ALTER TABLE [NotificationRecipients] DROP COLUMN [PhoneNumber];");
|
||||
|
||||
// Reversing to NOT NULL requires backfilling existing NULLs first, otherwise the
|
||||
// ALTER fails. Mirrors EF's generated default ('') for the previously-required column.
|
||||
migrationBuilder.Sql("UPDATE [NotificationRecipients] SET [EmailAddress] = '' WHERE [EmailAddress] IS NULL;");
|
||||
migrationBuilder.Sql("ALTER TABLE [NotificationRecipients] ALTER COLUMN [EmailAddress] nvarchar(500) NOT NULL;");
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
-1
@@ -824,7 +824,6 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("EmailAddress")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)");
|
||||
|
||||
@@ -836,6 +835,10 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
||||
b.Property<int>("NotificationListId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("PhoneNumber")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("nvarchar(32)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NotificationListId");
|
||||
@@ -843,6 +846,50 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
||||
b.ToTable("NotificationRecipients");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.SmsConfiguration", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("AccountSid")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<string>("ApiBaseUrl")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)");
|
||||
|
||||
b.Property<string>("AuthToken")
|
||||
.HasMaxLength(8000)
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("ConnectionTimeoutSeconds")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("FromNumber")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("nvarchar(32)");
|
||||
|
||||
b.Property<int>("MaxRetries")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("MessagingServiceSid")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<TimeSpan>("RetryDelay")
|
||||
.HasColumnType("time");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("SmsConfigurations");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.SmtpConfiguration", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
|
||||
+16
@@ -88,6 +88,22 @@ public class NotificationRepository : INotificationRepository
|
||||
if (entity != null) _context.Set<SmtpConfiguration>().Remove(entity);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SmsConfiguration?> GetSmsConfigurationAsync(CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<SmsConfiguration>().FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<SmsConfiguration>> GetAllSmsConfigurationsAsync(CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<SmsConfiguration>().ToListAsync(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task AddSmsConfigurationAsync(SmsConfiguration smsConfiguration, CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<SmsConfiguration>().AddAsync(smsConfiguration, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateSmsConfigurationAsync(SmsConfiguration smsConfiguration, CancellationToken cancellationToken = default)
|
||||
{ _context.Set<SmsConfiguration>().Update(smsConfiguration); return Task.CompletedTask; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
=> await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
@@ -105,6 +105,8 @@ public class ScadaBridgeDbContext : DbContext, IDataProtectionKeyContext
|
||||
public DbSet<NotificationRecipient> NotificationRecipients => Set<NotificationRecipient>();
|
||||
/// <summary>Gets the set of SMTP configurations.</summary>
|
||||
public DbSet<SmtpConfiguration> SmtpConfigurations => Set<SmtpConfiguration>();
|
||||
/// <summary>Gets the set of SMS configurations.</summary>
|
||||
public DbSet<SmsConfiguration> SmsConfigurations => Set<SmsConfiguration>();
|
||||
/// <summary>Gets the set of notifications.</summary>
|
||||
public DbSet<Notification> Notifications => Set<Notification>();
|
||||
|
||||
@@ -247,6 +249,7 @@ public class ScadaBridgeDbContext : DbContext, IDataProtectionKeyContext
|
||||
string? secretProperty = entry.Entity switch
|
||||
{
|
||||
SmtpConfiguration => nameof(SmtpConfiguration.Credentials),
|
||||
SmsConfiguration => nameof(SmsConfiguration.AuthToken),
|
||||
ExternalSystemDefinition => nameof(ExternalSystemDefinition.AuthConfiguration),
|
||||
DatabaseConnectionDefinition => nameof(DatabaseConnectionDefinition.ConnectionString),
|
||||
_ => null
|
||||
@@ -326,6 +329,10 @@ public class ScadaBridgeDbContext : DbContext, IDataProtectionKeyContext
|
||||
.Property(s => s.Credentials)
|
||||
.HasConversion(converter);
|
||||
|
||||
modelBuilder.Entity<SmsConfiguration>()
|
||||
.Property(s => s.AuthToken)
|
||||
.HasConversion(converter);
|
||||
|
||||
modelBuilder.Entity<ExternalSystemDefinition>()
|
||||
.Property(e => e.AuthConfiguration)
|
||||
.HasConversion(converter);
|
||||
|
||||
Reference in New Issue
Block a user