From e313eda9fd02024530ef44717476689faba26c2e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 17 Mar 2026 13:42:15 -0400 Subject: [PATCH] feat: add SiteNotificationRepository and SMTP storage --- .../Persistence/SiteStorageService.cs | 51 ++++ .../SiteNotificationRepository.cs | 255 ++++++++++++++++++ 2 files changed, 306 insertions(+) create mode 100644 src/ScadaLink.SiteRuntime/Repositories/SiteNotificationRepository.cs diff --git a/src/ScadaLink.SiteRuntime/Persistence/SiteStorageService.cs b/src/ScadaLink.SiteRuntime/Persistence/SiteStorageService.cs index 5ad0436..1bf0bb7 100644 --- a/src/ScadaLink.SiteRuntime/Persistence/SiteStorageService.cs +++ b/src/ScadaLink.SiteRuntime/Persistence/SiteStorageService.cs @@ -77,6 +77,18 @@ public class SiteStorageService recipient_emails TEXT NOT NULL, updated_at TEXT NOT NULL ); + + CREATE TABLE IF NOT EXISTS smtp_configurations ( + name TEXT PRIMARY KEY, + server TEXT NOT NULL, + port INTEGER NOT NULL, + auth_mode TEXT NOT NULL, + from_address TEXT NOT NULL, + username TEXT, + password TEXT, + oauth_config TEXT, + updated_at TEXT NOT NULL + ); "; await command.ExecuteNonQueryAsync(); @@ -416,6 +428,45 @@ public class SiteStorageService await command.ExecuteNonQueryAsync(); } + + // ── WP-33: SMTP Configuration CRUD ── + + /// + /// Stores or updates an SMTP configuration. + /// + public async Task StoreSmtpConfigurationAsync( + string name, string server, int port, string authMode, string fromAddress, + string? username, string? password, string? oauthConfig) + { + await using var connection = new SqliteConnection(_connectionString); + await connection.OpenAsync(); + + await using var command = connection.CreateCommand(); + command.CommandText = @" + INSERT INTO smtp_configurations (name, server, port, auth_mode, from_address, username, password, oauth_config, updated_at) + VALUES (@name, @server, @port, @authMode, @fromAddress, @username, @password, @oauthConfig, @updatedAt) + ON CONFLICT(name) DO UPDATE SET + server = excluded.server, + port = excluded.port, + auth_mode = excluded.auth_mode, + from_address = excluded.from_address, + username = excluded.username, + password = excluded.password, + oauth_config = excluded.oauth_config, + updated_at = excluded.updated_at"; + + command.Parameters.AddWithValue("@name", name); + command.Parameters.AddWithValue("@server", server); + command.Parameters.AddWithValue("@port", port); + command.Parameters.AddWithValue("@authMode", authMode); + command.Parameters.AddWithValue("@fromAddress", fromAddress); + command.Parameters.AddWithValue("@username", (object?)username ?? DBNull.Value); + command.Parameters.AddWithValue("@password", (object?)password ?? DBNull.Value); + command.Parameters.AddWithValue("@oauthConfig", (object?)oauthConfig ?? DBNull.Value); + command.Parameters.AddWithValue("@updatedAt", DateTimeOffset.UtcNow.ToString("O")); + + await command.ExecuteNonQueryAsync(); + } } /// diff --git a/src/ScadaLink.SiteRuntime/Repositories/SiteNotificationRepository.cs b/src/ScadaLink.SiteRuntime/Repositories/SiteNotificationRepository.cs new file mode 100644 index 0000000..0023cf3 --- /dev/null +++ b/src/ScadaLink.SiteRuntime/Repositories/SiteNotificationRepository.cs @@ -0,0 +1,255 @@ +using System.Text.Json; +using Microsoft.Data.Sqlite; +using ScadaLink.Commons.Entities.Notifications; +using ScadaLink.Commons.Interfaces.Repositories; +using ScadaLink.SiteRuntime.Persistence; + +namespace ScadaLink.SiteRuntime.Repositories; + +/// +/// Site-side read-only implementation of +/// backed by the local SQLite database via . +/// Write operations throw because site-local +/// artifacts are managed exclusively through deployment from Central. +/// +public class SiteNotificationRepository : INotificationRepository +{ + private readonly SiteStorageService _storage; + + public SiteNotificationRepository(SiteStorageService storage) + { + _storage = storage ?? throw new ArgumentNullException(nameof(storage)); + } + + // ── NotificationList (read) ── + + public async Task GetListByNameAsync( + string name, CancellationToken cancellationToken = default) + { + await using var connection = CreateConnection(); + await connection.OpenAsync(cancellationToken); + + await using var command = connection.CreateCommand(); + command.CommandText = @" + SELECT name, recipient_emails + FROM notification_lists + WHERE name = @name"; + command.Parameters.AddWithValue("@name", name); + + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + if (!await reader.ReadAsync(cancellationToken)) + return null; + + return MapNotificationList(reader); + } + + public async Task> GetAllNotificationListsAsync( + CancellationToken cancellationToken = default) + { + await using var connection = CreateConnection(); + await connection.OpenAsync(cancellationToken); + + await using var command = connection.CreateCommand(); + command.CommandText = "SELECT name, recipient_emails FROM notification_lists"; + + var results = new List(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + results.Add(MapNotificationList(reader)); + } + + return results; + } + + public async Task GetNotificationListByIdAsync( + int id, CancellationToken cancellationToken = default) + { + // The SQLite table is keyed by name, not by integer ID. + // Scan all rows and match on the synthetic ID derived from the name. + var all = await GetAllNotificationListsAsync(cancellationToken); + return all.FirstOrDefault(l => l.Id == id); + } + + // ── NotificationRecipient (read) ── + + public async Task> GetRecipientsByListIdAsync( + int notificationListId, CancellationToken cancellationToken = default) + { + // Find the parent list to get its name, then parse the recipient_emails JSON. + var list = await GetNotificationListByIdAsync(notificationListId, cancellationToken); + if (list is null) + return Array.Empty(); + + await using var connection = CreateConnection(); + await connection.OpenAsync(cancellationToken); + + await using var command = connection.CreateCommand(); + command.CommandText = @" + SELECT recipient_emails + FROM notification_lists + WHERE name = @name"; + command.Parameters.AddWithValue("@name", list.Name); + + var json = (string?)await command.ExecuteScalarAsync(cancellationToken); + if (string.IsNullOrWhiteSpace(json)) + return Array.Empty(); + + return ParseRecipientEmails(json, notificationListId); + } + + public async Task GetRecipientByIdAsync( + int id, CancellationToken cancellationToken = default) + { + // Scan all lists and their recipients to find the matching synthetic ID. + var lists = await GetAllNotificationListsAsync(cancellationToken); + foreach (var list in lists) + { + var recipients = await GetRecipientsByListIdAsync(list.Id, cancellationToken); + var match = recipients.FirstOrDefault(r => r.Id == id); + if (match is not null) + return match; + } + + return null; + } + + // ── SmtpConfiguration (read) ── + + public async Task> GetAllSmtpConfigurationsAsync( + CancellationToken cancellationToken = default) + { + await using var connection = CreateConnection(); + await connection.OpenAsync(cancellationToken); + + await using var command = connection.CreateCommand(); + command.CommandText = @" + SELECT name, server, port, auth_mode, from_address, username, password, oauth_config + FROM smtp_configurations"; + + var results = new List(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + results.Add(MapSmtpConfiguration(reader)); + } + + return results; + } + + public async Task GetSmtpConfigurationByIdAsync( + int id, CancellationToken cancellationToken = default) + { + var all = await GetAllSmtpConfigurationsAsync(cancellationToken); + return all.FirstOrDefault(s => s.Id == id); + } + + // ── Write operations (not supported on site) ── + + public Task AddNotificationListAsync(NotificationList list, CancellationToken cancellationToken = default) + => throw new NotSupportedException("Managed via artifact deployment from Central"); + + public Task UpdateNotificationListAsync(NotificationList list, CancellationToken cancellationToken = default) + => throw new NotSupportedException("Managed via artifact deployment from Central"); + + public Task DeleteNotificationListAsync(int id, CancellationToken cancellationToken = default) + => throw new NotSupportedException("Managed via artifact deployment from Central"); + + public Task AddRecipientAsync(NotificationRecipient recipient, CancellationToken cancellationToken = default) + => throw new NotSupportedException("Managed via artifact deployment from Central"); + + public Task UpdateRecipientAsync(NotificationRecipient recipient, CancellationToken cancellationToken = default) + => throw new NotSupportedException("Managed via artifact deployment from Central"); + + public Task DeleteRecipientAsync(int id, CancellationToken cancellationToken = default) + => throw new NotSupportedException("Managed via artifact deployment from Central"); + + public Task AddSmtpConfigurationAsync(SmtpConfiguration configuration, CancellationToken cancellationToken = default) + => throw new NotSupportedException("Managed via artifact deployment from Central"); + + public Task UpdateSmtpConfigurationAsync(SmtpConfiguration configuration, CancellationToken cancellationToken = default) + => throw new NotSupportedException("Managed via artifact deployment from Central"); + + public Task DeleteSmtpConfigurationAsync(int id, CancellationToken cancellationToken = default) + => throw new NotSupportedException("Managed via artifact deployment from Central"); + + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + => throw new NotSupportedException("Managed via artifact deployment from Central"); + + // ── Private helpers ── + + private SqliteConnection CreateConnection() + { + var field = typeof(SiteStorageService).GetField("_connectionString", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + var connectionString = (string)field!.GetValue(_storage)!; + return new SqliteConnection(connectionString); + } + + private static NotificationList MapNotificationList(SqliteDataReader reader) + { + var name = reader.GetString(0); + var list = new NotificationList(name) + { + Id = GenerateSyntheticId(name) + }; + + // Eagerly populate Recipients from the JSON column. + var emailsJson = reader.GetString(1); + var recipients = ParseRecipientEmails(emailsJson, list.Id); + foreach (var r in recipients) + { + list.Recipients.Add(r); + } + + return list; + } + + private static IReadOnlyList ParseRecipientEmails( + string json, int notificationListId) + { + try + { + var emails = JsonSerializer.Deserialize>(json, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + if (emails is null) + return Array.Empty(); + + return emails.Select(email => new NotificationRecipient( + name: email, + emailAddress: email) + { + Id = GenerateSyntheticId($"{notificationListId}:{email}"), + NotificationListId = notificationListId + }).ToList(); + } + catch (JsonException) + { + return Array.Empty(); + } + } + + private static SmtpConfiguration MapSmtpConfiguration(SqliteDataReader reader) + { + var name = reader.GetString(0); + return new SmtpConfiguration( + host: reader.GetString(1), + authType: reader.GetString(3), + fromAddress: reader.GetString(4)) + { + Id = GenerateSyntheticId(name), + Port = reader.GetInt32(2), + Credentials = reader.IsDBNull(5) ? null : reader.GetString(5), + TlsMode = reader.IsDBNull(7) ? null : reader.GetString(7) + }; + } + + /// + /// Generates a stable positive integer ID from a string name. + /// Uses a hash to produce a deterministic synthetic ID since the SQLite + /// tables are keyed by name rather than auto-increment integer. + /// + private static int GenerateSyntheticId(string name) + => name.GetHashCode() & 0x7FFFFFFF; +}