feat: add SiteNotificationRepository and SMTP storage
This commit is contained in:
@@ -77,6 +77,18 @@ public class SiteStorageService
|
|||||||
recipient_emails TEXT NOT NULL,
|
recipient_emails TEXT NOT NULL,
|
||||||
updated_at 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();
|
await command.ExecuteNonQueryAsync();
|
||||||
|
|
||||||
@@ -416,6 +428,45 @@ public class SiteStorageService
|
|||||||
|
|
||||||
await command.ExecuteNonQueryAsync();
|
await command.ExecuteNonQueryAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── WP-33: SMTP Configuration CRUD ──
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stores or updates an SMTP configuration.
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user