("Frontend:PasswordResetUrl")
- ?? "http://localhost:4200/reset-password";
- var separator = baseUrl.Contains('?', StringComparison.Ordinal) ? '&' : '?';
- var url = $"{baseUrl}{separator}email={Uri.EscapeDataString(user.Email ?? string.Empty)}&token={Uri.EscapeDataString(resetToken)}";
- var firstName = WebUtility.HtmlEncode(user.FirstName);
- var encodedUrl = WebUtility.HtmlEncode(url);
- var body = $$"""
-
-
- Hello {{firstName}},
- Use the link below to reset your CCE password.
- Reset password
- If you did not request a password reset, you can ignore this email.
-
-
- """;
-
- await _emailSender.SendAsync(user.Email ?? string.Empty, "Reset your CCE password", body, ct: ct)
- .ConfigureAwait(false);
- }
-}
diff --git a/backend/src/CCE.Infrastructure/Notifications/EmailNotificationChannelSender.cs b/backend/src/CCE.Infrastructure/Notifications/EmailNotificationChannelSender.cs
new file mode 100644
index 00000000..8bc05e8c
--- /dev/null
+++ b/backend/src/CCE.Infrastructure/Notifications/EmailNotificationChannelSender.cs
@@ -0,0 +1,95 @@
+using CCE.Application.Notifications;
+using CCE.Domain.Notifications;
+using CCE.Infrastructure.Email;
+using CCE.Integration.Communication;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace CCE.Infrastructure.Notifications;
+
+public sealed class EmailNotificationChannelSender : INotificationChannelHandler
+{
+ private readonly ICommunicationGatewayClient _client;
+ private readonly IOptions _options;
+ private readonly ILogger _logger;
+
+ public EmailNotificationChannelSender(
+ ICommunicationGatewayClient client,
+ IOptions options,
+ ILogger logger)
+ {
+ _client = client;
+ _options = options;
+ _logger = logger;
+ }
+
+ public NotificationChannel Channel => NotificationChannel.Email;
+
+ public bool ShouldSend(UserNotificationSettings? settings) => settings?.IsEnabled ?? true;
+
+ public async Task SendAsync(
+ RenderedNotification notification,
+ CancellationToken cancellationToken)
+ {
+ var to = notification.Email;
+ if (string.IsNullOrWhiteSpace(to))
+ {
+ _logger.LogWarning(
+ "Skipping email for template {TemplateCode}: no recipient email.",
+ notification.TemplateCode);
+ return new ChannelSendResult(
+ false, Error: "No recipient email address available.");
+ }
+
+ try
+ {
+ var request = new SendEmailRequest(
+ To: to,
+ From: _options.Value.FromAddress,
+ Subject: notification.Subject,
+ Html: notification.Body);
+
+ var response = await _client.SendEmailAsync(request, cancellationToken)
+ .ConfigureAwait(false);
+
+ if (!"success".Equals(response.Status, StringComparison.OrdinalIgnoreCase))
+ {
+ _logger.LogError(
+ "Gateway email send failed for {To} template {TemplateCode}: {Error}",
+ to, notification.TemplateCode, response.Error);
+ return new ChannelSendResult(
+ false, Error: $"Gateway email send failed: {response.Error}");
+ }
+
+ _logger.LogInformation(
+ "Sent email via gateway to {To} template {TemplateCode} (id {Id})",
+ to, notification.TemplateCode, response.Id);
+
+ return new ChannelSendResult(true, ProviderMessageId: response.Id);
+ }
+ catch (System.Net.Http.HttpRequestException ex)
+ {
+ _logger.LogError(
+ ex,
+ "Email channel HTTP failure for template {TemplateCode}",
+ notification.TemplateCode);
+ return new ChannelSendResult(false, Error: ex.Message);
+ }
+ catch (InvalidOperationException ex)
+ {
+ _logger.LogError(
+ ex,
+ "Email channel invalid operation for template {TemplateCode}",
+ notification.TemplateCode);
+ return new ChannelSendResult(false, Error: ex.Message);
+ }
+ catch (OperationCanceledException ex) when (ex.CancellationToken != cancellationToken)
+ {
+ _logger.LogError(
+ ex,
+ "Email channel timeout for template {TemplateCode}",
+ notification.TemplateCode);
+ return new ChannelSendResult(false, Error: ex.Message);
+ }
+ }
+}
diff --git a/backend/src/CCE.Infrastructure/Notifications/InAppNotificationChannelSender.cs b/backend/src/CCE.Infrastructure/Notifications/InAppNotificationChannelSender.cs
new file mode 100644
index 00000000..c48f5a20
--- /dev/null
+++ b/backend/src/CCE.Infrastructure/Notifications/InAppNotificationChannelSender.cs
@@ -0,0 +1,51 @@
+using CCE.Application.Common.Interfaces;
+using CCE.Application.Notifications;
+using CCE.Application.Notifications.Public;
+using CCE.Domain.Common;
+using CCE.Domain.Notifications;
+
+namespace CCE.Infrastructure.Notifications;
+
+public sealed class InAppNotificationChannelSender : INotificationChannelHandler
+{
+ private readonly IUserNotificationRepository _repo;
+ private readonly ISystemClock _clock;
+
+ public InAppNotificationChannelSender(IUserNotificationRepository repo, ISystemClock clock)
+ {
+ _repo = repo;
+ _clock = clock;
+ }
+
+ public NotificationChannel Channel => NotificationChannel.InApp;
+
+ public bool ShouldSend(UserNotificationSettings? settings) => settings?.IsEnabled ?? true;
+
+ public async Task SendAsync(
+ RenderedNotification notification,
+ CancellationToken cancellationToken)
+ {
+ if (notification.RecipientUserId is null)
+ {
+ return new ChannelSendResult(
+ false, Error: "In-app notifications require a recipient user ID.");
+ }
+
+ var userNotification = UserNotification.Render(
+ notification.RecipientUserId.Value,
+ notification.TemplateId,
+ notification.SubjectAr,
+ notification.SubjectEn,
+ notification.Body,
+ notification.Locale,
+ NotificationChannel.InApp);
+
+ userNotification.MarkSent(_clock);
+ await _repo.AddAsync(userNotification, cancellationToken).ConfigureAwait(false);
+
+ return new ChannelSendResult(
+ true,
+ UserNotificationId: userNotification.Id,
+ UserNotification: userNotification);
+ }
+}
diff --git a/backend/src/CCE.Infrastructure/Notifications/InProcessNotificationMessageDispatcher.cs b/backend/src/CCE.Infrastructure/Notifications/InProcessNotificationMessageDispatcher.cs
new file mode 100644
index 00000000..2924e9fb
--- /dev/null
+++ b/backend/src/CCE.Infrastructure/Notifications/InProcessNotificationMessageDispatcher.cs
@@ -0,0 +1,27 @@
+using CCE.Application.Notifications;
+using CCE.Application.Notifications.Messages;
+
+namespace CCE.Infrastructure.Notifications;
+
+public sealed class InProcessNotificationMessageDispatcher : INotificationMessageDispatcher
+{
+ private readonly INotificationGateway _gateway;
+
+ public InProcessNotificationMessageDispatcher(INotificationGateway gateway)
+ {
+ _gateway = gateway;
+ }
+
+ public async Task DispatchAsync(NotificationMessage message, CancellationToken ct)
+ {
+ await _gateway.SendAsync(new NotificationDispatchRequest(
+ TemplateCode: message.TemplateCode,
+ RecipientUserId: message.RecipientUserId,
+ Channels: message.Channels ?? [],
+ Variables: message.MetaData,
+ Locale: message.Locale,
+ Email: message.Email,
+ PhoneNumber: message.PhoneNumber,
+ CorrelationId: message.CorrelationId), ct).ConfigureAwait(false);
+ }
+}
diff --git a/backend/src/CCE.Infrastructure/Notifications/Messaging/MassTransitNotificationMessageDispatcher.cs b/backend/src/CCE.Infrastructure/Notifications/Messaging/MassTransitNotificationMessageDispatcher.cs
new file mode 100644
index 00000000..59c252ba
--- /dev/null
+++ b/backend/src/CCE.Infrastructure/Notifications/Messaging/MassTransitNotificationMessageDispatcher.cs
@@ -0,0 +1,27 @@
+using CCE.Application.Notifications.Messages;
+using MassTransit;
+
+namespace CCE.Infrastructure.Notifications.Messaging;
+
+///
+/// Drop-in replacement for .
+/// Instead of calling INotificationGateway inline it publishes a
+/// onto the MassTransit bus so the work
+/// is handled asynchronously by
+/// (which may run in this process, or in a separate worker process).
+///
+///
+/// Wire-up: replace the InProcessNotificationMessageDispatcher DI
+/// registration with this class. See MessagingServiceExtensions.
+///
+///
+public sealed class MassTransitNotificationMessageDispatcher : INotificationMessageDispatcher
+{
+ private readonly IPublishEndpoint _publishEndpoint;
+
+ public MassTransitNotificationMessageDispatcher(IPublishEndpoint publishEndpoint)
+ => _publishEndpoint = publishEndpoint;
+
+ public Task DispatchAsync(NotificationMessage message, CancellationToken ct)
+ => _publishEndpoint.Publish(message, ct);
+}
diff --git a/backend/src/CCE.Infrastructure/Notifications/Messaging/MessagingOptions.cs b/backend/src/CCE.Infrastructure/Notifications/Messaging/MessagingOptions.cs
new file mode 100644
index 00000000..257afff4
--- /dev/null
+++ b/backend/src/CCE.Infrastructure/Notifications/Messaging/MessagingOptions.cs
@@ -0,0 +1,38 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace CCE.Infrastructure.Notifications.Messaging;
+
+///
+/// Bound from appsettings.json section "Messaging".
+///
+public sealed class MessagingOptions
+{
+ public const string SectionName = "Messaging";
+
+ ///
+ /// Transport to use.
+ ///
+ /// - InMemory — default; same process, no broker required (dev / test).
+ /// - RabbitMQ — production; requires config.
+ ///
+ ///
+ [Required]
+ public string Transport { get; init; } = "InMemory";
+
+ /// RabbitMQ host URI, e.g. amqp://guest:guest@localhost.
+ public string? RabbitMqHost { get; init; }
+
+ ///
+ /// Virtual host inside RabbitMQ. Defaults to "/".
+ /// Use a dedicated vhost per environment (dev/staging/prod) to keep queues isolated.
+ ///
+ public string RabbitMqVirtualHost { get; init; } = "/";
+
+ ///
+ /// When true (default), is replaced
+ /// with . Set false to keep
+ /// the synchronous in-process dispatcher even when MassTransit is registered
+ /// (useful for integration tests that mock the gateway).
+ ///
+ public bool UseAsyncDispatcher { get; init; } = true;
+}
diff --git a/backend/src/CCE.Infrastructure/Notifications/Messaging/MessagingServiceExtensions.cs b/backend/src/CCE.Infrastructure/Notifications/Messaging/MessagingServiceExtensions.cs
new file mode 100644
index 00000000..ca756eba
--- /dev/null
+++ b/backend/src/CCE.Infrastructure/Notifications/Messaging/MessagingServiceExtensions.cs
@@ -0,0 +1,82 @@
+using CCE.Application.Notifications.Messages;
+using MassTransit;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+
+namespace CCE.Infrastructure.Notifications.Messaging;
+
+///
+/// Registers MassTransit with the correct transport based on
+/// appsettings.json → Messaging:Transport:
+///
+///
+/// - InMemoryNo broker. Messages flow in-process via a channel. Use for local dev and all tests.
+/// - RabbitMQProduction. Requires Messaging:RabbitMqHost and a running broker.
+///
+///
+/// Call services.AddCceMessaging(configuration) from
+/// .
+///
+public static class MessagingServiceExtensions
+{
+ public static IServiceCollection AddCceMessaging(
+ this IServiceCollection services,
+ IConfiguration configuration)
+ {
+ services.AddOptions()
+ .Bind(configuration.GetSection(MessagingOptions.SectionName))
+ .ValidateDataAnnotations()
+ .ValidateOnStart();
+
+ var options = configuration
+ .GetSection(MessagingOptions.SectionName)
+ .Get() ?? new MessagingOptions();
+
+ services.AddMassTransit(x =>
+ {
+ // Register consumer + its definition (retry policy, concurrency).
+ x.AddConsumer();
+
+ switch (options.Transport.ToUpperInvariant())
+ {
+ case "RABBITMQ":
+ x.UsingRabbitMq((ctx, cfg) =>
+ {
+ cfg.Host(options.RabbitMqHost ?? "amqp://guest:guest@localhost", options.RabbitMqVirtualHost, h =>
+ {
+ // Credentials are embedded in RabbitMqHost URI or set here.
+ // Production: use environment variables / Azure Key Vault secrets.
+ });
+
+ // Auto-configure endpoints from consumer definitions.
+ cfg.ConfigureEndpoints(ctx);
+ });
+ break;
+
+ default: // "InMemory" or missing
+ x.UsingInMemory((ctx, cfg) =>
+ {
+ cfg.ConfigureEndpoints(ctx);
+ });
+ break;
+ }
+ });
+
+ // Replace the synchronous in-process dispatcher with the async bus publisher
+ // only when UseAsyncDispatcher=true (default).
+ if (options.UseAsyncDispatcher)
+ {
+ // Remove the InProcessNotificationMessageDispatcher registered in DependencyInjection.cs
+ var descriptor = services.FirstOrDefault(
+ d => d.ServiceType == typeof(INotificationMessageDispatcher));
+ if (descriptor is not null)
+ services.Remove(descriptor);
+
+ services.AddScoped();
+ }
+
+ return services;
+ }
+}
diff --git a/backend/src/CCE.Infrastructure/Notifications/Messaging/NotificationMessageConsumer.cs b/backend/src/CCE.Infrastructure/Notifications/Messaging/NotificationMessageConsumer.cs
new file mode 100644
index 00000000..b49862a2
--- /dev/null
+++ b/backend/src/CCE.Infrastructure/Notifications/Messaging/NotificationMessageConsumer.cs
@@ -0,0 +1,65 @@
+using CCE.Application.Notifications;
+using CCE.Application.Notifications.Messages;
+using MassTransit;
+using Microsoft.Extensions.Logging;
+
+namespace CCE.Infrastructure.Notifications.Messaging;
+
+///
+/// MassTransit consumer that receives a from
+/// the bus and hands it to for template
+/// resolution, rendering, delivery and logging.
+///
+///
+/// This is the async counterpart to .
+/// The gateway call (and its DB + SMS/Email provider I/O) happens here, off the
+/// original HTTP request thread.
+///
+///
+///
+/// Retry policy is configured on the consumer definition
+/// (): 3 immediate retries,
+/// then messages move to the error queue for manual inspection.
+///
+///
+public sealed class NotificationMessageConsumer : IConsumer
+{
+ private readonly INotificationGateway _gateway;
+ private readonly ILogger _logger;
+
+ public NotificationMessageConsumer(
+ INotificationGateway gateway,
+ ILogger logger)
+ {
+ _gateway = gateway;
+ _logger = logger;
+ }
+
+ public async Task Consume(ConsumeContext context)
+ {
+ var message = context.Message;
+
+ _logger.LogInformation(
+ "Consuming NotificationMessage TemplateCode={TemplateCode} RecipientUserId={RecipientUserId}",
+ message.TemplateCode,
+ message.RecipientUserId);
+
+ var result = await _gateway.SendAsync(new NotificationDispatchRequest(
+ TemplateCode: message.TemplateCode,
+ RecipientUserId: message.RecipientUserId,
+ Channels: message.Channels ?? [],
+ Variables: message.MetaData,
+ Locale: message.Locale,
+ Email: message.Email,
+ PhoneNumber: message.PhoneNumber,
+ CorrelationId: message.CorrelationId),
+ context.CancellationToken).ConfigureAwait(false);
+
+ if (!result.IsSuccess)
+ {
+ _logger.LogWarning(
+ "NotificationMessage TemplateCode={TemplateCode} had one or more failed channel dispatches.",
+ message.TemplateCode);
+ }
+ }
+}
diff --git a/backend/src/CCE.Infrastructure/Notifications/Messaging/NotificationMessageConsumerDefinition.cs b/backend/src/CCE.Infrastructure/Notifications/Messaging/NotificationMessageConsumerDefinition.cs
new file mode 100644
index 00000000..767edf1b
--- /dev/null
+++ b/backend/src/CCE.Infrastructure/Notifications/Messaging/NotificationMessageConsumerDefinition.cs
@@ -0,0 +1,34 @@
+using MassTransit;
+
+namespace CCE.Infrastructure.Notifications.Messaging;
+
+///
+/// Defines retry, concurrency, and queue naming for
+/// .
+///
+/// MassTransit picks this up automatically via AddConsumer<,>.
+///
+public sealed class NotificationMessageConsumerDefinition
+ : ConsumerDefinition
+{
+ public NotificationMessageConsumerDefinition()
+ {
+ // One concurrent message per consumer instance (safe for DB write heavy work).
+ ConcurrentMessageLimit = 10;
+ }
+
+ protected override void ConfigureConsumer(
+ IReceiveEndpointConfigurator endpointConfigurator,
+ IConsumerConfigurator consumerConfigurator,
+ IRegistrationContext context)
+ {
+ // 3 immediate retries, 5-second interval.
+ // After exhausting retries MassTransit moves the message to the
+ // _error queue automatically — no message is silently dropped.
+ endpointConfigurator.UseMessageRetry(r =>
+ r.Intervals(
+ TimeSpan.FromSeconds(5),
+ TimeSpan.FromSeconds(15),
+ TimeSpan.FromSeconds(30)));
+ }
+}
diff --git a/backend/src/CCE.Infrastructure/Notifications/NotificationGateway.cs b/backend/src/CCE.Infrastructure/Notifications/NotificationGateway.cs
new file mode 100644
index 00000000..12e82d4f
--- /dev/null
+++ b/backend/src/CCE.Infrastructure/Notifications/NotificationGateway.cs
@@ -0,0 +1,288 @@
+using System.Text.Json;
+using CCE.Application.Common.Interfaces;
+using CCE.Application.Common.Pagination;
+using CCE.Application.Notifications;
+using CCE.Domain.Common;
+using CCE.Domain.Notifications;
+using Microsoft.Extensions.Logging;
+
+namespace CCE.Infrastructure.Notifications;
+
+public sealed class NotificationGateway : INotificationGateway
+{
+ private readonly ICceDbContext _db;
+ private readonly ICurrentUserAccessor _currentUser;
+ private readonly INotificationTemplateRepository _templates;
+ private readonly IUserNotificationSettingsRepository _settings;
+ private readonly INotificationLogRepository _logs;
+ private readonly INotificationTemplateRenderer _renderer;
+ private readonly IEnumerable _channelHandlers;
+ private readonly ISignalRNotificationPublisher? _signalR;
+ private readonly ILogger _logger;
+
+ public NotificationGateway(
+ ICceDbContext db,
+ ICurrentUserAccessor currentUser,
+ INotificationTemplateRepository templates,
+ IUserNotificationSettingsRepository settings,
+ INotificationLogRepository logs,
+ INotificationTemplateRenderer renderer,
+ IEnumerable channelHandlers,
+ ILogger logger,
+ ISignalRNotificationPublisher? signalR = null)
+ {
+ _db = db;
+ _currentUser = currentUser;
+ _templates = templates;
+ _settings = settings;
+ _logs = logs;
+ _renderer = renderer;
+ _channelHandlers = channelHandlers;
+ _logger = logger;
+ _signalR = signalR;
+ }
+
+ public async Task SendAsync(
+ NotificationDispatchRequest request,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+
+ if (string.IsNullOrWhiteSpace(request.TemplateCode))
+ throw new DomainException("TemplateCode is required.");
+
+ var requestedChannels = request.Channels?.ToList() ?? [];
+
+ // Resolve recipient data
+ string? email = request.Email;
+ string? phone = request.PhoneNumber;
+ string locale = request.Locale;
+
+ if (request.RecipientUserId is { } userId)
+ {
+ var user = (await _db.Users
+ .Where(u => u.Id == userId)
+ .Select(u => new { u.Email, u.PhoneNumber })
+ .ToListAsyncEither(cancellationToken)
+ .ConfigureAwait(false))
+ .FirstOrDefault();
+
+ if (user is not null)
+ {
+ email ??= user.Email;
+ phone ??= user.PhoneNumber;
+ }
+ }
+
+ var correlationId = request.CorrelationId ?? _currentUser.GetCorrelationId().ToString("N");
+ var results = new List();
+ var inAppUserNotifications = new List();
+
+ var templates = await _templates
+ .ListActiveByCodeAsync(request.TemplateCode, cancellationToken)
+ .ConfigureAwait(false);
+
+ var templateByChannel = templates.ToDictionary(t => t.Channel);
+ var channels = requestedChannels.Count == 0
+ ? templateByChannel.Keys.ToList()
+ : requestedChannels;
+
+ if (channels.Count == 0)
+ {
+ _logger.LogWarning(
+ "No active notification templates found for code {TemplateCode}.",
+ request.TemplateCode);
+ return new NotificationDispatchResult(
+ request.TemplateCode,
+ request.RecipientUserId,
+ []);
+ }
+
+ // Load user settings if applicable
+ Dictionary<(NotificationChannel, string?), UserNotificationSettings>? settingsMap = null;
+ if (request.RecipientUserId is { } settingsUserId)
+ {
+ var settings = await _settings
+ .ListForUserAndChannelsAsync(settingsUserId, channels, cancellationToken)
+ .ConfigureAwait(false);
+
+ settingsMap = settings.ToDictionary(
+ s => (s.Channel, (string?)s.EventCode),
+ s => s);
+ }
+
+ foreach (var channel in channels)
+ {
+ var result = await DispatchChannelAsync(
+ request,
+ channel,
+ email,
+ phone,
+ locale,
+ templateByChannel,
+ settingsMap,
+ correlationId,
+ inAppUserNotifications,
+ cancellationToken).ConfigureAwait(false);
+
+ results.Add(result);
+ }
+
+ await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+
+ // SignalR push after persistence
+ if (_signalR is not null && inAppUserNotifications.Count > 0)
+ {
+ foreach (var notif in inAppUserNotifications)
+ {
+ await _signalR.PublishAsync(notif, cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ return new NotificationDispatchResult(
+ request.TemplateCode,
+ request.RecipientUserId,
+ results);
+ }
+
+ private async Task DispatchChannelAsync(
+ NotificationDispatchRequest request,
+ NotificationChannel channel,
+ string? email,
+ string? phone,
+ string locale,
+ Dictionary templateByChannel,
+ Dictionary<(NotificationChannel, string?), UserNotificationSettings>? settingsMap,
+ string correlationId,
+ List inAppUserNotifications,
+ CancellationToken cancellationToken)
+ {
+ // Skip in-app/SMS for anonymous users
+ if (request.RecipientUserId is null && channel is NotificationChannel.InApp)
+ {
+ return new NotificationChannelDispatchResult(
+ channel,
+ NotificationDeliveryStatus.Skipped,
+ Error: "In-app notifications require a recipient user ID.");
+ }
+
+ UserNotificationSettings? channelSettings = null;
+ if (!request.BypassSettings && settingsMap is not null)
+ {
+ var eventKey = (channel, (string?)request.TemplateCode);
+ var defaultKey = (channel, (string?)null);
+
+ if (!settingsMap.TryGetValue(eventKey, out channelSettings))
+ {
+ settingsMap.TryGetValue(defaultKey, out channelSettings);
+ }
+ }
+
+ // Resolve template
+ if (!templateByChannel.TryGetValue(channel, out var template))
+ {
+ var log = NotificationLog.Create(
+ request.RecipientUserId,
+ request.TemplateCode,
+ null,
+ channel,
+ correlationId: correlationId);
+ log.MarkSkipped($"No active template found for channel {channel}.");
+ await _logs.AddAsync(log, cancellationToken).ConfigureAwait(false);
+
+ return new NotificationChannelDispatchResult(
+ channel,
+ NotificationDeliveryStatus.Skipped,
+ NotificationLogId: log.Id,
+ Error: $"No active template found for channel {channel}.");
+ }
+
+ // Render
+ var variables = request.Variables ?? new Dictionary();
+ var (subjectAr, subjectEn, body) = _renderer.Render(template, variables, locale);
+ var subject = locale == "ar" ? subjectAr : subjectEn;
+
+ var rendered = new RenderedNotification(
+ request.TemplateCode,
+ request.RecipientUserId,
+ template.Id,
+ subject,
+ subjectAr,
+ subjectEn,
+ body,
+ channel,
+ locale,
+ email,
+ phone);
+
+ // Create pending log
+ var payloadJson = SerializePayload(variables);
+ var notificationLog = NotificationLog.Create(
+ request.RecipientUserId,
+ request.TemplateCode,
+ template.Id,
+ channel,
+ payloadJson,
+ correlationId);
+ await _logs.AddAsync(notificationLog, cancellationToken).ConfigureAwait(false);
+
+ // Dispatch
+ var sender = _channelHandlers.FirstOrDefault(s => s.Channel == channel);
+ if (sender is null)
+ {
+ notificationLog.MarkSkipped($"No sender registered for channel {channel}.");
+ return new NotificationChannelDispatchResult(
+ channel,
+ NotificationDeliveryStatus.Skipped,
+ NotificationLogId: notificationLog.Id,
+ Error: $"No sender registered for channel {channel}.");
+ }
+
+ if (!sender.ShouldSend(channelSettings))
+ {
+ notificationLog.MarkSkipped("Channel disabled by user settings.");
+ return new NotificationChannelDispatchResult(
+ channel,
+ NotificationDeliveryStatus.Skipped,
+ NotificationLogId: notificationLog.Id,
+ Error: "Channel disabled by user settings.");
+ }
+
+ var sendResult = await sender.SendAsync(rendered, cancellationToken).ConfigureAwait(false);
+
+ if (sendResult.Success)
+ {
+ notificationLog.MarkSent(sendResult.ProviderMessageId);
+ }
+ else
+ {
+ notificationLog.MarkFailed(sendResult.Error ?? "Unknown error");
+ }
+
+ // Collect in-app notifications for batch persistence
+ if (channel == NotificationChannel.InApp && sendResult.UserNotification is { } userNotification)
+ {
+ inAppUserNotifications.Add(userNotification);
+ }
+
+ return new NotificationChannelDispatchResult(
+ channel,
+ sendResult.Success ? NotificationDeliveryStatus.Sent : NotificationDeliveryStatus.Failed,
+ NotificationLogId: notificationLog.Id,
+ UserNotificationId: sendResult.UserNotificationId,
+ ProviderMessageId: sendResult.ProviderMessageId,
+ Error: sendResult.Error);
+ }
+
+ private static string? SerializePayload(IReadOnlyDictionary variables)
+ {
+ try
+ {
+ return JsonSerializer.Serialize(variables);
+ }
+ catch (JsonException)
+ {
+ return null;
+ }
+ }
+}
diff --git a/backend/src/CCE.Infrastructure/Notifications/NotificationLogRepository.cs b/backend/src/CCE.Infrastructure/Notifications/NotificationLogRepository.cs
new file mode 100644
index 00000000..f91d02e9
--- /dev/null
+++ b/backend/src/CCE.Infrastructure/Notifications/NotificationLogRepository.cs
@@ -0,0 +1,14 @@
+using CCE.Application.Notifications;
+using CCE.Domain.Notifications;
+using CCE.Infrastructure.Persistence;
+using Microsoft.EntityFrameworkCore;
+
+namespace CCE.Infrastructure.Notifications;
+
+public sealed class NotificationLogRepository : EntityRepository, INotificationLogRepository
+{
+ public NotificationLogRepository(CceDbContext db) : base(db) { }
+
+ public async Task GetAsync(System.Guid id, CancellationToken ct)
+ => await Db.NotificationLogs.FirstOrDefaultAsync(l => l.Id == id, ct).ConfigureAwait(false);
+}
diff --git a/backend/src/CCE.Infrastructure/Notifications/NotificationTemplateRenderer.cs b/backend/src/CCE.Infrastructure/Notifications/NotificationTemplateRenderer.cs
new file mode 100644
index 00000000..3b67c33e
--- /dev/null
+++ b/backend/src/CCE.Infrastructure/Notifications/NotificationTemplateRenderer.cs
@@ -0,0 +1,84 @@
+using System.Text.Json;
+using System.Text.RegularExpressions;
+using CCE.Application.Notifications;
+using CCE.Domain.Common;
+using CCE.Domain.Notifications;
+
+namespace CCE.Infrastructure.Notifications;
+
+///
+/// Replaces {{Variable}} placeholders in template subject/body with values from the provided dictionary.
+///
+public sealed class NotificationTemplateRenderer : INotificationTemplateRenderer
+{
+ private static readonly Regex PlaceholderPattern = new(@"\{\{(\w+)\}\}", RegexOptions.Compiled);
+
+ public (string SubjectAr, string SubjectEn, string Body) Render(
+ NotificationTemplate template,
+ IReadOnlyDictionary variables,
+ string locale)
+ {
+ ArgumentNullException.ThrowIfNull(template);
+ ArgumentNullException.ThrowIfNull(variables);
+ if (locale != "ar" && locale != "en")
+ throw new DomainException("Locale must be 'ar' or 'en'.");
+
+ ValidateVariables(template, variables);
+
+ var subjectAr = ReplacePlaceholders(template.SubjectAr, variables);
+ var subjectEn = ReplacePlaceholders(template.SubjectEn, variables);
+ var body = locale == "ar"
+ ? ReplacePlaceholders(template.BodyAr, variables)
+ : ReplacePlaceholders(template.BodyEn, variables);
+
+ return (subjectAr, subjectEn, body);
+ }
+
+ private static void ValidateVariables(NotificationTemplate template, IReadOnlyDictionary variables)
+ {
+ var requiredKeys = ExtractRequiredKeys(template.VariableSchemaJson);
+ foreach (var key in requiredKeys)
+ {
+ if (!variables.ContainsKey(key) || string.IsNullOrWhiteSpace(variables[key]))
+ throw new DomainException($"Missing required notification variable: '{key}'.");
+ }
+ }
+
+ private static HashSet ExtractRequiredKeys(string variableSchemaJson)
+ {
+ try
+ {
+ using var doc = JsonDocument.Parse(variableSchemaJson);
+ if (doc.RootElement.ValueKind != JsonValueKind.Object)
+ return [];
+
+ var required = new HashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (var property in doc.RootElement.EnumerateObject())
+ {
+ if (property.Value.ValueKind == JsonValueKind.Object)
+ {
+ if (property.Value.TryGetProperty("required", out var reqProp) &&
+ reqProp.ValueKind == JsonValueKind.True)
+ {
+ required.Add(property.Name);
+ }
+ }
+ }
+ return required;
+ }
+ catch (JsonException)
+ {
+ // If schema is not valid JSON, fall back to extracting placeholders from the template body
+ return [];
+ }
+ }
+
+ private static string ReplacePlaceholders(string templateText, IReadOnlyDictionary variables)
+ {
+ return PlaceholderPattern.Replace(templateText, match =>
+ {
+ var key = match.Groups[1].Value;
+ return variables.TryGetValue(key, out var value) ? value : match.Value;
+ });
+ }
+}
diff --git a/backend/src/CCE.Infrastructure/Notifications/NotificationTemplateRepository.cs b/backend/src/CCE.Infrastructure/Notifications/NotificationTemplateRepository.cs
new file mode 100644
index 00000000..fd8f4bb8
--- /dev/null
+++ b/backend/src/CCE.Infrastructure/Notifications/NotificationTemplateRepository.cs
@@ -0,0 +1,30 @@
+using CCE.Application.Notifications;
+using CCE.Domain.Notifications;
+using CCE.Infrastructure.Persistence;
+using Microsoft.EntityFrameworkCore;
+
+namespace CCE.Infrastructure.Notifications;
+
+public sealed class NotificationTemplateRepository : EntityRepository, INotificationTemplateRepository
+{
+ public NotificationTemplateRepository(CceDbContext db) : base(db) { }
+
+ public async Task GetAsync(System.Guid id, CancellationToken ct)
+ => await Db.NotificationTemplates.FirstOrDefaultAsync(t => t.Id == id, ct).ConfigureAwait(false);
+
+ public async Task GetActiveByCodeAndChannelAsync(
+ string code,
+ NotificationChannel channel,
+ CancellationToken ct)
+ => await Db.NotificationTemplates
+ .FirstOrDefaultAsync(t => t.Code == code && t.Channel == channel && t.IsActive, ct)
+ .ConfigureAwait(false);
+
+ public async Task> ListActiveByCodeAsync(
+ string code,
+ CancellationToken ct)
+ => await Db.NotificationTemplates
+ .Where(t => t.Code == code && t.IsActive)
+ .ToListAsync(ct)
+ .ConfigureAwait(false);
+}
diff --git a/backend/src/CCE.Infrastructure/Notifications/NotificationTemplateService.cs b/backend/src/CCE.Infrastructure/Notifications/NotificationTemplateService.cs
deleted file mode 100644
index 2f8b402c..00000000
--- a/backend/src/CCE.Infrastructure/Notifications/NotificationTemplateService.cs
+++ /dev/null
@@ -1,32 +0,0 @@
-using CCE.Application.Notifications;
-using CCE.Domain.Notifications;
-using CCE.Infrastructure.Persistence;
-using Microsoft.EntityFrameworkCore;
-
-namespace CCE.Infrastructure.Notifications;
-
-public sealed class NotificationTemplateService : INotificationTemplateService
-{
- private readonly CceDbContext _db;
-
- public NotificationTemplateService(CceDbContext db)
- {
- _db = db;
- }
-
- public async Task SaveAsync(NotificationTemplate template, CancellationToken ct)
- {
- _db.NotificationTemplates.Add(template);
- await _db.SaveChangesAsync(ct).ConfigureAwait(false);
- }
-
- public async Task FindAsync(System.Guid id, CancellationToken ct)
- {
- return await _db.NotificationTemplates.FirstOrDefaultAsync(t => t.Id == id, ct).ConfigureAwait(false);
- }
-
- public async Task UpdateAsync(NotificationTemplate template, CancellationToken ct)
- {
- await _db.SaveChangesAsync(ct).ConfigureAwait(false);
- }
-}
diff --git a/backend/src/CCE.Infrastructure/Notifications/NotificationsHub.cs b/backend/src/CCE.Infrastructure/Notifications/NotificationsHub.cs
new file mode 100644
index 00000000..8e237417
--- /dev/null
+++ b/backend/src/CCE.Infrastructure/Notifications/NotificationsHub.cs
@@ -0,0 +1,28 @@
+using Microsoft.AspNetCore.SignalR;
+
+namespace CCE.Infrastructure.Notifications;
+
+public sealed class NotificationsHub : Hub
+{
+ public override async Task OnConnectedAsync()
+ {
+ var userId = Context.UserIdentifier;
+ if (!string.IsNullOrWhiteSpace(userId))
+ {
+ await Groups.AddToGroupAsync(Context.ConnectionId, $"user:{userId}").ConfigureAwait(false);
+ }
+
+ await base.OnConnectedAsync().ConfigureAwait(false);
+ }
+
+ public override async Task OnDisconnectedAsync(Exception? exception)
+ {
+ var userId = Context.UserIdentifier;
+ if (!string.IsNullOrWhiteSpace(userId))
+ {
+ await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"user:{userId}").ConfigureAwait(false);
+ }
+
+ await base.OnDisconnectedAsync(exception).ConfigureAwait(false);
+ }
+}
diff --git a/backend/src/CCE.Infrastructure/Notifications/SignalRNotificationPublisher.cs b/backend/src/CCE.Infrastructure/Notifications/SignalRNotificationPublisher.cs
new file mode 100644
index 00000000..af8ebc6d
--- /dev/null
+++ b/backend/src/CCE.Infrastructure/Notifications/SignalRNotificationPublisher.cs
@@ -0,0 +1,47 @@
+using CCE.Application.Notifications;
+using CCE.Domain.Notifications;
+using Microsoft.AspNetCore.SignalR;
+using Microsoft.Extensions.Logging;
+
+namespace CCE.Infrastructure.Notifications;
+
+public sealed class SignalRNotificationPublisher : ISignalRNotificationPublisher
+{
+ private readonly IHubContext _hubContext;
+ private readonly ILogger _logger;
+
+ public SignalRNotificationPublisher(
+ IHubContext hubContext,
+ ILogger logger)
+ {
+ _hubContext = hubContext;
+ _logger = logger;
+ }
+
+ public async Task PublishAsync(UserNotification notification, CancellationToken cancellationToken)
+ {
+ _logger.LogInformation(
+ "Publishing notification {NotificationId} to user {UserId}",
+ notification.Id,
+ notification.UserId);
+
+ await _hubContext
+ .Clients
+ .User(notification.UserId.ToString())
+ .SendAsync(
+ "ReceiveNotification",
+ new
+ {
+ notification.Id,
+ notification.TemplateId,
+ notification.RenderedSubjectAr,
+ notification.RenderedSubjectEn,
+ notification.RenderedBody,
+ notification.RenderedLocale,
+ notification.Status,
+ notification.SentOn
+ },
+ cancellationToken)
+ .ConfigureAwait(false);
+ }
+}
diff --git a/backend/src/CCE.Infrastructure/Notifications/SmsNotificationChannelSender.cs b/backend/src/CCE.Infrastructure/Notifications/SmsNotificationChannelSender.cs
new file mode 100644
index 00000000..2cba3d13
--- /dev/null
+++ b/backend/src/CCE.Infrastructure/Notifications/SmsNotificationChannelSender.cs
@@ -0,0 +1,88 @@
+using CCE.Application.Notifications;
+using CCE.Domain.Notifications;
+using CCE.Integration.Communication;
+using Microsoft.Extensions.Logging;
+
+namespace CCE.Infrastructure.Notifications;
+
+public sealed class SmsNotificationChannelSender : INotificationChannelHandler
+{
+ private readonly ICommunicationGatewayClient _client;
+ private readonly ILogger _logger;
+
+ public SmsNotificationChannelSender(
+ ICommunicationGatewayClient client,
+ ILogger logger)
+ {
+ _client = client;
+ _logger = logger;
+ }
+
+ public NotificationChannel Channel => NotificationChannel.Sms;
+
+ public bool ShouldSend(UserNotificationSettings? settings) => settings?.IsEnabled ?? true;
+
+ public async Task SendAsync(
+ RenderedNotification notification,
+ CancellationToken cancellationToken)
+ {
+ var to = notification.PhoneNumber;
+ if (string.IsNullOrWhiteSpace(to))
+ {
+ _logger.LogWarning(
+ "Skipping SMS for template {TemplateCode}: no phone number.",
+ notification.TemplateCode);
+ return new ChannelSendResult(
+ false, Error: "No recipient phone number available.");
+ }
+
+ try
+ {
+ var request = new SendSmsRequest(
+ To: to,
+ Message: notification.Body);
+
+ var response = await _client.SendSmsAsync(request, cancellationToken)
+ .ConfigureAwait(false);
+
+ if (!"success".Equals(response.Status, StringComparison.OrdinalIgnoreCase))
+ {
+ _logger.LogError(
+ "Gateway SMS send failed for {To} template {TemplateCode}: {Error}",
+ to, notification.TemplateCode, response.Error);
+ return new ChannelSendResult(
+ false, Error: $"Gateway SMS send failed: {response.Error}");
+ }
+
+ _logger.LogInformation(
+ "Sent SMS via gateway to {To} template {TemplateCode} (id {Id})",
+ to, notification.TemplateCode, response.Id);
+
+ return new ChannelSendResult(true, ProviderMessageId: response.Id);
+ }
+ catch (System.Net.Http.HttpRequestException ex)
+ {
+ _logger.LogError(
+ ex,
+ "SMS channel HTTP failure for template {TemplateCode}",
+ notification.TemplateCode);
+ return new ChannelSendResult(false, Error: ex.Message);
+ }
+ catch (InvalidOperationException ex)
+ {
+ _logger.LogError(
+ ex,
+ "SMS channel invalid operation for template {TemplateCode}",
+ notification.TemplateCode);
+ return new ChannelSendResult(false, Error: ex.Message);
+ }
+ catch (OperationCanceledException ex) when (ex.CancellationToken != cancellationToken)
+ {
+ _logger.LogError(
+ ex,
+ "SMS channel timeout for template {TemplateCode}",
+ notification.TemplateCode);
+ return new ChannelSendResult(false, Error: ex.Message);
+ }
+ }
+}
diff --git a/backend/src/CCE.Infrastructure/Notifications/UserNotificationRepository.cs b/backend/src/CCE.Infrastructure/Notifications/UserNotificationRepository.cs
new file mode 100644
index 00000000..5fee3b98
--- /dev/null
+++ b/backend/src/CCE.Infrastructure/Notifications/UserNotificationRepository.cs
@@ -0,0 +1,34 @@
+using CCE.Application.Notifications.Public;
+using CCE.Domain.Common;
+using CCE.Domain.Notifications;
+using CCE.Infrastructure.Persistence;
+using Microsoft.EntityFrameworkCore;
+
+namespace CCE.Infrastructure.Notifications;
+
+public sealed class UserNotificationRepository : EntityRepository, IUserNotificationRepository
+{
+ public UserNotificationRepository(CceDbContext db) : base(db) { }
+
+ public async Task GetAsync(System.Guid id, CancellationToken ct)
+ => await Db.UserNotifications.FirstOrDefaultAsync(n => n.Id == id, ct).ConfigureAwait(false);
+
+ public async Task MarkAllSentAsReadAsync(
+ System.Guid userId,
+ ISystemClock clock,
+ CancellationToken ct)
+ {
+ var notifications = await Db.UserNotifications
+ .Where(n => n.UserId == userId && n.Status == NotificationStatus.Sent)
+ .ToListAsync(ct)
+ .ConfigureAwait(false);
+
+ foreach (var n in notifications)
+ {
+ n.MarkRead(clock);
+ }
+
+ await Db.SaveChangesAsync(ct).ConfigureAwait(false);
+ return notifications.Count;
+ }
+}
diff --git a/backend/src/CCE.Infrastructure/Notifications/UserNotificationService.cs b/backend/src/CCE.Infrastructure/Notifications/UserNotificationService.cs
deleted file mode 100644
index 3f12870c..00000000
--- a/backend/src/CCE.Infrastructure/Notifications/UserNotificationService.cs
+++ /dev/null
@@ -1,37 +0,0 @@
-using CCE.Application.Notifications.Public;
-using CCE.Domain.Common;
-using CCE.Domain.Notifications;
-using CCE.Infrastructure.Persistence;
-using Microsoft.EntityFrameworkCore;
-
-namespace CCE.Infrastructure.Notifications;
-
-public sealed class UserNotificationService : IUserNotificationService
-{
- private readonly CceDbContext _db;
- private readonly ISystemClock _clock;
-
- public UserNotificationService(CceDbContext db, ISystemClock clock)
- {
- _db = db;
- _clock = clock;
- }
-
- public async Task FindAsync(System.Guid id, CancellationToken ct)
- => await _db.UserNotifications.FirstOrDefaultAsync(n => n.Id == id, ct).ConfigureAwait(false);
-
- public async Task UpdateAsync(UserNotification notification, CancellationToken ct)
- => await _db.SaveChangesAsync(ct).ConfigureAwait(false);
-
- public async Task MarkAllSentAsReadAsync(System.Guid userId, CancellationToken ct)
- {
- var now = _clock.UtcNow;
- // EF Core 7+ bulk update. Atomic.
- return await _db.UserNotifications
- .Where(n => n.UserId == userId && n.Status == NotificationStatus.Sent)
- .ExecuteUpdateAsync(setters => setters
- .SetProperty(n => n.Status, NotificationStatus.Read)
- .SetProperty(n => n.ReadOn, (System.DateTimeOffset?)now), ct)
- .ConfigureAwait(false);
- }
-}
diff --git a/backend/src/CCE.Infrastructure/Notifications/UserNotificationSettingsRepository.cs b/backend/src/CCE.Infrastructure/Notifications/UserNotificationSettingsRepository.cs
new file mode 100644
index 00000000..b270d41e
--- /dev/null
+++ b/backend/src/CCE.Infrastructure/Notifications/UserNotificationSettingsRepository.cs
@@ -0,0 +1,39 @@
+using CCE.Application.Notifications;
+using CCE.Domain.Notifications;
+using CCE.Infrastructure.Persistence;
+using Microsoft.EntityFrameworkCore;
+
+namespace CCE.Infrastructure.Notifications;
+
+public sealed class UserNotificationSettingsRepository : EntityRepository, IUserNotificationSettingsRepository
+{
+ public UserNotificationSettingsRepository(CceDbContext db) : base(db) { }
+
+ public async Task GetAsync(
+ System.Guid userId,
+ NotificationChannel channel,
+ string? eventCode,
+ CancellationToken ct)
+ => await Db.UserNotificationSettings
+ .FirstOrDefaultAsync(
+ s => s.UserId == userId && s.Channel == channel && s.EventCode == eventCode,
+ ct)
+ .ConfigureAwait(false);
+
+ public async Task> ListForUserAsync(
+ System.Guid userId,
+ CancellationToken ct)
+ => await Db.UserNotificationSettings
+ .Where(s => s.UserId == userId)
+ .ToListAsync(ct)
+ .ConfigureAwait(false);
+
+ public async Task