feat: add SiteNotificationRepository and SMTP storage

This commit is contained in:
Joseph Doherty
2026-03-17 13:42:15 -04:00
parent 0a1de710e8
commit e313eda9fd
2 changed files with 306 additions and 0 deletions

View File

@@ -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;
/// <summary>
/// Site-side read-only implementation of <see cref="INotificationRepository"/>
/// backed by the local SQLite database via <see cref="SiteStorageService"/>.
/// Write operations throw <see cref="NotSupportedException"/> because site-local
/// artifacts are managed exclusively through deployment from Central.
/// </summary>
public class SiteNotificationRepository : INotificationRepository
{
private readonly SiteStorageService _storage;
public SiteNotificationRepository(SiteStorageService storage)
{
_storage = storage ?? throw new ArgumentNullException(nameof(storage));
}
// ── NotificationList (read) ──
public async Task<NotificationList?> 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<IReadOnlyList<NotificationList>> 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<NotificationList>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
results.Add(MapNotificationList(reader));
}
return results;
}
public async Task<NotificationList?> 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<IReadOnlyList<NotificationRecipient>> 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<NotificationRecipient>();
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<NotificationRecipient>();
return ParseRecipientEmails(json, notificationListId);
}
public async Task<NotificationRecipient?> 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<IReadOnlyList<SmtpConfiguration>> 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<SmtpConfiguration>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
results.Add(MapSmtpConfiguration(reader));
}
return results;
}
public async Task<SmtpConfiguration?> 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<int> 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<NotificationRecipient> ParseRecipientEmails(
string json, int notificationListId)
{
try
{
var emails = JsonSerializer.Deserialize<List<string>>(json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (emails is null)
return Array.Empty<NotificationRecipient>();
return emails.Select(email => new NotificationRecipient(
name: email,
emailAddress: email)
{
Id = GenerateSyntheticId($"{notificationListId}:{email}"),
NotificationListId = notificationListId
}).ToList();
}
catch (JsonException)
{
return Array.Empty<NotificationRecipient>();
}
}
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)
};
}
/// <summary>
/// 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.
/// </summary>
private static int GenerateSyntheticId(string name)
=> name.GetHashCode() & 0x7FFFFFFF;
}