diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index a998dc31..5f9b2126 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -110,6 +110,14 @@ + + + + + + + + @@ -146,4 +154,13 @@ + + + + + + + + + diff --git a/backend/permissions.yaml b/backend/permissions.yaml index b98988af..6acdc391 100644 --- a/backend/permissions.yaml +++ b/backend/permissions.yaml @@ -152,6 +152,12 @@ groups: TemplateManage: description: Manage notification templates roles: [cce-super-admin, cce-admin] + LogView: + description: View notification logs and retry failed deliveries + roles: [cce-super-admin, cce-admin] + Send: + description: Send manual/admin notifications + roles: [cce-super-admin, cce-admin] Audit: Read: description: Query the audit-event log diff --git a/backend/src/CCE.Api.Common/Localization/Resources.yaml b/backend/src/CCE.Api.Common/Localization/Resources.yaml index 70e8107e..a4c36793 100644 --- a/backend/src/CCE.Api.Common/Localization/Resources.yaml +++ b/backend/src/CCE.Api.Common/Localization/Resources.yaml @@ -411,3 +411,23 @@ SETTINGS_UPDATED: CONTENT_UPDATE_FAILED: ar: "عذراً، حدثت مشكلة أثناء تحديث المحتوى" en: "Sorry, a problem occurred while updating the content" + +NOTIFICATION_SETTINGS_UPDATED: + ar: "تم تحديث إعدادات الإشعارات بنجاح" + en: "Notification settings updated successfully" + +NOTIFICATION_RETRIED: + ar: "تمت إعادة إرسال الإشعار بنجاح" + en: "Notification retried successfully" + +NOTIFICATIONS_MARKED_READ: + ar: "تم تحديد الإشعارات كمقروءة" + en: "Notifications marked as read" + +NOTIFICATION_TEMPLATE_CREATED: + ar: "تم إنشاء قالب الإشعار بنجاح" + en: "Notification template created successfully" + +NOTIFICATION_TEMPLATE_UPDATED: + ar: "تم تحديث قالب الإشعار بنجاح" + en: "Notification template updated successfully" diff --git a/backend/src/CCE.Api.External/Endpoints/NotificationsEndpoints.cs b/backend/src/CCE.Api.External/Endpoints/NotificationsEndpoints.cs index 91099b75..c941a216 100644 --- a/backend/src/CCE.Api.External/Endpoints/NotificationsEndpoints.cs +++ b/backend/src/CCE.Api.External/Endpoints/NotificationsEndpoints.cs @@ -1,6 +1,9 @@ +using CCE.Api.Common.Extensions; using CCE.Application.Common.Interfaces; using CCE.Application.Notifications.Public.Commands.MarkAllNotificationsRead; using CCE.Application.Notifications.Public.Commands.MarkNotificationRead; +using CCE.Application.Notifications.Public.Commands.UpdateMyNotificationSettings; +using CCE.Application.Notifications.Public.Queries.GetMyNotificationSettings; using CCE.Application.Notifications.Public.Queries.GetMyUnreadCount; using CCE.Application.Notifications.Public.Queries.ListMyNotifications; using CCE.Domain.Notifications; @@ -28,7 +31,7 @@ public static IEndpointRouteBuilder MapNotificationsEndpoints(this IEndpointRout if (userId == System.Guid.Empty) return Results.Unauthorized(); var query = new ListMyNotificationsQuery(userId, page ?? 1, pageSize ?? 20, status); var result = await mediator.Send(query, ct).ConfigureAwait(false); - return Results.Ok(result); + return result.ToHttpResult(); }).WithName("ListMyNotifications"); notif.MapGet("/unread-count", async ( @@ -37,8 +40,8 @@ public static IEndpointRouteBuilder MapNotificationsEndpoints(this IEndpointRout { var userId = currentUser.GetUserId() ?? System.Guid.Empty; if (userId == System.Guid.Empty) return Results.Unauthorized(); - var count = await mediator.Send(new GetMyUnreadCountQuery(userId), ct).ConfigureAwait(false); - return Results.Ok(new { count }); + var result = await mediator.Send(new GetMyUnreadCountQuery(userId), ct).ConfigureAwait(false); + return result.ToHttpResult(); }).WithName("GetMyUnreadNotificationCount"); notif.MapPost("/{id:guid}/mark-read", async ( @@ -48,8 +51,8 @@ public static IEndpointRouteBuilder MapNotificationsEndpoints(this IEndpointRout { var userId = currentUser.GetUserId() ?? System.Guid.Empty; if (userId == System.Guid.Empty) return Results.Unauthorized(); - await mediator.Send(new MarkNotificationReadCommand(id, userId), ct).ConfigureAwait(false); - return Results.NoContent(); + var result = await mediator.Send(new MarkNotificationReadCommand(id, userId), ct).ConfigureAwait(false); + return result.ToHttpResult(); }).WithName("MarkNotificationRead"); notif.MapPost("/mark-all-read", async ( @@ -58,10 +61,36 @@ public static IEndpointRouteBuilder MapNotificationsEndpoints(this IEndpointRout { var userId = currentUser.GetUserId() ?? System.Guid.Empty; if (userId == System.Guid.Empty) return Results.Unauthorized(); - var marked = await mediator.Send(new MarkAllNotificationsReadCommand(userId), ct).ConfigureAwait(false); - return Results.Ok(new { marked }); + var result = await mediator.Send(new MarkAllNotificationsReadCommand(userId), ct).ConfigureAwait(false); + return result.ToHttpResult(); }).WithName("MarkAllNotificationsRead"); + notif.MapGet("/settings", async ( + ICurrentUserAccessor currentUser, + IMediator mediator, CancellationToken ct) => + { + var userId = currentUser.GetUserId() ?? System.Guid.Empty; + if (userId == System.Guid.Empty) return Results.Unauthorized(); + var result = await mediator.Send(new GetMyNotificationSettingsQuery(userId), ct).ConfigureAwait(false); + return result.ToHttpResult(); + }).WithName("GetMyNotificationSettings"); + + notif.MapPut("/settings", async ( + UpdateMyNotificationSettingsRequest body, + ICurrentUserAccessor currentUser, + IMediator mediator, CancellationToken ct) => + { + var userId = currentUser.GetUserId() ?? System.Guid.Empty; + if (userId == System.Guid.Empty) return Results.Unauthorized(); + var command = new UpdateMyNotificationSettingsCommand( + userId, + body.Channel, + body.IsEnabled, + body.EventCode); + var result = await mediator.Send(command, ct).ConfigureAwait(false); + return result.ToHttpResult(); + }).WithName("UpdateMyNotificationSettings"); + return app; } } diff --git a/backend/src/CCE.Api.External/Endpoints/UpdateMyNotificationSettingsRequest.cs b/backend/src/CCE.Api.External/Endpoints/UpdateMyNotificationSettingsRequest.cs new file mode 100644 index 00000000..4c77f078 --- /dev/null +++ b/backend/src/CCE.Api.External/Endpoints/UpdateMyNotificationSettingsRequest.cs @@ -0,0 +1,8 @@ +using CCE.Domain.Notifications; + +namespace CCE.Api.External.Endpoints; + +public sealed record UpdateMyNotificationSettingsRequest( + NotificationChannel Channel, + bool IsEnabled, + string? EventCode = null); diff --git a/backend/src/CCE.Api.External/Hubs/SubClaimUserIdProvider.cs b/backend/src/CCE.Api.External/Hubs/SubClaimUserIdProvider.cs new file mode 100644 index 00000000..143f2be6 --- /dev/null +++ b/backend/src/CCE.Api.External/Hubs/SubClaimUserIdProvider.cs @@ -0,0 +1,17 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.SignalR; + +namespace CCE.Api.External.Hubs; + +/// +/// Routes SignalR messages by the JWT sub claim so that +/// Clients.User(userId) matches the CCE user identifier. +/// +public sealed class SubClaimUserIdProvider : IUserIdProvider +{ + public string? GetUserId(HubConnectionContext connection) + { + return connection.User?.FindFirstValue("sub") + ?? connection.User?.FindFirstValue(ClaimTypes.NameIdentifier); + } +} diff --git a/backend/src/CCE.Api.External/Program.cs b/backend/src/CCE.Api.External/Program.cs index 1473e30d..5fff70b1 100644 --- a/backend/src/CCE.Api.External/Program.cs +++ b/backend/src/CCE.Api.External/Program.cs @@ -8,12 +8,15 @@ using CCE.Api.Common.OpenApi; using CCE.Api.Common.RateLimiting; using CCE.Api.External.Endpoints; +using CCE.Api.External.Hubs; using CCE.Application; +using CCE.Infrastructure.Notifications; using CCE.Application.Common.CountryScope; using CCE.Application.Common.Interfaces; using CCE.Application.Health; using CCE.Infrastructure; using MediatR; +using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.DependencyInjection.Extensions; using Serilog; using System.Globalization; @@ -50,6 +53,8 @@ builder.Services.AddHttpContextAccessor(); builder.Services.Replace(ServiceDescriptor.Scoped()); builder.Services.Replace(ServiceDescriptor.Scoped()); +builder.Services.Replace(ServiceDescriptor.Singleton()); +builder.Services.AddSignalR().AddJsonProtocol(); var app = builder.Build(); @@ -83,9 +88,11 @@ // deployments leave the flag false → endpoints are never mounted. if (builder.Configuration.GetValue("Auth:DevMode")) { - app.MapDevAuthEndpoints(); + app.MapDevAuthEndpoints(); } +app.MapHub("/hubs/notifications"); + app.MapProfileEndpoints(); app.MapAuthEndpoints(CCE.Application.Identity.Auth.Common.LocalAuthApi.External); app.MapNotificationsEndpoints(); diff --git a/backend/src/CCE.Api.External/appsettings.Development.json b/backend/src/CCE.Api.External/appsettings.Development.json index 0d622ddc..5d10ae74 100644 --- a/backend/src/CCE.Api.External/appsettings.Development.json +++ b/backend/src/CCE.Api.External/appsettings.Development.json @@ -88,5 +88,13 @@ }, "Seq": { "ServerUrl": "http://localhost:5341" + }, + "Messaging": { + "Transport": "InMemory", + "UseAsyncDispatcher": true + // For RabbitMQ production: + // "Transport": "RabbitMQ", + // "RabbitMqHost": "amqp://guest:guest@localhost", + // "RabbitMqVirtualHost": "/cce-dev" } } diff --git a/backend/src/CCE.Api.Internal/Endpoints/CreateNotificationTemplateRequest.cs b/backend/src/CCE.Api.Internal/Endpoints/CreateNotificationTemplateRequest.cs new file mode 100644 index 00000000..949959b4 --- /dev/null +++ b/backend/src/CCE.Api.Internal/Endpoints/CreateNotificationTemplateRequest.cs @@ -0,0 +1,12 @@ +using CCE.Domain.Notifications; + +namespace CCE.Api.Internal.Endpoints; + +public sealed record CreateNotificationTemplateRequest( + string Code, + string SubjectAr, + string SubjectEn, + string BodyAr, + string BodyEn, + NotificationChannel Channel, + string VariableSchemaJson); diff --git a/backend/src/CCE.Api.Internal/Endpoints/NotificationLogEndpoints.cs b/backend/src/CCE.Api.Internal/Endpoints/NotificationLogEndpoints.cs new file mode 100644 index 00000000..b28695fd --- /dev/null +++ b/backend/src/CCE.Api.Internal/Endpoints/NotificationLogEndpoints.cs @@ -0,0 +1,55 @@ +using CCE.Api.Common.Extensions; +using CCE.Application.Notifications.Admin.Commands.RetryNotificationLog; +using CCE.Application.Notifications.Admin.Queries.GetNotificationLogById; +using CCE.Application.Notifications.Admin.Queries.ListNotificationLogs; +using CCE.Domain; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace CCE.Api.Internal.Endpoints; + +public static class NotificationLogEndpoints +{ + public static IEndpointRouteBuilder MapNotificationLogEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/admin/notification-logs") + .WithTags("Notification Logs") + .RequireAuthorization(Permissions.Notification_LogView); + + group.MapGet("", async ( + int? page, int? pageSize, + Guid? recipientUserId, string? templateCode, int? channel, int? status, + IMediator mediator, CancellationToken ct) => + { + var query = new ListNotificationLogsQuery( + page ?? 1, + pageSize ?? 20, + recipientUserId, + templateCode, + channel is { } c ? (CCE.Domain.Notifications.NotificationChannel)c : null, + status is { } s ? (CCE.Domain.Notifications.NotificationDeliveryStatus)s : null); + var result = await mediator.Send(query, ct).ConfigureAwait(false); + return result.ToHttpResult(); + }).WithName("ListNotificationLogs"); + + group.MapGet("/{id:guid}", async ( + Guid id, + IMediator mediator, CancellationToken ct) => + { + var result = await mediator.Send(new GetNotificationLogByIdQuery(id), ct).ConfigureAwait(false); + return result.ToHttpResult(); + }).WithName("GetNotificationLogById"); + + group.MapPost("/{id:guid}/retry", async ( + Guid id, + IMediator mediator, CancellationToken ct) => + { + var result = await mediator.Send(new RetryNotificationLogCommand(id), ct).ConfigureAwait(false); + return result.ToHttpResult(); + }).WithName("RetryNotificationLog"); + + return app; + } +} diff --git a/backend/src/CCE.Api.Internal/Endpoints/NotificationTemplateEndpoints.cs b/backend/src/CCE.Api.Internal/Endpoints/NotificationTemplateEndpoints.cs index fc10a085..c4663dd0 100644 --- a/backend/src/CCE.Api.Internal/Endpoints/NotificationTemplateEndpoints.cs +++ b/backend/src/CCE.Api.Internal/Endpoints/NotificationTemplateEndpoints.cs @@ -1,3 +1,4 @@ +using CCE.Api.Common.Extensions; using CCE.Application.Notifications.Commands.CreateNotificationTemplate; using CCE.Application.Notifications.Commands.UpdateNotificationTemplate; using CCE.Application.Notifications.Queries.GetNotificationTemplateById; @@ -28,7 +29,7 @@ public static IEndpointRouteBuilder MapNotificationTemplateEndpoints(this IEndpo Channel: channel, IsActive: isActive); var result = await mediator.Send(query, cancellationToken).ConfigureAwait(false); - return Results.Ok(result); + return result.ToHttpResult(); }) .RequireAuthorization(Permissions.Notification_TemplateManage) .WithName("ListNotificationTemplates"); @@ -37,8 +38,8 @@ public static IEndpointRouteBuilder MapNotificationTemplateEndpoints(this IEndpo System.Guid id, IMediator mediator, CancellationToken cancellationToken) => { - var dto = await mediator.Send(new GetNotificationTemplateByIdQuery(id), cancellationToken).ConfigureAwait(false); - return dto is null ? Results.NotFound() : Results.Ok(dto); + var result = await mediator.Send(new GetNotificationTemplateByIdQuery(id), cancellationToken).ConfigureAwait(false); + return result.ToHttpResult(); }) .RequireAuthorization(Permissions.Notification_TemplateManage) .WithName("GetNotificationTemplateById"); @@ -53,8 +54,8 @@ public static IEndpointRouteBuilder MapNotificationTemplateEndpoints(this IEndpo body.BodyAr, body.BodyEn, body.Channel, body.VariableSchemaJson); - var dto = await mediator.Send(cmd, cancellationToken).ConfigureAwait(false); - return Results.Created($"/api/admin/notification-templates/{dto.Id}", dto); + var result = await mediator.Send(cmd, cancellationToken).ConfigureAwait(false); + return result.ToCreatedHttpResult(); }) .RequireAuthorization(Permissions.Notification_TemplateManage) .WithName("CreateNotificationTemplate"); @@ -69,8 +70,8 @@ public static IEndpointRouteBuilder MapNotificationTemplateEndpoints(this IEndpo body.SubjectAr, body.SubjectEn, body.BodyAr, body.BodyEn, body.IsActive); - var dto = await mediator.Send(cmd, cancellationToken).ConfigureAwait(false); - return dto is null ? Results.NotFound() : Results.Ok(dto); + var result = await mediator.Send(cmd, cancellationToken).ConfigureAwait(false); + return result.ToHttpResult(); }) .RequireAuthorization(Permissions.Notification_TemplateManage) .WithName("UpdateNotificationTemplate"); @@ -78,19 +79,3 @@ public static IEndpointRouteBuilder MapNotificationTemplateEndpoints(this IEndpo return app; } } - -public sealed record CreateNotificationTemplateRequest( - string Code, - string SubjectAr, - string SubjectEn, - string BodyAr, - string BodyEn, - CCE.Domain.Notifications.NotificationChannel Channel, - string VariableSchemaJson); - -public sealed record UpdateNotificationTemplateRequest( - string SubjectAr, - string SubjectEn, - string BodyAr, - string BodyEn, - bool IsActive); diff --git a/backend/src/CCE.Api.Internal/Endpoints/UpdateNotificationTemplateRequest.cs b/backend/src/CCE.Api.Internal/Endpoints/UpdateNotificationTemplateRequest.cs new file mode 100644 index 00000000..6e4120b5 --- /dev/null +++ b/backend/src/CCE.Api.Internal/Endpoints/UpdateNotificationTemplateRequest.cs @@ -0,0 +1,8 @@ +namespace CCE.Api.Internal.Endpoints; + +public sealed record UpdateNotificationTemplateRequest( + string SubjectAr, + string SubjectEn, + string BodyAr, + string BodyEn, + bool IsActive); diff --git a/backend/src/CCE.Api.Internal/Program.cs b/backend/src/CCE.Api.Internal/Program.cs index 84c5894f..0f4a848e 100644 --- a/backend/src/CCE.Api.Internal/Program.cs +++ b/backend/src/CCE.Api.Internal/Program.cs @@ -43,6 +43,7 @@ builder.Services.AddHttpContextAccessor(); builder.Services.Replace(ServiceDescriptor.Scoped()); builder.Services.Replace(ServiceDescriptor.Scoped()); +builder.Services.AddSignalR().AddJsonProtocol(); var app = builder.Build(); @@ -78,6 +79,7 @@ app.MapTopicEndpoints(); app.MapCommunityModerationEndpoints(); app.MapNotificationTemplateEndpoints(); +app.MapNotificationLogEndpoints(); app.MapReportEndpoints(); app.MapAuditEndpoints(); app.MapHomepageSettingsEndpoints(); diff --git a/backend/src/CCE.Api.Internal/appsettings.Development.json b/backend/src/CCE.Api.Internal/appsettings.Development.json index 4e385e81..f31953f7 100644 --- a/backend/src/CCE.Api.Internal/appsettings.Development.json +++ b/backend/src/CCE.Api.Internal/appsettings.Development.json @@ -34,6 +34,10 @@ "GraphTenantDomain": "cce.local", "CallbackPath": "/signin-oidc" }, + "Messaging": { + "Transport": "InMemory", + "UseAsyncDispatcher": true + }, "LocalAuth": { "External": { "Issuer": "cce-api-external-dev", diff --git a/backend/src/CCE.Application/Common/Interfaces/ICceDbContext.cs b/backend/src/CCE.Application/Common/Interfaces/ICceDbContext.cs index 0f516df1..60fab9d3 100644 --- a/backend/src/CCE.Application/Common/Interfaces/ICceDbContext.cs +++ b/backend/src/CCE.Application/Common/Interfaces/ICceDbContext.cs @@ -51,6 +51,8 @@ public interface ICceDbContext IQueryable PostFollows { get; } IQueryable NotificationTemplates { get; } IQueryable UserNotifications { get; } + IQueryable NotificationLogs { get; } + IQueryable UserNotificationSettings { get; } IQueryable ServiceRatings { get; } IQueryable AuditEvents { get; } IQueryable KnowledgeMaps { get; } diff --git a/backend/src/CCE.Application/Community/ICommunityReadService.cs b/backend/src/CCE.Application/Community/ICommunityReadService.cs new file mode 100644 index 00000000..81b8eac0 --- /dev/null +++ b/backend/src/CCE.Application/Community/ICommunityReadService.cs @@ -0,0 +1,13 @@ +namespace CCE.Application.Community; + +public interface ICommunityReadService +{ + /// + /// Returns distinct user IDs who follow the given topic, + /// optionally excluding a specific user (e.g., the author). + /// + Task> GetTopicFollowerIdsAsync( + System.Guid topicId, + System.Guid? excludeUserId, + CancellationToken ct); +} diff --git a/backend/src/CCE.Application/Identity/Auth/Common/IPasswordResetEmailSender.cs b/backend/src/CCE.Application/Identity/Auth/Common/IPasswordResetEmailSender.cs deleted file mode 100644 index 14e3e805..00000000 --- a/backend/src/CCE.Application/Identity/Auth/Common/IPasswordResetEmailSender.cs +++ /dev/null @@ -1,8 +0,0 @@ -using CCE.Domain.Identity; - -namespace CCE.Application.Identity.Auth.Common; - -public interface IPasswordResetEmailSender -{ - Task SendAsync(User user, string resetToken, CancellationToken ct); -} diff --git a/backend/src/CCE.Application/Messages/MessageFactory.cs b/backend/src/CCE.Application/Messages/MessageFactory.cs index d35ce2a4..2641ddb2 100644 --- a/backend/src/CCE.Application/Messages/MessageFactory.cs +++ b/backend/src/CCE.Application/Messages/MessageFactory.cs @@ -96,6 +96,17 @@ public FieldError Field(string fieldName, string domainKey) public Response FileTooLarge() => BusinessRule("FILE_TOO_LARGE"); public Response EmptyFile() => BusinessRule("EMPTY_FILE"); + // ─── Convenience shortcuts (Notification domain) ─── + + public Response NotificationTemplateNotFound() => NotFound("TEMPLATE_NOT_FOUND"); + public Response NotificationLogNotFound() => NotFound("NOTIFICATION_NOT_FOUND"); + public Response NotificationSettingsUpdated() => Ok("NOTIFICATION_SETTINGS_UPDATED"); + public Response NotificationMarkedRead() => Ok("NOTIFICATION_MARKED_READ"); + public Response NotificationsMarkedRead(int count) => Ok(count, "NOTIFICATIONS_MARKED_READ"); + public Response NotificationRetried(T data) => Ok(data, "NOTIFICATION_RETRIED"); + public Response NotificationTemplateCreated(T data) => Ok(data, "NOTIFICATION_TEMPLATE_CREATED"); + public Response NotificationTemplateUpdated(T data) => Ok(data, "NOTIFICATION_TEMPLATE_UPDATED"); + // ─── Private ─── private Response Fail(string domainKey, MessageType type) diff --git a/backend/src/CCE.Application/Messages/SystemCode.cs b/backend/src/CCE.Application/Messages/SystemCode.cs index ddd39682..192317cf 100644 --- a/backend/src/CCE.Application/Messages/SystemCode.cs +++ b/backend/src/CCE.Application/Messages/SystemCode.cs @@ -176,6 +176,11 @@ public static class SystemCode public const string CON040 = "CON040"; // Notification created public const string CON041 = "CON041"; // Notification marked read public const string CON042 = "CON042"; // Notification deleted + public const string CON043 = "CON043"; // Notification settings updated + public const string CON044 = "CON044"; // Notification retried + public const string CON045 = "CON045"; // Notifications marked read + public const string CON046 = "CON046"; // Notification template created + public const string CON047 = "CON047"; // Notification template updated // ─── General Success ─── public const string CON100 = "CON100"; // Items listed successfully diff --git a/backend/src/CCE.Application/Messages/SystemCodeMap.cs b/backend/src/CCE.Application/Messages/SystemCodeMap.cs index ec2db140..1336d7b2 100644 --- a/backend/src/CCE.Application/Messages/SystemCodeMap.cs +++ b/backend/src/CCE.Application/Messages/SystemCodeMap.cs @@ -138,6 +138,11 @@ public static class SystemCodeMap ["NOTIFICATION_CREATED"] = SystemCode.CON040, ["NOTIFICATION_MARKED_READ"] = SystemCode.CON041, ["NOTIFICATION_DELETED"] = SystemCode.CON042, + ["NOTIFICATION_SETTINGS_UPDATED"] = SystemCode.CON043, + ["NOTIFICATION_RETRIED"] = SystemCode.CON044, + ["NOTIFICATIONS_MARKED_READ"] = SystemCode.CON045, + ["NOTIFICATION_TEMPLATE_CREATED"] = SystemCode.CON046, + ["NOTIFICATION_TEMPLATE_UPDATED"] = SystemCode.CON047, // ─── General Success ─── ["ITEMS_LISTED"] = SystemCode.CON100, diff --git a/backend/src/CCE.Application/Notifications/Admin/Commands/RetryNotificationLog/RetryNotificationLogCommand.cs b/backend/src/CCE.Application/Notifications/Admin/Commands/RetryNotificationLog/RetryNotificationLogCommand.cs new file mode 100644 index 00000000..ec596568 --- /dev/null +++ b/backend/src/CCE.Application/Notifications/Admin/Commands/RetryNotificationLog/RetryNotificationLogCommand.cs @@ -0,0 +1,6 @@ +using CCE.Application.Common; +using MediatR; + +namespace CCE.Application.Notifications.Admin.Commands.RetryNotificationLog; + +public sealed record RetryNotificationLogCommand(System.Guid Id) : IRequest>; diff --git a/backend/src/CCE.Application/Notifications/Admin/Commands/RetryNotificationLog/RetryNotificationLogCommandHandler.cs b/backend/src/CCE.Application/Notifications/Admin/Commands/RetryNotificationLog/RetryNotificationLogCommandHandler.cs new file mode 100644 index 00000000..55de6eaf --- /dev/null +++ b/backend/src/CCE.Application/Notifications/Admin/Commands/RetryNotificationLog/RetryNotificationLogCommandHandler.cs @@ -0,0 +1,131 @@ +using CCE.Application.Common; +using CCE.Application.Common.Interfaces; +using CCE.Application.Common.Pagination; +using CCE.Application.Messages; +using CCE.Domain.Common; +using CCE.Domain.Notifications; +using MediatR; + +namespace CCE.Application.Notifications.Admin.Commands.RetryNotificationLog; + +public sealed class RetryNotificationLogCommandHandler + : IRequestHandler> +{ + private readonly INotificationLogRepository _logRepository; + private readonly INotificationTemplateRepository _templateRepository; + private readonly IEnumerable _handlers; + private readonly INotificationTemplateRenderer _renderer; + private readonly ICceDbContext _db; + private readonly MessageFactory _msg; + + public RetryNotificationLogCommandHandler( + INotificationLogRepository logRepository, + INotificationTemplateRepository templateRepository, + IEnumerable handlers, + INotificationTemplateRenderer renderer, + ICceDbContext db, + MessageFactory msg) + { + _logRepository = logRepository; + _templateRepository = templateRepository; + _handlers = handlers; + _renderer = renderer; + _db = db; + _msg = msg; + } + + public async Task> Handle( + RetryNotificationLogCommand request, + CancellationToken cancellationToken) + { + var log = await _logRepository.GetAsync(request.Id, cancellationToken).ConfigureAwait(false); + + if (log is null) + return _msg.NotificationLogNotFound(); + + if (log.Status != NotificationDeliveryStatus.Failed && log.Status != NotificationDeliveryStatus.Skipped) + throw new DomainException($"Cannot retry a log with status {log.Status}."); + + log.IncrementAttempt(); + + // Resolve template + var template = await _templateRepository.GetActiveByCodeAndChannelAsync( + log.TemplateCode, + log.Channel, + cancellationToken) + .ConfigureAwait(false); + + if (template is null) + { + log.MarkSkipped("Template no longer available."); + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + return _msg.NotificationRetried(log.Id); + } + + // Resolve recipient data + string? email = null; + string? phone = null; + string locale = "en"; + + if (log.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; + } + } + + // Render + var variables = log.PayloadJson is not null + ? System.Text.Json.JsonSerializer.Deserialize>(log.PayloadJson) ?? new Dictionary() + : new Dictionary(); + + var (subjectAr, subjectEn, body) = _renderer.Render(template, variables, locale); + var subject = subjectEn; + + var rendered = new RenderedNotification( + log.TemplateCode, + log.RecipientUserId, + template.Id, + subject, + subjectAr, + subjectEn, + body, + log.Channel, + locale, + email, + phone); + + // Dispatch + var sender = _handlers.FirstOrDefault(s => s.Channel == log.Channel); + if (sender is null) + { + log.MarkSkipped($"No sender registered for channel {log.Channel}."); + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + return _msg.NotificationRetried(log.Id); + } + + var sendResult = await sender.SendAsync(rendered, cancellationToken).ConfigureAwait(false); + + if (sendResult.Success) + { + log.MarkSent(sendResult.ProviderMessageId); + } + else + { + log.MarkFailed(sendResult.Error ?? "Unknown error"); + } + + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + return _msg.NotificationRetried(log.Id); + } +} diff --git a/backend/src/CCE.Application/Notifications/Admin/Queries/GetNotificationLogById/GetNotificationLogByIdQuery.cs b/backend/src/CCE.Application/Notifications/Admin/Queries/GetNotificationLogById/GetNotificationLogByIdQuery.cs new file mode 100644 index 00000000..61a198eb --- /dev/null +++ b/backend/src/CCE.Application/Notifications/Admin/Queries/GetNotificationLogById/GetNotificationLogByIdQuery.cs @@ -0,0 +1,6 @@ +using CCE.Application.Common; +using MediatR; + +namespace CCE.Application.Notifications.Admin.Queries.GetNotificationLogById; + +public sealed record GetNotificationLogByIdQuery(System.Guid Id) : IRequest>; diff --git a/backend/src/CCE.Application/Notifications/Admin/Queries/GetNotificationLogById/GetNotificationLogByIdQueryHandler.cs b/backend/src/CCE.Application/Notifications/Admin/Queries/GetNotificationLogById/GetNotificationLogByIdQueryHandler.cs new file mode 100644 index 00000000..32095736 --- /dev/null +++ b/backend/src/CCE.Application/Notifications/Admin/Queries/GetNotificationLogById/GetNotificationLogByIdQueryHandler.cs @@ -0,0 +1,52 @@ +using CCE.Application.Common; +using CCE.Application.Common.Interfaces; +using CCE.Application.Common.Pagination; +using CCE.Application.Messages; +using CCE.Domain.Notifications; +using MediatR; + +namespace CCE.Application.Notifications.Admin.Queries.GetNotificationLogById; + +public sealed class GetNotificationLogByIdQueryHandler + : IRequestHandler> +{ + private readonly ICceDbContext _db; + private readonly MessageFactory _msg; + + public GetNotificationLogByIdQueryHandler(ICceDbContext db, MessageFactory msg) + { + _db = db; + _msg = msg; + } + + public async Task> Handle( + GetNotificationLogByIdQuery request, + CancellationToken cancellationToken) + { + var log = (await _db.NotificationLogs + .Where(l => l.Id == request.Id) + .ToListAsyncEither(cancellationToken) + .ConfigureAwait(false)) + .FirstOrDefault(); + + return log is null + ? _msg.NotificationLogNotFound() + : _msg.Ok(MapToDto(log), "ITEMS_LISTED"); + } + + internal static NotificationLogDto MapToDto(NotificationLog l) => new( + l.Id, + l.RecipientUserId, + l.TemplateCode, + l.TemplateId, + l.Channel, + l.Status, + l.ProviderMessageId, + l.Error, + l.AttemptCount, + l.CreatedOn, + l.SentOn, + l.FailedOn, + l.CorrelationId, + l.PayloadJson); +} diff --git a/backend/src/CCE.Application/Notifications/Admin/Queries/GetNotificationLogById/NotificationLogDto.cs b/backend/src/CCE.Application/Notifications/Admin/Queries/GetNotificationLogById/NotificationLogDto.cs new file mode 100644 index 00000000..697f265a --- /dev/null +++ b/backend/src/CCE.Application/Notifications/Admin/Queries/GetNotificationLogById/NotificationLogDto.cs @@ -0,0 +1,19 @@ +using CCE.Domain.Notifications; + +namespace CCE.Application.Notifications.Admin.Queries.GetNotificationLogById; + +public sealed record NotificationLogDto( + System.Guid Id, + System.Guid? RecipientUserId, + string TemplateCode, + System.Guid? TemplateId, + NotificationChannel Channel, + NotificationDeliveryStatus Status, + string? ProviderMessageId, + string? Error, + int AttemptCount, + System.DateTimeOffset CreatedOn, + System.DateTimeOffset? SentOn, + System.DateTimeOffset? FailedOn, + string? CorrelationId, + string? PayloadJson); diff --git a/backend/src/CCE.Application/Notifications/Admin/Queries/ListNotificationLogs/ListNotificationLogsQuery.cs b/backend/src/CCE.Application/Notifications/Admin/Queries/ListNotificationLogs/ListNotificationLogsQuery.cs new file mode 100644 index 00000000..9c8912f2 --- /dev/null +++ b/backend/src/CCE.Application/Notifications/Admin/Queries/ListNotificationLogs/ListNotificationLogsQuery.cs @@ -0,0 +1,14 @@ +using CCE.Application.Common; +using CCE.Application.Common.Pagination; +using CCE.Domain.Notifications; +using MediatR; + +namespace CCE.Application.Notifications.Admin.Queries.ListNotificationLogs; + +public sealed record ListNotificationLogsQuery( + int Page, + int PageSize, + System.Guid? RecipientUserId = null, + string? TemplateCode = null, + NotificationChannel? Channel = null, + NotificationDeliveryStatus? Status = null) : IRequest>>; diff --git a/backend/src/CCE.Application/Notifications/Admin/Queries/ListNotificationLogs/ListNotificationLogsQueryHandler.cs b/backend/src/CCE.Application/Notifications/Admin/Queries/ListNotificationLogs/ListNotificationLogsQueryHandler.cs new file mode 100644 index 00000000..3c93a457 --- /dev/null +++ b/backend/src/CCE.Application/Notifications/Admin/Queries/ListNotificationLogs/ListNotificationLogsQueryHandler.cs @@ -0,0 +1,67 @@ +using CCE.Application.Common; +using CCE.Application.Common.Interfaces; +using CCE.Application.Common.Pagination; +using CCE.Application.Messages; +using CCE.Domain.Notifications; +using MediatR; + +namespace CCE.Application.Notifications.Admin.Queries.ListNotificationLogs; + +public sealed class ListNotificationLogsQueryHandler + : IRequestHandler>> +{ + private readonly ICceDbContext _db; + private readonly MessageFactory _msg; + + public ListNotificationLogsQueryHandler(ICceDbContext db, MessageFactory msg) + { + _db = db; + _msg = msg; + } + + public async Task>> Handle( + ListNotificationLogsQuery request, + CancellationToken cancellationToken) + { + IQueryable query = _db.NotificationLogs; + + if (request.RecipientUserId is { } userId) + query = query.Where(l => l.RecipientUserId == userId); + + if (!string.IsNullOrWhiteSpace(request.TemplateCode)) + query = query.Where(l => l.TemplateCode == request.TemplateCode); + + if (request.Channel is { } channel) + query = query.Where(l => l.Channel == channel); + + if (request.Status is { } status) + query = query.Where(l => l.Status == status); + + query = query.OrderByDescending(l => l.CreatedOn).ThenByDescending(l => l.Id); + + var page = await query.ToPagedResultAsync( + request.Page, + request.PageSize, + cancellationToken) + .ConfigureAwait(false); + + var items = page.Items.Select(MapToDto).ToList(); + var result = new PagedResult(items, page.Page, page.PageSize, page.Total); + return _msg.Ok(result, "ITEMS_LISTED"); + } + + internal static NotificationLogListItemDto MapToDto(NotificationLog l) => new( + l.Id, + l.RecipientUserId, + l.TemplateCode, + l.TemplateId, + l.Channel, + l.Status, + l.ProviderMessageId, + l.Error, + l.AttemptCount, + l.CreatedOn, + l.SentOn, + l.FailedOn, + l.CorrelationId); +} diff --git a/backend/src/CCE.Application/Notifications/Admin/Queries/ListNotificationLogs/NotificationLogListItemDto.cs b/backend/src/CCE.Application/Notifications/Admin/Queries/ListNotificationLogs/NotificationLogListItemDto.cs new file mode 100644 index 00000000..7d2d65d6 --- /dev/null +++ b/backend/src/CCE.Application/Notifications/Admin/Queries/ListNotificationLogs/NotificationLogListItemDto.cs @@ -0,0 +1,18 @@ +using CCE.Domain.Notifications; + +namespace CCE.Application.Notifications.Admin.Queries.ListNotificationLogs; + +public sealed record NotificationLogListItemDto( + System.Guid Id, + System.Guid? RecipientUserId, + string TemplateCode, + System.Guid? TemplateId, + NotificationChannel Channel, + NotificationDeliveryStatus Status, + string? ProviderMessageId, + string? Error, + int AttemptCount, + System.DateTimeOffset CreatedOn, + System.DateTimeOffset? SentOn, + System.DateTimeOffset? FailedOn, + string? CorrelationId); diff --git a/backend/src/CCE.Application/Notifications/ChannelSendResult.cs b/backend/src/CCE.Application/Notifications/ChannelSendResult.cs new file mode 100644 index 00000000..f472b254 --- /dev/null +++ b/backend/src/CCE.Application/Notifications/ChannelSendResult.cs @@ -0,0 +1,10 @@ +using CCE.Domain.Notifications; + +namespace CCE.Application.Notifications; + +public sealed record ChannelSendResult( + bool Success, + string? ProviderMessageId = null, + string? Error = null, + System.Guid? UserNotificationId = null, + UserNotification? UserNotification = null); diff --git a/backend/src/CCE.Application/Notifications/Commands/CreateNotificationTemplate/CreateNotificationTemplateCommand.cs b/backend/src/CCE.Application/Notifications/Commands/CreateNotificationTemplate/CreateNotificationTemplateCommand.cs index bed6253a..a15ce1ae 100644 --- a/backend/src/CCE.Application/Notifications/Commands/CreateNotificationTemplate/CreateNotificationTemplateCommand.cs +++ b/backend/src/CCE.Application/Notifications/Commands/CreateNotificationTemplate/CreateNotificationTemplateCommand.cs @@ -1,4 +1,4 @@ -using CCE.Application.Notifications.Dtos; +using CCE.Application.Common; using CCE.Domain.Notifications; using MediatR; @@ -11,4 +11,4 @@ public sealed record CreateNotificationTemplateCommand( string BodyAr, string BodyEn, NotificationChannel Channel, - string VariableSchemaJson) : IRequest; + string VariableSchemaJson) : IRequest>; diff --git a/backend/src/CCE.Application/Notifications/Commands/CreateNotificationTemplate/CreateNotificationTemplateCommandHandler.cs b/backend/src/CCE.Application/Notifications/Commands/CreateNotificationTemplate/CreateNotificationTemplateCommandHandler.cs index 67c4fe0f..3d3f2627 100644 --- a/backend/src/CCE.Application/Notifications/Commands/CreateNotificationTemplate/CreateNotificationTemplateCommandHandler.cs +++ b/backend/src/CCE.Application/Notifications/Commands/CreateNotificationTemplate/CreateNotificationTemplateCommandHandler.cs @@ -1,21 +1,29 @@ -using CCE.Application.Notifications.Dtos; -using CCE.Application.Notifications.Queries.ListNotificationTemplates; +using CCE.Application.Common; +using CCE.Application.Common.Interfaces; +using CCE.Application.Messages; using CCE.Domain.Notifications; using MediatR; namespace CCE.Application.Notifications.Commands.CreateNotificationTemplate; public sealed class CreateNotificationTemplateCommandHandler - : IRequestHandler + : IRequestHandler> { - private readonly INotificationTemplateService _service; + private readonly INotificationTemplateRepository _repo; + private readonly ICceDbContext _db; + private readonly MessageFactory _msg; - public CreateNotificationTemplateCommandHandler(INotificationTemplateService service) + public CreateNotificationTemplateCommandHandler( + INotificationTemplateRepository repo, + ICceDbContext db, + MessageFactory msg) { - _service = service; + _repo = repo; + _db = db; + _msg = msg; } - public async Task Handle( + public async Task> Handle( CreateNotificationTemplateCommand request, CancellationToken cancellationToken) { @@ -28,8 +36,9 @@ public async Task Handle( request.Channel, request.VariableSchemaJson); - await _service.SaveAsync(template, cancellationToken).ConfigureAwait(false); + await _repo.AddAsync(template, cancellationToken).ConfigureAwait(false); + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - return ListNotificationTemplatesQueryHandler.MapToDto(template); + return _msg.NotificationTemplateCreated(template.Id); } } diff --git a/backend/src/CCE.Application/Notifications/Commands/UpdateNotificationTemplate/UpdateNotificationTemplateCommand.cs b/backend/src/CCE.Application/Notifications/Commands/UpdateNotificationTemplate/UpdateNotificationTemplateCommand.cs index 22341132..fd8e8d84 100644 --- a/backend/src/CCE.Application/Notifications/Commands/UpdateNotificationTemplate/UpdateNotificationTemplateCommand.cs +++ b/backend/src/CCE.Application/Notifications/Commands/UpdateNotificationTemplate/UpdateNotificationTemplateCommand.cs @@ -1,4 +1,4 @@ -using CCE.Application.Notifications.Dtos; +using CCE.Application.Common; using MediatR; namespace CCE.Application.Notifications.Commands.UpdateNotificationTemplate; @@ -9,4 +9,4 @@ public sealed record UpdateNotificationTemplateCommand( string SubjectEn, string BodyAr, string BodyEn, - bool IsActive) : IRequest; + bool IsActive) : IRequest>; diff --git a/backend/src/CCE.Application/Notifications/Commands/UpdateNotificationTemplate/UpdateNotificationTemplateCommandHandler.cs b/backend/src/CCE.Application/Notifications/Commands/UpdateNotificationTemplate/UpdateNotificationTemplateCommandHandler.cs index 969f11c3..221e66b6 100644 --- a/backend/src/CCE.Application/Notifications/Commands/UpdateNotificationTemplate/UpdateNotificationTemplateCommandHandler.cs +++ b/backend/src/CCE.Application/Notifications/Commands/UpdateNotificationTemplate/UpdateNotificationTemplateCommandHandler.cs @@ -1,27 +1,35 @@ -using CCE.Application.Notifications.Dtos; -using CCE.Application.Notifications.Queries.ListNotificationTemplates; +using CCE.Application.Common; +using CCE.Application.Common.Interfaces; +using CCE.Application.Messages; using MediatR; namespace CCE.Application.Notifications.Commands.UpdateNotificationTemplate; public sealed class UpdateNotificationTemplateCommandHandler - : IRequestHandler + : IRequestHandler> { - private readonly INotificationTemplateService _service; - - public UpdateNotificationTemplateCommandHandler(INotificationTemplateService service) + private readonly INotificationTemplateRepository _repo; + private readonly ICceDbContext _db; + private readonly MessageFactory _msg; + + public UpdateNotificationTemplateCommandHandler( + INotificationTemplateRepository repo, + ICceDbContext db, + MessageFactory msg) { - _service = service; + _repo = repo; + _db = db; + _msg = msg; } - public async Task Handle( + public async Task> Handle( UpdateNotificationTemplateCommand request, CancellationToken cancellationToken) { - var template = await _service.FindAsync(request.Id, cancellationToken).ConfigureAwait(false); + var template = await _repo.GetAsync(request.Id, cancellationToken).ConfigureAwait(false); if (template is null) { - return null; + return _msg.NotificationTemplateNotFound(); } template.UpdateContent(request.SubjectAr, request.SubjectEn, request.BodyAr, request.BodyEn); @@ -31,8 +39,8 @@ public UpdateNotificationTemplateCommandHandler(INotificationTemplateService ser else template.Deactivate(); - await _service.UpdateAsync(template, cancellationToken).ConfigureAwait(false); + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - return ListNotificationTemplatesQueryHandler.MapToDto(template); + return _msg.NotificationTemplateUpdated(template.Id); } } diff --git a/backend/src/CCE.Application/Notifications/Handlers/EventScheduledNotificationHandler.cs b/backend/src/CCE.Application/Notifications/Handlers/EventScheduledNotificationHandler.cs new file mode 100644 index 00000000..1ddfe843 --- /dev/null +++ b/backend/src/CCE.Application/Notifications/Handlers/EventScheduledNotificationHandler.cs @@ -0,0 +1,25 @@ +using CCE.Domain.Content.Events; +using MediatR; +using Microsoft.Extensions.Logging; + +namespace CCE.Application.Notifications.Handlers; + +public sealed class EventScheduledNotificationHandler + : INotificationHandler +{ + private readonly ILogger _logger; + + public EventScheduledNotificationHandler( + ILogger logger) + { + _logger = logger; + } + + public Task Handle(EventScheduledEvent notification, CancellationToken cancellationToken) + { + _logger.LogInformation( + "Event {EventId} scheduled. Audience notifications require explicit audience definition.", + notification.EventId); + return Task.CompletedTask; + } +} diff --git a/backend/src/CCE.Application/Notifications/Handlers/ExpertRegistrationApprovedNotificationHandler.cs b/backend/src/CCE.Application/Notifications/Handlers/ExpertRegistrationApprovedNotificationHandler.cs new file mode 100644 index 00000000..e14ac6ca --- /dev/null +++ b/backend/src/CCE.Application/Notifications/Handlers/ExpertRegistrationApprovedNotificationHandler.cs @@ -0,0 +1,30 @@ +using CCE.Application.Notifications.Messages; +using CCE.Domain.Identity.Events; +using CCE.Domain.Notifications; +using MediatR; + +namespace CCE.Application.Notifications.Handlers; + +public sealed class ExpertRegistrationApprovedNotificationHandler + : INotificationHandler +{ + private readonly INotificationMessageDispatcher _dispatcher; + + public ExpertRegistrationApprovedNotificationHandler(INotificationMessageDispatcher dispatcher) + { + _dispatcher = dispatcher; + } + + public async Task Handle( + ExpertRegistrationApprovedEvent notification, + CancellationToken cancellationToken) + { + await _dispatcher.DispatchAsync(new NotificationMessage( + TemplateCode: "EXPERT_REQUEST_APPROVED", + RecipientUserId: notification.RequestedById, + EventType: NotificationEventType.ExpertRequestApproved, + Channels: [NotificationChannel.InApp, NotificationChannel.Email], + MetaData: new Dictionary(), + Locale: "en"), cancellationToken).ConfigureAwait(false); + } +} diff --git a/backend/src/CCE.Application/Notifications/Handlers/ExpertRegistrationRejectedNotificationHandler.cs b/backend/src/CCE.Application/Notifications/Handlers/ExpertRegistrationRejectedNotificationHandler.cs new file mode 100644 index 00000000..38ca34df --- /dev/null +++ b/backend/src/CCE.Application/Notifications/Handlers/ExpertRegistrationRejectedNotificationHandler.cs @@ -0,0 +1,33 @@ +using CCE.Application.Notifications.Messages; +using CCE.Domain.Identity.Events; +using CCE.Domain.Notifications; +using MediatR; + +namespace CCE.Application.Notifications.Handlers; + +public sealed class ExpertRegistrationRejectedNotificationHandler + : INotificationHandler +{ + private readonly INotificationMessageDispatcher _dispatcher; + + public ExpertRegistrationRejectedNotificationHandler(INotificationMessageDispatcher dispatcher) + { + _dispatcher = dispatcher; + } + + public async Task Handle( + ExpertRegistrationRejectedEvent notification, + CancellationToken cancellationToken) + { + await _dispatcher.DispatchAsync(new NotificationMessage( + TemplateCode: "EXPERT_REQUEST_REJECTED", + RecipientUserId: notification.RequestedById, + EventType: NotificationEventType.ExpertRequestRejected, + Channels: [NotificationChannel.InApp, NotificationChannel.Email], + MetaData: new Dictionary + { + ["Reason"] = notification.RejectionReasonEn ?? "" + }, + Locale: "en"), cancellationToken).ConfigureAwait(false); + } +} diff --git a/backend/src/CCE.Application/Notifications/Handlers/NewsPublishedNotificationHandler.cs b/backend/src/CCE.Application/Notifications/Handlers/NewsPublishedNotificationHandler.cs new file mode 100644 index 00000000..6e634267 --- /dev/null +++ b/backend/src/CCE.Application/Notifications/Handlers/NewsPublishedNotificationHandler.cs @@ -0,0 +1,48 @@ +using CCE.Application.Common.Interfaces; +using CCE.Application.Content; +using CCE.Application.Notifications.Messages; +using CCE.Domain.Content.Events; +using CCE.Domain.Notifications; +using MediatR; +using Microsoft.Extensions.Logging; + +namespace CCE.Application.Notifications.Handlers; + +public sealed class NewsPublishedNotificationHandler + : INotificationHandler +{ + private readonly INewsRepository _newsRepo; + private readonly INotificationMessageDispatcher _dispatcher; + private readonly ILogger _logger; + + public NewsPublishedNotificationHandler( + INewsRepository newsRepo, + INotificationMessageDispatcher dispatcher, + ILogger logger) + { + _newsRepo = newsRepo; + _dispatcher = dispatcher; + _logger = logger; + } + + public async Task Handle(NewsPublishedEvent notification, CancellationToken cancellationToken) + { + var news = await _newsRepo.FindAsync(notification.NewsId, cancellationToken) + .ConfigureAwait(false); + + if (news is null) + { + _logger.LogWarning( + "News {NewsId} not found for notification.", notification.NewsId); + return; + } + + await _dispatcher.DispatchAsync(new NotificationMessage( + TemplateCode: "NEWS_PUBLISHED", + RecipientUserId: news.AuthorId, + EventType: NotificationEventType.NewsPublished, + Channels: [NotificationChannel.InApp], + MetaData: new Dictionary(), + Locale: "en"), cancellationToken).ConfigureAwait(false); + } +} diff --git a/backend/src/CCE.Application/Notifications/Handlers/PostCreatedNotificationHandler.cs b/backend/src/CCE.Application/Notifications/Handlers/PostCreatedNotificationHandler.cs new file mode 100644 index 00000000..687c5184 --- /dev/null +++ b/backend/src/CCE.Application/Notifications/Handlers/PostCreatedNotificationHandler.cs @@ -0,0 +1,55 @@ +using CCE.Application.Community; +using CCE.Application.Notifications.Messages; +using CCE.Domain.Community.Events; +using CCE.Domain.Notifications; +using MediatR; +using Microsoft.Extensions.Logging; + +namespace CCE.Application.Notifications.Handlers; + +public sealed class PostCreatedNotificationHandler + : INotificationHandler +{ + private readonly ICommunityReadService _communityRead; + private readonly INotificationMessageDispatcher _dispatcher; + private readonly ILogger _logger; + + public PostCreatedNotificationHandler( + ICommunityReadService communityRead, + INotificationMessageDispatcher dispatcher, + ILogger logger) + { + _communityRead = communityRead; + _dispatcher = dispatcher; + _logger = logger; + } + + public async Task Handle(PostCreatedEvent notification, CancellationToken cancellationToken) + { + var followerIds = await _communityRead.GetTopicFollowerIdsAsync( + notification.TopicId, + notification.AuthorId, + cancellationToken) + .ConfigureAwait(false); + + if (followerIds.Count == 0) + { + _logger.LogInformation( + "No followers to notify for post {PostId} in topic {TopicId}", + notification.PostId, + notification.TopicId); + return; + } + + foreach (var userId in followerIds) + { + await _dispatcher.DispatchAsync(new NotificationMessage( + TemplateCode: "COMMUNITY_POST_CREATED", + RecipientUserId: userId, + EventType: NotificationEventType.CommunityPostCreated, + Channels: [NotificationChannel.InApp], + MetaData: new Dictionary(), + Locale: notification.Locale), cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/backend/src/CCE.Application/Notifications/Handlers/ResourcePublishedNotificationHandler.cs b/backend/src/CCE.Application/Notifications/Handlers/ResourcePublishedNotificationHandler.cs new file mode 100644 index 00000000..51e71790 --- /dev/null +++ b/backend/src/CCE.Application/Notifications/Handlers/ResourcePublishedNotificationHandler.cs @@ -0,0 +1,47 @@ +using CCE.Application.Content; +using CCE.Application.Notifications.Messages; +using CCE.Domain.Content.Events; +using CCE.Domain.Notifications; +using MediatR; +using Microsoft.Extensions.Logging; + +namespace CCE.Application.Notifications.Handlers; + +public sealed class ResourcePublishedNotificationHandler + : INotificationHandler +{ + private readonly IResourceRepository _resourceRepo; + private readonly INotificationMessageDispatcher _dispatcher; + private readonly ILogger _logger; + + public ResourcePublishedNotificationHandler( + IResourceRepository resourceRepo, + INotificationMessageDispatcher dispatcher, + ILogger logger) + { + _resourceRepo = resourceRepo; + _dispatcher = dispatcher; + _logger = logger; + } + + public async Task Handle(ResourcePublishedEvent notification, CancellationToken cancellationToken) + { + var resource = await _resourceRepo.FindAsync(notification.ResourceId, cancellationToken) + .ConfigureAwait(false); + + if (resource is null) + { + _logger.LogWarning( + "Resource {ResourceId} not found for notification.", notification.ResourceId); + return; + } + + await _dispatcher.DispatchAsync(new NotificationMessage( + TemplateCode: "RESOURCE_PUBLISHED", + RecipientUserId: resource.UploadedById, + EventType: NotificationEventType.ResourcePublished, + Channels: [NotificationChannel.InApp], + MetaData: new Dictionary(), + Locale: "en"), cancellationToken).ConfigureAwait(false); + } +} diff --git a/backend/src/CCE.Application/Notifications/INotificationChannelHandler.cs b/backend/src/CCE.Application/Notifications/INotificationChannelHandler.cs new file mode 100644 index 00000000..516a1572 --- /dev/null +++ b/backend/src/CCE.Application/Notifications/INotificationChannelHandler.cs @@ -0,0 +1,14 @@ +using CCE.Domain.Notifications; + +namespace CCE.Application.Notifications; + +public interface INotificationChannelHandler +{ + NotificationChannel Channel { get; } + + bool ShouldSend(UserNotificationSettings? settings); + + Task SendAsync( + RenderedNotification notification, + CancellationToken cancellationToken); +} diff --git a/backend/src/CCE.Application/Notifications/INotificationGateway.cs b/backend/src/CCE.Application/Notifications/INotificationGateway.cs new file mode 100644 index 00000000..689b1d2e --- /dev/null +++ b/backend/src/CCE.Application/Notifications/INotificationGateway.cs @@ -0,0 +1,8 @@ +namespace CCE.Application.Notifications; + +public interface INotificationGateway +{ + Task SendAsync( + NotificationDispatchRequest request, + CancellationToken cancellationToken); +} diff --git a/backend/src/CCE.Application/Notifications/INotificationLogRepository.cs b/backend/src/CCE.Application/Notifications/INotificationLogRepository.cs new file mode 100644 index 00000000..c18b13ff --- /dev/null +++ b/backend/src/CCE.Application/Notifications/INotificationLogRepository.cs @@ -0,0 +1,10 @@ +using CCE.Domain.Notifications; + +namespace CCE.Application.Notifications; + +public interface INotificationLogRepository +{ + Task GetAsync(System.Guid id, CancellationToken ct); + + Task AddAsync(NotificationLog log, CancellationToken ct); +} diff --git a/backend/src/CCE.Application/Notifications/INotificationTemplateRenderer.cs b/backend/src/CCE.Application/Notifications/INotificationTemplateRenderer.cs new file mode 100644 index 00000000..af0a0c9c --- /dev/null +++ b/backend/src/CCE.Application/Notifications/INotificationTemplateRenderer.cs @@ -0,0 +1,18 @@ +using CCE.Domain.Notifications; + +namespace CCE.Application.Notifications; + +public interface INotificationTemplateRenderer +{ + /// + /// Renders subject and body by replacing {{Variable}} placeholders with values from . + /// + /// The template to render. + /// Variable values keyed by name. + /// "ar" or "en". + /// A tuple of (subjectAr, subjectEn, body). + (string SubjectAr, string SubjectEn, string Body) Render( + NotificationTemplate template, + IReadOnlyDictionary variables, + string locale); +} diff --git a/backend/src/CCE.Application/Notifications/INotificationTemplateRepository.cs b/backend/src/CCE.Application/Notifications/INotificationTemplateRepository.cs new file mode 100644 index 00000000..8afc3fe4 --- /dev/null +++ b/backend/src/CCE.Application/Notifications/INotificationTemplateRepository.cs @@ -0,0 +1,19 @@ +using CCE.Domain.Notifications; + +namespace CCE.Application.Notifications; + +public interface INotificationTemplateRepository +{ + Task GetAsync(System.Guid id, CancellationToken ct); + + Task GetActiveByCodeAndChannelAsync( + string code, + NotificationChannel channel, + CancellationToken ct); + + Task> ListActiveByCodeAsync( + string code, + CancellationToken ct); + + Task AddAsync(NotificationTemplate template, CancellationToken ct); +} diff --git a/backend/src/CCE.Application/Notifications/INotificationTemplateService.cs b/backend/src/CCE.Application/Notifications/INotificationTemplateService.cs deleted file mode 100644 index be34f83c..00000000 --- a/backend/src/CCE.Application/Notifications/INotificationTemplateService.cs +++ /dev/null @@ -1,10 +0,0 @@ -using CCE.Domain.Notifications; - -namespace CCE.Application.Notifications; - -public interface INotificationTemplateService -{ - Task SaveAsync(NotificationTemplate template, CancellationToken ct); - Task FindAsync(System.Guid id, CancellationToken ct); - Task UpdateAsync(NotificationTemplate template, CancellationToken ct); -} diff --git a/backend/src/CCE.Application/Notifications/ISignalRNotificationPublisher.cs b/backend/src/CCE.Application/Notifications/ISignalRNotificationPublisher.cs new file mode 100644 index 00000000..837d5a57 --- /dev/null +++ b/backend/src/CCE.Application/Notifications/ISignalRNotificationPublisher.cs @@ -0,0 +1,11 @@ +using CCE.Domain.Notifications; + +namespace CCE.Application.Notifications; + +/// +/// Publishes a persisted in-app notification to real-time subscribers via SignalR. +/// +public interface ISignalRNotificationPublisher +{ + Task PublishAsync(UserNotification notification, CancellationToken cancellationToken); +} diff --git a/backend/src/CCE.Application/Notifications/IUserNotificationSettingsRepository.cs b/backend/src/CCE.Application/Notifications/IUserNotificationSettingsRepository.cs new file mode 100644 index 00000000..155ac509 --- /dev/null +++ b/backend/src/CCE.Application/Notifications/IUserNotificationSettingsRepository.cs @@ -0,0 +1,23 @@ +using CCE.Domain.Notifications; + +namespace CCE.Application.Notifications; + +public interface IUserNotificationSettingsRepository +{ + Task GetAsync( + System.Guid userId, + NotificationChannel channel, + string? eventCode, + CancellationToken ct); + + Task> ListForUserAsync( + System.Guid userId, + CancellationToken ct); + + Task> ListForUserAndChannelsAsync( + System.Guid userId, + IReadOnlyCollection channels, + CancellationToken ct); + + Task AddAsync(UserNotificationSettings settings, CancellationToken ct); +} diff --git a/backend/src/CCE.Application/Notifications/Messages/INotificationMessageDispatcher.cs b/backend/src/CCE.Application/Notifications/Messages/INotificationMessageDispatcher.cs new file mode 100644 index 00000000..80b26530 --- /dev/null +++ b/backend/src/CCE.Application/Notifications/Messages/INotificationMessageDispatcher.cs @@ -0,0 +1,6 @@ +namespace CCE.Application.Notifications.Messages; + +public interface INotificationMessageDispatcher +{ + Task DispatchAsync(NotificationMessage message, CancellationToken ct); +} diff --git a/backend/src/CCE.Application/Notifications/Messages/NotificationMessage.cs b/backend/src/CCE.Application/Notifications/Messages/NotificationMessage.cs new file mode 100644 index 00000000..7c106ced --- /dev/null +++ b/backend/src/CCE.Application/Notifications/Messages/NotificationMessage.cs @@ -0,0 +1,14 @@ +using CCE.Domain.Notifications; + +namespace CCE.Application.Notifications.Messages; + +public sealed record NotificationMessage( + string TemplateCode, + System.Guid? RecipientUserId, + NotificationEventType EventType, + IReadOnlyDictionary? MetaData = null, + IReadOnlyCollection? Channels = null, + string Locale = "en", + string? Email = null, + string? PhoneNumber = null, + string? CorrelationId = null); diff --git a/backend/src/CCE.Application/Notifications/NotificationChannelDispatchResult.cs b/backend/src/CCE.Application/Notifications/NotificationChannelDispatchResult.cs new file mode 100644 index 00000000..10eafb1b --- /dev/null +++ b/backend/src/CCE.Application/Notifications/NotificationChannelDispatchResult.cs @@ -0,0 +1,11 @@ +using CCE.Domain.Notifications; + +namespace CCE.Application.Notifications; + +public sealed record NotificationChannelDispatchResult( + NotificationChannel Channel, + NotificationDeliveryStatus Status, + Guid? NotificationLogId = null, + Guid? UserNotificationId = null, + string? ProviderMessageId = null, + string? Error = null); diff --git a/backend/src/CCE.Application/Notifications/NotificationDispatchRequest.cs b/backend/src/CCE.Application/Notifications/NotificationDispatchRequest.cs new file mode 100644 index 00000000..0ec3c683 --- /dev/null +++ b/backend/src/CCE.Application/Notifications/NotificationDispatchRequest.cs @@ -0,0 +1,16 @@ +using CCE.Domain.Notifications; + +namespace CCE.Application.Notifications; + +public sealed record NotificationDispatchRequest( + string TemplateCode, + Guid? RecipientUserId, + IReadOnlyCollection Channels, + IReadOnlyDictionary? Variables = null, + string Locale = "en", + string? Email = null, + string? PhoneNumber = null, + string? Source = null, + string? CorrelationId = null, + string? DeduplicationKey = null, + bool BypassSettings = false); diff --git a/backend/src/CCE.Application/Notifications/NotificationDispatchResult.cs b/backend/src/CCE.Application/Notifications/NotificationDispatchResult.cs new file mode 100644 index 00000000..5c1c7712 --- /dev/null +++ b/backend/src/CCE.Application/Notifications/NotificationDispatchResult.cs @@ -0,0 +1,11 @@ +using CCE.Domain.Notifications; + +namespace CCE.Application.Notifications; + +public sealed record NotificationDispatchResult( + string TemplateCode, + Guid? RecipientUserId, + IReadOnlyCollection Results) +{ + public bool IsSuccess => Results.All(r => r.Status != NotificationDeliveryStatus.Failed); +} diff --git a/backend/src/CCE.Application/Notifications/Public/Commands/MarkAllNotificationsRead/MarkAllNotificationsReadCommand.cs b/backend/src/CCE.Application/Notifications/Public/Commands/MarkAllNotificationsRead/MarkAllNotificationsReadCommand.cs index 1e75893e..81b8549f 100644 --- a/backend/src/CCE.Application/Notifications/Public/Commands/MarkAllNotificationsRead/MarkAllNotificationsReadCommand.cs +++ b/backend/src/CCE.Application/Notifications/Public/Commands/MarkAllNotificationsRead/MarkAllNotificationsReadCommand.cs @@ -1,5 +1,6 @@ +using CCE.Application.Common; using MediatR; namespace CCE.Application.Notifications.Public.Commands.MarkAllNotificationsRead; -public sealed record MarkAllNotificationsReadCommand(System.Guid UserId) : IRequest; +public sealed record MarkAllNotificationsReadCommand(System.Guid UserId) : IRequest>; diff --git a/backend/src/CCE.Application/Notifications/Public/Commands/MarkAllNotificationsRead/MarkAllNotificationsReadCommandHandler.cs b/backend/src/CCE.Application/Notifications/Public/Commands/MarkAllNotificationsRead/MarkAllNotificationsReadCommandHandler.cs index 303bbbcf..74449ea2 100644 --- a/backend/src/CCE.Application/Notifications/Public/Commands/MarkAllNotificationsRead/MarkAllNotificationsReadCommandHandler.cs +++ b/backend/src/CCE.Application/Notifications/Public/Commands/MarkAllNotificationsRead/MarkAllNotificationsReadCommandHandler.cs @@ -1,18 +1,33 @@ +using CCE.Application.Common; +using CCE.Application.Messages; +using CCE.Application.Notifications.Public; +using CCE.Domain.Common; using MediatR; namespace CCE.Application.Notifications.Public.Commands.MarkAllNotificationsRead; -public sealed class MarkAllNotificationsReadCommandHandler : IRequestHandler +public sealed class MarkAllNotificationsReadCommandHandler : IRequestHandler> { - private readonly IUserNotificationService _service; + private readonly IUserNotificationRepository _repo; + private readonly MessageFactory _msg; + private readonly ISystemClock _clock; - public MarkAllNotificationsReadCommandHandler(IUserNotificationService service) + public MarkAllNotificationsReadCommandHandler( + IUserNotificationRepository repo, + MessageFactory msg, + ISystemClock clock) { - _service = service; + _repo = repo; + _msg = msg; + _clock = clock; } - public async Task Handle(MarkAllNotificationsReadCommand request, CancellationToken cancellationToken) + public async Task> Handle(MarkAllNotificationsReadCommand request, CancellationToken cancellationToken) { - return await _service.MarkAllSentAsReadAsync(request.UserId, cancellationToken).ConfigureAwait(false); + var count = await _repo.MarkAllSentAsReadAsync( + request.UserId, + _clock, + cancellationToken).ConfigureAwait(false); + return _msg.NotificationsMarkedRead(count); } } diff --git a/backend/src/CCE.Application/Notifications/Public/Commands/MarkNotificationRead/MarkNotificationReadCommand.cs b/backend/src/CCE.Application/Notifications/Public/Commands/MarkNotificationRead/MarkNotificationReadCommand.cs index b44514c5..d6b305b9 100644 --- a/backend/src/CCE.Application/Notifications/Public/Commands/MarkNotificationRead/MarkNotificationReadCommand.cs +++ b/backend/src/CCE.Application/Notifications/Public/Commands/MarkNotificationRead/MarkNotificationReadCommand.cs @@ -1,5 +1,6 @@ +using CCE.Application.Common; using MediatR; namespace CCE.Application.Notifications.Public.Commands.MarkNotificationRead; -public sealed record MarkNotificationReadCommand(System.Guid Id, System.Guid UserId) : IRequest; +public sealed record MarkNotificationReadCommand(System.Guid Id, System.Guid UserId) : IRequest>; diff --git a/backend/src/CCE.Application/Notifications/Public/Commands/MarkNotificationRead/MarkNotificationReadCommandHandler.cs b/backend/src/CCE.Application/Notifications/Public/Commands/MarkNotificationRead/MarkNotificationReadCommandHandler.cs index 73107444..92a1445b 100644 --- a/backend/src/CCE.Application/Notifications/Public/Commands/MarkNotificationRead/MarkNotificationReadCommandHandler.cs +++ b/backend/src/CCE.Application/Notifications/Public/Commands/MarkNotificationRead/MarkNotificationReadCommandHandler.cs @@ -1,29 +1,41 @@ +using CCE.Application.Common; +using CCE.Application.Common.Interfaces; +using CCE.Application.Messages; +using CCE.Application.Notifications.Public; using CCE.Domain.Common; using MediatR; namespace CCE.Application.Notifications.Public.Commands.MarkNotificationRead; -public sealed class MarkNotificationReadCommandHandler : IRequestHandler +public sealed class MarkNotificationReadCommandHandler : IRequestHandler> { - private readonly IUserNotificationService _service; + private readonly IUserNotificationRepository _repo; + private readonly ICceDbContext _db; + private readonly MessageFactory _msg; private readonly ISystemClock _clock; - public MarkNotificationReadCommandHandler(IUserNotificationService service, ISystemClock clock) + public MarkNotificationReadCommandHandler( + IUserNotificationRepository repo, + ICceDbContext db, + MessageFactory msg, + ISystemClock clock) { - _service = service; + _repo = repo; + _db = db; + _msg = msg; _clock = clock; } - public async Task Handle(MarkNotificationReadCommand request, CancellationToken cancellationToken) + public async Task> Handle(MarkNotificationReadCommand request, CancellationToken cancellationToken) { - var notif = await _service.FindAsync(request.Id, cancellationToken).ConfigureAwait(false); + var notif = await _repo.GetAsync(request.Id, cancellationToken).ConfigureAwait(false); if (notif is null || notif.UserId != request.UserId) - throw new KeyNotFoundException($"Notification {request.Id} not found."); + return _msg.NotificationLogNotFound(); notif.MarkRead(_clock); - await _service.UpdateAsync(notif, cancellationToken).ConfigureAwait(false); + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - return Unit.Value; + return _msg.NotificationMarkedRead(); } } diff --git a/backend/src/CCE.Application/Notifications/Public/Commands/UpdateMyNotificationSettings/UpdateMyNotificationSettingsCommand.cs b/backend/src/CCE.Application/Notifications/Public/Commands/UpdateMyNotificationSettings/UpdateMyNotificationSettingsCommand.cs new file mode 100644 index 00000000..93aca2da --- /dev/null +++ b/backend/src/CCE.Application/Notifications/Public/Commands/UpdateMyNotificationSettings/UpdateMyNotificationSettingsCommand.cs @@ -0,0 +1,11 @@ +using CCE.Application.Common; +using CCE.Domain.Notifications; +using MediatR; + +namespace CCE.Application.Notifications.Public.Commands.UpdateMyNotificationSettings; + +public sealed record UpdateMyNotificationSettingsCommand( + System.Guid UserId, + NotificationChannel Channel, + bool IsEnabled, + string? EventCode = null) : IRequest>; diff --git a/backend/src/CCE.Application/Notifications/Public/Commands/UpdateMyNotificationSettings/UpdateMyNotificationSettingsCommandHandler.cs b/backend/src/CCE.Application/Notifications/Public/Commands/UpdateMyNotificationSettings/UpdateMyNotificationSettingsCommandHandler.cs new file mode 100644 index 00000000..659d6d97 --- /dev/null +++ b/backend/src/CCE.Application/Notifications/Public/Commands/UpdateMyNotificationSettings/UpdateMyNotificationSettingsCommandHandler.cs @@ -0,0 +1,52 @@ +using CCE.Application.Common; +using CCE.Application.Common.Interfaces; +using CCE.Application.Messages; +using CCE.Domain.Notifications; +using MediatR; + +namespace CCE.Application.Notifications.Public.Commands.UpdateMyNotificationSettings; + +public sealed class UpdateMyNotificationSettingsCommandHandler + : IRequestHandler> +{ + private readonly IUserNotificationSettingsRepository _repo; + private readonly ICceDbContext _db; + private readonly MessageFactory _msg; + + public UpdateMyNotificationSettingsCommandHandler( + IUserNotificationSettingsRepository repo, + ICceDbContext db, + MessageFactory msg) + { + _repo = repo; + _db = db; + _msg = msg; + } + + public async Task> Handle( + UpdateMyNotificationSettingsCommand request, + CancellationToken cancellationToken) + { + var existing = await _repo.GetAsync( + request.UserId, + request.Channel, + request.EventCode, + cancellationToken) + .ConfigureAwait(false); + + if (existing is not null) + { + existing.Update(request.IsEnabled); + } + else + { + var settings = UserNotificationSettings.Create( + request.UserId, request.Channel, request.IsEnabled, request.EventCode); + await _repo.AddAsync(settings, cancellationToken).ConfigureAwait(false); + } + + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + return _msg.NotificationSettingsUpdated(); + } +} diff --git a/backend/src/CCE.Application/Notifications/Public/Dtos/NotificationSettingsDto.cs b/backend/src/CCE.Application/Notifications/Public/Dtos/NotificationSettingsDto.cs new file mode 100644 index 00000000..fc24e15c --- /dev/null +++ b/backend/src/CCE.Application/Notifications/Public/Dtos/NotificationSettingsDto.cs @@ -0,0 +1,8 @@ +using CCE.Domain.Notifications; + +namespace CCE.Application.Notifications.Public.Dtos; + +public sealed record NotificationSettingsDto( + NotificationChannel Channel, + string? EventCode, + bool IsEnabled); diff --git a/backend/src/CCE.Application/Notifications/Public/IUserNotificationRepository.cs b/backend/src/CCE.Application/Notifications/Public/IUserNotificationRepository.cs new file mode 100644 index 00000000..7c84d815 --- /dev/null +++ b/backend/src/CCE.Application/Notifications/Public/IUserNotificationRepository.cs @@ -0,0 +1,16 @@ +using CCE.Domain.Common; +using CCE.Domain.Notifications; + +namespace CCE.Application.Notifications.Public; + +public interface IUserNotificationRepository +{ + Task GetAsync(System.Guid id, CancellationToken ct); + + Task AddAsync(UserNotification notification, CancellationToken ct); + + Task MarkAllSentAsReadAsync( + System.Guid userId, + ISystemClock clock, + CancellationToken ct); +} diff --git a/backend/src/CCE.Application/Notifications/Public/IUserNotificationService.cs b/backend/src/CCE.Application/Notifications/Public/IUserNotificationService.cs deleted file mode 100644 index 1e6d6a9c..00000000 --- a/backend/src/CCE.Application/Notifications/Public/IUserNotificationService.cs +++ /dev/null @@ -1,10 +0,0 @@ -using CCE.Domain.Notifications; - -namespace CCE.Application.Notifications.Public; - -public interface IUserNotificationService -{ - Task FindAsync(System.Guid id, CancellationToken ct); - Task UpdateAsync(UserNotification notification, CancellationToken ct); - Task MarkAllSentAsReadAsync(System.Guid userId, CancellationToken ct); -} diff --git a/backend/src/CCE.Application/Notifications/Public/Queries/GetMyNotificationSettings/GetMyNotificationSettingsQuery.cs b/backend/src/CCE.Application/Notifications/Public/Queries/GetMyNotificationSettings/GetMyNotificationSettingsQuery.cs new file mode 100644 index 00000000..c6b5538a --- /dev/null +++ b/backend/src/CCE.Application/Notifications/Public/Queries/GetMyNotificationSettings/GetMyNotificationSettingsQuery.cs @@ -0,0 +1,8 @@ +using CCE.Application.Common; +using CCE.Application.Notifications.Public.Dtos; +using MediatR; + +namespace CCE.Application.Notifications.Public.Queries.GetMyNotificationSettings; + +public sealed record GetMyNotificationSettingsQuery(System.Guid UserId) + : IRequest>>; diff --git a/backend/src/CCE.Application/Notifications/Public/Queries/GetMyNotificationSettings/GetMyNotificationSettingsQueryHandler.cs b/backend/src/CCE.Application/Notifications/Public/Queries/GetMyNotificationSettings/GetMyNotificationSettingsQueryHandler.cs new file mode 100644 index 00000000..cfdd6b15 --- /dev/null +++ b/backend/src/CCE.Application/Notifications/Public/Queries/GetMyNotificationSettings/GetMyNotificationSettingsQueryHandler.cs @@ -0,0 +1,49 @@ +using CCE.Application.Common; +using CCE.Application.Common.Interfaces; +using CCE.Application.Common.Pagination; +using CCE.Application.Messages; +using CCE.Application.Notifications.Public.Dtos; +using CCE.Domain.Notifications; +using MediatR; + +namespace CCE.Application.Notifications.Public.Queries.GetMyNotificationSettings; + +public sealed class GetMyNotificationSettingsQueryHandler + : IRequestHandler>> +{ + private readonly ICceDbContext _db; + private readonly MessageFactory _msg; + + public GetMyNotificationSettingsQueryHandler(ICceDbContext db, MessageFactory msg) + { + _db = db; + _msg = msg; + } + + public async Task>> Handle( + GetMyNotificationSettingsQuery request, + CancellationToken cancellationToken) + { + var explicitSettings = await _db.UserNotificationSettings + .Where(s => s.UserId == request.UserId) + .OrderBy(s => s.Channel) + .ThenBy(s => s.EventCode) + .ToListAsyncEither(cancellationToken) + .ConfigureAwait(false); + + var dtos = explicitSettings + .Select(s => new NotificationSettingsDto(s.Channel, s.EventCode, s.IsEnabled)) + .ToList(); + + // Ensure every channel has at least a default entry + foreach (NotificationChannel channel in Enum.GetValues()) + { + if (!dtos.Any(d => d.Channel == channel && d.EventCode is null)) + { + dtos.Insert(0, new NotificationSettingsDto(channel, null, true)); + } + } + + return _msg.Ok>(dtos, "ITEMS_LISTED"); + } +} diff --git a/backend/src/CCE.Application/Notifications/Public/Queries/GetMyUnreadCount/GetMyUnreadCountQuery.cs b/backend/src/CCE.Application/Notifications/Public/Queries/GetMyUnreadCount/GetMyUnreadCountQuery.cs index d7089046..8b1246a6 100644 --- a/backend/src/CCE.Application/Notifications/Public/Queries/GetMyUnreadCount/GetMyUnreadCountQuery.cs +++ b/backend/src/CCE.Application/Notifications/Public/Queries/GetMyUnreadCount/GetMyUnreadCountQuery.cs @@ -1,5 +1,6 @@ +using CCE.Application.Common; using MediatR; namespace CCE.Application.Notifications.Public.Queries.GetMyUnreadCount; -public sealed record GetMyUnreadCountQuery(System.Guid UserId) : IRequest; +public sealed record GetMyUnreadCountQuery(System.Guid UserId) : IRequest>; diff --git a/backend/src/CCE.Application/Notifications/Public/Queries/GetMyUnreadCount/GetMyUnreadCountQueryHandler.cs b/backend/src/CCE.Application/Notifications/Public/Queries/GetMyUnreadCount/GetMyUnreadCountQueryHandler.cs index ea2a4746..87d584cc 100644 --- a/backend/src/CCE.Application/Notifications/Public/Queries/GetMyUnreadCount/GetMyUnreadCountQueryHandler.cs +++ b/backend/src/CCE.Application/Notifications/Public/Queries/GetMyUnreadCount/GetMyUnreadCountQueryHandler.cs @@ -1,25 +1,30 @@ +using CCE.Application.Common; using CCE.Application.Common.Interfaces; using CCE.Application.Common.Pagination; +using CCE.Application.Messages; using CCE.Domain.Notifications; using MediatR; namespace CCE.Application.Notifications.Public.Queries.GetMyUnreadCount; -public sealed class GetMyUnreadCountQueryHandler : IRequestHandler +public sealed class GetMyUnreadCountQueryHandler : IRequestHandler> { private readonly ICceDbContext _db; + private readonly MessageFactory _msg; - public GetMyUnreadCountQueryHandler(ICceDbContext db) + public GetMyUnreadCountQueryHandler(ICceDbContext db, MessageFactory msg) { _db = db; + _msg = msg; } - public async Task Handle(GetMyUnreadCountQuery request, CancellationToken cancellationToken) + public async Task> Handle(GetMyUnreadCountQuery request, CancellationToken cancellationToken) { var userId = request.UserId; - return await _db.UserNotifications + var count = await _db.UserNotifications .Where(n => n.UserId == userId && n.Status == NotificationStatus.Sent) .CountAsyncEither(cancellationToken) .ConfigureAwait(false); + return _msg.Ok(count, "ITEMS_LISTED"); } } diff --git a/backend/src/CCE.Application/Notifications/Public/Queries/ListMyNotifications/ListMyNotificationsQuery.cs b/backend/src/CCE.Application/Notifications/Public/Queries/ListMyNotifications/ListMyNotificationsQuery.cs index e43f2372..6c476818 100644 --- a/backend/src/CCE.Application/Notifications/Public/Queries/ListMyNotifications/ListMyNotificationsQuery.cs +++ b/backend/src/CCE.Application/Notifications/Public/Queries/ListMyNotifications/ListMyNotificationsQuery.cs @@ -1,3 +1,4 @@ +using CCE.Application.Common; using CCE.Application.Common.Pagination; using CCE.Application.Notifications.Public.Dtos; using CCE.Domain.Notifications; @@ -9,4 +10,4 @@ public sealed record ListMyNotificationsQuery( System.Guid UserId, int Page = 1, int PageSize = 20, - NotificationStatus? Status = null) : IRequest>; + NotificationStatus? Status = null) : IRequest>>; diff --git a/backend/src/CCE.Application/Notifications/Public/Queries/ListMyNotifications/ListMyNotificationsQueryHandler.cs b/backend/src/CCE.Application/Notifications/Public/Queries/ListMyNotifications/ListMyNotificationsQueryHandler.cs index 6c0f1d04..10dca722 100644 --- a/backend/src/CCE.Application/Notifications/Public/Queries/ListMyNotifications/ListMyNotificationsQueryHandler.cs +++ b/backend/src/CCE.Application/Notifications/Public/Queries/ListMyNotifications/ListMyNotificationsQueryHandler.cs @@ -1,5 +1,7 @@ +using CCE.Application.Common; using CCE.Application.Common.Interfaces; using CCE.Application.Common.Pagination; +using CCE.Application.Messages; using CCE.Application.Notifications.Public.Dtos; using CCE.Domain.Notifications; using MediatR; @@ -7,16 +9,18 @@ namespace CCE.Application.Notifications.Public.Queries.ListMyNotifications; public sealed class ListMyNotificationsQueryHandler - : IRequestHandler> + : IRequestHandler>> { private readonly ICceDbContext _db; + private readonly MessageFactory _msg; - public ListMyNotificationsQueryHandler(ICceDbContext db) + public ListMyNotificationsQueryHandler(ICceDbContext db, MessageFactory msg) { _db = db; + _msg = msg; } - public async Task> Handle( + public async Task>> Handle( ListMyNotificationsQuery request, CancellationToken cancellationToken) { @@ -34,7 +38,8 @@ public async Task> Handle( .ConfigureAwait(false); var items = page.Items.Select(MapToDto).ToList(); - return new PagedResult(items, page.Page, page.PageSize, page.Total); + var result = new PagedResult(items, page.Page, page.PageSize, page.Total); + return _msg.Ok(result, "ITEMS_LISTED"); } internal static UserNotificationDto MapToDto(UserNotification n) => new( diff --git a/backend/src/CCE.Application/Notifications/Queries/GetNotificationTemplateById/GetNotificationTemplateByIdQuery.cs b/backend/src/CCE.Application/Notifications/Queries/GetNotificationTemplateById/GetNotificationTemplateByIdQuery.cs index 61fcb276..a9c03ef3 100644 --- a/backend/src/CCE.Application/Notifications/Queries/GetNotificationTemplateById/GetNotificationTemplateByIdQuery.cs +++ b/backend/src/CCE.Application/Notifications/Queries/GetNotificationTemplateById/GetNotificationTemplateByIdQuery.cs @@ -1,6 +1,7 @@ +using CCE.Application.Common; using CCE.Application.Notifications.Dtos; using MediatR; namespace CCE.Application.Notifications.Queries.GetNotificationTemplateById; -public sealed record GetNotificationTemplateByIdQuery(System.Guid Id) : IRequest; +public sealed record GetNotificationTemplateByIdQuery(System.Guid Id) : IRequest>; diff --git a/backend/src/CCE.Application/Notifications/Queries/GetNotificationTemplateById/GetNotificationTemplateByIdQueryHandler.cs b/backend/src/CCE.Application/Notifications/Queries/GetNotificationTemplateById/GetNotificationTemplateByIdQueryHandler.cs index 1bbcc3cc..983b220f 100644 --- a/backend/src/CCE.Application/Notifications/Queries/GetNotificationTemplateById/GetNotificationTemplateByIdQueryHandler.cs +++ b/backend/src/CCE.Application/Notifications/Queries/GetNotificationTemplateById/GetNotificationTemplateByIdQueryHandler.cs @@ -1,5 +1,7 @@ +using CCE.Application.Common; using CCE.Application.Common.Interfaces; using CCE.Application.Common.Pagination; +using CCE.Application.Messages; using CCE.Application.Notifications.Dtos; using CCE.Application.Notifications.Queries.ListNotificationTemplates; using MediatR; @@ -7,16 +9,18 @@ namespace CCE.Application.Notifications.Queries.GetNotificationTemplateById; public sealed class GetNotificationTemplateByIdQueryHandler - : IRequestHandler + : IRequestHandler> { private readonly ICceDbContext _db; + private readonly MessageFactory _msg; - public GetNotificationTemplateByIdQueryHandler(ICceDbContext db) + public GetNotificationTemplateByIdQueryHandler(ICceDbContext db, MessageFactory msg) { _db = db; + _msg = msg; } - public async Task Handle( + public async Task> Handle( GetNotificationTemplateByIdQuery request, CancellationToken cancellationToken) { @@ -25,6 +29,8 @@ public GetNotificationTemplateByIdQueryHandler(ICceDbContext db) .ToListAsyncEither(cancellationToken) .ConfigureAwait(false); var template = list.SingleOrDefault(); - return template is null ? null : ListNotificationTemplatesQueryHandler.MapToDto(template); + return template is null + ? _msg.NotificationTemplateNotFound() + : _msg.Ok(ListNotificationTemplatesQueryHandler.MapToDto(template), "ITEMS_LISTED"); } } diff --git a/backend/src/CCE.Application/Notifications/Queries/ListNotificationTemplates/ListNotificationTemplatesQuery.cs b/backend/src/CCE.Application/Notifications/Queries/ListNotificationTemplates/ListNotificationTemplatesQuery.cs index f9392987..a0f0826b 100644 --- a/backend/src/CCE.Application/Notifications/Queries/ListNotificationTemplates/ListNotificationTemplatesQuery.cs +++ b/backend/src/CCE.Application/Notifications/Queries/ListNotificationTemplates/ListNotificationTemplatesQuery.cs @@ -1,3 +1,4 @@ +using CCE.Application.Common; using CCE.Application.Common.Pagination; using CCE.Application.Notifications.Dtos; using CCE.Domain.Notifications; @@ -9,4 +10,4 @@ public sealed record ListNotificationTemplatesQuery( int Page = 1, int PageSize = 20, NotificationChannel? Channel = null, - bool? IsActive = null) : IRequest>; + bool? IsActive = null) : IRequest>>; diff --git a/backend/src/CCE.Application/Notifications/Queries/ListNotificationTemplates/ListNotificationTemplatesQueryHandler.cs b/backend/src/CCE.Application/Notifications/Queries/ListNotificationTemplates/ListNotificationTemplatesQueryHandler.cs index e9380649..86ae779b 100644 --- a/backend/src/CCE.Application/Notifications/Queries/ListNotificationTemplates/ListNotificationTemplatesQueryHandler.cs +++ b/backend/src/CCE.Application/Notifications/Queries/ListNotificationTemplates/ListNotificationTemplatesQueryHandler.cs @@ -1,5 +1,7 @@ +using CCE.Application.Common; using CCE.Application.Common.Interfaces; using CCE.Application.Common.Pagination; +using CCE.Application.Messages; using CCE.Application.Notifications.Dtos; using CCE.Domain.Notifications; using MediatR; @@ -7,16 +9,18 @@ namespace CCE.Application.Notifications.Queries.ListNotificationTemplates; public sealed class ListNotificationTemplatesQueryHandler - : IRequestHandler> + : IRequestHandler>> { private readonly ICceDbContext _db; + private readonly MessageFactory _msg; - public ListNotificationTemplatesQueryHandler(ICceDbContext db) + public ListNotificationTemplatesQueryHandler(ICceDbContext db, MessageFactory msg) { _db = db; + _msg = msg; } - public async Task> Handle( + public async Task>> Handle( ListNotificationTemplatesQuery request, CancellationToken cancellationToken) { @@ -38,7 +42,8 @@ public async Task> Handle( .ConfigureAwait(false); var items = page.Items.Select(MapToDto).ToList(); - return new PagedResult(items, page.Page, page.PageSize, page.Total); + var result = new PagedResult(items, page.Page, page.PageSize, page.Total); + return _msg.Ok(result, "ITEMS_LISTED"); } internal static NotificationTemplateDto MapToDto(NotificationTemplate t) => new( diff --git a/backend/src/CCE.Application/Notifications/RenderedNotification.cs b/backend/src/CCE.Application/Notifications/RenderedNotification.cs new file mode 100644 index 00000000..0a6dbbd8 --- /dev/null +++ b/backend/src/CCE.Application/Notifications/RenderedNotification.cs @@ -0,0 +1,16 @@ +using CCE.Domain.Notifications; + +namespace CCE.Application.Notifications; + +public sealed record RenderedNotification( + string TemplateCode, + System.Guid? RecipientUserId, + System.Guid TemplateId, + string Subject, + string SubjectAr, + string SubjectEn, + string Body, + NotificationChannel Channel, + string Locale, + string? Email = null, + string? PhoneNumber = null); diff --git a/backend/src/CCE.Domain/Notifications/NotificationDeliveryStatus.cs b/backend/src/CCE.Domain/Notifications/NotificationDeliveryStatus.cs new file mode 100644 index 00000000..657b0b3d --- /dev/null +++ b/backend/src/CCE.Domain/Notifications/NotificationDeliveryStatus.cs @@ -0,0 +1,9 @@ +namespace CCE.Domain.Notifications; + +public enum NotificationDeliveryStatus +{ + Pending = 0, + Sent = 1, + Failed = 2, + Skipped = 3 +} diff --git a/backend/src/CCE.Domain/Notifications/NotificationEventType.cs b/backend/src/CCE.Domain/Notifications/NotificationEventType.cs new file mode 100644 index 00000000..58af5c86 --- /dev/null +++ b/backend/src/CCE.Domain/Notifications/NotificationEventType.cs @@ -0,0 +1,14 @@ +namespace CCE.Domain.Notifications; + +public enum NotificationEventType +{ + ExpertRequestApproved = 0, + ExpertRequestRejected = 1, + CountryResourceApproved = 2, + CountryResourceRejected = 3, + NewsPublished = 4, + ResourcePublished = 5, + EventScheduled = 6, + CommunityPostCreated = 7, + AdminAccountCreated = 8 +} diff --git a/backend/src/CCE.Domain/Notifications/NotificationLog.cs b/backend/src/CCE.Domain/Notifications/NotificationLog.cs new file mode 100644 index 00000000..4b4ddbb2 --- /dev/null +++ b/backend/src/CCE.Domain/Notifications/NotificationLog.cs @@ -0,0 +1,98 @@ +using CCE.Domain.Common; + +namespace CCE.Domain.Notifications; + +/// +/// Tracks every attempted delivery per channel. Supports admin troubleshooting and retry. +/// +public sealed class NotificationLog : Entity +{ + private NotificationLog( + System.Guid id, + System.Guid? recipientUserId, + string templateCode, + System.Guid? templateId, + NotificationChannel channel, + string? payloadJson, + string? correlationId) : base(id) + { + RecipientUserId = recipientUserId; + TemplateCode = templateCode; + TemplateId = templateId; + Channel = channel; + Status = NotificationDeliveryStatus.Pending; + AttemptCount = 1; + CreatedOn = System.DateTimeOffset.UtcNow; + PayloadJson = payloadJson; + CorrelationId = correlationId; + } + + public System.Guid? RecipientUserId { get; private set; } + public string TemplateCode { get; private set; } + public System.Guid? TemplateId { get; private set; } + public NotificationChannel Channel { get; private set; } + public NotificationDeliveryStatus Status { get; private set; } + public string? ProviderMessageId { get; private set; } + public string? Error { get; private set; } + public int AttemptCount { get; private set; } + public System.DateTimeOffset CreatedOn { get; private set; } + public System.DateTimeOffset? SentOn { get; private set; } + public System.DateTimeOffset? FailedOn { get; private set; } + public string? CorrelationId { get; private set; } + public string? PayloadJson { get; private set; } + + public static NotificationLog Create( + System.Guid? recipientUserId, + string templateCode, + System.Guid? templateId, + NotificationChannel channel, + string? payloadJson = null, + string? correlationId = null) + { + if (string.IsNullOrWhiteSpace(templateCode)) + throw new DomainException("TemplateCode is required."); + + return new NotificationLog( + System.Guid.NewGuid(), + recipientUserId, + templateCode, + templateId, + channel, + payloadJson, + correlationId); + } + + public void MarkSent(string? providerMessageId = null) + { + if (Status == NotificationDeliveryStatus.Sent) + throw new DomainException("Log is already marked as sent."); + + Status = NotificationDeliveryStatus.Sent; + ProviderMessageId = providerMessageId; + SentOn = System.DateTimeOffset.UtcNow; + } + + public void MarkFailed(string error) + { + if (Status == NotificationDeliveryStatus.Sent) + throw new DomainException("Cannot mark a sent log as failed."); + + Status = NotificationDeliveryStatus.Failed; + Error = error; + FailedOn = System.DateTimeOffset.UtcNow; + } + + public void MarkSkipped(string reason) + { + Status = NotificationDeliveryStatus.Skipped; + Error = reason; + } + + public void IncrementAttempt() + { + AttemptCount++; + Status = NotificationDeliveryStatus.Pending; + Error = null; + FailedOn = null; + } +} diff --git a/backend/src/CCE.Domain/Notifications/UserNotificationSettings.cs b/backend/src/CCE.Domain/Notifications/UserNotificationSettings.cs new file mode 100644 index 00000000..bd7fc8df --- /dev/null +++ b/backend/src/CCE.Domain/Notifications/UserNotificationSettings.cs @@ -0,0 +1,49 @@ +using CCE.Domain.Common; + +namespace CCE.Domain.Notifications; + +/// +/// User-level opt-in/opt-out for notification channels. A row with null EventCode +/// acts as the default for that channel; explicit EventCode rows override the default. +/// +public sealed class UserNotificationSettings : Entity +{ + private UserNotificationSettings( + System.Guid id, + System.Guid userId, + NotificationChannel channel, + string? eventCode, + bool isEnabled) : base(id) + { + UserId = userId; + Channel = channel; + EventCode = eventCode; + IsEnabled = isEnabled; + UpdatedOn = System.DateTimeOffset.UtcNow; + } + + public System.Guid UserId { get; private set; } + public NotificationChannel Channel { get; private set; } + public string? EventCode { get; private set; } + public bool IsEnabled { get; private set; } + public System.DateTimeOffset UpdatedOn { get; private set; } + + public static UserNotificationSettings Create( + System.Guid userId, + NotificationChannel channel, + bool isEnabled, + string? eventCode = null) + { + if (userId == System.Guid.Empty) + throw new DomainException("UserId is required."); + + return new UserNotificationSettings( + System.Guid.NewGuid(), userId, channel, eventCode, isEnabled); + } + + public void Update(bool isEnabled) + { + IsEnabled = isEnabled; + UpdatedOn = System.DateTimeOffset.UtcNow; + } +} diff --git a/backend/src/CCE.Infrastructure/CCE.Infrastructure.csproj b/backend/src/CCE.Infrastructure/CCE.Infrastructure.csproj index 597334ee..457884b6 100644 --- a/backend/src/CCE.Infrastructure/CCE.Infrastructure.csproj +++ b/backend/src/CCE.Infrastructure/CCE.Infrastructure.csproj @@ -13,13 +13,7 @@ - - - - - - - + @@ -47,6 +41,8 @@ + + diff --git a/backend/src/CCE.Infrastructure/Community/CommunityReadService.cs b/backend/src/CCE.Infrastructure/Community/CommunityReadService.cs new file mode 100644 index 00000000..8996b751 --- /dev/null +++ b/backend/src/CCE.Infrastructure/Community/CommunityReadService.cs @@ -0,0 +1,36 @@ +using CCE.Application.Community; +using CCE.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace CCE.Infrastructure.Community; + +public sealed class CommunityReadService : ICommunityReadService +{ + private readonly CceDbContext _db; + + public CommunityReadService(CceDbContext db) + { + _db = db; + } + + public async Task> GetTopicFollowerIdsAsync( + System.Guid topicId, + System.Guid? excludeUserId, + CancellationToken ct) + { + var query = _db.TopicFollows.Where(f => f.TopicId == topicId); + + if (excludeUserId is { } excl) + { + query = query.Where(f => f.UserId != excl); + } + + var ids = await query + .Select(f => f.UserId) + .Distinct() + .ToListAsync(ct) + .ConfigureAwait(false); + + return ids; + } +} diff --git a/backend/src/CCE.Infrastructure/DependencyInjection.cs b/backend/src/CCE.Infrastructure/DependencyInjection.cs index fe3aad04..cede8999 100644 --- a/backend/src/CCE.Infrastructure/DependencyInjection.cs +++ b/backend/src/CCE.Infrastructure/DependencyInjection.cs @@ -13,6 +13,7 @@ using CCE.Application.Identity.Public; using CCE.Application.InteractiveCity; using CCE.Application.Notifications; +using CCE.Application.Notifications.Messages; using CCE.Application.Notifications.Public; using CCE.Application.Reports; using CCE.Application.Search; @@ -25,6 +26,7 @@ using CCE.Infrastructure.Sanitization; using CCE.Infrastructure.Country; using CCE.Infrastructure.Notifications; +using CCE.Infrastructure.Notifications.Messaging; using CCE.Infrastructure.Reports; using CCE.Infrastructure.Surveys; using CCE.Application.Localization; @@ -121,7 +123,6 @@ public static IServiceCollection AddInfrastructure( services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); services.AddScoped(); // Sub-11 Phase 01 — Microsoft Graph user-create + CCE-side persist. @@ -185,8 +186,20 @@ public static IServiceCollection AddInfrastructure( services.AddScoped(); services.AddScoped(); - services.AddScoped(); - services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + // Notification gateway + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -207,6 +220,10 @@ public static IServiceCollection AddInfrastructure( // Interactive City services.AddScoped(); + // Messaging (MassTransit) — transport selected by Messaging:Transport in appsettings. + // InMemory by default (no broker); set to RabbitMQ in production. + services.AddCceMessaging(configuration); + // Search services.AddScoped(); services.AddScoped(); diff --git a/backend/src/CCE.Infrastructure/Identity/AuthService.cs b/backend/src/CCE.Infrastructure/Identity/AuthService.cs index d90ecd8f..a5a1c618 100644 --- a/backend/src/CCE.Infrastructure/Identity/AuthService.cs +++ b/backend/src/CCE.Infrastructure/Identity/AuthService.cs @@ -1,9 +1,12 @@ using CCE.Application.Common.Interfaces; using CCE.Application.Identity.Auth.Common; +using CCE.Application.Notifications; using CCE.Domain.Common; using CCE.Domain.Identity; +using CCE.Domain.Notifications; using CCE.Integration.AdminAuth; using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Options; namespace CCE.Infrastructure.Identity; @@ -18,7 +21,8 @@ public sealed class AuthService : IAuthService private readonly ICceDbContext _db; private readonly ISystemClock _clock; private readonly IOptions _options; - private readonly IPasswordResetEmailSender _emailSender; + private readonly INotificationGateway _gateway; + private readonly IConfiguration _config; private readonly IAdminAuthGatewayClient _adGateway; public AuthService( @@ -29,7 +33,8 @@ public AuthService( ICceDbContext db, ISystemClock clock, IOptions options, - IPasswordResetEmailSender emailSender, + INotificationGateway gateway, + IConfiguration config, IAdminAuthGatewayClient adGateway) { _userManager = userManager; @@ -39,7 +44,8 @@ public AuthService( _db = db; _clock = clock; _options = options; - _emailSender = emailSender; + _gateway = gateway; + _config = config; _adGateway = adGateway; } @@ -152,7 +158,23 @@ public async Task ForgotPasswordAsync(string email, CancellationToken ct) if (user is not null) { var token = await _userManager.GeneratePasswordResetTokenAsync(user).ConfigureAwait(false); - await _emailSender.SendAsync(user, PasswordResetTokenCodec.Encode(token), ct).ConfigureAwait(false); + var encodedToken = PasswordResetTokenCodec.Encode(token); + var baseUrl = _config.GetValue("Frontend:PasswordResetUrl") + ?? "http://localhost:4200/reset-password"; + var separator = baseUrl.Contains('?', StringComparison.Ordinal) ? '&' : '?'; + var resetUrl = $"{baseUrl}{separator}email={Uri.EscapeDataString(user.Email ?? string.Empty)}&token={Uri.EscapeDataString(encodedToken)}"; + + await _gateway.SendAsync(new NotificationDispatchRequest( + TemplateCode: "PASSWORD_RESET", + RecipientUserId: user.Id, + Channels: [NotificationChannel.Email, NotificationChannel.Sms], + Variables: new Dictionary + { + ["Name"] = user.FirstName, + ["ResetUrl"] = resetUrl + }, + Locale: user.LocalePreference, + BypassSettings: true), ct).ConfigureAwait(false); } } diff --git a/backend/src/CCE.Infrastructure/Identity/PasswordResetEmailSender.cs b/backend/src/CCE.Infrastructure/Identity/PasswordResetEmailSender.cs deleted file mode 100644 index d78cf6e7..00000000 --- a/backend/src/CCE.Infrastructure/Identity/PasswordResetEmailSender.cs +++ /dev/null @@ -1,42 +0,0 @@ -using System.Net; -using CCE.Application.Common.Interfaces; -using CCE.Application.Identity.Auth.Common; -using CCE.Domain.Identity; -using Microsoft.Extensions.Configuration; - -namespace CCE.Infrastructure.Identity; - -public sealed class PasswordResetEmailSender : IPasswordResetEmailSender -{ - private readonly IEmailSender _emailSender; - private readonly IConfiguration _configuration; - - public PasswordResetEmailSender(IEmailSender emailSender, IConfiguration configuration) - { - _emailSender = emailSender; - _configuration = configuration; - } - - public async Task SendAsync(User user, string resetToken, CancellationToken ct) - { - var baseUrl = _configuration.GetValue("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> ListForUserAndChannelsAsync( + System.Guid userId, + IReadOnlyCollection channels, + CancellationToken ct) + => await Db.UserNotificationSettings + .Where(s => s.UserId == userId && channels.Contains(s.Channel)) + .ToListAsync(ct) + .ConfigureAwait(false); +} diff --git a/backend/src/CCE.Infrastructure/Persistence/CceDbContext.cs b/backend/src/CCE.Infrastructure/Persistence/CceDbContext.cs index ff331ba8..7a742b30 100644 --- a/backend/src/CCE.Infrastructure/Persistence/CceDbContext.cs +++ b/backend/src/CCE.Infrastructure/Persistence/CceDbContext.cs @@ -78,6 +78,8 @@ public CceDbContext(DbContextOptions options) : base(options) { } // ─── Notifications ─── public DbSet NotificationTemplates => Set(); public DbSet UserNotifications => Set(); + public DbSet NotificationLogs => Set(); + public DbSet UserNotificationSettings => Set(); // ─── Surveys ─── public DbSet ServiceRatings => Set(); @@ -123,6 +125,8 @@ public CceDbContext(DbContextOptions options) : base(options) { } IQueryable ICceDbContext.PostFollows => PostFollows.AsNoTracking(); IQueryable ICceDbContext.NotificationTemplates => NotificationTemplates.AsNoTracking(); IQueryable ICceDbContext.UserNotifications => UserNotifications.AsNoTracking(); + IQueryable ICceDbContext.NotificationLogs => NotificationLogs.AsNoTracking(); + IQueryable ICceDbContext.UserNotificationSettings => UserNotificationSettings.AsNoTracking(); IQueryable ICceDbContext.ServiceRatings => ServiceRatings.AsNoTracking(); IQueryable ICceDbContext.AuditEvents => AuditEvents.AsNoTracking(); IQueryable ICceDbContext.KnowledgeMaps => KnowledgeMaps.AsNoTracking(); diff --git a/backend/src/CCE.Infrastructure/Persistence/Configurations/Notifications/NotificationLogConfiguration.cs b/backend/src/CCE.Infrastructure/Persistence/Configurations/Notifications/NotificationLogConfiguration.cs new file mode 100644 index 00000000..2df1c0b5 --- /dev/null +++ b/backend/src/CCE.Infrastructure/Persistence/Configurations/Notifications/NotificationLogConfiguration.cs @@ -0,0 +1,34 @@ +using CCE.Domain.Notifications; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CCE.Infrastructure.Persistence.Configurations.Notifications; + +internal sealed class NotificationLogConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(x => x.Id); + builder.Property(x => x.Id).ValueGeneratedNever(); + builder.Property(x => x.RecipientUserId); + builder.Property(x => x.TemplateCode).HasMaxLength(64).IsRequired(); + builder.Property(x => x.TemplateId); + builder.Property(x => x.Channel).HasConversion(); + builder.Property(x => x.Status).HasConversion(); + builder.Property(x => x.ProviderMessageId).HasMaxLength(256); + builder.Property(x => x.Error).HasColumnType("nvarchar(max)"); + builder.Property(x => x.AttemptCount).IsRequired(); + builder.Property(x => x.CreatedOn).IsRequired(); + builder.Property(x => x.SentOn); + builder.Property(x => x.FailedOn); + builder.Property(x => x.CorrelationId).HasMaxLength(64); + builder.Property(x => x.PayloadJson).HasColumnType("nvarchar(max)"); + + builder.HasIndex(x => new { x.RecipientUserId, x.Status, x.CreatedOn }) + .HasDatabaseName("ix_notification_log_recipient_status_created"); + builder.HasIndex(x => new { x.TemplateCode, x.Channel }) + .HasDatabaseName("ix_notification_log_template_channel"); + builder.HasIndex(x => x.CorrelationId) + .HasDatabaseName("ix_notification_log_correlation_id"); + } +} diff --git a/backend/src/CCE.Infrastructure/Persistence/Configurations/Notifications/NotificationTemplateConfiguration.cs b/backend/src/CCE.Infrastructure/Persistence/Configurations/Notifications/NotificationTemplateConfiguration.cs index 23b6b3b7..97ca0a80 100644 --- a/backend/src/CCE.Infrastructure/Persistence/Configurations/Notifications/NotificationTemplateConfiguration.cs +++ b/backend/src/CCE.Infrastructure/Persistence/Configurations/Notifications/NotificationTemplateConfiguration.cs @@ -17,6 +17,6 @@ public void Configure(EntityTypeBuilder builder) builder.Property(t => t.BodyEn).HasColumnType("nvarchar(max)"); builder.Property(t => t.Channel).HasConversion(); builder.Property(t => t.VariableSchemaJson).HasColumnType("nvarchar(max)"); - builder.HasIndex(t => t.Code).IsUnique().HasDatabaseName("ux_notification_template_code"); + builder.HasIndex(t => new { t.Code, t.Channel }).IsUnique().HasDatabaseName("ux_notification_template_code_channel"); } } diff --git a/backend/src/CCE.Infrastructure/Persistence/Configurations/Notifications/UserNotificationSettingsConfiguration.cs b/backend/src/CCE.Infrastructure/Persistence/Configurations/Notifications/UserNotificationSettingsConfiguration.cs new file mode 100644 index 00000000..2e7fab91 --- /dev/null +++ b/backend/src/CCE.Infrastructure/Persistence/Configurations/Notifications/UserNotificationSettingsConfiguration.cs @@ -0,0 +1,23 @@ +using CCE.Domain.Notifications; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CCE.Infrastructure.Persistence.Configurations.Notifications; + +internal sealed class UserNotificationSettingsConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(x => x.Id); + builder.Property(x => x.Id).ValueGeneratedNever(); + builder.Property(x => x.UserId).IsRequired(); + builder.Property(x => x.Channel).HasConversion().IsRequired(); + builder.Property(x => x.EventCode).HasMaxLength(64); + builder.Property(x => x.IsEnabled).IsRequired(); + builder.Property(x => x.UpdatedOn).IsRequired(); + + builder.HasIndex(x => new { x.UserId, x.Channel, x.EventCode }) + .IsUnique() + .HasDatabaseName("ux_user_notification_settings_user_channel_event"); + } +} diff --git a/backend/src/CCE.Infrastructure/Persistence/EntityRepository.cs b/backend/src/CCE.Infrastructure/Persistence/EntityRepository.cs new file mode 100644 index 00000000..8d5e6777 --- /dev/null +++ b/backend/src/CCE.Infrastructure/Persistence/EntityRepository.cs @@ -0,0 +1,31 @@ +using CCE.Domain.Common; +using Microsoft.EntityFrameworkCore; + +namespace CCE.Infrastructure.Persistence; + +public abstract class EntityRepository + where T : Entity + where TId : IEquatable +{ + protected CceDbContext Db { get; } + + protected EntityRepository(CceDbContext db) => Db = db; + + public virtual async Task GetByIdAsync(TId id, CancellationToken ct) + => await Db.Set().FindAsync(new object[] { id }, ct).ConfigureAwait(false); + + public virtual async Task AddAsync(T entity, CancellationToken ct) + => await Db.Set().AddAsync(entity, ct).ConfigureAwait(false); + + public virtual void Update(T entity) + { + if (Db.Entry(entity).State == EntityState.Detached) + { + Db.Set().Attach(entity); + Db.Entry(entity).State = EntityState.Modified; + } + } + + public virtual void Delete(T entity) + => Db.Set().Remove(entity); +} diff --git a/backend/src/CCE.Infrastructure/Persistence/Migrations/20260523111750_AddNotificationGateway.Designer.cs b/backend/src/CCE.Infrastructure/Persistence/Migrations/20260523111750_AddNotificationGateway.Designer.cs new file mode 100644 index 00000000..b46ff60a --- /dev/null +++ b/backend/src/CCE.Infrastructure/Persistence/Migrations/20260523111750_AddNotificationGateway.Designer.cs @@ -0,0 +1,3545 @@ +// +using System; +using CCE.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CCE.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(CceDbContext))] + [Migration("20260523111750_AddNotificationGateway")] + partial class AddNotificationGateway + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("CCE.Domain.Audit.AuditEvent", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("action"); + + b.Property("Actor") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("actor"); + + b.Property("CorrelationId") + .HasColumnType("uniqueidentifier") + .HasColumnName("correlation_id"); + + b.Property("Diff") + .HasColumnType("nvarchar(max)") + .HasColumnName("diff"); + + b.Property("OccurredOn") + .HasColumnType("datetimeoffset") + .HasColumnName("occurred_on"); + + b.Property("Resource") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("resource"); + + b.HasKey("Id") + .HasName("pk_audit_events"); + + b.HasIndex("CorrelationId") + .HasDatabaseName("ix_audit_events_correlation_id"); + + b.HasIndex("Actor", "OccurredOn") + .HasDatabaseName("ix_audit_events_actor_occurred_on"); + + b.ToTable("audit_events", null, t => + { + t.HasTrigger("trg_audit_events_no_update_delete"); + }); + + b.HasAnnotation("SqlServer:UseSqlOutputClause", false); + }); + + modelBuilder.Entity("CCE.Domain.Community.Post", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AnsweredReplyId") + .HasColumnType("uniqueidentifier") + .HasColumnName("answered_reply_id"); + + b.Property("AuthorId") + .HasColumnType("uniqueidentifier") + .HasColumnName("author_id"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)") + .HasColumnName("content"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsAnswerable") + .HasColumnType("bit") + .HasColumnName("is_answerable"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("locale"); + + b.Property("TopicId") + .HasColumnType("uniqueidentifier") + .HasColumnName("topic_id"); + + b.HasKey("Id") + .HasName("pk_posts"); + + b.HasIndex("TopicId") + .HasDatabaseName("ix_post_topic_id"); + + b.HasIndex("AuthorId", "CreatedOn") + .HasDatabaseName("ix_post_author_created"); + + b.ToTable("posts", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.PostFollow", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("FollowedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("followed_on"); + + b.Property("PostId") + .HasColumnType("uniqueidentifier") + .HasColumnName("post_id"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_post_follows"); + + b.HasIndex("PostId", "UserId") + .IsUnique() + .HasDatabaseName("ux_post_follow_post_user"); + + b.ToTable("post_follows", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.PostRating", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("PostId") + .HasColumnType("uniqueidentifier") + .HasColumnName("post_id"); + + b.Property("RatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("rated_on"); + + b.Property("Stars") + .HasColumnType("int") + .HasColumnName("stars"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_post_ratings"); + + b.HasIndex("PostId", "UserId") + .IsUnique() + .HasDatabaseName("ux_post_rating_post_user"); + + b.ToTable("post_ratings", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.PostReply", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AuthorId") + .HasColumnType("uniqueidentifier") + .HasColumnName("author_id"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)") + .HasColumnName("content"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsByExpert") + .HasColumnType("bit") + .HasColumnName("is_by_expert"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("locale"); + + b.Property("ParentReplyId") + .HasColumnType("uniqueidentifier") + .HasColumnName("parent_reply_id"); + + b.Property("PostId") + .HasColumnType("uniqueidentifier") + .HasColumnName("post_id"); + + b.HasKey("Id") + .HasName("pk_post_replies"); + + b.HasIndex("ParentReplyId") + .HasDatabaseName("ix_post_reply_parent_id"); + + b.HasIndex("PostId") + .HasDatabaseName("ix_post_reply_post_id"); + + b.ToTable("post_replies", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.Topic", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("DescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("IconUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("icon_url"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.Property("ParentId") + .HasColumnType("uniqueidentifier") + .HasColumnName("parent_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_topics"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ux_topic_slug_active") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("topics", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.TopicFollow", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("FollowedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("followed_on"); + + b.Property("TopicId") + .HasColumnType("uniqueidentifier") + .HasColumnName("topic_id"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_topic_follows"); + + b.HasIndex("TopicId", "UserId") + .IsUnique() + .HasDatabaseName("ux_topic_follow_topic_user"); + + b.ToTable("topic_follows", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.UserFollow", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("FollowedId") + .HasColumnType("uniqueidentifier") + .HasColumnName("followed_id"); + + b.Property("FollowedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("followed_on"); + + b.Property("FollowerId") + .HasColumnType("uniqueidentifier") + .HasColumnName("follower_id"); + + b.HasKey("Id") + .HasName("pk_user_follows"); + + b.HasIndex("FollowerId", "FollowedId") + .IsUnique() + .HasDatabaseName("ux_user_follow_follower_followed"); + + b.ToTable("user_follows", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.AssetFile", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("MimeType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("mime_type"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("original_file_name"); + + b.Property("ScannedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("scanned_on"); + + b.Property("SizeBytes") + .HasColumnType("bigint") + .HasColumnName("size_bytes"); + + b.Property("UploadedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("uploaded_by_id"); + + b.Property("UploadedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("uploaded_on"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("url"); + + b.Property("VirusScanStatus") + .HasColumnType("int") + .HasColumnName("virus_scan_status"); + + b.HasKey("Id") + .HasName("pk_asset_files"); + + b.HasIndex("VirusScanStatus") + .HasDatabaseName("ix_asset_file_scan_status"); + + b.ToTable("asset_files", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.Event", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("DescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("EndsOn") + .HasColumnType("datetimeoffset") + .HasColumnName("ends_on"); + + b.Property("FeaturedImageUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("featured_image_url"); + + b.Property("ICalUid") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("i_cal_uid"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("LocationAr") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("location_ar"); + + b.Property("LocationEn") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("location_en"); + + b.Property("OnlineMeetingUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("online_meeting_url"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.Property("StartsOn") + .HasColumnType("datetimeoffset") + .HasColumnName("starts_on"); + + b.Property("TitleAr") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_ar"); + + b.Property("TitleEn") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_en"); + + b.HasKey("Id") + .HasName("pk_events"); + + b.HasIndex("ICalUid") + .IsUnique() + .HasDatabaseName("ux_event_ical_uid"); + + b.HasIndex("StartsOn") + .HasDatabaseName("ix_event_starts_on"); + + b.ToTable("events", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.HomepageSection", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("ContentAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_ar"); + + b.Property("ContentEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_en"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.Property("SectionType") + .HasColumnType("int") + .HasColumnName("section_type"); + + b.HasKey("Id") + .HasName("pk_homepage_sections"); + + b.HasIndex("IsActive", "OrderIndex") + .HasDatabaseName("ix_homepage_section_active_order"); + + b.ToTable("homepage_sections", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.News", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AuthorId") + .HasColumnType("uniqueidentifier") + .HasColumnName("author_id"); + + b.Property("ContentAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_ar"); + + b.Property("ContentEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_en"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("FeaturedImageUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("featured_image_url"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("IsFeatured") + .HasColumnType("bit") + .HasColumnName("is_featured"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("PublishedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("published_on"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("slug"); + + b.Property("TitleAr") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_ar"); + + b.Property("TitleEn") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_en"); + + b.HasKey("Id") + .HasName("pk_news"); + + b.HasIndex("PublishedOn") + .HasDatabaseName("ix_news_published_on"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ux_news_slug_active") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("news", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.NewsletterSubscription", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("ConfirmationToken") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("confirmation_token"); + + b.Property("ConfirmedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("confirmed_on"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("nvarchar(320)") + .HasColumnName("email"); + + b.Property("IsConfirmed") + .HasColumnType("bit") + .HasColumnName("is_confirmed"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("LocalePreference") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("locale_preference"); + + b.Property("UnsubscribedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("unsubscribed_on"); + + b.HasKey("Id") + .HasName("pk_newsletter_subscriptions"); + + b.HasIndex("ConfirmationToken") + .HasDatabaseName("ix_newsletter_token"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("ux_newsletter_email"); + + b.ToTable("newsletter_subscriptions", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.Page", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("ContentAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_ar"); + + b.Property("ContentEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_en"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("PageType") + .HasColumnType("int") + .HasColumnName("page_type"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("slug"); + + b.Property("TitleAr") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_ar"); + + b.Property("TitleEn") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_en"); + + b.HasKey("Id") + .HasName("pk_pages"); + + b.HasIndex("PageType", "Slug") + .IsUnique() + .HasDatabaseName("ux_page_type_slug_active") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("pages", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.Resource", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AssetFileId") + .HasColumnType("uniqueidentifier") + .HasColumnName("asset_file_id"); + + b.Property("CategoryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("category_id"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("DescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("PublishedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("published_on"); + + b.Property("ResourceType") + .HasColumnType("int") + .HasColumnName("resource_type"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.Property("TitleAr") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_ar"); + + b.Property("TitleEn") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_en"); + + b.Property("UploadedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("uploaded_by_id"); + + b.Property("ViewCount") + .HasColumnType("bigint") + .HasColumnName("view_count"); + + b.HasKey("Id") + .HasName("pk_resources"); + + b.HasIndex("AssetFileId") + .HasDatabaseName("ix_resource_asset_file_id"); + + b.HasIndex("CountryId") + .HasDatabaseName("ix_resource_country_id"); + + b.HasIndex("CategoryId", "PublishedOn") + .HasDatabaseName("ix_resource_category_published"); + + b.ToTable("resources", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.ResourceCategory", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.Property("ParentId") + .HasColumnType("uniqueidentifier") + .HasColumnName("parent_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_resource_categories"); + + b.HasIndex("ParentId") + .HasDatabaseName("ix_resource_category_parent_id"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ux_resource_category_slug"); + + b.ToTable("resource_categories", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Country.Country", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("FlagUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("flag_url"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("IsoAlpha2") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("iso_alpha2"); + + b.Property("IsoAlpha3") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("nvarchar(3)") + .HasColumnName("iso_alpha3"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("LatestKapsarcSnapshotId") + .HasColumnType("uniqueidentifier") + .HasColumnName("latest_kapsarc_snapshot_id"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.Property("RegionAr") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("region_ar"); + + b.Property("RegionEn") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("region_en"); + + b.HasKey("Id") + .HasName("pk_countries"); + + b.HasIndex("IsoAlpha2") + .HasDatabaseName("ix_country_iso_alpha2"); + + b.HasIndex("IsoAlpha3") + .IsUnique() + .HasDatabaseName("ux_country_iso_alpha3_active") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("countries", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Country.CountryKapsarcSnapshot", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("Classification") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("classification"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("PerformanceScore") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)") + .HasColumnName("performance_score"); + + b.Property("SnapshotTakenOn") + .HasColumnType("datetimeoffset") + .HasColumnName("snapshot_taken_on"); + + b.Property("SourceVersion") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)") + .HasColumnName("source_version"); + + b.Property("TotalIndex") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)") + .HasColumnName("total_index"); + + b.HasKey("Id") + .HasName("pk_country_kapsarc_snapshots"); + + b.HasIndex("CountryId", "SnapshotTakenOn") + .HasDatabaseName("ix_kapsarc_snapshot_country_taken"); + + b.ToTable("country_kapsarc_snapshots", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Country.CountryProfile", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("ContactInfoAr") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("contact_info_ar"); + + b.Property("ContactInfoEn") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("contact_info_en"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("KeyInitiativesAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("key_initiatives_ar"); + + b.Property("KeyInitiativesEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("key_initiatives_en"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.HasKey("Id") + .HasName("pk_country_profiles"); + + b.HasIndex("CountryId") + .IsUnique() + .HasDatabaseName("ux_country_profile_country_id"); + + b.ToTable("country_profiles", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Country.CountryResourceRequest", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AdminNotesAr") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("admin_notes_ar"); + + b.Property("AdminNotesEn") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("admin_notes_en"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("ProcessedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("processed_by_id"); + + b.Property("ProcessedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("processed_on"); + + b.Property("ProposedAssetFileId") + .HasColumnType("uniqueidentifier") + .HasColumnName("proposed_asset_file_id"); + + b.Property("ProposedDescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("proposed_description_ar"); + + b.Property("ProposedDescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("proposed_description_en"); + + b.Property("ProposedResourceType") + .HasColumnType("int") + .HasColumnName("proposed_resource_type"); + + b.Property("ProposedTitleAr") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("proposed_title_ar"); + + b.Property("ProposedTitleEn") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("proposed_title_en"); + + b.Property("RequestedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("requested_by_id"); + + b.Property("Status") + .HasColumnType("int") + .HasColumnName("status"); + + b.Property("SubmittedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("submitted_on"); + + b.HasKey("Id") + .HasName("pk_country_resource_requests"); + + b.HasIndex("CountryId", "Status") + .HasDatabaseName("ix_country_request_country_status"); + + b.ToTable("country_resource_requests", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.ExpertProfile", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AcademicTitleAr") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("academic_title_ar"); + + b.Property("AcademicTitleEn") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("academic_title_en"); + + b.Property("ApprovedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("approved_by_id"); + + b.Property("ApprovedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("approved_on"); + + b.Property("BioAr") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("bio_ar"); + + b.Property("BioEn") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("bio_en"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.PrimitiveCollection("ExpertiseTags") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("expertise_tags"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_expert_profiles"); + + b.HasIndex("UserId") + .IsUnique() + .HasDatabaseName("ux_expert_profile_active_user") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("expert_profiles", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.ExpertRegistrationRequest", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("ProcessedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("processed_by_id"); + + b.Property("ProcessedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("processed_on"); + + b.Property("RejectionReasonAr") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("rejection_reason_ar"); + + b.Property("RejectionReasonEn") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("rejection_reason_en"); + + b.Property("RequestedBioAr") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("requested_bio_ar"); + + b.Property("RequestedBioEn") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("requested_bio_en"); + + b.Property("RequestedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("requested_by_id"); + + b.PrimitiveCollection("RequestedTags") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("requested_tags"); + + b.Property("Status") + .HasColumnType("int") + .HasColumnName("status"); + + b.Property("SubmittedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("submitted_on"); + + b.HasKey("Id") + .HasName("pk_expert_registration_requests"); + + b.HasIndex("RequestedById") + .HasDatabaseName("ix_expert_request_requested_by"); + + b.HasIndex("Status") + .HasDatabaseName("ix_expert_request_status"); + + b.ToTable("expert_registration_requests", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.RefreshToken", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset") + .HasColumnName("created_at_utc"); + + b.Property("CreatedByIp") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("created_by_ip"); + + b.Property("ExpiresAtUtc") + .HasColumnType("datetimeoffset") + .HasColumnName("expires_at_utc"); + + b.Property("ReplacedByTokenHash") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("replaced_by_token_hash"); + + b.Property("RevokedAtUtc") + .HasColumnType("datetimeoffset") + .HasColumnName("revoked_at_utc"); + + b.Property("RevokedByIp") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("revoked_by_ip"); + + b.Property("TokenFamilyId") + .HasColumnType("uniqueidentifier") + .HasColumnName("token_family_id"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("token_hash"); + + b.Property("UserAgent") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_refresh_tokens"); + + b.HasIndex("TokenFamilyId") + .HasDatabaseName("ix_refresh_tokens_token_family_id"); + + b.HasIndex("TokenHash") + .IsUnique() + .HasDatabaseName("ux_refresh_tokens_token_hash"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_refresh_tokens_user_id"); + + b.ToTable("refresh_tokens", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)") + .HasColumnName("concurrency_stamp"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("normalized_name"); + + b.HasKey("Id") + .HasName("pk_asp_net_roles"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[normalized_name] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.StateRepresentativeAssignment", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AssignedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("assigned_by_id"); + + b.Property("AssignedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("assigned_on"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("RevokedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("revoked_by_id"); + + b.Property("RevokedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("revoked_on"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_state_representative_assignments"); + + b.HasIndex("CountryId") + .HasDatabaseName("ix_state_rep_country_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_state_rep_user_id"); + + b.HasIndex("UserId", "CountryId") + .IsUnique() + .HasDatabaseName("ux_state_rep_active_user_country") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("state_representative_assignments", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AccessFailedCount") + .HasColumnType("int") + .HasColumnName("access_failed_count"); + + b.Property("AvatarUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("avatar_url"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)") + .HasColumnName("concurrency_stamp"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("email"); + + b.Property("EmailConfirmed") + .HasColumnType("bit") + .HasColumnName("email_confirmed"); + + b.Property("EntraIdObjectId") + .HasColumnType("uniqueidentifier") + .HasColumnName("entra_id_object_id"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)") + .HasColumnName("first_name"); + + b.PrimitiveCollection("Interests") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("interests"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("JobTitle") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)") + .HasColumnName("job_title"); + + b.Property("KnowledgeLevel") + .HasColumnType("int") + .HasColumnName("knowledge_level"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)") + .HasColumnName("last_name"); + + b.Property("LocalePreference") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("locale_preference"); + + b.Property("LockoutEnabled") + .HasColumnType("bit") + .HasColumnName("lockout_enabled"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset") + .HasColumnName("lockout_end"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("normalized_email"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("normalized_user_name"); + + b.Property("OrganizationName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("organization_name"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)") + .HasColumnName("password_hash"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)") + .HasColumnName("phone_number"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit") + .HasColumnName("phone_number_confirmed"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)") + .HasColumnName("security_stamp"); + + b.Property("Status") + .HasColumnType("int") + .HasColumnName("status"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit") + .HasColumnName("two_factor_enabled"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("user_name"); + + b.HasKey("Id") + .HasName("pk_asp_net_users"); + + b.HasIndex("CountryId") + .HasDatabaseName("ix_users_country_id"); + + b.HasIndex("EntraIdObjectId") + .IsUnique() + .HasDatabaseName("ix_asp_net_users_entra_id_object_id") + .HasFilter("[entra_id_object_id] IS NOT NULL"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[normalized_user_name] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.InteractiveCity.CityScenario", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CityType") + .HasColumnType("int") + .HasColumnName("city_type"); + + b.Property("ConfigurationJson") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("configuration_json"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.Property("TargetYear") + .HasColumnType("int") + .HasColumnName("target_year"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_city_scenarios"); + + b.HasIndex("UserId", "LastModifiedOn") + .HasDatabaseName("ix_city_scenario_user_modified"); + + b.ToTable("city_scenarios", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.InteractiveCity.CityScenarioResult", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("ComputedAt") + .HasColumnType("datetimeoffset") + .HasColumnName("computed_at"); + + b.Property("ComputedCarbonNeutralityYear") + .HasColumnType("int") + .HasColumnName("computed_carbon_neutrality_year"); + + b.Property("ComputedTotalCostUsd") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)") + .HasColumnName("computed_total_cost_usd"); + + b.Property("EngineVersion") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("engine_version"); + + b.Property("ScenarioId") + .HasColumnType("uniqueidentifier") + .HasColumnName("scenario_id"); + + b.HasKey("Id") + .HasName("pk_city_scenario_results"); + + b.HasIndex("ScenarioId", "ComputedAt") + .HasDatabaseName("ix_city_result_scenario_at"); + + b.ToTable("city_scenario_results", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.InteractiveCity.CityTechnology", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CarbonImpactKgPerYear") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)") + .HasColumnName("carbon_impact_kg_per_year"); + + b.Property("CategoryAr") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("category_ar"); + + b.Property("CategoryEn") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("category_en"); + + b.Property("CostUsd") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)") + .HasColumnName("cost_usd"); + + b.Property("DescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("IconUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("icon_url"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.HasKey("Id") + .HasName("pk_city_technologies"); + + b.HasIndex("IsActive") + .HasDatabaseName("ix_city_tech_is_active"); + + b.ToTable("city_technologies", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.KnowledgeMaps.KnowledgeMap", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("DescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_knowledge_maps"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ux_knowledge_map_slug_active") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("knowledge_maps", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.KnowledgeMaps.KnowledgeMapAssociation", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AssociatedId") + .HasColumnType("uniqueidentifier") + .HasColumnName("associated_id"); + + b.Property("AssociatedType") + .HasColumnType("int") + .HasColumnName("associated_type"); + + b.Property("NodeId") + .HasColumnType("uniqueidentifier") + .HasColumnName("node_id"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.HasKey("Id") + .HasName("pk_knowledge_map_associations"); + + b.HasIndex("NodeId", "AssociatedType", "AssociatedId") + .IsUnique() + .HasDatabaseName("ux_km_assoc_node_type_id"); + + b.ToTable("knowledge_map_associations", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.KnowledgeMaps.KnowledgeMapEdge", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("FromNodeId") + .HasColumnType("uniqueidentifier") + .HasColumnName("from_node_id"); + + b.Property("MapId") + .HasColumnType("uniqueidentifier") + .HasColumnName("map_id"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.Property("RelationshipType") + .HasColumnType("int") + .HasColumnName("relationship_type"); + + b.Property("ToNodeId") + .HasColumnType("uniqueidentifier") + .HasColumnName("to_node_id"); + + b.HasKey("Id") + .HasName("pk_knowledge_map_edges"); + + b.HasIndex("FromNodeId") + .HasDatabaseName("ix_km_edge_from_node"); + + b.HasIndex("ToNodeId") + .HasDatabaseName("ix_km_edge_to_node"); + + b.HasIndex("MapId", "FromNodeId", "ToNodeId", "RelationshipType") + .IsUnique() + .HasDatabaseName("ux_km_edge_map_from_to_relation"); + + b.ToTable("knowledge_map_edges", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.KnowledgeMaps.KnowledgeMapNode", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("DescriptionAr") + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("IconUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("icon_url"); + + b.Property("LayoutX") + .HasColumnType("float") + .HasColumnName("layout_x"); + + b.Property("LayoutY") + .HasColumnType("float") + .HasColumnName("layout_y"); + + b.Property("MapId") + .HasColumnType("uniqueidentifier") + .HasColumnName("map_id"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.Property("NodeType") + .HasColumnType("int") + .HasColumnName("node_type"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.HasKey("Id") + .HasName("pk_knowledge_map_nodes"); + + b.HasIndex("MapId", "OrderIndex") + .HasDatabaseName("ix_km_node_map_order"); + + b.ToTable("knowledge_map_nodes", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Media.MediaFile", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AltTextAr") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasColumnName("alt_text_ar"); + + b.Property("AltTextEn") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasColumnName("alt_text_en"); + + b.Property("DescriptionAr") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("description_en"); + + b.Property("MimeType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("mime_type"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)") + .HasColumnName("original_file_name"); + + b.Property("SizeBytes") + .HasColumnType("bigint") + .HasColumnName("size_bytes"); + + b.Property("StorageKey") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasColumnName("storage_key"); + + b.Property("TitleAr") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)") + .HasColumnName("title_ar"); + + b.Property("TitleEn") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)") + .HasColumnName("title_en"); + + b.Property("UploadedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("uploaded_by_id"); + + b.Property("UploadedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("uploaded_on"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("url"); + + b.HasKey("Id") + .HasName("pk_media_files"); + + b.ToTable("media_files", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Notifications.NotificationLog", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AttemptCount") + .HasColumnType("int") + .HasColumnName("attempt_count"); + + b.Property("Channel") + .HasColumnType("int") + .HasColumnName("channel"); + + b.Property("CorrelationId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("correlation_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("Error") + .HasColumnType("nvarchar(max)") + .HasColumnName("error"); + + b.Property("FailedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("failed_on"); + + b.Property("PayloadJson") + .HasColumnType("nvarchar(max)") + .HasColumnName("payload_json"); + + b.Property("ProviderMessageId") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("provider_message_id"); + + b.Property("RecipientUserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("recipient_user_id"); + + b.Property("SentOn") + .HasColumnType("datetimeoffset") + .HasColumnName("sent_on"); + + b.Property("Status") + .HasColumnType("int") + .HasColumnName("status"); + + b.Property("TemplateCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("template_code"); + + b.Property("TemplateId") + .HasColumnType("uniqueidentifier") + .HasColumnName("template_id"); + + b.HasKey("Id") + .HasName("pk_notification_logs"); + + b.HasIndex("CorrelationId") + .HasDatabaseName("ix_notification_log_correlation_id"); + + b.HasIndex("TemplateCode", "Channel") + .HasDatabaseName("ix_notification_log_template_channel"); + + b.HasIndex("RecipientUserId", "Status", "CreatedOn") + .HasDatabaseName("ix_notification_log_recipient_status_created"); + + b.ToTable("notification_logs", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Notifications.NotificationTemplate", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("BodyAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("body_ar"); + + b.Property("BodyEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("body_en"); + + b.Property("Channel") + .HasColumnType("int") + .HasColumnName("channel"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("code"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("SubjectAr") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("subject_ar"); + + b.Property("SubjectEn") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("subject_en"); + + b.Property("VariableSchemaJson") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("variable_schema_json"); + + b.HasKey("Id") + .HasName("pk_notification_templates"); + + b.HasIndex("Code", "Channel") + .IsUnique() + .HasDatabaseName("ux_notification_template_code_channel"); + + b.ToTable("notification_templates", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Notifications.UserNotification", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("Channel") + .HasColumnType("int") + .HasColumnName("channel"); + + b.Property("ReadOn") + .HasColumnType("datetimeoffset") + .HasColumnName("read_on"); + + b.Property("RenderedBody") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("rendered_body"); + + b.Property("RenderedLocale") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("rendered_locale"); + + b.Property("RenderedSubjectAr") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("rendered_subject_ar"); + + b.Property("RenderedSubjectEn") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("rendered_subject_en"); + + b.Property("SentOn") + .HasColumnType("datetimeoffset") + .HasColumnName("sent_on"); + + b.Property("Status") + .HasColumnType("int") + .HasColumnName("status"); + + b.Property("TemplateId") + .HasColumnType("uniqueidentifier") + .HasColumnName("template_id"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_user_notifications"); + + b.HasIndex("UserId", "Status") + .HasDatabaseName("ix_user_notification_user_status"); + + b.ToTable("user_notifications", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Notifications.UserNotificationSettings", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("Channel") + .HasColumnType("int") + .HasColumnName("channel"); + + b.Property("EventCode") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("event_code"); + + b.Property("IsEnabled") + .HasColumnType("bit") + .HasColumnName("is_enabled"); + + b.Property("UpdatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("updated_on"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_user_notification_settings"); + + b.HasIndex("UserId", "Channel", "EventCode") + .IsUnique() + .HasDatabaseName("ux_user_notification_settings_user_channel_event") + .HasFilter("[event_code] IS NOT NULL"); + + b.ToTable("user_notification_settings", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.AboutSettings", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("HowToUseVideoUrl") + .HasColumnType("nvarchar(max)") + .HasColumnName("how_to_use_video_url"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.HasKey("Id") + .HasName("pk_about_settings"); + + b.ToTable("about_settings", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.GlossaryEntry", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AboutSettingsId") + .HasColumnType("uniqueidentifier") + .HasColumnName("about_settings_id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.HasKey("Id") + .HasName("pk_glossary_entries"); + + b.HasIndex("AboutSettingsId") + .HasDatabaseName("ix_glossary_entries_about_settings_id"); + + b.ToTable("glossary_entries", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.HomepageCountry", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("HomepageSettingsId") + .HasColumnType("uniqueidentifier") + .HasColumnName("homepage_settings_id"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.HasKey("Id") + .HasName("pk_homepage_countries"); + + b.HasIndex("HomepageSettingsId", "CountryId") + .IsUnique() + .HasDatabaseName("ix_homepage_country_settings_country"); + + b.ToTable("homepage_countries", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.HomepageSettings", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CceConceptsAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("cce_concepts_ar"); + + b.Property("CceConceptsEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("cce_concepts_en"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.Property("VideoUrl") + .HasColumnType("nvarchar(max)") + .HasColumnName("video_url"); + + b.HasKey("Id") + .HasName("pk_homepage_settings"); + + b.ToTable("homepage_settings", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.KnowledgePartner", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AboutSettingsId") + .HasColumnType("uniqueidentifier") + .HasColumnName("about_settings_id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("LogoUrl") + .HasColumnType("nvarchar(max)") + .HasColumnName("logo_url"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.Property("WebsiteUrl") + .HasColumnType("nvarchar(max)") + .HasColumnName("website_url"); + + b.HasKey("Id") + .HasName("pk_knowledge_partners"); + + b.HasIndex("AboutSettingsId") + .HasDatabaseName("ix_knowledge_partners_about_settings_id"); + + b.ToTable("knowledge_partners", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.PoliciesSettings", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.HasKey("Id") + .HasName("pk_policies_settings"); + + b.ToTable("policies_settings", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.PolicySection", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.Property("PoliciesSettingsId") + .HasColumnType("uniqueidentifier") + .HasColumnName("policies_settings_id"); + + b.Property("Type") + .HasColumnType("int") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_policy_sections"); + + b.HasIndex("PoliciesSettingsId") + .HasDatabaseName("ix_policy_sections_policies_settings_id"); + + b.ToTable("policy_sections", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Surveys.SearchQueryLog", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("locale"); + + b.Property("QueryText") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("query_text"); + + b.Property("ResponseTimeMs") + .HasColumnType("int") + .HasColumnName("response_time_ms"); + + b.Property("ResultsCount") + .HasColumnType("int") + .HasColumnName("results_count"); + + b.Property("SubmittedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("submitted_on"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_search_query_logs"); + + b.HasIndex("SubmittedOn") + .HasDatabaseName("ix_search_query_log_submitted_on"); + + b.ToTable("search_query_logs", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Surveys.ServiceRating", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CommentAr") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("comment_ar"); + + b.Property("CommentEn") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("comment_en"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("locale"); + + b.Property("Page") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("page"); + + b.Property("Rating") + .HasColumnType("int") + .HasColumnName("rating"); + + b.Property("SubmittedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("submitted_on"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_service_ratings"); + + b.HasIndex("SubmittedOn") + .HasDatabaseName("ix_service_rating_submitted_on"); + + b.ToTable("service_ratings", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)") + .HasColumnName("claim_type"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)") + .HasColumnName("claim_value"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier") + .HasColumnName("role_id"); + + b.HasKey("Id") + .HasName("pk_asp_net_role_claims"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_asp_net_role_claims_role_id"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)") + .HasColumnName("claim_type"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)") + .HasColumnName("claim_value"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_asp_net_user_claims"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_asp_net_user_claims_user_id"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)") + .HasColumnName("login_provider"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)") + .HasColumnName("provider_key"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)") + .HasColumnName("provider_display_name"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("LoginProvider", "ProviderKey") + .HasName("pk_asp_net_user_logins"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_asp_net_user_logins_user_id"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier") + .HasColumnName("role_id"); + + b.HasKey("UserId", "RoleId") + .HasName("pk_asp_net_user_roles"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_asp_net_user_roles_role_id"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)") + .HasColumnName("login_provider"); + + b.Property("Name") + .HasColumnType("nvarchar(450)") + .HasColumnName("name"); + + b.Property("Value") + .HasColumnType("nvarchar(max)") + .HasColumnName("value"); + + b.HasKey("UserId", "LoginProvider", "Name") + .HasName("pk_asp_net_user_tokens"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.RefreshToken", b => + { + b.HasOne("CCE.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_refresh_tokens_asp_net_users_user_id"); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.AboutSettings", b => + { + b.OwnsOne("CCE.Domain.PlatformSettings.ValueObjects.LocalizedText", "Description", b1 => + { + b1.Property("AboutSettingsId") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b1.Property("Ar") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("description_ar"); + + b1.Property("En") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("description_en"); + + b1.HasKey("AboutSettingsId"); + + b1.ToTable("about_settings"); + + b1.WithOwner() + .HasForeignKey("AboutSettingsId") + .HasConstraintName("fk_about_settings_about_settings_id"); + }); + + b.Navigation("Description") + .IsRequired(); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.GlossaryEntry", b => + { + b.HasOne("CCE.Domain.PlatformSettings.AboutSettings", null) + .WithMany("GlossaryEntries") + .HasForeignKey("AboutSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_glossary_entries_about_settings_about_settings_id"); + + b.OwnsOne("CCE.Domain.PlatformSettings.ValueObjects.LocalizedText", "Definition", b1 => + { + b1.Property("GlossaryEntryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b1.Property("Ar") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("definition_ar"); + + b1.Property("En") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("definition_en"); + + b1.HasKey("GlossaryEntryId"); + + b1.ToTable("glossary_entries"); + + b1.WithOwner() + .HasForeignKey("GlossaryEntryId") + .HasConstraintName("fk_glossary_entries_glossary_entries_id"); + }); + + b.OwnsOne("CCE.Domain.PlatformSettings.ValueObjects.LocalizedText", "Term", b1 => + { + b1.Property("GlossaryEntryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b1.Property("Ar") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("term_ar"); + + b1.Property("En") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("term_en"); + + b1.HasKey("GlossaryEntryId"); + + b1.ToTable("glossary_entries"); + + b1.WithOwner() + .HasForeignKey("GlossaryEntryId") + .HasConstraintName("fk_glossary_entries_glossary_entries_id"); + }); + + b.Navigation("Definition") + .IsRequired(); + + b.Navigation("Term") + .IsRequired(); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.HomepageCountry", b => + { + b.HasOne("CCE.Domain.PlatformSettings.HomepageSettings", null) + .WithMany("Countries") + .HasForeignKey("HomepageSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_homepage_countries_homepage_settings_homepage_settings_id"); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.HomepageSettings", b => + { + b.OwnsOne("CCE.Domain.PlatformSettings.ValueObjects.LocalizedText", "Objective", b1 => + { + b1.Property("HomepageSettingsId") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b1.Property("Ar") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("objective_ar"); + + b1.Property("En") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("objective_en"); + + b1.HasKey("HomepageSettingsId"); + + b1.ToTable("homepage_settings"); + + b1.WithOwner() + .HasForeignKey("HomepageSettingsId") + .HasConstraintName("fk_homepage_settings_homepage_settings_id"); + }); + + b.Navigation("Objective") + .IsRequired(); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.KnowledgePartner", b => + { + b.HasOne("CCE.Domain.PlatformSettings.AboutSettings", null) + .WithMany("KnowledgePartners") + .HasForeignKey("AboutSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_knowledge_partners_about_settings_about_settings_id"); + + b.OwnsOne("CCE.Domain.PlatformSettings.ValueObjects.LocalizedText", "Description", b1 => + { + b1.Property("KnowledgePartnerId") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b1.Property("Ar") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("description_ar"); + + b1.Property("En") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("description_en"); + + b1.HasKey("KnowledgePartnerId"); + + b1.ToTable("knowledge_partners"); + + b1.WithOwner() + .HasForeignKey("KnowledgePartnerId") + .HasConstraintName("fk_knowledge_partners_knowledge_partners_id"); + }); + + b.OwnsOne("CCE.Domain.PlatformSettings.ValueObjects.LocalizedText", "Name", b1 => + { + b1.Property("KnowledgePartnerId") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b1.Property("Ar") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)") + .HasColumnName("name_ar"); + + b1.Property("En") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)") + .HasColumnName("name_en"); + + b1.HasKey("KnowledgePartnerId"); + + b1.ToTable("knowledge_partners"); + + b1.WithOwner() + .HasForeignKey("KnowledgePartnerId") + .HasConstraintName("fk_knowledge_partners_knowledge_partners_id"); + }); + + b.Navigation("Description"); + + b.Navigation("Name") + .IsRequired(); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.PolicySection", b => + { + b.HasOne("CCE.Domain.PlatformSettings.PoliciesSettings", null) + .WithMany("Sections") + .HasForeignKey("PoliciesSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_policy_sections_policies_settings_policies_settings_id"); + + b.OwnsOne("CCE.Domain.PlatformSettings.ValueObjects.LocalizedText", "Content", b1 => + { + b1.Property("PolicySectionId") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b1.Property("Ar") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_ar"); + + b1.Property("En") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_en"); + + b1.HasKey("PolicySectionId"); + + b1.ToTable("policy_sections"); + + b1.WithOwner() + .HasForeignKey("PolicySectionId") + .HasConstraintName("fk_policy_sections_policy_sections_id"); + }); + + b.OwnsOne("CCE.Domain.PlatformSettings.ValueObjects.LocalizedText", "Title", b1 => + { + b1.Property("PolicySectionId") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b1.Property("Ar") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasColumnName("title_ar"); + + b1.Property("En") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasColumnName("title_en"); + + b1.HasKey("PolicySectionId"); + + b1.ToTable("policy_sections"); + + b1.WithOwner() + .HasForeignKey("PolicySectionId") + .HasConstraintName("fk_policy_sections_policy_sections_id"); + }); + + b.Navigation("Content") + .IsRequired(); + + b.Navigation("Title") + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("CCE.Domain.Identity.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_role_claims_asp_net_roles_role_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("CCE.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_user_claims_asp_net_users_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("CCE.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_user_logins_asp_net_users_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("CCE.Domain.Identity.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_user_roles_asp_net_roles_role_id"); + + b.HasOne("CCE.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_user_roles_asp_net_users_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("CCE.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_user_tokens_asp_net_users_user_id"); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.AboutSettings", b => + { + b.Navigation("GlossaryEntries"); + + b.Navigation("KnowledgePartners"); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.HomepageSettings", b => + { + b.Navigation("Countries"); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.PoliciesSettings", b => + { + b.Navigation("Sections"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/CCE.Infrastructure/Persistence/Migrations/20260523111750_AddNotificationGateway.cs b/backend/src/CCE.Infrastructure/Persistence/Migrations/20260523111750_AddNotificationGateway.cs new file mode 100644 index 00000000..43b865a5 --- /dev/null +++ b/backend/src/CCE.Infrastructure/Persistence/Migrations/20260523111750_AddNotificationGateway.cs @@ -0,0 +1,107 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CCE.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddNotificationGateway : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "ux_notification_template_code", + table: "notification_templates"); + + migrationBuilder.CreateTable( + name: "notification_logs", + columns: table => new + { + id = table.Column(type: "uniqueidentifier", nullable: false), + recipient_user_id = table.Column(type: "uniqueidentifier", nullable: true), + template_code = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + template_id = table.Column(type: "uniqueidentifier", nullable: true), + channel = table.Column(type: "int", nullable: false), + status = table.Column(type: "int", nullable: false), + provider_message_id = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + error = table.Column(type: "nvarchar(max)", nullable: true), + attempt_count = table.Column(type: "int", nullable: false), + created_on = table.Column(type: "datetimeoffset", nullable: false), + sent_on = table.Column(type: "datetimeoffset", nullable: true), + failed_on = table.Column(type: "datetimeoffset", nullable: true), + correlation_id = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true), + payload_json = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_notification_logs", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "user_notification_settings", + columns: table => new + { + id = table.Column(type: "uniqueidentifier", nullable: false), + user_id = table.Column(type: "uniqueidentifier", nullable: false), + channel = table.Column(type: "int", nullable: false), + event_code = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true), + is_enabled = table.Column(type: "bit", nullable: false), + updated_on = table.Column(type: "datetimeoffset", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_user_notification_settings", x => x.id); + }); + + migrationBuilder.CreateIndex( + name: "ux_notification_template_code_channel", + table: "notification_templates", + columns: new[] { "code", "channel" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_notification_log_correlation_id", + table: "notification_logs", + column: "correlation_id"); + + migrationBuilder.CreateIndex( + name: "ix_notification_log_recipient_status_created", + table: "notification_logs", + columns: new[] { "recipient_user_id", "status", "created_on" }); + + migrationBuilder.CreateIndex( + name: "ix_notification_log_template_channel", + table: "notification_logs", + columns: new[] { "template_code", "channel" }); + + migrationBuilder.CreateIndex( + name: "ux_user_notification_settings_user_channel_event", + table: "user_notification_settings", + columns: new[] { "user_id", "channel", "event_code" }, + unique: true, + filter: "[event_code] IS NOT NULL"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "notification_logs"); + + migrationBuilder.DropTable( + name: "user_notification_settings"); + + migrationBuilder.DropIndex( + name: "ux_notification_template_code_channel", + table: "notification_templates"); + + migrationBuilder.CreateIndex( + name: "ux_notification_template_code", + table: "notification_templates", + column: "code", + unique: true); + } + } +} diff --git a/backend/src/CCE.Infrastructure/Persistence/Migrations/CceDbContextModelSnapshot.cs b/backend/src/CCE.Infrastructure/Persistence/Migrations/CceDbContextModelSnapshot.cs index ebd3045c..a03fd2dc 100644 --- a/backend/src/CCE.Infrastructure/Persistence/Migrations/CceDbContextModelSnapshot.cs +++ b/backend/src/CCE.Infrastructure/Persistence/Migrations/CceDbContextModelSnapshot.cs @@ -2382,6 +2382,83 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("media_files", (string)null); }); + modelBuilder.Entity("CCE.Domain.Notifications.NotificationLog", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AttemptCount") + .HasColumnType("int") + .HasColumnName("attempt_count"); + + b.Property("Channel") + .HasColumnType("int") + .HasColumnName("channel"); + + b.Property("CorrelationId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("correlation_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("Error") + .HasColumnType("nvarchar(max)") + .HasColumnName("error"); + + b.Property("FailedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("failed_on"); + + b.Property("PayloadJson") + .HasColumnType("nvarchar(max)") + .HasColumnName("payload_json"); + + b.Property("ProviderMessageId") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("provider_message_id"); + + b.Property("RecipientUserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("recipient_user_id"); + + b.Property("SentOn") + .HasColumnType("datetimeoffset") + .HasColumnName("sent_on"); + + b.Property("Status") + .HasColumnType("int") + .HasColumnName("status"); + + b.Property("TemplateCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("template_code"); + + b.Property("TemplateId") + .HasColumnType("uniqueidentifier") + .HasColumnName("template_id"); + + b.HasKey("Id") + .HasName("pk_notification_logs"); + + b.HasIndex("CorrelationId") + .HasDatabaseName("ix_notification_log_correlation_id"); + + b.HasIndex("TemplateCode", "Channel") + .HasDatabaseName("ix_notification_log_template_channel"); + + b.HasIndex("RecipientUserId", "Status", "CreatedOn") + .HasDatabaseName("ix_notification_log_recipient_status_created"); + + b.ToTable("notification_logs", (string)null); + }); + modelBuilder.Entity("CCE.Domain.Notifications.NotificationTemplate", b => { b.Property("Id") @@ -2432,9 +2509,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id") .HasName("pk_notification_templates"); - b.HasIndex("Code") + b.HasIndex("Code", "Channel") .IsUnique() - .HasDatabaseName("ux_notification_template_code"); + .HasDatabaseName("ux_notification_template_code_channel"); b.ToTable("notification_templates", (string)null); }); @@ -2501,6 +2578,44 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("user_notifications", (string)null); }); + modelBuilder.Entity("CCE.Domain.Notifications.UserNotificationSettings", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("Channel") + .HasColumnType("int") + .HasColumnName("channel"); + + b.Property("EventCode") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("event_code"); + + b.Property("IsEnabled") + .HasColumnType("bit") + .HasColumnName("is_enabled"); + + b.Property("UpdatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("updated_on"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_user_notification_settings"); + + b.HasIndex("UserId", "Channel", "EventCode") + .IsUnique() + .HasDatabaseName("ux_user_notification_settings_user_channel_event") + .HasFilter("[event_code] IS NOT NULL"); + + b.ToTable("user_notification_settings", (string)null); + }); + modelBuilder.Entity("CCE.Domain.PlatformSettings.AboutSettings", b => { b.Property("Id") diff --git a/backend/src/CCE.Seeder/Seeders/ReferenceDataSeeder.cs b/backend/src/CCE.Seeder/Seeders/ReferenceDataSeeder.cs index acc4131c..f30c792f 100644 --- a/backend/src/CCE.Seeder/Seeders/ReferenceDataSeeder.cs +++ b/backend/src/CCE.Seeder/Seeders/ReferenceDataSeeder.cs @@ -163,29 +163,116 @@ private static readonly (string Code, string SubjectAr, string SubjectEn, string BodyAr, string BodyEn, CCE.Domain.Notifications.NotificationChannel Channel)[] InitialTemplates = { + // ACCOUNT_CREATED ("ACCOUNT_CREATED", "تم إنشاء حسابك", "Your account is created", "مرحباً {{Name}}، تم إنشاء حسابك بنجاح.", "Hi {{Name}}, your account is now active.", CCE.Domain.Notifications.NotificationChannel.Email), + ("ACCOUNT_CREATED", "تم إنشاء حسابك", "Your account is created", + "مرحباً {{Name}}، تم إنشاء حسابك بنجاح.", "Hi {{Name}}, your account is now active.", + CCE.Domain.Notifications.NotificationChannel.Sms), + ("ACCOUNT_CREATED", "تم إنشاء حسابك", "Your account is created", + "مرحباً {{Name}}، تم إنشاء حسابك بنجاح.", "Hi {{Name}}, your account is now active.", + CCE.Domain.Notifications.NotificationChannel.InApp), + + // EXPERT_REQUEST_APPROVED ("EXPERT_REQUEST_APPROVED", "تمت الموافقة على طلبك", "Your expert request was approved", "مرحباً {{Name}}، تمت الموافقة على طلب الخبير الخاص بك.", "Hi {{Name}}, your expert-registration request has been approved.", CCE.Domain.Notifications.NotificationChannel.Email), + ("EXPERT_REQUEST_APPROVED", "تمت الموافقة", "Approved", + "تمت الموافقة على طلب الخبير.", "Your expert request has been approved.", + CCE.Domain.Notifications.NotificationChannel.Sms), + ("EXPERT_REQUEST_APPROVED", "تمت الموافقة على طلبك", "Your expert request was approved", + "مرحباً {{Name}}، تمت الموافقة على طلب الخبير الخاص بك.", + "Hi {{Name}}, your expert-registration request has been approved.", + CCE.Domain.Notifications.NotificationChannel.InApp), + + // EXPERT_REQUEST_REJECTED ("EXPERT_REQUEST_REJECTED", "تم رفض طلبك", "Your expert request was rejected", "نأسف، تم رفض طلب الخبير: {{Reason}}", "Sorry, your expert request was rejected: {{Reason}}", CCE.Domain.Notifications.NotificationChannel.Email), + ("EXPERT_REQUEST_REJECTED", "تم الرفض", "Rejected", + "نأسف، تم رفض طلب الخبير.", "Sorry, your expert request was rejected.", + CCE.Domain.Notifications.NotificationChannel.Sms), + ("EXPERT_REQUEST_REJECTED", "تم رفض طلبك", "Your expert request was rejected", + "نأسف، تم رفض طلب الخبير: {{Reason}}", "Sorry, your expert request was rejected: {{Reason}}", + CCE.Domain.Notifications.NotificationChannel.InApp), + + // RESOURCE_REQUEST_APPROVED ("RESOURCE_REQUEST_APPROVED", "تمت الموافقة على المورد", "Country resource approved", "تمت الموافقة على مساهمة الدولة الخاصة بك.", "Your country resource submission was approved.", + CCE.Domain.Notifications.NotificationChannel.Email), + ("RESOURCE_REQUEST_APPROVED", "تمت الموافقة", "Approved", + "تمت الموافقة على المورد.", "Your country resource was approved.", + CCE.Domain.Notifications.NotificationChannel.Sms), + ("RESOURCE_REQUEST_APPROVED", "تمت الموافقة على المورد", "Country resource approved", + "تمت الموافقة على مساهمة الدولة الخاصة بك.", "Your country resource submission was approved.", + CCE.Domain.Notifications.NotificationChannel.InApp), + + // NEWS_PUBLISHED + ("NEWS_PUBLISHED", "تم نشر خبر", "News published", + "تم نشر الخبر.", "Your news article has been published.", + CCE.Domain.Notifications.NotificationChannel.Email), + ("NEWS_PUBLISHED", "تم النشر", "Published", + "تم نشر الخبر.", "News published.", + CCE.Domain.Notifications.NotificationChannel.Sms), + ("NEWS_PUBLISHED", "تم نشر خبر", "News published", + "تم نشر الخبر.", "Your news article has been published.", + CCE.Domain.Notifications.NotificationChannel.InApp), + + // RESOURCE_PUBLISHED + ("RESOURCE_PUBLISHED", "تم نشر مورد", "Resource published", + "تم نشر المورد.", "Your resource has been published.", + CCE.Domain.Notifications.NotificationChannel.Email), + ("RESOURCE_PUBLISHED", "تم النشر", "Published", + "تم نشر المورد.", "Resource published.", + CCE.Domain.Notifications.NotificationChannel.Sms), + ("RESOURCE_PUBLISHED", "تم نشر مورد", "Resource published", + "تم نشر المورد.", "Your resource has been published.", + CCE.Domain.Notifications.NotificationChannel.InApp), + + // EVENT_SCHEDULED + ("EVENT_SCHEDULED", "تم جدولة فعالية", "Event scheduled", + "تم جدولة الفعالية.", "The event has been scheduled.", + CCE.Domain.Notifications.NotificationChannel.Email), + ("EVENT_SCHEDULED", "تم الجدولة", "Scheduled", + "تم جدولة الفعالية.", "Event scheduled.", + CCE.Domain.Notifications.NotificationChannel.Sms), + ("EVENT_SCHEDULED", "تم جدولة فعالية", "Event scheduled", + "تم جدولة الفعالية.", "The event has been scheduled.", + CCE.Domain.Notifications.NotificationChannel.InApp), + + // COMMUNITY_POST_CREATED + ("COMMUNITY_POST_CREATED", "منشور جديد", "New post", + "تم إنشاء منشور جديد في الموضوع الذي تتابعه.", "A new post was created in a topic you follow.", + CCE.Domain.Notifications.NotificationChannel.Email), + ("COMMUNITY_POST_CREATED", "منشور جديد", "New post", + "منشور جديد.", "New post.", + CCE.Domain.Notifications.NotificationChannel.Sms), + ("COMMUNITY_POST_CREATED", "منشور جديد", "New post", + "تم إنشاء منشور جديد في الموضوع الذي تتابعه.", "A new post was created in a topic you follow.", CCE.Domain.Notifications.NotificationChannel.InApp), + + // PASSWORD_RESET + ("PASSWORD_RESET", "استعادة كلمة المرور", "Reset your password", + "مرحباً {{Name}}، استخدم الرابط التالي لإعادة تعيين كلمة المرور: {{ResetUrl}}", + "Hi {{Name}}, use the link below to reset your password: {{ResetUrl}}", + CCE.Domain.Notifications.NotificationChannel.Email), + ("PASSWORD_RESET", "استعادة كلمة المرور", "Reset your password", + "مرحباً {{Name}}، رابط إعادة تعيين كلمة المرور: {{ResetUrl}}", + "Hi {{Name}}, reset your password: {{ResetUrl}}", + CCE.Domain.Notifications.NotificationChannel.Sms), }; private async Task SeedNotificationTemplatesAsync(CancellationToken ct) { foreach (var t in InitialTemplates) { - var id = DeterministicGuid.From($"template:{t.Code}"); var exists = await _ctx.NotificationTemplates - .AnyAsync(x => x.Id == id, ct).ConfigureAwait(false); + .AnyAsync(x => x.Code == t.Code && x.Channel == t.Channel, ct) + .ConfigureAwait(false); if (exists) continue; + var id = DeterministicGuid.From($"template:{t.Code}:{(int)t.Channel}"); var template = CCE.Domain.Notifications.NotificationTemplate.Define( t.Code, t.SubjectAr, t.SubjectEn, t.BodyAr, t.BodyEn, t.Channel, "{}"); typeof(CCE.Domain.Notifications.NotificationTemplate) diff --git a/backend/tests/CCE.Application.Tests/Notifications/CreateNotificationTemplateCommandHandlerTests.cs b/backend/tests/CCE.Application.Tests/Notifications/CreateNotificationTemplateCommandHandlerTests.cs index 78c155a6..613529d3 100644 --- a/backend/tests/CCE.Application.Tests/Notifications/CreateNotificationTemplateCommandHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Notifications/CreateNotificationTemplateCommandHandlerTests.cs @@ -1,3 +1,4 @@ +using CCE.Application.Common.Interfaces; using CCE.Application.Notifications; using CCE.Application.Notifications.Commands.CreateNotificationTemplate; using CCE.Domain.Notifications; @@ -7,10 +8,11 @@ namespace CCE.Application.Tests.Notifications; public class CreateNotificationTemplateCommandHandlerTests { [Fact] - public async Task Persists_template_and_returns_dto_when_inputs_valid() + public async Task Persists_template_and_returns_id_when_inputs_valid() { - var service = Substitute.For(); - var sut = new CreateNotificationTemplateCommandHandler(service); + var repo = Substitute.For(); + var db = Substitute.For(); + var sut = new CreateNotificationTemplateCommandHandler(repo, db, NotificationTestMessages.Create()); var cmd = new CreateNotificationTemplateCommand( "WELCOME_EMAIL", @@ -19,12 +21,11 @@ public async Task Persists_template_and_returns_dto_when_inputs_valid() NotificationChannel.Email, "{}"); - var dto = await sut.Handle(cmd, CancellationToken.None); + var result = await sut.Handle(cmd, CancellationToken.None); - dto.Code.Should().Be("WELCOME_EMAIL"); - dto.SubjectEn.Should().Be("Welcome"); - dto.Channel.Should().Be(NotificationChannel.Email); - dto.IsActive.Should().BeTrue(); - await service.Received(1).SaveAsync(Arg.Any(), Arg.Any()); + result.Success.Should().BeTrue(); + result.Data.Should().NotBe(System.Guid.Empty); + await repo.Received(1).AddAsync(Arg.Any(), Arg.Any()); + await db.Received(1).SaveChangesAsync(Arg.Any()); } } diff --git a/backend/tests/CCE.Application.Tests/Notifications/GetNotificationTemplateByIdQueryHandlerTests.cs b/backend/tests/CCE.Application.Tests/Notifications/GetNotificationTemplateByIdQueryHandlerTests.cs index 6b72af96..68c9210b 100644 --- a/backend/tests/CCE.Application.Tests/Notifications/GetNotificationTemplateByIdQueryHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Notifications/GetNotificationTemplateByIdQueryHandlerTests.cs @@ -7,14 +7,15 @@ namespace CCE.Application.Tests.Notifications; public class GetNotificationTemplateByIdQueryHandlerTests { [Fact] - public async Task Returns_null_when_template_not_found() + public async Task Returns_failure_response_when_template_not_found() { var db = BuildDb(System.Array.Empty()); - var sut = new GetNotificationTemplateByIdQueryHandler(db); + var sut = new GetNotificationTemplateByIdQueryHandler(db, NotificationTestMessages.Create()); var result = await sut.Handle(new GetNotificationTemplateByIdQuery(System.Guid.NewGuid()), CancellationToken.None); - result.Should().BeNull(); + result.Success.Should().BeFalse(); + result.Data.Should().BeNull(); } [Fact] @@ -30,20 +31,20 @@ public async Task Returns_dto_with_all_fields_when_found() "{\"name\": \"string\"}"); var db = BuildDb(new[] { template }); - var sut = new GetNotificationTemplateByIdQueryHandler(db); + var sut = new GetNotificationTemplateByIdQueryHandler(db, NotificationTestMessages.Create()); var result = await sut.Handle(new GetNotificationTemplateByIdQuery(template.Id), CancellationToken.None); result.Should().NotBeNull(); - result!.Id.Should().Be(template.Id); - result.Code.Should().Be("WELCOME_EMAIL"); - result.SubjectAr.Should().Be("مرحبا"); - result.SubjectEn.Should().Be("Welcome"); - result.BodyAr.Should().Be("جسم عربي"); - result.BodyEn.Should().Be("English body"); - result.Channel.Should().Be(NotificationChannel.Email); - result.VariableSchemaJson.Should().Be("{\"name\": \"string\"}"); - result.IsActive.Should().BeTrue(); + result.Data!.Id.Should().Be(template.Id); + result.Data.Code.Should().Be("WELCOME_EMAIL"); + result.Data.SubjectAr.Should().Be("مرحبا"); + result.Data.SubjectEn.Should().Be("Welcome"); + result.Data.BodyAr.Should().Be("جسم عربي"); + result.Data.BodyEn.Should().Be("English body"); + result.Data.Channel.Should().Be(NotificationChannel.Email); + result.Data.VariableSchemaJson.Should().Be("{\"name\": \"string\"}"); + result.Data.IsActive.Should().BeTrue(); } private static ICceDbContext BuildDb(IEnumerable templates) diff --git a/backend/tests/CCE.Application.Tests/Notifications/ListNotificationTemplatesQueryHandlerTests.cs b/backend/tests/CCE.Application.Tests/Notifications/ListNotificationTemplatesQueryHandlerTests.cs index 9ec4232b..e231cab8 100644 --- a/backend/tests/CCE.Application.Tests/Notifications/ListNotificationTemplatesQueryHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Notifications/ListNotificationTemplatesQueryHandlerTests.cs @@ -10,14 +10,14 @@ public class ListNotificationTemplatesQueryHandlerTests public async Task Returns_empty_paged_result_when_no_templates_exist() { var db = BuildDb(System.Array.Empty()); - var sut = new ListNotificationTemplatesQueryHandler(db); + var sut = new ListNotificationTemplatesQueryHandler(db, NotificationTestMessages.Create()); var result = await sut.Handle(new ListNotificationTemplatesQuery(Page: 1, PageSize: 20), CancellationToken.None); - result.Items.Should().BeEmpty(); - result.Total.Should().Be(0); - result.Page.Should().Be(1); - result.PageSize.Should().Be(20); + result.Data!.Items.Should().BeEmpty(); + result.Data.Total.Should().Be(0); + result.Data.Page.Should().Be(1); + result.Data.PageSize.Should().Be(20); } [Fact] @@ -27,13 +27,13 @@ public async Task Returns_templates_sorted_by_Code_ascending() var beta = NotificationTemplate.Define("BETA_CODE", "ب", "Beta Subject", "جسم", "Beta Body", NotificationChannel.Email, "{}"); var db = BuildDb(new[] { beta, alpha }); - var sut = new ListNotificationTemplatesQueryHandler(db); + var sut = new ListNotificationTemplatesQueryHandler(db, NotificationTestMessages.Create()); var result = await sut.Handle(new ListNotificationTemplatesQuery(Page: 1, PageSize: 20), CancellationToken.None); - result.Total.Should().Be(2); - result.Items[0].Code.Should().Be("ALPHA_CODE"); - result.Items[1].Code.Should().Be("BETA_CODE"); + result.Data!.Total.Should().Be(2); + result.Data.Items[0].Code.Should().Be("ALPHA_CODE"); + result.Data.Items[1].Code.Should().Be("BETA_CODE"); } [Fact] @@ -44,12 +44,12 @@ public async Task Filters_by_channel_and_isActive() sms.Deactivate(); var db = BuildDb(new[] { email, sms }); - var sut = new ListNotificationTemplatesQueryHandler(db); + var sut = new ListNotificationTemplatesQueryHandler(db, NotificationTestMessages.Create()); var result = await sut.Handle(new ListNotificationTemplatesQuery(Channel: NotificationChannel.Email, IsActive: true), CancellationToken.None); - result.Total.Should().Be(1); - result.Items.Single().Code.Should().Be("EMAIL_TMPL"); + result.Data!.Total.Should().Be(1); + result.Data.Items.Single().Code.Should().Be("EMAIL_TMPL"); } private static ICceDbContext BuildDb(IEnumerable templates) diff --git a/backend/tests/CCE.Application.Tests/Notifications/NotificationTestMessages.cs b/backend/tests/CCE.Application.Tests/Notifications/NotificationTestMessages.cs new file mode 100644 index 00000000..4a695904 --- /dev/null +++ b/backend/tests/CCE.Application.Tests/Notifications/NotificationTestMessages.cs @@ -0,0 +1,14 @@ +using CCE.Application.Localization; +using CCE.Application.Messages; + +namespace CCE.Application.Tests.Notifications; + +internal static class NotificationTestMessages +{ + public static MessageFactory Create() + { + var localization = Substitute.For(); + localization.GetString(Arg.Any(), Arg.Any()).Returns(call => call[0]!.ToString()!); + return new MessageFactory(localization); + } +} diff --git a/backend/tests/CCE.Application.Tests/Notifications/Public/GetMyUnreadCountQueryHandlerTests.cs b/backend/tests/CCE.Application.Tests/Notifications/Public/GetMyUnreadCountQueryHandlerTests.cs index 4198f4ea..69a2e867 100644 --- a/backend/tests/CCE.Application.Tests/Notifications/Public/GetMyUnreadCountQueryHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Notifications/Public/GetMyUnreadCountQueryHandlerTests.cs @@ -1,5 +1,6 @@ using CCE.Application.Common.Interfaces; using CCE.Application.Notifications.Public.Queries.GetMyUnreadCount; +using CCE.Application.Tests.Notifications; using CCE.Domain.Notifications; using CCE.TestInfrastructure.Time; @@ -31,10 +32,10 @@ public async Task Returns_count_of_Sent_notifications_only() var db = Substitute.For(); db.UserNotifications.Returns(new[] { sent1, sent2, read, pending }.AsQueryable()); - var sut = new GetMyUnreadCountQueryHandler(db); + var sut = new GetMyUnreadCountQueryHandler(db, NotificationTestMessages.Create()); - var count = await sut.Handle(new GetMyUnreadCountQuery(userId), CancellationToken.None); + var result = await sut.Handle(new GetMyUnreadCountQuery(userId), CancellationToken.None); - count.Should().Be(2); + result.Data.Should().Be(2); } } diff --git a/backend/tests/CCE.Application.Tests/Notifications/Public/ListMyNotificationsQueryHandlerTests.cs b/backend/tests/CCE.Application.Tests/Notifications/Public/ListMyNotificationsQueryHandlerTests.cs index 744c0db0..736502da 100644 --- a/backend/tests/CCE.Application.Tests/Notifications/Public/ListMyNotificationsQueryHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Notifications/Public/ListMyNotificationsQueryHandlerTests.cs @@ -1,5 +1,6 @@ using CCE.Application.Common.Interfaces; using CCE.Application.Notifications.Public.Queries.ListMyNotifications; +using CCE.Application.Tests.Notifications; using CCE.Domain.Notifications; using CCE.TestInfrastructure.Time; @@ -20,13 +21,13 @@ private static UserNotification MakeSent(System.Guid userId) public async Task Returns_empty_when_user_has_no_notifications() { var db = BuildDb(System.Array.Empty()); - var sut = new ListMyNotificationsQueryHandler(db); + var sut = new ListMyNotificationsQueryHandler(db, NotificationTestMessages.Create()); var userId = System.Guid.NewGuid(); var result = await sut.Handle(new ListMyNotificationsQuery(userId), CancellationToken.None); - result.Items.Should().BeEmpty(); - result.Total.Should().Be(0); + result.Data!.Items.Should().BeEmpty(); + result.Data.Total.Should().Be(0); } [Fact] @@ -38,12 +39,12 @@ public async Task Returns_only_notifications_belonging_to_the_requesting_user() var other = MakeSent(otherId); var db = BuildDb(new[] { mine, other }); - var sut = new ListMyNotificationsQueryHandler(db); + var sut = new ListMyNotificationsQueryHandler(db, NotificationTestMessages.Create()); var result = await sut.Handle(new ListMyNotificationsQuery(myId), CancellationToken.None); - result.Total.Should().Be(1); - result.Items.Single().Id.Should().Be(mine.Id); + result.Data!.Total.Should().Be(1); + result.Data.Items.Single().Id.Should().Be(mine.Id); } [Fact] @@ -61,14 +62,14 @@ public async Task Filters_by_status_when_provided() read.MarkRead(clock); var db = BuildDb(new[] { sent, read }); - var sut = new ListMyNotificationsQueryHandler(db); + var sut = new ListMyNotificationsQueryHandler(db, NotificationTestMessages.Create()); var result = await sut.Handle( new ListMyNotificationsQuery(userId, Status: NotificationStatus.Sent), CancellationToken.None); - result.Total.Should().Be(1); - result.Items.Single().Status.Should().Be(NotificationStatus.Sent); + result.Data!.Total.Should().Be(1); + result.Data.Items.Single().Status.Should().Be(NotificationStatus.Sent); } private static ICceDbContext BuildDb(IEnumerable notifications) diff --git a/backend/tests/CCE.Application.Tests/Notifications/Public/MarkNotificationReadCommandHandlerTests.cs b/backend/tests/CCE.Application.Tests/Notifications/Public/MarkNotificationReadCommandHandlerTests.cs index 155e5aa7..c0215939 100644 --- a/backend/tests/CCE.Application.Tests/Notifications/Public/MarkNotificationReadCommandHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Notifications/Public/MarkNotificationReadCommandHandlerTests.cs @@ -1,3 +1,5 @@ +using CCE.Application.Common.Interfaces; +using CCE.Application.Tests.Notifications; using CCE.Application.Notifications.Public; using CCE.Application.Notifications.Public.Commands.MarkNotificationRead; using CCE.Domain.Common; @@ -18,19 +20,20 @@ private static (UserNotification notification, FakeSystemClock clock) MakeSentNo } [Fact] - public async Task Throws_KeyNotFoundException_when_notification_not_found_or_belongs_to_different_user() + public async Task Returns_not_found_response_when_notification_not_found_or_belongs_to_different_user() { - var service = Substitute.For(); + var repo = Substitute.For(); var clock = new FakeSystemClock(); - service.FindAsync(Arg.Any(), Arg.Any()) + repo.GetAsync(Arg.Any(), Arg.Any()) .Returns((UserNotification?)null); - var sut = new MarkNotificationReadCommandHandler(service, clock); + var db = Substitute.For(); + var sut = new MarkNotificationReadCommandHandler(repo, db, NotificationTestMessages.Create(), clock); var cmd = new MarkNotificationReadCommand(System.Guid.NewGuid(), System.Guid.NewGuid()); - var act = async () => await sut.Handle(cmd, CancellationToken.None); + var result = await sut.Handle(cmd, CancellationToken.None); - await act.Should().ThrowAsync(); + result.Success.Should().BeFalse(); } [Fact] @@ -39,16 +42,18 @@ public async Task Marks_notification_as_read_and_calls_update() var userId = System.Guid.NewGuid(); var (notif, clock) = MakeSentNotification(userId); - var service = Substitute.For(); - service.FindAsync(notif.Id, Arg.Any()) + var repo = Substitute.For(); + repo.GetAsync(notif.Id, Arg.Any()) .Returns(notif); - var sut = new MarkNotificationReadCommandHandler(service, clock); + var db = Substitute.For(); + var sut = new MarkNotificationReadCommandHandler(repo, db, NotificationTestMessages.Create(), clock); var cmd = new MarkNotificationReadCommand(notif.Id, userId); - await sut.Handle(cmd, CancellationToken.None); + var result = await sut.Handle(cmd, CancellationToken.None); + result.Success.Should().BeTrue(); notif.Status.Should().Be(NotificationStatus.Read); - await service.Received(1).UpdateAsync(notif, Arg.Any()); + await db.Received(1).SaveChangesAsync(Arg.Any()); } } diff --git a/backend/tests/CCE.Application.Tests/Notifications/UpdateNotificationTemplateCommandHandlerTests.cs b/backend/tests/CCE.Application.Tests/Notifications/UpdateNotificationTemplateCommandHandlerTests.cs index d3e361bf..5cbd77f5 100644 --- a/backend/tests/CCE.Application.Tests/Notifications/UpdateNotificationTemplateCommandHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Notifications/UpdateNotificationTemplateCommandHandlerTests.cs @@ -1,3 +1,4 @@ +using CCE.Application.Common.Interfaces; using CCE.Application.Notifications; using CCE.Application.Notifications.Commands.UpdateNotificationTemplate; using CCE.Domain.Notifications; @@ -7,20 +8,21 @@ namespace CCE.Application.Tests.Notifications; public class UpdateNotificationTemplateCommandHandlerTests { [Fact] - public async Task Returns_null_when_template_not_found() + public async Task Returns_not_found_response_when_template_not_found() { - var service = Substitute.For(); - service.FindAsync(Arg.Any(), Arg.Any()) + var repo = Substitute.For(); + repo.GetAsync(Arg.Any(), Arg.Any()) .Returns((NotificationTemplate?)null); - var sut = new UpdateNotificationTemplateCommandHandler(service); + var db = Substitute.For(); + var sut = new UpdateNotificationTemplateCommandHandler(repo, db, NotificationTestMessages.Create()); var result = await sut.Handle(BuildCommand(System.Guid.NewGuid()), CancellationToken.None); - result.Should().BeNull(); + result.Success.Should().BeFalse(); } [Fact] - public async Task Updates_content_and_active_state_and_returns_dto() + public async Task Updates_content_and_active_state_and_returns_id() { var template = NotificationTemplate.Define( "OLD_CODE", @@ -29,10 +31,11 @@ public async Task Updates_content_and_active_state_and_returns_dto() NotificationChannel.Email, "{}"); - var service = Substitute.For(); - service.FindAsync(template.Id, Arg.Any()).Returns(template); + var repo = Substitute.For(); + repo.GetAsync(template.Id, Arg.Any()).Returns(template); - var sut = new UpdateNotificationTemplateCommandHandler(service); + var db = Substitute.For(); + var sut = new UpdateNotificationTemplateCommandHandler(repo, db, NotificationTestMessages.Create()); var cmd = new UpdateNotificationTemplateCommand( template.Id, @@ -42,11 +45,12 @@ public async Task Updates_content_and_active_state_and_returns_dto() var result = await sut.Handle(cmd, CancellationToken.None); - result.Should().NotBeNull(); - result!.SubjectEn.Should().Be("New Subject"); - result.BodyEn.Should().Be("New Body"); - result.IsActive.Should().BeFalse(); - await service.Received(1).UpdateAsync(template, Arg.Any()); + result.Success.Should().BeTrue(); + result.Data.Should().Be(template.Id); + template.SubjectEn.Should().Be("New Subject"); + template.BodyEn.Should().Be("New Body"); + template.IsActive.Should().BeFalse(); + await db.Received(1).SaveChangesAsync(Arg.Any()); } private static UpdateNotificationTemplateCommand BuildCommand(System.Guid id) => diff --git a/backend/tests/CCE.Domain.Tests/PermissionsYamlSchemaTests.cs b/backend/tests/CCE.Domain.Tests/PermissionsYamlSchemaTests.cs index cfe41d37..96eacf02 100644 --- a/backend/tests/CCE.Domain.Tests/PermissionsYamlSchemaTests.cs +++ b/backend/tests/CCE.Domain.Tests/PermissionsYamlSchemaTests.cs @@ -72,7 +72,7 @@ public void All_BRD_required_permissions_are_present() [Fact] public void Permissions_All_count_matches_BRD_matrix() { - Permissions.All.Count.Should().Be(42); + Permissions.All.Count.Should().Be(45); } [Fact]