Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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/

Expand All @@ -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/
Expand Down
2 changes: 2 additions & 0 deletions Dockerfile.worker
Original file line number Diff line number Diff line change
Expand Up @@ -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/

Expand Down
5 changes: 5 additions & 0 deletions SimpleModule.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,11 @@
<Project Path="modules/Email/src/SimpleModule.Email/SimpleModule.Email.csproj" />
<Project Path="modules/Email/tests/SimpleModule.Email.Tests/SimpleModule.Email.Tests.csproj" />
</Folder>
<Folder Name="/modules/Notifications/">
<Project Path="modules/Notifications/src/SimpleModule.Notifications.Contracts/SimpleModule.Notifications.Contracts.csproj" />
<Project Path="modules/Notifications/src/SimpleModule.Notifications/SimpleModule.Notifications.csproj" />
<Project Path="modules/Notifications/tests/SimpleModule.Notifications.Tests/SimpleModule.Notifications.Tests.csproj" />
</Folder>
<Folder Name="/template/">
<Project Path="template/SimpleModule.Host/SimpleModule.Host.csproj" />
<Project Path="template/SimpleModule.Worker/SimpleModule.Worker.csproj" />
Expand Down
Original file line number Diff line number Diff line change
@@ -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; }
}
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace SimpleModule.Notifications.Contracts;

/// <summary>
/// A notification that can be dispatched to one or more channels for a given recipient.
/// Implement <see cref="Via"/> to declare which channel names should attempt delivery
/// and override the per-channel <c>To*</c> methods to supply the channel-specific payload.
/// </summary>
public interface INotification
{
/// <summary>Stable identifier for this notification type (e.g. <c>"orders.shipped"</c>).</summary>
string NotificationType { get; }

/// <summary>Channel names to deliver this notification on (see <see cref="NotificationsConstants.Channels"/>).</summary>
string[] Via(NotificationRecipient recipient);

MailMessage? ToMail(NotificationRecipient recipient) => null;
DatabaseNotificationPayload? ToDatabase(NotificationRecipient recipient) => null;
SmsMessage? ToSms(NotificationRecipient recipient) => null;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using SimpleModule.Core;
using SimpleModule.Users.Contracts;

namespace SimpleModule.Notifications.Contracts;

public interface INotificationsContracts
{
Task<PagedResult<Notification>> ListAsync(UserId userId, QueryNotificationsRequest request);
Task<int> GetUnreadCountAsync(UserId userId, CancellationToken cancellationToken = default);
Task<Notification?> GetByIdAsync(NotificationId id, UserId userId);
Task<bool> MarkReadAsync(NotificationId id, UserId userId);
Task<int> MarkAllReadAsync(UserId userId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
namespace SimpleModule.Notifications.Contracts;

/// <summary>
/// High-level send API. <see cref="SendAsync"/> queues delivery through the background
/// jobs system so the caller does not block on slow channels. <see cref="SendNowAsync"/>
/// dispatches synchronously and is intended for tests and tightly-coupled flows where
/// the caller wants to surface errors immediately.
/// </summary>
public interface INotifier
{
Task SendAsync<T>(
NotificationRecipient recipient,
T notification,
CancellationToken cancellationToken = default
)
where T : INotification;

Task SendNowAsync<T>(
NotificationRecipient recipient,
T notification,
CancellationToken cancellationToken = default
)
where T : INotification;
}
Original file line number Diff line number Diff line change
@@ -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; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using SimpleModule.Core;
using SimpleModule.Core.Entities;
using SimpleModule.Users.Contracts;

namespace SimpleModule.Notifications.Contracts;

[Dto]
public class Notification : Entity<NotificationId>
{
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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
using Vogen;

namespace SimpleModule.Notifications.Contracts;

[ValueObject<Guid>(conversions: Conversions.SystemTextJson | Conversions.EfCoreValueConverter)]
public readonly partial struct NotificationId;
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using SimpleModule.Core;
using SimpleModule.Users.Contracts;

namespace SimpleModule.Notifications.Contracts;

/// <summary>
/// Identifies a recipient of a notification and supplies the per-channel addresses
/// the channel implementations need. A recipient is keyed by <see cref="UserId"/> so
/// the database channel can persist notifications, while the optional addresses let
/// the mail/sms channels deliver out-of-band.
/// </summary>
[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; }
}
Original file line number Diff line number Diff line change
@@ -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";
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Library</OutputType>
<DefineConstants>$(DefineConstants);VOGEN_NO_VALIDATION</DefineConstants>
<Description>Notifications module contracts for SimpleModule. Multi-channel notification dispatch (mail, database, SMS, etc.).</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Vogen" />
<ProjectReference Include="..\..\..\..\framework\SimpleModule.Core\SimpleModule.Core.csproj" />
<ProjectReference Include="..\..\..\..\modules\Users\src\SimpleModule.Users.Contracts\SimpleModule.Users.Contracts.csproj" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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<NotificationsDbContext> outbox,
ILogger<DatabaseChannel> 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
);
}
Loading
Loading