From f7352cee2a653ba48f9a1f9fcece8be9916643f0 Mon Sep 17 00:00:00 2001 From: MOHAMED RASHED Date: Thu, 25 Jun 2026 13:24:00 +0300 Subject: [PATCH] feat: Firebase FCM push notification channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds push as a first-class notification channel alongside email and in-app, following the existing INotificationChannelHandler plug-in model. - UserDeviceToken entity: one row per (UserId, DeviceId); token rotates, deviceId is stable. EF migration: AddUserDeviceToken - POST /api/me/device-tokens — register/refresh FCM token (upsert by deviceId) - DELETE /api/me/device-tokens/{deviceId} — unregister - PushNotificationChannelSender: fetches active tokens, sends FCM multicast, deactivates stale tokens on UNREGISTERED/INVALID_ARGUMENT errors - FirebaseMessagingService wraps FirebaseAdmin SDK with singleton guard (DefaultInstance ?? Create) and forwards CancellationToken - Firebase section in appsettings; channel skipped cleanly when unconfigured - MetaData added to RenderedNotification so handlers can pass deep-link context (postId, communityId, etc.) into FCM data payload - NotificationChannel.Push added to all existing notification handlers Co-Authored-By: Claude Sonnet 4.6 --- .../Endpoints/DeviceTokenEndpoints.cs | 59 + backend/src/CCE.Api.External/Program.cs | 1 + .../Endpoints/NotificationTestEndpoints.cs | 32 + backend/src/CCE.Api.Internal/Program.cs | 1 + .../RetryNotificationLogCommandHandler.cs | 10 +- .../SendTestPush/SendTestPushCommand.cs | 9 + .../SendTestPushCommandHandler.cs | 27 + .../GetNotificationLogByIdQueryHandler.cs | 2 +- ...reateNotificationTemplateCommandHandler.cs | 4 +- ...pdateNotificationTemplateCommandHandler.cs | 6 +- ...ntentRequestApprovedNotificationHandler.cs | 2 +- ...ntentRequestRejectedNotificationHandler.cs | 2 +- ...RegistrationApprovedNotificationHandler.cs | 2 +- ...RegistrationRejectedNotificationHandler.cs | 2 +- .../NewsPublishedNotificationHandler.cs | 2 +- .../ResourcePublishedNotificationHandler.cs | 2 +- .../Notifications/IFirebasePushService.cs | 6 + .../IUserDeviceTokenRepository.cs | 18 + .../MarkAllNotificationsReadCommandHandler.cs | 4 +- .../MarkNotificationReadCommandHandler.cs | 6 +- .../RegisterDeviceTokenCommand.cs | 11 + .../RegisterDeviceTokenCommandHandler.cs | 56 + .../RegisterDeviceTokenCommandValidator.cs | 16 + .../UnregisterDeviceTokenCommand.cs | 9 + .../UnregisterDeviceTokenCommandHandler.cs | 40 + ...ateMyNotificationSettingsCommandHandler.cs | 4 +- ...GetNotificationTemplateByIdQueryHandler.cs | 2 +- .../Notifications/RenderedNotification.cs | 3 +- .../Notifications/NotificationChannel.cs | 2 +- .../Notifications/UserDeviceToken.cs | 66 + .../CCE.Infrastructure/DependencyInjection.cs | 12 + .../Firebase/FirebaseMessagingService.cs | 41 + .../Firebase/FirebaseOptions.cs | 11 + .../Firebase/FirebasePushService.cs | 26 + .../Firebase/IFirebaseMessagingService.cs | 9 + .../Notifications/NotificationGateway.cs | 3 +- .../PushNotificationChannelSender.cs | 116 + .../UserDeviceTokenRepository.cs | 42 + .../UserDeviceTokenConfiguration.cs | 29 + ...60624135014_AddUserDeviceToken.Designer.cs | 5246 +++++++++++++++++ .../20260624135014_AddUserDeviceToken.cs | 56 + .../Migrations/CceDbContextModelSnapshot.cs | 57 + 42 files changed, 6026 insertions(+), 28 deletions(-) create mode 100644 backend/src/CCE.Api.External/Endpoints/DeviceTokenEndpoints.cs create mode 100644 backend/src/CCE.Api.Internal/Endpoints/NotificationTestEndpoints.cs create mode 100644 backend/src/CCE.Application/Notifications/Admin/Commands/SendTestPush/SendTestPushCommand.cs create mode 100644 backend/src/CCE.Application/Notifications/Admin/Commands/SendTestPush/SendTestPushCommandHandler.cs create mode 100644 backend/src/CCE.Application/Notifications/IFirebasePushService.cs create mode 100644 backend/src/CCE.Application/Notifications/IUserDeviceTokenRepository.cs create mode 100644 backend/src/CCE.Application/Notifications/Public/Commands/RegisterDeviceToken/RegisterDeviceTokenCommand.cs create mode 100644 backend/src/CCE.Application/Notifications/Public/Commands/RegisterDeviceToken/RegisterDeviceTokenCommandHandler.cs create mode 100644 backend/src/CCE.Application/Notifications/Public/Commands/RegisterDeviceToken/RegisterDeviceTokenCommandValidator.cs create mode 100644 backend/src/CCE.Application/Notifications/Public/Commands/UnregisterDeviceToken/UnregisterDeviceTokenCommand.cs create mode 100644 backend/src/CCE.Application/Notifications/Public/Commands/UnregisterDeviceToken/UnregisterDeviceTokenCommandHandler.cs create mode 100644 backend/src/CCE.Domain/Notifications/UserDeviceToken.cs create mode 100644 backend/src/CCE.Infrastructure/Firebase/FirebaseMessagingService.cs create mode 100644 backend/src/CCE.Infrastructure/Firebase/FirebaseOptions.cs create mode 100644 backend/src/CCE.Infrastructure/Firebase/FirebasePushService.cs create mode 100644 backend/src/CCE.Infrastructure/Firebase/IFirebaseMessagingService.cs create mode 100644 backend/src/CCE.Infrastructure/Notifications/PushNotificationChannelSender.cs create mode 100644 backend/src/CCE.Infrastructure/Notifications/UserDeviceTokenRepository.cs create mode 100644 backend/src/CCE.Infrastructure/Persistence/Configurations/Notifications/UserDeviceTokenConfiguration.cs create mode 100644 backend/src/CCE.Infrastructure/Persistence/Migrations/20260624135014_AddUserDeviceToken.Designer.cs create mode 100644 backend/src/CCE.Infrastructure/Persistence/Migrations/20260624135014_AddUserDeviceToken.cs diff --git a/backend/src/CCE.Api.External/Endpoints/DeviceTokenEndpoints.cs b/backend/src/CCE.Api.External/Endpoints/DeviceTokenEndpoints.cs new file mode 100644 index 00000000..bc10562d --- /dev/null +++ b/backend/src/CCE.Api.External/Endpoints/DeviceTokenEndpoints.cs @@ -0,0 +1,59 @@ +using CCE.Api.Common.Extensions; +using CCE.Api.Common.Results; +using CCE.Application.Common.Interfaces; +using CCE.Application.Notifications.Public.Commands.RegisterDeviceToken; +using CCE.Application.Notifications.Public.Commands.UnregisterDeviceToken; +using CCE.Domain; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace CCE.Api.External.Endpoints; + +public static class DeviceTokenEndpoints +{ + public static IEndpointRouteBuilder MapDeviceTokenEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/me/device-tokens") + .WithTags("Notifications") + .RequireAuthorization(); + + group.MapPost("", async ( + RegisterDeviceTokenRequest body, + ICurrentUserAccessor currentUser, + IMediator mediator, + CancellationToken ct) => + { + var userId = currentUser.GetUserId() ?? System.Guid.Empty; + if (userId == System.Guid.Empty) return EnvelopeResults.Unauthorized(); + var cmd = new RegisterDeviceTokenCommand(userId, body.Token, body.Platform, body.DeviceId); + var result = await mediator.Send(cmd, ct).ConfigureAwait(false); + return result.ToHttpResult(); + }) + .WithName("RegisterDeviceToken") + .RequireAuthorization(Permissions.Notification_DeviceToken_Register); + + group.MapDelete("/{deviceId}", async ( + string deviceId, + ICurrentUserAccessor currentUser, + IMediator mediator, + CancellationToken ct) => + { + var userId = currentUser.GetUserId() ?? System.Guid.Empty; + if (userId == System.Guid.Empty) return EnvelopeResults.Unauthorized(); + var cmd = new UnregisterDeviceTokenCommand(userId, deviceId); + var result = await mediator.Send(cmd, ct).ConfigureAwait(false); + return result.ToHttpResult(); + }) + .WithName("UnregisterDeviceToken") + .RequireAuthorization(Permissions.Notification_DeviceToken_Delete); + + return app; + } +} + +public sealed record RegisterDeviceTokenRequest( + string Token, + string Platform, + string DeviceId); diff --git a/backend/src/CCE.Api.External/Program.cs b/backend/src/CCE.Api.External/Program.cs index fd921d17..e8d91780 100644 --- a/backend/src/CCE.Api.External/Program.cs +++ b/backend/src/CCE.Api.External/Program.cs @@ -101,6 +101,7 @@ app.MapAssetEndpoints(); app.MapAuthEndpoints(CCE.Application.Identity.Auth.Common.LocalAuthApi.External); app.MapNotificationsEndpoints(); +app.MapDeviceTokenEndpoints(); app.MapTagsPublicEndpoints(); app.MapSharePublicEndpoints(); app.MapNewsPublicEndpoints(); diff --git a/backend/src/CCE.Api.Internal/Endpoints/NotificationTestEndpoints.cs b/backend/src/CCE.Api.Internal/Endpoints/NotificationTestEndpoints.cs new file mode 100644 index 00000000..aef0cda9 --- /dev/null +++ b/backend/src/CCE.Api.Internal/Endpoints/NotificationTestEndpoints.cs @@ -0,0 +1,32 @@ +using CCE.Api.Common.Extensions; +using CCE.Application.Notifications.Admin.Commands.SendTestPush; +using CCE.Domain; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; + +namespace CCE.Api.Internal.Endpoints; + +public static class NotificationTestEndpoints +{ + public static IEndpointRouteBuilder MapNotificationTestEndpoints(this IEndpointRouteBuilder app) + { + app.MapPost("/api/admin/notifications/test-push", async ( + SendTestPushRequest body, + IMediator mediator, + CancellationToken ct) => + { + var result = await mediator.Send( + new SendTestPushCommand(body.Token, body.Title, body.Body), ct) + .ConfigureAwait(false); + return result.ToHttpResult(); + }) + .WithTags("Notifications") + .WithName("SendTestPush") + .RequireAuthorization(Permissions.Notification_Send); + + return app; + } +} + +public sealed record SendTestPushRequest(string Token, string Title, string Body); diff --git a/backend/src/CCE.Api.Internal/Program.cs b/backend/src/CCE.Api.Internal/Program.cs index 367216b7..3ebf7a44 100644 --- a/backend/src/CCE.Api.Internal/Program.cs +++ b/backend/src/CCE.Api.Internal/Program.cs @@ -96,6 +96,7 @@ app.MapCommunityAdminEndpoints(); app.MapNotificationTemplateEndpoints(); app.MapNotificationLogEndpoints(); +app.MapNotificationTestEndpoints(); app.MapReportEndpoints(); app.MapAuditEndpoints(); app.MapCacheManagementEndpoints(); diff --git a/backend/src/CCE.Application/Notifications/Admin/Commands/RetryNotificationLog/RetryNotificationLogCommandHandler.cs b/backend/src/CCE.Application/Notifications/Admin/Commands/RetryNotificationLog/RetryNotificationLogCommandHandler.cs index 55de6eaf..b71a0f13 100644 --- a/backend/src/CCE.Application/Notifications/Admin/Commands/RetryNotificationLog/RetryNotificationLogCommandHandler.cs +++ b/backend/src/CCE.Application/Notifications/Admin/Commands/RetryNotificationLog/RetryNotificationLogCommandHandler.cs @@ -1,4 +1,4 @@ -using CCE.Application.Common; +using CCE.Application.Common; using CCE.Application.Common.Interfaces; using CCE.Application.Common.Pagination; using CCE.Application.Messages; @@ -41,7 +41,7 @@ public RetryNotificationLogCommandHandler( var log = await _logRepository.GetAsync(request.Id, cancellationToken).ConfigureAwait(false); if (log is null) - return _msg.NotificationLogNotFound(); + return _msg.NotFound(MessageKeys.Notifications.NOTIFICATION_NOT_FOUND); if (log.Status != NotificationDeliveryStatus.Failed && log.Status != NotificationDeliveryStatus.Skipped) throw new DomainException($"Cannot retry a log with status {log.Status}."); @@ -59,7 +59,7 @@ public RetryNotificationLogCommandHandler( { log.MarkSkipped("Template no longer available."); await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - return _msg.NotificationRetried(log.Id); + return _msg.Ok(log.Id, MessageKeys.Notifications.NOTIFICATION_RETRIED); } // Resolve recipient data @@ -110,7 +110,7 @@ public RetryNotificationLogCommandHandler( { log.MarkSkipped($"No sender registered for channel {log.Channel}."); await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - return _msg.NotificationRetried(log.Id); + return _msg.Ok(log.Id, MessageKeys.Notifications.NOTIFICATION_RETRIED); } var sendResult = await sender.SendAsync(rendered, cancellationToken).ConfigureAwait(false); @@ -126,6 +126,6 @@ public RetryNotificationLogCommandHandler( await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - return _msg.NotificationRetried(log.Id); + return _msg.Ok(log.Id, MessageKeys.Notifications.NOTIFICATION_RETRIED); } } diff --git a/backend/src/CCE.Application/Notifications/Admin/Commands/SendTestPush/SendTestPushCommand.cs b/backend/src/CCE.Application/Notifications/Admin/Commands/SendTestPush/SendTestPushCommand.cs new file mode 100644 index 00000000..b8271b5d --- /dev/null +++ b/backend/src/CCE.Application/Notifications/Admin/Commands/SendTestPush/SendTestPushCommand.cs @@ -0,0 +1,9 @@ +using CCE.Application.Common; +using MediatR; + +namespace CCE.Application.Notifications.Admin.Commands.SendTestPush; + +public sealed record SendTestPushCommand(string Token, string Title, string Body) + : IRequest>; + +public sealed record TestPushResultDto(int Sent, int Failed); diff --git a/backend/src/CCE.Application/Notifications/Admin/Commands/SendTestPush/SendTestPushCommandHandler.cs b/backend/src/CCE.Application/Notifications/Admin/Commands/SendTestPush/SendTestPushCommandHandler.cs new file mode 100644 index 00000000..ed77a7b7 --- /dev/null +++ b/backend/src/CCE.Application/Notifications/Admin/Commands/SendTestPush/SendTestPushCommandHandler.cs @@ -0,0 +1,27 @@ +using CCE.Application.Common; +using CCE.Application.Messages; +using MediatR; + +namespace CCE.Application.Notifications.Admin.Commands.SendTestPush; + +public sealed class SendTestPushCommandHandler + : IRequestHandler> +{ + private readonly IFirebasePushService _push; + private readonly MessageFactory _msg; + + public SendTestPushCommandHandler(IFirebasePushService push, MessageFactory msg) + { + _push = push; + _msg = msg; + } + + public async Task> Handle( + SendTestPushCommand request, CancellationToken cancellationToken) + { + var (sent, failed) = await _push + .SendAsync(request.Token, request.Title, request.Body, cancellationToken) + .ConfigureAwait(false); + return _msg.Ok(new TestPushResultDto(sent, failed), MessageKeys.General.SUCCESS_OPERATION); + } +} diff --git a/backend/src/CCE.Application/Notifications/Admin/Queries/GetNotificationLogById/GetNotificationLogByIdQueryHandler.cs b/backend/src/CCE.Application/Notifications/Admin/Queries/GetNotificationLogById/GetNotificationLogByIdQueryHandler.cs index 1ad2e1f4..88cf5566 100644 --- a/backend/src/CCE.Application/Notifications/Admin/Queries/GetNotificationLogById/GetNotificationLogByIdQueryHandler.cs +++ b/backend/src/CCE.Application/Notifications/Admin/Queries/GetNotificationLogById/GetNotificationLogByIdQueryHandler.cs @@ -30,7 +30,7 @@ public async Task> Handle( .FirstOrDefault(); return log is null - ? _msg.NotificationLogNotFound() + ? _msg.NotFound(MessageKeys.Notifications.NOTIFICATION_NOT_FOUND) : _msg.Ok(MapToDto(log), MessageKeys.General.ITEMS_LISTED); } diff --git a/backend/src/CCE.Application/Notifications/Commands/CreateNotificationTemplate/CreateNotificationTemplateCommandHandler.cs b/backend/src/CCE.Application/Notifications/Commands/CreateNotificationTemplate/CreateNotificationTemplateCommandHandler.cs index 3d3f2627..3a01099b 100644 --- a/backend/src/CCE.Application/Notifications/Commands/CreateNotificationTemplate/CreateNotificationTemplateCommandHandler.cs +++ b/backend/src/CCE.Application/Notifications/Commands/CreateNotificationTemplate/CreateNotificationTemplateCommandHandler.cs @@ -1,4 +1,4 @@ -using CCE.Application.Common; +using CCE.Application.Common; using CCE.Application.Common.Interfaces; using CCE.Application.Messages; using CCE.Domain.Notifications; @@ -39,6 +39,6 @@ public CreateNotificationTemplateCommandHandler( await _repo.AddAsync(template, cancellationToken).ConfigureAwait(false); await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - return _msg.NotificationTemplateCreated(template.Id); + return _msg.Ok(template.Id, MessageKeys.Notifications.NOTIFICATION_TEMPLATE_CREATED); } } diff --git a/backend/src/CCE.Application/Notifications/Commands/UpdateNotificationTemplate/UpdateNotificationTemplateCommandHandler.cs b/backend/src/CCE.Application/Notifications/Commands/UpdateNotificationTemplate/UpdateNotificationTemplateCommandHandler.cs index 221e66b6..a4588d2a 100644 --- a/backend/src/CCE.Application/Notifications/Commands/UpdateNotificationTemplate/UpdateNotificationTemplateCommandHandler.cs +++ b/backend/src/CCE.Application/Notifications/Commands/UpdateNotificationTemplate/UpdateNotificationTemplateCommandHandler.cs @@ -1,4 +1,4 @@ -using CCE.Application.Common; +using CCE.Application.Common; using CCE.Application.Common.Interfaces; using CCE.Application.Messages; using MediatR; @@ -29,7 +29,7 @@ public UpdateNotificationTemplateCommandHandler( var template = await _repo.GetAsync(request.Id, cancellationToken).ConfigureAwait(false); if (template is null) { - return _msg.NotificationTemplateNotFound(); + return _msg.NotFound(MessageKeys.Notifications.TEMPLATE_NOT_FOUND); } template.UpdateContent(request.SubjectAr, request.SubjectEn, request.BodyAr, request.BodyEn); @@ -41,6 +41,6 @@ public UpdateNotificationTemplateCommandHandler( await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - return _msg.NotificationTemplateUpdated(template.Id); + return _msg.Ok(template.Id, MessageKeys.Notifications.NOTIFICATION_TEMPLATE_UPDATED); } } diff --git a/backend/src/CCE.Application/Notifications/Handlers/CountryContentRequestApprovedNotificationHandler.cs b/backend/src/CCE.Application/Notifications/Handlers/CountryContentRequestApprovedNotificationHandler.cs index 7d14aef1..18850e59 100644 --- a/backend/src/CCE.Application/Notifications/Handlers/CountryContentRequestApprovedNotificationHandler.cs +++ b/backend/src/CCE.Application/Notifications/Handlers/CountryContentRequestApprovedNotificationHandler.cs @@ -23,7 +23,7 @@ await _dispatcher.DispatchAsync(new NotificationMessage( TemplateCode: "COUNTRY_CONTENT_REQUEST_APPROVED", RecipientUserId: notification.RequestedById, EventType: NotificationEventType.CountryResourceApproved, - Channels: [NotificationChannel.InApp, NotificationChannel.Email], + Channels: [NotificationChannel.InApp, NotificationChannel.Email, NotificationChannel.Push], MetaData: new Dictionary { ["RequestId"] = notification.RequestId.ToString(), diff --git a/backend/src/CCE.Application/Notifications/Handlers/CountryContentRequestRejectedNotificationHandler.cs b/backend/src/CCE.Application/Notifications/Handlers/CountryContentRequestRejectedNotificationHandler.cs index ed0a0d54..2c0dde62 100644 --- a/backend/src/CCE.Application/Notifications/Handlers/CountryContentRequestRejectedNotificationHandler.cs +++ b/backend/src/CCE.Application/Notifications/Handlers/CountryContentRequestRejectedNotificationHandler.cs @@ -23,7 +23,7 @@ await _dispatcher.DispatchAsync(new NotificationMessage( TemplateCode: "COUNTRY_CONTENT_REQUEST_REJECTED", RecipientUserId: notification.RequestedById, EventType: NotificationEventType.CountryResourceRejected, - Channels: [NotificationChannel.InApp, NotificationChannel.Email], + Channels: [NotificationChannel.InApp, NotificationChannel.Email, NotificationChannel.Push], MetaData: new Dictionary { ["RequestId"] = notification.RequestId.ToString(), diff --git a/backend/src/CCE.Application/Notifications/Handlers/ExpertRegistrationApprovedNotificationHandler.cs b/backend/src/CCE.Application/Notifications/Handlers/ExpertRegistrationApprovedNotificationHandler.cs index 0b54a6db..123ae314 100644 --- a/backend/src/CCE.Application/Notifications/Handlers/ExpertRegistrationApprovedNotificationHandler.cs +++ b/backend/src/CCE.Application/Notifications/Handlers/ExpertRegistrationApprovedNotificationHandler.cs @@ -24,7 +24,7 @@ await _dispatcher.DispatchAsync(new NotificationMessage( TemplateCode: MessageKeys.Identity.EXPERT_REQUEST_APPROVED, RecipientUserId: notification.RequestedById, EventType: NotificationEventType.ExpertRequestApproved, - Channels: [NotificationChannel.InApp, NotificationChannel.Email], + Channels: [NotificationChannel.InApp, NotificationChannel.Email, NotificationChannel.Push], 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 index 095bab84..9f227046 100644 --- a/backend/src/CCE.Application/Notifications/Handlers/ExpertRegistrationRejectedNotificationHandler.cs +++ b/backend/src/CCE.Application/Notifications/Handlers/ExpertRegistrationRejectedNotificationHandler.cs @@ -24,7 +24,7 @@ await _dispatcher.DispatchAsync(new NotificationMessage( TemplateCode: MessageKeys.Identity.EXPERT_REQUEST_REJECTED, RecipientUserId: notification.RequestedById, EventType: NotificationEventType.ExpertRequestRejected, - Channels: [NotificationChannel.InApp, NotificationChannel.Email], + Channels: [NotificationChannel.InApp, NotificationChannel.Email, NotificationChannel.Push], MetaData: new Dictionary { ["Reason"] = notification.RejectionReasonEn ?? "" diff --git a/backend/src/CCE.Application/Notifications/Handlers/NewsPublishedNotificationHandler.cs b/backend/src/CCE.Application/Notifications/Handlers/NewsPublishedNotificationHandler.cs index b5606143..9bf313c5 100644 --- a/backend/src/CCE.Application/Notifications/Handlers/NewsPublishedNotificationHandler.cs +++ b/backend/src/CCE.Application/Notifications/Handlers/NewsPublishedNotificationHandler.cs @@ -41,7 +41,7 @@ await _dispatcher.DispatchAsync(new NotificationMessage( TemplateCode: "NEWS_PUBLISHED", RecipientUserId: news.AuthorId, EventType: NotificationEventType.NewsPublished, - Channels: [NotificationChannel.InApp], + Channels: [NotificationChannel.InApp, NotificationChannel.Push], MetaData: new Dictionary { ["TitleAr"] = news.TitleAr, diff --git a/backend/src/CCE.Application/Notifications/Handlers/ResourcePublishedNotificationHandler.cs b/backend/src/CCE.Application/Notifications/Handlers/ResourcePublishedNotificationHandler.cs index af932eda..67d1ef9f 100644 --- a/backend/src/CCE.Application/Notifications/Handlers/ResourcePublishedNotificationHandler.cs +++ b/backend/src/CCE.Application/Notifications/Handlers/ResourcePublishedNotificationHandler.cs @@ -40,7 +40,7 @@ await _dispatcher.DispatchAsync(new NotificationMessage( TemplateCode: "RESOURCE_PUBLISHED", RecipientUserId: resource.UploadedById, EventType: NotificationEventType.ResourcePublished, - Channels: [NotificationChannel.InApp], + Channels: [NotificationChannel.InApp, NotificationChannel.Push], MetaData: new Dictionary { ["TitleAr"] = resource.TitleAr, diff --git a/backend/src/CCE.Application/Notifications/IFirebasePushService.cs b/backend/src/CCE.Application/Notifications/IFirebasePushService.cs new file mode 100644 index 00000000..82b0f6f2 --- /dev/null +++ b/backend/src/CCE.Application/Notifications/IFirebasePushService.cs @@ -0,0 +1,6 @@ +namespace CCE.Application.Notifications; + +public interface IFirebasePushService +{ + Task<(int Sent, int Failed)> SendAsync(string token, string title, string body, CancellationToken ct); +} diff --git a/backend/src/CCE.Application/Notifications/IUserDeviceTokenRepository.cs b/backend/src/CCE.Application/Notifications/IUserDeviceTokenRepository.cs new file mode 100644 index 00000000..4c4e3649 --- /dev/null +++ b/backend/src/CCE.Application/Notifications/IUserDeviceTokenRepository.cs @@ -0,0 +1,18 @@ +using CCE.Domain.Notifications; + +namespace CCE.Application.Notifications; + +public interface IUserDeviceTokenRepository +{ + Task> GetActiveByUserIdAsync( + System.Guid userId, CancellationToken cancellationToken); + + Task GetByUserAndDeviceAsync( + System.Guid userId, string deviceId, CancellationToken cancellationToken); + + Task AddAsync(UserDeviceToken token, CancellationToken cancellationToken); + + /// Deactivates tokens matching the given FCM token values after FCM rejects them. + Task DeactivateByTokensAsync( + System.Collections.Generic.IReadOnlyList fcmTokens, CancellationToken cancellationToken); +} 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 74449ea2..1dd0efed 100644 --- a/backend/src/CCE.Application/Notifications/Public/Commands/MarkAllNotificationsRead/MarkAllNotificationsReadCommandHandler.cs +++ b/backend/src/CCE.Application/Notifications/Public/Commands/MarkAllNotificationsRead/MarkAllNotificationsReadCommandHandler.cs @@ -1,4 +1,4 @@ -using CCE.Application.Common; +using CCE.Application.Common; using CCE.Application.Messages; using CCE.Application.Notifications.Public; using CCE.Domain.Common; @@ -28,6 +28,6 @@ public async Task> Handle(MarkAllNotificationsReadCommand request, request.UserId, _clock, cancellationToken).ConfigureAwait(false); - return _msg.NotificationsMarkedRead(count); + return _msg.Ok(count, MessageKeys.Notifications.NOTIFICATIONS_MARKED_READ); } } 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 af3b869b..450eda35 100644 --- a/backend/src/CCE.Application/Notifications/Public/Commands/MarkNotificationRead/MarkNotificationReadCommandHandler.cs +++ b/backend/src/CCE.Application/Notifications/Public/Commands/MarkNotificationRead/MarkNotificationReadCommandHandler.cs @@ -1,4 +1,4 @@ -using CCE.Application.Common; +using CCE.Application.Common; using CCE.Application.Common.Interfaces; using CCE.Application.Community; using CCE.Application.Messages; @@ -35,7 +35,7 @@ public async Task> Handle(MarkNotificationReadCommand request var notif = await _repo.GetAsync(request.Id, cancellationToken).ConfigureAwait(false); if (notif is null || notif.UserId != request.UserId) - return _msg.NotificationLogNotFound(); + return _msg.NotFound(MessageKeys.Notifications.NOTIFICATION_NOT_FOUND); notif.MarkRead(_clock); await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); @@ -45,6 +45,6 @@ public async Task> Handle(MarkNotificationReadCommand request await _feedStore.IncrementNotificationCountAsync(notif.UserId, delta: -1, cancellationToken) .ConfigureAwait(false); - return _msg.NotificationMarkedRead(); + return _msg.Ok(MessageKeys.Notifications.NOTIFICATION_MARKED_READ); } } diff --git a/backend/src/CCE.Application/Notifications/Public/Commands/RegisterDeviceToken/RegisterDeviceTokenCommand.cs b/backend/src/CCE.Application/Notifications/Public/Commands/RegisterDeviceToken/RegisterDeviceTokenCommand.cs new file mode 100644 index 00000000..8cab9fc5 --- /dev/null +++ b/backend/src/CCE.Application/Notifications/Public/Commands/RegisterDeviceToken/RegisterDeviceTokenCommand.cs @@ -0,0 +1,11 @@ +using CCE.Application.Common; +using MediatR; + +namespace CCE.Application.Notifications.Public.Commands.RegisterDeviceToken; + +public sealed record RegisterDeviceTokenCommand( + System.Guid UserId, + string Token, + string Platform, + string DeviceId +) : IRequest>; diff --git a/backend/src/CCE.Application/Notifications/Public/Commands/RegisterDeviceToken/RegisterDeviceTokenCommandHandler.cs b/backend/src/CCE.Application/Notifications/Public/Commands/RegisterDeviceToken/RegisterDeviceTokenCommandHandler.cs new file mode 100644 index 00000000..51811eba --- /dev/null +++ b/backend/src/CCE.Application/Notifications/Public/Commands/RegisterDeviceToken/RegisterDeviceTokenCommandHandler.cs @@ -0,0 +1,56 @@ +using CCE.Application.Common; +using CCE.Application.Common.Interfaces; +using CCE.Application.Messages; +using CCE.Domain.Common; +using CCE.Domain.Notifications; +using MediatR; + +namespace CCE.Application.Notifications.Public.Commands.RegisterDeviceToken; + +public sealed class RegisterDeviceTokenCommandHandler + : IRequestHandler> +{ + private readonly IUserDeviceTokenRepository _repo; + private readonly ICceDbContext _db; + private readonly MessageFactory _msg; + private readonly ISystemClock _clock; + + public RegisterDeviceTokenCommandHandler( + IUserDeviceTokenRepository repo, + ICceDbContext db, + MessageFactory msg, + ISystemClock clock) + { + _repo = repo; + _db = db; + _msg = msg; + _clock = clock; + } + + public async Task> Handle( + RegisterDeviceTokenCommand request, + CancellationToken cancellationToken) + { + var existing = await _repo + .GetByUserAndDeviceAsync(request.UserId, request.DeviceId, cancellationToken) + .ConfigureAwait(false); + + if (existing is not null) + { + existing.Refresh(request.Token, _clock); + } + else + { + var token = UserDeviceToken.Register( + request.UserId, + request.DeviceId, + request.Token, + request.Platform, + _clock); + await _repo.AddAsync(token, cancellationToken).ConfigureAwait(false); + } + + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + return _msg.Ok(MessageKeys.Notifications.DEVICE_TOKEN_REGISTERED); + } +} diff --git a/backend/src/CCE.Application/Notifications/Public/Commands/RegisterDeviceToken/RegisterDeviceTokenCommandValidator.cs b/backend/src/CCE.Application/Notifications/Public/Commands/RegisterDeviceToken/RegisterDeviceTokenCommandValidator.cs new file mode 100644 index 00000000..ed78d84b --- /dev/null +++ b/backend/src/CCE.Application/Notifications/Public/Commands/RegisterDeviceToken/RegisterDeviceTokenCommandValidator.cs @@ -0,0 +1,16 @@ +using FluentValidation; + +namespace CCE.Application.Notifications.Public.Commands.RegisterDeviceToken; + +public sealed class RegisterDeviceTokenCommandValidator + : AbstractValidator +{ + public RegisterDeviceTokenCommandValidator() + { + RuleFor(x => x.Token).NotEmpty().MaximumLength(512); + RuleFor(x => x.DeviceId).NotEmpty().MaximumLength(128); + RuleFor(x => x.Platform).NotEmpty() + .Must(p => p is "ios" or "android" or "web") + .WithMessage("Platform must be 'ios', 'android', or 'web'."); + } +} diff --git a/backend/src/CCE.Application/Notifications/Public/Commands/UnregisterDeviceToken/UnregisterDeviceTokenCommand.cs b/backend/src/CCE.Application/Notifications/Public/Commands/UnregisterDeviceToken/UnregisterDeviceTokenCommand.cs new file mode 100644 index 00000000..1be1e30f --- /dev/null +++ b/backend/src/CCE.Application/Notifications/Public/Commands/UnregisterDeviceToken/UnregisterDeviceTokenCommand.cs @@ -0,0 +1,9 @@ +using CCE.Application.Common; +using MediatR; + +namespace CCE.Application.Notifications.Public.Commands.UnregisterDeviceToken; + +public sealed record UnregisterDeviceTokenCommand( + System.Guid UserId, + string DeviceId +) : IRequest>; diff --git a/backend/src/CCE.Application/Notifications/Public/Commands/UnregisterDeviceToken/UnregisterDeviceTokenCommandHandler.cs b/backend/src/CCE.Application/Notifications/Public/Commands/UnregisterDeviceToken/UnregisterDeviceTokenCommandHandler.cs new file mode 100644 index 00000000..275b58cc --- /dev/null +++ b/backend/src/CCE.Application/Notifications/Public/Commands/UnregisterDeviceToken/UnregisterDeviceTokenCommandHandler.cs @@ -0,0 +1,40 @@ +using CCE.Application.Common; +using CCE.Application.Common.Interfaces; +using CCE.Application.Messages; +using MediatR; + +namespace CCE.Application.Notifications.Public.Commands.UnregisterDeviceToken; + +public sealed class UnregisterDeviceTokenCommandHandler + : IRequestHandler> +{ + private readonly IUserDeviceTokenRepository _repo; + private readonly ICceDbContext _db; + private readonly MessageFactory _msg; + + public UnregisterDeviceTokenCommandHandler( + IUserDeviceTokenRepository repo, + ICceDbContext db, + MessageFactory msg) + { + _repo = repo; + _db = db; + _msg = msg; + } + + public async Task> Handle( + UnregisterDeviceTokenCommand request, + CancellationToken cancellationToken) + { + var existing = await _repo + .GetByUserAndDeviceAsync(request.UserId, request.DeviceId, cancellationToken) + .ConfigureAwait(false); + + if (existing is null || existing.UserId != request.UserId) + return _msg.NotFound(MessageKeys.Notifications.DEVICE_TOKEN_NOT_FOUND); + + existing.Deactivate(); + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + return _msg.Ok(MessageKeys.Notifications.DEVICE_TOKEN_DELETED); + } +} diff --git a/backend/src/CCE.Application/Notifications/Public/Commands/UpdateMyNotificationSettings/UpdateMyNotificationSettingsCommandHandler.cs b/backend/src/CCE.Application/Notifications/Public/Commands/UpdateMyNotificationSettings/UpdateMyNotificationSettingsCommandHandler.cs index 659d6d97..fe9e956c 100644 --- a/backend/src/CCE.Application/Notifications/Public/Commands/UpdateMyNotificationSettings/UpdateMyNotificationSettingsCommandHandler.cs +++ b/backend/src/CCE.Application/Notifications/Public/Commands/UpdateMyNotificationSettings/UpdateMyNotificationSettingsCommandHandler.cs @@ -1,4 +1,4 @@ -using CCE.Application.Common; +using CCE.Application.Common; using CCE.Application.Common.Interfaces; using CCE.Application.Messages; using CCE.Domain.Notifications; @@ -47,6 +47,6 @@ public async Task> Handle( await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - return _msg.NotificationSettingsUpdated(); + return _msg.Ok(MessageKeys.Notifications.NOTIFICATION_SETTINGS_UPDATED); } } diff --git a/backend/src/CCE.Application/Notifications/Queries/GetNotificationTemplateById/GetNotificationTemplateByIdQueryHandler.cs b/backend/src/CCE.Application/Notifications/Queries/GetNotificationTemplateById/GetNotificationTemplateByIdQueryHandler.cs index 3b024d28..2dd47f13 100644 --- a/backend/src/CCE.Application/Notifications/Queries/GetNotificationTemplateById/GetNotificationTemplateByIdQueryHandler.cs +++ b/backend/src/CCE.Application/Notifications/Queries/GetNotificationTemplateById/GetNotificationTemplateByIdQueryHandler.cs @@ -30,7 +30,7 @@ public async Task> Handle( .ConfigureAwait(false); var template = list.SingleOrDefault(); return template is null - ? _msg.NotificationTemplateNotFound() + ? _msg.NotFound(MessageKeys.Notifications.TEMPLATE_NOT_FOUND) : _msg.Ok(ListNotificationTemplatesQueryHandler.MapToDto(template), MessageKeys.General.ITEMS_LISTED); } } diff --git a/backend/src/CCE.Application/Notifications/RenderedNotification.cs b/backend/src/CCE.Application/Notifications/RenderedNotification.cs index 0a6dbbd8..85ef893b 100644 --- a/backend/src/CCE.Application/Notifications/RenderedNotification.cs +++ b/backend/src/CCE.Application/Notifications/RenderedNotification.cs @@ -13,4 +13,5 @@ public sealed record RenderedNotification( NotificationChannel Channel, string Locale, string? Email = null, - string? PhoneNumber = null); + string? PhoneNumber = null, + System.Collections.Generic.IReadOnlyDictionary? MetaData = null); diff --git a/backend/src/CCE.Domain/Notifications/NotificationChannel.cs b/backend/src/CCE.Domain/Notifications/NotificationChannel.cs index 9098eb43..4717bcfd 100644 --- a/backend/src/CCE.Domain/Notifications/NotificationChannel.cs +++ b/backend/src/CCE.Domain/Notifications/NotificationChannel.cs @@ -1,2 +1,2 @@ namespace CCE.Domain.Notifications; -public enum NotificationChannel { Email = 0, Sms = 1, InApp = 2 } +public enum NotificationChannel { Email = 0, Sms = 1, InApp = 2, Push = 3 } diff --git a/backend/src/CCE.Domain/Notifications/UserDeviceToken.cs b/backend/src/CCE.Domain/Notifications/UserDeviceToken.cs new file mode 100644 index 00000000..55a01cad --- /dev/null +++ b/backend/src/CCE.Domain/Notifications/UserDeviceToken.cs @@ -0,0 +1,66 @@ +using CCE.Domain.Common; + +namespace CCE.Domain.Notifications; + +/// +/// FCM registration token for a physical device. +/// One row per (UserId, DeviceId). DeviceId is a stable client-generated UUID; Token rotates. +/// NOT audited — high-cardinality, managed by device lifecycle. +/// +public sealed class UserDeviceToken : Entity +{ + private UserDeviceToken( + System.Guid id, + System.Guid userId, + string deviceId, + string token, + string platform, + System.DateTimeOffset registeredOn) : base(id) + { + UserId = userId; + DeviceId = deviceId; + Token = token; + Platform = platform; + RegisteredOn = registeredOn; + LastSeenOn = registeredOn; + IsActive = true; + } + + public System.Guid UserId { get; private set; } + /// Stable UUID the client generates on first launch. Never rotates. + public string DeviceId { get; private set; } + /// FCM registration token. Rotates; updated via Refresh(). + public string Token { get; private set; } + /// "ios" | "android" | "web" + public string Platform { get; private set; } + public System.DateTimeOffset RegisteredOn { get; private set; } + public System.DateTimeOffset LastSeenOn { get; private set; } + public bool IsActive { get; private set; } + + public static UserDeviceToken Register( + System.Guid userId, + string deviceId, + string token, + string platform, + ISystemClock clock) + { + if (userId == System.Guid.Empty) throw new DomainException("UserId is required."); + if (string.IsNullOrWhiteSpace(deviceId)) throw new DomainException("DeviceId is required."); + if (string.IsNullOrWhiteSpace(token)) throw new DomainException("Token is required."); + if (platform is not ("ios" or "android" or "web")) + throw new DomainException("Platform must be 'ios', 'android', or 'web'."); + return new UserDeviceToken(System.Guid.NewGuid(), userId, deviceId, token, platform, clock.UtcNow); + } + + /// Called when the client reports a refreshed FCM token for an existing device. + public void Refresh(string newToken, ISystemClock clock) + { + if (string.IsNullOrWhiteSpace(newToken)) throw new DomainException("Token is required."); + Token = newToken; + LastSeenOn = clock.UtcNow; + IsActive = true; + } + + /// Called when FCM reports the token is no longer valid. + public void Deactivate() => IsActive = false; +} diff --git a/backend/src/CCE.Infrastructure/DependencyInjection.cs b/backend/src/CCE.Infrastructure/DependencyInjection.cs index 48b6dd79..1458bfce 100644 --- a/backend/src/CCE.Infrastructure/DependencyInjection.cs +++ b/backend/src/CCE.Infrastructure/DependencyInjection.cs @@ -27,6 +27,7 @@ using CCE.Infrastructure.Media; using CCE.Infrastructure.Sanitization; using CCE.Infrastructure.Country; +using CCE.Infrastructure.Firebase; using CCE.Infrastructure.Notifications; using CCE.Infrastructure.Notifications.Messaging; using CCE.Infrastructure.Reports; @@ -234,6 +235,17 @@ public static IServiceCollection AddInfrastructure( services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + + // Firebase push channel — only wired when ProjectId + ServiceAccountJson are configured. + services.Configure(configuration.GetSection(FirebaseOptions.SectionName)); + var firebaseOpts = configuration.GetSection(FirebaseOptions.SectionName).Get(); + if (firebaseOpts?.IsConfigured == true) + { + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); + } services.AddScoped(); services.AddSingleton(); diff --git a/backend/src/CCE.Infrastructure/Firebase/FirebaseMessagingService.cs b/backend/src/CCE.Infrastructure/Firebase/FirebaseMessagingService.cs new file mode 100644 index 00000000..ed7c5061 --- /dev/null +++ b/backend/src/CCE.Infrastructure/Firebase/FirebaseMessagingService.cs @@ -0,0 +1,41 @@ +using FirebaseAdmin; +using FirebaseAdmin.Messaging; +using Google.Apis.Auth.OAuth2; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace CCE.Infrastructure.Firebase; + +public sealed class FirebaseMessagingService : IFirebaseMessagingService +{ + private readonly FirebaseMessaging _messaging; + private readonly ILogger _logger; + + public FirebaseMessagingService( + IOptions options, + ILogger logger) + { + _logger = logger; + var opts = options.Value; + + // FirebaseApp is a process-wide singleton; DefaultInstance is null on first init. + var app = FirebaseApp.DefaultInstance ?? FirebaseApp.Create(new AppOptions + { + Credential = GoogleCredential.FromJson(opts.ServiceAccountJson), + ProjectId = opts.ProjectId + }); + + _messaging = FirebaseMessaging.GetMessaging(app); + } + + public async Task SendMulticastAsync( + MulticastMessage message, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var response = await _messaging.SendEachForMulticastAsync(message, cancellationToken).ConfigureAwait(false); + _logger.LogDebug( + "FCM multicast: {SuccessCount} sent, {FailureCount} failed.", + response.SuccessCount, response.FailureCount); + return response; + } +} diff --git a/backend/src/CCE.Infrastructure/Firebase/FirebaseOptions.cs b/backend/src/CCE.Infrastructure/Firebase/FirebaseOptions.cs new file mode 100644 index 00000000..918e4827 --- /dev/null +++ b/backend/src/CCE.Infrastructure/Firebase/FirebaseOptions.cs @@ -0,0 +1,11 @@ +namespace CCE.Infrastructure.Firebase; + +public sealed class FirebaseOptions +{ + public const string SectionName = "Firebase"; + public string ProjectId { get; init; } = string.Empty; + /// Raw service-account JSON string. Inject via env var or user-secrets — never commit to source control. + public string ServiceAccountJson { get; init; } = string.Empty; + public bool IsConfigured => !string.IsNullOrWhiteSpace(ProjectId) + && !string.IsNullOrWhiteSpace(ServiceAccountJson); +} diff --git a/backend/src/CCE.Infrastructure/Firebase/FirebasePushService.cs b/backend/src/CCE.Infrastructure/Firebase/FirebasePushService.cs new file mode 100644 index 00000000..77b04454 --- /dev/null +++ b/backend/src/CCE.Infrastructure/Firebase/FirebasePushService.cs @@ -0,0 +1,26 @@ +using CCE.Application.Notifications; +using FirebaseAdmin.Messaging; + +namespace CCE.Infrastructure.Firebase; + +public sealed class FirebasePushService : IFirebasePushService +{ + private readonly IFirebaseMessagingService _messaging; + + public FirebasePushService(IFirebaseMessagingService messaging) + { + _messaging = messaging; + } + + public async Task<(int Sent, int Failed)> SendAsync( + string token, string title, string body, CancellationToken ct) + { + var message = new MulticastMessage + { + Tokens = new[] { token }, + Notification = new Notification { Title = title, Body = body } + }; + var response = await _messaging.SendMulticastAsync(message, ct).ConfigureAwait(false); + return (response.SuccessCount, response.FailureCount); + } +} diff --git a/backend/src/CCE.Infrastructure/Firebase/IFirebaseMessagingService.cs b/backend/src/CCE.Infrastructure/Firebase/IFirebaseMessagingService.cs new file mode 100644 index 00000000..eb061bda --- /dev/null +++ b/backend/src/CCE.Infrastructure/Firebase/IFirebaseMessagingService.cs @@ -0,0 +1,9 @@ +using FirebaseAdmin.Messaging; + +namespace CCE.Infrastructure.Firebase; + +public interface IFirebaseMessagingService +{ + Task SendMulticastAsync( + MulticastMessage message, CancellationToken cancellationToken); +} diff --git a/backend/src/CCE.Infrastructure/Notifications/NotificationGateway.cs b/backend/src/CCE.Infrastructure/Notifications/NotificationGateway.cs index 5bff6103..cb57ad9f 100644 --- a/backend/src/CCE.Infrastructure/Notifications/NotificationGateway.cs +++ b/backend/src/CCE.Infrastructure/Notifications/NotificationGateway.cs @@ -213,7 +213,8 @@ private async Task DispatchChannelAsync( channel, locale, email, - phone); + phone, + MetaData: request.Variables); // Create pending log var payloadJson = SerializePayload(variables); diff --git a/backend/src/CCE.Infrastructure/Notifications/PushNotificationChannelSender.cs b/backend/src/CCE.Infrastructure/Notifications/PushNotificationChannelSender.cs new file mode 100644 index 00000000..a51e56fb --- /dev/null +++ b/backend/src/CCE.Infrastructure/Notifications/PushNotificationChannelSender.cs @@ -0,0 +1,116 @@ +using System.Collections.Generic; +using CCE.Application.Notifications; +using CCE.Domain.Notifications; +using CCE.Infrastructure.Firebase; +using FirebaseAdmin.Messaging; +using Microsoft.Extensions.Logging; + +namespace CCE.Infrastructure.Notifications; + +public sealed class PushNotificationChannelSender : INotificationChannelHandler +{ + // FCM error codes meaning the token is permanently invalid. + private static readonly HashSet _staleTokenCodes = new(StringComparer.OrdinalIgnoreCase) + { + "UNREGISTERED", + "INVALID_ARGUMENT", + "SENDER_ID_MISMATCH" + }; + + private readonly IUserDeviceTokenRepository _tokenRepo; + private readonly IFirebaseMessagingService _firebase; + private readonly ILogger _logger; + + public PushNotificationChannelSender( + IUserDeviceTokenRepository tokenRepo, + IFirebaseMessagingService firebase, + ILogger logger) + { + _tokenRepo = tokenRepo; + _firebase = firebase; + _logger = logger; + } + + public NotificationChannel Channel => NotificationChannel.Push; + + 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: "Push requires a recipient user ID."); + + var deviceTokens = await _tokenRepo + .GetActiveByUserIdAsync(notification.RecipientUserId.Value, cancellationToken) + .ConfigureAwait(false); + + if (deviceTokens.Count == 0) + { + _logger.LogDebug( + "No active device tokens for user {UserId}; skipping push for {TemplateCode}.", + notification.RecipientUserId, notification.TemplateCode); + return new ChannelSendResult(true, ProviderMessageId: "no-devices"); + } + + var rawTokens = new List(deviceTokens.Count); + foreach (var dt in deviceTokens) + rawTokens.Add(dt.Token); + + var data = new Dictionary + { + ["templateCode"] = notification.TemplateCode, + ["locale"] = notification.Locale + }; + + if (notification.MetaData is not null) + { + foreach (var kv in notification.MetaData) + data[kv.Key] = kv.Value; + } + + var message = new MulticastMessage + { + Tokens = rawTokens, + Notification = new Notification + { + Title = notification.Subject, + Body = notification.Body + }, + Data = data, + Apns = new ApnsConfig { Aps = new Aps { Sound = "default" } }, + Android = new AndroidConfig { Priority = Priority.High } + }; + + var batchResponse = await _firebase + .SendMulticastAsync(message, cancellationToken) + .ConfigureAwait(false); + + var staleTokens = new List(); + for (var i = 0; i < batchResponse.Responses.Count; i++) + { + var r = batchResponse.Responses[i]; + if (!r.IsSuccess && r.Exception?.MessagingErrorCode is { } code + && _staleTokenCodes.Contains(code.ToString())) + { + staleTokens.Add(rawTokens[i]); + } + } + + if (staleTokens.Count > 0) + { + _logger.LogInformation( + "Deactivating {Count} stale FCM tokens for user {UserId}.", + staleTokens.Count, notification.RecipientUserId); + await _tokenRepo + .DeactivateByTokensAsync(staleTokens, cancellationToken) + .ConfigureAwait(false); + } + + var success = batchResponse.SuccessCount > 0 || deviceTokens.Count == 0; + return new ChannelSendResult( + success, + Error: success ? null : $"All {batchResponse.FailureCount} FCM sends failed."); + } +} diff --git a/backend/src/CCE.Infrastructure/Notifications/UserDeviceTokenRepository.cs b/backend/src/CCE.Infrastructure/Notifications/UserDeviceTokenRepository.cs new file mode 100644 index 00000000..b372d12f --- /dev/null +++ b/backend/src/CCE.Infrastructure/Notifications/UserDeviceTokenRepository.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; +using System.Linq; +using CCE.Application.Notifications; +using CCE.Domain.Notifications; +using CCE.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace CCE.Infrastructure.Notifications; + +public sealed class UserDeviceTokenRepository + : EntityRepository, IUserDeviceTokenRepository +{ + public UserDeviceTokenRepository(CceDbContext db) : base(db) { } + + public async Task> GetActiveByUserIdAsync( + System.Guid userId, CancellationToken cancellationToken) + => await Db.Set() + .Where(t => t.UserId == userId && t.IsActive) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + public async Task GetByUserAndDeviceAsync( + System.Guid userId, string deviceId, CancellationToken cancellationToken) + => await Db.Set() + .FirstOrDefaultAsync(t => t.UserId == userId && t.DeviceId == deviceId, cancellationToken) + .ConfigureAwait(false); + + public override async Task AddAsync(UserDeviceToken token, CancellationToken ct) + => await Db.Set().AddAsync(token, ct).ConfigureAwait(false); + + public async Task DeactivateByTokensAsync( + IReadOnlyList fcmTokens, CancellationToken cancellationToken) + { + var tokens = await Db.Set() + .Where(t => fcmTokens.Contains(t.Token) && t.IsActive) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + foreach (var t in tokens) + t.Deactivate(); + } +} diff --git a/backend/src/CCE.Infrastructure/Persistence/Configurations/Notifications/UserDeviceTokenConfiguration.cs b/backend/src/CCE.Infrastructure/Persistence/Configurations/Notifications/UserDeviceTokenConfiguration.cs new file mode 100644 index 00000000..2f7d59e1 --- /dev/null +++ b/backend/src/CCE.Infrastructure/Persistence/Configurations/Notifications/UserDeviceTokenConfiguration.cs @@ -0,0 +1,29 @@ +using CCE.Domain.Notifications; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CCE.Infrastructure.Persistence.Configurations.Notifications; + +public sealed class UserDeviceTokenConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("user_device_token"); + builder.HasKey(t => t.Id); + + builder.Property(t => t.UserId).IsRequired(); + builder.Property(t => t.DeviceId).IsRequired().HasMaxLength(128); + builder.Property(t => t.Token).IsRequired().HasMaxLength(512); + builder.Property(t => t.Platform).IsRequired().HasMaxLength(16); + builder.Property(t => t.RegisteredOn).IsRequired(); + builder.Property(t => t.LastSeenOn).IsRequired(); + builder.Property(t => t.IsActive).IsRequired(); + + // One row per physical device per user. + builder.HasIndex(t => new { t.UserId, t.DeviceId }).IsUnique(); + // Fast active-token fetch on every push send. + builder.HasIndex(t => new { t.UserId, t.IsActive }); + // Fast stale-token deactivation after FCM rejects a token value. + builder.HasIndex(t => t.Token); + } +} diff --git a/backend/src/CCE.Infrastructure/Persistence/Migrations/20260624135014_AddUserDeviceToken.Designer.cs b/backend/src/CCE.Infrastructure/Persistence/Migrations/20260624135014_AddUserDeviceToken.Designer.cs new file mode 100644 index 00000000..c3059109 --- /dev/null +++ b/backend/src/CCE.Infrastructure/Persistence/Migrations/20260624135014_AddUserDeviceToken.Designer.cs @@ -0,0 +1,5246 @@ +// +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("20260624135014_AddUserDeviceToken")] + partial class AddUserDeviceToken + { + /// + 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.Community", 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("FollowerCount") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0) + .HasColumnName("follower_count"); + + 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("MemberCount") + .HasColumnType("int") + .HasColumnName("member_count"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)") + .HasColumnName("name_en"); + + b.Property("PostCount") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0) + .HasColumnName("post_count"); + + b.Property("PresentationJson") + .HasColumnType("nvarchar(max)") + .HasColumnName("presentation_json"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("nvarchar(160)") + .HasColumnName("slug"); + + b.Property("Visibility") + .HasColumnType("int") + .HasColumnName("visibility"); + + b.HasKey("Id") + .HasName("pk_communities"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ux_community_slug_active") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("communities", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.CommunityFollow", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CommunityId") + .HasColumnType("uniqueidentifier") + .HasColumnName("community_id"); + + b.Property("FollowedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("followed_on"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_community_follows"); + + b.HasIndex("CommunityId", "UserId") + .IsUnique() + .HasDatabaseName("ux_community_follow_community_user"); + + b.ToTable("community_follows", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.CommunityJoinRequest", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CommunityId") + .HasColumnType("uniqueidentifier") + .HasColumnName("community_id"); + + b.Property("DecidedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("decided_by_id"); + + b.Property("DecidedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("decided_on"); + + b.Property("RequestedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("requested_on"); + + b.Property("Status") + .HasColumnType("int") + .HasColumnName("status"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_community_join_requests"); + + b.HasIndex("CommunityId", "Status") + .HasDatabaseName("ix_community_join_request_community_status"); + + b.HasIndex("CommunityId", "UserId") + .IsUnique() + .HasDatabaseName("ux_community_join_request_pending") + .HasFilter("[status] = 0"); + + b.ToTable("community_join_requests", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.CommunityMembership", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CommunityId") + .HasColumnType("uniqueidentifier") + .HasColumnName("community_id"); + + b.Property("JoinedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("joined_on"); + + b.Property("Role") + .HasColumnType("int") + .HasColumnName("role"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_community_memberships"); + + b.HasIndex("CommunityId", "UserId") + .IsUnique() + .HasDatabaseName("ux_community_membership_community_user"); + + b.ToTable("community_memberships", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.Mention", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("MentionedByUserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("mentioned_by_user_id"); + + b.Property("MentionedUserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("mentioned_user_id"); + + b.Property("SourceId") + .HasColumnType("uniqueidentifier") + .HasColumnName("source_id"); + + b.Property("SourceType") + .HasColumnType("int") + .HasColumnName("source_type"); + + b.HasKey("Id") + .HasName("pk_mentions"); + + b.HasIndex("MentionedUserId", "CreatedOn") + .HasDatabaseName("ix_mention_user_created"); + + b.HasIndex("SourceType", "SourceId", "MentionedUserId") + .IsUnique() + .HasDatabaseName("ux_mention_source_user"); + + b.ToTable("mentions", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.Poll", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AllowMultiple") + .HasColumnType("bit") + .HasColumnName("allow_multiple"); + + b.Property("Deadline") + .HasColumnType("datetimeoffset") + .HasColumnName("deadline"); + + b.Property("IsAnonymous") + .HasColumnType("bit") + .HasColumnName("is_anonymous"); + + b.Property("PostId") + .HasColumnType("uniqueidentifier") + .HasColumnName("post_id"); + + b.Property("ShowResultsBeforeClose") + .HasColumnType("bit") + .HasColumnName("show_results_before_close"); + + b.HasKey("Id") + .HasName("pk_polls"); + + b.HasIndex("PostId") + .IsUnique() + .HasDatabaseName("ux_poll_post"); + + b.ToTable("polls", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.PollOption", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)") + .HasColumnName("label"); + + b.Property("PollId") + .HasColumnType("uniqueidentifier") + .HasColumnName("poll_id"); + + b.Property("SortOrder") + .HasColumnType("int") + .HasColumnName("sort_order"); + + b.Property("VoteCount") + .HasColumnType("int") + .HasColumnName("vote_count"); + + b.HasKey("Id") + .HasName("pk_poll_options"); + + b.HasIndex("PollId", "SortOrder") + .HasDatabaseName("ix_poll_option_poll_sort"); + + b.ToTable("poll_options", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.PollVote", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("PollId") + .HasColumnType("uniqueidentifier") + .HasColumnName("poll_id"); + + b.Property("PollOptionId") + .HasColumnType("uniqueidentifier") + .HasColumnName("poll_option_id"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.Property("VotedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("voted_on"); + + b.HasKey("Id") + .HasName("pk_poll_votes"); + + b.HasIndex("PollId", "UserId") + .HasDatabaseName("ix_poll_vote_poll_user"); + + b.HasIndex("PollOptionId", "UserId") + .IsUnique() + .HasDatabaseName("ux_poll_vote_option_user"); + + b.ToTable("poll_votes", (string)null); + }); + + 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("CommentsCount") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0) + .HasColumnName("comments_count"); + + b.Property("CommunityId") + .HasColumnType("uniqueidentifier") + .HasColumnName("community_id"); + + b.Property("Content") + .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("DownvoteCount") + .HasColumnType("int") + .HasColumnName("downvote_count"); + + 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("PublishedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("published_on"); + + b.Property("Score") + .HasColumnType("float") + .HasColumnName("score"); + + b.Property("ShareCount") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0) + .HasColumnName("share_count"); + + b.Property("Status") + .HasColumnType("int") + .HasColumnName("status"); + + b.Property("Title") + .HasMaxLength(150) + .HasColumnType("nvarchar(150)") + .HasColumnName("title"); + + b.Property("TopicId") + .HasColumnType("uniqueidentifier") + .HasColumnName("topic_id"); + + b.Property("Type") + .HasColumnType("int") + .HasColumnName("type"); + + b.Property("UpvoteCount") + .HasColumnType("int") + .HasColumnName("upvote_count"); + + b.Property("ViewCount") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0) + .HasColumnName("view_count"); + + b.HasKey("Id") + .HasName("pk_posts"); + + b.HasIndex("Score") + .IsDescending() + .HasDatabaseName("ix_post_score"); + + b.HasIndex("TopicId") + .HasDatabaseName("ix_post_topic_id"); + + b.HasIndex("AuthorId", "CreatedOn") + .HasDatabaseName("ix_post_author_created"); + + b.HasIndex("AuthorId", "Status") + .HasDatabaseName("ix_post_author_status"); + + b.HasIndex("CommunityId", "Score") + .IsDescending(false, true) + .HasDatabaseName("ix_post_community_score"); + + b.ToTable("posts", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.PostAttachment", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AssetFileId") + .HasColumnType("uniqueidentifier") + .HasColumnName("asset_file_id"); + + b.Property("Kind") + .HasColumnType("int") + .HasColumnName("kind"); + + b.Property("MetadataJson") + .HasColumnType("nvarchar(max)") + .HasColumnName("metadata_json"); + + b.Property("PostId") + .HasColumnType("uniqueidentifier") + .HasColumnName("post_id"); + + b.Property("SortOrder") + .HasColumnType("int") + .HasColumnName("sort_order"); + + b.HasKey("Id") + .HasName("pk_post_attachments"); + + b.HasIndex("AssetFileId") + .HasDatabaseName("ix_post_attachments_asset_file_id"); + + b.HasIndex("PostId", "SortOrder") + .HasDatabaseName("ix_post_attachment_post_sort"); + + b.ToTable("post_attachments", (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.PostReply", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AuthorId") + .HasColumnType("uniqueidentifier") + .HasColumnName("author_id"); + + b.Property("ChildCount") + .HasColumnType("int") + .HasColumnName("child_count"); + + 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("Depth") + .HasColumnType("int") + .HasColumnName("depth"); + + b.Property("DownvoteCount") + .HasColumnType("int") + .HasColumnName("downvote_count"); + + 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.Property("Score") + .HasColumnType("float") + .HasColumnName("score"); + + b.Property("ThreadPath") + .IsRequired() + .HasMaxLength(900) + .HasColumnType("nvarchar(900)") + .HasColumnName("thread_path"); + + b.Property("UpvoteCount") + .HasColumnType("int") + .HasColumnName("upvote_count"); + + b.HasKey("Id") + .HasName("pk_post_replies"); + + b.HasIndex("ParentReplyId") + .HasDatabaseName("ix_post_reply_parent_id"); + + b.HasIndex("ThreadPath") + .HasDatabaseName("ix_post_reply_thread_path"); + + b.HasIndex("PostId", "Score") + .HasDatabaseName("ix_post_reply_post_score"); + + b.ToTable("post_replies", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.PostVote", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("PostId") + .HasColumnType("uniqueidentifier") + .HasColumnName("post_id"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.Property("Value") + .HasColumnType("int") + .HasColumnName("value"); + + b.Property("VotedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("voted_on"); + + b.HasKey("Id") + .HasName("pk_post_votes"); + + b.HasIndex("PostId", "UserId") + .IsUnique() + .HasDatabaseName("ux_post_vote_post_user"); + + b.ToTable("post_votes", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.ReplyVote", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("ReplyId") + .HasColumnType("uniqueidentifier") + .HasColumnName("reply_id"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.Property("Value") + .HasColumnType("int") + .HasColumnName("value"); + + b.Property("VotedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("voted_on"); + + b.HasKey("Id") + .HasName("pk_reply_votes"); + + b.HasIndex("ReplyId", "UserId") + .IsUnique() + .HasDatabaseName("ux_reply_vote_reply_user"); + + b.ToTable("reply_votes", (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("JobSectorId") + .HasColumnType("uniqueidentifier") + .HasColumnName("job_sector_id"); + + b.Property("KnowledgeLevelId") + .HasColumnType("uniqueidentifier") + .HasColumnName("knowledge_level_id"); + + 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.Property("TopicId") + .HasColumnType("uniqueidentifier") + .HasColumnName("topic_id"); + + b.HasKey("Id") + .HasName("pk_events"); + + b.HasIndex("ICalUid") + .IsUnique() + .HasDatabaseName("ux_event_ical_uid"); + + b.HasIndex("StartsOn") + .HasDatabaseName("ix_event_starts_on"); + + b.HasIndex("TopicId") + .HasDatabaseName("ix_event_topic_id"); + + 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("JobSectorId") + .HasColumnType("uniqueidentifier") + .HasColumnName("job_sector_id"); + + b.Property("KnowledgeLevelId") + .HasColumnType("uniqueidentifier") + .HasColumnName("knowledge_level_id"); + + 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("TitleAr") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_ar"); + + b.Property("TitleEn") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_en"); + + b.Property("TopicId") + .HasColumnType("uniqueidentifier") + .HasColumnName("topic_id"); + + b.HasKey("Id") + .HasName("pk_news"); + + b.HasIndex("PublishedOn") + .HasDatabaseName("ix_news_published_on"); + + b.HasIndex("TopicId") + .HasDatabaseName("ix_news_topic_id"); + + 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("JobSectorId") + .HasColumnType("uniqueidentifier") + .HasColumnName("job_sector_id"); + + b.Property("KnowledgeLevelId") + .HasColumnType("uniqueidentifier") + .HasColumnName("knowledge_level_id"); + + 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(255) + .HasColumnType("nvarchar(255)") + .HasColumnName("title_ar"); + + b.Property("TitleEn") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)") + .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("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.Content.ResourceCountry", b => + { + b.Property("ResourceId") + .HasColumnType("uniqueidentifier") + .HasColumnName("resource_id"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.HasKey("ResourceId", "CountryId") + .HasName("pk_resource_country"); + + b.HasIndex("CountryId") + .HasDatabaseName("ix_resource_country_country_id"); + + b.ToTable("resource_country", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.Tag", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("Color") + .HasMaxLength(7) + .HasColumnType("nvarchar(7)") + .HasColumnName("color"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("name_en"); + + b.HasKey("Id") + .HasName("pk_tags"); + + b.HasIndex("NameEn") + .IsUnique() + .HasDatabaseName("ux_tag_name_en"); + + b.ToTable("tags", (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("DialCode") + .HasMaxLength(16) + .HasColumnType("nvarchar(16)") + .HasColumnName("dial_code"); + + b.Property("FlagUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("flag_url"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("IsCceCountry") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false) + .HasColumnName("is_cce_country"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("IsoAlpha2") + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("iso_alpha2"); + + b.Property("IsoAlpha3") + .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") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("region_ar"); + + b.Property("RegionEn") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("region_en"); + + b.HasKey("Id") + .HasName("pk_countries"); + + b.HasIndex("DialCode") + .HasDatabaseName("ix_country_dial_code") + .HasFilter("[dial_code] IS NOT NULL"); + + b.HasIndex("IsoAlpha2") + .HasDatabaseName("ix_country_iso_alpha2"); + + b.HasIndex("IsoAlpha3") + .IsUnique() + .HasDatabaseName("ux_country_iso_alpha3_active") + .HasFilter("[is_deleted] = 0 AND [is_cce_country] = 1"); + + b.ToTable("countries", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Country.CountryContentRequest", 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("ProposedCategoryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("proposed_category_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("ProposedEndsOn") + .HasColumnType("datetimeoffset") + .HasColumnName("proposed_ends_on"); + + b.Property("ProposedJobSectorId") + .HasColumnType("uniqueidentifier") + .HasColumnName("proposed_job_sector_id"); + + b.Property("ProposedKnowledgeLevelId") + .HasColumnType("uniqueidentifier") + .HasColumnName("proposed_knowledge_level_id"); + + b.Property("ProposedLocationAr") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("proposed_location_ar"); + + b.Property("ProposedLocationEn") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("proposed_location_en"); + + b.Property("ProposedOnlineMeetingUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("proposed_online_meeting_url"); + + b.Property("ProposedResourceType") + .HasColumnType("int") + .HasColumnName("proposed_resource_type"); + + b.Property("ProposedStartsOn") + .HasColumnType("datetimeoffset") + .HasColumnName("proposed_starts_on"); + + 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("ProposedTopicId") + .HasColumnType("uniqueidentifier") + .HasColumnName("proposed_topic_id"); + + 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.Property("Type") + .HasColumnType("int") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_country_content_requests"); + + b.HasIndex("CountryId", "Status", "Type") + .HasDatabaseName("ix_country_content_request_country_status_type"); + + b.ToTable("country_content_requests", (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("AreaSqKm") + .HasColumnType("decimal(18,2)") + .HasColumnName("area_sq_km"); + + 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("GdpPerCapita") + .HasColumnType("decimal(18,2)") + .HasColumnName("gdp_per_capita"); + + 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("NationallyDeterminedContributionAssetId") + .HasColumnType("uniqueidentifier") + .HasColumnName("nationally_determined_contribution_asset_id"); + + b.Property("Population") + .HasColumnType("int") + .HasColumnName("population"); + + 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.Evaluation.ServiceEvaluation", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("ContentSuitability") + .HasColumnType("int") + .HasColumnName("content_suitability"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("EaseOfUse") + .HasColumnType("int") + .HasColumnName("ease_of_use"); + + b.Property("Feedback") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasColumnName("feedback"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("OverallSatisfaction") + .HasColumnType("int") + .HasColumnName("overall_satisfaction"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_service_evaluations"); + + b.HasIndex("CreatedOn") + .HasDatabaseName("ix_service_evaluation_created_on"); + + b.ToTable("service_evaluations", (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.ExpertRequestAttachment", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AssetFileId") + .HasColumnType("uniqueidentifier") + .HasColumnName("asset_file_id"); + + b.Property("AttachmentType") + .HasColumnType("int") + .HasColumnName("attachment_type"); + + b.Property("ExpertRequestId") + .HasColumnType("uniqueidentifier") + .HasColumnName("expert_request_id"); + + b.Property("UploadedAt") + .HasColumnType("datetimeoffset") + .HasColumnName("uploaded_at"); + + b.HasKey("Id") + .HasName("pk_expert_request_attachments"); + + b.HasIndex("ExpertRequestId") + .HasDatabaseName("ix_expert_request_attachments_expert_request_id"); + + b.ToTable("expert_request_attachments", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.InterestTopic", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)") + .HasColumnName("category"); + + 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_interest_topics"); + + b.ToTable("interest_topics", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.PermissionAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .HasColumnType("int") + .HasColumnName("action"); + + b.Property("ChangedAtUtc") + .HasColumnType("datetimeoffset") + .HasColumnName("changed_at_utc"); + + b.Property("ChangedByEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("changed_by_email"); + + b.Property("ChangedByUserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("changed_by_user_id"); + + b.Property("PermissionName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)") + .HasColumnName("permission_name"); + + b.Property("RoleName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("role_name"); + + b.HasKey("Id") + .HasName("pk_permission_audit_logs"); + + b.ToTable("permission_audit_logs", (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("CommentsCount") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0) + .HasColumnName("comments_count"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)") + .HasColumnName("concurrency_stamp"); + + 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("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.Property("FollowerCount") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0) + .HasColumnName("follower_count"); + + b.Property("FollowingCount") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0) + .HasColumnName("following_count"); + + 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("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + 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("PostsCount") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0) + .HasColumnName("posts_count"); + + 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") + .IsUnique() + .HasDatabaseName("ix_users_normalized_email_unique") + .HasFilter("[normalized_email] IS NOT NULL"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[normalized_user_name] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.UserInterestTopic", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.Property("InterestTopicId") + .HasColumnType("uniqueidentifier") + .HasColumnName("interest_topic_id"); + + b.HasKey("UserId", "InterestTopicId") + .HasName("pk_user_interest_topics"); + + b.HasIndex("InterestTopicId") + .HasDatabaseName("ix_user_interest_topics_interest_topic_id"); + + b.ToTable("user_interest_topics", (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.InteractiveMaps.InteractiveMap", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("DescriptionAr") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("description_en"); + + 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_interactive_maps"); + + b.ToTable("interactive_maps", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.InteractiveMaps.InteractiveMapNode", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("Category") + .HasColumnType("int") + .HasColumnName("category"); + + b.Property("CategoryNameAr") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("category_name_ar"); + + b.Property("CategoryNameEn") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("category_name_en"); + + b.Property("IconKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("icon_key"); + + b.Property("InteractiveMapId") + .HasColumnType("uniqueidentifier") + .HasColumnName("interactive_map_id"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("Level") + .HasColumnType("int") + .HasColumnName("level"); + + 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("ParentId") + .HasColumnType("uniqueidentifier") + .HasColumnName("parent_id"); + + b.Property("TopicId") + .HasColumnType("uniqueidentifier") + .HasColumnName("topic_id"); + + b.HasKey("Id") + .HasName("pk_interactive_map_nodes"); + + b.HasIndex("InteractiveMapId") + .HasDatabaseName("ix_interactive_map_node_map_id"); + + b.HasIndex("ParentId") + .HasDatabaseName("ix_interactive_map_node_parent_id"); + + b.HasIndex("TopicId") + .HasDatabaseName("ix_interactive_map_node_topic_id"); + + b.ToTable("interactive_map_nodes", (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.UserDeviceToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("device_id"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("LastSeenOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_seen_on"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)") + .HasColumnName("platform"); + + b.Property("RegisteredOn") + .HasColumnType("datetimeoffset") + .HasColumnName("registered_on"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("token"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_user_device_token"); + + b.HasIndex("Token") + .HasDatabaseName("ix_user_device_token_token"); + + b.HasIndex("UserId", "DeviceId") + .IsUnique() + .HasDatabaseName("ix_user_device_token_user_id_device_id"); + + b.HasIndex("UserId", "IsActive") + .HasDatabaseName("ix_user_device_token_user_id_is_active"); + + b.ToTable("user_device_token", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Notifications.UserNotification", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("ActorId") + .HasColumnType("uniqueidentifier") + .HasColumnName("actor_id"); + + b.Property("Channel") + .HasColumnType("int") + .HasColumnName("channel"); + + b.Property("MetaData") + .HasColumnType("nvarchar(max)") + .HasColumnName("meta_data"); + + 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("CCE.Domain.Verification.OtpVerification", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AttemptCount") + .HasColumnType("int") + .HasColumnName("attempt_count"); + + b.Property("CodeHash") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("code_hash"); + + b.Property("Contact") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("contact"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset") + .HasColumnName("created_at"); + + 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("ExpiresAt") + .HasColumnType("datetimeoffset") + .HasColumnName("expires_at"); + + b.Property("ExtraData") + .HasColumnType("nvarchar(max)") + .HasColumnName("extra_data"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("IsInvalidated") + .HasColumnType("bit") + .HasColumnName("is_invalidated"); + + b.Property("IsVerified") + .HasColumnType("bit") + .HasColumnName("is_verified"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("LastSentAt") + .HasColumnType("datetimeoffset") + .HasColumnName("last_sent_at"); + + b.Property("TypeId") + .HasColumnType("int") + .HasColumnName("type_id"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_otp_verifications"); + + b.HasIndex("Contact", "TypeId") + .HasDatabaseName("ix_otp_verifications_contact_type_id"); + + b.HasIndex("UserId", "Contact", "TypeId") + .HasDatabaseName("ix_otp_verifications_user_contact_type"); + + b.ToTable("otp_verifications", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Verification.UserVerification", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("Contact") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("contact"); + + 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("IsVerified") + .HasColumnType("bit") + .HasColumnName("is_verified"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("TypeId") + .HasColumnType("int") + .HasColumnName("type_id"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.Property("VerifiedAt") + .HasColumnType("datetimeoffset") + .HasColumnName("verified_at"); + + b.HasKey("Id") + .HasName("pk_user_verifications"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_verifications_user_id"); + + b.HasIndex("Contact", "TypeId") + .IsUnique() + .HasDatabaseName("ix_user_verifications_contact_type_id"); + + b.ToTable("user_verifications", (string)null); + }); + + modelBuilder.Entity("EventTag", b => + { + b.Property("EventId") + .HasColumnType("uniqueidentifier") + .HasColumnName("event_id"); + + b.Property("TagsId") + .HasColumnType("uniqueidentifier") + .HasColumnName("tags_id"); + + b.HasKey("EventId", "TagsId") + .HasName("pk_event_tag"); + + b.HasIndex("TagsId") + .HasDatabaseName("ix_event_tag_tags_id"); + + b.ToTable("event_tag", (string)null); + }); + + modelBuilder.Entity("InteractiveMapNodeTag", b => + { + b.Property("InteractiveMapNodeId") + .HasColumnType("uniqueidentifier") + .HasColumnName("interactive_map_node_id"); + + b.Property("TagsId") + .HasColumnType("uniqueidentifier") + .HasColumnName("tags_id"); + + b.HasKey("InteractiveMapNodeId", "TagsId") + .HasName("pk_interactive_map_node_tag"); + + b.HasIndex("TagsId") + .HasDatabaseName("ix_interactive_map_node_tag_tags_id"); + + b.ToTable("interactive_map_node_tag", (string)null); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("datetime2") + .HasColumnName("consumed"); + + b.Property("ConsumerId") + .HasColumnType("uniqueidentifier") + .HasColumnName("consumer_id"); + + b.Property("Delivered") + .HasColumnType("datetime2") + .HasColumnName("delivered"); + + b.Property("ExpirationTime") + .HasColumnType("datetime2") + .HasColumnName("expiration_time"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint") + .HasColumnName("last_sequence_number"); + + b.Property("LockId") + .HasColumnType("uniqueidentifier") + .HasColumnName("lock_id"); + + b.Property("MessageId") + .HasColumnType("uniqueidentifier") + .HasColumnName("message_id"); + + b.Property("ReceiveCount") + .HasColumnType("int") + .HasColumnName("receive_count"); + + b.Property("Received") + .HasColumnType("datetime2") + .HasColumnName("received"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.HasKey("Id") + .HasName("pk_inbox_state"); + + b.HasAlternateKey("MessageId", "ConsumerId") + .HasName("ak_inbox_state_message_id_consumer_id"); + + b.HasIndex("Delivered") + .HasDatabaseName("ix_inbox_state_delivered"); + + b.ToTable("inbox_state", (string)null); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("sequence_number"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("body"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("content_type"); + + b.Property("ConversationId") + .HasColumnType("uniqueidentifier") + .HasColumnName("conversation_id"); + + b.Property("CorrelationId") + .HasColumnType("uniqueidentifier") + .HasColumnName("correlation_id"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("destination_address"); + + b.Property("EnqueueTime") + .HasColumnType("datetime2") + .HasColumnName("enqueue_time"); + + b.Property("ExpirationTime") + .HasColumnType("datetime2") + .HasColumnName("expiration_time"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("fault_address"); + + b.Property("Headers") + .HasColumnType("nvarchar(max)") + .HasColumnName("headers"); + + b.Property("InboxConsumerId") + .HasColumnType("uniqueidentifier") + .HasColumnName("inbox_consumer_id"); + + b.Property("InboxMessageId") + .HasColumnType("uniqueidentifier") + .HasColumnName("inbox_message_id"); + + b.Property("InitiatorId") + .HasColumnType("uniqueidentifier") + .HasColumnName("initiator_id"); + + b.Property("MessageId") + .HasColumnType("uniqueidentifier") + .HasColumnName("message_id"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("message_type"); + + b.Property("OutboxId") + .HasColumnType("uniqueidentifier") + .HasColumnName("outbox_id"); + + b.Property("Properties") + .HasColumnType("nvarchar(max)") + .HasColumnName("properties"); + + b.Property("RequestId") + .HasColumnType("uniqueidentifier") + .HasColumnName("request_id"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("response_address"); + + b.Property("SentTime") + .HasColumnType("datetime2") + .HasColumnName("sent_time"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("source_address"); + + b.HasKey("SequenceNumber") + .HasName("pk_outbox_message"); + + b.HasIndex("EnqueueTime") + .HasDatabaseName("ix_outbox_message_enqueue_time"); + + b.HasIndex("ExpirationTime") + .HasDatabaseName("ix_outbox_message_expiration_time"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique() + .HasDatabaseName("ix_outbox_message_outbox_id_sequence_number") + .HasFilter("[outbox_id] IS NOT NULL"); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique() + .HasDatabaseName("ix_outbox_message_inbox_message_id_inbox_consumer_id_sequence_number") + .HasFilter("[inbox_message_id] IS NOT NULL AND [inbox_consumer_id] IS NOT NULL"); + + b.ToTable("outbox_message", (string)null); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasColumnName("outbox_id"); + + b.Property("Created") + .HasColumnType("datetime2") + .HasColumnName("created"); + + b.Property("Delivered") + .HasColumnType("datetime2") + .HasColumnName("delivered"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint") + .HasColumnName("last_sequence_number"); + + b.Property("LockId") + .HasColumnType("uniqueidentifier") + .HasColumnName("lock_id"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.HasKey("OutboxId") + .HasName("pk_outbox_state"); + + b.HasIndex("Created") + .HasDatabaseName("ix_outbox_state_created"); + + b.ToTable("outbox_state", (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("NewsTag", b => + { + b.Property("NewsId") + .HasColumnType("uniqueidentifier") + .HasColumnName("news_id"); + + b.Property("TagsId") + .HasColumnType("uniqueidentifier") + .HasColumnName("tags_id"); + + b.HasKey("NewsId", "TagsId") + .HasName("pk_news_tag"); + + b.HasIndex("TagsId") + .HasDatabaseName("ix_news_tag_tags_id"); + + b.ToTable("news_tag", (string)null); + }); + + modelBuilder.Entity("PostTag", b => + { + b.Property("PostId") + .HasColumnType("uniqueidentifier") + .HasColumnName("post_id"); + + b.Property("TagsId") + .HasColumnType("uniqueidentifier") + .HasColumnName("tags_id"); + + b.HasKey("PostId", "TagsId") + .HasName("pk_post_tag"); + + b.HasIndex("TagsId") + .HasDatabaseName("ix_post_tag_tags_id"); + + b.ToTable("post_tag", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.PollOption", b => + { + b.HasOne("CCE.Domain.Community.Poll", null) + .WithMany("Options") + .HasForeignKey("PollId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_poll_options_polls_poll_id"); + }); + + modelBuilder.Entity("CCE.Domain.Community.Post", b => + { + b.HasOne("CCE.Domain.Community.Community", null) + .WithMany() + .HasForeignKey("CommunityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_posts_communities_community_id"); + }); + + modelBuilder.Entity("CCE.Domain.Community.PostAttachment", b => + { + b.HasOne("CCE.Domain.Content.AssetFile", null) + .WithMany() + .HasForeignKey("AssetFileId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_post_attachments_asset_files_asset_file_id"); + + b.HasOne("CCE.Domain.Community.Post", null) + .WithMany("Attachments") + .HasForeignKey("PostId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_post_attachments_posts_post_id"); + }); + + modelBuilder.Entity("CCE.Domain.Content.ResourceCountry", b => + { + b.HasOne("CCE.Domain.Content.Resource", null) + .WithMany("Countries") + .HasForeignKey("ResourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_resource_country_resources_resource_id"); + }); + + modelBuilder.Entity("CCE.Domain.Identity.ExpertRequestAttachment", b => + { + b.HasOne("CCE.Domain.Identity.ExpertRegistrationRequest", null) + .WithMany("Attachments") + .HasForeignKey("ExpertRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_expert_request_attachments_expert_registration_requests_expert_request_id"); + }); + + 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.Identity.UserInterestTopic", b => + { + b.HasOne("CCE.Domain.Identity.InterestTopic", "InterestTopic") + .WithMany() + .HasForeignKey("InterestTopicId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_interest_topics_interest_topics_interest_topic_id"); + + b.HasOne("CCE.Domain.Identity.User", "User") + .WithMany("UserInterestTopics") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_interest_topics_users_user_id"); + + b.Navigation("InterestTopic"); + + b.Navigation("User"); + }); + + 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("CCE.Domain.Verification.UserVerification", b => + { + b.HasOne("CCE.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .HasConstraintName("fk_user_verifications_asp_net_users_user_id"); + }); + + modelBuilder.Entity("EventTag", b => + { + b.HasOne("CCE.Domain.Content.Event", null) + .WithMany() + .HasForeignKey("EventId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_event_tag_events_event_id"); + + b.HasOne("CCE.Domain.Content.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_event_tag_tags_tags_id"); + }); + + modelBuilder.Entity("InteractiveMapNodeTag", b => + { + b.HasOne("CCE.Domain.InteractiveMaps.InteractiveMapNode", null) + .WithMany() + .HasForeignKey("InteractiveMapNodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_interactive_map_node_tag_interactive_map_nodes_interactive_map_node_id"); + + b.HasOne("CCE.Domain.Content.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_interactive_map_node_tag_tags_tags_id"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId") + .HasConstraintName("fk_outbox_message_outbox_state_outbox_id"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId") + .HasConstraintName("fk_outbox_message_inbox_state_inbox_message_id_inbox_consumer_id"); + }); + + 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("NewsTag", b => + { + b.HasOne("CCE.Domain.Content.News", null) + .WithMany() + .HasForeignKey("NewsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_news_tag_news_news_id"); + + b.HasOne("CCE.Domain.Content.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_news_tag_tags_tags_id"); + }); + + modelBuilder.Entity("PostTag", b => + { + b.HasOne("CCE.Domain.Community.Post", null) + .WithMany() + .HasForeignKey("PostId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_post_tag_posts_post_id"); + + b.HasOne("CCE.Domain.Content.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_post_tag_tags_tags_id"); + }); + + modelBuilder.Entity("CCE.Domain.Community.Poll", b => + { + b.Navigation("Options"); + }); + + modelBuilder.Entity("CCE.Domain.Community.Post", b => + { + b.Navigation("Attachments"); + }); + + modelBuilder.Entity("CCE.Domain.Content.Resource", b => + { + b.Navigation("Countries"); + }); + + modelBuilder.Entity("CCE.Domain.Identity.ExpertRegistrationRequest", b => + { + b.Navigation("Attachments"); + }); + + modelBuilder.Entity("CCE.Domain.Identity.User", b => + { + b.Navigation("UserInterestTopics"); + }); + + 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/20260624135014_AddUserDeviceToken.cs b/backend/src/CCE.Infrastructure/Persistence/Migrations/20260624135014_AddUserDeviceToken.cs new file mode 100644 index 00000000..f171f371 --- /dev/null +++ b/backend/src/CCE.Infrastructure/Persistence/Migrations/20260624135014_AddUserDeviceToken.cs @@ -0,0 +1,56 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CCE.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddUserDeviceToken : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "user_device_token", + columns: table => new + { + id = table.Column(type: "uniqueidentifier", nullable: false), + user_id = table.Column(type: "uniqueidentifier", nullable: false), + device_id = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + token = table.Column(type: "nvarchar(512)", maxLength: 512, nullable: false), + platform = table.Column(type: "nvarchar(16)", maxLength: 16, nullable: false), + registered_on = table.Column(type: "datetimeoffset", nullable: false), + last_seen_on = table.Column(type: "datetimeoffset", nullable: false), + is_active = table.Column(type: "bit", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_user_device_token", x => x.id); + }); + + migrationBuilder.CreateIndex( + name: "ix_user_device_token_token", + table: "user_device_token", + column: "token"); + + migrationBuilder.CreateIndex( + name: "ix_user_device_token_user_id_device_id", + table: "user_device_token", + columns: new[] { "user_id", "device_id" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_user_device_token_user_id_is_active", + table: "user_device_token", + columns: new[] { "user_id", "is_active" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "user_device_token"); + } + } +} diff --git a/backend/src/CCE.Infrastructure/Persistence/Migrations/CceDbContextModelSnapshot.cs b/backend/src/CCE.Infrastructure/Persistence/Migrations/CceDbContextModelSnapshot.cs index e4e0a91c..8ec28df0 100644 --- a/backend/src/CCE.Infrastructure/Persistence/Migrations/CceDbContextModelSnapshot.cs +++ b/backend/src/CCE.Infrastructure/Persistence/Migrations/CceDbContextModelSnapshot.cs @@ -3500,6 +3500,63 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("notification_templates", (string)null); }); + modelBuilder.Entity("CCE.Domain.Notifications.UserDeviceToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("device_id"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("LastSeenOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_seen_on"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)") + .HasColumnName("platform"); + + b.Property("RegisteredOn") + .HasColumnType("datetimeoffset") + .HasColumnName("registered_on"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("token"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_user_device_token"); + + b.HasIndex("Token") + .HasDatabaseName("ix_user_device_token_token"); + + b.HasIndex("UserId", "DeviceId") + .IsUnique() + .HasDatabaseName("ix_user_device_token_user_id_device_id"); + + b.HasIndex("UserId", "IsActive") + .HasDatabaseName("ix_user_device_token_user_id_is_active"); + + b.ToTable("user_device_token", (string)null); + }); + modelBuilder.Entity("CCE.Domain.Notifications.UserNotification", b => { b.Property("Id")