diff --git a/Dockerfile b/Dockerfile index 7efcb985..6c63ee4b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -63,6 +63,8 @@ COPY modules/Localization/src/SimpleModule.Localization.Contracts/*.csproj modul COPY modules/Localization/src/SimpleModule.Localization/*.csproj modules/Localization/src/SimpleModule.Localization/ COPY modules/Email/src/SimpleModule.Email.Contracts/*.csproj modules/Email/src/SimpleModule.Email.Contracts/ COPY modules/Email/src/SimpleModule.Email/*.csproj modules/Email/src/SimpleModule.Email/ +COPY modules/Notifications/src/SimpleModule.Notifications.Contracts/*.csproj modules/Notifications/src/SimpleModule.Notifications.Contracts/ +COPY modules/Notifications/src/SimpleModule.Notifications/*.csproj modules/Notifications/src/SimpleModule.Notifications/ COPY modules/RateLimiting/src/SimpleModule.RateLimiting.Contracts/*.csproj modules/RateLimiting/src/SimpleModule.RateLimiting.Contracts/ COPY modules/RateLimiting/src/SimpleModule.RateLimiting/*.csproj modules/RateLimiting/src/SimpleModule.RateLimiting/ @@ -89,6 +91,7 @@ COPY modules/Tenants/src/SimpleModule.Tenants/package.json modules/Tenants/src/S COPY modules/Users/src/SimpleModule.Users/package.json modules/Users/src/SimpleModule.Users/ COPY modules/BackgroundJobs/src/SimpleModule.BackgroundJobs/package.json modules/BackgroundJobs/src/SimpleModule.BackgroundJobs/ COPY modules/Email/src/SimpleModule.Email/package.json modules/Email/src/SimpleModule.Email/ +COPY modules/Notifications/src/SimpleModule.Notifications/package.json modules/Notifications/src/SimpleModule.Notifications/ COPY modules/RateLimiting/src/SimpleModule.RateLimiting/package.json modules/RateLimiting/src/SimpleModule.RateLimiting/ COPY packages/SimpleModule.Client/package.json packages/SimpleModule.Client/ COPY packages/SimpleModule.Theme.Default/package.json packages/SimpleModule.Theme.Default/ diff --git a/Dockerfile.worker b/Dockerfile.worker index f701f58e..67c00a87 100644 --- a/Dockerfile.worker +++ b/Dockerfile.worker @@ -71,6 +71,8 @@ COPY modules/Localization/src/SimpleModule.Localization.Contracts/*.csproj modul COPY modules/Localization/src/SimpleModule.Localization/*.csproj modules/Localization/src/SimpleModule.Localization/ COPY modules/Email/src/SimpleModule.Email.Contracts/*.csproj modules/Email/src/SimpleModule.Email.Contracts/ COPY modules/Email/src/SimpleModule.Email/*.csproj modules/Email/src/SimpleModule.Email/ +COPY modules/Notifications/src/SimpleModule.Notifications.Contracts/*.csproj modules/Notifications/src/SimpleModule.Notifications.Contracts/ +COPY modules/Notifications/src/SimpleModule.Notifications/*.csproj modules/Notifications/src/SimpleModule.Notifications/ COPY modules/RateLimiting/src/SimpleModule.RateLimiting.Contracts/*.csproj modules/RateLimiting/src/SimpleModule.RateLimiting.Contracts/ COPY modules/RateLimiting/src/SimpleModule.RateLimiting/*.csproj modules/RateLimiting/src/SimpleModule.RateLimiting/ diff --git a/SimpleModule.slnx b/SimpleModule.slnx index c2dba1d3..5b84407f 100644 --- a/SimpleModule.slnx +++ b/SimpleModule.slnx @@ -92,6 +92,11 @@ + + + + + diff --git a/modules/Notifications/src/SimpleModule.Notifications.Contracts/DatabaseNotificationPayload.cs b/modules/Notifications/src/SimpleModule.Notifications.Contracts/DatabaseNotificationPayload.cs new file mode 100644 index 00000000..8ebb924e --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications.Contracts/DatabaseNotificationPayload.cs @@ -0,0 +1,20 @@ +using SimpleModule.Core; + +namespace SimpleModule.Notifications.Contracts; + +[NoDtoGeneration] +public sealed class DatabaseNotificationPayload +{ + public DatabaseNotificationPayload() { } + + public DatabaseNotificationPayload(string? title, string? body, object? data = null) + { + Title = title; + Body = body; + Data = data; + } + + public string? Title { get; set; } + public string? Body { get; set; } + public object? Data { get; set; } +} diff --git a/modules/Notifications/src/SimpleModule.Notifications.Contracts/Events/NotificationFailedEvent.cs b/modules/Notifications/src/SimpleModule.Notifications.Contracts/Events/NotificationFailedEvent.cs new file mode 100644 index 00000000..a2c3754b --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications.Contracts/Events/NotificationFailedEvent.cs @@ -0,0 +1,11 @@ +using SimpleModule.Core.Events; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Notifications.Contracts.Events; + +public sealed record NotificationFailedEvent( + UserId UserId, + string NotificationType, + string Channel, + string Error +) : DomainEvent; diff --git a/modules/Notifications/src/SimpleModule.Notifications.Contracts/Events/NotificationSentEvent.cs b/modules/Notifications/src/SimpleModule.Notifications.Contracts/Events/NotificationSentEvent.cs new file mode 100644 index 00000000..0cb72299 --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications.Contracts/Events/NotificationSentEvent.cs @@ -0,0 +1,10 @@ +using SimpleModule.Core.Events; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Notifications.Contracts.Events; + +public sealed record NotificationSentEvent( + UserId UserId, + string NotificationType, + string Channel +) : DomainEvent; diff --git a/modules/Notifications/src/SimpleModule.Notifications.Contracts/INotification.cs b/modules/Notifications/src/SimpleModule.Notifications.Contracts/INotification.cs new file mode 100644 index 00000000..5c1a0be5 --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications.Contracts/INotification.cs @@ -0,0 +1,19 @@ +namespace SimpleModule.Notifications.Contracts; + +/// +/// A notification that can be dispatched to one or more channels for a given recipient. +/// Implement to declare which channel names should attempt delivery +/// and override the per-channel To* methods to supply the channel-specific payload. +/// +public interface INotification +{ + /// Stable identifier for this notification type (e.g. "orders.shipped"). + string NotificationType { get; } + + /// Channel names to deliver this notification on (see ). + string[] Via(NotificationRecipient recipient); + + MailMessage? ToMail(NotificationRecipient recipient) => null; + DatabaseNotificationPayload? ToDatabase(NotificationRecipient recipient) => null; + SmsMessage? ToSms(NotificationRecipient recipient) => null; +} diff --git a/modules/Notifications/src/SimpleModule.Notifications.Contracts/INotificationsContracts.cs b/modules/Notifications/src/SimpleModule.Notifications.Contracts/INotificationsContracts.cs new file mode 100644 index 00000000..c732bd03 --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications.Contracts/INotificationsContracts.cs @@ -0,0 +1,13 @@ +using SimpleModule.Core; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Notifications.Contracts; + +public interface INotificationsContracts +{ + Task> ListAsync(UserId userId, QueryNotificationsRequest request); + Task GetUnreadCountAsync(UserId userId, CancellationToken cancellationToken = default); + Task GetByIdAsync(NotificationId id, UserId userId); + Task MarkReadAsync(NotificationId id, UserId userId); + Task MarkAllReadAsync(UserId userId); +} diff --git a/modules/Notifications/src/SimpleModule.Notifications.Contracts/INotifier.cs b/modules/Notifications/src/SimpleModule.Notifications.Contracts/INotifier.cs new file mode 100644 index 00000000..3e0aa4ad --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications.Contracts/INotifier.cs @@ -0,0 +1,24 @@ +namespace SimpleModule.Notifications.Contracts; + +/// +/// High-level send API. queues delivery through the background +/// jobs system so the caller does not block on slow channels. +/// dispatches synchronously and is intended for tests and tightly-coupled flows where +/// the caller wants to surface errors immediately. +/// +public interface INotifier +{ + Task SendAsync( + NotificationRecipient recipient, + T notification, + CancellationToken cancellationToken = default + ) + where T : INotification; + + Task SendNowAsync( + NotificationRecipient recipient, + T notification, + CancellationToken cancellationToken = default + ) + where T : INotification; +} diff --git a/modules/Notifications/src/SimpleModule.Notifications.Contracts/MailMessage.cs b/modules/Notifications/src/SimpleModule.Notifications.Contracts/MailMessage.cs new file mode 100644 index 00000000..d833e781 --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications.Contracts/MailMessage.cs @@ -0,0 +1,20 @@ +using SimpleModule.Core; + +namespace SimpleModule.Notifications.Contracts; + +[NoDtoGeneration] +public sealed class MailMessage +{ + public MailMessage() { } + + public MailMessage(string subject, string body, bool isHtml = false) + { + Subject = subject; + Body = body; + IsHtml = isHtml; + } + + public string Subject { get; set; } = string.Empty; + public string Body { get; set; } = string.Empty; + public bool IsHtml { get; set; } +} diff --git a/modules/Notifications/src/SimpleModule.Notifications.Contracts/Notification.cs b/modules/Notifications/src/SimpleModule.Notifications.Contracts/Notification.cs new file mode 100644 index 00000000..e783d8bb --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications.Contracts/Notification.cs @@ -0,0 +1,21 @@ +using SimpleModule.Core; +using SimpleModule.Core.Entities; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Notifications.Contracts; + +[Dto] +public class Notification : Entity +{ + public UserId UserId { get; set; } + public string Type { get; set; } = string.Empty; + public string Channel { get; set; } = string.Empty; + public string? Title { get; set; } + public string? Body { get; set; } + + // JSON payload — arbitrary, channel-specific or in-app data + public string DataJson { get; set; } = "{}"; + + public DateTimeOffset? ReadAt { get; set; } + public bool IsRead => ReadAt.HasValue; +} diff --git a/modules/Notifications/src/SimpleModule.Notifications.Contracts/NotificationId.cs b/modules/Notifications/src/SimpleModule.Notifications.Contracts/NotificationId.cs new file mode 100644 index 00000000..2b91e6c4 --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications.Contracts/NotificationId.cs @@ -0,0 +1,6 @@ +using Vogen; + +namespace SimpleModule.Notifications.Contracts; + +[ValueObject(conversions: Conversions.SystemTextJson | Conversions.EfCoreValueConverter)] +public readonly partial struct NotificationId; diff --git a/modules/Notifications/src/SimpleModule.Notifications.Contracts/NotificationRecipient.cs b/modules/Notifications/src/SimpleModule.Notifications.Contracts/NotificationRecipient.cs new file mode 100644 index 00000000..abd5d185 --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications.Contracts/NotificationRecipient.cs @@ -0,0 +1,27 @@ +using SimpleModule.Core; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Notifications.Contracts; + +/// +/// Identifies a recipient of a notification and supplies the per-channel addresses +/// the channel implementations need. A recipient is keyed by so +/// the database channel can persist notifications, while the optional addresses let +/// the mail/sms channels deliver out-of-band. +/// +[NoDtoGeneration] +public sealed class NotificationRecipient +{ + public NotificationRecipient() { } + + public NotificationRecipient(UserId userId, string? email = null, string? phoneNumber = null) + { + UserId = userId; + Email = email; + PhoneNumber = phoneNumber; + } + + public UserId UserId { get; set; } + public string? Email { get; set; } + public string? PhoneNumber { get; set; } +} diff --git a/modules/Notifications/src/SimpleModule.Notifications.Contracts/NotificationsConstants.cs b/modules/Notifications/src/SimpleModule.Notifications.Contracts/NotificationsConstants.cs new file mode 100644 index 00000000..9a3ba523 --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications.Contracts/NotificationsConstants.cs @@ -0,0 +1,36 @@ +namespace SimpleModule.Notifications.Contracts; + +public static class NotificationsConstants +{ + public const string ModuleName = "Notifications"; + public const string RoutePrefix = "/api/notifications"; + public const string ViewPrefix = "/notifications"; + + public static class Channels + { + public const string Mail = "mail"; + public const string Database = "database"; + public const string Sms = "sms"; + } + + public static class Routes + { + // API endpoints + public const string List = "/"; + public const string UnreadCount = "/unread-count"; + public const string MarkRead = "/{id}/read"; + public const string MarkAllRead = "/read-all"; + public const string Send = "/send"; + + // View endpoints + public const string Inbox = "/"; + } + + public static class SettingsKeys + { + public const string MailChannelEnabled = "notifications.channel.mail.enabled"; + public const string DatabaseChannelEnabled = "notifications.channel.database.enabled"; + public const string SmsChannelEnabled = "notifications.channel.sms.enabled"; + public const string DefaultPageSize = "notifications.defaultPageSize"; + } +} diff --git a/modules/Notifications/src/SimpleModule.Notifications.Contracts/QueryNotificationsRequest.cs b/modules/Notifications/src/SimpleModule.Notifications.Contracts/QueryNotificationsRequest.cs new file mode 100644 index 00000000..043a525c --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications.Contracts/QueryNotificationsRequest.cs @@ -0,0 +1,16 @@ +using SimpleModule.Core; + +namespace SimpleModule.Notifications.Contracts; + +[Dto] +public class QueryNotificationsRequest +{ + public int? Page { get; set; } + public int? PageSize { get; set; } + public bool? UnreadOnly { get; set; } + public string? Channel { get; set; } + public string? Type { get; set; } + + public int EffectivePage => Page is > 0 ? Page.Value : 1; + public int EffectivePageSize => PageSize is > 0 and <= 100 ? PageSize.Value : 20; +} diff --git a/modules/Notifications/src/SimpleModule.Notifications.Contracts/SimpleModule.Notifications.Contracts.csproj b/modules/Notifications/src/SimpleModule.Notifications.Contracts/SimpleModule.Notifications.Contracts.csproj new file mode 100644 index 00000000..0843e49c --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications.Contracts/SimpleModule.Notifications.Contracts.csproj @@ -0,0 +1,14 @@ + + + net10.0 + Library + $(DefineConstants);VOGEN_NO_VALIDATION + Notifications module contracts for SimpleModule. Multi-channel notification dispatch (mail, database, SMS, etc.). + + + + + + + + diff --git a/modules/Notifications/src/SimpleModule.Notifications.Contracts/SmsMessage.cs b/modules/Notifications/src/SimpleModule.Notifications.Contracts/SmsMessage.cs new file mode 100644 index 00000000..e399575d --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications.Contracts/SmsMessage.cs @@ -0,0 +1,16 @@ +using SimpleModule.Core; + +namespace SimpleModule.Notifications.Contracts; + +[NoDtoGeneration] +public sealed class SmsMessage +{ + public SmsMessage() { } + + public SmsMessage(string body) + { + Body = body; + } + + public string Body { get; set; } = string.Empty; +} diff --git a/modules/Notifications/src/SimpleModule.Notifications/Channels/DatabaseChannel.cs b/modules/Notifications/src/SimpleModule.Notifications/Channels/DatabaseChannel.cs new file mode 100644 index 00000000..e236b145 --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications/Channels/DatabaseChannel.cs @@ -0,0 +1,67 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; +using SimpleModule.Notifications.Contracts; +using SimpleModule.Notifications.Contracts.Events; +using Wolverine.EntityFrameworkCore; + +namespace SimpleModule.Notifications.Channels; + +public partial class DatabaseChannel( + NotificationsDbContext db, + IDbContextOutbox outbox, + ILogger logger +) : INotificationChannel +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + public string Name => NotificationsConstants.Channels.Database; + + public async Task SendAsync( + NotificationRecipient recipient, + INotification notification, + CancellationToken cancellationToken = default + ) + { + var payload = notification.ToDatabase(recipient); + if (payload is null) + { + return; + } + + var entity = new Notification + { + Id = NotificationId.From(Guid.CreateVersion7()), + UserId = recipient.UserId, + Type = notification.NotificationType, + Channel = Name, + Title = payload.Title, + Body = payload.Body, + DataJson = payload.Data is null + ? "{}" + : JsonSerializer.Serialize(payload.Data, JsonOptions), + }; + + db.Notifications.Add(entity); + + await outbox.PublishAsync( + new NotificationSentEvent(recipient.UserId, notification.NotificationType, Name) + ); + await outbox.SaveChangesAndFlushMessagesAsync(cancellationToken); + + LogPersisted(logger, entity.Id, recipient.UserId.Value, notification.NotificationType); + } + + [LoggerMessage( + Level = LogLevel.Information, + Message = "Persisted notification {NotificationId} for user {UserId} type {Type}" + )] + private static partial void LogPersisted( + ILogger logger, + NotificationId notificationId, + string userId, + string type + ); +} diff --git a/modules/Notifications/src/SimpleModule.Notifications/Channels/INotificationChannel.cs b/modules/Notifications/src/SimpleModule.Notifications/Channels/INotificationChannel.cs new file mode 100644 index 00000000..ec96f708 --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications/Channels/INotificationChannel.cs @@ -0,0 +1,27 @@ +using SimpleModule.Notifications.Contracts; + +namespace SimpleModule.Notifications.Channels; + +/// +/// A delivery channel — mail, database, sms, slack, push, etc. Each channel +/// implementation is registered in DI by name and the notifier dispatches to it +/// based on what returns. +/// +/// +/// Lives in the implementation assembly (not SimpleModule.Notifications.Contracts) +/// because each channel is a per-module-internal extension point with many implementations, +/// which the source generator's contract rules forbid in a Contracts assembly. Optional +/// out-of-tree channels (Slack, push, custom SMS providers) reference this assembly +/// directly the same way Email providers reference SimpleModule.Email. +/// +public interface INotificationChannel +{ + /// Channel identifier matching the names returned from . + string Name { get; } + + Task SendAsync( + NotificationRecipient recipient, + INotification notification, + CancellationToken cancellationToken = default + ); +} diff --git a/modules/Notifications/src/SimpleModule.Notifications/Channels/INotificationChannelRegistry.cs b/modules/Notifications/src/SimpleModule.Notifications/Channels/INotificationChannelRegistry.cs new file mode 100644 index 00000000..8a1dd495 --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications/Channels/INotificationChannelRegistry.cs @@ -0,0 +1,24 @@ +using SimpleModule.Notifications.Contracts; + +namespace SimpleModule.Notifications.Channels; + +public interface INotificationChannelRegistry +{ + INotificationChannel? Find(string name); + IReadOnlyCollection RegisteredNames { get; } +} + +public sealed class NotificationChannelRegistry : INotificationChannelRegistry +{ + private readonly Dictionary _channels; + + public NotificationChannelRegistry(IEnumerable channels) + { + _channels = channels.ToDictionary(c => c.Name, StringComparer.OrdinalIgnoreCase); + } + + public INotificationChannel? Find(string name) => + _channels.TryGetValue(name, out var channel) ? channel : null; + + public IReadOnlyCollection RegisteredNames => _channels.Keys; +} diff --git a/modules/Notifications/src/SimpleModule.Notifications/Channels/LogSmsChannel.cs b/modules/Notifications/src/SimpleModule.Notifications/Channels/LogSmsChannel.cs new file mode 100644 index 00000000..28eadab3 --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications/Channels/LogSmsChannel.cs @@ -0,0 +1,48 @@ +using Microsoft.Extensions.Logging; +using SimpleModule.Notifications.Contracts; + +namespace SimpleModule.Notifications.Channels; + +/// +/// Default SMS channel: writes the message to the log. A real provider (Twilio, etc.) +/// can be plugged in by replacing this registration with another +/// implementation whose returns "sms". +/// +public partial class LogSmsChannel(ILogger logger) : INotificationChannel +{ + public string Name => NotificationsConstants.Channels.Sms; + + public Task SendAsync( + NotificationRecipient recipient, + INotification notification, + CancellationToken cancellationToken = default + ) + { + var sms = notification.ToSms(recipient); + if (sms is null) + { + return Task.CompletedTask; + } + + if (string.IsNullOrWhiteSpace(recipient.PhoneNumber)) + { + LogMissingPhone(logger, recipient.UserId.Value, notification.NotificationType); + return Task.CompletedTask; + } + + LogSms(logger, recipient.PhoneNumber, notification.NotificationType, sms.Body); + return Task.CompletedTask; + } + + [LoggerMessage( + Level = LogLevel.Information, + Message = "[SMS] to={Phone} type={Type} body={Body}" + )] + private static partial void LogSms(ILogger logger, string phone, string type, string body); + + [LoggerMessage( + Level = LogLevel.Warning, + Message = "Skipping SMS notification for user {UserId} type {Type}: recipient has no phone number" + )] + private static partial void LogMissingPhone(ILogger logger, string userId, string type); +} diff --git a/modules/Notifications/src/SimpleModule.Notifications/Channels/MailChannel.cs b/modules/Notifications/src/SimpleModule.Notifications/Channels/MailChannel.cs new file mode 100644 index 00000000..4d94b476 --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications/Channels/MailChannel.cs @@ -0,0 +1,51 @@ +using Microsoft.Extensions.Logging; +using SimpleModule.Email.Contracts; +using SimpleModule.Notifications.Contracts; + +namespace SimpleModule.Notifications.Channels; + +/// +/// Forwards a notification's mail payload to the Email module. Skips silently when the +/// recipient has no email address — channel selection happens upstream in the notifier, +/// but we still defend here so mis-configured callers don't fault. +/// +public partial class MailChannel(IEmailContracts email, ILogger logger) + : INotificationChannel +{ + public string Name => NotificationsConstants.Channels.Mail; + + public async Task SendAsync( + NotificationRecipient recipient, + INotification notification, + CancellationToken cancellationToken = default + ) + { + var mail = notification.ToMail(recipient); + if (mail is null) + { + return; + } + + if (string.IsNullOrWhiteSpace(recipient.Email)) + { + LogMissingEmail(logger, recipient.UserId.Value, notification.NotificationType); + return; + } + + await email.SendEmailAsync( + new SendEmailRequest + { + To = recipient.Email, + Subject = mail.Subject, + Body = mail.Body, + IsHtml = mail.IsHtml, + } + ); + } + + [LoggerMessage( + Level = LogLevel.Warning, + Message = "Skipping mail notification for user {UserId} type {Type}: recipient has no email address" + )] + private static partial void LogMissingEmail(ILogger logger, string userId, string type); +} diff --git a/modules/Notifications/src/SimpleModule.Notifications/Endpoints/Notifications/ListNotificationsEndpoint.cs b/modules/Notifications/src/SimpleModule.Notifications/Endpoints/Notifications/ListNotificationsEndpoint.cs new file mode 100644 index 00000000..4a40ed93 --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications/Endpoints/Notifications/ListNotificationsEndpoint.cs @@ -0,0 +1,27 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.Core.Extensions; +using SimpleModule.Notifications.Contracts; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Notifications.Endpoints.Notifications; + +public class ListNotificationsEndpoint : IEndpoint +{ + public const string Route = NotificationsConstants.Routes.List; + + public void Map(IEndpointRouteBuilder app) => + app.MapGet( + Route, + ( + [AsParameters] QueryNotificationsRequest request, + HttpContext context, + INotificationsContracts notifications + ) => + notifications.ListAsync(UserId.From(context.User.GetUserId()!), request) + ) + .RequirePermission(NotificationsPermissions.ViewOwn); +} diff --git a/modules/Notifications/src/SimpleModule.Notifications/Endpoints/Notifications/MarkAllReadEndpoint.cs b/modules/Notifications/src/SimpleModule.Notifications/Endpoints/Notifications/MarkAllReadEndpoint.cs new file mode 100644 index 00000000..e9ad5a37 --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications/Endpoints/Notifications/MarkAllReadEndpoint.cs @@ -0,0 +1,29 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.Core.Extensions; +using SimpleModule.Notifications.Contracts; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Notifications.Endpoints.Notifications; + +public class MarkAllReadEndpoint : IEndpoint +{ + public const string Route = NotificationsConstants.Routes.MarkAllRead; + public const string Method = "POST"; + + public void Map(IEndpointRouteBuilder app) => + app.MapPost( + Route, + async (HttpContext context, INotificationsContracts notifications) => + { + var marked = await notifications.MarkAllReadAsync( + UserId.From(context.User.GetUserId()!) + ); + return new { marked }; + } + ) + .RequirePermission(NotificationsPermissions.ViewOwn); +} diff --git a/modules/Notifications/src/SimpleModule.Notifications/Endpoints/Notifications/MarkReadEndpoint.cs b/modules/Notifications/src/SimpleModule.Notifications/Endpoints/Notifications/MarkReadEndpoint.cs new file mode 100644 index 00000000..185dd49e --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications/Endpoints/Notifications/MarkReadEndpoint.cs @@ -0,0 +1,34 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.Core.Extensions; +using SimpleModule.Notifications.Contracts; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Notifications.Endpoints.Notifications; + +public class MarkReadEndpoint : IEndpoint +{ + public const string Route = NotificationsConstants.Routes.MarkRead; + public const string Method = "POST"; + + public void Map(IEndpointRouteBuilder app) => + app.MapPost( + Route, + async Task ( + Guid id, + HttpContext context, + INotificationsContracts notifications + ) => + { + var ok = await notifications.MarkReadAsync( + NotificationId.From(id), + UserId.From(context.User.GetUserId()!) + ); + return ok ? TypedResults.NoContent() : TypedResults.NotFound(); + } + ) + .RequirePermission(NotificationsPermissions.ViewOwn); +} diff --git a/modules/Notifications/src/SimpleModule.Notifications/Endpoints/Notifications/UnreadCountEndpoint.cs b/modules/Notifications/src/SimpleModule.Notifications/Endpoints/Notifications/UnreadCountEndpoint.cs new file mode 100644 index 00000000..82097d2d --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications/Endpoints/Notifications/UnreadCountEndpoint.cs @@ -0,0 +1,33 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.Core.Extensions; +using SimpleModule.Notifications.Contracts; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Notifications.Endpoints.Notifications; + +public class UnreadCountEndpoint : IEndpoint +{ + public const string Route = NotificationsConstants.Routes.UnreadCount; + + public void Map(IEndpointRouteBuilder app) => + app.MapGet( + Route, + async ( + HttpContext context, + INotificationsContracts notifications, + CancellationToken ct + ) => + { + var count = await notifications.GetUnreadCountAsync( + UserId.From(context.User.GetUserId()!), + ct + ); + return new { count }; + } + ) + .RequirePermission(NotificationsPermissions.ViewOwn); +} diff --git a/modules/Notifications/src/SimpleModule.Notifications/EntityConfigurations/NotificationConfiguration.cs b/modules/Notifications/src/SimpleModule.Notifications/EntityConfigurations/NotificationConfiguration.cs new file mode 100644 index 00000000..3b59c640 --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications/EntityConfigurations/NotificationConfiguration.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using SimpleModule.Notifications.Contracts; + +namespace SimpleModule.Notifications.EntityConfigurations; + +public class NotificationConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(n => n.Id); + builder.Property(n => n.UserId).IsRequired().HasMaxLength(450); + builder.Property(n => n.Type).IsRequired().HasMaxLength(200); + builder.Property(n => n.Channel).IsRequired().HasMaxLength(50); + builder.Property(n => n.Title).HasMaxLength(500); + builder.Property(n => n.Body).HasMaxLength(4000); + builder.Property(n => n.DataJson).IsRequired(); + builder.Property(n => n.ConcurrencyStamp).HasMaxLength(64); + builder.Ignore(n => n.IsRead); + + // Covers the inbox list query (filter UserId + order CreatedAt DESC, Id as tiebreaker) + // and the unread-count query (predicate on UserId + ReadAt). + builder.HasIndex(n => new { n.UserId, n.CreatedAt, n.Id }); + builder.HasIndex(n => new { n.UserId, n.ReadAt }); + } +} diff --git a/modules/Notifications/src/SimpleModule.Notifications/Jobs/DispatchNotificationJob.cs b/modules/Notifications/src/SimpleModule.Notifications/Jobs/DispatchNotificationJob.cs new file mode 100644 index 00000000..ebbe7a74 --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications/Jobs/DispatchNotificationJob.cs @@ -0,0 +1,93 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; +using SimpleModule.BackgroundJobs.Contracts; +using SimpleModule.Notifications.Channels; +using SimpleModule.Notifications.Contracts; +using SimpleModule.Notifications.Contracts.Events; +using SimpleModule.Notifications.Services; +using SimpleModule.Users.Contracts; +using Wolverine; + +namespace SimpleModule.Notifications.Jobs; + +public sealed record DispatchNotificationJobData( + string UserId, + string? Email, + string? PhoneNumber, + string ChannelName, + string NotificationTypeName, + string NotificationJson +); + +public class DispatchNotificationJob( + INotificationChannelRegistry channels, + IMessageBus bus, + ILogger logger +) : IModuleJob +{ + public async Task ExecuteAsync( + IJobExecutionContext context, + CancellationToken cancellationToken + ) + { + var jobData = context.GetData(); + + var channel = channels.Find(jobData.ChannelName); + if (channel is null) + { + context.Log($"Unknown channel '{jobData.ChannelName}'; dropping notification."); + return; + } + + var notificationType = Type.GetType(jobData.NotificationTypeName); + if (notificationType is null) + { + context.Log( + $"Unable to resolve notification type {jobData.NotificationTypeName}; dropping." + ); + return; + } + + if ( + JsonSerializer.Deserialize(jobData.NotificationJson, notificationType) + is not INotification notification + ) + { + context.Log($"Failed to deserialize notification of type {notificationType.Name}."); + return; + } + + var recipient = new NotificationRecipient( + UserId.From(jobData.UserId), + jobData.Email, + jobData.PhoneNumber + ); + + try + { + await channel.SendAsync(recipient, notification, cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + NotificationsLog.ChannelFailure( + logger, + jobData.ChannelName, + notification.NotificationType, + jobData.UserId, + ex + ); + + await bus.PublishAsync( + new NotificationFailedEvent( + recipient.UserId, + notification.NotificationType, + jobData.ChannelName, + ex.Message + ) + ); + throw; + } + + context.ReportProgress(100); + } +} diff --git a/modules/Notifications/src/SimpleModule.Notifications/NotificationsDbContext.cs b/modules/Notifications/src/SimpleModule.Notifications/NotificationsDbContext.cs new file mode 100644 index 00000000..7288cc8f --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications/NotificationsDbContext.cs @@ -0,0 +1,46 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Microsoft.Extensions.Options; +using SimpleModule.Database; +using SimpleModule.Notifications.Contracts; +using SimpleModule.Notifications.EntityConfigurations; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Notifications; + +public class NotificationsDbContext( + DbContextOptions options, + IOptions dbOptions +) : DbContext(options) +{ + public DbSet Notifications => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ApplyConfiguration(new NotificationConfiguration()); + modelBuilder.ApplyModuleSchema("Notifications", dbOptions.Value); + } + + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) + { + configurationBuilder + .Properties() + .HaveConversion< + NotificationId.EfCoreValueConverter, + NotificationId.EfCoreValueComparer + >(); + configurationBuilder + .Properties() + .HaveConversion(); + + if (dbOptions.Value.DetectProvider("Notifications") == DatabaseProvider.Sqlite) + { + configurationBuilder + .Properties() + .HaveConversion(); + configurationBuilder + .Properties() + .HaveConversion(); + } + } +} diff --git a/modules/Notifications/src/SimpleModule.Notifications/NotificationsModule.cs b/modules/Notifications/src/SimpleModule.Notifications/NotificationsModule.cs new file mode 100644 index 00000000..1c0ea43f --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications/NotificationsModule.cs @@ -0,0 +1,109 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using SimpleModule.BackgroundJobs.Contracts; +using SimpleModule.Core; +using SimpleModule.Core.Settings; +using SimpleModule.Database; +using SimpleModule.Notifications.Channels; +using SimpleModule.Notifications.Contracts; +using SimpleModule.Notifications.Jobs; +using SimpleModule.Notifications.Services; + +namespace SimpleModule.Notifications; + +[Module( + NotificationsConstants.ModuleName, + RoutePrefix = NotificationsConstants.RoutePrefix, + ViewPrefix = NotificationsConstants.ViewPrefix +)] +public class NotificationsModule : IModule, IModuleServices +{ + public void ConfigureMiddleware(IApplicationBuilder app) + { + // DispatchNotificationJob and the Notifier rely on IBackgroundJobs (whose + // implementation lives in the BackgroundJobs module, not its Contracts assembly). + // Fail fast with a directive message if the implementation isn't installed. + var probe = app.ApplicationServices.GetRequiredService(); + if (!probe.IsService(typeof(IBackgroundJobs))) + { + throw new InvalidOperationException( + "SimpleModule.Notifications requires SimpleModule.BackgroundJobs to be installed. " + + "Add a reference to the SimpleModule.BackgroundJobs project so IBackgroundJobs can be resolved." + ); + } + } + + public void ConfigureServices(IServiceCollection services, IConfiguration configuration) + { + services.AddModuleDbContext( + configuration, + NotificationsConstants.ModuleName + ); + services.Configure(configuration.GetSection("Notifications")); + + services.AddScoped(); + services.AddScoped(); + + // Channels — registered as INotificationChannel so the registry can enumerate them. + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddModuleJob(); + } + + public void ConfigureSettings(ISettingsBuilder settings) + { + settings + .Add( + new SettingDefinition + { + Key = NotificationsConstants.SettingsKeys.MailChannelEnabled, + DisplayName = "Mail notifications", + Description = "Receive notifications by email.", + Group = "Notifications", + Scope = SettingScope.User, + DefaultValue = "true", + Type = SettingType.Bool, + } + ) + .Add( + new SettingDefinition + { + Key = NotificationsConstants.SettingsKeys.DatabaseChannelEnabled, + DisplayName = "In-app notifications", + Description = "Show notifications in the in-app inbox.", + Group = "Notifications", + Scope = SettingScope.User, + DefaultValue = "true", + Type = SettingType.Bool, + } + ) + .Add( + new SettingDefinition + { + Key = NotificationsConstants.SettingsKeys.SmsChannelEnabled, + DisplayName = "SMS notifications", + Description = "Receive notifications by SMS (when a phone number is on file).", + Group = "Notifications", + Scope = SettingScope.User, + DefaultValue = "false", + Type = SettingType.Bool, + } + ) + .Add( + new SettingDefinition + { + Key = NotificationsConstants.SettingsKeys.DefaultPageSize, + DisplayName = "Inbox page size", + Description = "Notifications to load per page in the inbox.", + Group = "Notifications", + Scope = SettingScope.Application, + DefaultValue = "20", + Type = SettingType.Number, + } + ); + } +} diff --git a/modules/Notifications/src/SimpleModule.Notifications/NotificationsModuleOptions.cs b/modules/Notifications/src/SimpleModule.Notifications/NotificationsModuleOptions.cs new file mode 100644 index 00000000..52107ab4 --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications/NotificationsModuleOptions.cs @@ -0,0 +1,11 @@ +using SimpleModule.Core; + +namespace SimpleModule.Notifications; + +public class NotificationsModuleOptions : IModuleOptions +{ + public bool MailChannelEnabled { get; set; } = true; + public bool DatabaseChannelEnabled { get; set; } = true; + public bool SmsChannelEnabled { get; set; } + public int DefaultPageSize { get; set; } = 20; +} diff --git a/modules/Notifications/src/SimpleModule.Notifications/NotificationsPermissions.cs b/modules/Notifications/src/SimpleModule.Notifications/NotificationsPermissions.cs new file mode 100644 index 00000000..27708688 --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications/NotificationsPermissions.cs @@ -0,0 +1,9 @@ +using SimpleModule.Core.Authorization; + +namespace SimpleModule.Notifications; + +public sealed class NotificationsPermissions : IModulePermissions +{ + public const string ViewOwn = "Notifications.ViewOwn"; + public const string SendToOthers = "Notifications.SendToOthers"; +} diff --git a/modules/Notifications/src/SimpleModule.Notifications/Pages/Inbox.tsx b/modules/Notifications/src/SimpleModule.Notifications/Pages/Inbox.tsx new file mode 100644 index 00000000..ec05e9bd --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications/Pages/Inbox.tsx @@ -0,0 +1,81 @@ +import { router } from '@inertiajs/react'; +import { Badge, Button, Card, CardContent, PageShell } from '@simplemodule/ui'; +import type { Notification } from '../types'; + +interface Props { + items: Notification[]; + totalCount: number; + unreadCount: number; +} + +function formatDate(value: string): string { + try { + return new Date(value).toLocaleString(); + } catch { + return value; + } +} + +export default function Inbox({ items, totalCount, unreadCount }: Props) { + const markRead = (id: string) => { + router.post( + `/api/notifications/${id}/read`, + {}, + { preserveScroll: true, onSuccess: () => router.reload({ only: ['items', 'unreadCount'] }) }, + ); + }; + + const markAllRead = () => { + router.post( + '/api/notifications/read-all', + {}, + { preserveScroll: true, onSuccess: () => router.reload({ only: ['items', 'unreadCount'] }) }, + ); + }; + + return ( + + + + Mark all read + + + + {items.length === 0 ? ( + + + You're all caught up. + + + ) : ( + + {items.map((n) => ( + + + + + {!n.readAt && New} + {n.channel} + {n.type} + + {n.title && {n.title}} + {n.body && {n.body}} + {formatDate(n.createdAt)} + + {!n.readAt && ( + markRead(n.id)} variant="ghost" size="sm"> + Mark read + + )} + + + ))} + + )} + + ); +} diff --git a/modules/Notifications/src/SimpleModule.Notifications/Pages/InboxEndpoint.cs b/modules/Notifications/src/SimpleModule.Notifications/Pages/InboxEndpoint.cs new file mode 100644 index 00000000..e898d24a --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications/Pages/InboxEndpoint.cs @@ -0,0 +1,45 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.Core.Extensions; +using SimpleModule.Core.Inertia; +using SimpleModule.Notifications.Contracts; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Notifications.Pages; + +public class InboxEndpoint : IViewEndpoint +{ + public const string Route = NotificationsConstants.Routes.Inbox; + + public void Map(IEndpointRouteBuilder app) => + app.MapGet( + Route, + async ( + HttpContext context, + INotificationsContracts notifications, + bool? unreadOnly + ) => + { + var userId = UserId.From(context.User.GetUserId()!); + var page = await notifications.ListAsync( + userId, + new QueryNotificationsRequest { UnreadOnly = unreadOnly } + ); + var unreadCount = await notifications.GetUnreadCountAsync(userId); + + return Inertia.Render( + "Notifications/Inbox", + new + { + items = page.Items, + totalCount = page.TotalCount, + unreadCount, + } + ); + } + ) + .RequirePermission(NotificationsPermissions.ViewOwn); +} diff --git a/modules/Notifications/src/SimpleModule.Notifications/Pages/index.ts b/modules/Notifications/src/SimpleModule.Notifications/Pages/index.ts new file mode 100644 index 00000000..9f535473 --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications/Pages/index.ts @@ -0,0 +1,3 @@ +export const pages: Record = { + 'Notifications/Inbox': () => import('./Inbox'), +}; diff --git a/modules/Notifications/src/SimpleModule.Notifications/Services/NotificationService.cs b/modules/Notifications/src/SimpleModule.Notifications/Services/NotificationService.cs new file mode 100644 index 00000000..a035d2e4 --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications/Services/NotificationService.cs @@ -0,0 +1,91 @@ +using Microsoft.EntityFrameworkCore; +using SimpleModule.Core; +using SimpleModule.Notifications.Contracts; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Notifications.Services; + +public class NotificationService(NotificationsDbContext db) : INotificationsContracts +{ + public async Task> ListAsync( + UserId userId, + QueryNotificationsRequest request + ) + { + var query = db.Notifications.AsNoTracking().Where(n => n.UserId == userId); + + if (request.UnreadOnly == true) + { + query = query.Where(n => n.ReadAt == null); + } + if (!string.IsNullOrWhiteSpace(request.Channel)) + { + query = query.Where(n => n.Channel == request.Channel); + } + if (!string.IsNullOrWhiteSpace(request.Type)) + { + query = query.Where(n => n.Type == request.Type); + } + + var totalCount = await query.CountAsync(); + var page = request.EffectivePage; + var pageSize = request.EffectivePageSize; + + var items = await query + .OrderByDescending(n => n.CreatedAt) + .ThenByDescending(n => n.Id) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + + return new PagedResult + { + Items = items, + TotalCount = totalCount, + Page = page, + PageSize = pageSize, + }; + } + + public Task GetUnreadCountAsync( + UserId userId, + CancellationToken cancellationToken = default + ) => + db.Notifications.CountAsync( + n => n.UserId == userId && n.ReadAt == null, + cancellationToken + ); + + public async Task GetByIdAsync(NotificationId id, UserId userId) => + await db.Notifications.AsNoTracking().FirstOrDefaultAsync(n => + n.Id == id && n.UserId == userId + ); + + public async Task MarkReadAsync(NotificationId id, UserId userId) + { + var notification = await db.Notifications.FirstOrDefaultAsync(n => + n.Id == id && n.UserId == userId + ); + if (notification is null) + { + return false; + } + + if (notification.ReadAt is not null) + { + return true; + } + + notification.ReadAt = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(); + return true; + } + + public async Task MarkAllReadAsync(UserId userId) + { + var now = DateTimeOffset.UtcNow; + return await db + .Notifications.Where(n => n.UserId == userId && n.ReadAt == null) + .ExecuteUpdateAsync(s => s.SetProperty(n => n.ReadAt, now)); + } +} diff --git a/modules/Notifications/src/SimpleModule.Notifications/Services/NotificationsLog.cs b/modules/Notifications/src/SimpleModule.Notifications/Services/NotificationsLog.cs new file mode 100644 index 00000000..07d35d3d --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications/Services/NotificationsLog.cs @@ -0,0 +1,24 @@ +using Microsoft.Extensions.Logging; + +namespace SimpleModule.Notifications.Services; + +internal static partial class NotificationsLog +{ + [LoggerMessage( + Level = LogLevel.Error, + Message = "Notification channel {Channel} failed for type {Type} user {UserId}" + )] + public static partial void ChannelFailure( + ILogger logger, + string channel, + string type, + string userId, + Exception exception + ); + + [LoggerMessage( + Level = LogLevel.Warning, + Message = "Unknown notification channel '{Channel}' requested for type {Type}; skipping" + )] + public static partial void UnknownChannel(ILogger logger, string channel, string type); +} diff --git a/modules/Notifications/src/SimpleModule.Notifications/Services/Notifier.cs b/modules/Notifications/src/SimpleModule.Notifications/Services/Notifier.cs new file mode 100644 index 00000000..b7ae6201 --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications/Services/Notifier.cs @@ -0,0 +1,91 @@ +using Microsoft.Extensions.Logging; +using SimpleModule.BackgroundJobs.Contracts; +using SimpleModule.Notifications.Channels; +using SimpleModule.Notifications.Contracts; +using SimpleModule.Notifications.Contracts.Events; +using SimpleModule.Notifications.Jobs; +using Wolverine; + +namespace SimpleModule.Notifications.Services; + +public class Notifier( + INotificationChannelRegistry channels, + IBackgroundJobs backgroundJobs, + IMessageBus bus, + ILogger logger +) : INotifier +{ + public async Task SendAsync( + NotificationRecipient recipient, + T notification, + CancellationToken cancellationToken = default + ) + where T : INotification + { + // One job per channel so a slow/failing channel cannot block siblings and so retries are per-channel. + var channelNames = notification.Via(recipient); + var notificationJson = System.Text.Json.JsonSerializer.Serialize( + notification, + notification.GetType() + ); + var typeName = typeof(T).AssemblyQualifiedName!; + + foreach (var channelName in channelNames) + { + await backgroundJobs.EnqueueAsync( + new DispatchNotificationJobData( + recipient.UserId.Value, + recipient.Email, + recipient.PhoneNumber, + channelName, + typeName, + notificationJson + ), + cancellationToken + ); + } + } + + public async Task SendNowAsync( + NotificationRecipient recipient, + T notification, + CancellationToken cancellationToken = default + ) + where T : INotification + { + var channelNames = notification.Via(recipient); + + foreach (var channelName in channelNames) + { + var channel = channels.Find(channelName); + if (channel is null) + { + NotificationsLog.UnknownChannel(logger, channelName, notification.NotificationType); + continue; + } + + try + { + await channel.SendAsync(recipient, notification, cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + NotificationsLog.ChannelFailure( + logger, + channelName, + notification.NotificationType, + recipient.UserId.Value, + ex + ); + await bus.PublishAsync( + new NotificationFailedEvent( + recipient.UserId, + notification.NotificationType, + channelName, + ex.Message + ) + ); + } + } + } +} diff --git a/modules/Notifications/src/SimpleModule.Notifications/SimpleModule.Notifications.csproj b/modules/Notifications/src/SimpleModule.Notifications/SimpleModule.Notifications.csproj new file mode 100644 index 00000000..3f8421c7 --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications/SimpleModule.Notifications.csproj @@ -0,0 +1,21 @@ + + + net10.0 + Notifications module for SimpleModule. Multi-channel dispatch (mail, database, SMS) with in-app bell-icon UI. + + + + + + + + + + + + + + %(Filename)Endpoint.cs + + + diff --git a/modules/Notifications/src/SimpleModule.Notifications/package.json b/modules/Notifications/src/SimpleModule.Notifications/package.json new file mode 100644 index 00000000..2fd9dfea --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications/package.json @@ -0,0 +1,14 @@ +{ + "private": true, + "name": "@simplemodule/notifications", + "version": "0.0.0", + "scripts": { + "build": "cross-env VITE_MODE=prod vite build --configLoader runner", + "build:dev": "cross-env VITE_MODE=dev vite build --configLoader runner", + "watch": "cross-env VITE_MODE=dev vite build --configLoader runner --watch" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + } +} diff --git a/modules/Notifications/src/SimpleModule.Notifications/tsconfig.json b/modules/Notifications/src/SimpleModule.Notifications/tsconfig.json new file mode 100644 index 00000000..3e759d10 --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@simplemodule/tsconfig/base", + "compilerOptions": { + "paths": { + "@/*": ["./*"] + } + } +} diff --git a/modules/Notifications/src/SimpleModule.Notifications/types.ts b/modules/Notifications/src/SimpleModule.Notifications/types.ts new file mode 100644 index 00000000..2aed0700 --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications/types.ts @@ -0,0 +1,26 @@ +// Auto-generated from [Dto] types — do not edit +export interface Notification { + userId: string; + type: string; + channel: string; + title: string; + body: string; + dataJson: string; + readAt: string | null; + isRead: boolean; + id: string; + createdAt: string; + updatedAt: string; + concurrencyStamp: string; +} + +export interface QueryNotificationsRequest { + page: number | null; + pageSize: number | null; + unreadOnly: boolean | null; + channel: string; + type: string; + effectivePage: number; + effectivePageSize: number; +} + diff --git a/modules/Notifications/src/SimpleModule.Notifications/vite.config.ts b/modules/Notifications/src/SimpleModule.Notifications/vite.config.ts new file mode 100644 index 00000000..a247db62 --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications/vite.config.ts @@ -0,0 +1,3 @@ +import { defineModuleConfig } from '@simplemodule/client/module'; + +export default defineModuleConfig(import.meta.dirname); diff --git a/modules/Notifications/tests/SimpleModule.Notifications.Tests/GlobalUsings.cs b/modules/Notifications/tests/SimpleModule.Notifications.Tests/GlobalUsings.cs new file mode 100644 index 00000000..c802f448 --- /dev/null +++ b/modules/Notifications/tests/SimpleModule.Notifications.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/modules/Notifications/tests/SimpleModule.Notifications.Tests/SimpleModule.Notifications.Tests.csproj b/modules/Notifications/tests/SimpleModule.Notifications.Tests/SimpleModule.Notifications.Tests.csproj new file mode 100644 index 00000000..f741d5cf --- /dev/null +++ b/modules/Notifications/tests/SimpleModule.Notifications.Tests/SimpleModule.Notifications.Tests.csproj @@ -0,0 +1,26 @@ + + + net10.0 + false + Exe + + + + + + + + + + + + + + + + + + + + + diff --git a/modules/Notifications/tests/SimpleModule.Notifications.Tests/Unit/NotificationServiceTests.cs b/modules/Notifications/tests/SimpleModule.Notifications.Tests/Unit/NotificationServiceTests.cs new file mode 100644 index 00000000..bc744c8d --- /dev/null +++ b/modules/Notifications/tests/SimpleModule.Notifications.Tests/Unit/NotificationServiceTests.cs @@ -0,0 +1,134 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using SimpleModule.Database; +using SimpleModule.Notifications.Contracts; +using SimpleModule.Notifications.Services; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Notifications.Tests.Unit; + +public sealed class NotificationServiceTests : IDisposable +{ + private readonly NotificationsDbContext _db; + private readonly NotificationService _sut; + private readonly UserId _userId = UserId.From("user-1"); + + public NotificationServiceTests() + { + var options = new DbContextOptionsBuilder() + .UseSqlite("Data Source=:memory:") + .Options; + var dbOptions = Options.Create( + new DatabaseOptions + { + ModuleConnections = new Dictionary + { + ["Notifications"] = "Data Source=:memory:", + }, + } + ); + _db = new NotificationsDbContext(options, dbOptions); + _db.Database.OpenConnection(); + _db.Database.EnsureCreated(); + _sut = new NotificationService(_db); + } + + public void Dispose() => _db.Dispose(); + + private async Task SeedAsync(UserId? userId = null, DateTimeOffset? readAt = null) + { + var n = new Notification + { + Id = NotificationId.From(Guid.CreateVersion7()), + UserId = userId ?? _userId, + Type = "test.event", + Channel = NotificationsConstants.Channels.Database, + Title = "Title", + Body = "Body", + DataJson = "{}", + ReadAt = readAt, + }; + _db.Notifications.Add(n); + await _db.SaveChangesAsync(); + return n; + } + + [Fact] + public async Task ListAsync_ReturnsOnlyOwnNotifications() + { + await SeedAsync(); + await SeedAsync(); + await SeedAsync(userId: UserId.From("other-user")); + + var result = await _sut.ListAsync(_userId, new QueryNotificationsRequest()); + + result.Items.Should().HaveCount(2); + result.TotalCount.Should().Be(2); + } + + [Fact] + public async Task ListAsync_UnreadOnly_FiltersReadNotifications() + { + await SeedAsync(); + await SeedAsync(readAt: DateTimeOffset.UtcNow); + + var result = await _sut.ListAsync( + _userId, + new QueryNotificationsRequest { UnreadOnly = true } + ); + + result.TotalCount.Should().Be(1); + } + + [Fact] + public async Task GetUnreadCountAsync_ReturnsUnreadOnly() + { + await SeedAsync(); + await SeedAsync(); + await SeedAsync(readAt: DateTimeOffset.UtcNow); + + var count = await _sut.GetUnreadCountAsync(_userId); + + count.Should().Be(2); + } + + [Fact] + public async Task MarkReadAsync_SetsReadAt() + { + var n = await SeedAsync(); + + var result = await _sut.MarkReadAsync(n.Id, _userId); + + result.Should().BeTrue(); + var refreshed = await _db.Notifications.AsNoTracking().FirstAsync(x => x.Id == n.Id); + refreshed.ReadAt.Should().NotBeNull(); + } + + [Fact] + public async Task MarkReadAsync_WithDifferentUser_ReturnsFalse() + { + var n = await SeedAsync(); + + var result = await _sut.MarkReadAsync(n.Id, UserId.From("not-the-owner")); + + result.Should().BeFalse(); + } + + [Fact] + public async Task MarkAllReadAsync_MarksAllUnreadForUser() + { + await SeedAsync(); + await SeedAsync(); + await SeedAsync(readAt: DateTimeOffset.UtcNow); + await SeedAsync(userId: UserId.From("other")); + + var marked = await _sut.MarkAllReadAsync(_userId); + + marked.Should().Be(2); + var remainingUnread = await _sut.GetUnreadCountAsync(_userId); + remainingUnread.Should().Be(0); + var otherUserUnread = await _sut.GetUnreadCountAsync(UserId.From("other")); + otherUserUnread.Should().Be(1); + } +} diff --git a/modules/Notifications/tests/SimpleModule.Notifications.Tests/Unit/NotifierTests.cs b/modules/Notifications/tests/SimpleModule.Notifications.Tests/Unit/NotifierTests.cs new file mode 100644 index 00000000..2c3f49d2 --- /dev/null +++ b/modules/Notifications/tests/SimpleModule.Notifications.Tests/Unit/NotifierTests.cs @@ -0,0 +1,147 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using SimpleModule.Notifications.Channels; +using SimpleModule.Notifications.Contracts; +using SimpleModule.Notifications.Jobs; +using SimpleModule.Notifications.Services; +using SimpleModule.Users.Contracts; +using Wolverine; + +namespace SimpleModule.Notifications.Tests.Unit; + +public sealed class NotifierTests +{ + private sealed class CapturingChannel(string name) : INotificationChannel + { + public string Name { get; } = name; + public List<(NotificationRecipient Recipient, INotification Notification)> Calls { get; } = + []; + + public Task SendAsync( + NotificationRecipient recipient, + INotification notification, + CancellationToken cancellationToken = default + ) + { + Calls.Add((recipient, notification)); + return Task.CompletedTask; + } + } + + private sealed class FailingChannel(string name) : INotificationChannel + { + public string Name { get; } = name; + + public Task SendAsync( + NotificationRecipient recipient, + INotification notification, + CancellationToken cancellationToken = default + ) => throw new InvalidOperationException("boom"); + } + + private sealed record TestNotification(string[] Channels) : INotification + { + public string NotificationType => "test.event"; + + public string[] Via(NotificationRecipient recipient) => Channels; + + public DatabaseNotificationPayload? ToDatabase(NotificationRecipient recipient) => + new("Title", "Body"); + } + + [Fact] + public async Task SendNowAsync_DispatchesToEachChannel() + { + var db = new CapturingChannel(NotificationsConstants.Channels.Database); + var mail = new CapturingChannel(NotificationsConstants.Channels.Mail); + var registry = new NotificationChannelRegistry([db, mail]); + var sut = new Notifier( + registry, + new TestBackgroundJobs(), + Substitute.For(), + NullLogger.Instance + ); + + var recipient = new NotificationRecipient(UserId.From("u1"), "u1@test.com"); + var notification = new TestNotification( + [NotificationsConstants.Channels.Database, NotificationsConstants.Channels.Mail] + ); + + await sut.SendNowAsync(recipient, notification); + + db.Calls.Should().HaveCount(1); + mail.Calls.Should().HaveCount(1); + } + + [Fact] + public async Task SendNowAsync_UnknownChannelIsSkipped() + { + var db = new CapturingChannel(NotificationsConstants.Channels.Database); + var registry = new NotificationChannelRegistry([db]); + var sut = new Notifier( + registry, + new TestBackgroundJobs(), + Substitute.For(), + NullLogger.Instance + ); + + var recipient = new NotificationRecipient(UserId.From("u1")); + const string unregisteredChannel = "unregistered"; + var notification = new TestNotification( + [unregisteredChannel, NotificationsConstants.Channels.Database] + ); + + await sut.SendNowAsync(recipient, notification); + + db.Calls.Should().HaveCount(1); + } + + [Fact] + public async Task SendNowAsync_PublishesFailedEvent_WhenChannelThrows() + { + var failing = new FailingChannel(NotificationsConstants.Channels.Database); + var registry = new NotificationChannelRegistry([failing]); + var bus = Substitute.For(); + var sut = new Notifier( + registry, + new TestBackgroundJobs(), + bus, + NullLogger.Instance + ); + + var recipient = new NotificationRecipient(UserId.From("u1")); + var notification = new TestNotification([NotificationsConstants.Channels.Database]); + + await sut.SendNowAsync(recipient, notification); + + await bus.Received(1) + .PublishAsync( + Arg.Any(), + Arg.Any() + ); + } + + [Fact] + public async Task SendAsync_EnqueuesOneJobPerChannel() + { + var registry = new NotificationChannelRegistry([]); + var jobs = new TestBackgroundJobs(); + var sut = new Notifier( + registry, + jobs, + Substitute.For(), + NullLogger.Instance + ); + + var recipient = new NotificationRecipient(UserId.From("u1"), "u1@test.com"); + var notification = new TestNotification( + [NotificationsConstants.Channels.Database, NotificationsConstants.Channels.Mail] + ); + + await sut.SendAsync(recipient, notification); + + jobs.EnqueuedJobs.Should().HaveCount(2); + jobs.EnqueuedJobs.Should().AllSatisfy(j => j.JobType.Should().Be()); + } +} diff --git a/modules/Notifications/tests/SimpleModule.Notifications.Tests/Unit/TestBackgroundJobs.cs b/modules/Notifications/tests/SimpleModule.Notifications.Tests/Unit/TestBackgroundJobs.cs new file mode 100644 index 00000000..1c6961cd --- /dev/null +++ b/modules/Notifications/tests/SimpleModule.Notifications.Tests/Unit/TestBackgroundJobs.cs @@ -0,0 +1,41 @@ +using SimpleModule.BackgroundJobs.Contracts; + +namespace SimpleModule.Notifications.Tests.Unit; + +internal sealed class TestBackgroundJobs : IBackgroundJobs +{ + public List<(Type JobType, object? Data)> EnqueuedJobs { get; } = []; + + public Task EnqueueAsync(object? data = null, CancellationToken ct = default) + where TJob : IModuleJob + { + EnqueuedJobs.Add((typeof(TJob), data)); + return Task.FromResult(JobId.From(Guid.NewGuid())); + } + + public Task ScheduleAsync( + DateTimeOffset executeAt, + object? data = null, + CancellationToken ct = default + ) + where TJob : IModuleJob => Task.FromResult(JobId.From(Guid.NewGuid())); + + public Task AddRecurringAsync( + string name, + string cronExpression, + object? data = null, + CancellationToken ct = default + ) + where TJob : IModuleJob => Task.FromResult(RecurringJobId.From(Guid.NewGuid())); + + public Task RemoveRecurringAsync(RecurringJobId id, CancellationToken ct = default) => + Task.CompletedTask; + + public Task ToggleRecurringAsync(RecurringJobId id, CancellationToken ct = default) => + Task.FromResult(true); + + public Task CancelAsync(JobId jobId, CancellationToken ct = default) => Task.CompletedTask; + + public Task GetStatusAsync(JobId jobId, CancellationToken ct = default) => + Task.FromResult(null); +} diff --git a/package-lock.json b/package-lock.json index 7f88e90e..2a9920cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -151,6 +151,14 @@ "react-dom": "^19.0.0" } }, + "modules/Notifications/src/SimpleModule.Notifications": { + "name": "@simplemodule/notifications", + "version": "0.0.0", + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, "modules/OpenIddict/src/SimpleModule.OpenIddict": { "name": "@simplemodule/openiddict", "version": "0.0.0", @@ -4024,6 +4032,10 @@ "resolved": "tests/k6", "link": true }, + "node_modules/@simplemodule/notifications": { + "resolved": "modules/Notifications/src/SimpleModule.Notifications", + "link": true + }, "node_modules/@simplemodule/openiddict": { "resolved": "modules/OpenIddict/src/SimpleModule.OpenIddict", "link": true diff --git a/packages/SimpleModule.Client/src/routes.ts b/packages/SimpleModule.Client/src/routes.ts index 767e4133..3a453388 100644 --- a/packages/SimpleModule.Client/src/routes.ts +++ b/packages/SimpleModule.Client/src/routes.ts @@ -192,6 +192,17 @@ export const routes = { templates: () => '/email/templates' as const, }, }, + notifications: { + api: { + listNotifications: () => '/api/notifications' as const, + markAllRead: () => '/api/notifications/read-all' as const, + markRead: (id: string | number) => `/api/notifications/${id}/read`, + unreadCount: () => '/api/notifications/unread-count' as const, + }, + views: { + inbox: () => '/notifications' as const, + }, + }, openIddict: { api: { authorization: () => '/connect/authorize' as const, diff --git a/template/SimpleModule.Host/Migrations/20260511185843_AddNotificationsModule.Designer.cs b/template/SimpleModule.Host/Migrations/20260511185843_AddNotificationsModule.Designer.cs new file mode 100644 index 00000000..192c7568 --- /dev/null +++ b/template/SimpleModule.Host/Migrations/20260511185843_AddNotificationsModule.Designer.cs @@ -0,0 +1,1447 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using SimpleModule.Host; + +#nullable disable + +namespace SimpleModule.Host.Migrations +{ + [DbContext(typeof(HostDbContext))] + [Migration("20260511185843_AddNotificationsModule")] + partial class AddNotificationsModule + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.3"); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("Users_AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Users_AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("Users_AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("Users_AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("Users_AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ApplicationType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ClientId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ClientSecret") + .HasColumnType("TEXT"); + + b.Property("ClientType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ConsentType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .HasColumnType("TEXT"); + + b.Property("DisplayNames") + .HasColumnType("TEXT"); + + b.Property("JsonWebKeySet") + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("TEXT"); + + b.Property("PostLogoutRedirectUris") + .HasColumnType("TEXT"); + + b.Property("Properties") + .HasColumnType("TEXT"); + + b.Property("RedirectUris") + .HasColumnType("TEXT"); + + b.Property("Requirements") + .HasColumnType("TEXT"); + + b.Property("Settings") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ClientId") + .IsUnique(); + + b.ToTable("OpenIddict_OpenIddictApplications", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ApplicationId") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Properties") + .HasColumnType("TEXT"); + + b.Property("Scopes") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("TEXT"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddict_OpenIddictAuthorizations", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreScope", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("Descriptions") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .HasColumnType("TEXT"); + + b.Property("DisplayNames") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Properties") + .HasColumnType("TEXT"); + + b.Property("Resources") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("OpenIddict_OpenIddictScopes", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ApplicationId") + .HasColumnType("TEXT"); + + b.Property("AuthorizationId") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("Payload") + .HasColumnType("TEXT"); + + b.Property("Properties") + .HasColumnType("TEXT"); + + b.Property("RedemptionDate") + .HasColumnType("TEXT"); + + b.Property("ReferenceId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("TEXT"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AuthorizationId"); + + b.HasIndex("ReferenceId") + .IsUnique(); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddict_OpenIddictTokens", (string)null); + }); + + modelBuilder.Entity("SimpleModule.AuditLogs.Contracts.AuditEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Action") + .HasColumnType("INTEGER"); + + b.Property("Changes") + .HasColumnType("TEXT"); + + b.Property("CorrelationId") + .HasColumnType("TEXT"); + + b.Property("DurationMs") + .HasColumnType("INTEGER"); + + b.Property("EntityId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EntityType") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("HttpMethod") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("TEXT"); + + b.Property("Metadata") + .HasColumnType("TEXT"); + + b.Property("Module") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Path") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("QueryString") + .HasColumnType("TEXT"); + + b.Property("RequestBody") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("StatusCode") + .HasColumnType("INTEGER"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("UserAgent") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Action"); + + b.HasIndex("CorrelationId"); + + b.HasIndex("Path"); + + b.HasIndex("Source"); + + b.HasIndex("StatusCode"); + + b.HasIndex("Timestamp") + .IsDescending(); + + b.HasIndex("EntityType", "EntityId"); + + b.HasIndex("Module", "Timestamp") + .IsDescending(false, true); + + b.HasIndex("UserId", "Timestamp") + .IsDescending(false, true); + + b.ToTable("AuditLogs_AuditEntries", (string)null); + }); + + modelBuilder.Entity("SimpleModule.BackgroundJobs.Contracts.JobProgress", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("JobTypeName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Logs") + .HasColumnType("TEXT"); + + b.Property("ModuleName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ProgressMessage") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("ProgressPercentage") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ModuleName"); + + b.ToTable("BackgroundJobs_JobProgress", (string)null); + }); + + modelBuilder.Entity("SimpleModule.BackgroundJobs.Contracts.JobQueueEntryEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AttemptCount") + .HasColumnType("INTEGER"); + + b.Property("ClaimedAt") + .HasColumnType("TEXT"); + + b.Property("ClaimedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CronExpression") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Error") + .HasColumnType("TEXT"); + + b.Property("JobTypeName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("RecurringName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ScheduledAt") + .HasColumnType("TEXT"); + + b.Property("SerializedData") + .HasColumnType("TEXT"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RecurringName") + .HasDatabaseName("IX_JobQueueEntries_RecurringName"); + + b.HasIndex("State", "ScheduledAt") + .HasDatabaseName("IX_JobQueueEntries_State_ScheduledAt"); + + b.ToTable("BackgroundJobs_JobQueueEntries", (string)null); + }); + + modelBuilder.Entity("SimpleModule.Email.Contracts.EmailMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bcc") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Body") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Cc") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("IsHtml") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ReplyTo") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("RetryCount") + .HasColumnType("INTEGER"); + + b.Property("SentAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("TemplateSlug") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("To") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Status"); + + b.ToTable("Email_EmailMessages", (string)null); + }); + + modelBuilder.Entity("SimpleModule.Email.Contracts.EmailTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DefaultReplyTo") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("IsHtml") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Email_EmailTemplates", (string)null); + }); + + modelBuilder.Entity("SimpleModule.FeatureFlags.Contracts.FeatureFlagEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IsDeprecated") + .HasColumnType("INTEGER"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("FeatureFlags_FeatureFlags", (string)null); + }); + + modelBuilder.Entity("SimpleModule.FeatureFlags.Contracts.FeatureFlagOverrideEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("FlagName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("OverrideType") + .HasColumnType("INTEGER"); + + b.Property("OverrideValue") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("FlagName", "OverrideType", "OverrideValue") + .IsUnique(); + + b.ToTable("FeatureFlags_FeatureFlagOverrides", (string)null); + }); + + modelBuilder.Entity("SimpleModule.FileStorage.Contracts.StoredFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedByUserId") + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Folder") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("Size") + .HasColumnType("INTEGER"); + + b.Property("StoragePath") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.HasIndex("Folder"); + + b.HasIndex("Folder", "FileName") + .IsUnique(); + + b.ToTable("FileStorage_StoredFiles", (string)null); + }); + + modelBuilder.Entity("SimpleModule.Notifications.Contracts.Notification", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Body") + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DataJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ReadAt") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property
{n.title}
{n.body}
{formatDate(n.createdAt)}