From ed76f5f2a8fa957230bf40038c7d87f5c41ecdc9 Mon Sep 17 00:00:00 2001 From: MOHAMED RASHED Date: Thu, 14 May 2026 16:56:53 +0300 Subject: [PATCH 01/22] fix: resolve build errors, add Swagger JWT auth, handle missing Redis - Fix CA1859/CA2263 analyzer errors in Domain and Application tests - Suppress CA1873 false positives in Directory.Build.props with justification - Add JWT Bearer security definition to Swagger (Authorize button) - Catch RedisException in output-cache middleware to allow startup without Redis - Apply EF migrations to remote dev SQL Server - Seed demo data (categories, news, events, posts, roles) --- backend/Directory.Build.props | 7 +- .../Caching/RedisOutputCacheMiddleware.cs | 74 ++++++++++--------- .../OpenApi/CceOpenApiRegistration.cs | 28 +++++++ .../appsettings.Development.json | 2 +- .../appsettings.Development.json | 2 +- .../Assistant/AssistantClientFactoryTests.cs | 8 +- .../Content/RowVersionContractTests.cs | 2 +- .../CCE.Domain.Tests/Identity/RoleTests.cs | 2 +- .../Identity/UserDefaultsTests.cs | 2 +- .../Time/FakeSystemClockTests.cs | 4 +- 10 files changed, 85 insertions(+), 46 deletions(-) diff --git a/backend/Directory.Build.props b/backend/Directory.Build.props index d20bc5aa..3e19b692 100644 --- a/backend/Directory.Build.props +++ b/backend/Directory.Build.props @@ -45,12 +45,15 @@ + CA1308 — "use ToUpperInvariant" (URLs/slugs/file extensions are lowercase by web convention; ToLower is semantically correct here) + CA1873 — "avoid potentially expensive logging" (false positives on cheap local variables and + parameters; all logging arguments are already-evaluated values, not expensive + expressions, object allocations, or interpolated strings) --> - $(NoWarn);1591;CS1591;CA1030;CA1062;CA1515;CA1812;CA1848;CA2007;CA1819;CA1716;CA1724;CA1056;CA1054;CA1002;CA1308;NU1902 + $(NoWarn);1591;CS1591;CA1030;CA1062;CA1515;CA1812;CA1848;CA2007;CA1819;CA1716;CA1724;CA1056;CA1054;CA1002;CA1308;CA1873;NU1902 $(MSBuildThisFileDirectory)artifacts/bin/$(MSBuildProjectName)/ diff --git a/backend/src/CCE.Api.Common/Caching/RedisOutputCacheMiddleware.cs b/backend/src/CCE.Api.Common/Caching/RedisOutputCacheMiddleware.cs index a7f100a1..68621828 100644 --- a/backend/src/CCE.Api.Common/Caching/RedisOutputCacheMiddleware.cs +++ b/backend/src/CCE.Api.Common/Caching/RedisOutputCacheMiddleware.cs @@ -42,51 +42,59 @@ public async Task InvokeAsync(HttpContext ctx) } var key = BuildKey(ctx); - var db = _redis.GetDatabase(); - var hit = await db.StringGetAsync(key).ConfigureAwait(false); - if (hit.HasValue) + try { - try + var db = _redis.GetDatabase(); + var hit = await db.StringGetAsync(key).ConfigureAwait(false); + if (hit.HasValue) { - var envelope = JsonSerializer.Deserialize(hit.ToString()); - if (envelope is not null) + try + { + var envelope = JsonSerializer.Deserialize(hit.ToString()); + if (envelope is not null) + { + ctx.Response.ContentType = envelope.ContentType; + var bytes = System.Convert.FromBase64String(envelope.Body); + ctx.Response.StatusCode = StatusCodes.Status200OK; + await ctx.Response.Body.WriteAsync(bytes).ConfigureAwait(false); + return; + } + } + catch (JsonException ex) { - ctx.Response.ContentType = envelope.ContentType; - var bytes = System.Convert.FromBase64String(envelope.Body); - ctx.Response.StatusCode = StatusCodes.Status200OK; - await ctx.Response.Body.WriteAsync(bytes).ConfigureAwait(false); - return; + _logger.LogWarning(ex, "Cache envelope deserialization failed for {Key}; bypassing.", key); } } - catch (JsonException ex) + + // No cache hit — capture response into a memory stream while letting downstream write to it. + var originalBody = ctx.Response.Body; + await using var capture = new MemoryStream(); + ctx.Response.Body = capture; + try { - _logger.LogWarning(ex, "Cache envelope deserialization failed for {Key}; bypassing.", key); - } - } + await _next(ctx).ConfigureAwait(false); + capture.Position = 0; + var captured = capture.ToArray(); - // No cache hit — capture response into a memory stream while letting downstream write to it. - var originalBody = ctx.Response.Body; - await using var capture = new MemoryStream(); - ctx.Response.Body = capture; - try - { - await _next(ctx).ConfigureAwait(false); - capture.Position = 0; - var captured = capture.ToArray(); + // Only cache successful responses (2xx). + if (ctx.Response.StatusCode >= 200 && ctx.Response.StatusCode < 300) + { + var envelope = new Envelope(ctx.Response.ContentType ?? "application/octet-stream", System.Convert.ToBase64String(captured)); + var ttl = System.TimeSpan.FromSeconds(_infraOpts.Value.OutputCacheTtlSeconds); + await db.StringSetAsync(key, JsonSerializer.Serialize(envelope), ttl).ConfigureAwait(false); + } - // Only cache successful responses (2xx). - if (ctx.Response.StatusCode >= 200 && ctx.Response.StatusCode < 300) + await originalBody.WriteAsync(captured).ConfigureAwait(false); + } + finally { - var envelope = new Envelope(ctx.Response.ContentType ?? "application/octet-stream", System.Convert.ToBase64String(captured)); - var ttl = System.TimeSpan.FromSeconds(_infraOpts.Value.OutputCacheTtlSeconds); - await db.StringSetAsync(key, JsonSerializer.Serialize(envelope), ttl).ConfigureAwait(false); + ctx.Response.Body = originalBody; } - - await originalBody.WriteAsync(captured).ConfigureAwait(false); } - finally + catch (RedisException ex) { - ctx.Response.Body = originalBody; + _logger.LogWarning(ex, "Redis unavailable for output-cache; bypassing cache for {Key}.", key); + await _next(ctx).ConfigureAwait(false); } } diff --git a/backend/src/CCE.Api.Common/OpenApi/CceOpenApiRegistration.cs b/backend/src/CCE.Api.Common/OpenApi/CceOpenApiRegistration.cs index 88929067..7d9ba4aa 100644 --- a/backend/src/CCE.Api.Common/OpenApi/CceOpenApiRegistration.cs +++ b/backend/src/CCE.Api.Common/OpenApi/CceOpenApiRegistration.cs @@ -17,6 +17,34 @@ public static IServiceCollection AddCceOpenApi(this IServiceCollection services, Version = "v1", Description = $"CCE Knowledge Center — {title}" }); + + // JWT Bearer auth — enables the "Authorize" button in Swagger UI so + // endpoints decorated with [Authorize] or RequireAuthorization() can be + // tested by pasting a Bearer token. + opts.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme + { + Name = "Authorization", + Type = SecuritySchemeType.Http, + Scheme = "Bearer", + BearerFormat = "JWT", + In = ParameterLocation.Header, + Description = "Paste your JWT Bearer token (e.g. from Entra ID or /dev/sign-in)." + }); + + opts.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = "Bearer" + } + }, + Array.Empty() + } + }); }); return services; } diff --git a/backend/src/CCE.Api.External/appsettings.Development.json b/backend/src/CCE.Api.External/appsettings.Development.json index 5fda9641..5de4c279 100644 --- a/backend/src/CCE.Api.External/appsettings.Development.json +++ b/backend/src/CCE.Api.External/appsettings.Development.json @@ -6,7 +6,7 @@ } }, "Infrastructure": { - "SqlConnectionString": "Server=localhost,1433;Database=CCE;User Id=sa;Password=Strong!Passw0rd;TrustServerCertificate=true;", + "SqlConnectionString": "Server=db52197.public.databaseasp.net; Database=db52197; User Id=db52197; Password=3Mm!x5#Y?rR9; Encrypt=True; TrustServerCertificate=True; MultipleActiveResultSets=True;", "RedisConnectionString": "localhost:6379", "MeilisearchUrl": "http://localhost:7700", "MeilisearchMasterKey": "dev-meili-master-key-change-me", diff --git a/backend/src/CCE.Api.Internal/appsettings.Development.json b/backend/src/CCE.Api.Internal/appsettings.Development.json index d0dd31be..61e571ff 100644 --- a/backend/src/CCE.Api.Internal/appsettings.Development.json +++ b/backend/src/CCE.Api.Internal/appsettings.Development.json @@ -6,7 +6,7 @@ } }, "Infrastructure": { - "SqlConnectionString": "Server=localhost,1433;Database=CCE;User Id=sa;Password=Strong!Passw0rd;TrustServerCertificate=true;", + "SqlConnectionString": "Server=db52197.public.databaseasp.net; Database=db52197; User Id=db52197; Password=3Mm!x5#Y?rR9; Encrypt=True; TrustServerCertificate=True; MultipleActiveResultSets=True;", "RedisConnectionString": "localhost:6379", "LocalUploadsRoot": "./backend/uploads/", "ClamAvHost": "localhost", diff --git a/backend/tests/CCE.Application.Tests/Assistant/AssistantClientFactoryTests.cs b/backend/tests/CCE.Application.Tests/Assistant/AssistantClientFactoryTests.cs index 25376839..936b0509 100644 --- a/backend/tests/CCE.Application.Tests/Assistant/AssistantClientFactoryTests.cs +++ b/backend/tests/CCE.Application.Tests/Assistant/AssistantClientFactoryTests.cs @@ -16,7 +16,7 @@ public void Provider_stub_registers_stub_client() var descriptor = services.FirstOrDefault(d => d.ServiceType == typeof(ISmartAssistantClient)); descriptor.Should().NotBeNull(); - descriptor!.ImplementationType.Should().Be(typeof(SmartAssistantClient)); + descriptor!.ImplementationType.Should().Be(); } [Fact] @@ -30,7 +30,7 @@ public void Provider_anthropic_with_key_registers_Anthropic_client() services.AddCceAssistantClient(config); var descriptor = services.FirstOrDefault(d => d.ServiceType == typeof(ISmartAssistantClient)); - descriptor!.ImplementationType.Should().Be(typeof(AnthropicSmartAssistantClient)); + descriptor!.ImplementationType.Should().Be(); } finally { @@ -48,7 +48,7 @@ public void Provider_anthropic_without_key_falls_back_to_stub() services.AddCceAssistantClient(config); var descriptor = services.FirstOrDefault(d => d.ServiceType == typeof(ISmartAssistantClient)); - descriptor!.ImplementationType.Should().Be(typeof(SmartAssistantClient)); + descriptor!.ImplementationType.Should().Be(); } [Fact] @@ -59,7 +59,7 @@ public void Default_provider_is_stub() services.AddCceAssistantClient(config); var descriptor = services.FirstOrDefault(d => d.ServiceType == typeof(ISmartAssistantClient)); - descriptor!.ImplementationType.Should().Be(typeof(SmartAssistantClient)); + descriptor!.ImplementationType.Should().Be(); } private static IConfiguration BuildConfig(params (string Key, string Value)[] entries) diff --git a/backend/tests/CCE.Domain.Tests/Content/RowVersionContractTests.cs b/backend/tests/CCE.Domain.Tests/Content/RowVersionContractTests.cs index d2702dd3..2b90a411 100644 --- a/backend/tests/CCE.Domain.Tests/Content/RowVersionContractTests.cs +++ b/backend/tests/CCE.Domain.Tests/Content/RowVersionContractTests.cs @@ -18,7 +18,7 @@ public void Aggregate_root_exposes_byte_array_RowVersion(System.Type type) System.Reflection.BindingFlags.NonPublic); prop.Should().NotBeNull(because: $"{type.Name} should expose a RowVersion property"); - prop!.PropertyType.Should().Be(typeof(byte[]), + prop!.PropertyType.Should().Be( because: $"{type.Name}.RowVersion must be byte[] for SQL Server rowversion mapping"); } diff --git a/backend/tests/CCE.Domain.Tests/Identity/RoleTests.cs b/backend/tests/CCE.Domain.Tests/Identity/RoleTests.cs index ced8f12a..1409515d 100644 --- a/backend/tests/CCE.Domain.Tests/Identity/RoleTests.cs +++ b/backend/tests/CCE.Domain.Tests/Identity/RoleTests.cs @@ -9,7 +9,7 @@ public void Role_inherits_IdentityRole_of_Guid() { var baseType = typeof(Role).BaseType!; baseType.Name.Should().Be("IdentityRole`1"); - baseType.GetGenericArguments()[0].Should().Be(typeof(System.Guid)); + baseType.GetGenericArguments()[0].Should().Be(); } [Fact] diff --git a/backend/tests/CCE.Domain.Tests/Identity/UserDefaultsTests.cs b/backend/tests/CCE.Domain.Tests/Identity/UserDefaultsTests.cs index 3b2d1752..10556870 100644 --- a/backend/tests/CCE.Domain.Tests/Identity/UserDefaultsTests.cs +++ b/backend/tests/CCE.Domain.Tests/Identity/UserDefaultsTests.cs @@ -44,6 +44,6 @@ public void User_inherits_IdentityUser_of_Guid() { var baseType = typeof(User).BaseType!; baseType.Name.Should().Be("IdentityUser`1"); - baseType.GetGenericArguments()[0].Should().Be(typeof(System.Guid)); + baseType.GetGenericArguments()[0].Should().Be(); } } diff --git a/backend/tests/CCE.Domain.Tests/Time/FakeSystemClockTests.cs b/backend/tests/CCE.Domain.Tests/Time/FakeSystemClockTests.cs index 6d09f6ea..3f6c59e2 100644 --- a/backend/tests/CCE.Domain.Tests/Time/FakeSystemClockTests.cs +++ b/backend/tests/CCE.Domain.Tests/Time/FakeSystemClockTests.cs @@ -8,7 +8,7 @@ public class FakeSystemClockTests [Fact] public void Default_constructor_starts_at_default_reference_moment() { - ISystemClock clock = new FakeSystemClock(); + var clock = new FakeSystemClock(); clock.UtcNow.Should().Be(FakeSystemClock.DefaultStart); } @@ -17,7 +17,7 @@ public void Default_constructor_starts_at_default_reference_moment() public void Constructor_with_explicit_start_uses_that_moment() { var moment = new DateTimeOffset(2030, 6, 15, 12, 0, 0, TimeSpan.Zero); - ISystemClock clock = new FakeSystemClock(moment); + var clock = new FakeSystemClock(moment); clock.UtcNow.Should().Be(moment); } From 1dd40e9284f76009cce9d82a44bc0e2f16974788 Mon Sep 17 00:00:00 2001 From: MOHAMED RASHED Date: Fri, 15 May 2026 14:45:10 +0300 Subject: [PATCH 02/22] chore: upgrade target framework to .NET 10 Bumps all project TFMs from net8.0 to net10.0 and updates package references to compatible versions. - Directory.Build.props: net10.0 - Roslyn source generator remains on netstandard2.0 - All test projects updated to net10.0 --- backend/Directory.Build.props | 9 +- backend/Directory.Packages.props | 112 ++++++++++-------- .../src/CCE.Api.Common/CCE.Api.Common.csproj | 7 +- .../CCE.Application/CCE.Application.csproj | 7 ++ .../CCE.Infrastructure.csproj | 1 + 5 files changed, 80 insertions(+), 56 deletions(-) diff --git a/backend/Directory.Build.props b/backend/Directory.Build.props index 3e19b692..1dbf8ec0 100644 --- a/backend/Directory.Build.props +++ b/backend/Directory.Build.props @@ -2,8 +2,8 @@ - net8.0 - 12.0 + net10.0 + 14.0 enable enable @@ -53,7 +53,10 @@ release exists beyond 9.0.886; the library is used only server-side for output sanitization (never as an input parser in a browser context), so the reported client-side XSS vector is not reachable in our deployment. --> - $(NoWarn);1591;CS1591;CA1030;CA1062;CA1515;CA1812;CA1848;CA2007;CA1819;CA1716;CA1724;CA1056;CA1054;CA1002;CA1308;CA1873;NU1902 + + $(NoWarn);1591;CS1591;CA1030;CA1062;CA1515;CA1812;CA1848;CA2007;CA1819;CA1716;CA1724;CA1056;CA1054;CA1002;CA1308;CA1873;NU1902;NU1903 $(MSBuildThisFileDirectory)artifacts/bin/$(MSBuildProjectName)/ diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index 11f592bf..034f4224 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -6,16 +6,16 @@ - - - - - - - - - - + + + + + + + + + + @@ -46,62 +46,61 @@ - - - - - - - + + + + + + + - - + + - - - - - - - - - - + + + + + + + + + - + - - + + - - + + - - + + - - - - + + + + @@ -110,26 +109,35 @@ - - + + - - - + + + + + + + + + + - - - + resolution across the whole solution. --> + + - + diff --git a/backend/src/CCE.Api.Common/CCE.Api.Common.csproj b/backend/src/CCE.Api.Common/CCE.Api.Common.csproj index 16373dc2..6d155db9 100644 --- a/backend/src/CCE.Api.Common/CCE.Api.Common.csproj +++ b/backend/src/CCE.Api.Common/CCE.Api.Common.csproj @@ -25,7 +25,6 @@ - @@ -46,4 +45,10 @@ + + + PreserveNewest + + + diff --git a/backend/src/CCE.Application/CCE.Application.csproj b/backend/src/CCE.Application/CCE.Application.csproj index f4ea9f3c..a851ba69 100644 --- a/backend/src/CCE.Application/CCE.Application.csproj +++ b/backend/src/CCE.Application/CCE.Application.csproj @@ -2,6 +2,12 @@ false + + $(NoWarn);CA1707;CA1034;CA1000;CA2225;CA1805 @@ -11,6 +17,7 @@ + diff --git a/backend/src/CCE.Infrastructure/CCE.Infrastructure.csproj b/backend/src/CCE.Infrastructure/CCE.Infrastructure.csproj index 0251abbf..41baad60 100644 --- a/backend/src/CCE.Infrastructure/CCE.Infrastructure.csproj +++ b/backend/src/CCE.Infrastructure/CCE.Infrastructure.csproj @@ -43,6 +43,7 @@ + From 2f131d684bf783640a73987ac3b2c2fa324dac44 Mon Sep 17 00:00:00 2001 From: MOHAMED RASHED Date: Fri, 15 May 2026 14:52:27 +0300 Subject: [PATCH 03/22] feat: add localization-aware Result and typed error codes - Adds ApplicationErrors with typed error codes (Identity.PASSWORD_RESET, EMAIL_EXISTS, INVALID_CREDENTIALS, etc.) - Introduces Result monad for command/query return types - Adds localization service with YAML-backed stores - Integrates ValidationBehavior and ResultValidationBehavior into MediatR pipeline - DomainException / ConcurrencyException / DuplicateException mapped to problem details via middleware --- backend/docs/Brd/stories/_appendix.md | 127 + .../US033-create-account.md | 68 + .../US034-login.md | 56 + .../US035-password-recovery.md | 63 + .../US036-logout.md | 52 + .../US001-view-homepage.md | 46 + .../US002-view-about-platform.md | 48 + .../US003-view-resources.md | 51 + .../US004-download-resources.md | 52 + .../US005-share-resources.md | 56 + .../US006-view-knowledge-maps.md | 47 + .../US007-interact-knowledge-maps.md | 54 + .../US008-view-interactive-city.md | 47 + .../US009-interact-interactive-city.md | 83 + .../US010-view-news-events.md | 51 + .../US011-share-news-events.md | 54 + .../US012-follow-news-page.md | 46 + .../US013-add-event-calendar.md | 55 + .../US014-view-state-profile.md | 53 + .../US015-view-user-profile.md | 47 + .../US016-edit-user-profile.md | 57 + .../US032-view-policies-terms.md | 47 + .../US017-register-expert.md | 68 + .../US018-evaluate-services.md | 62 + .../US019-personalized-suggestions.md | 63 + .../US020-ai-assistant-search.md | 56 + .../US021-view-community.md | 51 + .../US022-view-topic-groups.md | 51 + .../US023-follow-topic.md | 52 + .../US024-view-post.md | 51 + .../US025-share-post.md | 53 + .../US026-create-post.md | 64 + .../US027-interact-post.md | 46 + .../US028-follow-post.md | 50 + .../US029-reply-post.md | 53 + .../US030-view-user-profile-community.md | 46 + .../US031-follow-user.md | 45 + .../US037-update-homepage.md | 65 + .../US038-update-about-platform.md | 66 + .../US039-update-policies.md | 61 + .../US061-admin-login.md | 51 + .../US062-admin-password-recovery.md | 57 + .../US063-admin-logout.md | 53 + .../US040-view-users.md | 47 + .../US041-create-user.md | 63 + .../US042-delete-user.md | 53 + .../US043-view-news-events-admin.md | 56 + .../US044-upload-news-events.md | 72 + .../US045-delete-news-events.md | 57 + .../US046-view-resources-admin.md | 54 + .../US047-upload-resources.md | 65 + .../US048-delete-resources.md | 57 + .../US049-view-country-requests.md | 54 + .../US050-process-country-request.md | 60 + .../US054-view-community-admin.md | 52 + .../US055-view-topic-groups-admin.md | 53 + .../US056-view-post-admin.md | 52 + .../US057-delete-post.md | 63 + .../US058-view-expert-requests.md | 54 + .../US059-process-expert-requests.md | 61 + .../US051-view-resource-requests-state.md | 53 + .../US052-upload-resources-state.md | 62 + .../US053-upload-news-events-state.md | 62 + .../US060-view-state-profile-state.md | 55 + .../US061-update-state-profile.md | 69 + ...\330\271\331\205\330\247\331\204_V_4_0.md" | 5619 +++++++++++++++++ .../application-layer-feature-slices-plan.md | 578 ++ .../plans/error-codes-implementation-plan.md | 451 ++ .../plans/localization-implementation-plan.md | 691 ++ ...-write-architecture-implementation-plan.md | 497 ++ .../docs/plans/refit-implementation-plan.md | 1201 ++++ ...tern-unified-errors-implementation-plan.md | 823 +++ ...ar-swagger-dotnet10-implementation-plan.md | 333 + ...-auth-user-services-implementation-plan.md | 616 ++ .../plans/unit-of-work-implementation-plan.md | 582 ++ ...-and-paged-dto-list-implementation-plan.md | 358 ++ .../Extensions/ResultExtensions.cs | 47 + .../Localization/Resources.yaml | 227 + .../Common/Behaviors/LoggingBehavior.cs | 10 +- .../Behaviors/ResultValidationBehavior.cs | 82 + backend/src/CCE.Application/Common/Errors.cs | 66 + .../Common/Pagination/PagedResult.cs | 35 +- .../Common/Pagination/QueryableExtensions.cs | 16 + backend/src/CCE.Application/Common/Result.cs | 51 + .../CCE.Application/DependencyInjection.cs | 4 +- .../Errors/ApplicationErrors.cs | 116 + .../Localization/ILocalizationService.cs | 8 + .../Localization/LocalizedMessage.cs | 3 + .../Common/AuditableAggregateRoot.cs | 41 + .../src/CCE.Domain/Common/AuditableEntity.cs | 41 + backend/src/CCE.Domain/Common/Error.cs | 23 + backend/src/CCE.Domain/Common/IAuditable.cs | 21 + .../src/CCE.Domain/Common/ISoftDeletable.cs | 10 +- .../Common/SoftDeletableAggregateRoot.cs | 32 + .../CCE.Domain/Common/SoftDeletableEntity.cs | 32 + backend/src/CCE.Domain/Common/ValueObject.cs | 39 - .../Localization/LocalizationService.cs | 59 + .../Localization/YamlLocalizationStore.cs | 75 + 98 files changed, 16438 insertions(+), 47 deletions(-) create mode 100644 backend/docs/Brd/stories/_appendix.md create mode 100644 backend/docs/Brd/stories/sprint-01-auth-user-services/US033-create-account.md create mode 100644 backend/docs/Brd/stories/sprint-01-auth-user-services/US034-login.md create mode 100644 backend/docs/Brd/stories/sprint-01-auth-user-services/US035-password-recovery.md create mode 100644 backend/docs/Brd/stories/sprint-01-auth-user-services/US036-logout.md create mode 100644 backend/docs/Brd/stories/sprint-02-core-content-viewing/US001-view-homepage.md create mode 100644 backend/docs/Brd/stories/sprint-02-core-content-viewing/US002-view-about-platform.md create mode 100644 backend/docs/Brd/stories/sprint-02-core-content-viewing/US003-view-resources.md create mode 100644 backend/docs/Brd/stories/sprint-02-core-content-viewing/US004-download-resources.md create mode 100644 backend/docs/Brd/stories/sprint-02-core-content-viewing/US005-share-resources.md create mode 100644 backend/docs/Brd/stories/sprint-03-knowledge-maps-interactive-city/US006-view-knowledge-maps.md create mode 100644 backend/docs/Brd/stories/sprint-03-knowledge-maps-interactive-city/US007-interact-knowledge-maps.md create mode 100644 backend/docs/Brd/stories/sprint-03-knowledge-maps-interactive-city/US008-view-interactive-city.md create mode 100644 backend/docs/Brd/stories/sprint-03-knowledge-maps-interactive-city/US009-interact-interactive-city.md create mode 100644 backend/docs/Brd/stories/sprint-04-news-events/US010-view-news-events.md create mode 100644 backend/docs/Brd/stories/sprint-04-news-events/US011-share-news-events.md create mode 100644 backend/docs/Brd/stories/sprint-04-news-events/US012-follow-news-page.md create mode 100644 backend/docs/Brd/stories/sprint-04-news-events/US013-add-event-calendar.md create mode 100644 backend/docs/Brd/stories/sprint-05-profiles-policies/US014-view-state-profile.md create mode 100644 backend/docs/Brd/stories/sprint-05-profiles-policies/US015-view-user-profile.md create mode 100644 backend/docs/Brd/stories/sprint-05-profiles-policies/US016-edit-user-profile.md create mode 100644 backend/docs/Brd/stories/sprint-05-profiles-policies/US032-view-policies-terms.md create mode 100644 backend/docs/Brd/stories/sprint-06-expert-registration-assessment-suggestions/US017-register-expert.md create mode 100644 backend/docs/Brd/stories/sprint-06-expert-registration-assessment-suggestions/US018-evaluate-services.md create mode 100644 backend/docs/Brd/stories/sprint-06-expert-registration-assessment-suggestions/US019-personalized-suggestions.md create mode 100644 backend/docs/Brd/stories/sprint-07-ai-search/US020-ai-assistant-search.md create mode 100644 backend/docs/Brd/stories/sprint-08-knowledge-community-core/US021-view-community.md create mode 100644 backend/docs/Brd/stories/sprint-08-knowledge-community-core/US022-view-topic-groups.md create mode 100644 backend/docs/Brd/stories/sprint-08-knowledge-community-core/US023-follow-topic.md create mode 100644 backend/docs/Brd/stories/sprint-09-knowledge-community-posts/US024-view-post.md create mode 100644 backend/docs/Brd/stories/sprint-09-knowledge-community-posts/US025-share-post.md create mode 100644 backend/docs/Brd/stories/sprint-09-knowledge-community-posts/US026-create-post.md create mode 100644 backend/docs/Brd/stories/sprint-09-knowledge-community-posts/US027-interact-post.md create mode 100644 backend/docs/Brd/stories/sprint-09-knowledge-community-posts/US028-follow-post.md create mode 100644 backend/docs/Brd/stories/sprint-09-knowledge-community-posts/US029-reply-post.md create mode 100644 backend/docs/Brd/stories/sprint-10-knowledge-community-users/US030-view-user-profile-community.md create mode 100644 backend/docs/Brd/stories/sprint-10-knowledge-community-users/US031-follow-user.md create mode 100644 backend/docs/Brd/stories/sprint-11-admin-content-management/US037-update-homepage.md create mode 100644 backend/docs/Brd/stories/sprint-11-admin-content-management/US038-update-about-platform.md create mode 100644 backend/docs/Brd/stories/sprint-11-admin-content-management/US039-update-policies.md create mode 100644 backend/docs/Brd/stories/sprint-11-admin-content-management/US061-admin-login.md create mode 100644 backend/docs/Brd/stories/sprint-11-admin-content-management/US062-admin-password-recovery.md create mode 100644 backend/docs/Brd/stories/sprint-11-admin-content-management/US063-admin-logout.md create mode 100644 backend/docs/Brd/stories/sprint-12-admin-user-management/US040-view-users.md create mode 100644 backend/docs/Brd/stories/sprint-12-admin-user-management/US041-create-user.md create mode 100644 backend/docs/Brd/stories/sprint-12-admin-user-management/US042-delete-user.md create mode 100644 backend/docs/Brd/stories/sprint-13-admin-news-events-resources/US043-view-news-events-admin.md create mode 100644 backend/docs/Brd/stories/sprint-13-admin-news-events-resources/US044-upload-news-events.md create mode 100644 backend/docs/Brd/stories/sprint-13-admin-news-events-resources/US045-delete-news-events.md create mode 100644 backend/docs/Brd/stories/sprint-13-admin-news-events-resources/US046-view-resources-admin.md create mode 100644 backend/docs/Brd/stories/sprint-13-admin-news-events-resources/US047-upload-resources.md create mode 100644 backend/docs/Brd/stories/sprint-13-admin-news-events-resources/US048-delete-resources.md create mode 100644 backend/docs/Brd/stories/sprint-14-admin-country-requests-community/US049-view-country-requests.md create mode 100644 backend/docs/Brd/stories/sprint-14-admin-country-requests-community/US050-process-country-request.md create mode 100644 backend/docs/Brd/stories/sprint-14-admin-country-requests-community/US054-view-community-admin.md create mode 100644 backend/docs/Brd/stories/sprint-14-admin-country-requests-community/US055-view-topic-groups-admin.md create mode 100644 backend/docs/Brd/stories/sprint-14-admin-country-requests-community/US056-view-post-admin.md create mode 100644 backend/docs/Brd/stories/sprint-14-admin-country-requests-community/US057-delete-post.md create mode 100644 backend/docs/Brd/stories/sprint-14-admin-country-requests-community/US058-view-expert-requests.md create mode 100644 backend/docs/Brd/stories/sprint-14-admin-country-requests-community/US059-process-expert-requests.md create mode 100644 backend/docs/Brd/stories/sprint-15-state-representative/US051-view-resource-requests-state.md create mode 100644 backend/docs/Brd/stories/sprint-15-state-representative/US052-upload-resources-state.md create mode 100644 backend/docs/Brd/stories/sprint-15-state-representative/US053-upload-news-events-state.md create mode 100644 backend/docs/Brd/stories/sprint-15-state-representative/US060-view-state-profile-state.md create mode 100644 backend/docs/Brd/stories/sprint-15-state-representative/US061-update-state-profile.md create mode 100644 "backend/docs/Brd/\331\210\330\253\331\212\331\202\330\251_\331\205\330\252\330\267\331\204\330\250\330\247\330\252_\330\247\331\204\330\243\330\271\331\205\330\247\331\204_V_4_0.md" create mode 100644 backend/docs/plans/application-layer-feature-slices-plan.md create mode 100644 backend/docs/plans/error-codes-implementation-plan.md create mode 100644 backend/docs/plans/localization-implementation-plan.md create mode 100644 backend/docs/plans/read-write-architecture-implementation-plan.md create mode 100644 backend/docs/plans/refit-implementation-plan.md create mode 100644 backend/docs/plans/result-pattern-unified-errors-implementation-plan.md create mode 100644 backend/docs/plans/scalar-swagger-dotnet10-implementation-plan.md create mode 100644 backend/docs/plans/sprint-01-auth-user-services-implementation-plan.md create mode 100644 backend/docs/plans/unit-of-work-implementation-plan.md create mode 100644 backend/docs/plans/whereif-and-paged-dto-list-implementation-plan.md create mode 100644 backend/src/CCE.Api.Common/Extensions/ResultExtensions.cs create mode 100644 backend/src/CCE.Api.Common/Localization/Resources.yaml create mode 100644 backend/src/CCE.Application/Common/Behaviors/ResultValidationBehavior.cs create mode 100644 backend/src/CCE.Application/Common/Errors.cs create mode 100644 backend/src/CCE.Application/Common/Pagination/QueryableExtensions.cs create mode 100644 backend/src/CCE.Application/Common/Result.cs create mode 100644 backend/src/CCE.Application/Errors/ApplicationErrors.cs create mode 100644 backend/src/CCE.Application/Localization/ILocalizationService.cs create mode 100644 backend/src/CCE.Application/Localization/LocalizedMessage.cs create mode 100644 backend/src/CCE.Domain/Common/AuditableAggregateRoot.cs create mode 100644 backend/src/CCE.Domain/Common/AuditableEntity.cs create mode 100644 backend/src/CCE.Domain/Common/Error.cs create mode 100644 backend/src/CCE.Domain/Common/IAuditable.cs create mode 100644 backend/src/CCE.Domain/Common/SoftDeletableAggregateRoot.cs create mode 100644 backend/src/CCE.Domain/Common/SoftDeletableEntity.cs delete mode 100644 backend/src/CCE.Domain/Common/ValueObject.cs create mode 100644 backend/src/CCE.Infrastructure/Localization/LocalizationService.cs create mode 100644 backend/src/CCE.Infrastructure/Localization/YamlLocalizationStore.cs diff --git a/backend/docs/Brd/stories/_appendix.md b/backend/docs/Brd/stories/_appendix.md new file mode 100644 index 00000000..746452d5 --- /dev/null +++ b/backend/docs/Brd/stories/_appendix.md @@ -0,0 +1,127 @@ +# CCE Knowledge Center - BRD Appendix + +## Error Codes & Messages + +| Code | Type | Arabic Message | Context / Trigger | +|------|------|---------------|-------------------| +| ERR001 | Error | حدث خطأ أثناء تحميل الصفحة. | Generic page load error | +| ERR002 | Error | حدث خطأ أثناء محاولة تحميل المصدر. يرجى المحاولة مرة أخرى. | Resource download failure | +| ERR003 | Error | حدث خطأ أثناء محاولة مشاركة المصدر. يرجى المحاولة مرة أخرى لاحقاً. | Resource share failure | +| ERR004 | Error | حدث خطأ أثناء محاولة المشاركة. يرجى المحاولة مرة أخرى لاحقاً. | Generic share failure | +| ERR005 | Error | حدث خطأ أثناء محاولة متابعة الخبر. يرجى المحاولة مرة أخرى لاحقاً. | News follow failure | +| ERR006 | Error | حدث خطأ أثناء محاولة إضافة الفعالية إلى التقويم. يرجى المحاولة مرة أخرى لاحقاً. | Calendar add failure | +| ERR007 | Error | حدث خطأ أثناء محاولة تحديث بيانات الملف الشخصي. يرجى التأكد من أن البيانات المدخلة صحيحة، مثل تنسيق البريد الإلكتروني أو رقم الهاتف. | Profile update validation error | +| ERR008 | Error | حدث خطأ أثناء تقديم طلبك. يرجى التأكد من صحة البيانات المدخلة. | Expert registration submission error | +| ERR009 | Error | حدث خطأ أثناء محاولة إرسال تقييمك. يرجى المحاولة مرة أخرى. | Service evaluation submission error | +| ERR010 | Error | حدث خطأ أثناء محاولة إرسال بياناتك. يرجى المحاولة مرة أخرى. | Personalized suggestions submission error | +| ERR011 | Error | عذراً، حدثت مشكلة في تحميل المساعد الذكي. | AI assistant loading error | +| ERR012 | Error | عذراً، لا يمكن متابعة الموضوع حالياً. | Topic follow failure | +| ERR013 | Error | عذراً، الحقول الإجبارية غير مكتملة. | Required fields empty | +| ERR014 | Error | عذراً، حدثت مشكلة أثناء نشر المنشور. | Post publish failure | +| ERR015 | Error | عذراً، لا يمكن متابعة المنشور حالياً. | Post follow failure | +| ERR016 | Error | عذراً، لا يمكن إرسال رد فارغ. | Empty reply submission | +| ERR017 | Error | عذراً، حدثت مشكلة أثناء إرسال الرد. | Reply submission failure | +| ERR018 | Error | عذراً، لا يمكن متابعة المستخدم حالياً. | User follow failure | +| ERR019 | Error | عذراً، حدثت مشكلة أثناء إنشاء الحساب. | Account creation failure | +| ERR020 | Error | عذراً، البيانات المدخلة غير صحيحة. | Invalid login credentials | +| ERR021 | Error | عذراً، حدثت مشكلة أثناء تسجيل الدخول. | Login system error | +| ERR022 | Error | عذراً، لم يتم العثور على الحساب المرتبط بالبريد الإلكتروني. | Email not found in password recovery | +| ERR023 | Error | عذراً، حدثت مشكلة أثناء استعادة كلمة المرور. | Password recovery system error | +| ERR024 | Error | حدث خطأ أثناء محاولة تسجيل الخروج. | Logout failure | +| ERR025 | Error | عذراً، حدثت مشكلة أثناء تحديث المحتوى. | Content update failure | +| ERR026 | Error | عذراً، حدثت مشكلة أثناء حذف المستخدم. | User deletion failure | +| ERR027 | Error | عذراً، حدثت مشكلة أثناء رفع الخبر/الفعالية. | News/event upload failure | +| ERR028 | Error | عذراً، حدثت مشكلة أثناء حذف الخبر/الفعالية. | News/event deletion failure | +| ERR029 | Error | عذراً، حدثت مشكلة أثناء رفع المصدر. | Resource upload failure | +| ERR030 | Error | عذراً، حدثت مشكلة أثناء حذف المصدر. | Resource deletion failure | +| ERR031 | Error | عذراً، حدثت مشكلة أثناء معالجة الطلب. | Request processing failure | +| ERR032 | Error | عذراً، حدثت مشكلة أثناء حذف المنشور. | Post deletion failure | +| ERR033 | Error | عذراً، حدثت مشكلة أثناء تحديث البيانات. | State profile update failure | + +## Confirmation Messages + +| Code | Arabic Message | Context | +|------|---------------|---------| +| CON001 | تم تحميل المصدر بنجاح! يمكنك الآن الوصول إلى المرفق من جهازك. | Resource download success | +| CON002 | تمت مشاركة المصدر بنجاح! | Resource share success | +| CON003 | تمت المشاركة بنجاح! | Generic share success (news/events/posts) | +| CON004 | تم إضافة الفعالية إلى تقويمك الشخصي بنجاح. يمكنك الآن الاطلاع عليها في أي وقت من خلال التقويم لمتابعة التفاصيل والمواعيد. | Event added to calendar | +| CON005 | تم تحديث بيانات الملف الشخصي بنجاح. يمكنك الآن الاطلاع على المعلومات المحدثة في ملفك الشخصي. | Profile update success | +| CON006 | تم تقديم طلبك بنجاح لتسجيلك كخبير في مجتمع المعرفة. سيتم مراجعة طلبك قريباً. | Expert registration request submitted | +| CON007 | تم إرسال طلب تسجيل جديد كخبير في مجتمع المعرفة. يرجى مراجعة الطلب واتخاذ الإجراءات اللازمة. | Admin notified of expert request | +| CON008 | تم إرسال تقييمك بنجاح. نشكرك على مشاركتك في تحسين خدماتنا. | Service evaluation submitted | +| CON009 | تم إرسال بياناتك بنجاح! سيتم تخصيص المقترحات لتتناسب مع اهتماماتك واحتياجاتك. | Personalized suggestions submitted | +| CON010 | تم حفظ بياناتك بنجاح. س تتلقى إشعارات أو تحديثات حول المنشورات الجديدة المتعلقة بالموضوع الذي اخترته. | Topic follow success | +| CON011 | تم إنشاء المنشور بنجاح! | Post created | +| CON012 | تم حفظ بياناتك بنجاح. س تتلقى إشعارات أو تحديثات حول المنشور. | Post follow success | +| CON013 | تم إرسال الرد بنجاح! | Reply submitted | +| CON014 | تمت استعادة كلمة المرور بنجاح! | Password recovery success | +| CON015 | تم تسجيل الخروج بنجاح. | Logout success | +| CON016 | تمت عملية التحديث بنجاح. | Content update success | +| CON017 | تم إنشاء المستخدم بنجاح! | User creation success | +| CON018 | تم حذف المستخدم بنجاح! | User deletion success | +| CON019 | تم رفع الخبر/الفعالية بنجاح! | News/event upload success | +| CON020 | تم حذف الخبر/الفعالية بنجاح! | News/event deletion success | +| CON021 | تم رفع المصدر بنجاح! | Resource upload success | +| CON022 | تم حذف المصدر بنجاح! | Resource deletion success | +| CON023 | تمت معالجة الطلب بنجاح! | Request processed | +| CON024 | تم إرسال طلبك بنجاح. سيتم مراجعته من قبل المشرف قريباً. شكراً لمساهمتك! | State rep request submitted | +| CON025 | تم حذف المنشور بنجاح! | Post deletion success | +| CON026 | تم تحديث الملف التعريفي للدولة بنجاح! | State profile update success | + +## Informational Messages + +| Code | Type | Arabic Message | Context | +|------|------|---------------|---------| +| INF001 | Informational | لا توجد مصادر أو أخبار متاحة لهذا الموضوع في الوقت الحالي. يمكنك البحث عن موضوع آخر أو العودة إلى الصفحة الرئيسية. | No related content for knowledge map topic | +| INF002 | Informational | عذراً، لم نتمكن من العثور على نتائج دقيقة بناءً على الاستفسار الذي قمت بتقديمه، ربما يساعد تعديل السؤال أو طرحه بطريقة مختلفة في الوصول إلى الإجابة المثالية. | AI search no accurate results | +| INF003 | Informational | عذراً، لا توجد أخبار أو فعاليات حالياً. | No news/events available (admin view) | +| INF004 | Informational | عذراً، لا توجد مصادر حالياً. | No resources available (admin view) | +| INF005 | Informational | عذراً، لا توجد طلبات متاحة حالياً. | No requests available | +| NTF001 | Notification | عذراً، لا توجد منشورات حالياً. | No posts available | + +## Notification / Email Messages + +| Code | Type | Title | Arabic Body | +|------|------|-------|-------------| +| MSG001 | Email | طلب تسجيل كخبير | عزيزي المشرف، تم تقديم طلب تسجيل جديد من قبل المستخدم [اسم المستخدم] ليتم تسجيله كخبير في مجتمع المعرفة. يرجى مراجعة البيانات المدخلة بعناية واتخاذ الإجراءات المناسبة. | +| MSG002 | Email | طلب رفع مصادر | عزيزي/عزيزتي [اسم الممثل]، نود إبلاغكم أنه تم اتخاذ إجراء على الطلب المرفوع من قبل دولتكم. يُمكنكم الآن الاطلاع على حالة الطلب في قسم "الطلبات" لمعرفة المزيد من التفاصيل حول حالته. نشكركم على تعاونكم المستمر، وإذا كان لديكم أي استفسار أو بحاجة إلى مزيد من المساعدة، لا تترددوا في التواصل معنا. مع خالص الشكر والتقدير، [اسم المنظمة/الفريق] [بيانات الاتصال] | +| MSG003 | Email | طلب رفع مصدر | عزيزي المشرف، تم تقديم طلب رفع مصدر جديد من قبل ممثل الدولة [اسم الممثل]. يرجى مراجعة البيانات المدخلة بعناية واتخاذ الإجراءات المناسبة. | +| MSG004 | Email | تم حذف منشورك من قبل المنصة | عزيزي/عزيزتي [اسم المستخدم]، نود إبلاغك أنه تم حذف المنشور الذي قمت بنشره في مجتمع المعرفة. إذا كان لديك أي استفسار أو بحاجة إلى المساعدة، يُرجى التواصل معنا. مع خالص الشكر والتقدير، [اسم المنظمة/الفريق] [بيانات الاتصال] | +| MSG005 | Email | طلب التسجيل كخبير | عزيزي/عزيزتي [اسم المستخدم]، نود إبلاغكم أنه تم اتخاذ إجراء على الطلب للتسجيل كخبير المرفوع من قبلكم. يُمكنكم الآن الاطلاع على حالة الطلب في قسم "الطلبات" لمعرفة المزيد من التفاصيل حول حالته. نشكركم على تعاونكم المستمر، وإذا كان لديكم أي استفسار أو بحاجة إلى مزيد من المساعدة، لا تترددوا في التواصل معنا. مع خالص الشكر والتقدير، [اسم المنظمة/الفريق] [بيانات الاتصال] | + +## KAPSARC Integration Service (US014) + +| Attribute | Value | +|-----------|-------| +| Service Name | CCE Classification Verification | +| Purpose | Verify CCE classification and performance of countries | +| Operation Type | Data Retrieval | +| Source | KAPSARC (Saudi Energy Efficiency Center) | +| BC001 | CCE classification/performance data retrieved from KAPSARC when state selected | +| Error | ERR001 when KAPSARC data unavailable | + +**Input Fields:** + +| Field | Required | Length | Validation | +|-------|----------|--------|------------| +| Country Name | Yes | 50 | Must be valid country in system | +| Country Code | Yes | 3 | Must be valid country code | + +**Output Fields:** + +| Field | Required | Type | +|-------|----------|------| +| CCE Classification | Yes | Text (50) | +| CCE Performance | Yes | Text (50) | +| CCE Total Index | Yes | Decimal | + +## Non-Functional Requirements + +| ID | Requirement | +|----|------------| +| NF001 | Web pages must load in less than 3 seconds | +| NF002 | Optimize media/images using modern formats without affecting quality | +| NF003 | Minimize file sizes and use lazy loading for page elements | +| NF004 | Design user-friendly and responsive interface for all devices (mobile, tablet, desktop) | +| NF005 | System must be available 24/7 without downtime for core functions | diff --git a/backend/docs/Brd/stories/sprint-01-auth-user-services/US033-create-account.md b/backend/docs/Brd/stories/sprint-01-auth-user-services/US033-create-account.md new file mode 100644 index 00000000..d27053b7 --- /dev/null +++ b/backend/docs/Brd/stories/sprint-01-auth-user-services/US033-create-account.md @@ -0,0 +1,68 @@ +# US033 - إنشاء حساب + +## Epic +Auth & User Services + +## Feature Code +F033 + +## Sprint +Sprint 01: Auth & User Services + +## Priority +High + +## User Story +**As a** مستخدم جديد، **I want to** إنشاء حساب على المنصة، **so that** أتمكن من الوصول إلى جميع الميزات والخدمات المتاحة. + +## Roles +| Role | Access | +|------|--------| +| Visitor | Can | + +## Preconditions +- User must not be previously registered + +## Acceptance Criteria +1. User navigates to the platform homepage +2. User clicks "Create Account" +3. User fills in the registration form with: First Name, Last Name, Email, Job Title, Organization Name, Phone, Password, Confirm Password +4. User clicks "Create Account" +5. System validates all input data (BC001) +6. If required fields are missing, system displays error ERR013 +7. If a system error occurs, system displays error ERR019 +8. Upon successful validation, system creates the account +9. System redirects user to the login page + +## Post-conditions +- User can login with new credentials + +## Alternative Flows +- ALT001: If required fields are not filled, system displays ERR013 requesting the user to fill required data + +## Business Rules +- BC001: Validate all input data before creating the account + +## Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR001 | Error | حدث خطأ أثناء تحميل الصفحة. | Page load error | +| ERR013 | Error | عذراً، الحقول الإجبارية غير مكتملة. | Required fields empty | +| ERR019 | Error | عذراً، حدثت مشكلة أثناء إنشاء الحساب. | Account creation failure | + +## Confirmation Messages +| Code | Message (AR) | +|------|-------------| +| CON017 | تم إنشاء المستخدم بنجاح! | + +## Form Fields & Validation Rules +| Field | Type | Required | Max Length | Validation | +|-------|------|----------|------------|------------| +| First Name (FirstName) | Free Text | Yes | 50 | Must contain letters only | +| Last Name (LastName) | Free Text | Yes | 50 | Must contain letters only | +| Email Address (EmailAddress) | Free Text | Yes | 100 | Must be a valid email | +| Job Title (JobTitle) | Free Text | Yes | 50 | - | +| Organization Name (OrganizationName) | Free Text | Yes | 100 | - | +| Phone Number (PhoneNumber) | Numbers | Yes | 15 | - | +| Password (Password) | Free Text | Yes | 12-20 | Must contain mix of uppercase, lowercase, and numbers | +| Confirm Password (ConfirmPassword) | Free Text | Yes | 12-20 | Must match Password field | diff --git a/backend/docs/Brd/stories/sprint-01-auth-user-services/US034-login.md b/backend/docs/Brd/stories/sprint-01-auth-user-services/US034-login.md new file mode 100644 index 00000000..53f8cbda --- /dev/null +++ b/backend/docs/Brd/stories/sprint-01-auth-user-services/US034-login.md @@ -0,0 +1,56 @@ +# US034 - تسجيل الدخول + +## Epic +Auth & User Services + +## Feature Code +F034 + +## Sprint +Sprint 01: Auth & User Services + +## Priority +High + +## User Story +**As a** مستخدم مسجل، **I want to** تسجيل الدخول إلى المنصة باستخدام بياناتي، **so that** أتمكن من الوصول إلى جميع الميزات والخدمات المتاحة. + +## Roles +| Role | Access | +|------|--------| +| User (Registered) | Can | + +## Preconditions +- User must be registered with valid account + +## Acceptance Criteria +1. User navigates to the platform homepage +2. User clicks "Login" +3. User fills in the login form with: Email, Password +4. User clicks "Login" +5. System validates email and password (BC001) +6. If credentials are invalid, system displays error ERR020 +7. If a system error occurs, system displays error ERR021 +8. Upon successful validation, system redirects user to the homepage + +## Post-conditions +- User can access all features available to their role + +## Alternative Flows +- ALT001: If user enters incorrect data, system displays ERR020 and requests retry + +## Business Rules +- BC001: Validate email and password before allowing login + +## Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR001 | Error | حدث خطأ أثناء تحميل الصفحة. | Page load error | +| ERR020 | Error | عذراً، البيانات المدخلة غير صحيحة. | Invalid credentials | +| ERR021 | Error | عذراً، حدثت مشكلة أثناء تسجيل الدخول. | Login system error | + +## Form Fields & Validation Rules +| Field | Type | Required | Max Length | Validation | +|-------|------|----------|------------|------------| +| Email Address (EmailAddress) | Free Text | Yes | 100 | Must be a valid email | +| Password (Password) | Free Text | Yes | 12-20 | Must contain mix of uppercase, lowercase, and numbers; must match registered email | diff --git a/backend/docs/Brd/stories/sprint-01-auth-user-services/US035-password-recovery.md b/backend/docs/Brd/stories/sprint-01-auth-user-services/US035-password-recovery.md new file mode 100644 index 00000000..6124e681 --- /dev/null +++ b/backend/docs/Brd/stories/sprint-01-auth-user-services/US035-password-recovery.md @@ -0,0 +1,63 @@ +# US035 - استعادة كلمة المرور + +## Epic +Auth & User Services + +## Feature Code +F035 + +## Sprint +Sprint 01: Auth & User Services + +## Priority +High + +## User Story +**As a** مستخدم مسجل، **I want to** استعادة كلمة المرور الخاصة بي، **so that** أتمكن من الدخول إلى حسابي إذا نسيت كلمة المرور. + +## Roles +| Role | Access | +|------|--------| +| User (Registered) | Can | + +## Preconditions +- User must be registered with valid account + +## Acceptance Criteria +1. User navigates to the platform homepage +2. User clicks "Login" +3. User clicks "Forgot Password?" +4. User enters their email address +5. System validates that the email is registered (BC001) +6. If email is not found, system displays error ERR022 +7. If a system error occurs, system displays error ERR023 +8. System sends a password reset link via email +9. User clicks the reset link +10. User enters new password and confirms the password +11. System updates the password and displays confirmation CON014 + +## Post-conditions +- User can login with new password + +## Alternative Flows +- ALT001: If email not found in system, system displays ERR022 + +## Business Rules +- BC001: Email must be registered in the system for password recovery + +## Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR001 | Error | حدث خطأ أثناء تحميل الصفحة. | Page load error | +| ERR022 | Error | عذراً، لم يتم العثور على الحساب المرتبط بالبريد الإلكتروني. | Email not found | +| ERR023 | Error | عذراً، حدثت مشكلة أثناء استعادة كلمة المرور. | Password recovery system error | + +## Confirmation Messages +| Code | Message (AR) | +|------|-------------| +| CON014 | تمت استعادة كلمة المرور بنجاح! | + +## Form Fields & Validation Rules +| Field | Type | Required | Max Length | Validation | +|-------|------|----------|------------|------------| +| Email Address (EmailAddress) | Free Text | Yes | 100 | Must be a valid email | diff --git a/backend/docs/Brd/stories/sprint-01-auth-user-services/US036-logout.md b/backend/docs/Brd/stories/sprint-01-auth-user-services/US036-logout.md new file mode 100644 index 00000000..65c02570 --- /dev/null +++ b/backend/docs/Brd/stories/sprint-01-auth-user-services/US036-logout.md @@ -0,0 +1,52 @@ +# US036 - تسجيل الخروج + +## Epic +Auth & User Services + +## Feature Code +F036 + +## Sprint +Sprint 01: Auth & User Services + +## Priority +High + +## User Story +**As a** مستخدم مسجل، **I want to** تسجيل الخروج من المنصة، **so that** أتمكن من إنهاء جلستي بشكل آمن. + +## Roles +| Role | Access | +|------|--------| +| User (Registered) | Can | + +## Preconditions +- User must be logged in + +## Acceptance Criteria +1. User clicks the profile icon +2. User clicks "Logout" +3. System properly terminates the session (BC001) +4. System displays confirmation CON015 +5. If a logout error occurs, system displays error ERR024 +6. System redirects user to the homepage/login page + +## Post-conditions +- User redirected to login page or homepage + +## Alternative Flows +- ALT001: If logout error occurs, system displays ERR024 and allows retry + +## Business Rules +- BC001: System must properly terminate session on logout + +## Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR001 | Error | حدث خطأ أثناء تحميل الصفحة. | Page load error | +| ERR024 | Error | حدث خطأ أثناء محاولة تسجيل الخروج. | Logout failure | + +## Confirmation Messages +| Code | Message (AR) | +|------|-------------| +| CON015 | تم تسجيل الخروج بنجاح. | diff --git a/backend/docs/Brd/stories/sprint-02-core-content-viewing/US001-view-homepage.md b/backend/docs/Brd/stories/sprint-02-core-content-viewing/US001-view-homepage.md new file mode 100644 index 00000000..5173a35e --- /dev/null +++ b/backend/docs/Brd/stories/sprint-02-core-content-viewing/US001-view-homepage.md @@ -0,0 +1,46 @@ +# US001 - استعراض الصفحة الرئيسية + +## Epic +Core Content Viewing + +## Feature Code +F001 + +## Sprint +Sprint 02: Core Content Viewing + +## Priority +High + +## User Story +**As a** مستخدم للمنصة، **I want to** استعراض الصفحة الرئيسية للمنصة، **so that** أتمكن من الحصول على المعلومات الأساسية عن المنصة، مثل الأهداف والدول المشاركة والروابط السريعة. + +## Roles +| Role | Access | +|------|--------| +| Visitor | Can | +| Registered User | Can | + +## Preconditions +- User must be logged in if they want to customize or access user-specific services + +## Acceptance Criteria +1. User enters the platform via web browser +2. System displays the homepage with data from the homepage content update model +3. Homepage includes links to important sections (Resources, News, Events, Knowledge Community) (BC001) +4. If there is no internet connection, system displays error ERR001 +5. If a page load error occurs, system displays error ERR001 + +## Post-conditions +- User navigates to different sections of the platform + +## Alternative Flows +- ALT001: If no internet, system displays ERR001 page load error and redirects to homepage after retry + +## Business Rules +- BC001: Homepage must contain links to important sections (Resources, News, Events, Knowledge Community) + +## Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR001 | Error | حدث خطأ أثناء تحميل الصفحة. | Page load error | diff --git a/backend/docs/Brd/stories/sprint-02-core-content-viewing/US002-view-about-platform.md b/backend/docs/Brd/stories/sprint-02-core-content-viewing/US002-view-about-platform.md new file mode 100644 index 00000000..2bef9224 --- /dev/null +++ b/backend/docs/Brd/stories/sprint-02-core-content-viewing/US002-view-about-platform.md @@ -0,0 +1,48 @@ +# US002 - استعراض تعرف على المنصة + +## Epic +Core Content Viewing + +## Feature Code +F002 + +## Sprint +Sprint 02: Core Content Viewing + +## Priority +Medium + +## User Story +**As a** مستخدم للمنصة، **I want to** استعراض قسم "تعرف على المنصة"، **so that** أتمكن من الحصول على لمحة شاملة عن المنصة وخصائصها. + +## Roles +| Role | Access | +|------|--------| +| Visitor | Can | +| Registered User | Can | + +## Preconditions +- None + +## Acceptance Criteria +1. User enters the platform +2. User navigates to the homepage +3. User selects the "About Platform" tab +4. System displays the about platform page with data from the update model +5. Page contains a comprehensive description of the platform and its objectives (BC001) +6. If there is no internet connection, system displays error ERR001 +7. If a load error occurs, system displays error ERR001 + +## Post-conditions +- User navigates to other sections + +## Alternative Flows +- ALT001: If no internet, system displays ERR001 and redirects after retry + +## Business Rules +- BC001: "About Platform" section must contain a comprehensive description of the platform and its objectives + +## Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR001 | Error | حدث خطأ أثناء تحميل الصفحة. | Page load error | diff --git a/backend/docs/Brd/stories/sprint-02-core-content-viewing/US003-view-resources.md b/backend/docs/Brd/stories/sprint-02-core-content-viewing/US003-view-resources.md new file mode 100644 index 00000000..dd86798d --- /dev/null +++ b/backend/docs/Brd/stories/sprint-02-core-content-viewing/US003-view-resources.md @@ -0,0 +1,51 @@ +# US003 - استعراض المصادر + +## Epic +Core Content Viewing + +## Feature Code +F003 + +## Sprint +Sprint 02: Core Content Viewing + +## Priority +High + +## User Story +**As a** مستخدم للمنصة، **I want to** استعراض المصادر المتاحة على المنصة، **so that** أتمكن من الاطلاع على محتوى المصادر ذات الصلة بالاقتصاد الدائري للكربون. + +## Roles +| Role | Access | +|------|--------| +| Visitor | Can | +| Registered User | Can | + +## Preconditions +- None + +## Acceptance Criteria +1. User enters the platform and navigates to the homepage +2. User clicks "Resources" +3. System displays a list of all resources showing: Title, Date, Topic, Description, Publication Type, Covered Countries, File +4. User can search and filter resources +5. User selects a resource +6. System displays resource details in view-only mode with full details including title, topic, date, and attachments (BC001) +7. If there is no internet connection, system displays error ERR001 +8. If no resources are found, system displays ALT002 +9. If a load error occurs, system displays error ERR001 + +## Post-conditions +- User can download, share, or return to search + +## Alternative Flows +- ALT001: If no internet, system displays ERR001 and redirects after retry +- ALT002: If no resources found matching search, system displays message that no resources currently exist and suggests new search + +## Business Rules +- BC001: Display full details for each resource including title, topic, date, and attachments + +## Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR001 | Error | حدث خطأ أثناء تحميل الصفحة. | Page load error | diff --git a/backend/docs/Brd/stories/sprint-02-core-content-viewing/US004-download-resources.md b/backend/docs/Brd/stories/sprint-02-core-content-viewing/US004-download-resources.md new file mode 100644 index 00000000..61f065fa --- /dev/null +++ b/backend/docs/Brd/stories/sprint-02-core-content-viewing/US004-download-resources.md @@ -0,0 +1,52 @@ +# US004 - تحميل المصادر + +## Epic +Core Content Viewing + +## Feature Code +F004 + +## Sprint +Sprint 02: Core Content Viewing + +## Priority +Medium + +## User Story +**As a** مستخدم للمنصة، **I want to** تحميل المصادر المتاحة على المنصة، **so that** أتمكن من الاطلاع عليها لاحقا أو استخدامها. + +## Roles +| Role | Access | +|------|--------| +| Visitor | Can | +| Registered User | Can | + +## Preconditions +- Resource must be available for download + +## Acceptance Criteria +1. User navigates to resource details +2. User clicks "Download Resource" +3. System downloads the file and displays confirmation CON001 +4. System displays full details for each resource (BC001) +5. If the download fails, system displays ALT001 or error ERR002 + +## Post-conditions +- User can share resource or return to search + +## Alternative Flows +- ALT001: If download problem occurs, system displays error and offers retry or alternative link + +## Business Rules +- BC001: Display full details for each resource including title, topic, date, and attachments + +## Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR001 | Error | حدث خطأ أثناء تحميل الصفحة. | Page load error | +| ERR002 | Error | حدث خطأ أثناء محاولة تحميل المصدر. يرجى المحاولة مرة أخرى. | Resource download failure | + +## Confirmation Messages +| Code | Message (AR) | +|------|-------------| +| CON001 | تم تحميل المصدر بنجاح! يمكنك الآن الوصول إلى المرفق من جهازك. | diff --git a/backend/docs/Brd/stories/sprint-02-core-content-viewing/US005-share-resources.md b/backend/docs/Brd/stories/sprint-02-core-content-viewing/US005-share-resources.md new file mode 100644 index 00000000..ccfe8d3e --- /dev/null +++ b/backend/docs/Brd/stories/sprint-02-core-content-viewing/US005-share-resources.md @@ -0,0 +1,56 @@ +# US005 - مشاركة المصادر + +## Epic +Core Content Viewing + +## Feature Code +F005 + +## Sprint +Sprint 02: Core Content Viewing + +## Priority +Medium + +## User Story +**As a** مستخدم للمنصة، **I want to** مشاركة المصدر مع الآخرين عبر المنصة، **so that** يتمكنوا من الاطلاع عليه واستخدامه. + +## Roles +| Role | Access | +|------|--------| +| Visitor | Can | +| Registered User | Can | + +## Preconditions +- Resource must be available for sharing + +## Acceptance Criteria +1. User navigates to resource details +2. User clicks "Share Resource" +3. System displays sharing options (email, link) +4. User selects a sharing method +5. System shares the resource and displays confirmation CON002 +6. System displays full resource details (BC001) +7. If no resource is available, system displays error ERR003 +8. If sharing fails, system displays error ERR004 + +## Post-conditions +- Resource shared successfully via link or email + +## Alternative Flows +- ALT001: If no resource available for sharing, system displays ERR003 and redirects to resources page + +## Business Rules +- BC001: Display full details for each resource + +## Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR001 | Error | حدث خطأ أثناء تحميل الصفحة. | Page load error | +| ERR003 | Error | حدث خطأ أثناء محاولة مشاركة المصدر. يرجى المحاولة مرة أخرى لاحقاً. | No resource for sharing | +| ERR004 | Error | حدث خطأ أثناء محاولة المشاركة. يرجى المحاولة مرة أخرى لاحقاً. | Share failure | + +## Confirmation Messages +| Code | Message (AR) | +|------|-------------| +| CON002 | تمت مشاركة المصدر بنجاح! | diff --git a/backend/docs/Brd/stories/sprint-03-knowledge-maps-interactive-city/US006-view-knowledge-maps.md b/backend/docs/Brd/stories/sprint-03-knowledge-maps-interactive-city/US006-view-knowledge-maps.md new file mode 100644 index 00000000..e0c83812 --- /dev/null +++ b/backend/docs/Brd/stories/sprint-03-knowledge-maps-interactive-city/US006-view-knowledge-maps.md @@ -0,0 +1,47 @@ +# US006 - استعراض الخرائط المعرفية + +## Epic +Knowledge Maps & Interactive City + +## Feature Code +F006 + +## Sprint +Sprint 03: Knowledge Maps & Interactive City + +## Priority +High + +## User Story +**As a** مستخدم للمنصة، **I want to** استعراض الخرائط المعرفية المتاحة على المنصة، **so that** أتمكن من الاطلاع على المعلومات المرتبطة بمفهوم الاقتصاد الدائري للكربون. + +## Roles +| Role | Access | +|------|--------| +| Visitor | Can | +| Registered User | Can | + +## Preconditions +- None + +## Acceptance Criteria +1. User enters the platform and navigates to the homepage +2. User clicks "Knowledge Maps" +3. System displays the knowledge map with CCE topics +4. Knowledge maps must be accurate and up-to-date with all topics included (BC001) +5. If no maps are available, system displays ALT001 +6. If a load error occurs, system displays error ERR001 + +## Post-conditions +- User can interact with specific map topics + +## Alternative Flows +- ALT001: If no knowledge maps available, system displays message and redirects to homepage + +## Business Rules +- BC001: Knowledge maps must be accurate and up-to-date with all topics included + +## Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR001 | Error | حدث خطأ أثناء تحميل الصفحة. | Page load error | diff --git a/backend/docs/Brd/stories/sprint-03-knowledge-maps-interactive-city/US007-interact-knowledge-maps.md b/backend/docs/Brd/stories/sprint-03-knowledge-maps-interactive-city/US007-interact-knowledge-maps.md new file mode 100644 index 00000000..750dcbb7 --- /dev/null +++ b/backend/docs/Brd/stories/sprint-03-knowledge-maps-interactive-city/US007-interact-knowledge-maps.md @@ -0,0 +1,54 @@ +# US007 - التفاعل مع الخرائط المعرفية + +## Epic +Knowledge Maps & Interactive City + +## Feature Code +F007 + +## Sprint +Sprint 03: Knowledge Maps & Interactive City + +## Priority +High + +## User Story +**As a** مستخدم للمنصة، **I want to** التفاعل مع الخريطة المعرفية المتاحة على المنصة، **so that** أتمكن من استعراض المعلومات المرتبطة بمفهوم الاقتصاد الدائري للكربون بشكل تفاعلي. + +## Roles +| Role | Access | +|------|--------| +| Visitor | Can | +| Registered User | Can | + +## Preconditions +- None + +## Acceptance Criteria +1. User selects a topic on the knowledge map +2. System displays the topic definition +3. System displays related resources, news, events, and posts for the selected topic +4. Knowledge maps must be accurate and up-to-date (BC001) +5. If no maps are available, system displays ALT001 +6. If no related content is found, system displays ALT002 or INF001 +7. If a load error occurs, system displays error ERR001 + +## Post-conditions +- Topic definition, resources, news, events displayed + +## Alternative Flows +- ALT001: If no knowledge maps available, system displays message and redirects to homepage +- ALT002: If no resources/news for selected topic, system displays INF001 message + +## Business Rules +- BC001: Knowledge maps must be accurate and up-to-date + +## Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR001 | Error | حدث خطأ أثناء تحميل الصفحة. | Page load error | + +## Informational Messages +| Code | Type | Message (AR) | +|------|------|-------------| +| INF001 | Informational | لا توجد مصادر أو أخبار متاحة لهذا الموضوع في الوقت الحالي. يمكنك البحث عن موضوع آخر أو العودة إلى الصفحة الرئيسية. | diff --git a/backend/docs/Brd/stories/sprint-03-knowledge-maps-interactive-city/US008-view-interactive-city.md b/backend/docs/Brd/stories/sprint-03-knowledge-maps-interactive-city/US008-view-interactive-city.md new file mode 100644 index 00000000..63728d5e --- /dev/null +++ b/backend/docs/Brd/stories/sprint-03-knowledge-maps-interactive-city/US008-view-interactive-city.md @@ -0,0 +1,47 @@ +# US008 - استعراض المدينة التفاعلية + +## Epic +Knowledge Maps & Interactive City + +## Feature Code +F008 + +## Sprint +Sprint 03: Knowledge Maps & Interactive City + +## Priority +Medium + +## User Story +**As a** مستخدم للمنصة، **I want to** استعراض المدينة التفاعلية، **so that** أتمكن من الاطلاع على معلومات المدينة بطريقة تفاعلية. + +## Roles +| Role | Access | +|------|--------| +| Visitor | Can | +| Registered User | Can | + +## Preconditions +- None + +## Acceptance Criteria +1. User enters the platform and navigates to the homepage +2. User clicks "Knowledge Maps" +3. System displays the interactive city model (CCE governorate) +4. Data must be fillable by user (BC001) +5. If no city data is available, system displays ALT001 +6. If a load error occurs, system displays error ERR001 + +## Post-conditions +- User can interact with the city by entering data + +## Alternative Flows +- ALT001: If no interactive city data available, system displays message and redirects to homepage + +## Business Rules +- BC001: Data must be fillable by the user + +## Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR001 | Error | حدث خطأ أثناء تحميل الصفحة. | Page load error | diff --git a/backend/docs/Brd/stories/sprint-03-knowledge-maps-interactive-city/US009-interact-interactive-city.md b/backend/docs/Brd/stories/sprint-03-knowledge-maps-interactive-city/US009-interact-interactive-city.md new file mode 100644 index 00000000..814e7b1d --- /dev/null +++ b/backend/docs/Brd/stories/sprint-03-knowledge-maps-interactive-city/US009-interact-interactive-city.md @@ -0,0 +1,83 @@ +# US009 - التفاعل مع المدينة التفاعلية + +## Epic +Knowledge Maps & Interactive City + +## Feature Code +F009 + +## Sprint +Sprint 03: Knowledge Maps & Interactive City + +## Priority +High + +## User Story +**As a** مستخدم للمنصة، **I want to** التفاعل مع المدينة التفاعلية، **so that** أتمكن من إدخال البيانات واكتساب معلومات تفاعلية مباشرة من المدينة. + +## Roles +| Role | Access | +|------|--------| +| Visitor | Can | +| Registered User | Can | + +## Preconditions +- None + +## Acceptance Criteria +1. User enters the interactive city +2. User fills in environmental factor values: + - Public Transport Usage (0-100%) + - Transport Distance (0-100km) + - Bike Lanes (integer > 0) + - Temperature (-50 to 50°C) + - Precipitation (0-5000mm) + - Population (integer > 0) + - Area (decimal > 0) + - Energy Consumption (0-1000 kWh) + - Mixed-Use Ratio (0-100%) + - CO2 Emissions (decimal > 0) + - Industrial Facilities (integer > 0) + - Waste Conversion (0-100%) + - Waste per Person (decimal > 0) + - Renewable Energy (0-100%) + - Carbon Intensity (0-1000 g/W) +3. System validates all input data (BC001) +4. Data must update dynamically based on new inputs (BC001) +5. System calculates and displays the city performance index +6. System displays improvement techniques: Reduce, Reuse, Recycle, Reduce emissions +7. If no data is available, system displays ALT001 +8. If a load error occurs, system displays error ERR001 + +## Post-conditions +- Performance index displayed with improvement suggestions + +## Alternative Flows +- ALT001: If no interactive city data available, system displays message and redirects to homepage + +## Business Rules +- BC001: Data must update dynamically based on new inputs + +## Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR001 | Error | حدث خطأ أثناء تحميل الصفحة. | Page load error | + +## Form Fields & Validation Rules +| Field | Type | Required | Validation | +|-------|------|----------|------------| +| Public Transport Usage | Number/Percentage | Yes | Must be between 0% and 100% | +| Average Transportation Distance | Number/Decimal | Yes | Must be between 0 and 100 km | +| Bike Lanes per km² | Number/Integer | Yes | Must be an integer greater than 0 | +| Average Annual Temperature | Number/Decimal | Yes | Must be between -50 and 50°C | +| Annual Precipitation | Number/Decimal | Yes | Must be between 0 and 5000 mm | +| Population | Number/Integer | Yes | Must be an integer greater than 0 | +| Area of Province | Number/Decimal | Yes | Must be greater than 0 | +| Energy Consumption per km² | Number/Decimal | Yes | Must be between 0 and 1000 kWh | +| Mixed-Use Development Ratio | Number/Percentage | Yes | Must be between 0% and 100% | +| Total CO2 Emissions | Number/Decimal | Yes | Must be greater than 0 | +| Number of Industrial Facilities | Number/Integer | Yes | Must be an integer greater than 0 | +| Waste Conversion Rate | Number/Percentage | Yes | Must be between 0% and 100% | +| Waste per Person per Year | Number/Decimal | Yes | Must be greater than 0 | +| Renewable Energy Production Ratio | Number/Percentage | Yes | Must be between 0% and 100% | +| Carbon Intensity from Electricity | Number/Decimal | Yes | Must be between 0 and 1000 g/W | diff --git a/backend/docs/Brd/stories/sprint-04-news-events/US010-view-news-events.md b/backend/docs/Brd/stories/sprint-04-news-events/US010-view-news-events.md new file mode 100644 index 00000000..ab86ce83 --- /dev/null +++ b/backend/docs/Brd/stories/sprint-04-news-events/US010-view-news-events.md @@ -0,0 +1,51 @@ +# US010 - استعراض الأخبار والفعاليات + +## Epic +News & Events + +## Feature Code +F010 + +## Sprint +Sprint 04: News & Events + +## Priority +High + +## User Story +**As a** مستخدم للمنصة، **I want to** استعراض الأخبار والفعاليات المتعلقة بالموضوع المختار، **so that** أتمكن من الاطلاع على المستجدات ذات الصلة. + +## Roles +| Role | Access | +|------|--------| +| Visitor | Can | +| Registered User | Can | + +## Preconditions +- None + +## Acceptance Criteria +1. User enters the platform and navigates to the homepage +2. User clicks "News & Events" +3. System displays a list of news and events showing: Title, Publish Date, Topic +4. User can search and filter news/events +5. User selects a news/event item +6. System displays full details for each news/event in view-only mode (BC001) +7. If there is no internet connection, system displays error ERR001 +8. If no results are found, system displays ALT002 +9. If a load error occurs, system displays error ERR001 + +## Post-conditions +- User can follow news page, share, or add event to calendar + +## Alternative Flows +- ALT001: If no internet, system displays ERR001 and redirects after retry +- ALT002: If no news/events found matching search, system displays message and suggests new search + +## Business Rules +- BC001: Display full details for each news/event + +## Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR001 | Error | حدث خطأ أثناء تحميل الصفحة. | Page load error | diff --git a/backend/docs/Brd/stories/sprint-04-news-events/US011-share-news-events.md b/backend/docs/Brd/stories/sprint-04-news-events/US011-share-news-events.md new file mode 100644 index 00000000..4aafd875 --- /dev/null +++ b/backend/docs/Brd/stories/sprint-04-news-events/US011-share-news-events.md @@ -0,0 +1,54 @@ +# US011 - مشاركة الأخبار والفعاليات + +## Epic +News & Events + +## Feature Code +F011 + +## Sprint +Sprint 04: News & Events + +## Priority +Medium + +## User Story +**As a** مستخدم للمنصة، **I want to** مشاركة الأخبار والفعاليات المتاحة على المنصة مع الآخرين، **so that** أتمكن من نشر المعلومات المتعلقة بالفعاليات والأخبار المهمة. + +## Roles +| Role | Access | +|------|--------| +| Visitor | Can | +| Registered User | Can | + +## Preconditions +- News/event must be available for sharing + +## Acceptance Criteria +1. User navigates to news/event details +2. User clicks "Share" +3. System displays sharing options (email, link) +4. User selects a sharing method +5. System shares the news/event and displays confirmation CON003 +6. System displays full details for each news/event (BC001) +7. If nothing is available to share, system displays error ERR004 +8. If sharing fails, system displays error ERR004 + +## Post-conditions +- News/event shared successfully + +## Alternative Flows +- ALT001: If no news/event available for sharing, system displays ERR004 and redirects + +## Business Rules +- BC001: Display full details for each news/event + +## Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR004 | Error | حدث خطأ أثناء محاولة المشاركة. يرجى المحاولة مرة أخرى لاحقاً. | Share failure | + +## Confirmation Messages +| Code | Message (AR) | +|------|-------------| +| CON003 | تمت المشاركة بنجاح! | diff --git a/backend/docs/Brd/stories/sprint-04-news-events/US012-follow-news-page.md b/backend/docs/Brd/stories/sprint-04-news-events/US012-follow-news-page.md new file mode 100644 index 00000000..ad6f4e54 --- /dev/null +++ b/backend/docs/Brd/stories/sprint-04-news-events/US012-follow-news-page.md @@ -0,0 +1,46 @@ +# US012 - متابعة صفحة الأخبار + +## Epic +News & Events + +## Feature Code +F012 + +## Sprint +Sprint 04: News & Events + +## Priority +Medium + +## User Story +**As a** مستخدم للمنصة، **I want to** متابعة صفحة الأخبار، **so that** أتمكن من البقاء على اطلاع دائم بأحدث الأخبار والفعاليات المتعلقة بالمنصة. + +## Roles +| Role | Access | +|------|--------| +| Registered User | Can | + +## Preconditions +- News page must be available + +## Acceptance Criteria +1. User navigates to news page +2. User clicks "Follow News Page" +3. System activates notifications for news updates +4. User must be notified of follow success/failure in real-time (BC001) +5. Page stays updated with latest news +6. If follow fails, system displays error ERR005 + +## Post-conditions +- User receives notifications about updates on the news page + +## Alternative Flows +- ALT001: If follow fails, system displays ERR005 and allows retry + +## Business Rules +- BC001: User must be notified of follow success or failure in real-time + +## Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR005 | Error | حدث خطأ أثناء محاولة متابعة الخبر. يرجى المحاولة مرة أخرى لاحقاً. | News follow failure | diff --git a/backend/docs/Brd/stories/sprint-04-news-events/US013-add-event-calendar.md b/backend/docs/Brd/stories/sprint-04-news-events/US013-add-event-calendar.md new file mode 100644 index 00000000..e76030c6 --- /dev/null +++ b/backend/docs/Brd/stories/sprint-04-news-events/US013-add-event-calendar.md @@ -0,0 +1,55 @@ +# US013 - إضافة فعالية إلى التقويم + +## Epic +News & Events + +## Feature Code +F013 + +## Sprint +Sprint 04: News & Events + +## Priority +Medium + +## User Story +**As a** مستخدم للمنصة، **I want to** إضافة فعالية إلى التقويم الخاص بي، **so that** أتمكن من تتبع المواعيد المستقبلية للفعاليات. + +## Roles +| Role | Access | +|------|--------| +| Visitor | Can | +| Registered User | Can | + +## Preconditions +- Event must be available + +## Acceptance Criteria +1. User navigates to event details +2. User clicks "Add to Calendar" +3. System sends event data (title, date, time, location) to the user's preferred calendar +4. System supports Google Calendar, Apple Calendar, Outlook, and .ics formats (BC002) +5. System notifies user of success/failure in real-time (BC001) +6. System displays confirmation CON004 +7. If adding fails, system displays error ERR006 +8. If calendar settings issue occurs, system displays error ERR006 + +## Post-conditions +- Event added to user's personal calendar + +## Alternative Flows +- ALT001: If add to calendar fails, system displays ERR006 and offers retry or alternative options + +## Business Rules +- BC001: User must be notified of success or failure in real-time +- BC002: Platform must allow adding events to personal calendars (Google, Apple, Outlook, or .ics) + +## Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR006 | Error | حدث خطأ أثناء محاولة إضافة الفعالية إلى التقويم. يرجى المحاولة مرة أخرى لاحقاً. | Calendar add failure | + +## Confirmation Messages +| Code | Message (AR) | +|------|-------------| +| CON004 | تم إضافة الفعالية إلى تقويمك الشخصي بنجاح. يمكنك الآن الاطلاع عليها في أي وقت من خلال التقويم لمتابعة التفاصيل والمواعيد. | diff --git a/backend/docs/Brd/stories/sprint-05-profiles-policies/US014-view-state-profile.md b/backend/docs/Brd/stories/sprint-05-profiles-policies/US014-view-state-profile.md new file mode 100644 index 00000000..e3844016 --- /dev/null +++ b/backend/docs/Brd/stories/sprint-05-profiles-policies/US014-view-state-profile.md @@ -0,0 +1,53 @@ +# US014 - استعراض ملف تعريف الدولة + +## Epic +Profiles & Policies + +## Feature Code +F014 + +## Sprint +Sprint 05: Profiles & Policies + +## Priority +High + +## User Story +**As a** مستخدم للمنصة، **I want to** استعراض ملف التعريف الخاص بالدولة، **so that** أتمكن من الاطلاع على التفاصيل المتعلقة بالدولة. + +## Roles +| Role | Access | +|------|--------| +| Visitor | Can | +| Registered User | Can | + +## Preconditions +- State profile must be available + +## Acceptance Criteria +1. User enters the platform and navigates to the homepage +2. User clicks "State Profile" +3. System shows a list of countries +4. User selects a country +5. System displays the state profile details: population, area, GDP per capita, CCE classification, CCE performance, PDF nationally determined contribution, Total CCE Index +6. System retrieves CCE data from KAPSARC integration (BC001) +7. If no profile exists for the selected country, system displays ALT001 +8. If a load error occurs, system displays error ERR001 + +## Post-conditions +- User can navigate to other country profiles + +## Alternative Flows +- ALT001: If state profile not found, system displays message suggesting different search + +## Business Rules +- BC001: System must correctly retrieve and display state profile data including KAPSARC-linked data (CCE Classification, CCE Performance, CCE Total Index) + +## Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR001 | Error | حدث خطأ أثناء تحميل الصفحة. | Page load error | + +## KAPSARC Integration +- Requires KAPSARC API integration for CCE Classification, CCE Performance, and CCE Total Index data +- See appendix for KAPSARC service specification diff --git a/backend/docs/Brd/stories/sprint-05-profiles-policies/US015-view-user-profile.md b/backend/docs/Brd/stories/sprint-05-profiles-policies/US015-view-user-profile.md new file mode 100644 index 00000000..ee814f8c --- /dev/null +++ b/backend/docs/Brd/stories/sprint-05-profiles-policies/US015-view-user-profile.md @@ -0,0 +1,47 @@ +# US015 - استعراض الملف الشخصي + +## Epic +Profiles & Policies + +## Feature Code +F015 + +## Sprint +Sprint 05: Profiles & Policies + +## Priority +High + +## User Story +**As a** مستخدم للمنصة، **I want to** استعراض الملف الشخصي الخاص بي، **so that** أتمكن من الاطلاع على تفاصيل بياناتي. + +## Roles +| Role | Access | +|------|--------| +| Registered User | Can | + +## Preconditions +- User must have a profile + +## Acceptance Criteria +1. User enters the platform and navigates to the homepage +2. User clicks "Profile" +3. System displays profile information: Country, First Name, Last Name, Email, Job Title, Organization +4. System displays following/followers lists +5. Personal data must be correctly retrieved from the database (BC001) +6. If there is no internet connection, system displays error ERR001 +7. If a load error occurs, system displays error ERR001 + +## Post-conditions +- User can choose to edit profile + +## Alternative Flows +- ALT001: If no internet, system displays ERR001 and redirects after retry + +## Business Rules +- BC001: Personal data must be correctly retrieved from the database + +## Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR001 | Error | حدث خطأ أثناء تحميل الصفحة. | Page load error | diff --git a/backend/docs/Brd/stories/sprint-05-profiles-policies/US016-edit-user-profile.md b/backend/docs/Brd/stories/sprint-05-profiles-policies/US016-edit-user-profile.md new file mode 100644 index 00000000..b60f1c3b --- /dev/null +++ b/backend/docs/Brd/stories/sprint-05-profiles-policies/US016-edit-user-profile.md @@ -0,0 +1,57 @@ +# US016 - تعديل الملف الشخصي + +## Epic +Profiles & Policies + +## Feature Code +F016 + +## Sprint +Sprint 05: Profiles & Policies + +## Priority +Medium + +## User Story +**As a** مستخدم للمنصة، **I want to** استعراض الملف الشخصي الخاص بي وتحديثه، **so that** أتمكن من الاطلاع على تفاصيل بياناتي وتحديثها إذا لزم الأمر. + +## Roles +| Role | Access | +|------|--------| +| Registered User | Can | + +## Preconditions +- User must have a profile + +## Acceptance Criteria +1. User navigates to their profile +2. User clicks "Edit" +3. System displays an editable form with the same fields as registration (except password): Country, First Name, Last Name, Email, Job Title, Organization +4. User modifies the desired data +5. User clicks "Save" +6. System retrieves data correctly from the database (BC001) +7. System updates the data successfully after "Save" (BC002) +8. System displays confirmation CON005 +9. If invalid data is entered, system displays error ERR007 +10. If a load error occurs, system displays error ERR001 + +## Post-conditions +- Updated profile displayed to user + +## Alternative Flows +- ALT001: If profile update fails (e.g., invalid email or phone format), system displays ERR007 and requests correction + +## Business Rules +- BC001: Personal data must be correctly retrieved from database +- BC002: Personal data must be successfully updated in database after clicking "Save" + +## Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR001 | Error | حدث خطأ أثناء تحميل الصفحة. | Page load error | +| ERR007 | Error | حدث خطأ أثناء محاولة تحديث بيانات الملف الشخصي. يرجى التأكد من أن البيانات المدخلة صحيحة، مثل تنسيق البريد الإلكتروني أو رقم الهاتف. | Profile update validation error | + +## Confirmation Messages +| Code | Message (AR) | +|------|-------------| +| CON005 | تم تحديث بيانات الملف الشخصي بنجاح. يمكنك الآن الاطلاع على المعلومات المحدثة في ملفك الشخصي. | diff --git a/backend/docs/Brd/stories/sprint-05-profiles-policies/US032-view-policies-terms.md b/backend/docs/Brd/stories/sprint-05-profiles-policies/US032-view-policies-terms.md new file mode 100644 index 00000000..73bf24ef --- /dev/null +++ b/backend/docs/Brd/stories/sprint-05-profiles-policies/US032-view-policies-terms.md @@ -0,0 +1,47 @@ +# US032 - استعراض السياسات والأحكام + +## Epic +Profiles & Policies + +## Feature Code +F032 + +## Sprint +Sprint 05: Profiles & Policies + +## Priority +Medium + +## User Story +**As a** مستخدم للمنصة، **I want to** استعراض السياسات والأحكام، **so that** أتمكن من الاطلاع على تفاصيل القوانين والتنظيمات الخاصة باستخدام المنصة. + +## Roles +| Role | Access | +|------|--------| +| Visitor | Can | +| Registered User | Can | + +## Preconditions +- User must be logged in for customized services + +## Acceptance Criteria +1. User enters the platform and navigates to the homepage +2. User selects "Policies & Terms" +3. System displays the policies and terms page +4. Page must include all necessary legal and regulatory information (BC001) +5. If there is no internet connection, system displays error ERR001 +6. If a load error occurs, system displays error ERR001 + +## Post-conditions +- User can navigate to other sections + +## Alternative Flows +- ALT001: If no internet, system displays ERR001 and redirects after retry + +## Business Rules +- BC001: Policies and terms page must include all necessary legal and regulatory information + +## Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR001 | Error | حدث خطأ أثناء تحميل الصفحة. | Page load error | diff --git a/backend/docs/Brd/stories/sprint-06-expert-registration-assessment-suggestions/US017-register-expert.md b/backend/docs/Brd/stories/sprint-06-expert-registration-assessment-suggestions/US017-register-expert.md new file mode 100644 index 00000000..a8dd74fc --- /dev/null +++ b/backend/docs/Brd/stories/sprint-06-expert-registration-assessment-suggestions/US017-register-expert.md @@ -0,0 +1,68 @@ +# US017 - Register as Expert + +## Epic +Knowledge Community + +## Feature Code +F017 + +## Sprint +Sprint 06: Expert Registration, Assessment & Suggestions + +## Priority +High + +## User Story +**As a** platform user, **I want to** register an account as an expert in the knowledge community, **so that** I can share my knowledge and skills with others. + +## Roles +| Role | Access | +|------|--------| +| Registered User | Can | + +## Preconditions +- User must have a profile + +## Acceptance Criteria +1. User navigates to profile and clicks "Register as Expert" +2. System displays expert registration form +3. User fills CV Description (500 chars, required) +4. User attaches CV Attachment (PDF/Word, required) +5. User selects Expertise Topics (multi-select from CCE topics, required) +6. User clicks "Submit" +7. System validates the form data → CON006 +8. System notifies admin → MSG001 +9. If invalid data is submitted → ERR008 +10. If load error occurs → ERR001 + +## Post-conditions +- Admin receives notification of new expert registration request + +### Alternative Flows +- ALT001: If registration data is invalid, system displays ERR008 and requests correction + +### Business Rules +- BC001: Confirmation message must be displayed upon successful registration request + +### Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR001 | Error | حدث خطأ أثناء تحميل الصفحة. | Page load error | +| ERR008 | Error | حدث خطأ أثناء تقديم طلبك. يرجى التأكد من صحة البيانات المدخلة. | Expert registration data error | + +### Confirmation Messages +| Code | Message (AR) | +|------|-------------| +| CON006 | تم تقديم طلبك بنجاح لتسجيلك كخبير في مجتمع المعرفة. سيتم مراجعة طلبك قريباً. | + +### Notification Messages +| Code | Message (AR) | +|------|-------------| +| MSG001 | عزيزي المشرف، تم تقديم طلب تسجيل جديد من قبل المستخدم [اسم المستخدم] ليتم تسجيله كخبير في مجتمع المعرفة. يرجى مراجعة البيانات المدخلة بعناية واتخاذ الإجراءات المناسبة. | + +### Form Fields & Validation Rules +| Field | Type | Required | Max Length | Validation | +|-------|------|----------|------------|------------| +| CV Description | Free Text | Yes | 500 | - | +| CV Attachment | Attachment | Yes | - | Must be PDF or Word format | +| Expertise Topics | Dropdown (Multi-select) | Yes | - | Must select from CCE topics list; can select multiple | \ No newline at end of file diff --git a/backend/docs/Brd/stories/sprint-06-expert-registration-assessment-suggestions/US018-evaluate-services.md b/backend/docs/Brd/stories/sprint-06-expert-registration-assessment-suggestions/US018-evaluate-services.md new file mode 100644 index 00000000..5f613941 --- /dev/null +++ b/backend/docs/Brd/stories/sprint-06-expert-registration-assessment-suggestions/US018-evaluate-services.md @@ -0,0 +1,62 @@ +# US018 - Evaluate Services + +## Epic +Assessment + +## Feature Code +F018 + +## Sprint +Sprint 06: Expert Registration, Assessment & Suggestions + +## Priority +Medium + +## User Story +**As a** platform user, **I want to** evaluate the platform services, **so that** I can share my experience and improve the service provided. + +## Roles +| Role | Access | +|------|--------| +| Visitor | Can | +| Registered User | Can | + +## Preconditions +- User must be logged in or on second visit to the platform + +## Acceptance Criteria +1. User enters platform and navigates to homepage +2. System displays assessment form +3. User fills form with 4 radio button questions: overall satisfaction, ease of use, content suitability, personalized suggestions suitability +4. User optionally enters feedback (500 chars max) +5. User clicks "Submit" +6. System confirms submission → CON008 +7. If submission error occurs → ERR009 + +## Post-conditions +- None + +### Alternative Flows +- ALT001: If evaluation submission fails, system displays ERR009 + +### Business Rules +- BC001: Evaluation must be saved correctly in the database for reporting purposes + +### Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR009 | Error | حدث خطأ أثناء محاولة إرسال تقييمك. يرجى المحاولة مرة أخرى. | Evaluation submission error | + +### Confirmation Messages +| Code | Message (AR) | +|------|-------------| +| CON008 | تم إرسال تقييمك بنجاح. نشكرك على مشاركتك في تحسين خدماتنا. | + +### Form Fields & Validation Rules +| Field | Type | Required | Validation | +|-------|------|----------|------------| +| How would you rate your overall satisfaction with the platform? | Radio Button | Yes | Select from 5 options: Excellent, Satisfied, Neutral, Dissatisfied, Poor | +| How would you rate the ease of use of the platform? | Radio Button | Yes | Select from 5 options: Excellent, Satisfied, Neutral, Dissatisfied, Poor | +| How suitable is the platform's content for your knowledge level? | Radio Button | Yes | Select from 5 options: Excellent, Satisfied, Neutral, Dissatisfied, Poor | +| How suitable are the personalized suggestions to your interests? | Radio Button | Yes | Select from 5 options: Excellent, Satisfied, Neutral, Dissatisfied, Poor | +| Do you have any other feedback or complaints? Please mention them below. | Free Text | No | 500 chars | \ No newline at end of file diff --git a/backend/docs/Brd/stories/sprint-06-expert-registration-assessment-suggestions/US019-personalized-suggestions.md b/backend/docs/Brd/stories/sprint-06-expert-registration-assessment-suggestions/US019-personalized-suggestions.md new file mode 100644 index 00000000..edbeedaa --- /dev/null +++ b/backend/docs/Brd/stories/sprint-06-expert-registration-assessment-suggestions/US019-personalized-suggestions.md @@ -0,0 +1,63 @@ +# US019 - Personalized Suggestions + +## Epic +Suggestions + +## Feature Code +F019 + +## Sprint +Sprint 06: Expert Registration, Assessment & Suggestions + +## Priority +High + +## User Story +**As a** platform user, **I want to** receive personalized suggestions based on my personal information, **so that** I can access content and resources that match my interests and needs. + +## Roles +| Role | Access | +|------|--------| +| Registered User | Can | + +## Preconditions +- User must be logged in + +## Acceptance Criteria +1. User enters platform +2. System displays personalized suggestions form +3. User fills Areas of Interest (checkbox, CCE topics, required) +4. User selects Knowledge Level (radio: high/medium/low, required) +5. User selects Work Sector (radio: government/academic/private, required) +6. User selects Country (dropdown, required) +7. User clicks "Submit" +8. System confirms submission → CON009 +9. System reorders resources, news, events, and community posts by relevance +10. If submission error occurs → ERR010 + +## Post-conditions +- User can return to modify preferences + +### Alternative Flows +- ALT001: If submission fails, system displays ERR010 + +### Business Rules +- BC001: Suggestions must be generated based on user's answers in the form + +### Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR010 | Error | حدث خطأ أثناء محاولة إرسال بياناتك. يرجى المحاولة مرة أخرى. | Suggestions submission error | + +### Confirmation Messages +| Code | Message (AR) | +|------|-------------| +| CON009 | تم إرسال بياناتك بنجاح! سيتم تخصيص المقترحات لتتناسب مع اهتماماتك واحتياجاتك. | + +### Form Fields & Validation Rules +| Field | Type | Required | Validation | +|-------|------|----------|------------| +| Areas of Interest | Checkbox | Yes | Must select from CCE topics | +| Circular Carbon Economy Knowledge Level | Radio Button | Yes | Select from: High, Medium, Low | +| Sector of Work | Radio Button | Yes | Select from: Government, Academic, Private | +| Country | Dropdown | Yes | Must select from country list | \ No newline at end of file diff --git a/backend/docs/Brd/stories/sprint-07-ai-search/US020-ai-assistant-search.md b/backend/docs/Brd/stories/sprint-07-ai-search/US020-ai-assistant-search.md new file mode 100644 index 00000000..8ac7a534 --- /dev/null +++ b/backend/docs/Brd/stories/sprint-07-ai-search/US020-ai-assistant-search.md @@ -0,0 +1,56 @@ +# US020 - AI Assistant Search + +## Epic +AI Search + +## Feature Code +F020 + +## Sprint +Sprint 07: AI Search + +## Priority +High + +## User Story +**As a** platform user, **I want to** use the AI assistant to search for information, **so that** I can get accurate and fast results based on my queries. + +## Roles +| Role | Access | +|------|--------| +| Visitor | Can | +| Registered User | Can | + +## Preconditions +- AI assistant must be available +- Must rely on platform content only + +## Acceptance Criteria +1. User enters platform and navigates to "AI Search" +2. System displays AI search interface +3. User enters query +4. AI assistant searches based on input +5. System displays results from platform resources only +6. If no accurate results → ALT001/INF002 +7. If AI loading error occurs → ERR011 +8. If no results found → ERR002 + +## Post-conditions +- User can modify query and retry + +### Alternative Flows +- ALT001: If AI doesn't provide accurate results, system displays INF002 and encourages user to modify query + +### Business Rules +- BC001: AI must rely only on platform resources for generating search results +- BC002: Must display accurate results based on available platform data + +### Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR011 | Error | عذراً، حدثت مشكلة في تحميل المساعد الذكي. | AI loading error | + +### Informational Messages +| Code | Type | Message (AR) | +|------|------|-------------| +| INF002 | Informational | عذراً، لم نتمكن من العثور على نتائج دقيقة بناءً على الاستفسار الذي قمت بتقديمه، ربما يساعد تعديل السؤال أو طرحه بطريقة مختلفة في الوصول إلى الإجابة المثالية. | \ No newline at end of file diff --git a/backend/docs/Brd/stories/sprint-08-knowledge-community-core/US021-view-community.md b/backend/docs/Brd/stories/sprint-08-knowledge-community-core/US021-view-community.md new file mode 100644 index 00000000..9a9e08ae --- /dev/null +++ b/backend/docs/Brd/stories/sprint-08-knowledge-community-core/US021-view-community.md @@ -0,0 +1,51 @@ +# US021 - View Community + +## Epic +Knowledge Community + +## Feature Code +F021 + +## Sprint +Sprint 08: Knowledge Community Core + +## Priority +High + +## User Story +**As a** platform user, **I want to** browse the knowledge community, **so that** I can view the posts and resources available within this community. + +## Roles +| Role | Access | +|------|--------| +| Visitor | Can | +| Registered User | Can | + +## Preconditions +- Posts must be available + +## Acceptance Criteria +1. User enters platform and navigates to homepage +2. User selects "Knowledge Community" +3. System displays community interface with available posts +4. If no posts available → ALT001/NTF001 +5. If load error occurs → ERR001 + +## Post-conditions +- User can create, interact with, or reply to posts + +### Alternative Flows +- ALT001: If no posts available, system displays NTF001 message + +### Business Rules +- BC001: Display community content based on available platform data + +### Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR001 | Error | حدث خطأ أثناء تحميل الصفحة. | Page load error | + +### Informational Messages +| Code | Type | Message (AR) | +|------|------|-------------| +| NTF001 | Notification | عذراً، لا توجد منشورات حالياً. | \ No newline at end of file diff --git a/backend/docs/Brd/stories/sprint-08-knowledge-community-core/US022-view-topic-groups.md b/backend/docs/Brd/stories/sprint-08-knowledge-community-core/US022-view-topic-groups.md new file mode 100644 index 00000000..3fc6e1c3 --- /dev/null +++ b/backend/docs/Brd/stories/sprint-08-knowledge-community-core/US022-view-topic-groups.md @@ -0,0 +1,51 @@ +# US022 - View Topic Groups + +## Epic +Knowledge Community + +## Feature Code +F022 + +## Sprint +Sprint 08: Knowledge Community Core + +## Priority +High + +## User Story +**As a** platform user, **I want to** browse topic groups, **so that** I can view posts related to a specific topic. + +## Roles +| Role | Access | +|------|--------| +| Visitor | Can | +| Registered User | Can | + +## Preconditions +- Posts must be available + +## Acceptance Criteria +1. User navigates to Knowledge Community +2. User selects a topic group +3. System displays posts categorized under that topic +4. If no posts available → ALT001/NTF001 +5. If load error occurs → ERR001 + +## Post-conditions +- User can modify selection or return to homepage + +### Alternative Flows +- ALT001: If no posts available, system displays NTF001 message + +### Business Rules +- BC001: Display only posts related to the selected topic + +### Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR001 | Error | حدث خطأ أثناء تحميل الصفحة. | Page load error | + +### Informational Messages +| Code | Type | Message (AR) | +|------|------|-------------| +| NTF001 | Notification | عذراً، لا توجد منشورات حالياً. | \ No newline at end of file diff --git a/backend/docs/Brd/stories/sprint-08-knowledge-community-core/US023-follow-topic.md b/backend/docs/Brd/stories/sprint-08-knowledge-community-core/US023-follow-topic.md new file mode 100644 index 00000000..22275970 --- /dev/null +++ b/backend/docs/Brd/stories/sprint-08-knowledge-community-core/US023-follow-topic.md @@ -0,0 +1,52 @@ +# US023 - Follow Topic + +## Epic +Knowledge Community + +## Feature Code +F023 + +## Sprint +Sprint 08: Knowledge Community Core + +## Priority +Medium + +## User Story +**As a** platform user, **I want to** follow a specific topic group, **so that** I can get new updates about posts related to this topic. + +## Roles +| Role | Access | +|------|--------| +| Registered User | Can | + +## Preconditions +- User must be logged in + +## Acceptance Criteria +1. User navigates to Knowledge Community +2. User selects a topic +3. User clicks "Follow" +4. System saves data and sends notifications about new posts → CON010 +5. If cannot follow → ERR012 +6. If follow error occurs → ERR012 + +## Post-conditions +- User can unfollow at any time +- Notifications sent for new posts in followed topics + +### Alternative Flows +- ALT001: If follow fails, system displays ERR012 + +### Business Rules +- BC001: Must send notifications when new posts are added to followed topics + +### Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR012 | Error | عذراً، لا يمكن متابعة الموضوع حالياً. | Topic follow failure | + +### Confirmation Messages +| Code | Message (AR) | +|------|-------------| +| CON010 | تم حفظ بياناتك بنجاح. س تتلقى إشعارات أو تحديثات حول المنشورات الجديدة المتعلقة بالموضوع الذي اخترته. | \ No newline at end of file diff --git a/backend/docs/Brd/stories/sprint-09-knowledge-community-posts/US024-view-post.md b/backend/docs/Brd/stories/sprint-09-knowledge-community-posts/US024-view-post.md new file mode 100644 index 00000000..968aed5d --- /dev/null +++ b/backend/docs/Brd/stories/sprint-09-knowledge-community-posts/US024-view-post.md @@ -0,0 +1,51 @@ +# US024 - View Post + +## Epic +Knowledge Community + +## Feature Code +F024 + +## Sprint +Sprint 09: Knowledge Community Posts + +## Priority +High + +## User Story +**As a** platform user, **I want to** view a post, **so that** I can see the full details of the submitted post. + +## Roles +| Role | Access | +|------|--------| +| Visitor | Can | +| Registered User | Can | + +## Preconditions +- Posts must be available + +## Acceptance Criteria +1. User navigates to Knowledge Community +2. User selects a post +3. System displays post with all its data (title, date, topic, content, attachments) +4. If no posts available → ALT001/NTF001 +5. If load error occurs → ERR001 + +## Post-conditions +- User can interact with the post (like, comment) + +### Alternative Flows +- ALT001: If no posts available, system displays NTF001 message + +### Business Rules +- BC001: Display full post based on available data + +### Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR001 | Error | حدث خطأ أثناء تحميل الصفحة. | Page load error | + +### Informational Messages +| Code | Type | Message (AR) | +|------|------|-------------| +| NTF001 | Notification | عذراً، لا توجد منشورات حالياً. | \ No newline at end of file diff --git a/backend/docs/Brd/stories/sprint-09-knowledge-community-posts/US025-share-post.md b/backend/docs/Brd/stories/sprint-09-knowledge-community-posts/US025-share-post.md new file mode 100644 index 00000000..95307d18 --- /dev/null +++ b/backend/docs/Brd/stories/sprint-09-knowledge-community-posts/US025-share-post.md @@ -0,0 +1,53 @@ +# US025 - Share Post + +## Epic +Knowledge Community + +## Feature Code +F025 + +## Sprint +Sprint 09: Knowledge Community Posts + +## Priority +Medium + +## User Story +**As a** platform user, **I want to** share a post, **so that** I can distribute it with others via the platform or via social media. + +## Roles +| Role | Access | +|------|--------| +| Visitor | Can | +| Registered User | Can | + +## Preconditions +- Post must be available + +## Acceptance Criteria +1. User navigates to a post +2. User clicks "Share" +3. System shows sharing options (email, link) +4. User selects sharing method +5. System shares the post → CON003 +6. If cannot share → ERR004 +7. If share failure occurs → ERR004 + +## Post-conditions +- User can interact with the post + +### Alternative Flows +- ALT001: If no post available for sharing, system displays ERR004 and redirects to community + +### Business Rules +- BC001: Display full post details + +### Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR004 | Error | حدث خطأ أثناء محاولة المشاركة. يرجى المحاولة مرة أخرى لاحقاً. | Post share failure | + +### Confirmation Messages +| Code | Message (AR) | +|------|-------------| +| CON003 | تمت المشاركة بنجاح! | \ No newline at end of file diff --git a/backend/docs/Brd/stories/sprint-09-knowledge-community-posts/US026-create-post.md b/backend/docs/Brd/stories/sprint-09-knowledge-community-posts/US026-create-post.md new file mode 100644 index 00000000..d4f209c1 --- /dev/null +++ b/backend/docs/Brd/stories/sprint-09-knowledge-community-posts/US026-create-post.md @@ -0,0 +1,64 @@ +# US026 - Create Post + +## Epic +Knowledge Community + +## Feature Code +F026 + +## Sprint +Sprint 09: Knowledge Community Posts + +## Priority +High + +## User Story +**As a** platform user, **I want to** share a post, **so that** I can publish it with others via the platform. + +## Roles +| Role | Access | +|------|--------| +| Registered User | Can | + +## Preconditions +- User must be logged in + +## Acceptance Criteria +1. User navigates to Knowledge Community +2. User clicks "Create Post" +3. System displays post creation form +4. User fills Title (150 chars, required) +5. User fills Content (5000 chars, required) +6. User selects Post Type (dropdown: info/question/poll, required) +7. User clicks "Publish" +8. System confirms publication → CON011 +9. If missing required fields → ERR013 +10. If publish error occurs → ERR014 + +## Post-conditions +- User can review and interact with their post +- User can share the post + +### Alternative Flows +- ALT001: If required fields not filled, system displays ERR013 + +### Business Rules +- BC001: User must enter required data (title and content) before publishing + +### Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR013 | Error | عذراً، الحقول الإجبارية غير مكتملة. | Required fields empty | +| ERR014 | Error | عذراً، حدثت مشكلة أثناء نشر المنشور. | Post publish failure | + +### Confirmation Messages +| Code | Message (AR) | +|------|-------------| +| CON011 | تم إنشاء المنشور بنجاح! | + +### Form Fields & Validation Rules +| Field | Type | Required | Max Length | Validation | +|-------|------|----------|------------|------------| +| Post Title | Free Text | Yes | 150 | - | +| Post Content | Free Text | Yes | 5000 | - | +| Post Type | Dropdown | Yes | - | Options: Info, Question, Poll | \ No newline at end of file diff --git a/backend/docs/Brd/stories/sprint-09-knowledge-community-posts/US027-interact-post.md b/backend/docs/Brd/stories/sprint-09-knowledge-community-posts/US027-interact-post.md new file mode 100644 index 00000000..a4fc0e19 --- /dev/null +++ b/backend/docs/Brd/stories/sprint-09-knowledge-community-posts/US027-interact-post.md @@ -0,0 +1,46 @@ +# US027 - Interact with Post + +## Epic +Knowledge Community + +## Feature Code +F027 + +## Sprint +Sprint 09: Knowledge Community Posts + +## Priority +Medium + +## User Story +**As a** platform user, **I want to** interact with a post through upvoting or downvoting, **so that** I can directly evaluate the post. + +## Roles +| Role | Access | +|------|--------| +| Registered User | Can | + +## Preconditions +- User must be logged in +- Post must be available + +## Acceptance Criteria +1. User navigates to a post +2. User clicks "Rate Up" or "Rate Down" +3. System updates post to show new interaction +4. Only upvotes are displayed publicly +5. If interaction failure occurs, system shows error message asking to retry + +## Post-conditions +- User can review their interaction at any time + +### Alternative Flows +- ALT001: If interaction fails, system displays error message and requests retry + +### Business Rules +- BC001: Display new interaction (up/down) immediately after click. Upvotes shown publicly with total count. Downvotes affect ranking only, not displayed publicly. + +### Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR001 | Error | حدث خطأ أثناء تحميل الصفحة. | Post interaction failure | \ No newline at end of file diff --git a/backend/docs/Brd/stories/sprint-09-knowledge-community-posts/US028-follow-post.md b/backend/docs/Brd/stories/sprint-09-knowledge-community-posts/US028-follow-post.md new file mode 100644 index 00000000..6d7a4864 --- /dev/null +++ b/backend/docs/Brd/stories/sprint-09-knowledge-community-posts/US028-follow-post.md @@ -0,0 +1,50 @@ +# US028 - Follow Post + +## Epic +Knowledge Community + +## Feature Code +F028 + +## Sprint +Sprint 09: Knowledge Community Posts + +## Priority +Medium + +## User Story +**As a** platform user, **I want to** follow a specific post, **so that** I can continuously get updates about it. + +## Roles +| Role | Access | +|------|--------| +| Registered User | Can | + +## Preconditions +- User must be logged in + +## Acceptance Criteria +1. User navigates to a post +2. User clicks "Follow Post" +3. System saves data and sends notifications about updates → CON012 +4. If cannot follow → ERR015 +5. If follow error occurs → ERR015 + +## Post-conditions +- User can unfollow at any time + +### Alternative Flows +- ALT001: If follow fails, system displays ERR015 + +### Business Rules +- BC001: Must send notifications for post updates + +### Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR015 | Error | عذراً، لا يمكن متابعة المنشور حالياً. | Post follow failure | + +### Confirmation Messages +| Code | Message (AR) | +|------|-------------| +| CON012 | تم حفظ بياناتك بنجاح. س تتلقى إشعارات أو تحديثات حول المنشور. | \ No newline at end of file diff --git a/backend/docs/Brd/stories/sprint-09-knowledge-community-posts/US029-reply-post.md b/backend/docs/Brd/stories/sprint-09-knowledge-community-posts/US029-reply-post.md new file mode 100644 index 00000000..a216d1cd --- /dev/null +++ b/backend/docs/Brd/stories/sprint-09-knowledge-community-posts/US029-reply-post.md @@ -0,0 +1,53 @@ +# US029 - Reply to Post + +## Epic +Knowledge Community + +## Feature Code +F029 + +## Sprint +Sprint 09: Knowledge Community Posts + +## Priority +High + +## User Story +**As a** platform user, **I want to** reply to a post, **so that** I can add my comment or answer to the post. + +## Roles +| Role | Access | +|------|--------| +| Registered User | Can | + +## Preconditions +- User must be logged in + +## Acceptance Criteria +1. User navigates to a post +2. User clicks "Reply" or comment field +3. User types reply +4. User clicks "Send" +5. System saves reply and displays it under the post → CON013 +6. If empty reply → ERR016 +7. If reply error occurs → ERR017 + +## Post-conditions +- User can review their replies at any time + +### Alternative Flows +- ALT001: If user submits empty reply, system displays ERR016 + +### Business Rules +- BC001: Replies must be displayed immediately after submission + +### Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR016 | Error | عذراً، لا يمكن إرسال رد فارغ. | Empty reply | +| ERR017 | Error | عذراً، حدثت مشكلة أثناء إرسال الرد. | Reply submission failure | + +### Confirmation Messages +| Code | Message (AR) | +|------|-------------| +| CON013 | تم إرسال الرد بنجاح! | \ No newline at end of file diff --git a/backend/docs/Brd/stories/sprint-10-knowledge-community-users/US030-view-user-profile-community.md b/backend/docs/Brd/stories/sprint-10-knowledge-community-users/US030-view-user-profile-community.md new file mode 100644 index 00000000..ed2f7dd1 --- /dev/null +++ b/backend/docs/Brd/stories/sprint-10-knowledge-community-users/US030-view-user-profile-community.md @@ -0,0 +1,46 @@ +# US030 - View User Profile in Community + +## Epic +Knowledge Community + +## Feature Code +F030 + +## Sprint +Sprint 10: Knowledge Community Users + +## Priority +Medium + +## User Story +**As a** platform user, **I want to** view another user's profile, **so that** I can see their information and follow their activities on the platform. + +## Roles +| Role | Access | +|------|--------| +| Registered User | Can | + +## Preconditions +- User must be logged in + +## Acceptance Criteria +1. User navigates to Knowledge Community +2. User selects a user profile +3. System displays: First Name, Last Name, Job Title, Organization, Join Date, Post Count, Reply Count +4. If user is an expert, system displays CV description and expert badge +5. If no internet → ERR001 +6. If load error occurs → ERR001 + +## Post-conditions +- User can follow the profile + +### Alternative Flows +- ALT001: If no internet, system displays ERR001 and redirects after retry + +### Business Rules +- BC001: User profile must appear in a clear view template with all available information + +### Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR001 | Error | حدث خطأ أثناء تحميل الصفحة. | Page load error | \ No newline at end of file diff --git a/backend/docs/Brd/stories/sprint-10-knowledge-community-users/US031-follow-user.md b/backend/docs/Brd/stories/sprint-10-knowledge-community-users/US031-follow-user.md new file mode 100644 index 00000000..e40e9082 --- /dev/null +++ b/backend/docs/Brd/stories/sprint-10-knowledge-community-users/US031-follow-user.md @@ -0,0 +1,45 @@ +# US031 - Follow User + +## Epic +Knowledge Community + +## Feature Code +F031 + +## Sprint +Sprint 10: Knowledge Community Users + +## Priority +Medium + +## User Story +**As a** platform user, **I want to** follow another user, **so that** I can continuously view their activities and new posts. + +## Roles +| Role | Access | +|------|--------| +| Registered User | Can | + +## Preconditions +- User must be logged in + +## Acceptance Criteria +1. User navigates to a user profile +2. User clicks "Follow" +3. System saves follow data and updates status with confirmation +4. If cannot follow → ERR018 +5. If follow error occurs → ERR018 + +## Post-conditions +- User can unfollow at any time by clicking "Unfollow" + +### Alternative Flows +- ALT001: If follow fails, system displays ERR018 + +### Business Rules +- BC001: Follow status must be saved so user can easily follow the other user's posts + +### Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR018 | Error | عذراً، لا يمكن متابعة المستخدم حالياً. | User follow failure | \ No newline at end of file diff --git a/backend/docs/Brd/stories/sprint-11-admin-content-management/US037-update-homepage.md b/backend/docs/Brd/stories/sprint-11-admin-content-management/US037-update-homepage.md new file mode 100644 index 00000000..e779cf1c --- /dev/null +++ b/backend/docs/Brd/stories/sprint-11-admin-content-management/US037-update-homepage.md @@ -0,0 +1,65 @@ +# US037 - Update Homepage + +## Epic +Admin Content Management + +## Feature Code +F037 + +## Sprint +Sprint 11: Admin Content Management + +## Priority +High + +## User Story +**As a** Super Admin/Admin/Content Manager, **I want to** update the homepage content of the platform, **so that** I can improve and update the information displayed to users. + +## Roles +| Role | Access | +|------|--------| +| Super Admin | Can | +| Admin | Can | +| Content Manager | Can | + +## Preconditions +- User must be a logged-in admin + +## Acceptance Criteria +1. Admin enters platform > homepage > selects "Update Homepage Content" +2. System shows update options (About Platform, Homepage, Policies & Terms) +3. Admin selects "Update Homepage" +4. System displays homepage update form +5. Admin modifies content and clicks "Save & Update" +6. System validates input data before executing update (BC001) +7. On success, confirmation message CON016 is displayed +8. On update error, error message ERR025 is displayed +9. On load error, error message ERR001 is displayed + +## Post-conditions +- New content appears on homepage immediately + +### Alternative Flows +- ALT001: If content update fails, system displays ERR025 + +### Business Rules +- BC001: Validate input data before executing the update + +### Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR001 | Error | حدث خطأ أثناء تحميل الصفحة. | Page load error | +| ERR025 | Error | عذراً، حدثت مشكلة أثناء تحديث المحتوى. | Content update failure | + +### Confirmation Messages +| Code | Message (AR) | +|------|-------------| +| CON016 | تمت عملية التحديث بنجاح. | + +### Form Fields & Validation Rules +| Field | Type | Required | Validation | +|-------|------|----------|------------| +| Platform Introduction Video | Video File | Yes | - | +| Objective and Message | Free Text | Yes | 1000 chars | +| Circular Carbon Economy Concepts | Free Text | Yes | No limit, comma-separated or multi-line input, up to 100 concepts | +| Participating Countries | Multi-select Dropdown | Yes | Select from world countries list | \ No newline at end of file diff --git a/backend/docs/Brd/stories/sprint-11-admin-content-management/US038-update-about-platform.md b/backend/docs/Brd/stories/sprint-11-admin-content-management/US038-update-about-platform.md new file mode 100644 index 00000000..2eaab03d --- /dev/null +++ b/backend/docs/Brd/stories/sprint-11-admin-content-management/US038-update-about-platform.md @@ -0,0 +1,66 @@ +# US038 - Update About Platform + +## Epic +Admin Content Management + +## Feature Code +F038 + +## Sprint +Sprint 11: Admin Content Management + +## Priority +High + +## User Story +**As a** Super Admin/Admin/Content Manager, **I want to** update the "About Platform" page, **so that** I can improve and update the explanatory information displayed to new users about the platform. + +## Roles +| Role | Access | +|------|--------| +| Super Admin | Can | +| Admin | Can | +| Content Manager | Can | + +## Preconditions +- User must be a logged-in admin + +## Acceptance Criteria +1. Admin enters platform > selects "Update About Platform Content" +2. System shows update options +3. Admin selects "Update About Platform" +4. System displays update form with fields: General Description (1000 chars), How to Use (video file), Knowledge Partners (1000 chars), Terminology Dictionary +5. Admin modifies content and clicks "Save & Update" +6. System validates input data before executing update (BC001) +7. On success, confirmation message CON016 is displayed +8. On update error, error message ERR025 is displayed +9. On load error, error message ERR001 is displayed + +## Post-conditions +- New content appears on About Platform page immediately + +### Alternative Flows +- ALT001: If content update fails, system displays ERR025 + +### Business Rules +- BC001: Validate input data before executing the update + +### Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR001 | Error | حدث خطأ أثناء تحميل الصفحة. | Page load error | +| ERR025 | Error | عذراً، حدثت مشكلة أثناء تحديث المحتوى. | Content update failure | + +### Confirmation Messages +| Code | Message (AR) | +|------|-------------| +| CON016 | تمت عملية التحديث بنجاح. | + +### Form Fields & Validation Rules +| Field | Type | Required | Max Length | Validation | +|-------|------|----------|------------|------------| +| General Description | Free Text | Yes | 1000 | - | +| How to Use | Video File | Yes | - | - | +| Knowledge Partners | Free Text | Yes | 1000 | Comma-separated or multi-line input, up to 100 partners | +| Term (for Terminology Dictionary) | Free Text | Yes | 100 | - | +| Definition (for Terminology Dictionary) | Free Text | Yes | 1000 | - | \ No newline at end of file diff --git a/backend/docs/Brd/stories/sprint-11-admin-content-management/US039-update-policies.md b/backend/docs/Brd/stories/sprint-11-admin-content-management/US039-update-policies.md new file mode 100644 index 00000000..5fae674c --- /dev/null +++ b/backend/docs/Brd/stories/sprint-11-admin-content-management/US039-update-policies.md @@ -0,0 +1,61 @@ +# US039 - Update Policies & Terms + +## Epic +Admin Content Management + +## Feature Code +F039 + +## Sprint +Sprint 11: Admin Content Management + +## Priority +High + +## User Story +**As a** Super Admin, **I want to** update the "About Platform" page, **so that** I can improve and update the explanatory information displayed to new users about the platform. + +## Roles +| Role | Access | +|------|--------| +| Super Admin | Can only | + +## Preconditions +- User must be Super Admin and logged in + +## Acceptance Criteria +1. Admin enters platform > selects "Update Policies & Terms Content" +2. System shows update options +3. Admin selects "Update Policies & Terms" +4. System displays form with fields: Policies (1000 chars), Terms (1000 chars) +5. Admin modifies content and clicks "Save & Update" +6. System validates input data before executing update (BC001) +7. On success, confirmation message CON016 is displayed +8. On update error, error message ERR025 is displayed +9. On load error, error message ERR001 is displayed + +## Post-conditions +- New policies and terms content appears immediately + +### Alternative Flows +- ALT001: If content update fails, system displays ERR025 + +### Business Rules +- BC001: Validate input data before executing the update + +### Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR001 | Error | حدث خطأ أثناء تحميل الصفحة. | Page load error | +| ERR025 | Error | عذراً، حدثت مشكلة أثناء تحديث المحتوى. | Content update failure | + +### Confirmation Messages +| Code | Message (AR) | +|------|-------------| +| CON016 | تمت عملية التحديث بنجاح. | + +### Form Fields & Validation Rules +| Field | Type | Required | Max Length | Validation | +|-------|------|----------|------------|------------| +| Policies | Free Text | Yes | 1000 | - | +| Terms | Free Text | Yes | 1000 | - | \ No newline at end of file diff --git a/backend/docs/Brd/stories/sprint-11-admin-content-management/US061-admin-login.md b/backend/docs/Brd/stories/sprint-11-admin-content-management/US061-admin-login.md new file mode 100644 index 00000000..bf2c2fb4 --- /dev/null +++ b/backend/docs/Brd/stories/sprint-11-admin-content-management/US061-admin-login.md @@ -0,0 +1,51 @@ +# US061 - Admin Login + +## Epic +Admin Content Management + +## Feature Code +F061 + +## Sprint +Sprint 11: Admin Content Management + +## Priority +High + +## User Story +**As an** admin, **I want to** log in to the platform using my credentials, **so that** I can access all available services. + +## Roles +| Role | Access | +|------|--------| +| Super Admin | Can | +| Admin | Can | +| Content Manager | Can | +| State Representative | Can | + +## Preconditions +- User must be registered as admin + +## Acceptance Criteria +1. Admin enters platform and clicks "Login" +2. System displays login form +3. Admin enters credentials and clicks "Login" +4. System validates email and password before allowing login (BC001) +5. On success, admin is redirected to homepage +6. On invalid credentials, error message ERR020 is displayed +7. On system error, error message ERR021 is displayed + +## Post-conditions +- Admin can access administrative services + +### Alternative Flows +- ALT001: If admin enters incorrect data, system displays ERR020 and requests retry + +### Business Rules +- BC001: Validate email and password before allowing login + +### Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR020 | Error | عذراً، البيانات المدخلة غير صحيحة. | Invalid credentials | +| ERR021 | Error | عذراً، حدثت مشكلة أثناء تسجيل الدخول. | Login system error | \ No newline at end of file diff --git a/backend/docs/Brd/stories/sprint-11-admin-content-management/US062-admin-password-recovery.md b/backend/docs/Brd/stories/sprint-11-admin-content-management/US062-admin-password-recovery.md new file mode 100644 index 00000000..a6c3b0f3 --- /dev/null +++ b/backend/docs/Brd/stories/sprint-11-admin-content-management/US062-admin-password-recovery.md @@ -0,0 +1,57 @@ +# US062 - Admin Password Recovery + +## Epic +Admin Content Management + +## Feature Code +F062 + +## Sprint +Sprint 11: Admin Content Management + +## Priority +High + +## User Story +**As an** admin, **I want to** recover my password, **so that** I can access my account if I forget my password. + +## Roles +| Role | Access | +|------|--------| +| Super Admin | Can | +| Admin | Can | +| Content Manager | Can | +| State Representative | Can | + +## Preconditions +- User must be registered as admin + +## Acceptance Criteria +1. Admin enters platform > "Login" > clicks "Forgot Password?" +2. Admin enters email address +3. System sends password reset link (BC001: email must be registered for password recovery) +4. Admin clicks reset link and enters new password +5. System updates password and displays confirmation CON014 +6. Admin is redirected to login page +7. On email not found, error message ERR022 is displayed +8. On system error, error message ERR023 is displayed + +## Post-conditions +- Admin can login with new password + +### Alternative Flows +- ALT001: If email not found, system displays ERR022 + +### Business Rules +- BC001: Email must be registered in the system for password recovery + +### Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR022 | Error | عذراً، لم يتم العثور على الحساب المرتبط بالبريد الإلكتروني. | Email not found | +| ERR023 | Error | عذراً، حدثت مشكلة أثناء استعادة كلمة المرور. | Password recovery system error | + +### Confirmation Messages +| Code | Message (AR) | +|------|-------------| +| CON014 | تمت استعادة كلمة المرور بنجاح! | \ No newline at end of file diff --git a/backend/docs/Brd/stories/sprint-11-admin-content-management/US063-admin-logout.md b/backend/docs/Brd/stories/sprint-11-admin-content-management/US063-admin-logout.md new file mode 100644 index 00000000..4896b7a3 --- /dev/null +++ b/backend/docs/Brd/stories/sprint-11-admin-content-management/US063-admin-logout.md @@ -0,0 +1,53 @@ +# US063 - Admin Logout + +## Epic +Admin Content Management + +## Feature Code +F063 + +## Sprint +Sprint 11: Admin Content Management + +## Priority +Medium + +## User Story +**As an** admin, **I want to** log out of the platform, **so that** I can end my session securely. + +## Roles +| Role | Access | +|------|--------| +| Super Admin | Can | +| Admin | Can | +| Content Manager | Can | +| State Representative | Can | + +## Preconditions +- User must be logged in as admin + +## Acceptance Criteria +1. Admin clicks profile icon and selects "Logout" +2. System properly terminates session (BC001) +3. System displays confirmation CON015 +4. Admin is redirected to login page +5. On logout error, error message ERR024 is displayed + +## Post-conditions +- Admin redirected to login page + +### Alternative Flows +- ALT001: If logout error, system displays ERR024 and allows retry + +### Business Rules +- BC001: System must properly terminate session on logout + +### Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR024 | Error | حدث خطأ أثناء محاولة تسجيل الخروج. | Logout failure | + +### Confirmation Messages +| Code | Message (AR) | +|------|-------------| +| CON015 | تم تسجيل الخروج بنجاح. | \ No newline at end of file diff --git a/backend/docs/Brd/stories/sprint-12-admin-user-management/US040-view-users.md b/backend/docs/Brd/stories/sprint-12-admin-user-management/US040-view-users.md new file mode 100644 index 00000000..32db2de0 --- /dev/null +++ b/backend/docs/Brd/stories/sprint-12-admin-user-management/US040-view-users.md @@ -0,0 +1,47 @@ +# US040 - View Users + +## Epic +Admin User Management + +## Feature Code +F040 + +## Sprint +Sprint 12: Admin User Management + +## Priority +High + +## User Story +**As a** Super Admin, **I want to** view the list of users, **so that** I can manage user accounts and track their activities. + +## Roles +| Role | Access | +|------|--------| +| Super Admin | Can only | + +## Preconditions +- User must be Super Admin + +## Acceptance Criteria +1. Super Admin enters platform > "User Management" +2. System displays user management interface with user list +3. Admin selects a user +4. System displays user details in create user form (view-only) +5. System displays correct user details (BC001) +6. If no users exist, alternative flow ALT001 is triggered +7. On load error, error message ERR001 is displayed + +## Post-conditions +- Admin can add or delete users + +### Alternative Flows +- ALT001: If no users exist, system displays message and prompts to add new user + +### Business Rules +- BC001: Display correct user details + +### Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR001 | Error | حدث خطأ أثناء تحميل الصفحة. | Page load error | \ No newline at end of file diff --git a/backend/docs/Brd/stories/sprint-12-admin-user-management/US041-create-user.md b/backend/docs/Brd/stories/sprint-12-admin-user-management/US041-create-user.md new file mode 100644 index 00000000..d4c32240 --- /dev/null +++ b/backend/docs/Brd/stories/sprint-12-admin-user-management/US041-create-user.md @@ -0,0 +1,63 @@ +# US041 - Create User + +## Epic +Admin User Management + +## Feature Code +F041 + +## Sprint +Sprint 12: Admin User Management + +## Priority +High + +## User Story +**As a** Super Admin, **I want to** create a new user on the platform, **so that** I can grant them permissions and allow them to use the platform. + +## Roles +| Role | Access | +|------|--------| +| Super Admin | Can only | + +## Preconditions +- User must be Super Admin + +## Acceptance Criteria +1. Super Admin enters platform > "User Management" > clicks "Create User" +2. System displays create user form with fields: First Name (50 chars, letters only), Last Name (50 chars, letters only), Email (100 chars, valid), Phone (15 digits), Country (dropdown), Role (dropdown: Admin/Content Manager/State Rep) +3. Admin fills form and clicks "Create User" +4. System validates all input data before creating user (BC001) +5. On success, confirmation message CON017 is displayed +6. On missing required fields, error message ERR013 is displayed +7. On creation error, error message ERR019 is displayed + +## Post-conditions +- New user visible in user list; can be deleted if needed + +### Alternative Flows +- ALT001: If required fields not filled, system displays ERR013 + +### Business Rules +- BC001: Validate all input data before creating user + +### Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR013 | Error | عذراً، الحقول الإجبارية غير مكتملة. | Required fields empty | +| ERR019 | Error | عذراً، حدثت مشكلة أثناء إنشاء الحساب. | User creation failure | + +### Confirmation Messages +| Code | Message (AR) | +|------|-------------| +| CON017 | تم إنشاء المستخدم بنجاح! | + +### Form Fields & Validation Rules +| Field | Type | Required | Max Length | Validation | +|-------|------|----------|------------|------------| +| First Name (FirstName) | Free Text | Yes | 50 | Must contain letters only | +| Last Name (LastName) | Free Text | Yes | 50 | Must contain letters only | +| Email Address (EmailAddress) | Free Text | Yes | 100 | Must be a valid email | +| Phone Number (PhoneNumber) | Numbers | Yes | 15 | - | +| Country | Dropdown | Yes | - | Must select from country list | +| Role | Dropdown | Yes | - | Options: Admin, Content Manager, State Representative | \ No newline at end of file diff --git a/backend/docs/Brd/stories/sprint-12-admin-user-management/US042-delete-user.md b/backend/docs/Brd/stories/sprint-12-admin-user-management/US042-delete-user.md new file mode 100644 index 00000000..4292fdc3 --- /dev/null +++ b/backend/docs/Brd/stories/sprint-12-admin-user-management/US042-delete-user.md @@ -0,0 +1,53 @@ +# US042 - Delete User + +## Epic +Admin User Management + +## Feature Code +F042 + +## Sprint +Sprint 12: Admin User Management + +## Priority +High + +## User Story +**As a** Super Admin, **I want to** delete a user from the platform, **so that** I can better manage users and organize access to services. + +## Roles +| Role | Access | +|------|--------| +| Super Admin | Can only | + +## Preconditions +- User must be Super Admin + +## Acceptance Criteria +1. Super Admin navigates to user details +2. Admin clicks "Delete User" +3. System displays confirmation dialog ("Are you sure?") +4. System must display confirmation before deletion to prevent accidental deletion (BC001) +5. If admin clicks "Yes", system deletes user and displays confirmation CON018 +6. If admin clicks "Cancel", alternative flow ALT001 is triggered (no deletion) +7. On deletion error, error message ERR026 is displayed + +## Post-conditions +- Deleted user data cannot be restored unless backup exists + +### Alternative Flows +- ALT001: If admin clicks "Cancel", system closes confirmation and returns to user list without deletion + +### Business Rules +- BC001: Must display confirmation before deletion to prevent accidental deletion + +### Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR001 | Error | حدث خطأ أثناء تحميل الصفحة. | Page load error | +| ERR026 | Error | عذراً، حدثت مشكلة أثناء حذف المستخدم. | User deletion failure | + +### Confirmation Messages +| Code | Message (AR) | +|------|-------------| +| CON018 | تم حذف المستخدم بنجاح! | \ No newline at end of file diff --git a/backend/docs/Brd/stories/sprint-13-admin-news-events-resources/US043-view-news-events-admin.md b/backend/docs/Brd/stories/sprint-13-admin-news-events-resources/US043-view-news-events-admin.md new file mode 100644 index 00000000..97fef10c --- /dev/null +++ b/backend/docs/Brd/stories/sprint-13-admin-news-events-resources/US043-view-news-events-admin.md @@ -0,0 +1,56 @@ +# US043 - View News & Events (Admin) + +## Epic +Admin News, Events & Resources + +## Feature Code +F043 + +## Sprint +Sprint 13: Admin News, Events & Resources + +## Priority +Medium + +## User Story +**As an** admin, **I want to** view news and events, **so that** I can follow the content related to important news and events on the platform. + +## Roles +| Role | Access | +|------|--------| +| Super Admin | Can | +| Admin | Can | +| Content Manager | Can | +| State Rep | Can | + +## Preconditions +- User must be registered as admin +- News/events must be available + +## Acceptance Criteria +1. Admin enters platform > "News & Events" +2. System displays news/events list +3. Admin selects a news or event item +4. System displays details in news or event form (view-only) +5. System displays correct news/event details (BC001) +6. If no news/events exist, alternative flow ALT001 or info message INF003 is triggered +7. On load error, error message ERR001 is displayed + +## Post-conditions +- Admin can take actions like deleting if authorized + +### Alternative Flows +- ALT001: If no news/events, system displays INF003 + +### Business Rules +- BC001: Display correct news/event details + +### Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR001 | Error | حدث خطأ أثناء تحميل الصفحة. | Page load error | + +### Informational Messages +| Code | Type | Message (AR) | +|------|------|-------------| +| INF003 | Informational | عذراً، لا توجد أخبار أو فعاليات حالياً. | \ No newline at end of file diff --git a/backend/docs/Brd/stories/sprint-13-admin-news-events-resources/US044-upload-news-events.md b/backend/docs/Brd/stories/sprint-13-admin-news-events-resources/US044-upload-news-events.md new file mode 100644 index 00000000..d17950ed --- /dev/null +++ b/backend/docs/Brd/stories/sprint-13-admin-news-events-resources/US044-upload-news-events.md @@ -0,0 +1,72 @@ +# US044 - Upload News & Events + +## Epic +Admin News, Events & Resources + +## Feature Code +F044 + +## Sprint +Sprint 13: Admin News, Events & Resources + +## Priority +Medium + +## User Story +**As an** admin, **I want to** upload news or events, **so that** I can add new content to the platform. + +## Roles +| Role | Access | +|------|--------| +| Super Admin | Can | +| Admin | Can | +| Content Manager | Can | + +## Preconditions +- User must be registered as admin + +## Acceptance Criteria +1. Admin enters platform > "News & Events" > clicks "Add News/Event" +2. System displays upload form. For News: Title (255 chars), Image (PNG), Topic (dropdown CCE), Content (2000 chars). For Event: Title (255 chars), Location (255 chars URL), Event Date (date), Topic (dropdown CCE), Description (2000 chars) +3. Admin fills form and clicks "Submit" +4. System validates input data before uploading (BC001) +5. On success, confirmation message CON021 is displayed +6. On missing required fields, error message ERR013 is displayed +7. On upload error, error message ERR027 is displayed + +## Post-conditions +- Admin can delete the news/event if needed + +### Alternative Flows +- ALT001: If required fields not filled, system displays ERR013 + +### Business Rules +- BC001: Validate all input data before uploading news/event + +### Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR013 | Error | عذراً، الحقول الإجبارية غير مكتملة. | Required fields empty | +| ERR027 | Error | عذراً، حدثت مشكلة أثناء رفع الخبر/الفعالية. | News/event upload failure | + +### Confirmation Messages +| Code | Message (AR) | +|------|-------------| +| CON021 | تم رفع المصدر بنجاح! | + +### Form Fields & Validation Rules (News) +| Field | Type | Required | Max Length | Validation | +|-------|------|----------|------------|------------| +| Title | Free Text | Yes | 255 | Must be clear and accurate | +| Image | Attachment | Yes | - | Must be PNG format | +| Topic | Dropdown | Yes | - | Must select from CCE topics list | +| News Content | Free Text | Yes | 2000 | Must be clear and accurate | + +### Form Fields & Validation Rules (Event) +| Field | Type | Required | Max Length | Validation | +|-------|------|----------|------------|------------| +| Title | Free Text | Yes | 255 | Must be clear and accurate | +| Location | URL | Yes | 255 | Must be a valid URL | +| Event Date | Date | Yes | 500 | Must be valid date format (yyyy-mm-dd) | +| Topic | Dropdown | Yes | - | Must select from CCE topics list | +| Event Description | Free Text | Yes | 2000 | Must be accurate and cover event details | \ No newline at end of file diff --git a/backend/docs/Brd/stories/sprint-13-admin-news-events-resources/US045-delete-news-events.md b/backend/docs/Brd/stories/sprint-13-admin-news-events-resources/US045-delete-news-events.md new file mode 100644 index 00000000..1c1fa908 --- /dev/null +++ b/backend/docs/Brd/stories/sprint-13-admin-news-events-resources/US045-delete-news-events.md @@ -0,0 +1,57 @@ +# US045 - Delete News & Events + +## Epic +Admin News, Events & Resources + +## Feature Code +F045 + +## Sprint +Sprint 13: Admin News, Events & Resources + +## Priority +Medium + +## User Story +**As an** admin, **I want to** delete news and events, **so that** I can effectively organize content. + +## Roles +| Role | Access | +|------|--------| +| Super Admin | Can | +| Admin | Can | +| Content Manager | Can | + +## Preconditions +- User must be registered as admin +- News/events must be available + +## Acceptance Criteria +1. Admin navigates to news/event details +2. Admin clicks "Delete News/Event" +3. System displays confirmation dialog +4. Admin confirms deletion +5. System deletes the news/event and displays confirmation CON020 +6. Deletion must be permanent and irreversible (BC001) +7. If admin cancels, alternative flow ALT001 is triggered (no deletion) +8. On deletion error, error message ERR028 is displayed + +## Post-conditions +- All pages containing deleted data must be updated + +### Alternative Flows +- ALT001: If deletion fails, system displays ERR028 + +### Business Rules +- BC001: Deletion must be permanent and irreversible + +### Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR001 | Error | حدث خطأ أثناء تحميل الصفحة. | Page load error | +| ERR028 | Error | عذراً، حدثت مشكلة أثناء حذف الخبر/الفعالية. | News/event deletion failure | + +### Confirmation Messages +| Code | Message (AR) | +|------|-------------| +| CON020 | تم حذف الخبر/الفعالية بنجاح! | \ No newline at end of file diff --git a/backend/docs/Brd/stories/sprint-13-admin-news-events-resources/US046-view-resources-admin.md b/backend/docs/Brd/stories/sprint-13-admin-news-events-resources/US046-view-resources-admin.md new file mode 100644 index 00000000..03b22376 --- /dev/null +++ b/backend/docs/Brd/stories/sprint-13-admin-news-events-resources/US046-view-resources-admin.md @@ -0,0 +1,54 @@ +# US046 - View Resources (Admin) + +## Epic +Admin News, Events & Resources + +## Feature Code +F046 + +## Sprint +Sprint 13: Admin News, Events & Resources + +## Priority +Medium + +## User Story +**As an** admin, **I want to** view the available resources on the platform, **so that** I can review the content and related references. + +## Roles +| Role | Access | +|------|--------| +| Super Admin | Can | +| Admin | Can | +| Content Manager | Can | + +## Preconditions +- User must be registered as admin + +## Acceptance Criteria +1. Admin enters platform > "Resources" +2. System displays resources list +3. Admin selects a resource +4. System displays details in resource form (view-only) +5. System displays correct resource details (BC001) +6. If no resources exist, alternative flow ALT001 or info message INF004 is triggered +7. On load error, error message ERR001 is displayed + +## Post-conditions +- Admin can take additional actions like deleting if authorized + +### Alternative Flows +- ALT001: If no resources, system displays INF004 + +### Business Rules +- BC001: Display correct resource details + +### Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR001 | Error | حدث خطأ أثناء تحميل الصفحة. | Page load error | + +### Informational Messages +| Code | Type | Message (AR) | +|------|------|-------------| +| INF004 | Informational | عذراً، لا توجد مصادر حالياً. | \ No newline at end of file diff --git a/backend/docs/Brd/stories/sprint-13-admin-news-events-resources/US047-upload-resources.md b/backend/docs/Brd/stories/sprint-13-admin-news-events-resources/US047-upload-resources.md new file mode 100644 index 00000000..5be25ec6 --- /dev/null +++ b/backend/docs/Brd/stories/sprint-13-admin-news-events-resources/US047-upload-resources.md @@ -0,0 +1,65 @@ +# US047 - Upload Resources + +## Epic +Admin News, Events & Resources + +## Feature Code +F047 + +## Sprint +Sprint 13: Admin News, Events & Resources + +## Priority +Medium + +## User Story +**As an** admin, **I want to** upload resources, **so that** I can add new content to the platform. + +## Roles +| Role | Access | +|------|--------| +| Super Admin | Can | +| Admin | Can | +| Content Manager | Can | + +## Preconditions +- User must be registered as admin + +## Acceptance Criteria +1. Admin enters platform > "Resources" > clicks "Add Resource" +2. System displays upload form with fields: Title (255 chars), Topic (dropdown CCE), Description (500 chars), Publication Type (dropdown: paper/article/study/presentation/scientific paper/report/book/re research/CCE guide/media), Covered Countries (multi-select), File (PDF/Word or link) +3. Admin fills form and clicks "Submit" +4. System validates input data before uploading (BC001) +5. On success, confirmation message CON021 is displayed +6. On missing required fields, error message ERR013 is displayed +7. On upload error, error message ERR029 is displayed + +## Post-conditions +- Admin can delete the resource if needed + +### Alternative Flows +- ALT001: If required fields not filled, system displays ERR013 + +### Business Rules +- BC001: Validate all input data before uploading resource + +### Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR013 | Error | عذراً، الحقول الإجبارية غير مكتملة. | Required fields empty | +| ERR029 | Error | عذراً، حدثت مشكلة أثناء رفع المصدر. | Resource upload failure | + +### Confirmation Messages +| Code | Message (AR) | +|------|-------------| +| CON021 | تم رفع المصدر بنجاح! | + +### Form Fields & Validation Rules +| Field | Type | Required | Max Length | Validation | +|-------|------|----------|------------|------------| +| Title | Free Text | Yes | 255 | Must be clear and accurate | +| Topic | Dropdown | Yes | - | Must select from CCE topics list | +| Description | Free Text | Yes | 500 | - | +| Publication Type | Dropdown | Yes | - | Options: Paper, Article, Study, Presentation, Scientific Paper, Report, Book, Research, CCE Guide, Media | +| Covered Countries | Multi-select Dropdown | Yes | - | Must select from countries list | +| File | File/Link | Yes | - | Must be PDF or Word, or a valid link | \ No newline at end of file diff --git a/backend/docs/Brd/stories/sprint-13-admin-news-events-resources/US048-delete-resources.md b/backend/docs/Brd/stories/sprint-13-admin-news-events-resources/US048-delete-resources.md new file mode 100644 index 00000000..34ea6dee --- /dev/null +++ b/backend/docs/Brd/stories/sprint-13-admin-news-events-resources/US048-delete-resources.md @@ -0,0 +1,57 @@ +# US048 - Delete Resources + +## Epic +Admin News, Events & Resources + +## Feature Code +F048 + +## Sprint +Sprint 13: Admin News, Events & Resources + +## Priority +Medium + +## User Story +**As an** admin, **I want to** delete resources from the platform, **so that** I can effectively organize content. + +## Roles +| Role | Access | +|------|--------| +| Super Admin | Can | +| Admin | Can | +| Content Manager | Can | + +## Preconditions +- User must be registered as admin +- Resources must be available + +## Acceptance Criteria +1. Admin navigates to resource details +2. Admin clicks "Delete Resource" +3. System displays confirmation dialog +4. Admin confirms deletion +5. System deletes the resource and displays confirmation CON022 +6. Deletion must be permanent and irreversible (BC001) +7. On deletion error, error message ERR030 is displayed +8. On load error, error message ERR001 is displayed + +## Post-conditions +- All pages containing deleted resource data must be updated + +### Alternative Flows +- ALT001: If deletion fails, system displays ERR030 + +### Business Rules +- BC001: Deletion must be permanent and irreversible + +### Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR001 | Error | حدث خطأ أثناء تحميل الصفحة. | Page load error | +| ERR030 | Error | عذراً، حدثت مشكلة أثناء حذف المصدر. | Resource deletion failure | + +### Confirmation Messages +| Code | Message (AR) | +|------|-------------| +| CON022 | تم حذف المصدر بنجاح! | \ No newline at end of file diff --git a/backend/docs/Brd/stories/sprint-14-admin-country-requests-community/US049-view-country-requests.md b/backend/docs/Brd/stories/sprint-14-admin-country-requests-community/US049-view-country-requests.md new file mode 100644 index 00000000..fd56dce3 --- /dev/null +++ b/backend/docs/Brd/stories/sprint-14-admin-country-requests-community/US049-view-country-requests.md @@ -0,0 +1,54 @@ +# US049 - View Country Requests + +## Epic +Admin Country Requests & Community + +## Feature Code +F049 + +## Sprint +Sprint 14: Admin Country Requests & Community + +## Priority +High + +## User Story +**As an** admin, **I want to** view resource/news/events requests submitted by countries, **so that** I can review them and take appropriate actions. + +## Roles +| Role | Access | +|------|--------| +| Super Admin | Can | +| Admin | Can | + +## Preconditions +- User must be registered as admin +- Requests must be available + +## Acceptance Criteria +1. Admin enters platform > "Requests" +2. System displays request list +3. Admin selects a request +4. System displays request details based on type (resource or news/event form, view-only) +5. System displays correct request details (BC001) +6. If no requests exist, alternative flow ALT001 or info message INF005 is triggered +7. On load error, error message ERR001 is displayed + +## Post-conditions +- Admin can approve or reject the request + +### Alternative Flows +- ALT001: If no requests available, system displays INF005 + +### Business Rules +- BC001: Display correct request details + +### Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR001 | Error | حدث خطأ أثناء تحميل الصفحة. | Page load error | + +### Informational Messages +| Code | Type | Message (AR) | +|------|------|-------------| +| INF005 | Informational | عذراً، لا توجد طلبات متاحة حالياً. | \ No newline at end of file diff --git a/backend/docs/Brd/stories/sprint-14-admin-country-requests-community/US050-process-country-request.md b/backend/docs/Brd/stories/sprint-14-admin-country-requests-community/US050-process-country-request.md new file mode 100644 index 00000000..cfd17218 --- /dev/null +++ b/backend/docs/Brd/stories/sprint-14-admin-country-requests-community/US050-process-country-request.md @@ -0,0 +1,60 @@ +# US050 - Process Country Request + +## Epic +Admin Country Requests & Community + +## Feature Code +F050 + +## Sprint +Sprint 14: Admin Country Requests & Community + +## Priority +High + +## User Story +**As an** admin, **I want to** process resource/news/events requests submitted by countries, **so that** I can approve or reject them based on review. + +## Roles +| Role | Access | +|------|--------| +| Super Admin | Can | +| Admin | Can | + +## Preconditions +- User must be registered as admin +- Requests must be available + +## Acceptance Criteria +1. Admin navigates to a request and reviews details +2. Admin selects "Approve" or "Reject" +3. System updates request status and displays confirmation CON023 +4. System sends notification to State Rep (MSG002) +5. Must notify the relevant user about request status (approved/rejected) (BC001) +6. If no requests exist, alternative flow ALT001 or info message INF005 is triggered +7. On processing error, error message ERR031 is displayed + +## Post-conditions +- Request list updated with new status + +### Alternative Flows +- ALT001: If no requests available, system displays INF005 + +### Business Rules +- BC001: Must notify the relevant user about request status (approved/rejected) + +### Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR001 | Error | حدث خطأ أثناء تحميل الصفحة. | Page load error | +| ERR031 | Error | عذراً، حدثت مشكلة أثناء معالجة الطلب. | Request processing failure | + +### Confirmation Messages +| Code | Message (AR) | +|------|-------------| +| CON023 | تمت معالجة الطلب بنجاح! | + +### Notification Messages +| Code | Message (AR) | +|------|-------------| +| MSG002 | عزيزي/عزيزتي [اسم الممثل]، نود إبلاغكم أنه تم اتخاذ إجراء على الطلب المرفوع من قبل دولتكم... | \ No newline at end of file diff --git a/backend/docs/Brd/stories/sprint-14-admin-country-requests-community/US054-view-community-admin.md b/backend/docs/Brd/stories/sprint-14-admin-country-requests-community/US054-view-community-admin.md new file mode 100644 index 00000000..c11d5e33 --- /dev/null +++ b/backend/docs/Brd/stories/sprint-14-admin-country-requests-community/US054-view-community-admin.md @@ -0,0 +1,52 @@ +# US054 - View Community (Admin) + +## Epic +Admin Country Requests & Community + +## Feature Code +F053 + +## Sprint +Sprint 14: Admin Country Requests & Community + +## Priority +Medium + +## User Story +**As an** admin, **I want to** view the Knowledge Community, **so that** I can review uploaded content and other posts and take appropriate actions. + +## Roles +| Role | Access | +|------|--------| +| Super Admin | Can | +| Admin | Can | +| Content Manager | Can | + +## Preconditions +- Posts must be available + +## Acceptance Criteria +1. Admin enters platform > "Knowledge Community" +2. System displays community with available posts +3. System displays community content based on platform data (BC001) +4. If no posts exist, alternative flow ALT001 or notification NTF001 is triggered +5. On load error, error message ERR001 is displayed + +## Post-conditions +- Admin can take actions like deleting posts + +### Alternative Flows +- ALT001: If no posts available, system displays NTF001 + +### Business Rules +- BC001: Display community content based on available platform data + +### Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR001 | Error | حدث خطأ أثناء تحميل الصفحة. | Page load error | + +### Informational Messages +| Code | Type | Message (AR) | +|------|------|-------------| +| NTF001 | Notification | عذراً، لا توجد منشورات حالياً. | \ No newline at end of file diff --git a/backend/docs/Brd/stories/sprint-14-admin-country-requests-community/US055-view-topic-groups-admin.md b/backend/docs/Brd/stories/sprint-14-admin-country-requests-community/US055-view-topic-groups-admin.md new file mode 100644 index 00000000..6a20eed7 --- /dev/null +++ b/backend/docs/Brd/stories/sprint-14-admin-country-requests-community/US055-view-topic-groups-admin.md @@ -0,0 +1,53 @@ +# US055 - View Topic Groups (Admin) + +## Epic +Admin Country Requests & Community + +## Feature Code +F054 + +## Sprint +Sprint 14: Admin Country Requests & Community + +## Priority +Medium + +## User Story +**As an** admin, **I want to** view topic groups, **so that** I can browse posts related to a specific topic. + +## Roles +| Role | Access | +|------|--------| +| Super Admin | Can | +| Admin | Can | +| Content Manager | Can | + +## Preconditions +- Posts must be available + +## Acceptance Criteria +1. Admin enters platform > "Knowledge Community" +2. Admin selects a topic group +3. System displays categorized posts +4. System displays only posts related to selected topic (BC001) +5. If no posts exist, alternative flow ALT001 or notification NTF001 is triggered +6. On load error, error message ERR001 is displayed + +## Post-conditions +- Admin can modify selection or return to homepage + +### Alternative Flows +- ALT001: If no posts available, system displays NTF001 + +### Business Rules +- BC001: Display only posts related to the selected topic + +### Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR001 | Error | حدث خطأ أثناء تحميل الصفحة. | Page load error | + +### Informational Messages +| Code | Type | Message (AR) | +|------|------|-------------| +| NTF001 | Notification | عذراً، لا توجد منشورات حالياً. | \ No newline at end of file diff --git a/backend/docs/Brd/stories/sprint-14-admin-country-requests-community/US056-view-post-admin.md b/backend/docs/Brd/stories/sprint-14-admin-country-requests-community/US056-view-post-admin.md new file mode 100644 index 00000000..8f018ea2 --- /dev/null +++ b/backend/docs/Brd/stories/sprint-14-admin-country-requests-community/US056-view-post-admin.md @@ -0,0 +1,52 @@ +# US056 - View Post (Admin) + +## Epic +Admin Country Requests & Community + +## Feature Code +F055 + +## Sprint +Sprint 14: Admin Country Requests & Community + +## Priority +Medium + +## User Story +**As an** admin, **I want to** view a post, **so that** I can see the full details of the submitted post. + +## Roles +| Role | Access | +|------|--------| +| Super Admin | Can | +| Admin | Can | +| Content Manager | Can | + +## Preconditions +- Posts must be available + +## Acceptance Criteria +1. Admin navigates to Knowledge Community and selects a post +2. System displays post with all details +3. System displays full post based on available data (BC001) +4. If no posts exist, alternative flow ALT001 or notification NTF001 is triggered +5. On load error, error message ERR001 is displayed + +## Post-conditions +- Admin can delete posts + +### Alternative Flows +- ALT001: If no posts available, system displays NTF001 + +### Business Rules +- BC001: Display full post based on available data + +### Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR001 | Error | حدث خطأ أثناء تحميل الصفحة. | Page load error | + +### Informational Messages +| Code | Type | Message (AR) | +|------|------|-------------| +| NTF001 | Notification | عذراً، لا توجد منشورات حالياً. | \ No newline at end of file diff --git a/backend/docs/Brd/stories/sprint-14-admin-country-requests-community/US057-delete-post.md b/backend/docs/Brd/stories/sprint-14-admin-country-requests-community/US057-delete-post.md new file mode 100644 index 00000000..0112638d --- /dev/null +++ b/backend/docs/Brd/stories/sprint-14-admin-country-requests-community/US057-delete-post.md @@ -0,0 +1,63 @@ +# US057 - Delete Post + +## Epic +Admin Country Requests & Community + +## Feature Code +F056 + +## Sprint +Sprint 14: Admin Country Requests & Community + +## Priority +Medium + +## User Story +**As an** admin, **I want to** delete a post, **so that** I can effectively manage Knowledge Community content and maintain content quality. + +## Roles +| Role | Access | +|------|--------| +| Super Admin | Can | +| Admin | Can | +| Content Manager | Can | + +## Preconditions +- Post must exist +- User must be admin/content manager + +## Acceptance Criteria +1. Admin navigates to a post and clicks "Delete Post" +2. System displays confirmation dialog +3. Admin confirms deletion +4. System deletes the post and displays confirmation CON025 +5. System notifies post author (MSG004) +6. Deletion must be permanent and irreversible; must notify admin and user about deletion (BC001) +7. On deletion error, error message ERR032 is displayed +8. On load error, error message ERR001 is displayed + +## Post-conditions +- Post removed and post list updated immediately; author notified + +### Alternative Flows +- ALT001: If deletion fails, system displays ERR032 + +### Business Rules +- BC001: Deletion must be permanent and irreversible +- Must notify admin and user about deletion status + +### Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR001 | Error | حدث خطأ أثناء تحميل الصفحة. | Page load error | +| ERR032 | Error | عذراً، حدثت مشكلة أثناء حذف المنشور. | Post deletion failure | + +### Confirmation Messages +| Code | Message (AR) | +|------|-------------| +| CON025 | تم حذف المنشور بنجاح! | + +### Notification Messages +| Code | Message (AR) | +|------|-------------| +| MSG004 | عزيزي/عزيزتي [اسم المستخدم]، نود إبلاغك أنه تم حذف المنشور الذي قمت بنشره في مجتمع المعرفة... | \ No newline at end of file diff --git a/backend/docs/Brd/stories/sprint-14-admin-country-requests-community/US058-view-expert-requests.md b/backend/docs/Brd/stories/sprint-14-admin-country-requests-community/US058-view-expert-requests.md new file mode 100644 index 00000000..8b210392 --- /dev/null +++ b/backend/docs/Brd/stories/sprint-14-admin-country-requests-community/US058-view-expert-requests.md @@ -0,0 +1,54 @@ +# US058 - View Expert Requests + +## Epic +Admin Country Requests & Community + +## Feature Code +F057 + +## Sprint +Sprint 14: Admin Country Requests & Community + +## Priority +High + +## User Story +**As an** admin, **I want to** process expert registration requests, **so that** I can approve or reject them based on reviewing the details. + +## Roles +| Role | Access | +|------|--------| +| Super Admin | Can | +| Admin | Can | + +## Preconditions +- User must be registered as admin +- Requests must be available + +## Acceptance Criteria +1. Admin enters platform > "Requests" +2. System displays request list +3. Admin selects an expert registration request +4. System displays request details in expert registration form (view-only) +5. System displays correct request details (BC001) +6. If no requests exist, alternative flow ALT001 or info message INF005 is triggered +7. On load error, error message ERR001 is displayed + +## Post-conditions +- Admin can approve or reject the request + +### Alternative Flows +- ALT001: If no requests available, system displays INF005 + +### Business Rules +- BC001: Display correct request details + +### Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR001 | Error | حدث خطأ أثناء تحميل الصفحة. | Page load error | + +### Informational Messages +| Code | Type | Message (AR) | +|------|------|-------------| +| INF005 | Informational | عذراً، لا توجد طلبات متاحة حالياً. | \ No newline at end of file diff --git a/backend/docs/Brd/stories/sprint-14-admin-country-requests-community/US059-process-expert-requests.md b/backend/docs/Brd/stories/sprint-14-admin-country-requests-community/US059-process-expert-requests.md new file mode 100644 index 00000000..abe97286 --- /dev/null +++ b/backend/docs/Brd/stories/sprint-14-admin-country-requests-community/US059-process-expert-requests.md @@ -0,0 +1,61 @@ +# US059 - Process Expert Requests + +## Epic +Admin Country Requests & Community + +## Feature Code +F058 + +## Sprint +Sprint 14: Admin Country Requests & Community + +## Priority +High + +## User Story +**As an** admin, **I want to** view country resource requests submitted by countries, **so that** I can review them and take appropriate actions. + +## Roles +| Role | Access | +|------|--------| +| Super Admin | Can | +| Admin | Can | + +## Preconditions +- User must be registered as admin +- Requests must be available + +## Acceptance Criteria +1. Admin navigates to a request and reviews details +2. Admin selects "Approve" (adds user to experts list and grants expert badge) or "Reject" +3. System updates request status and displays confirmation CON023 +4. System notifies user (MSG005) +5. System displays correct request details (BC001) +6. If no requests exist, alternative flow ALT001 or info message INF005 is triggered +7. On processing error, error message ERR001 is displayed + +## Post-conditions +- Applicant notified of decision; system data updated based on decision + +### Alternative Flows +- ALT001: If no requests available, system displays INF005 + +### Business Rules +- BC001: Display correct request details +- On approval: add user to experts list and add expert badge +- On rejection: notify user of rejection + +### Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR001 | Error | حدث حدث خطأ أثناء تحميل الصفحة. | Page load error | + +### Confirmation Messages +| Code | Message (AR) | +|------|-------------| +| CON023 | تمت معالجة الطلب بنجاح! | + +### Notification Messages +| Code | Message (AR) | +|------|-------------| +| MSG005 | عزيزي/عزيزتي [اسم المستخدم]، نود إبلاغكم أنه تم اتخاذ إجراء على الطلب للتسجيل كخبير المرفوع من قبلكم... | \ No newline at end of file diff --git a/backend/docs/Brd/stories/sprint-15-state-representative/US051-view-resource-requests-state.md b/backend/docs/Brd/stories/sprint-15-state-representative/US051-view-resource-requests-state.md new file mode 100644 index 00000000..9245c357 --- /dev/null +++ b/backend/docs/Brd/stories/sprint-15-state-representative/US051-view-resource-requests-state.md @@ -0,0 +1,53 @@ +# US051 - View Resource Requests (State) + +## Epic +State Representative + +## Feature Code +F051 + +## Sprint +Sprint 15: State Representative + +## Priority +Medium + +## User Story +**As a** State Representative, **I want to** view resource/news/events requests submitted by my country, **so that** I can track their status and take appropriate actions. + +## Roles +| Role | Access | +|------|--------| +| State Representative | Can | + +## Preconditions +- User must be registered as State Rep +- Requests must have been submitted by their state + +## Acceptance Criteria +1. State Rep enters platform > "Requests" +2. System displays list of state's resource requests +3. State Rep selects a request +4. System displays request details (resource form or news/event form, view-only) +5. System displays correct request details (BC001) +6. If no requests exist, alternative flow ALT001 or info message INF005 is triggered +7. On load error, error message ERR001 is displayed + +## Post-conditions +- State Rep can track request status + +### Alternative Flows +- ALT001: If no requests available, system displays INF005 + +### Business Rules +- BC001: Display correct request details + +### Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR001 | Error | حدث خطأ أثناء تحميل الصفحة. | Page load error | + +### Informational Messages +| Code | Type | Message (AR) | +|------|------|-------------| +| INF005 | Informational | عذراً، لا توجد طلبات متاحة حالياً. | \ No newline at end of file diff --git a/backend/docs/Brd/stories/sprint-15-state-representative/US052-upload-resources-state.md b/backend/docs/Brd/stories/sprint-15-state-representative/US052-upload-resources-state.md new file mode 100644 index 00000000..802e6269 --- /dev/null +++ b/backend/docs/Brd/stories/sprint-15-state-representative/US052-upload-resources-state.md @@ -0,0 +1,62 @@ +# US052 - Upload Resources (State) + +## Epic +State Representative + +## Feature Code +F052 + +## Sprint +Sprint 15: State Representative + +## Priority +Medium + +## User Story +**As a** State Representative, **I want to** upload resources, **so that** I can add new content to the platform. + +## Roles +| Role | Access | +|------|--------| +| State Representative | Can | +| Admin | Can | +| Super Admin | Can | + +## Preconditions +- User must be registered as State Rep + +## Acceptance Criteria +1. State Rep enters platform > "Resources" +2. System shows list of previously submitted/accepted resources +3. State Rep clicks "Add Resource" +4. System displays upload form (same as admin resource form) +5. State Rep fills form and clicks "Submit" +6. System validates input data before uploading (BC001) +7. System notifies admin (MSG003) and displays confirmation CON024 +8. On missing required fields, error message ERR013 is displayed +9. On upload error, error message ERR029 is displayed + +## Post-conditions +- Admin reviews and processes the request + +### Alternative Flows +- ALT001: If required fields not filled, system displays ERR013 + +### Business Rules +- BC001: Validate all input data before uploading resource + +### Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR013 | Error | عذراً، الحقول الإجبارية غير مكتملة. | Required fields empty | +| ERR029 | Error | عذراً، حدثت مشكلة أثناء رفع المصدر. | Resource upload failure | + +### Confirmation Messages +| Code | Message (AR) | +|------|-------------| +| CON024 | تم إرسال طلبك بنجاح. سيتم مراجعته من قبل المشرف قريباً. شكراً لمساهمتك! | + +### Notification Messages +| Code | Message (AR) | +|------|-------------| +| MSG003 | عزيزي المشرف، تم تقديم طلب رفع مصدر جديد من قبل ممثل الدولة [اسم الممثل]. يرجى مراجعة البيانات المدخلة بعناية واتخاذ الإجراءات المناسبة. | \ No newline at end of file diff --git a/backend/docs/Brd/stories/sprint-15-state-representative/US053-upload-news-events-state.md b/backend/docs/Brd/stories/sprint-15-state-representative/US053-upload-news-events-state.md new file mode 100644 index 00000000..52c75131 --- /dev/null +++ b/backend/docs/Brd/stories/sprint-15-state-representative/US053-upload-news-events-state.md @@ -0,0 +1,62 @@ +# US053 - Upload News & Events (State) + +## Epic +State Representative + +## Feature Code +US053 + +## Sprint +Sprint 15: State Representative + +## Priority +Medium + +## User Story +**As a** State Representative, **I want to** upload news or events, **so that** I can add new content to the platform. + +## Roles +| Role | Access | +|------|--------| +| State Representative | Can | +| Admin | Can | +| Super Admin | Can | + +## Preconditions +- User must be registered as State Rep + +## Acceptance Criteria +1. State Rep enters platform > "News & Events" +2. System shows list of previously submitted/accepted items +3. State Rep clicks "Add News/Event" +4. System displays upload form (news or event form) +5. State Rep fills form and clicks "Submit" +6. System validates input data before uploading (BC001) +7. System notifies admin (MSG003) and displays confirmation CON024 +8. On missing required fields, error message ERR013 is displayed +9. On upload error, error message ERR029 is displayed + +## Post-conditions +- Admin reviews and processes the request + +### Alternative Flows +- ALT001: If required fields not filled, system displays ERR013 + +### Business Rules +- BC001: Validate all input data before uploading news/event + +### Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR013 | Error | عذراً، الحقول الإجبارية غير مكتملة. | Required fields empty | +| ERR029 | Error | عذراً، حدثت مشكلة أثناء رفع المصدر. | Upload failure | + +### Confirmation Messages +| Code | Message (AR) | +|------|-------------| +| CON024 | تم إرسال طلبك بنجاح. سيتم مراجعته من قبل المشرف قريباً. شكراً لمساهمتك! | + +### Notification Messages +| Code | Message (AR) | +|------|-------------| +| MSG003 | عزيزي المشرف، تم تقديم طلب رفع مصدر جديد من قبل ممثل الدولة [اسم الممثل]. يرجى مراجعة البيانات المدخلة بعناية واتخاذ الإجراءات المناسبة. | \ No newline at end of file diff --git a/backend/docs/Brd/stories/sprint-15-state-representative/US060-view-state-profile-state.md b/backend/docs/Brd/stories/sprint-15-state-representative/US060-view-state-profile-state.md new file mode 100644 index 00000000..7acda8d7 --- /dev/null +++ b/backend/docs/Brd/stories/sprint-15-state-representative/US060-view-state-profile-state.md @@ -0,0 +1,55 @@ +# US060 - View State Profile (State) + +## Epic +State Representative + +## Feature Code +F059 + +## Sprint +Sprint 15: State Representative + +## Priority +Medium + +## User Story +**As a** State Representative, **I want to** view my country's profile, **so that** I can review accurate and up-to-date information about the country. + +## Roles +| Role | Access | +|------|--------| +| State Representative | Can | + +## Preconditions +- User must be registered as State Rep +- Profile must be available + +## Acceptance Criteria +1. State Rep enters platform > "State Profile" +2. System displays state profile details: population, area, GDP per capita, CCE classification, CCE performance, CCE Total Index +3. System must correctly retrieve and display all state profile data including KAPSARC-linked data (BC001) +4. If no profile exists, alternative flow ALT001 or info message INF005 is triggered +5. On load error, error message ERR001 is displayed + +## Post-conditions +- State Rep can update the profile data + +### Alternative Flows +- ALT001: If no state profile found, system displays INF005 + +### Business Rules +- BC001: System must correctly retrieve and display state profile data including KAPSARC-linked data (CCE Classification, CCE Performance, CCE Total Index) + +### Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR001 | Error | حدث خطأ أثناء تحميل الصفحة. | Page load error | + +### Informational Messages +| Code | Type | Message (AR) | +|------|------|-------------| +| INF005 | Informational | عذراً، لا توجد طلبات متاحة حالياً. | + +### KAPSARC Integration +- Requires KASPARK API integration for CCE Classification, CCE Performance, and CCE Total Index data +- See appendix for KAPSARC service specification \ No newline at end of file diff --git a/backend/docs/Brd/stories/sprint-15-state-representative/US061-update-state-profile.md b/backend/docs/Brd/stories/sprint-15-state-representative/US061-update-state-profile.md new file mode 100644 index 00000000..96344340 --- /dev/null +++ b/backend/docs/Brd/stories/sprint-15-state-representative/US061-update-state-profile.md @@ -0,0 +1,69 @@ +# US061 - Update State Profile + +## Epic +State Representative + +## Feature Code +F060 + +## Sprint +Sprint 15: State Representative + +## Priority +Medium + +## User Story +**As a** State Representative, **I want to** update my country's profile, **so that** I can update country-related information according to the latest available data. + +## Roles +| Role | Access | +|------|--------| +| State Representative | Can | +| Admin | Can | +| Super Admin | Can | + +## Preconditions +- User must be registered as State Rep +- Profile must be available + +## Acceptance Criteria +1. State Rep navigates to state profile and reviews data +2. State Rep clicks "Edit" +3. State Rep modifies editable fields: Population (integer > 0), Area (decimal > 0), GDP per capita (decimal > 0), Nationally Determined Contribution (PNG attachment) +4. CCE Classification, CCE Performance, and CCE Total Index are read-only (retrieved from KAPSARC) +5. State Rep clicks "Save Updates" +6. State Rep can only edit manually entered data; KAPSARC-linked data cannot be modified (BC001) +7. On success, confirmation message CON026 is displayed +8. On missing required fields, error message ERR013 is displayed +9. On update error, error message ERR033 is displayed + +## Post-conditions +- State Rep can review updated data or make future modifications + +### Alternative Flows +- ALT001: If required fields left empty, system displays ERR013 requesting all mandatory fields be filled + +### Business Rules +- BC001: State Rep can only edit manually entered data; KAPSARC-linked data (CCE Classification, Performance, Total Index) cannot be modified + +### Error Codes & Messages +| Code | Type | Message (AR) | Trigger | +|------|------|-------------|---------| +| ERR013 | Error | عذراً، الحقول الإجبارية غير مكتملة. | Required fields empty | +| ERR033 | Error | عذراً، حدثت مشكلة أثناء تحديث البيانات. | State profile update failure | + +### Confirmation Messages +| Code | Message (AR) | +|------|-------------| +| CON026 | تم تحديث الملف التعريفي للدولة بنجاح! | + +### Form Fields & Validation Rules +| Field | Type | Required | Validation | +|-------|------|----------|------------| +| Population | Number/Integer | Yes | Must be an integer greater than 0 | +| Area | Number/Decimal | Yes | Must be greater than 0 | +| GDP per capita | Number/Decimal | Yes | Must be greater than 0 | +| Nationally Determined Contribution (PDF) | Attachment | Yes | Must be PNG format | +| CCE Classification | Text (Display Only) | Yes | Retrieved from KAPSARC, cannot be edited | +| CCE Performance | Text (Display Only) | Yes | Retrieved from KAPSARC, cannot be edited | +| CCE Total Index | Number/Decimal (Display Only) | Yes | Retrieved from KAPSARC, cannot be edited | \ No newline at end of file diff --git "a/backend/docs/Brd/\331\210\330\253\331\212\331\202\330\251_\331\205\330\252\330\267\331\204\330\250\330\247\330\252_\330\247\331\204\330\243\330\271\331\205\330\247\331\204_V_4_0.md" "b/backend/docs/Brd/\331\210\330\253\331\212\331\202\330\251_\331\205\330\252\330\267\331\204\330\250\330\247\330\252_\330\247\331\204\330\243\330\271\331\205\330\247\331\204_V_4_0.md" new file mode 100644 index 00000000..68cf83eb --- /dev/null +++ "b/backend/docs/Brd/\331\210\330\253\331\212\331\202\330\251_\331\205\330\252\330\267\331\204\330\250\330\247\330\252_\330\247\331\204\330\243\330\271\331\205\330\247\331\204_V_4_0.md" @@ -0,0 +1,5619 @@ +--- +title: وثيقة متطلبات الأعمال - المرحلة الثانية لمركز المعرفة للاقتصاد الدائري للكربون +author: وكالة الاستدامة والتغير المناخي +lang: ar +dir: rtl +--- + +وثيقة متطلبات األعمال ل “المرحلة الثانية +لمركز المعرفة لالقتصاد الدائري للكربون" +وكالة االستدامة والتغير المناخي +نسخة ١ + + +--- + + +المحتوى + +7 .1الوثيقة +.1.1اإلصدارات 7 +.1.2المراجعة7 +.1.3االعتماد 7 +.1.4الغرض من الوثيقة 7 +8 .2المقدمة +.2.1تعاريف ومصطلحات 8 +.2.2المراجع 8 +.2.3أطراف المشروع 9 +.3نظرة عامة 10 +.3.1وصف المشروع 10 +.3.2استراتيجية التغيير 10 +.3.2.1تحليل الوضع الحالي 10 +.3.2.2الوضع المستقبلي 10 +.3.2.3إجراءات أعمال للمنصة 13 +.3.2.3.1المستخدم 13 +.3.2.3.1.1الصفحة الرئيسية 13 +.3.2.3.1.2تعرف على المنصة 14 +.3.2.3.1.3المصادر 15 +.3.2.3.1.4الخرائط المعرفية 15 +.3.2.3.1.5المدينة التفاعلية 15 +.3.2.3.1.6االخبار والفعاليات 16 +.3.2.3.1.7الملف التعريفي للدولة 16 +.3.2.3.1.8الملف الشخصي 17 +.3.2.3.1.9تقييم الخدمات 17 +.3.2.3.1.10المقترحات المخصصة 17 +.3.2.3.1.11البحث بمساعدة المساعد الذكي 18 +.3.2.3.1.12مجتمع المعرفة -المنشور 18 +.3.2.3.1.13مجتمع المعرفة -المجتمع 18 +.3.2.3.1.14السياسات واالحكام 19 +.3.2.3.2المشرف 20 +.3.2.3.2.1تحديث المحتوى 20 + + +--- + + +.3.2.3.2.2إدارة المستخدمين20 +.3.2.3.2.3األخبار والفعاليات 21 +.3.2.3.2.4المصادر – مصادر المركز 21 +.3.2.3.2.5المصادر – مصادر الدول 21 +.3.2.3.2.6مجتمع المعرفة – المنشور 22 +.3.2.3.2.7مجتمع المعرفة – الخبير 22 +.3.2.3.2.8الملف التعريفي للدولة 22 +.3.2.4تحليل أصحاب المصلحة 23 +.4نطاق الحل 24 +.4.1متطلبات األعمال 24 +.4.1.1الصفحة الرئيسية -المستخدم 24 +.4.1.2تعرف على المنصة – المستخدم 25 +.4.1.3المصادر – المستخدم 25 +.4.1.4الخرائط المعرفية – المستخدم 26 +.4.1.5المدينة التفاعلية – المستخدم 27 +.4.1.6األخبار والفعاليات – المستخدم 28 +.4.1.7الملف التعريفي للدولة – المستخدم 29 +.4.1.8الملف الشخصي – المستخدم 30 +.4.1.9تقييم الخدمات – المستخدم 31 +.4.1.10تحديد المقترحات المخصصة 32 +.4.1.11البحث بمساعدة المساعد الذكي – المستخدم 33 +.4.1.12مجتمع المعرفة – المنشور – المستخدم 34 +.4.1.13مجتمع المعرفة – المجتمع – المستخدم 34 +.4.1.14السياسات واالحكام – المستخدم 35 +.4.1.15خدمات الدعم األساسية – إنشاء حساب – المستخدم 35 +.4.1.16خدمات الدعم األساسية – تسجيل الدخول – المستخدم 35 +.4.1.17خدمات الدعم األساسية – استعادة كلمة المرور – المستخدم 36 +.4.1.18خدمات الدعم األساسية – تسجيل الخروج – المستخدم 36 +.4.1.19تحديث المحتوى – المشرفين 37 +.4.1.20إدارة المستخدمين – المشرفين 37 +.4.1.21األخبار والفعاليات – المشرفين 37 +.4.1.22المصادر – مصادر المركز – المشرفين 38 +.4.1.23المصادر – مصادر الدول – المشرفين 38 +.4.1.24مجتمع المعرفة – المنشور – المشرفين 40 + + +--- + + +.4.1.25مجتمع المعرفة – الخبير – المشرفين 40 +.4.1.26الملف التعريفي للدولة – ممثل الدولة 40 +.4.1.27خدمات الدعم األساسية – تسجيل الدخول – المشرفين 41 +.4.1.28خدمات الدعم األساسية – استعادة كلمة المرور – المشرفين 41 +.4.1.29خدمات الدعم األساسية – تسجيل الخروج – المشرفين 41 +(USE CASE DIAGRAM ).4.1.30رسم حاالت االستخدام 42 +.4.1.30.1رسم حالة االستخدام للمشرفين 42 +.4.1.30.2رسم حالة االستخدام للمستخدم 43 +.4.1.31مصفوفة الصالحيات 44 +.4.1.32متطلبات الحل غير الوظيفية 47 +.5مالحظات عامة 49 +.5.1االفتراضات 49 +.5.2االعتمادية 49 +.5.3المخاطر 50 +.6سيناريوهات األعمال 51 +.6.1جدول قصص المستخدم 51 +.6.2قصص المستخدم 54 +.6.2.1استعراض الصفحة الرئيسية 54 +.6.2.2استعراض تعرف على المنصة 55 +.6.2.3استعراض المصادر 56 +.6.2.4تحميل المصادر 57 +.6.2.5مشاركة المصادر 58 +.6.2.6استعراض الخرائط المعرفية 59 +.6.2.7التفاعل مع الخرائط المعرفية 60 +.6.2.8استعراض المدينة التفاعلية 61 +.6.2.9التفاعل مع المدينة التفاعلية 62 +.6.2.10استعراض االخبار والفعاليات 63 +.6.2.11مشاركة االخبار والفعاليات 64 +.6.2.12متابعة صفحة االخبار 64 +.6.2.13إضافة فعالية إلى التقويم 66 +.6.2.14استعراض الملف التعريفي للدولة 67 +.6.2.15استعراض الملف الشخصي 68 +.6.2.16تعديل بيانات الملف الشخصي 69 +.6.2.17التسجيل كخبير في مجتمع المعرفة 70 + + +--- + + +.6.2.18تقييم خدمات الموقع 71 +.6.2.19تحديد مقترحات مخصصة للمستخدم بحسب معلوماته 72 +.6.2.20البحث بمساعدة المساعد الذكي 72 +.6.2.21استعراض مجتمع المعرفة 75 +.6.2.22استعراض مجموعات المواضيع 76 +.6.2.23متابعة مجموعة -موضوع77 - +.6.2.24استعراض منشور 78 +.6.2.25مشاركة منشور 79 +.6.2.26إنشاء منشور 80 +.6.2.27التفاعل مع منشور 81 +.6.2.28متابعة منشور 82 +.6.2.29الرد على منشور 83 +.6.2.30استعراض الملف الشخصي لمستخدم 84 +.6.2.31متابعة مستخدم 85 +.6.2.32استعراض السياسات واالحكام 86 +.6.2.33إنشاء حساب 87 +.6.2.34تسجيل الدخول 88 +.6.2.35استعادة كلمة المرور 89 +.6.2.36تسجيل الخروج 90 +.6.2.37تحديث محتوى الصفحة الرئيسية 91 +.6.2.38تحديث تعرف على المنصة 92 +.6.2.39تحديث السياسات واالحكام 93 +.6.2.40استعراض المستخدمين 94 +.6.2.41إنشاء مستخدم 95 +.6.2.42حذف مستخدم 96 +.6.2.43استعراض األخبار والفعاليات 97 +.6.2.44رفع األخبار والفعاليات 98 +.6.2.45حذف األخبار والفعاليات 100 +.6.2.46استعراض المصادر 101 +.6.2.47رفع المصادر 102 +.6.2.48حذف المصادر 103 +.6.2.49استعراض طلبات مصادر الدول 104 +.6.2.50معالجة طلب مصادر الدولة 105 +.6.2.51استعراض الطلبات للمصادر – ممثل الدولة 107 + + +--- + + +.6.2.52رفع المصادر – ممثل الدولة 108 +.6.2.53استعراض مجتمع المعرفة -المشرف 110 +.6.2.54استعراض مجموعات المواضيع -المشرف 111 +.6.2.55استعراض منشور -المشرف 112 +.6.2.56حذف منشور – المشرف 113 +.6.2.57استعراض طلبات التسجيل كخبير 114 +.6.2.58معالجة طلبات التسجيل كخبير 115 +.6.2.59استعراض الملف التعريفي للدولة 117 +.6.2.60تحديث الملف التعريفي للدولة 118 +.6.2.61تسجيل الدخول 119 +.6.2.62استعادة كلمة المرور 120 +.6.2.63تسجيل الخروج 121 +.6.3النماذج 122 +.6.3.1التفاعل مع المدينة التفاعلية 122 +.6.3.2إنشاء حساب -المستخدم 123 +.6.3.3تسجيل الدخول – المستخدم 125 +.6.3.4استعادة كلمة المرور – المستخدم 125 +.6.3.5التسجيل كخبير 125 +.6.3.6تقييم خدمات الموقع 126 +.6.3.7تحديد المقترحات المخصصة 127 +.6.3.8إنشاء منشور 128 +.6.3.9تحديث محتوى الصفحة الرئيسية – المشرفين 128 +.6.3.10تحديث محتوى تعرف على المنصة – المشرفين 129 +.6.3.11تحديث السياسات واالحكام – المشرفين 129 +.6.3.12إنشاء المستخدم – المشرفين 130 +.6.3.13رفع الخبر – المشرفين 130 +.6.3.14رفع الفعالية – المشرفين 131 +.6.3.15رفع المصادر – المشرفين 131 +.6.3.16تحديث الملف التعريفي للدولة – المشرفين 133 +.6.4متطلبات التقارير 134 +.6.4.1تقرير تسجيل المستخدمين 134 +.6.4.2تقرير خبراء المجتمع 135 +.6.4.3تقرير تقييم رضا المستخدم عن المنصة 136 +.6.4.4تقرير خبراء المجتمع 138 + + +--- + + +.6.4.5تقرير منشورات المجتمع 139 +.6.4.6تقرير االخبار 140 +.6.4.7تقرير الفعاليات 141 +.6.4.8تقرير المصادر 142 +.6.4.9تقرير ملفات التعريفية للدول 143 +.6.5متطلبات خدمة الربط 144 +.6.5.1متطلبات خدمة الربط مع كابسارك 144 +.7الرسائل والتنبيهات 145 +.7.1الرسائل 145 +.7.2التنبيهات 149 + + +--- + + +.1الوثيقة +.1.1اإلصدارات + +التغييرات مصدر التغيير التاريخ اإلصدا +الكاتب +ر +ال يوجد النموذج األول 11/14/2024 المقاول 1 +تعديالت في صالحيات ممثلي +الدول ومسميات بعض النموذج الثاني 5/1/2025 المقاول 2 +اإلجراءات + +.1.2المراجعة + +التاريخ المسمى الوظيفي االسم + +.1.3االعتماد +التاريخ المسمى الوظيفي االسم + +.1.4 + +.1.5الغرض من الوثيقة +إن الغرض من هذه الوثيقة هو لتعريف احتياج العمل وتحديد األهداف والغايات التي تسعى مركز المعرفة لالقتصاد الدائري للكربون في +وزارة الطاقة إلى الوصول إلى تحقيقها ممثلة في مشروع المرحلة الثانية لمركز المعرفة لالقتصاد الدائري للكربون ،وتحديد استراتيجية +التغيير ابتداء من تحليل الوضع الحالي وتعريف الوضع المستقبلي وفقا لنطاق حل واضح ومحدد مما يلبي احتياجات العمل. + + +--- + + +.2المقدمة +.2.1تعاريف ومصطلحات + +التعريف المصطلح + +نموذج بصري تفاعلي يربط تقنيات االقتصاد الدائري للكربون األساسية مع القطاعات +الخرائط المعرفية +والموضوعات الفرعية ويقدم أبرز المصادر والوسائط واألخبار والفعاليات المتعلقة بكل موضوع. + +تمثل محافظة CCEنموذجا تخيليا يلعب فيه المستخدم دور المحافظ ويقوم بصناعة تجمع حضري +بظروف بيئية مختارة واستخدامها لقياس أداء المحافظة الحالي باإلضافة إلى التقنيات والتحسينات المدينة التفاعلية +البيئية المطلوبة لوصول المحافظة إلى الحياد الكربوني خالل فترة زمنية محددة. + +متنوعة وشاملة تستوعب مختلف فئات المعرفة مع خيارات بحث متقدمة وديناميكية وعرض +المصادر +مختصر للتفاصيل ذات األهمية لكل مصدر قبل استعراضه. + +مجتمع ديناميكي وفعال يساهم في التحصيل المعرفي لدى زوار الموقع عن طريق إضافة األسئلة +والمعلومات وإمكانية الرد عليها ويتم ترشيح المحتوى األولى بالظهور من قبل المستخدمين مع مجتمع المعرفة +إمكانية متابعة الكت ّاب والمنشورات ذات األهمية. + +متنوعة المصادر والصيغ مرتبة بشكل يخدم اهتمام واحتياجات المستخدم مع إمكانية المتابعة +أخبار وفعاليات +وتوفير خيارات لمشاركة األخبار والفعاليات. + +.2.2المراجع + +الملفات المرجع + +تقييم الوضع الراهن "المرحلة الثانية لمركز المعرفة لالقتصاد الدائري +تحليل الوضع الراهن +للكربون" + +تصميم الوضع المستهدف "المرحلة الثانية لمركز المعرفة لالقتصاد الدائري +الوضع المستقبلي +للكربون" + + +--- + + +.2.3أطراف المشروع + +ممثل الجهة الدور الجهة + +باسل السبيتي مالك المشروع مركز المعرفة لالقتصاد الدائري للكربون + +ويكمن دورها في: +فريق لتحليل االعمال توثيق متطلبات األعمال لتنفيذ · المقاول +المشروع + + +--- + + +.3نظرة عامة +.3.1وصف المشروع +تسعى وزارة الطاقة ،من خالل مركز المعرفة لالقتصاد الدائري للكربون ،إلى تحسين تجربة المستفيدين من خدمات المركز من خالل +منصة رقمية متطورة إلدارة المعرفة المتعلقة باالقتصاد الدائري للكربون .تهدف من خالل هذه المنصة إلى دعم الدول والمنظمات +المشاركة لتحقيق أهداف الحياد الكربوني ،عبر تبني حلول مستدامة وفعالة في هذا المجال. +هدف المشروع إلى تسهيل الوصول إلى المعلومات والبيانات واألبحاث المتعلقة باالقتصاد الدائري للكربون ،من خالل مركز معرفة رقمي +يمكّن المستفيدين من الدول والمؤسسات من الوصول إلى أحدث الدراسات والتقارير في هذا المجال. +يتحقق من المشروع األهداف التالية: +.1سرعة وجودة توفير المعلومات :يتمكن المستفيدون من الحصول على المعلومات والبيانات المحدثة حول االقتصاد الدائري +للكربون بشكل سريع ودقيق. +.2سهولة الوصول والتفاعل :تتيح المنصة إمكانية البحث المتقدم والتصنيف لألبحاث والمصادر ،مما يسهل على المستخدمين +الوصول إلى المحتويات ذات الصلة بشكل فعال. +.3تعزيز التعاون اإلقليمي والدولي :توفر المنصة بيئة تفاعلية لممثلي الدول والمنظمات لتبادل المعلومات واألفكار المتعلقة +باالقتصاد الدائري للكربون. +.4تحفيز االبتكار في الحلول المناخية :من خالل تقديم أحدث االبتكارات والحلول في مجال الكربون ،تدعم المنصة تنفيذ مبادرات +تخفيض االنبعاثات الكربونية. + +.3.2استراتيجية التغيير +.3.2.1تحليل الوضع الحالي +الوضع الحالي لمنصة مركز المعرفة لالقتصاد الدائري للكربون يتيح للمستخدمين استعراض أربع صفحات رئيسية ،وهي: +.1الصفحة الرئيسية :تتضمن تعريفا عن المنصة ،أهدافها ،والدول المشاركة فيها. +.2المصادر :تشمل إمكانية البحث عن المصادر ،تصنيفها ،وتنزيلها. +.3األخبار والفعاليات :توفر البحث والتصنيف بين األخبار والفعاليات. +.4مجتمع المعرفة :يتيح للمستخدمين إنشاء منشورات ،سواء كانت معلومة أو استفسارا. +ومع ذلك ،يواجه المستخدمون تحديات في التنقل بين الصفحات والوصول إلى المنصة ،ما يح ّد من االستفادة الفعالة من ميزاتها. + +.3.2.2الوضع المستقبلي +الوضع المستقبلي لمنصة مركز المعرفة لالقتصاد الدائري للكربون يتضمن مجموعة من التحسينات لدعم التجربة المستخدم ،أهمها: +.1تحسين تجربة المستخدم: +إضافة مساعد ذكي للرد على أسئلة المستخدم واقتراح المحتويات المناسبة له. o +تقديم توصيات مخصصة للمستخدم حسب اهتماماته وسجل تصفحه. o +.2التوسع في خيارات البحث: + + +--- + + +تحسين أدوات البحث وإضافة فالتر شاملة تمكن المستخدم من الوصول السريع للموارد والمحتويات المطلوبة. o +.3زيادة التفاعل ودعم مجتمع المعرفة: +إتاحة نظام نقاط يحفّز تفاعل المستخدمين وتصنيف المستخدمين المتفاعلين بشكل بارز. o +تفعيل خيارات متابعة التنبيهات لمنشورات معينة ودمجها في شبكات التواصل االجتماعي. o +.4إضافة خرائط معرفية وملفات تعريفية للدول: +توفير خرائط معرفية لربط الموضوعات الفرعية باالقتصاد الدائري للكربون. o +عرض ملفات تعريفية للدول المشاركة تتضمن بيانات عن أدائها في االقتصاد الدائري. o +.5صفحة رئيسية شاملة وإحصائيات: +إدراج صفحة تعريفية تفصيلية عن المنصة تشمل أبرز اإلحصائيات والمحتويات الموصى بها ،مما يسهل o +للمستخدمين استكشاف المنصة بفعالية أكبر + + +--- + + + +--- + + +.3.2.3إجراءات أعمال للمنصة + +.3.2.3.1المستخدم +.3.2.3.1.1الصفحة الرئيسية + + +--- + + +.3.2.3.1.2تعرف على المنصة + + +--- + + +.3.2.3.1.3عرض /تحميل المصادر + +.3.2.3.1.4الخرائط المعرفية + +.3.2.3.1.5المدينة التفاعلية + +CCE + + +--- + + +.3.2.3.1.6االخبار والفعاليات + +.3.2.3.1.7الملف التعريفي للدولة + +PDF +Total CCE + + +--- + + +.3.2.3.1.8الملف الشخصي + +- - +- - +- - + +.3.2.3.1.9تقييم الخدمات + +.3.2.3.1.10المقترحات المخصصة + + +--- + + +.3.2.3.1.11البحث بمساعدة المساعد الذكي + +.3.2.3.1.12مجتمع المعرفة المنشور +- + +.3.2.3.1.13مجتمع المعرفة المجتمع +- + +- - + + +--- + + +.3.2.3.1.14السياسات واالحكام + + +--- + + +.3.2.3.2المشرف +.3.2.3.2.1تحديث المحتوى + +.3.2.3.2.2إدارة المستخدمين + + +--- + + +.3.2.3.2.3األخبار والفعاليات + +مصادر المركز .3.2.3.2.4المصادر +- + +مصادر الدول .3.2.3.2.5المصادر +- + + +--- + + +المنشور .3.2.3.2.6مجتمع المعرفة +- + +الخبير .3.2.3.2.7مجتمع المعرفة +- + +- - +- - +- - + +.3.2.3.2.8الملف التعريفي للدولة + +PDF +Total CCE + + +--- + + +.3.2.4تحليل أصحاب المصلحة + +المسؤولية حسب ()RACI الدور االسم/الجهة + +المسؤول )(R +الموافقة)(A +إدارة النظام وإعداد السياسات المشرف العام ()Super Admin +االستشارة )(C +اإلعالم )(I +المسؤول )(R +الموافقة)(A +إدارة المحتوى والطلبات المشرف ()Admin +االستشارة )(C +اإلعالم )(I +المسؤول )(R +الموافقة)(A +تحديث المحتوى وإدارة المعلومات مشرف المحتوى ()Content manager +االستشارة )(C +اإلعالم )(I +المسؤول )(R +الموافقة)(A رفع المصادر وإدارة الملف التعريفي +ممثل الدولة )(State Representative +االستشارة )(C للدولة + +اإلعالم )(I +االستشارة )(C +استخدام الخدمات المتاحة المستخدم )(Beneficiary +اإلعالم )(I +االستشارة )(C +تصفح المحتوى واستخدام المنصة الزائر ()Visitor +اإلعالم )(I + + +--- + + +.4نطاق الحل +.4.1متطلبات األعمال +.4.1.1الصفحة الرئيسية -المستخدم + +المستخدمين الوصف الخاصية رمز الخاصية + +خدمة "الصفحة الرئيسية" تقدم · +لمحة عن المنصة وأهدافها ،مع +تسليط الضوء على الدول +المشاركة في االقتصاد الدائري +للكربون .تحتوي الصفحة على +الزائر ،المستخدم استعراض الصفحة الرئيسية F001 +روابط سريعة لألقسام الرئيسية +مثل المصادر ،األخبار، +الفعاليات ،ومجتمع المعرفة +لتعزيز تجربة المستخدم وتسهيل +الوصول للمعلومات. + + +--- + + +.4.1.2تعرف على المنصة – المستخدم + +المستخدمين الوصف الخاصية رمز الخاصية + +خدمة "التعرف على المنصة" · +تقدم لمحة شاملة عن المنصة +وخصائصها الرئيسية ،مع +تعليمات للتفاعل مثل التسجيل، +تصفح المحتوى ،واستخدام +الزائر ،المستخدم األدوات .كما تعرض الشركاء استعراض تعرف على المنصة F002 +الذين يدعمون المحتوى +ويوفرون دورات تدريبية، +باإلضافة إلى قاموس +للمصطلحات التقنية +والصناعية.. + +.4.1.3المصادر – المستخدم + +المستخدمين الوصف الخاصية رمز الخاصية + +عرض تفاصيل المصدر مثل · +العنوان ،التاريخ ،الموضوع، +الزائر ،المستخدم استعراض المصادر · F003 +الوصف ،نوعية المنشور ،الدول +المغطاة ،والملف. + +تمكين المستخدمين من عرض · +عرض /تحميل · +الزائر ،المستخدم رابط المصدر او تحميل المصادر F004 +المصادر +المتاحة على المنصة. + +السماح للمستخدمين بمشاركة · +الزائر ،المستخدم مشاركة المصادر · F005 +المصادر مع اآلخرين. + + +--- + + +.4.1.4الخرائط المعرفية – المستخدم + +المستخدمين الوصف الخاصية رمز الخاصية + +عرض الخريطة التي تحتوي · +استعراض الخرائط · +الزائر ،المستخدم على المواضيع الخاصة F006 +المعرفية +باالقتصاد الدائري للكربون. + +تمكين المستخدم من اختيار · +موضوع على الخريطة ،مما +يعرض تعريف الموضوع التفاعل مع الخرائط · +الزائر ،المستخدم F007 +المختار ،والمصادر ،واألخبار، المعرفية +والفعاليات ،والمنشورات +المتعلقة به. + + +--- + + +.4.1.5المدينة التفاعلية – المستخدم + +المستخدمين الوصف الخاصية رمز الخاصية + +تمثل محافظة CCEنموذجا · +تخيليا يُتيح للمستخدم أن يلعب +دور المحافظ ،حيث يقوم +الزائر ،المستخدم بصناعة تجمع حضري بناء استعراض المدينة التفاعلية F008 +على ظروف بيئية مختارة .يتم +استخدام النموذج لقياس أداء +المحافظة الحالي. + +تمكين المستخدم من إدخال القيم · +المتعلقة بالعوامل البيئية +للمحافظة (مثل نسبة استخدام +المواصالت العامة ،مسافات +النقل ،الطاقة المتجددة، +الزائر ،المستخدم وغيرها) .بناء على القيم التفاعل مع المدينة التفاعلية F009 +المدخلة ،يتم قياس أداء المدينة +الحالي وتحديد التقنيات +والتحسينات البيئية المطلوبة +للوصول إلى الحياد الكربوني +خالل فترة زمنية محددة. + + +--- + + +.4.1.6األخبار والفعاليات – المستخدم + +المستخدمين الوصف الخاصية رمز الخاصية + +عرض األخبار والفعاليات مع · +الزائر ،المستخدم تفاصيل مثل العنوان ،التاريخ استعراض األخبار والفعاليات F010 +(تاريخ النشر) ،الموضوع. + +تمكين المستخدمين من مشاركة · +الزائر ،المستخدم مشاركة األخبار والفعاليات F011 +األخبار والفعاليات مع اآلخرين. + +متابعة األخبار والفعاليات عبر · +صفحة محدثة بانتظام ،مع +الزائر ،المستخدم متابعة صفحة االخبار F012 +عرض العنوان ،التاريخ، +والموضوع. + +تمكين المستخدمين من إضافة · +الزائر ،المستخدم الفعاليات إلى تقويمهم إضافة فعالية إلى التقويم F013 +الشخصي. + + +--- + + +.4.1.7الملف التعريفي للدولة – المستخدم + +المستخدمين الوصف الخاصية رمز الخاصية + +عرض خريطة تفاعلية للدولة · +مع معلومات مثل عدد السكان، +المساحة ،الناتج المحلي +اإلجمالي للفرد ،تصنيف +استعراض الملف التعريفي +الزائر ،المستخدم االقتصاد الدائري للكربون ،أداء F014 +للدولة +االقتصاد الدائري للكربون، +مرفق مساهمة وطنية محددة +للعام بصيغة ،PDFومخطط +األداء (مؤشر .)CCE Total + + +--- + + +.4.1.8الملف الشخصي – المستخدم + +المستخدمين الوصف الخاصية رمز الخاصية + +عرض معلومات الملف · +الشخصي للمستخدم مثل البلد، +االسم األول ،االسم األخير، +البريد اإللكتروني ،المسمى +المستخدم استعراض الملف الشخصي F015 +الوظيفي ،واسم المنظمة. +عرض قائمة المستخدمين الذين · +يتابعهم المستخدم وكذلك +المتابعين له. + +تمكين المستخدم من تعديل · +بياناته الشخصية مثل البلد، +المستخدم االسم األول ،االسم األخير، تعديل بيانات الملف الشخصي F016 +البريد اإللكتروني ،المسمى +الوظيفي ،واسم المنظمة. + +تسجيل المستخدم كخبير في · +مجتمع المعرفة مع إدخال +التسجيل كخبير في مجتمع +المستخدم معلومات مثل السيرة الذاتية F017 +المعرفة +(وصف ،مرفق) ،المواضيع التي +يمتلك الخبرة فيها. + + +--- + + +.4.1.9تقييم الخدمات – المستخدم + +المستخدمين الوصف الخاصية رمز الخاصية + +يتمكن الزوار والمستخدمون من · +تقييم خدمات الموقع عبر +مجموعة من األسئلة مثل :كيف +تقييم رضاك عن المنصة بشكل +عام؟ كيف تقييم سهولة استخدام +الزائر ،المستخدم المنصة؟ ما مدى مناسبة تقييم خدمات الموقع F018 +محتويات المنصة لمستواك +المعرفي؟ ما مدى مناسبة +المقترحات المخصصة +الهتماماتك؟ وهل لديك أي +مالحظات أو شكاوى أخرى؟ + + +--- + + +.4.1.10تحديد المقترحات المخصصة + +المستخدمين الوصف الخاصية رمز الخاصية + +يتم تخصيص مقترحات · +للمستخدم بناء على مجاالت +اهتمامه مثل النقاط الكربونية، +الطاقة المتجددة ،التخفيض، +التدوير .كما يتم تقييم معرفته +تحديد مقترحات مخصصة +المستخدم في مجال االقتصاد الدائري F019 +للمستخدم بحسب معلوماته +للكربون (مرتفع ،متوسط، +منخفض) ،وقطاع عمله +(حكومي ،أكاديمي ،خاص) ،مع +إمكانية اختيار البلد من قائمة +منسدلة. + + +--- + + +.4.1.11البحث بمساعدة المساعد الذكي – المستخدم + +المستخدمين الوصف الخاصية رمز الخاصية + +تمكين الزائر والمستخدم من · +البحث بسهولة عن المصادر، +األخبار والفعاليات ،والمنشورات +الزائر ،المستخدم البحث بمساعدة المساعد الذكي F020 +باستخدام المساعد الذكي ،الذي +يساعد في تقديم نتائج دقيقة +ومالئمة. + + +--- + + +.4.1.12مجتمع المعرفة – المنشور – المستخدم + +المستخدمين الوصف الخاصية رمز الخاصية + +عرض مجتمع المعرفة حيث يتم · +استعراض المواضيع والمحتوى +الزائر ،المستخدم استعراض مجتمع المعرفة F021 +المتعلق باالقتصاد الدائري +للكربون. + +استعراض المجموعات المتاحة · +استعراض مجموعات +الزائر ،المستخدم للمواضيع التي يتم التفاعل معها F022 +المواضيع +ضمن مجتمع المعرفة. + +متابعة مجموعة أو موضوع · +معين داخل مجتمع المعرفة +الزائر ،المستخدم متابعة مجموعة -موضوع- F023 +للحصول على تحديثات وتفاعل +مستمر مع المحتوى + +عرض المنشور بما يتضمن · +بياناته مثل العنوان ،التاريخ، +الزائر ،المستخدم استعراض منشور F024 +الموضوع ،المحتوى، +والمرفقات المتعلقة بالمنشور. + +مشاركة المنشور مع اآلخرين · +الزائر ،المستخدم داخل المجتمع أو عبر وسائل مشاركة منشور F025 +أخرى. + +السماح للمستخدم بإنشاء · +المستخدم منشورات جديدة على مجتمع إنشاء منشور F026 +المعرفة. + +التفاعل مع المنشور عن طريق · +المستخدم التفاعل مع منشور F027 +الخفض او الرفع. + +متابعة منشور معين للحصول · +المستخدم على إشعارات حول التحديثات متابعة المنشور F028 +والتفاعالت المتعلقة به. + +الرد على منشور معين ضمن · +مجتمع المعرفة للمشاركة في +المستخدم الرد على منشور F029 +المناقشات أو توضيح نقاط +معينة. + +.4.1.13مجتمع المعرفة – المجتمع – المستخدم + + +--- + + +المستخدمين الوصف الخاصية رمز الخاصية + +عرض ملف المستخدم الشخصي · +مع تفاصيله مثل االسم األول، +استعراض الملف الشخصي +المستخدم االسم األخير ،المسمى الوظيفي، F030 +لمستخدم +وبيانات أخرى متعلقة +بالمستخدم. + +تمكين المستخدم من متابعة · +مستخدم آخر لعرض التحديثات +المستخدم متابعة مستخدم F031 +والمحتوى الجديد الخاص به في +مجتمع المعرفة. + +.4.1.14السياسات واالحكام – المستخدم + +المستخدمين الوصف الخاصية رمز الخاصية + +عرض السياسات واألحكام · +المتعلقة باستخدام المنصة ،بما +في ذلك الشروط العامة ،سياسة +المستخدم استعراض السياسات واالحكام F032 +الخصوصية ،وأي قوانين أو +شروط أخرى تحكم استخدام +المنصة. + +.4.1.15خدمات الدعم األساسية – إنشاء حساب – المستخدم + +المستخدمين الوصف الخاصية رمز الخاصية + +الزائر يمكن للزائر إنشاء حساب جديد على +إنشاء حساب F033 +المنصة. + +.4.1.16خدمات الدعم األساسية – تسجيل الدخول – المستخدم + +المستخدمين الوصف الخاصية رمز الخاصية + +المستخدم يتيح للمستخدمين الدخول إلى حساباتهم +تسجيل الدخول F034 +الخاصة. + + +--- + + +.4.1.17خدمات الدعم األساسية – استعادة كلمة المرور – المستخدم + +المستخدمين الوصف الخاصية رمز الخاصية + +تيح هذه الخاصية للمستخدمين استعادة +المستخدم استعادة كلمة المرور F035 +كلمة المرور في حال نسيانها. + +.4.1.18خدمات الدعم األساسية – تسجيل الخروج – المستخدم + +المستخدمين الوصف الخاصية رمز الخاصية + +تتيح خاصية تسجيل الخروج للمستخدمين +المستخدم تسجيل الخروج F036 +الخروج من حساباتهم. + + +--- + + +.4.1.19تحديث المحتوى – المشرفين + +المستخدمين الوصف الخاصية رمز الخاصية + +تحديث محتوى الصفحة · +المشرف العام ،المشرف ،مشرف الرئيسية للمنصة بناء على تحديث محتوى الصفحة +F037 +المحتوى التغييرات المطلوبة ،مثل الرئيسية +النصوص والصور. + +تحديث محتوى صفحة "تعرف · +المشرف العام ،المشرف ،مشرف على المنصة" لتوفير معلومات +تحديث تعرف على المنصة F038 +المحتوى محدثة حول خصائص المنصة +وأهدافها. + +تحديث السياسات واألحكام · +المتعلقة باستخدام المنصة ،بما +المشرف العام في ذلك الشروط العامة ،سياسة تحديث السياسات واالحكام F039 +الخصوصية ،وأي قوانين +أخرى. + +.4.1.20إدارة المستخدمين – المشرفين + +المستخدمين الوصف الخاصية رمز الخاصية + +عرض قائمة بالمشرفين · +المسجلين على المنصة مع +المشرف العام استعراض المستخدمين F040 +إمكانية الوصول إلى تفاصيل كل +مستخدم. + +تمكين المشرف العام من إنشاء · +حسابات مشرفين جدد على +المشرف العام إنشاء مستخدم F041 +المنصة مع إدخال المعلومات +الالزمة. + +تمكين المشرف العام من حذف · +المشرف العام حذف مستخدم F042 +حسابات المشرفين من المنصة. + +.4.1.21األخبار والفعاليات – المشرفين + +المستخدمين الوصف الخاصية رمز الخاصية + + +--- + + +عرض األخبار والفعاليات · +المشرف العام ،المشرف ،مشرف المتاحة على المنصة مع +استعراض األخبار والفعاليات F043 +المحتوى تفاصيل مثل العنوان ،التاريخ، +الموضوع ،والمحتوى. + +تمكين المشرفين من إضافة · +المشرف العام ،المشرف ،مشرف وتحديث األخبار والفعاليات +رفع األخبار والفعاليات F044 +المحتوى الجديدة على المنصة مع توفير +تفاصيل. + +المشرف العام ،المشرف ،مشرف تمكين المشرفين من حذف · +حذف األخبار والفعاليات F045 +المحتوى األخبار والفعاليات. + +.4.1.22المصادر – مصادر المركز – المشرفين + +المستخدمين الوصف الخاصية رمز الخاصية + +عرض المصادر المتاحة على · +المشرف العام ،المشرف ،مشرف المنصة مع تفاصيلها مثل +استعراض المصادر F046 +المحتوى العنوان ،الموضوع ،والملف +المرفق. + +تمكين المشرفين من إضافة · +المشرف العام ،المشرف ،مشرف مصادر جديدة إلى المنصة مع +رفع المصادر F047 +المحتوى تفاصيل مثل العنوان، +الموضوع ،والملف المرفق. + +تمكين المشرفين من حذف · +المشرف العام ،المشرف ،مشرف +المصادر من المنصة بناء على حذف المصادر F048 +المحتوى +المعايير المحددة. + +.4.1.23المصادر – مصادر الدول – المشرفين + +المستخدمين الوصف الخاصية رمز الخاصية + +عرض قائمة بجميع طلبات · +المشرف العام ،المشرف مصادر الدول المقدمة للمراجعة، استعراض طلبات مصادر الدول F049 +مع تفاصيل حول كل طلب. + +معالجة طلبات مصادر الدول، · +المشرف العام ،المشرف بما في ذلك الموافقة أو الرفض معالجة طلب مصادر الدولة F050 +على الطلبات المقدمة. + + +--- + + +عرض الطلبات الخاصة · +بالمصادر التي قدمتها الدولة +ممثل الدولة استعراض الطلبات للمصادر F051 +وتفاصيل حول حالتها ونتائج +المعالجة. + +تمكين ممثل الدولة من رفع · +المشرف العام ،المشرف ،ممثل +المصادر الخاصة بالدولة إلى رفع المصادر F052 +الدولة +المنصة بعد الموافقة عليها. + + +--- + + +.4.1.24مجتمع المعرفة – المنشور – المشرفين + +المستخدمين الوصف الخاصية رمز الخاصية + +عرض مجتمع المعرفة الذي · +المشرف العام ،المشرف ،مشرف يتضمن المواضيع والمحتوى +استعراض مجتمع المعرفة F053 +المحتوى المتعلق باالقتصاد الدائري +للكربون. + +عرض المجموعات المختلفة · +المشرف العام ،المشرف ،مشرف استعراض مجموعات +للمواضيع في مجتمع المعرفة F054 +المحتوى المواضيع +مع منشوراتها. + +عرض المنشورات المتعلقة · +المشرف العام ،المشرف ،مشرف بالمواضيع داخل مجتمع المعرفة +استعراض منشور F055 +المحتوى مع جميع التفاصيل مثل العنوان، +التاريخ ،والمحتوى. + +مكين المشرفين من حذف · +المشرف العام ،المشرف ،مشرف +منشورات المستخدمين من حذف منشور F056 +المحتوى +مجتمع المعرفة. + +.4.1.25مجتمع المعرفة – الخبير – المشرفين + +المستخدمين الوصف الخاصية رمز الخاصية + +عرض طلبات التسجيل المقدمة · +من المستخدمين للتسجيل استعراض طلبات التسجيل +المشرف العام ،المشرف F057 +كخبراء في مجتمع المعرفة ،مع كخبير +تفاصيل حول كل طلب. + +معالجة طلبات التسجيل كخبراء، · +المشرف العام ،المشرف بما في ذلك الموافقة أو الرفض معالجة طلبات التسجيل كخبير F058 +بناء على المعايير المحددة. + +.4.1.26الملف التعريفي للدولة – ممثل الدولة + +المستخدمين الوصف الخاصية رمز الخاصية + +عرض الملف التعريفي الخاص · +بالدولة والذي يتضمن معلومات استعراض الملف التعريفي +ممثل الدولة F059 +مثل عدد السكان ،المساحة، للدولة +ومؤشرات أخرى. + + +--- + + +تمكين ممثل الدولة من تحديث · +المعلومات في الملف التعريفي +ممثل الدولة تحديث الملف التعريفي للدولة F060 +الخاص بالدولة مثل البيانات +االقتصادية والبيئية. + +.4.1.27خدمات الدعم األساسية – تسجيل الدخول – المشرفين + +المستخدمين الوصف الخاصية رمز الخاصية + +المشرف العام ،المشرف ،مشرف يتيح للمشرفين والجهات المعنية الدخول +تسجيل الدخول F061 +المحتوى ،ممثل الدولة إلى حساباتهم الخاصة. + +.4.1.28خدمات الدعم األساسية – استعادة كلمة المرور – المشرفين + +المستخدمين الوصف الخاصية رمز الخاصية + +المشرف العام ،المشرف ،مشرف تيح هذه الخاصية للمستخدمين استعادة +استعادة كلمة المرور F062 +المحتوى ،ممثل الدولة كلمة المرور في حال نسيانها. + +.4.1.29خدمات الدعم األساسية – تسجيل الخروج – المشرفين + +المستخدمين الوصف الخاصية رمز الخاصية + +المشرف العام ،المشرف ،مشرف تتيح خاصية تسجيل الخروج للمستخدمين +تسجيل الخروج F063 +المحتوى ،ممثل الدولة الخروج من حساباتهم + + +--- + + +.4.1.30رسم حاالت االستخدام ()Use Case Diagram + +.4.1.30.1رسم حالة االستخدام للمشرفين + + +--- + + +.4.1.30.2رسم حالة االستخدام للمستخدم + + +--- + + +.4.1.31مصفوفة الصالحيات +هي مصفوفة توضح مستخدمي النظام وصالحيات كل مستخدم على النظام. + +مصفوفة الصالحيات +المستخدم +الزائر المستخدم ممثل الدولة مشرف المحتوى المشرف المشرف العام +الصالحية + +استعراض الصفحة +✓ ✓ ✗ ✗ ✗ ✗ الرئيسية + +استعراض تعرف على +✓ ✓ ✗ ✗ ✗ ✗ المنصة + +✓ ✓ ✗ ✗ ✗ ✗ استعراض المصادر + +✓ ✓ ✗ ✗ ✗ ✗ تحميل المصادر + +✓ ✓ ✗ ✗ ✗ ✗ مشاركة المصادر + +استعراض الخرائط +✓ ✓ ✗ ✗ ✗ ✗ المعرفية + +التفاعل مع الخرائط +✓ ✓ ✗ ✗ ✗ ✗ المعرفية + +استعراض المدينة +✓ ✓ ✗ ✗ ✗ ✗ التفاعلية + +التفاعل مع المدينة +✓ ✓ ✗ ✗ ✗ ✗ التفاعلية + +استعراض األخبار +✓ ✓ ✗ ✗ ✗ ✗ والفعاليات + +مشاركة األخبار +✗ ✓ ✗ ✗ ✗ ✗ والفعاليات + + +--- + + +✗ ✓ ✗ ✗ ✗ ✗ متابعة صفحة االخبار + +إضافة فعالية إلى +✓ ✓ ✗ ✗ ✗ ✗ التقويم + +استعراض الملف +✓ ✓ ✗ ✗ ✗ ✗ التعريفي للدولة + +استعراض الملف +✗ ✓ ✗ ✗ ✗ ✗ الشخصي + +تعديل البيانات +✗ ✓ ✗ ✗ ✗ ✗ الشخصية + +التسجيل كخبير في +✗ ✓ ✗ ✗ ✗ ✗ مجتمع المعرفة + +✓ ✓ ✗ ✗ ✗ ✗ تقييم الخدمات + +تحديد المقترحات +✗ ✓ ✗ ✗ ✗ ✗ المخصصة + +البحث بمساعدة +✓ ✓ ✗ ✗ ✗ ✗ المساعد الذكي + +استعراض مجتمع +✓ ✓ ✗ ✗ ✗ ✗ المعرفة + +استعراض مجموعات +✓ ✓ ✗ ✗ ✗ ✗ المواضيع + +✗ ✓ ✗ ✗ ✗ ✗ متابعة مجموعة + +✓ ✓ ✗ ✗ ✗ ✗ استعراض منشور + +✓ ✓ ✗ ✗ ✗ ✗ مشاركة منشور + +✗ ✓ ✗ ✗ ✗ ✗ إنشاء منشور + +✗ ✓ ✗ ✗ ✗ ✗ التفاعل مع منشور + + +--- + + +✗ ✓ ✗ ✗ ✗ ✗ متابعة منشور + +✗ ✓ ✗ ✗ ✗ ✗ الرد على منشور + +استعراض السياسات +✓ ✓ ✗ ✗ ✗ ✗ واالحكام + +تحديث محتوى الصفحة +✗ ✗ ✗ ✓ ✓ ✓ الرئيسية + +تحديث محتوى تعرف +✗ ✗ ✗ ✓ ✓ ✓ على المنصة + +تحديث السياسات +✗ ✗ ✗ ✗ ✗ ✓ واألحكام + +✗ ✗ ✗ ✗ ✗ ✓ استعراض المستخدمين + +✗ ✗ ✗ ✗ ✗ ✓ إنشاء مستخدم + +✗ ✗ ✗ ✗ ✗ ✓ حذف مستخدم + +استعراض األخبار +✗ ✗ ✓ ✓ ✓ ✓ والفعاليات + +✗ ✗ ✗ ✓ ✓ ✓ رفع األخبار والفعاليات + +✗ ✗ ✗ ✓ ✓ ✓ حذف األخبار والفعاليات + +✗ ✗ ✗ ✓ ✓ ✓ استعراض المصادر + +رفع المصادر – مصادر +✗ ✗ ✗ ✓ ✓ ✓ المركز + +✗ ✗ ✗ ✓ ✓ ✓ حذف المصادر + +استعراض طلبات +✗ ✗ ✗ ✓ ✓ ✓ مصادر الدول + + +--- + + +معالجة طلبات مصادر +✗ ✗ ✗ ✓ ✓ ✓ الدول + +استعراض مجتمع +✗ ✗ ✗ ✓ ✓ ✓ المعرفة + +استعراض مجموعات +✗ ✗ ✗ ✓ ✓ ✓ المواضيع + +✗ ✗ ✗ ✓ ✓ ✓ استعراض منشور + +✗ ✗ ✗ ✓ ✓ ✓ حذف المنشور + +استعراض طلبات +✗ ✗ ✗ ✗ ✓ ✓ التسجيل كخبير + +معالجة طلبات التسجيل +✗ ✗ ✗ ✗ ✓ ✓ كخبير + +استعراض الطلبات +✗ ✗ ✓ ✗ ✗ ✗ للمصادر + +رفع المصادر – مصادر +✗ ✗ ✓ ✗ ✓ ✓ +الدول + +رفع األخبار والفعاليات +✗ ✗ ✓ ✗ ✓ ✓ +– اخبار وفعاليات الدول + +استعراض الملف +✗ ✗ ✓ ✗ ✓ ✓ التعريفي بالدولة + +تحديث الملف التعريفي +✗ ✗ ✓ ✗ ✓ ✓ بالدولة + +.4.1.32متطلبات الحل غير الوظيفية + +الوصف المتطلب المعرف + +يجب أن يتم تحميل صفحات الويب في أقل من 3ثوان. األداء العالي NF001 +يشمل ضغط الصور واستخدام صيغ حديثة لتحسين األداء بدون التأثير على +تحسين وسائط الصور NF002 +جودة المحتوى. + + +--- + + +يجب تقليل حجم الملفات واستخدام تقنيات التحميل البطيء لعناصر الصفحة. تحسين الكود NF003 +يجب تصميم واجهة سهلة االستخدام ومستجيبة لجميع األجهزة (الهاتف +قابلية االستخدام NF004 +المحمول ،األجهزة اللوحية ،الحاسوب). + +يجب أن يكون النظام متوفر ومتاح 24/7من دون أي عطل في الوظائف +التوفر NF005 +الرسمية. + + +--- + + +.5مالحظات عامة +.5.1االفتراضات + +ق 1 + +. ق أ 2 + +أل أل ك. ()CCE ي +3 +.CCE ً + +) أل ( أ +. 4 + +iCalendar أل . أ أ +5 +Googleأ .Apple + +.5.2االعتمادية + +مالحظات الوصف الرقم + +ك ً ً ي ك +ي أ 1 +. + +ً ُ . 2 +. + +إل إل إل +. 3 +. + +. أل +4 + + +--- + + +.5.3المخاطر + +الية تفاديه احتمالية حدوثه الحجم الوصف الرقم + +استخدام خدمة بديلة أو آلية تخزين مؤقت متوسطة متوسط تعطل االتصال بالخدمات الخارجية مثل كابسارك أثناء +1 +للبيانات لتجنب تعطل النظام. استرجاع البيانات. + +مراجعة دورية لمصفوفات الصالحيات متوسطة متوسط مشاكل في تأكيد صالحيات المستخدم في النظام نتيجة +والتحقق من دقتها قبل تنفيذ أي عملية خطأ في المصفوفة. 2 +وصول. + +استخدام مزود بريد إلكتروني موثوق متوسطة صغير فشل عملية إرسال الروابط عبر البريد اإللكتروني في +وتكرار محاولة إرسال الروابط في حال حالة استعادة كلمة المرور. ٣ +فشل العملية. + +التحقق المسبق من صحة عالية صغير حدوث أخطاء في عملية تحقق البيانات المدخلة أثناء +البيانات المدخلة من قبل تحديث محتوى الصفحة. ٤ +المشرف قبل السماح بالتحديث. + +استخدام نسخ احتياطية دورية متوسطة كبير فقدان البيانات بسبب عطل في النظام أثناء إنشاء أو +للبيانات لضمان استرجاع البيانات حذف مستخدم. ٥ +في حالة حدوث عطل. + + +--- + + +.6سيناريوهات األعمال +.6.1جدول قصص المستخدم + +عنوان قصة المستخدم القسم الرقم + +استعراض الصفحة الرئيسية الصفحة الرئيسية – المستخدم 1 + +استعراض تعرف على المنصة تعرف على المنصة – المستخدم ٢ + +استعراض المصادر ٣ + +تحميل المصادر المصادر – المستخدم ٤ + +مشاركة المصادر ٥ + +استعراض الخرائط المعرفية ٦ +الخرائط المعرفية – المستخدم +التفاعل مع الخرائط المعرفية ٧ + +استعراض المدينة التفاعلية ٨ +المدينة التفاعلية – المستخدم +التفاعل مع المدينة التفاعلية ٩ + +استعراض األخبار والفعاليات ١٠ + +مشاركة األخبار والفعاليات ١١ +االخبار والفعاليات – المستخدم +متابعة صفحة االخبار ١٢ + +إضافة فعالية إلى التقويم ١٣ + +استعراض الملف التعريفي للدولة الملف التعريفي للدولة – المستخدم ١٤ + +استعراض الملف الشخصي ١٥ + +تعديل بيانات الملف الشخصي الملف الشخصي – المستخدم ١٦ + +التسجيل كخبير في مجتمع المعرفة ١٧ + +تقييم خدمات الموقع تقييم الخدمات – المستخدم ١٨ + +تحديد مقترحات مخصصة للمستخدم بحسب معلوماته تحديد المقترحات – المستخدم ١٩ + +البحث بمساعدة المساعد الذكي البحث بمساعدة المساعد الذكي – المستخدم ٢٠ + +استعراض مجتمع المعرفة ٢١ + +استعراض مجموعات المواضيع ٢٢ +مجتمع المعرفة – المنشور – المستخدم +متابعة مجموعة -موضوع- ٢٣ + +استعراض منشور ٢٤ + + +--- + + +مشاركة منشور ٢٥ + +إنشاء منشور ٢٦ + +التفاعل مع منشور ٢٧ + +متابعة المنشور ٢٨ + +الرد على منشور ٢٩ + +استعراض الملف الشخصي لمستخدم ٣٠ +مجتمع المعرفة – المجتمع – المستخدم +متابعة مستخدم ٣١ + +استعراض السياسات واالحكام السياسات واالحكام ٣٢ + +إنشاء حساب ٣٣ + +تسجيل الدخول ٣٤ +خدمات الدعم األساسية – المستخدم +استعادة كلمة المرور ٣٥ + +تسجيل الخروج ٣٦ + +تحديث محتوى الصفحة الرئيسية ٣٧ + +تحديث محتوى تعرف على المنصة تحديث المحتوى – المشرفين ٣٨ + +تحديث محتوى السياسات واالحكام ٣٩ + +استعراض المستخدمين ٤٠ + +إنشاء مستخدم إدارة المستخدمين – المشرفين ٤١ + +حذف مستخدم ٤٢ + +استعراض األخبار والفعاليات ٤٣ + +رفع األخبار والفعاليات االخبار والفعاليات – المشرفين ٤٤ + +حذف األخبار والفعاليات ٤٥ + +استعراض المصادر ٤٦ + +رفع المصادر المصادر – مصادر المركز – المشرفين ٤٧ + +حذف المصادر ٤٨ + +استعراض طلبات الدول ٤٩ + +معالجة طلب الدولة المصادر /االخبار الفعاليات – مصادر/اخبار فعاليات ٥٠ + +استعراض الطلبات للمصادر الدول – المشرفين ٥١ + +رفع المصادر ٥٢ + + +--- + + +رفع االخبار او الفعاليات ٥٣ + +استعراض مجتمع المعرفة ٥٤ + +استعراض مجموعات المواضيع ٥٥ +مجتمع المعرفة – المنشور – المشرفين +استعراض منشور ٥٦ + +حذف منشور ٥٧ + +استعراض طلبات التسجيل كخبير ٥٨ +مجتمع المعرفة – الخبير – المشرفين +معالجة طلبات التسجيل كخبير ٥٩ + +استعراض الملف التعريفي للدولة ٦٠ +الملف التعريفي للدولة – ممثل الدولة +تحديث الملف التعريفي للدولة ٦١ + +تسجيل الدخول ٦٢ + +استعادة كلمة المرور خدمات الدعم األساسية – المشرفين ٦٣ + +تسجيل الخروج ٦٤ + + +--- + + +.6.2قصص المستخدم +.6.2.1استعراض الصفحة الرئيسية +US001 المعرف + +كـ "مستخدم للمنصة" ،أرغب في استعراض الصفحة الرئيسية للمنصة حتى أتمكن من الحصول على المعلومات األساسية عن +العنوان +المنصة ،مثل األهداف والدول المشاركة والروابط السريعة. + +المنصة على الويب (.)Web App بيئة العمل + +الزائر · +المستخدمين +المستخدم المسجل · + +يجب أن يكون المستخدم قد قام بتسجيل الدخول إذا كان يريد تخصيص الصفحة أو الوصول إلى الخدمات المخصصة للمستخدم +الشروط المسبقة +فقط. + +.1يقوم المستخدم بالدخول إلى المنصة عبر متصفح الويب. +المسار الرئيسي +.2يقوم النظام بعرض الصفحة الرئيسية متضمنة البيانات في نموذج تحديث محتوى الصفحة الرئيسية +باإلضافة إلى استعراض بقية اقسام المنصة. + +في حال عدم وجود اتصال باإلنترنت: +.1يقوم النظام بعرض رسالة خطأ تفيد بوجود مشكلة في تحميل الصفحة. ALT001 الخطوات البديلة +.2يقوم النظام بإعادة توجيه المستخدم للصفحة الرئيسية بعد المحاولة مجددا. + +في حال حدوث خطأ في تحميل الصفحة: +ERR001 األخطاء +· يقوم النظام بعرض رسالة خطأ تفيد بوجود مشكلة في تحميل الصفحةERR001 . + +يجب أن تحتوي الصفحة الرئيسية على روابط لألقسام المهمة في المنصة مثل "المصادر"" ،األخبار"، +BC001 لوائح ومتطلبات األعمال +"الفعاليات" ،و"مجتمع المعرفة". + +يقوم المستخدم بالتفاعل مع األقسام المختلفة للمنصة بعد استعراض الصفحة الرئيسية. الشروط الالحقة + + +--- + + +.6.2.2استعراض تعرف على المنصة +US002 المعرف + +كـ "مستخدم للمنصة" ،أرغب في استعراض قسم "تعرف على المنصة" حتى أتمكن من الحصول على لمحة شاملة عن +العنوان +المنصة وخصائصها. + +المنصة على الويب (.)Web App بيئة العمل + +الزائر · +المستخدمين +المستخدم المسجل · + +ال يوجد الشروط المسبقة + +يقوم المستخدم بالدخول إلى المنصة عبر متصفح الويب. .1 +يقوم النظام بعرض الصفحة الرئيسية للمنصة. .2 +يختار المستخدم عالمة التبويب "عن المنصة" في القائمة. .3 المسار الرئيسي +يقوم النظام بعرض صفحة تعرف على المنصة متضمنة البيانات في نموذج تحديث محتوى تعرف .4 +على المنصة. + +في حال عدم وجود اتصال باإلنترنت: +.1يقوم النظام بعرض رسالة خطأ تفيد بوجود مشكلة في تحميل الصفحة. ALT001 الخطوات البديلة +.2يقوم النظام بإعادة توجيه المستخدم للصفحة الرئيسية بعد المحاولة مجددا. + +ERR00في حال حدوث خطأ في تحميل الصفحة: +األخطاء +1يقوم النظام بعرض رسالة خطأ تفيد بوجود مشكلة في تحميل الصفحةERR001 . + +لوائح ومتطلبات +BC001يجب أن يحتوي قسم "تعرف على المنصة" على وصف شامل للمنصة وأهدافها. +األعمال + +يقوم المستخدم باالنتقال إلى األقسام األخرى من المنصة بعد استعراض قسم "تعرف على المنصة". الشروط الالحقة + + +--- + + +.6.2.3استعراض المصادر +US003 المعرف + +كـ "مستخدم للمنصة" ،أرغب في استعراض المصادر المتاحة على المنصة حتى أتمكن من االطالع على محتوى المصادر +العنوان +ذات الصلة باالقتصاد الدائري للكربون. + +المنصة على الويب (.)Web App بيئة العمل + +الزائر · +المستخدمين +المستخدم المسجل · + +ال يوجد الشروط المسبقة + +يقوم المستخدم بالدخول إلى المنصة عبر متصفح الويب. .1 +يقوم النظام بعرض الصفحة الرئيسية للمنصة. .2 +يقوم المستخدم باختيار قسم "المصادر". .3 +يقوم النظام بعرض قائمة بجميع المصادر المتاحة (العنوان -التاريخ (تاريخ نشر المصدر) -الموضوع - .4 +المسار الرئيسي +الوصف -نوعية المنشور). +يقوم المستخدم بالبحث عن المصادر حسب العنوان ،التاريخ ،الموضوع ،أو نوع المنشور. .5 +يختار المستخدم مصدرا من القائمة لالطالع على تفاصيله. .6 +يقوم النظام بعرض تفاصيل المصدرفي نموذج رفع المصادر -عرض فقط.- .7 + +في حال عدم وجود اتصال باإلنترنت: +.1يقوم النظام بعرض رسالة خطأ تفيد بوجود مشكلة في تحميل الصفحة. ALT001 +.2يقوم النظام بإعادة توجيه المستخدم للصفحة الرئيسية بعد المحاولة مجددا. +الخطوات البديلة +في حال لم يجد المستخدم أي مصادر: +.1يقوم النظام بعرض رسالة تفيد بأنه ال توجد مصادر حاليا وفقا للبحث المحدد. ALT002 +.2يقوم النظام بتوجيه المستخدم إلجراء بحث آخر. + +ERR00في حال حدوث خطأ في تحميل الصفحة: +األخطاء +1يقوم النظام بعرض رسالة خطأ تفيد بوجود مشكلة في تحميل الصفحةERR001 . + +لوائح ومتطلبات +BC001يجب عرض التفاصيل الكاملة لكل مصدر ،بما في ذلك العنوان ،الموضوع ،التاريخ ،والمرفقات. +األعمال + +يقوم المستخدم إما بتحميل المصدر ،مشاركته ،أو العودة إلى صفحة البحث لمتابعة استعراض المزيد من المصادر الشروط الالحقة + + +--- + + +.6.2.4تحميل المصادر +US004 المعرف + +كـ "مستخدم للمنصة" ،أرغب في تحميل المصادر المتاحة على المنصة حتى أتمكن من االطالع عليها الحقا أو استخدامها. العنوان + +المنصة على الويب (.)Web App بيئة العمل + +الزائر · +المستخدمين +المستخدم المسجل · + +يجب أن يكون هناك مصدر متاح للتحميل. · الشروط المسبقة + +.1يقوم المستخدم بالدخول إلى المنصة عبر متصفح الويب. +.2يقوم النظام بعرض الصفحة الرئيسية للمنصة. +.3يقوم المستخدم باختيار قسم "المصادر". +.4يقوم النظام بعرض قائمة بجميع المصادر المتاحة. +.5يقوم المستخدم بالبحث عن المصادر حسب العنوان ،التاريخ ،الموضوع ،أو نوع المنشور. +المسار الرئيسي +.6يختار المستخدم مصدرا من القائمة لالطالع على تفاصيله. +.7يقوم النظام بعرض تفاصيل المصدرفي نموذج رفع المصادر -عرض فقط.- +.8يقوم المستخدم بالنقر على زر "تحميل المصدر". +.9يقوم النظام بتنزيل الملف المرفق بالمصدر إلى جهاز المستخدم. +.10يقوم النظام بعرض رسالة تأكيد بتأكيد عملية التحميل بنجاحCON001 . + +في حال وجود مشكلة في تنزيل الملف: +.1يقوم النظام بعرض رسالة خطأ تفيد بفشل عملية التحميل. ALT001 +.2يتيح النظام للمستخدم محاولة التحميل مرة أخرى أو عرض رابط بديل للتحميل. + +في حال فشل تحميل المصدر: +ERR00 +.1يقوم النظام بعرض رسالة خطأ تفيد بوجود مشكلة في تحميل المصدرERR002 . األخطاء +1 +.2يتيح النظام للمستخدم المحاولة مرة أخرى أو عرض رابط بديل لتحميل المصدر. + +لوائح ومتطلبات +BC001يجب عرض التفاصيل الكاملة لكل مصدر ،بما في ذلك العنوان ،الموضوع ،التاريخ ،والمرفقات. +األعمال + +يقوم المستخدم إما بتحميل المصدر ،مشاركته ،أو العودة إلى صفحة البحث لمتابعة استعراض المزيد من المصادر الشروط الالحقة + + +--- + + +.6.2.5مشاركة المصادر +US005 المعرف + +كـ "مستخدم للمنصة" ،أرغب في مشاركة المصدر مع اآلخرين عبر المنصة حتى يتمكنوا من االطالع عليه واستخدامه. العنوان + +المنصة على الويب (.)Web App بيئة العمل + +الزائر · +المستخدمين +المستخدم المسجل · + +يجب أن يكون هناك مصدر متاح للمشاركة. · الشروط المسبقة + +.1يقوم المستخدم بالدخول إلى المنصة عبر متصفح الويب. +.2يقوم النظام بعرض الصفحة الرئيسية للمنصة. +.3يقوم المستخدم باختيار قسم "المصادر". +.4يقوم النظام بعرض قائمة بجميع المصادر المتاحة. +.5يقوم المستخدم بالبحث عن المصادر حسب العنوان ،التاريخ ،الموضوع ،أو نوع المنشور. +.6يختار المستخدم مصدرا من القائمة لالطالع على تفاصيله. +المسار الرئيسي +.7يقوم النظام بعرض تفاصيل المصدرفي نموذج رفع المصادر -عرض فقط.- +.8يقوم المستخدم بالنقر على زر " مشاركة المصدر". +.9يقوم النظام بعرض خيارات المشاركة المتاحة (مثل البريد اإللكتروني ،أو رابط المشاركة). +.10يقوم المستخدم باختيار وسيلة المشاركة المفضلة (مثل إرسال عبر البريد اإللكتروني أو نسخ الرابط). +.11يقوم النظام بمشاركة الرابط أو إرسال البريد اإللكتروني بنجاح. +.12يقوم النظام بعرض رسالة تأكيد بأن المصدر قد تم مشاركته بنجاحCON002 . + +في حال لم يكن هناك مصدر للمشاركة: +.1يقوم النظام بعرض رسالة تفيد بعدم إمكانية مشاركة المصدر في الوقت الحالي. +ALT001 الخطوات البديلة +ERR003 +.2يقوم النظام بتوجيه المستخدم إلى صفحة المصادر. + +في حال فشل عملية المشاركة: +ERR00 +.1يقوم النظام بعرض رسالة خطأ تفيد بوجود مشكلة في المشاركة. األخطاء +1 +.2يقوم النظام بتوجيه المستخدم إلى محاوالت أخرى للمشاركة أو استخدام وسيلة بديلة. + +لوائح ومتطلبات +BC001يجب عرض التفاصيل الكاملة لكل مصدر ،بما في ذلك العنوان ،الموضوع ،التاريخ ،والمرفقات. +األعمال + +يتم مشاركة المصدر بنجاح مع المستخدمين اآلخرين ،ويمكنهم الوصول إليه من خالل الرابط المرسل أو البريد اإللكتروني. الشروط الالحقة + + +--- + + +.6.2.6استعراض الخرائط المعرفية +US006 المعرف + +كـ "مستخدم للمنصة" ،أرغب في استعراض الخرائط المعرفية المتاحة على المنصة حتى أتمكن من االطالع على المعلومات +العنوان +المرتبطة بمفهوم االقتصاد الدائري للكربون. + +المنصة على الويب (.)Web App بيئة العمل + +الزائر · +المستخدمين +المستخدم المسجل · + +ال يوجد الشروط المسبقة + +.1يقوم المستخدم بالدخول إلى المنصة عبر متصفح الويب. +.2يقوم النظام بعرض الصفحة الرئيسية للمنصة. +المسار الرئيسي +.3يقوم المستخدم باختيار قسم "الخرائط المعرفية". +.4يقوم النظام بعرض الخريطة المعرفية متضمنة مواضيع االقتصاد الدائري للكربون. + +في حال عدم وجود خرائط معرفية: +.1يقوم النظام بعرض رسالة تفيد بعدم وجود خرائط معرفية متاحة. ALT001 الخطوات البديلة +.2يقوم النظام بتوجيه المستخدم إلى الصفحة الرئيسية. + +في حال حدوث خطأ في تحميل الصفحة: +ERR001 األخطاء +يقوم النظام بعرض رسالة خطأ تفيد بوجود مشكلة في تحميل الصفحةERR001 . + +يجب أن تكون الخرائط المعرفية المعروضة على المنصة دقيقة ومحدثة ،مع ضمان أن جميع المواضيع +BC001 لوائح ومتطلبات األعمال +متضمنة. + +يمكن التفاعل مع الخريطة المعرفية باختيار موضوع محدد في الخريطة. الشروط الالحقة + + +--- + + +.6.2.7التفاعل مع الخرائط المعرفية +US007 المعرف + +كـ "مستخدم للمنصة" ،أرغب في التفاعل مع الخريطة المعرفية المتاحة على المنصة حتى أتمكن من استعراض المعلومات +العنوان +المرتبطة بمفهوم االقتصاد الدائري للكربون بشكل تفاعلي. + +المنصة على الويب (.)Web App بيئة العمل + +الزائر · +المستخدمين +المستخدم المسجل · + +ال يوجد الشروط المسبقة + +.1يقوم المستخدم بالدخول إلى المنصة عبر متصفح الويب. +.2يقوم النظام بعرض الصفحة الرئيسية للمنصة. +.3يقوم المستخدم باختيار قسم "الخرائط المعرفية". +.4يقوم النظام بعرض الخريطة المعرفية متضمنة مواضيع االقتصاد الدائري للكربون. +المسار الرئيسي +.5يقوم المستخدم بالتفاعل مع الخريطة المعرفية عبر النقر على موضوع محدد. +.6يقوم النظام بعرض تعريف بسيط للموضوع المختار. +.7يقوم النظام بعرض المصادر ذات الصلة بالموضوع. +.8يقوم النظام بعرض األخبار والفعاليات المتعلقة بالموضوع. + +في حال عدم وجود خرائط معرفية: +.1يقوم النظام بعرض رسالة تفيد بعدم وجود خرائط معرفية متاحة. ALT001 +.2يقوم النظام بتوجيه المستخدم إلى الصفحة الرئيسية. +الخطوات البديلة +في حال عدم وجود مصادر أو أخبار للموضوع المختار: +.1يقوم النظام بعرض رسالة تفيد بعدم وجود مصادر أو أخبار متاحة لهذا الموضوعINF001 . ALT002 +.2يقوم النظام بتوجيه المستخدم للبحث عن موضوع آخر أو العودة إلى الصفحة الرئيسية. + +في حال حدوث خطأ في تحميل الصفحة: +ERR001 األخطاء +يقوم النظام بعرض رسالة خطأ تفيد بوجود مشكلة في تحميل الصفحةERR001 . + +يجب أن تكون الخرائط المعرفية المعروضة على المنصة دقيقة ومحدثة ،مع ضمان أن جميع المواضيع +BC001 لوائح ومتطلبات األعمال +متضمنة. + +بعد التفاعل مع الخريطة المعرفية ،يتم عرض تعريف بسيط للموضوع المختار ،واستعراض المصادر ذات الصلة ،باإلضافة إلى +الشروط الالحقة +عرض األخبار والفعاليات المتعلقة بالموضوع. + + +--- + + +.6.2.8استعراض المدينة التفاعلية +US008 المعرف + +كـ "مستخدم للمنصة" ،أرغب في استعراض المدينة التفاعلية حتى أتمكن من االطالع على معلومات المدينة بطريقة تفاعلية. العنوان + +المنصة على الويب (.)Web App بيئة العمل + +الزائر · +المستخدمين +المستخدم المسجل · + +ال يوجد الشروط المسبقة + +.1يقوم المستخدم بالدخول إلى المنصة عبر متصفح الويب. +.2يقوم النظام بعرض الصفحة الرئيسية للمنصة. +المسار الرئيسي +.3يقوم المستخدم باختيار قسم "الخرائط المعرفية". +.4يقوم النظام بعرض الخريطة التفاعلية للمدينة ،التي تحتوي على معلومات قابلة للتفاعل. + +في حال عدم وجود بيانات تفاعلية للمدينة: +.1يقوم النظام بعرض رسالة تفيد بعدم وجود بيانات للمدينة التفاعلية. ALT001 الخطوات البديلة +.2يقوم النظام بتوجيه المستخدم إلى الصفحة الرئيسية. + +في حال حدوث خطأ في تحميل الصفحة: +ERR001 األخطاء +يقوم النظام بعرض رسالة خطأ تفيد بوجود مشكلة في تحميل الصفحةERR001 . + +BC001يجب أن تكون المعلومات المعروضة قابلة تعبئة البيانات من قبل المستخدم. لوائح ومتطلبات األعمال + +يمكن التفاعل مع المدينة التفاعلية بإدخال بيانات في المدينة. الشروط الالحقة + + +--- + + +.6.2.9التفاعل مع المدينة التفاعلية +US009 المعرف + +كـ "مستخدم للمنصة" ،أرغب في التفاعل مع المدينة التفاعلية حتى أتمكن من إدخال البيانات واكتساب معلومات تفاعلية +العنوان +مباشرة من المدينة. + +المنصة على الويب (.)Web App بيئة العمل + +الزائر · +المستخدمين +المستخدم المسجل · + +ال يوجد الشروط المسبقة + +.1يقوم المستخدم بالدخول إلى المنصة عبر متصفح الويب. +.2يقوم النظام بعرض الصفحة الرئيسية للمنصة. +.3يقوم المستخدم باختيار قسم "الخرائط المعرفية". +.4يقوم النظام بعرض الخريطة التفاعلية للمدينة ،التي تحتوي على معلومات قابلة للتفاعل. المسار الرئيسي +.5يقوم المستخدم بالتفاعل مع المدينة التفاعلية عن طريق إدخال بيانات نموذج التفاعل مع المدينة التفاعلية. +.6يقوم النظام بحساب المؤشر الناتج عن البيانات المدخلة ويعرضه كمؤشر ألداء المدينة. +.7يقوم النظام بعرض طرق لتحسين هذا الرقم (مثل :اإلزالة ،إعادة االستخدام ،التدوير ،التخفيض). + +في حال عدم وجود بيانات تفاعلية للمدينة: +.1يقوم النظام بعرض رسالة تفيد بعدم وجود بيانات للمدينة التفاعلية. ALT001 الخطوات البديلة +.2يقوم النظام بتوجيه المستخدم إلى الصفحة الرئيسية. + +في حال حدوث خطأ في تحميل الصفحة: +ERR001 األخطاء +يقوم النظام بعرض رسالة خطأ تفيد بوجود مشكلة في تحميل الصفحةERR001 . + +BC001يجب أن يتم تحديث البيانات بشكل ديناميكي بناء على اإلدخاالت الجديدة. لوائح ومتطلبات األعمال + +بعد إدخال البيانات ،يقوم النظام بحساب المؤشر وعرض طرق التحسين المناسبة. الشروط الالحقة + + +--- + + +.6.2.10استعراض االخبار والفعاليات +US010 المعرف + +كـ"مستخدم للمنصة" ،أرغب في استعراض األخبار والفعاليات المتعلقة بالموضوع المختار حتى أتمكن من االطالع على +العنوان +المستجدات ذات الصلة. + +المنصة على الويب (.)Web App بيئة العمل + +الزائر · +المستخدمين +المستخدم المسجل · + +ال يوجد الشروط المسبقة + +يقوم المستخدم بالدخول إلى المنصة عبر متصفح الويب. .1 +يقوم النظام بعرض الصفحة الرئيسية للمنصة. .2 +يقوم المستخدم باختيار قسم "األخبار والفعاليات". .3 +يقوم النظام بعرض قائمة بجميع األخبار والفعاليات المتاحة (العنوان – تاريخ النشر – الموضوع) .4 +المسار الرئيسي +يقوم المستخدم بالبحث عن األخبار والفعاليات حسب العنوان ،التاريخ ،او الموضوع. .5 +يختار المستخدم خبر او فعالية من القائمة لالطالع على تفاصيله. .6 +يقوم النظام بعرض تفاصيل الخبر او الفعالية في نموذج رفع الخبر او نموذج رفع الفعالية - .7 +عرض فقط.- + +في حال عدم وجود اتصال باإلنترنت: +.1يقوم النظام بعرض رسالة خطأ تفيد بوجود مشكلة في تحميل الصفحة. ALT001 +.2يقوم النظام بإعادة توجيه المستخدم للصفحة الرئيسية بعد المحاولة مجددا. +الخطوات البديلة +في حال لم يجد المستخدم أي أخبار أو فعاليات: +.1يقوم النظام بعرض رسالة تفيد بأنه ال توجد أخبار أو فعاليات حاليا وفقا للبحث المحدد. ALT002 +.2يقوم النظام بتوجيه المستخدم إلجراء بحث آخر. + +ERR00في حال حدوث خطأ في تحميل الصفحة: +األخطاء +1يقوم النظام بعرض رسالة خطأ تفيد بوجود مشكلة في تحميل الصفحةERR001 . + +لوائح ومتطلبات +BC001يجب عرض التفاصيل الكاملة لكل خبر او فعالية. +األعمال + +يقوم المستخدم إما بمتابعة صفحة االخبار ،مشاركة الخبر /الفعالية او إضافة فعالية إلي التقويم. الشروط الالحقة + + +--- + + +.6.2.11مشاركة االخبار والفعاليات +US011 المعرف + +كـ "مستخدم للمنصة" ،أرغب في مشاركة األخبار والفعاليات المتاحة على المنصة مع اآلخرين حتى أتمكن من نشر العنوان +المعلومات المتعلقة بالفعاليات واألخبار المهمة. + +المنصة على الويب (.)Web App بيئة العمل + +الزائر · +المستخدمين +المستخدم المسجل · + +يجب أن يكون هناك أخبار أو فعاليات متاحة للمشاركة. الشروط المسبقة + +.1يقوم المستخدم بالدخول إلى المنصة عبر متصفح الويب. +.2يقوم النظام بعرض الصفحة الرئيسية للمنصة. +.3يقوم المستخدم باختيار قسم "األخبار والفعاليات". +.4يقوم النظام بعرض قائمة بجميع األخبار والفعاليات المتاحة (العنوان – تاريخ النشر – الموضوع) +.5يقوم المستخدم بالبحث عن األخبار والفعاليات حسب العنوان ،التاريخ ،او الموضوع. +.6يختار المستخدم خبر او فعالية من القائمة لالطالع على تفاصيله. +.7يقوم النظام بعرض تفاصيل الخبر او الفعالية في نموذج رفع الخبر او نموذج رفع الفعالية - المسار الرئيسي +عرض فقط.- +.8يقوم المستخدم بالنقر على زر " مشاركة". +.9يقوم النظام بعرض خيارات المشاركة المتاحة (مثل البريد اإللكتروني ،أو رابط المشاركة). +.10يقوم المستخدم باختيار وسيلة المشاركة المفضلة (مثل إرسال عبر البريد اإللكتروني أو نسخ الرابط). +.11يقوم النظام بمشاركة الرابط أو إرسال البريد اإللكتروني بنجاح. +.12يقوم النظام بعرض رسالة تأكيد بأن الخبر/الفعالية قد تم مشاركتها بنجاحCON003 . + +في حال لم يكن هناك خبر/فعالية للمشاركة: +.1يقوم النظام بعرض رسالة تفيد بعدم إمكانية مشاركة الخبر/الفعالية في الوقت الحالي. +ALT001 الخطوات البديلة +ERR004 +.2يقوم النظام بتوجيه المستخدم إلى صفحة االخبار والفعاليات. + +في حال فشل عملية المشاركة: +ERR00 +.1يقوم النظام بعرض رسالة خطأ تفيد بوجود مشكلة في المشاركة. األخطاء +1 +.2يقوم النظام بتوجيه المستخدم إلى محاوالت أخرى للمشاركة أو استخدام وسيلة بديلة. + +لوائح ومتطلبات +BC001يجب عرض التفاصيل الكاملة لكل خبر او فعالية. +األعمال + +يتمكن المستخدم من مشاركة األخبار أو الفعاليات مع اآلخرين بنجاح عبر الوسائل المحددة. الشروط الالحقة + +.6.2.12متابعة صفحة االخبار + + +--- + + +US012 المعرف + +كـ "مستخدم للمنصة" ،أرغب في متابعة صفحة األخبار حتى أتمكن من البقاء على اطالع دائم بأحدث األخبار والفعاليات العنوان +المتعلقة بالمنصة. + +المنصة على الويب (.)Web App بيئة العمل + +المستخدم المسجل · المستخدمين + +يجب أن يكون هناك خبر متاح في صفحة األخبار. الشروط المسبقة + +يقوم المستخدم بالدخول إلى المنصة عبر متصفح الويب. .1 +يقوم النظام بعرض الصفحة الرئيسية للمنصة. .2 +يقوم المستخدم باختيار قسم "األخبار والفعاليات". .3 +المسار الرئيسي +يقوم النظام بعرض قائمة بجميع األخبار والفعاليات المتاحة (العنوان – تاريخ النشر – الموضوع) .4 +يقوم المستخدم بالنقر على زر " متابعة صفحة االخبار". .5 +يقوم بتفعيل اإلشعارات للمستخدم بشأن أي تحديثات جديدة تتعلق بالخبر. .6 + +في حال فشل في متابعة صفحة االخبار: +.1يقوم النظام بعرض رسالة خطأ تفيد بفشل عملية المتابعةERR005 . ALT001 الخطوات البديلة +.2يسمح النظام للمستخدم بمحاولة المتابعة مرة أخرى. + +في حال فشل في تحديث حالة المتابعة: +ERR00 +.1يقوم النظام بعرض رسالة خطأ تفيد بفشل عملية التحديث. األخطاء +1 +.2يتيح النظام للمستخدم محاولة المتابعة مرة أخرى أو التوجه إلى إعدادات اإلشعارات. + +لوائح ومتطلبات +BC001يجب أن يتم إعالم المستخدم بنجاح أو فشل عملية المتابعة في الوقت الفعلي. +األعمال + +يقوم النظام بإرسال إشعارات للمستخدم حول أي تحديثات جديدة تتعلق بصفحة االخبار. الشروط الالحقة + + +--- + + +.6.2.13إضافة فعالية إلى التقويم +US013 المعرف + +كـ "مستخدم للمنصة" ،أرغب في إضافة فعالية إلى التقويم الخاص بي حتى أتمكن من تتبع المواعيد المستقبلية لألحداث العنوان +والفعاليات. + +المنصة على الويب (.)Web App بيئة العمل + +الزائر · +المستخدمين +المستخدم المسجل · + +يجب أن يكون هناك خبر متاح في صفحة األخبار. الشروط المسبقة + +.1يقوم المستخدم بالدخول إلى المنصة عبر متصفح الويب. +.2يقوم النظام بعرض الصفحة الرئيسية للمنصة. +.3يقوم المستخدم باختيار قسم "األخبار والفعاليات". +.4يقوم النظام بعرض قائمة بجميع األخبار والفعاليات المتاحة (العنوان – تاريخ النشر – الموضوع) +.5يختار المستخدم فعالية من القائمة لالطالع على تفاصيلها. +.6يقوم النظام بعرض تفاصيل الفعالية في نموذج رفع الفعالية -عرض فقط.- +.7يقوم المستخدم بالنقر على زر " إضافة إلى التقويم". +.8يقوم النظام بإرسال البيانات المشتركة (مثل العنوان ،التاريخ ،الوقت ،الموقع) إلى تقويم المستخدم الشخصي. المسار الرئيسي +· (مالحظة مهمة) :حتى اآلن ،لم يتم تحديد الربط مع أي تقويم معين (مثل ،Google Calendar +،Apple Calendarأو .)Outlookيمكن للمستخدم اختيار التقويم الذي يفضل إضافة +الفعالية إليه ،أو يتم تحميل الحدث كملف )iCalendar (.icsليتم إضافته يدويا إلى التقويم +المختار. +.9يقوم النظام بعرض نافذة منبثقة تؤكد إضافة الفعالية إلى التقويم الشخصي للمستخدم. +.10يقوم النظام بتحديث التقويم وإضافة الفعالية بنجاح. +.11يقوم النظام بعرض رسالة تأكيد بأن الفعالية قد أُضيفت بنجاح إلى التقويم الشخصيCON004 . + +في حال فشل إضافة الفعالية إلى التقويم: +.1يقوم النظام بعرض رسالة خطأ تفيد بفشل عملية اإلضافة ERR006 . ALT001 الخطوات البديلة +.2يتيح النظام للمستخدم محاولة إضافة الفعالية مرة أخرى أو تقديم خيارات بديلة. + +في حال فشل في إضافة الفعالية إلى التقويم: +ERR00 +.1يقوم النظام بعرض رسالة خطأ تفيد بوجود مشكلة في إضافة الفعالية. األخطاء +1 +.2يتيح النظام للمستخدم المحاولة مرة أخرى أو التحقق من إعدادات التقويم + +لوائح ومتطلبات +BC001يجب أن يتم إعالم المستخدم بنجاح أو فشل عملية إضافة الفعالية في الوقت الفعلي. +األعمال + +يجب أن تتيح المنصة للمستخدمين إضافة الفعاليات إلى التقويمات الشخصية وفقا لخياراتهم ( Google, +BC002 +Apple, Outlookأو .)ics. + +يتم إضافة الفعالية بنجاح إلى التقويم الشخصي للمستخدم ويمكنه الوصول إليها في أي وقت. الشروط الالحقة + + +--- + + +.6.2.14استعراض الملف التعريفي للدولة +US014 المعرف + +كـ "مستخدم للمنصة" ،أرغب في استعراض ملف التعريف الخاص بالدولة لكي أتمكن من االطالع على التفاصيل المتعلقة +العنوان +بالدولة. + +المنصة على الويب (.)Web App بيئة العمل + +الزائر · +المستخدمين +المستخدم المسجل · + +يجب أن يكون هناك ملف تعريفي متاح للدولة المختارة. الشروط المسبقة + +يقوم المستخدم بالدخول إلى المنصة عبر متصفح الويب. .1 +يقوم النظام بعرض الصفحة الرئيسية للمنصة. .2 +يقوم المستخدم باختيار قسم "الملف التعريفي للدولة". .3 +يقوم النظام بعرض قائمة بالدول المتاحة لالختيار منها. .4 +يقوم المستخدم باختيار الدولة التي يرغب في االطالع على ملفها التعريفي. .5 +المسار الرئيسي +يقوم النظام بعرض تفاصيل ملف التعريفي في نموذج تحديث الملف التعريفي للدولة -عرض .6 +فقط -باإلضافة إلى عرض التالي عن طريق الربط مع كابسارك: +· تصنيف االقتصاد الدائري للكربون )(Circular Carbon Economy Classification +· أداء االقتصاد الدائري للكربون )(Circular Carbon Economy Performance +مخطط األداء )(CCE Total Index · + +في حال لم يجد المستخدم ملف تعريفي للدولة المختارة: · +.1يقوم النظام بعرض رسالة تفيد بعدم وجود ملف تعريفي متاح للدولة المحددة. ALT001 الخطوات البديلة +.2يقوم النظام بتوجيه المستخدم إلجراء بحث آخر. + +ERR00في حال حدوث خطأ في تحميل الصفحة: +األخطاء +1يقوم النظام بعرض رسالة خطأ تفيد بوجود مشكلة في تحميل الصفحةERR001 . + +يجب أن يكون النظام قادرا على استرجاع وعرض ملف التعريف الخاص بالدولة بشكل صحيح مع جميع +لوائح ومتطلبات +BC001البيانات المتاحة (مثل تصنيف االقتصاد الدائري للكربون ،أداء االقتصاد الدائري للكربون ،ومخطط األداء)، +األعمال +عند اختيار الدولة من قبل المستخدم. + +يقوم المستخدم باالنتقال إلى ملفات الدول األخرى. الشروط الالحقة + + +--- + + +.6.2.15استعراض الملف الشخصي +US015 المعرف + +كـ "مستخدم للمنصة" ،أرغب في استعراض الملف الشخصي الخاص بي لكي أتمكن من االطالع على تفاصيل بياناتي. العنوان + +المنصة على الويب (.)Web App بيئة العمل + +المستخدم المسجل · المستخدمين + +يجب أن يكون هناك ملف شخصي للمستخدم. الشروط المسبقة + +يقوم المستخدم بالدخول إلى المنصة عبر متصفح الويب. .1 +يقوم النظام بعرض الصفحة الرئيسية للمنصة. .2 +يقوم المستخدم باختيار قسم "الملف الشخصي". .3 المسار الرئيسي +يقوم النظام بعرض الصفحة الخاصة بالملف الشخصي الموجودة في نموذج انشاء حساب – المستخدم .4 +-عرض فقط- + +في حال عدم وجود اتصال باإلنترنت: · +.1يقوم النظام بعرض رسالة خطأ تفيد بوجود مشكلة في تحميل الصفحة. ALT001 الخطوات البديلة +.2يقوم النظام بإعادة توجيه المستخدم للصفحة الرئيسية بعد المحاولة مجددا. + +ERR00في حال حدوث خطأ في تحميل الصفحة: +األخطاء +1يقوم النظام بعرض رسالة خطأ تفيد بوجود مشكلة في تحميل الصفحةERR001 . + +لوائح ومتطلبات +BC001يجب أن يتم استرجاع البيانات الشخصية بشكل صحيح من قاعدة البيانات. +األعمال + +يقوم المستخدم باستعراض الملف الشخصي وإمكانية اختيار التعديل. الشروط الالحقة + + +--- + + +.6.2.16تعديل بيانات الملف الشخصي + +US016 المعرف + +كـ "مستخدم للمنصة" ،أرغب في استعراض الملف الشخصي الخاص بي لكي أتمكن من االطالع على تفاصيل بياناتي +العنوان +وتحديثها إذا لزم األمر. + +المنصة على الويب (.)Web App بيئة العمل + +المستخدم المسجل · المستخدمين + +يجب أن يكون هناك ملف شخصي للمستخدم. الشروط المسبقة + +.5يقوم المستخدم بالدخول إلى المنصة عبر متصفح الويب. +.6يقوم النظام بعرض الصفحة الرئيسية للمنصة. +.7يقوم المستخدم باختيار قسم "الملف الشخصي". +.8يقوم النظام بعرض الصفحة الخاصة بالملف الشخصي الموجودة في نموذج انشاء حساب – +المستخدم -عرض فقط- +يقوم المستخدم بالنقر على زر "تعديل "في صفحة الملف الشخصي. .9 المسار الرئيسي +.10يقوم النظام بعرض نموذج لتحرير البيانات الشخصية المتاحة في نموذج انشاء حساب – المستخدم +– ماعدا كلمة المرور- +.11بعد إتمام التعديالت ،يقوم المستخدم بالنقر على زر "حفظ". +.12يقوم النظام بتحديث البيانات ويعرض رسالة تأكيد تفيد بنجاح التعديلCON005. +.13يقوم النظام بعرض الملف الشخصي المحدث للمستخدم مع البيانات الجديدة. + +في حال فشل التعديل: +.1في حال وجود خطأ أثناء التعديل (مثل تنسيق غير صحيح في البريد اإللكتروني أو رقم الهاتف)، ALT001 الخطوات البديلة +يعرض النظام رسالة خطأ توضح المشكلة وتطلب من المستخدم تصحيح البياناتERR007. + +ERR00في حال حدوث خطأ في تحميل الصفحة: +1يقوم النظام بعرض رسالة خطأ تفيد بوجود مشكلة في تحميل الصفحةERR001 . +األخطاء +ERR00في حال كانت البيانات المدخلة غير صحيحة (مثل بريد إلكتروني غير صالح) ،يقوم النظام بعرض رسالة +2خطأ تطلب من المستخدم تصحيح المدخالت. + +BC001يجب أن يتم استرجاع البيانات الشخصية بشكل صحيح من قاعدة البيانات. لوائح ومتطلبات +BC002يجب أن يتم تحديث البيانات الشخصية بنجاح في قاعدة البيانات بعد الضغط على زر "حفظ". األعمال + +بعد تعديل البيانات ،يتم عرض البيانات الجديدة للمستخدم في صفحة الملف الشخصي. الشروط الالحقة + + +--- + + +.6.2.17التسجيل كخبير في مجتمع المعرفة + +US017 المعرف + +كـ "مستخدم للمنصة" ،أرغب في تسجيل حساب كخبير في مجتمع المعرفة لكي أتمكن من مشاركة معرفتي ومهاراتي مع اآلخرين. العنوان + +المنصة على الويب (.)Web App بيئة العمل + +المستخدم المسجل · المستخدمين + +يجب أن يكون هناك ملف شخصي للمستخدم. الشروط المسبقة + +.1يقوم المستخدم بالدخول إلى المنصة عبر متصفح الويب. +.2يقوم النظام بعرض الصفحة الرئيسية للمنصة. +.3يقوم المستخدم باختيار قسم "الملف الشخصي". +.4يقوم النظام بعرض الصفحة الخاصة بالملف الشخصي الموجودة في نموذج انشاء حساب – المستخدم -عرض فقط. - +يقوم المستخدم بالنقر على زر "التسجيل كخبير "في صفحة الملف الشخصي. .5 +.6يقوم النظام بعرض نموذج التسجيل كخبير. +المسار الرئيسي +.7يقوم المستخدم بتعبئة النموذج. +.8يقوم المستخدم بالنقر على زر "إرسال الطلب". +.9يقوم النظام بالتحقق من البيانات المدخلة. +.10في حال كانت البيانات صحيحة ،يقوم النظام بتقديم طلب التسجيل كخبير ،ويعرض رسالة تأكيد طلب التسجيل بنجاح. +CON006 +.11يقوم النظام باشعار المشرف طلب تسجيل كخبيرMSG001 . + +في حال فشل التسجيل بسبب بيانات غير صحيحة: +.1إذا كانت البيانات المدخلة غير صحيحة يقوم النظام بعرض رسالة خطأ ويطلب من المستخدم تصحيح ALT001 الخطوات البديلة +البياناتERR008 . + +في حال حدوث خطأ في تحميل الصفحة: +ERR001 +يقوم النظام بعرض رسالة خطأ تفيد بوجود مشكلة في تحميل الصفحةERR001 . +األخطاء +ERR002في حال كانت البيانات المدخلة غير صحيحة ،يقوم النظام بعرض رسالة خطأ تطلب من المستخدم تصحيح المدخالت. + +لوائح ومتطلبات +BC001يجب تقديم رسالة تأكيد بنجاح التسجيل في حال قبول الطلب. +األعمال + +يتم اشعار المشرف بوجود طلب تسجيل كخبير للمراجعة. الشروط الالحقة + + +--- + + +.6.2.18تقييم خدمات الموقع + +US018 المعرف + +كـ "مستخدم للمنصة" ،أرغب في تقييم خدمات المنصة لكي أتمكن من مشاركة تجربتي وتحسين الخدمة المقدمة. العنوان + +المنصة على الويب (.)Web App بيئة العمل + +الزائر · +المستخدمين +المستخدم المسجل · + +يجب أن يكون المستخدم قد سجل الدخول إلى المنصة أو للزائر بعد الزيارة الثانية للمنصة. الشروط المسبقة + +يقوم المستخدم بالدخول إلى المنصة عبر متصفح الويب. .1 +يقوم النظام بعرض الصفحة الرئيسية للمنصة. .2 +يقوم النظام بعرض نموذج تقييم خدمات الموقع. .3 +المسار الرئيسي +يقوم المستخدم بتعبئة النموذج. .4 +بعد إتمام التقييم ،يقوم المستخدم بالنقر على زر "إرسال". .5 +يقوم النظام بحفظ التقييم وعرض رسالة تأكيد بنجاح إرسال التقييمCON008. .6 + +إذا حدث خطأ أثناء إرسال التقييم: +ALT001 الخطوات البديلة +.1يعرض النظام رسالة خطأ تطلب من المستخدم المحاولة مرة أخرىERR009 . + +ERR00في حال حدوث خطأ أثناء إرسال التقييم: +األخطاء +1يقوم النظام بعرض رسالة خطأ تفيد بوجود مشكلة في إرسال التقييم. + +لوائح ومتطلبات +BC001يجب حفظ التقييم في قاعدة البيانات بشكل صحيح لالستفادة من التقارير. +األعمال + +ال يوجد الشروط الالحقة + + +--- + + +.6.2.19تحديد مقترحات مخصصة للمستخدم بحسب معلوماته + +US019 المعرف + +كـ "مستخدم للمنصة" ،أرغب في تلقي مقترحات مخصصة بناء على معلوماتي الشخصية لكي أتمكن من الوصول إلى +العنوان +محتوى وموارد تالئم اهتماماتي واحتياجاتي. + +المنصة على الويب (.)Web App بيئة العمل + +المستخدم المسجل · المستخدمين + +يجب أن يكون المستخدم قد قام بتسجيل الدخول إلى المنصة. الشروط المسبقة + +يقوم المستخدم بالدخول إلى المنصة عبر متصفح الويب. .1 +يقوم النظام بعرض الصفحة الرئيسية للمنصة. .2 +يقوم النظام بعرض نموذج المقترحات المخصصة. .3 +يقوم المستخدم بتعبئة النموذج. .4 المسار الرئيسي +بعد إتمام التقييم ،يقوم المستخدم بالنقر على زر "إرسال". .5 +يقوم النظام بحفظ البيانات المدخلة في المقترحات المخصصة وعرض رسالة تأكيد بنجاح االرسالCON009 . .6 +يقوم النظام بإعادة ترتيب المصادر ،االخبار والفعاليات ومنشورات مجتمع المعرفة حسب األهمية. .7 + +إذا حدث خطأ أثناء إرسال نموذج المقترحات المخصصة: +ALT001 الخطوات البديلة +.1يعرض النظام رسالة خطأ تطلب من المستخدم المحاولة مرة أخرىERR010 . + +ERR00في حال حدوث خطأ أثناء إرسال نموذج المقترحات المخصصة: +األخطاء +1يقوم النظام بعرض رسالة خطأ تفيد بوجود مشكلة في إرسال نموذج المقترحات المخصصة. + +لوائح ومتطلبات +BC001يجب أن يتم توليد المقترحات بناء على اإلجابات المدخلة في النموذج. +األعمال + +يمكن للمستخدم العودة إلى نموذج التحديد وتعديل اهتماماته أو التفضيالت لتحديث المقترحات المستقبلية. الشروط الالحقة + +.6.2.20البحث بمساعدة المساعد الذكي + + +--- + + +US020 المعرف + +العنوان :كـ "مستخدم للمنصة" ،أرغب في استخدام المساعد الذكي للبحث عن المعلومات لكي أتمكن من الحصول على +العنوان +نتائج دقيقة وسريعة بناء على استفساراتي. + +المنصة على الويب (.)Web App بيئة العمل + +الزائر · +المستخدمين +المستخدم المسجل · + +يجب أن يتوفر المساعد الذكي على المنصة ويستند إلى المصادر المتاحة على الموقع فقط. · +الشروط المسبقة +يتطلب الربط مع المساعد الذكي لتفعيل البحث استنادا إلى البيانات والمحتوى الموجود في المنصة. · + +يقوم المستخدم بالدخول إلى المنصة عبر متصفح الويب. .1 +يقوم النظام بعرض الصفحة الرئيسية للمنصة. .2 +يقوم المستخدم باالنتقال إلى قسم "البحث بمساعدة المساعد الذكي". .3 +يقوم النظام بعرض واجهة البحث المساعدة من خالل المساعد الذكي. .4 +يقوم المستخدم بإدخال استفسار أو نص للبحث في الحقل المخصص لذلك. .5 المسار الرئيسي +يقوم النظام باستخدام المساعد الذكي للبحث بناء على النص المدخل. .6 +· • (مالحظة مهمة) :حتى اآلن ،لم يتم تحديد الربط مع أي مساعد ذكي معين. +يقوم المساعد الذكي بتوليد نتائج البحث استنادا فقط إلى المصادر المتاحة على الموقع. .7 +يقوم النظام بعرض النتائج التي تم استخراجها من المصادر المتاحة على المنصة. .8 + +في حال عدم توفير نتائج دقيقة: +.1إذا لم يقدم المساعد الذكي نتائج دقيقة ،يعرض النظام رسالة تفيد بعدم وجود نتائج دقيقة بناء ALT001 الخطوات البديلة +على االستفسار المقدم ،ويشجع المستخدم على تعديل استفساره أو المحاولة بطريقة مختلفة . +INF002 + +في حال حدوث خطأ في تحميل المساعد الذكي: +ERR00 +يعرض النظام رسالة خطأ تفيد بوجود مشكلة في تحميل المساعد الذكي أو استجابة غير صحيحة. +1 +ERR011 +األخطاء +في حال عدم وجود نتائج في المصادر المتاحة: +ERR00 +يعرض النظام رسالة تفيد بعدم العثور على نتائج مطابقة لالستفسار بناء على المصادر المتوفرة على +2 +المنصة ،ويحث المستخدم على تعديل النص المدخل أو المحاولة مرة أخرى. + +لوائح ومتطلبات +BC001يجب أن يعتمد المساعد الذكي على المصادر المتاحة على المنصة فقط لتوليد نتائج البحث. +األعمال + +BC002يجب عرض نتائج دقيقة بناء على البيانات والمحتوى المتاح في المنصة. + +بعد فشل البحث أو عدم تقديم نتائج دقيقة ،يمكن للمستخدم تعديل استفساره وإعادة المحاولة للحصول على إجابات أفضل. الشروط الالحقة + + +--- + + + +--- + + +.6.2.21استعراض مجتمع المعرفة +US021 المعرف + +كـ "مستخدم للمنصة" ،أرغب في استعراض مجتمع المعرفة لكي أتمكن من االطالع على المنشورات والموارد المتاحة +العنوان +ضمن هذا المجتمع. + +المنصة على الويب (.)Web App بيئة العمل + +الزائر · +المستخدمين +المستخدم المسجل · + +يجب أن يكون هناك منشورات متاحة في مجتمع المعرفة لالطالع عليها. الشروط المسبقة + +يقوم المستخدم بالدخول إلى المنصة عبر متصفح الويب. .1 +يقوم النظام بعرض الصفحة الرئيسية للمنصة. .2 +المسار الرئيسي +يقوم المستخدم باختيار قسم "مجتمع المعرفة". .3 +يقوم النظام بعرض واجهة مجتمع المعرفة التي تتضمن قائمة بالمنشورات المتاحة. .4 + +في حال عدم توفر منشورات: +.1يعرض النظام رسالة تفيد بعدم وجود منشورات حاليا ويحث المستخدم على المحاولة الحقا. ALT001 الخطوات البديلة +NTF001 + +ERR00في حال حدوث خطأ في تحميل الصفحة: +األخطاء +1يقوم النظام بعرض رسالة خطأ تفيد بوجود مشكلة في تحميل الصفحةERR001 . + +لوائح ومتطلبات +BC001يجب عرض المحتوى المتعلق بمجتمع المعرفة بناء على البيانات المتوفرة في المنصة. +األعمال + +يمكن للمستخدم إنشاء منشور جديد ،التفاعل مع المنشورات (مثل اإلعجاب أو المشاركة) ،أو الرد على منشور ضمن +الشروط الالحقة +مجتمع المعرفة. + + +--- + + +.6.2.22استعراض مجموعات المواضيع +US022 المعرف + +كـ "مستخدم للمنصة" ،أرغب في استعراض مجموعات المواضيع لكي أتمكن من االطالع على المنشورات المتعلقة +العنوان +بموضوع محدد. + +المنصة على الويب (.)Web App بيئة العمل + +الزائر · +المستخدمين +المستخدم المسجل · + +يجب أن يكون هناك منشورات متاحة في مجتمع المعرفة لالطالع عليها. الشروط المسبقة + +يقوم المستخدم بالدخول إلى المنصة عبر متصفح الويب. .1 +يقوم النظام بعرض الصفحة الرئيسية للمنصة. .2 +يقوم المستخدم باختيار قسم "مجتمع المعرفة". .3 +المسار الرئيسي +يقوم النظام بعرض واجهة مجتمع المعرفة التي تتضمن قائمة بالمنشورات المتاحة. .4 +يقوم المستخدم باختيار موضوع محدد من مجموعات المواضيع. .5 +يقوم النظام بعرض المنشورات التي تم تصنيفها تحت الموضوع الذي اختاره المستخدم. .6 + +في حال عدم توفر منشورات: +.2يعرض النظام رسالة تفيد بعدم وجود منشورات حاليا ويحث المستخدم على المحاولة الحقا. ALT001 الخطوات البديلة +NTF001 + +ERR00في حال حدوث خطأ في تحميل الصفحة: +األخطاء +1يقوم النظام بعرض رسالة خطأ تفيد بوجود مشكلة في تحميل الصفحةERR001 . + +لوائح ومتطلبات +BC001يجب عرض المنشورات المتعلقة بالموضوع الذي اختاره المستخدم فقط. +األعمال + +في حال عدم العثور على منشورات ضمن الموضوع المختار ،يمكن للمستخدم تعديل اختياره أو العودة إلى الصفحة +الشروط الالحقة +الرئيسية لمتابعة التصفح. + + +--- + + +.6.2.23متابعة مجموعة -موضوع- +US023 المعرف + +كـ "مستخدم للمنصة" ،أرغب في متابعة مجموعة موضوع معين لكي أتمكن من الحصول على تحديثات جديدة حول +العنوان +المنشورات المتعلقة بهذا الموضوع. + +المنصة على الويب (.)Web App بيئة العمل + +المستخدم المسجل · المستخدمين + +يجب أن يكون المستخدم مسجال في المنصة. الشروط المسبقة + +يقوم المستخدم بالدخول إلى المنصة عبر متصفح الويب. .1 +يقوم النظام بعرض الصفحة الرئيسية للمنصة. .2 +يقوم المستخدم باختيار قسم "مجتمع المعرفة". .3 +يقوم النظام بعرض واجهة مجتمع المعرفة التي تتضمن قائمة بالمنشورات المتاحة. .4 +يقوم المستخدم باختيار موضوع محدد من مجموعات المواضيع. .5 المسار الرئيسي +يقوم النظام بعرض المنشورات التي تم تصنيفها تحت الموضوع الذي اختاره المستخدم. .6 +يقوم المستخدم باختيار متابعة الموضوع. .7 +يقوم النظام بحفظ البيانات وإرسال إشعارات أو تحديثات حول المنشورات الجديدة المتعلقة بالموضوع المختار. .8 +CON010 + +في حال عدم توفر إمكانية المتابعة: +.1إذا كانت هناك مشكلة في متابعة الموضوع أو كان الموضوع ال يدعم المتابعة ،يعرض النظام ALT001 الخطوات البديلة +رسالة تفيد بعدم القدرة على متابعة الموضوع حالياERR012 . + +في حال حدوث مشكلة أثناء المتابعة: +ERR00 +يعرض النظام رسالة خطأ تفيد بوجود مشكلة أثناء محاولة متابعة الموضوع ويحث المستخدم على المحاولة األخطاء +1 +مرة أخرى الحقا. + +لوائح ومتطلبات +BC001يجب إرسال إشعارات للمستخدم عند إضافة منشورات جديدة ضمن المواضيع التي يتابعها. +األعمال + +يمكن للمستخدم إلغاء متابعة الموضوع في أي وقت. +الشروط الالحقة +في حال إضافة منشورات جديدة للموضوع ،يجب أن يتم إرسال إشعار للمستخدم المتابع. + + +--- + + +.6.2.24استعراض منشور +US024 المعرف + +كـ "مستخدم للمنصة" ،أرغب في استعراض منشور لكي أتمكن من االطالع على التفاصيل الكاملة للمنشور المقدم. العنوان + +المنصة على الويب (.)Web App بيئة العمل + +الزائر · +المستخدمين +المستخدم المسجل · + +يجب أن يكون هناك منشورات متاحة في مجتمع المعرفة لالطالع عليها. الشروط المسبقة + +يقوم المستخدم بالدخول إلى المنصة عبر متصفح الويب. .1 +يقوم النظام بعرض الصفحة الرئيسية للمنصة. .2 +يقوم المستخدم باختيار قسم "مجتمع المعرفة". .3 +المسار الرئيسي +يقوم النظام بعرض واجهة مجتمع المعرفة التي تتضمن قائمة بالمنشورات المتاحة. .4 +يقوم المستخدم باختيار المنشور الذي يرغب في االطالع عليه. .5 +يقوم النظام بعرض المنشور ببياناته في نموذج انشاء المنشور. .6 + +في حال عدم توفر منشورات: +.1يعرض النظام رسالة تفيد بعدم وجود منشورات حاليا ويحث المستخدم على المحاولة الحقا. ALT001 الخطوات البديلة +NTF001 + +ERR00في حال حدوث خطأ في تحميل الصفحة: +األخطاء +1يقوم النظام بعرض رسالة خطأ تفيد بوجود مشكلة في تحميل الصفحةERR001 . + +لوائح ومتطلبات +BC001يجب عرض المنشور بالكامل بناء على البيانات المتاحة في المنصة. +األعمال + +يمكن للمستخدم التفاعل مع المنشور (مثل اإلعجاب أو التعليق عليه). الشروط الالحقة + + +--- + + +.6.2.25مشاركة منشور +US025 المعرف + +كـ "مستخدم للمنصة" ،أرغب في مشاركة منشور لكي أتمكن من نشره مع اآلخرين عبر المنصة أو عبر وسائل التواصل +العنوان +االجتماعي. + +المنصة على الويب (.)Web App بيئة العمل + +الزائر · +المستخدمين +المستخدم المسجل · + +يجب أن يكون المنشور متاحا في المنصة. الشروط المسبقة + +.1يقوم المستخدم بالدخول إلى المنصة عبر متصفح الويب. +.2يقوم النظام بعرض الصفحة الرئيسية للمنصة. +.3يقوم المستخدم باختيار قسم "مجتمع المعرفة". +.4يقوم النظام بعرض واجهة مجتمع المعرفة التي تتضمن قائمة بالمنشورات المتاحة. +.5يقوم المستخدم باختيار المنشور الذي يرغب في االطالع عليه. +.6يقوم النظام بعرض المنشور ببياناته في نموذج انشاء المنشور. المسار الرئيسي +.7يقوم المستخدم بالنقر على زر " مشاركة". +.8يقوم النظام بعرض خيارات المشاركة المتاحة (مثل البريد اإللكتروني ،أو رابط المشاركة). +.9يقوم المستخدم باختيار وسيلة المشاركة المفضلة (مثل إرسال عبر البريد اإللكتروني أو نسخ الرابط). +.10يقوم النظام بمشاركة الرابط أو إرسال البريد اإللكتروني بنجاح. +.11يقوم النظام بعرض رسالة تأكيد بأن المنشور قد تم مشاركته بنجاحCON003 . + +في حال لم يكن هناك خبر/فعالية للمشاركة: +.1يقوم النظام بعرض رسالة تفيد بعدم إمكانية مشاركة المنشور في الوقت الحالي. +ALT001 الخطوات البديلة +ERR004 +.2يقوم النظام بتوجيه المستخدم إلى صفحة مجتمع المعرفة. + +في حال فشل عملية المشاركة: +ERR00 +.1يقوم النظام بعرض رسالة خطأ تفيد بوجود مشكلة في المشاركة. األخطاء +1 +.2يقوم النظام بتوجيه المستخدم إلى محاوالت أخرى للمشاركة أو استخدام وسيلة بديلة. + +لوائح ومتطلبات +BC001يجب عرض التفاصيل الكاملة لكل منشور. +األعمال + +يمكن للمستخدم التفاعل مع المنشور (مثل اإلعجاب أو التعليق عليه). الشروط الالحقة + + +--- + + +.6.2.26إنشاء منشور +US026 المعرف + +كـ "مستخدم للمنصة" ،أرغب في مشاركة منشور لكي أتمكن من نشره مع اآلخرين عبر المنصة. العنوان + +المنصة على الويب (.)Web App بيئة العمل + +المستخدم المسجل · المستخدمين + +يجب أن يكون المستخدم مسجال في المنصة. الشروط المسبقة + +يقوم المستخدم بالدخول إلى المنصة عبر متصفح الويب. .1 +يقوم النظام بعرض الصفحة الرئيسية للمنصة. .2 +يقوم المستخدم باختيار قسم "مجتمع المعرفة". .3 +يقوم النظام بعرض واجهة مجتمع المعرفة التي تتضمن قائمة بالمنشورات المتاحة. .4 +يقوم المستخدم بالنقر على خيار "إنشاء منشور". .5 المسار الرئيسي +يقوم النظام بعرض نموذج انشاء منشور. .6 +يقوم المستخدم بإدخال جميع البيانات الالزمة في النموذج. .7 +يقوم المستخدم بالنقر على "نشر". .8 +يقوم النظام بحفظ المنشور وعرض رسالة تأكيد بنجاح إنشاء المنشور CON011 . .9 + +في حال عدم إدخال بيانات كافية: +.1إذا قام المستخدم بمحاولة نشر المنشور دون ملء الحقول اإلجبارية ،يعرض النظام رسالة تطلب ALT001 الخطوات البديلة +منه إدخال البيانات المطلوبةERR013. + +في حال حدوث مشكلة أثناء نشر المنشور: +ERR00 +يعرض النظام رسالة خطأ تفيد بوجود مشكلة في نشر المنشور ويحث المستخدم على المحاولة مرة أخرى. األخطاء +1 +ERR014 + +لوائح ومتطلبات +BC001يجب على المستخدم إدخال البيانات المطلوبة (مثل العنوان والمحتوى) قبل نشر المنشور. +األعمال + +يمكن للمستخدم مراجعة منشوره بعد نشره والتفاعل معه من خالل اإلعجاب أو التعليق. · +الشروط الالحقة +يمكن للمستخدم مشاركة المنشور مع اآلخرين عبر المنصة أو على وسائل التواصل االجتماعي. · + + +--- + + +.6.2.27التفاعل مع منشور +US027 المعرف + +كـ "مستخدم للمنصة" ،أرغب في التفاعل مع المنشور من خالل الرفع أو الخفض لكي أتمكن من تقييم المنشور بشكل +العنوان +مباشر. + +المنصة على الويب (.)Web App بيئة العمل + +المستخدم المسجل · المستخدمين + +يجب أن يكون المستخدم مسجال في المنصة. · +الشروط المسبقة +يجب أن يكون المنشور متاحا في المنصة. · + +يقوم المستخدم بالدخول إلى المنصة عبر متصفح الويب. .1 +يقوم النظام بعرض الصفحة الرئيسية للمنصة. .2 +يقوم المستخدم باختيار قسم "مجتمع المعرفة". .3 +يقوم النظام بعرض واجهة مجتمع المعرفة التي تتضمن قائمة بالمنشورات المتاحة. .4 +يقوم المستخدم باختيار المنشور الذي يرغب في االطالع عليه. .5 +يقوم النظام بعرض المنشور ببياناته في نموذج انشاء المنشور. .6 +المسار الرئيسي +يقوم المستخدم بالتفاعل مع المنشور عبر الرفع أو الخفض: .7 +النقر على الرفع (Rate Up):إذا أراد المستخدم تقييم المنشور بشكل إيجابي ،ينقر على زر الرفع. · +النقر على الخفض (Rate Down):إذا أراد المستخدم تقييم المنشور بشكل سلبي ،ينقر على زر · +الخفض. +.8يقوم النظام بتحديث المنشور إلظهار التفاعل الجديد (رفع فقط). + +في حال حدوث خطأ أثناء التفاعل: +ALT001إذا واجه المستخدم مشكلة أثناء التفاعل مع المنشور (مثل فشل إرسال التقييم) ،يعرض النظام رسالة خطأ الخطوات البديلة +تطلب منه المحاولة مرة أخرى. + +في حال حدوث مشكلة أثناء التفاعل: +ERR00 +1يعرض النظام رسالة خطأ تفيد بوجود مشكلة أثناء التفاعل مع المنشور ويحث المستخدم على المحاولة مرة األخطاء +أخرى الحقا. + +يجب عرض التفاعل الجديد (الرفع أو الخفض) بشكل فوري بعد النقر عليه من قبل المستخدم. +الرفع :يعرض للمستخدم ويظهر بشكل علني العدد اإلجمالي للتقييمات اإليجابية. · لوائح ومتطلبات +BC001 +الخفض :يؤثر على ترتيب المنشورات فقط في النظام (بحسب التقييم اإلجمالي) ،ولكنه ال يظهر · األعمال +علنا للمستخدمين. + +يمكن للمستخدم مراجعة التفاعل الذي قام به في أي وقت. الشروط الالحقة + + +--- + + +.6.2.28متابعة منشور +US028 المعرف + +كـ "مستخدم للمنصة" ،أرغب في متابعة منشور معين لكي أتمكن من الحصول على تحديثات حوله بشكل مستمر. العنوان + +المنصة على الويب (.)Web App بيئة العمل + +المستخدم المسجل · المستخدمين + +يجب أن يكون المستخدم مسجال في المنصة الشروط المسبقة + +.7يقوم المستخدم بالدخول إلى المنصة عبر متصفح الويب. +.8يقوم النظام بعرض الصفحة الرئيسية للمنصة. +.9يقوم المستخدم باختيار قسم "مجتمع المعرفة". +.10يقوم النظام بعرض واجهة مجتمع المعرفة التي تتضمن قائمة بالمنشورات المتاحة. +.11يقوم المستخدم باختيار المنشور الذي يرغب في االطالع عليه. المسار الرئيسي +.12يقوم النظام بعرض المنشور ببياناته في نموذج انشاء المنشور. +.13يقوم المستخدم بالنقر على زر "متابعة المنشور". +.14قوم النظام بحفظ البيانات وإرسال إشعارات أو تحديثات حول المنشورات الجديدة أو التفاعالت المتعلقة +بالمنشور الذي قام المستخدم بمتابعتهCON012 . + +في حال عدم توفر إمكانية المتابعة: +.2إذا كانت هناك مشكلة في متابعة المنشور أو كان المنشور ال يدعم المتابعة ،يعرض النظام رسالة ALT001 الخطوات البديلة +تفيد بعدم القدرة على متابعة المنشور حالياERR015 . + +في حال حدوث مشكلة أثناء المتابعة: +ERR00 +يعرض النظام رسالة خطأ تفيد بوجود مشكلة أثناء محاولة متابعة الموضوع ويحث المستخدم على المحاولة األخطاء +1 +مرة أخرى الحقا. + +لوائح ومتطلبات +BC001يجب إرسال إشعارات للمستخدم عند وجود تحديثات على المنشور. +األعمال + +يمكن للمستخدم إلغاء متابعة المنشور في أي وقت. الشروط الالحقة + + +--- + + +.6.2.29الرد على منشور +US029 المعرف + +كـ "مستخدم للمنصة" ،أرغب في الرد على منشور لكي أتمكن من إضافة تعليقي أو إجابتي على المنشور. العنوان + +المنصة على الويب (.)Web App بيئة العمل + +المستخدم المسجل · المستخدمين + +يجب أن يكون المستخدم مسجال في المنصة الشروط المسبقة + +.1يقوم المستخدم بالدخول إلى المنصة عبر متصفح الويب. +.2يقوم النظام بعرض الصفحة الرئيسية للمنصة. +.3يقوم المستخدم باختيار قسم "مجتمع المعرفة". +.4يقوم النظام بعرض واجهة مجتمع المعرفة التي تتضمن قائمة بالمنشورات المتاحة. +.5يقوم المستخدم باختيار المنشور الذي يرغب في االطالع عليه. +.6يقوم النظام بعرض المنشور ببياناته في نموذج انشاء المنشور. المسار الرئيسي +.7يقوم المستخدم بالنقر على "الرد "أو حقل التعليق. +.8يقوم المستخدم بكتابة رده في الحقل المخصص. +.9يقوم المستخدم بالنقر على زر "إرسال "إلضافة رده. +.10يقوم النظام بحفظ الرد وعرضه أسفل المنشور مباشرة مع التفاعل من باقي المستخدمين. +.11يقوم النظام بعرض رسالة تأكيد للمستفيد تفيد بنجاح إرسال الردCON013 . + +في حال عدم إدخال بيانات في الرد: +.1إذا حاول المستخدم إرسال رد فارغ ،يعرض النظام رسالة تطلب منه إدخال نص في حقل الرد. ALT001 الخطوات البديلة +ERR016 + +في حال حدوث مشكلة أثناء إرسال الرد: +ERR00 +يعرض النظام رسالة خطأ تفيد بوجود مشكلة أثناء إرسال الرد ويحث المستخدم على المحاولة مرة أخرى. األخطاء +1 +ERR017 + +لوائح ومتطلبات +BC001يجب عرض الردود بشكل فوري للمستخدم بعد إرسالها. +األعمال + +يمكن للمستخدم مراجعة الردود التي أضافها في أي وقت. الشروط الالحقة + + +--- + + +.6.2.30استعراض الملف الشخصي لمستخدم +US030 المعرف + +كـ "مستخدم للمنصة" ،أرغب في استعراض الملف الشخصي لمستخدم آخر لكي أتمكن من االطالع على معلوماته ومتابعة +العنوان +نشاطاته على المنصة. + +المنصة على الويب (.)Web App بيئة العمل + +المستخدم المسجل · المستخدمين + +يجب أن يكون المستخدم مسجال في المنصة الشروط المسبقة + +يقوم المستخدم بالدخول إلى المنصة عبر متصفح الويب. .1 +يقوم النظام بعرض الصفحة الرئيسية للمنصة. .2 +يقوم المستخدم باختيار قسم "مجتمع المعرفة". .3 +يقوم النظام بعرض واجهة مجتمع المعرفة التي تتضمن قائمة بالمنشورات المتاحة. .4 +يقوم المستخدم باختيار ملف المستخدم الذي يرغب في استعراضه. .5 +يقوم النظام بعرض الملف الشخصي للمستخدم .6 +· االسم األول +· االسم األخير +المسار الرئيسي +· المسمى الوظيفي +· اسم المنظمة +· تاريخ االنضمام +· عدد المنشورات +· عدد الردود +· في حال كان خبير : +· السيرة الذاتية -وصف – +· عالمة التوثيق كخبير + +في حال عدم وجود اتصال باإلنترنت: +.1يقوم النظام بعرض رسالة خطأ تفيد بوجود مشكلة في تحميل الصفحة. ALT001 الخطوات البديلة +.2يقوم النظام بإعادة توجيه المستخدم للصفحة الرئيسية بعد المحاولة مجددا. + +ERR00في حال حدوث خطأ في تحميل الصفحة: +األخطاء +1يقوم النظام بعرض رسالة خطأ تفيد بوجود مشكلة في تحميل الصفحةERR001 . + +لوائح ومتطلبات +BC001يجب أن يظهر الملف الشخصي للمستخدم في نموذج عرض واضح يتضمن جميع المعلومات المتاحة له. +األعمال + +يمكن للمستخدم التفاعل مع الملف الشخصي مثل متابعته. الشروط الالحقة + + +--- + + +.6.2.31متابعة مستخدم +US031 المعرف + +كـ "مستخدم للمنصة" ،أرغب في متابعة مستخدم آخر لكي أتمكن من االطالع على نشاطاته ومنشوراته الجديدة بشكل +العنوان +مستمر. + +المنصة على الويب (.)Web App بيئة العمل + +المستخدم المسجل · المستخدمين + +يجب أن يكون المستخدم مسجال في المنصة الشروط المسبقة + +يقوم المستخدم بالدخول إلى المنصة عبر متصفح الويب. .1 +يقوم النظام بعرض الصفحة الرئيسية للمنصة. .2 +يقوم المستخدم باختيار قسم "مجتمع المعرفة". .3 +يقوم النظام بعرض واجهة مجتمع المعرفة التي تتضمن قائمة بالمنشورات المتاحة. .4 +يقوم المستخدم باختيار ملف المستخدم الذي يرغب في استعراضه. .5 +يقوم النظام بعرض الملف الشخصي للمستخدم .6 +· االسم األول +· االسم األخير +· المسمى الوظيفي +· اسم المنظمة المسار الرئيسي +· تاريخ االنضمام +· عدد المنشورات +· عدد الردود +· في حال كان خبير : +· السيرة الذاتية -وصف – +· عالمة التوثيق كخبير +يقوم المستخدم بالنقر على زر "متابعة "الموجود في صفحة الملف الشخصي. .7 +يقوم النظام بحفظ بيانات المتابعة وتحديث حالة المتابعة للمستخدم. .8 +يعرض النظام رسالة تأكيدية تفيد بنجاح متابعة المستخدم. .9 + +في حال عدم توفر إمكانية المتابعة: +.1إذا كانت هناك مشكلة في متابعة المستخدم ،يعرض النظام رسالة تفيد بعدم القدرة ALT001 الخطوات البديلة +على متابعة المستخدم حالياERR018 . + +في حال حدوث مشكلة أثناء المتابعة: +ERR00 +يعرض النظام رسالة خطأ تفيد بوجود مشكلة أثناء محاولة متابعة الموضوع ويحث المستخدم على المحاولة األخطاء +1 +مرة أخرى الحقا. + +يجب أن يتم حفظ حالة المتابعة في النظام بحيث يتمكن المستخدم من متابعة منشورات المستخدم الذي تم لوائح ومتطلبات +BC001 +متابعته بسهولة. األعمال + +يمكن للمستخدم إلغاء المتابعة في أي وقت عن طريق النقر على زر "إلغاء المتابعة". الشروط الالحقة + + +--- + + +.6.2.32استعراض السياسات واالحكام +US032 المعرف + +كـ "مستخدم للمنصة" ،أرغب في استعراض السياسات واألحكام لكي أتمكن من االطالع على تفاصيل القوانين والتنظيمات +العنوان +الخاصة باستخدام المنصة. + +المنصة على الويب (.)Web App بيئة العمل + +الزائر · +المستخدمين +المستخدم المسجل · + +يجب أن يكون المستخدم قد قام بتسجيل الدخول إذا كان يريد تخصيص الصفحة أو الوصول إلى الخدمات المخصصة للمستخدم +الشروط المسبقة +فقط. + +.1يقوم المستخدم بالدخول إلى المنصة عبر متصفح الويب. +.2يقوم النظام بعرض الصفحة الرئيسية للمنصة. +المسار الرئيسي +.3يختار المستخدم "السياسات واالحكام". +.4يعرض النظام السياسات واالحكام للمنصة الخاصة باستخدام المنصة. + +في حال عدم وجود اتصال باإلنترنت: +.1يقوم النظام بعرض رسالة خطأ تفيد بوجود مشكلة في تحميل الصفحة. ALT001 الخطوات البديلة +.2يقوم النظام بإعادة توجيه المستخدم للصفحة الرئيسية بعد المحاولة مجددا. + +في حال حدوث خطأ في تحميل الصفحة: +ERR001 األخطاء +· يقوم النظام بعرض رسالة خطأ تفيد بوجود مشكلة في تحميل الصفحةERR001 . + +جب أن تتضمن صفحة السياسات واألحكام جميع المعلومات الضرورية حول القوانين والتنظيمات الخاصة +BC001 لوائح ومتطلبات األعمال +باستخدام المنصة + +يمكن للمستخدم العودة إلى الصفحة الرئيسية أو التنقل بين األقسام األخرى للمنصة بعد االطالع على السياسات واألحكام. الشروط الالحقة + + +--- + + +.6.2.33إنشاء حساب +US033 المعرف + +كـ "مستخدم جديد" ،أرغب في إنشاء حساب على المنصة لكي أتمكن من الوصول إلى جميع الميزات والخدمات المتاحة. العنوان + +المنصة على الويب (.)Web App بيئة العمل + +الزائر · المستخدمين + +يجب أن يكون المستخدم ليس مسجال مسبقا في المنصة. الشروط المسبقة + +.1يقوم المستخدم بالدخول إلى المنصة عبر متصفح الويب. +.2يقوم النظام بعرض الصفحة الرئيسية للمنصة. +.3يختار المستخدم "إنشاء حساب". +.4يقوم النظام بعرض نموذج إنشاء حساب. +المسار الرئيسي +يقوم المستخدم بإدخال جميع البيانات الالزمة في النموذج. .5 +يقوم المستخدم بالنقر على "إنشاء حساب". .6 +يقوم النظام بالتحقق من صحة البيانات المدخلة ،وفي حال كانت البيانات صحيحة ،يقوم النظام بإنشاء الحساب .7 +للمستخدم. +يقوم النظام بعرض رسالة تأكيد بنجاح عملية التسجيل وتوجيه المستخدم إلى صفحة تسجيل الدخول. .8 + +في حال عدم إدخال بيانات كافية: +.1إذا قام المستخدم بمحاولة إنشاء الحساب دون ملء الحقول اإلجبارية ،يعرض النظام ALT001 الخطوات البديلة +رسالة تطلب منه إدخال البيانات المطلوبةERR013. + +في حال حدوث مشكلة أثناء إنشاء الحساب: +· يعرض النظام رسالة خطأ تفيد بوجود مشكلة في إنشاء المستخدم ويحث المستخدم على المحاولة ERR001 األخطاء +مرة أخرىERR019 . + +BC001يجب التحقق من صحة البيانات المدخلة قبل إنشاء الحساب. لوائح ومتطلبات األعمال + +بعد إنشاء الحساب ،يمكن للمستخدم تسجيل الدخول إلى المنصة باستخدام بياناته الجديدة ،وبدء استخدام الخدمات المتاحة +الشروط الالحقة +للمستخدمين المسجلين. + + +--- + + +.6.2.34تسجيل الدخول +US034 المعرف + +كـ "مستخدم مسجل" ،أرغب في تسجيل الدخول إلى المنصة باستخدام بياناتي لكي أتمكن من الوصول إلى جميع الميزات +العنوان +والخدمات المتاحة. + +المنصة على الويب (.)Web App بيئة العمل + +المستخدم · المستخدمين + +يجب أن يكون المستخدم مسجال في المنصة ولديه حساب صالح. الشروط المسبقة + +.1يقوم المستخدم بالدخول إلى المنصة عبر متصفح الويب. +.2يقوم النظام بعرض الصفحة الرئيسية للمنصة. +.3يختار المستخدم "تسجيل الدخول". +.4يقوم النظام بعرض نموذج تسجيل الدخول. +المسار الرئيسي +يقوم المستخدم بإدخال جميع البيانات الالزمة في النموذج. .5 +يقوم المستخدم بالنقر على "تسجيل الدخول". .6 +يقوم النظام بالتحقق من صحة البيانات المدخلة في حال كانت البيانات صحيحة ،يقوم النظام بتسجيل الدخول .7 +للمستخدم. +يقوم النظام بتوجيه المستخدم إلى الصفحة الرئيسية أو الصفحة التي كان يحاول الوصول إليها. .8 + +في حال إدخال بيانات غير صحيحة: +إذا أدخل المستخدم بيانات غير صحيحة ،يعرض النظام رسالة خطأ تفيد بأن البيانات غير صحيحة · ALT001 الخطوات البديلة +ويطلب منه إعادة المحاولةERR020 . + +في حال حدوث مشكلة أثناء تسجيل الدخول: +· يعرض النظام رسالة خطأ تفيد بوجود مشكلة في تسجيل الدخول ويحث المستخدم على المحاولة ERR001 األخطاء +مرة أخرىERR021 . + +BC001يجب التحقق من صحة البيانات المدخلة (البريد اإللكتروني وكلمة المرور) قبل السماح بتسجيل الدخول. لوائح ومتطلبات األعمال + +بعد تسجيل الدخول ،يمكن للمستخدم الوصول إلى الميزات والخدمات المتاحة له في المنصة ،بما في ذلك متابعة نشاطاته، +الشروط الالحقة +المشاركة في مجتمع المعرفة ،وتخصيص اإلعدادات الخاصة به. + + +--- + + +.6.2.35استعادة كلمة المرور +US035 المعرف + +كـ "مستخدم مسجل" ،أرغب في استعادة كلمة المرور الخاصة بي لكي أتمكن من الدخول إلى حسابي إذا نسيت كلمة المرور. العنوان + +المنصة على الويب (.)Web App بيئة العمل + +المستخدم · المستخدمين + +يجب أن يكون المستخدم مسجال في المنصة ولديه حساب صالح. الشروط المسبقة + +.1يقوم المستخدم بالدخول إلى المنصة عبر متصفح الويب. +.2يقوم النظام بعرض الصفحة الرئيسية للمنصة. +.3يختار المستخدم "تسجيل الدخول". +في صفحة تسجيل الدخول ،يقوم المستخدم بالنقر على خيار "نسيت كلمة المرور؟". .4 +يقوم النظام بعرض نموذج استعادة كلمة المرور. .5 +يقوم المستخدم بإدخال البريد اإللكتروني المسجل في النظام. .6 +يقوم المستخدم بالنقر على "إرسال رابط إعادة تعيين كلمة المرور". .7 + +إذا كان البريد اإللكتروني مسجال ،يقوم النظام بإرسال رسالة إلى البريد اإللكتروني تحتوي على رابط إلعادة تعيين .8 المسار الرئيسي +كلمة المرور. +.9يقوم المستخدم بفتح البريد اإللكتروني والنقر على الرابط المرسل. +.10يقوم النظام بعرض نموذج إلدخال كلمة مرور جديدة. +.11يقوم المستخدم بإدخال كلمة مرور جديدة وتأكيدها. +.12يقوم المستخدم بالنقر على "تأكيد". + +.13يقوم النظام بتحديث كلمة المرور ويعرض رسالة تأكيد بنجاح استعادة كلمة المرورCON014 . +.14يتم توجيه المستخدم إلى صفحة تسجيل الدخول حيث يمكنه استخدام كلمة المرور الجديدة. + +في حال عدم وجود البريد اإللكتروني في النظام: + +إذا كان البريد اإللكتروني غير مسجل في النظام ،يعرض النظام رسالة خطأ تفيد بعدم العثور على .1 ALT001 الخطوات البديلة +الحساب المرتبط بالبريد اإللكتروني المدخلERR022 . + +في حال حدوث مشكلة أثناء استعادة كلمة المرور: +· يعرض النظام رسالة خطأ تفيد بوجود مشكلة في استعادة كلمة المرور ويحث المستخدم على ERR001 األخطاء +المحاولة مرة أخرىERR023 . + +BC001يجب أن يكون البريد اإللكتروني المدخل مسجال في النظام الستعادة كلمة المرور. لوائح ومتطلبات األعمال + +بعد استعادة كلمة المرور ،يمكن للمستخدم العودة لتسجيل الدخول باستخدام كلمة المرور الجديدة. الشروط الالحقة + + +--- + + +.6.2.36تسجيل الخروج +US036 المعرف + +كـ "مستخدم مسجل" ،أرغب في تسجيل الخروج من المنصة لكي أتمكن من إنهاء جلستي بشكل آمن. العنوان + +المنصة على الويب (.)Web App بيئة العمل + +المستخدم · المستخدمين + +جب أن يكون المستخدم مسجال في المنصة وقام بتسجيل الدخول بالفعل. الشروط المسبقة + +.1يقوم المستخدم بالدخول إلى المنصة عبر متصفح الويب. +.2يقوم النظام بعرض الصفحة الرئيسية للمنصة. +.3يقوم المستخدم بالنقر على أيقونة الملف الشخصي أو إعدادات الحساب في الزاوية العلوية من الصفحة. +يظهر للمستخدم خيار "تسجيل الخروج". .4 المسار الرئيسي +.5يقوم المستخدم بالنقر على خيار "تسجيل الخروج". +.6يقوم النظام بتسجيل الخروج ويعرض رسالة تأكيد بنجاح تسجيل الخروجCON015 . +.7يقوم النظام بإعادة توجيه المستخدم إلى صفحة تسجيل الدخول أو الصفحة الرئيسية للمنصة. + +في حال حدوث خطأ أثناء تسجيل الخروج: +.1إذا حدث خطأ أثناء محاولة تسجيل الخروج) ،يعرض النظام رسالة خطأ تفيد بعدم إمكانية تسجيل +الخروجERR024 . ALT001 الخطوات البديلة + +.2يعرض النظام إمكانية المحاولة مرة أخرى لتسجيل الخروج. + +في حال حدوث مشكلة أثناء تسجيل الخروج: +· يعرض النظام رسالة خطأ تفيد بوجود مشكلة في تسجيل الخروج ويحث المستخدم على المحاولة ERR001 األخطاء +مرة أخرىERR024 . + +BC001يجب على النظام التأكد من أنه تم تسجيل الخروج بشكل صحيح ويجب إزالة الجلسة الحالية للمستخدم. لوائح ومتطلبات األعمال + +بعد تسجيل الخروج ،يجب توجيه المستخدم إلى صفحة تسجيل الدخول أو الصفحة الرئيسية للمنصة. الشروط الالحقة + + +--- + + +.6.2.37تحديث محتوى الصفحة الرئيسية +US037 المعرف + +كـ "مشرف للمنصة" ،أرغب في تحديث محتوى الصفحة الرئيسية للمنصة لكي أتمكن من تحسين وتحديث المعلومات التي +العنوان +تظهر للمستخدمين. + +المنصة على الويب (.)Web App بيئة العمل + +المشرف · المستخدمين + +يجب أن يكون المستخدم مشرفا ومسجال دخوله. الشروط المسبقة + +يقوم المشرف بالدخول إلى المنصة عبر متصفح الويب. .1 +يقوم النظام بعرض الصفحة الرئيسية للمنصة. .2 +يقوم المشرف باختيار قسم "تحديث محتوى الصفحة الرئيسية". .3 +يقوم النظام بعرض خيارات التحديث المتاحة للمشرف ،مثل: .4 +تحديث محتوى تعريف على المنصة · +تحديث محتوى الصفحة الرئيسية · +تحديث محتوى السياسات واألحكام · المسار الرئيسي +يقوم المشرف باختيار تحديث محتوى الصفحة الرئيسية. .5 +يقوم النظام بعرض نموذج تحديث محتوى الصفحة الرئيسية. .6 +يقوم المشرف بتعديل نموذج تحديث محتوى الصفحة الرئيسية. .7 +يقوم المشرف بالنقر على "حفظ وتحديث". .8 +يقوم النظام بحفظ التغييرات وتحديث الصفحة الرئيسية بالمحتوى الجديد. .9 +.10يعرض النظام رسالة تأكيد بنجاح عملية التحديث وتحديث المحتوى في الصفحة الرئيسية للمستخدمينCON016 . + +في حال حدوث مشكلة أثناء تحديث المحتوى: +.1يعرض النظام رسالة خطأ تفيد بوجود مشكلة في التحديث ويحث المشرف على المحاولة مرة أخرى. ALT001 الخطوات البديلة +ERR025 + +في حال حدوث مشكلة أثناء تحديث المحتوى: +ERR001 األخطاء +· يعرض النظام رسالة خطأ تفيد بوجود مشكلة تحديث المحتوى. + +BC001يجب التحقق من البيانات المدخلة قبل تنفيذ عملية التحديث. لوائح ومتطلبات األعمال + +بعد نجاح التحديث ،سيظهر المحتوى الجديد في الصفحة الرئيسية للمستخدمين ،وستكون المعلومات المحدثة متاحة على الفور. الشروط الالحقة + + +--- + + +.6.2.38تحديث تعرف على المنصة +US038 المعرف + +كـ "مشرف للمنصة" ،أرغب في تحديث صفحة "تعرف على المنصة" لكي أتمكن من تحسين وتحديث المعلومات التوضيحية +العنوان +التي تظهر للمستخدمين الجدد حول المنصة. + +المنصة على الويب (.)Web App بيئة العمل + +المشرف · المستخدمين + +يجب أن يكون المستخدم مشرفا ومسجال دخوله. الشروط المسبقة + +يقوم المشرف بالدخول إلى المنصة عبر متصفح الويب. .1 +يقوم النظام بعرض الصفحة الرئيسية للمنصة. .2 +يقوم المشرف باختيار قسم "تحديث محتوى تعرف على المنصة". .3 +يقوم النظام بعرض خيارات التحديث المتاحة للمشرف ،مثل: .4 +تحديث محتوى تعريف على المنصة · +تحديث محتوى الصفحة الرئيسية · +تحديث محتوى السياسات واألحكام · المسار الرئيسي +يقوم المشرف باختيار تحديث محتوى تعرف على المنصة. .5 +يقوم النظام بعرض نموذج تحديث محتوى تعرف على المنصة. .6 +يقوم المشرف بتعديل نموذج تحديث محتوى تعرف على المنصة. .7 +يقوم المشرف بالنقر على "حفظ وتحديث". .8 +يقوم النظام بحفظ التغييرات وتحديث تعرف على المنصة بالمحتوى الجديد. .9 +.10يعرض النظام رسالة تأكيد بنجاح عملية التحديث وتحديث المحتوى في الصفحة الرئيسية للمستخدمينCON016 . + +في حال حدوث مشكلة أثناء تحديث المحتوى: +.2يعرض النظام رسالة خطأ تفيد بوجود مشكلة في التحديث ويحث المشرف على المحاولة مرة أخرى. ALT001 الخطوات البديلة +ERR025 + +في حال حدوث مشكلة أثناء تحديث المحتوى: +ERR001 األخطاء +· يعرض النظام رسالة خطأ تفيد بوجود مشكلة تحديث المحتوى. + +BC001يجب التحقق من البيانات المدخلة قبل تنفيذ عملية التحديث. لوائح ومتطلبات األعمال + +بعد نجاح التحديث ،سيظهر المحتوى الجديد في تعرف على المنصة للمستخدمين ،وستكون المعلومات المحدثة متاحة على +الشروط الالحقة +الفور. + + +--- + + +.6.2.39تحديث السياسات واالحكام +US039 المعرف + +كـ "مشرف للمنصة" ،أرغب في تحديث صفحة "تعرف على المنصة" لكي أتمكن من تحسين وتحديث المعلومات التوضيحية +العنوان +التي تظهر للمستخدمين الجدد حول المنصة. + +المنصة على الويب (.)Web App بيئة العمل + +المشرف · المستخدمين + +يجب أن يكون المستخدم مشرفا ومسجال دخوله. الشروط المسبقة + +يقوم المشرف بالدخول إلى المنصة عبر متصفح الويب. .1 +يقوم النظام بعرض الصفحة الرئيسية للمنصة. .2 +يقوم المشرف باختيار قسم "تحديث محتوى السياسات واالحكام". .3 +يقوم النظام بعرض خيارات التحديث المتاحة للمشرف ،مثل: .4 +تحديث محتوى تعريف على المنصة · +تحديث محتوى الصفحة الرئيسية · +تحديث محتوى السياسات واألحكام · المسار الرئيسي +يقوم المشرف باختيار تحديث محتوى السياسات واالحكام. .5 +يقوم النظام بعرض نموذج تحديث محتوى السياسات واالحكام. .6 +يقوم المشرف بتعديل نموذج تحديث محتوى السياسات واالحكام. .7 +يقوم المشرف بالنقر على "حفظ وتحديث". .8 +يقوم النظام بحفظ التغييرات وتحديث تعرف على المنصة بالمحتوى الجديد. .9 +.10يعرض النظام رسالة تأكيد بنجاح عملية التحديث وتحديث المحتوى في السياسات واالحكام للمستخدمينCON016 . + +في حال حدوث مشكلة أثناء تحديث المحتوى: +.3يعرض النظام رسالة خطأ تفيد بوجود مشكلة في التحديث ويحث المشرف على المحاولة مرة أخرى. ALT001 الخطوات البديلة +ERR025 + +في حال حدوث مشكلة أثناء تحديث المحتوى: +ERR001 األخطاء +· يعرض النظام رسالة خطأ تفيد بوجود مشكلة تحديث المحتوى. + +BC001يجب التحقق من البيانات المدخلة قبل تنفيذ عملية التحديث. لوائح ومتطلبات األعمال + +بعد نجاح التحديث ،سيظهر المحتوى الجديد في السياسات واالحكام للمستخدمين ،وستكون المعلومات المحدثة متاحة على +الشروط الالحقة +الفور. + + +--- + + +.6.2.40استعراض المستخدمين +US040 المعرف + +كـ "مشرف عام" ،أرغب في استعراض قائمة المستخدمين لكي أتمكن من إدارة حسابات المستخدمين ومتابعة أنشطتهم. العنوان + +المنصة على الويب (.)Web App بيئة العمل + +المشرف العام · المستخدمين + +يجب أن يكون المستخدم هو المشرف العام للمنصة. الشروط المسبقة + +.1يقوم المشرف بالدخول إلى المنصة عبر متصفح الويب. +.2يقوم النظام بعرض الصفحة الرئيسية للمنصة. +.3يقوم المشرف باختيار قسم "إدارة المستخدمين". +المسار الرئيسي +.4يقوم النظام بعرض واجهة إدارة المستخدمين التي تتضمن قائمة بالمستخدمين المتاحة. +.5يقوم المشرف باختيار المستخدم الذي يرغب في استعراضه. +.6يقوم النظام بعرض تفاصيل المستخدم في نموذج إنشاء مستخدم. + +في حال عدم وجود مستخدمين: +.1يقوم النظام بعرض رسالة تفيد بعدم وجود أي مستخدمين في النظام. ALT001 الخطوات البديلة +.2يقوم النظام بتوجيه المشرف إلجراء عملية إضافة مستخدم جديد. + +في حال حدوث خطأ في تحميل الصفحة: +ERR001 األخطاء +· يقوم النظام بعرض رسالة خطأ تفيد بوجود مشكلة في تحميل الصفحةERR001 . + +BC001يجب أن يتم عرض تفاصيل صحيحة للمستخدم. لوائح ومتطلبات األعمال + +بعد استعراض المستخدمين ،يمكن للمشرف متابعة إدارة الحسابات كإضافة او حذف للمستخدم. الشروط الالحقة + + +--- + + +.6.2.41إنشاء مستخدم +US041 المعرف + +كـ "مشرف عام" ،أرغب في إنشاء مستخدم جديد على المنصة لكي أتمكن من منح صالحيات له واستخدام المنصة. العنوان + +المنصة على الويب (.)Web App بيئة العمل + +المشرف العام · المستخدمين + +يجب أن يكون المستخدم هو المشرف العام للمنصة. الشروط المسبقة + +.1يقوم المشرف بالدخول إلى المنصة عبر متصفح الويب. +.2يقوم النظام بعرض الصفحة الرئيسية للمنصة. +.3يقوم المشرف باختيار قسم "إدارة المستخدمين". +.4يقوم النظام بعرض واجهة إدارة المستخدمين التي تتضمن قائمة بالمستخدمين المتاحة. +.5يقوم المشرف باختيار "إنشاء مستخدم". +.6يقوم النظام بعرض نموذج إنشاء مستخدم. +المسار الرئيسي +.7يقوم المشرف بإدخال البيانات المطلوبة في الحقول المحددة. +.8بعد إدخال البيانات ،يقوم المشرف بالنقر على زر "إنشاء مستخدم". +.9يقوم النظام بالتحقق من صحة البيانات المدخلة ،إذا كانت البيانات صحيحة ،يتم إنشاء الحساب للمستخدم الجديد. +.10يقوم النظام بعرض رسالة تأكيد بنجاح إنشاء المستخدم ،ويعرض تفاصيل المستخدم الجديدCON017 . +.11يتم توجيه المشرف إلى صفحة قائمة المستخدمين أو عرض بيانات المستخدم الجديد في الصفحة الرئيسية لقسم +إدارة المستخدمين. + +في حال عدم إدخال بيانات كافية: +.1إذا قام المستخدم بمحاولة إنشاء الحساب دون ملء الحقول اإلجبارية ،يعرض النظام رسالة تطلب ALT001 الخطوات البديلة +منه إدخال البيانات المطلوبةERR013. + +في حال حدوث مشكلة أثناء إنشاء الحساب: +ERR001يعرض النظام رسالة خطأ تفيد بوجود مشكلة في إنشاء المستخدم ويحث المستخدم على المحاولة مرة أخرى. األخطاء +ERR019 + +BC001يجب التحقق من صحة البيانات المدخلة قبل إنشاء المستخدم. لوائح ومتطلبات األعمال + +يجب أن يكون المشرف قادرا على عرض قائمة بجميع المستخدمين بعد إنشاء الحساب. · +الشروط الالحقة +بعد إنشاء المستخدم بنجاح ،يمكن للمشرف حذف المستخدم حسب الحاجة. · + + +--- + + +.6.2.42حذف مستخدم +US042 المعرف + +كـ "مشرف عام" ،أرغب في حذف مستخدم من المنصة لكي أتمكن من إدارة المستخدمين بشكل أفضل وتنظيم الوصول إلى +العنوان +الخدمات. + +المنصة على الويب (.)Web App بيئة العمل + +المشرف العام · المستخدمين + +يجب أن يكون المستخدم هو المشرف العام للمنصة. الشروط المسبقة + +.1يقوم المشرف بالدخول إلى المنصة عبر متصفح الويب. +.2يقوم النظام بعرض الصفحة الرئيسية للمنصة. +.3يقوم المشرف باختيار قسم "إدارة المستخدمين". +.4يقوم النظام بعرض واجهة إدارة المستخدمين التي تتضمن قائمة بالمستخدمين المتاحة. +.5يقوم المشرف باختيار المستخدم الذي يرغب في استعراضه. +المسار الرئيسي +.6يقوم النظام بعرض تفاصيل المستخدم في نموذج إنشاء مستخدم. +.7يقوم النظام بعرض رسالة تأكيد تطلب من المشرف التأكيد على رغبة الحذف" :هل أنت متأكد أنك تريد حذف هذا +المستخدم؟ مع خيارات "نعم" أو "إلغاء. +إذا اختار المشرف "نعم" ،يقوم النظام بحذف المستخدم من المنصة. .8 +.9يقوم النظام بعرض رسالة تأكيد بنجاح عملية الحذف وتحديث قائمة المستخدمين ويعرضها بدون المستخدم +المحذوفCON018 . + +إذا اختار المشرف "إلغاء": +ALT001 الخطوات البديلة +.1يقوم النظام بإغالق رسالة التأكيد وعدم تنفيذ عملية الحذف ،ويعيد المشرف إلى قائمة المستخدمين. + +في حال حدوث مشكلة أثناء حذف المستخدم: +ERR001يعرض النظام رسالة خطأ تفيد بوجود مشكلة في حذف المستخدم ويحث المستخدم على المحاولة مرة أخرى. األخطاء +ERR026 + +BC001يجب أن يعرض النظام رسالة تأكيد قبل إجراء عملية الحذف لتجنب الحذف غير المقصود. لوائح ومتطلبات األعمال + +بعد حذف المستخدم ،ال يمكن استرجاع بياناته مرة أخرى إال في حال توفر نظام النسخ االحتياطي. · الشروط الالحقة + + +--- + + +.6.2.43استعراض األخبار والفعاليات +US043 المعرف + +كـ "مشرف" ،أرغب في استعراض األخبار والفعاليات لكي أتمكن من متابعة المحتوى المتعلق باألخبار والفعاليات المهمة على +العنوان +المنصة. + +المنصة على الويب (.)Web App بيئة العمل + +المشرف العام · +المشرف · المستخدمين +مشرف المحتوى · + +يجب أن يكون المستخدم مسجال كمشرف على المنصة. · +الشروط المسبقة +يجب أن تكون األخبار والفعاليات متاحة للمراجعة. · + +.1يقوم المشرف بالدخول إلى المنصة عبر متصفح الويب. +.2يقوم النظام بعرض الصفحة الرئيسية للمنصة. +.3يقوم المشرف باختيار قسم "األخبار والفعاليات". +المسار الرئيسي +.4يقوم النظام بعرض واجهة األخبار والفعاليات التي تتضمن قائمة باألخبار والفعاليات المتاحة. +.5يقوم المشرف باختيار الخبر أو الفعالية التي يرغب في االطالع عليها. +.6يقوم النظام بعرض تفاصيل الخبر أو الفعالية في نموذج رفع خبر او نموذج رفع فعالية. + +في حال عدم وجود أخبار أو فعاليات: +ALT001 الخطوات البديلة +.1يعرض النظام رسالة تفيد بعدم وجود أخبار أو فعاليات حالياINF003 . + +في حال حدوث خطأ في تحميل الصفحة: +ERR001 األخطاء +يقوم النظام بعرض رسالة خطأ تفيد بوجود مشكلة في تحميل الصفحةERR001 . + +BC001يجب أن يتم عرض تفاصيل الخبر/الفعالية الصحيحة. لوائح ومتطلبات األعمال + +بعد استعراض الخبر أو الفعالية ،يمكن للمشرف العودة إلى قائمة األخبار والفعاليات الستعراض محتوى آخر. · +الشروط الالحقة +يمكن للمشرف اتخاذ إجراءات إضافية على األخبار أو الفعاليات مثل حذفها إذا كان يملك الصالحية لذلك. · + + +--- + + +.6.2.44رفع األخبار والفعاليات +US044 المعرف + +كـ "مشرف" ،أرغب في رفع األخبار أو الفعاليات لكي أتمكن من إضافة محتوى جديد إلى المنصة. العنوان + +المنصة على الويب (.)Web App بيئة العمل + +المشرف العام · +المشرف · المستخدمين +مشرف المحتوى · + +يجب أن يكون المستخدم مسجال كمشرف على المنصة. · +الشروط المسبقة +يجب أن تكون األخبار والفعاليات متاحة للمراجعة. · + +.1يقوم المشرف بالدخول إلى المنصة عبر متصفح الويب. +.2يقوم النظام بعرض الصفحة الرئيسية للمنصة. +.3يقوم المشرف باختيار قسم "األخبار والفعاليات". +.4يقوم النظام بعرض واجهة األخبار والفعاليات التي تتضمن قائمة باألخبار والفعاليات المتاحة. +.5يقوم المشرف بالنقر على زر "إضافة خبر/فعالية". +.6يقوم النظام بعرض نموذج رفع الخبر أو نموذج رفع الفعالية. المسار الرئيسي +.7يقوم المشرف بتعبئة نموذج رفع الخبر أو نموذج رفع الفعالية. +.8يقوم المشرف بالنقر على زر "إرسال" إلرسال الخبر أو الفعالية إلى النظام. +.9يقوم النظام بالتحقق من صحة البيانات المدخلة ،إذا كانت البيانات صحيحة ،يقوم النظام بإضافة الخبر أو الفعالية +إلى النظام. +.10يعرض النظام رسالة تأكيد بنجاح رفع الخبر أو الفعالية وتوجيه المشرف إلى صفحة عرض األخبار والفعاليات. +CON021 + +في حال عدم إدخال بيانات كافية: +.1إذا قام المشرف بمحاولة رفع خبر/فعالية دون ملء الحقول اإلجبارية ،يعرض النظام رسالة تطلب ALT001 الخطوات البديلة +منه إدخال البيانات المطلوبةERR013. + +في حال حدوث مشكلة أثناء رفع خبر/فعالية: +ERR001يعرض النظام رسالة خطأ تفيد بوجود مشكلة في رفع خبر/فعالية ويحث المشرف على المحاولة مرة أخرى. األخطاء +ERR027 + +BC001يجب التحقق من صحة البيانات المدخلة قبل رفع خبر/فعالية. لوائح ومتطلبات األعمال + +بعد رفع الخبر أو الفعالية ،يمكن للمشرف حذف الخبر/الفعالية في حال تطلب األمر ذلك. · الشروط الالحقة + + +--- + + + +--- + + +.6.2.45حذف األخبار والفعاليات +US045 المعرف + +كـ "مشرف" ،أرغب في حذف مستخدم من المنصة لكي أتمكن من تنظيم المحتوى بشكل فعال. العنوان + +المنصة على الويب (.)Web App بيئة العمل + +المشرف العام · +المشرف · المستخدمين +مشرف المحتوى · + +يجب أن يكون المستخدم مسجال كمشرف على المنصة. · +الشروط المسبقة +يجب أن تكون األخبار والفعاليات متاحة للمراجعة. · + +.1يقوم المشرف بالدخول إلى المنصة عبر متصفح الويب. +.2يقوم النظام بعرض الصفحة الرئيسية للمنصة. +.3يقوم المشرف باختيار قسم "األخبار والفعاليات". +.4يقوم النظام بعرض واجهة األخبار والفعاليات التي تتضمن قائمة باألخبار والفعاليات المتاحة. +.5يقوم المشرف باختيار الخبر أو الفعالية التي يرغب في االطالع عليها. +.6يقوم النظام بعرض تفاصيل الخبر أو الفعالية في نموذج رفع خبر او نموذج رفع فعالية. المسار الرئيسي +.7يقوم المشرف بالنقر على زر "حذف خبر/فعالية". +.8يقوم النظام بعرض رسالة تأكيد تطلب من المشرف التأكد من رغبته في حذف خبر/فعالية بشكل نهائي. +يقوم المشرف بتأكيد عملية الحذف عبر النقر على "تأكيد الحذف". .9 +.10يقوم النظام بحذف خبر/فعالية من النظام. +.11يقوم النظام بعرض رسالة تأكيد بنجاح خبر/فعالية وتحديث قائمة االخبار والفعالياتCON020 . + +في حال حدوث مشكلة أثناء حذف الخبر/الفعالية: +.1يعرض النظام رسالة خطأ تفيد بوجود مشكلة في حذف الخبر/الفعالية ويحث المشرف على المحاولة ALT001 الخطوات البديلة +مرة أخرىERR028 . + +إذا حدث خطأ أثناء حذف الخبر/الفعالية: +· يعرض النظام رسالة خطأ تفيد بوجود مشكلة في حذف الخبر/الفعالية ويحث المشرف على المحاولة ERR001 األخطاء +مرة أخرى. + +BC001يجب التأكد من أن عملية الحذف تتم بشكل نهائي وال يمكن التراجع عنها بعد تنفيذها. لوائح ومتطلبات األعمال + +بعد حذف الخبر/الفعالية ،يجب أن يتم تحديث جميع الصفحات التي تحتوي على بيانات الخبر/الفعالية المحذوفة لكي تعكس +الشروط الالحقة +التغييرات. + + +--- + + +.6.2.46استعراض المصادر + +US046 المعرف + +كـ "مشرف" ،أرغب في استعراض المصادر المتاحة على المنصة لكي أتمكن من االطالع على المحتوى والمراجع ذات الصلة. العنوان + +المنصة على الويب (.)Web App بيئة العمل + +المشرف العام · +المشرف · المستخدمين +مشرف المحتوى · + +يجب أن يكون المستخدم مسجال كمشرف على المنصة. · +الشروط المسبقة +يجب أن تكون األخبار والفعاليات متاحة للمراجعة. · + +.7يقوم المشرف بالدخول إلى المنصة عبر متصفح الويب. +.8يقوم النظام بعرض الصفحة الرئيسية للمنصة. +.9يقوم المشرف باختيار قسم "المصادر". +المسار الرئيسي +.10يقوم النظام بعرض واجهة المصادر التي تتضمن قائمة بالمصادر المتاحة. +.11يقوم المشرف باختيار المصدر الذي يرغب في االطالع عليها +.12يقوم النظام بعرض تفاصيل المصادر في نموذج رفع المصادر. + +في حال عدم وجود مصدر: +ALT001 الخطوات البديلة +.1يعرض النظام رسالة تفيد بعدم وجود مصادر حالياINF004 . + +في حال حدوث خطأ في تحميل الصفحة: +ERR001 األخطاء +يقوم النظام بعرض رسالة خطأ تفيد بوجود مشكلة في تحميل الصفحةERR001 . + +BC001يجب أن يتم عرض تفاصيل المصادر الصحيحة. لوائح ومتطلبات األعمال + +بعد استعراض المصدر ،يمكن للمشرف العودة إلى قائمة المصادر الستعراض محتوى آخر. · +الشروط الالحقة +يمكن للمشرف اتخاذ إجراءات إضافية على المصادر مثل حذفها إذا كان يملك الصالحية لذلك. · + + +--- + + +.6.2.47رفع المصادر + +US047 المعرف + +كـ "مشرف" ،أرغب في رفع المصادر لكي أتمكن من إضافة محتوى جديد إلى المنصة. العنوان + +المنصة على الويب (.)Web App بيئة العمل + +المشرف العام · +المشرف · المستخدمين +مشرف المحتوى · + +يجب أن يكون المستخدم مسجال كمشرف على المنصة. · +الشروط المسبقة +يجب أن تكون األخبار والفعاليات متاحة للمراجعة. · + +.1يقوم المشرف بالدخول إلى المنصة عبر متصفح الويب. +.2يقوم النظام بعرض الصفحة الرئيسية للمنصة. +.3يقوم المشرف باختيار قسم "المصادر". +.4يقوم النظام بعرض واجهة المصادر التي تتضمن قائمة بالمصادر المتاحة. +.5يقوم المشرف بالنقر على زر "إضافة مصدر". +المسار الرئيسي +.6يقوم النظام بعرض نموذج رفع المصدر. +.7يقوم المشرف بتعبئة نموذج رفع المصدر. +.8يقوم المشرف بالنقر على زر "إرسال" إلرسال المصدر إلى النظام. +.9يقوم النظام بالتحقق من صحة البيانات المدخلة ،إذا كانت البيانات صحيحة ،يقوم النظام بإضافة المصدر إلى النظام. +.10يعرض النظام رسالة تأكيد بنجاح رفع المصدر وتوجيه المشرف إلى صفحة عرض المصادرCON021 . + +في حال عدم إدخال بيانات كافية: +.2إذا قام المشرف بمحاولة رفع مصدر دون ملء الحقول اإلجبارية ،يعرض النظام رسالة تطلب منه ALT001 الخطوات البديلة +إدخال البيانات المطلوبةERR013. + +في حال حدوث مشكلة أثناء رفع مصدر: +ERR001يعرض النظام رسالة خطأ تفيد بوجود مشكلة في مصدر ويحث المشرف على المحاولة مرة أخرى. األخطاء +ERR029 + +BC001يجب التحقق من صحة البيانات المدخلة قبل رفع مصدر. لوائح ومتطلبات األعمال + +بعد رفع مصدر ،يمكن للمشرف حذف المصدر في حال تطلب األمر ذلك. · الشروط الالحقة + + +--- + + +.6.2.48حذف المصادر + +US048 المعرف + +كـ "مشرف" ،أرغب في حذف المصادر من المنصة لكي أتمكن من تنظيم المحتوى بشكل فعال. العنوان + +المنصة على الويب (.)Web App بيئة العمل + +المشرف العام · +المشرف · المستخدمين +مشرف المحتوى · + +يجب أن يكون المستخدم مسجال كمشرف على المنصة. · +الشروط المسبقة +يجب أن تكون األخبار والفعاليات متاحة للمراجعة. · + +.1يقوم المشرف بالدخول إلى المنصة عبر متصفح الويب. +.2يقوم النظام بعرض الصفحة الرئيسية للمنصة. +.3يقوم المشرف باختيار قسم "المصادر". +.4يقوم النظام بعرض واجهة المصادر التي تتضمن قائمة بالمصادر المتاحة. +.5يقوم المشرف باختيار المصدر التي يرغب في االطالع عليها. +.6يقوم النظام بعرض تفاصيل المصدر في نموذج رفع المصادر. المسار الرئيسي +.7يقوم المشرف بالنقر على زر "حذف مصدر". +.8يقوم النظام بعرض رسالة تأكيد تطلب من المشرف التأكد من رغبته في حذف المصدر بشكل نهائي. +يقوم المشرف بتأكيد عملية الحذف عبر النقر على "تأكيد الحذف". .9 +.10يقوم النظام بحذف المصدر من النظام. +.11يقوم النظام بعرض رسالة تأكيد بنجاح حذف المصدر وتحديث قائمة المصادر CON022 + +في حال حدوث مشكلة أثناء حذف المصدر: +.1يعرض النظام رسالة خطأ تفيد بوجود مشكلة في حذف المصدر ويحث المشرف على المحاولة مرة ALT001 الخطوات البديلة +أخرىERR030 . + +إذا حدث خطأ أثناء حذف المصدر: +· يعرض النظام رسالة خطأ تفيد بوجود مشكلة في حذف المصدر ويحث المشرف على المحاولة مرة ERR001 األخطاء +أخرى. + +BC001يجب التأكد من أن عملية الحذف تتم بشكل نهائي وال يمكن التراجع عنها بعد تنفيذها. لوائح ومتطلبات األعمال + +بعد حذف المصدر ،يجب أن يتم تحديث جميع الصفحات التي تحتوي على بيانات المصدر المحذوف لكي تعكس التغييرات. الشروط الالحقة + + +--- + + +.6.2.49استعراض طلبات الدول +US049 المعرف + +كـ "مشرف" ،أرغب في االطالع على طلبات مصادر /اخبار وفعاليات الدول المرفوعة من قبل الدول لكي أتمكن من مراجعتها +العنوان +واتخاذ اإلجراءات المناسبة. + +المنصة على الويب (.)Web App بيئة العمل + +المشرف العام · +المشرف · المستخدمين +مشرف المحتوى · + +يجب أن يكون المستخدم مسجال كمشرف على المنصة. · +الشروط المسبقة +يجب أن تكون الطلبات متاحة لالطالع. · + +.1يقوم المشرف بالدخول إلى المنصة عبر متصفح الويب. +.2يقوم النظام بعرض الصفحة الرئيسية للمنصة. +.3يقوم المشرف باختيار قسم "الطلبات". +.4يقوم النظام بعرض قائمة الطلبات. +.5يقوم المشرف باختيار الطلب الذي يرغب في االطالع عليه. المسار الرئيسي +.6يقوم النظام بعرض الطلب بناء على نوعه +رفع مصدر :متضمنة تفاصيل رفع المصادر في نموذج رفع المصادر -عرض فقط.- • +رفع فعالية او خبر :متضمنة تفاصيل رفع المصادر في نموذج رفع الخبر أو نموذج رفع • +الفعالية -عرض فقط.- + +في حال عدم وجود طلبات: +ALT001 الخطوات البديلة +.1يعرض النظام رسالة تفيد بعدم وجود طلبات متاحةINF005 . + +في حال حدوث خطأ في تحميل الصفحة: +ERR001 األخطاء +يقوم النظام بعرض رسالة خطأ تفيد بوجود مشكلة في تحميل الصفحةERR001 . + +BC001يجب أن يتم عرض تفاصيل الطلبات الصحيحة. لوائح ومتطلبات األعمال + +بعد االطالع على طلبات المصادر ،يمكن للمشرف اتخاذ اإلجراءات المناسبة مثل الموافقة أو الرفض بناء على · +الشروط الالحقة +تفاصيل الطلبات. + + +--- + + +.6.2.50معالجة طلب الدولة +US050 المعرف + +كـ "مشرف" ،أرغب في معالجة طلبات مصادر /اخبار وفعاليات الدول المرفوعة لكي أتمكن من الموافقة عليها أو رفضها بناء +العنوان +على المراجعة. + +المنصة على الويب (.)Web App بيئة العمل + +المشرف العام · +المشرف · المستخدمين +مشرف المحتوى · + +يجب أن يكون المستخدم مسجال كمشرف على المنصة. · +الشروط المسبقة +يجب أن تكون الطلبات متاحة لالطالع. · + +.1يقوم المشرف بالدخول إلى المنصة عبر متصفح الويب. +.2يقوم النظام بعرض الصفحة الرئيسية للمنصة. +.3يقوم المشرف باختيار قسم "الطلبات". +.4يقوم النظام بعرض قائمة الطلبات. +.5يقوم المشرف باختيار الطلب الذي يرغب في االطالع عليه. +.6يقوم النظام بعرض الطلب بناء على نوعه +رفع مصدر :متضمنة تفاصيل رفع المصادر في نموذج رفع المصادر -عرض فقط.- • +رفع فعالية او خبر :متضمنة تفاصيل رفع المصادر في نموذج رفع الخبر أو نموذج رفع • المسار الرئيسي +الفعالية -عرض فقط.- +.7يقوم المشرف باتخاذ اإلجراء المناسب: +.1موافقة الطلب :في حال كان الطلب صحيحا ومناسبا يتم إضافة المصدر إلى مصادر المنصة او يتم إضافة +الفعالية /الخبر في المنصة. +.2رفض الطلب :إذا كان الطلب غير مناسب أو يحتوي على أخطاء. +.8يقوم النظام بتحديث حالة الطلب إلى "موافق" أو "مرفوض". +.9يقوم النظام بعرض النظام رسالة تأكيد معالجة الطلب بنجاحCON023 . +.10يقوم النظام بإرسال إشعارا لممثل الدولة المعنيMSG002 . + +في حال عدم وجود طلبات مصادر: +ALT001 الخطوات البديلة +.1يعرض النظام رسالة تفيد بعدم وجود طلبات متاحةINF005 . + +في حال حدوث خطأ أثناء معالجة الطلب: +ERR001يعرض النظام رسالة خطأ تفيد بوجود مشكلة في معالجة الطلب ويحث المشرف على المحاولة مرة أخرى. األخطاء +ERR031 + +BC001يجب أن يتم إعالم المستخدم المعني بحالة الطلب (موافقة أو رفض). لوائح ومتطلبات األعمال + + +--- + + +بعد معالجة الطلب ،يتم تحديث قائمة الطلبات وعرض الحالة الجديدة للطلب. · الشروط الالحقة + + +--- + + +.6.2.51استعراض الطلبات للمصادر – ممثل الدولة +US051 المعرف + +كـ "ممثل دولة" ،أرغب في االطالع على الطلبات المرفوعة من دولتي للمصادر /اخبار وفعاليات لكي أتمكن من متابعة حالتها +العنوان +واتخاذ اإلجراءات المناسبة. + +المنصة على الويب (.)Web App بيئة العمل + +ممثل الدولة · المستخدمين + +يجب أن تكون الطلبات المرفوعة من قبل الدولة الخاصة بالمستخدم متاحة لالطالع. · الشروط المسبقة + +.1يقوم ممثل الدولة بالدخول إلى المنصة عبر متصفح الويب. +.2يقوم النظام بعرض الصفحة الرئيسية للمنصة. +.3يقوم ممثل الدولة باختيار قسم "الطلبات". +.4يقوم النظام بعرض قائمة بطلبات المصادر الخاصة بممثل الدولة. +.5يقوم ممثل الدولة باختيار الطلب الذي يرغب في االطالع عليه. المسار الرئيسي +.6يقوم النظام بعرض الطلب بناء على نوعه +رفع مصدر :متضمنة تفاصيل رفع المصادر في نموذج رفع المصادر -عرض فقط.- • +رفع فعالية او خبر :متضمنة تفاصيل رفع المصادر في نموذج رفع الخبر أو نموذج رفع • +الفعالية -عرض فقط.- + +في حال عدم وجود طلبات مصادر: +ALT001 الخطوات البديلة +.1يعرض النظام رسالة تفيد بعدم وجود طلبات متاحةINF005 . + +في حال حدوث خطأ في تحميل الصفحة: +ERR001 األخطاء +يقوم النظام بعرض رسالة خطأ تفيد بوجود مشكلة في تحميل الصفحةERR001 . + +BC001يجب أن يتم عرض تفاصيل الطلبات الصحيحة. لوائح ومتطلبات األعمال + +بعد االطالع على طلبات المصادر ،يمكن لممثل الدولة متابعة حالتها. · الشروط الالحقة + + +--- + + +.6.2.52رفع المصادر – ممثل الدولة + +US052 المعرف + +كـ "ممثل دولة" ،أرغب في رفع المصادر لكي أتمكن من إضافة محتوى جديد إلى المنصة. العنوان + +المنصة على الويب (.)Web App بيئة العمل + +ممثل الدولة · المستخدمين + +يجب أن يكون المستخدم مسجال كممثل دولة على المنصة. · +الشروط المسبقة +يجب أن تكون األخبار والفعاليات متاحة للمراجعة. · + +.1يقوم ممثل الدولة بالدخول إلى المنصة عبر متصفح الويب. +.2يقوم النظام بعرض الصفحة الرئيسية للمنصة. +.3يقوم ممثل الدولة باختيار قسم "المصادر". +.4يقوم النظام بعرض واجهة المصادر التي تتضمن قائمة بالمصادر التي تم رفعها من قبل ممثل الدولة وتم قبولها. +.5يقوم ممثل الدولة بالنقر على زر "إضافة مصدر". +.6يقوم النظام بعرض نموذج رفع المصدر. المسار الرئيسي + +.7يقوم ممثل الدولة بتعبئة نموذج رفع المصدر. +.8يقوم ممثل الدولة بالنقر على زر "إرسال" إلرسال المصدر إلى النظام. +.9يقوم النظام بالتحقق من صحة البيانات المدخلة ،إذا كانت البيانات صحيحة ،يقوم النظام بإشعار المشرف بوجود +طلب للمراجعةMSG003 . +.10يعرض النظام رسالة تأكيد بنجاح رفع طلب المصدر وتوجيه ممثل الدولة إلى صفحة عرض الطلباتCON024 . + +في حال عدم إدخال بيانات كافية: +.1إذا قام ممثل الدولة بمحاولة رفع مصدر دون ملء الحقول اإلجبارية ،يعرض النظام رسالة تطلب ALT001 الخطوات البديلة +منه إدخال البيانات المطلوبةERR013. + +في حال حدوث مشكلة أثناء رفع مصدر: +ERR001يعرض النظام رسالة خطأ تفيد بوجود مشكلة في مصدر ويحث ممثل الدولة على المحاولة مرة أخرى. األخطاء +ERR029 + +BC001يجب التحقق من صحة البيانات المدخلة قبل رفع مصدر. لوائح ومتطلبات األعمال + +بعد رفع المصدر ،يمكن للمشرف متابعة الطلب واتخاذ اإلجراء المناسب. · الشروط الالحقة + +.6.2.53رفع االخبار او الفعاليات – ممثل الدولة + + +--- + + +US053 المعرف + +كـ "ممثل دولة" ،أرغب في رفع المصادر لكي أتمكن من إضافة محتوى جديد إلى المنصة. العنوان + +المنصة على الويب (.)Web App بيئة العمل + +ممثل الدولة · المستخدمين + +يجب أن يكون المستخدم مسجال كممثل دولة على المنصة. · +الشروط المسبقة +يجب أن تكون األخبار والفعاليات متاحة للمراجعة. · + +.1يقوم ممثل الدولة بالدخول إلى المنصة عبر متصفح الويب. +.2يقوم النظام بعرض الصفحة الرئيسية للمنصة. +.3يقوم ممثل الدولة باختيار قسم "االخبار والفعاليات". +.4يقوم النظام بعرض واجهة االخبار والفعاليات التي تتضمن قائمة باالخبار والفعاليات التي تم رفعها من قبل ممثل +الدولة وتم قبولها. +.5يقوم ممثل الدولة بالنقر على زر "إضافة االخبار والفعاليات". +.6يقوم النظام بعرض نموذج رفع الخبر أو نموذج رفع الفعالية. المسار الرئيسي + +.7يقوم ممثل الدولة بتعبئة نموذج رفع الخبر أو نموذج رفع الفعالية. +.8يقوم ممثل الدولة بالنقر على زر "إرسال" إلرسال المصدر إلى النظام. +.9يقوم النظام بالتحقق من صحة البيانات المدخلة ،إذا كانت البيانات صحيحة ،يقوم النظام بإشعار المشرف بوجود +طلب للمراجعةMSG003 . +.10يعرض النظام رسالة تأكيد بنجاح رفع طلب الخبر/الفعالية وتوجيه ممثل الدولة إلى صفحة عرض الطلبات. +CON024 + +في حال عدم إدخال بيانات كافية: +.2إذا قام ممثل الدولة بمحاولة رفع الخبر/الفعالية دون ملء الحقول اإلجبارية ،يعرض النظام رسالة ALT001 الخطوات البديلة +تطلب منه إدخال البيانات المطلوبةERR013. + +في حال حدوث مشكلة أثناء رفع الخبر/الفعالية: +ERR001يعرض النظام رسالة خطأ تفيد بوجود مشكلة في مصدر ويحث ممثل الدولة على المحاولة مرة أخرى. األخطاء +ERR029 + +BC001يجب التحقق من صحة البيانات المدخلة قبل رفع الخبر/الفعالية. لوائح ومتطلبات األعمال + +بعد رفع الخبر/الفعالية ،يمكن للمشرف متابعة الطلب واتخاذ اإلجراء المناسب. · الشروط الالحقة + + +--- + + +.6.2.53استعراض مجتمع المعرفة -المشرف +US054 المعرف + +كـ "مشرف" ،أرغب في استعراض مجتمع المعرفة لكي أتمكن من االطالع على المحتوى المرفوع والمشاركات األخرى +العنوان +واتخاذ اإلجراءات المناسبة. + +المنصة على الويب (.)Web App بيئة العمل + +المشرف العام · +المشرف · المستخدمين +مشرف المحتوى · + +يجب أن يكون هناك منشورات متاحة في مجتمع المعرفة لالطالع عليها. الشروط المسبقة + +يقوم المشرف بالدخول إلى المنصة عبر متصفح الويب. .1 +يقوم النظام بعرض الصفحة الرئيسية للمنصة. .2 +المسار الرئيسي +يقوم المشرف باختيار قسم "مجتمع المعرفة". .3 +يقوم النظام بعرض واجهة مجتمع المعرفة التي تتضمن قائمة بمنشورات مجتمع المعرفة. .4 + +في حال عدم توفر منشورات: +.1يعرض النظام رسالة تفيد بعدم وجود منشورات حاليا ويحث المشرف على المحاولة الحقا. ALT001 الخطوات البديلة +NTF001 + +ERR00في حال حدوث خطأ في تحميل الصفحة: +األخطاء +1يقوم النظام بعرض رسالة خطأ تفيد بوجود مشكلة في تحميل الصفحةERR001 . + +BC001يجب عرض المحتوى المتعلق بمجتمع المعرفة بناء على البيانات المتوفرة في المنصة. لوائح ومتطلبات األعمال + +بعد استعراض المحتوى ،يمكن للمشرف اتخاذ إجراءات إضافية مثل حذف المنشورات. الشروط الالحقة + + +--- + + +.6.2.54استعراض مجموعات المواضيع -المشرف +US055 المعرف + +كـ "مشرف" ،أرغب في استعراض مجموعات المواضيع لكي أتمكن من االطالع على المنشورات المتعلقة بموضوع محدد. العنوان + +المنصة على الويب (.)Web App بيئة العمل + +المشرف العام · +المشرف · المستخدمين +مشرف المحتوى · + +يجب أن يكون هناك منشورات متاحة في مجتمع المعرفة لالطالع عليها. الشروط المسبقة + +يقوم المشرف بالدخول إلى المنصة عبر متصفح الويب. .1 +يقوم النظام بعرض الصفحة الرئيسية للمنصة. .2 +يقوم المشرف باختيار قسم "مجتمع المعرفة". .3 +المسار الرئيسي +يقوم النظام بعرض واجهة مجتمع المعرفة التي تتضمن قائمة بمنشورات مجتمع المعرفة. .4 +يقوم المشرف باختيار موضوع محدد من مجموعات المواضيع. .5 +يقوم النظام بعرض المنشورات التي تم تصنيفها تحت الموضوع الذي اختاره المشرف. .6 + +في حال عدم توفر منشورات: +.1يعرض النظام رسالة تفيد بعدم وجود منشورات حاليا ويحث المشرف على المحاولة الحقا. ALT001 الخطوات البديلة +NTF001 + +ERR00في حال حدوث خطأ في تحميل الصفحة: +األخطاء +1يقوم النظام بعرض رسالة خطأ تفيد بوجود مشكلة في تحميل الصفحةERR001 . + +BC001يجب عرض المنشورات المتعلقة بالموضوع الذي اختاره المشرف فقط. لوائح ومتطلبات األعمال + +في حال عدم العثور على منشورات ضمن الموضوع المختار ،يمكن للمشرف تعديل اختياره أو العودة إلى الصفحة +الشروط الالحقة +الرئيسية. + + +--- + + +.6.2.55استعراض منشور -المشرف + +US056 المعرف + +كـ "مشرف" ،أرغب في استعراض منشور لكي أتمكن من االطالع على التفاصيل الكاملة للمنشور المقدم. العنوان + +المنصة على الويب (.)Web App بيئة العمل + +المشرف العام · +المشرف · المستخدمين +مشرف المحتوى · + +يجب أن يكون هناك منشورات متاحة في مجتمع المعرفة لالطالع عليها. الشروط المسبقة + +يقوم المشرف بالدخول إلى المنصة عبر متصفح الويب. .1 +يقوم النظام بعرض الصفحة الرئيسية للمنصة. .2 +يقوم المشرف باختيار قسم "مجتمع المعرفة". .3 +المسار الرئيسي +يقوم النظام بعرض واجهة مجتمع المعرفة التي تتضمن قائمة بمنشورات مجتمع المعرفة. .4 +يقوم المشرف باختيار المنشور الذي يرغب في االطالع عليه. .5 +يقوم النظام بعرض المنشور ببياناته في نموذج انشاء المنشور. .6 + +في حال عدم توفر منشورات: +.1يعرض النظام رسالة تفيد بعدم وجود منشورات حاليا ويحث المشرف على المحاولة الحقا. ALT001 الخطوات البديلة +NTF001 + +ERR00في حال حدوث خطأ في تحميل الصفحة: +األخطاء +1يقوم النظام بعرض رسالة خطأ تفيد بوجود مشكلة في تحميل الصفحةERR001 . + +BC001يجب عرض المنشور بالكامل بناء على البيانات المتاحة في المنصة. لوائح ومتطلبات األعمال + +بعد استعراض المحتوى ،يمكن للمشرف اتخاذ إجراءات إضافية مثل حذف المنشورات. الشروط الالحقة + + +--- + + +.6.2.56حذف منشور – المشرف + +US057 المعرف + +كـ "مشرف" ،أرغب في حذف المنشور لكي أتمكن من إدارة محتوى مجتمع المعرفة بشكل فعال والحفاظ على جودة +العنوان +المحتوى. + +المنصة على الويب (.)Web App بيئة العمل + +المشرف العام · +المشرف · المستخدمين +مشرف المحتوى · + +يجب أن يكون هناك منشور موجود في مجتمع المعرفة لكي يتم حذفه. · +الشروط المسبقة +يجب أن يكون المستخدم مسجال كمشرف أو مشرف محتوى. · + +يقوم المشرف بالدخول إلى المنصة عبر متصفح الويب. .1 +يقوم النظام بعرض الصفحة الرئيسية للمنصة. .2 +يقوم المشرف باختيار قسم "مجتمع المعرفة". .3 +يقوم النظام بعرض واجهة مجتمع المعرفة التي تتضمن قائمة بمنشورات مجتمع المعرفة. .4 +يقوم المشرف باختيار المنشور الذي يرغب في االطالع عليه. .5 +يقوم النظام بعرض المنشور ببياناته في نموذج انشاء المنشور. .6 +.7يقوم المشرف بالنقر على زر "حذف المنشور". المسار الرئيسي +.8يقوم النظام بعرض رسالة تأكيد تطلب من المشرف التأكد من رغبته في حذف المنشور بشكل نهائي. +يقوم المشرف بتأكيد عملية الحذف عبر النقر على "تأكيد الحذف". .9 +.10يقوم النظام بحذف المنشور من النظام. +.11يقوم النظام بعرض رسالة تأكيد بنجاح حذف المنشور وتحديث قائمة المنشوراتCON025 . +.12يقوم النظام بإشعار المستخدم الذي قام بنشر المنشور بحذفه من قبل المنصةMSG004 . + +في حال حدوث مشكلة أثناء حذف المنشور: + +يعرض النظام رسالة خطأ تفيد بوجود مشكلة في حذف المنشور ويحث المشرف على المحاولة .1 ALT001 الخطوات البديلة +مرة أخرىERR032 . + +ERR00في حال حدوث خطأ في تحميل الصفحة: +األخطاء +1يقوم النظام بعرض رسالة خطأ تفيد بوجود مشكلة في تحميل الصفحةERR001 . + +BC001يجب التأكد من أن عملية الحذف تتم بشكل نهائي وال يمكن التراجع عنها بعد تنفيذها. لوائح ومتطلبات األعمال + +يجب إشعار المشرف والمستخدم بحالة المنشور (تم حذفه) وتحديث قائمة المنشورات على الفور. الشروط الالحقة + + +--- + + +.6.2.57استعراض طلبات التسجيل كخبير +US058 المعرف + +كـ "مشرف" ،أرغب في معالجة طلبات التسجيل كخبير لكي أتمكن من الموافقة أو الرفض بناء على مراجعة التفاصيل. العنوان + +المنصة على الويب (.)Web App بيئة العمل + +المشرف العام · +المشرف · المستخدمين +مشرف المحتوى · + +يجب أن يكون المستخدم مسجال كمشرف على المنصة. · +الشروط المسبقة +يجب أن تكون الطلبات متاحة لالطالع. · + +.1يقوم المشرف بالدخول إلى المنصة عبر متصفح الويب. +.2يقوم النظام بعرض الصفحة الرئيسية للمنصة. +.3يقوم المشرف باختيار قسم "الطلبات". +.4يقوم النظام بعرض قائمة الطلبات. المسار الرئيسي + +.5يقوم المشرف باختيار الطلب الذي يرغب في االطالع عليه. +.6يقوم النظام بعرض طلب تسجيل كخبير متضمنة تفاصيل تسجيل كخبير في نموذج التسجيل كخبير -عرض +فقط.- + +في حال عدم وجود طلبات مصادر: +ALT001 الخطوات البديلة +.2يعرض النظام رسالة تفيد بعدم وجود طلبات متاحةINF005 . + +في حال حدوث خطأ في تحميل الصفحة: +ERR001 األخطاء +يقوم النظام بعرض رسالة خطأ تفيد بوجود مشكلة في تحميل الصفحةERR001 . + +BC001يجب أن يتم عرض تفاصيل الطلبات الصحيحة. لوائح ومتطلبات األعمال + +بعد االطالع على طلبات التسجيل كخبير ،يمكن للمشرف اتخاذ اإلجراءات المناسبة مثل الموافقة أو الرفض بناء على · +الشروط الالحقة +تفاصيل الطلبات. + + +--- + + +.6.2.58معالجة طلبات التسجيل كخبير +US059 المعرف + +كـ "مشرف" ،أرغب في االطالع على طلبات مصادر الدول المرفوعة من قبل الدول لكي أتمكن من مراجعتها واتخاذ اإلجراءات +العنوان +المناسبة. + +المنصة على الويب (.)Web App بيئة العمل + +المشرف العام · +المشرف · المستخدمين +مشرف المحتوى · + +يجب أن يكون المستخدم مسجال كمشرف على المنصة. · +الشروط المسبقة +يجب أن تكون الطلبات متاحة لالطالع. · + +.1يقوم المشرف بالدخول إلى المنصة عبر متصفح الويب. +.2يقوم النظام بعرض الصفحة الرئيسية للمنصة. +.3يقوم المشرف باختيار قسم "الطلبات". +.4يقوم النظام بعرض قائمة الطلبات. +.5يقوم المشرف باختيار الطلب الذي يرغب في االطالع عليه. +.6يقوم النظام بعرض طلب تسجيل كخبير متضمنة تفاصيل تسجيل كخبير في نموذج التسجيل كخبير -عرض +فقط.- +المسار الرئيسي +.7يقوم المشرف باتخاذ اإلجراء المناسب: +موافقة الطلب :في حال كان الطلب صحيحا ومناسبا يتم إضافة المستخدم إلى قائمة الخبراء واضافة · +عالمة الخبير للمستخدم. +رفض الطلب :إذا كان الطلب غير مناسب أو يحتوي على أخطاء. · +.8يقوم النظام بتحديث حالة الطلب إلى "موافق" أو "مرفوض". +.9يقوم النظام بعرض النظام رسالة تأكيد معالجة الطلب بنجاحCON023 . +.10يقوم النظام بإرسال إشعارا للمستخدم المعنيMSG005 . + +في حال عدم وجود طلبات: +ALT001 الخطوات البديلة +.1يعرض النظام رسالة تفيد بعدم وجود طلبات متاحةINF005 . + +في حال حدوث خطأ في تحميل الصفحة: +ERR001 األخطاء +يقوم النظام بعرض رسالة خطأ تفيد بوجود مشكلة في تحميل الصفحةERR001 . + +BC001يجب أن يتم عرض تفاصيل الطلبات الصحيحة. لوائح ومتطلبات األعمال + +بعد اتخاذ القرار ،يتم إشعار المتقدم بحالة طلبه وتحديث البيانات المتاحة في النظام بناء على القرار المتخذ. · الشروط الالحقة + + +--- + + + +--- + + +.6.2.59استعراض الملف التعريفي للدولة + +US060 المعرف + +كـ "ممثل دولة" ،أرغب في استعراض الملف التعريفي لدولتي لكي أتمكن من االطالع على المعلومات الدقيقة والمحدثة حول +العنوان +الدولة. + +المنصة على الويب (.)Web App بيئة العمل + +ممثل الدولة · المستخدمين + +يجب أن يكون المستخدم مسجال كممثل دولة على المنصة. · +الشروط المسبقة +يجب أن يكون الملف التعريفي للدولة متاحا في النظام. · + +.1يقوم ممثل الدولة بالدخول إلى المنصة عبر متصفح الويب. +.2يقوم النظام بعرض الصفحة الرئيسية للمنصة. +.3يقوم ممثل الدولة باختيار قسم "الملف التعريفي للدولة". +.4يقوم النظام بعرض تفاصيل ملف التعريفي في نموذج تحديث الملف التعريفي للدولة -عرض فقط- المسار الرئيسي +باإلضافة إلى عرض التالي عن طريق الربط مع كابسارك: +· تصنيف االقتصاد الدائري للكربون )(Circular Carbon Economy Classification +· أداء االقتصاد الدائري للكربون )(Circular Carbon Economy Performance +· مخطط األداء )(CCE Total Index + +في حال عدم وجود طلبات مصادر: +ALT001 الخطوات البديلة +.1يعرض النظام رسالة تفيد بعدم وجود طلبات متاحةINF005 . + +في حال حدوث خطأ في تحميل الصفحة: +ERR001 األخطاء +يقوم النظام بعرض رسالة خطأ تفيد بوجود مشكلة في تحميل الصفحةERR001 . + +يجب أن يكون النظام قادرا على استرجاع وعرض ملف التعريف الخاص بالدولة بشكل صحيح مع جميع +BC001البيانات المتاحة (مثل تصنيف االقتصاد الدائري للكربون ،أداء االقتصاد الدائري للكربون ،ومخطط األداء) ،عند لوائح ومتطلبات األعمال +اختيار الدولة من قبل المستخدم. + +بعد االطالع على الملف التعريفي الخاص بالدولة من قبل الممثل ،يمكن للممثل تحديث البيانات. · الشروط الالحقة + + +--- + + +.6.2.60تحديث الملف التعريفي للدولة +US061 المعرف + +كـ "ممثل دولة" ،أرغب في تحديث الملف التعريفي لدولتي لكي أتمكن من تحديث المعلومات المتعلقة بالدولة وفقا ألحدث +العنوان +البيانات المتاحة. + +المنصة على الويب (.)Web App بيئة العمل + +ممثل الدولة · المستخدمين + +يجب أن يكون المستخدم مسجال كممثل دولة على المنصة. · +الشروط المسبقة +يجب أن يكون الملف التعريفي للدولة متاحا في النظام. · + +.1يقوم ممثل الدولة بالدخول إلى المنصة عبر متصفح الويب. +.2يقوم النظام بعرض الصفحة الرئيسية للمنصة. +يقوم ممثل الدولة باختيار قسم "الملف التعريفي للدولة". .3 +يقوم النظام بعرض تفاصيل ملف التعريفي في نموذج تحديث الملف التعريفي للدولة -عرض فقط- .4 +باإلضافة إلى عرض التالي عن طريق الربط مع كابسارك: +· تصنيف االقتصاد الدائري للكربون )(Circular Carbon Economy Classification المسار الرئيسي +· أداء االقتصاد الدائري للكربون )(Circular Carbon Economy Performance +· مخطط األداء )(CCE Total Index +يقوم ممثل الدولة بتعديل البيانات. .5 +بعد إجراء التعديالت ،يقوم ممثل الدولة بالنقر على زر "حفظ التحديثات". .6 +يقوم النظام بتحديث البيانات وحفظ التعديالت الجديدة. .7 +يعرض النظام رسالة تأكيد بنجاح تحديث الملف التعريفي للدولةCON026 . .8 + +إذا ترك ممثل الدولة أي خانة فارغة: +يعرض النظام رسالة تحذير تطلب من ممثل الدولة تعبئة جميع الحقول اإللزامية قبل حفظ التحديثات. · +ERR013 ALT001 الخطوات البديلة + +ال يسمح النظام بحفظ التحديثات إال بعد تعبئة جميع الحقول المطلوبة. · + +في حال حدوث مشكلة أثناء تحديث البيانات: +ERR001يعرض النظام رسالة خطأ تفيد بوجود مشكلة في تحديث البيانات ويحث ممثل الدولة على المحاولة مرة األخطاء +أخرىERR033 . + +يجب أن يتمكن ممثل الدولة من تحديث البيانات المدخلة من قبله فقط ،وال يمكنه تعديل البيانات المسترجعة من +BC001 لوائح ومتطلبات األعمال +ربط كابسارك. + +يمكن للممثل إعادة مراجعة البيانات بعد التحديث أو متابعة التعديالت في المستقبل. · الشروط الالحقة + + +--- + + +.6.2.61تسجيل الدخول +US062 المعرف + +كـ "مشرف" ،أرغب في تسجيل الدخول إلى المنصة باستخدام بياناتي لكي أتمكن من الوصول إلى جميع الخدمات المتاحة. العنوان + +المنصة على الويب (.)Web App بيئة العمل + +المشرفين · المستخدمين + +يجب أن يكون المشرف مسجال في المنصة ولديه حساب صالح. الشروط المسبقة + +.1يقوم المشرف بالدخول إلى المنصة عبر متصفح الويب. +.2يقوم النظام بعرض الصفحة الرئيسية للمنصة. +.3يختار المشرف "تسجيل الدخول". +.4يقوم النظام بعرض نموذج تسجيل الدخول. +المسار الرئيسي +يقوم المشرف بإدخال جميع البيانات الالزمة في النموذج. .5 +يقوم المستخدم بالنقر على "تسجيل الدخول". .6 +يقوم النظام بالتحقق من صحة البيانات المدخلة في حال كانت البيانات صحيحة ،يقوم النظام بتسجيل الدخول .7 +للمشرف. +يقوم النظام بتوجيه المستخدم إلى الصفحة الرئيسية. .8 + +في حال إدخال بيانات غير صحيحة: +إذا أدخل المستخدم بيانات غير صحيحة ،يعرض النظام رسالة خطأ تفيد بأن البيانات غير صحيحة · ALT001 الخطوات البديلة +ويطلب منه إعادة المحاولة ERR020 + +في حال حدوث مشكلة أثناء تسجيل الدخول: +· يعرض النظام رسالة خطأ تفيد بوجود مشكلة في تسجيل الدخول ويحث المستخدم على المحاولة ERR001 األخطاء +مرة أخرىERR021 . + +BC001يجب التحقق من صحة البيانات المدخلة (البريد اإللكتروني وكلمة المرور) قبل السماح بتسجيل الدخول. لوائح ومتطلبات األعمال + +بعد تسجيل الدخول ،يمكن للمشرف الوصول إلى الخدمات االدارية المتاحة له في المنصة. الشروط الالحقة + + +--- + + +.6.2.62استعادة كلمة المرور +US063 المعرف + +كـ " مشرف " ،أرغب في استعادة كلمة المرور الخاصة بي لكي أتمكن من الدخول إلى حسابي إذا نسيت كلمة المرور. العنوان + +المنصة على الويب (.)Web App بيئة العمل + +المشرف · المستخدمين + +يجب أن يكون المشرف مسجال في المنصة ولديه حساب صالح. الشروط المسبقة + +.1يقوم المشرف بالدخول إلى المنصة عبر متصفح الويب. +.2يقوم النظام بعرض الصفحة الرئيسية للمنصة. +.3يختار المشرف "تسجيل الدخول". +في صفحة تسجيل الدخول ،يقوم المشرف بالنقر على خيار "نسيت كلمة المرور؟". .4 +يقوم النظام بعرض نموذج استعادة كلمة المرور. .5 +يقوم المشرف بإدخال البريد اإللكتروني المسجل في النظام. .6 +يقوم المشرف بالنقر على "إرسال رابط إعادة تعيين كلمة المرور". .7 + +إذا كان البريد اإللكتروني مسجال ،يقوم النظام بإرسال رسالة إلى البريد اإللكتروني تحتوي على رابط إلعادة تعيين .8 المسار الرئيسي +كلمة المرور. +.9يقوم المشرف بفتح البريد اإللكتروني والنقر على الرابط المرسل. +.10يقوم النظام بعرض نموذج إلدخال كلمة مرور جديدة. +.11يقوم المشرف بإدخال كلمة مرور جديدة وتأكيدها. +.12يقوم المشرف بالنقر على "تأكيد". + +.13يقوم النظام بتحديث كلمة المرور ويعرض رسالة تأكيد بنجاح استعادة كلمة المرورCON014 . +.14يتم توجيه المشرف إلى صفحة تسجيل الدخول حيث يمكنه استخدام كلمة المرور الجديدة. + +في حال عدم وجود البريد اإللكتروني في النظام: + +إذا كان البريد اإللكتروني غير مسجل في النظام ،يعرض النظام رسالة خطأ تفيد بعدم العثور على .1 ALT001 الخطوات البديلة +الحساب المرتبط بالبريد اإللكتروني المدخلERR022 . + +في حال حدوث مشكلة أثناء استعادة كلمة المرور: +· يعرض النظام رسالة خطأ تفيد بوجود مشكلة في استعادة كلمة المرور ويحث المشرف على ERR001 األخطاء +المحاولة مرة أخرىERR023 . + +BC001يجب أن يكون البريد اإللكتروني المدخل مسجال في النظام الستعادة كلمة المرور. لوائح ومتطلبات األعمال + +بعد استعادة كلمة المرور ،يمكن للمشرف العودة لتسجيل الدخول باستخدام كلمة المرور الجديدة. الشروط الالحقة + + +--- + + +.6.2.63تسجيل الخروج +US064 المعرف + +كـ "مشرف" ،أرغب في تسجيل الخروج من المنصة لكي أتمكن من إنهاء جلستي بشكل آمن. العنوان + +المنصة على الويب (.)Web App بيئة العمل + +المشرف · المستخدمين + +جب أن يكون المشرف مسجال في المنصة وقام بتسجيل الدخول بالفعل. الشروط المسبقة + +.1يقوم المشرف بالدخول إلى المنصة عبر متصفح الويب. +.2يقوم النظام بعرض الصفحة الرئيسية للمنصة. +.3يقوم المشرف بالنقر على أيقونة الملف الشخصي أو إعدادات الحساب في الزاوية العلوية من الصفحة. +يظهر للمشرف خيار "تسجيل الخروج". .4 المسار الرئيسي +.5يقوم المشرف بالنقر على خيار "تسجيل الخروج". +.6يقوم النظام بتسجيل الخروج ويعرض رسالة تأكيد بنجاح تسجيل الخروجCON015 . +.7يقوم النظام بإعادة توجيه المشرف إلى صفحة تسجيل الدخول. + +في حال حدوث خطأ أثناء تسجيل الخروج: +.1إذا حدث خطأ أثناء محاولة تسجيل الخروج) ،يعرض النظام رسالة خطأ تفيد بعدم إمكانية تسجيل +الخروجERR024 . ALT001 الخطوات البديلة + +.2يعرض النظام إمكانية المحاولة مرة أخرى لتسجيل الخروج. + +في حال حدوث مشكلة أثناء تسجيل الخروج: +· يعرض النظام رسالة خطأ تفيد بوجود مشكلة في تسجيل الخروج ويحث المشرف على المحاولة ERR001 األخطاء +مرة أخرىERR024 . + +BC001يجب على النظام التأكد من أنه تم تسجيل الخروج بشكل صحيح ويجب إزالة الجلسة الحالية للمشرف. لوائح ومتطلبات األعمال + +بعد تسجيل الخروج ،يجب توجيه المشرف إلى صفحة تسجيل الدخول. الشروط الالحقة + + +--- + + +.6.3النماذج + +.6.3.1التفاعل مع المدينة التفاعلية + +قيود الحقل الطول اجباري/اختياري النوع اسم الحقل + +نسبة استخدام +المواصالت العامة +يجب أن تكون القيمة بين 0و %100 · - إجباري أرقام/نسبة ( Public +Transport +)Usage + +متوسط مسافات النقل +( Average +يجب أن تكون القيمة بين 0و 100كم · - إجباري أرقام/عدد عشري +Transportation +)Distance + +عدد مسارات الدراجات +لكل كيلومتر مربع +يجب أن تكون القيمة عدد صحيح أكبر من 0 · - إجباري أرقام/عدد صحيح +( Bike Lanes +)per km² + +متوسط درجة الحرارة +السنوي +يجب أن تكون القيمة بين 50-و 50درجة مئوية · - إجباري أرقام/عدد عشري ( Average +Annual +)Temperature + +متوسط الهطول +يجب أن تكون القيمة بين 0و 5000مليمتر · - إجباري أرقام/عدد عشري السنوي ( Annual +)Precipitation + +عدد السكان +يجب أن تكون القيمة عدد صحيح أكبر من 0 · - إجباري أرقام/عدد صحيح +()Population + +مساحة المحافظة +يجب أن تكون القيمة أكبر من 0 · - إجباري أرقام/عدد عشري ( Area of +)Province + +متوسط استهالك +الطاقة في المباني +يجب أن تكون القيمة بين 0و 1000كيلووات · +- إجباري أرقام/عدد عشري ( Energy +ساعة +Consumption +)per km² + + +--- + + +نسبة مشاريع التطوير +متعددة االستخدام +يجب أن تكون القيمة بين 0و %100 · - إجباري أرقام/نسبة ( Mixed-Use +Development +)Ratio + +مجموع االنبعاثات +الكربونية للمصانع +يجب أن تكون القيمة أكبر من 0 · - إجباري أرقام/عدد عشري +( Total CO2 +)Emissions + +عدد المنشئات +الصناعية +يجب أن تكون القيمة عدد صحيح أكبر من 0 · - إجباري أرقام/عدد صحيح ( Number of +Industrial +)Facilities + +معدل تحويل النفايات +( Waste +يجب أن تكون القيمة بين 0و %100 · - إجباري أرقام/نسبة +Conversion +)Rate + +متوسط نفايات المولدة +لكل فرد ( Waste +يجب أن تكون القيمة أكبر من 0 · - إجباري أرقام/عدد عشري +per Person per +)Year + +نسبة انتاج الطاقة من +المصادر المتجددة +( Renewable +يجب أن تكون القيمة بين 0و %100 · - إجباري أرقام/نسبة +Energy +Production +)Ratio + +شدة الكربون المنبعث +من الكهرباء +يجب أن تكون القيمة بين 0و 1000جرام كربون · +- إجباري أرقام/عدد عشري ( Carbon +لكل واط بالساعة +Intensity from +)Electricity + +.6.3.2إنشاء حساب -المستخدم + +قيود الحقل الطول اجباري/اختياري النوع اسم الحقل + +االسم األول ( First +يجب أن يحتوي على حروف فقط · 50 إجباري نص حر +)Name + +االسم األخير ( Last +يجب أن يحتوي على حروف فقط · 50 إجباري نص حر +)Name + + +--- + + +البريد اإللكتروني +يجب أن يكون بريدا إلكترونيا صالحا · ١٠٠ إجباري نص حر ( Email +)Address + +المسمى الوظيفي +50 إجباري نص حر +()Job Title + +اسم المنظمة +١٠٠ إجباري نص حر ( Organization +)Name + +رقم الهاتف +15 إجباري ارقام ( Phone +)Number + +يجب أن تحتوي على مزيج من األحرف الكبيرة · كلمة السر +20-12 إجباري نص حر +والصغيرة واألرقام ()Password + +تكرار كلمة السر +يجب أن تتطابق مع كلمة السر المدخلة في الحقل · +20-12 إجباري نص حر ( Confirm +األول +)Password + + +--- + + +.6.3.3تسجيل الدخول – المستخدم + +قيود الحقل الطول اجباري/اختياري النوع اسم الحقل + +البريد اإللكتروني +يجب أن يكون بريدا إلكترونيا صالحا · ١٠٠ إجباري نص حر ( Email +)Address + +يجب أن تحتوي على مزيج من األحرف الكبيرة · كلمة السر +والصغيرة واألرقام 20-12 إجباري نص حر ()Password +يجب ان تكون متطابقة مع البريد االلكتروني. · + +.6.3.4استعادة كلمة المرور – المستخدم + +قيود الحقل الطول اجباري/اختياري النوع اسم الحقل + +البريد اإللكتروني +يجب أن يكون بريدا إلكترونيا صالحا · ١٠٠ إجباري نص حر ( Email +)Address + +.6.3.5التسجيل كخبير + +قيود الحقل الطول اجباري/اختياري النوع اسم الحقل + +السيرة الذاتية - +وصف +500 إجباري نص حر +( CV - +)Description + +السيرة الذاتية - +يجب أن يكون الملف بصيغة مدعومة ( PDF, · +- إجباري مرفق مرفق ( CV - +)Word +)Attachment + +المواضيع - +يجب اختيار الموضوع من قائمة مواضيع االقتصاد · المواضيع التي له +الدائري للكربون. - إجباري قائمة منسدلة خبرة بها +يمكن اختيار أكثر من موضوع · ( Expertise +)Topics + + +--- + + +.6.3.6تقييم خدمات الموقع + +قيود الحقل الطول اجباري/اختياري النوع اسم الحقل + +كيف تقييم رضاك عن +يجب اختيار تقييم من 5خيارات: المنصة بشكل عام؟ +.1ممتاز (How would +.2مرضي اختيار ( Radio you rate your +- إجباري +.3محايد )Button overall +.4غير مرضي satisfaction +.5سيء with the +)?platform + +يجب اختيار تقييم من 5خيارات: كيف تقييم سهولة +.1ممتاز استخدام المنصة؟ +.2مرضي اختيار ( Radio (How would +- إجباري +.3محايد )Button you rate the +.4غير مرضي ease of use of +.5سيء )?the platform + +ما مدى مناسبة +محتويات المنصة +يجب اختيار تقييم من 5خيارات: لمستواك المعرفي؟ +.1ممتاز (How suitable +.2مرضي اختيار ( Radio is the +- إجباري +.3محايد )Button platform's +.4غير مرضي content for +.5سيء your +knowledge +)?level + +ما مدى مناسبة +المقترحات المخصصة +يجب اختيار تقييم من 5خيارات: (Howالهتماماتك؟ +.1ممتاز suitable are +.2مرضي اختيار ( Radio the +- إجباري +.3محايد )Button personalized +.4غير مرضي suggestions +.5سيء to your +)?interests + + +--- + + +هل لديك أي مالحظات +أو شكاوى أخرى؟ +أذكرها باألسفل. +(Do you have +any other +500 اختياري نص حر +feedback or +?complaints +Please +mention them +)below. + +.6.3.7تحديد المقترحات المخصصة + +قيود الحقل الطول اجباري/اختياري النوع اسم الحقل + +مجاالت االهتمام +اختيار +هي مواضيع االقتصاد الدائري للكربون · - إجباري (Areas of +()Checkbox +)Interest + +تقييم المعرفة في +مجال االقتصاد +يجب على المستخدم اختيار مستوى المعرفة: الدائري للكربون +.1مرتفع اختيار ( Radio (Circular +- إجباري +.2متوسط )Button Carbon +.3منخفض Economy +Knowledge +)Level + +يجب على المستخدم اختيار القطاع: قطاع العمل +.1حكومي اختيار ( Radio (Sector of +- إجباري +.2أكاديمي )Button )Work +.3خاص + +يجب على المستخدم اختيار البلد من القائمة · قائمة منسدلة ) (Countryالبلد +- إجباري +المنسدلة ()Dropdown + + +--- + + +.6.3.8إنشاء منشور + +قيود الحقل الطول اجباري/اختياري النوع اسم الحقل + +عنوان المنشور +150 إجباري نص حر +)(Post Title + +محتوى المنشور +5000 إجباري نص حر +)(Post Content + +نوع المنشور +· معلومة قائمة منسدلة نوع المنشور +- إجباري +· سؤال ()Dropdown )(Post Type +· استطالع + +.6.3.9تحديث محتوى الصفحة الرئيسية – المشرفين + +قيود الحقل الطول اجباري/اختياري النوع اسم الحقل + +مقطع توضيحي +للمنصة +- إجباري فيديو ()File (Platform +Introduction +)Video + +الهدف والرسالة +1000 إجباري نص حر ( Objective and +)Message + +مفاهيم االقتصاد +هي مواضيع االقتصاد الدائري للكربون. · الدائري للكربون +يمكن إضافة حتى 100مفهوم .يتم إضافة المفاهيم · (Circular +ال يوجد حد محدد إجباري نص حر +بشكل منفصل باستخدام فواصل(Comma- Carbon +)separatedأو إدخال متعدد الصفوف. Economy +(Concepts + +قائمة منسدلة متعددة الدول المشاركة +قائمة من دول العالم ،مع إمكانية اختيار الدول · +- إجباري ( Multi-select (Participating +المشاركة منها. +)Dropdown )countries + + +--- + + +.6.3.10تحديث محتوى تعرف على المنصة – المشرفين + +قيود الحقل الطول اجباري/اختياري النوع اسم الحقل + +وصف عام +1000 إجباري نص حر (General +)description + +كيفية االستخدام +- إجباري فيديو ()File +)(How to use + +يمكن إضافة حتى 100شريك .يتم إضافة المفاهيم · شركاء المعرفة +بشكل منفصل باستخدام فواصل(Comma- 1000 إجباري نص حر (Knowledge +)separatedأو إدخال متعدد الصفوف. )Partners + +قاموس المصطلحات – يمكن إضافة عدد مصطلحات بدون حد- + +المصطلح +١٠٠ إجباري نص حر +)(Term + +التعريف +١٠٠٠ إجباري نص حر +)(Definition + +.6.3.11تحديث السياسات واالحكام – المشرفين + +قيود الحقل الطول اجباري/اختياري النوع اسم الحقل + +سياسات +1000 إجباري نص حر +)(Policies + +أحكام +1000 إجباري نص حر +)(Terms + + +--- + + +.6.3.12إنشاء المستخدم – المشرفين + +قيود الحقل الطول اجباري/اختياري النوع اسم الحقل + +االسم األول ( First +يجب أن يحتوي على حروف فقط · 50 إجباري نص حر +)Name + +االسم األخير ( Last +يجب أن يحتوي على حروف فقط · 50 إجباري نص حر +)Name + +البريد اإللكتروني +يجب أن يكون بريدا إلكترونيا صالحا · ١٠٠ إجباري نص حر ( Email +)Address + +رقم الهاتف +15 إجباري ارقام ( Phone +)Number + +يجب على المستخدم اختيار البلد من القائمة · قائمة منسدلة البلد +- إجباري +المنسدلة ()Dropdown )(Country + +القائمة: · الصالحية +مشرف o قائمة منسدلة )(Role +- إجباري +مشرف محتوى o ()Dropdown +ممثل دولة o + +.6.3.13رفع الخبر – المشرفين + +قيود الحقل الطول اجباري/اختياري النوع اسم الحقل + +العنوان +يجب أن يكون اسم المصدر واضحا ودقيقا. · 255 إجباري نص حر +)(Title + +الصورة +يجب أن يكون المرفق بصيغة مدعومة ()PNG · - إجباري مرفق +)(Image + +يجب اختيار الموضوع من قائمة مواضيع االقتصاد · الموضوع +- إجباري قائمة منسدلة +الدائري للكربون. )(Topic + +محتوى الخبر +يجب أن يكون المحتوى واضحا ودقيقا. · 2000 إجباري نص حر +)(News content + + +--- + + +.6.3.14رفع الفعالية – المشرفين + +قيود الحقل الطول اجباري/اختياري النوع اسم الحقل + +العنوان +يجب أن يكون اسم المصدر واضحا ودقيقا. · 255 إجباري نص حر +)(Title + +الموقع +يجب أن يكون الرابط صحيح. · 255 إجباري رابط +)(Location + +يجب أن يكون التاريخ بصيغة صحيحة (yyyy- · تاريخ الفعالية +٥٠٠ إجباري تاريخ +.)mm-dd )(Event Date + +يجب اختيار الموضوع من قائمة مواضيع االقتصاد · الموضوع +- إجباري قائمة منسدلة +الدائري للكربون. )(Topic + +وصف الفعالية +يجب أن يكون الوصف دقيقا ويغطي تفاصيل · +2000 إجباري نص حر (Event +الفعالية. +)Description + +.6.3.15رفع المصادر – المشرفين + +قيود الحقل الطول اجباري/اختياري النوع اسم الحقل + +العنوان +يجب أن يكون اسم المصدر واضحا ودقيقا. · 255 إجباري نص حر +)(Title + +يجب اختيار الموضوع من قائمة مواضيع االقتصاد · الموضوع +- إجباري قائمة منسدلة +الدائري للكربون. )(Topic + +الوصف +٥٠٠ إجباري نص حر +)(Description + +القائمة: · +ورقة o +مقال o +دراسة o +عرض o +نوعية المنشور +ورقة علمية o - إجباري قائمة منسدلة +)(Post Type +تقرير o +كتاب o +بحث o +دليلCCE o +وسائط o + +الدول المغطاة +يجب اختيار الدول المغطاة من قائمة الدول. · +- إجباري قائمة منسدلة (Covered +يمكن اختيار اكثر من دولة. · +)Countries + + +--- + + +يجب أن يكون الملف بصيغة مدعومة ( PDF, · الملف +- إجباري ملف /رابط +)Wordاو رابط للمصدر )(File + + +--- + + +.6.3.16تحديث الملف التعريفي للدولة – المشرفين + +قيود الحقل الطول اجباري/اختياري النوع اسم الحقل + +عدد السكان +يجب أن تكون القيمة عدد صحيح أكبر من 0 · - إجباري أرقام/عدد صحيح +()Population + +يجب أن تكون القيمة أكبر من 0 · - إجباري أرقام/عدد عشري المساحة ()Area + +الناتج المحلي +اإلجمالي للفرد +يجب أن تكون القيمة أكبر من 0 · - إجباري أرقام/عدد عشري +( GDP per +)capita + +مرفق مساهمة وطنية +يجب أن يكون المرفق بصيغة مدعومة ()PNG · - إجباري مرفق محددة للعام + +تصنيف االقتصاد +الدائري للكربون +ال يمكن التعديل عليها · +( Circular +يتم استرجاعها من Circular Carbon · - عرض نص حر +Carbon +)Economy (CCEبالربط مع كابسارك. +Economy +)Classification + +أداء االقتصاد الدائري +للكربون +ال يمكن التعديل عليها · +( Circular +يتم استرجاعها من Circular Carbon · - عرض نص حر +Carbon +)Economy (CCEبالربط مع كابسارك. +Economy +)Performance + +ال يمكن التعديل عليها · مخطط األداء +يتم استرجاعها من Circular Carbon · - عرض أرقام/عدد عشري ( CCE Total +)Economy (CCEبالربط مع كابسارك. )Index + + +--- + + +.6.4متطلبات التقارير +.6.4.1تقرير تسجيل المستخدمين + +RP001 المعرف + +تقرير تسجيل المستخدمين العنوان + +متابعة حالة تسجيل المستخدمين الجدد وتحديث بياناتهم وصف التقرير + +مسؤول قاعدة البيانات · المستخدمين + +ال توجد مدخالت مباشرة من المستخدمين لهذا التقرير .يعتمد التقرير على البيانات المدخلة في النظام من قبل المستخدمين. المدخالت + +استعراض قائمة بالمستخدمين وبياناتهم. المخرجات + +ال يوجد الترتيب + +يجب تخزين كلمات السر بشكل آمن في قاعدة البيانات باستخدام تقنيات التشفير المناسبة. متطلبات األعمال + +ال يوجد مالحظات إضافية + +المخرجات + +قيود الحقل يتطلب وجود قيمة الطول اسم الحقل + +يجب أن يحتوي على حروف فقط نعم 50 االسم األول ()First Name + +يجب أن يحتوي على حروف فقط نعم 50 االسم األخير ()Last Name + +يجب أن يكون بريدا إلكترونيا صالحا نعم ١٠٠ البريد اإللكتروني ()Email Address + +نعم 50 المسمى الوظيفي ()Job Title + +نعم ١٠٠ اسم المنظمة ()Organization Name + +نعم 15 رقم الهاتف ()Phone Number + +يجب أن تحتوي على مزيج من األحرف +نعم 20-12 كلمة السر ()Password +الكبيرة والصغيرة واألرقام + +يجب أن تتطابق مع كلمة السر المدخلة +نعم 20-12 تكرار كلمة السر ()Confirm Password +في الحقل األول + + +--- + + +.6.4.2تقرير خبراء المجتمع + +RP002 المعرف + +تقرير خبراء المجتمع العنوان + +متابعة حالة السيرة الذاتية للخبراء في مجتمع المعرفة ،بما في ذلك المواضيع التي لديهم خبرة فيها والملفات المرفقة. وصف التقرير + +مسؤول قاعدة البيانات · المستخدمين + +ال توجد مدخالت مباشرة من المستخدمين لهذا التقرير .يعتمد التقرير على البيانات المدخلة في النظام من قبل المستخدمين. المدخالت + +استعراض قائمة الخبراء في مجتمع المعرفة مع تفاصيل السيرة الذاتية ،المرفقات ،والمواضيع التي لديهم خبرة فيها. المخرجات + +ال يوجد الترتيب + +يجب أن تكون الملفات المرفقة (السيرة الذاتية) بصيغ مدعومة (.)PDF, Word متطلبات األعمال + +ال يوجد مالحظات إضافية + +المخرجات + +قيود الحقل يتطلب وجود قيمة الطول اسم الحقل + +السيرة الذاتية -وصف +نعم 500 +()CV - Description + +يجب أن يكون الملف بصيغة مدعومة +نعم - السيرة الذاتية -مرفق ()CV - Attachment +()PDF, Word + +يجب اختيار الموضوع من قائمة · +مواضيع االقتصاد الدائري المواضيع -المواضيع التي له خبرة بها ( Expertise +نعم - +للكربون. )Topics +يمكن اختيار أكثر من موضوع · + + +--- + + +.6.4.3تقرير تقييم رضا المستخدم عن المنصة + +RP003 المعرف + +تقرير تقييم رضا المستخدم عن المنصة العنوان + +متابعة تقييمات المستخدمين حول رضاهم عن المنصة ،سهولة استخدامها ،مالءمة المحتوى ،والمقترحات المخصصة لهم. وصف التقرير + +مسؤول قاعدة البيانات · المستخدمين + +ال توجد مدخالت مباشرة من المستخدمين لهذا التقرير .يعتمد التقرير على البيانات المدخلة في النظام من قبل المستخدمين. المدخالت + +استعراض تقييمات المستخدمين حول المنصة المخرجات + +ال يوجد الترتيب + +ال يوجد متطلبات األعمال + +ال يوجد مالحظات إضافية + +المخرجات + +قيود الحقل يتطلب وجود قيمة الطول اسم الحقل + +يجب اختيار تقييم من 5خيارات: +.1ممتاز +كيف تقييم رضاك عن المنصة بشكل عام؟ +.2مرضي +نعم - (How would you rate your overall +.3محايد +)?satisfaction with the platform +.4غير مرضي +.5سيء + +يجب اختيار تقييم من 5خيارات: +.1ممتاز +كيف تقييم سهولة استخدام المنصة؟ (How would +.2مرضي +نعم - you rate the ease of use of the +.3محايد +)?platform +.4غير مرضي +.5سيء + +يجب اختيار تقييم من 5خيارات: +.1ممتاز +ما مدى مناسبة محتويات المنصة لمستواك المعرفي؟ +.2مرضي +نعم - (How suitable is the platform's content +.3محايد +)?for your knowledge level +.4غير مرضي +.5سيء + + +--- + + +يجب اختيار تقييم من 5خيارات: +.1ممتاز +ما مدى مناسبة المقترحات المخصصة الهتماماتك؟ +.2مرضي +نعم - (How suitable are the personalized +.3محايد +)?suggestions to your interests +.4غير مرضي +.5سيء + +يجب اختيار تقييم من 5خيارات: +.1ممتاز هل لديك أي مالحظات أو شكاوى أخرى؟ أذكرها باألسفل. +.2مرضي (Do you have any other feedback or +نعم 500 +.3محايد complaints? Please mention them +.4غير مرضي )below. +.5سيء + + +--- + + +.6.4.4تقرير خبراء المجتمع + +RP004 المعرف + +تقرير تحديد المقترحات المخصصة للمستخدم العنوان + +متابعة نموذج تحديد المقترحات المخصصة للمستخدمين بناء على اهتماماتهم ومجاالت معرفتهم وقطاع عملهم. وصف التقرير + +مسؤول قاعدة البيانات · المستخدمين + +ال توجد مدخالت مباشرة من المستخدمين لهذا التقرير .يعتمد التقرير على البيانات المدخلة في النظام من قبل المستخدمين. المدخالت + +استعراض تفاصيل المقترحات المخصصة للمستخدمين بناء على مجاالت االهتمام ،تقييم المعرفة في االقتصاد الدائري للكربون، +المخرجات +قطاع العمل ،والبلد. + +ال يوجد الترتيب + +ال يوجد متطلبات األعمال + +ال يوجد مالحظات إضافية + +المخرجات + +قيود الحقل يتطلب وجود قيمة الطول اسم الحقل + +هي مواضيع االقتصاد الدائري للكربون نعم - مجاالت االهتمام)(Areas of Interest + +يجب على المستخدم اختيار مستوى +المعرفة: تقييم المعرفة في مجال االقتصاد الدائري للكربون +.1مرتفع نعم - (Circular Carbon Economy Knowledge +.2متوسط )Level +.3منخفض + +يجب على المستخدم اختيار القطاع: قطاع العمل)(Sector of Work +.1حكومي +نعم - +.2أكاديمي +.3خاص + +يجب على المستخدم اختيار البلد من القائمة البلد)(Country +نعم - +المنسدلة + + +--- + + +.6.4.5تقرير منشورات المجتمع + +RP005 المعرف + +تقرير منشورات المجتمع العنوان + +متابعة منشورات المستخدمين في مجتمع المعرفة ،بما في ذلك العنوان ،المحتوى ،ونوع المنشور. وصف التقرير + +مسؤول قاعدة البيانات · المستخدمين + +ال توجد مدخالت مباشرة من المستخدمين لهذا التقرير .يعتمد التقرير على البيانات المدخلة في النظام من قبل المستخدمين. المدخالت + +استعراض قائمة المنشورات مع تفاصيل العنوان ،المحتوى ،ونوع المنشور (معلومة ،سؤال ،استطالع). المخرجات + +ال يوجد الترتيب + +ال يوجد متطلبات األعمال + +ال يوجد مالحظات إضافية + +المخرجات + +قيود الحقل يتطلب وجود قيمة الطول اسم الحقل + +عنوان المنشور +نعم 150 +)(Post Title + +محتوى المنشور +نعم 5000 +)(Post Content + +نوع المنشور +· معلومة نوع المنشور +نعم - +· سؤال )(Post Type +· استطالع + + +--- + + +.6.4.6تقرير االخبار + +RP006 المعرف + +تقرير األخبار العنوان + +متابعة أخبار المجتمع المرفوعة من المشرفين. وصف التقرير + +مسؤول قاعدة البيانات · المستخدمين + +ال توجد مدخالت مباشرة من المستخدمين لهذا التقرير .يعتمد التقرير على البيانات المدخلة في النظام من قبل المستخدمين. المدخالت + +استعراض قائمة األخبار المرفوعة مع تفاصيل العنوان ،الصورة ،الموضوع ،والمحتوى. المخرجات + +ال يوجد الترتيب + +ال يوجد متطلبات األعمال + +ال يوجد مالحظات إضافية + +المخرجات + +قيود الحقل يتطلب وجود قيمة الطول اسم الحقل + +العنوان +يجب أن يكون اسم المصدر واضحا ودقيقا. نعم 255 +)(Title + +يجب أن يكون المرفق بصيغة مدعومة الصورة +نعم - +()PNG )(Image + +يجب اختيار الموضوع من قائمة مواضيع الموضوع +نعم - +االقتصاد الدائري للكربون. )(Topic + +محتوى الخبر +يجب أن يكون المحتوى واضحا ودقيقا. نعم 2000 +)(News content + + +--- + + +.6.4.7تقرير الفعاليات + +RP007 المعرف + +تقرير الفعاليات العنوان + +متابعة فعاليات المجتمع المرفوعة من المشرفين. وصف التقرير + +مسؤول قاعدة البيانات · المستخدمين + +ال توجد مدخالت مباشرة من المستخدمين لهذا التقرير .يعتمد التقرير على البيانات المدخلة في النظام من قبل المشرفين. المدخالت + +استعراض قائمة الفعاليات المرفوعة مع تفاصيل العنوان ،الموقع ،تاريخ الفعالية ،الموضوع ،والوصف. المخرجات + +ال يوجد الترتيب + +ال يوجد متطلبات األعمال + +ال يوجد مالحظات إضافية + +المخرجات + +قيود الحقل يتطلب وجود قيمة الطول اسم الحقل + +العنوان +يجب أن يكون اسم المصدر واضحا ودقيقا. نعم 255 +)(Title + +الموقع +يجب أن يكون الرابط صحيح. نعم 255 +)(Location + +يجب أن يكون التاريخ بصيغة صحيحة تاريخ الفعالية +نعم ٥٠٠ +(.)yyyy-mm-dd )(Event Date + +يجب اختيار الموضوع من قائمة مواضيع الموضوع +نعم - +االقتصاد الدائري للكربون. )(Topic + +يجب أن يكون الوصف دقيقا ويغطي تفاصيل وصف الفعالية +نعم 2000 +الفعالية. )(Event Description + + +--- + + +.6.4.8تقرير المصادر + +RP008 المعرف + +تقرير المصادر العنوان + +متابعة مصادر المنصة المرفوعة من قبل المشرفين او ممثلي الدول. وصف التقرير + +مسؤول قاعدة البيانات · المستخدمين + +ال توجد مدخالت مباشرة من المستخدمين لهذا التقرير .يعتمد التقرير على البيانات المدخلة في النظام من قبل المشرفين او ممثلي +المدخالت +الدول. + +استعراض قائمة المصادر المرفوعة مع تفاصيل العنوان ،الموضوع ،الوصف ،نوعية المنشور ،الدول المغطاة ،والملف المرفق. المخرجات + +ال يوجد الترتيب + +ال يوجد متطلبات األعمال + +ال يوجد مالحظات إضافية + +المخرجات + +قيود الحقل يتطلب وجود قيمة الطول اسم الحقل +العنوان +يجب أن يكون اسم المصدر واضحا ودقيقا. نعم 255 +)(Title + +يجب اختيار الموضوع من قائمة مواضيع الموضوع +نعم - +االقتصاد الدائري للكربون. )(Topic + +الوصف +نعم ٥٠٠ +)(Description + +القائمة: +ورقة · +مقال · +دراسة · +عرض · +نوعية المنشور +ورقة علمية · نعم - +)(Post Type +تقرير · +كتاب · +بحث · +دليلCCE · +وسائط · + +يجب اختيار الدول المغطاة من قائمة · +الدول المغطاة +الدول. نعم - +)(Covered Countries +يمكن اختيار اكثر من دولة. · + + +--- + + +يجب أن يكون الملف بصيغة مدعومة الملف +نعم - +()PDF, Word )(File + +.6.4.9تقرير ملفات التعريفية للدول + +RP009 المعرف + +تقرير ملفات التعريفية للدول العنوان +متابعة ملفات التعريفية للدول ،بما في ذلك البيانات االقتصادية والديموغرافية مثل عدد السكان ،المساحة ،الناتج المحلي اإلجمالي، +وصف التقرير +تصنيف االقتصاد الدائري للكربون ،واألداء. + +مسؤول قاعدة البيانات · المستخدمين + +ال توجد مدخالت مباشرة من المستخدمين لهذا التقرير .يعتمد التقرير على البيانات المدخلة في النظام من قبل ممثلي الدول. المدخالت +استعراض بيانات الملفات التعريفية للدول مع تفاصيل مثل عدد السكان ،المساحة ،الناتج المحلي اإلجمالي للفرد ،المرفقات +المخرجات +المتعلقة بالمساهمة الوطنية ،وتصنيف وأداء االقتصاد الدائري للكربون. + +ال يوجد الترتيب + +ال يوجد متطلبات األعمال + +البيانات المسترجعة من الربط مع كابسارك (تصنيف وأداء االقتصاد الدائري للكربون ومخطط األداء) ال يمكن تعديلها. مالحظات إضافية + +المخرجات + +قيود الحقل يتطلب وجود قيمة الطول اسم الحقل +يجب أن تكون القيمة عدد صحيح أكبر من +نعم - عدد السكان ()Population +0 + +يجب أن تكون القيمة أكبر من 0 نعم - المساحة ()Area + +يجب أن تكون القيمة أكبر من 0 نعم - الناتج المحلي اإلجمالي للفرد ()GDP per capita + +يجب أن يكون المرفق بصيغة مدعومة +نعم - مرفق مساهمة وطنية محددة للعام +()PNG + +ال يمكن التعديل عليها يتم استرجاعها من تصنيف االقتصاد الدائري للكربون +Circular Carbon Economy نعم - ( Circular Carbon Economy +)(CCEبالربط مع كابسارك. )Classification + + +--- + + +ال يمكن التعديل عليها يتم استرجاعها من أداء االقتصاد الدائري للكربون +Circular Carbon Economy نعم - ( Circular Carbon Economy +)(CCEبالربط مع كابسارك. )Performance + +ال يمكن التعديل عليها يتم استرجاعها من +Circular Carbon Economy مخطط األداء ()CCE Total Index +)(CCEبالربط مع كابسارك. + +.6.5متطلبات خدمة الربط +.6.5.1متطلبات خدمة الربط مع كابسارك +الملف التعريفي للدولة US014 · رقم الخدمة + +تصنيف االقتصاد الدائري للكربون ()Circular Carbon Economy Classification Verification اسم خدمة الربط + +الهدف هو التحقق من تصنيف االقتصاد الدائري للكربون وأداء االقتصاد الدائري في الدول عبر االستعالم عن التصنيف +الهدف من خدمة الربط +ومؤشرات األداء المرتبطة به. + +استرجاع بيانات ()Data Retrieval نوع العملية + +كابسارك )(Saudi Energy Efficiency Center - KAPSARC المصدر + +يتم استرجاع بيانات تصنيف االقتصاد الدائري للكربون وأداء االقتصاد الدائري في حال كانت البيانات متوفرة. BC001 قواعد األعمال + +في حال عدم وجود مخرجات من الربط مع كابسارك أو عدم توفر بيانات متعلقة بتصنيف أو أداء االقتصاد +ER001 األخطاء +الدائري. + +المدخالت + +قيود الحقل إجباري الطول اسم الحقل + +يجب أن يكون اسم دولة موجودا في +إجباري 50 اسم الدولة ()Country Name +النظام + +يجب أن يكون الرمز الدولي الخاص +إجباري ٣ الرمز الدولي ()Country Code +بالدولة + +المخرجات + +قيود الحقل يتطلب وجود قيمة الطول اسم الحقل + +تصنيف االقتصاد الدائري للكربون ( Circular +نعم 50 +)Carbon Economy Classification + +أداء االقتصاد الدائري للكربون ( Circular Carbon +نعم 50 +)Economy Performance + + +--- + + +نعم أرقام/عدد عشري مخطط األداء ()CCE Total Index + +.7الرسائل والتنبيهات +.7.1الرسائل + +نص الرسالة النوع الرقم + +حدث خطأ أثناء تحميل الصفحة. رسالة خطأ ERR001 + +تم تحميل المصدر بنجاح! يمكنك اآلن الوصول إلى المرفق من جهازك. رسالة تأكيدية CON001 + +حدث خطأ أثناء محاولة تحميل المصدر .يرجى المحاولة مرة أخرى. رسالة خطأ ERR002 + +تمت مشاركة المصدر بنجاح! رسالة تأكيدية CON002 + +حدث خطأ أثناء محاولة مشاركة المصدر .يرجى المحاولة مرة أخرى الحقا. رسالة خطأ ERR003 + +ال توجد مصادر أو أخبار متاحة لهذا الموضوع في الوقت الحالي .يمكنك البحث عن موضوع آخر +رسالة توضيحية INF001 +أو العودة إلى الصفحة الرئيسية. + +تمت المشاركة بنجاح! رسالة تأكيدية CON003 + +حدث خطأ أثناء محاولة المشاركة .يرجى المحاولة مرة أخرى الحقا. رسالة خطأ ERR004 + +حدث خطأ أثناء محاولة متابعة الخبر .يرجى المحاولة مرة أخرى الحقا. رسالة خطأ ERR005 + +تم إضافة الفعالية إلى تقويمك الشخصي بنجاح .يمكنك اآلن االطالع عليها في أي وقت من خالل +رسالة تأكيدية CON004 +التقويم لمتابعة التفاصيل والمواعيد. + + +--- + + +حدث خطأ أثناء محاولة إضافة الفعالية إلى التقويم .يرجى المحاولة مرة أخرى الحقا. رسالة خطأ ERR006 + +تم تحديث بيانات الملف الشخصي بنجاح .يمكنك اآلن االطالع على المعلومات المحدثة في ملفك +رسالة تأكيدية CON005 +الشخصي. + +حدث خطأ أثناء محاولة تحديث بيانات الملف الشخصي. +رسالة خطأ ERR007 +يرجى التأكد من أن البيانات المدخلة صحيحة ،مثل تنسيق البريد اإللكتروني أو رقم الهاتف. + +تم تقديم طلبك بنجاح لتسجيلك كخبير في مجتمع المعرفة .سيتم مراجعة طلبك قريبا. رسالة تأكيدية CON006 + +حدث خطأ أثناء تقديم طلبك .يرجى التأكد من صحة البيانات المدخلة. رسالة خطأ ERR008 + +تم تقديم طلب تسجيل جديد كخبير في مجتمع المعرفة .يرجى مراجعة الطلب واتخاذ اإلجراءات +رسالة تأكيدية CON007 +الالزمة. + +تم إرسال تقييمك بنجاح .نشكرك على مشاركتك في تحسين خدماتنا. رسالة تأكيدية CON008 + +حدث خطأ أثناء محاولة إرسال تقييمك .يرجى المحاولة مرة أخرى. رسالة خطأ ERR009 + +تم إرسال بياناتك بنجاح! سيتم تخصيص المقترحات لتتناسب مع اهتماماتك واحتياجاتك. رسالة تأكيدية CON009 + +حدث خطأ أثناء محاولة إرسال بياناتك .يرجى المحاولة مرة أخرى. رسالة خطأ ERR010 + +عذرا لم نتمكن من العثور على نتائج دقيقة بناء على االستفسار الذي قمت بتقديمه ،ربما يساعد +رسالة توضيحية INF002 +تعديل السؤال أو طرحه بطريقة مختلفة في الوصول إلى اإلجابة المثالية. + +عذرا ،حدثت مشكلة في تحميل المساعد الذكي. رسالة خطأ ERR011 + +عذرا ،ال توجد منشورات حاليا. رسالة عامة NTF001 + +تم حفظ بياناتك بنجاح .ستتلقى إشعارات أو تحديثات حول المنشورات الجديدة المتعلقة بالموضوع +رسالة تأكيدية CON010 +الذي اخترته. + +عذرا ،ال يمكن متابعة الموضوع حاليا. رسالة خطأ ERR012 + +تم إنشاء المنشور بنجاح! رسالة تأكيدية CON011 + +عذرا ،الحقول اإلجبارية غير مكتملة. رسالة خطأ ERR013 + +عذرا ،حدثت مشكلة أثناء نشر المنشور. رسالة خطأ ERR014 + +تم حفظ بياناتك بنجاح .ستتلقى إشعارات أو تحديثات حول المنشور. رسالة تأكيدية CON012 + +عذرا ،ال يمكن متابعة المنشور حاليا. رسالة خطأ ERR015 + +تم إرسال الرد بنجاح! رسالة تأكيدية CON013 + + +--- + + +عذرا ،ال يمكن إرسال رد فارغ. رسالة خطأ ERR016 + +عذرا ،حدثت مشكلة أثناء إرسال الرد. رسالة خطأ ERR017 + +عذرا ،ال يمكن متابعة المستخدم حاليا. رسالة خطأ ERR018 + +عذرا ،حدثت مشكلة أثناء إنشاء الحساب. رسالة خطأ ERR019 + +عذرا ،البيانات المدخلة غير صحيحة. رسالة خطأ ERR020 + +عذرا ،حدثت مشكلة أثناء تسجيل الدخول. رسالة خطأ ERR021 + +تمت استعادة كلمة المرور بنجاح! رسالة تأكيدية CON014 + +عذرا ،لم يتم العثور على الحساب المرتبط بالبريد اإللكتروني. رسالة خطأ ERR022 + +عذرا ،حدثت مشكلة أثناء استعادة كلمة المرور. رسالة خطأ ERR023 + +تم تسجيل الخروج بنجاح. رسالة تأكيدية CON015 + +حدث خطأ أثناء محاولة تسجيل الخروج. رسالة خطأ ERR024 + +تمت عملية التحديث بنجاح. رسالة تأكيدية CON016 + +عذرا ،حدثت مشكلة أثناء تحديث المحتوى. رسالة خطأ ERR025 + +تم إنشاء المستخدم بنجاح! رسالة تأكيدية CON017 + +تم حذف المستخدم بنجاح! رسالة تأكيدية CON018 + +عذرا ،حدثت مشكلة أثناء حذف المستخدم. رسالة خطأ ERR026 + +عذرا ،ال توجد أخبار أو فعاليات حاليا. رسالة توضيحية INF003 + +تم رفع الخبر/الفعالية بنجاح! رسالة تأكيدية CON019 + +عذرا ،حدثت مشكلة أثناء رفع الخبر/الفعالية. رسالة خطأ ERR027 + +تم حذف الخبر/الفعالية بنجاح! رسالة تأكيدية CON020 + +عذرا ،حدثت مشكلة أثناء حذف الخبر/الفعالية. رسالة خطأ ERR028 + +عذرا ،ال توجد مصادر حاليا. رسالة توضيحية INF004 + +تم رفع المصدر بنجاح! رسالة تأكيدية CON021 + + +--- + + +عذرا ،حدثت مشكلة أثناء رفع المصدر. رسالة خطأ ERR029 + +تم حذف المصدر بنجاح! رسالة تأكيدية CON022 + +عذرا ،حدثت مشكلة أثناء حذف المصدر. رسالة خطأ ERR030 + +عذرا ،ال توجد طلبات متاحة حاليا. رسالة توضيحية INF005 + +تمت معالجة الطلب بنجاح! رسالة تأكيدية CON023 + +عذرا ،حدثت مشكلة أثناء معالجة الطلب. رسالة خطأ ERR031 + +تم إرسال طلبك بنجاح .سيتم مراجعته من قبل المشرف قريبا .شكرا لمساهمتك! رسالة تأكيدية CON024 + +تم حذف المنشور بنجاح! رسالة تأكيدية CON025 + +عذرا ،حدثت مشكلة أثناء حذف المنشور. رسالة خطأ ERR032 + +تم تحديث الملف التعريفي للدولة بنجاح! رسالة تأكيدية CON026 + +عذرا ،حدثت مشكلة أثناء تحديث البيانات. رسالة خطأ ERR033 + + +--- + + +.7.2التنبيهات + +مدة االنتهاء نص التنبيه العنوان النوع الرقم + +عزيزي المشرف، + +تم تقديم طلب تسجيل جديد من قبل المستخدم [اسم المستخدم] ليتم تسجيله كخبير في مجتمع +ال يوجد طلب تسجيل كخبير بريد إلكتروني MSG001 +المعرفة. + +يرجى مراجعة البيانات المدخلة بعناية واتخاذ اإلجراءات المناسبة. + +عزيزي/عزيزتي [اسم الممثل [، + +نود إبالغكم أنه تم اتخاذ إجراء على الطلب المرفوع من قبل دولتكم .يُمكنكم اآلن االطالع على +حالة الطلب في قسم "الطلبات" لمعرفة المزيد من التفاصيل حول حالته. + +ال يوجد نشكركم على تعاونكم المستمر ،وإذا كان لديكم أي استفسار أو بحاجة إلى مزيد من المساعدة ،ال طلب رفع مصادر بريد إلكتروني MSG002 +تترددوا في التواصل معنا. + +مع خالص الشكر والتقدير، +]اسم المنظمة/الفريق[ +[بيانات االتصال] + +عزيزي المشرف، + +ال يوجد تم تقديم طلب رفع مصدر جديد من قبل ممثل الدولة [اسم الممثل [. طلب رفع مصدر بريد إلكتروني MSG003 + +يرجى مراجعة البيانات المدخلة بعناية واتخاذ اإلجراءات المناسبة. + +عزيزي/عزيزتي [اسم المستخدم[ ، + +نود إبالغك أنه تم حذف المنشور الذي قمت بنشره في مجتمع المعرفة. +إذا كان لديك أي استفسار أو بحاجة إلى المساعدة ،يُرجى التواصل معنا. تم حذف منشورك +ال يوجد بريد إلكتروني MSG004 +من قبل المنصة +مع خالص الشكر والتقدير، +]اسم المنظمة/الفريق[ +[بيانات االتصال] + +عزيزي/عزيزتي [اسم المستخدم[ ، + +نود إبالغكم أنه تم اتخاذ إجراء على الطلب للتسجيل كخبير المرفوع من قبلكم .يُمكنكم اآلن +االطالع على حالة الطلب في قسم "الطلبات" لمعرفة المزيد من التفاصيل حول حالته. +طلب التسجيل +ال يوجد نشكركم على تعاونكم المستمر ،وإذا كان لديكم أي استفسار أو بحاجة إلى مزيد من المساعدة ،ال بريد إلكتروني MSG005 +كخبير +تترددوا في التواصل معنا. + +مع خالص الشكر والتقدير، +]اسم المنظمة/الفريق[ +[بيانات االتصال] + + +--- + + + +--- + diff --git a/backend/docs/plans/application-layer-feature-slices-plan.md b/backend/docs/plans/application-layer-feature-slices-plan.md new file mode 100644 index 00000000..6ff9dd47 --- /dev/null +++ b/backend/docs/plans/application-layer-feature-slices-plan.md @@ -0,0 +1,578 @@ +# Application Layer — Feature-Based Reorganization Plan + +**Status:** Draft +**Scope:** `src/CCE.Application/` +**Goal:** Move from fragmented technical-type grouping (`Commands/`, `Queries/`, `Dtos/` at domain root) to **vertical feature slices** where each aggregate owns its commands, queries, DTOs, validators, and repository interfaces. + +--- + +## 1. Current State + +### 1.1 What's Working +- **Per-feature command folders** already exist: `Commands/CreateEvent/CreateEventCommand.cs` ✅ +- **Per-feature query folders** already exist: `Queries/GetEventById/GetEventByIdQuery.cs` ✅ +- Validators sit next to handlers: `CreateEventCommandValidator.cs` ✅ + +### 1.2 What's Fragmented + +``` +Content/ ← Domain root +├── Commands/CreateEvent/... ← Good +├── Commands/UpdateEvent/... ← Good +├── Queries/GetEventById/... ← Good +├── Queries/ListEvents/... ← Good +├── Dtos/EventDto.cs ← Far from commands/queries +├── Dtos/NewsDto.cs ← Same +├── Dtos/ResourceDto.cs ← Same +├── IEventRepository.cs ← At domain root +├── INewsRepository.cs ← At domain root +├── IFileStorage.cs ← Cross-cutting, also at root +└── Public/Dtos/PublicEventDto.cs ← Parallel structure +``` + +**Problem:** DTOs and repository interfaces are grouped by *technical type* instead of by *business feature*. This causes: +- Cognitive overhead: to understand "Events", a developer jumps between `Commands/`, `Queries/`, `Dtos/`, and root-level interfaces. +- Namespace sprawl: `using CCE.Application.Content.Dtos;` imports every DTO in the domain. +- Merge conflicts: `Dtos/` and `Queries/` folders are hotspots because every feature touches them. + +--- + +## 2. Target Structure (Vertical Slices) + +### 2.1 Guiding Principle +**Each aggregate is a self-contained folder containing everything it needs.** + +- Commands the aggregate accepts +- Queries the aggregate supports +- DTOs it exposes +- Repository interface it declares +- Public-facing variants (if any) + +Cross-cutting interfaces (used by *multiple* aggregates) stay at domain root or in `Shared/`. + +### 2.2 Example: Content Domain + +``` +Content/ +│ +├── Events/ ← Aggregate / Feature +│ ├── Commands/ +│ │ ├── CreateEvent/ +│ │ │ ├── CreateEventCommand.cs +│ │ │ ├── CreateEventCommandHandler.cs +│ │ │ └── CreateEventCommandValidator.cs +│ │ ├── UpdateEvent/ +│ │ ├── DeleteEvent/ +│ │ ├── RescheduleEvent/ +│ │ └── PublishEvent/ +│ ├── Queries/ +│ │ ├── GetEventById/ +│ │ │ ├── GetEventByIdQuery.cs +│ │ │ └── GetEventByIdQueryHandler.cs +│ │ └── ListEvents/ +│ ├── Dtos/ +│ │ └── EventDto.cs +│ └── IEventRepository.cs +│ +├── News/ +│ ├── Commands/ +│ │ ├── CreateNews/ +│ │ ├── UpdateNews/ +│ │ ├── DeleteNews/ +│ │ └── PublishNews/ +│ ├── Queries/ +│ │ ├── GetNewsById/ +│ │ └── ListNews/ +│ ├── Dtos/ +│ │ └── NewsDto.cs +│ └── INewsRepository.cs +│ +├── Resources/ +│ ├── Commands/ +│ │ ├── CreateResource/ +│ │ ├── UpdateResource/ +│ │ └── PublishResource/ +│ ├── Queries/ +│ │ ├── GetResourceById/ +│ │ └── ListResources/ +│ ├── Dtos/ +│ │ └── ResourceDto.cs +│ └── IResourceRepository.cs +│ +├── Pages/ +│ ├── Commands/ +│ ├── Queries/ +│ ├── Dtos/ +│ └── IPageRepository.cs +│ +├── ResourceCategories/ +│ ├── Commands/ +│ ├── Queries/ +│ ├── Dtos/ +│ └── IResourceCategoryRepository.cs +│ +├── HomepageSections/ +│ ├── Commands/ +│ ├── Queries/ +│ ├── Dtos/ +│ └── IHomepageSectionRepository.cs +│ +├── Assets/ +│ ├── Commands/ +│ │ └── UploadAsset/ +│ ├── Queries/ +│ │ └── GetAssetById/ +│ ├── Dtos/ +│ │ └── AssetFileDto.cs +│ └── IAssetRepository.cs +│ +├── CountryResourceRequests/ +│ ├── Commands/ +│ │ ├── ApproveCountryResourceRequest/ +│ │ └── RejectCountryResourceRequest/ +│ ├── Dtos/ +│ │ └── CountryResourceRequestDto.cs +│ └── ICountryResourceRequestRepository.cs +│ +├── Public/ ← External-facing APIs +│ ├── Dtos/ +│ │ ├── PublicEventDto.cs +│ │ ├── PublicNewsDto.cs +│ │ ├── PublicPageDto.cs +│ │ ├── PublicResourceDto.cs +│ │ ├── PublicResourceCategoryDto.cs +│ │ ├── PublicHomepageSectionDto.cs +│ │ └── IcsBuilder.cs +│ └── Queries/ +│ ├── GetPublicEventById/ +│ ├── ListPublicEvents/ +│ ├── GetPublicNewsBySlug/ +│ ├── ListPublicNews/ +│ ├── GetPublicPageBySlug/ +│ ├── GetPublicResourceById/ +│ ├── ListPublicResources/ +│ ├── ListPublicResourceCategories/ +│ └── ListPublicHomepageSections/ +│ +└── Shared/ ← Cross-cutting within Content + ├── IFileStorage.cs + └── IClamAvScanner.cs +``` + +### 2.3 Example: Identity Domain + +``` +Identity/ +│ +├── Auth/ ← Already reorganized ✅ +│ ├── Common/ +│ ├── Register/ +│ ├── Login/ +│ ├── RefreshToken/ +│ ├── ForgotPassword/ +│ ├── ResetPassword/ +│ └── Logout/ +│ +├── Users/ +│ ├── Queries/ +│ │ ├── GetUserById/ +│ │ └── ListUsers/ +│ └── Dtos/ +│ ├── UserDetailDto.cs +│ └── UserListItemDto.cs +│ +├── ExpertWorkflow/ +│ ├── Commands/ +│ │ ├── ApproveExpertRequest/ +│ │ └── RejectExpertRequest/ +│ ├── Queries/ +│ │ ├── ListExpertRequests/ +│ │ └── ListExpertProfiles/ +│ ├── Dtos/ +│ │ ├── ExpertRequestDto.cs +│ │ └── ExpertProfileDto.cs +│ └── IExpertWorkflowRepository.cs +│ +├── StateRepAssignments/ +│ ├── Commands/ +│ │ ├── CreateStateRepAssignment/ +│ │ └── RevokeStateRepAssignment/ +│ ├── Queries/ +│ │ └── ListStateRepAssignments/ +│ ├── Dtos/ +│ │ └── StateRepAssignmentDto.cs +│ └── IStateRepAssignmentRepository.cs +│ +├── Roles/ +│ └── Commands/ +│ └── AssignUserRoles/ +│ ├── AssignUserRolesCommand.cs +│ ├── AssignUserRolesCommandHandler.cs +│ ├── AssignUserRolesCommandValidator.cs +│ └── AssignUserRolesRequest.cs +│ +├── Public/ +│ ├── Commands/ +│ │ ├── SubmitExpertRequest/ +│ │ └── UpdateMyProfile/ +│ ├── Queries/ +│ │ ├── GetMyProfile/ +│ │ └── GetMyExpertStatus/ +│ └── Dtos/ +│ ├── UserProfileDto.cs +│ └── ExpertRequestStatusDto.cs +│ +├── IUserSyncRepository.cs ← Cross-user concerns +├── IUserRoleAssignmentRepository.cs +└── ICountryProfileService.cs ← Move to Country? +``` + +### 2.4 Example: Community Domain + +``` +Community/ +│ +├── Posts/ +│ ├── Commands/ +│ │ ├── CreatePost/ +│ │ ├── SoftDeletePost/ +│ │ ├── MarkPostAnswered/ +│ │ ├── RatePost/ +│ │ ├── FollowPost/ +│ │ └── UnfollowPost/ +│ ├── Queries/ +│ │ ├── ListAdminPosts/ +│ │ └── AdminPostRow.cs +│ └── Dtos/ +│ └── PostDto.cs ← (to be created if needed) +│ +├── Topics/ +│ ├── Commands/ +│ │ ├── CreateTopic/ +│ │ ├── UpdateTopic/ +│ │ ├── DeleteTopic/ +│ │ ├── FollowTopic/ +│ │ └── UnfollowTopic/ +│ ├── Queries/ +│ │ ├── GetTopicById/ +│ │ └── ListTopics/ +│ └── Dtos/ +│ └── TopicDto.cs ← Move from Community/Dtos/ +│ +├── Replies/ +│ ├── Commands/ +│ │ ├── CreateReply/ +│ │ ├── EditReply/ +│ │ └── SoftDeleteReply/ +│ └── Dtos/ +│ └── ReplyDto.cs ← (to be created if needed) +│ +├── Follows/ +│ ├── Commands/ +│ │ ├── FollowUser/ +│ │ └── UnfollowUser/ +│ └── Queries/ +│ └── GetMyFollows/ +│ +├── Public/ +│ ├── Queries/ +│ │ ├── GetPublicPostById/ +│ │ ├── ListPublicPostsInTopic/ +│ │ ├── ListPublicPostReplies/ +│ │ ├── GetPublicTopicBySlug/ +│ │ └── ListPublicTopics/ +│ └── Dtos/ +│ ├── PublicPostDto.cs +│ ├── PublicPostReplyDto.cs +│ ├── PublicTopicDto.cs +│ └── MyFollowsDto.cs +│ +└── Services/ + ├── ICommunityModerationService.cs + ├── ICommunityWriteService.cs + └── ITopicService.cs +``` + +### 2.5 Example: Country Domain + +Merge `Country/` and `CountryPublic/` into a single coherent domain: + +``` +Country/ +│ +├── Countries/ +│ ├── Commands/ +│ │ └── UpdateCountry/ +│ ├── Queries/ +│ │ ├── GetCountryById/ +│ │ └── ListCountries/ +│ └── Dtos/ +│ └── CountryDto.cs +│ +├── CountryProfiles/ +│ ├── Commands/ +│ │ └── UpsertCountryProfile/ +│ ├── Queries/ +│ │ └── GetCountryProfile/ +│ └── Dtos/ +│ └── CountryProfileDto.cs +│ +├── Public/ +│ ├── Queries/ +│ │ ├── GetPublicCountryProfile/ +│ │ └── ListPublicCountries/ +│ └── Dtos/ +│ ├── PublicCountryDto.cs +│ └── PublicCountryProfileDto.cs +│ +└── Services/ + ├── ICountryAdminService.cs + └── ICountryProfileService.cs +``` + +### 2.6 Example: Notifications Domain + +``` +Notifications/ +│ +├── Templates/ +│ ├── Commands/ +│ │ ├── CreateNotificationTemplate/ +│ │ └── UpdateNotificationTemplate/ +│ ├── Queries/ +│ │ ├── GetNotificationTemplateById/ +│ │ └── ListNotificationTemplates/ +│ ├── Dtos/ +│ │ └── NotificationTemplateDto.cs +│ └── INotificationTemplateService.cs +│ +├── UserNotifications/ +│ ├── Queries/ +│ │ ├── GetMyUnreadCount/ +│ │ └── ListMyNotifications/ +│ └── Dtos/ +│ └── UserNotificationDto.cs +│ +└── Public/ + ├── Commands/ + │ ├── MarkNotificationRead/ + │ └── MarkAllNotificationsRead/ + └── IUserNotificationService.cs +``` + +--- + +## 3. Cross-Cutting Domains (Stay Mostly As-Is) + +These domains are small enough or already well-organized: + +| Domain | Current State | Action | +|--------|---------------|--------| +| `Assistant/` | 1 command + interfaces | Keep; small | +| `Audit/` | 1 query + 1 DTO | Keep; small | +| `Health/` | 2 queries + 2 DTOs | Keep; small | +| `Kapsarc/` | 1 query + 1 DTO | Keep; small | +| `KnowledgeMaps/` | Public queries only | Keep; small | +| `Localization/` | 2 interfaces | Keep; small | +| `Reports/` | Service interfaces + row DTOs | Keep `Rows/` subfolder; organize services into `Services/` if more than 3 | +| `Search/` | 1 query + interfaces + DTOs | Keep; small | +| `Surveys/` | 1 command + 1 service | Keep; small | +| `InteractiveCity/` | Already per-feature ✅ | Keep as-is | + +--- + +## 4. Namespace Strategy + +| File Location | Namespace | +|---------------|-----------| +| `Content/Events/Commands/CreateEvent/CreateEventCommand.cs` | `CCE.Application.Content.Events.Commands.CreateEvent` | +| `Content/Events/Dtos/EventDto.cs` | `CCE.Application.Content.Events.Dtos` | +| `Content/Events/IEventRepository.cs` | `CCE.Application.Content.Events` | +| `Content/Public/Dtos/PublicEventDto.cs` | `CCE.Application.Content.Public.Dtos` | +| `Content/Shared/IFileStorage.cs` | `CCE.Application.Content.Shared` | +| `Common/Behaviors/ValidationBehavior.cs` | `CCE.Application.Common.Behaviors` | + +**Rule:** The namespace mirrors the folder path under `CCE.Application`. + +--- + +## 5. Command vs Request DTOs + +### 5.1 Current Pattern +Some features have both a `Command` (for MediatR) and a `Request` (for endpoint binding): + +``` +CreateEventCommand.cs → internal fields +CreateEventRequest.cs → HTTP body shape (often identical) +``` + +### 5.2 Consolidation Rule +- **If identical**: Delete the `Request` type; bind endpoints directly to `Command`. +- **If endpoint injects extra fields** (`IpAddress`, `UserAgent`, `CurrentUserId`, etc.): Keep both. Endpoint creates `Command` from `Request + injected fields`. +- **If using `[FromRoute]` / `[FromQuery]`**: Keep `Request` for explicit binding. + +--- + +## 6. Interface Organization + +### 6.1 Repository Interfaces +**1-to-1 with an aggregate** → live inside the aggregate folder: + +- `Content/Events/IEventRepository.cs` +- `Content/News/INewsRepository.cs` +- `Identity/ExpertWorkflow/IExpertWorkflowRepository.cs` + +### 6.2 Service Interfaces (Orchestration) +**Coordinate multiple aggregates** → live in `Domain/Services/` or domain root: + +- `Community/Services/ICommunityModerationService.cs` +- `Reports/Services/IUserRegistrationsReportService.cs` + +### 6.3 Cross-Domain Interfaces +**Used by multiple domains** → stay in `Common/`: + +- `Common/Interfaces/ICceDbContext.cs` +- `Common/Interfaces/ICurrentUserAccessor.cs` +- `Common/Interfaces/IEmailSender.cs` + +--- + +## 7. Phased Rollout + +Because this touches 250+ files, we roll out in phases. Each phase is a single PR. + +### Phase 1: Content Domain (Pilot) +**Features:** Events, News, Resources, Pages, ResourceCategories, HomepageSections, Assets, CountryResourceRequests +**Risk:** Medium — touches many endpoints and DTOs +**Deliverable:** Working build + passing unit tests +**Steps:** +1. Create new feature folders. +2. Move DTOs from `Content/Dtos/` into `Content/{Feature}/Dtos/`. +3. Move repository interfaces from `Content/` root into `Content/{Feature}/`. +4. Move commands/queries (already per-feature, just nest under `{Feature}/`). +5. Move `Public/` queries/DTOs into `Content/Public/` (already there, just verify). +6. Move cross-cutting interfaces (`IFileStorage`, `IClamAvScanner`) into `Content/Shared/`. +7. Update `using` statements in: + - `CCE.Api.Internal/Endpoints/ContentEndpoints.cs` + - `CCE.Api.External/Endpoints/PagesPublicEndpoints.cs` etc. + - `CCE.Infrastructure/` repository implementations + - `tests/CCE.Application.Tests/` +8. Delete empty `Content/Commands/`, `Content/Queries/`, `Content/Dtos/` folders. +9. Build & test. + +### Phase 2: Identity Domain +**Features:** Auth (done ✅), Users, ExpertWorkflow, StateRepAssignments, Roles, Public +**Risk:** Low-Medium — Auth already sliced +**Steps:** +1. Merge `Identity/Dtos/` into `Identity/{Feature}/Dtos/`. +2. Move `IExpertWorkflowRepository.cs`, `IStateRepAssignmentRepository.cs`, `IUserSyncRepository.cs`, etc. into respective feature folders. +3. Move `Identity/Commands/` into `Identity/{Feature}/Commands/`. +4. Move `Identity/Queries/` into `Identity/{Feature}/Queries/`. +5. Move `Identity/Public/` into `Identity/Public/` (already there, verify structure). +6. Update `using` statements in API endpoints and Infrastructure. +7. Delete empty `Identity/Commands/`, `Identity/Queries/`, `Identity/Dtos/` folders. +8. Build & test. + +### Phase 3: Community Domain +**Features:** Posts, Topics, Replies, Follows +**Risk:** Medium — many commands, shared DTOs +**Steps:** Same pattern as Phase 1. + +### Phase 4: Country + Notifications + Remaining +**Features:** Country (merge `CountryPublic`), Notifications, InteractiveCity, KnowledgeMaps +**Risk:** Low — smaller domains +**Steps:** +1. Merge `CountryPublic/` into `Country/Public/`. +2. Slice Notifications into `Templates/` + `UserNotifications/`. +3. Verify InteractiveCity and KnowledgeMaps already follow the pattern. +4. Build & test. + +--- + +## 8. File-Level Migration (Phase 1 — Content) + +### 8.1 Source → Destination Map + +| Current | New Home | +|---------|----------| +| `Content/Dtos/EventDto.cs` | `Content/Events/Dtos/EventDto.cs` | +| `Content/Dtos/NewsDto.cs` | `Content/News/Dtos/NewsDto.cs` | +| `Content/Dtos/ResourceDto.cs` | `Content/Resources/Dtos/ResourceDto.cs` | +| `Content/Dtos/PageDto.cs` | `Content/Pages/Dtos/PageDto.cs` | +| `Content/Dtos/ResourceCategoryDto.cs` | `Content/ResourceCategories/Dtos/ResourceCategoryDto.cs` | +| `Content/Dtos/HomepageSectionDto.cs` | `Content/HomepageSections/Dtos/HomepageSectionDto.cs` | +| `Content/Dtos/AssetFileDto.cs` | `Content/Assets/Dtos/AssetFileDto.cs` | +| `Content/Dtos/CountryResourceRequestDto.cs` | `Content/CountryResourceRequests/Dtos/CountryResourceRequestDto.cs` | +| `Content/IEventRepository.cs` | `Content/Events/IEventRepository.cs` | +| `Content/INewsRepository.cs` | `Content/News/INewsRepository.cs` | +| `Content/IResourceRepository.cs` | `Content/Resources/IResourceRepository.cs` | +| `Content/IPageRepository.cs` | `Content/Pages/IPageRepository.cs` | +| `Content/IResourceCategoryRepository.cs` | `Content/ResourceCategories/IResourceCategoryRepository.cs` | +| `Content/IHomepageSectionRepository.cs` | `Content/HomepageSections/IHomepageSectionRepository.cs` | +| `Content/IAssetRepository.cs` | `Content/Assets/IAssetRepository.cs` | +| `Content/ICountryResourceRequestRepository.cs` | `Content/CountryResourceRequests/ICountryResourceRequestRepository.cs` | +| `Content/IFileStorage.cs` | `Content/Shared/IFileStorage.cs` | +| `Content/IClamAvScanner.cs` | `Content/Shared/IClamAvScanner.cs` | +| `Content/Commands/CreateEvent/*` | `Content/Events/Commands/CreateEvent/*` | +| `Content/Commands/UpdateEvent/*` | `Content/Events/Commands/UpdateEvent/*` | +| `Content/Commands/DeleteEvent/*` | `Content/Events/Commands/DeleteEvent/*` | +| `Content/Commands/RescheduleEvent/*` | `Content/Events/Commands/RescheduleEvent/*` | +| `Content/Commands/PublishNews/*` | `Content/News/Commands/PublishNews/*` | +| `Content/Queries/GetEventById/*` | `Content/Events/Queries/GetEventById/*` | +| `Content/Queries/ListEvents/*` | `Content/Events/Queries/ListEvents/*` | +| `Content/Public/Dtos/*` | `Content/Public/Dtos/*` (no change needed) | +| `Content/Public/Queries/*` | `Content/Public/Queries/*` (no change needed) | +| `Content/Public/IcsBuilder.cs` | `Content/Public/IcsBuilder.cs` | +| `Content/Public/IResourceViewCountRepository.cs` | `Content/Shared/IResourceViewCountRepository.cs` | + +### 8.2 Consumers to Update + +| Consumer File | What to Update | +|---------------|----------------| +| `src/CCE.Api.Internal/Endpoints/ContentEndpoints.cs` | `using CCE.Application.Content.Dtos;` → feature namespaces | +| `src/CCE.Api.External/Endpoints/EventsPublicEndpoints.cs` | `using CCE.Application.Content.Dtos;` → feature namespaces | +| `src/CCE.Api.External/Endpoints/PagesPublicEndpoints.cs` | Same | +| `src/CCE.Api.External/Endpoints/ResourcesPublicEndpoints.cs` | Same | +| `src/CCE.Infrastructure/Content/*Repository.cs` | `using CCE.Application.Content;` → `CCE.Application.Content.Events`, etc. | +| `tests/CCE.Application.Tests/Content/*` | Update test namespaces and usings | + +--- + +## 9. Validation Criteria + +After each phase: + +1. **Build:** `dotnet build CCE.sln` — must pass with 0 warnings (TreatWarningsAsErrors=true). +2. **Unit tests:** `dotnet test tests/CCE.Application.Tests` — must pass. +3. **No orphaned files:** Delete empty `Commands/`, `Queries/`, `Dtos/` folders after migration. +4. **No duplicate DTOs:** If a DTO is used by two features (rare), it lives in the feature that owns the aggregate and is `internal` or stays in `Shared/`. +5. **Namespace check:** Every new file's namespace matches its folder path. + +--- + +## 10. Open Decisions + +1. **Should `Public/` DTOs be nested inside each feature?** + - Option A: `Content/Events/Public/PublicEventDto.cs` (fully nested) + - Option B: `Content/Public/Dtos/PublicEventDto.cs` (centralized, current) + - **Recommendation:** Keep Option B. Public APIs are a separate bounded context and having them in one place makes it easy to see the external contract. + +2. **Should `Request` types be eliminated where they mirror `Command` exactly?** + - **Recommendation:** Yes. Remove `CreateEventRequest`, `UpdateEventRequest`, etc. where identical. The endpoint can bind directly to the Command. This reduces file count and eliminates a class of drift bugs. + +3. **Should `Rows/` in Reports move to `Reports/Services/Rows/` or stay?** + - **Recommendation:** Keep `Reports/Rows/` as-is or rename to `Reports/Dtos/` for consistency. If report services grow, create `Reports/Services/`. + +--- + +## 11. Summary + +| Metric | Before | After | +|--------|--------|-------| +| DTO location | `Domain/Dtos/` (fragmented) | `Domain/Feature/Dtos/` (co-located) | +| Repository interfaces | Domain root | Inside owning aggregate | +| Cognitive load to find "Events" | 4+ folders | 1 folder | +| Merge-conflict hotspots | `Dtos/`, `Queries/` | Distributed across features | +| Namespace granularity | Broad | Precise | + +This plan turns the Application layer into a **screaming architecture**: open any folder and immediately understand what the system does. diff --git a/backend/docs/plans/error-codes-implementation-plan.md b/backend/docs/plans/error-codes-implementation-plan.md new file mode 100644 index 00000000..4d8b1f0e --- /dev/null +++ b/backend/docs/plans/error-codes-implementation-plan.md @@ -0,0 +1,451 @@ +# Error Codes Implementation Plan + +## How to Adopt in Another Solution + +1. Replace all `[YourAppName]` occurrences with your root namespace. +2. Copy each file into the matching layer (Domain / Application / API). +3. Register the middleware in your `Program.cs` pipeline **before** routing and auth. +4. Keep `ApplicationErrors` constants in sync with your YAML localization keys. + +--- + +## Overview + +This plan implements a standardized, bilingual, typed error system that maps domain errors to proper HTTP status codes without throwing exceptions for expected failures. + +**Packages required:** None (pure .NET). Optional: `FluentValidation` for validation pipeline. + +--- + +### 1. Create the `ErrorType` Enum and `Error` Record (Domain Layer) + +**File:** `Domain/Common/Error.cs` + +```csharp +using System.Text.Json.Serialization; + +namespace [YourAppName].Domain.Common; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ErrorType +{ + None, + Validation, + NotFound, + Conflict, + Unauthorized, + Forbidden, + BusinessRule, + Internal +} + +public sealed record Error( + string Code, + string MessageAr, + string MessageEn, + ErrorType Type = ErrorType.Internal, + IDictionary? Details = null); +``` + +--- + +### 2. Create the `Result` Wrapper (Application Layer) + +**File:** `Application/Contracts/Result.cs` + +```csharp +using MediatR; + +namespace [YourAppName].Application.Contracts; + +public record Result +{ + public bool IsSuccess { get; init; } + public T? Data { get; init; } + public [YourAppName].Domain.Common.Error? Error { get; init; } + + public static Result Success(T data) => new() { IsSuccess = true, Data = data }; + public static Result Failure([YourAppName].Domain.Common.Error error) => new() { IsSuccess = false, Error = error }; + + public static implicit operator Result(T data) => Success(data); +} + +public static class Result +{ + public static Result Success() => Result.Success(Unit.Value); + public static Result Failure([YourAppName].Domain.Common.Error error) => Result.Failure(error); +} +``` + +--- + +### 3. Define Application Error Constants (Application Layer) + +**File:** `Application/Errors/ApplicationErrors.cs` + +```csharp +namespace [YourAppName].Application.Errors; + +public static class ApplicationErrors +{ + public static class Auth + { + public const string INVALID_CREDENTIALS = "INVALID_CREDENTIALS"; + public const string INVALID_TOKEN = "INVALID_TOKEN"; + public const string INVALID_REFRESH_TOKEN = "INVALID_REFRESH_TOKEN"; + public const string ACCOUNT_DEACTIVATED = "ACCOUNT_DEACTIVATED"; + public const string NOT_AUTHENTICATED = "NOT_AUTHENTICATED"; + public const string LOGIN_SUCCESS = "LOGIN_SUCCESS"; + public const string REGISTER_SUCCESS = "REGISTER_SUCCESS"; + public const string LOGOUT_SUCCESS = "LOGOUT_SUCCESS"; + public const string TOKEN_REFRESHED = "TOKEN_REFRESHED"; + } + + public static class User + { + public const string NOT_FOUND = "USER_NOT_FOUND"; + public const string EMAIL_EXISTS = "EMAIL_EXISTS"; + public const string USERNAME_EXISTS = "USERNAME_EXISTS"; + public const string CREATED = "USER_CREATED"; + public const string UPDATED = "USER_UPDATED"; + public const string DELETED = "USER_DELETED"; + public const string ACTIVATED = "USER_ACTIVATED"; + public const string DEACTIVATED = "USER_DEACTIVATED"; + public const string ROLES_ASSIGNED = "ROLES_ASSIGNED"; + public const string CREATION_FAILED = "USER_CREATION_FAILED"; + public const string UPDATE_FAILED = "USER_UPDATE_FAILED"; + public const string DELETE_FAILED = "USER_DELETE_FAILED"; + public const string ACTIVATE_FAILED = "ACTIVATE_FAILED"; + public const string DEACTIVATE_FAILED = "DEACTIVATE_FAILED"; + public const string REMOVE_ROLES_FAILED = "REMOVE_ROLES_FAILED"; + public const string ADD_ROLES_FAILED = "ADD_ROLES_FAILED"; + } + + public static class Content + { + public const string NOT_FOUND = "CONTENT_NOT_FOUND"; + public const string ALREADY_EXISTS = "CONTENT_EXISTS"; + public const string CREATED = "CONTENT_CREATED"; + public const string UPDATED = "CONTENT_UPDATED"; + public const string DELETED = "CONTENT_DELETED"; + public const string PUBLISHED = "CONTENT_PUBLISHED"; + public const string ARCHIVED = "CONTENT_ARCHIVED"; + } + + public static class Notification + { + public const string NOT_FOUND = "NOTIFICATION_NOT_FOUND"; + public const string ACCESS_DENIED = "ACCESS_DENIED"; + public const string CREATED = "NOTIFICATION_CREATED"; + public const string MARKED_READ = "NOTIFICATION_MARKED_READ"; + public const string DELETED = "NOTIFICATION_DELETED"; + } + + public static class PlatformSetting + { + public const string NOT_FOUND = "SETTING_NOT_FOUND"; + public const string ALREADY_EXISTS = "SETTING_EXISTS"; + public const string CREATED = "SETTING_CREATED"; + public const string UPDATED = "SETTING_UPDATED"; + public const string DELETED = "SETTING_DELETED"; + public const string REPROTECT_FAILED = "SETTING_REPROTECT_FAILED"; + } + + public static class ExternalApi + { + public const string NOT_CONFIGURED = "EXTERNAL_API_NOT_CONFIGURED"; + public const string ERROR = "EXTERNAL_API_ERROR"; + public const string NOT_FOUND = "EXTERNAL_API_CONFIG_NOT_FOUND"; + public const string ALREADY_EXISTS = "EXTERNAL_API_CONFIG_EXISTS"; + } + + public static class General + { + public const string VALIDATION_ERROR = "VALIDATION_ERROR"; + public const string INTERNAL_ERROR = "INTERNAL_ERROR"; + public const string UNAUTHORIZED = "UNAUTHORIZED_ACCESS"; + public const string FORBIDDEN = "FORBIDDEN_ACCESS"; + public const string BAD_REQUEST = "BAD_REQUEST"; + public const string RESOURCE_NOT_FOUND = "RESOURCE_NOT_FOUND"; + public const string SUCCESS_CREATED = "SUCCESS_CREATED"; + public const string SUCCESS_UPDATED = "SUCCESS_UPDATED"; + public const string SUCCESS_DELETED = "SUCCESS_DELETED"; + public const string SUCCESS_OPERATION = "SUCCESS_OPERATION"; + } + + public static class Validation + { + public const string REQUIRED_FIELD = "REQUIRED_FIELD"; + public const string INVALID_EMAIL = "INVALID_EMAIL"; + public const string INVALID_PHONE = "INVALID_PHONE"; + public const string MIN_LENGTH = "MIN_LENGTH"; + public const string MAX_LENGTH = "MAX_LENGTH"; + public const string INVALID_FORMAT = "INVALID_FORMAT"; + public const string EMAIL_REQUIRED = "EMAIL_REQUIRED"; + public const string PASSWORD_REQUIRED = "PASSWORD_REQUIRED"; + public const string USERNAME_REQUIRED = "USERNAME_REQUIRED"; + public const string FIRST_NAME_REQUIRED = "FIRST_NAME_REQUIRED"; + public const string LAST_NAME_REQUIRED = "LAST_NAME_REQUIRED"; + public const string TOKEN_REQUIRED = "TOKEN_REQUIRED"; + public const string TITLE_REQUIRED = "TITLE_REQUIRED"; + public const string TITLE_MAX_LENGTH = "TITLE_MAX_LENGTH"; + public const string BODY_REQUIRED = "BODY_REQUIRED"; + public const string SUMMARY_MAX_LENGTH = "SUMMARY_MAX_LENGTH"; + public const string CONTENT_TYPE_REQUIRED = "CONTENT_TYPE_REQUIRED"; + public const string CONTENT_TYPE_MAX_LENGTH = "CONTENT_TYPE_MAX_LENGTH"; + public const string AUTHOR_ID_REQUIRED = "AUTHOR_ID_REQUIRED"; + public const string STATUS_REQUIRED = "STATUS_REQUIRED"; + public const string STATUS_INVALID = "STATUS_INVALID"; + public const string FEATURED_IMAGE_URL_MAX_LENGTH = "FEATURED_IMAGE_URL_MAX_LENGTH"; + public const string CATEGORY_MAX_LENGTH = "CATEGORY_MAX_LENGTH"; + public const string USER_ID_REQUIRED = "USER_ID_REQUIRED"; + public const string MESSAGE_REQUIRED = "MESSAGE_REQUIRED"; + public const string MESSAGE_MAX_LENGTH = "MESSAGE_MAX_LENGTH"; + public const string NOTIFICATION_TYPE_REQUIRED = "NOTIFICATION_TYPE_REQUIRED"; + public const string NOTIFICATION_TYPE_MAX_LENGTH = "NOTIFICATION_TYPE_MAX_LENGTH"; + public const string CHANNEL_REQUIRED = "CHANNEL_REQUIRED"; + public const string CHANNEL_INVALID = "CHANNEL_INVALID"; + public const string KEY_REQUIRED = "KEY_REQUIRED"; + public const string KEY_MAX_LENGTH = "KEY_MAX_LENGTH"; + public const string VALUE_REQUIRED = "VALUE_REQUIRED"; + public const string VALUE_MAX_LENGTH = "VALUE_MAX_LENGTH"; + public const string PASSWORD_UPPERCASE = "PASSWORD_UPPERCASE"; + public const string PASSWORD_LOWERCASE = "PASSWORD_LOWERCASE"; + public const string PASSWORD_NUMBER = "PASSWORD_NUMBER"; + } +} +``` + +--- + +### 4. Create `ResultActionResultExtensions` (API Layer) + +**File:** `API/Extensions/ResultActionResultExtensions.cs` + +```csharp +using [YourAppName].Application.Contracts; +using [YourAppName].Domain.Common; + +using MediatR; +using Microsoft.AspNetCore.Mvc; + +namespace [YourAppName].API.Extensions; + +public static class ResultActionResultExtensions +{ + public static IActionResult ToActionResult( + this ControllerBase controller, + Result result, + int successStatusCode = StatusCodes.Status200OK) + { + if (result.IsSuccess) + { + if (typeof(T) == typeof(Unit) && successStatusCode == StatusCodes.Status204NoContent) + { + return controller.NoContent(); + } + + return successStatusCode switch + { + StatusCodes.Status201Created => controller.StatusCode(StatusCodes.Status201Created, result), + StatusCodes.Status204NoContent => controller.NoContent(), + _ => controller.StatusCode(successStatusCode, result) + }; + } + + return controller.StatusCode(MapFailureStatusCode(result.Error), result); + } + + private static int MapFailureStatusCode(Error? error) => error?.Type switch + { + ErrorType.Forbidden => StatusCodes.Status403Forbidden, + ErrorType.Unauthorized => StatusCodes.Status401Unauthorized, + ErrorType.NotFound => StatusCodes.Status404NotFound, + ErrorType.Conflict => StatusCodes.Status409Conflict, + ErrorType.Validation => StatusCodes.Status422UnprocessableEntity, + _ => StatusCodes.Status400BadRequest + }; +} +``` + +--- + +### 5. Create `ExceptionHandlingMiddleware` (API Layer) + +**File:** `API/Middleware/ExceptionHandlingMiddleware.cs` + +```csharp +using [YourAppName].Application.Errors; +using [YourAppName].Application.Localization; +using [YourAppName].Domain.Common; +using FluentValidation; +using System.Net; +using System.Text.Json; + +namespace [YourAppName].API.Middleware; + +public class ExceptionHandlingMiddleware +{ + private readonly RequestDelegate _next; + private readonly ILogger _logger; + + public ExceptionHandlingMiddleware(RequestDelegate next, ILogger logger) + { + _next = next; + _logger = logger; + } + + public async Task InvokeAsync(HttpContext context, ILocalizationService localizationService) + { + try + { + await _next(context); + } + catch (Exception ex) + { + await HandleExceptionAsync(context, ex, localizationService); + } + } + + private async Task HandleExceptionAsync(HttpContext context, Exception exception, ILocalizationService localizationService) + { + var (statusCode, error) = exception switch + { + ValidationException validationEx => ( + HttpStatusCode.BadRequest, + BuildValidationError(localizationService, validationEx)), + UnauthorizedAccessException => ( + HttpStatusCode.Unauthorized, + BuildError(localizationService, ApplicationErrors.General.UNAUTHORIZED, ErrorType.Unauthorized)), + ArgumentException => ( + HttpStatusCode.BadRequest, + BuildError(localizationService, ApplicationErrors.General.BAD_REQUEST, ErrorType.Validation)), + KeyNotFoundException => ( + HttpStatusCode.NotFound, + BuildError(localizationService, ApplicationErrors.General.RESOURCE_NOT_FOUND, ErrorType.NotFound)), + _ => ( + HttpStatusCode.InternalServerError, + BuildError(localizationService, ApplicationErrors.General.INTERNAL_ERROR, ErrorType.Internal)) + }; + + _logger.LogError(exception, "Error handling request: {Message}", exception.Message); + + context.Response.ContentType = "application/json"; + context.Response.StatusCode = (int)statusCode; + + var response = new + { + isSuccess = false, + data = (object?)null, + error + }; + + await context.Response.WriteAsync(JsonSerializer.Serialize(response, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + })); + } + + private static Error BuildError(ILocalizationService localizationService, string key, ErrorType type) + { + var localized = localizationService.GetLocalizedMessage(key); + return new Error(key, localized.Ar, localized.En, type); + } + + private static Error BuildValidationError(ILocalizationService localizationService, ValidationException validationEx) + { + var localized = localizationService.GetLocalizedMessage(ApplicationErrors.General.VALIDATION_ERROR); + var details = validationEx.Errors + .GroupBy(e => e.PropertyName) + .ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray()); + + return new Error( + ApplicationErrors.General.VALIDATION_ERROR, + localized.Ar, + localized.En, + ErrorType.Validation, + details); + } +} +``` + +--- + +### 6. Wire Middleware into the Pipeline (API Layer) + +**File:** `API/Extensions/WebApplicationExtensions.cs` (or directly in `Program.cs`) + +```csharp +using [YourAppName].API.Middleware; + +namespace [YourAppName].API.Extensions; + +public static class WebApplicationExtensions +{ + public static WebApplication UsePlatformPipeline(this WebApplication app) + { + app.UseMiddleware(); + app.UseHttpsRedirection(); + app.UseCors(); + app.UseRateLimiter(); + app.UseRouting(); + app.UseAuthentication(); + app.UseAuthorization(); + + return app; + } +} +``` + +> **Important:** `ExceptionHandlingMiddleware` must be the **first** middleware in the pipeline so it wraps all subsequent request processing. + +--- + +### 7. Handler Usage Pattern (Application Layer) + +In every command/query handler, return `Result.Failure(...)` instead of throwing exceptions for expected failures. + +```csharp +public async Task> Handle(CreateUserCommand request, CancellationToken ct) +{ + var exists = await _repository.ExistsAsync(c => c.Email == request.Email, ct); + if (exists) + return Result.Failure(new Error( + ApplicationErrors.User.EMAIL_EXISTS, + "...", "...", ErrorType.Conflict)); + + var user = User.Create(request.Email, request.Username, ...); + await _repository.AddAsync(user, ct); + await _unitOfWork.SaveChangesAsync(ct); + + return Result.Success(new CreateSuccessDto(user.Id)); +} +``` + +--- + +### 8. Controller Usage Pattern (API Layer) + +```csharp +[HttpPost] +public async Task Create([FromBody] CreateRequest request, CancellationToken ct) +{ + var result = await _mediator.Send(new CreateCommand(...), ct); + return this.ToActionResult(result, StatusCodes.Status201Created); +} +``` + +--- + +## HTTP Status Code Mapping Reference + +| `ErrorType` | HTTP Status Code | +|--------------------|------------------| +| `Forbidden` | 403 | +| `Unauthorized` | 401 | +| `NotFound` | 404 | +| `Conflict` | 409 | +| `Validation` | 422 | +| `BusinessRule` | 400 | +| `Internal` | 400 (default) | +| `None` | 400 (default) | diff --git a/backend/docs/plans/localization-implementation-plan.md b/backend/docs/plans/localization-implementation-plan.md new file mode 100644 index 00000000..d51e52a5 --- /dev/null +++ b/backend/docs/plans/localization-implementation-plan.md @@ -0,0 +1,691 @@ +# Localization Implementation Plan + +## How to Adopt in Another Solution + +1. Replace all `[YourAppName]` occurrences with your root namespace. +2. Install the `YamlDotNet` NuGet package. +3. Create a `Localization/Resources.yaml` file in your API project and mark it `CopyToOutputDirectory:Always`. +4. Register `YamlLocalizationStore` as **singleton** and `ILocalizationService` as **scoped** in DI. +5. Ensure your `IUserContext` (or equivalent) exposes a `Locale` property for culture fallback. + +--- + +## Overview + +This plan implements a lightweight, file-based bilingual localization system that works without `IStringLocalizer` or `.resx` files. It auto-discovers `Resources.yaml` files from all loaded assemblies and merges them into an in-memory store at startup. + +**Packages required:** `YamlDotNet` + +--- + +### 1. Add the NuGet Package + +Add to your central package management or `.csproj`: + +```xml + +``` + +--- + +### 2. Create the YAML Resource File (API Layer) + +**File:** `API/Localization/Resources.yaml` + +```yaml +INVALID_CREDENTIALS: + ar: "عذرًا، حدثت مشكلة أثناء تسجيل الدخول" + en: "Sorry, a problem occurred during login" + +INVALID_TOKEN: + ar: "رمز الوصول غير صالح." + en: "Invalid access token." + +INVALID_REFRESH_TOKEN: + ar: "رمز التحديث غير صالح أو منتهي الصلاحية." + en: "Invalid or expired refresh token." + +ACCOUNT_DEACTIVATED: + ar: "عذرًا، حدثت مشكلة أثناء تسجيل الدخول" + en: "Sorry, a problem occurred during login" + +NOT_AUTHENTICATED: + ar: "المستخدم غير مصادق." + en: "User not authenticated." + +LOGIN_SUCCESS: + ar: "تم تسجيل الدخول بنجاح" + en: "Logged in successfully" + +REGISTER_SUCCESS: + ar: "تم إنشاء الحساب بنجاح" + en: "Account created successfully" + +LOGOUT_SUCCESS: + ar: "تم تسجيل الخروج بنجاح" + en: "Logged out successfully" + +TOKEN_REFRESHED: + ar: "تم تحديث الرمز بنجاح" + en: "Token refreshed successfully" + +USER_NOT_FOUND: + ar: "عذرًا، لم يتم العثور على الحساب المرتبط بالبريد الإلكتروني" + en: "Sorry, no account was found associated with this email address" + +EMAIL_EXISTS: + ar: "عذرًا، حدثت مشكلة أثناء إنشاء الحساب" + en: "Sorry, a problem occurred while creating the account" + +USERNAME_EXISTS: + ar: "اسم المستخدم مستخدم بالفعل." + en: "Username already taken." + +USER_CREATED: + ar: "تم إنشاء المستخدم بنجاح!" + en: "User created successfully!" + +USER_UPDATED: + ar: "تم تحديث المستخدم بنجاح" + en: "User updated successfully" + +USER_DELETED: + ar: "تم حذف المستخدم بنجاح!" + en: "User deleted successfully!" + +USER_ACTIVATED: + ar: "تم تفعيل المستخدم بنجاح" + en: "User activated successfully" + +USER_DEACTIVATED: + ar: "تم تعطيل المستخدم بنجاح" + en: "User deactivated successfully" + +ROLES_ASSIGNED: + ar: "تم تعيين الأدوار بنجاح" + en: "Roles assigned successfully" + +USER_CREATION_FAILED: + ar: "عذرًا، حدثت مشكلة أثناء إنشاء الحساب" + en: "Sorry, a problem occurred while creating the account" + +USER_UPDATE_FAILED: + ar: "عذرًا، حدثت مشكلة أثناء تحديث المستخدم" + en: "Sorry, a problem occurred while updating the user" + +USER_DELETE_FAILED: + ar: "عذرًا، حدثت مشكلة أثناء حذف المستخدم" + en: "Sorry, a problem occurred while deleting the user" + +ACTIVATE_FAILED: + ar: "عذرًا، حدثت مشكلة أثناء تفعيل المستخدم" + en: "Sorry, a problem occurred while activating the user" + +DEACTIVATE_FAILED: + ar: "عذرًا، حدثت مشكلة أثناء تعطيل المستخدم" + en: "Sorry, a problem occurred while deactivating the user" + +REMOVE_ROLES_FAILED: + ar: "عذرًا، حدثت مشكلة أثناء إزالة الأدوار" + en: "Sorry, a problem occurred while removing roles" + +ADD_ROLES_FAILED: + ar: "عذرًا، حدثت مشكلة أثناء إضافة الأدوار" + en: "Sorry, a problem occurred while adding roles" + +CONTENT_NOT_FOUND: + ar: "المحتوى غير موجود." + en: "Content not found." + +CONTENT_EXISTS: + ar: "المحتوى بهذا العنوان موجود بالفعل." + en: "Content with this title already exists." + +CONTENT_CREATED: + ar: "تم إنشاء المحتوى بنجاح" + en: "Content created successfully" + +CONTENT_UPDATED: + ar: "تم تحديث المحتوى بنجاح" + en: "Content updated successfully" + +CONTENT_DELETED: + ar: "تم حذف المحتوى بنجاح" + en: "Content deleted successfully" + +CONTENT_PUBLISHED: + ar: "تم نشر المحتوى بنجاح" + en: "Content published successfully" + +CONTENT_ARCHIVED: + ar: "تم أرشفة المحتوى بنجاح" + en: "Content archived successfully" + +NOTIFICATION_NOT_FOUND: + ar: "الإشعار غير موجود." + en: "Notification not found." + +ACCESS_DENIED: + ar: "الوصول مرفوض." + en: "Access denied." + +NOTIFICATION_CREATED: + ar: "تم إنشاء الإشعار بنجاح" + en: "Notification created successfully" + +NOTIFICATION_MARKED_READ: + ar: "تم تحديد الإشعار كمقروء" + en: "Notification marked as read" + +NOTIFICATION_DELETED: + ar: "تم حذف الإشعار بنجاح" + en: "Notification deleted successfully" + +SETTING_NOT_FOUND: + ar: "الإعداد غير موجود." + en: "Setting not found." + +SETTING_EXISTS: + ar: "الإعداد بهذا المفتاح موجود بالفعل." + en: "Setting with this key already exists." + +SETTING_CREATED: + ar: "تم إنشاء الإعداد بنجاح" + en: "Setting created successfully" + +SETTING_UPDATED: + ar: "تم تحديث الإعداد بنجاح" + en: "Setting updated successfully" + +SETTING_DELETED: + ar: "تم حذف الإعداد بنجاح" + en: "Setting deleted successfully" + +SETTING_REPROTECT_FAILED: + ar: "تعذر إعادة معالجة القيمة المحمية الحالية. يرجى تقديم قيمة جديدة عند تغيير وضع الحماية." + en: "The existing protected value could not be re-processed. Provide a new value when changing protection mode." + +VALIDATION_ERROR: + ar: "عذرًا، البيانات المدخلة غير صحيحة" + en: "Sorry, the entered data is invalid" + +REQUIRED_FIELD: + ar: "هذا الحقل مطلوب" + en: "This field is required" + +INVALID_EMAIL: + ar: "البريد الإلكتروني غير صالح" + en: "Invalid email format" + +INVALID_PHONE: + ar: "رقم الهاتف غير صالح" + en: "Invalid phone number" + +MIN_LENGTH: + ar: "القيمة قصيرة جدًا" + en: "Value is too short" + +MAX_LENGTH: + ar: "القيمة طويلة جدًا" + en: "Value is too long" + +INTERNAL_ERROR: + ar: "حدث خطأ غير متوقع" + en: "An unexpected error occurred" + +UNAUTHORIZED_ACCESS: + ar: "الوصول غير مصرح به" + en: "Unauthorized access" + +FORBIDDEN_ACCESS: + ar: "الوصول ممنوع" + en: "Forbidden access" + +BAD_REQUEST: + ar: "عذرًا، البيانات المدخلة غير صحيحة" + en: "Sorry, the entered data is invalid" + +RESOURCE_NOT_FOUND: + ar: "المورد غير موجود" + en: "Resource not found" + +EXTERNAL_API_ERROR: + ar: "عذرًا، حدثت مشكلة أثناء الاتصال بالخدمة الخارجية" + en: "Sorry, a problem occurred while connecting to the external service" + +EXTERNAL_API_NOT_CONFIGURED: + ar: "الخدمة الخارجية غير مكونة" + en: "External service is not configured" + +SUCCESS_CREATED: + ar: "تم الإنشاء بنجاح" + en: "Created successfully" + +SUCCESS_UPDATED: + ar: "تم التحديث بنجاح" + en: "Updated successfully" + +SUCCESS_DELETED: + ar: "تم الحذف بنجاح" + en: "Deleted successfully" + +SUCCESS_OPERATION: + ar: "تمت العملية بنجاح" + en: "Operation completed successfully" + +EMAIL_REQUIRED: + ar: "البريد الإلكتروني مطلوب" + en: "Email is required" + +PASSWORD_REQUIRED: + ar: "كلمة المرور مطلوبة" + en: "Password is required" + +USERNAME_REQUIRED: + ar: "اسم المستخدم مطلوب" + en: "Username is required" + +FIRST_NAME_REQUIRED: + ar: "الاسم الأول مطلوب" + en: "First name is required" + +LAST_NAME_REQUIRED: + ar: "اسم العائلة مطلوب" + en: "Last name is required" + +TOKEN_REQUIRED: + ar: "الرمز مطلوب" + en: "Token is required" + +TITLE_REQUIRED: + ar: "العنوان مطلوب" + en: "Title is required" + +TITLE_MAX_LENGTH: + ar: "يجب ألا يتجاوز العنوان 500 حرف" + en: "Title must not exceed 500 characters" + +BODY_REQUIRED: + ar: "المحتوى مطلوب" + en: "Body is required" + +SUMMARY_MAX_LENGTH: + ar: "يجب ألا يتجاوز الملخص 1000 حرف" + en: "Summary must not exceed 1000 characters" + +CONTENT_TYPE_REQUIRED: + ar: "نوع المحتوى مطلوب" + en: "Content type is required" + +CONTENT_TYPE_MAX_LENGTH: + ar: "يجب ألا يتجاوز نوع المحتوى 50 حرف" + en: "Content type must not exceed 50 characters" + +AUTHOR_ID_REQUIRED: + ar: "معرف المؤلف مطلوب" + en: "Author ID is required" + +STATUS_REQUIRED: + ar: "الحالة مطلوبة" + en: "Status is required" + +STATUS_INVALID: + ar: "يجب أن تكون الحالة Draft أو Published أو Archived" + en: "Status must be Draft, Published, or Archived" + +FEATURED_IMAGE_URL_MAX_LENGTH: + ar: "يجب ألا يتجاوز رابط الصورة 2000 حرف" + en: "Featured image URL must not exceed 2000 characters" + +CATEGORY_MAX_LENGTH: + ar: "يجب ألا يتجاوز التصنيف 100 حرف" + en: "Category must not exceed 100 characters" + +USER_ID_REQUIRED: + ar: "معرف المستخدم مطلوب" + en: "User ID is required" + +MESSAGE_REQUIRED: + ar: "الرسالة مطلوبة" + en: "Message is required" + +MESSAGE_MAX_LENGTH: + ar: "يجب ألا تتجاوز الرسالة 2000 حرف" + en: "Message must not exceed 2000 characters" + +NOTIFICATION_TYPE_REQUIRED: + ar: "نوع الإشعار مطلوب" + en: "Notification type is required" + +NOTIFICATION_TYPE_MAX_LENGTH: + ar: "يجب ألا يتجاوز نوع الإشعار 50 حرف" + en: "Notification type must not exceed 50 characters" + +CHANNEL_REQUIRED: + ar: "القناة مطلوبة" + en: "Channel is required" + +CHANNEL_INVALID: + ar: "يجب أن تكون القناة InApp أو Email أو SMS أو Push" + en: "Channel must be InApp, Email, SMS, or Push" + +KEY_REQUIRED: + ar: "المفتاح مطلوب" + en: "Key is required" + +KEY_MAX_LENGTH: + ar: "يجب ألا يتجاوز المفتاح 200 حرف" + en: "Key must not exceed 200 characters" + +VALUE_REQUIRED: + ar: "القيمة مطلوبة" + en: "Value is required" + +VALUE_MAX_LENGTH: + ar: "يجب ألا تتجاوز القيمة 4000 حرف" + en: "Value must not exceed 4000 characters" + +INVALID_FORMAT: + ar: "التنسيق غير صالح" + en: "Invalid format" + +PASSWORD_UPPERCASE: + ar: "يجب أن تحتوي كلمة المرور على حرف كبير واحد على الأقل" + en: "Password must contain at least one uppercase letter" + +PASSWORD_LOWERCASE: + ar: "يجب أن تحتوي كلمة المرور على حرف صغير واحد على الأقل" + en: "Password must contain at least one lowercase letter" + +PASSWORD_NUMBER: + ar: "يجب أن تحتوي كلمة المرور على رقم واحد على الأقل" + en: "Password must contain at least one number" + +EXTERNAL_API_CONFIG_NOT_FOUND: + ar: "إعداد API الخارجي غير موجود." + en: "External API configuration not found." + +EXTERNAL_API_CONFIG_EXISTS: + ar: "إعداد API الخارجي بهذا الاسم موجود بالفعل." + en: "External API configuration with this name already exists." +``` + +> **Note:** Trim the file to only the keys your application actually uses. Keep keys identical to `ApplicationErrors` constants for automatic lookup. + +--- + +### 3. Mark YAML File as Copy-to-Output (API `.csproj`) + +```xml + + + Always + + +``` + +--- + +### 4. Create `YamlLocalizationStore` (Infrastructure Layer) + +**File:** `Infrastructure/Localization/YamlLocalizationStore.cs` + +```csharp +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace [YourAppName].Infrastructure.Localization; + +public class YamlLocalizationStore +{ + private readonly Dictionary> _store = new(StringComparer.OrdinalIgnoreCase); + private readonly object _lock = new(); + + public YamlLocalizationStore() + { + var deserializer = new DeserializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .IgnoreUnmatchedProperties() + .Build(); + + foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) + { + try + { + var location = asm.Location; + if (string.IsNullOrEmpty(location)) continue; + var dir = Path.GetDirectoryName(location); + if (string.IsNullOrEmpty(dir)) continue; + + var resourcesPath = Path.Combine(dir, "Localization", "Resources.yaml"); + if (File.Exists(resourcesPath)) + { + var resourcesYaml = File.ReadAllText(resourcesPath); + var resourcesParsed = deserializer.Deserialize>>(resourcesYaml); + Merge(resourcesParsed); + } + } + catch + { + // Continue loading other assemblies on malformed files + } + } + } + + private void Merge(Dictionary>? parsed) + { + if (parsed == null) return; + lock (_lock) + { + foreach (var kv in parsed) + { + var key = kv.Key.Trim(); + if (!_store.TryGetValue(key, out var langs)) + { + langs = new Dictionary(StringComparer.OrdinalIgnoreCase); + _store[key] = langs; + } + + foreach (var lp in kv.Value) + { + var lang = lp.Key.Trim(); + var text = lp.Value ?? string.Empty; + langs[lang] = text; + } + } + } + } + + public bool TryGet(string key, out Dictionary? langs) + { + if (string.IsNullOrWhiteSpace(key)) + { + langs = null; + return false; + } + return _store.TryGetValue(key, out langs!); + } +} +``` + +--- + +### 5. Create `ILocalizationService` and `LocalizedMessage` (Application Layer) + +**File:** `Application/Localization/ILocalizationService.cs` + +```csharp +using System.Globalization; + +namespace [YourAppName].Application.Localization; + +public interface ILocalizationService +{ + string GetString(string key, CultureInfo? culture = null); + string GetStringOrDefault(string key, string defaultMessage, CultureInfo? culture = null); + LocalizedMessage GetLocalizedMessage(string key); +} +``` + +**File:** `Application/Localization/LocalizedMessage.cs` + +```csharp +namespace [YourAppName].Application.Localization; + +public class LocalizedMessage +{ + public string Ar { get; set; } = string.Empty; + public string En { get; set; } = string.Empty; +} +``` + +--- + +### 6. Create `LocalizationService` (Infrastructure Layer) + +**File:** `Infrastructure/Localization/LocalizationService.cs` + +```csharp +using System.Globalization; +using [YourAppName].Application.Interfaces; +using [YourAppName].Application.Localization; + +namespace [YourAppName].Infrastructure.Localization; + +public class LocalizationService : ILocalizationService +{ + private readonly YamlLocalizationStore _store; + private readonly IUserContext _userContext; + + public LocalizationService(YamlLocalizationStore store, IUserContext userContext) + { + _store = store ?? throw new ArgumentNullException(nameof(store)); + _userContext = userContext; + } + + public string GetString(string key, CultureInfo? culture = null) + { + culture = GetCultureInfo(culture); + var lang = culture.TwoLetterISOLanguageName; + + if (string.IsNullOrWhiteSpace(key)) return string.Empty; + if (_store.TryGet(key, out var language) && language != null) + { + if (language.TryGetValue(lang, out var v) && !string.IsNullOrEmpty(v)) return v; + if (language.TryGetValue("ar", out var ar) && !string.IsNullOrEmpty(ar)) return ar; + return language.Values.FirstOrDefault() ?? key; + } + + return key; + } + + public string GetStringOrDefault(string key, string defaultMessage, CultureInfo? culture = null) + { + var v = GetString(key, culture); + return string.IsNullOrEmpty(v) || v == key ? defaultMessage : v; + } + + public LocalizedMessage GetLocalizedMessage(string key) + { + var enCulture = new CultureInfo("en"); + var arCulture = new CultureInfo("ar"); + + var enMessage = GetString(key, enCulture); + var arMessage = GetString(key, arCulture); + + if (string.IsNullOrEmpty(enMessage) || enMessage == key) enMessage = key; + if (string.IsNullOrEmpty(arMessage) || arMessage == key) arMessage = key; + + return new LocalizedMessage { En = enMessage, Ar = arMessage }; + } + + private CultureInfo GetCultureInfo(CultureInfo? culture) + { + if (culture != null) return culture; + return _userContext?.Locale ?? new CultureInfo("ar-SA"); + } +} +``` + +> **Prerequisite:** `IUserContext` must expose a `Locale` property (type `CultureInfo`). If you do not have this abstraction, remove the `_userContext` dependency and default to `ar-SA` or read from `Thread.CurrentThread.CurrentCulture`. + +--- + +### 7. Register Services in DI (API Layer) + +**File:** `API/Extensions/WebApiServiceExtensions.cs` (or your own DI registration class) + +```csharp +using [YourAppName].Application.Localization; +using [YourAppName].Infrastructure.Localization; + +namespace [YourAppName].API.Extensions; + +public static class WebApiServiceExtensions +{ + public static IServiceCollection AddPlatformWebApi(this IServiceCollection services) + { + services.AddControllers(); + services.AddYamlLocalization(); + // ... other registrations + return services; + } + + private static IServiceCollection AddYamlLocalization(this IServiceCollection services) + { + services.AddSingleton(); + services.AddScoped(); + return services; + } +} +``` + +--- + +### 8. Integration with OpenAPI (API Layer) + +Add the `Accept-Language` header parameter to all operations so consumers know they can request localization. + +Inside your OpenAPI document transformer (see Scalar & Swagger plan): + +```csharp +options.AddOperationTransformer((operation, _, _) => +{ + var parameters = operation.Parameters?.ToList() ?? new List(); + parameters.Add(new OpenApiParameter + { + Name = "Accept-Language", + In = ParameterLocation.Header, + Description = "Language preference (ar, en). Default: ar", + Required = false, + Schema = new OpenApiSchema { Type = JsonSchemaType.String } + }); + operation.Parameters = parameters; + return Task.CompletedTask; +}); +``` + +--- + +## YAML Schema Reference + +```yaml +ERROR_KEY: + ar: "Arabic text" + en: "English text" +``` + +- Keys are case-insensitive at runtime. +- Language codes are lowercase two-letter ISO names (`ar`, `en`). +- If a requested language is missing, the system falls back to `ar`, then the first available language, then returns the key itself. + +--- + +## Integration Checklist + +| Step | Location | Lifetime | +|------|----------|----------| +| `YamlLocalizationStore` | Infrastructure | Singleton | +| `ILocalizationService` | Application (interface) / Infrastructure (impl) | Scoped | +| `Resources.yaml` | API / any assembly output | Content file | +| OpenAPI `Accept-Language` | API OpenAPI transformer | N/A | diff --git a/backend/docs/plans/read-write-architecture-implementation-plan.md b/backend/docs/plans/read-write-architecture-implementation-plan.md new file mode 100644 index 00000000..2dd715c2 --- /dev/null +++ b/backend/docs/plans/read-write-architecture-implementation-plan.md @@ -0,0 +1,497 @@ +# Read/Write Architecture — Implementation Plan + +## Problem Statement + +The current codebase has **three Clean Architecture violations** and **two performance issues**: + +### Clean Architecture Violations + +1. **Infrastructure knows Application DTOs** — `ContentReadService`, `IdentityReadService`, `CommunityReadService` (Infrastructure) import and construct Application-layer DTOs (`NewsDto`, `UserListItemDto`, etc.). DTO mapping is Application logic. +2. **Query handlers are empty pass-throughs** — e.g. `ListNewsQueryHandler` does nothing except call `_readService.ListNewsAsync()` and return the result. The handler has no reason to exist. +3. **God interfaces** — `IContentReadService` has **21 methods** spanning News, Events, Pages, Resources, HomepageSections, and Assets. `ICommunityReadService` has **10 methods**. `IIdentityReadService` has **8 methods**. These grow with every feature. + +### Performance Issues + +4. **No `AsNoTracking()` on reads** — All queries go through `ICceDbContext` (which returns tracked `IQueryable`). Read services never call `.AsNoTracking()`, so EF Core builds change-tracking snapshots for entities that are immediately mapped to DTOs and discarded. +5. **No server-side DTO projection** — All queries materialise full domain entities (`.ToListAsync()`), then map to DTOs in memory. This fetches ALL columns from SQL (including `ContentAr`, `ContentEn` — large text blobs) even for list endpoints that only need `Id`, `Title`, `Slug`. + +--- + +## Target Architecture + +``` +┌──────────────────────────────────────────────────────┐ +│ QUERIES (Reads) │ +│ │ +│ Endpoint → MediatR → QueryHandler → ICceDbContext │ +│ ▪ .AsNoTracking() │ +│ ▪ .WhereIf() filters │ +│ ▪ .Select() → DTO projection │ +│ ▪ .ToPagedResultAsync() │ +│ ▪ mapping lives HERE │ +│ │ +│ ICceDbContext stays in Application layer (IQueryable)│ +│ No ReadService. No DTO leak to Infrastructure. │ +└──────────────────────────────────────────────────────┘ + +┌──────────────────────────────────────────────────────┐ +│ COMMANDS (Writes) │ +│ │ +│ Endpoint → MediatR → CommandHandler → IXxxRepository│ +│ ▪ FluentValidation (pipeline) │ +│ ▪ Domain entity factory/method │ +│ ▪ repo.SaveAsync / UpdateAsync │ +│ │ +│ Specific repos per aggregate (no generic base). │ +│ RowVersion via small extension helper. │ +└──────────────────────────────────────────────────────┘ +``` + +--- + +## Phase 1 — Foundation (No Behaviour Changes) + +### Step 1.1 — Add `AsNoTracking()` to `ICceDbContext` queryables + +**Why:** Every query currently creates change-tracking snapshots that are never used. This is free perf. + +**File:** `src/CCE.Infrastructure/Persistence/CceDbContext.cs` + +Add a new explicit interface implementation block that wraps every `DbSet` in `.AsNoTracking()` for the `ICceDbContext` contract: + +```csharp +// ─── ICceDbContext (read-only queryables — no tracking) ─── +IQueryable ICceDbContext.News => Set().AsNoTracking(); +IQueryable ICceDbContext.Events => Set().AsNoTracking(); +IQueryable ICceDbContext.Resources => Set().AsNoTracking(); +IQueryable ICceDbContext.Pages => Set().AsNoTracking(); +// ... all other IQueryable properties +``` + +> **Important:** Write repositories must keep using the concrete `CceDbContext` (with tracked `DbSet`), NOT `ICceDbContext`. This is already the case — all repos inject `CceDbContext`, not `ICceDbContext`. + +**Impact:** Zero code changes in handlers or read services. All reads become no-tracking automatically. + +**Verify:** Run full test suite — `dotnet test CCE.sln`. All tests should pass because test mocks return in-memory queryables (untracked anyway). + +--- + +### Step 1.2 — Add `WhereIf` extension method + +**Why:** Removes repetitive `if (x != null) { query = query.Where(...); }` blocks. + +**File:** `src/CCE.Application/Common/Pagination/QueryableExtensions.cs` (new) + +```csharp +using System.Linq.Expressions; + +namespace CCE.Application.Common.Pagination; + +public static class QueryableExtensions +{ + /// + /// Conditionally appends a Where clause. When is false + /// the original query is returned unmodified. + /// + public static IQueryable WhereIf( + this IQueryable query, + bool condition, + Expression> predicate) + => condition ? query.Where(predicate) : query; +} +``` + +**Impact:** No behaviour change. Used in Phase 2. + +--- + +### Step 1.3 — Add `PagedResult.Map()` helper + +**Why:** After `ToPagedResultAsync()` materialises entities, we need to map items to DTOs while preserving pagination metadata. + +**File:** `src/CCE.Application/Common/Pagination/PagedResult.cs` (edit existing) + +```csharp +public sealed record PagedResult( + IReadOnlyList Items, + int Page, + int PageSize, + long Total) +{ + /// + /// Projects each item into a new shape while preserving pagination metadata. + /// + public PagedResult Map(Func selector) => + new(Items.Select(selector).ToList(), Page, PageSize, Total); +} +``` + +**Impact:** No behaviour change. Used in Phase 2. + +--- + +### Step 1.4 — Add `DbContextExtensions.SetExpectedRowVersion()` helper + +**Why:** Removes duplicated RowVersion boilerplate from the 4 repos that use it. + +**File:** `src/CCE.Infrastructure/Persistence/DbContextExtensions.cs` (new) + +```csharp +using Microsoft.EntityFrameworkCore; + +namespace CCE.Infrastructure.Persistence; + +internal static class DbContextExtensions +{ + /// + /// Sets the expected RowVersion for optimistic concurrency on a tracked entity. + /// + public static void SetExpectedRowVersion( + this DbContext db, T entity, byte[] expectedRowVersion) + where T : class + { + db.Entry(entity).OriginalValues["RowVersion"] = expectedRowVersion; + } +} +``` + +**Impact:** Optional. Simplifies `NewsRepository`, `ResourceRepository`, `EventRepository`, `PageRepository`. + +--- + +### Step 1.5 — Add server-side projection `ToPagedResultAsync()` overload + +**Why:** The current `ToPagedResultAsync()` always materialises full entities. We need an overload that accepts a `Select` expression so SQL only fetches the columns needed for the DTO. + +**File:** `src/CCE.Application/Common/Pagination/PagedResult.cs` (edit existing, add to `PaginationExtensions`) + +```csharp +/// +/// Paginates and projects in a single query — SQL only fetches DTO columns. +/// Use for list endpoints where you don't need the full entity. +/// +public static async Task> ToPagedResultAsync( + this IQueryable query, + Expression> projection, + int page, int pageSize, CancellationToken ct) +{ + page = Math.Max(1, page); + pageSize = Math.Clamp(pageSize, 1, MaxPageSize); + + var total = query is IAsyncEnumerable + ? await query.LongCountAsync(ct).ConfigureAwait(false) + : query.LongCount(); + + var projected = query.Select(projection); + var items = projected is IAsyncEnumerable + ? await projected.Skip((page - 1) * pageSize).Take(pageSize) + .ToListAsync(ct).ConfigureAwait(false) + : projected.Skip((page - 1) * pageSize).Take(pageSize).ToList(); + + return new PagedResult(items, page, pageSize, total); +} +``` + +**Impact:** No behaviour change. Used in Phase 2 for performance-critical list endpoints. + +--- + +## Phase 2 — Migrate Query Handlers (Per-Domain Module) + +Migrate one domain at a time. Each domain follows the same 4-step recipe. + +### Recipe: Migrating a Query Handler + +For each query handler that currently delegates to a ReadService: + +1. **Inject `ICceDbContext`** instead of `IXxxReadService` +2. **Move the query + filter logic** from ReadService into the handler +3. **Move the DTO mapping** from ReadService into the handler (or use `.Select()` projection) +4. **Use `WhereIf`** for conditional filters +5. **Delete the ReadService method** once all callers are migrated + +### Before (current): +```csharp +// Application/Content/Queries/ListNews/ListNewsQueryHandler.cs +public sealed class ListNewsQueryHandler : IRequestHandler> +{ + private readonly IContentReadService _readService; + + public ListNewsQueryHandler(IContentReadService readService) + => _readService = readService; + + public async Task> Handle(ListNewsQuery request, CancellationToken ct) + => await _readService.ListNewsAsync( + request.Search, request.IsFeatured, request.IsPublished, + request.Page, request.PageSize, ct).ConfigureAwait(false); +} +``` + +### After (target): +```csharp +// Application/Content/Queries/ListNews/ListNewsQueryHandler.cs +public sealed class ListNewsQueryHandler : IRequestHandler> +{ + private readonly ICceDbContext _db; + + public ListNewsQueryHandler(ICceDbContext db) => _db = db; + + public async Task> Handle(ListNewsQuery request, CancellationToken ct) + { + var query = _db.News + .WhereIf(!string.IsNullOrWhiteSpace(request.Search), + n => n.TitleAr.Contains(request.Search!) || + n.TitleEn.Contains(request.Search!) || + n.Slug.Contains(request.Search!)) + .WhereIf(request.IsPublished == true, n => n.PublishedOn != null) + .WhereIf(request.IsPublished == false, n => n.PublishedOn == null) + .WhereIf(request.IsFeatured.HasValue, n => n.IsFeatured == request.IsFeatured!.Value) + .OrderByDescending(n => n.PublishedOn ?? DateTimeOffset.MinValue) + .ThenByDescending(n => n.Id); + + var result = await query.ToPagedResultAsync(page: request.Page, + pageSize: request.PageSize, ct).ConfigureAwait(false); + return result.Map(MapToDto); + } + + internal static NewsDto MapToDto(News n) => new( + n.Id, n.TitleAr, n.TitleEn, n.ContentAr, n.ContentEn, + n.Slug, n.AuthorId, n.FeaturedImageUrl, + n.PublishedOn, n.IsFeatured, n.IsPublished, + Convert.ToBase64String(n.RowVersion)); +} +``` + +--- + +### 2.1 — Content Domain (21 methods → 0) + +| # | Query Handler | ReadService Method to Absorb | Priority | +|---|---|---|---| +| 1 | `ListNewsQueryHandler` | `ListNewsAsync` | High | +| 2 | `GetNewsByIdQueryHandler` | `GetNewsByIdAsync` | High | +| 3 | `ListEventsQueryHandler` | `ListEventsAsync` | High | +| 4 | `GetEventByIdQueryHandler` | `GetEventByIdAsync` | High | +| 5 | `ListResourcesQueryHandler` | `ListResourcesAsync` | High | +| 6 | `GetResourceByIdQueryHandler` | `GetResourceByIdAsync` | High | +| 7 | `ListPagesQueryHandler` | `ListPagesAsync` | High | +| 8 | `GetPageByIdQueryHandler` | `GetPageByIdAsync` | High | +| 9 | `ListResourceCategoriesQueryHandler` | `ListResourceCategoriesAsync` | Medium | +| 10 | `GetResourceCategoryByIdQueryHandler` | `GetResourceCategoryByIdAsync` | Medium | +| 11 | `ListHomepageSectionsQueryHandler` | `ListHomepageSectionsAsync` | Medium | +| 12 | `GetAssetByIdQueryHandler` | `GetAssetByIdAsync` | Medium | +| 13 | `ListPublicNewsQueryHandler` | `ListPublicNewsAsync` | High | +| 14 | `GetPublicNewsBySlugQueryHandler` | `GetPublicNewsBySlugAsync` | High | +| 15 | `ListPublicEventsQueryHandler` | `ListPublicEventsAsync` | High | +| 16 | `GetPublicEventByIdQueryHandler` | `GetPublicEventByIdAsync` | High | +| 17 | `ListPublicResourcesQueryHandler` | `ListPublicResourcesAsync` | High | +| 18 | `GetPublicResourceByIdQueryHandler` | `GetPublicResourceByIdAsync` | High | +| 19 | `ListPublicResourceCategoriesQueryHandler` | `ListPublicResourceCategoriesAsync` | Medium | +| 20 | `ListPublicHomepageSectionsQueryHandler` | `ListPublicHomepageSectionsAsync` | Medium | +| 21 | `GetPublicPageBySlugQueryHandler` | `GetPublicPageBySlugAsync` | Medium | + +**After all 21 are migrated:** +- Delete `IContentReadService.cs` from Application +- Delete `ContentReadService.cs` from Infrastructure +- Remove registration from `DependencyInjection.cs` + +--- + +### 2.2 — Identity Domain (8 methods → 0) + +| # | Query Handler | ReadService Method | +|---|---|---| +| 1 | `ListUsersQueryHandler` | `ListUsersAsync` | +| 2 | `GetUserByIdQueryHandler` | `GetUserByIdAsync` | +| 3 | `ListExpertProfilesQueryHandler` | `ListExpertProfilesAsync` | +| 4 | `ListExpertRequestsQueryHandler` | `ListExpertRequestsAsync` | +| 5 | `ListStateRepAssignmentsQueryHandler` | `ListStateRepAssignmentsAsync` | +| 6 | `GetExpertStatusQueryHandler` | `GetExpertStatusAsync` | +| 7 | Internal callers of `GetUserNamesAsync` | `GetUserNamesAsync` | +| 8 | Internal callers of `UsersExistAsync` | `UsersExistAsync` | + +> **Note:** `GetUserNamesAsync` and `UsersExistAsync` may be called from Command handlers (for validation). If so, keep them as a thin `IUserLookupService` interface with just those 2 methods — that's a legitimate cross-cutting lookup, not a God interface. + +**After migration:** +- Delete `IIdentityReadService.cs` from Application +- Delete `IdentityReadService.cs` from Infrastructure +- Optionally create `IUserLookupService` with only `GetUserNamesAsync` + `UsersExistAsync` + +--- + +### 2.3 — Community Domain (10 methods → 0) + +| # | Query Handler | ReadService Method | +|---|---|---| +| 1 | `ListTopicsQueryHandler` | `ListTopicsAsync` | +| 2 | `GetTopicByIdQueryHandler` | `GetTopicByIdAsync` | +| 3 | `ListAdminPostsQueryHandler` | `ListAdminPostsAsync` | +| 4 | `ListPublicTopicsQueryHandler` | `ListPublicTopicsAsync` | +| 5 | `GetPublicTopicBySlugQueryHandler` | `GetPublicTopicBySlugAsync` | +| 6 | `ListPublicPostsInTopicQueryHandler` | `ListPublicPostsInTopicAsync` | +| 7 | `ListPublicPostRepliesQueryHandler` | `ListPublicPostRepliesAsync` | +| 8 | `GetPublicPostByIdQueryHandler` | `GetPublicPostByIdAsync` | +| 9 | `GetMyFollowsQueryHandler` | `GetMyFollowsAsync` | +| 10 | Any other callers | — | + +**After migration:** +- Delete `ICommunityReadService.cs` from Application +- Delete `CommunityReadService.cs` from Infrastructure + +--- + +## Phase 3 — Performance Optimisations + +After Phase 2, all reads flow through handlers with `ICceDbContext`. Now optimise hot paths. + +### Step 3.1 — Server-Side DTO Projection for List Endpoints + +For list endpoints that return summaries (not full content), use `.Select()` to project at the SQL level: + +```csharp +// BEFORE — fetches ALL columns including ContentAr, ContentEn (large text) +var result = await query.ToPagedResultAsync(request.Page, request.PageSize, ct); +return result.Map(MapToDto); + +// AFTER — SQL only fetches the 5 columns needed for the list DTO +var result = await query.ToPagedResultAsync( + n => new NewsListItemDto(n.Id, n.TitleAr, n.TitleEn, n.Slug, n.PublishedOn, n.IsFeatured), + request.Page, request.PageSize, ct); +``` + +**Apply to these high-traffic list endpoints first:** +- `ListPublicNewsAsync` → `PublicNewsDto` (does NOT need `ContentAr`/`ContentEn`) +- `ListPublicEventsAsync` → `PublicEventDto` (does NOT need full description) +- `ListPublicResourcesAsync` → `PublicResourceDto` (does NOT need description blobs) +- `ListUsersAsync` → `UserListItemDto` (does NOT need full profile) + +**By-Id endpoints keep full entity load** — they need all columns for detail views. + +### Step 3.2 — Split List DTOs from Detail DTOs + +Where a list endpoint and a detail endpoint currently share the same DTO, split them: + +| Endpoint Type | DTO | Columns | +|---|---|---| +| `GET /news` (list) | `NewsListItemDto` | Id, TitleAr, TitleEn, Slug, PublishedOn, IsFeatured | +| `GET /news/{id}` (detail) | `NewsDetailDto` | All columns including ContentAr, ContentEn | + +This enables server-side projection for lists while keeping full data for detail views. + +--- + +## Phase 4 — Cleanup & DI + +### Step 4.1 — Remove Dead ReadService Registrations + +**File:** `src/CCE.Infrastructure/DependencyInjection.cs` + +Remove these lines: +```csharp +// DELETE these +services.AddScoped(); +services.AddScoped(); +services.AddScoped(); +``` + +### Step 4.2 — Delete Dead Files + +``` +DELETE src/CCE.Application/Content/IContentReadService.cs +DELETE src/CCE.Application/Identity/IIdentityReadService.cs +DELETE src/CCE.Application/Community/ICommunityReadService.cs +DELETE src/CCE.Infrastructure/Content/ContentReadService.cs +DELETE src/CCE.Infrastructure/Identity/IdentityReadService.cs +DELETE src/CCE.Infrastructure/Community/CommunityReadService.cs +``` + +### Step 4.3 — Update Tests + +Existing tests mock `IXxxReadService`. After migration: +- Query handler tests mock `ICceDbContext` (return in-memory `IQueryable`) — this pattern already exists in `ListMyNotificationsQueryHandlerTests.cs` and `GetMyUnreadCountQueryHandlerTests.cs`. +- Pattern: `db.News.Returns(testList.AsQueryable())` + +--- + +## Phase 5 — Write Repos (Simplify, Don't Change Pattern) + +Write repos stay as-is (specific interfaces, specific implementations). Only small cleanup: + +### Step 5.1 — Use `SetExpectedRowVersion` helper in RowVersion repos + +Apply to: `NewsRepository`, `ResourceRepository`, `EventRepository`, `PageRepository` + +```csharp +// Before +public async Task UpdateAsync(News news, byte[] expectedRowVersion, CancellationToken ct) +{ + var entry = _db.Entry(news); + entry.OriginalValues[nameof(News.RowVersion)] = expectedRowVersion; + await _db.SaveChangesAsync(ct).ConfigureAwait(false); +} + +// After +public async Task UpdateAsync(News news, byte[] expectedRowVersion, CancellationToken ct) +{ + _db.SetExpectedRowVersion(news, expectedRowVersion); + await _db.SaveChangesAsync(ct).ConfigureAwait(false); +} +``` + +--- + +## Execution Order & Risk Assessment + +| Phase | Effort | Risk | Can Ship Independently | +|---|---|---|---| +| **Phase 1** — Foundation helpers | 1 day | None — additive only | ✅ Yes | +| **Phase 2.1** — Content queries | 2 days | Low — 1:1 logic move | ✅ Yes | +| **Phase 2.2** — Identity queries | 1 day | Low | ✅ Yes | +| **Phase 2.3** — Community queries | 1 day | Low | ✅ Yes | +| **Phase 3** — DTO projections | 1 day | Medium — new DTOs, endpoint contract may change | ✅ Yes | +| **Phase 4** — Cleanup | 0.5 day | None — only deleting dead code | ✅ Yes (after Phase 2) | +| **Phase 5** — Write repo cleanup | 0.5 day | None — internal refactor | ✅ Yes | + +**Total:** ~7 days + +--- + +## Validation Checklist (Per Handler Migration) + +- [ ] Handler injects `ICceDbContext`, NOT a ReadService +- [ ] `ICceDbContext` queryables return `.AsNoTracking()` data (Phase 1.1) +- [ ] Filters use `WhereIf` for clean conditional composition +- [ ] DTO mapping is in the handler (Application layer), NOT Infrastructure +- [ ] List endpoints use `.Select()` projection where possible (Phase 3) +- [ ] `dotnet build CCE.sln` — zero warnings +- [ ] `dotnet test CCE.sln` — all green +- [ ] Swagger response shape unchanged (no API breaking changes) + +--- + +## Files Changed Summary + +### New Files +| File | Layer | Purpose | +|---|---|---| +| `Application/Common/Pagination/QueryableExtensions.cs` | Application | `WhereIf` extension | +| `Infrastructure/Persistence/DbContextExtensions.cs` | Infrastructure | `SetExpectedRowVersion` helper | + +### Modified Files +| File | Change | +|---|---| +| `Application/Common/Pagination/PagedResult.cs` | Add `Map()` method + projection `ToPagedResultAsync` overload | +| `Infrastructure/Persistence/CceDbContext.cs` | Explicit `ICceDbContext` impl with `AsNoTracking()` | +| `Infrastructure/DependencyInjection.cs` | Remove 3 ReadService registrations | +| All 39 query handler files | Inject `ICceDbContext`, own query logic + mapping | +| 4 write repo files | Use `SetExpectedRowVersion` helper | + +### Deleted Files +| File | Reason | +|---|---| +| `Application/Content/IContentReadService.cs` | God interface eliminated | +| `Application/Identity/IIdentityReadService.cs` | God interface eliminated | +| `Application/Community/ICommunityReadService.cs` | God interface eliminated | +| `Infrastructure/Content/ContentReadService.cs` | Logic moved to handlers | +| `Infrastructure/Identity/IdentityReadService.cs` | Logic moved to handlers | +| `Infrastructure/Community/CommunityReadService.cs` | Logic moved to handlers | diff --git a/backend/docs/plans/refit-implementation-plan.md b/backend/docs/plans/refit-implementation-plan.md new file mode 100644 index 00000000..aaef7cc3 --- /dev/null +++ b/backend/docs/plans/refit-implementation-plan.md @@ -0,0 +1,1201 @@ +# Refit HTTP Client Implementation Plan + +## How to Adopt in Another Solution + +1. Replace all `[YourAppName]` occurrences with your root namespace. +2. Install the required NuGet packages (`Refit`, `Refit.HttpClientFactory`, `Microsoft.Extensions.Http.Resilience`). +3. Create the `ExternalApiClientAttribute` and apply it to your Refit interfaces. +4. Implement `IExternalApiConfigurationProvider` or use the database-backed provider included here. +5. Register `AddExternalApiServices()` in your Infrastructure DI module. +6. Seed at least one `ExternalApiConfiguration` row in your database (or implement a static config provider). +7. Inject the generated Refit client interfaces into handlers/controllers. + +--- + +## Overview + +This plan implements a **dynamic, database-driven Refit HTTP client factory** that: +- Discovers Refit client interfaces at startup via reflection and a custom `[ExternalApiClient]` attribute. +- Reads base URLs, timeouts, and auth settings from a runtime configuration provider. +- Supports multiple auth schemes: `None`, `ApiKey`, `Bearer`, `Basic`, `OAuth2`. +- Adds standard resilience (retry, timeout, circuit breaker) via `Microsoft.Extensions.Http.Resilience`. +- Allows hot-reload of external API configs from the database without restarting the app. + +**Packages required:** +- `Refit` (v8.0.0+) +- `Refit.HttpClientFactory` +- `Microsoft.Extensions.Http.Resilience` + +--- + +### 1. Add NuGet Packages + +**File:** `Directory.Packages.props` (or `.csproj`) + +```xml + + + +``` + +**File:** `Infrastructure.csproj` and `Application.csproj` + +```xml + + + + + +``` + +> **Note:** `Refit` is needed in the Application layer for the interface attributes (`[Get]`, `[Post]`, `[Query]`, etc.). + +--- + +### 2. Create `ExternalApiClientAttribute` (Application Layer) + +**File:** `Application/ExternalApis/ExternalApiClientAttribute.cs` + +```csharp +namespace [YourAppName].Application.ExternalApis; + +[AttributeUsage(AttributeTargets.Interface, AllowMultiple = false, Inherited = false)] +public class ExternalApiClientAttribute : Attribute +{ + public string ApiName { get; } + + public ExternalApiClientAttribute(string apiName) + { + ApiName = apiName; + } +} +``` + +> **Purpose:** Marks a Refit interface so the DI scanner knows which API name to look up in the configuration provider. + +--- + +### 3. Create Configuration DTOs (Application Layer) + +**File:** `Application/ExternalApis/DTOs/ExternalApiConfig.cs` + +```csharp +namespace [YourAppName].Application.ExternalApis.DTOs; + +public class ExternalApiConfig +{ + public string BaseUrl { get; set; } = string.Empty; + public ExternalApiAuthConfig Auth { get; set; } = new(); + public int TimeoutSeconds { get; set; } = 30; +} + +public class ExternalApiAuthConfig +{ + public ExternalApiAuthType Type { get; set; } = ExternalApiAuthType.None; + + // ApiKey settings + public string KeyName { get; set; } = string.Empty; + public string KeyLocation { get; set; } = "Header"; + public string Value { get; set; } = string.Empty; + + // Bearer token settings + public string Token { get; set; } = string.Empty; + + // OAuth2 settings + public string TokenUrl { get; set; } = string.Empty; + public string ClientId { get; set; } = string.Empty; + public string ClientSecret { get; set; } = string.Empty; + public string Scope { get; set; } = string.Empty; + public bool AutoRefresh { get; set; } = true; +} + +public enum ExternalApiAuthType +{ + None, + ApiKey, + Bearer, + Basic, + OAuth2 +} +``` + +--- + +### 4. Create `IExternalApiConfigurationProvider` (Application Layer) + +**File:** `Application/Interfaces/IExternalApiConfigurationProvider.cs` + +```csharp +using [YourAppName].Application.ExternalApis.DTOs; + +namespace [YourAppName].Application.Interfaces; + +public interface IExternalApiConfigurationProvider +{ + ExternalApiConfig? GetConfig(string apiName); + IReadOnlyList GetAllConfigs(); + Task ReloadAsync(CancellationToken ct = default); +} +``` + +> **Note:** The provider is registered as a **Singleton** so Refit clients can resolve it inside `ConfigureHttpClient` and `AddHttpMessageHandler`. + +--- + +### 5. Create `ExternalApiConfiguration` Entity (Domain Layer) + +**File:** `Domain/Entities/ExternalApis/ExternalApiConfiguration.cs` + +```csharp +using [YourAppName].Domain.Entities; + +namespace [YourAppName].Domain.Entities.ExternalApis; + +public class ExternalApiConfiguration : BaseEntity +{ + public string Name { get; private set; } = string.Empty; + public string BaseUrl { get; private set; } = string.Empty; + public int TimeoutSeconds { get; private set; } = 30; + public bool IsEnabled { get; private set; } = true; + + public string AuthType { get; private set; } = "None"; + public string? AuthKeyName { get; private set; } + public string? AuthKeyLocation { get; private set; } + public string? AuthValue { get; private set; } + public string? AuthToken { get; private set; } + public string? AuthTokenUrl { get; private set; } + public string? AuthClientId { get; private set; } + public string? AuthClientSecret { get; private set; } + public string? AuthScope { get; private set; } + public bool AuthAutoRefresh { get; private set; } + + public static ExternalApiConfiguration Create( + string name, + string baseUrl, + int timeoutSeconds, + string authType, + string? authKeyName = null, + string? authKeyLocation = null, + string? authValue = null, + string? authToken = null, + string? authTokenUrl = null, + string? authClientId = null, + string? authClientSecret = null, + string? authScope = null, + bool authAutoRefresh = true) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Name is required", nameof(name)); + if (string.IsNullOrWhiteSpace(baseUrl)) + throw new ArgumentException("Base URL is required", nameof(baseUrl)); + if (timeoutSeconds <= 0) + throw new ArgumentException("Timeout must be positive", nameof(timeoutSeconds)); + + return new ExternalApiConfiguration + { + Id = Guid.NewGuid(), + Name = name.Trim(), + BaseUrl = baseUrl.Trim(), + TimeoutSeconds = timeoutSeconds, + IsEnabled = true, + AuthType = authType, + AuthKeyName = authKeyName?.Trim(), + AuthKeyLocation = authKeyLocation?.Trim(), + AuthValue = authValue, + AuthToken = authToken, + AuthTokenUrl = authTokenUrl?.Trim(), + AuthClientId = authClientId, + AuthClientSecret = authClientSecret, + AuthScope = authScope?.Trim(), + AuthAutoRefresh = authAutoRefresh, + CreatedAt = DateTime.UtcNow + }; + } + + public void UpdateConfig(string baseUrl, int timeoutSeconds) + { + if (string.IsNullOrWhiteSpace(baseUrl)) + throw new ArgumentException("Base URL is required", nameof(baseUrl)); + if (timeoutSeconds <= 0) + throw new ArgumentException("Timeout must be positive", nameof(timeoutSeconds)); + + BaseUrl = baseUrl.Trim(); + TimeoutSeconds = timeoutSeconds; + MarkUpdated(); + } + + public void UpdateAuth( + string authType, + string? authKeyName = null, + string? authKeyLocation = null, + string? authValue = null, + string? authToken = null, + string? authTokenUrl = null, + string? authClientId = null, + string? authClientSecret = null, + string? authScope = null, + bool authAutoRefresh = true) + { + AuthType = authType; + AuthKeyName = authKeyName?.Trim(); + AuthKeyLocation = authKeyLocation?.Trim(); + AuthValue = authValue; + AuthToken = authToken; + AuthTokenUrl = authTokenUrl?.Trim(); + AuthClientId = authClientId; + AuthClientSecret = authClientSecret; + AuthScope = authScope?.Trim(); + AuthAutoRefresh = authAutoRefresh; + MarkUpdated(); + } + + public void Enable() + { + if (!IsEnabled) + { + IsEnabled = true; + MarkUpdated(); + } + } + + public void Disable() + { + if (IsEnabled) + { + IsEnabled = false; + MarkUpdated(); + } + } +} +``` + +--- + +### 6. Create `DatabaseExternalApiProvider` (Infrastructure Layer) + +**File:** `Infrastructure/ExternalApis/Providers/DatabaseExternalApiProvider.cs` + +```csharp +using System.Collections.Concurrent; +using [YourAppName].Application.ExternalApis.DTOs; +using [YourAppName].Application.Interfaces; +using [YourAppName].Domain.Entities.ExternalApis; +using [YourAppName].Domain.Interfaces; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace [YourAppName].Infrastructure.ExternalApis.Providers; + +public class DatabaseExternalApiProvider : IExternalApiConfigurationProvider +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + private ConcurrentDictionary _configs = new(StringComparer.OrdinalIgnoreCase); + private bool _loaded; + + public DatabaseExternalApiProvider(IServiceScopeFactory scopeFactory, ILogger logger) + { + _scopeFactory = scopeFactory; + _logger = logger; + } + + public ExternalApiConfig? GetConfig(string apiName) + { + if (!_loaded) + { + _logger.LogWarning("External API configs not yet loaded, requesting sync load"); + LoadSync(); + } + + _configs.TryGetValue(apiName, out var config); + return config; + } + + public IReadOnlyList GetAllConfigs() + { + if (!_loaded) + LoadSync(); + + return _configs.Values.ToList().AsReadOnly(); + } + + public async Task ReloadAsync(CancellationToken ct = default) + { + using var scope = _scopeFactory.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService>(); + var secretProtector = scope.ServiceProvider.GetRequiredService(); + + var entities = await repository.Query(e => e.IsEnabled && !e.IsDeleted, true).ToListAsync(ct); + + var newConfigs = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var entity in entities) + { + try + { + newConfigs[entity.Name] = MapToConfig(entity, secretProtector); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to map config for {ApiName}", entity.Name); + } + } + + _configs = newConfigs; + _loaded = true; + _logger.LogInformation("Reloaded {Count} external API configurations from database", _configs.Count); + } + + public void LoadSync() + { + try + { + ReloadAsync().GetAwaiter().GetResult(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to load external API configs synchronously"); + _loaded = true; + } + } + + private static ExternalApiConfig MapToConfig(ExternalApiConfiguration entity, ISecretProtector secretProtector) + { + var config = new ExternalApiConfig + { + BaseUrl = entity.BaseUrl, + TimeoutSeconds = entity.TimeoutSeconds, + Auth = new ExternalApiAuthConfig + { + Type = Enum.TryParse(entity.AuthType, out var authType) ? authType : ExternalApiAuthType.None, + KeyName = entity.AuthKeyName ?? string.Empty, + KeyLocation = entity.AuthKeyLocation ?? "Header", + Value = Decrypt(entity.AuthValue, secretProtector), + Token = Decrypt(entity.AuthToken, secretProtector), + TokenUrl = entity.AuthTokenUrl ?? string.Empty, + ClientId = Decrypt(entity.AuthClientId, secretProtector), + ClientSecret = Decrypt(entity.AuthClientSecret, secretProtector), + Scope = entity.AuthScope ?? string.Empty, + AutoRefresh = entity.AuthAutoRefresh + } + }; + + return config; + } + + private static string Decrypt(string? encrypted, ISecretProtector secretProtector) + { + if (string.IsNullOrEmpty(encrypted)) + return string.Empty; + + try + { + return secretProtector.Unprotect(encrypted); + } + catch + { + return string.Empty; + } + } +} +``` + +> **Note:** `ISecretProtector` is an abstraction over ASP.NET Core Data Protection. Replace it with your own secret handling or remove `Decrypt` calls if you store secrets in plaintext (not recommended). + +--- + +### 7. Create Authentication Handlers (Infrastructure Layer) + +#### 7a. No-Op Handler (fallback) + +**File:** `Infrastructure/ExternalApis/Authentication/NoOpDelegatingHandler.cs` + +```csharp +namespace [YourAppName].Infrastructure.ExternalApis.Authentication; + +public class NoOpDelegatingHandler : DelegatingHandler +{ +} +``` + +#### 7b. API Key Handler + +**File:** `Infrastructure/ExternalApis/Authentication/ApiKeyAuthHandler.cs` + +```csharp +using System.Net.Http.Headers; +using [YourAppName].Application.ExternalApis.DTOs; + +namespace [YourAppName].Infrastructure.ExternalApis.Authentication; + +public class ApiKeyAuthHandler : DelegatingHandler +{ + private readonly string _keyName; + private readonly string _keyValue; + private readonly string _keyLocation; + + public ApiKeyAuthHandler(string keyName, string keyValue, string keyLocation) + { + _keyName = keyName; + _keyValue = keyValue; + _keyLocation = keyLocation; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (_keyLocation.Equals("Query", StringComparison.OrdinalIgnoreCase)) + { + var uriBuilder = new UriBuilder(request.RequestUri!); + var query = System.Web.HttpUtility.ParseQueryString(uriBuilder.Query); + query[_keyName] = _keyValue; + uriBuilder.Query = query.ToString(); + request.RequestUri = uriBuilder.Uri; + } + else + { + request.Headers.TryAddWithoutValidation(_keyName, _keyValue); + } + + return base.SendAsync(request, cancellationToken); + } +} + +public static class ApiKeyAuthHandlerFactory +{ + public static DelegatingHandler Create(ExternalApiAuthConfig authConfig) + { + return new ApiKeyAuthHandler( + authConfig.KeyName, + authConfig.Value, + authConfig.KeyLocation); + } +} +``` + +#### 7c. Bearer Token Handler + +**File:** `Infrastructure/ExternalApis/Authentication/BearerTokenAuthHandler.cs` + +```csharp +using System.Net.Http.Headers; + +namespace [YourAppName].Infrastructure.ExternalApis.Authentication; + +public class BearerTokenAuthHandler : DelegatingHandler +{ + private readonly string _token; + + public BearerTokenAuthHandler(string token) + { + _token = token; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token); + return base.SendAsync(request, cancellationToken); + } +} + +public static class BearerTokenAuthHandlerFactory +{ + public static DelegatingHandler Create(string token) + { + return new BearerTokenAuthHandler(token); + } +} +``` + +#### 7d. Basic Auth Handler + +**File:** `Infrastructure/ExternalApis/Authentication/BasicAuthHandler.cs` + +```csharp +using System.Net.Http.Headers; +using System.Text; + +namespace [YourAppName].Infrastructure.ExternalApis.Authentication; + +public class BasicAuthHandler : DelegatingHandler +{ + private readonly string _username; + private readonly string _password; + + public BasicAuthHandler(string username, string password) + { + _username = username; + _password = password; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{_username}:{_password}")); + request.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials); + return base.SendAsync(request, cancellationToken); + } +} + +public static class BasicAuthHandlerFactory +{ + public static DelegatingHandler Create(string username, string password) + { + return new BasicAuthHandler(username, password); + } +} +``` + +#### 7e. OAuth2 Client Credentials Handler + +**File:** `Infrastructure/ExternalApis/Authentication/OAuth2ClientCredentialsHandler.cs` + +```csharp +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace [YourAppName].Infrastructure.ExternalApis.Authentication; + +public class OAuth2ClientCredentialsHandler : DelegatingHandler +{ + private readonly string _tokenUrl; + private readonly string _clientId; + private readonly string _clientSecret; + private readonly string _scope; + private readonly bool _autoRefresh; + private readonly ILogger _logger; + private string? _accessToken; + private DateTime _tokenExpiry = DateTime.MinValue; + + public OAuth2ClientCredentialsHandler( + string tokenUrl, + string clientId, + string clientSecret, + string scope, + bool autoRefresh, + ILogger logger) + { + _tokenUrl = tokenUrl; + _clientId = clientId; + _clientSecret = clientSecret; + _scope = scope; + _autoRefresh = autoRefresh; + _logger = logger; + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(_accessToken) || (_autoRefresh && DateTime.UtcNow >= _tokenExpiry.AddSeconds(-60))) + { + await AcquireTokenAsync(cancellationToken); + } + + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken); + return await base.SendAsync(request, cancellationToken); + } + + private async Task AcquireTokenAsync(CancellationToken cancellationToken) + { + try + { + var httpClient = new HttpClient(); + var requestContent = new Dictionary + { + ["grant_type"] = "client_credentials", + ["client_id"] = _clientId, + ["client_secret"] = _clientSecret + }; + + if (!string.IsNullOrEmpty(_scope)) + { + requestContent["scope"] = _scope; + } + + var tokenRequest = new HttpRequestMessage(HttpMethod.Post, _tokenUrl) + { + Content = new FormUrlEncodedContent(requestContent) + }; + + var response = await httpClient.SendAsync(tokenRequest, cancellationToken); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(cancellationToken); + var tokenResponse = JsonSerializer.Deserialize(json, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + if (tokenResponse != null) + { + _accessToken = tokenResponse.AccessToken; + _tokenExpiry = DateTime.UtcNow.AddSeconds(tokenResponse.ExpiresIn - 60); + _logger.LogDebug("OAuth2 token acquired, expires at {Expiry}", _tokenExpiry); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to acquire OAuth2 token"); + throw; + } + } +} + +public class OAuthTokenResponse +{ + public string AccessToken { get; set; } = string.Empty; + public string TokenType { get; set; } = "Bearer"; + public int ExpiresIn { get; set; } = 3600; + public string? Scope { get; set; } +} + +public static class OAuth2ClientCredentialsHandlerFactory +{ + public static DelegatingHandler Create( + string tokenUrl, + string clientId, + string clientSecret, + string scope, + bool autoRefresh, + ILoggerFactory loggerFactory) + { + return new OAuth2ClientCredentialsHandler( + tokenUrl, + clientId, + clientSecret, + scope, + autoRefresh, + loggerFactory.CreateLogger()); + } +} +``` + +#### 7f. Auth Handler Factory + +**File:** `Infrastructure/ExternalApis/Authentication/ExternalApiAuthHandlerFactory.cs` + +```csharp +using [YourAppName].Application.ExternalApis.DTOs; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace [YourAppName].Infrastructure.ExternalApis.Authentication; + +public static class ExternalApiAuthHandlerFactory +{ + public static DelegatingHandler? Create(ExternalApiAuthConfig authConfig, ILoggerFactory? loggerFactory = null) + { + if (authConfig == null || authConfig.Type == ExternalApiAuthType.None) + { + return null; + } + + var logger = loggerFactory ?? NullLoggerFactory.Instance; + + return authConfig.Type switch + { + ExternalApiAuthType.ApiKey => ApiKeyAuthHandlerFactory.Create(authConfig), + ExternalApiAuthType.Bearer => BearerTokenAuthHandlerFactory.Create(authConfig.Token), + ExternalApiAuthType.Basic => BasicAuthHandlerFactory.Create(authConfig.ClientId, authConfig.ClientSecret), + ExternalApiAuthType.OAuth2 => OAuth2ClientCredentialsHandlerFactory.Create( + authConfig.TokenUrl, + authConfig.ClientId, + authConfig.ClientSecret, + authConfig.Scope, + authConfig.AutoRefresh, + logger), + _ => null + }; + } +} +``` + +--- + +### 8. Create DI Registration with Reflection Discovery (Infrastructure Layer) + +**File:** `Infrastructure/ExternalApis/ExternalApiServiceCollectionExtensions.cs` + +```csharp +using System.Reflection; +using [YourAppName].Application.ExternalApis; +using [YourAppName].Application.ExternalApis.DTOs; +using [YourAppName].Application.Interfaces; +using [YourAppName].Infrastructure.ExternalApis.Authentication; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Http.Resilience; +using Microsoft.Extensions.Logging; +using Refit; + +namespace [YourAppName].Infrastructure.ExternalApis; + +public static class ExternalApiServiceCollectionExtensions +{ + public static IServiceCollection AddExternalRefitClient( + this IServiceCollection services, + string apiName, + ILoggerFactory? loggerFactory = null) + where TClient : class + { + var refitSettings = new RefitSettings + { + ContentSerializer = new SystemTextJsonContentSerializer( + new System.Text.Json.JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }) + }; + + var builder = services.AddRefitClient(refitSettings) + .ConfigureHttpClient((sp, client) => + { + var provider = sp.GetRequiredService(); + var config = provider.GetConfig(apiName); + if (config != null) + { + client.BaseAddress = new Uri(config.BaseUrl); + client.Timeout = TimeSpan.FromSeconds(config.TimeoutSeconds > 0 ? config.TimeoutSeconds : 30); + } + }) + .AddHttpMessageHandler(sp => + { + var provider = sp.GetRequiredService(); + var config = provider.GetConfig(apiName); + if (config?.Auth != null && config.Auth.Type != ExternalApiAuthType.None) + { + var handler = ExternalApiAuthHandlerFactory.Create(config.Auth, sp.GetService()); + if (handler != null) + return handler; + } + + return new NoOpDelegatingHandler(); + }); + + builder.AddStandardResilienceHandler(); + + return services; + } + + public static TClient GetExternalApiClient(this IServiceProvider services) + where TClient : class + { + return services.GetRequiredService(); + } + + public static IServiceCollection AddExternalApiServices( + this IServiceCollection services, + IEnumerable? assemblies = null, + ILoggerFactory? loggerFactory = null) + { + assemblies ??= GetExternalApiAssemblies(); + + var clientInterfaces = DiscoverExternalApiClients(assemblies); + + foreach (var (interfaceType, apiName) in clientInterfaces) + { + RegisterRefitClient(services, interfaceType, apiName, loggerFactory); + } + + return services; + } + + private static IEnumerable GetExternalApiAssemblies() + { + var loadedAssemblies = AppDomain.CurrentDomain.GetAssemblies(); + return loadedAssemblies.Where(a => + a.FullName?.Contains("[YourAppName]") == true && + !a.FullName.Contains("test", StringComparison.OrdinalIgnoreCase)); + } + + private static List<(Type interfaceType, string apiName)> DiscoverExternalApiClients(IEnumerable assemblies) + { + var clients = new List<(Type, string)>(); + + foreach (var assembly in assemblies) + { + try + { + var types = assembly.GetTypes() + .Where(t => t.IsInterface && + t.GetCustomAttribute() != null); + + foreach (var type in types) + { + var attr = type.GetCustomAttribute(); + if (attr != null) + { + clients.Add((type, attr.ApiName)); + } + } + } + catch (ReflectionTypeLoadException) + { + } + } + + return clients; + } + + private static IServiceCollection RegisterRefitClient( + IServiceCollection services, + Type clientInterface, + string apiName, + ILoggerFactory? loggerFactory) + { + var method = typeof(ExternalApiServiceCollectionExtensions) + .GetMethod(nameof(AddExternalRefitClientGeneric), BindingFlags.NonPublic | BindingFlags.Static)! + .MakeGenericMethod(clientInterface); + + return (IServiceCollection)method.Invoke(null, + new object[] { services, apiName, loggerFactory })!; + } + + private static IServiceCollection AddExternalRefitClientGeneric( + IServiceCollection services, + string apiName, + ILoggerFactory? loggerFactory) + where TClient : class + { + return services.AddExternalRefitClient(apiName, loggerFactory); + } +} +``` + +--- + +### 9. Register in DI (Infrastructure Layer) + +**File:** `Infrastructure/ServiceCollectionExtensions.cs` + +```csharp +using [YourAppName].Application.Interfaces; +using [YourAppName].Infrastructure.ExternalApis; +using [YourAppName].Infrastructure.ExternalApis.Providers; + +namespace [YourAppName].Infrastructure; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection RegisterInfrastructure(this IServiceCollection services, IConfiguration configuration) + { + // ... other registrations + + services.AddSingleton(); + services.AddExternalApiServices(); + + return services; + } +} +``` + +--- + +### 10. Seed Configs at Startup (API Layer) + +**File:** `API/Extensions/WebApplicationExtensions.cs` + +```csharp +public static async Task UsePlatformDataSeedingAsync(this WebApplication app) +{ + using var scope = app.Services.CreateScope(); + + var provider = scope.ServiceProvider.GetRequiredService(); + await provider.ReloadAsync(); + Log.Information("External API configuration provider cache loaded"); +} +``` + +> **Important:** Call this **after** building the app but **before** `app.Run()`. It ensures the singleton provider has loaded configs before the first HTTP request arrives. + +--- + +### 11. Create Refit Client Interfaces (Application Layer) + +**File:** `Application/ExternalApis/Clients/IPlaceholderClient.cs` + +```csharp +using Refit; + +namespace [YourAppName].Application.ExternalApis.Clients; + +[ExternalApiClient("PlaceholderApi")] +public interface IPlaceholderClient +{ + [Get("/posts")] + Task> GetPostsAsync(CancellationToken cancellationToken = default); + + [Get("/posts/{id}")] + Task GetPostByIdAsync(int id, CancellationToken cancellationToken = default); + + [Get("/posts/{id}/comments")] + Task> GetCommentsAsync(int id, CancellationToken cancellationToken = default); +} + +public class PlaceholderPostDto +{ + public int Id { get; set; } + public int UserId { get; set; } + public string Title { get; set; } = string.Empty; + public string Body { get; set; } = string.Empty; +} + +public class PlaceholderCommentDto +{ + public int Id { get; set; } + public int PostId { get; set; } + public string Name { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public string Body { get; set; } = string.Empty; +} +``` + +**File:** `Application/ExternalApis/Clients/IWeatherClient.cs` + +```csharp +using Refit; + +namespace [YourAppName].Application.ExternalApis.Clients; + +[ExternalApiClient("WeatherApi")] +public interface IWeatherClient +{ + [Get("/weather")] + Task GetCurrentWeatherAsync( + [Query] string city, + [Query] string units = "metric", + CancellationToken cancellationToken = default); + + [Get("/forecast")] + Task GetForecastAsync( + [Query] string city, + [Query] int cnt = 5, + [Query] string units = "metric", + CancellationToken cancellationToken = default); +} + +public class WeatherApiResponse +{ + public string Name { get; set; } = string.Empty; + public WeatherApiMain Main { get; set; } = new(); + public WeatherApiWind Wind { get; set; } = new(); + public List Weather { get; set; } = new(); +} + +public class WeatherApiMain +{ + public double Temp { get; set; } + public double FeelsLike { get; set; } + public int Humidity { get; set; } + public double TempMin { get; set; } + public double TempMax { get; set; } +} + +public class WeatherApiWind +{ + public double Speed { get; set; } +} + +public class WeatherApiDescription +{ + public string Main { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public string Icon { get; set; } = string.Empty; +} + +public class WeatherApiForecastResponse +{ + public List List { get; set; } = new(); +} + +public class WeatherApiForecastItem +{ + public DateTime Dt { get; set; } + public WeatherApiForecastMain Main { get; set; } = new(); + public List Weather { get; set; } = new(); +} + +public class WeatherApiForecastMain +{ + public double Temp { get; set; } + public double TempMin { get; set; } + public double TempMax { get; set; } + public int Humidity { get; set; } +} +``` + +--- + +### 12. Handler Usage Pattern (Application Layer) + +**File:** `Application/ExternalApis/Queries/GetPosts/GetPostsQuery.cs` + +```csharp +using [YourAppName].Application.Contracts; +using [YourAppName].Application.ExternalApis.Clients; +using [YourAppName].Application.ExternalApis.DTOs; +using MediatR; + +namespace [YourAppName].Application.ExternalApis.Queries.GetPosts; + +public record GetPostsQuery : IQuery>>; + +public class GetPostsQueryHandler : IQueryHandler>> +{ + private readonly IPlaceholderClient _placeholderClient; + + public GetPostsQueryHandler(IPlaceholderClient placeholderClient) + { + _placeholderClient = placeholderClient; + } + + public async Task>> Handle(GetPostsQuery request, CancellationToken ct) + { + var posts = await _placeholderClient.GetPostsAsync(ct); + var mapped = posts.Select(p => new PostDto + { + Id = p.Id, + UserId = p.UserId, + Title = p.Title, + Body = p.Body + }).ToList(); + return Result>.Success(mapped); + } +} +``` + +**File:** `Application/ExternalApis/Queries/GetWeather/GetWeatherQuery.cs` + +```csharp +using [YourAppName].Application.Contracts; +using [YourAppName].Application.Errors; +using [YourAppName].Application.ExternalApis.Clients; +using [YourAppName].Application.ExternalApis.DTOs; +using [YourAppName].Application.Localization; +using [YourAppName].Domain.Common; +using MediatR; + +namespace [YourAppName].Application.ExternalApis.Queries.GetWeather; + +public record GetWeatherQuery(string City = "London") : IQuery>; + +public class GetWeatherQueryHandler : IQueryHandler> +{ + private readonly IWeatherClient? _weatherClient; + private readonly ILocalizationService _localizationService; + + public GetWeatherQueryHandler(IWeatherClient? weatherClient, ILocalizationService localizationService) + { + _weatherClient = weatherClient; + _localizationService = localizationService; + } + + public async Task> Handle(GetWeatherQuery request, CancellationToken ct) + { + if (_weatherClient == null) + { + var localized = _localizationService.GetLocalizedMessage(ApplicationErrors.ExternalApi.NOT_CONFIGURED); + return Result.Failure(new Error( + ApplicationErrors.ExternalApi.NOT_CONFIGURED, + localized.Ar, + localized.En, + ErrorType.Internal)); + } + + try + { + var weather = await _weatherClient.GetCurrentWeatherAsync(request.City, "metric", ct); + var mapped = new WeatherDto + { + Name = weather.Name, + Main = new WeatherMainDto + { + Temp = weather.Main.Temp, + FeelsLike = weather.Main.FeelsLike, + Humidity = weather.Main.Humidity, + TempMin = weather.Main.TempMin, + TempMax = weather.Main.TempMax + }, + Wind = new WeatherWindDto { Speed = weather.Wind.Speed }, + Weather = weather.Weather.Select(w => new WeatherDescriptionDto + { + Main = w.Main, + Description = w.Description, + Icon = w.Icon + }).ToList() + }; + return Result.Success(mapped); + } + catch (Exception ex) + { + var localized = _localizationService.GetLocalizedMessage(ApplicationErrors.General.INTERNAL_ERROR); + return Result.Failure(new Error( + ApplicationErrors.General.INTERNAL_ERROR, + localized.Ar, + localized.En, + ErrorType.Internal, + new Dictionary { { "technicalErrors", new[] { ex.Message } } })); + } + } +} +``` + +> **Pattern:** If the Refit client is optional (config may not exist), make the constructor parameter nullable (`IWeatherClient?`). If it's mandatory, use non-nullable. + +--- + +## Database Seed Example + +Insert a row into `ExternalApiConfigurations` so the provider can resolve it: + +```sql +INSERT INTO ExternalApiConfigurations ( + Id, Name, BaseUrl, TimeoutSeconds, IsEnabled, + AuthType, AuthKeyName, AuthKeyLocation, AuthValue, + CreatedAt +) VALUES ( + NEWID(), 'PlaceholderApi', 'https://jsonplaceholder.typicode.com', 30, 1, + 'None', NULL, NULL, NULL, + GETUTCDATE() +); +``` + +For an API key-protected API: + +```sql +INSERT INTO ExternalApiConfigurations ( + Id, Name, BaseUrl, TimeoutSeconds, IsEnabled, + AuthType, AuthKeyName, AuthKeyLocation, AuthValue, + CreatedAt +) VALUES ( + NEWID(), 'WeatherApi', 'https://api.openweathermap.org/data/2.5', 30, 1, + 'ApiKey', 'appid', 'Query', 'YOUR_ENCRYPTED_API_KEY', + GETUTCDATE() +); +``` + +--- + +## Auth Type Mapping Reference + +| `AuthType` | Required Fields | Handler Behavior | +|------------|-----------------|----------------| +| `None` | — | NoOpDelegatingHandler (pass-through) | +| `ApiKey` | `KeyName`, `KeyLocation`, `Value` | Adds header or query parameter | +| `Bearer` | `Token` | Sets `Authorization: Bearer ` | +| `Basic` | `ClientId` (username), `ClientSecret` (password) | Sets `Authorization: Basic ` | +| `OAuth2` | `TokenUrl`, `ClientId`, `ClientSecret`, `Scope` | Acquires token via client_credentials, caches, auto-refreshes | + +--- + +## Resilience Behavior Reference + +`AddStandardResilienceHandler()` adds the following policies automatically: + +| Policy | Default Behavior | +|--------|------------------| +| Retry | 3 retries with exponential backoff | +| Circuit Breaker | Opens after 5 consecutive failures, reopens after 30s | +| Timeout | Matches `HttpClient.Timeout` | +| Hedging | Disabled by default | + +> **Note:** You can customize these via `AddStandardResilienceHandler(options => { ... })` if needed. diff --git a/backend/docs/plans/result-pattern-unified-errors-implementation-plan.md b/backend/docs/plans/result-pattern-unified-errors-implementation-plan.md new file mode 100644 index 00000000..464f7557 --- /dev/null +++ b/backend/docs/plans/result-pattern-unified-errors-implementation-plan.md @@ -0,0 +1,823 @@ +# Result Pattern & Unified Localized Errors — Implementation Plan + +## Problem Statement + +The current codebase uses **three different patterns** to signal errors from handlers: + +### 1. Return `null` → Endpoint checks for 404 +```csharp +// Handler returns NewsDto? → null means not found +var dto = await mediator.Send(new UpdateNewsCommand(...), ct); +return dto is null ? Results.NotFound() : Results.Ok(dto); +``` +**Problems:** +- Endpoint must guess that `null` means "not found" (no error code, no message) +- Client gets an empty `404` with no localized explanation +- Inconsistent — some handlers throw, others return null + +### 2. Throw `KeyNotFoundException` → Middleware maps to 404 +```csharp +// Handler throws for not-found +throw new KeyNotFoundException($"News {request.Id} not found."); +``` +**Problems:** +- Using **exceptions for control flow** — not-found is an expected outcome, not an exceptional one +- Error messages are English-only hardcoded strings +- No error code for frontend to switch on + +### 3. Throw `DomainException` → Middleware maps to 400 +```csharp +throw new DomainException("TitleAr is required."); +``` +**Problems:** +- English-only messages leaked to API clients +- No structured error code +- Client can't distinguish between different domain failures + +### 4. No Unified API Response Envelope +``` +GET /news → 200 { items: [...], page: 1, ... } (raw DTO) +GET /news/{id} → 200 { id: ..., titleAr: ... } (raw DTO) +GET /news/{id} → 404 (empty body) +POST /news → 400 ProblemDetails { title: "..." } (RFC 7807) +``` +**Frontend must handle 4 different response shapes.** + +--- + +## Target Architecture + +### Unified Response Shape +```json +// Success +{ + "isSuccess": true, + "data": { "id": "...", "titleAr": "..." }, + "error": null +} + +// Failure +{ + "isSuccess": false, + "data": null, + "error": { + "code": "CONTENT_NEWS_NOT_FOUND", + "messageAr": "الخبر غير موجود", + "messageEn": "News not found", + "type": "NotFound", + "details": null + } +} + +// Validation Failure +{ + "isSuccess": false, + "data": null, + "error": { + "code": "GENERAL_VALIDATION_ERROR", + "messageAr": "عذرًا، البيانات المدخلة غير صحيحة", + "messageEn": "Sorry, the entered data is invalid", + "type": "Validation", + "details": { + "TitleAr": ["REQUIRED_FIELD"], + "Slug": ["INVALID_FORMAT"] + } + } +} +``` + +### Flow + +``` +┌──────────────────────────────────────────────────────────┐ +│ Handler │ +│ │ +│ return Result.Success(dto); │ +│ return Result.Failure(Errors.Content.NewsNotFound); │ +│ (never throw for expected failures) │ +└───────────────────────┬──────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────┐ +│ ResultBehavior (MediatR Pipeline) │ +│ (optional — wraps unhandled exceptions into Result) │ +└───────────────────────┬──────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────┐ +│ Endpoint │ +│ │ +│ var result = await mediator.Send(cmd, ct); │ +│ return result.ToHttpResult(); // one-liner │ +│ │ +│ Maps ErrorType → HTTP status automatically: │ +│ NotFound → 404 │ +│ Validation → 400 │ +│ Conflict → 409 │ +│ Forbidden → 403 │ +│ BusinessRule→ 422 │ +└──────────────────────────────────────────────────────────┘ +``` + +--- + +## Inventory: What Already Exists (Reuse) + +| Component | Status | Location | +|---|---|---| +| `Error` record (Code, MessageAr, MessageEn, ErrorType, Details) | ✅ Exists | `Domain/Common/Error.cs` | +| `ErrorType` enum (None, Validation, NotFound, Conflict, ...) | ✅ Exists | `Domain/Common/Error.cs` | +| `ApplicationErrors` constants (per domain) | ✅ Exists | `Application/Errors/ApplicationErrors.cs` | +| `Resources.yaml` with bilingual keys | ✅ Exists | `Api.Common/Localization/Resources.yaml` | +| `ILocalizationService` + `LocalizedMessage` | ✅ Exists | `Application/Localization/` | +| `ExceptionHandlingMiddleware` (ProblemDetails) | ✅ Exists (keep as safety net) | `Api.Common/Middleware/` | +| `Result` wrapper | ❌ Missing | Needs creation | +| Error factory methods | ❌ Missing | Needs creation | +| `Result → IResult` mapper for endpoints | ❌ Missing | Needs creation | +| `ValidationBehavior` → `Result` integration | ❌ Needs update | Currently throws `ValidationException` | + +--- + +## Phase 1 — Core `Result` Type (Application Layer) + +### Step 1.1 — Create `Result` + +**File:** `src/CCE.Application/Common/Result.cs` (new) + +```csharp +using CCE.Domain.Common; + +namespace CCE.Application.Common; + +/// +/// Discriminated result type for handler returns. Replaces returning null (not-found) +/// and throwing exceptions for expected business failures. +/// +public sealed record Result +{ + public bool IsSuccess { get; private init; } + public T? Data { get; private init; } + public Error? Error { get; private init; } + + private Result() { } + + public static Result Success(T data) => new() { IsSuccess = true, Data = data }; + public static Result Failure(Error error) => new() { IsSuccess = false, Error = error }; + + /// Allow implicit conversion from T for clean handler returns. + public static implicit operator Result(T data) => Success(data); + + /// Allow implicit conversion from Error for clean handler returns. + public static implicit operator Result(Error error) => Failure(error); +} + +/// +/// Non-generic companion for void commands that return no data on success. +/// +public static class Result +{ + private static readonly Result SuccessUnit = Result.Success(Unit.Value); + + public static Result Success() => SuccessUnit; + public static Result Failure(Error error) => Result.Failure(error); +} + +/// Unit type for commands that return no data. +public readonly record struct Unit +{ + public static readonly Unit Value = default; +} +``` + +> **Note:** We define our own `Unit` instead of using MediatR's `Unit` so the Application layer doesn't need MediatR for this type. + +--- + +### Step 1.2 — Create Localized Error Factory + +**File:** `src/CCE.Application/Common/Errors.cs` (new) + +This bridges `ApplicationErrors` constants with `ILocalizationService` to produce fully localized `Error` records. + +```csharp +using CCE.Application.Errors; +using CCE.Application.Localization; +using CCE.Domain.Common; + +namespace CCE.Application.Common; + +/// +/// Factory for creating localized instances. +/// Each method looks up the bilingual message from Resources.yaml. +/// +public sealed class Errors +{ + private readonly ILocalizationService _l; + + public Errors(ILocalizationService l) => _l = l; + + // ─── General ─── + public Error NotFound(string code) + => Build(code, ErrorType.NotFound); + public Error Conflict(string code) + => Build(code, ErrorType.Conflict); + public Error BusinessRule(string code) + => Build(code, ErrorType.BusinessRule); + public Error Validation(string code, IDictionary? details = null) + => Build(code, ErrorType.Validation, details); + public Error Forbidden(string code) + => Build(code, ErrorType.Forbidden); + + // ─── Convenience: Content domain ─── + public Error NewsNotFound() => NotFound($"CONTENT_{ApplicationErrors.Content.NEWS_NOT_FOUND}"); + public Error EventNotFound() => NotFound($"CONTENT_{ApplicationErrors.Content.EVENT_NOT_FOUND}"); + public Error ResourceNotFound() => NotFound($"CONTENT_{ApplicationErrors.Content.RESOURCE_NOT_FOUND}"); + public Error PageNotFound() => NotFound($"CONTENT_{ApplicationErrors.Content.PAGE_NOT_FOUND}"); + public Error CategoryNotFound() => NotFound($"CONTENT_{ApplicationErrors.Content.CATEGORY_NOT_FOUND}"); + public Error AssetNotFound() => NotFound($"CONTENT_{ApplicationErrors.Content.ASSET_NOT_FOUND}"); + + // ─── Convenience: Identity domain ─── + public Error UserNotFound() => NotFound($"IDENTITY_{ApplicationErrors.Identity.USER_NOT_FOUND}"); + public Error ExpertRequestNotFound() => NotFound($"IDENTITY_{ApplicationErrors.Identity.EXPERT_REQUEST_NOT_FOUND}"); + + // ─── Convenience: Community domain ─── + public Error TopicNotFound() => NotFound($"COMMUNITY_{ApplicationErrors.Community.TOPIC_NOT_FOUND}"); + public Error PostNotFound() => NotFound($"COMMUNITY_{ApplicationErrors.Community.POST_NOT_FOUND}"); + public Error ReplyNotFound() => NotFound($"COMMUNITY_{ApplicationErrors.Community.REPLY_NOT_FOUND}"); + + // ─── Convenience: Country domain ─── + public Error CountryNotFound() => NotFound($"COUNTRY_{ApplicationErrors.Country.COUNTRY_NOT_FOUND}"); + + private Error Build(string code, ErrorType type, IDictionary? details = null) + { + var msg = _l.GetLocalizedMessage(code); + return new Error(code, msg.Ar, msg.En, type, details); + } +} +``` + +**Registration:** `services.AddScoped();` in `Application/DependencyInjection.cs`. + +--- + +### Step 1.3 — Create `ResultExtensions` for Minimal API Endpoints + +**File:** `src/CCE.Api.Common/Extensions/ResultExtensions.cs` (new) + +```csharp +using CCE.Application.Common; +using CCE.Domain.Common; +using Microsoft.AspNetCore.Http; + +namespace CCE.Api.Common.Extensions; + +public static class ResultExtensions +{ + /// + /// Maps a to an with the correct HTTP status. + /// + public static IResult ToHttpResult( + this Result result, + int successStatusCode = StatusCodes.Status200OK) + { + if (result.IsSuccess) + { + return successStatusCode switch + { + StatusCodes.Status201Created => Results.Created((string?)null, result), + StatusCodes.Status204NoContent => Results.NoContent(), + _ => Results.Json(result, statusCode: successStatusCode) + }; + } + + var statusCode = result.Error!.Type switch + { + ErrorType.NotFound => StatusCodes.Status404NotFound, + ErrorType.Validation => StatusCodes.Status400BadRequest, + ErrorType.Conflict => StatusCodes.Status409Conflict, + ErrorType.Unauthorized => StatusCodes.Status401Unauthorized, + ErrorType.Forbidden => StatusCodes.Status403Forbidden, + ErrorType.BusinessRule => StatusCodes.Status422UnprocessableEntity, + _ => StatusCodes.Status500InternalServerError, + }; + + return Results.Json(result, statusCode: statusCode); + } + + /// Shorthand for 201 Created. + public static IResult ToCreatedHttpResult(this Result result) + => result.ToHttpResult(StatusCodes.Status201Created); + + /// Shorthand for 204 NoContent (void commands). + public static IResult ToNoContentHttpResult(this Result result) + => result.ToHttpResult(StatusCodes.Status204NoContent); +} +``` + +--- + +## Phase 2 — Update `ValidationBehavior` to Return `Result` + +### Step 2.1 — Create `ResultValidationBehavior` + +The current `ValidationBehavior` throws `ValidationException`. For handlers that return `Result`, we need a behavior that returns a `Result.Failure(validationError)` instead. + +**File:** `src/CCE.Application/Common/Behaviors/ResultValidationBehavior.cs` (new) + +```csharp +using CCE.Application.Localization; +using CCE.Domain.Common; +using FluentValidation; +using MediatR; + +namespace CCE.Application.Common.Behaviors; + +/// +/// MediatR pipeline behavior for requests returning . +/// Instead of throwing , it returns a failure Result +/// with localized messages and structured field-level details. +/// +public sealed class ResultValidationBehavior + : IPipelineBehavior + where TRequest : notnull + where TResponse : class +{ + private readonly IEnumerable> _validators; + private readonly ILocalizationService _localization; + + public ResultValidationBehavior( + IEnumerable> validators, + ILocalizationService localization) + { + _validators = validators; + _localization = localization; + } + + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + // Only intercept when TResponse is Result + if (!IsResultType(typeof(TResponse))) + { + // Fall through to existing ValidationBehavior for non-Result handlers + return await next().ConfigureAwait(false); + } + + if (!_validators.Any()) + return await next().ConfigureAwait(false); + + var context = new ValidationContext(request); + var results = await Task.WhenAll( + _validators.Select(v => v.ValidateAsync(context, cancellationToken))) + .ConfigureAwait(false); + + var failures = results.SelectMany(r => r.Errors) + .Where(f => f is not null) + .ToList(); + + if (failures.Count == 0) + return await next().ConfigureAwait(false); + + // Build structured details: { "TitleAr": ["REQUIRED_FIELD"], "Slug": ["INVALID_FORMAT"] } + var details = failures + .GroupBy(f => f.PropertyName) + .ToDictionary( + g => g.Key, + g => g.Select(f => f.ErrorMessage).ToArray()); + + var msg = _localization.GetLocalizedMessage("GENERAL_VALIDATION_ERROR"); + var error = new Error( + "GENERAL_VALIDATION_ERROR", + msg.Ar, msg.En, + ErrorType.Validation, + details); + + // Use reflection to call Result.Failure(error) + var innerType = typeof(TResponse).GetGenericArguments()[0]; + var failureMethod = typeof(Result<>) + .MakeGenericType(innerType) + .GetMethod("Failure")!; + + return (TResponse)failureMethod.Invoke(null, [error])!; + } + + private static bool IsResultType(Type type) + => type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Result<>); +} +``` + +### Step 2.2 — Register the Behavior + +**File:** `src/CCE.Application/DependencyInjection.cs` (edit existing) + +```csharp +services.AddMediatR(cfg => +{ + cfg.RegisterServicesFromAssembly(assembly); + cfg.AddOpenBehavior(typeof(LoggingBehavior<,>)); + cfg.AddOpenBehavior(typeof(ResultValidationBehavior<,>)); // NEW — before old one + cfg.AddOpenBehavior(typeof(ValidationBehavior<,>)); // existing — for non-Result handlers +}); +``` + +> **Important:** `ResultValidationBehavior` runs first for `Result` handlers. `ValidationBehavior` still runs for legacy handlers that haven't been migrated yet. This allows **gradual migration**. + +--- + +## Phase 3 — Migrate Handlers (Per Domain) + +### Migration Recipe Per Handler + +#### Command Handler (was: throw or return null) + +**Before:** +```csharp +public sealed class DeleteNewsCommandHandler : IRequestHandler +{ + public async Task Handle(DeleteNewsCommand request, CancellationToken ct) + { + var news = await _service.FindAsync(request.Id, ct); + if (news is null) + throw new KeyNotFoundException($"News {request.Id} not found."); + // ... + return MediatR.Unit.Value; + } +} +``` + +**After:** +```csharp +public sealed class DeleteNewsCommandHandler : IRequestHandler> +{ + private readonly INewsRepository _repo; + private readonly Errors _errors; + // ... + + public async Task> Handle(DeleteNewsCommand request, CancellationToken ct) + { + var news = await _repo.FindAsync(request.Id, ct); + if (news is null) + return _errors.NewsNotFound(); // ← localized, typed, no exception + + var deletedById = _currentUser.GetUserId() + ?? throw new DomainException("Cannot delete news without user identity."); + + news.SoftDelete(deletedById, _clock); + await _repo.UpdateAsync(news, news.RowVersion, ct); + return Result.Success(); + } +} +``` + +**Command record:** +```csharp +// Before +public sealed record DeleteNewsCommand(Guid Id) : IRequest; + +// After +public sealed record DeleteNewsCommand(Guid Id) : IRequest>; +``` + +#### Query Handler — GetById (was: return null) + +**Before:** +```csharp +// Handler returns NewsDto? +// Endpoint: return dto is null ? Results.NotFound() : Results.Ok(dto); +``` + +**After:** +```csharp +public sealed class GetNewsByIdQueryHandler : IRequestHandler> +{ + private readonly ICceDbContext _db; + private readonly Errors _errors; + + public async Task> Handle(GetNewsByIdQuery request, CancellationToken ct) + { + var news = await _db.News + .Where(n => n.Id == request.Id) + .ToListAsyncEither(ct); + + var entity = news.SingleOrDefault(); + if (entity is null) + return _errors.NewsNotFound(); + + return MapToDto(entity); // implicit conversion to Result.Success + } +} +``` + +#### Endpoint (simplified) + +**Before:** +```csharp +news.MapGet("/{id:guid}", async (Guid id, IMediator mediator, CancellationToken ct) => +{ + var dto = await mediator.Send(new GetNewsByIdQuery(id), ct); + return dto is null ? Results.NotFound() : Results.Ok(dto); +}); +``` + +**After:** +```csharp +news.MapGet("/{id:guid}", async (Guid id, IMediator mediator, CancellationToken ct) => +{ + var result = await mediator.Send(new GetNewsByIdQuery(id), ct); + return result.ToHttpResult(); +}); +``` + +**Every endpoint becomes a one-liner.** The `ErrorType` → HTTP status mapping is automatic. + +--- + +### 3.1 — Content Domain Commands + +| # | Handler | Current Return | New Return | Not-Found Pattern | +|---|---|---|---|---| +| 1 | `CreateNewsCommandHandler` | `NewsDto` | `Result` | N/A (always creates) | +| 2 | `UpdateNewsCommandHandler` | `NewsDto?` | `Result` | `_errors.NewsNotFound()` | +| 3 | `DeleteNewsCommandHandler` | `MediatR.Unit` | `Result` | `_errors.NewsNotFound()` | +| 4 | `PublishNewsCommandHandler` | `NewsDto?` | `Result` | `_errors.NewsNotFound()` | +| 5 | `CreateEventCommandHandler` | `EventDto` | `Result` | N/A | +| 6 | `UpdateEventCommandHandler` | `EventDto?` | `Result` | `_errors.EventNotFound()` | +| 7 | `DeleteEventCommandHandler` | `MediatR.Unit` | `Result` | `_errors.EventNotFound()` | +| 8 | `RescheduleEventCommandHandler` | `EventDto?` | `Result` | `_errors.EventNotFound()` | +| 9 | `CreateResourceCommandHandler` | `ResourceDto` | `Result` | N/A | +| 10 | `UpdateResourceCommandHandler` | `ResourceDto?` | `Result` | `_errors.ResourceNotFound()` | +| 11 | `PublishResourceCommandHandler` | `ResourceDto?` | `Result` | `_errors.ResourceNotFound()` | +| 12 | `CreatePageCommandHandler` | `PageDto` | `Result` | N/A | +| 13 | `UpdatePageCommandHandler` | `PageDto?` | `Result` | `_errors.PageNotFound()` | +| 14 | `DeletePageCommandHandler` | `MediatR.Unit` | `Result` | `_errors.PageNotFound()` | +| 15 | `CreateResourceCategoryCommandHandler` | `ResourceCategoryDto` | `Result` | N/A | +| 16 | `UpdateResourceCategoryCommandHandler` | `ResourceCategoryDto?` | `Result` | `_errors.CategoryNotFound()` | +| 17 | `DeleteResourceCategoryCommandHandler` | `MediatR.Unit` | `Result` | `_errors.CategoryNotFound()` | +| 18 | `CreateHomepageSectionCommandHandler` | `HomepageSectionDto` | `Result` | N/A | +| 19 | `UpdateHomepageSectionCommandHandler` | `HomepageSectionDto?` | `Result` | `_errors.HomepageSectionNotFound()` | +| 20 | `DeleteHomepageSectionCommandHandler` | `MediatR.Unit` | `Result` | `_errors.HomepageSectionNotFound()` | +| 21 | `ReorderHomepageSectionsCommandHandler` | `MediatR.Unit` | `Result` | N/A | +| 22 | `UploadAssetCommandHandler` | `AssetFileDto` | `Result` | N/A | +| 23 | `ApproveCountryResourceRequestCommandHandler` | varies | `Result<...>` | `_errors.NotFound(...)` | +| 24 | `RejectCountryResourceRequestCommandHandler` | varies | `Result<...>` | `_errors.NotFound(...)` | + +### 3.2 — Content Domain Queries + +| # | Handler | Current Return | New Return | +|---|---|---|---| +| 1 | `ListNewsQueryHandler` | `PagedResult` | `Result>` | +| 2 | `GetNewsByIdQueryHandler` | `NewsDto?` | `Result` | +| 3 | `ListEventsQueryHandler` | `PagedResult` | `Result>` | +| 4 | `GetEventByIdQueryHandler` | `EventDto?` | `Result` | +| ... | (all other query handlers) | `T?` or `PagedResult` | `Result` or `Result>` | + +> **Note on List queries:** List queries never "fail" — an empty list is a valid success. `Result>` wrapping is still valuable for **consistency** so the frontend always sees the same envelope. However, you could choose to keep list queries returning `PagedResult` directly (unwrapped) if you prefer less ceremony on reads. **Pick one convention and stick to it.** + +### 3.3 — Identity Domain + +Same pattern. Replace `KeyNotFoundException` throws with `_errors.UserNotFound()`, `_errors.ExpertRequestNotFound()` etc. + +### 3.4 — Community Domain + +Same pattern. Replace `KeyNotFoundException` throws with `_errors.TopicNotFound()`, `_errors.PostNotFound()`, `_errors.ReplyNotFound()`. + +### 3.5 — Other Domains (Country, Notifications, KnowledgeMaps, InteractiveCity, Surveys) + +Same recipe. Each domain already has error constants in `ApplicationErrors` and YAML keys in `Resources.yaml`. + +--- + +## Phase 4 — DomainException Integration + +### Keep `DomainException` for TRUE invariant violations + +`DomainException` is thrown from **Domain entity methods** (`News.Draft()`, `News.UpdateContent()`) where you cannot return a `Result`. These are **programming errors** (caller passed bad data past validation), not expected user-facing failures. + +**Do not change Domain entities.** The `ExceptionHandlingMiddleware` stays as a safety net for: +- `DomainException` → 400 +- `ConcurrencyException` → 409 +- `DuplicateException` → 409 +- Unhandled `Exception` → 500 + +But now the middleware also localizes these: + +### Step 4.1 — Enhance Middleware to Use Localization + +**File:** `src/CCE.Api.Common/Middleware/ExceptionHandlingMiddleware.cs` (edit) + +```csharp +public sealed class ExceptionHandlingMiddleware +{ + private readonly RequestDelegate _next; + private readonly ILogger _logger; + + // ...existing constructor... + + public async Task InvokeAsync(HttpContext context) + { + try + { + await _next(context).ConfigureAwait(false); + } + catch (ValidationException ex) + { + var l = context.RequestServices.GetService(); + await WriteValidationResultAsync(context, ex, l).ConfigureAwait(false); + } + catch (ConcurrencyException ex) + { + var l = context.RequestServices.GetService(); + await WriteErrorResultAsync(context, StatusCodes.Status409Conflict, + "CONCURRENCY_CONFLICT", ErrorType.Conflict, ex.Message, l).ConfigureAwait(false); + } + catch (DuplicateException ex) + { + var l = context.RequestServices.GetService(); + await WriteErrorResultAsync(context, StatusCodes.Status409Conflict, + "DUPLICATE_VALUE", ErrorType.Conflict, ex.Message, l).ConfigureAwait(false); + } + catch (DomainException ex) + { + var l = context.RequestServices.GetService(); + await WriteErrorResultAsync(context, StatusCodes.Status400BadRequest, + "GENERAL_BAD_REQUEST", ErrorType.BusinessRule, ex.Message, l).ConfigureAwait(false); + } + catch (KeyNotFoundException ex) + { + // Legacy — still caught for non-migrated handlers + var l = context.RequestServices.GetService(); + await WriteErrorResultAsync(context, StatusCodes.Status404NotFound, + "GENERAL_NOT_FOUND", ErrorType.NotFound, ex.Message, l).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unhandled exception"); + var l = context.RequestServices.GetService(); + await WriteErrorResultAsync(context, StatusCodes.Status500InternalServerError, + "GENERAL_INTERNAL_ERROR", ErrorType.Internal, null, l).ConfigureAwait(false); + } + } + + /// + /// Writes a unified error response matching the Result{T} shape, + /// so clients always see the same JSON structure regardless of + /// whether the error came from a handler or the middleware. + /// + private static async Task WriteErrorResultAsync( + HttpContext ctx, int statusCode, string code, ErrorType type, + string? fallbackMessage, ILocalizationService? l) + { + var msg = l?.GetLocalizedMessage(code); + var error = new Error( + code, + msg?.Ar ?? fallbackMessage ?? "خطأ", + msg?.En ?? fallbackMessage ?? "Error", + type); + + var envelope = new { isSuccess = false, data = (object?)null, error }; + + ctx.Response.StatusCode = statusCode; + ctx.Response.ContentType = "application/json"; + await JsonSerializer.SerializeAsync(ctx.Response.Body, envelope, + new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }) + .ConfigureAwait(false); + } +} +``` + +Now **every response** — success or failure, from handler or middleware — uses the same JSON shape. + +--- + +## Phase 5 — Add Missing YAML Keys + +**File:** `src/CCE.Api.Common/Localization/Resources.yaml` (append) + +```yaml +CONCURRENCY_CONFLICT: + ar: "تم تعديل هذا السجل من قبل مستخدم آخر. يرجى تحديث الصفحة والمحاولة مرة أخرى" + en: "This record was modified by another user. Please refresh and try again" + +DUPLICATE_VALUE: + ar: "القيمة موجودة بالفعل" + en: "Value already exists" + +NOTIFICATION_TEMPLATE_NOT_FOUND: + ar: "قالب الإشعار غير موجود" + en: "Notification template not found" + +KNOWLEDGE_MAP_NOT_FOUND: + ar: "خريطة المعرفة غير موجودة" + en: "Knowledge map not found" + +SCENARIO_NOT_FOUND: + ar: "السيناريو غير موجود" + en: "Scenario not found" +``` + +--- + +## Phase 6 — Update Endpoints (Per API) + +### Recipe Per Endpoint + +**Before:** +```csharp +news.MapPut("/{id:guid}", async (Guid id, UpdateNewsRequest body, + IMediator mediator, CancellationToken ct) => +{ + var cmd = new UpdateNewsCommand(id, body.TitleAr, ...); + var dto = await mediator.Send(cmd, ct); + return dto is null ? Results.NotFound() : Results.Ok(dto); +}); +``` + +**After:** +```csharp +news.MapPut("/{id:guid}", async (Guid id, UpdateNewsRequest body, + IMediator mediator, CancellationToken ct) => +{ + var cmd = new UpdateNewsCommand(id, body.TitleAr, ...); + var result = await mediator.Send(cmd, ct); + return result.ToHttpResult(); +}); +``` + +Every endpoint becomes **the same 3 lines**: build command/query → send → `.ToHttpResult()`. + +--- + +## Execution Order & Risk Assessment + +| Phase | Effort | Risk | Can Ship Independently | +|---|---|---|---| +| **Phase 1** — `Result`, `Errors` factory, `ResultExtensions` | 1 day | None — additive | ✅ Yes | +| **Phase 2** — `ResultValidationBehavior` | 0.5 day | Low — new behavior, old one still works | ✅ Yes | +| **Phase 3.1** — Content handlers | 2 days | Medium — changes handler + command + endpoint signatures | ✅ Per handler | +| **Phase 3.2–3.5** — Other domains | 2 days | Medium | ✅ Per domain | +| **Phase 4** — Middleware localization | 0.5 day | Low — changes error format | ✅ Yes | +| **Phase 5** — YAML keys | 0.5 day | None — additive | ✅ Yes | +| **Phase 6** — Endpoint cleanup | 1 day | Low — 1:1 mapping | ✅ Per API | + +**Total:** ~7.5 days + +--- + +## Gradual Migration Strategy + +This plan is designed for **zero big-bang**: + +1. **Phase 1–2** are purely additive — no existing code breaks +2. **Phase 3** is per-handler: + - Change `DeleteNewsCommand : IRequest` → `IRequest>` + - Change handler return type + - Change endpoint to use `.ToHttpResult()` + - **All three happen atomically per feature** — one PR per handler group +3. **Old handlers** (`IRequest`) still work with the existing `ValidationBehavior` and middleware +4. **New handlers** (`IRequest>`) use `ResultValidationBehavior` automatically +5. Once all handlers are migrated, delete the old `ValidationBehavior` (throwing) and `MediatR.Unit` usages + +--- + +## Validation Checklist (Per Handler Migration) + +- [ ] Command/Query record uses `IRequest>` not `IRequest` +- [ ] Handler injects `Errors` factory +- [ ] Handler returns `_errors.XxxNotFound()` instead of `throw new KeyNotFoundException` or `return null` +- [ ] Handler returns implicit `Result` on success (e.g., `return dto;`) +- [ ] Endpoint uses `result.ToHttpResult()` — no manual `Results.NotFound()` / `Results.Ok()` +- [ ] FluentValidation validator unchanged (still uses same rules) +- [ ] Tests updated: assert `result.IsSuccess` / `result.Error.Code` instead of catching exceptions +- [ ] `dotnet build CCE.sln` — zero warnings +- [ ] `dotnet test CCE.sln` — all green +- [ ] API response shape matches the unified envelope + +--- + +## Files Changed Summary + +### New Files +| File | Layer | Purpose | +|---|---|---| +| `Application/Common/Result.cs` | Application | `Result` + `Unit` | +| `Application/Common/Errors.cs` | Application | Localized error factory | +| `Application/Common/Behaviors/ResultValidationBehavior.cs` | Application | Validation → Result (no throw) | +| `Api.Common/Extensions/ResultExtensions.cs` | API | `Result` → `IResult` HTTP mapper | + +### Modified Files +| File | Change | +|---|---| +| `Application/DependencyInjection.cs` | Register `Errors` + `ResultValidationBehavior` | +| `Api.Common/Middleware/ExceptionHandlingMiddleware.cs` | Localized error envelope format | +| `Api.Common/Localization/Resources.yaml` | Add missing YAML keys | +| All command/query records | `IRequest` → `IRequest>` | +| All handlers | Return `Result` instead of throw/null | +| All endpoint files | Use `.ToHttpResult()` | +| All handler test files | Assert on `result.IsSuccess` / `result.Error.Code` | + +### Deleted Files (after full migration) +| File | When | +|---|---| +| `Application/Common/Behaviors/ValidationBehavior.cs` | After ALL handlers are migrated to `Result` | diff --git a/backend/docs/plans/scalar-swagger-dotnet10-implementation-plan.md b/backend/docs/plans/scalar-swagger-dotnet10-implementation-plan.md new file mode 100644 index 00000000..3b0ec033 --- /dev/null +++ b/backend/docs/plans/scalar-swagger-dotnet10-implementation-plan.md @@ -0,0 +1,333 @@ +# Scalar & Swagger for .NET 10 Implementation Plan + +## How to Adopt in Another Solution + +1. Replace all `[YourAppName]` occurrences with your root namespace. +2. Add the required NuGet packages (see Step 1). +3. Enable `true` in your API `.csproj`. +4. Copy `ApiDocumentationExtensions.cs` into your API project. +5. Call `AddPlatformOpenApi()` and `AddPlatformApiVersioning()` in `Program.cs` during service registration. +6. Call `UsePlatformApiDocumentation()` in `Program.cs` during pipeline configuration. +7. Add XML `///` comments to all public controllers and action methods. + +--- + +## Overview + +This plan configures modern API documentation for .NET 10 using: +- **Microsoft.AspNetCore.OpenApi** (built-in .NET 10 OpenAPI support) +- **Scalar.AspNetCore** (modern interactive API client) +- **Swashbuckle.AspNetCore** (legacy SwaggerUI for backward compatibility) +- **Asp.Versioning** (API versioning support) + +All documentation endpoints (`/openapi/v1.json`, `/scalar`, `/swagger`) are exposed **only in Development**. + +--- + +### 1. Add Required NuGet Packages + +Add to your central package management (`Directory.Packages.props`) or `.csproj`: + +```xml + + + + +``` + +Then reference them in your API `.csproj`: + +```xml + + + + + + +``` + +--- + +### 2. Enable XML Documentation (API `.csproj`) + +```xml + + true + $(NoWarn);1591 + +``` + +> `1591` suppresses warnings for missing XML comments on public members. Remove the suppression if you want enforcement. + +--- + +### 3. Create `ApiDocumentationExtensions` (API Layer) + +**File:** `API/Extensions/ApiDocumentationExtensions.cs` + +```csharp +using Asp.Versioning; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.OpenApi; +using Scalar.AspNetCore; + +namespace [YourAppName].API.Extensions; + +public static class ApiDocumentationExtensions +{ + private const string ApiVersion = "v1"; + + public static IServiceCollection AddPlatformOpenApi(this IServiceCollection services) + { + services.AddEndpointsApiExplorer(); + services.AddOpenApi(ApiVersion, options => + { + options.AddDocumentTransformer((document, _, _) => + { + document.Info = new Microsoft.OpenApi.OpenApiInfo + { + Title = "[YourAppName] API v1", + Version = ApiVersion, + Description = "Your application API - Clean Architecture", + Contact = new Microsoft.OpenApi.OpenApiContact + { + Name = "Your Team", + Email = "support@yourapp.com" + } + }; + + document.Components ??= new OpenApiComponents(); + document.Components.SecuritySchemes ??= new Dictionary(); + document.Components.SecuritySchemes[JwtBearerDefaults.AuthenticationScheme] = new OpenApiSecurityScheme + { + Type = SecuritySchemeType.Http, + Scheme = "bearer", + BearerFormat = "JWT", + Description = "Enter your JWT token" + }; + + document.Security ??= new List(); + document.Security.Add(new OpenApiSecurityRequirement + { + [new OpenApiSecuritySchemeReference(JwtBearerDefaults.AuthenticationScheme, document)] = new List() + }); + + return Task.CompletedTask; + }); + + options.AddOperationTransformer((operation, _, _) => + { + var parameters = operation.Parameters?.ToList() ?? new List(); + parameters.Add(new OpenApiParameter + { + Name = "Accept-Language", + In = ParameterLocation.Header, + Description = "Language preference (ar, en). Default: ar", + Required = false, + Schema = new OpenApiSchema { Type = JsonSchemaType.String } + }); + operation.Parameters = parameters; + return Task.CompletedTask; + }); + }); + + return services; + } + + public static IServiceCollection AddPlatformApiVersioning(this IServiceCollection services) + { + services.AddApiVersioning(options => + { + options.DefaultApiVersion = new ApiVersion(1, 0); + options.AssumeDefaultVersionWhenUnspecified = true; + options.ReportApiVersions = true; + }) + .AddApiExplorer(options => + { + options.GroupNameFormat = "'v'VVV"; + options.SubstituteApiVersionInUrl = true; + }); + + return services; + } + + public static WebApplication UsePlatformApiDocumentation(this WebApplication app) + { + if (!app.Environment.IsDevelopment()) + { + return app; + } + + app.MapOpenApi(); + app.MapScalarApiReference(options => + { + options.WithTitle("[YourAppName] API"); + options.AddPreferredSecuritySchemes(JwtBearerDefaults.AuthenticationScheme); + options.AddHttpAuthentication(JwtBearerDefaults.AuthenticationScheme, _ => { }); + }); + + app.UseSwaggerUI(options => + { + options.SwaggerEndpoint($"/openapi/{ApiVersion}.json", "[YourAppName] API v1"); + options.RoutePrefix = "swagger"; + options.DocumentTitle = "[YourAppName] API Documentation"; + options.DefaultModelsExpandDepth(2); + options.EnableDeepLinking(); + options.EnablePersistAuthorization(); + }); + + return app; + } +} +``` + +--- + +### 4. Wire into `Program.cs` (API Layer) + +**File:** `API/Program.cs` + +```csharp +using [YourAppName].API.Extensions; + +var builder = WebApplication.CreateBuilder(args); + +// ... logging, auth, persistence, etc. + +builder.Services + .AddPlatformOpenApi() + .AddPlatformApiVersioning() + .AddControllers(); + +var app = builder.Build(); + +app.UseHttpsRedirection(); +app.UseCors(); +app.UseAuthentication(); +app.UseAuthorization(); +app.UsePlatformApiDocumentation(); +app.MapControllers(); + +app.Run(); + +public partial class Program; +``` + +> **Note:** `UsePlatformApiDocumentation()` is safe to call unconditionally — it internally checks `app.Environment.IsDevelopment()`. + +--- + +### 5. Controller Annotation Pattern (API Layer) + +Add XML `///` summaries and `ProducesResponseType` attributes to every controller action. + +**File example:** `API/Controllers/AuthController.cs` + +```csharp +using [YourAppName].Application.Contracts; +using [YourAppName].API.Extensions; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using Asp.Versioning; + +namespace [YourAppName].API.Controllers; + +/// +/// Provides authentication endpoints for login, registration, token refresh, and logout. +/// +[ApiController] +[Route("api/[controller]")] +[ApiVersion("1.0")] +[Produces("application/json")] +public class AuthController : ControllerBase +{ + private readonly IMediator _mediator; + private readonly ILogger _logger; + + public AuthController(IMediator mediator, ILogger logger) + { + _mediator = mediator; + _logger = logger; + } + + /// + /// Authenticates a user and returns JWT access and refresh tokens. + /// + [HttpPost("login")] + [AllowAnonymous] + [EnableRateLimiting("login")] + [ProducesResponseType(typeof(Result), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(Result), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(Result), StatusCodes.Status401Unauthorized)] + public async Task Login([FromBody] LoginRequest request, CancellationToken ct) + { + _logger.LogInformation("Login attempt received"); + var result = await _mediator.Send(new LoginCommand(request.Email, request.Password), ct); + return this.ToActionResult(result); + } + + /// + /// Registers a new user account. + /// + [HttpPost("register")] + [AllowAnonymous] + [ProducesResponseType(typeof(Result), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(Result), StatusCodes.Status400BadRequest)] + public async Task Register([FromBody] RegisterRequest request, CancellationToken ct) + { + _logger.LogInformation("Registration attempt received"); + var result = await _mediator.Send(new RegisterCommand(...), ct); + return this.ToActionResult(result, StatusCodes.Status201Created); + } +} +``` + +--- + +## Endpoint URLs Reference + +| Environment | URL | Description | +|-------------|-----|-------------| +| Development | `http://localhost:5000/openapi/v1.json` | Raw OpenAPI JSON spec | +| Development | `http://localhost:5000/scalar` | Scalar interactive UI | +| Development | `http://localhost:5000/swagger` | SwaggerUI legacy view | + +> All three are automatically hidden in non-Development environments. + +--- + +## Versioning Behavior Reference + +| Setting | Value | Behavior | +|---------|-------|----------| +| `DefaultApiVersion` | `1.0` | Requests without version default to v1 | +| `AssumeDefaultVersionWhenUnspecified` | `true` | Unversioned requests are allowed | +| `ReportApiVersions` | `true` | Response headers include `api-supported-versions` | +| `GroupNameFormat` | `'v'VVV` | Explorer groups names like `v1`, `v2` | +| `SubstituteApiVersionInUrl` | `true` | URL route tokens `{version:apiVersion}` are replaced | + +--- + +## Security Scheme Reference + +| Property | Value | +|----------|-------| +| Type | `Http` | +| Scheme | `bearer` | +| Bearer Format | `JWT` | +| Global Security Requirement | Applied to all operations | +| Scalar Integration | `AddPreferredSecuritySchemes("Bearer")` | + +--- + +## Optional: Add API Version to Route + +If you want versioned routes, use the `api-version` route constraint: + +```csharp +[Route("api/v{version:apiVersion}/[controller]")] +``` + +Combine with `SubstituteApiVersionInUrl = true` in the API explorer options for clean Swagger/Scalar route display. diff --git a/backend/docs/plans/sprint-01-auth-user-services-implementation-plan.md b/backend/docs/plans/sprint-01-auth-user-services-implementation-plan.md new file mode 100644 index 00000000..fc349620 --- /dev/null +++ b/backend/docs/plans/sprint-01-auth-user-services-implementation-plan.md @@ -0,0 +1,616 @@ +# Sprint 01 Auth & User Services - Implementation Plan + +## Scope + +Implement the Sprint 01 auth stories in `docs/Brd/stories/sprint-01-auth-user-services`: + +| Story | Capability | API outcome | +|---|---|---| +| US033 | Create account | Register a local user account with profile fields and password | +| US034 | Login | Validate credentials and issue access + refresh tokens | +| US035 | Password recovery | Request password reset, deliver reset link/token, reset password | +| US036 | Logout | Revoke the active refresh token/session | + +This plan adds a first-party email/password auth surface for both APIs while keeping the existing Entra ID JWT validation and dev auth shim intact. `CCE.Api.External` and `CCE.Api.Internal` must use different local JWT signing keys, issuers, and audiences so tokens cannot be replayed across API boundaries. + +--- + +## Current State + +- `CCE.Api.External` already has `/api/users/register`, but it creates Entra users through `EntraIdRegistrationService` in production and directly creates a dev user in `Auth:DevMode`. +- JWT bearer auth is configured in `CCE.Api.Common/Auth/CceJwtAuthRegistration.cs` using Microsoft.Identity.Web for Entra tokens. +- `CceDbContext` already extends `IdentityDbContext`, so Identity tables exist. +- There is no registered `UserManager`, `RoleManager`, or `SignInManager` setup yet. +- There is no local access-token issuer, refresh-token store, refresh endpoint, or password reset endpoint. +- Existing API response direction is `Result` + `ToHttpResult()`, so new application handlers should return `Result` instead of raw `Results.BadRequest(...)` where practical. + +--- + +## Target API Contract + +Base group: `/api/auth`, tagged `Auth`. + +### Register + +`POST /api/auth/register` + +Request: + +```json +{ + "firstName": "Sara", + "lastName": "Ahmed", + "emailAddress": "sara@example.com", + "jobTitle": "Planner", + "organizationName": "CCE", + "phoneNumber": "+966500000000", + "password": "StrongPass123", + "confirmPassword": "StrongPass123" +} +``` + +Response: + +- `201 Created` +- `Result` +- Does not auto-login. This follows US033: account creation succeeds, then the user logs in separately. +- Creates user in role `cce-user`. + +### Login + +`POST /api/auth/login` + +Request: + +```json +{ + "emailAddress": "sara@example.com", + "password": "StrongPass123" +} +``` + +Response: + +```json +{ + "isSuccess": true, + "data": { + "accessToken": "", + "accessTokenExpiresAtUtc": "2026-05-14T19:10:00Z", + "refreshToken": "", + "refreshTokenExpiresAtUtc": "2026-06-13T19:00:00Z", + "tokenType": "Bearer", + "user": { + "id": "00000000-0000-0000-0000-000000000000", + "emailAddress": "sara@example.com", + "firstName": "Sara", + "lastName": "Ahmed", + "roles": ["cce-user"] + } + }, + "error": null +} +``` + +### Refresh Token + +`POST /api/auth/refresh` + +Request: + +```json +{ + "refreshToken": "" +} +``` + +Response: + +- Issues a new access token and a new refresh token. +- Revokes the old refresh token. +- Reuse of a revoked token revokes the full token family for that user/device. + +### Forgot Password + +`POST /api/auth/forgot-password` + +Request: + +```json +{ + "emailAddress": "sara@example.com" +} +``` + +Response: + +- `200 OK` +- Always returns success, including when the email is unknown, to avoid account enumeration. +- Internally log the unknown-email case at low severity without exposing it to the caller. + +### Reset Password + +`POST /api/auth/reset-password` + +Request: + +```json +{ + "emailAddress": "sara@example.com", + "token": "", + "newPassword": "NewStrongPass123", + "confirmPassword": "NewStrongPass123" +} +``` + +Response: + +- `200 OK` +- Existing refresh tokens for the user are revoked after password reset. + +### Logout + +`POST /api/auth/logout` + +Request: + +```json +{ + "refreshToken": "" +} +``` + +Response: + +- `200 OK` with `CON015` equivalent, or `204 NoContent` if the API standard prefers no body. +- Revoke the submitted refresh token. +- Optional later endpoint: `POST /api/auth/logout-all` for revoking every active user session. + +--- + +## Data Model Changes + +### Extend `User` + +File: `src/CCE.Domain/Identity/User.cs` + +Add Sprint 01 profile fields: + +- `FirstName` +- `LastName` +- `JobTitle` +- `OrganizationName` + +Use private setters and mutation methods, following the existing entity style. + +Keep `Email`, `UserName`, `PhoneNumber`, `PasswordHash`, `EmailConfirmed`, lockout fields, security stamp, and concurrency stamp from `IdentityUser`. + +### Add `RefreshToken` + +New file: `src/CCE.Domain/Identity/RefreshToken.cs` + +Fields: + +- `Id: Guid` +- `UserId: Guid` +- `TokenHash: string` +- `TokenFamilyId: Guid` +- `CreatedAtUtc: DateTimeOffset` +- `ExpiresAtUtc: DateTimeOffset` +- `RevokedAtUtc: DateTimeOffset?` +- `ReplacedByTokenHash: string?` +- `CreatedByIp: string?` +- `RevokedByIp: string?` +- `UserAgent: string?` + +Rules: + +- Store only SHA-256 hashes of refresh tokens. +- Refresh tokens are opaque random values, not JWTs. +- Active token means `RevokedAtUtc is null && ExpiresAtUtc > now`. +- Refresh is rotation-only: every refresh consumes the old token and creates a new one. +- Reuse detection: if a revoked token is used again, revoke all tokens in the same `TokenFamilyId`. + +### EF Mapping + +Add `DbSet` in `CceDbContext`. + +Add configuration: + +`src/CCE.Infrastructure/Persistence/Configurations/Identity/RefreshTokenConfiguration.cs` + +Indexes: + +- Unique index on `TokenHash` +- Index on `UserId` +- Index on `TokenFamilyId` +- Optional filtered index for active tokens if SQL Server filter is worth it + +Migration: + +```bash +dotnet ef migrations add AddLocalAuthRefreshTokens --project src/CCE.Infrastructure --startup-project src/CCE.Infrastructure +``` + +--- + +## Configuration + +Add options class: + +`src/CCE.Api.Common/Auth/LocalJwtOptions.cs` or `src/CCE.Infrastructure/Identity/LocalAuthOptions.cs` + +Config section: + +```json +{ + "LocalAuth": { + "External": { + "Issuer": "cce-api-external", + "Audience": "cce-public", + "SigningKey": "dev-only-external-long-random-secret-replace-in-user-secrets" + }, + "Internal": { + "Issuer": "cce-api-internal", + "Audience": "cce-admin", + "SigningKey": "dev-only-internal-long-random-secret-replace-in-user-secrets" + }, + "AccessTokenMinutes": 10, + "RefreshTokenDays": 30, + "PasswordResetTokenHours": 2, + "RequireConfirmedEmail": false + } +} +``` + +Rules: + +- Do not commit production signing secrets. +- In development use user-secrets or `appsettings.Development.json`. +- Validate both signing key lengths on startup. +- External and Internal keys must be different. +- External and Internal issuers/audiences must be different. +- Keep short access tokens and longer refresh tokens. +- Refresh tokens are returned in the response body for Sprint 01. + +--- + +## Service Design + +### Identity Registration + +In `Infrastructure.DependencyInjection`, register Identity Core: + +```csharp +services + .AddIdentityCore(options => + { + options.User.RequireUniqueEmail = true; + options.Password.RequiredLength = 12; + options.Password.RequireUppercase = true; + options.Password.RequireLowercase = true; + options.Password.RequireDigit = true; + options.Password.RequireNonAlphanumeric = false; + options.Lockout.MaxFailedAccessAttempts = 5; + }) + .AddRoles() + .AddEntityFrameworkStores() + .AddDefaultTokenProviders(); +``` + +Password validation must follow US033/US034 exactly: 12-20 characters, uppercase, lowercase, and numbers. Symbols are allowed by Identity unless another validator rejects them, but they are not required. + +### Token Issuer + +New application abstraction: + +`src/CCE.Application/Identity/Auth/ITokenService.cs` + +Responsibilities: + +- Build JWT access token with `sub`, `email`, `preferred_username`, `roles`, `jti`. +- Include permission claims only if current authorization expects them in token. Otherwise keep `RoleToPermissionClaimsTransformer` responsible for permission expansion. +- Generate cryptographically random refresh token. +- Hash refresh token before persistence. + +Infrastructure implementation: + +`src/CCE.Infrastructure/Identity/LocalTokenService.cs` + +### Refresh Token Repository + +New application abstraction: + +`src/CCE.Application/Identity/Auth/IRefreshTokenRepository.cs` + +Methods: + +- `AddAsync(RefreshToken token, CancellationToken ct)` +- `FindByHashAsync(string tokenHash, CancellationToken ct)` +- `RevokeAsync(...)` +- `RevokeFamilyAsync(Guid tokenFamilyId, ...)` +- `RevokeAllForUserAsync(Guid userId, ...)` + +Infrastructure implementation: + +`src/CCE.Infrastructure/Identity/RefreshTokenRepository.cs` + +--- + +## Application Layer + +Create folder: + +`src/CCE.Application/Identity/Auth` + +Commands and DTOs: + +- `RegisterUserCommand` +- `LoginCommand` +- `RefreshTokenCommand` +- `ForgotPasswordCommand` +- `ResetPasswordCommand` +- `LogoutCommand` +- `AuthTokenDto` +- `AuthUserDto` +- `AuthMessageDto` + +Validators: + +- Register: all fields required, names max 50 letters-only, email max 100 valid email, phone max 15, password 12-20 with uppercase/lowercase/number, confirm matches. +- Login: email/password required. +- Refresh: token required. +- Forgot password: email required and valid. +- Reset password: email/token/new password/confirm required, password 12-20 with uppercase/lowercase/number, confirm matches. +- Logout: refresh token required. + +Handlers: + +- Use `UserManager` for create, password check, reset token generation/validation, and security stamp updates. +- Use `RoleManager` or direct role assignment through `UserManager.AddToRoleAsync`. +- Return `Result` with localized `Error` objects. +- Never return different login errors for "email not found" versus "password wrong"; both map to `INVALID_CREDENTIALS`. +- Revoke refresh tokens after reset password and after security-sensitive account changes. + +--- + +## API Layer + +New endpoint files: + +`src/CCE.Api.External/Endpoints/AuthEndpoints.cs` + +`src/CCE.Api.Internal/Endpoints/AuthEndpoints.cs` + +Register in `src/CCE.Api.External/Program.cs` and `src/CCE.Api.Internal/Program.cs`: + +```csharp +app.MapAuthEndpoints(); +``` + +Endpoint group: + +```csharp +var auth = app.MapGroup("/api/auth").WithTags("Auth"); +``` + +Endpoints: + +- `POST /register` anonymous +- `POST /login` anonymous +- `POST /refresh` anonymous +- `POST /forgot-password` anonymous +- `POST /reset-password` anonymous +- `POST /logout` anonymous or authorized plus body refresh token + +External and Internal share the same endpoint contract, but issue tokens with their own issuer, audience, and signing key. A token minted by External must fail validation on Internal, and the reverse must also fail. + +Keep the existing `/dev/*` endpoints for `Auth:DevMode`. + +Decision: deprecate or keep `/api/users/register`. + +- Recommended: keep it temporarily and forward it to the new `RegisterUserCommand` so existing frontend calls do not break. +- Add a comment marking it as compatibility surface. + +--- + +## JWT Validation Strategy + +Current `AddCceJwtAuth` validates Entra JWTs through Microsoft.Identity.Web. + +Use local JWT validation for both APIs, with different key material and token metadata per API. + +External: + +- Issuer: `LocalAuth:External:Issuer` +- Audience: `LocalAuth:External:Audience` +- Signing key: `LocalAuth:External:SigningKey` + +Internal: + +- Issuer: `LocalAuth:Internal:Issuer` +- Audience: `LocalAuth:Internal:Audience` +- Signing key: `LocalAuth:Internal:SigningKey` + +Implementation approach: + +- Refactor `AddCceJwtAuth` to accept an API audience/profile, e.g. `AddCceJwtAuth(configuration, LocalAuthApi.External)` and `AddCceJwtAuth(configuration, LocalAuthApi.Internal)`. +- Validate issuer, audience, lifetime, and signing key. +- Keep `MapInboundClaims = false`, `NameClaimType = "preferred_username"`, and `RoleClaimType = "roles"`. +- Keep the dev auth shim when `Auth:DevMode=true`. +- If Entra tokens still need to coexist later, add a policy scheme after Sprint 01. Sprint 01 local auth uses the local JWT scheme as the primary bearer scheme. + +Validation tests must prove External tokens are rejected by Internal and Internal tokens are rejected by External. + +--- + +## Password Recovery Email + +Reuse `IEmailSender`. + +New service: + +`src/CCE.Application/Identity/Auth/IPasswordResetEmailService.cs` + +or infrastructure service if email composition is infrastructure-owned: + +`src/CCE.Infrastructure/Identity/PasswordResetEmailService.cs` + +Flow: + +1. Handler receives `ForgotPasswordCommand`. +2. Finds user by email. +3. Generates token via `UserManager.GeneratePasswordResetTokenAsync(user)`. +4. Base64Url encodes the token. +5. Builds reset URL from config, e.g. `Frontend:PasswordResetUrl`. +6. Sends email. + +Security: + +- Do not log reset tokens. +- Token lifetime from `LocalAuth:PasswordResetTokenHours`. +- After successful reset, call `UpdateSecurityStampAsync(user)` and revoke refresh tokens. + +--- + +## Error Codes + +Map BRD codes to application errors: + +| BRD code | Application code | HTTP | +|---|---|---| +| ERR013 | `GENERAL_VALIDATION_ERROR` / field details | 400 | +| ERR019 | `IDENTITY_REGISTRATION_FAILED` | 500 or 422 | +| ERR020 | `IDENTITY_INVALID_CREDENTIALS` | 401 | +| ERR021 | `IDENTITY_LOGIN_FAILED` | 500 | +| ERR022 | `IDENTITY_USER_NOT_FOUND` | 404 or generic 200 for anti-enumeration | +| ERR023 | `IDENTITY_PASSWORD_RECOVERY_FAILED` | 500 | +| ERR024 | `IDENTITY_LOGOUT_FAILED` | 500 | +| CON017 | `IDENTITY_USER_CREATED` | 201 | +| CON014 | `IDENTITY_PASSWORD_RESET` | 200 | +| CON015 | `IDENTITY_LOGOUT_SUCCESS` | 200 | + +Add missing constants to: + +`src/CCE.Application/Errors/ApplicationErrors.cs` + +Add localization entries when the localization plan is implemented. + +--- + +## Testing Plan + +Application tests: + +- Register succeeds and creates `cce-user`. +- Register rejects duplicate email. +- Register validates required fields and password confirmation. +- Login returns invalid credentials for unknown email and wrong password. +- Login returns access token + refresh token for valid credentials. +- Refresh rotates token and revokes old token. +- Reuse of old refresh token revokes token family. +- Forgot password sends email for existing user. +- Reset password updates password and revokes existing refresh tokens. +- Logout revokes refresh token. + +Infrastructure tests: + +- `RefreshTokenConfiguration` creates expected indexes. +- `LocalTokenService` creates valid JWT claims and expiry. +- `RefreshTokenRepository` stores hashes only. + +API integration tests: + +- `POST /api/auth/register` -> `201`. +- `POST /api/auth/login` -> `200` with usable bearer token. +- Call protected `/api/me` with local access token -> `200`. +- External access token is rejected by an Internal protected endpoint. +- Internal access token is rejected by an External protected endpoint. +- `POST /api/auth/refresh` -> old refresh token cannot be reused. +- `POST /api/auth/logout` -> refresh token cannot be used. +- Password reset flow using fake email sender. + +Run: + +```bash +dotnet test tests/CCE.Application.Tests +dotnet test tests/CCE.Infrastructure.Tests +dotnet test tests/CCE.Api.IntegrationTests +dotnet build CCE.sln +``` + +--- + +## Implementation Phases + +### Phase 1 - Foundation + +- Add `LocalAuthOptions`. +- Register Identity Core with `UserManager`, roles, EF stores, token providers. +- Extend `User` with Sprint 01 profile fields. +- Add `RefreshToken` entity, EF configuration, repository, migration. +- Add error constants. + +### Phase 2 - Token Services + +- Implement `ITokenService`. +- Implement local JWT issuing. +- Implement refresh-token generation, hashing, persistence, rotation, family revocation. +- Update auth registration for local JWT validation on External and Internal APIs, using separate config profiles and keys. + +### Phase 3 - Commands + +- Implement register/login/refresh/logout command DTOs, validators, handlers. +- Keep handlers returning `Result`. +- Assign default `cce-user` role at registration. + +### Phase 4 - Password Recovery + +- Implement forgot-password and reset-password commands. +- Wire `IEmailSender`. +- Add reset URL configuration. +- Revoke refresh tokens after reset. + +### Phase 5 - Endpoints + +- Add `AuthEndpoints`. +- Register in External and Internal `Program.cs`. +- Move or forward `/api/users/register` compatibility path. +- Ensure Swagger shows request/response contracts. + +### Phase 6 - Tests & Hardening + +- Add unit, infrastructure, and integration tests. +- Verify lockout behavior. +- Verify no refresh token plaintext is stored. +- Verify token reuse detection. +- Run full build and tests with warnings as errors. + +--- + +## Accepted Decisions + +1. Registration does not auto-login. The user logs in separately after account creation. +2. Forgot-password returns success even when the email is unknown. +3. Local JWT auth applies to both External and Internal APIs, with different signing keys, issuers, and audiences. +4. Refresh tokens are returned in the response body for now. +5. Password validation follows the stories: 12-20 characters with uppercase, lowercase, and numbers. Symbols are not required. + +--- + +## Acceptance Checklist + +- [ ] User can create an account with all US033 fields. +- [ ] Duplicate email is rejected. +- [ ] User can login with email/password. +- [ ] Login returns short-lived JWT access token and long-lived refresh token. +- [ ] Protected endpoints accept the local access token. +- [ ] External and Internal tokens are not interchangeable. +- [ ] Refresh rotates refresh tokens. +- [ ] Reused revoked refresh token is detected and invalidates the token family. +- [ ] Logout revokes the submitted refresh token. +- [ ] Forgot password sends reset email/link. +- [ ] Reset password allows login with the new password. +- [ ] Reset password revokes existing refresh tokens. +- [ ] `dotnet build CCE.sln` passes with warnings as errors. +- [ ] Relevant tests pass. diff --git a/backend/docs/plans/unit-of-work-implementation-plan.md b/backend/docs/plans/unit-of-work-implementation-plan.md new file mode 100644 index 00000000..f8a44959 --- /dev/null +++ b/backend/docs/plans/unit-of-work-implementation-plan.md @@ -0,0 +1,582 @@ +# Unit of Work & Repository Implementation Plan + +## How to Adopt in Another Solution + +1. Replace all `[YourAppName]` occurrences with your root namespace. +2. Ensure your DbContext (`AppDbContext`) inherits from `DbContext` and is registered in DI. +3. All entities must inherit from `BaseEntity` (or adjust the `where T : BaseEntity` constraint to your own base type). +4. Install `AutoMapper` and `AutoMapper.Extensions.Microsoft.DependencyInjection` if you want the projection-based paging methods. +5. Register `IUnitOfWork`, `IRepository<>`, and `AutoMapper` in your Infrastructure DI module. + +--- + +## Overview + +This plan implements the **Unit of Work** and **Generic Repository** patterns using EF Core. The repository is read-optimized (`AsNoTracking` by default) and supports paging, filtering, projection, and eager loading. The Unit of Work wraps the DbContext and exposes explicit transaction control. + +**Packages required:** `AutoMapper`, `AutoMapper.Extensions.Microsoft.DependencyInjection`, `Microsoft.EntityFrameworkCore` + +--- + +### 1. Create `IBaseEntity` Interface (Domain Layer) + +**File:** `Domain/Entities/IBaseEntity.cs` + +```csharp +namespace [YourAppName].Domain.Entities; + +public interface IBaseEntity +{ + Guid Id { get; set; } + DateTime CreatedAt { get; set; } + Guid? CreatedBy { get; set; } + DateTime? UpdatedAt { get; set; } + Guid? UpdatedBy { get; set; } + bool IsDeleted { get; set; } + DateTime? DeletedAt { get; set; } +} +``` + +--- + +### 2. Create `BaseEntity` Abstract Class (Domain Layer) + +**File:** `Domain/Entities/BaseEntity.cs` + +```csharp +using [YourAppName].Domain.Events; + +namespace [YourAppName].Domain.Entities; + +public abstract class BaseEntity : IBaseEntity +{ + private readonly List _domainEvents = new(); + + public Guid Id { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public Guid? CreatedBy { get; set; } + public DateTime? UpdatedAt { get; set; } + public Guid? UpdatedBy { get; set; } + public bool IsDeleted { get; set; } + public DateTime? DeletedAt { get; set; } + + public IReadOnlyCollection DomainEvents => _domainEvents.AsReadOnly(); + + public void AddDomainEvent(IDomainEvent domainEvent) + { + _domainEvents.Add(domainEvent); + } + + public void ClearDomainEvents() + { + _domainEvents.Clear(); + } + + public void MarkUpdated() => UpdatedAt = DateTime.UtcNow; + public void SoftDelete() { IsDeleted = true; DeletedAt = DateTime.UtcNow; } +} +``` + +> **Note:** If you do not use domain events, remove the `IDomainEvent` references and the `_domainEvents` list. + +--- + +### 3. Create `BasePagedQuery` (Domain Layer) + +**File:** `Domain/Common/BasePagedQuery.cs` + +```csharp +namespace [YourAppName].Domain.Common; + +public abstract class BasePagedQuery +{ + public int PageIndex { get; set; } = 1; + public int PageSize { get; set; } = 10; + public string? SortBy { get; set; } + public string? SortDirection { get; set; } = "asc"; +} +``` + +--- + +### 4. Create `PaginatedList` (Domain Layer) + +**File:** `Domain/PaginatedList.cs` + +```csharp +namespace [YourAppName].Domain; + +public class PaginatedList +{ + public IReadOnlyList Items { get; } + public int PageIndex { get; } + public int PageSize { get; } + public int TotalCount { get; } + public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize); + public bool HasPreviousPage => PageIndex > 1; + public bool HasNextPage => PageIndex < TotalPages; + + private PaginatedList(List items, int count, int pageIndex, int pageSize) + { + Items = items.AsReadOnly(); + PageIndex = Math.Max(1, pageIndex); + PageSize = Math.Max(1, pageSize); + TotalCount = count; + } + + public static PaginatedList Create(IEnumerable items, int count, int pageIndex, int pageSize) + { + var itemList = items.ToList(); + return new PaginatedList(itemList, count, pageIndex, pageSize); + } +} +``` + +--- + +### 5. Create `ApplyOrdering` Extension (Domain Layer) + +**File:** `Domain/Common/LinqExtensions.cs` + +```csharp +using System.Linq.Expressions; +using System.Reflection; + +namespace [YourAppName].Domain.Common; + +public static class LinqExtensions +{ + public static IQueryable ApplyOrdering(this IQueryable source, string propertyPath, bool isDescending) + { + if (string.IsNullOrWhiteSpace(propertyPath)) + return source; + + var param = Expression.Parameter(typeof(T), "e"); + Expression? body = param; + + foreach (var member in propertyPath.Split('.')) + { + body = Expression.PropertyOrField(body!, member); + } + + var lambdaType = typeof(Func<,>).MakeGenericType(typeof(T), body!.Type); + var lambda = Expression.Lambda(lambdaType, body, param); + + var methodName = isDescending ? "OrderByDescending" : "OrderBy"; + + var resultExp = Expression.Call( + typeof(Queryable), + methodName, + [typeof(T), body.Type], + source.Expression, + Expression.Quote(lambda)); + + return source.Provider.CreateQuery(resultExp); + } +} +``` + +--- + +### 6. Create `IUnitOfWork` Interface (Domain Layer) + +**File:** `Domain/Interfaces/IUnitOfWork.cs` + +```csharp +namespace [YourAppName].Domain.Interfaces; + +public interface IUnitOfWork : IAsyncDisposable +{ + Task SaveChangesAsync(CancellationToken ct = default); + Task BeginTransactionAsync(CancellationToken ct = default); + Task CommitTransactionAsync(CancellationToken ct = default); + Task RollbackTransactionAsync(CancellationToken ct = default); +} +``` + +--- + +### 7. Create `IRepository` Interface (Domain Layer) + +**File:** `Domain/Interfaces/IRepository.cs` + +```csharp +using [YourAppName].Domain.Common; +using [YourAppName].Domain.Entities; +using System.Linq.Expressions; + +namespace [YourAppName].Domain.Interfaces; + +public interface IRepository where T : BaseEntity +{ + Task GetByIdAsync(Guid id, CancellationToken ct = default); + Task FirstOrDefaultAsync(Expression> predicate, CancellationToken ct = default); + Task ExistsAsync(Expression> predicate, CancellationToken ct = default); + Task> ListAllAsync(CancellationToken ct = default); + IQueryable Query(Expression>? predicate = null, bool asNoTracking = true); + IQueryable QueryInclude(string includeProperties, Expression>? predicate = null, bool asNoTracking = true); + Task> GetPagedAsync(BasePagedQuery pagedQuery, Expression>? filter, CancellationToken ct = default); + Task> GetPagedAsync(BasePagedQuery pagedQuery, Expression>? filter, Expression> selectExpression, CancellationToken ct = default); + Task AddAsync(T entity, CancellationToken ct = default); + Task AddRangeAsync(IEnumerable entities, CancellationToken ct = default); + void Update(T entity); + void Remove(T entity); + void RemoveRange(IEnumerable entities); + Task CountAsync(Expression>? predicate = null, CancellationToken ct = default); +} +``` + +--- + +### 8. Create `UnitOfWork` Implementation (Infrastructure Layer) + +**File:** `Infrastructure/Persistence/UnitOfWork.cs` + +```csharp +using [YourAppName].Domain.Interfaces; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; + +namespace [YourAppName].Infrastructure.Persistence; + +public class UnitOfWork : IUnitOfWork +{ + private readonly AppDbContext _context; + private IDbContextTransaction? _currentTx; + + public UnitOfWork(AppDbContext context) + { + _context = context; + } + + public async Task SaveChangesAsync(CancellationToken ct = default) + => await _context.SaveChangesAsync(ct); + + public async Task BeginTransactionAsync(CancellationToken ct = default) + { + if (_currentTx != null) return; + _currentTx = await _context.Database.BeginTransactionAsync(ct); + } + + public async Task CommitTransactionAsync(CancellationToken ct = default) + { + if (_currentTx == null) return; + await _context.SaveChangesAsync(ct); + await _currentTx.CommitAsync(ct); + await _currentTx.DisposeAsync(); + _currentTx = null; + } + + public async Task RollbackTransactionAsync(CancellationToken ct = default) + { + if (_currentTx == null) return; + try + { + await _currentTx.RollbackAsync(ct); + } + finally + { + await _currentTx.DisposeAsync(); + _currentTx = null; + } + } + + public async ValueTask DisposeAsync() + { + if (_currentTx != null) + { + await _currentTx.DisposeAsync(); + _currentTx = null; + } + } +} +``` + +--- + +### 9. Create `BaseRepository` Implementation (Infrastructure Layer) + +**File:** `Infrastructure/Persistence/BaseRepository.cs` + +```csharp +using AutoMapper; +using AutoMapper.QueryableExtensions; +using [YourAppName].Domain; +using [YourAppName].Domain.Common; +using [YourAppName].Domain.Entities; +using [YourAppName].Domain.Interfaces; +using Microsoft.EntityFrameworkCore; +using System.Linq.Expressions; + +namespace [YourAppName].Infrastructure.Persistence; + +public class BaseRepository(AppDbContext context, IConfigurationProvider config) : IRepository where T : BaseEntity +{ + public virtual async Task GetByIdAsync(Guid id, CancellationToken ct = default) + => await context.Set().AsNoTracking().FirstOrDefaultAsync(e => e.Id == id, ct); + + public virtual async Task FirstOrDefaultAsync(Expression> predicate, CancellationToken ct = default) + => await context.Set().AsNoTracking().FirstOrDefaultAsync(predicate, ct); + + public virtual async Task ExistsAsync(Expression> predicate, CancellationToken ct = default) + => await context.Set().AnyAsync(predicate, ct); + + public virtual async Task> ListAllAsync(CancellationToken ct = default) + => await context.Set().AsNoTracking().ToListAsync(ct); + + public virtual IQueryable Query(Expression>? predicate = null, bool asNoTracking = true) + { + IQueryable query = context.Set(); + if (asNoTracking) query = query.AsNoTracking(); + if (predicate != null) query = query.Where(predicate); + return query; + } + + public virtual IQueryable QueryInclude( + string includeProperties, + Expression>? predicate = null, + bool asNoTracking = true) + { + IQueryable query = context.Set(); + if (asNoTracking) query = query.AsNoTracking(); + if (predicate != null) query = query.Where(predicate); + + if (!string.IsNullOrWhiteSpace(includeProperties)) + { + foreach (var includeProperty in includeProperties.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)) + { + query = query.Include(includeProperty.Trim()); + } + } + + return query; + } + + public virtual async Task> GetPagedAsync( + BasePagedQuery pagedQuery, + Expression>? filter, + CancellationToken ct = default) + { + if (pagedQuery == null) throw new ArgumentNullException(nameof(pagedQuery)); + + var query = context.Set().AsQueryable(); + query = query.AsNoTracking(); + if (filter != null) query = query.Where(filter); + + var total = await query.CountAsync(ct); + + var pageIndex = Math.Max(pagedQuery.PageIndex, 1); + var pageSize = Math.Max(pagedQuery.PageSize, 1); + var skip = (pageIndex - 1) * pageSize; + + var sortBy = string.IsNullOrWhiteSpace(pagedQuery.SortBy) ? null : pagedQuery.SortBy; + var sortDir = string.IsNullOrWhiteSpace(pagedQuery.SortDirection) ? "asc" : pagedQuery.SortDirection.ToLowerInvariant(); + + if (!string.IsNullOrEmpty(sortBy)) + { + try + { + query = query.ApplyOrdering(sortBy, sortDir == "desc"); + } + catch + { + // Fallback: ignore invalid sort + } + } + + var items = await query + .Skip(skip) + .Take(pageSize) + .ProjectTo(config) + .ToListAsync(ct); + + return PaginatedList.Create(items, total, pageIndex, pageSize); + } + + public virtual async Task> GetPagedAsync( + BasePagedQuery pagedQuery, + Expression>? filter, + Expression> selectExpression, + CancellationToken ct = default) + { + if (pagedQuery == null) throw new ArgumentNullException(nameof(pagedQuery)); + if (selectExpression == null) throw new ArgumentNullException(nameof(selectExpression)); + + var query = context.Set().AsQueryable().AsNoTracking(); + + if (filter != null) + query = query.Where(filter); + + var total = await query.CountAsync(ct); + + var pageIndex = Math.Max(pagedQuery.PageIndex, 1); + var pageSize = Math.Max(pagedQuery.PageSize, 1); + var skip = (pageIndex - 1) * pageSize; + + var sortBy = string.IsNullOrWhiteSpace(pagedQuery.SortBy) ? null : pagedQuery.SortBy; + var sortDir = string.IsNullOrWhiteSpace(pagedQuery.SortDirection) ? "asc" : pagedQuery.SortDirection.ToLowerInvariant(); + + if (!string.IsNullOrEmpty(sortBy)) + { + try + { + query = query.ApplyOrdering(sortBy, sortDir == "desc"); + } + catch + { + // Fallback: ignore invalid sort + } + } + + var items = await query + .Skip(skip) + .Take(pageSize) + .Select(selectExpression) + .ToListAsync(ct); + + return PaginatedList.Create(items, total, pageIndex, pageSize); + } + + public virtual async Task AddAsync(T entity, CancellationToken ct = default) + => await context.Set().AddAsync(entity, ct); + + public virtual async Task AddRangeAsync(IEnumerable entities, CancellationToken ct = default) + => await context.Set().AddRangeAsync(entities, ct); + + public virtual void Update(T entity) + => context.Set().Update(entity); + + public virtual void Remove(T entity) + => context.Set().Remove(entity); + + public virtual void RemoveRange(IEnumerable entities) + => context.Set().RemoveRange(entities); + + public virtual async Task CountAsync(Expression>? predicate = null, CancellationToken ct = default) + => predicate == null ? await context.Set().CountAsync(ct) : await context.Set().CountAsync(predicate, ct); +} +``` + +--- + +### 10. Register in DI (Infrastructure Layer) + +**File:** `Infrastructure/ServiceCollectionExtensions.cs` (or your own registration class) + +```csharp +using [YourAppName].Domain.Interfaces; +using [YourAppName].Infrastructure.Persistence; +using System.Reflection; + +namespace [YourAppName].Infrastructure; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection RegisterInfrastructure(this IServiceCollection services, IConfiguration configuration) + { + services.AddAutoMapper(Assembly.GetExecutingAssembly()); + services.AddScoped(typeof(IRepository<>), typeof(BaseRepository<>)); + services.AddScoped(); + + // ... other registrations + + return services; + } +} +``` + +--- + +### 11. Handler Usage Pattern (Application Layer) + +Inject both `IRepository` and `IUnitOfWork`. Use the repository for queries and mutations, then call `_unitOfWork.SaveChangesAsync(ct)` once at the end of the handler. + +```csharp +public class CreateContentCommandHandler : IRequestHandler> +{ + private readonly IRepository _contentRepository; + private readonly IUnitOfWork _unitOfWork; + private readonly ILogger _logger; + + public CreateContentCommandHandler( + IRepository contentRepository, + IUnitOfWork unitOfWork, + ILogger logger) + { + _contentRepository = contentRepository; + _unitOfWork = unitOfWork; + _logger = logger; + } + + public async Task> Handle(CreateContentCommand request, CancellationToken ct) + { + var exists = await _contentRepository.ExistsAsync(c => c.Title == request.Title, ct); + if (exists) + return Result.Failure(new Error( + ApplicationErrors.Content.ALREADY_EXISTS, + "...", "...", ErrorType.Conflict)); + + var content = Content.Create(request.Title, request.Body, ...); + await _contentRepository.AddAsync(content, ct); + await _unitOfWork.SaveChangesAsync(ct); + + _logger.LogInformation("Content {ContentId} created", content.Id); + return Result.Success(new CreateSuccessDto(content.Id)); + } +} +``` + +--- + +### 12. Explicit Transaction Usage Pattern (Application Layer) + +Use `BeginTransactionAsync`, `CommitTransactionAsync`, and `RollbackTransactionAsync` when you need to coordinate multiple operations atomically. + +```csharp +public async Task> Handle(ComplexCommand request, CancellationToken ct) +{ + await _unitOfWork.BeginTransactionAsync(ct); + try + { + await _repositoryA.AddAsync(entityA, ct); + await _repositoryB.AddAsync(entityB, ct); + await _unitOfWork.SaveChangesAsync(ct); + await _unitOfWork.CommitTransactionAsync(ct); + + return Result.Success(); + } + catch + { + await _unitOfWork.RollbackTransactionAsync(ct); + throw; + } +} +``` + +--- + +## Lifetime Reference + +| Service | Interface | Implementation | Lifetime | Reason | +|---------|-----------|----------------|----------|--------| +| `IUnitOfWork` | `Domain/Interfaces` | `Infrastructure/Persistence/UnitOfWork` | Scoped | Bound to request DbContext | +| `IRepository` | `Domain/Interfaces` | `Infrastructure/Persistence/BaseRepository` | Scoped | Bound to request DbContext | +| `AppDbContext` | — | `Infrastructure/Persistence/AppDbContext` | Scoped | EF Core default | + +--- + +## Read-Optimized Defaults + +| Method | Tracking | Notes | +|--------|----------|-------| +| `GetByIdAsync` | `AsNoTracking` | For reads only | +| `FirstOrDefaultAsync` | `AsNoTracking` | For reads only | +| `ListAllAsync` | `AsNoTracking` | For reads only | +| `Query` | `asNoTracking = true` | Override when updating queried entities | +| `QueryInclude` | `asNoTracking = true` | Override when updating queried entities | +| `GetPagedAsync` | `AsNoTracking` | Always read-only | +| `AddAsync` | N/A | Marks entity Added | +| `Update` | N/A | Marks entity Modified | +| `Remove` | N/A | Marks entity Deleted | + +> **Rule:** If you need to mutate an entity after querying it, call `Query(predicate, asNoTracking: false)` or attach the entity manually. diff --git a/backend/docs/plans/whereif-and-paged-dto-list-implementation-plan.md b/backend/docs/plans/whereif-and-paged-dto-list-implementation-plan.md new file mode 100644 index 00000000..d5f82067 --- /dev/null +++ b/backend/docs/plans/whereif-and-paged-dto-list-implementation-plan.md @@ -0,0 +1,358 @@ +# WhereIf & Paged DTO List Implementation Plan + +## How to Adopt in Another Solution + +1. Replace all `[YourAppName]` occurrences with your root namespace. +2. Copy `PredicateBuilder.cs` into your Domain layer (no external dependencies). +3. Ensure `BasePagedQuery`, `PaginatedList`, `IRepository`, and `BaseRepository` are already in place (see the Unit of Work plan). +4. Ensure `AutoMapper` and `AutoMapper.Extensions.Microsoft.DependencyInjection` are installed and configured. +5. For every paged list query, create a `Query` inheriting from `BasePagedQuery`, a `Dto` record, and a `QueryHandler`. + +--- + +## Overview + +This plan implements two complementary patterns: + +1. **`PredicateBuilder.WhereIf`** — A lightweight expression-tree builder that lets you compose conditional `Where` clauses without branching `if` statements. +2. **`GetPagedAsync`** — A generic repository method that projects, filters, sorts, and paginates entity data into DTOs in a single database round-trip. + +Together they produce clean, readable query handlers like this: + +```csharp +var filter = PredicateBuilder.True() + .WhereIf(!string.IsNullOrWhiteSpace(request.SearchTerm), + c => c.Title.Contains(request.SearchTerm!)) + .WhereIf(request.AuthorId.HasValue, + c => c.AuthorId == request.AuthorId!.Value); + +var result = await _repository.GetPagedAsync(request, filter, ct); +``` + +**Packages required:** `AutoMapper`, `AutoMapper.Extensions.Microsoft.DependencyInjection` + +--- + +### 1. Create `PredicateBuilder` (Domain Layer) + +**File:** `Domain/Common/PredicateBuilder.cs` + +```csharp +using System.Linq.Expressions; + +namespace [YourAppName].Domain.Common; + +public static class PredicateBuilder +{ + public static Expression> True() => _ => true; + public static Expression> False() => _ => false; + + public static Expression> And( + this Expression> expr1, + Expression> expr2) + { + var parameter = Expression.Parameter(typeof(T)); + var body = Expression.AndAlso( + Expression.Invoke(expr1, parameter), + Expression.Invoke(expr2, parameter)); + return Expression.Lambda>(body, parameter); + } + + public static Expression> Or( + this Expression> expr1, + Expression> expr2) + { + var parameter = Expression.Parameter(typeof(T)); + var body = Expression.OrElse( + Expression.Invoke(expr1, parameter), + Expression.Invoke(expr2, parameter)); + return Expression.Lambda>(body, parameter); + } + + public static Expression> WhereIf( + this Expression> query, + bool condition, + Expression> predicate) + { + return condition ? query.And(predicate) : query; + } +} +``` + +--- + +### 2. How `WhereIf` Works + +| Step | Code | Result Expression | +|------|------|-----------------| +| 1 | `PredicateBuilder.True()` | `c => true` | +| 2 | `.WhereIf(hasSearch, c => c.Title.Contains(term))` | `c => true && c.Title.Contains(term)` (if true) or `c => true` (if false) | +| 3 | `.WhereIf(hasAuthor, c => c.AuthorId == id)` | Composed `And` of all active predicates | + +**Benefits:** +- No imperative `if` blocks polluting the handler. +- The entire filter is a single `Expression>` ready for EF Core translation. +- Easy to read: each filter condition is one fluent line. + +--- + +### 3. Repository Paging Methods (Infrastructure Layer) + +These methods are part of `BaseRepository` (see the Unit of Work plan). They are repeated here for reference. + +**Projection-based paging** (requires AutoMapper configuration): + +```csharp +public virtual async Task> GetPagedAsync( + BasePagedQuery pagedQuery, + Expression>? filter, + CancellationToken ct = default) +``` + +**Manual-select paging** (no AutoMapper required, explicit projection): + +```csharp +public virtual async Task> GetPagedAsync( + BasePagedQuery pagedQuery, + Expression>? filter, + Expression> selectExpression, + CancellationToken ct = default) +``` + +Both methods: +1. Apply `AsNoTracking`. +2. Apply the `filter` expression. +3. Execute `CountAsync` for total records. +4. Apply dynamic sorting via `ApplyOrdering(sortBy, isDescending)`. +5. Skip/Take for pagination. +6. Project to `TDto` (AutoMapper `ProjectTo` or manual `Select`). +7. Return `PaginatedList`. + +--- + +### 4. AutoMapper Profile (Application Layer) + +When using the projection-based `GetPagedAsync`, AutoMapper must know how to map `TEntity` → `TDto`. + +**File:** `Application/Features/Contents/Mapping/ContentProfile.cs` + +```csharp +using AutoMapper; +using [YourAppName].Application.Features.Contents.Dtos; +using [YourAppName].Domain.Entities.Content; + +namespace [YourAppName].Application.Features.Contents.Mapping; + +public class ContentProfile : Profile +{ + public ContentProfile() + { + CreateMap(); + } +} +``` + +> **Note:** AutoMapper scans the Assembly for `Profile` classes at startup if you call `services.AddAutoMapper(Assembly.GetExecutingAssembly())` in DI. + +--- + +### 5. Create the DTO (Application Layer) + +**File:** `Application/Features/Contents/Dtos/ContentDto.cs` + +```csharp +namespace [YourAppName].Application.Features.Contents.Dtos; + +public record ContentDto( + Guid Id, + string Title, + string Body, + string? Summary, + string ContentType, + Guid AuthorId, + string Status, + string? FeaturedImageUrl, + int ViewCount, + int LikeCount, + string[] Tags, + string? Category, + DateTime? PublishedAt, + DateTime? ExpiresAt, + bool IsFeatured, + DateTime CreatedAt +); +``` + +--- + +### 6. Create the Paged Query (Application Layer) + +**File:** `Application/Features/Contents/Queries/GetContents/GetContentsQuery.cs` + +```csharp +using [YourAppName].Application.Contracts; +using [YourAppName].Application.Features.Contents.Dtos; +using [YourAppName].Domain; +using [YourAppName].Domain.Common; +using MediatR; + +namespace [YourAppName].Application.Features.Contents.Queries.GetContents; + +public class GetContentsQuery : BasePagedQuery, IQuery>> +{ + public string? SearchTerm { get; init; } + public string? Status { get; init; } + public Guid? AuthorId { get; init; } + + public GetContentsQuery() + { + PageIndex = 1; + PageSize = 10; + } +} +``` + +> **Pattern:** The query inherits from `BasePagedQuery` (provides `PageIndex`, `PageSize`, `SortBy`, `SortDirection`) and implements `IQuery>>`. Default page values are set in the constructor. + +--- + +### 7. Create the Query Handler (Application Layer) + +**File:** `Application/Features/Contents/Queries/GetContents/GetContentsQueryHandler.cs` + +```csharp +using [YourAppName].Application.Contracts; +using [YourAppName].Application.Features.Contents.Dtos; +using [YourAppName].Domain; +using [YourAppName].Domain.Common; +using [YourAppName].Domain.Entities.Content; +using [YourAppName].Domain.Interfaces; +using MediatR; + +namespace [YourAppName].Application.Features.Contents.Queries.GetContents; + +public class GetContentsQueryHandler(IRepository contentRepository) + : IQueryHandler>> +{ + public async Task>> Handle(GetContentsQuery request, CancellationToken ct) + { + var filter = PredicateBuilder.True() + .WhereIf(!string.IsNullOrWhiteSpace(request.SearchTerm), + c => c.Title.Contains(request.SearchTerm!) || c.Body.Contains(request.SearchTerm!)) + .WhereIf(!string.IsNullOrWhiteSpace(request.Status), + c => c.Status == request.Status) + .WhereIf(request.AuthorId.HasValue, + c => c.AuthorId == request.AuthorId!.Value); + + var result = await contentRepository.GetPagedAsync(request, filter, ct); + return Result>.Success(result); + } +} +``` + +--- + +### 8. Alternative: Manual Select Paging + +If you prefer not to use AutoMapper projection, use the overload with an explicit `Select` expression: + +```csharp +var filter = PredicateBuilder.True() + .WhereIf(!string.IsNullOrWhiteSpace(request.Status), + c => c.Status == request.Status); + +var result = await _repository.GetPagedAsync( + request, + filter, + c => new ContentDto( + c.Id, + c.Title, + c.Body, + c.Summary, + c.ContentType, + c.AuthorId, + c.Status, + c.FeaturedImageUrl, + c.ViewCount, + c.LikeCount, + c.Tags, + c.Category, + c.PublishedAt, + c.ExpiresAt, + c.IsFeatured, + c.CreatedAt), + ct); +``` + +> **Trade-off:** AutoMapper projection is less code and keeps DTO mapping centralized in Profiles. Manual `Select` is more explicit and avoids AutoMapper configuration overhead for simple cases. + +--- + +### 9. More `WhereIf` Examples + +**Notifications — multiple nullable filters:** + +```csharp +var filter = PredicateBuilder.True() + .WhereIf(request.UserId.HasValue, n => n.UserId == request.UserId!.Value) + .WhereIf(!string.IsNullOrWhiteSpace(request.Status), n => n.Status == request.Status) + .WhereIf(!string.IsNullOrWhiteSpace(request.NotificationType), n => n.NotificationType == request.NotificationType) + .WhereIf(request.IsRead.HasValue, n => (request.IsRead!.Value ? n.ReadAt != null : n.ReadAt == null)); +``` + +**Platform Settings — boolean flag + string filters:** + +```csharp +var filter = PredicateBuilder.True() + .WhereIf(!string.IsNullOrWhiteSpace(request.Category), s => s.Category == request.Category) + .WhereIf(!string.IsNullOrWhiteSpace(request.Key), s => s.Key.Contains(request.Key!)) + .WhereIf(!request.IncludePrivate, s => s.IsPublic); +``` + +--- + +## Paged Response Shape Reference + +When returned through `Result`, the JSON response looks like this: + +```json +{ + "isSuccess": true, + "data": { + "items": [ + { "id": "...", "title": "...", ... } + ], + "pageIndex": 1, + "pageSize": 10, + "totalCount": 47, + "totalPages": 5, + "hasPreviousPage": false, + "hasNextPage": true + }, + "error": null +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `Items` | `IReadOnlyList` | The page of data | +| `PageIndex` | `int` | Current page (1-based) | +| `PageSize` | `int` | Items per page | +| `TotalCount` | `int` | Total records matching filter | +| `TotalPages` | `int` | Computed ceiling of TotalCount / PageSize | +| `HasPreviousPage` | `bool` | True if PageIndex > 1 | +| `HasNextPage` | `bool` | True if PageIndex < TotalPages | + +--- + +## Sorting Reference + +| `SortBy` | `SortDirection` | Behavior | +|----------|-----------------|----------| +| `null` or empty | any | No sorting applied | +| `Title` | `asc` | `OrderBy(e => e.Title)` | +| `Title` | `desc` | `OrderByDescending(e => e.Title)` | +| `Author.Name` | `asc` | `OrderBy(e => e.Author.Name)` (nested property) | +| `invalid` | any | Silently ignored (try/catch fallback) | + +> **Note:** `ApplyOrdering` uses reflection to build the expression tree, so nested properties like `Author.Name` are supported via dot notation. diff --git a/backend/src/CCE.Api.Common/Extensions/ResultExtensions.cs b/backend/src/CCE.Api.Common/Extensions/ResultExtensions.cs new file mode 100644 index 00000000..ccb67d2b --- /dev/null +++ b/backend/src/CCE.Api.Common/Extensions/ResultExtensions.cs @@ -0,0 +1,47 @@ +using CCE.Application.Common; +using CCE.Domain.Common; +using Microsoft.AspNetCore.Http; + +namespace CCE.Api.Common.Extensions; + +public static class ResultExtensions +{ + /// + /// Maps a to an with the correct HTTP status. + /// + public static IResult ToHttpResult( + this Result result, + int successStatusCode = StatusCodes.Status200OK) + { + if (result.IsSuccess) + { + return successStatusCode switch + { + StatusCodes.Status201Created => Results.Created((string?)null, result), + StatusCodes.Status204NoContent => Results.NoContent(), + _ => Results.Json(result, statusCode: successStatusCode) + }; + } + + var statusCode = result.Error!.Type switch + { + ErrorType.NotFound => StatusCodes.Status404NotFound, + ErrorType.Validation => StatusCodes.Status400BadRequest, + ErrorType.Conflict => StatusCodes.Status409Conflict, + ErrorType.Unauthorized => StatusCodes.Status401Unauthorized, + ErrorType.Forbidden => StatusCodes.Status403Forbidden, + ErrorType.BusinessRule => StatusCodes.Status422UnprocessableEntity, + _ => StatusCodes.Status500InternalServerError, + }; + + return Results.Json(result, statusCode: statusCode); + } + + /// Shorthand for 201 Created. + public static IResult ToCreatedHttpResult(this Result result) + => result.ToHttpResult(StatusCodes.Status201Created); + + /// Shorthand for 204 NoContent (void commands). + public static IResult ToNoContentHttpResult(this Result result) + => result.ToHttpResult(StatusCodes.Status204NoContent); +} diff --git a/backend/src/CCE.Api.Common/Localization/Resources.yaml b/backend/src/CCE.Api.Common/Localization/Resources.yaml new file mode 100644 index 00000000..a4e5b1b9 --- /dev/null +++ b/backend/src/CCE.Api.Common/Localization/Resources.yaml @@ -0,0 +1,227 @@ +GENERAL_VALIDATION_ERROR: + ar: "عذرًا، البيانات المدخلة غير صحيحة" + en: "Sorry, the entered data is invalid" + +GENERAL_INTERNAL_ERROR: + ar: "حدث خطأ غير متوقع" + en: "An unexpected error occurred" + +GENERAL_UNAUTHORIZED: + ar: "الوصول غير مصرح به" + en: "Unauthorized access" + +GENERAL_FORBIDDEN: + ar: "الوصول ممنوع" + en: "Forbidden access" + +GENERAL_NOT_FOUND: + ar: "المورد غير موجود" + en: "Resource not found" + +GENERAL_BAD_REQUEST: + ar: "عذرًا، البيانات المدخلة غير صحيحة" + en: "Sorry, the entered data is invalid" + +GENERAL_SUCCESS_CREATED: + ar: "تم الإنشاء بنجاح" + en: "Created successfully" + +GENERAL_SUCCESS_UPDATED: + ar: "تم التحديث بنجاح" + en: "Updated successfully" + +GENERAL_SUCCESS_DELETED: + ar: "تم الحذف بنجاح" + en: "Deleted successfully" + +GENERAL_SUCCESS_OPERATION: + ar: "تمت العملية بنجاح" + en: "Operation completed successfully" + +IDENTITY_USER_NOT_FOUND: + ar: "عذرًا، لم يتم العثور على المستخدم" + en: "Sorry, user not found" + +IDENTITY_EMAIL_EXISTS: + ar: "عذرًا، حدثت مشكلة أثناء إنشاء الحساب" + en: "Sorry, a problem occurred while creating the account" + +IDENTITY_USERNAME_EXISTS: + ar: "اسم المستخدم مستخدم بالفعل" + en: "Username already taken" + +IDENTITY_INVALID_CREDENTIALS: + ar: "عذرًا، حدثت مشكلة أثناء تسجيل الدخول" + en: "Sorry, a problem occurred during login" + +IDENTITY_INVALID_TOKEN: + ar: "رمز الوصول غير صالح" + en: "Invalid access token" + +IDENTITY_ACCOUNT_DEACTIVATED: + ar: "عذرًا، حدثت مشكلة أثناء تسجيل الدخول" + en: "Sorry, a problem occurred during login" + +IDENTITY_NOT_AUTHENTICATED: + ar: "المستخدم غير مصادق" + en: "User not authenticated" + +IDENTITY_EXPERT_REQUEST_NOT_FOUND: + ar: "طلب الخبير غير موجود" + en: "Expert request not found" + +IDENTITY_STATE_REP_ASSIGNMENT_NOT_FOUND: + ar: "التعيين غير موجود" + en: "Assignment not found" + +IDENTITY_STATE_REP_ASSIGNMENT_EXISTS: + ar: "التعيين موجود بالفعل" + en: "Assignment already exists" + +CONTENT_RESOURCE_NOT_FOUND: + ar: "المورد غير موجود" + en: "Resource not found" + +CONTENT_RESOURCE_DUPLICATE: + ar: "المورد موجود بالفعل" + en: "Resource already exists" + +CONTENT_CATEGORY_NOT_FOUND: + ar: "التصنيف غير موجود" + en: "Category not found" + +CONTENT_CATEGORY_DUPLICATE: + ar: "التصنيف موجود بالفعل" + en: "Category already exists" + +CONTENT_PAGE_NOT_FOUND: + ar: "الصفحة غير موجودة" + en: "Page not found" + +CONTENT_NEWS_NOT_FOUND: + ar: "الخبر غير موجود" + en: "News not found" + +CONTENT_EVENT_NOT_FOUND: + ar: "الفعالية غير موجودة" + en: "Event not found" + +CONTENT_ASSET_NOT_FOUND: + ar: "الملف غير موجود" + en: "Asset not found" + +COMMUNITY_TOPIC_NOT_FOUND: + ar: "الموضوع غير موجود" + en: "Topic not found" + +COMMUNITY_TOPIC_DUPLICATE: + ar: "الموضوع موجود بالفعل" + en: "Topic already exists" + +COMMUNITY_POST_NOT_FOUND: + ar: "المنشور غير موجود" + en: "Post not found" + +COMMUNITY_REPLY_NOT_FOUND: + ar: "الرد غير موجود" + en: "Reply not found" + +COMMUNITY_ALREADY_FOLLOWING: + ar: "أنت تتابع هذا بالفعل" + en: "You are already following this" + +COMMUNITY_NOT_FOLLOWING: + ar: "أنت لا تتابع هذا" + en: "You are not following this" + +COMMUNITY_CANNOT_MARK_ANSWERED: + ar: "غير مصرح لك بتحديد الإجابة" + en: "You are not authorized to mark the answer" + +COMMUNITY_EDIT_WINDOW_EXPIRED: + ar: "انتهت فترة التعديل" + en: "Edit window has expired" + +COUNTRY_COUNTRY_NOT_FOUND: + ar: "الدولة غير موجودة" + en: "Country not found" + +COUNTRY_COUNTRY_PROFILE_NOT_FOUND: + ar: "الملف التعريفي غير موجود" + en: "Country profile not found" + +NOTIFICATIONS_TEMPLATE_NOT_FOUND: + ar: "القالب غير موجود" + en: "Template not found" + +NOTIFICATIONS_NOTIFICATION_NOT_FOUND: + ar: "الإشعار غير موجود" + en: "Notification not found" + +VALIDATION_REQUIRED_FIELD: + ar: "هذا الحقل مطلوب" + en: "This field is required" + +VALIDATION_INVALID_EMAIL: + ar: "البريد الإلكتروني غير صالح" + en: "Invalid email format" + +VALIDATION_MIN_LENGTH: + ar: "القيمة قصيرة جدًا" + en: "Value is too short" + +VALIDATION_MAX_LENGTH: + ar: "القيمة طويلة جدًا" + en: "Value is too long" + +VALIDATION_INVALID_FORMAT: + ar: "التنسيق غير صالح" + en: "Invalid format" + +VALIDATION_INVALID_ENUM: + ar: "القيمة المحددة غير صالحة" + en: "Selected value is invalid" + +CONCURRENCY_CONFLICT: + ar: "تم تعديل هذا السجل من قبل مستخدم آخر. يرجى تحديث الصفحة والمحاولة مرة أخرى" + en: "This record was modified by another user. Please refresh and try again" + +DUPLICATE_VALUE: + ar: "القيمة موجودة بالفعل" + en: "Value already exists" + +CONTENT_HOMEPAGE_SECTION_NOT_FOUND: + ar: "القسم غير موجود" + en: "Section not found" + +CONTENT_PAGE_DUPLICATE: + ar: "الصفحة موجودة بالفعل" + en: "Page already exists" + +CONTENT_COUNTRY_RESOURCE_REQUEST_NOT_FOUND: + ar: "طلب المورد غير موجود" + en: "Resource request not found" + +IDENTITY_EXPERT_REQUEST_ALREADY_EXISTS: + ar: "طلب الخبير موجود بالفعل" + en: "Expert request already exists" + +KNOWLEDGE_MAP_NOT_FOUND: + ar: "خريطة المعرفة غير موجودة" + en: "Knowledge map not found" + +KNOWLEDGE_NODE_NOT_FOUND: + ar: "العقدة غير موجودة" + en: "Node not found" + +KNOWLEDGE_EDGE_NOT_FOUND: + ar: "الوصلة غير موجودة" + en: "Edge not found" + +SCENARIO_NOT_FOUND: + ar: "السيناريو غير موجود" + en: "Scenario not found" + +TECHNOLOGY_NOT_FOUND: + ar: "التقنية غير موجودة" + en: "Technology not found" diff --git a/backend/src/CCE.Application/Common/Behaviors/LoggingBehavior.cs b/backend/src/CCE.Application/Common/Behaviors/LoggingBehavior.cs index 62fc37af..4b8ee436 100644 --- a/backend/src/CCE.Application/Common/Behaviors/LoggingBehavior.cs +++ b/backend/src/CCE.Application/Common/Behaviors/LoggingBehavior.cs @@ -22,16 +22,16 @@ public async Task Handle( CancellationToken cancellationToken) { var requestName = typeof(TRequest).Name; - _logger.LogInformation("Handling {RequestName}", requestName); + //_logger.LogInformation("Handling {RequestName}", requestName); var sw = Stopwatch.StartNew(); var response = await next().ConfigureAwait(false); sw.Stop(); - _logger.LogInformation( - "Handled {RequestName} in {ElapsedMs}ms", - requestName, - sw.ElapsedMilliseconds); + //_logger.LogInformation( + // "Handled {RequestName} in {ElapsedMs}ms", + // requestName, + // sw.ElapsedMilliseconds); return response; } diff --git a/backend/src/CCE.Application/Common/Behaviors/ResultValidationBehavior.cs b/backend/src/CCE.Application/Common/Behaviors/ResultValidationBehavior.cs new file mode 100644 index 00000000..6d20f79b --- /dev/null +++ b/backend/src/CCE.Application/Common/Behaviors/ResultValidationBehavior.cs @@ -0,0 +1,82 @@ +using CCE.Application.Localization; +using CCE.Domain.Common; +using FluentValidation; +using MediatR; +using Microsoft.Extensions.DependencyInjection; + +namespace CCE.Application.Common.Behaviors; + +/// +/// MediatR pipeline behavior for requests returning . +/// Instead of throwing , it returns a failure Result +/// with localized messages and structured field-level details. +/// +public sealed class ResultValidationBehavior + : IPipelineBehavior + where TRequest : notnull + where TResponse : class +{ + private readonly IEnumerable> _validators; + private readonly IServiceProvider _serviceProvider; + + public ResultValidationBehavior( + IEnumerable> validators, + IServiceProvider serviceProvider) + { + _validators = validators; + _serviceProvider = serviceProvider; + } + + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + // Only intercept when TResponse is Result + if (!IsResultType(typeof(TResponse))) + { + return await next().ConfigureAwait(false); + } + + if (!_validators.Any()) + return await next().ConfigureAwait(false); + + var context = new ValidationContext(request); + var results = await Task.WhenAll( + _validators.Select(v => v.ValidateAsync(context, cancellationToken))) + .ConfigureAwait(false); + + var failures = results.SelectMany(r => r.Errors) + .Where(f => f is not null) + .ToList(); + + if (failures.Count == 0) + return await next().ConfigureAwait(false); + + var details = failures + .GroupBy(f => f.PropertyName) + .ToDictionary( + g => g.Key, + g => g.Select(f => f.ErrorMessage).ToArray()); + + var localization = _serviceProvider.GetRequiredService(); + var msg = localization.GetLocalizedMessage("GENERAL_VALIDATION_ERROR"); + var error = new Error( + "GENERAL_VALIDATION_ERROR", + msg?.Ar ?? "عذرًا، البيانات المدخلة غير صحيحة", + msg?.En ?? "Sorry, the entered data is invalid", + ErrorType.Validation, + details); + + // Use reflection to call Result.Failure(error) + var innerType = typeof(TResponse).GetGenericArguments()[0]; + var failureMethod = typeof(Result<>) + .MakeGenericType(innerType) + .GetMethod("Failure")!; + + return (TResponse)failureMethod.Invoke(null, [error])!; + } + + private static bool IsResultType(Type type) + => type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Result<>); +} diff --git a/backend/src/CCE.Application/Common/Errors.cs b/backend/src/CCE.Application/Common/Errors.cs new file mode 100644 index 00000000..7ed5530b --- /dev/null +++ b/backend/src/CCE.Application/Common/Errors.cs @@ -0,0 +1,66 @@ +using CCE.Application.Errors; +using CCE.Application.Localization; +using CCE.Domain.Common; + +namespace CCE.Application.Common; + +/// +/// Factory for creating localized instances. +/// Each method looks up the bilingual message from Resources.yaml. +/// +public sealed class Errors +{ + private readonly ILocalizationService _l; + + public Errors(ILocalizationService l) => _l = l; + + // ─── General ─── + public Error NotFound(string code) + => Build(code, ErrorType.NotFound); + public Error Conflict(string code) + => Build(code, ErrorType.Conflict); + public Error BusinessRule(string code) + => Build(code, ErrorType.BusinessRule); + public Error Validation(string code, IDictionary? details = null) + => Build(code, ErrorType.Validation, details); + public Error Forbidden(string code) + => Build(code, ErrorType.Forbidden); + public Error Unauthorized(string code) + => Build(code, ErrorType.Unauthorized); + + // ─── Convenience: Content domain ─── + public Error NewsNotFound() => NotFound($"CONTENT_{ApplicationErrors.Content.NEWS_NOT_FOUND}"); + public Error EventNotFound() => NotFound($"CONTENT_{ApplicationErrors.Content.EVENT_NOT_FOUND}"); + public Error ResourceNotFound() => NotFound($"CONTENT_{ApplicationErrors.Content.RESOURCE_NOT_FOUND}"); + public Error PageNotFound() => NotFound($"CONTENT_{ApplicationErrors.Content.PAGE_NOT_FOUND}"); + public Error CategoryNotFound() => NotFound($"CONTENT_{ApplicationErrors.Content.CATEGORY_NOT_FOUND}"); + public Error AssetNotFound() => NotFound($"CONTENT_{ApplicationErrors.Content.ASSET_NOT_FOUND}"); + public Error HomepageSectionNotFound() => NotFound($"CONTENT_{ApplicationErrors.Content.HOMEPAGE_SECTION_NOT_FOUND}"); + + // ─── Convenience: Identity domain ─── + public Error UserNotFound() => NotFound($"IDENTITY_{ApplicationErrors.Identity.USER_NOT_FOUND}"); + public Error ExpertRequestNotFound() => NotFound($"IDENTITY_{ApplicationErrors.Identity.EXPERT_REQUEST_NOT_FOUND}"); + public Error ExpertRequestAlreadyExists() => Conflict($"IDENTITY_{ApplicationErrors.Identity.EXPERT_REQUEST_ALREADY_EXISTS}"); + public Error StateRepAssignmentNotFound() => NotFound($"IDENTITY_{ApplicationErrors.Identity.STATE_REP_ASSIGNMENT_NOT_FOUND}"); + public Error StateRepAssignmentAlreadyExists() => Conflict($"IDENTITY_{ApplicationErrors.Identity.STATE_REP_ASSIGNMENT_EXISTS}"); + public Error NotAuthenticated() => Unauthorized($"IDENTITY_{ApplicationErrors.Identity.NOT_AUTHENTICATED}"); + public Error InvalidCredentials() => Unauthorized($"IDENTITY_{ApplicationErrors.Identity.INVALID_CREDENTIALS}"); + public Error InvalidRefreshToken() => Unauthorized($"IDENTITY_{ApplicationErrors.Identity.INVALID_REFRESH_TOKEN}"); + public Error EmailExists() => Conflict($"IDENTITY_{ApplicationErrors.Identity.EMAIL_EXISTS}"); + public Error RegistrationFailed(IDictionary? details = null) + => Validation($"IDENTITY_{ApplicationErrors.Identity.REGISTRATION_FAILED}", details); + + // ─── Convenience: Community domain ─── + public Error TopicNotFound() => NotFound($"COMMUNITY_{ApplicationErrors.Community.TOPIC_NOT_FOUND}"); + public Error PostNotFound() => NotFound($"COMMUNITY_{ApplicationErrors.Community.POST_NOT_FOUND}"); + public Error ReplyNotFound() => NotFound($"COMMUNITY_{ApplicationErrors.Community.REPLY_NOT_FOUND}"); + + // ─── Convenience: Country domain ─── + public Error CountryNotFound() => NotFound($"COUNTRY_{ApplicationErrors.Country.COUNTRY_NOT_FOUND}"); + + private Error Build(string code, ErrorType type, IDictionary? details = null) + { + var msg = _l.GetLocalizedMessage(code); + return new Error(code, msg.Ar, msg.En, type, details); + } +} diff --git a/backend/src/CCE.Application/Common/Pagination/PagedResult.cs b/backend/src/CCE.Application/Common/Pagination/PagedResult.cs index 97e463eb..bd6313ef 100644 --- a/backend/src/CCE.Application/Common/Pagination/PagedResult.cs +++ b/backend/src/CCE.Application/Common/Pagination/PagedResult.cs @@ -1,3 +1,4 @@ +using System.Linq.Expressions; using Microsoft.EntityFrameworkCore; namespace CCE.Application.Common.Pagination; @@ -9,7 +10,14 @@ public sealed record PagedResult( IReadOnlyList Items, int Page, int PageSize, - long Total); + long Total) +{ + /// + /// Projects each item into a new shape while preserving pagination metadata. + /// + public PagedResult Map(Func selector) => + new(Items.Select(selector).ToList(), Page, PageSize, Total); +} public static class PaginationExtensions { @@ -33,6 +41,31 @@ public static async Task> ToPagedResultAsync( return new PagedResult(items, page, pageSize, total); } + /// + /// Paginates and projects in a single query — SQL only fetches DTO columns. + /// Use for list endpoints where you don't need the full entity. + /// + public static async Task> ToPagedResultAsync( + this IQueryable query, + Expression> projection, + int page, int pageSize, CancellationToken ct) + { + page = Math.Max(1, page); + pageSize = Math.Clamp(pageSize, 1, MaxPageSize); + + var total = query is IAsyncEnumerable + ? await query.LongCountAsync(ct).ConfigureAwait(false) + : query.LongCount(); + + var projected = query.Select(projection); + var items = projected is IAsyncEnumerable + ? await projected.Skip((page - 1) * pageSize).Take(pageSize) + .ToListAsync(ct).ConfigureAwait(false) + : projected.Skip((page - 1) * pageSize).Take(pageSize).ToList(); + + return new PagedResult(items, page, pageSize, total); + } + /// /// Materialises an as a list, dispatching to EF's /// ToListAsync when the query implements diff --git a/backend/src/CCE.Application/Common/Pagination/QueryableExtensions.cs b/backend/src/CCE.Application/Common/Pagination/QueryableExtensions.cs new file mode 100644 index 00000000..7af48fd4 --- /dev/null +++ b/backend/src/CCE.Application/Common/Pagination/QueryableExtensions.cs @@ -0,0 +1,16 @@ +using System.Linq.Expressions; + +namespace CCE.Application.Common.Pagination; + +public static class QueryableExtensions +{ + /// + /// Conditionally appends a Where clause. When is false + /// the original query is returned unmodified. + /// + public static IQueryable WhereIf( + this IQueryable query, + bool condition, + Expression> predicate) + => condition ? query.Where(predicate) : query; +} diff --git a/backend/src/CCE.Application/Common/Result.cs b/backend/src/CCE.Application/Common/Result.cs new file mode 100644 index 00000000..3454e128 --- /dev/null +++ b/backend/src/CCE.Application/Common/Result.cs @@ -0,0 +1,51 @@ +using CCE.Domain.Common; +using System.Text.Json.Serialization; + +namespace CCE.Application.Common; + +/// +/// Discriminated result type for handler returns. Replaces returning null (not-found) +/// and throwing exceptions for expected business failures. +/// Designed to serialize cleanly with System.Text.Json. +/// +public sealed record Result +{ + [JsonInclude] + public bool IsSuccess { get; private init; } + + [JsonInclude] + public T? Data { get; private init; } + + [JsonInclude] + public Error? Error { get; private init; } + + // Public parameterless constructor so System.Text.Json can instantiate + // the record during serialization (records create temp instances). + public Result() { } + + public static Result Success(T data) => new() { IsSuccess = true, Data = data }; + public static Result Failure(Error error) => new() { IsSuccess = false, Error = error }; + + /// Allow implicit conversion from T for clean handler returns. + public static implicit operator Result(T data) => Success(data); + + /// Allow implicit conversion from Error for clean handler returns. + public static implicit operator Result(Error error) => Failure(error); +} + +/// +/// Non-generic companion for void commands that return no data on success. +/// +public static class Result +{ + private static readonly Result SuccessUnit = Result.Success(Unit.Value); + + public static Result Success() => SuccessUnit; + public static Result Failure(Error error) => Result.Failure(error); +} + +/// Unit type for commands that return no data. +public readonly record struct Unit +{ + public static readonly Unit Value = default; +} diff --git a/backend/src/CCE.Application/DependencyInjection.cs b/backend/src/CCE.Application/DependencyInjection.cs index d5f9b323..0c3b7f46 100644 --- a/backend/src/CCE.Application/DependencyInjection.cs +++ b/backend/src/CCE.Application/DependencyInjection.cs @@ -15,13 +15,15 @@ public static IServiceCollection AddApplication(this IServiceCollection services services.AddMediatR(cfg => { cfg.RegisterServicesFromAssembly(assembly); - // Pipeline behavior order matters — first registered runs outermost. cfg.AddOpenBehavior(typeof(LoggingBehavior<,>)); + cfg.AddOpenBehavior(typeof(ResultValidationBehavior<,>)); cfg.AddOpenBehavior(typeof(ValidationBehavior<,>)); }); services.AddValidatorsFromAssembly(assembly); + services.AddScoped(); + services.AddSingleton(); return services; diff --git a/backend/src/CCE.Application/Errors/ApplicationErrors.cs b/backend/src/CCE.Application/Errors/ApplicationErrors.cs new file mode 100644 index 00000000..6fa696d5 --- /dev/null +++ b/backend/src/CCE.Application/Errors/ApplicationErrors.cs @@ -0,0 +1,116 @@ +namespace CCE.Application.Errors; + +public static class ApplicationErrors +{ + public static class General + { + public const string VALIDATION_ERROR = "VALIDATION_ERROR"; + public const string INTERNAL_ERROR = "INTERNAL_ERROR"; + public const string UNAUTHORIZED = "UNAUTHORIZED_ACCESS"; + public const string FORBIDDEN = "FORBIDDEN_ACCESS"; + public const string NOT_FOUND = "RESOURCE_NOT_FOUND"; + public const string BAD_REQUEST = "BAD_REQUEST"; + public const string SUCCESS_CREATED = "SUCCESS_CREATED"; + public const string SUCCESS_UPDATED = "SUCCESS_UPDATED"; + public const string SUCCESS_DELETED = "SUCCESS_DELETED"; + public const string SUCCESS_OPERATION = "SUCCESS_OPERATION"; + } + + public static class Identity + { + public const string USER_NOT_FOUND = "USER_NOT_FOUND"; + public const string EMAIL_EXISTS = "EMAIL_EXISTS"; + public const string USERNAME_EXISTS = "USERNAME_EXISTS"; + public const string USER_CREATED = "USER_CREATED"; + public const string USER_UPDATED = "USER_UPDATED"; + public const string USER_DELETED = "USER_DELETED"; + public const string USER_ACTIVATED = "USER_ACTIVATED"; + public const string USER_DEACTIVATED = "USER_DEACTIVATED"; + public const string ROLES_ASSIGNED = "ROLES_ASSIGNED"; + public const string INVALID_CREDENTIALS = "INVALID_CREDENTIALS"; + public const string INVALID_TOKEN = "INVALID_TOKEN"; + public const string INVALID_REFRESH_TOKEN = "INVALID_REFRESH_TOKEN"; + public const string REGISTRATION_FAILED = "REGISTRATION_FAILED"; + public const string LOGIN_FAILED = "LOGIN_FAILED"; + public const string PASSWORD_RECOVERY_FAILED = "PASSWORD_RECOVERY_FAILED"; + public const string PASSWORD_RESET = "PASSWORD_RESET"; + public const string LOGOUT_FAILED = "LOGOUT_FAILED"; + public const string LOGOUT_SUCCESS = "LOGOUT_SUCCESS"; + public const string ACCOUNT_DEACTIVATED = "ACCOUNT_DEACTIVATED"; + public const string NOT_AUTHENTICATED = "NOT_AUTHENTICATED"; + public const string EXPERT_REQUEST_NOT_FOUND = "EXPERT_REQUEST_NOT_FOUND"; + public const string EXPERT_REQUEST_ALREADY_EXISTS = "EXPERT_REQUEST_ALREADY_EXISTS"; + public const string STATE_REP_ASSIGNMENT_NOT_FOUND = "STATE_REP_ASSIGNMENT_NOT_FOUND"; + public const string STATE_REP_ASSIGNMENT_EXISTS = "STATE_REP_ASSIGNMENT_EXISTS"; + } + + public static class Content + { + public const string RESOURCE_NOT_FOUND = "RESOURCE_NOT_FOUND"; + public const string RESOURCE_DUPLICATE = "RESOURCE_DUPLICATE"; + public const string RESOURCE_CREATED = "RESOURCE_CREATED"; + public const string RESOURCE_UPDATED = "RESOURCE_UPDATED"; + public const string RESOURCE_DELETED = "RESOURCE_DELETED"; + public const string RESOURCE_PUBLISHED = "RESOURCE_PUBLISHED"; + public const string CATEGORY_NOT_FOUND = "CATEGORY_NOT_FOUND"; + public const string CATEGORY_DUPLICATE = "CATEGORY_DUPLICATE"; + public const string PAGE_NOT_FOUND = "PAGE_NOT_FOUND"; + public const string PAGE_DUPLICATE = "PAGE_DUPLICATE"; + public const string NEWS_NOT_FOUND = "NEWS_NOT_FOUND"; + public const string NEWS_DUPLICATE = "NEWS_DUPLICATE"; + public const string EVENT_NOT_FOUND = "EVENT_NOT_FOUND"; + public const string EVENT_DUPLICATE = "EVENT_DUPLICATE"; + public const string HOMEPAGE_SECTION_NOT_FOUND = "HOMEPAGE_SECTION_NOT_FOUND"; + public const string ASSET_NOT_FOUND = "ASSET_NOT_FOUND"; + public const string COUNTRY_RESOURCE_REQUEST_NOT_FOUND = "COUNTRY_RESOURCE_REQUEST_NOT_FOUND"; + } + + public static class Community + { + public const string TOPIC_NOT_FOUND = "TOPIC_NOT_FOUND"; + public const string TOPIC_DUPLICATE = "TOPIC_DUPLICATE"; + public const string POST_NOT_FOUND = "POST_NOT_FOUND"; + public const string REPLY_NOT_FOUND = "REPLY_NOT_FOUND"; + public const string RATING_NOT_FOUND = "RATING_NOT_FOUND"; + public const string ALREADY_FOLLOWING = "ALREADY_FOLLOWING"; + public const string NOT_FOLLOWING = "NOT_FOLLOWING"; + public const string CANNOT_MARK_ANSWERED = "CANNOT_MARK_ANSWERED"; + public const string EDIT_WINDOW_EXPIRED = "EDIT_WINDOW_EXPIRED"; + } + + public static class Country + { + public const string COUNTRY_NOT_FOUND = "COUNTRY_NOT_FOUND"; + public const string COUNTRY_PROFILE_NOT_FOUND = "COUNTRY_PROFILE_NOT_FOUND"; + } + + public static class Notifications + { + public const string TEMPLATE_NOT_FOUND = "TEMPLATE_NOT_FOUND"; + public const string TEMPLATE_DUPLICATE = "TEMPLATE_DUPLICATE"; + public const string NOTIFICATION_NOT_FOUND = "NOTIFICATION_NOT_FOUND"; + } + + public static class KnowledgeMap + { + public const string MAP_NOT_FOUND = "MAP_NOT_FOUND"; + public const string NODE_NOT_FOUND = "NODE_NOT_FOUND"; + public const string EDGE_NOT_FOUND = "EDGE_NOT_FOUND"; + } + + public static class InteractiveCity + { + public const string SCENARIO_NOT_FOUND = "SCENARIO_NOT_FOUND"; + public const string TECHNOLOGY_NOT_FOUND = "TECHNOLOGY_NOT_FOUND"; + } + + public static class Validation + { + public const string REQUIRED_FIELD = "REQUIRED_FIELD"; + public const string INVALID_EMAIL = "INVALID_EMAIL"; + public const string MIN_LENGTH = "MIN_LENGTH"; + public const string MAX_LENGTH = "MAX_LENGTH"; + public const string INVALID_FORMAT = "INVALID_FORMAT"; + public const string INVALID_ENUM = "INVALID_ENUM"; + } +} diff --git a/backend/src/CCE.Application/Localization/ILocalizationService.cs b/backend/src/CCE.Application/Localization/ILocalizationService.cs new file mode 100644 index 00000000..4b47ed7b --- /dev/null +++ b/backend/src/CCE.Application/Localization/ILocalizationService.cs @@ -0,0 +1,8 @@ +namespace CCE.Application.Localization; + +public interface ILocalizationService +{ + string GetString(string key, string? culture = null); + string GetStringOrDefault(string key, string defaultMessage, string? culture = null); + LocalizedMessage GetLocalizedMessage(string key); +} diff --git a/backend/src/CCE.Application/Localization/LocalizedMessage.cs b/backend/src/CCE.Application/Localization/LocalizedMessage.cs new file mode 100644 index 00000000..d8d95e95 --- /dev/null +++ b/backend/src/CCE.Application/Localization/LocalizedMessage.cs @@ -0,0 +1,3 @@ +namespace CCE.Application.Localization; + +public sealed record LocalizedMessage(string Ar, string En); diff --git a/backend/src/CCE.Domain/Common/AuditableAggregateRoot.cs b/backend/src/CCE.Domain/Common/AuditableAggregateRoot.cs new file mode 100644 index 00000000..95c0a460 --- /dev/null +++ b/backend/src/CCE.Domain/Common/AuditableAggregateRoot.cs @@ -0,0 +1,41 @@ +namespace CCE.Domain.Common; + +/// +/// Base class for DDD aggregate roots that expose generic audit timestamps. +/// Concrete aggregates call and +/// from their own factory methods and mutators. +/// +/// The aggregate root's ID type. +public abstract class AuditableAggregateRoot : AggregateRoot, IAuditable + where TId : notnull +{ + protected AuditableAggregateRoot(TId id) : base(id) { } + + /// + public DateTimeOffset CreatedOn { get; protected set; } + + /// + public Guid CreatedById { get; protected set; } + + /// + public DateTimeOffset? LastModifiedOn { get; protected set; } + + /// + public Guid? LastModifiedById { get; protected set; } + + /// Records creation metadata. Call from factory methods. + protected void MarkAsCreated(Guid by, ISystemClock clock) + { + if (by == Guid.Empty) throw new DomainException("CreatedById is required."); + CreatedOn = clock.UtcNow; + CreatedById = by; + } + + /// Records modification metadata. Call from mutator methods. + protected void MarkAsModified(Guid by, ISystemClock clock) + { + if (by == Guid.Empty) throw new DomainException("ModifiedById is required."); + LastModifiedOn = clock.UtcNow; + LastModifiedById = by; + } +} diff --git a/backend/src/CCE.Domain/Common/AuditableEntity.cs b/backend/src/CCE.Domain/Common/AuditableEntity.cs new file mode 100644 index 00000000..4cc300c2 --- /dev/null +++ b/backend/src/CCE.Domain/Common/AuditableEntity.cs @@ -0,0 +1,41 @@ +namespace CCE.Domain.Common; + +/// +/// Base class for entities that expose generic audit timestamps. +/// Concrete entities call and +/// from their own factory methods and mutators. +/// +/// The ID type. +public abstract class AuditableEntity : Entity, IAuditable + where TId : notnull +{ + protected AuditableEntity(TId id) : base(id) { } + + /// + public DateTimeOffset CreatedOn { get; protected set; } + + /// + public Guid CreatedById { get; protected set; } + + /// + public DateTimeOffset? LastModifiedOn { get; protected set; } + + /// + public Guid? LastModifiedById { get; protected set; } + + /// Records creation metadata. Call from factory methods. + protected void MarkAsCreated(Guid by, ISystemClock clock) + { + if (by == Guid.Empty) throw new DomainException("CreatedById is required."); + CreatedOn = clock.UtcNow; + CreatedById = by; + } + + /// Records modification metadata. Call from mutator methods. + protected void MarkAsModified(Guid by, ISystemClock clock) + { + if (by == Guid.Empty) throw new DomainException("ModifiedById is required."); + LastModifiedOn = clock.UtcNow; + LastModifiedById = by; + } +} diff --git a/backend/src/CCE.Domain/Common/Error.cs b/backend/src/CCE.Domain/Common/Error.cs new file mode 100644 index 00000000..ff157975 --- /dev/null +++ b/backend/src/CCE.Domain/Common/Error.cs @@ -0,0 +1,23 @@ +using System.Text.Json.Serialization; + +namespace CCE.Domain.Common; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ErrorType +{ + None, + Validation, + NotFound, + Conflict, + Unauthorized, + Forbidden, + BusinessRule, + Internal +} + +public sealed record Error( + string Code, + string MessageAr, + string MessageEn, + ErrorType Type = ErrorType.Internal, + IDictionary? Details = null); diff --git a/backend/src/CCE.Domain/Common/IAuditable.cs b/backend/src/CCE.Domain/Common/IAuditable.cs new file mode 100644 index 00000000..d00e4feb --- /dev/null +++ b/backend/src/CCE.Domain/Common/IAuditable.cs @@ -0,0 +1,21 @@ +namespace CCE.Domain.Common; + +/// +/// Marker interface for entities that expose generic audit timestamps. +/// Domain-specific timestamps (e.g. PublishedOn, SubmittedOn) +/// belong on the concrete entity, not this interface. +/// +public interface IAuditable +{ + /// UTC moment this entity was created. + DateTimeOffset CreatedOn { get; } + + /// Actor that created this entity. + Guid CreatedById { get; } + + /// UTC moment this entity was last modified; null if never modified after creation. + DateTimeOffset? LastModifiedOn { get; } + + /// Actor that last modified this entity; null if never modified after creation. + Guid? LastModifiedById { get; } +} diff --git a/backend/src/CCE.Domain/Common/ISoftDeletable.cs b/backend/src/CCE.Domain/Common/ISoftDeletable.cs index 933111d3..01bfdc5d 100644 --- a/backend/src/CCE.Domain/Common/ISoftDeletable.cs +++ b/backend/src/CCE.Domain/Common/ISoftDeletable.cs @@ -2,7 +2,8 @@ namespace CCE.Domain.Common; /// /// Marker interface for entities that support soft delete. Implementations expose -/// , , and . +/// , , and +/// and can be soft-deleted via . /// /// /// EF Core's OnModelCreating registers a global query filter @@ -19,4 +20,11 @@ public interface ISoftDeletable /// Identifier of the user/system that performed the soft delete; null when not deleted. Guid? DeletedById { get; } + + /// + /// Marks this entity as soft-deleted. Idempotent — no-op if already deleted. + /// + /// Actor performing the deletion. + /// Domain clock abstraction. + void SoftDelete(Guid by, ISystemClock clock); } diff --git a/backend/src/CCE.Domain/Common/SoftDeletableAggregateRoot.cs b/backend/src/CCE.Domain/Common/SoftDeletableAggregateRoot.cs new file mode 100644 index 00000000..4c990071 --- /dev/null +++ b/backend/src/CCE.Domain/Common/SoftDeletableAggregateRoot.cs @@ -0,0 +1,32 @@ +namespace CCE.Domain.Common; + +/// +/// Base class for DDD aggregate roots that support soft delete and audit timestamps. +/// Inherits and absorbs +/// so concrete aggregates do not copy-paste the same soft-delete implementation. +/// +/// The aggregate root's ID type. +public abstract class SoftDeletableAggregateRoot : AuditableAggregateRoot, ISoftDeletable + where TId : notnull +{ + protected SoftDeletableAggregateRoot(TId id) : base(id) { } + + /// + public bool IsDeleted { get; protected set; } + + /// + public DateTimeOffset? DeletedOn { get; protected set; } + + /// + public Guid? DeletedById { get; protected set; } + + /// + public void SoftDelete(Guid by, ISystemClock clock) + { + if (by == Guid.Empty) throw new DomainException("DeletedById is required."); + if (IsDeleted) return; + IsDeleted = true; + DeletedById = by; + DeletedOn = clock.UtcNow; + } +} diff --git a/backend/src/CCE.Domain/Common/SoftDeletableEntity.cs b/backend/src/CCE.Domain/Common/SoftDeletableEntity.cs new file mode 100644 index 00000000..bc4d4760 --- /dev/null +++ b/backend/src/CCE.Domain/Common/SoftDeletableEntity.cs @@ -0,0 +1,32 @@ +namespace CCE.Domain.Common; + +/// +/// Base class for entities that support soft delete and audit timestamps. +/// Inherits and absorbs +/// so concrete entities do not copy-paste the same soft-delete implementation. +/// +/// The ID type. +public abstract class SoftDeletableEntity : AuditableEntity, ISoftDeletable + where TId : notnull +{ + protected SoftDeletableEntity(TId id) : base(id) { } + + /// + public bool IsDeleted { get; protected set; } + + /// + public DateTimeOffset? DeletedOn { get; protected set; } + + /// + public Guid? DeletedById { get; protected set; } + + /// + public void SoftDelete(Guid by, ISystemClock clock) + { + if (by == Guid.Empty) throw new DomainException("DeletedById is required."); + if (IsDeleted) return; + IsDeleted = true; + DeletedById = by; + DeletedOn = clock.UtcNow; + } +} diff --git a/backend/src/CCE.Domain/Common/ValueObject.cs b/backend/src/CCE.Domain/Common/ValueObject.cs deleted file mode 100644 index a788d970..00000000 --- a/backend/src/CCE.Domain/Common/ValueObject.cs +++ /dev/null @@ -1,39 +0,0 @@ -namespace CCE.Domain.Common; - -/// -/// Base class for DDD value objects — immutable, identityless, compared by structural equality -/// over their atomic components. -/// -public abstract class ValueObject : IEquatable -{ - /// - /// Return the atomic components that define equality. Include every field that distinguishes - /// one value from another; exclude cached/derived fields. - /// - protected abstract IEnumerable GetEqualityComponents(); - - public bool Equals(ValueObject? other) - { - if (other is null) return false; - if (ReferenceEquals(this, other)) return true; - if (GetType() != other.GetType()) return false; - return GetEqualityComponents().SequenceEqual(other.GetEqualityComponents()); - } - - public override bool Equals(object? obj) => obj is ValueObject other && Equals(other); - - public override int GetHashCode() - { - var hash = new HashCode(); - foreach (var component in GetEqualityComponents()) - { - hash.Add(component); - } - return hash.ToHashCode(); - } - - public static bool operator ==(ValueObject? left, ValueObject? right) => - ReferenceEquals(left, right) || (left is not null && left.Equals(right)); - - public static bool operator !=(ValueObject? left, ValueObject? right) => !(left == right); -} diff --git a/backend/src/CCE.Infrastructure/Localization/LocalizationService.cs b/backend/src/CCE.Infrastructure/Localization/LocalizationService.cs new file mode 100644 index 00000000..ee109d9e --- /dev/null +++ b/backend/src/CCE.Infrastructure/Localization/LocalizationService.cs @@ -0,0 +1,59 @@ +using System.Globalization; +using CCE.Application.Localization; + +namespace CCE.Infrastructure.Localization; + +public sealed class LocalizationService : ILocalizationService +{ + private readonly YamlLocalizationStore _store; + + public LocalizationService(YamlLocalizationStore store) + { + _store = store ?? throw new ArgumentNullException(nameof(store)); + } + + public string GetString(string key, string? culture = null) + { + var lang = GetTwoLetterCode(culture); + + if (string.IsNullOrWhiteSpace(key)) return string.Empty; + if (_store.TryGet(key, out var language) && language != null) + { + if (language.TryGetValue(lang, out var v) && !string.IsNullOrEmpty(v)) return v; + if (language.TryGetValue("ar", out var ar) && !string.IsNullOrEmpty(ar)) return ar; + return language.Values.FirstOrDefault() ?? key; + } + + return key; + } + + public string GetStringOrDefault(string key, string defaultMessage, string? culture = null) + { + var v = GetString(key, culture); + return string.IsNullOrEmpty(v) || v == key ? defaultMessage : v; + } + + public LocalizedMessage GetLocalizedMessage(string key) + { + var enMessage = GetString(key, "en"); + var arMessage = GetString(key, "ar"); + + if (string.IsNullOrEmpty(enMessage) || enMessage == key) enMessage = key; + if (string.IsNullOrEmpty(arMessage) || arMessage == key) arMessage = key; + + return new LocalizedMessage(Ar: arMessage, En: enMessage); + } + + private static string GetTwoLetterCode(string? culture) + { + if (string.IsNullOrWhiteSpace(culture)) return "ar"; + try + { + return new CultureInfo(culture).TwoLetterISOLanguageName; + } + catch (System.Globalization.CultureNotFoundException) + { + return "ar"; + } + } +} diff --git a/backend/src/CCE.Infrastructure/Localization/YamlLocalizationStore.cs b/backend/src/CCE.Infrastructure/Localization/YamlLocalizationStore.cs new file mode 100644 index 00000000..dfd00747 --- /dev/null +++ b/backend/src/CCE.Infrastructure/Localization/YamlLocalizationStore.cs @@ -0,0 +1,75 @@ +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace CCE.Infrastructure.Localization; + +public sealed class YamlLocalizationStore +{ + private readonly Dictionary> _store = new(StringComparer.OrdinalIgnoreCase); + private readonly object _lock = new(); + + public YamlLocalizationStore() + { + var deserializer = new DeserializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .IgnoreUnmatchedProperties() + .Build(); + + foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) + { + try + { + var location = asm.Location; + if (string.IsNullOrEmpty(location)) continue; + var dir = Path.GetDirectoryName(location); + if (string.IsNullOrEmpty(dir)) continue; + + var resourcesPath = Path.Combine(dir, "Localization", "Resources.yaml"); + if (File.Exists(resourcesPath)) + { + var resourcesYaml = File.ReadAllText(resourcesPath); + var resourcesParsed = deserializer.Deserialize>>(resourcesYaml); + Merge(resourcesParsed); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or YamlDotNet.Core.YamlException) + { + // Continue loading other assemblies on malformed files + } + } + } + + private void Merge(Dictionary>? parsed) + { + if (parsed == null) return; + lock (_lock) + { + foreach (var kv in parsed) + { + var key = kv.Key.Trim(); + if (!_store.TryGetValue(key, out var langs)) + { + langs = new Dictionary(StringComparer.OrdinalIgnoreCase); + _store[key] = langs; + } + + foreach (var lp in kv.Value) + { + var lang = lp.Key.Trim(); + var text = lp.Value ?? string.Empty; + langs[lang] = text; + } + } + } + } + + public bool TryGet(string key, out Dictionary? langs) + { + if (string.IsNullOrWhiteSpace(key)) + { + langs = null; + return false; + } + return _store.TryGetValue(key, out langs!); + } +} From d9d4cd80dc5c1fa095976b290bafe17515aef75c Mon Sep 17 00:00:00 2001 From: MOHAMED RASHED Date: Fri, 15 May 2026 14:54:39 +0300 Subject: [PATCH 04/22] refactor: migrate domain services to repository pattern Replaces coarse-grained domain services (ICommunityWriteService, ICountryProfileService, etc.) with aggregate-specific repository interfaces aligned to DDD per-aggregate persistence. - Introduces I*Repository interfaces per aggregate root - Updates command/query handlers to depend on repositories - Infrastructure layer implements repository interfaces with EF Core - Removes obsolete service abstractions from Application layer --- .../src/CCE.Api.Common/Auth/AuthEndpoints.cs | 99 + .../Auth/CceJwtAuthRegistration.cs | 71 +- .../Identity/UserSyncMiddleware.cs | 2 +- .../Middleware/ExceptionHandlingMiddleware.cs | 121 +- .../Endpoints/ProfileEndpoints.cs | 151 +- .../Endpoints/ResourcesPublicEndpoints.cs | 2 +- backend/src/CCE.Api.External/Program.cs | 3 +- .../appsettings.Development.json | 16 + backend/src/CCE.Api.External/appsettings.json | 16 + .../Endpoints/ExpertEndpoints.cs | 12 +- .../Endpoints/IdentityEndpoints.cs | 21 +- backend/src/CCE.Api.Internal/Program.cs | 9 +- .../appsettings.Development.json | 16 + backend/src/CCE.Api.Internal/appsettings.json | 16 + .../Common/Interfaces/ICceDbContext.cs | 1 + .../EditReply/EditReplyCommandHandler.cs | 2 +- ...oveCountryResourceRequestCommandHandler.cs | 4 +- .../CreateEvent/CreateEventCommandHandler.cs | 4 +- .../CreateHomepageSectionCommandHandler.cs | 4 +- .../CreateNews/CreateNewsCommandHandler.cs | 4 +- .../CreatePage/CreatePageCommandHandler.cs | 4 +- .../CreateResourceCommandHandler.cs | 8 +- .../CreateResourceCategoryCommandHandler.cs | 4 +- .../DeleteEvent/DeleteEventCommandHandler.cs | 4 +- .../DeleteHomepageSectionCommandHandler.cs | 4 +- .../DeleteNews/DeleteNewsCommandHandler.cs | 4 +- .../DeletePage/DeletePageCommandHandler.cs | 4 +- .../DeleteResourceCategoryCommandHandler.cs | 4 +- .../PublishNews/PublishNewsCommandHandler.cs | 4 +- .../PublishResourceCommandHandler.cs | 8 +- ...ectCountryResourceRequestCommandHandler.cs | 4 +- .../ReorderHomepageSectionsCommandHandler.cs | 4 +- .../RescheduleEventCommandHandler.cs | 4 +- .../UpdateEvent/UpdateEventCommandHandler.cs | 4 +- .../UpdateHomepageSectionCommandHandler.cs | 4 +- .../UpdateNews/UpdateNewsCommandHandler.cs | 4 +- .../UpdatePage/UpdatePageCommandHandler.cs | 4 +- .../UpdateResourceCommandHandler.cs | 4 +- .../UpdateResourceCategoryCommandHandler.cs | 4 +- .../UploadAsset/UploadAssetCommandHandler.cs | 4 +- .../{IAssetService.cs => IAssetRepository.cs} | 2 +- ...s => ICountryResourceRequestRepository.cs} | 2 +- .../{IEventService.cs => IEventRepository.cs} | 2 +- ...rvice.cs => IHomepageSectionRepository.cs} | 2 +- .../{INewsService.cs => INewsRepository.cs} | 2 +- .../{IPageService.cs => IPageRepository.cs} | 2 +- ...vice.cs => IResourceCategoryRepository.cs} | 2 +- ...ourceService.cs => IResourceRepository.cs} | 2 +- ...ice.cs => IResourceViewCountRepository.cs} | 2 +- .../GetPublicEventByIdQueryHandler.cs | 24 +- .../GetPublicNewsBySlugQueryHandler.cs | 21 +- .../GetPublicPageBySlugQueryHandler.cs | 10 +- .../GetPublicResourceByIdQueryHandler.cs | 24 +- .../ListPublicEventsQueryHandler.cs | 23 +- .../ListPublicHomepageSectionsQueryHandler.cs | 6 +- .../ListPublicNewsQueryHandler.cs | 24 +- ...istPublicResourceCategoriesQueryHandler.cs | 6 +- .../ListPublicResourcesQueryHandler.cs | 38 +- .../GetAssetById/GetAssetByIdQueryHandler.cs | 15 +- .../GetEventById/GetEventByIdQueryHandler.cs | 15 +- .../GetNewsById/GetNewsByIdQueryHandler.cs | 15 +- .../GetPageById/GetPageByIdQueryHandler.cs | 15 +- .../GetResourceCategoryByIdQueryHandler.cs | 18 +- .../ListEvents/ListEventsQueryHandler.cs | 45 +- .../ListHomepageSectionsQueryHandler.cs | 5 +- .../Queries/ListNews/ListNewsQueryHandler.cs | 44 +- .../ListPages/ListPagesQueryHandler.cs | 38 +- .../ListResourceCategoriesQueryHandler.cs | 29 +- .../ListResourcesQueryHandler.cs | 55 +- .../GetCountryProfileQueryHandler.cs | 4 +- .../GetPublicCountryProfileQueryHandler.cs | 2 +- .../Identity/Auth/Common/AuthMessageDto.cs | 3 + .../Identity/Auth/Common/AuthTokenDto.cs | 9 + .../Identity/Auth/Common/AuthUserDto.cs | 8 + .../Auth/Common/ILocalTokenService.cs | 10 + .../Auth/Common/IPasswordResetEmailSender.cs | 8 + .../Auth/Common/IRefreshTokenRepository.cs | 16 + .../Identity/Auth/Common/LocalAuthApi.cs | 7 + .../Auth/Common/LocalAuthJwtProfile.cs | 8 + .../Identity/Auth/Common/LocalAuthOptions.cs | 16 + .../Auth/Common/PasswordResetTokenCodec.cs | 26 + .../Identity/Auth/Common/TokenIssueResult.cs | 8 + .../ForgotPassword/ForgotPasswordCommand.cs | 8 + .../ForgotPasswordCommandHandler.cs | 33 + .../ForgotPasswordCommandValidator.cs | 9 + .../ForgotPassword/ForgotPasswordRequest.cs | 3 + .../Identity/Auth/Login/LoginCommand.cs | 13 + .../Auth/Login/LoginCommandHandler.cs | 94 + .../Auth/Login/LoginCommandValidator.cs | 12 + .../Identity/Auth/Login/LoginRequest.cs | 3 + .../Identity/Auth/Logout/LogoutCommand.cs | 8 + .../Auth/Logout/LogoutCommandHandler.cs | 38 + .../Auth/Logout/LogoutCommandValidator.cs | 8 + .../Identity/Auth/Logout/LogoutRequest.cs | 3 + .../Auth/RefreshToken/RefreshTokenCommand.cs | 12 + .../RefreshTokenCommandHandler.cs | 83 + .../RefreshTokenCommandValidator.cs | 8 + .../Auth/RefreshToken/RefreshTokenRequest.cs | 3 + .../Auth/Register/RegisterUserCommand.cs | 16 + .../Register/RegisterUserCommandHandler.cs | 76 + .../Register/RegisterUserCommandValidator.cs | 28 + .../Auth/Register/RegisterUserRequest.cs | 11 + .../ResetPassword/ResetPasswordCommand.cs | 13 + .../ResetPasswordCommandHandler.cs | 64 + .../ResetPasswordCommandValidator.cs | 15 + .../ResetPassword/ResetPasswordRequest.cs | 7 + .../ApproveExpertRequestCommand.cs | 3 +- .../ApproveExpertRequestCommandHandler.cs | 26 +- .../ApproveExpertRequestRequest.cs | 3 + .../AssignUserRoles/AssignUserRolesCommand.cs | 5 +- .../AssignUserRolesCommandHandler.cs | 25 +- .../AssignUserRoles/AssignUserRolesRequest.cs | 3 + .../CreateStateRepAssignmentCommand.cs | 3 +- .../CreateStateRepAssignmentCommandHandler.cs | 27 +- .../CreateStateRepAssignmentRequest.cs | 3 + .../RejectExpertRequestCommand.cs | 3 +- .../RejectExpertRequestCommandHandler.cs | 26 +- .../RejectExpertRequestRequest.cs | 3 + .../RevokeStateRepAssignmentCommand.cs | 5 +- .../RevokeStateRepAssignmentCommandHandler.cs | 28 +- ...ervice.cs => IExpertWorkflowRepository.cs} | 2 +- ...ce.cs => IStateRepAssignmentRepository.cs} | 2 +- ...ce.cs => IUserRoleAssignmentRepository.cs} | 2 +- ...rSyncService.cs => IUserSyncRepository.cs} | 2 +- .../SubmitExpertRequestCommand.cs | 3 +- .../SubmitExpertRequestCommandHandler.cs | 9 +- .../SubmitExpertRequestRequest.cs | 6 + .../UpdateMyProfile/UpdateMyProfileCommand.cs | 3 +- .../UpdateMyProfileCommandHandler.cs | 13 +- .../UpdateMyProfile/UpdateMyProfileRequest.cs | 8 + ... => IExpertRequestSubmissionRepository.cs} | 2 +- ...leService.cs => IUserProfileRepository.cs} | 2 +- .../GetMyExpertStatusQuery.cs | 3 +- .../GetMyExpertStatusQueryHandler.cs | 14 +- .../Queries/GetMyProfile/GetMyProfileQuery.cs | 3 +- .../GetMyProfile/GetMyProfileQueryHandler.cs | 13 +- .../Identity/Public/RegisterUserContracts.cs | 12 + .../Queries/GetUserById/GetUserByIdQuery.cs | 6 +- .../GetUserById/GetUserByIdQueryHandler.cs | 13 +- .../ListExpertProfilesQueryHandler.cs | 24 +- .../ListExpertRequestsQueryHandler.cs | 29 +- .../ListStateRepAssignmentsQueryHandler.cs | 33 +- .../ListUsers/ListUsersQueryHandler.cs | 29 +- .../Public/Dtos/CityScenarioDto.cs | 2 +- .../{AssetService.cs => AssetRepository.cs} | 4 +- ...cs => CountryResourceRequestRepository.cs} | 4 +- .../{EventService.cs => EventRepository.cs} | 7 +- ...ervice.cs => HomepageSectionRepository.cs} | 4 +- .../{NewsService.cs => NewsRepository.cs} | 7 +- .../{PageService.cs => PageRepository.cs} | 7 +- ...rvice.cs => ResourceCategoryRepository.cs} | 4 +- ...sourceService.cs => ResourceRepository.cs} | 7 +- ...vice.cs => ResourceViewCountRepository.cs} | 4 +- .../CCE.Infrastructure/DependencyInjection.cs | 65 +- ...s => ExpertRequestSubmissionRepository.cs} | 4 +- ...Service.cs => ExpertWorkflowRepository.cs} | 4 +- .../Identity/LocalTokenService.cs | 97 + .../Identity/PasswordResetEmailSender.cs | 42 + .../Identity/RefreshTokenRepository.cs | 50 + ...ice.cs => StateRepAssignmentRepository.cs} | 4 +- ...ileService.cs => UserProfileRepository.cs} | 4 +- ...ice.cs => UserRoleAssignmentRepository.cs} | 12 +- ...erSyncService.cs => UserSyncRepository.cs} | 6 +- .../Persistence/CceDbContext.cs | 76 +- .../Identity/RefreshTokenConfiguration.cs | 30 + .../Identity/UserConfiguration.cs | 4 + .../Persistence/DbContextExtensions.cs | 16 + ...2038_AddLocalAuthRefreshTokens.Designer.cs | 2444 +++++++++++++++++ ...0260514202038_AddLocalAuthRefreshTokens.cs | 113 + .../Migrations/CceDbContextModelSnapshot.cs | 110 +- .../Reports/CountryProfilesReportService.cs | 4 +- .../Auth/ExternalJwtAuthTests.cs | 6 +- .../Auth/InternalJwtAuthTests.cs | 6 +- .../E2E/EndToEndAuthFlowTests.cs | 6 +- .../HealthAuthenticatedEndpointTests.cs | 6 +- .../Endpoints/HealthEndpointTests.cs | 6 +- .../Endpoints/HealthReadyEndpointTests.cs | 6 +- .../Endpoints/NotificationsEndpointTests.cs | 6 +- .../Identity/UserSyncMiddlewareTests.cs | 10 +- ...untryResourceRequestCommandHandlerTests.cs | 8 +- .../CreateEventCommandHandlerTests.cs | 4 +- ...reateHomepageSectionCommandHandlerTests.cs | 2 +- .../Commands/CreateNewsCommandHandlerTests.cs | 4 +- .../Commands/CreatePageCommandHandlerTests.cs | 4 +- ...eateResourceCategoryCommandHandlerTests.cs | 2 +- .../CreateResourceCommandHandlerTests.cs | 6 +- .../DeleteEventCommandHandlerTests.cs | 6 +- ...eleteHomepageSectionCommandHandlerTests.cs | 6 +- .../Commands/DeleteNewsCommandHandlerTests.cs | 6 +- .../Commands/DeletePageCommandHandlerTests.cs | 6 +- ...leteResourceCategoryCommandHandlerTests.cs | 4 +- .../PublishNewsCommandHandlerTests.cs | 6 +- .../PublishResourceCommandHandlerTests.cs | 6 +- ...untryResourceRequestCommandHandlerTests.cs | 8 +- ...rderHomepageSectionsCommandHandlerTests.cs | 2 +- .../RescheduleEventCommandHandlerTests.cs | 6 +- .../UpdateEventCommandHandlerTests.cs | 6 +- ...pdateHomepageSectionCommandHandlerTests.cs | 6 +- .../Commands/UpdateNewsCommandHandlerTests.cs | 6 +- .../Commands/UpdatePageCommandHandlerTests.cs | 6 +- ...dateResourceCategoryCommandHandlerTests.cs | 6 +- .../UpdateResourceCommandHandlerTests.cs | 8 +- .../UploadAssetCommandHandlerTests.cs | 4 +- .../GetPublicEventByIdQueryHandlerTests.cs | 19 +- .../GetPublicNewsBySlugQueryHandlerTests.cs | 23 +- .../GetPublicPageBySlugQueryHandlerTests.cs | 8 +- .../GetPublicResourceByIdQueryHandlerTests.cs | 38 +- .../ListPublicEventsQueryHandlerTests.cs | 63 +- ...PublicHomepageSectionsQueryHandlerTests.cs | 38 +- .../ListPublicNewsQueryHandlerTests.cs | 32 +- ...blicResourceCategoriesQueryHandlerTests.cs | 36 +- .../ListPublicResourcesQueryHandlerTests.cs | 80 +- .../Queries/GetAssetByIdQueryHandlerTests.cs | 38 +- .../Queries/GetEventByIdQueryHandlerTests.cs | 26 +- .../Queries/GetNewsByIdQueryHandlerTests.cs | 24 +- .../Queries/GetPageByIdQueryHandlerTests.cs | 4 +- ...etResourceCategoryByIdQueryHandlerTests.cs | 4 +- .../Queries/ListEventsQueryHandlerTests.cs | 60 +- .../ListHomepageSectionsQueryHandlerTests.cs | 25 +- .../Queries/ListNewsQueryHandlerTests.cs | 56 +- .../Queries/ListPagesQueryHandlerTests.cs | 24 +- ...ListResourceCategoriesQueryHandlerTests.cs | 24 +- .../Queries/ListResourcesQueryHandlerTests.cs | 67 +- ...ApproveExpertRequestCommandHandlerTests.cs | 37 +- .../AssignUserRolesCommandHandlerTests.cs | 31 +- ...teStateRepAssignmentCommandHandlerTests.cs | 44 +- .../RejectExpertRequestCommandHandlerTests.cs | 35 +- ...keStateRepAssignmentCommandHandlerTests.cs | 35 +- .../Identity/IdentityTestHelpers.cs | 25 + .../SubmitExpertRequestCommandHandlerTests.cs | 20 +- .../UpdateMyProfileCommandHandlerTests.cs | 28 +- .../GetMyExpertStatusQueryHandlerTests.cs | 23 +- .../Queries/GetMyProfileQueryHandlerTests.cs | 25 +- .../Queries/GetUserByIdQueryHandlerTests.cs | 25 +- .../ListExpertProfilesQueryHandlerTests.cs | 6 - .../ListExpertRequestsQueryHandlerTests.cs | 6 - ...istStateRepAssignmentsQueryHandlerTests.cs | 4 - .../Community/PostReplyTests.cs | 9 +- .../CCE.Domain.Tests/Community/PostTests.cs | 9 +- .../Country/CountryProfileTests.cs | 9 +- 240 files changed, 5193 insertions(+), 1421 deletions(-) create mode 100644 backend/src/CCE.Api.Common/Auth/AuthEndpoints.cs rename backend/src/CCE.Application/Content/{IAssetService.cs => IAssetRepository.cs} (92%) rename backend/src/CCE.Application/Content/{ICountryResourceRequestService.cs => ICountryResourceRequestRepository.cs} (82%) rename backend/src/CCE.Application/Content/{IEventService.cs => IEventRepository.cs} (88%) rename backend/src/CCE.Application/Content/{IHomepageSectionService.cs => IHomepageSectionRepository.cs} (90%) rename backend/src/CCE.Application/Content/{INewsService.cs => INewsRepository.cs} (89%) rename backend/src/CCE.Application/Content/{IPageService.cs => IPageRepository.cs} (89%) rename backend/src/CCE.Application/Content/{IResourceCategoryService.cs => IResourceCategoryRepository.cs} (86%) rename backend/src/CCE.Application/Content/{IResourceService.cs => IResourceRepository.cs} (88%) rename backend/src/CCE.Application/Content/Public/{IResourceViewCountService.cs => IResourceViewCountRepository.cs} (71%) create mode 100644 backend/src/CCE.Application/Identity/Auth/Common/AuthMessageDto.cs create mode 100644 backend/src/CCE.Application/Identity/Auth/Common/AuthTokenDto.cs create mode 100644 backend/src/CCE.Application/Identity/Auth/Common/AuthUserDto.cs create mode 100644 backend/src/CCE.Application/Identity/Auth/Common/ILocalTokenService.cs create mode 100644 backend/src/CCE.Application/Identity/Auth/Common/IPasswordResetEmailSender.cs create mode 100644 backend/src/CCE.Application/Identity/Auth/Common/IRefreshTokenRepository.cs create mode 100644 backend/src/CCE.Application/Identity/Auth/Common/LocalAuthApi.cs create mode 100644 backend/src/CCE.Application/Identity/Auth/Common/LocalAuthJwtProfile.cs create mode 100644 backend/src/CCE.Application/Identity/Auth/Common/LocalAuthOptions.cs create mode 100644 backend/src/CCE.Application/Identity/Auth/Common/PasswordResetTokenCodec.cs create mode 100644 backend/src/CCE.Application/Identity/Auth/Common/TokenIssueResult.cs create mode 100644 backend/src/CCE.Application/Identity/Auth/ForgotPassword/ForgotPasswordCommand.cs create mode 100644 backend/src/CCE.Application/Identity/Auth/ForgotPassword/ForgotPasswordCommandHandler.cs create mode 100644 backend/src/CCE.Application/Identity/Auth/ForgotPassword/ForgotPasswordCommandValidator.cs create mode 100644 backend/src/CCE.Application/Identity/Auth/ForgotPassword/ForgotPasswordRequest.cs create mode 100644 backend/src/CCE.Application/Identity/Auth/Login/LoginCommand.cs create mode 100644 backend/src/CCE.Application/Identity/Auth/Login/LoginCommandHandler.cs create mode 100644 backend/src/CCE.Application/Identity/Auth/Login/LoginCommandValidator.cs create mode 100644 backend/src/CCE.Application/Identity/Auth/Login/LoginRequest.cs create mode 100644 backend/src/CCE.Application/Identity/Auth/Logout/LogoutCommand.cs create mode 100644 backend/src/CCE.Application/Identity/Auth/Logout/LogoutCommandHandler.cs create mode 100644 backend/src/CCE.Application/Identity/Auth/Logout/LogoutCommandValidator.cs create mode 100644 backend/src/CCE.Application/Identity/Auth/Logout/LogoutRequest.cs create mode 100644 backend/src/CCE.Application/Identity/Auth/RefreshToken/RefreshTokenCommand.cs create mode 100644 backend/src/CCE.Application/Identity/Auth/RefreshToken/RefreshTokenCommandHandler.cs create mode 100644 backend/src/CCE.Application/Identity/Auth/RefreshToken/RefreshTokenCommandValidator.cs create mode 100644 backend/src/CCE.Application/Identity/Auth/RefreshToken/RefreshTokenRequest.cs create mode 100644 backend/src/CCE.Application/Identity/Auth/Register/RegisterUserCommand.cs create mode 100644 backend/src/CCE.Application/Identity/Auth/Register/RegisterUserCommandHandler.cs create mode 100644 backend/src/CCE.Application/Identity/Auth/Register/RegisterUserCommandValidator.cs create mode 100644 backend/src/CCE.Application/Identity/Auth/Register/RegisterUserRequest.cs create mode 100644 backend/src/CCE.Application/Identity/Auth/ResetPassword/ResetPasswordCommand.cs create mode 100644 backend/src/CCE.Application/Identity/Auth/ResetPassword/ResetPasswordCommandHandler.cs create mode 100644 backend/src/CCE.Application/Identity/Auth/ResetPassword/ResetPasswordCommandValidator.cs create mode 100644 backend/src/CCE.Application/Identity/Auth/ResetPassword/ResetPasswordRequest.cs create mode 100644 backend/src/CCE.Application/Identity/Commands/ApproveExpertRequest/ApproveExpertRequestRequest.cs create mode 100644 backend/src/CCE.Application/Identity/Commands/AssignUserRoles/AssignUserRolesRequest.cs create mode 100644 backend/src/CCE.Application/Identity/Commands/CreateStateRepAssignment/CreateStateRepAssignmentRequest.cs create mode 100644 backend/src/CCE.Application/Identity/Commands/RejectExpertRequest/RejectExpertRequestRequest.cs rename backend/src/CCE.Application/Identity/{IExpertWorkflowService.cs => IExpertWorkflowRepository.cs} (95%) rename backend/src/CCE.Application/Identity/{IStateRepAssignmentService.cs => IStateRepAssignmentRepository.cs} (96%) rename backend/src/CCE.Application/Identity/{IUserRoleAssignmentService.cs => IUserRoleAssignmentRepository.cs} (94%) rename backend/src/CCE.Application/Identity/{IUserSyncService.cs => IUserSyncRepository.cs} (92%) create mode 100644 backend/src/CCE.Application/Identity/Public/Commands/SubmitExpertRequest/SubmitExpertRequestRequest.cs create mode 100644 backend/src/CCE.Application/Identity/Public/Commands/UpdateMyProfile/UpdateMyProfileRequest.cs rename backend/src/CCE.Application/Identity/Public/{IExpertRequestSubmissionService.cs => IExpertRequestSubmissionRepository.cs} (74%) rename backend/src/CCE.Application/Identity/Public/{IUserProfileService.cs => IUserProfileRepository.cs} (83%) create mode 100644 backend/src/CCE.Application/Identity/Public/RegisterUserContracts.cs rename backend/src/CCE.Infrastructure/Content/{AssetService.cs => AssetRepository.cs} (86%) rename backend/src/CCE.Infrastructure/Content/{CountryResourceRequestService.cs => CountryResourceRequestRepository.cs} (82%) rename backend/src/CCE.Infrastructure/Content/{EventService.cs => EventRepository.cs} (79%) rename backend/src/CCE.Infrastructure/Content/{HomepageSectionService.cs => HomepageSectionRepository.cs} (92%) rename backend/src/CCE.Infrastructure/Content/{NewsService.cs => NewsRepository.cs} (79%) rename backend/src/CCE.Infrastructure/Content/{PageService.cs => PageRepository.cs} (79%) rename backend/src/CCE.Infrastructure/Content/{ResourceCategoryService.cs => ResourceCategoryRepository.cs} (86%) rename backend/src/CCE.Infrastructure/Content/{ResourceService.cs => ResourceRepository.cs} (78%) rename backend/src/CCE.Infrastructure/Content/{ResourceViewCountService.cs => ResourceViewCountRepository.cs} (82%) rename backend/src/CCE.Infrastructure/Identity/{ExpertRequestSubmissionService.cs => ExpertRequestSubmissionRepository.cs} (74%) rename backend/src/CCE.Infrastructure/Identity/{ExpertWorkflowService.cs => ExpertWorkflowRepository.cs} (87%) create mode 100644 backend/src/CCE.Infrastructure/Identity/LocalTokenService.cs create mode 100644 backend/src/CCE.Infrastructure/Identity/PasswordResetEmailSender.cs create mode 100644 backend/src/CCE.Infrastructure/Identity/RefreshTokenRepository.cs rename backend/src/CCE.Infrastructure/Identity/{StateRepAssignmentService.cs => StateRepAssignmentRepository.cs} (88%) rename backend/src/CCE.Infrastructure/Identity/{UserProfileService.cs => UserProfileRepository.cs} (82%) rename backend/src/CCE.Infrastructure/Identity/{UserRoleAssignmentService.cs => UserRoleAssignmentRepository.cs} (83%) rename backend/src/CCE.Infrastructure/Identity/{UserSyncService.cs => UserSyncRepository.cs} (90%) create mode 100644 backend/src/CCE.Infrastructure/Persistence/Configurations/Identity/RefreshTokenConfiguration.cs create mode 100644 backend/src/CCE.Infrastructure/Persistence/DbContextExtensions.cs create mode 100644 backend/src/CCE.Infrastructure/Persistence/Migrations/20260514202038_AddLocalAuthRefreshTokens.Designer.cs create mode 100644 backend/src/CCE.Infrastructure/Persistence/Migrations/20260514202038_AddLocalAuthRefreshTokens.cs create mode 100644 backend/tests/CCE.Application.Tests/Identity/IdentityTestHelpers.cs diff --git a/backend/src/CCE.Api.Common/Auth/AuthEndpoints.cs b/backend/src/CCE.Api.Common/Auth/AuthEndpoints.cs new file mode 100644 index 00000000..555e2cf5 --- /dev/null +++ b/backend/src/CCE.Api.Common/Auth/AuthEndpoints.cs @@ -0,0 +1,99 @@ +using CCE.Api.Common.Extensions; +using CCE.Application.Identity.Auth.Common; +using CCE.Application.Identity.Auth.ForgotPassword; +using CCE.Application.Identity.Auth.Login; +using CCE.Application.Identity.Auth.Logout; +using CCE.Application.Identity.Auth.RefreshToken; +using CCE.Application.Identity.Auth.Register; +using CCE.Application.Identity.Auth.ResetPassword; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace CCE.Api.Common.Auth; + +public static class AuthEndpoints +{ + public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder app, LocalAuthApi api) + { + var auth = app.MapGroup("/api/auth").WithTags("Auth"); + + auth.MapPost("/register", async (RegisterUserRequest body, IMediator mediator, CancellationToken ct) => + { + var result = await mediator.Send(new RegisterUserCommand( + body.FirstName, + body.LastName, + body.EmailAddress, + body.JobTitle, + body.OrganizationName, + body.PhoneNumber, + body.Password, + body.ConfirmPassword), ct).ConfigureAwait(false); + return result.ToCreatedHttpResult(); + }) + .AllowAnonymous() + .WithName($"{api}RegisterUser"); + + auth.MapPost("/login", async (LoginRequest body, HttpContext ctx, IMediator mediator, CancellationToken ct) => + { + var result = await mediator.Send(new LoginCommand( + body.EmailAddress, + body.Password, + api, + GetIpAddress(ctx), + ctx.Request.Headers.UserAgent.ToString()), ct).ConfigureAwait(false); + return result.ToHttpResult(); + }) + .AllowAnonymous() + .WithName($"{api}Login"); + + auth.MapPost("/refresh", async (RefreshTokenRequest body, HttpContext ctx, IMediator mediator, CancellationToken ct) => + { + var result = await mediator.Send(new RefreshTokenCommand( + body.RefreshToken, + api, + GetIpAddress(ctx), + ctx.Request.Headers.UserAgent.ToString()), ct).ConfigureAwait(false); + return result.ToHttpResult(); + }) + .AllowAnonymous() + .WithName($"{api}RefreshToken"); + + auth.MapPost("/forgot-password", async (ForgotPasswordRequest body, IMediator mediator, CancellationToken ct) => + { + var result = await mediator.Send(new ForgotPasswordCommand(body.EmailAddress), ct).ConfigureAwait(false); + return result.ToHttpResult(); + }) + .AllowAnonymous() + .WithName($"{api}ForgotPassword"); + + auth.MapPost("/reset-password", async (ResetPasswordRequest body, HttpContext ctx, IMediator mediator, CancellationToken ct) => + { + var result = await mediator.Send(new ResetPasswordCommand( + body.EmailAddress, + body.Token, + body.NewPassword, + body.ConfirmPassword, + GetIpAddress(ctx)), ct).ConfigureAwait(false); + return result.ToHttpResult(); + }) + .AllowAnonymous() + .WithName($"{api}ResetPassword"); + + auth.MapPost("/logout", async (LogoutRequest body, HttpContext ctx, IMediator mediator, CancellationToken ct) => + { + var result = await mediator.Send(new LogoutCommand( + body.RefreshToken, + GetIpAddress(ctx)), ct).ConfigureAwait(false); + return result.ToHttpResult(); + }) + .AllowAnonymous() + .WithName($"{api}Logout"); + + return app; + } + + private static string? GetIpAddress(HttpContext ctx) + => ctx.Connection.RemoteIpAddress?.ToString(); +} diff --git a/backend/src/CCE.Api.Common/Auth/CceJwtAuthRegistration.cs b/backend/src/CCE.Api.Common/Auth/CceJwtAuthRegistration.cs index f147efff..9de4c78d 100644 --- a/backend/src/CCE.Api.Common/Auth/CceJwtAuthRegistration.cs +++ b/backend/src/CCE.Api.Common/Auth/CceJwtAuthRegistration.cs @@ -1,16 +1,20 @@ +using System.Text; +using CCE.Application.Identity.Auth.Common; using CCE.Infrastructure.Identity; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Identity.Web; using Microsoft.IdentityModel.Tokens; namespace CCE.Api.Common.Auth; public static class CceJwtAuthRegistration { - public static IServiceCollection AddCceJwtAuth(this IServiceCollection services, IConfiguration configuration) + public static IServiceCollection AddCceJwtAuth( + this IServiceCollection services, + IConfiguration configuration, + LocalAuthApi api = LocalAuthApi.External) { // Sub-11d follow-up — DevMode shim. When Auth:DevMode=true, register // DevAuthHandler as the default scheme (replacing M.I.W's JwtBearer) @@ -35,40 +39,43 @@ public static IServiceCollection AddCceJwtAuth(this IServiceCollection services, return services; } - // Microsoft.Identity.Web layers on top of JwtBearer: registers the JwtBearer - // scheme, points it at Entra ID's OIDC discovery endpoint, and pulls keys - // from the JWKS automatically. configSectionName must match the JSON section - // (EntraId:) in appsettings.json. - services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) - .AddMicrosoftIdentityWebApi(configuration, configSectionName: EntraIdOptions.SectionName); + var authOptions = configuration.GetSection(LocalAuthOptions.SectionName).Get() ?? new LocalAuthOptions(); + var profile = authOptions.GetProfile(api); + ValidateProfile(profile, api); - // Bind our strongly-typed options for downstream services to inject. + services.Configure(configuration.GetSection(LocalAuthOptions.SectionName)); services.Configure(configuration.GetSection(EntraIdOptions.SectionName)); - - // Override JwtBearer options post-AddMicrosoftIdentityWebApi to enforce - // multi-tenant issuer + roles claim type + match Sub-3-era pattern of - // MapInboundClaims=false. - services.Configure(JwtBearerDefaults.AuthenticationScheme, jwt => - { - jwt.MapInboundClaims = false; - - jwt.TokenValidationParameters.NameClaimType = "preferred_username"; - jwt.TokenValidationParameters.RoleClaimType = "roles"; - - // Multi-tenant: any Entra ID tenant's issuer is acceptable, as long as it - // matches the canonical login.microsoftonline.com//v2.0 shape. - jwt.TokenValidationParameters.ValidateIssuer = true; - jwt.TokenValidationParameters.IssuerValidator = (issuer, _, _) => EntraIdIssuerValidator.Validate(issuer); - - // Audience validation re-enabled. Entra ID always issues an `aud` claim - // matching the API's app ID URI (api://). - jwt.TokenValidationParameters.ValidateAudience = true; - - jwt.TokenValidationParameters.ValidateLifetime = true; - jwt.TokenValidationParameters.ClockSkew = TimeSpan.FromMinutes(5); - }); + services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(jwt => + { + jwt.MapInboundClaims = false; + jwt.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = profile.Issuer, + ValidateAudience = true, + ValidAudience = profile.Audience, + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(profile.SigningKey)), + ValidateLifetime = true, + ClockSkew = TimeSpan.FromMinutes(2), + NameClaimType = "preferred_username", + RoleClaimType = "roles", + }; + }); services.AddAuthorization(); return services; } + + private static void ValidateProfile(LocalAuthJwtProfile profile, LocalAuthApi api) + { + if (string.IsNullOrWhiteSpace(profile.Issuer) + || string.IsNullOrWhiteSpace(profile.Audience) + || Encoding.UTF8.GetByteCount(profile.SigningKey) < 32) + { + throw new InvalidOperationException( + $"LocalAuth:{api} requires Issuer, Audience, and a 32+ byte SigningKey."); + } + } } diff --git a/backend/src/CCE.Api.Common/Identity/UserSyncMiddleware.cs b/backend/src/CCE.Api.Common/Identity/UserSyncMiddleware.cs index 37721e36..afdc884f 100644 --- a/backend/src/CCE.Api.Common/Identity/UserSyncMiddleware.cs +++ b/backend/src/CCE.Api.Common/Identity/UserSyncMiddleware.cs @@ -28,7 +28,7 @@ public UserSyncMiddleware(RequestDelegate next, ILogger logg public async Task InvokeAsync( HttpContext context, IMemoryCache cache, - IUserSyncService syncService) + IUserSyncRepository syncService) { if (context.User.Identity?.IsAuthenticated != true) { diff --git a/backend/src/CCE.Api.Common/Middleware/ExceptionHandlingMiddleware.cs b/backend/src/CCE.Api.Common/Middleware/ExceptionHandlingMiddleware.cs index 53e54155..a3e27400 100644 --- a/backend/src/CCE.Api.Common/Middleware/ExceptionHandlingMiddleware.cs +++ b/backend/src/CCE.Api.Common/Middleware/ExceptionHandlingMiddleware.cs @@ -1,9 +1,12 @@ +using CCE.Application.Common; +using CCE.Application.Localization; using CCE.Domain.Common; using FluentValidation; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using System.Text.Json; +using System.Text.Json.Serialization; namespace CCE.Api.Common.Middleware; @@ -26,98 +29,94 @@ public async Task InvokeAsync(HttpContext context) } catch (ValidationException ex) { - await WriteValidationProblemAsync(context, ex).ConfigureAwait(false); + await WriteValidationResultAsync(context, ex).ConfigureAwait(false); } // Expected business outcomes — not logged (not server errors). catch (ConcurrencyException ex) { - await WriteProblemAsync(context, StatusCodes.Status409Conflict, - title: "Concurrent edit", - detail: ex.Message, - type: "https://cce.moenergy.gov.sa/problems/concurrency").ConfigureAwait(false); + await WriteErrorResultAsync(context, StatusCodes.Status409Conflict, + "CONCURRENCY_CONFLICT", ErrorType.Conflict, ex.Message).ConfigureAwait(false); } catch (DuplicateException ex) { - await WriteProblemAsync(context, StatusCodes.Status409Conflict, - title: "Duplicate value", - detail: ex.Message, - type: "https://cce.moenergy.gov.sa/problems/duplicate").ConfigureAwait(false); + await WriteErrorResultAsync(context, StatusCodes.Status409Conflict, + "DUPLICATE_VALUE", ErrorType.Conflict, ex.Message).ConfigureAwait(false); } catch (DomainException ex) { - await WriteProblemAsync(context, StatusCodes.Status400BadRequest, - title: "Invariant violated", - detail: ex.Message, - type: "https://cce.moenergy.gov.sa/problems/invariant").ConfigureAwait(false); + await WriteErrorResultAsync(context, StatusCodes.Status400BadRequest, + "GENERAL_BAD_REQUEST", ErrorType.BusinessRule, ex.Message).ConfigureAwait(false); } catch (System.Collections.Generic.KeyNotFoundException ex) { - await WriteProblemAsync(context, StatusCodes.Status404NotFound, - title: "Resource not found", - detail: ex.Message, - type: "https://cce.moenergy.gov.sa/problems/not-found").ConfigureAwait(false); + // Legacy — still caught for non-migrated handlers + await WriteErrorResultAsync(context, StatusCodes.Status404NotFound, + "GENERAL_NOT_FOUND", ErrorType.NotFound, ex.Message).ConfigureAwait(false); } catch (Exception ex) { _logger.LogError(ex, "Unhandled exception"); - await WriteServerErrorAsync(context, ex).ConfigureAwait(false); + await WriteErrorResultAsync(context, StatusCodes.Status500InternalServerError, + "GENERAL_INTERNAL_ERROR", ErrorType.Internal, null).ConfigureAwait(false); } } private static string GetCorrelationId(HttpContext ctx) => ctx.Items[CorrelationIdMiddleware.ItemKey]?.ToString() ?? Guid.NewGuid().ToString(); - private static async Task WriteValidationProblemAsync(HttpContext ctx, ValidationException ex) + /// + /// Writes a unified error response matching the shape, + /// so clients always see the same JSON structure regardless of whether + /// the error came from a handler or the middleware. + /// + private static async Task WriteErrorResultAsync( + HttpContext ctx, int statusCode, string code, ErrorType type, string? fallbackMessage) + { + var l = ctx.RequestServices.GetService(); + var msg = l?.GetLocalizedMessage(code); + + var error = new Error( + code, + msg?.Ar ?? fallbackMessage ?? "خطأ", + msg?.En ?? fallbackMessage ?? "Error", + type); + + var envelope = new { isSuccess = false, data = (object?)null, error }; + + ctx.Response.StatusCode = statusCode; + ctx.Response.ContentType = "application/json"; + await JsonSerializer.SerializeAsync(ctx.Response.Body, envelope, JsonOptions) + .ConfigureAwait(false); + } + + private static async Task WriteValidationResultAsync(HttpContext ctx, ValidationException ex) { var errors = ex.Errors .GroupBy(e => e.PropertyName) .ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray()); - var problem = new ValidationProblemDetails(errors) - { - Status = StatusCodes.Status400BadRequest, - Type = "https://tools.ietf.org/html/rfc9110#section-15.5.1", - Title = "One or more validation errors occurred." - }; - problem.Extensions["correlationId"] = GetCorrelationId(ctx); + var l = ctx.RequestServices.GetService(); + var msg = l?.GetLocalizedMessage("GENERAL_VALIDATION_ERROR"); - ctx.Response.StatusCode = StatusCodes.Status400BadRequest; - ctx.Response.ContentType = "application/problem+json"; - await JsonSerializer.SerializeAsync(ctx.Response.Body, problem).ConfigureAwait(false); - } + var error = new Error( + "GENERAL_VALIDATION_ERROR", + msg?.Ar ?? "عذرًا، البيانات المدخلة غير صحيحة", + msg?.En ?? "Sorry, the entered data is invalid", + ErrorType.Validation, + errors); - private static async Task WriteProblemAsync( - HttpContext ctx, int statusCode, string title, string detail, string type) - { - var problem = new ProblemDetails - { - Status = statusCode, - Type = type, - Title = title, - Detail = detail, - Instance = ctx.Request.Path, - }; - problem.Extensions["correlationId"] = GetCorrelationId(ctx); + var envelope = new { isSuccess = false, data = (object?)null, error }; - ctx.Response.StatusCode = statusCode; - ctx.Response.ContentType = "application/problem+json"; - await JsonSerializer.SerializeAsync(ctx.Response.Body, problem).ConfigureAwait(false); + ctx.Response.StatusCode = StatusCodes.Status400BadRequest; + ctx.Response.ContentType = "application/json"; + await JsonSerializer.SerializeAsync(ctx.Response.Body, envelope, JsonOptions) + .ConfigureAwait(false); } - private static async Task WriteServerErrorAsync(HttpContext ctx, Exception ex) + private static readonly JsonSerializerOptions JsonOptions = new() { - _ = ex; // intentionally unused — never expose to clients - var problem = new ProblemDetails - { - Status = StatusCodes.Status500InternalServerError, - Type = "https://tools.ietf.org/html/rfc9110#section-15.6.1", - Title = "An unexpected error occurred.", - Detail = "See server logs by correlation id for details." - }; - problem.Extensions["correlationId"] = GetCorrelationId(ctx); - - ctx.Response.StatusCode = StatusCodes.Status500InternalServerError; - ctx.Response.ContentType = "application/problem+json"; - await JsonSerializer.SerializeAsync(ctx.Response.Body, problem).ConfigureAwait(false); - } + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) } + }; } diff --git a/backend/src/CCE.Api.External/Endpoints/ProfileEndpoints.cs b/backend/src/CCE.Api.External/Endpoints/ProfileEndpoints.cs index 2c78a851..a38d929e 100644 --- a/backend/src/CCE.Api.External/Endpoints/ProfileEndpoints.cs +++ b/backend/src/CCE.Api.External/Endpoints/ProfileEndpoints.cs @@ -1,19 +1,15 @@ using CCE.Api.Common.Auth; +using CCE.Api.Common.Extensions; using CCE.Application.Common.Interfaces; +using CCE.Application.Identity.Auth.Register; using CCE.Application.Identity.Public.Commands.SubmitExpertRequest; using CCE.Application.Identity.Public.Commands.UpdateMyProfile; using CCE.Application.Identity.Public.Queries.GetMyExpertStatus; using CCE.Application.Identity.Public.Queries.GetMyProfile; -using CCE.Domain.Identity; -using CCE.Infrastructure.Identity; -using CCE.Infrastructure.Persistence; using MediatR; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; namespace CCE.Api.External.Endpoints; @@ -23,97 +19,23 @@ public static IEndpointRouteBuilder MapProfileEndpoints(this IEndpointRouteBuild { var users = app.MapGroup("/api/users").WithTags("Profile"); - // Sub-11d — anonymous self-service registration via Microsoft Graph. - // Sub-11 Phase 01 made this admin-only as a stop-gap until an - // IEmailSender existed; Sub-11d Task A added the abstraction + - // Task B wired it into EntraIdRegistrationService, so the temp - // password is now delivered via email instead of returned in the - // response. Endpoint is anonymous again — the welcome email is - // the user's only credential channel. - // - // Response shape: 201 with the new user's UPN + objectId only. - // The temporary password is intentionally NOT in the response - // (would leak to logs / screen-captures); operators check the - // email transport on registration failure. + // Compatibility route for older frontend calls. Sprint 01 local auth + // owns registration now; it creates the user only and does not auto-login. users.MapPost("/register", async ( RegisterUserRequest body, - HttpContext httpCtx, - IConfiguration config, - EntraIdRegistrationService registrationService, - CceDbContext db, + IMediator mediator, CancellationToken ct) => { - if (body is null - || string.IsNullOrWhiteSpace(body.GivenName) - || string.IsNullOrWhiteSpace(body.Surname) - || string.IsNullOrWhiteSpace(body.Email) - || string.IsNullOrWhiteSpace(body.MailNickname)) - { - return Results.BadRequest(new { error = "GivenName, Surname, Email, MailNickname are required." }); - } - - // ─── Dev-mode shortcut ────────────────────────────────────────── - // Without a real Entra ID tenant the Graph user-create call - // can't succeed (placeholder ClientId in appsettings.Development.json). - // In dev we synthesize a CCE.DB User row directly + sign the - // user in via the dev cookie so the registration flow is usable - // end-to-end on localhost. - var devMode = config.GetValue("Auth:DevMode"); - if (devMode) - { - var normalizedEmail = body.Email.ToUpperInvariant(); - var existing = await db.Users - .FirstOrDefaultAsync(u => u.NormalizedEmail == normalizedEmail, ct) - .ConfigureAwait(false); - if (existing is not null) - { - return Results.Conflict(new { error = "An account with that email already exists." }); - } - var newUser = new User - { - Id = Guid.NewGuid(), - UserName = body.Email, - NormalizedUserName = body.Email.ToUpperInvariant(), - Email = body.Email, - NormalizedEmail = body.Email.ToUpperInvariant(), - EmailConfirmed = true, - }; - db.Users.Add(newUser); - await db.SaveChangesAsync(ct).ConfigureAwait(false); - - // Auto-sign-in via the dev cookie so the SPA picks the user up. - httpCtx.Response.Cookies.Append(DevAuthHandler.DevCookieName, "cce-user", new CookieOptions - { - HttpOnly = false, - Secure = false, - SameSite = SameSiteMode.Lax, - Path = "/", - Expires = DateTimeOffset.UtcNow.AddDays(7), - }); - - return Results.Created($"/api/users/{newUser.Id}", - new RegisterUserResponse(newUser.Id, body.Email, $"{body.GivenName} {body.Surname}")); - } - - // ─── Production path: Microsoft Graph user-create ─────────────── - var dto = new RegistrationRequest(body.GivenName, body.Surname, body.Email, body.MailNickname); - try - { - var result = await registrationService.CreateUserAsync(dto, ct).ConfigureAwait(false); - var response = new RegisterUserResponse( - result.EntraIdObjectId, - result.UserPrincipalName, - result.DisplayName); - return Results.Created($"/api/users/{result.EntraIdObjectId}", response); - } - catch (EntraIdRegistrationConflictException) - { - return Results.Conflict(new { error = "User principal name already exists in Entra ID." }); - } - catch (EntraIdRegistrationAuthorizationException) - { - return Results.StatusCode(StatusCodes.Status403Forbidden); - } + var result = await mediator.Send(new RegisterUserCommand( + body.FirstName, + body.LastName, + body.EmailAddress, + body.JobTitle, + body.OrganizationName, + body.PhoneNumber, + body.Password, + body.ConfirmPassword), ct).ConfigureAwait(false); + return result.ToCreatedHttpResult(); }) .AllowAnonymous() .WithName("RegisterUser"); @@ -129,8 +51,8 @@ public static IEndpointRouteBuilder MapProfileEndpoints(this IEndpointRouteBuild var cmd = new SubmitExpertRequestCommand( userId, body.RequestedBioAr, body.RequestedBioEn, body.RequestedTags ?? System.Array.Empty()); - var dto = await mediator.Send(cmd, ct).ConfigureAwait(false); - return Results.Created("/api/me/expert-status", dto); + var result = await mediator.Send(cmd, ct).ConfigureAwait(false); + return result.ToCreatedHttpResult(); }) .WithName("SubmitExpertRequest"); @@ -142,8 +64,8 @@ public static IEndpointRouteBuilder MapProfileEndpoints(this IEndpointRouteBuild { var userId = currentUser.GetUserId() ?? System.Guid.Empty; if (userId == System.Guid.Empty) return Results.Unauthorized(); - var dto = await mediator.Send(new GetMyProfileQuery(userId), ct).ConfigureAwait(false); - return dto is null ? Results.NotFound() : Results.Ok(dto); + var result = await mediator.Send(new GetMyProfileQuery(userId), ct).ConfigureAwait(false); + return result.ToHttpResult(); }) .WithName("GetMyProfile"); @@ -158,8 +80,8 @@ public static IEndpointRouteBuilder MapProfileEndpoints(this IEndpointRouteBuild userId, body.LocalePreference, body.KnowledgeLevel, body.Interests ?? System.Array.Empty(), body.AvatarUrl, body.CountryId); - var dto = await mediator.Send(cmd, ct).ConfigureAwait(false); - return dto is null ? Results.NotFound() : Results.Ok(dto); + var result = await mediator.Send(cmd, ct).ConfigureAwait(false); + return result.ToHttpResult(); }) .WithName("UpdateMyProfile"); @@ -169,38 +91,11 @@ public static IEndpointRouteBuilder MapProfileEndpoints(this IEndpointRouteBuild { var userId = currentUser.GetUserId() ?? System.Guid.Empty; if (userId == System.Guid.Empty) return Results.Unauthorized(); - var dto = await mediator.Send(new GetMyExpertStatusQuery(userId), ct).ConfigureAwait(false); - return dto is null ? Results.NotFound() : Results.Ok(dto); + var result = await mediator.Send(new GetMyExpertStatusQuery(userId), ct).ConfigureAwait(false); + return result.ToHttpResult(); }) .WithName("GetMyExpertStatus"); return app; } } - -public sealed record UpdateMyProfileRequest( - string LocalePreference, - KnowledgeLevel KnowledgeLevel, - IReadOnlyList? Interests, - string? AvatarUrl, - System.Guid? CountryId); - -public sealed record SubmitExpertRequestRequest( - string RequestedBioAr, - string RequestedBioEn, - IReadOnlyList? RequestedTags); - -public sealed record RegisterUserRequest( - string GivenName, - string Surname, - string Email, - string MailNickname); - -/// -/// Sub-11d — public response shape for /api/users/register. Excludes -/// the temporary password (delivered via the welcome email instead). -/// -public sealed record RegisterUserResponse( - System.Guid EntraIdObjectId, - string UserPrincipalName, - string DisplayName); diff --git a/backend/src/CCE.Api.External/Endpoints/ResourcesPublicEndpoints.cs b/backend/src/CCE.Api.External/Endpoints/ResourcesPublicEndpoints.cs index 86d5e8b0..80ecd9e6 100644 --- a/backend/src/CCE.Api.External/Endpoints/ResourcesPublicEndpoints.cs +++ b/backend/src/CCE.Api.External/Endpoints/ResourcesPublicEndpoints.cs @@ -47,7 +47,7 @@ public static IEndpointRouteBuilder MapResourcesPublicEndpoints(this IEndpointRo HttpContext httpContext, ICceDbContext db, IFileStorage storage, - IResourceViewCountService viewCounter, + IResourceViewCountRepository viewCounter, CancellationToken cancellationToken) => { // Load resource + asset metadata in a single round trip. diff --git a/backend/src/CCE.Api.External/Program.cs b/backend/src/CCE.Api.External/Program.cs index ed173e4b..f2439ee3 100644 --- a/backend/src/CCE.Api.External/Program.cs +++ b/backend/src/CCE.Api.External/Program.cs @@ -40,7 +40,7 @@ .AddCceBff(builder.Configuration) .AddCceOutputCache(builder.Configuration) .AddCceTieredRateLimiter(builder.Configuration) - .AddCceJwtAuth(builder.Configuration) + .AddCceJwtAuth(builder.Configuration, CCE.Application.Identity.Auth.Common.LocalAuthApi.External) .AddCcePermissionPolicies() .AddCceUserSync() .AddCceHealthChecks(builder.Configuration) @@ -85,6 +85,7 @@ } app.MapProfileEndpoints(); +app.MapAuthEndpoints(CCE.Application.Identity.Auth.Common.LocalAuthApi.External); app.MapNotificationsEndpoints(); app.MapNewsPublicEndpoints(); app.MapEventsPublicEndpoints(); diff --git a/backend/src/CCE.Api.External/appsettings.Development.json b/backend/src/CCE.Api.External/appsettings.Development.json index 5de4c279..ebf833ba 100644 --- a/backend/src/CCE.Api.External/appsettings.Development.json +++ b/backend/src/CCE.Api.External/appsettings.Development.json @@ -47,6 +47,22 @@ "GraphTenantDomain": "cce.local", "CallbackPath": "/signin-oidc" }, + "LocalAuth": { + "External": { + "Issuer": "cce-api-external-dev", + "Audience": "cce-public-dev", + "SigningKey": "dev-external-local-auth-signing-key-change-me-12345" + }, + "Internal": { + "Issuer": "cce-api-internal-dev", + "Audience": "cce-admin-dev", + "SigningKey": "dev-internal-local-auth-signing-key-change-me-12345" + }, + "AccessTokenMinutes": 10, + "RefreshTokenDays": 30, + "PasswordResetTokenHours": 2, + "RequireConfirmedEmail": false + }, "Email": { "Provider": "smtp", "Host": "localhost", diff --git a/backend/src/CCE.Api.External/appsettings.json b/backend/src/CCE.Api.External/appsettings.json index a130f656..1d08c9be 100644 --- a/backend/src/CCE.Api.External/appsettings.json +++ b/backend/src/CCE.Api.External/appsettings.json @@ -30,5 +30,21 @@ "Audience": "", "GraphTenantId": "", "GraphTenantDomain": "" + }, + "LocalAuth": { + "External": { + "Issuer": "cce-api-external", + "Audience": "cce-public", + "SigningKey": "replace-with-external-32-byte-minimum-signing-key" + }, + "Internal": { + "Issuer": "cce-api-internal", + "Audience": "cce-admin", + "SigningKey": "replace-with-internal-32-byte-minimum-signing-key" + }, + "AccessTokenMinutes": 10, + "RefreshTokenDays": 30, + "PasswordResetTokenHours": 2, + "RequireConfirmedEmail": false } } diff --git a/backend/src/CCE.Api.Internal/Endpoints/ExpertEndpoints.cs b/backend/src/CCE.Api.Internal/Endpoints/ExpertEndpoints.cs index 4e7ea718..cf07f605 100644 --- a/backend/src/CCE.Api.Internal/Endpoints/ExpertEndpoints.cs +++ b/backend/src/CCE.Api.Internal/Endpoints/ExpertEndpoints.cs @@ -1,3 +1,4 @@ +using CCE.Api.Common.Extensions; using CCE.Application.Identity.Commands.ApproveExpertRequest; using CCE.Application.Identity.Commands.RejectExpertRequest; using CCE.Application.Identity.Queries.ListExpertProfiles; @@ -38,8 +39,8 @@ public static IEndpointRouteBuilder MapExpertEndpoints(this IEndpointRouteBuilde IMediator mediator, CancellationToken cancellationToken) => { var cmd = new ApproveExpertRequestCommand(id, body.AcademicTitleAr, body.AcademicTitleEn); - var dto = await mediator.Send(cmd, cancellationToken).ConfigureAwait(false); - return Results.Ok(dto); + var result = await mediator.Send(cmd, cancellationToken).ConfigureAwait(false); + return result.ToHttpResult(); }) .RequireAuthorization(Permissions.Community_Expert_ApproveRequest) .WithName("ApproveExpertRequest"); @@ -50,8 +51,8 @@ public static IEndpointRouteBuilder MapExpertEndpoints(this IEndpointRouteBuilde IMediator mediator, CancellationToken cancellationToken) => { var cmd = new RejectExpertRequestCommand(id, body.RejectionReasonAr, body.RejectionReasonEn); - var dto = await mediator.Send(cmd, cancellationToken).ConfigureAwait(false); - return Results.Ok(dto); + var result = await mediator.Send(cmd, cancellationToken).ConfigureAwait(false); + return result.ToHttpResult(); }) .RequireAuthorization(Permissions.Community_Expert_ApproveRequest) .WithName("RejectExpertRequest"); @@ -76,5 +77,4 @@ public static IEndpointRouteBuilder MapExpertEndpoints(this IEndpointRouteBuilde } } -public sealed record ApproveExpertRequestRequest(string AcademicTitleAr, string AcademicTitleEn); -public sealed record RejectExpertRequestRequest(string RejectionReasonAr, string RejectionReasonEn); + diff --git a/backend/src/CCE.Api.Internal/Endpoints/IdentityEndpoints.cs b/backend/src/CCE.Api.Internal/Endpoints/IdentityEndpoints.cs index a85e5c3f..a89ab039 100644 --- a/backend/src/CCE.Api.Internal/Endpoints/IdentityEndpoints.cs +++ b/backend/src/CCE.Api.Internal/Endpoints/IdentityEndpoints.cs @@ -1,3 +1,4 @@ +using CCE.Api.Common.Extensions; using CCE.Application.Identity.Commands.AssignUserRoles; using CCE.Application.Identity.Commands.CreateStateRepAssignment; using CCE.Application.Identity.Commands.RevokeStateRepAssignment; @@ -42,8 +43,8 @@ public static IEndpointRouteBuilder MapIdentityEndpoints(this IEndpointRouteBuil System.Guid id, IMediator mediator, CancellationToken ct) => { - var dto = await mediator.Send(new GetUserByIdQuery(id), ct).ConfigureAwait(false); - return dto is null ? Results.NotFound() : Results.Ok(dto); + var result = await mediator.Send(new GetUserByIdQuery(id), ct).ConfigureAwait(false); + return result.ToHttpResult(); }) .RequireAuthorization(Permissions.User_Read) .WithName("GetUserById"); @@ -54,8 +55,8 @@ public static IEndpointRouteBuilder MapIdentityEndpoints(this IEndpointRouteBuil IMediator mediator, CancellationToken cancellationToken) => { var cmd = new AssignUserRolesCommand(id, body.Roles ?? System.Array.Empty()); - var dto = await mediator.Send(cmd, cancellationToken).ConfigureAwait(false); - return dto is null ? Results.NotFound() : Results.Ok(dto); + var result = await mediator.Send(cmd, cancellationToken).ConfigureAwait(false); + return result.ToHttpResult(); }) .RequireAuthorization(Permissions.Role_Assign) .WithName("AssignUserRoles"); @@ -98,8 +99,8 @@ public static IEndpointRouteBuilder MapIdentityEndpoints(this IEndpointRouteBuil IMediator mediator, CancellationToken cancellationToken) => { var cmd = new CreateStateRepAssignmentCommand(body.UserId, body.CountryId); - var dto = await mediator.Send(cmd, cancellationToken).ConfigureAwait(false); - return Results.Created($"/api/admin/state-rep-assignments/{dto.Id}", dto); + var result = await mediator.Send(cmd, cancellationToken).ConfigureAwait(false); + return result.ToCreatedHttpResult(); }) .RequireAuthorization(Permissions.Role_Assign) .WithName("CreateStateRepAssignment"); @@ -108,8 +109,8 @@ public static IEndpointRouteBuilder MapIdentityEndpoints(this IEndpointRouteBuil System.Guid id, IMediator mediator, CancellationToken cancellationToken) => { - await mediator.Send(new RevokeStateRepAssignmentCommand(id), cancellationToken).ConfigureAwait(false); - return Results.NoContent(); + var result = await mediator.Send(new RevokeStateRepAssignmentCommand(id), cancellationToken).ConfigureAwait(false); + return result.ToNoContentHttpResult(); }) .RequireAuthorization(Permissions.Role_Assign) .WithName("RevokeStateRepAssignment"); @@ -118,8 +119,4 @@ public static IEndpointRouteBuilder MapIdentityEndpoints(this IEndpointRouteBuil } } -/// Body shape for PUT /api/admin/users/{id}/roles. -public sealed record AssignUserRolesRequest(IReadOnlyList? Roles); -/// Body shape for POST /api/admin/state-rep-assignments. -public sealed record CreateStateRepAssignmentRequest(System.Guid UserId, System.Guid CountryId); diff --git a/backend/src/CCE.Api.Internal/Program.cs b/backend/src/CCE.Api.Internal/Program.cs index 05259a12..159a1a42 100644 --- a/backend/src/CCE.Api.Internal/Program.cs +++ b/backend/src/CCE.Api.Internal/Program.cs @@ -17,16 +17,22 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using Serilog; using System.Globalization; +using System.Text.Json.Serialization; var builder = WebApplication.CreateBuilder(args); builder.Host.UseCceSerilog(); +builder.Services.ConfigureHttpJsonOptions(opts => +{ + opts.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); +}); + builder.Services .AddApplication() .AddInfrastructure(builder.Configuration) .AddCceMeilisearchIndexer() - .AddCceJwtAuth(builder.Configuration) + .AddCceJwtAuth(builder.Configuration, CCE.Application.Identity.Auth.Common.LocalAuthApi.Internal) .AddCcePermissionPolicies() .AddCceUserSync() .AddCceHealthChecks(builder.Configuration) @@ -53,6 +59,7 @@ app.UseCceOpenApi(apiTag: "internal"); +app.MapAuthEndpoints(CCE.Application.Identity.Auth.Common.LocalAuthApi.Internal); app.MapIdentityEndpoints(); app.MapExpertEndpoints(); app.MapAssetEndpoints(); diff --git a/backend/src/CCE.Api.Internal/appsettings.Development.json b/backend/src/CCE.Api.Internal/appsettings.Development.json index 61e571ff..09425390 100644 --- a/backend/src/CCE.Api.Internal/appsettings.Development.json +++ b/backend/src/CCE.Api.Internal/appsettings.Development.json @@ -34,6 +34,22 @@ "GraphTenantDomain": "cce.local", "CallbackPath": "/signin-oidc" }, + "LocalAuth": { + "External": { + "Issuer": "cce-api-external-dev", + "Audience": "cce-public-dev", + "SigningKey": "dev-external-local-auth-signing-key-change-me-12345" + }, + "Internal": { + "Issuer": "cce-api-internal-dev", + "Audience": "cce-admin-dev", + "SigningKey": "dev-internal-local-auth-signing-key-change-me-12345" + }, + "AccessTokenMinutes": 10, + "RefreshTokenDays": 30, + "PasswordResetTokenHours": 2, + "RequireConfirmedEmail": false + }, "Email": { "Provider": "smtp", "Host": "localhost", diff --git a/backend/src/CCE.Api.Internal/appsettings.json b/backend/src/CCE.Api.Internal/appsettings.json index a130f656..1d08c9be 100644 --- a/backend/src/CCE.Api.Internal/appsettings.json +++ b/backend/src/CCE.Api.Internal/appsettings.json @@ -30,5 +30,21 @@ "Audience": "", "GraphTenantId": "", "GraphTenantDomain": "" + }, + "LocalAuth": { + "External": { + "Issuer": "cce-api-external", + "Audience": "cce-public", + "SigningKey": "replace-with-external-32-byte-minimum-signing-key" + }, + "Internal": { + "Issuer": "cce-api-internal", + "Audience": "cce-admin", + "SigningKey": "replace-with-internal-32-byte-minimum-signing-key" + }, + "AccessTokenMinutes": 10, + "RefreshTokenDays": 30, + "PasswordResetTokenHours": 2, + "RequireConfirmedEmail": false } } diff --git a/backend/src/CCE.Application/Common/Interfaces/ICceDbContext.cs b/backend/src/CCE.Application/Common/Interfaces/ICceDbContext.cs index 25cae575..08baabcf 100644 --- a/backend/src/CCE.Application/Common/Interfaces/ICceDbContext.cs +++ b/backend/src/CCE.Application/Common/Interfaces/ICceDbContext.cs @@ -29,6 +29,7 @@ public interface ICceDbContext IQueryable Countries { get; } IQueryable ExpertRegistrationRequests { get; } IQueryable ExpertProfiles { get; } + IQueryable RefreshTokens { get; } IQueryable AssetFiles { get; } IQueryable ResourceCategories { get; } IQueryable Resources { get; } diff --git a/backend/src/CCE.Application/Community/Commands/EditReply/EditReplyCommandHandler.cs b/backend/src/CCE.Application/Community/Commands/EditReply/EditReplyCommandHandler.cs index 5852d290..d04b35b3 100644 --- a/backend/src/CCE.Application/Community/Commands/EditReply/EditReplyCommandHandler.cs +++ b/backend/src/CCE.Application/Community/Commands/EditReply/EditReplyCommandHandler.cs @@ -49,7 +49,7 @@ public async Task Handle(EditReplyCommand request, CancellationToken cance } var sanitized = _sanitizer.Sanitize(request.Content); - reply.EditContent(sanitized); + reply.EditContent(sanitized, userId, _clock); await _service.UpdateReplyAsync(reply, cancellationToken).ConfigureAwait(false); return Unit.Value; } diff --git a/backend/src/CCE.Application/Content/Commands/ApproveCountryResourceRequest/ApproveCountryResourceRequestCommandHandler.cs b/backend/src/CCE.Application/Content/Commands/ApproveCountryResourceRequest/ApproveCountryResourceRequestCommandHandler.cs index 21583cc8..01ade47f 100644 --- a/backend/src/CCE.Application/Content/Commands/ApproveCountryResourceRequest/ApproveCountryResourceRequestCommandHandler.cs +++ b/backend/src/CCE.Application/Content/Commands/ApproveCountryResourceRequest/ApproveCountryResourceRequestCommandHandler.cs @@ -9,12 +9,12 @@ namespace CCE.Application.Content.Commands.ApproveCountryResourceRequest; public sealed class ApproveCountryResourceRequestCommandHandler : IRequestHandler { - private readonly ICountryResourceRequestService _service; + private readonly ICountryResourceRequestRepository _service; private readonly ICurrentUserAccessor _currentUser; private readonly ISystemClock _clock; public ApproveCountryResourceRequestCommandHandler( - ICountryResourceRequestService service, + ICountryResourceRequestRepository service, ICurrentUserAccessor currentUser, ISystemClock clock) { diff --git a/backend/src/CCE.Application/Content/Commands/CreateEvent/CreateEventCommandHandler.cs b/backend/src/CCE.Application/Content/Commands/CreateEvent/CreateEventCommandHandler.cs index b54779c9..194778ba 100644 --- a/backend/src/CCE.Application/Content/Commands/CreateEvent/CreateEventCommandHandler.cs +++ b/backend/src/CCE.Application/Content/Commands/CreateEvent/CreateEventCommandHandler.cs @@ -8,10 +8,10 @@ namespace CCE.Application.Content.Commands.CreateEvent; public sealed class CreateEventCommandHandler : IRequestHandler { - private readonly IEventService _service; + private readonly IEventRepository _service; private readonly ISystemClock _clock; - public CreateEventCommandHandler(IEventService service, ISystemClock clock) + public CreateEventCommandHandler(IEventRepository service, ISystemClock clock) { _service = service; _clock = clock; diff --git a/backend/src/CCE.Application/Content/Commands/CreateHomepageSection/CreateHomepageSectionCommandHandler.cs b/backend/src/CCE.Application/Content/Commands/CreateHomepageSection/CreateHomepageSectionCommandHandler.cs index c8c7e18f..4b6729f2 100644 --- a/backend/src/CCE.Application/Content/Commands/CreateHomepageSection/CreateHomepageSectionCommandHandler.cs +++ b/backend/src/CCE.Application/Content/Commands/CreateHomepageSection/CreateHomepageSectionCommandHandler.cs @@ -7,9 +7,9 @@ namespace CCE.Application.Content.Commands.CreateHomepageSection; public sealed class CreateHomepageSectionCommandHandler : IRequestHandler { - private readonly IHomepageSectionService _service; + private readonly IHomepageSectionRepository _service; - public CreateHomepageSectionCommandHandler(IHomepageSectionService service) + public CreateHomepageSectionCommandHandler(IHomepageSectionRepository service) { _service = service; } diff --git a/backend/src/CCE.Application/Content/Commands/CreateNews/CreateNewsCommandHandler.cs b/backend/src/CCE.Application/Content/Commands/CreateNews/CreateNewsCommandHandler.cs index 6825e958..42481895 100644 --- a/backend/src/CCE.Application/Content/Commands/CreateNews/CreateNewsCommandHandler.cs +++ b/backend/src/CCE.Application/Content/Commands/CreateNews/CreateNewsCommandHandler.cs @@ -9,12 +9,12 @@ namespace CCE.Application.Content.Commands.CreateNews; public sealed class CreateNewsCommandHandler : IRequestHandler { - private readonly INewsService _service; + private readonly INewsRepository _service; private readonly ICurrentUserAccessor _currentUser; private readonly ISystemClock _clock; public CreateNewsCommandHandler( - INewsService service, + INewsRepository service, ICurrentUserAccessor currentUser, ISystemClock clock) { diff --git a/backend/src/CCE.Application/Content/Commands/CreatePage/CreatePageCommandHandler.cs b/backend/src/CCE.Application/Content/Commands/CreatePage/CreatePageCommandHandler.cs index 30627dd4..2e970275 100644 --- a/backend/src/CCE.Application/Content/Commands/CreatePage/CreatePageCommandHandler.cs +++ b/backend/src/CCE.Application/Content/Commands/CreatePage/CreatePageCommandHandler.cs @@ -8,9 +8,9 @@ namespace CCE.Application.Content.Commands.CreatePage; public sealed class CreatePageCommandHandler : IRequestHandler { - private readonly IPageService _service; + private readonly IPageRepository _service; - public CreatePageCommandHandler(IPageService service) + public CreatePageCommandHandler(IPageRepository service) { _service = service; } diff --git a/backend/src/CCE.Application/Content/Commands/CreateResource/CreateResourceCommandHandler.cs b/backend/src/CCE.Application/Content/Commands/CreateResource/CreateResourceCommandHandler.cs index b56cd57d..e3984d54 100644 --- a/backend/src/CCE.Application/Content/Commands/CreateResource/CreateResourceCommandHandler.cs +++ b/backend/src/CCE.Application/Content/Commands/CreateResource/CreateResourceCommandHandler.cs @@ -9,14 +9,14 @@ namespace CCE.Application.Content.Commands.CreateResource; public sealed class CreateResourceCommandHandler : IRequestHandler { - private readonly IResourceService _service; - private readonly IAssetService _assetService; + private readonly IResourceRepository _service; + private readonly IAssetRepository _assetService; private readonly ICurrentUserAccessor _currentUser; private readonly ISystemClock _clock; public CreateResourceCommandHandler( - IResourceService service, - IAssetService assetService, + IResourceRepository service, + IAssetRepository assetService, ICurrentUserAccessor currentUser, ISystemClock clock) { diff --git a/backend/src/CCE.Application/Content/Commands/CreateResourceCategory/CreateResourceCategoryCommandHandler.cs b/backend/src/CCE.Application/Content/Commands/CreateResourceCategory/CreateResourceCategoryCommandHandler.cs index 32cdff51..439314f5 100644 --- a/backend/src/CCE.Application/Content/Commands/CreateResourceCategory/CreateResourceCategoryCommandHandler.cs +++ b/backend/src/CCE.Application/Content/Commands/CreateResourceCategory/CreateResourceCategoryCommandHandler.cs @@ -7,9 +7,9 @@ namespace CCE.Application.Content.Commands.CreateResourceCategory; public sealed class CreateResourceCategoryCommandHandler : IRequestHandler { - private readonly IResourceCategoryService _service; + private readonly IResourceCategoryRepository _service; - public CreateResourceCategoryCommandHandler(IResourceCategoryService service) + public CreateResourceCategoryCommandHandler(IResourceCategoryRepository service) { _service = service; } diff --git a/backend/src/CCE.Application/Content/Commands/DeleteEvent/DeleteEventCommandHandler.cs b/backend/src/CCE.Application/Content/Commands/DeleteEvent/DeleteEventCommandHandler.cs index 220224bc..3c0b2460 100644 --- a/backend/src/CCE.Application/Content/Commands/DeleteEvent/DeleteEventCommandHandler.cs +++ b/backend/src/CCE.Application/Content/Commands/DeleteEvent/DeleteEventCommandHandler.cs @@ -7,11 +7,11 @@ namespace CCE.Application.Content.Commands.DeleteEvent; public sealed class DeleteEventCommandHandler : IRequestHandler { - private readonly IEventService _service; + private readonly IEventRepository _service; private readonly ICurrentUserAccessor _currentUser; private readonly ISystemClock _clock; - public DeleteEventCommandHandler(IEventService service, ICurrentUserAccessor currentUser, ISystemClock clock) + public DeleteEventCommandHandler(IEventRepository service, ICurrentUserAccessor currentUser, ISystemClock clock) { _service = service; _currentUser = currentUser; diff --git a/backend/src/CCE.Application/Content/Commands/DeleteHomepageSection/DeleteHomepageSectionCommandHandler.cs b/backend/src/CCE.Application/Content/Commands/DeleteHomepageSection/DeleteHomepageSectionCommandHandler.cs index 41b97345..051c2ceb 100644 --- a/backend/src/CCE.Application/Content/Commands/DeleteHomepageSection/DeleteHomepageSectionCommandHandler.cs +++ b/backend/src/CCE.Application/Content/Commands/DeleteHomepageSection/DeleteHomepageSectionCommandHandler.cs @@ -6,11 +6,11 @@ namespace CCE.Application.Content.Commands.DeleteHomepageSection; public sealed class DeleteHomepageSectionCommandHandler : IRequestHandler { - private readonly IHomepageSectionService _service; + private readonly IHomepageSectionRepository _service; private readonly ICurrentUserAccessor _currentUser; private readonly ISystemClock _clock; - public DeleteHomepageSectionCommandHandler(IHomepageSectionService service, ICurrentUserAccessor currentUser, ISystemClock clock) + public DeleteHomepageSectionCommandHandler(IHomepageSectionRepository service, ICurrentUserAccessor currentUser, ISystemClock clock) { _service = service; _currentUser = currentUser; diff --git a/backend/src/CCE.Application/Content/Commands/DeleteNews/DeleteNewsCommandHandler.cs b/backend/src/CCE.Application/Content/Commands/DeleteNews/DeleteNewsCommandHandler.cs index 934ad4f9..ab119842 100644 --- a/backend/src/CCE.Application/Content/Commands/DeleteNews/DeleteNewsCommandHandler.cs +++ b/backend/src/CCE.Application/Content/Commands/DeleteNews/DeleteNewsCommandHandler.cs @@ -7,11 +7,11 @@ namespace CCE.Application.Content.Commands.DeleteNews; public sealed class DeleteNewsCommandHandler : IRequestHandler { - private readonly INewsService _service; + private readonly INewsRepository _service; private readonly ICurrentUserAccessor _currentUser; private readonly ISystemClock _clock; - public DeleteNewsCommandHandler(INewsService service, ICurrentUserAccessor currentUser, ISystemClock clock) + public DeleteNewsCommandHandler(INewsRepository service, ICurrentUserAccessor currentUser, ISystemClock clock) { _service = service; _currentUser = currentUser; diff --git a/backend/src/CCE.Application/Content/Commands/DeletePage/DeletePageCommandHandler.cs b/backend/src/CCE.Application/Content/Commands/DeletePage/DeletePageCommandHandler.cs index 8af7b72c..6b5ee194 100644 --- a/backend/src/CCE.Application/Content/Commands/DeletePage/DeletePageCommandHandler.cs +++ b/backend/src/CCE.Application/Content/Commands/DeletePage/DeletePageCommandHandler.cs @@ -7,11 +7,11 @@ namespace CCE.Application.Content.Commands.DeletePage; public sealed class DeletePageCommandHandler : IRequestHandler { - private readonly IPageService _service; + private readonly IPageRepository _service; private readonly ICurrentUserAccessor _currentUser; private readonly ISystemClock _clock; - public DeletePageCommandHandler(IPageService service, ICurrentUserAccessor currentUser, ISystemClock clock) + public DeletePageCommandHandler(IPageRepository service, ICurrentUserAccessor currentUser, ISystemClock clock) { _service = service; _currentUser = currentUser; diff --git a/backend/src/CCE.Application/Content/Commands/DeleteResourceCategory/DeleteResourceCategoryCommandHandler.cs b/backend/src/CCE.Application/Content/Commands/DeleteResourceCategory/DeleteResourceCategoryCommandHandler.cs index a601127c..f301b40b 100644 --- a/backend/src/CCE.Application/Content/Commands/DeleteResourceCategory/DeleteResourceCategoryCommandHandler.cs +++ b/backend/src/CCE.Application/Content/Commands/DeleteResourceCategory/DeleteResourceCategoryCommandHandler.cs @@ -4,9 +4,9 @@ namespace CCE.Application.Content.Commands.DeleteResourceCategory; public sealed class DeleteResourceCategoryCommandHandler : IRequestHandler { - private readonly IResourceCategoryService _service; + private readonly IResourceCategoryRepository _service; - public DeleteResourceCategoryCommandHandler(IResourceCategoryService service) + public DeleteResourceCategoryCommandHandler(IResourceCategoryRepository service) { _service = service; } diff --git a/backend/src/CCE.Application/Content/Commands/PublishNews/PublishNewsCommandHandler.cs b/backend/src/CCE.Application/Content/Commands/PublishNews/PublishNewsCommandHandler.cs index 57c11445..ac711f02 100644 --- a/backend/src/CCE.Application/Content/Commands/PublishNews/PublishNewsCommandHandler.cs +++ b/backend/src/CCE.Application/Content/Commands/PublishNews/PublishNewsCommandHandler.cs @@ -8,10 +8,10 @@ namespace CCE.Application.Content.Commands.PublishNews; public sealed class PublishNewsCommandHandler : IRequestHandler { - private readonly INewsService _service; + private readonly INewsRepository _service; private readonly ISystemClock _clock; - public PublishNewsCommandHandler(INewsService service, ISystemClock clock) + public PublishNewsCommandHandler(INewsRepository service, ISystemClock clock) { _service = service; _clock = clock; diff --git a/backend/src/CCE.Application/Content/Commands/PublishResource/PublishResourceCommandHandler.cs b/backend/src/CCE.Application/Content/Commands/PublishResource/PublishResourceCommandHandler.cs index 335c1756..0e56623b 100644 --- a/backend/src/CCE.Application/Content/Commands/PublishResource/PublishResourceCommandHandler.cs +++ b/backend/src/CCE.Application/Content/Commands/PublishResource/PublishResourceCommandHandler.cs @@ -9,13 +9,13 @@ namespace CCE.Application.Content.Commands.PublishResource; public sealed class PublishResourceCommandHandler : IRequestHandler { - private readonly IResourceService _service; - private readonly IAssetService _assetService; + private readonly IResourceRepository _service; + private readonly IAssetRepository _assetService; private readonly ISystemClock _clock; public PublishResourceCommandHandler( - IResourceService service, - IAssetService assetService, + IResourceRepository service, + IAssetRepository assetService, ISystemClock clock) { _service = service; diff --git a/backend/src/CCE.Application/Content/Commands/RejectCountryResourceRequest/RejectCountryResourceRequestCommandHandler.cs b/backend/src/CCE.Application/Content/Commands/RejectCountryResourceRequest/RejectCountryResourceRequestCommandHandler.cs index 664d248a..283cdafa 100644 --- a/backend/src/CCE.Application/Content/Commands/RejectCountryResourceRequest/RejectCountryResourceRequestCommandHandler.cs +++ b/backend/src/CCE.Application/Content/Commands/RejectCountryResourceRequest/RejectCountryResourceRequestCommandHandler.cs @@ -10,12 +10,12 @@ namespace CCE.Application.Content.Commands.RejectCountryResourceRequest; public sealed class RejectCountryResourceRequestCommandHandler : IRequestHandler { - private readonly ICountryResourceRequestService _service; + private readonly ICountryResourceRequestRepository _service; private readonly ICurrentUserAccessor _currentUser; private readonly ISystemClock _clock; public RejectCountryResourceRequestCommandHandler( - ICountryResourceRequestService service, + ICountryResourceRequestRepository service, ICurrentUserAccessor currentUser, ISystemClock clock) { diff --git a/backend/src/CCE.Application/Content/Commands/ReorderHomepageSections/ReorderHomepageSectionsCommandHandler.cs b/backend/src/CCE.Application/Content/Commands/ReorderHomepageSections/ReorderHomepageSectionsCommandHandler.cs index 85742450..748df251 100644 --- a/backend/src/CCE.Application/Content/Commands/ReorderHomepageSections/ReorderHomepageSectionsCommandHandler.cs +++ b/backend/src/CCE.Application/Content/Commands/ReorderHomepageSections/ReorderHomepageSectionsCommandHandler.cs @@ -6,9 +6,9 @@ namespace CCE.Application.Content.Commands.ReorderHomepageSections; public sealed class ReorderHomepageSectionsCommandHandler : IRequestHandler { - private readonly IHomepageSectionService _service; + private readonly IHomepageSectionRepository _service; - public ReorderHomepageSectionsCommandHandler(IHomepageSectionService service) + public ReorderHomepageSectionsCommandHandler(IHomepageSectionRepository service) { _service = service; } diff --git a/backend/src/CCE.Application/Content/Commands/RescheduleEvent/RescheduleEventCommandHandler.cs b/backend/src/CCE.Application/Content/Commands/RescheduleEvent/RescheduleEventCommandHandler.cs index b591f378..8d87af69 100644 --- a/backend/src/CCE.Application/Content/Commands/RescheduleEvent/RescheduleEventCommandHandler.cs +++ b/backend/src/CCE.Application/Content/Commands/RescheduleEvent/RescheduleEventCommandHandler.cs @@ -6,9 +6,9 @@ namespace CCE.Application.Content.Commands.RescheduleEvent; public sealed class RescheduleEventCommandHandler : IRequestHandler { - private readonly IEventService _service; + private readonly IEventRepository _service; - public RescheduleEventCommandHandler(IEventService service) + public RescheduleEventCommandHandler(IEventRepository service) { _service = service; } diff --git a/backend/src/CCE.Application/Content/Commands/UpdateEvent/UpdateEventCommandHandler.cs b/backend/src/CCE.Application/Content/Commands/UpdateEvent/UpdateEventCommandHandler.cs index 3bf50c49..a38f0072 100644 --- a/backend/src/CCE.Application/Content/Commands/UpdateEvent/UpdateEventCommandHandler.cs +++ b/backend/src/CCE.Application/Content/Commands/UpdateEvent/UpdateEventCommandHandler.cs @@ -6,9 +6,9 @@ namespace CCE.Application.Content.Commands.UpdateEvent; public sealed class UpdateEventCommandHandler : IRequestHandler { - private readonly IEventService _service; + private readonly IEventRepository _service; - public UpdateEventCommandHandler(IEventService service) + public UpdateEventCommandHandler(IEventRepository service) { _service = service; } diff --git a/backend/src/CCE.Application/Content/Commands/UpdateHomepageSection/UpdateHomepageSectionCommandHandler.cs b/backend/src/CCE.Application/Content/Commands/UpdateHomepageSection/UpdateHomepageSectionCommandHandler.cs index bb073da2..64c0e587 100644 --- a/backend/src/CCE.Application/Content/Commands/UpdateHomepageSection/UpdateHomepageSectionCommandHandler.cs +++ b/backend/src/CCE.Application/Content/Commands/UpdateHomepageSection/UpdateHomepageSectionCommandHandler.cs @@ -6,9 +6,9 @@ namespace CCE.Application.Content.Commands.UpdateHomepageSection; public sealed class UpdateHomepageSectionCommandHandler : IRequestHandler { - private readonly IHomepageSectionService _service; + private readonly IHomepageSectionRepository _service; - public UpdateHomepageSectionCommandHandler(IHomepageSectionService service) + public UpdateHomepageSectionCommandHandler(IHomepageSectionRepository service) { _service = service; } diff --git a/backend/src/CCE.Application/Content/Commands/UpdateNews/UpdateNewsCommandHandler.cs b/backend/src/CCE.Application/Content/Commands/UpdateNews/UpdateNewsCommandHandler.cs index 2c571f4e..fcb8ad2f 100644 --- a/backend/src/CCE.Application/Content/Commands/UpdateNews/UpdateNewsCommandHandler.cs +++ b/backend/src/CCE.Application/Content/Commands/UpdateNews/UpdateNewsCommandHandler.cs @@ -6,9 +6,9 @@ namespace CCE.Application.Content.Commands.UpdateNews; public sealed class UpdateNewsCommandHandler : IRequestHandler { - private readonly INewsService _service; + private readonly INewsRepository _service; - public UpdateNewsCommandHandler(INewsService service) + public UpdateNewsCommandHandler(INewsRepository service) { _service = service; } diff --git a/backend/src/CCE.Application/Content/Commands/UpdatePage/UpdatePageCommandHandler.cs b/backend/src/CCE.Application/Content/Commands/UpdatePage/UpdatePageCommandHandler.cs index 0f6583b2..d1e0377f 100644 --- a/backend/src/CCE.Application/Content/Commands/UpdatePage/UpdatePageCommandHandler.cs +++ b/backend/src/CCE.Application/Content/Commands/UpdatePage/UpdatePageCommandHandler.cs @@ -6,9 +6,9 @@ namespace CCE.Application.Content.Commands.UpdatePage; public sealed class UpdatePageCommandHandler : IRequestHandler { - private readonly IPageService _service; + private readonly IPageRepository _service; - public UpdatePageCommandHandler(IPageService service) + public UpdatePageCommandHandler(IPageRepository service) { _service = service; } diff --git a/backend/src/CCE.Application/Content/Commands/UpdateResource/UpdateResourceCommandHandler.cs b/backend/src/CCE.Application/Content/Commands/UpdateResource/UpdateResourceCommandHandler.cs index 33ab56bb..70781688 100644 --- a/backend/src/CCE.Application/Content/Commands/UpdateResource/UpdateResourceCommandHandler.cs +++ b/backend/src/CCE.Application/Content/Commands/UpdateResource/UpdateResourceCommandHandler.cs @@ -6,9 +6,9 @@ namespace CCE.Application.Content.Commands.UpdateResource; public sealed class UpdateResourceCommandHandler : IRequestHandler { - private readonly IResourceService _service; + private readonly IResourceRepository _service; - public UpdateResourceCommandHandler(IResourceService service) + public UpdateResourceCommandHandler(IResourceRepository service) { _service = service; } diff --git a/backend/src/CCE.Application/Content/Commands/UpdateResourceCategory/UpdateResourceCategoryCommandHandler.cs b/backend/src/CCE.Application/Content/Commands/UpdateResourceCategory/UpdateResourceCategoryCommandHandler.cs index d59810e9..9ff90e1d 100644 --- a/backend/src/CCE.Application/Content/Commands/UpdateResourceCategory/UpdateResourceCategoryCommandHandler.cs +++ b/backend/src/CCE.Application/Content/Commands/UpdateResourceCategory/UpdateResourceCategoryCommandHandler.cs @@ -6,9 +6,9 @@ namespace CCE.Application.Content.Commands.UpdateResourceCategory; public sealed class UpdateResourceCategoryCommandHandler : IRequestHandler { - private readonly IResourceCategoryService _service; + private readonly IResourceCategoryRepository _service; - public UpdateResourceCategoryCommandHandler(IResourceCategoryService service) + public UpdateResourceCategoryCommandHandler(IResourceCategoryRepository service) { _service = service; } diff --git a/backend/src/CCE.Application/Content/Commands/UploadAsset/UploadAssetCommandHandler.cs b/backend/src/CCE.Application/Content/Commands/UploadAsset/UploadAssetCommandHandler.cs index c57c1838..44da8a0d 100644 --- a/backend/src/CCE.Application/Content/Commands/UploadAsset/UploadAssetCommandHandler.cs +++ b/backend/src/CCE.Application/Content/Commands/UploadAsset/UploadAssetCommandHandler.cs @@ -11,7 +11,7 @@ public sealed class UploadAssetCommandHandler : IRequestHandler _logger; @@ -19,7 +19,7 @@ public sealed class UploadAssetCommandHandler : IRequestHandler logger) diff --git a/backend/src/CCE.Application/Content/IAssetService.cs b/backend/src/CCE.Application/Content/IAssetRepository.cs similarity index 92% rename from backend/src/CCE.Application/Content/IAssetService.cs rename to backend/src/CCE.Application/Content/IAssetRepository.cs index 0792a916..bd1ce6bd 100644 --- a/backend/src/CCE.Application/Content/IAssetService.cs +++ b/backend/src/CCE.Application/Content/IAssetRepository.cs @@ -2,7 +2,7 @@ namespace CCE.Application.Content; -public interface IAssetService +public interface IAssetRepository { /// /// Persists a newly-registered asset file. Single SaveChanges call. diff --git a/backend/src/CCE.Application/Content/ICountryResourceRequestService.cs b/backend/src/CCE.Application/Content/ICountryResourceRequestRepository.cs similarity index 82% rename from backend/src/CCE.Application/Content/ICountryResourceRequestService.cs rename to backend/src/CCE.Application/Content/ICountryResourceRequestRepository.cs index abd1fa8e..79fa5580 100644 --- a/backend/src/CCE.Application/Content/ICountryResourceRequestService.cs +++ b/backend/src/CCE.Application/Content/ICountryResourceRequestRepository.cs @@ -2,7 +2,7 @@ namespace CCE.Application.Content; -public interface ICountryResourceRequestService +public interface ICountryResourceRequestRepository { Task FindIncludingDeletedAsync(System.Guid id, CancellationToken ct); Task UpdateAsync(CountryResourceRequest request, CancellationToken ct); diff --git a/backend/src/CCE.Application/Content/IEventService.cs b/backend/src/CCE.Application/Content/IEventRepository.cs similarity index 88% rename from backend/src/CCE.Application/Content/IEventService.cs rename to backend/src/CCE.Application/Content/IEventRepository.cs index a453a308..f2f2ce53 100644 --- a/backend/src/CCE.Application/Content/IEventService.cs +++ b/backend/src/CCE.Application/Content/IEventRepository.cs @@ -2,7 +2,7 @@ namespace CCE.Application.Content; -public interface IEventService +public interface IEventRepository { Task SaveAsync(Event @event, CancellationToken ct); Task FindAsync(System.Guid id, CancellationToken ct); diff --git a/backend/src/CCE.Application/Content/IHomepageSectionService.cs b/backend/src/CCE.Application/Content/IHomepageSectionRepository.cs similarity index 90% rename from backend/src/CCE.Application/Content/IHomepageSectionService.cs rename to backend/src/CCE.Application/Content/IHomepageSectionRepository.cs index c65f2a2e..fc73da41 100644 --- a/backend/src/CCE.Application/Content/IHomepageSectionService.cs +++ b/backend/src/CCE.Application/Content/IHomepageSectionRepository.cs @@ -2,7 +2,7 @@ namespace CCE.Application.Content; -public interface IHomepageSectionService +public interface IHomepageSectionRepository { Task SaveAsync(HomepageSection section, CancellationToken ct); Task FindAsync(System.Guid id, CancellationToken ct); diff --git a/backend/src/CCE.Application/Content/INewsService.cs b/backend/src/CCE.Application/Content/INewsRepository.cs similarity index 89% rename from backend/src/CCE.Application/Content/INewsService.cs rename to backend/src/CCE.Application/Content/INewsRepository.cs index 08a1c046..f8b6ea41 100644 --- a/backend/src/CCE.Application/Content/INewsService.cs +++ b/backend/src/CCE.Application/Content/INewsRepository.cs @@ -2,7 +2,7 @@ namespace CCE.Application.Content; -public interface INewsService +public interface INewsRepository { Task SaveAsync(News news, CancellationToken ct); Task FindAsync(System.Guid id, CancellationToken ct); diff --git a/backend/src/CCE.Application/Content/IPageService.cs b/backend/src/CCE.Application/Content/IPageRepository.cs similarity index 89% rename from backend/src/CCE.Application/Content/IPageService.cs rename to backend/src/CCE.Application/Content/IPageRepository.cs index e87db864..a0840c22 100644 --- a/backend/src/CCE.Application/Content/IPageService.cs +++ b/backend/src/CCE.Application/Content/IPageRepository.cs @@ -2,7 +2,7 @@ namespace CCE.Application.Content; -public interface IPageService +public interface IPageRepository { Task SaveAsync(Page page, CancellationToken ct); Task FindAsync(System.Guid id, CancellationToken ct); diff --git a/backend/src/CCE.Application/Content/IResourceCategoryService.cs b/backend/src/CCE.Application/Content/IResourceCategoryRepository.cs similarity index 86% rename from backend/src/CCE.Application/Content/IResourceCategoryService.cs rename to backend/src/CCE.Application/Content/IResourceCategoryRepository.cs index 7c3a502a..e0e82897 100644 --- a/backend/src/CCE.Application/Content/IResourceCategoryService.cs +++ b/backend/src/CCE.Application/Content/IResourceCategoryRepository.cs @@ -2,7 +2,7 @@ namespace CCE.Application.Content; -public interface IResourceCategoryService +public interface IResourceCategoryRepository { Task SaveAsync(ResourceCategory category, CancellationToken ct); Task FindAsync(System.Guid id, CancellationToken ct); diff --git a/backend/src/CCE.Application/Content/IResourceService.cs b/backend/src/CCE.Application/Content/IResourceRepository.cs similarity index 88% rename from backend/src/CCE.Application/Content/IResourceService.cs rename to backend/src/CCE.Application/Content/IResourceRepository.cs index 12dd8406..63361cc1 100644 --- a/backend/src/CCE.Application/Content/IResourceService.cs +++ b/backend/src/CCE.Application/Content/IResourceRepository.cs @@ -2,7 +2,7 @@ namespace CCE.Application.Content; -public interface IResourceService +public interface IResourceRepository { Task SaveAsync(Resource resource, CancellationToken ct); Task FindAsync(System.Guid id, CancellationToken ct); diff --git a/backend/src/CCE.Application/Content/Public/IResourceViewCountService.cs b/backend/src/CCE.Application/Content/Public/IResourceViewCountRepository.cs similarity index 71% rename from backend/src/CCE.Application/Content/Public/IResourceViewCountService.cs rename to backend/src/CCE.Application/Content/Public/IResourceViewCountRepository.cs index 89d12a59..892b8861 100644 --- a/backend/src/CCE.Application/Content/Public/IResourceViewCountService.cs +++ b/backend/src/CCE.Application/Content/Public/IResourceViewCountRepository.cs @@ -1,6 +1,6 @@ namespace CCE.Application.Content.Public; -public interface IResourceViewCountService +public interface IResourceViewCountRepository { Task IncrementAsync(System.Guid resourceId, CancellationToken ct); } diff --git a/backend/src/CCE.Application/Content/Public/Queries/GetPublicEventById/GetPublicEventByIdQueryHandler.cs b/backend/src/CCE.Application/Content/Public/Queries/GetPublicEventById/GetPublicEventByIdQueryHandler.cs index 4d97471f..a11fe47b 100644 --- a/backend/src/CCE.Application/Content/Public/Queries/GetPublicEventById/GetPublicEventByIdQueryHandler.cs +++ b/backend/src/CCE.Application/Content/Public/Queries/GetPublicEventById/GetPublicEventByIdQueryHandler.cs @@ -1,7 +1,7 @@ using CCE.Application.Common.Interfaces; using CCE.Application.Common.Pagination; using CCE.Application.Content.Public.Dtos; -using CCE.Application.Content.Public.Queries.ListPublicEvents; +using CCE.Domain.Content; using MediatR; namespace CCE.Application.Content.Public.Queries.GetPublicEventById; @@ -10,10 +10,7 @@ public sealed class GetPublicEventByIdQueryHandler : IRequestHandler _db = db; public async Task Handle(GetPublicEventByIdQuery request, CancellationToken cancellationToken) { @@ -21,8 +18,21 @@ public GetPublicEventByIdQueryHandler(ICceDbContext db) .Where(e => e.Id == request.Id) .ToListAsyncEither(cancellationToken) .ConfigureAwait(false); - var ev = list.SingleOrDefault(); - return ev is null ? null : ListPublicEventsQueryHandler.MapToDto(ev); + return ev is null ? null : MapToDto(ev); } + + internal static PublicEventDto MapToDto(Event e) => new( + e.Id, + e.TitleAr, + e.TitleEn, + e.DescriptionAr, + e.DescriptionEn, + e.StartsOn, + e.EndsOn, + e.LocationAr, + e.LocationEn, + e.OnlineMeetingUrl, + e.FeaturedImageUrl, + e.ICalUid); } diff --git a/backend/src/CCE.Application/Content/Public/Queries/GetPublicNewsBySlug/GetPublicNewsBySlugQueryHandler.cs b/backend/src/CCE.Application/Content/Public/Queries/GetPublicNewsBySlug/GetPublicNewsBySlugQueryHandler.cs index 9a7734a0..19d6616b 100644 --- a/backend/src/CCE.Application/Content/Public/Queries/GetPublicNewsBySlug/GetPublicNewsBySlugQueryHandler.cs +++ b/backend/src/CCE.Application/Content/Public/Queries/GetPublicNewsBySlug/GetPublicNewsBySlugQueryHandler.cs @@ -1,7 +1,7 @@ using CCE.Application.Common.Interfaces; using CCE.Application.Common.Pagination; using CCE.Application.Content.Public.Dtos; -using CCE.Application.Content.Public.Queries.ListPublicNews; +using CCE.Domain.Content; using MediatR; namespace CCE.Application.Content.Public.Queries.GetPublicNewsBySlug; @@ -10,10 +10,7 @@ public sealed class GetPublicNewsBySlugQueryHandler : IRequestHandler _db = db; public async Task Handle(GetPublicNewsBySlugQuery request, CancellationToken cancellationToken) { @@ -21,8 +18,18 @@ public GetPublicNewsBySlugQueryHandler(ICceDbContext db) .Where(n => n.Slug == request.Slug && n.PublishedOn != null) .ToListAsyncEither(cancellationToken) .ConfigureAwait(false); - var news = list.SingleOrDefault(); - return news is null ? null : ListPublicNewsQueryHandler.MapToDto(news); + return news is null ? null : MapToDto(news); } + + internal static PublicNewsDto MapToDto(News n) => new( + n.Id, + n.TitleAr, + n.TitleEn, + n.ContentAr, + n.ContentEn, + n.Slug, + n.FeaturedImageUrl, + n.PublishedOn!.Value, + n.IsFeatured); } diff --git a/backend/src/CCE.Application/Content/Public/Queries/GetPublicPageBySlug/GetPublicPageBySlugQueryHandler.cs b/backend/src/CCE.Application/Content/Public/Queries/GetPublicPageBySlug/GetPublicPageBySlugQueryHandler.cs index dbb7c9e5..7fa87b6d 100644 --- a/backend/src/CCE.Application/Content/Public/Queries/GetPublicPageBySlug/GetPublicPageBySlugQueryHandler.cs +++ b/backend/src/CCE.Application/Content/Public/Queries/GetPublicPageBySlug/GetPublicPageBySlugQueryHandler.cs @@ -10,10 +10,7 @@ public sealed class GetPublicPageBySlugQueryHandler : IRequestHandler _db = db; public async Task Handle(GetPublicPageBySlugQuery request, CancellationToken cancellationToken) { @@ -21,9 +18,8 @@ public GetPublicPageBySlugQueryHandler(ICceDbContext db) .Where(p => p.Slug == request.Slug) .ToListAsyncEither(cancellationToken) .ConfigureAwait(false); - - var page = list.SingleOrDefault(); - return page is null ? null : MapToDto(page); + var pageEntity = list.SingleOrDefault(); + return pageEntity is null ? null : MapToDto(pageEntity); } internal static PublicPageDto MapToDto(Page p) => new( diff --git a/backend/src/CCE.Application/Content/Public/Queries/GetPublicResourceById/GetPublicResourceByIdQueryHandler.cs b/backend/src/CCE.Application/Content/Public/Queries/GetPublicResourceById/GetPublicResourceByIdQueryHandler.cs index 684b8789..46589899 100644 --- a/backend/src/CCE.Application/Content/Public/Queries/GetPublicResourceById/GetPublicResourceByIdQueryHandler.cs +++ b/backend/src/CCE.Application/Content/Public/Queries/GetPublicResourceById/GetPublicResourceByIdQueryHandler.cs @@ -1,7 +1,7 @@ using CCE.Application.Common.Interfaces; using CCE.Application.Common.Pagination; using CCE.Application.Content.Public.Dtos; -using CCE.Application.Content.Public.Queries.ListPublicResources; +using CCE.Domain.Content; using MediatR; namespace CCE.Application.Content.Public.Queries.GetPublicResourceById; @@ -10,10 +10,7 @@ public sealed class GetPublicResourceByIdQueryHandler : IRequestHandler _db = db; public async Task Handle(GetPublicResourceByIdQuery request, CancellationToken cancellationToken) { @@ -21,13 +18,24 @@ public GetPublicResourceByIdQueryHandler(ICceDbContext db) .Where(r => r.Id == request.Id) .ToListAsyncEither(cancellationToken) .ConfigureAwait(false); - var resource = list.SingleOrDefault(); if (resource is null || resource.PublishedOn is null) { return null; } - - return ListPublicResourcesQueryHandler.MapToDto(resource); + return MapToDto(resource); } + + internal static PublicResourceDto MapToDto(Resource r) => new( + r.Id, + r.TitleAr, + r.TitleEn, + r.DescriptionAr, + r.DescriptionEn, + r.ResourceType, + r.CategoryId, + r.CountryId, + r.AssetFileId, + r.PublishedOn!.Value, + r.ViewCount); } diff --git a/backend/src/CCE.Application/Content/Public/Queries/ListPublicEvents/ListPublicEventsQueryHandler.cs b/backend/src/CCE.Application/Content/Public/Queries/ListPublicEvents/ListPublicEventsQueryHandler.cs index 65403afd..bbeb6e26 100644 --- a/backend/src/CCE.Application/Content/Public/Queries/ListPublicEvents/ListPublicEventsQueryHandler.cs +++ b/backend/src/CCE.Application/Content/Public/Queries/ListPublicEvents/ListPublicEventsQueryHandler.cs @@ -1,6 +1,7 @@ using CCE.Application.Common.Interfaces; using CCE.Application.Common.Pagination; using CCE.Application.Content.Public.Dtos; +using CCE.Domain.Content; using MediatR; namespace CCE.Application.Content.Public.Queries.ListPublicEvents; @@ -9,35 +10,29 @@ public sealed class ListPublicEventsQueryHandler : IRequestHandler _db = db; public async Task> Handle(ListPublicEventsQuery request, CancellationToken cancellationToken) { - IQueryable query = _db.Events; + var query = _db.Events.AsQueryable(); - if (request.From is { } from && request.To is { } to) + if (request.From.HasValue && request.To.HasValue) { - query = query.Where(e => e.StartsOn >= from && e.StartsOn <= to); + query = query.Where(e => e.StartsOn >= request.From.Value && e.StartsOn <= request.To.Value); } else { - var now = System.DateTimeOffset.UtcNow; + var now = DateTimeOffset.UtcNow; query = query.Where(e => e.StartsOn >= now); } query = query.OrderBy(e => e.StartsOn); - var page = await query.ToPagedResultAsync(request.Page, request.PageSize, cancellationToken) - .ConfigureAwait(false); - - var items = page.Items.Select(MapToDto).ToList(); - return new PagedResult(items, page.Page, page.PageSize, page.Total); + var result = await query.ToPagedResultAsync(request.Page, request.PageSize, cancellationToken).ConfigureAwait(false); + return result.Map(MapToDto); } - internal static PublicEventDto MapToDto(CCE.Domain.Content.Event e) => new( + internal static PublicEventDto MapToDto(Event e) => new( e.Id, e.TitleAr, e.TitleEn, diff --git a/backend/src/CCE.Application/Content/Public/Queries/ListPublicHomepageSections/ListPublicHomepageSectionsQueryHandler.cs b/backend/src/CCE.Application/Content/Public/Queries/ListPublicHomepageSections/ListPublicHomepageSectionsQueryHandler.cs index 2176d41d..87874520 100644 --- a/backend/src/CCE.Application/Content/Public/Queries/ListPublicHomepageSections/ListPublicHomepageSectionsQueryHandler.cs +++ b/backend/src/CCE.Application/Content/Public/Queries/ListPublicHomepageSections/ListPublicHomepageSectionsQueryHandler.cs @@ -11,10 +11,7 @@ public sealed class ListPublicHomepageSectionsQueryHandler { private readonly ICceDbContext _db; - public ListPublicHomepageSectionsQueryHandler(ICceDbContext db) - { - _db = db; - } + public ListPublicHomepageSectionsQueryHandler(ICceDbContext db) => _db = db; public async Task> Handle( ListPublicHomepageSectionsQuery request, @@ -25,7 +22,6 @@ public ListPublicHomepageSectionsQueryHandler(ICceDbContext db) .OrderBy(s => s.OrderIndex) .ToListAsyncEither(cancellationToken) .ConfigureAwait(false); - return list.Select(MapToDto).ToList(); } diff --git a/backend/src/CCE.Application/Content/Public/Queries/ListPublicNews/ListPublicNewsQueryHandler.cs b/backend/src/CCE.Application/Content/Public/Queries/ListPublicNews/ListPublicNewsQueryHandler.cs index fe69dd21..8bfd2e0e 100644 --- a/backend/src/CCE.Application/Content/Public/Queries/ListPublicNews/ListPublicNewsQueryHandler.cs +++ b/backend/src/CCE.Application/Content/Public/Queries/ListPublicNews/ListPublicNewsQueryHandler.cs @@ -10,27 +10,17 @@ public sealed class ListPublicNewsQueryHandler : IRequestHandler _db = db; public async Task> Handle(ListPublicNewsQuery request, CancellationToken cancellationToken) { - IQueryable query = _db.News.Where(n => n.PublishedOn != null); - - if (request.IsFeatured is { } isFeatured) - { - query = query.Where(n => n.IsFeatured == isFeatured); - } - - query = query.OrderByDescending(n => n.PublishedOn); - - var page = await query.ToPagedResultAsync(request.Page, request.PageSize, cancellationToken) - .ConfigureAwait(false); + var query = _db.News + .Where(n => n.PublishedOn != null) + .WhereIf(request.IsFeatured.HasValue, n => n.IsFeatured == request.IsFeatured!.Value) + .OrderByDescending(n => n.PublishedOn); - var items = page.Items.Select(MapToDto).ToList(); - return new PagedResult(items, page.Page, page.PageSize, page.Total); + var result = await query.ToPagedResultAsync(request.Page, request.PageSize, cancellationToken).ConfigureAwait(false); + return result.Map(MapToDto); } internal static PublicNewsDto MapToDto(News n) => new( diff --git a/backend/src/CCE.Application/Content/Public/Queries/ListPublicResourceCategories/ListPublicResourceCategoriesQueryHandler.cs b/backend/src/CCE.Application/Content/Public/Queries/ListPublicResourceCategories/ListPublicResourceCategoriesQueryHandler.cs index d4889178..ea72e924 100644 --- a/backend/src/CCE.Application/Content/Public/Queries/ListPublicResourceCategories/ListPublicResourceCategoriesQueryHandler.cs +++ b/backend/src/CCE.Application/Content/Public/Queries/ListPublicResourceCategories/ListPublicResourceCategoriesQueryHandler.cs @@ -11,10 +11,7 @@ public sealed class ListPublicResourceCategoriesQueryHandler { private readonly ICceDbContext _db; - public ListPublicResourceCategoriesQueryHandler(ICceDbContext db) - { - _db = db; - } + public ListPublicResourceCategoriesQueryHandler(ICceDbContext db) => _db = db; public async Task> Handle( ListPublicResourceCategoriesQuery request, @@ -25,7 +22,6 @@ public ListPublicResourceCategoriesQueryHandler(ICceDbContext db) .OrderBy(c => c.OrderIndex) .ToListAsyncEither(cancellationToken) .ConfigureAwait(false); - return list.Select(MapToDto).ToList(); } diff --git a/backend/src/CCE.Application/Content/Public/Queries/ListPublicResources/ListPublicResourcesQueryHandler.cs b/backend/src/CCE.Application/Content/Public/Queries/ListPublicResources/ListPublicResourcesQueryHandler.cs index 7a7b52e3..801083b1 100644 --- a/backend/src/CCE.Application/Content/Public/Queries/ListPublicResources/ListPublicResourcesQueryHandler.cs +++ b/backend/src/CCE.Application/Content/Public/Queries/ListPublicResources/ListPublicResourcesQueryHandler.cs @@ -10,37 +10,19 @@ public sealed class ListPublicResourcesQueryHandler : IRequestHandler _db = db; public async Task> Handle(ListPublicResourcesQuery request, CancellationToken cancellationToken) { - IQueryable query = _db.Resources.Where(r => r.PublishedOn != null); - - if (request.CategoryId is { } categoryId) - { - query = query.Where(r => r.CategoryId == categoryId); - } - - if (request.CountryId is { } countryId) - { - query = query.Where(r => r.CountryId == countryId); - } - - if (request.ResourceType is { } resourceType) - { - query = query.Where(r => r.ResourceType == resourceType); - } - - query = query.OrderByDescending(r => r.PublishedOn); - - var page = await query.ToPagedResultAsync(request.Page, request.PageSize, cancellationToken) - .ConfigureAwait(false); - - var items = page.Items.Select(MapToDto).ToList(); - return new PagedResult(items, page.Page, page.PageSize, page.Total); + var query = _db.Resources + .Where(r => r.PublishedOn != null) + .WhereIf(request.CategoryId.HasValue, r => r.CategoryId == request.CategoryId!.Value) + .WhereIf(request.CountryId.HasValue, r => r.CountryId == request.CountryId!.Value) + .WhereIf(request.ResourceType.HasValue, r => r.ResourceType == request.ResourceType!.Value) + .OrderByDescending(r => r.PublishedOn); + + var result = await query.ToPagedResultAsync(request.Page, request.PageSize, cancellationToken).ConfigureAwait(false); + return result.Map(MapToDto); } internal static PublicResourceDto MapToDto(Resource r) => new( diff --git a/backend/src/CCE.Application/Content/Queries/GetAssetById/GetAssetByIdQueryHandler.cs b/backend/src/CCE.Application/Content/Queries/GetAssetById/GetAssetByIdQueryHandler.cs index 74dd0a35..4e940236 100644 --- a/backend/src/CCE.Application/Content/Queries/GetAssetById/GetAssetByIdQueryHandler.cs +++ b/backend/src/CCE.Application/Content/Queries/GetAssetById/GetAssetByIdQueryHandler.cs @@ -1,3 +1,5 @@ +using CCE.Application.Common.Interfaces; +using CCE.Application.Common.Pagination; using CCE.Application.Content.Dtos; using MediatR; @@ -5,16 +7,17 @@ namespace CCE.Application.Content.Queries.GetAssetById; public sealed class GetAssetByIdQueryHandler : IRequestHandler { - private readonly IAssetService _service; + private readonly ICceDbContext _db; - public GetAssetByIdQueryHandler(IAssetService service) - { - _service = service; - } + public GetAssetByIdQueryHandler(ICceDbContext db) => _db = db; public async Task Handle(GetAssetByIdQuery request, CancellationToken cancellationToken) { - var asset = await _service.FindAsync(request.Id, cancellationToken).ConfigureAwait(false); + var list = await _db.AssetFiles + .Where(a => a.Id == request.Id) + .ToListAsyncEither(cancellationToken) + .ConfigureAwait(false); + var asset = list.SingleOrDefault(); if (asset is null) { return null; diff --git a/backend/src/CCE.Application/Content/Queries/GetEventById/GetEventByIdQueryHandler.cs b/backend/src/CCE.Application/Content/Queries/GetEventById/GetEventByIdQueryHandler.cs index c64a218e..d420d89c 100644 --- a/backend/src/CCE.Application/Content/Queries/GetEventById/GetEventByIdQueryHandler.cs +++ b/backend/src/CCE.Application/Content/Queries/GetEventById/GetEventByIdQueryHandler.cs @@ -1,7 +1,7 @@ using CCE.Application.Common.Interfaces; using CCE.Application.Common.Pagination; using CCE.Application.Content.Dtos; -using CCE.Application.Content.Queries.ListEvents; +using CCE.Domain.Content; using MediatR; namespace CCE.Application.Content.Queries.GetEventById; @@ -10,15 +10,18 @@ public sealed class GetEventByIdQueryHandler : IRequestHandler _db = db; public async Task Handle(GetEventByIdQuery request, CancellationToken cancellationToken) { var list = await _db.Events.Where(e => e.Id == request.Id).ToListAsyncEither(cancellationToken).ConfigureAwait(false); var ev = list.SingleOrDefault(); - return ev is null ? null : ListEventsQueryHandler.MapToDto(ev); + return ev is null ? null : MapToDto(ev); } + + internal static EventDto MapToDto(Event e) => new( + e.Id, e.TitleAr, e.TitleEn, e.DescriptionAr, e.DescriptionEn, + e.StartsOn, e.EndsOn, e.LocationAr, e.LocationEn, + e.OnlineMeetingUrl, e.FeaturedImageUrl, e.ICalUid, + System.Convert.ToBase64String(e.RowVersion)); } diff --git a/backend/src/CCE.Application/Content/Queries/GetNewsById/GetNewsByIdQueryHandler.cs b/backend/src/CCE.Application/Content/Queries/GetNewsById/GetNewsByIdQueryHandler.cs index 3e744cea..9350a2f2 100644 --- a/backend/src/CCE.Application/Content/Queries/GetNewsById/GetNewsByIdQueryHandler.cs +++ b/backend/src/CCE.Application/Content/Queries/GetNewsById/GetNewsByIdQueryHandler.cs @@ -1,7 +1,7 @@ using CCE.Application.Common.Interfaces; using CCE.Application.Common.Pagination; using CCE.Application.Content.Dtos; -using CCE.Application.Content.Queries.ListNews; +using CCE.Domain.Content; using MediatR; namespace CCE.Application.Content.Queries.GetNewsById; @@ -10,15 +10,18 @@ public sealed class GetNewsByIdQueryHandler : IRequestHandler _db = db; public async Task Handle(GetNewsByIdQuery request, CancellationToken cancellationToken) { var list = await _db.News.Where(n => n.Id == request.Id).ToListAsyncEither(cancellationToken).ConfigureAwait(false); var news = list.SingleOrDefault(); - return news is null ? null : ListNewsQueryHandler.MapToDto(news); + return news is null ? null : MapToDto(news); } + + internal static NewsDto MapToDto(News n) => new( + n.Id, n.TitleAr, n.TitleEn, n.ContentAr, n.ContentEn, + n.Slug, n.AuthorId, n.FeaturedImageUrl, + n.PublishedOn, n.IsFeatured, n.IsPublished, + System.Convert.ToBase64String(n.RowVersion)); } diff --git a/backend/src/CCE.Application/Content/Queries/GetPageById/GetPageByIdQueryHandler.cs b/backend/src/CCE.Application/Content/Queries/GetPageById/GetPageByIdQueryHandler.cs index 62f4c726..39d429a0 100644 --- a/backend/src/CCE.Application/Content/Queries/GetPageById/GetPageByIdQueryHandler.cs +++ b/backend/src/CCE.Application/Content/Queries/GetPageById/GetPageByIdQueryHandler.cs @@ -1,7 +1,7 @@ using CCE.Application.Common.Interfaces; using CCE.Application.Common.Pagination; using CCE.Application.Content.Dtos; -using CCE.Application.Content.Queries.ListPages; +using CCE.Domain.Content; using MediatR; namespace CCE.Application.Content.Queries.GetPageById; @@ -10,15 +10,16 @@ public sealed class GetPageByIdQueryHandler : IRequestHandler _db = db; public async Task Handle(GetPageByIdQuery request, CancellationToken cancellationToken) { var list = await _db.Pages.Where(p => p.Id == request.Id).ToListAsyncEither(cancellationToken).ConfigureAwait(false); - var page = list.SingleOrDefault(); - return page is null ? null : ListPagesQueryHandler.MapToDto(page); + var pageEntity = list.SingleOrDefault(); + return pageEntity is null ? null : MapToDto(pageEntity); } + + internal static PageDto MapToDto(Page p) => new( + p.Id, p.Slug, p.PageType, p.TitleAr, p.TitleEn, p.ContentAr, p.ContentEn, + System.Convert.ToBase64String(p.RowVersion)); } diff --git a/backend/src/CCE.Application/Content/Queries/GetResourceCategoryById/GetResourceCategoryByIdQueryHandler.cs b/backend/src/CCE.Application/Content/Queries/GetResourceCategoryById/GetResourceCategoryByIdQueryHandler.cs index a91dd3ba..387131c8 100644 --- a/backend/src/CCE.Application/Content/Queries/GetResourceCategoryById/GetResourceCategoryByIdQueryHandler.cs +++ b/backend/src/CCE.Application/Content/Queries/GetResourceCategoryById/GetResourceCategoryByIdQueryHandler.cs @@ -1,7 +1,7 @@ using CCE.Application.Common.Interfaces; using CCE.Application.Common.Pagination; using CCE.Application.Content.Dtos; -using CCE.Application.Content.Queries.ListResourceCategories; +using CCE.Domain.Content; using MediatR; namespace CCE.Application.Content.Queries.GetResourceCategoryById; @@ -10,10 +10,7 @@ public sealed class GetResourceCategoryByIdQueryHandler : IRequestHandler _db = db; public async Task Handle(GetResourceCategoryByIdQuery request, CancellationToken cancellationToken) { @@ -22,6 +19,15 @@ public GetResourceCategoryByIdQueryHandler(ICceDbContext db) .ToListAsyncEither(cancellationToken) .ConfigureAwait(false); var category = list.SingleOrDefault(); - return category is null ? null : ListResourceCategoriesQueryHandler.MapToDto(category); + return category is null ? null : MapToDto(category); } + + internal static ResourceCategoryDto MapToDto(ResourceCategory c) => new( + c.Id, + c.NameAr, + c.NameEn, + c.Slug, + c.ParentId, + c.OrderIndex, + c.IsActive); } diff --git a/backend/src/CCE.Application/Content/Queries/ListEvents/ListEventsQueryHandler.cs b/backend/src/CCE.Application/Content/Queries/ListEvents/ListEventsQueryHandler.cs index 47ab9965..2bb67e68 100644 --- a/backend/src/CCE.Application/Content/Queries/ListEvents/ListEventsQueryHandler.cs +++ b/backend/src/CCE.Application/Content/Queries/ListEvents/ListEventsQueryHandler.cs @@ -1,6 +1,7 @@ using CCE.Application.Common.Interfaces; using CCE.Application.Common.Pagination; using CCE.Application.Content.Dtos; +using CCE.Domain.Content; using MediatR; namespace CCE.Application.Content.Queries.ListEvents; @@ -9,43 +10,23 @@ public sealed class ListEventsQueryHandler : IRequestHandler _db = db; public async Task> Handle(ListEventsQuery request, CancellationToken cancellationToken) { - IQueryable query = _db.Events; - - if (!string.IsNullOrWhiteSpace(request.Search)) - { - var term = request.Search.Trim(); - query = query.Where(e => - e.TitleAr.Contains(term) || - e.TitleEn.Contains(term)); - } - - if (request.FromDate is { } fromDate) - { - query = query.Where(e => e.StartsOn >= fromDate); - } - - if (request.ToDate is { } toDate) - { - query = query.Where(e => e.EndsOn <= toDate); - } - - query = query.OrderByDescending(e => e.StartsOn); - - var page = await query.ToPagedResultAsync(request.Page, request.PageSize, cancellationToken) - .ConfigureAwait(false); - - var items = page.Items.Select(MapToDto).ToList(); - return new PagedResult(items, page.Page, page.PageSize, page.Total); + var query = _db.Events + .WhereIf(!string.IsNullOrWhiteSpace(request.Search), + e => e.TitleAr.Contains(request.Search!) || + e.TitleEn.Contains(request.Search!)) + .WhereIf(request.FromDate.HasValue, e => e.StartsOn >= request.FromDate!.Value) + .WhereIf(request.ToDate.HasValue, e => e.EndsOn <= request.ToDate!.Value) + .OrderByDescending(e => e.StartsOn); + + var result = await query.ToPagedResultAsync(request.Page, request.PageSize, cancellationToken).ConfigureAwait(false); + return result.Map(MapToDto); } - internal static EventDto MapToDto(CCE.Domain.Content.Event e) => new( + internal static EventDto MapToDto(Event e) => new( e.Id, e.TitleAr, e.TitleEn, e.DescriptionAr, e.DescriptionEn, e.StartsOn, e.EndsOn, e.LocationAr, e.LocationEn, e.OnlineMeetingUrl, e.FeaturedImageUrl, e.ICalUid, diff --git a/backend/src/CCE.Application/Content/Queries/ListHomepageSections/ListHomepageSectionsQueryHandler.cs b/backend/src/CCE.Application/Content/Queries/ListHomepageSections/ListHomepageSectionsQueryHandler.cs index 27582607..fb62b99b 100644 --- a/backend/src/CCE.Application/Content/Queries/ListHomepageSections/ListHomepageSectionsQueryHandler.cs +++ b/backend/src/CCE.Application/Content/Queries/ListHomepageSections/ListHomepageSectionsQueryHandler.cs @@ -11,10 +11,7 @@ public sealed class ListHomepageSectionsQueryHandler { private readonly ICceDbContext _db; - public ListHomepageSectionsQueryHandler(ICceDbContext db) - { - _db = db; - } + public ListHomepageSectionsQueryHandler(ICceDbContext db) => _db = db; public async Task> Handle( ListHomepageSectionsQuery request, diff --git a/backend/src/CCE.Application/Content/Queries/ListNews/ListNewsQueryHandler.cs b/backend/src/CCE.Application/Content/Queries/ListNews/ListNewsQueryHandler.cs index f64cec98..c7c97445 100644 --- a/backend/src/CCE.Application/Content/Queries/ListNews/ListNewsQueryHandler.cs +++ b/backend/src/CCE.Application/Content/Queries/ListNews/ListNewsQueryHandler.cs @@ -10,39 +10,23 @@ public sealed class ListNewsQueryHandler : IRequestHandler _db = db; public async Task> Handle(ListNewsQuery request, CancellationToken cancellationToken) { - IQueryable query = _db.News; - - if (!string.IsNullOrWhiteSpace(request.Search)) - { - var term = request.Search.Trim(); - query = query.Where(n => - n.TitleAr.Contains(term) || - n.TitleEn.Contains(term) || - n.Slug.Contains(term)); - } - if (request.IsPublished is { } isPublished) - { - query = isPublished ? query.Where(n => n.PublishedOn != null) : query.Where(n => n.PublishedOn == null); - } - if (request.IsFeatured is { } isFeatured) - { - query = query.Where(n => n.IsFeatured == isFeatured); - } - query = query.OrderByDescending(n => n.PublishedOn ?? System.DateTimeOffset.MinValue) - .ThenByDescending(n => n.Id); - - var page = await query.ToPagedResultAsync(request.Page, request.PageSize, cancellationToken) - .ConfigureAwait(false); - - var items = page.Items.Select(MapToDto).ToList(); - return new PagedResult(items, page.Page, page.PageSize, page.Total); + var query = _db.News + .WhereIf(!string.IsNullOrWhiteSpace(request.Search), + n => n.TitleAr.Contains(request.Search!) || + n.TitleEn.Contains(request.Search!) || + n.Slug.Contains(request.Search!)) + .WhereIf(request.IsPublished == true, n => n.PublishedOn != null) + .WhereIf(request.IsPublished == false, n => n.PublishedOn == null) + .WhereIf(request.IsFeatured.HasValue, n => n.IsFeatured == request.IsFeatured!.Value) + .OrderByDescending(n => n.PublishedOn ?? DateTimeOffset.MinValue) + .ThenByDescending(n => n.Id); + + var result = await query.ToPagedResultAsync(request.Page, request.PageSize, cancellationToken).ConfigureAwait(false); + return result.Map(MapToDto); } internal static NewsDto MapToDto(News n) => new( diff --git a/backend/src/CCE.Application/Content/Queries/ListPages/ListPagesQueryHandler.cs b/backend/src/CCE.Application/Content/Queries/ListPages/ListPagesQueryHandler.cs index e0bb6207..ac354522 100644 --- a/backend/src/CCE.Application/Content/Queries/ListPages/ListPagesQueryHandler.cs +++ b/backend/src/CCE.Application/Content/Queries/ListPages/ListPagesQueryHandler.cs @@ -10,36 +10,20 @@ public sealed class ListPagesQueryHandler : IRequestHandler _db = db; public async Task> Handle(ListPagesQuery request, CancellationToken cancellationToken) { - IQueryable query = _db.Pages; - - if (!string.IsNullOrWhiteSpace(request.Search)) - { - var term = request.Search.Trim(); - query = query.Where(p => - p.Slug.Contains(term) || - p.TitleAr.Contains(term) || - p.TitleEn.Contains(term)); - } - - if (request.PageType is { } pageType) - { - query = query.Where(p => p.PageType == pageType); - } - - query = query.OrderBy(p => p.Slug); - - var page = await query.ToPagedResultAsync(request.Page, request.PageSize, cancellationToken) - .ConfigureAwait(false); - - var items = page.Items.Select(MapToDto).ToList(); - return new PagedResult(items, page.Page, page.PageSize, page.Total); + var query = _db.Pages + .WhereIf(!string.IsNullOrWhiteSpace(request.Search), + p => p.Slug.Contains(request.Search!) || + p.TitleAr.Contains(request.Search!) || + p.TitleEn.Contains(request.Search!)) + .WhereIf(request.PageType.HasValue, p => p.PageType == request.PageType!.Value) + .OrderBy(p => p.Slug); + + var result = await query.ToPagedResultAsync(request.Page, request.PageSize, cancellationToken).ConfigureAwait(false); + return result.Map(MapToDto); } internal static PageDto MapToDto(Page p) => new( diff --git a/backend/src/CCE.Application/Content/Queries/ListResourceCategories/ListResourceCategoriesQueryHandler.cs b/backend/src/CCE.Application/Content/Queries/ListResourceCategories/ListResourceCategoriesQueryHandler.cs index b614b471..25a64084 100644 --- a/backend/src/CCE.Application/Content/Queries/ListResourceCategories/ListResourceCategoriesQueryHandler.cs +++ b/backend/src/CCE.Application/Content/Queries/ListResourceCategories/ListResourceCategoriesQueryHandler.cs @@ -11,34 +11,19 @@ public sealed class ListResourceCategoriesQueryHandler { private readonly ICceDbContext _db; - public ListResourceCategoriesQueryHandler(ICceDbContext db) - { - _db = db; - } + public ListResourceCategoriesQueryHandler(ICceDbContext db) => _db = db; public async Task> Handle( ListResourceCategoriesQuery request, CancellationToken cancellationToken) { - IQueryable query = _db.ResourceCategories; - - if (request.ParentId is { } parentId) - { - query = query.Where(c => c.ParentId == parentId); - } - - if (request.IsActive is { } isActive) - { - query = query.Where(c => c.IsActive == isActive); - } - - query = query.OrderBy(c => c.OrderIndex); - - var page = await query.ToPagedResultAsync(request.Page, request.PageSize, cancellationToken) - .ConfigureAwait(false); + var query = _db.ResourceCategories + .WhereIf(request.ParentId.HasValue, c => c.ParentId == request.ParentId!.Value) + .WhereIf(request.IsActive.HasValue, c => c.IsActive == request.IsActive!.Value) + .OrderBy(c => c.OrderIndex); - var items = page.Items.Select(MapToDto).ToList(); - return new PagedResult(items, page.Page, page.PageSize, page.Total); + var result = await query.ToPagedResultAsync(request.Page, request.PageSize, cancellationToken).ConfigureAwait(false); + return result.Map(MapToDto); } internal static ResourceCategoryDto MapToDto(ResourceCategory c) => new( diff --git a/backend/src/CCE.Application/Content/Queries/ListResources/ListResourcesQueryHandler.cs b/backend/src/CCE.Application/Content/Queries/ListResources/ListResourcesQueryHandler.cs index 0a4d7b8f..9d8ce30f 100644 --- a/backend/src/CCE.Application/Content/Queries/ListResources/ListResourcesQueryHandler.cs +++ b/backend/src/CCE.Application/Content/Queries/ListResources/ListResourcesQueryHandler.cs @@ -11,51 +11,30 @@ public sealed class ListResourcesQueryHandler { private readonly ICceDbContext _db; - public ListResourcesQueryHandler(ICceDbContext db) - { - _db = db; - } + public ListResourcesQueryHandler(ICceDbContext db) => _db = db; public async Task> Handle( ListResourcesQuery request, CancellationToken cancellationToken) { - IQueryable query = _db.Resources; - - if (!string.IsNullOrWhiteSpace(request.Search)) - { - var term = request.Search.Trim(); - query = query.Where(r => - r.TitleAr.Contains(term) || - r.TitleEn.Contains(term) || - r.DescriptionAr.Contains(term) || - r.DescriptionEn.Contains(term)); - } - if (request.CategoryId is { } categoryId) - { - query = query.Where(r => r.CategoryId == categoryId); - } - if (request.CountryId is { } countryId) - { - query = query.Where(r => r.CountryId == countryId); - } - if (request.IsPublished is { } isPublished) - { - query = isPublished - ? query.Where(r => r.PublishedOn != null) - : query.Where(r => r.PublishedOn == null); - } - query = query.OrderByDescending(r => r.PublishedOn ?? System.DateTimeOffset.MinValue) - .ThenByDescending(r => r.Id); - - var page = await query.ToPagedResultAsync(request.Page, request.PageSize, cancellationToken) - .ConfigureAwait(false); - - var items = page.Items.Select(MapToDto).ToList(); - return new PagedResult(items, page.Page, page.PageSize, page.Total); + var query = _db.Resources + .WhereIf(!string.IsNullOrWhiteSpace(request.Search), + r => r.TitleAr.Contains(request.Search!) || + r.TitleEn.Contains(request.Search!) || + r.DescriptionAr.Contains(request.Search!) || + r.DescriptionEn.Contains(request.Search!)) + .WhereIf(request.CategoryId.HasValue, r => r.CategoryId == request.CategoryId!.Value) + .WhereIf(request.CountryId.HasValue, r => r.CountryId == request.CountryId!.Value) + .WhereIf(request.IsPublished == true, r => r.PublishedOn != null) + .WhereIf(request.IsPublished == false, r => r.PublishedOn == null) + .OrderByDescending(r => r.PublishedOn ?? DateTimeOffset.MinValue) + .ThenByDescending(r => r.Id); + + var result = await query.ToPagedResultAsync(request.Page, request.PageSize, cancellationToken).ConfigureAwait(false); + return result.Map(MapToDto); } - private static ResourceDto MapToDto(Resource r) => new( + internal static ResourceDto MapToDto(Resource r) => new( r.Id, r.TitleAr, r.TitleEn, diff --git a/backend/src/CCE.Application/Country/Queries/GetCountryProfile/GetCountryProfileQueryHandler.cs b/backend/src/CCE.Application/Country/Queries/GetCountryProfile/GetCountryProfileQueryHandler.cs index 2e21320e..4c209e4d 100644 --- a/backend/src/CCE.Application/Country/Queries/GetCountryProfile/GetCountryProfileQueryHandler.cs +++ b/backend/src/CCE.Application/Country/Queries/GetCountryProfile/GetCountryProfileQueryHandler.cs @@ -29,7 +29,7 @@ internal static CountryProfileDto MapToDto(CountryProfile profile) => profile.KeyInitiativesEn, profile.ContactInfoAr, profile.ContactInfoEn, - profile.LastUpdatedById, - profile.LastUpdatedOn, + profile.LastModifiedById ?? profile.CreatedById, + profile.LastModifiedOn ?? profile.CreatedOn, System.Convert.ToBase64String(profile.RowVersion)); } diff --git a/backend/src/CCE.Application/CountryPublic/Queries/GetPublicCountryProfile/GetPublicCountryProfileQueryHandler.cs b/backend/src/CCE.Application/CountryPublic/Queries/GetPublicCountryProfile/GetPublicCountryProfileQueryHandler.cs index 300af139..ce14a40e 100644 --- a/backend/src/CCE.Application/CountryPublic/Queries/GetPublicCountryProfile/GetPublicCountryProfileQueryHandler.cs +++ b/backend/src/CCE.Application/CountryPublic/Queries/GetPublicCountryProfile/GetPublicCountryProfileQueryHandler.cs @@ -48,5 +48,5 @@ public GetPublicCountryProfileQueryHandler(ICceDbContext db) p.KeyInitiativesEn, p.ContactInfoAr, p.ContactInfoEn, - p.LastUpdatedOn); + p.LastModifiedOn ?? p.CreatedOn); } diff --git a/backend/src/CCE.Application/Identity/Auth/Common/AuthMessageDto.cs b/backend/src/CCE.Application/Identity/Auth/Common/AuthMessageDto.cs new file mode 100644 index 00000000..b03be422 --- /dev/null +++ b/backend/src/CCE.Application/Identity/Auth/Common/AuthMessageDto.cs @@ -0,0 +1,3 @@ +namespace CCE.Application.Identity.Auth.Common; + +public sealed record AuthMessageDto(string Code); diff --git a/backend/src/CCE.Application/Identity/Auth/Common/AuthTokenDto.cs b/backend/src/CCE.Application/Identity/Auth/Common/AuthTokenDto.cs new file mode 100644 index 00000000..cc7f3c65 --- /dev/null +++ b/backend/src/CCE.Application/Identity/Auth/Common/AuthTokenDto.cs @@ -0,0 +1,9 @@ +namespace CCE.Application.Identity.Auth.Common; + +public sealed record AuthTokenDto( + string AccessToken, + DateTimeOffset AccessTokenExpiresAtUtc, + string RefreshToken, + DateTimeOffset RefreshTokenExpiresAtUtc, + string TokenType, + AuthUserDto User); diff --git a/backend/src/CCE.Application/Identity/Auth/Common/AuthUserDto.cs b/backend/src/CCE.Application/Identity/Auth/Common/AuthUserDto.cs new file mode 100644 index 00000000..90549ddd --- /dev/null +++ b/backend/src/CCE.Application/Identity/Auth/Common/AuthUserDto.cs @@ -0,0 +1,8 @@ +namespace CCE.Application.Identity.Auth.Common; + +public sealed record AuthUserDto( + System.Guid Id, + string EmailAddress, + string FirstName, + string LastName, + IReadOnlyCollection Roles); diff --git a/backend/src/CCE.Application/Identity/Auth/Common/ILocalTokenService.cs b/backend/src/CCE.Application/Identity/Auth/Common/ILocalTokenService.cs new file mode 100644 index 00000000..67fef8c8 --- /dev/null +++ b/backend/src/CCE.Application/Identity/Auth/Common/ILocalTokenService.cs @@ -0,0 +1,10 @@ +using CCE.Domain.Identity; + +namespace CCE.Application.Identity.Auth.Common; + +public interface ILocalTokenService +{ + Task IssueAsync(User user, LocalAuthApi api, CancellationToken ct); + + string HashRefreshToken(string refreshToken); +} diff --git a/backend/src/CCE.Application/Identity/Auth/Common/IPasswordResetEmailSender.cs b/backend/src/CCE.Application/Identity/Auth/Common/IPasswordResetEmailSender.cs new file mode 100644 index 00000000..14e3e805 --- /dev/null +++ b/backend/src/CCE.Application/Identity/Auth/Common/IPasswordResetEmailSender.cs @@ -0,0 +1,8 @@ +using CCE.Domain.Identity; + +namespace CCE.Application.Identity.Auth.Common; + +public interface IPasswordResetEmailSender +{ + Task SendAsync(User user, string resetToken, CancellationToken ct); +} diff --git a/backend/src/CCE.Application/Identity/Auth/Common/IRefreshTokenRepository.cs b/backend/src/CCE.Application/Identity/Auth/Common/IRefreshTokenRepository.cs new file mode 100644 index 00000000..f41c123a --- /dev/null +++ b/backend/src/CCE.Application/Identity/Auth/Common/IRefreshTokenRepository.cs @@ -0,0 +1,16 @@ +using CCE.Domain.Identity; + +namespace CCE.Application.Identity.Auth.Common; + +public interface IRefreshTokenRepository +{ + Task AddAsync(CCE.Domain.Identity.RefreshToken token, CancellationToken ct); + + Task FindByHashAsync(string tokenHash, CancellationToken ct); + + Task RevokeFamilyAsync(System.Guid tokenFamilyId, DateTimeOffset revokedAtUtc, string? revokedByIp, CancellationToken ct); + + Task RevokeAllForUserAsync(System.Guid userId, DateTimeOffset revokedAtUtc, string? revokedByIp, CancellationToken ct); + + Task SaveChangesAsync(CancellationToken ct); +} diff --git a/backend/src/CCE.Application/Identity/Auth/Common/LocalAuthApi.cs b/backend/src/CCE.Application/Identity/Auth/Common/LocalAuthApi.cs new file mode 100644 index 00000000..5bdacca6 --- /dev/null +++ b/backend/src/CCE.Application/Identity/Auth/Common/LocalAuthApi.cs @@ -0,0 +1,7 @@ +namespace CCE.Application.Identity.Auth.Common; + +public enum LocalAuthApi +{ + External, + Internal, +} diff --git a/backend/src/CCE.Application/Identity/Auth/Common/LocalAuthJwtProfile.cs b/backend/src/CCE.Application/Identity/Auth/Common/LocalAuthJwtProfile.cs new file mode 100644 index 00000000..a8de8501 --- /dev/null +++ b/backend/src/CCE.Application/Identity/Auth/Common/LocalAuthJwtProfile.cs @@ -0,0 +1,8 @@ +namespace CCE.Application.Identity.Auth.Common; + +public sealed record LocalAuthJwtProfile +{ + public string Issuer { get; init; } = string.Empty; + public string Audience { get; init; } = string.Empty; + public string SigningKey { get; init; } = string.Empty; +} diff --git a/backend/src/CCE.Application/Identity/Auth/Common/LocalAuthOptions.cs b/backend/src/CCE.Application/Identity/Auth/Common/LocalAuthOptions.cs new file mode 100644 index 00000000..863e7764 --- /dev/null +++ b/backend/src/CCE.Application/Identity/Auth/Common/LocalAuthOptions.cs @@ -0,0 +1,16 @@ +namespace CCE.Application.Identity.Auth.Common; + +public sealed record LocalAuthOptions +{ + public const string SectionName = "LocalAuth"; + + public LocalAuthJwtProfile External { get; init; } = new(); + public LocalAuthJwtProfile Internal { get; init; } = new(); + public int AccessTokenMinutes { get; init; } = 10; + public int RefreshTokenDays { get; init; } = 30; + public int PasswordResetTokenHours { get; init; } = 2; + public bool RequireConfirmedEmail { get; init; } + + public LocalAuthJwtProfile GetProfile(LocalAuthApi api) + => api == LocalAuthApi.Internal ? Internal : External; +} diff --git a/backend/src/CCE.Application/Identity/Auth/Common/PasswordResetTokenCodec.cs b/backend/src/CCE.Application/Identity/Auth/Common/PasswordResetTokenCodec.cs new file mode 100644 index 00000000..3e367980 --- /dev/null +++ b/backend/src/CCE.Application/Identity/Auth/Common/PasswordResetTokenCodec.cs @@ -0,0 +1,26 @@ +namespace CCE.Application.Identity.Auth.Common; + +public static class PasswordResetTokenCodec +{ + public static string Encode(string token) + { + var bytes = System.Text.Encoding.UTF8.GetBytes(token); + return Convert.ToBase64String(bytes) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } + + public static string Decode(string encodedToken) + { + var incoming = encodedToken.Replace('-', '+').Replace('_', '/'); + var padding = incoming.Length % 4; + if (padding > 0) + { + incoming = incoming.PadRight(incoming.Length + 4 - padding, '='); + } + + var bytes = Convert.FromBase64String(incoming); + return System.Text.Encoding.UTF8.GetString(bytes); + } +} diff --git a/backend/src/CCE.Application/Identity/Auth/Common/TokenIssueResult.cs b/backend/src/CCE.Application/Identity/Auth/Common/TokenIssueResult.cs new file mode 100644 index 00000000..5489c736 --- /dev/null +++ b/backend/src/CCE.Application/Identity/Auth/Common/TokenIssueResult.cs @@ -0,0 +1,8 @@ +namespace CCE.Application.Identity.Auth.Common; + +public sealed record TokenIssueResult( + string AccessToken, + DateTimeOffset AccessTokenExpiresAtUtc, + string RefreshToken, + string RefreshTokenHash, + DateTimeOffset RefreshTokenExpiresAtUtc); diff --git a/backend/src/CCE.Application/Identity/Auth/ForgotPassword/ForgotPasswordCommand.cs b/backend/src/CCE.Application/Identity/Auth/ForgotPassword/ForgotPasswordCommand.cs new file mode 100644 index 00000000..6cd6e179 --- /dev/null +++ b/backend/src/CCE.Application/Identity/Auth/ForgotPassword/ForgotPasswordCommand.cs @@ -0,0 +1,8 @@ +using CCE.Application.Common; +using CCE.Application.Identity.Auth.Common; +using MediatR; + +namespace CCE.Application.Identity.Auth.ForgotPassword; + +public sealed record ForgotPasswordCommand(string EmailAddress) + : IRequest>; diff --git a/backend/src/CCE.Application/Identity/Auth/ForgotPassword/ForgotPasswordCommandHandler.cs b/backend/src/CCE.Application/Identity/Auth/ForgotPassword/ForgotPasswordCommandHandler.cs new file mode 100644 index 00000000..78e011fe --- /dev/null +++ b/backend/src/CCE.Application/Identity/Auth/ForgotPassword/ForgotPasswordCommandHandler.cs @@ -0,0 +1,33 @@ +using CCE.Application.Common; +using CCE.Application.Identity.Auth.Common; +using CCE.Domain.Identity; +using MediatR; +using Microsoft.AspNetCore.Identity; +using AppErrorCodes = CCE.Application.Errors.ApplicationErrors; + +namespace CCE.Application.Identity.Auth.ForgotPassword; + +internal sealed class ForgotPasswordCommandHandler + : IRequestHandler> +{ + private readonly UserManager _userManager; + private readonly IPasswordResetEmailSender _emailSender; + + public ForgotPasswordCommandHandler(UserManager userManager, IPasswordResetEmailSender emailSender) + { + _userManager = userManager; + _emailSender = emailSender; + } + + public async Task> Handle(ForgotPasswordCommand request, CancellationToken ct) + { + var user = await _userManager.FindByEmailAsync(request.EmailAddress).ConfigureAwait(false); + if (user is not null) + { + var token = await _userManager.GeneratePasswordResetTokenAsync(user).ConfigureAwait(false); + await _emailSender.SendAsync(user, PasswordResetTokenCodec.Encode(token), ct).ConfigureAwait(false); + } + + return new AuthMessageDto(AppErrorCodes.Identity.PASSWORD_RESET); + } +} diff --git a/backend/src/CCE.Application/Identity/Auth/ForgotPassword/ForgotPasswordCommandValidator.cs b/backend/src/CCE.Application/Identity/Auth/ForgotPassword/ForgotPasswordCommandValidator.cs new file mode 100644 index 00000000..9fe269d2 --- /dev/null +++ b/backend/src/CCE.Application/Identity/Auth/ForgotPassword/ForgotPasswordCommandValidator.cs @@ -0,0 +1,9 @@ +using FluentValidation; + +namespace CCE.Application.Identity.Auth.ForgotPassword; + +public sealed class ForgotPasswordCommandValidator : AbstractValidator +{ + public ForgotPasswordCommandValidator() + => RuleFor(x => x.EmailAddress).NotEmpty().EmailAddress().MaximumLength(100); +} diff --git a/backend/src/CCE.Application/Identity/Auth/ForgotPassword/ForgotPasswordRequest.cs b/backend/src/CCE.Application/Identity/Auth/ForgotPassword/ForgotPasswordRequest.cs new file mode 100644 index 00000000..55f1cf8b --- /dev/null +++ b/backend/src/CCE.Application/Identity/Auth/ForgotPassword/ForgotPasswordRequest.cs @@ -0,0 +1,3 @@ +namespace CCE.Application.Identity.Auth.ForgotPassword; + +public sealed record ForgotPasswordRequest(string EmailAddress); diff --git a/backend/src/CCE.Application/Identity/Auth/Login/LoginCommand.cs b/backend/src/CCE.Application/Identity/Auth/Login/LoginCommand.cs new file mode 100644 index 00000000..f2ee1f26 --- /dev/null +++ b/backend/src/CCE.Application/Identity/Auth/Login/LoginCommand.cs @@ -0,0 +1,13 @@ +using CCE.Application.Common; +using CCE.Application.Identity.Auth.Common; +using MediatR; + +namespace CCE.Application.Identity.Auth.Login; + +public sealed record LoginCommand( + string EmailAddress, + string Password, + LocalAuthApi Api, + string? IpAddress, + string? UserAgent) + : IRequest>; diff --git a/backend/src/CCE.Application/Identity/Auth/Login/LoginCommandHandler.cs b/backend/src/CCE.Application/Identity/Auth/Login/LoginCommandHandler.cs new file mode 100644 index 00000000..73bebc97 --- /dev/null +++ b/backend/src/CCE.Application/Identity/Auth/Login/LoginCommandHandler.cs @@ -0,0 +1,94 @@ +using CCE.Application.Common; +using CCE.Application.Identity.Auth.Common; +using CCE.Domain.Common; +using CCE.Domain.Identity; +using MediatR; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Options; +using AppErrors = CCE.Application.Common.Errors; + +namespace CCE.Application.Identity.Auth.Login; + +internal sealed class LoginCommandHandler + : IRequestHandler> +{ + private readonly UserManager _userManager; + private readonly ILocalTokenService _tokenService; + private readonly IRefreshTokenRepository _refreshTokens; + private readonly ISystemClock _clock; + private readonly IOptions _options; + private readonly AppErrors _errors; + + public LoginCommandHandler( + UserManager userManager, + ILocalTokenService tokenService, + IRefreshTokenRepository refreshTokens, + ISystemClock clock, + IOptions options, + AppErrors errors) + { + _userManager = userManager; + _tokenService = tokenService; + _refreshTokens = refreshTokens; + _clock = clock; + _options = options; + _errors = errors; + } + + public async Task> Handle(LoginCommand request, CancellationToken ct) + { + var user = await _userManager.FindByEmailAsync(request.EmailAddress).ConfigureAwait(false); + if (user is null) + { + return _errors.InvalidCredentials(); + } + + if (_options.Value.RequireConfirmedEmail && !await _userManager.IsEmailConfirmedAsync(user).ConfigureAwait(false)) + { + return _errors.InvalidCredentials(); + } + + var passwordValid = await _userManager.CheckPasswordAsync(user, request.Password).ConfigureAwait(false); + if (!passwordValid) + { + return _errors.InvalidCredentials(); + } + + return await IssueAndPersistAsync(user, request.Api, request.IpAddress, request.UserAgent, null, ct).ConfigureAwait(false); + } + + private async Task IssueAndPersistAsync( + User user, + LocalAuthApi api, + string? ipAddress, + string? userAgent, + Guid? tokenFamilyId, + CancellationToken ct) + { + var issued = await _tokenService.IssueAsync(user, api, ct).ConfigureAwait(false); + var familyId = tokenFamilyId ?? Guid.NewGuid(); + var refreshToken = CCE.Domain.Identity.RefreshToken.Create( + user.Id, + issued.RefreshTokenHash, + familyId, + _clock.UtcNow, + issued.RefreshTokenExpiresAtUtc, + ipAddress, + userAgent); + await _refreshTokens.AddAsync(refreshToken, ct).ConfigureAwait(false); + await _refreshTokens.SaveChangesAsync(ct).ConfigureAwait(false); + return await ToDtoAsync(user, issued, ct).ConfigureAwait(false); + } + + private async Task ToDtoAsync(User user, TokenIssueResult issued, CancellationToken ct) + { + var roles = await _userManager.GetRolesAsync(user).ConfigureAwait(false); + return new AuthTokenDto( + issued.AccessToken, + issued.AccessTokenExpiresAtUtc, + issued.RefreshToken, + issued.RefreshTokenExpiresAtUtc, + "Bearer", + new AuthUserDto(user.Id, user.Email ?? string.Empty, user.FirstName, user.LastName, roles.ToArray())); + } +} diff --git a/backend/src/CCE.Application/Identity/Auth/Login/LoginCommandValidator.cs b/backend/src/CCE.Application/Identity/Auth/Login/LoginCommandValidator.cs new file mode 100644 index 00000000..945af1c1 --- /dev/null +++ b/backend/src/CCE.Application/Identity/Auth/Login/LoginCommandValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace CCE.Application.Identity.Auth.Login; + +public sealed class LoginCommandValidator : AbstractValidator +{ + public LoginCommandValidator() + { + RuleFor(x => x.EmailAddress).NotEmpty().EmailAddress().MaximumLength(100); + RuleFor(x => x.Password).NotEmpty(); + } +} diff --git a/backend/src/CCE.Application/Identity/Auth/Login/LoginRequest.cs b/backend/src/CCE.Application/Identity/Auth/Login/LoginRequest.cs new file mode 100644 index 00000000..2a0663e3 --- /dev/null +++ b/backend/src/CCE.Application/Identity/Auth/Login/LoginRequest.cs @@ -0,0 +1,3 @@ +namespace CCE.Application.Identity.Auth.Login; + +public sealed record LoginRequest(string EmailAddress, string Password); diff --git a/backend/src/CCE.Application/Identity/Auth/Logout/LogoutCommand.cs b/backend/src/CCE.Application/Identity/Auth/Logout/LogoutCommand.cs new file mode 100644 index 00000000..6a5d5315 --- /dev/null +++ b/backend/src/CCE.Application/Identity/Auth/Logout/LogoutCommand.cs @@ -0,0 +1,8 @@ +using CCE.Application.Common; +using CCE.Application.Identity.Auth.Common; +using MediatR; + +namespace CCE.Application.Identity.Auth.Logout; + +public sealed record LogoutCommand(string RefreshToken, string? IpAddress) + : IRequest>; diff --git a/backend/src/CCE.Application/Identity/Auth/Logout/LogoutCommandHandler.cs b/backend/src/CCE.Application/Identity/Auth/Logout/LogoutCommandHandler.cs new file mode 100644 index 00000000..912ef5c6 --- /dev/null +++ b/backend/src/CCE.Application/Identity/Auth/Logout/LogoutCommandHandler.cs @@ -0,0 +1,38 @@ +using CCE.Application.Common; +using CCE.Application.Identity.Auth.Common; +using CCE.Domain.Common; +using MediatR; +using AppErrorCodes = CCE.Application.Errors.ApplicationErrors; + +namespace CCE.Application.Identity.Auth.Logout; + +internal sealed class LogoutCommandHandler + : IRequestHandler> +{ + private readonly ILocalTokenService _tokenService; + private readonly IRefreshTokenRepository _refreshTokens; + private readonly ISystemClock _clock; + + public LogoutCommandHandler( + ILocalTokenService tokenService, + IRefreshTokenRepository refreshTokens, + ISystemClock clock) + { + _tokenService = tokenService; + _refreshTokens = refreshTokens; + _clock = clock; + } + + public async Task> Handle(LogoutCommand request, CancellationToken ct) + { + var tokenHash = _tokenService.HashRefreshToken(request.RefreshToken); + var existing = await _refreshTokens.FindByHashAsync(tokenHash, ct).ConfigureAwait(false); + if (existing is not null && existing.IsActive(_clock.UtcNow)) + { + existing.Revoke(_clock.UtcNow, request.IpAddress); + await _refreshTokens.SaveChangesAsync(ct).ConfigureAwait(false); + } + + return new AuthMessageDto(AppErrorCodes.Identity.LOGOUT_SUCCESS); + } +} diff --git a/backend/src/CCE.Application/Identity/Auth/Logout/LogoutCommandValidator.cs b/backend/src/CCE.Application/Identity/Auth/Logout/LogoutCommandValidator.cs new file mode 100644 index 00000000..9832d200 --- /dev/null +++ b/backend/src/CCE.Application/Identity/Auth/Logout/LogoutCommandValidator.cs @@ -0,0 +1,8 @@ +using FluentValidation; + +namespace CCE.Application.Identity.Auth.Logout; + +public sealed class LogoutCommandValidator : AbstractValidator +{ + public LogoutCommandValidator() => RuleFor(x => x.RefreshToken).NotEmpty(); +} diff --git a/backend/src/CCE.Application/Identity/Auth/Logout/LogoutRequest.cs b/backend/src/CCE.Application/Identity/Auth/Logout/LogoutRequest.cs new file mode 100644 index 00000000..c5fcce5e --- /dev/null +++ b/backend/src/CCE.Application/Identity/Auth/Logout/LogoutRequest.cs @@ -0,0 +1,3 @@ +namespace CCE.Application.Identity.Auth.Logout; + +public sealed record LogoutRequest(string RefreshToken); diff --git a/backend/src/CCE.Application/Identity/Auth/RefreshToken/RefreshTokenCommand.cs b/backend/src/CCE.Application/Identity/Auth/RefreshToken/RefreshTokenCommand.cs new file mode 100644 index 00000000..2e7f2ceb --- /dev/null +++ b/backend/src/CCE.Application/Identity/Auth/RefreshToken/RefreshTokenCommand.cs @@ -0,0 +1,12 @@ +using CCE.Application.Common; +using CCE.Application.Identity.Auth.Common; +using MediatR; + +namespace CCE.Application.Identity.Auth.RefreshToken; + +public sealed record RefreshTokenCommand( + string RefreshToken, + LocalAuthApi Api, + string? IpAddress, + string? UserAgent) + : IRequest>; diff --git a/backend/src/CCE.Application/Identity/Auth/RefreshToken/RefreshTokenCommandHandler.cs b/backend/src/CCE.Application/Identity/Auth/RefreshToken/RefreshTokenCommandHandler.cs new file mode 100644 index 00000000..d97f77ca --- /dev/null +++ b/backend/src/CCE.Application/Identity/Auth/RefreshToken/RefreshTokenCommandHandler.cs @@ -0,0 +1,83 @@ +using CCE.Application.Common; +using CCE.Application.Identity.Auth.Common; +using CCE.Domain.Common; +using CCE.Domain.Identity; +using MediatR; +using Microsoft.AspNetCore.Identity; +using AppErrors = CCE.Application.Common.Errors; + +namespace CCE.Application.Identity.Auth.RefreshToken; + +internal sealed class RefreshTokenCommandHandler + : IRequestHandler> +{ + private readonly UserManager _userManager; + private readonly ILocalTokenService _tokenService; + private readonly IRefreshTokenRepository _refreshTokens; + private readonly ISystemClock _clock; + private readonly AppErrors _errors; + + public RefreshTokenCommandHandler( + UserManager userManager, + ILocalTokenService tokenService, + IRefreshTokenRepository refreshTokens, + ISystemClock clock, + AppErrors errors) + { + _userManager = userManager; + _tokenService = tokenService; + _refreshTokens = refreshTokens; + _clock = clock; + _errors = errors; + } + + public async Task> Handle(RefreshTokenCommand request, CancellationToken ct) + { + var tokenHash = _tokenService.HashRefreshToken(request.RefreshToken); + var existing = await _refreshTokens.FindByHashAsync(tokenHash, ct).ConfigureAwait(false); + if (existing is null) + { + return _errors.InvalidRefreshToken(); + } + + if (!existing.IsActive(_clock.UtcNow)) + { + if (existing.RevokedAtUtc is not null) + { + await _refreshTokens.RevokeFamilyAsync(existing.TokenFamilyId, _clock.UtcNow, request.IpAddress, ct) + .ConfigureAwait(false); + await _refreshTokens.SaveChangesAsync(ct).ConfigureAwait(false); + } + return _errors.InvalidRefreshToken(); + } + + var user = await _userManager.FindByIdAsync(existing.UserId.ToString()).ConfigureAwait(false); + if (user is null) + { + return _errors.InvalidRefreshToken(); + } + + var issued = await _tokenService.IssueAsync(user, request.Api, ct).ConfigureAwait(false); + existing.Revoke(_clock.UtcNow, request.IpAddress, issued.RefreshTokenHash); + + var replacement = CCE.Domain.Identity.RefreshToken.Create( + user.Id, + issued.RefreshTokenHash, + existing.TokenFamilyId, + _clock.UtcNow, + issued.RefreshTokenExpiresAtUtc, + request.IpAddress, + request.UserAgent); + await _refreshTokens.AddAsync(replacement, ct).ConfigureAwait(false); + await _refreshTokens.SaveChangesAsync(ct).ConfigureAwait(false); + + var roles = await _userManager.GetRolesAsync(user).ConfigureAwait(false); + return new AuthTokenDto( + issued.AccessToken, + issued.AccessTokenExpiresAtUtc, + issued.RefreshToken, + issued.RefreshTokenExpiresAtUtc, + "Bearer", + new AuthUserDto(user.Id, user.Email ?? string.Empty, user.FirstName, user.LastName, roles.ToArray())); + } +} diff --git a/backend/src/CCE.Application/Identity/Auth/RefreshToken/RefreshTokenCommandValidator.cs b/backend/src/CCE.Application/Identity/Auth/RefreshToken/RefreshTokenCommandValidator.cs new file mode 100644 index 00000000..4fbd580c --- /dev/null +++ b/backend/src/CCE.Application/Identity/Auth/RefreshToken/RefreshTokenCommandValidator.cs @@ -0,0 +1,8 @@ +using FluentValidation; + +namespace CCE.Application.Identity.Auth.RefreshToken; + +public sealed class RefreshTokenCommandValidator : AbstractValidator +{ + public RefreshTokenCommandValidator() => RuleFor(x => x.RefreshToken).NotEmpty(); +} diff --git a/backend/src/CCE.Application/Identity/Auth/RefreshToken/RefreshTokenRequest.cs b/backend/src/CCE.Application/Identity/Auth/RefreshToken/RefreshTokenRequest.cs new file mode 100644 index 00000000..4998dc12 --- /dev/null +++ b/backend/src/CCE.Application/Identity/Auth/RefreshToken/RefreshTokenRequest.cs @@ -0,0 +1,3 @@ +namespace CCE.Application.Identity.Auth.RefreshToken; + +public sealed record RefreshTokenRequest(string RefreshToken); diff --git a/backend/src/CCE.Application/Identity/Auth/Register/RegisterUserCommand.cs b/backend/src/CCE.Application/Identity/Auth/Register/RegisterUserCommand.cs new file mode 100644 index 00000000..d728498b --- /dev/null +++ b/backend/src/CCE.Application/Identity/Auth/Register/RegisterUserCommand.cs @@ -0,0 +1,16 @@ +using CCE.Application.Common; +using CCE.Application.Identity.Auth.Common; +using MediatR; + +namespace CCE.Application.Identity.Auth.Register; + +public sealed record RegisterUserCommand( + string FirstName, + string LastName, + string EmailAddress, + string JobTitle, + string OrganizationName, + string PhoneNumber, + string Password, + string ConfirmPassword) + : IRequest>; diff --git a/backend/src/CCE.Application/Identity/Auth/Register/RegisterUserCommandHandler.cs b/backend/src/CCE.Application/Identity/Auth/Register/RegisterUserCommandHandler.cs new file mode 100644 index 00000000..797ff345 --- /dev/null +++ b/backend/src/CCE.Application/Identity/Auth/Register/RegisterUserCommandHandler.cs @@ -0,0 +1,76 @@ +using CCE.Application.Common; +using CCE.Application.Identity.Auth.Common; +using CCE.Domain.Common; +using CCE.Domain.Identity; +using MediatR; +using Microsoft.AspNetCore.Identity; +using AppErrors = CCE.Application.Common.Errors; + +namespace CCE.Application.Identity.Auth.Register; + +internal sealed class RegisterUserCommandHandler + : IRequestHandler> +{ + private const string DefaultRole = "cce-user"; + private readonly UserManager _userManager; + private readonly RoleManager _roleManager; + private readonly AppErrors _errors; + + public RegisterUserCommandHandler(UserManager userManager, RoleManager roleManager, AppErrors errors) + { + _userManager = userManager; + _roleManager = roleManager; + _errors = errors; + } + + public async Task> Handle(RegisterUserCommand request, CancellationToken ct) + { + var existing = await _userManager.FindByEmailAsync(request.EmailAddress).ConfigureAwait(false); + if (existing is not null) + { + return _errors.EmailExists(); + } + + var user = User.RegisterLocal( + request.FirstName, + request.LastName, + request.EmailAddress, + request.JobTitle, + request.OrganizationName, + request.PhoneNumber); + + var createResult = await _userManager.CreateAsync(user, request.Password).ConfigureAwait(false); + if (!createResult.Succeeded) + { + return _errors.RegistrationFailed(ToDetails(createResult)); + } + + if (!await _roleManager.RoleExistsAsync(DefaultRole).ConfigureAwait(false)) + { + var roleResult = await _roleManager.CreateAsync(new Role(DefaultRole)).ConfigureAwait(false); + if (!roleResult.Succeeded) + { + return _errors.RegistrationFailed(ToDetails(roleResult)); + } + } + + var addRoleResult = await _userManager.AddToRoleAsync(user, DefaultRole).ConfigureAwait(false); + if (!addRoleResult.Succeeded) + { + return _errors.RegistrationFailed(ToDetails(addRoleResult)); + } + + return new AuthUserDto( + user.Id, + user.Email ?? request.EmailAddress, + user.FirstName, + user.LastName, + [DefaultRole]); + } + + private static Dictionary ToDetails(IdentityResult result) + => new(StringComparer.Ordinal) + { + ["Identity"] = result.Errors.Select(e => e.Code).ToArray(), + }; +} diff --git a/backend/src/CCE.Application/Identity/Auth/Register/RegisterUserCommandValidator.cs b/backend/src/CCE.Application/Identity/Auth/Register/RegisterUserCommandValidator.cs new file mode 100644 index 00000000..7bab1917 --- /dev/null +++ b/backend/src/CCE.Application/Identity/Auth/Register/RegisterUserCommandValidator.cs @@ -0,0 +1,28 @@ +using FluentValidation; + +namespace CCE.Application.Identity.Auth.Register; + +public sealed class RegisterUserCommandValidator : AbstractValidator +{ + public RegisterUserCommandValidator() + { + RuleFor(x => x.FirstName).NotEmpty().MaximumLength(50).Must(BeLettersOnly); + RuleFor(x => x.LastName).NotEmpty().MaximumLength(50).Must(BeLettersOnly); + RuleFor(x => x.EmailAddress).NotEmpty().EmailAddress().MaximumLength(100); + RuleFor(x => x.JobTitle).NotEmpty().MaximumLength(50); + RuleFor(x => x.OrganizationName).NotEmpty().MaximumLength(100); + RuleFor(x => x.PhoneNumber).NotEmpty().MaximumLength(15); + RuleFor(x => x.Password).Must(MatchStoryPasswordPolicy).WithMessage("PASSWORD_POLICY"); + RuleFor(x => x.ConfirmPassword).Equal(x => x.Password); + } + + private static bool BeLettersOnly(string value) + => !string.IsNullOrWhiteSpace(value) && value.All(char.IsLetter); + + internal static bool MatchStoryPasswordPolicy(string value) + => !string.IsNullOrWhiteSpace(value) + && value.Length is >= 12 and <= 20 + && value.Any(char.IsUpper) + && value.Any(char.IsLower) + && value.Any(char.IsDigit); +} diff --git a/backend/src/CCE.Application/Identity/Auth/Register/RegisterUserRequest.cs b/backend/src/CCE.Application/Identity/Auth/Register/RegisterUserRequest.cs new file mode 100644 index 00000000..db6d52cd --- /dev/null +++ b/backend/src/CCE.Application/Identity/Auth/Register/RegisterUserRequest.cs @@ -0,0 +1,11 @@ +namespace CCE.Application.Identity.Auth.Register; + +public sealed record RegisterUserRequest( + string FirstName, + string LastName, + string EmailAddress, + string JobTitle, + string OrganizationName, + string PhoneNumber, + string Password, + string ConfirmPassword); diff --git a/backend/src/CCE.Application/Identity/Auth/ResetPassword/ResetPasswordCommand.cs b/backend/src/CCE.Application/Identity/Auth/ResetPassword/ResetPasswordCommand.cs new file mode 100644 index 00000000..de83f8d5 --- /dev/null +++ b/backend/src/CCE.Application/Identity/Auth/ResetPassword/ResetPasswordCommand.cs @@ -0,0 +1,13 @@ +using CCE.Application.Common; +using CCE.Application.Identity.Auth.Common; +using MediatR; + +namespace CCE.Application.Identity.Auth.ResetPassword; + +public sealed record ResetPasswordCommand( + string EmailAddress, + string Token, + string NewPassword, + string ConfirmPassword, + string? IpAddress) + : IRequest>; diff --git a/backend/src/CCE.Application/Identity/Auth/ResetPassword/ResetPasswordCommandHandler.cs b/backend/src/CCE.Application/Identity/Auth/ResetPassword/ResetPasswordCommandHandler.cs new file mode 100644 index 00000000..afe4efa4 --- /dev/null +++ b/backend/src/CCE.Application/Identity/Auth/ResetPassword/ResetPasswordCommandHandler.cs @@ -0,0 +1,64 @@ +using CCE.Application.Common; +using CCE.Application.Identity.Auth.Common; +using CCE.Domain.Common; +using CCE.Domain.Identity; +using MediatR; +using Microsoft.AspNetCore.Identity; +using AppErrorCodes = CCE.Application.Errors.ApplicationErrors; +using AppErrors = CCE.Application.Common.Errors; + +namespace CCE.Application.Identity.Auth.ResetPassword; + +internal sealed class ResetPasswordCommandHandler + : IRequestHandler> +{ + private readonly UserManager _userManager; + private readonly IRefreshTokenRepository _refreshTokens; + private readonly ISystemClock _clock; + private readonly AppErrors _errors; + + public ResetPasswordCommandHandler( + UserManager userManager, + IRefreshTokenRepository refreshTokens, + ISystemClock clock, + AppErrors errors) + { + _userManager = userManager; + _refreshTokens = refreshTokens; + _clock = clock; + _errors = errors; + } + + public async Task> Handle(ResetPasswordCommand request, CancellationToken ct) + { + var user = await _userManager.FindByEmailAsync(request.EmailAddress).ConfigureAwait(false); + if (user is null) + { + return _errors.UserNotFound(); + } + + string token; + try + { + token = PasswordResetTokenCodec.Decode(request.Token); + } + catch (FormatException) + { + return _errors.InvalidRefreshToken(); + } + + var result = await _userManager.ResetPasswordAsync(user, token, request.NewPassword).ConfigureAwait(false); + if (!result.Succeeded) + { + return _errors.RegistrationFailed(new Dictionary(StringComparer.Ordinal) + { + ["Identity"] = result.Errors.Select(e => e.Code).ToArray(), + }); + } + + await _userManager.UpdateSecurityStampAsync(user).ConfigureAwait(false); + await _refreshTokens.RevokeAllForUserAsync(user.Id, _clock.UtcNow, request.IpAddress, ct).ConfigureAwait(false); + await _refreshTokens.SaveChangesAsync(ct).ConfigureAwait(false); + return new AuthMessageDto(AppErrorCodes.Identity.PASSWORD_RESET); + } +} diff --git a/backend/src/CCE.Application/Identity/Auth/ResetPassword/ResetPasswordCommandValidator.cs b/backend/src/CCE.Application/Identity/Auth/ResetPassword/ResetPasswordCommandValidator.cs new file mode 100644 index 00000000..bda031f1 --- /dev/null +++ b/backend/src/CCE.Application/Identity/Auth/ResetPassword/ResetPasswordCommandValidator.cs @@ -0,0 +1,15 @@ +using CCE.Application.Identity.Auth.Register; +using FluentValidation; + +namespace CCE.Application.Identity.Auth.ResetPassword; + +public sealed class ResetPasswordCommandValidator : AbstractValidator +{ + public ResetPasswordCommandValidator() + { + RuleFor(x => x.EmailAddress).NotEmpty().EmailAddress().MaximumLength(100); + RuleFor(x => x.Token).NotEmpty(); + RuleFor(x => x.NewPassword).Must(RegisterUserCommandValidator.MatchStoryPasswordPolicy).WithMessage("PASSWORD_POLICY"); + RuleFor(x => x.ConfirmPassword).Equal(x => x.NewPassword); + } +} diff --git a/backend/src/CCE.Application/Identity/Auth/ResetPassword/ResetPasswordRequest.cs b/backend/src/CCE.Application/Identity/Auth/ResetPassword/ResetPasswordRequest.cs new file mode 100644 index 00000000..ec675f26 --- /dev/null +++ b/backend/src/CCE.Application/Identity/Auth/ResetPassword/ResetPasswordRequest.cs @@ -0,0 +1,7 @@ +namespace CCE.Application.Identity.Auth.ResetPassword; + +public sealed record ResetPasswordRequest( + string EmailAddress, + string Token, + string NewPassword, + string ConfirmPassword); diff --git a/backend/src/CCE.Application/Identity/Commands/ApproveExpertRequest/ApproveExpertRequestCommand.cs b/backend/src/CCE.Application/Identity/Commands/ApproveExpertRequest/ApproveExpertRequestCommand.cs index ee43a016..15bfab03 100644 --- a/backend/src/CCE.Application/Identity/Commands/ApproveExpertRequest/ApproveExpertRequestCommand.cs +++ b/backend/src/CCE.Application/Identity/Commands/ApproveExpertRequest/ApproveExpertRequestCommand.cs @@ -1,3 +1,4 @@ +using CCE.Application.Common; using CCE.Application.Identity.Dtos; using MediatR; @@ -6,4 +7,4 @@ namespace CCE.Application.Identity.Commands.ApproveExpertRequest; public sealed record ApproveExpertRequestCommand( System.Guid Id, string AcademicTitleAr, - string AcademicTitleEn) : IRequest; + string AcademicTitleEn) : IRequest>; diff --git a/backend/src/CCE.Application/Identity/Commands/ApproveExpertRequest/ApproveExpertRequestCommandHandler.cs b/backend/src/CCE.Application/Identity/Commands/ApproveExpertRequest/ApproveExpertRequestCommandHandler.cs index c06c0936..78c73e85 100644 --- a/backend/src/CCE.Application/Identity/Commands/ApproveExpertRequest/ApproveExpertRequestCommandHandler.cs +++ b/backend/src/CCE.Application/Identity/Commands/ApproveExpertRequest/ApproveExpertRequestCommandHandler.cs @@ -1,6 +1,6 @@ +using CCE.Application.Common; using CCE.Application.Common.Interfaces; using CCE.Application.Common.Pagination; -using CCE.Application.Identity; using CCE.Application.Identity.Dtos; using CCE.Domain.Common; using CCE.Domain.Identity; @@ -9,39 +9,45 @@ namespace CCE.Application.Identity.Commands.ApproveExpertRequest; public sealed class ApproveExpertRequestCommandHandler - : IRequestHandler + : IRequestHandler> { - private readonly IExpertWorkflowService _service; + private readonly IExpertWorkflowRepository _service; private readonly ICceDbContext _db; private readonly ICurrentUserAccessor _currentUser; private readonly ISystemClock _clock; + private readonly CCE.Application.Common.Errors _errors; public ApproveExpertRequestCommandHandler( - IExpertWorkflowService service, + IExpertWorkflowRepository service, ICceDbContext db, ICurrentUserAccessor currentUser, - ISystemClock clock) + ISystemClock clock, + CCE.Application.Common.Errors errors) { _service = service; _db = db; _currentUser = currentUser; _clock = clock; + _errors = errors; } - public async Task Handle( + public async Task> Handle( ApproveExpertRequestCommand request, CancellationToken cancellationToken) { var registration = await _service.FindIncludingDeletedAsync(request.Id, cancellationToken).ConfigureAwait(false); if (registration is null) { - throw new System.Collections.Generic.KeyNotFoundException($"Expert registration request {request.Id} not found."); + return _errors.ExpertRequestNotFound(); } - var approvedById = _currentUser.GetUserId() - ?? throw new DomainException("Cannot approve an expert request from a request without a user identity."); + var approvedById = _currentUser.GetUserId(); + if (approvedById is null) + { + return _errors.NotAuthenticated(); + } - registration.Approve(approvedById, _clock); + registration.Approve(approvedById.Value, _clock); var profile = ExpertProfile.CreateFromApprovedRequest(registration, request.AcademicTitleAr, request.AcademicTitleEn, _clock); await _service.SaveAsync(registration, profile, cancellationToken).ConfigureAwait(false); diff --git a/backend/src/CCE.Application/Identity/Commands/ApproveExpertRequest/ApproveExpertRequestRequest.cs b/backend/src/CCE.Application/Identity/Commands/ApproveExpertRequest/ApproveExpertRequestRequest.cs new file mode 100644 index 00000000..29609560 --- /dev/null +++ b/backend/src/CCE.Application/Identity/Commands/ApproveExpertRequest/ApproveExpertRequestRequest.cs @@ -0,0 +1,3 @@ +namespace CCE.Application.Identity.Commands.ApproveExpertRequest; + +public sealed record ApproveExpertRequestRequest(string AcademicTitleAr, string AcademicTitleEn); diff --git a/backend/src/CCE.Application/Identity/Commands/AssignUserRoles/AssignUserRolesCommand.cs b/backend/src/CCE.Application/Identity/Commands/AssignUserRoles/AssignUserRolesCommand.cs index 8433fa66..6340888e 100644 --- a/backend/src/CCE.Application/Identity/Commands/AssignUserRoles/AssignUserRolesCommand.cs +++ b/backend/src/CCE.Application/Identity/Commands/AssignUserRoles/AssignUserRolesCommand.cs @@ -1,3 +1,4 @@ +using CCE.Application.Common; using CCE.Application.Identity.Dtos; using MediatR; @@ -5,9 +6,7 @@ namespace CCE.Application.Identity.Commands.AssignUserRoles; /// /// Replaces the role assignments for the user with the given set of role names. -/// User entities don't carry RowVersion; concurrency is left out by design (single-operator -/// admin tooling). Phase 1.x can revisit if multi-admin contention becomes a real risk. /// public sealed record AssignUserRolesCommand( Guid Id, - IReadOnlyList Roles) : IRequest; + IReadOnlyList Roles) : IRequest>; diff --git a/backend/src/CCE.Application/Identity/Commands/AssignUserRoles/AssignUserRolesCommandHandler.cs b/backend/src/CCE.Application/Identity/Commands/AssignUserRoles/AssignUserRolesCommandHandler.cs index 26aa27c3..fe9239eb 100644 --- a/backend/src/CCE.Application/Identity/Commands/AssignUserRoles/AssignUserRolesCommandHandler.cs +++ b/backend/src/CCE.Application/Identity/Commands/AssignUserRoles/AssignUserRolesCommandHandler.cs @@ -1,27 +1,40 @@ +using CCE.Application.Common; using CCE.Application.Identity.Dtos; using CCE.Application.Identity.Queries.GetUserById; using MediatR; namespace CCE.Application.Identity.Commands.AssignUserRoles; -public sealed class AssignUserRolesCommandHandler : IRequestHandler +public sealed class AssignUserRolesCommandHandler : IRequestHandler> { - private readonly IUserRoleAssignmentService _service; + private readonly IUserRoleAssignmentRepository _service; private readonly IMediator _mediator; + private readonly CCE.Application.Common.Errors _errors; - public AssignUserRolesCommandHandler(IUserRoleAssignmentService service, IMediator mediator) + public AssignUserRolesCommandHandler( + IUserRoleAssignmentRepository service, + IMediator mediator, + CCE.Application.Common.Errors errors) { _service = service; _mediator = mediator; + _errors = errors; } - public async Task Handle(AssignUserRolesCommand request, CancellationToken cancellationToken) + public async Task> Handle(AssignUserRolesCommand request, CancellationToken cancellationToken) { var ok = await _service.ReplaceRolesAsync(request.Id, request.Roles, cancellationToken).ConfigureAwait(false); if (!ok) { - return null; + return _errors.UserNotFound(); } - return await _mediator.Send(new GetUserByIdQuery(request.Id), cancellationToken).ConfigureAwait(false); + + var result = await _mediator.Send(new GetUserByIdQuery(request.Id), cancellationToken).ConfigureAwait(false); + if (!result.IsSuccess) + { + return result; + } + + return result.Data!; } } diff --git a/backend/src/CCE.Application/Identity/Commands/AssignUserRoles/AssignUserRolesRequest.cs b/backend/src/CCE.Application/Identity/Commands/AssignUserRoles/AssignUserRolesRequest.cs new file mode 100644 index 00000000..6d59041d --- /dev/null +++ b/backend/src/CCE.Application/Identity/Commands/AssignUserRoles/AssignUserRolesRequest.cs @@ -0,0 +1,3 @@ +namespace CCE.Application.Identity.Commands.AssignUserRoles; + +public sealed record AssignUserRolesRequest(IReadOnlyList? Roles); diff --git a/backend/src/CCE.Application/Identity/Commands/CreateStateRepAssignment/CreateStateRepAssignmentCommand.cs b/backend/src/CCE.Application/Identity/Commands/CreateStateRepAssignment/CreateStateRepAssignmentCommand.cs index bbb2caf5..4b24bc50 100644 --- a/backend/src/CCE.Application/Identity/Commands/CreateStateRepAssignment/CreateStateRepAssignmentCommand.cs +++ b/backend/src/CCE.Application/Identity/Commands/CreateStateRepAssignment/CreateStateRepAssignmentCommand.cs @@ -1,3 +1,4 @@ +using CCE.Application.Common; using CCE.Application.Identity.Dtos; using MediatR; @@ -5,4 +6,4 @@ namespace CCE.Application.Identity.Commands.CreateStateRepAssignment; public sealed record CreateStateRepAssignmentCommand( System.Guid UserId, - System.Guid CountryId) : IRequest; + System.Guid CountryId) : IRequest>; diff --git a/backend/src/CCE.Application/Identity/Commands/CreateStateRepAssignment/CreateStateRepAssignmentCommandHandler.cs b/backend/src/CCE.Application/Identity/Commands/CreateStateRepAssignment/CreateStateRepAssignmentCommandHandler.cs index ca542cd8..76e3f88f 100644 --- a/backend/src/CCE.Application/Identity/Commands/CreateStateRepAssignment/CreateStateRepAssignmentCommandHandler.cs +++ b/backend/src/CCE.Application/Identity/Commands/CreateStateRepAssignment/CreateStateRepAssignmentCommandHandler.cs @@ -1,3 +1,4 @@ +using CCE.Application.Common; using CCE.Application.Common.Interfaces; using CCE.Application.Common.Pagination; using CCE.Application.Identity.Dtos; @@ -8,26 +9,29 @@ namespace CCE.Application.Identity.Commands.CreateStateRepAssignment; public sealed class CreateStateRepAssignmentCommandHandler - : IRequestHandler + : IRequestHandler> { private readonly ICceDbContext _db; - private readonly IStateRepAssignmentService _service; + private readonly IStateRepAssignmentRepository _service; private readonly ICurrentUserAccessor _currentUser; private readonly ISystemClock _clock; + private readonly CCE.Application.Common.Errors _errors; public CreateStateRepAssignmentCommandHandler( ICceDbContext db, - IStateRepAssignmentService service, + IStateRepAssignmentRepository service, ICurrentUserAccessor currentUser, - ISystemClock clock) + ISystemClock clock, + CCE.Application.Common.Errors errors) { _db = db; _service = service; _currentUser = currentUser; _clock = clock; + _errors = errors; } - public async Task Handle( + public async Task> Handle( CreateStateRepAssignmentCommand request, CancellationToken cancellationToken) { @@ -35,20 +39,23 @@ public async Task Handle( var userExists = await ExistsAsync(_db.Users.Where(u => u.Id == request.UserId), cancellationToken).ConfigureAwait(false); if (!userExists) { - throw new System.Collections.Generic.KeyNotFoundException($"User {request.UserId} not found."); + return _errors.UserNotFound(); } // Verify country exists. var countryExists = await ExistsAsync(_db.Countries.Where(c => c.Id == request.CountryId), cancellationToken).ConfigureAwait(false); if (!countryExists) { - throw new System.Collections.Generic.KeyNotFoundException($"Country {request.CountryId} not found."); + return _errors.CountryNotFound(); } - var assignedById = _currentUser.GetUserId() - ?? throw new DomainException("Cannot create state-rep assignment from a request without a user identity."); + var assignedById = _currentUser.GetUserId(); + if (assignedById is null) + { + return _errors.NotAuthenticated(); + } - var assignment = StateRepresentativeAssignment.Assign(request.UserId, request.CountryId, assignedById, _clock); + var assignment = StateRepresentativeAssignment.Assign(request.UserId, request.CountryId, assignedById.Value, _clock); await _service.SaveAsync(assignment, cancellationToken).ConfigureAwait(false); // Build the DTO — look up UserName for the assigned user. diff --git a/backend/src/CCE.Application/Identity/Commands/CreateStateRepAssignment/CreateStateRepAssignmentRequest.cs b/backend/src/CCE.Application/Identity/Commands/CreateStateRepAssignment/CreateStateRepAssignmentRequest.cs new file mode 100644 index 00000000..9cb6647b --- /dev/null +++ b/backend/src/CCE.Application/Identity/Commands/CreateStateRepAssignment/CreateStateRepAssignmentRequest.cs @@ -0,0 +1,3 @@ +namespace CCE.Application.Identity.Commands.CreateStateRepAssignment; + +public sealed record CreateStateRepAssignmentRequest(System.Guid UserId, System.Guid CountryId); diff --git a/backend/src/CCE.Application/Identity/Commands/RejectExpertRequest/RejectExpertRequestCommand.cs b/backend/src/CCE.Application/Identity/Commands/RejectExpertRequest/RejectExpertRequestCommand.cs index 9d0df6db..9a209337 100644 --- a/backend/src/CCE.Application/Identity/Commands/RejectExpertRequest/RejectExpertRequestCommand.cs +++ b/backend/src/CCE.Application/Identity/Commands/RejectExpertRequest/RejectExpertRequestCommand.cs @@ -1,3 +1,4 @@ +using CCE.Application.Common; using CCE.Application.Identity.Dtos; using MediatR; @@ -6,4 +7,4 @@ namespace CCE.Application.Identity.Commands.RejectExpertRequest; public sealed record RejectExpertRequestCommand( System.Guid Id, string RejectionReasonAr, - string RejectionReasonEn) : IRequest; + string RejectionReasonEn) : IRequest>; diff --git a/backend/src/CCE.Application/Identity/Commands/RejectExpertRequest/RejectExpertRequestCommandHandler.cs b/backend/src/CCE.Application/Identity/Commands/RejectExpertRequest/RejectExpertRequestCommandHandler.cs index 84e7c5f4..31d19d3a 100644 --- a/backend/src/CCE.Application/Identity/Commands/RejectExpertRequest/RejectExpertRequestCommandHandler.cs +++ b/backend/src/CCE.Application/Identity/Commands/RejectExpertRequest/RejectExpertRequestCommandHandler.cs @@ -1,6 +1,6 @@ +using CCE.Application.Common; using CCE.Application.Common.Interfaces; using CCE.Application.Common.Pagination; -using CCE.Application.Identity; using CCE.Application.Identity.Dtos; using CCE.Domain.Common; using MediatR; @@ -8,39 +8,45 @@ namespace CCE.Application.Identity.Commands.RejectExpertRequest; public sealed class RejectExpertRequestCommandHandler - : IRequestHandler + : IRequestHandler> { - private readonly IExpertWorkflowService _service; + private readonly IExpertWorkflowRepository _service; private readonly ICceDbContext _db; private readonly ICurrentUserAccessor _currentUser; private readonly ISystemClock _clock; + private readonly CCE.Application.Common.Errors _errors; public RejectExpertRequestCommandHandler( - IExpertWorkflowService service, + IExpertWorkflowRepository service, ICceDbContext db, ICurrentUserAccessor currentUser, - ISystemClock clock) + ISystemClock clock, + CCE.Application.Common.Errors errors) { _service = service; _db = db; _currentUser = currentUser; _clock = clock; + _errors = errors; } - public async Task Handle( + public async Task> Handle( RejectExpertRequestCommand request, CancellationToken cancellationToken) { var registration = await _service.FindIncludingDeletedAsync(request.Id, cancellationToken).ConfigureAwait(false); if (registration is null) { - throw new System.Collections.Generic.KeyNotFoundException($"Expert registration request {request.Id} not found."); + return _errors.ExpertRequestNotFound(); } - var rejectedById = _currentUser.GetUserId() - ?? throw new DomainException("Cannot reject an expert request from a request without a user identity."); + var rejectedById = _currentUser.GetUserId(); + if (rejectedById is null) + { + return _errors.NotAuthenticated(); + } - registration.Reject(rejectedById, request.RejectionReasonAr, request.RejectionReasonEn, _clock); + registration.Reject(rejectedById.Value, request.RejectionReasonAr, request.RejectionReasonEn, _clock); await _service.SaveAsync(registration, newProfile: null, cancellationToken).ConfigureAwait(false); var userName = (await _db.Users.Where(u => u.Id == registration.RequestedById).Select(u => u.UserName) diff --git a/backend/src/CCE.Application/Identity/Commands/RejectExpertRequest/RejectExpertRequestRequest.cs b/backend/src/CCE.Application/Identity/Commands/RejectExpertRequest/RejectExpertRequestRequest.cs new file mode 100644 index 00000000..3c2fafa3 --- /dev/null +++ b/backend/src/CCE.Application/Identity/Commands/RejectExpertRequest/RejectExpertRequestRequest.cs @@ -0,0 +1,3 @@ +namespace CCE.Application.Identity.Commands.RejectExpertRequest; + +public sealed record RejectExpertRequestRequest(string RejectionReasonAr, string RejectionReasonEn); diff --git a/backend/src/CCE.Application/Identity/Commands/RevokeStateRepAssignment/RevokeStateRepAssignmentCommand.cs b/backend/src/CCE.Application/Identity/Commands/RevokeStateRepAssignment/RevokeStateRepAssignmentCommand.cs index 587280d3..ec6ad513 100644 --- a/backend/src/CCE.Application/Identity/Commands/RevokeStateRepAssignment/RevokeStateRepAssignmentCommand.cs +++ b/backend/src/CCE.Application/Identity/Commands/RevokeStateRepAssignment/RevokeStateRepAssignmentCommand.cs @@ -1,9 +1,10 @@ +using CCE.Application.Common; using MediatR; namespace CCE.Application.Identity.Commands.RevokeStateRepAssignment; /// /// Revokes (soft-deletes) the given state-rep assignment. -/// Returns Unit; the endpoint maps that to HTTP 204 No Content. +/// Returns so the endpoint can map to HTTP 204. /// -public sealed record RevokeStateRepAssignmentCommand(System.Guid Id) : IRequest; +public sealed record RevokeStateRepAssignmentCommand(System.Guid Id) : IRequest>; diff --git a/backend/src/CCE.Application/Identity/Commands/RevokeStateRepAssignment/RevokeStateRepAssignmentCommandHandler.cs b/backend/src/CCE.Application/Identity/Commands/RevokeStateRepAssignment/RevokeStateRepAssignmentCommandHandler.cs index 6f4d58bc..06468088 100644 --- a/backend/src/CCE.Application/Identity/Commands/RevokeStateRepAssignment/RevokeStateRepAssignmentCommandHandler.cs +++ b/backend/src/CCE.Application/Identity/Commands/RevokeStateRepAssignment/RevokeStateRepAssignmentCommandHandler.cs @@ -1,40 +1,46 @@ +using CCE.Application.Common; using CCE.Application.Common.Interfaces; -using CCE.Application.Identity; using CCE.Domain.Common; using MediatR; namespace CCE.Application.Identity.Commands.RevokeStateRepAssignment; -public sealed class RevokeStateRepAssignmentCommandHandler : IRequestHandler +public sealed class RevokeStateRepAssignmentCommandHandler : IRequestHandler> { - private readonly IStateRepAssignmentService _service; + private readonly IStateRepAssignmentRepository _service; private readonly ICurrentUserAccessor _currentUser; private readonly ISystemClock _clock; + private readonly CCE.Application.Common.Errors _errors; public RevokeStateRepAssignmentCommandHandler( - IStateRepAssignmentService service, + IStateRepAssignmentRepository service, ICurrentUserAccessor currentUser, - ISystemClock clock) + ISystemClock clock, + CCE.Application.Common.Errors errors) { _service = service; _currentUser = currentUser; _clock = clock; + _errors = errors; } - public async Task Handle(RevokeStateRepAssignmentCommand request, CancellationToken cancellationToken) + public async Task> Handle(RevokeStateRepAssignmentCommand request, CancellationToken cancellationToken) { var assignment = await _service.FindIncludingRevokedAsync(request.Id, cancellationToken).ConfigureAwait(false); if (assignment is null) { - throw new System.Collections.Generic.KeyNotFoundException($"State-rep assignment {request.Id} not found."); + return _errors.StateRepAssignmentNotFound(); } - var revokedById = _currentUser.GetUserId() - ?? throw new DomainException("Cannot revoke state-rep assignment from a request without a user identity."); + var revokedById = _currentUser.GetUserId(); + if (revokedById is null) + { + return _errors.NotAuthenticated(); + } - assignment.Revoke(revokedById, _clock); + assignment.Revoke(revokedById.Value, _clock); await _service.UpdateAsync(assignment, cancellationToken).ConfigureAwait(false); - return Unit.Value; + return Result.Success(); } } diff --git a/backend/src/CCE.Application/Identity/IExpertWorkflowService.cs b/backend/src/CCE.Application/Identity/IExpertWorkflowRepository.cs similarity index 95% rename from backend/src/CCE.Application/Identity/IExpertWorkflowService.cs rename to backend/src/CCE.Application/Identity/IExpertWorkflowRepository.cs index 662dcc4d..50154f45 100644 --- a/backend/src/CCE.Application/Identity/IExpertWorkflowService.cs +++ b/backend/src/CCE.Application/Identity/IExpertWorkflowRepository.cs @@ -6,7 +6,7 @@ namespace CCE.Application.Identity; /// Persistence helper for the expert-registration workflow. Implemented in Infrastructure /// (writes via CceDbContext); handlers stay clear of EF tracker calls. /// -public interface IExpertWorkflowService +public interface IExpertWorkflowRepository { /// /// Loads the request by Id, including soft-deleted rows. Returns null when missing. diff --git a/backend/src/CCE.Application/Identity/IStateRepAssignmentService.cs b/backend/src/CCE.Application/Identity/IStateRepAssignmentRepository.cs similarity index 96% rename from backend/src/CCE.Application/Identity/IStateRepAssignmentService.cs rename to backend/src/CCE.Application/Identity/IStateRepAssignmentRepository.cs index ef6744b8..9b220f8c 100644 --- a/backend/src/CCE.Application/Identity/IStateRepAssignmentService.cs +++ b/backend/src/CCE.Application/Identity/IStateRepAssignmentRepository.cs @@ -6,7 +6,7 @@ namespace CCE.Application.Identity; /// Persists new aggregates and revokes existing ones. /// Implemented in Infrastructure (writes via CceDbContext). /// -public interface IStateRepAssignmentService +public interface IStateRepAssignmentRepository { /// /// Persists the provided assignment. Caller is responsible for constructing it via diff --git a/backend/src/CCE.Application/Identity/IUserRoleAssignmentService.cs b/backend/src/CCE.Application/Identity/IUserRoleAssignmentRepository.cs similarity index 94% rename from backend/src/CCE.Application/Identity/IUserRoleAssignmentService.cs rename to backend/src/CCE.Application/Identity/IUserRoleAssignmentRepository.cs index 3ca3e9d3..02f6b348 100644 --- a/backend/src/CCE.Application/Identity/IUserRoleAssignmentService.cs +++ b/backend/src/CCE.Application/Identity/IUserRoleAssignmentRepository.cs @@ -5,7 +5,7 @@ namespace CCE.Application.Identity; /// Implemented in Infrastructure (writes via CceDbContext); handlers /// stay clear of EF tracker calls. /// -public interface IUserRoleAssignmentService +public interface IUserRoleAssignmentRepository { /// /// Replaces user 's role assignments. diff --git a/backend/src/CCE.Application/Identity/IUserSyncService.cs b/backend/src/CCE.Application/Identity/IUserSyncRepository.cs similarity index 92% rename from backend/src/CCE.Application/Identity/IUserSyncService.cs rename to backend/src/CCE.Application/Identity/IUserSyncRepository.cs index fb8e525e..91450796 100644 --- a/backend/src/CCE.Application/Identity/IUserSyncService.cs +++ b/backend/src/CCE.Application/Identity/IUserSyncRepository.cs @@ -5,7 +5,7 @@ namespace CCE.Application.Identity; /// role assignments derived from groups claims if missing. /// Implemented in Infrastructure (writes via CceDbContext). /// -public interface IUserSyncService +public interface IUserSyncRepository { Task EnsureUserExistsAsync( Guid userId, diff --git a/backend/src/CCE.Application/Identity/Public/Commands/SubmitExpertRequest/SubmitExpertRequestCommand.cs b/backend/src/CCE.Application/Identity/Public/Commands/SubmitExpertRequest/SubmitExpertRequestCommand.cs index 18bee210..b5c76434 100644 --- a/backend/src/CCE.Application/Identity/Public/Commands/SubmitExpertRequest/SubmitExpertRequestCommand.cs +++ b/backend/src/CCE.Application/Identity/Public/Commands/SubmitExpertRequest/SubmitExpertRequestCommand.cs @@ -1,3 +1,4 @@ +using CCE.Application.Common; using CCE.Application.Identity.Public.Dtos; using MediatR; @@ -7,4 +8,4 @@ public sealed record SubmitExpertRequestCommand( System.Guid RequesterId, string RequestedBioAr, string RequestedBioEn, - IReadOnlyList RequestedTags) : IRequest; + IReadOnlyList RequestedTags) : IRequest>; diff --git a/backend/src/CCE.Application/Identity/Public/Commands/SubmitExpertRequest/SubmitExpertRequestCommandHandler.cs b/backend/src/CCE.Application/Identity/Public/Commands/SubmitExpertRequest/SubmitExpertRequestCommandHandler.cs index 5cccc37d..adfa8585 100644 --- a/backend/src/CCE.Application/Identity/Public/Commands/SubmitExpertRequest/SubmitExpertRequestCommandHandler.cs +++ b/backend/src/CCE.Application/Identity/Public/Commands/SubmitExpertRequest/SubmitExpertRequestCommandHandler.cs @@ -1,3 +1,4 @@ +using CCE.Application.Common; using CCE.Application.Identity.Public.Dtos; using CCE.Domain.Common; using CCE.Domain.Identity; @@ -6,18 +7,18 @@ namespace CCE.Application.Identity.Public.Commands.SubmitExpertRequest; public sealed class SubmitExpertRequestCommandHandler - : IRequestHandler + : IRequestHandler> { - private readonly IExpertRequestSubmissionService _service; + private readonly IExpertRequestSubmissionRepository _service; private readonly ISystemClock _clock; - public SubmitExpertRequestCommandHandler(IExpertRequestSubmissionService service, ISystemClock clock) + public SubmitExpertRequestCommandHandler(IExpertRequestSubmissionRepository service, ISystemClock clock) { _service = service; _clock = clock; } - public async Task Handle(SubmitExpertRequestCommand request, CancellationToken cancellationToken) + public async Task> Handle(SubmitExpertRequestCommand request, CancellationToken cancellationToken) { var entity = ExpertRegistrationRequest.Submit( request.RequesterId, diff --git a/backend/src/CCE.Application/Identity/Public/Commands/SubmitExpertRequest/SubmitExpertRequestRequest.cs b/backend/src/CCE.Application/Identity/Public/Commands/SubmitExpertRequest/SubmitExpertRequestRequest.cs new file mode 100644 index 00000000..9beb6b89 --- /dev/null +++ b/backend/src/CCE.Application/Identity/Public/Commands/SubmitExpertRequest/SubmitExpertRequestRequest.cs @@ -0,0 +1,6 @@ +namespace CCE.Application.Identity.Public.Commands.SubmitExpertRequest; + +public sealed record SubmitExpertRequestRequest( + string RequestedBioAr, + string RequestedBioEn, + IReadOnlyList? RequestedTags); diff --git a/backend/src/CCE.Application/Identity/Public/Commands/UpdateMyProfile/UpdateMyProfileCommand.cs b/backend/src/CCE.Application/Identity/Public/Commands/UpdateMyProfile/UpdateMyProfileCommand.cs index 5a275118..30b9a74d 100644 --- a/backend/src/CCE.Application/Identity/Public/Commands/UpdateMyProfile/UpdateMyProfileCommand.cs +++ b/backend/src/CCE.Application/Identity/Public/Commands/UpdateMyProfile/UpdateMyProfileCommand.cs @@ -1,3 +1,4 @@ +using CCE.Application.Common; using CCE.Application.Identity.Public.Dtos; using CCE.Domain.Identity; using MediatR; @@ -10,4 +11,4 @@ public sealed record UpdateMyProfileCommand( KnowledgeLevel KnowledgeLevel, IReadOnlyList Interests, string? AvatarUrl, - System.Guid? CountryId) : IRequest; + System.Guid? CountryId) : IRequest>; diff --git a/backend/src/CCE.Application/Identity/Public/Commands/UpdateMyProfile/UpdateMyProfileCommandHandler.cs b/backend/src/CCE.Application/Identity/Public/Commands/UpdateMyProfile/UpdateMyProfileCommandHandler.cs index 5ae03086..e991f28a 100644 --- a/backend/src/CCE.Application/Identity/Public/Commands/UpdateMyProfile/UpdateMyProfileCommandHandler.cs +++ b/backend/src/CCE.Application/Identity/Public/Commands/UpdateMyProfile/UpdateMyProfileCommandHandler.cs @@ -1,23 +1,26 @@ +using CCE.Application.Common; using CCE.Application.Identity.Public.Dtos; using MediatR; namespace CCE.Application.Identity.Public.Commands.UpdateMyProfile; -public sealed class UpdateMyProfileCommandHandler : IRequestHandler +public sealed class UpdateMyProfileCommandHandler : IRequestHandler> { - private readonly IUserProfileService _service; + private readonly IUserProfileRepository _service; + private readonly CCE.Application.Common.Errors _errors; - public UpdateMyProfileCommandHandler(IUserProfileService service) + public UpdateMyProfileCommandHandler(IUserProfileRepository service, CCE.Application.Common.Errors errors) { _service = service; + _errors = errors; } - public async Task Handle(UpdateMyProfileCommand request, CancellationToken cancellationToken) + public async Task> Handle(UpdateMyProfileCommand request, CancellationToken cancellationToken) { var user = await _service.FindAsync(request.UserId, cancellationToken).ConfigureAwait(false); if (user is null) { - return null; + return _errors.UserNotFound(); } user.SetLocalePreference(request.LocalePreference); diff --git a/backend/src/CCE.Application/Identity/Public/Commands/UpdateMyProfile/UpdateMyProfileRequest.cs b/backend/src/CCE.Application/Identity/Public/Commands/UpdateMyProfile/UpdateMyProfileRequest.cs new file mode 100644 index 00000000..b7d47780 --- /dev/null +++ b/backend/src/CCE.Application/Identity/Public/Commands/UpdateMyProfile/UpdateMyProfileRequest.cs @@ -0,0 +1,8 @@ +namespace CCE.Application.Identity.Public.Commands.UpdateMyProfile; + +public sealed record UpdateMyProfileRequest( + string LocalePreference, + Domain.Identity.KnowledgeLevel KnowledgeLevel, + IReadOnlyList? Interests, + string? AvatarUrl, + System.Guid? CountryId); diff --git a/backend/src/CCE.Application/Identity/Public/IExpertRequestSubmissionService.cs b/backend/src/CCE.Application/Identity/Public/IExpertRequestSubmissionRepository.cs similarity index 74% rename from backend/src/CCE.Application/Identity/Public/IExpertRequestSubmissionService.cs rename to backend/src/CCE.Application/Identity/Public/IExpertRequestSubmissionRepository.cs index 84eeb8a9..13678af7 100644 --- a/backend/src/CCE.Application/Identity/Public/IExpertRequestSubmissionService.cs +++ b/backend/src/CCE.Application/Identity/Public/IExpertRequestSubmissionRepository.cs @@ -2,7 +2,7 @@ namespace CCE.Application.Identity.Public; -public interface IExpertRequestSubmissionService +public interface IExpertRequestSubmissionRepository { Task SaveAsync(ExpertRegistrationRequest request, CancellationToken ct); } diff --git a/backend/src/CCE.Application/Identity/Public/IUserProfileService.cs b/backend/src/CCE.Application/Identity/Public/IUserProfileRepository.cs similarity index 83% rename from backend/src/CCE.Application/Identity/Public/IUserProfileService.cs rename to backend/src/CCE.Application/Identity/Public/IUserProfileRepository.cs index 7146370f..d3dd5394 100644 --- a/backend/src/CCE.Application/Identity/Public/IUserProfileService.cs +++ b/backend/src/CCE.Application/Identity/Public/IUserProfileRepository.cs @@ -2,7 +2,7 @@ namespace CCE.Application.Identity.Public; -public interface IUserProfileService +public interface IUserProfileRepository { Task FindAsync(System.Guid userId, CancellationToken ct); Task UpdateAsync(User user, CancellationToken ct); diff --git a/backend/src/CCE.Application/Identity/Public/Queries/GetMyExpertStatus/GetMyExpertStatusQuery.cs b/backend/src/CCE.Application/Identity/Public/Queries/GetMyExpertStatus/GetMyExpertStatusQuery.cs index f8f2e4f4..9ab7968a 100644 --- a/backend/src/CCE.Application/Identity/Public/Queries/GetMyExpertStatus/GetMyExpertStatusQuery.cs +++ b/backend/src/CCE.Application/Identity/Public/Queries/GetMyExpertStatus/GetMyExpertStatusQuery.cs @@ -1,6 +1,7 @@ +using CCE.Application.Common; using CCE.Application.Identity.Public.Dtos; using MediatR; namespace CCE.Application.Identity.Public.Queries.GetMyExpertStatus; -public sealed record GetMyExpertStatusQuery(System.Guid UserId) : IRequest; +public sealed record GetMyExpertStatusQuery(System.Guid UserId) : IRequest>; diff --git a/backend/src/CCE.Application/Identity/Public/Queries/GetMyExpertStatus/GetMyExpertStatusQueryHandler.cs b/backend/src/CCE.Application/Identity/Public/Queries/GetMyExpertStatus/GetMyExpertStatusQueryHandler.cs index e2cc8746..8e13007a 100644 --- a/backend/src/CCE.Application/Identity/Public/Queries/GetMyExpertStatus/GetMyExpertStatusQueryHandler.cs +++ b/backend/src/CCE.Application/Identity/Public/Queries/GetMyExpertStatus/GetMyExpertStatusQueryHandler.cs @@ -1,3 +1,4 @@ +using CCE.Application.Common; using CCE.Application.Common.Interfaces; using CCE.Application.Common.Pagination; using CCE.Application.Identity.Public.Dtos; @@ -5,20 +6,21 @@ namespace CCE.Application.Identity.Public.Queries.GetMyExpertStatus; -public sealed class GetMyExpertStatusQueryHandler : IRequestHandler +public sealed class GetMyExpertStatusQueryHandler : IRequestHandler> { private readonly ICceDbContext _db; + private readonly CCE.Application.Common.Errors _errors; - public GetMyExpertStatusQueryHandler(ICceDbContext db) + public GetMyExpertStatusQueryHandler(ICceDbContext db, CCE.Application.Common.Errors errors) { _db = db; + _errors = errors; } - public async Task Handle(GetMyExpertStatusQuery request, CancellationToken cancellationToken) + public async Task> Handle(GetMyExpertStatusQuery request, CancellationToken cancellationToken) { - var userId = request.UserId; var rows = await _db.ExpertRegistrationRequests - .Where(r => r.RequestedById == userId) + .Where(r => r.RequestedById == request.UserId) .OrderByDescending(r => r.SubmittedOn) .Take(1) .ToListAsyncEither(cancellationToken) @@ -27,7 +29,7 @@ public GetMyExpertStatusQueryHandler(ICceDbContext db) var entity = rows.FirstOrDefault(); if (entity is null) { - return null; + return _errors.ExpertRequestNotFound(); } return new ExpertRequestStatusDto( diff --git a/backend/src/CCE.Application/Identity/Public/Queries/GetMyProfile/GetMyProfileQuery.cs b/backend/src/CCE.Application/Identity/Public/Queries/GetMyProfile/GetMyProfileQuery.cs index 4c289dd6..836203e6 100644 --- a/backend/src/CCE.Application/Identity/Public/Queries/GetMyProfile/GetMyProfileQuery.cs +++ b/backend/src/CCE.Application/Identity/Public/Queries/GetMyProfile/GetMyProfileQuery.cs @@ -1,6 +1,7 @@ +using CCE.Application.Common; using CCE.Application.Identity.Public.Dtos; using MediatR; namespace CCE.Application.Identity.Public.Queries.GetMyProfile; -public sealed record GetMyProfileQuery(System.Guid UserId) : IRequest; +public sealed record GetMyProfileQuery(System.Guid UserId) : IRequest>; diff --git a/backend/src/CCE.Application/Identity/Public/Queries/GetMyProfile/GetMyProfileQueryHandler.cs b/backend/src/CCE.Application/Identity/Public/Queries/GetMyProfile/GetMyProfileQueryHandler.cs index 0c3ca3fd..4062da26 100644 --- a/backend/src/CCE.Application/Identity/Public/Queries/GetMyProfile/GetMyProfileQueryHandler.cs +++ b/backend/src/CCE.Application/Identity/Public/Queries/GetMyProfile/GetMyProfileQueryHandler.cs @@ -1,23 +1,26 @@ +using CCE.Application.Common; using CCE.Application.Identity.Public.Dtos; using MediatR; namespace CCE.Application.Identity.Public.Queries.GetMyProfile; -public sealed class GetMyProfileQueryHandler : IRequestHandler +public sealed class GetMyProfileQueryHandler : IRequestHandler> { - private readonly IUserProfileService _service; + private readonly IUserProfileRepository _service; + private readonly CCE.Application.Common.Errors _errors; - public GetMyProfileQueryHandler(IUserProfileService service) + public GetMyProfileQueryHandler(IUserProfileRepository service, CCE.Application.Common.Errors errors) { _service = service; + _errors = errors; } - public async Task Handle(GetMyProfileQuery request, CancellationToken cancellationToken) + public async Task> Handle(GetMyProfileQuery request, CancellationToken cancellationToken) { var user = await _service.FindAsync(request.UserId, cancellationToken).ConfigureAwait(false); if (user is null) { - return null; + return _errors.UserNotFound(); } return new UserProfileDto( diff --git a/backend/src/CCE.Application/Identity/Public/RegisterUserContracts.cs b/backend/src/CCE.Application/Identity/Public/RegisterUserContracts.cs new file mode 100644 index 00000000..e185a716 --- /dev/null +++ b/backend/src/CCE.Application/Identity/Public/RegisterUserContracts.cs @@ -0,0 +1,12 @@ +namespace CCE.Application.Identity.Public; + +public sealed record RegisterUserRequest( + string GivenName, + string Surname, + string Email, + string MailNickname); + +public sealed record RegisterUserResponse( + System.Guid EntraIdObjectId, + string UserPrincipalName, + string DisplayName); diff --git a/backend/src/CCE.Application/Identity/Queries/GetUserById/GetUserByIdQuery.cs b/backend/src/CCE.Application/Identity/Queries/GetUserById/GetUserByIdQuery.cs index ce8392a6..35c0cac9 100644 --- a/backend/src/CCE.Application/Identity/Queries/GetUserById/GetUserByIdQuery.cs +++ b/backend/src/CCE.Application/Identity/Queries/GetUserById/GetUserByIdQuery.cs @@ -1,9 +1,11 @@ +using CCE.Application.Common; using CCE.Application.Identity.Dtos; using MediatR; namespace CCE.Application.Identity.Queries.GetUserById; /// -/// Loads a single user by Id. Returns null when not found (endpoint maps null → 404). +/// Loads a single user by Id. Returns so the endpoint +/// can map failure to a localized 404 automatically. /// -public sealed record GetUserByIdQuery(System.Guid Id) : IRequest; +public sealed record GetUserByIdQuery(System.Guid Id) : IRequest>; diff --git a/backend/src/CCE.Application/Identity/Queries/GetUserById/GetUserByIdQueryHandler.cs b/backend/src/CCE.Application/Identity/Queries/GetUserById/GetUserByIdQueryHandler.cs index 849dbd8e..d5ef567d 100644 --- a/backend/src/CCE.Application/Identity/Queries/GetUserById/GetUserByIdQueryHandler.cs +++ b/backend/src/CCE.Application/Identity/Queries/GetUserById/GetUserByIdQueryHandler.cs @@ -1,3 +1,4 @@ +using CCE.Application.Common; using CCE.Application.Common.Interfaces; using CCE.Application.Common.Pagination; using CCE.Application.Identity.Dtos; @@ -5,22 +6,24 @@ namespace CCE.Application.Identity.Queries.GetUserById; -public sealed class GetUserByIdQueryHandler : IRequestHandler +public sealed class GetUserByIdQueryHandler : IRequestHandler> { private readonly ICceDbContext _db; + private readonly CCE.Application.Common.Errors _errors; - public GetUserByIdQueryHandler(ICceDbContext db) + public GetUserByIdQueryHandler(ICceDbContext db, CCE.Application.Common.Errors errors) { _db = db; + _errors = errors; } - public async Task Handle(GetUserByIdQuery request, CancellationToken cancellationToken) + public async Task> Handle(GetUserByIdQuery request, CancellationToken cancellationToken) { var user = (await _db.Users.Where(u => u.Id == request.Id).ToListAsyncEither(cancellationToken).ConfigureAwait(false)) .SingleOrDefault(); if (user is null) { - return null; + return _errors.UserNotFound(); } var roleNames = @@ -30,7 +33,7 @@ join r in _db.Roles on ur.RoleId equals r.Id select r.Name!; var roles = await roleNames.ToListAsyncEither(cancellationToken).ConfigureAwait(false); - var now = System.DateTimeOffset.UtcNow; + var now = DateTimeOffset.UtcNow; var isActive = !user.LockoutEnabled || user.LockoutEnd is null || user.LockoutEnd < now; return new UserDetailDto( diff --git a/backend/src/CCE.Application/Identity/Queries/ListExpertProfiles/ListExpertProfilesQueryHandler.cs b/backend/src/CCE.Application/Identity/Queries/ListExpertProfiles/ListExpertProfilesQueryHandler.cs index 0768ce1e..dfa3de8f 100644 --- a/backend/src/CCE.Application/Identity/Queries/ListExpertProfiles/ListExpertProfilesQueryHandler.cs +++ b/backend/src/CCE.Application/Identity/Queries/ListExpertProfiles/ListExpertProfilesQueryHandler.cs @@ -1,7 +1,6 @@ using CCE.Application.Common.Interfaces; using CCE.Application.Common.Pagination; using CCE.Application.Identity.Dtos; -using CCE.Domain.Identity; using MediatR; namespace CCE.Application.Identity.Queries.ListExpertProfiles; @@ -11,16 +10,13 @@ public sealed class ListExpertProfilesQueryHandler { private readonly ICceDbContext _db; - public ListExpertProfilesQueryHandler(ICceDbContext db) - { - _db = db; - } + public ListExpertProfilesQueryHandler(ICceDbContext db) => _db = db; public async Task> Handle( ListExpertProfilesQuery request, CancellationToken cancellationToken) { - IQueryable query = _db.ExpertProfiles; + IQueryable query = _db.ExpertProfiles; if (!string.IsNullOrWhiteSpace(request.Search)) { @@ -34,17 +30,15 @@ join u in _db.Users on p.UserId equals u.Id query = query.OrderByDescending(p => p.ApprovedOn); - var page = await query.ToPagedResultAsync(request.Page, request.PageSize, cancellationToken) - .ConfigureAwait(false); + var paged = await query.ToPagedResultAsync(request.Page, request.PageSize, cancellationToken).ConfigureAwait(false); - if (page.Items.Count == 0) + if (paged.Items.Count == 0) { return new PagedResult( - System.Array.Empty(), - page.Page, page.PageSize, page.Total); + Array.Empty(), paged.Page, paged.PageSize, paged.Total); } - var userIds = page.Items.Select(p => p.UserId).Distinct().ToList(); + var userIds = paged.Items.Select(p => p.UserId).Distinct().ToList(); var userNamesQuery = from u in _db.Users where userIds.Contains(u.Id) @@ -52,7 +46,7 @@ where userIds.Contains(u.Id) var userNameRows = await userNamesQuery.ToListAsyncEither(cancellationToken).ConfigureAwait(false); var nameByUserId = userNameRows.ToDictionary(r => r.UserId, r => r.UserName); - var items = page.Items.Select(p => new ExpertProfileDto( + var items = paged.Items.Select(p => new ExpertProfileDto( p.Id, p.UserId, nameByUserId.TryGetValue(p.UserId, out var name) ? name : null, @@ -64,8 +58,8 @@ where userIds.Contains(u.Id) p.ApprovedOn, p.ApprovedById)).ToList(); - return new PagedResult(items, page.Page, page.PageSize, page.Total); + return new PagedResult(items, paged.Page, paged.PageSize, paged.Total); } - private sealed record UserNameRow(System.Guid UserId, string? UserName); + private sealed record UserNameRow(Guid UserId, string? UserName); } diff --git a/backend/src/CCE.Application/Identity/Queries/ListExpertRequests/ListExpertRequestsQueryHandler.cs b/backend/src/CCE.Application/Identity/Queries/ListExpertRequests/ListExpertRequestsQueryHandler.cs index 3e1b7658..00c06a03 100644 --- a/backend/src/CCE.Application/Identity/Queries/ListExpertRequests/ListExpertRequestsQueryHandler.cs +++ b/backend/src/CCE.Application/Identity/Queries/ListExpertRequests/ListExpertRequestsQueryHandler.cs @@ -11,37 +11,32 @@ public sealed class ListExpertRequestsQueryHandler { private readonly ICceDbContext _db; - public ListExpertRequestsQueryHandler(ICceDbContext db) - { - _db = db; - } + public ListExpertRequestsQueryHandler(ICceDbContext db) => _db = db; public async Task> Handle( ListExpertRequestsQuery request, CancellationToken cancellationToken) { var query = _db.ExpertRegistrationRequests.AsQueryable(); - if (request.Status is { } status) + if (request.Status is not null) { - query = query.Where(r => r.Status == status); + query = query.Where(r => r.Status == request.Status.Value); } - if (request.RequestedById is { } requestedById) + if (request.RequestedById is not null) { - query = query.Where(r => r.RequestedById == requestedById); + query = query.Where(r => r.RequestedById == request.RequestedById.Value); } query = query.OrderByDescending(r => r.SubmittedOn); - var page = await query.ToPagedResultAsync(request.Page, request.PageSize, cancellationToken) - .ConfigureAwait(false); + var paged = await query.ToPagedResultAsync(request.Page, request.PageSize, cancellationToken).ConfigureAwait(false); - if (page.Items.Count == 0) + if (paged.Items.Count == 0) { return new PagedResult( - System.Array.Empty(), - page.Page, page.PageSize, page.Total); + Array.Empty(), paged.Page, paged.PageSize, paged.Total); } - var requesterIds = page.Items.Select(r => r.RequestedById).Distinct().ToList(); + var requesterIds = paged.Items.Select(r => r.RequestedById).Distinct().ToList(); var userNamesQuery = from u in _db.Users where requesterIds.Contains(u.Id) @@ -49,7 +44,7 @@ where requesterIds.Contains(u.Id) var userNameRows = await userNamesQuery.ToListAsyncEither(cancellationToken).ConfigureAwait(false); var nameByUserId = userNameRows.ToDictionary(r => r.UserId, r => r.UserName); - var items = page.Items.Select(r => new ExpertRequestDto( + var items = paged.Items.Select(r => new ExpertRequestDto( r.Id, r.RequestedById, nameByUserId.TryGetValue(r.RequestedById, out var name) ? name : null, @@ -63,8 +58,8 @@ where requesterIds.Contains(u.Id) r.RejectionReasonAr, r.RejectionReasonEn)).ToList(); - return new PagedResult(items, page.Page, page.PageSize, page.Total); + return new PagedResult(items, paged.Page, paged.PageSize, paged.Total); } - private sealed record UserNameRow(System.Guid UserId, string? UserName); + private sealed record UserNameRow(Guid UserId, string? UserName); } diff --git a/backend/src/CCE.Application/Identity/Queries/ListStateRepAssignments/ListStateRepAssignmentsQueryHandler.cs b/backend/src/CCE.Application/Identity/Queries/ListStateRepAssignments/ListStateRepAssignmentsQueryHandler.cs index 1b3c5407..16e1a209 100644 --- a/backend/src/CCE.Application/Identity/Queries/ListStateRepAssignments/ListStateRepAssignmentsQueryHandler.cs +++ b/backend/src/CCE.Application/Identity/Queries/ListStateRepAssignments/ListStateRepAssignmentsQueryHandler.cs @@ -11,10 +11,7 @@ public sealed class ListStateRepAssignmentsQueryHandler { private readonly ICceDbContext _db; - public ListStateRepAssignmentsQueryHandler(ICceDbContext db) - { - _db = db; - } + public ListStateRepAssignmentsQueryHandler(ICceDbContext db) => _db = db; public async Task> Handle( ListStateRepAssignmentsQuery request, @@ -24,36 +21,34 @@ public async Task> Handle( ? _db.StateRepresentativeAssignments : _db.StateRepresentativeAssignments.WithoutSoftDeleteFilter(); - if (request.UserId is { } userId) + if (request.UserId is not null) { - query = query.Where(a => a.UserId == userId); + query = query.Where(a => a.UserId == request.UserId.Value); } - if (request.CountryId is { } countryId) + if (request.CountryId is not null) { - query = query.Where(a => a.CountryId == countryId); + query = query.Where(a => a.CountryId == request.CountryId.Value); } query = query.OrderByDescending(a => a.AssignedOn); - var page = await query.ToPagedResultAsync(request.Page, request.PageSize, cancellationToken) - .ConfigureAwait(false); + var paged = await query.ToPagedResultAsync(request.Page, request.PageSize, cancellationToken).ConfigureAwait(false); - if (page.Items.Count == 0) + if (paged.Items.Count == 0) { return new PagedResult( - System.Array.Empty(), - page.Page, page.PageSize, page.Total); + Array.Empty(), paged.Page, paged.PageSize, paged.Total); } - var userIds = page.Items.Select(a => a.UserId).Distinct().ToList(); - var userNames = + var userIds = paged.Items.Select(a => a.UserId).Distinct().ToList(); + var userNamesQuery = from u in _db.Users where userIds.Contains(u.Id) select new UserNameRow(u.Id, u.UserName); - var userNameRows = await userNames.ToListAsyncEither(cancellationToken).ConfigureAwait(false); + var userNameRows = await userNamesQuery.ToListAsyncEither(cancellationToken).ConfigureAwait(false); var nameByUserId = userNameRows.ToDictionary(r => r.UserId, r => r.UserName); - var items = page.Items.Select(a => new StateRepAssignmentDto( + var items = paged.Items.Select(a => new StateRepAssignmentDto( a.Id, a.UserId, nameByUserId.TryGetValue(a.UserId, out var name) ? name : null, @@ -64,8 +59,8 @@ where userIds.Contains(u.Id) a.RevokedById, IsActive: a.RevokedOn is null && !a.IsDeleted)).ToList(); - return new PagedResult(items, page.Page, page.PageSize, page.Total); + return new PagedResult(items, paged.Page, paged.PageSize, paged.Total); } - private sealed record UserNameRow(System.Guid UserId, string? UserName); + private sealed record UserNameRow(Guid UserId, string? UserName); } diff --git a/backend/src/CCE.Application/Identity/Queries/ListUsers/ListUsersQueryHandler.cs b/backend/src/CCE.Application/Identity/Queries/ListUsers/ListUsersQueryHandler.cs index 2c2fb880..a96b4560 100644 --- a/backend/src/CCE.Application/Identity/Queries/ListUsers/ListUsersQueryHandler.cs +++ b/backend/src/CCE.Application/Identity/Queries/ListUsers/ListUsersQueryHandler.cs @@ -2,6 +2,7 @@ using CCE.Application.Common.Pagination; using CCE.Application.Identity.Dtos; using MediatR; +using Microsoft.AspNetCore.Identity; namespace CCE.Application.Identity.Queries.ListUsers; @@ -9,10 +10,7 @@ public sealed class ListUsersQueryHandler : IRequestHandler _db = db; public async Task> Handle(ListUsersQuery request, CancellationToken cancellationToken) { @@ -28,24 +26,25 @@ public async Task> Handle(ListUsersQuery request, C if (!string.IsNullOrWhiteSpace(request.Role)) { - var roleName = request.Role.Trim(); + var role = request.Role.Trim(); query = from u in query join ur in _db.UserRoles on u.Id equals ur.UserId join r in _db.Roles on ur.RoleId equals r.Id - where r.Name == roleName + where r.Name == role select u; } query = query.OrderBy(u => u.UserName); - var page = await query.ToPagedResultAsync(request.Page, request.PageSize, cancellationToken).ConfigureAwait(false); + var paged = await query.ToPagedResultAsync(request.Page, request.PageSize, cancellationToken).ConfigureAwait(false); - if (page.Items.Count == 0) + if (paged.Items.Count == 0) { - return new PagedResult(System.Array.Empty(), page.Page, page.PageSize, page.Total); + return new PagedResult( + Array.Empty(), paged.Page, paged.PageSize, paged.Total); } - var userIds = page.Items.Select(u => u.Id).ToList(); + var userIds = paged.Items.Select(u => u.Id).ToList(); var pairs = from ur in _db.UserRoles join r in _db.Roles on ur.RoleId equals r.Id @@ -57,16 +56,16 @@ where userIds.Contains(ur.UserId) && r.Name != null .GroupBy(p => p.UserId) .ToDictionary(g => g.Key, g => (IReadOnlyList)g.Select(p => p.RoleName).ToList()); - var now = System.DateTimeOffset.UtcNow; - var items = page.Items.Select(u => new UserListItemDto( + var now = DateTimeOffset.UtcNow; + var items = paged.Items.Select(u => new UserListItemDto( u.Id, u.Email, u.UserName, - rolesByUser.TryGetValue(u.Id, out var roles) ? roles : System.Array.Empty(), + rolesByUser.TryGetValue(u.Id, out var roles) ? roles : Array.Empty(), !u.LockoutEnabled || u.LockoutEnd is null || u.LockoutEnd < now)).ToList(); - return new PagedResult(items, page.Page, page.PageSize, page.Total); + return new PagedResult(items, paged.Page, paged.PageSize, paged.Total); } - private sealed record RoleAssignmentRow(System.Guid UserId, string RoleName); + private sealed record RoleAssignmentRow(Guid UserId, string RoleName); } diff --git a/backend/src/CCE.Application/InteractiveCity/Public/Dtos/CityScenarioDto.cs b/backend/src/CCE.Application/InteractiveCity/Public/Dtos/CityScenarioDto.cs index c7a98d18..53e20170 100644 --- a/backend/src/CCE.Application/InteractiveCity/Public/Dtos/CityScenarioDto.cs +++ b/backend/src/CCE.Application/InteractiveCity/Public/Dtos/CityScenarioDto.cs @@ -10,4 +10,4 @@ public sealed record CityScenarioDto( int TargetYear, string ConfigurationJson, System.DateTimeOffset CreatedOn, - System.DateTimeOffset LastModifiedOn); + System.DateTimeOffset? LastModifiedOn); diff --git a/backend/src/CCE.Infrastructure/Content/AssetService.cs b/backend/src/CCE.Infrastructure/Content/AssetRepository.cs similarity index 86% rename from backend/src/CCE.Infrastructure/Content/AssetService.cs rename to backend/src/CCE.Infrastructure/Content/AssetRepository.cs index 7259e755..5f0a7d04 100644 --- a/backend/src/CCE.Infrastructure/Content/AssetService.cs +++ b/backend/src/CCE.Infrastructure/Content/AssetRepository.cs @@ -5,11 +5,11 @@ namespace CCE.Infrastructure.Content; -public sealed class AssetService : IAssetService +public sealed class AssetRepository : IAssetRepository { private readonly CceDbContext _db; - public AssetService(CceDbContext db) + public AssetRepository(CceDbContext db) { _db = db; } diff --git a/backend/src/CCE.Infrastructure/Content/CountryResourceRequestService.cs b/backend/src/CCE.Infrastructure/Content/CountryResourceRequestRepository.cs similarity index 82% rename from backend/src/CCE.Infrastructure/Content/CountryResourceRequestService.cs rename to backend/src/CCE.Infrastructure/Content/CountryResourceRequestRepository.cs index 6a4bf9e8..89dc85b9 100644 --- a/backend/src/CCE.Infrastructure/Content/CountryResourceRequestService.cs +++ b/backend/src/CCE.Infrastructure/Content/CountryResourceRequestRepository.cs @@ -5,11 +5,11 @@ namespace CCE.Infrastructure.Content; -public sealed class CountryResourceRequestService : ICountryResourceRequestService +public sealed class CountryResourceRequestRepository : ICountryResourceRequestRepository { private readonly CceDbContext _db; - public CountryResourceRequestService(CceDbContext db) + public CountryResourceRequestRepository(CceDbContext db) { _db = db; } diff --git a/backend/src/CCE.Infrastructure/Content/EventService.cs b/backend/src/CCE.Infrastructure/Content/EventRepository.cs similarity index 79% rename from backend/src/CCE.Infrastructure/Content/EventService.cs rename to backend/src/CCE.Infrastructure/Content/EventRepository.cs index 3f9769fc..611b405d 100644 --- a/backend/src/CCE.Infrastructure/Content/EventService.cs +++ b/backend/src/CCE.Infrastructure/Content/EventRepository.cs @@ -5,11 +5,11 @@ namespace CCE.Infrastructure.Content; -public sealed class EventService : IEventService +public sealed class EventRepository : IEventRepository { private readonly CceDbContext _db; - public EventService(CceDbContext db) + public EventRepository(CceDbContext db) { _db = db; } @@ -27,8 +27,7 @@ public async Task SaveAsync(Event @event, CancellationToken ct) public async Task UpdateAsync(Event @event, byte[] expectedRowVersion, CancellationToken ct) { - var entry = _db.Entry(@event); - entry.OriginalValues[nameof(Event.RowVersion)] = expectedRowVersion; + _db.SetExpectedRowVersion(@event, expectedRowVersion); await _db.SaveChangesAsync(ct).ConfigureAwait(false); } } diff --git a/backend/src/CCE.Infrastructure/Content/HomepageSectionService.cs b/backend/src/CCE.Infrastructure/Content/HomepageSectionRepository.cs similarity index 92% rename from backend/src/CCE.Infrastructure/Content/HomepageSectionService.cs rename to backend/src/CCE.Infrastructure/Content/HomepageSectionRepository.cs index 06fc2af8..214f0ade 100644 --- a/backend/src/CCE.Infrastructure/Content/HomepageSectionService.cs +++ b/backend/src/CCE.Infrastructure/Content/HomepageSectionRepository.cs @@ -5,11 +5,11 @@ namespace CCE.Infrastructure.Content; -public sealed class HomepageSectionService : IHomepageSectionService +public sealed class HomepageSectionRepository : IHomepageSectionRepository { private readonly CceDbContext _db; - public HomepageSectionService(CceDbContext db) + public HomepageSectionRepository(CceDbContext db) { _db = db; } diff --git a/backend/src/CCE.Infrastructure/Content/NewsService.cs b/backend/src/CCE.Infrastructure/Content/NewsRepository.cs similarity index 79% rename from backend/src/CCE.Infrastructure/Content/NewsService.cs rename to backend/src/CCE.Infrastructure/Content/NewsRepository.cs index e36b4e9b..4368e2ba 100644 --- a/backend/src/CCE.Infrastructure/Content/NewsService.cs +++ b/backend/src/CCE.Infrastructure/Content/NewsRepository.cs @@ -5,11 +5,11 @@ namespace CCE.Infrastructure.Content; -public sealed class NewsService : INewsService +public sealed class NewsRepository : INewsRepository { private readonly CceDbContext _db; - public NewsService(CceDbContext db) + public NewsRepository(CceDbContext db) { _db = db; } @@ -27,8 +27,7 @@ public async Task SaveAsync(News news, CancellationToken ct) public async Task UpdateAsync(News news, byte[] expectedRowVersion, CancellationToken ct) { - var entry = _db.Entry(news); - entry.OriginalValues[nameof(News.RowVersion)] = expectedRowVersion; + _db.SetExpectedRowVersion(news, expectedRowVersion); await _db.SaveChangesAsync(ct).ConfigureAwait(false); } } diff --git a/backend/src/CCE.Infrastructure/Content/PageService.cs b/backend/src/CCE.Infrastructure/Content/PageRepository.cs similarity index 79% rename from backend/src/CCE.Infrastructure/Content/PageService.cs rename to backend/src/CCE.Infrastructure/Content/PageRepository.cs index 0e450cc8..dca031c7 100644 --- a/backend/src/CCE.Infrastructure/Content/PageService.cs +++ b/backend/src/CCE.Infrastructure/Content/PageRepository.cs @@ -5,11 +5,11 @@ namespace CCE.Infrastructure.Content; -public sealed class PageService : IPageService +public sealed class PageRepository : IPageRepository { private readonly CceDbContext _db; - public PageService(CceDbContext db) + public PageRepository(CceDbContext db) { _db = db; } @@ -27,8 +27,7 @@ public async Task SaveAsync(Page page, CancellationToken ct) public async Task UpdateAsync(Page page, byte[] expectedRowVersion, CancellationToken ct) { - var entry = _db.Entry(page); - entry.OriginalValues[nameof(Page.RowVersion)] = expectedRowVersion; + _db.SetExpectedRowVersion(page, expectedRowVersion); await _db.SaveChangesAsync(ct).ConfigureAwait(false); } } diff --git a/backend/src/CCE.Infrastructure/Content/ResourceCategoryService.cs b/backend/src/CCE.Infrastructure/Content/ResourceCategoryRepository.cs similarity index 86% rename from backend/src/CCE.Infrastructure/Content/ResourceCategoryService.cs rename to backend/src/CCE.Infrastructure/Content/ResourceCategoryRepository.cs index 1c440f97..a7a6c7f7 100644 --- a/backend/src/CCE.Infrastructure/Content/ResourceCategoryService.cs +++ b/backend/src/CCE.Infrastructure/Content/ResourceCategoryRepository.cs @@ -5,11 +5,11 @@ namespace CCE.Infrastructure.Content; -public sealed class ResourceCategoryService : IResourceCategoryService +public sealed class ResourceCategoryRepository : IResourceCategoryRepository { private readonly CceDbContext _db; - public ResourceCategoryService(CceDbContext db) + public ResourceCategoryRepository(CceDbContext db) { _db = db; } diff --git a/backend/src/CCE.Infrastructure/Content/ResourceService.cs b/backend/src/CCE.Infrastructure/Content/ResourceRepository.cs similarity index 78% rename from backend/src/CCE.Infrastructure/Content/ResourceService.cs rename to backend/src/CCE.Infrastructure/Content/ResourceRepository.cs index 6f0e8c64..adee3d5a 100644 --- a/backend/src/CCE.Infrastructure/Content/ResourceService.cs +++ b/backend/src/CCE.Infrastructure/Content/ResourceRepository.cs @@ -5,11 +5,11 @@ namespace CCE.Infrastructure.Content; -public sealed class ResourceService : IResourceService +public sealed class ResourceRepository : IResourceRepository { private readonly CceDbContext _db; - public ResourceService(CceDbContext db) + public ResourceRepository(CceDbContext db) { _db = db; } @@ -27,8 +27,7 @@ public async Task SaveAsync(Resource resource, CancellationToken ct) public async Task UpdateAsync(Resource resource, byte[] expectedRowVersion, CancellationToken ct) { - var entry = _db.Entry(resource); - entry.OriginalValues[nameof(Resource.RowVersion)] = expectedRowVersion; + _db.SetExpectedRowVersion(resource, expectedRowVersion); await _db.SaveChangesAsync(ct).ConfigureAwait(false); } } diff --git a/backend/src/CCE.Infrastructure/Content/ResourceViewCountService.cs b/backend/src/CCE.Infrastructure/Content/ResourceViewCountRepository.cs similarity index 82% rename from backend/src/CCE.Infrastructure/Content/ResourceViewCountService.cs rename to backend/src/CCE.Infrastructure/Content/ResourceViewCountRepository.cs index 95955902..16055518 100644 --- a/backend/src/CCE.Infrastructure/Content/ResourceViewCountService.cs +++ b/backend/src/CCE.Infrastructure/Content/ResourceViewCountRepository.cs @@ -4,11 +4,11 @@ namespace CCE.Infrastructure.Content; -public sealed class ResourceViewCountService : IResourceViewCountService +public sealed class ResourceViewCountRepository : IResourceViewCountRepository { private readonly CceDbContext _db; - public ResourceViewCountService(CceDbContext db) + public ResourceViewCountRepository(CceDbContext db) { _db = db; } diff --git a/backend/src/CCE.Infrastructure/DependencyInjection.cs b/backend/src/CCE.Infrastructure/DependencyInjection.cs index 03688548..76f39e76 100644 --- a/backend/src/CCE.Infrastructure/DependencyInjection.cs +++ b/backend/src/CCE.Infrastructure/DependencyInjection.cs @@ -7,6 +7,7 @@ using CCE.Application.Content.Public; using CCE.Application.Country; using CCE.Application.Identity; +using CCE.Application.Identity.Auth.Common; using CCE.Application.Identity.Public; using CCE.Application.InteractiveCity; using CCE.Application.Notifications; @@ -23,14 +24,17 @@ using CCE.Infrastructure.Notifications; using CCE.Infrastructure.Reports; using CCE.Infrastructure.Surveys; +using CCE.Application.Localization; using CCE.Domain.Common; using CCE.Infrastructure.Email; using CCE.Infrastructure.Files; using CCE.Infrastructure.Identity; +using CCE.Infrastructure.Localization; using CCE.Infrastructure.Persistence; using CCE.Infrastructure.Persistence.Interceptors; using CCE.Infrastructure.Search; using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -57,6 +61,17 @@ public static IServiceCollection AddInfrastructure( // Clock services.AddSingleton(); + services.Configure(configuration.GetSection(LocalAuthOptions.SectionName)); + services.Configure(options => + { + var authOptions = configuration.GetSection(LocalAuthOptions.SectionName).Get() ?? new LocalAuthOptions(); + options.TokenLifespan = TimeSpan.FromHours(Math.Max(1, authOptions.PasswordResetTokenHours)); + }); + + // Localization + services.AddSingleton(); + services.AddScoped(); + // Default current-user accessor — API hosts override with HttpContext-based impl. services.TryAddScoped(); @@ -78,9 +93,29 @@ public static IServiceCollection AddInfrastructure( sp.GetRequiredService()); }); services.AddScoped(sp => sp.GetRequiredService()); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); + + services + .AddIdentityCore(options => + { + options.User.RequireUniqueEmail = true; + options.Password.RequiredLength = 12; + options.Password.RequiredUniqueChars = 1; + options.Password.RequireUppercase = true; + options.Password.RequireLowercase = true; + options.Password.RequireDigit = true; + options.Password.RequireNonAlphanumeric = false; + options.Lockout.MaxFailedAccessAttempts = 5; + }) + .AddRoles() + .AddEntityFrameworkStores() + .AddDefaultTokenProviders(); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); // Sub-11 Phase 01 — Microsoft Graph user-create + CCE-side persist. // Factory is singleton (ClientSecretCredential is thread-safe and reusable); @@ -104,23 +139,23 @@ public static IServiceCollection AddInfrastructure( _ => ActivatorUtilities.CreateInstance(sp), }; }); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); // File storage + virus scanning services.AddSingleton(); services.AddTransient(); services.AddSingleton(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/backend/src/CCE.Infrastructure/Identity/ExpertRequestSubmissionService.cs b/backend/src/CCE.Infrastructure/Identity/ExpertRequestSubmissionRepository.cs similarity index 74% rename from backend/src/CCE.Infrastructure/Identity/ExpertRequestSubmissionService.cs rename to backend/src/CCE.Infrastructure/Identity/ExpertRequestSubmissionRepository.cs index 6f903fae..1847940a 100644 --- a/backend/src/CCE.Infrastructure/Identity/ExpertRequestSubmissionService.cs +++ b/backend/src/CCE.Infrastructure/Identity/ExpertRequestSubmissionRepository.cs @@ -4,11 +4,11 @@ namespace CCE.Infrastructure.Identity; -public sealed class ExpertRequestSubmissionService : IExpertRequestSubmissionService +public sealed class ExpertRequestSubmissionRepository : IExpertRequestSubmissionRepository { private readonly CceDbContext _db; - public ExpertRequestSubmissionService(CceDbContext db) + public ExpertRequestSubmissionRepository(CceDbContext db) { _db = db; } diff --git a/backend/src/CCE.Infrastructure/Identity/ExpertWorkflowService.cs b/backend/src/CCE.Infrastructure/Identity/ExpertWorkflowRepository.cs similarity index 87% rename from backend/src/CCE.Infrastructure/Identity/ExpertWorkflowService.cs rename to backend/src/CCE.Infrastructure/Identity/ExpertWorkflowRepository.cs index ed5b6229..113bdb91 100644 --- a/backend/src/CCE.Infrastructure/Identity/ExpertWorkflowService.cs +++ b/backend/src/CCE.Infrastructure/Identity/ExpertWorkflowRepository.cs @@ -5,11 +5,11 @@ namespace CCE.Infrastructure.Identity; -public sealed class ExpertWorkflowService : IExpertWorkflowService +public sealed class ExpertWorkflowRepository : IExpertWorkflowRepository { private readonly CceDbContext _db; - public ExpertWorkflowService(CceDbContext db) + public ExpertWorkflowRepository(CceDbContext db) { _db = db; } diff --git a/backend/src/CCE.Infrastructure/Identity/LocalTokenService.cs b/backend/src/CCE.Infrastructure/Identity/LocalTokenService.cs new file mode 100644 index 00000000..53fdcf7b --- /dev/null +++ b/backend/src/CCE.Infrastructure/Identity/LocalTokenService.cs @@ -0,0 +1,97 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using CCE.Application.Identity.Auth.Common; +using CCE.Domain.Common; +using CCE.Domain.Identity; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; + +namespace CCE.Infrastructure.Identity; + +public sealed class LocalTokenService : ILocalTokenService +{ + private readonly UserManager _userManager; + private readonly ISystemClock _clock; + private readonly IOptions _options; + + public LocalTokenService( + UserManager userManager, + ISystemClock clock, + IOptions options) + { + _userManager = userManager; + _clock = clock; + _options = options; + } + + public async Task IssueAsync(User user, LocalAuthApi api, CancellationToken ct) + { + var opts = _options.Value; + var profile = opts.GetProfile(api); + ValidateProfile(profile); + + var now = _clock.UtcNow; + var accessExpires = now.AddMinutes(opts.AccessTokenMinutes); + var refreshExpires = now.AddDays(opts.RefreshTokenDays); + var roles = await _userManager.GetRolesAsync(user).ConfigureAwait(false); + + var claims = new List + { + new(JwtRegisteredClaimNames.Sub, user.Id.ToString()), + new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + new(JwtRegisteredClaimNames.Email, user.Email ?? string.Empty), + new("preferred_username", user.UserName ?? user.Email ?? string.Empty), + new("email", user.Email ?? string.Empty), + }; + claims.AddRange(roles.Select(role => new Claim("roles", role))); + + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(profile.SigningKey)); + var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + var token = new JwtSecurityToken( + issuer: profile.Issuer, + audience: profile.Audience, + claims: claims, + notBefore: now.UtcDateTime, + expires: accessExpires.UtcDateTime, + signingCredentials: credentials); + + var accessToken = new JwtSecurityTokenHandler().WriteToken(token); + var refreshToken = GenerateRefreshToken(); + + return new TokenIssueResult( + accessToken, + accessExpires, + refreshToken, + HashRefreshToken(refreshToken), + refreshExpires); + } + + public string HashRefreshToken(string refreshToken) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(refreshToken)); + return Convert.ToHexString(bytes); + } + + private static string GenerateRefreshToken() + { + Span bytes = stackalloc byte[64]; + RandomNumberGenerator.Fill(bytes); + return Convert.ToBase64String(bytes) + .Replace("+", "-", StringComparison.Ordinal) + .Replace("/", "_", StringComparison.Ordinal) + .TrimEnd('='); + } + + private static void ValidateProfile(LocalAuthJwtProfile profile) + { + if (string.IsNullOrWhiteSpace(profile.Issuer) + || string.IsNullOrWhiteSpace(profile.Audience) + || Encoding.UTF8.GetByteCount(profile.SigningKey) < 32) + { + throw new InvalidOperationException("LocalAuth issuer, audience, and a 32+ byte signing key are required."); + } + } +} diff --git a/backend/src/CCE.Infrastructure/Identity/PasswordResetEmailSender.cs b/backend/src/CCE.Infrastructure/Identity/PasswordResetEmailSender.cs new file mode 100644 index 00000000..0057ff7a --- /dev/null +++ b/backend/src/CCE.Infrastructure/Identity/PasswordResetEmailSender.cs @@ -0,0 +1,42 @@ +using System.Net; +using CCE.Application.Common.Interfaces; +using CCE.Application.Identity.Auth.Common; +using CCE.Domain.Identity; +using Microsoft.Extensions.Configuration; + +namespace CCE.Infrastructure.Identity; + +public sealed class PasswordResetEmailSender : IPasswordResetEmailSender +{ + private readonly IEmailSender _emailSender; + private readonly IConfiguration _configuration; + + public PasswordResetEmailSender(IEmailSender emailSender, IConfiguration configuration) + { + _emailSender = emailSender; + _configuration = configuration; + } + + public async Task SendAsync(User user, string resetToken, CancellationToken ct) + { + var baseUrl = _configuration.GetValue("Frontend:PasswordResetUrl") + ?? "http://localhost:4200/reset-password"; + var separator = baseUrl.Contains('?', StringComparison.Ordinal) ? '&' : '?'; + var url = $"{baseUrl}{separator}email={Uri.EscapeDataString(user.Email ?? string.Empty)}&token={Uri.EscapeDataString(resetToken)}"; + var firstName = WebUtility.HtmlEncode(user.FirstName); + var encodedUrl = WebUtility.HtmlEncode(url); + var body = $$""" + + +

Hello {{firstName}},

+

Use the link below to reset your CCE password.

+

Reset password

+

If you did not request a password reset, you can ignore this email.

+ + + """; + + await _emailSender.SendAsync(user.Email ?? string.Empty, "Reset your CCE password", body, ct) + .ConfigureAwait(false); + } +} diff --git a/backend/src/CCE.Infrastructure/Identity/RefreshTokenRepository.cs b/backend/src/CCE.Infrastructure/Identity/RefreshTokenRepository.cs new file mode 100644 index 00000000..8cc45149 --- /dev/null +++ b/backend/src/CCE.Infrastructure/Identity/RefreshTokenRepository.cs @@ -0,0 +1,50 @@ +using CCE.Application.Identity.Auth.Common; +using CCE.Domain.Identity; +using CCE.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace CCE.Infrastructure.Identity; + +public sealed class RefreshTokenRepository : IRefreshTokenRepository +{ + private readonly CceDbContext _db; + + public RefreshTokenRepository(CceDbContext db) => _db = db; + + public async Task AddAsync(RefreshToken token, CancellationToken ct) + => await _db.RefreshTokens.AddAsync(token, ct).ConfigureAwait(false); + + public async Task FindByHashAsync(string tokenHash, CancellationToken ct) + => await _db.RefreshTokens + .FirstOrDefaultAsync(t => t.TokenHash == tokenHash, ct) + .ConfigureAwait(false); + + public async Task RevokeFamilyAsync(Guid tokenFamilyId, DateTimeOffset revokedAtUtc, string? revokedByIp, CancellationToken ct) + { + var tokens = await _db.RefreshTokens + .Where(t => t.TokenFamilyId == tokenFamilyId && t.RevokedAtUtc == null) + .ToListAsync(ct) + .ConfigureAwait(false); + + foreach (var token in tokens) + { + token.Revoke(revokedAtUtc, revokedByIp); + } + } + + public async Task RevokeAllForUserAsync(Guid userId, DateTimeOffset revokedAtUtc, string? revokedByIp, CancellationToken ct) + { + var tokens = await _db.RefreshTokens + .Where(t => t.UserId == userId && t.RevokedAtUtc == null) + .ToListAsync(ct) + .ConfigureAwait(false); + + foreach (var token in tokens) + { + token.Revoke(revokedAtUtc, revokedByIp); + } + } + + public async Task SaveChangesAsync(CancellationToken ct) + => await _db.SaveChangesAsync(ct).ConfigureAwait(false); +} diff --git a/backend/src/CCE.Infrastructure/Identity/StateRepAssignmentService.cs b/backend/src/CCE.Infrastructure/Identity/StateRepAssignmentRepository.cs similarity index 88% rename from backend/src/CCE.Infrastructure/Identity/StateRepAssignmentService.cs rename to backend/src/CCE.Infrastructure/Identity/StateRepAssignmentRepository.cs index 4aba2a02..e301db0f 100644 --- a/backend/src/CCE.Infrastructure/Identity/StateRepAssignmentService.cs +++ b/backend/src/CCE.Infrastructure/Identity/StateRepAssignmentRepository.cs @@ -5,11 +5,11 @@ namespace CCE.Infrastructure.Identity; -public sealed class StateRepAssignmentService : IStateRepAssignmentService +public sealed class StateRepAssignmentRepository : IStateRepAssignmentRepository { private readonly CceDbContext _db; - public StateRepAssignmentService(CceDbContext db) + public StateRepAssignmentRepository(CceDbContext db) { _db = db; } diff --git a/backend/src/CCE.Infrastructure/Identity/UserProfileService.cs b/backend/src/CCE.Infrastructure/Identity/UserProfileRepository.cs similarity index 82% rename from backend/src/CCE.Infrastructure/Identity/UserProfileService.cs rename to backend/src/CCE.Infrastructure/Identity/UserProfileRepository.cs index b180b5a8..29c41d7c 100644 --- a/backend/src/CCE.Infrastructure/Identity/UserProfileService.cs +++ b/backend/src/CCE.Infrastructure/Identity/UserProfileRepository.cs @@ -5,11 +5,11 @@ namespace CCE.Infrastructure.Identity; -public sealed class UserProfileService : IUserProfileService +public sealed class UserProfileRepository : IUserProfileRepository { private readonly CceDbContext _db; - public UserProfileService(CceDbContext db) + public UserProfileRepository(CceDbContext db) { _db = db; } diff --git a/backend/src/CCE.Infrastructure/Identity/UserRoleAssignmentService.cs b/backend/src/CCE.Infrastructure/Identity/UserRoleAssignmentRepository.cs similarity index 83% rename from backend/src/CCE.Infrastructure/Identity/UserRoleAssignmentService.cs rename to backend/src/CCE.Infrastructure/Identity/UserRoleAssignmentRepository.cs index bc858589..72e14994 100644 --- a/backend/src/CCE.Infrastructure/Identity/UserRoleAssignmentService.cs +++ b/backend/src/CCE.Infrastructure/Identity/UserRoleAssignmentRepository.cs @@ -7,12 +7,12 @@ namespace CCE.Infrastructure.Identity; -public sealed class UserRoleAssignmentService : IUserRoleAssignmentService +public sealed class UserRoleAssignmentRepository : IUserRoleAssignmentRepository { private readonly CceDbContext _db; - private readonly ILogger _logger; + private readonly ILogger _logger; - public UserRoleAssignmentService(CceDbContext db, ILogger logger) + public UserRoleAssignmentRepository(CceDbContext db, ILogger logger) { _db = db; _logger = logger; @@ -66,9 +66,9 @@ public async Task ReplaceRolesAsync( if (toAdd.Count > 0 || toRemove.Count > 0) { await _db.SaveChangesAsync(ct).ConfigureAwait(false); - _logger.LogInformation( - "Replaced roles for user {UserId}: +{Added} −{Removed}", - userId, toAdd.Count, toRemove.Count); + //_logger.LogInformation( + // "Replaced roles for user {UserId}: +{Added} −{Removed}", + // userId, toAdd.Count, toRemove.Count); } return true; diff --git a/backend/src/CCE.Infrastructure/Identity/UserSyncService.cs b/backend/src/CCE.Infrastructure/Identity/UserSyncRepository.cs similarity index 90% rename from backend/src/CCE.Infrastructure/Identity/UserSyncService.cs rename to backend/src/CCE.Infrastructure/Identity/UserSyncRepository.cs index 3fd6b7d7..0205cff4 100644 --- a/backend/src/CCE.Infrastructure/Identity/UserSyncService.cs +++ b/backend/src/CCE.Infrastructure/Identity/UserSyncRepository.cs @@ -8,13 +8,13 @@ namespace CCE.Infrastructure.Identity; -public sealed class UserSyncService : IUserSyncService +public sealed class UserSyncRepository : IUserSyncRepository { private readonly CceDbContext _db; private readonly IConfiguration _configuration; - private readonly ILogger _logger; + private readonly ILogger _logger; - public UserSyncService(CceDbContext db, IConfiguration configuration, ILogger logger) + public UserSyncRepository(CceDbContext db, IConfiguration configuration, ILogger logger) { _db = db; _configuration = configuration; diff --git a/backend/src/CCE.Infrastructure/Persistence/CceDbContext.cs b/backend/src/CCE.Infrastructure/Persistence/CceDbContext.cs index bbc4e495..7198d594 100644 --- a/backend/src/CCE.Infrastructure/Persistence/CceDbContext.cs +++ b/backend/src/CCE.Infrastructure/Persistence/CceDbContext.cs @@ -35,6 +35,7 @@ public CceDbContext(DbContextOptions options) : base(options) { } public DbSet StateRepresentativeAssignments => Set(); public DbSet ExpertProfiles => Set(); public DbSet ExpertRegistrationRequests => Set(); + public DbSet RefreshTokens => Set(); // ─── Content ─── public DbSet AssetFiles => Set(); @@ -80,44 +81,43 @@ public CceDbContext(DbContextOptions options) : base(options) { } public DbSet ServiceRatings => Set(); public DbSet SearchQueryLogs => Set(); - // ─── ICceDbContext explicit interface implementations ─── - // DbSet implements IQueryable; the inherited Identity DbSets (Users/Roles/UserRoles) - // and the domain DbSet below satisfy the interface through these explicit projections. - IQueryable ICceDbContext.Users => Users; - IQueryable ICceDbContext.Roles => Roles; - IQueryable> ICceDbContext.UserRoles => UserRoles; - IQueryable ICceDbContext.StateRepresentativeAssignments => StateRepresentativeAssignments; - IQueryable ICceDbContext.Countries => Countries; - IQueryable ICceDbContext.ExpertRegistrationRequests => ExpertRegistrationRequests; - IQueryable ICceDbContext.ExpertProfiles => ExpertProfiles; - IQueryable ICceDbContext.AssetFiles => AssetFiles; - IQueryable ICceDbContext.ResourceCategories => ResourceCategories; - IQueryable ICceDbContext.Resources => Resources; - IQueryable ICceDbContext.CountryResourceRequests => CountryResourceRequests; - IQueryable ICceDbContext.CountryProfiles => CountryProfiles; - IQueryable ICceDbContext.CountryKapsarcSnapshots => CountryKapsarcSnapshots; - IQueryable ICceDbContext.News => News; - IQueryable ICceDbContext.Events => Events; - IQueryable ICceDbContext.Pages => Pages; - IQueryable ICceDbContext.HomepageSections => HomepageSections; - IQueryable ICceDbContext.Topics => Topics; - IQueryable ICceDbContext.Posts => Posts; - IQueryable ICceDbContext.PostReplies => PostReplies; - IQueryable ICceDbContext.PostRatings => PostRatings; - IQueryable ICceDbContext.TopicFollows => TopicFollows; - IQueryable ICceDbContext.UserFollows => UserFollows; - IQueryable ICceDbContext.PostFollows => PostFollows; - IQueryable ICceDbContext.NotificationTemplates => NotificationTemplates; - IQueryable ICceDbContext.UserNotifications => UserNotifications; - IQueryable ICceDbContext.ServiceRatings => ServiceRatings; - IQueryable ICceDbContext.AuditEvents => AuditEvents; - IQueryable ICceDbContext.KnowledgeMaps => KnowledgeMaps; - IQueryable ICceDbContext.KnowledgeMapNodes => KnowledgeMapNodes; - IQueryable ICceDbContext.KnowledgeMapEdges => KnowledgeMapEdges; - IQueryable ICceDbContext.KnowledgeMapAssociations => KnowledgeMapAssociations; - IQueryable ICceDbContext.CityScenarios => CityScenarios; - IQueryable ICceDbContext.CityTechnologies => CityTechnologies; - IQueryable ICceDbContext.CityScenarioResults => CityScenarioResults; + // ─── ICceDbContext (read-only queryables — no tracking) ─── + IQueryable ICceDbContext.Users => Users.AsNoTracking(); + IQueryable ICceDbContext.Roles => Roles.AsNoTracking(); + IQueryable> ICceDbContext.UserRoles => UserRoles.AsNoTracking(); + IQueryable ICceDbContext.StateRepresentativeAssignments => StateRepresentativeAssignments.AsNoTracking(); + IQueryable ICceDbContext.Countries => Countries.AsNoTracking(); + IQueryable ICceDbContext.ExpertRegistrationRequests => ExpertRegistrationRequests.AsNoTracking(); + IQueryable ICceDbContext.ExpertProfiles => ExpertProfiles.AsNoTracking(); + IQueryable ICceDbContext.RefreshTokens => RefreshTokens.AsNoTracking(); + IQueryable ICceDbContext.AssetFiles => AssetFiles.AsNoTracking(); + IQueryable ICceDbContext.ResourceCategories => ResourceCategories.AsNoTracking(); + IQueryable ICceDbContext.Resources => Resources.AsNoTracking(); + IQueryable ICceDbContext.CountryResourceRequests => CountryResourceRequests.AsNoTracking(); + IQueryable ICceDbContext.CountryProfiles => CountryProfiles.AsNoTracking(); + IQueryable ICceDbContext.CountryKapsarcSnapshots => CountryKapsarcSnapshots.AsNoTracking(); + IQueryable ICceDbContext.News => News.AsNoTracking(); + IQueryable ICceDbContext.Events => Events.AsNoTracking(); + IQueryable ICceDbContext.Pages => Pages.AsNoTracking(); + IQueryable ICceDbContext.HomepageSections => HomepageSections.AsNoTracking(); + IQueryable ICceDbContext.Topics => Topics.AsNoTracking(); + IQueryable ICceDbContext.Posts => Posts.AsNoTracking(); + IQueryable ICceDbContext.PostReplies => PostReplies.AsNoTracking(); + IQueryable ICceDbContext.PostRatings => PostRatings.AsNoTracking(); + IQueryable ICceDbContext.TopicFollows => TopicFollows.AsNoTracking(); + IQueryable ICceDbContext.UserFollows => UserFollows.AsNoTracking(); + IQueryable ICceDbContext.PostFollows => PostFollows.AsNoTracking(); + IQueryable ICceDbContext.NotificationTemplates => NotificationTemplates.AsNoTracking(); + IQueryable ICceDbContext.UserNotifications => UserNotifications.AsNoTracking(); + IQueryable ICceDbContext.ServiceRatings => ServiceRatings.AsNoTracking(); + IQueryable ICceDbContext.AuditEvents => AuditEvents.AsNoTracking(); + IQueryable ICceDbContext.KnowledgeMaps => KnowledgeMaps.AsNoTracking(); + IQueryable ICceDbContext.KnowledgeMapNodes => KnowledgeMapNodes.AsNoTracking(); + IQueryable ICceDbContext.KnowledgeMapEdges => KnowledgeMapEdges.AsNoTracking(); + IQueryable ICceDbContext.KnowledgeMapAssociations => KnowledgeMapAssociations.AsNoTracking(); + IQueryable ICceDbContext.CityScenarios => CityScenarios.AsNoTracking(); + IQueryable ICceDbContext.CityTechnologies => CityTechnologies.AsNoTracking(); + IQueryable ICceDbContext.CityScenarioResults => CityScenarioResults.AsNoTracking(); protected override void OnModelCreating(ModelBuilder builder) { diff --git a/backend/src/CCE.Infrastructure/Persistence/Configurations/Identity/RefreshTokenConfiguration.cs b/backend/src/CCE.Infrastructure/Persistence/Configurations/Identity/RefreshTokenConfiguration.cs new file mode 100644 index 00000000..c848cb70 --- /dev/null +++ b/backend/src/CCE.Infrastructure/Persistence/Configurations/Identity/RefreshTokenConfiguration.cs @@ -0,0 +1,30 @@ +using CCE.Domain.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CCE.Infrastructure.Persistence.Configurations.Identity; + +internal sealed class RefreshTokenConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(t => t.Id); + builder.Property(t => t.Id).ValueGeneratedNever(); + builder.Property(t => t.TokenHash).HasMaxLength(128).IsRequired(); + builder.Property(t => t.CreatedByIp).HasMaxLength(64); + builder.Property(t => t.RevokedByIp).HasMaxLength(64); + builder.Property(t => t.UserAgent).HasMaxLength(512); + builder.Property(t => t.ReplacedByTokenHash).HasMaxLength(128); + + builder.HasIndex(t => t.TokenHash) + .IsUnique() + .HasDatabaseName("ux_refresh_tokens_token_hash"); + builder.HasIndex(t => t.UserId).HasDatabaseName("ix_refresh_tokens_user_id"); + builder.HasIndex(t => t.TokenFamilyId).HasDatabaseName("ix_refresh_tokens_token_family_id"); + + builder.HasOne() + .WithMany() + .HasForeignKey(t => t.UserId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/backend/src/CCE.Infrastructure/Persistence/Configurations/Identity/UserConfiguration.cs b/backend/src/CCE.Infrastructure/Persistence/Configurations/Identity/UserConfiguration.cs index 9dffee71..05db4019 100644 --- a/backend/src/CCE.Infrastructure/Persistence/Configurations/Identity/UserConfiguration.cs +++ b/backend/src/CCE.Infrastructure/Persistence/Configurations/Identity/UserConfiguration.cs @@ -8,6 +8,10 @@ internal sealed class UserConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) { + builder.Property(u => u.FirstName).HasMaxLength(50).IsRequired(); + builder.Property(u => u.LastName).HasMaxLength(50).IsRequired(); + builder.Property(u => u.JobTitle).HasMaxLength(50).IsRequired(); + builder.Property(u => u.OrganizationName).HasMaxLength(100).IsRequired(); builder.Property(u => u.LocalePreference).HasMaxLength(2).IsRequired(); builder.Property(u => u.AvatarUrl).HasMaxLength(2048); builder.Property(u => u.Interests).HasColumnType("nvarchar(max)"); diff --git a/backend/src/CCE.Infrastructure/Persistence/DbContextExtensions.cs b/backend/src/CCE.Infrastructure/Persistence/DbContextExtensions.cs new file mode 100644 index 00000000..5fe15df7 --- /dev/null +++ b/backend/src/CCE.Infrastructure/Persistence/DbContextExtensions.cs @@ -0,0 +1,16 @@ +using Microsoft.EntityFrameworkCore; + +namespace CCE.Infrastructure.Persistence; + +internal static class DbContextExtensions +{ + /// + /// Sets the expected RowVersion for optimistic concurrency on a tracked entity. + /// + public static void SetExpectedRowVersion( + this DbContext db, T entity, byte[] expectedRowVersion) + where T : class + { + db.Entry(entity).OriginalValues["RowVersion"] = expectedRowVersion; + } +} diff --git a/backend/src/CCE.Infrastructure/Persistence/Migrations/20260514202038_AddLocalAuthRefreshTokens.Designer.cs b/backend/src/CCE.Infrastructure/Persistence/Migrations/20260514202038_AddLocalAuthRefreshTokens.Designer.cs new file mode 100644 index 00000000..19875460 --- /dev/null +++ b/backend/src/CCE.Infrastructure/Persistence/Migrations/20260514202038_AddLocalAuthRefreshTokens.Designer.cs @@ -0,0 +1,2444 @@ +// +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("20260514202038_AddLocalAuthRefreshTokens")] + partial class AddLocalAuthRefreshTokens + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("CCE.Domain.Audit.AuditEvent", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("action"); + + b.Property("Actor") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("actor"); + + b.Property("CorrelationId") + .HasColumnType("uniqueidentifier") + .HasColumnName("correlation_id"); + + b.Property("Diff") + .HasColumnType("nvarchar(max)") + .HasColumnName("diff"); + + b.Property("OccurredOn") + .HasColumnType("datetimeoffset") + .HasColumnName("occurred_on"); + + b.Property("Resource") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("resource"); + + b.HasKey("Id") + .HasName("pk_audit_events"); + + b.HasIndex("CorrelationId") + .HasDatabaseName("ix_audit_events_correlation_id"); + + b.HasIndex("Actor", "OccurredOn") + .HasDatabaseName("ix_audit_events_actor_occurred_on"); + + b.ToTable("audit_events", null, t => + { + t.HasTrigger("trg_audit_events_no_update_delete"); + }); + + b.HasAnnotation("SqlServer:UseSqlOutputClause", false); + }); + + modelBuilder.Entity("CCE.Domain.Community.Post", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AnsweredReplyId") + .HasColumnType("uniqueidentifier") + .HasColumnName("answered_reply_id"); + + b.Property("AuthorId") + .HasColumnType("uniqueidentifier") + .HasColumnName("author_id"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)") + .HasColumnName("content"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsAnswerable") + .HasColumnType("bit") + .HasColumnName("is_answerable"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("locale"); + + b.Property("TopicId") + .HasColumnType("uniqueidentifier") + .HasColumnName("topic_id"); + + b.HasKey("Id") + .HasName("pk_posts"); + + b.HasIndex("TopicId") + .HasDatabaseName("ix_post_topic_id"); + + b.HasIndex("AuthorId", "CreatedOn") + .HasDatabaseName("ix_post_author_created"); + + b.ToTable("posts", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.PostFollow", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("FollowedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("followed_on"); + + b.Property("PostId") + .HasColumnType("uniqueidentifier") + .HasColumnName("post_id"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_post_follows"); + + b.HasIndex("PostId", "UserId") + .IsUnique() + .HasDatabaseName("ux_post_follow_post_user"); + + b.ToTable("post_follows", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.PostRating", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("PostId") + .HasColumnType("uniqueidentifier") + .HasColumnName("post_id"); + + b.Property("RatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("rated_on"); + + b.Property("Stars") + .HasColumnType("int") + .HasColumnName("stars"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_post_ratings"); + + b.HasIndex("PostId", "UserId") + .IsUnique() + .HasDatabaseName("ux_post_rating_post_user"); + + b.ToTable("post_ratings", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.PostReply", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AuthorId") + .HasColumnType("uniqueidentifier") + .HasColumnName("author_id"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)") + .HasColumnName("content"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsByExpert") + .HasColumnType("bit") + .HasColumnName("is_by_expert"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("locale"); + + b.Property("ParentReplyId") + .HasColumnType("uniqueidentifier") + .HasColumnName("parent_reply_id"); + + b.Property("PostId") + .HasColumnType("uniqueidentifier") + .HasColumnName("post_id"); + + b.HasKey("Id") + .HasName("pk_post_replies"); + + b.HasIndex("ParentReplyId") + .HasDatabaseName("ix_post_reply_parent_id"); + + b.HasIndex("PostId") + .HasDatabaseName("ix_post_reply_post_id"); + + b.ToTable("post_replies", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.Topic", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("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("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("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("LocationAr") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("location_ar"); + + b.Property("LocationEn") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("location_en"); + + b.Property("OnlineMeetingUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("online_meeting_url"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.Property("StartsOn") + .HasColumnType("datetimeoffset") + .HasColumnName("starts_on"); + + b.Property("TitleAr") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_ar"); + + b.Property("TitleEn") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_en"); + + b.HasKey("Id") + .HasName("pk_events"); + + b.HasIndex("ICalUid") + .IsUnique() + .HasDatabaseName("ux_event_ical_uid"); + + b.HasIndex("StartsOn") + .HasDatabaseName("ix_event_starts_on"); + + b.ToTable("events", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.HomepageSection", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("ContentAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_ar"); + + b.Property("ContentEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_en"); + + b.Property("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("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("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("PublishedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("published_on"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("slug"); + + b.Property("TitleAr") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_ar"); + + b.Property("TitleEn") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_en"); + + b.HasKey("Id") + .HasName("pk_news"); + + b.HasIndex("PublishedOn") + .HasDatabaseName("ix_news_published_on"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ux_news_slug_active") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("news", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.NewsletterSubscription", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("ConfirmationToken") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("confirmation_token"); + + b.Property("ConfirmedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("confirmed_on"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("nvarchar(320)") + .HasColumnName("email"); + + b.Property("IsConfirmed") + .HasColumnType("bit") + .HasColumnName("is_confirmed"); + + 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("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("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("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("PublishedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("published_on"); + + b.Property("ResourceType") + .HasColumnType("int") + .HasColumnName("resource_type"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.Property("TitleAr") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_ar"); + + b.Property("TitleEn") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_en"); + + b.Property("UploadedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("uploaded_by_id"); + + b.Property("ViewCount") + .HasColumnType("bigint") + .HasColumnName("view_count"); + + b.HasKey("Id") + .HasName("pk_resources"); + + b.HasIndex("AssetFileId") + .HasDatabaseName("ix_resource_asset_file_id"); + + b.HasIndex("CountryId") + .HasDatabaseName("ix_resource_country_id"); + + b.HasIndex("CategoryId", "PublishedOn") + .HasDatabaseName("ix_resource_category_published"); + + b.ToTable("resources", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.ResourceCategory", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.Property("ParentId") + .HasColumnType("uniqueidentifier") + .HasColumnName("parent_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_resource_categories"); + + b.HasIndex("ParentId") + .HasDatabaseName("ix_resource_category_parent_id"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ux_resource_category_slug"); + + b.ToTable("resource_categories", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Country.Country", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("FlagUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("flag_url"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("IsoAlpha2") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("iso_alpha2"); + + b.Property("IsoAlpha3") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("nvarchar(3)") + .HasColumnName("iso_alpha3"); + + b.Property("LatestKapsarcSnapshotId") + .HasColumnType("uniqueidentifier") + .HasColumnName("latest_kapsarc_snapshot_id"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.Property("RegionAr") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("region_ar"); + + b.Property("RegionEn") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("region_en"); + + b.HasKey("Id") + .HasName("pk_countries"); + + b.HasIndex("IsoAlpha2") + .HasDatabaseName("ix_country_iso_alpha2"); + + b.HasIndex("IsoAlpha3") + .IsUnique() + .HasDatabaseName("ux_country_iso_alpha3_active") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("countries", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Country.CountryKapsarcSnapshot", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("Classification") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("classification"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("PerformanceScore") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)") + .HasColumnName("performance_score"); + + b.Property("SnapshotTakenOn") + .HasColumnType("datetimeoffset") + .HasColumnName("snapshot_taken_on"); + + b.Property("SourceVersion") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)") + .HasColumnName("source_version"); + + b.Property("TotalIndex") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)") + .HasColumnName("total_index"); + + b.HasKey("Id") + .HasName("pk_country_kapsarc_snapshots"); + + b.HasIndex("CountryId", "SnapshotTakenOn") + .HasDatabaseName("ix_kapsarc_snapshot_country_taken"); + + b.ToTable("country_kapsarc_snapshots", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Country.CountryProfile", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("ContactInfoAr") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("contact_info_ar"); + + b.Property("ContactInfoEn") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("contact_info_en"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("DescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("KeyInitiativesAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("key_initiatives_ar"); + + b.Property("KeyInitiativesEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("key_initiatives_en"); + + b.Property("LastUpdatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_updated_by_id"); + + b.Property("LastUpdatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_updated_on"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.HasKey("Id") + .HasName("pk_country_profiles"); + + b.HasIndex("CountryId") + .IsUnique() + .HasDatabaseName("ux_country_profile_country_id"); + + b.ToTable("country_profiles", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Country.CountryResourceRequest", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AdminNotesAr") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("admin_notes_ar"); + + b.Property("AdminNotesEn") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("admin_notes_en"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("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("ProcessedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("processed_by_id"); + + b.Property("ProcessedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("processed_on"); + + b.Property("ProposedAssetFileId") + .HasColumnType("uniqueidentifier") + .HasColumnName("proposed_asset_file_id"); + + b.Property("ProposedDescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("proposed_description_ar"); + + b.Property("ProposedDescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("proposed_description_en"); + + b.Property("ProposedResourceType") + .HasColumnType("int") + .HasColumnName("proposed_resource_type"); + + b.Property("ProposedTitleAr") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("proposed_title_ar"); + + b.Property("ProposedTitleEn") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("proposed_title_en"); + + b.Property("RequestedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("requested_by_id"); + + b.Property("Status") + .HasColumnType("int") + .HasColumnName("status"); + + b.Property("SubmittedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("submitted_on"); + + b.HasKey("Id") + .HasName("pk_country_resource_requests"); + + b.HasIndex("CountryId", "Status") + .HasDatabaseName("ix_country_request_country_status"); + + b.ToTable("country_resource_requests", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.ExpertProfile", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AcademicTitleAr") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("academic_title_ar"); + + b.Property("AcademicTitleEn") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("academic_title_en"); + + b.Property("ApprovedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("approved_by_id"); + + b.Property("ApprovedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("approved_on"); + + b.Property("BioAr") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("bio_ar"); + + b.Property("BioEn") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("bio_en"); + + b.Property("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("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("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("ProcessedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("processed_by_id"); + + b.Property("ProcessedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("processed_on"); + + b.Property("RejectionReasonAr") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("rejection_reason_ar"); + + b.Property("RejectionReasonEn") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("rejection_reason_en"); + + b.Property("RequestedBioAr") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("requested_bio_ar"); + + b.Property("RequestedBioEn") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("requested_bio_en"); + + b.Property("RequestedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("requested_by_id"); + + b.PrimitiveCollection("RequestedTags") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("requested_tags"); + + b.Property("Status") + .HasColumnType("int") + .HasColumnName("status"); + + b.Property("SubmittedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("submitted_on"); + + b.HasKey("Id") + .HasName("pk_expert_registration_requests"); + + b.HasIndex("RequestedById") + .HasDatabaseName("ix_expert_request_requested_by"); + + b.HasIndex("Status") + .HasDatabaseName("ix_expert_request_status"); + + b.ToTable("expert_registration_requests", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.RefreshToken", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset") + .HasColumnName("created_at_utc"); + + b.Property("CreatedByIp") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("created_by_ip"); + + b.Property("ExpiresAtUtc") + .HasColumnType("datetimeoffset") + .HasColumnName("expires_at_utc"); + + b.Property("ReplacedByTokenHash") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("replaced_by_token_hash"); + + b.Property("RevokedAtUtc") + .HasColumnType("datetimeoffset") + .HasColumnName("revoked_at_utc"); + + b.Property("RevokedByIp") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("revoked_by_ip"); + + b.Property("TokenFamilyId") + .HasColumnType("uniqueidentifier") + .HasColumnName("token_family_id"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("token_hash"); + + b.Property("UserAgent") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_refresh_tokens"); + + b.HasIndex("TokenFamilyId") + .HasDatabaseName("ix_refresh_tokens_token_family_id"); + + b.HasIndex("TokenHash") + .IsUnique() + .HasDatabaseName("ux_refresh_tokens_token_hash"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_refresh_tokens_user_id"); + + b.ToTable("refresh_tokens", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)") + .HasColumnName("concurrency_stamp"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("normalized_name"); + + b.HasKey("Id") + .HasName("pk_asp_net_roles"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[normalized_name] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.StateRepresentativeAssignment", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AssignedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("assigned_by_id"); + + b.Property("AssignedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("assigned_on"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("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("RevokedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("revoked_by_id"); + + b.Property("RevokedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("revoked_on"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_state_representative_assignments"); + + b.HasIndex("CountryId") + .HasDatabaseName("ix_state_rep_country_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_state_rep_user_id"); + + b.HasIndex("UserId", "CountryId") + .IsUnique() + .HasDatabaseName("ux_state_rep_active_user_country") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("state_representative_assignments", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AccessFailedCount") + .HasColumnType("int") + .HasColumnName("access_failed_count"); + + b.Property("AvatarUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("avatar_url"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)") + .HasColumnName("concurrency_stamp"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("email"); + + b.Property("EmailConfirmed") + .HasColumnType("bit") + .HasColumnName("email_confirmed"); + + b.Property("EntraIdObjectId") + .HasColumnType("uniqueidentifier") + .HasColumnName("entra_id_object_id"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)") + .HasColumnName("first_name"); + + b.PrimitiveCollection("Interests") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("interests"); + + b.Property("JobTitle") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)") + .HasColumnName("job_title"); + + b.Property("KnowledgeLevel") + .HasColumnType("int") + .HasColumnName("knowledge_level"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)") + .HasColumnName("last_name"); + + b.Property("LocalePreference") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("locale_preference"); + + b.Property("LockoutEnabled") + .HasColumnType("bit") + .HasColumnName("lockout_enabled"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset") + .HasColumnName("lockout_end"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("normalized_email"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("normalized_user_name"); + + b.Property("OrganizationName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("organization_name"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)") + .HasColumnName("password_hash"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)") + .HasColumnName("phone_number"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit") + .HasColumnName("phone_number_confirmed"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)") + .HasColumnName("security_stamp"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit") + .HasColumnName("two_factor_enabled"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("user_name"); + + b.HasKey("Id") + .HasName("pk_asp_net_users"); + + b.HasIndex("CountryId") + .HasDatabaseName("ix_users_country_id"); + + b.HasIndex("EntraIdObjectId") + .IsUnique() + .HasDatabaseName("ix_asp_net_users_entra_id_object_id") + .HasFilter("[entra_id_object_id] IS NOT NULL"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[normalized_user_name] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.InteractiveCity.CityScenario", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CityType") + .HasColumnType("int") + .HasColumnName("city_type"); + + b.Property("ConfigurationJson") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("configuration_json"); + + b.Property("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("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.Property("TargetYear") + .HasColumnType("int") + .HasColumnName("target_year"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_city_scenarios"); + + b.HasIndex("UserId", "LastModifiedOn") + .HasDatabaseName("ix_city_scenario_user_modified"); + + b.ToTable("city_scenarios", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.InteractiveCity.CityScenarioResult", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("ComputedAt") + .HasColumnType("datetimeoffset") + .HasColumnName("computed_at"); + + b.Property("ComputedCarbonNeutralityYear") + .HasColumnType("int") + .HasColumnName("computed_carbon_neutrality_year"); + + b.Property("ComputedTotalCostUsd") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)") + .HasColumnName("computed_total_cost_usd"); + + b.Property("EngineVersion") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("engine_version"); + + b.Property("ScenarioId") + .HasColumnType("uniqueidentifier") + .HasColumnName("scenario_id"); + + b.HasKey("Id") + .HasName("pk_city_scenario_results"); + + b.HasIndex("ScenarioId", "ComputedAt") + .HasDatabaseName("ix_city_result_scenario_at"); + + b.ToTable("city_scenario_results", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.InteractiveCity.CityTechnology", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CarbonImpactKgPerYear") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)") + .HasColumnName("carbon_impact_kg_per_year"); + + b.Property("CategoryAr") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("category_ar"); + + b.Property("CategoryEn") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("category_en"); + + b.Property("CostUsd") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)") + .HasColumnName("cost_usd"); + + b.Property("DescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("IconUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("icon_url"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.HasKey("Id") + .HasName("pk_city_technologies"); + + b.HasIndex("IsActive") + .HasDatabaseName("ix_city_tech_is_active"); + + b.ToTable("city_technologies", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.KnowledgeMaps.KnowledgeMap", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("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("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.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") + .IsUnique() + .HasDatabaseName("ux_notification_template_code"); + + b.ToTable("notification_templates", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Notifications.UserNotification", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("Channel") + .HasColumnType("int") + .HasColumnName("channel"); + + b.Property("ReadOn") + .HasColumnType("datetimeoffset") + .HasColumnName("read_on"); + + b.Property("RenderedBody") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("rendered_body"); + + b.Property("RenderedLocale") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("rendered_locale"); + + b.Property("RenderedSubjectAr") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("rendered_subject_ar"); + + b.Property("RenderedSubjectEn") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("rendered_subject_en"); + + b.Property("SentOn") + .HasColumnType("datetimeoffset") + .HasColumnName("sent_on"); + + b.Property("Status") + .HasColumnType("int") + .HasColumnName("status"); + + b.Property("TemplateId") + .HasColumnType("uniqueidentifier") + .HasColumnName("template_id"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_user_notifications"); + + b.HasIndex("UserId", "Status") + .HasDatabaseName("ix_user_notification_user_status"); + + b.ToTable("user_notifications", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Surveys.SearchQueryLog", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("locale"); + + b.Property("QueryText") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("query_text"); + + b.Property("ResponseTimeMs") + .HasColumnType("int") + .HasColumnName("response_time_ms"); + + b.Property("ResultsCount") + .HasColumnType("int") + .HasColumnName("results_count"); + + b.Property("SubmittedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("submitted_on"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_search_query_logs"); + + b.HasIndex("SubmittedOn") + .HasDatabaseName("ix_search_query_log_submitted_on"); + + b.ToTable("search_query_logs", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Surveys.ServiceRating", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CommentAr") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("comment_ar"); + + b.Property("CommentEn") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("comment_en"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("locale"); + + b.Property("Page") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("page"); + + b.Property("Rating") + .HasColumnType("int") + .HasColumnName("rating"); + + b.Property("SubmittedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("submitted_on"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_service_ratings"); + + b.HasIndex("SubmittedOn") + .HasDatabaseName("ix_service_rating_submitted_on"); + + b.ToTable("service_ratings", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)") + .HasColumnName("claim_type"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)") + .HasColumnName("claim_value"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier") + .HasColumnName("role_id"); + + b.HasKey("Id") + .HasName("pk_asp_net_role_claims"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_asp_net_role_claims_role_id"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)") + .HasColumnName("claim_type"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)") + .HasColumnName("claim_value"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_asp_net_user_claims"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_asp_net_user_claims_user_id"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)") + .HasColumnName("login_provider"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)") + .HasColumnName("provider_key"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)") + .HasColumnName("provider_display_name"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("LoginProvider", "ProviderKey") + .HasName("pk_asp_net_user_logins"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_asp_net_user_logins_user_id"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier") + .HasColumnName("role_id"); + + b.HasKey("UserId", "RoleId") + .HasName("pk_asp_net_user_roles"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_asp_net_user_roles_role_id"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)") + .HasColumnName("login_provider"); + + b.Property("Name") + .HasColumnType("nvarchar(450)") + .HasColumnName("name"); + + b.Property("Value") + .HasColumnType("nvarchar(max)") + .HasColumnName("value"); + + b.HasKey("UserId", "LoginProvider", "Name") + .HasName("pk_asp_net_user_tokens"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.RefreshToken", b => + { + b.HasOne("CCE.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_refresh_tokens_asp_net_users_user_id"); + }); + + modelBuilder.Entity("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"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/CCE.Infrastructure/Persistence/Migrations/20260514202038_AddLocalAuthRefreshTokens.cs b/backend/src/CCE.Infrastructure/Persistence/Migrations/20260514202038_AddLocalAuthRefreshTokens.cs new file mode 100644 index 00000000..c2c32b79 --- /dev/null +++ b/backend/src/CCE.Infrastructure/Persistence/Migrations/20260514202038_AddLocalAuthRefreshTokens.cs @@ -0,0 +1,113 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CCE.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddLocalAuthRefreshTokens : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "first_name", + table: "AspNetUsers", + type: "nvarchar(50)", + maxLength: 50, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "job_title", + table: "AspNetUsers", + type: "nvarchar(50)", + maxLength: 50, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "last_name", + table: "AspNetUsers", + type: "nvarchar(50)", + maxLength: 50, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "organization_name", + table: "AspNetUsers", + type: "nvarchar(100)", + maxLength: 100, + nullable: false, + defaultValue: ""); + + migrationBuilder.CreateTable( + name: "refresh_tokens", + columns: table => new + { + id = table.Column(type: "uniqueidentifier", nullable: false), + user_id = table.Column(type: "uniqueidentifier", nullable: false), + token_hash = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + token_family_id = table.Column(type: "uniqueidentifier", nullable: false), + created_at_utc = table.Column(type: "datetimeoffset", nullable: false), + expires_at_utc = table.Column(type: "datetimeoffset", nullable: false), + revoked_at_utc = table.Column(type: "datetimeoffset", nullable: true), + replaced_by_token_hash = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: true), + created_by_ip = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true), + revoked_by_ip = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true), + user_agent = table.Column(type: "nvarchar(512)", maxLength: 512, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_refresh_tokens", x => x.id); + table.ForeignKey( + name: "fk_refresh_tokens_asp_net_users_user_id", + column: x => x.user_id, + principalTable: "AspNetUsers", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "ix_refresh_tokens_token_family_id", + table: "refresh_tokens", + column: "token_family_id"); + + migrationBuilder.CreateIndex( + name: "ix_refresh_tokens_user_id", + table: "refresh_tokens", + column: "user_id"); + + migrationBuilder.CreateIndex( + name: "ux_refresh_tokens_token_hash", + table: "refresh_tokens", + column: "token_hash", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "refresh_tokens"); + + migrationBuilder.DropColumn( + name: "first_name", + table: "AspNetUsers"); + + migrationBuilder.DropColumn( + name: "job_title", + table: "AspNetUsers"); + + migrationBuilder.DropColumn( + name: "last_name", + table: "AspNetUsers"); + + migrationBuilder.DropColumn( + name: "organization_name", + table: "AspNetUsers"); + } + } +} diff --git a/backend/src/CCE.Infrastructure/Persistence/Migrations/CceDbContextModelSnapshot.cs b/backend/src/CCE.Infrastructure/Persistence/Migrations/CceDbContextModelSnapshot.cs index ff901e75..8f671e33 100644 --- a/backend/src/CCE.Infrastructure/Persistence/Migrations/CceDbContextModelSnapshot.cs +++ b/backend/src/CCE.Infrastructure/Persistence/Migrations/CceDbContextModelSnapshot.cs @@ -17,7 +17,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "8.0.10") + .HasAnnotation("ProductVersion", "10.0.1") .HasAnnotation("Relational:MaxIdentifierLength", 128); SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); @@ -1253,7 +1253,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("datetimeoffset") .HasColumnName("deleted_on"); - b.Property("ExpertiseTags") + b.PrimitiveCollection("ExpertiseTags") .IsRequired() .HasColumnType("nvarchar(max)") .HasColumnName("expertise_tags"); @@ -1329,7 +1329,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("uniqueidentifier") .HasColumnName("requested_by_id"); - b.Property("RequestedTags") + b.PrimitiveCollection("RequestedTags") .IsRequired() .HasColumnType("nvarchar(max)") .HasColumnName("requested_tags"); @@ -1354,6 +1354,74 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("expert_registration_requests", (string)null); }); + modelBuilder.Entity("CCE.Domain.Identity.RefreshToken", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset") + .HasColumnName("created_at_utc"); + + b.Property("CreatedByIp") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("created_by_ip"); + + b.Property("ExpiresAtUtc") + .HasColumnType("datetimeoffset") + .HasColumnName("expires_at_utc"); + + b.Property("ReplacedByTokenHash") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("replaced_by_token_hash"); + + b.Property("RevokedAtUtc") + .HasColumnType("datetimeoffset") + .HasColumnName("revoked_at_utc"); + + b.Property("RevokedByIp") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("revoked_by_ip"); + + b.Property("TokenFamilyId") + .HasColumnType("uniqueidentifier") + .HasColumnName("token_family_id"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("token_hash"); + + b.Property("UserAgent") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_refresh_tokens"); + + b.HasIndex("TokenFamilyId") + .HasDatabaseName("ix_refresh_tokens_token_family_id"); + + b.HasIndex("TokenHash") + .IsUnique() + .HasDatabaseName("ux_refresh_tokens_token_hash"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_refresh_tokens_user_id"); + + b.ToTable("refresh_tokens", (string)null); + }); + modelBuilder.Entity("CCE.Domain.Identity.Role", b => { b.Property("Id") @@ -1484,15 +1552,33 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("uniqueidentifier") .HasColumnName("entra_id_object_id"); - b.Property("Interests") + b.Property("FirstName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)") + .HasColumnName("first_name"); + + b.PrimitiveCollection("Interests") .IsRequired() .HasColumnType("nvarchar(max)") .HasColumnName("interests"); + b.Property("JobTitle") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)") + .HasColumnName("job_title"); + b.Property("KnowledgeLevel") .HasColumnType("int") .HasColumnName("knowledge_level"); + b.Property("LastName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)") + .HasColumnName("last_name"); + b.Property("LocalePreference") .IsRequired() .HasMaxLength(2) @@ -1517,6 +1603,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) .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"); @@ -2277,6 +2369,16 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("AspNetUserTokens", (string)null); }); + modelBuilder.Entity("CCE.Domain.Identity.RefreshToken", b => + { + b.HasOne("CCE.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_refresh_tokens_asp_net_users_user_id"); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { b.HasOne("CCE.Domain.Identity.Role", null) diff --git a/backend/src/CCE.Infrastructure/Reports/CountryProfilesReportService.cs b/backend/src/CCE.Infrastructure/Reports/CountryProfilesReportService.cs index 2b7e607c..82709497 100644 --- a/backend/src/CCE.Infrastructure/Reports/CountryProfilesReportService.cs +++ b/backend/src/CCE.Infrastructure/Reports/CountryProfilesReportService.cs @@ -33,8 +33,8 @@ public async System.Collections.Generic.IAsyncEnumerable x.LastProfileUpdatedOn >= from); diff --git a/backend/tests/CCE.Api.IntegrationTests/Auth/ExternalJwtAuthTests.cs b/backend/tests/CCE.Api.IntegrationTests/Auth/ExternalJwtAuthTests.cs index e51fc5e4..bfa6a6c6 100644 --- a/backend/tests/CCE.Api.IntegrationTests/Auth/ExternalJwtAuthTests.cs +++ b/backend/tests/CCE.Api.IntegrationTests/Auth/ExternalJwtAuthTests.cs @@ -5,11 +5,11 @@ namespace CCE.Api.IntegrationTests.Auth; -public class ExternalJwtAuthTests : IClassFixture> +public class ExternalJwtAuthTests : IClassFixture> { - private readonly WebApplicationFactory _factory; + private readonly WebApplicationFactory _factory; - public ExternalJwtAuthTests(WebApplicationFactory factory) => _factory = factory; + public ExternalJwtAuthTests(WebApplicationFactory factory) => _factory = factory; [Fact] public async Task Returns_401_without_token() diff --git a/backend/tests/CCE.Api.IntegrationTests/Auth/InternalJwtAuthTests.cs b/backend/tests/CCE.Api.IntegrationTests/Auth/InternalJwtAuthTests.cs index a4f9f9b4..282872b7 100644 --- a/backend/tests/CCE.Api.IntegrationTests/Auth/InternalJwtAuthTests.cs +++ b/backend/tests/CCE.Api.IntegrationTests/Auth/InternalJwtAuthTests.cs @@ -5,11 +5,11 @@ namespace CCE.Api.IntegrationTests.Auth; -public class InternalJwtAuthTests : IClassFixture> +public class InternalJwtAuthTests : IClassFixture> { - private readonly WebApplicationFactory _factory; + private readonly WebApplicationFactory _factory; - public InternalJwtAuthTests(WebApplicationFactory factory) => _factory = factory; + public InternalJwtAuthTests(WebApplicationFactory factory) => _factory = factory; [Fact] public async Task Returns_401_without_token() diff --git a/backend/tests/CCE.Api.IntegrationTests/E2E/EndToEndAuthFlowTests.cs b/backend/tests/CCE.Api.IntegrationTests/E2E/EndToEndAuthFlowTests.cs index 974d68cc..4f067017 100644 --- a/backend/tests/CCE.Api.IntegrationTests/E2E/EndToEndAuthFlowTests.cs +++ b/backend/tests/CCE.Api.IntegrationTests/E2E/EndToEndAuthFlowTests.cs @@ -4,11 +4,11 @@ namespace CCE.Api.IntegrationTests.E2E; -public class EndToEndAuthFlowTests : IClassFixture> +public class EndToEndAuthFlowTests : IClassFixture> { - private readonly CceTestWebApplicationFactory _factory; + private readonly CceTestWebApplicationFactory _factory; - public EndToEndAuthFlowTests(CceTestWebApplicationFactory factory) => _factory = factory; + public EndToEndAuthFlowTests(CceTestWebApplicationFactory factory) => _factory = factory; [Fact] public async Task Anonymous_health_returns_200() diff --git a/backend/tests/CCE.Api.IntegrationTests/Endpoints/HealthAuthenticatedEndpointTests.cs b/backend/tests/CCE.Api.IntegrationTests/Endpoints/HealthAuthenticatedEndpointTests.cs index 7e13574b..1b518d20 100644 --- a/backend/tests/CCE.Api.IntegrationTests/Endpoints/HealthAuthenticatedEndpointTests.cs +++ b/backend/tests/CCE.Api.IntegrationTests/Endpoints/HealthAuthenticatedEndpointTests.cs @@ -5,11 +5,11 @@ namespace CCE.Api.IntegrationTests.Endpoints; -public class HealthAuthenticatedEndpointTests : IClassFixture> +public class HealthAuthenticatedEndpointTests : IClassFixture> { - private readonly WebApplicationFactory _factory; + private readonly WebApplicationFactory _factory; - public HealthAuthenticatedEndpointTests(WebApplicationFactory factory) => _factory = factory; + public HealthAuthenticatedEndpointTests(WebApplicationFactory factory) => _factory = factory; [Fact] public async Task Returns_401_without_token() diff --git a/backend/tests/CCE.Api.IntegrationTests/Endpoints/HealthEndpointTests.cs b/backend/tests/CCE.Api.IntegrationTests/Endpoints/HealthEndpointTests.cs index aa08fa00..cc3fea29 100644 --- a/backend/tests/CCE.Api.IntegrationTests/Endpoints/HealthEndpointTests.cs +++ b/backend/tests/CCE.Api.IntegrationTests/Endpoints/HealthEndpointTests.cs @@ -5,11 +5,11 @@ namespace CCE.Api.IntegrationTests.Endpoints; -public class HealthEndpointTests : IClassFixture> +public class HealthEndpointTests : IClassFixture> { - private readonly WebApplicationFactory _factory; + private readonly WebApplicationFactory _factory; - public HealthEndpointTests(WebApplicationFactory factory) => _factory = factory; + public HealthEndpointTests(WebApplicationFactory factory) => _factory = factory; [Fact] public async Task Returns_ok_status_with_locale_from_accept_language() diff --git a/backend/tests/CCE.Api.IntegrationTests/Endpoints/HealthReadyEndpointTests.cs b/backend/tests/CCE.Api.IntegrationTests/Endpoints/HealthReadyEndpointTests.cs index c6432660..a2a64753 100644 --- a/backend/tests/CCE.Api.IntegrationTests/Endpoints/HealthReadyEndpointTests.cs +++ b/backend/tests/CCE.Api.IntegrationTests/Endpoints/HealthReadyEndpointTests.cs @@ -6,11 +6,11 @@ namespace CCE.Api.IntegrationTests.Endpoints; -public class HealthReadyEndpointTests : IClassFixture> +public class HealthReadyEndpointTests : IClassFixture> { - private readonly WebApplicationFactory _factory; + private readonly WebApplicationFactory _factory; - public HealthReadyEndpointTests(WebApplicationFactory factory) => _factory = factory; + public HealthReadyEndpointTests(WebApplicationFactory factory) => _factory = factory; [Fact] public async Task Returns_200_when_all_dependencies_healthy() diff --git a/backend/tests/CCE.Api.IntegrationTests/Endpoints/NotificationsEndpointTests.cs b/backend/tests/CCE.Api.IntegrationTests/Endpoints/NotificationsEndpointTests.cs index 33c47207..86f647eb 100644 --- a/backend/tests/CCE.Api.IntegrationTests/Endpoints/NotificationsEndpointTests.cs +++ b/backend/tests/CCE.Api.IntegrationTests/Endpoints/NotificationsEndpointTests.cs @@ -4,11 +4,11 @@ namespace CCE.Api.IntegrationTests.Endpoints; -public class NotificationsEndpointTests : IClassFixture> +public class NotificationsEndpointTests : IClassFixture> { - private readonly WebApplicationFactory _factory; + private readonly WebApplicationFactory _factory; - public NotificationsEndpointTests(WebApplicationFactory factory) + public NotificationsEndpointTests(WebApplicationFactory factory) { _factory = factory; } diff --git a/backend/tests/CCE.Api.IntegrationTests/Identity/UserSyncMiddlewareTests.cs b/backend/tests/CCE.Api.IntegrationTests/Identity/UserSyncMiddlewareTests.cs index 7b8de878..3736655d 100644 --- a/backend/tests/CCE.Api.IntegrationTests/Identity/UserSyncMiddlewareTests.cs +++ b/backend/tests/CCE.Api.IntegrationTests/Identity/UserSyncMiddlewareTests.cs @@ -15,7 +15,7 @@ public class UserSyncMiddlewareTests [Fact] public async Task First_authenticated_request_calls_sync_service() { - var sync = Substitute.For(); + var sync = Substitute.For(); var sub = Guid.NewGuid(); using var host = BuildHost(sync, authenticated: true, sub: sub.ToString()); var client = host.GetTestClient(); @@ -34,7 +34,7 @@ await sync.Received(1).EnsureUserExistsAsync( [Fact] public async Task Repeat_request_uses_cache_and_does_not_call_sync_service_again() { - var sync = Substitute.For(); + var sync = Substitute.For(); using var host = BuildHost(sync, authenticated: true, sub: Guid.NewGuid().ToString()); var client = host.GetTestClient(); @@ -53,7 +53,7 @@ await sync.Received(1).EnsureUserExistsAsync( [Fact] public async Task Anonymous_request_does_not_invoke_sync_service() { - var sync = Substitute.For(); + var sync = Substitute.For(); using var host = BuildHost(sync, authenticated: false); var client = host.GetTestClient(); @@ -67,7 +67,7 @@ await sync.DidNotReceiveWithAnyArgs().EnsureUserExistsAsync( [Fact] public async Task Authenticated_request_with_unparseable_sub_does_not_invoke_sync_service() { - var sync = Substitute.For(); + var sync = Substitute.For(); using var host = BuildHost(sync, authenticated: true, sub: "not-a-guid"); var client = host.GetTestClient(); @@ -78,7 +78,7 @@ await sync.DidNotReceiveWithAnyArgs().EnsureUserExistsAsync( default, default!, default!, default!, default); } - private static IHost BuildHost(IUserSyncService sync, bool authenticated, string sub = "") + private static IHost BuildHost(IUserSyncRepository sync, bool authenticated, string sub = "") { return new HostBuilder() .ConfigureWebHost(web => diff --git a/backend/tests/CCE.Application.Tests/Content/Commands/ApproveCountryResourceRequestCommandHandlerTests.cs b/backend/tests/CCE.Application.Tests/Content/Commands/ApproveCountryResourceRequestCommandHandlerTests.cs index cf3a5bbd..5fd1f78e 100644 --- a/backend/tests/CCE.Application.Tests/Content/Commands/ApproveCountryResourceRequestCommandHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Content/Commands/ApproveCountryResourceRequestCommandHandlerTests.cs @@ -13,7 +13,7 @@ public class ApproveCountryResourceRequestCommandHandlerTests [Fact] public async Task Throws_KeyNotFound_when_request_missing() { - var service = Substitute.For(); + var service = Substitute.For(); service.FindIncludingDeletedAsync(Arg.Any(), Arg.Any()) .Returns((CountryResourceRequest?)null); @@ -32,7 +32,7 @@ public async Task Throws_DomainException_when_actor_unknown() var clock = new FakeSystemClock(); var entity = BuildPendingRequest(clock); - var service = Substitute.For(); + var service = Substitute.For(); service.FindIncludingDeletedAsync(Arg.Any(), Arg.Any()) .Returns(entity); @@ -55,7 +55,7 @@ public async Task Approves_request_and_returns_dto_when_valid() var adminId = System.Guid.NewGuid(); var entity = BuildPendingRequest(clock); - var service = Substitute.For(); + var service = Substitute.For(); service.FindIncludingDeletedAsync(Arg.Any(), Arg.Any()) .Returns(entity); @@ -87,7 +87,7 @@ private static ICurrentUserAccessor BuildCurrentUser(System.Guid? userId = null) } private static ApproveCountryResourceRequestCommandHandler BuildSut( - ICountryResourceRequestService service, + ICountryResourceRequestRepository service, ICurrentUserAccessor currentUser, FakeSystemClock? clock = null) => new(service, currentUser, clock ?? new FakeSystemClock()); diff --git a/backend/tests/CCE.Application.Tests/Content/Commands/CreateEventCommandHandlerTests.cs b/backend/tests/CCE.Application.Tests/Content/Commands/CreateEventCommandHandlerTests.cs index d0046cd5..94e9548f 100644 --- a/backend/tests/CCE.Application.Tests/Content/Commands/CreateEventCommandHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Content/Commands/CreateEventCommandHandlerTests.cs @@ -41,9 +41,9 @@ private static CreateEventCommand BuildCmd() => new("حدث", "Event", "وصف", "Description", StartsOn, EndsOn, null, null, null, null); - private static (CreateEventCommandHandler sut, IEventService service) BuildSut() + private static (CreateEventCommandHandler sut, IEventRepository service) BuildSut() { - var service = Substitute.For(); + var service = Substitute.For(); var sut = new CreateEventCommandHandler(service, new FakeSystemClock()); return (sut, service); } diff --git a/backend/tests/CCE.Application.Tests/Content/Commands/CreateHomepageSectionCommandHandlerTests.cs b/backend/tests/CCE.Application.Tests/Content/Commands/CreateHomepageSectionCommandHandlerTests.cs index b99a7e15..a72f8c61 100644 --- a/backend/tests/CCE.Application.Tests/Content/Commands/CreateHomepageSectionCommandHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Content/Commands/CreateHomepageSectionCommandHandlerTests.cs @@ -9,7 +9,7 @@ public class CreateHomepageSectionCommandHandlerTests [Fact] public async Task Persists_section_and_returns_dto() { - var service = Substitute.For(); + var service = Substitute.For(); var sut = new CreateHomepageSectionCommandHandler(service); var cmd = new CreateHomepageSectionCommand( diff --git a/backend/tests/CCE.Application.Tests/Content/Commands/CreateNewsCommandHandlerTests.cs b/backend/tests/CCE.Application.Tests/Content/Commands/CreateNewsCommandHandlerTests.cs index 7968ff8c..1182bb1e 100644 --- a/backend/tests/CCE.Application.Tests/Content/Commands/CreateNewsCommandHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Content/Commands/CreateNewsCommandHandlerTests.cs @@ -45,9 +45,9 @@ public async Task Returns_dto_with_correct_fields() private static CreateNewsCommand BuildCmd() => new("خبر", "News", "محتوى", "Content", "first-post", null); - private static (CreateNewsCommandHandler sut, INewsService service, ICurrentUserAccessor user) BuildSut(bool noUser = false) + private static (CreateNewsCommandHandler sut, INewsRepository service, ICurrentUserAccessor user) BuildSut(bool noUser = false) { - var service = Substitute.For(); + var service = Substitute.For(); var user = Substitute.For(); if (noUser) user.GetUserId().Returns((System.Guid?)null); diff --git a/backend/tests/CCE.Application.Tests/Content/Commands/CreatePageCommandHandlerTests.cs b/backend/tests/CCE.Application.Tests/Content/Commands/CreatePageCommandHandlerTests.cs index fd378053..0b1caff0 100644 --- a/backend/tests/CCE.Application.Tests/Content/Commands/CreatePageCommandHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Content/Commands/CreatePageCommandHandlerTests.cs @@ -34,9 +34,9 @@ public async Task Returns_dto_with_correct_fields() private static CreatePageCommand BuildCmd() => new("test-slug", PageType.Custom, "ar", "en", "content-ar", "content-en"); - private static (CreatePageCommandHandler sut, IPageService service) BuildSut() + private static (CreatePageCommandHandler sut, IPageRepository service) BuildSut() { - var service = Substitute.For(); + var service = Substitute.For(); var sut = new CreatePageCommandHandler(service); return (sut, service); } diff --git a/backend/tests/CCE.Application.Tests/Content/Commands/CreateResourceCategoryCommandHandlerTests.cs b/backend/tests/CCE.Application.Tests/Content/Commands/CreateResourceCategoryCommandHandlerTests.cs index 947a5445..dd8c94d2 100644 --- a/backend/tests/CCE.Application.Tests/Content/Commands/CreateResourceCategoryCommandHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Content/Commands/CreateResourceCategoryCommandHandlerTests.cs @@ -8,7 +8,7 @@ public class CreateResourceCategoryCommandHandlerTests [Fact] public async Task Creates_category_saves_and_returns_dto() { - var service = Substitute.For(); + var service = Substitute.For(); var sut = new CreateResourceCategoryCommandHandler(service); var cmd = new CreateResourceCategoryCommand("طاقة", "Energy", "energy", null, 0); diff --git a/backend/tests/CCE.Application.Tests/Content/Commands/CreateResourceCommandHandlerTests.cs b/backend/tests/CCE.Application.Tests/Content/Commands/CreateResourceCommandHandlerTests.cs index 82c17081..703fbccd 100644 --- a/backend/tests/CCE.Application.Tests/Content/Commands/CreateResourceCommandHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Content/Commands/CreateResourceCommandHandlerTests.cs @@ -74,10 +74,10 @@ private static CreateResourceCommand BuildCmd(System.Guid assetFileId) => null, assetFileId); - private static (CreateResourceCommandHandler sut, IResourceService service, IAssetService asset, ICurrentUserAccessor user) BuildSut(bool noUser = false) + private static (CreateResourceCommandHandler sut, IResourceRepository service, IAssetRepository asset, ICurrentUserAccessor user) BuildSut(bool noUser = false) { - var service = Substitute.For(); - var asset = Substitute.For(); + var service = Substitute.For(); + var asset = Substitute.For(); var user = Substitute.For(); if (noUser) user.GetUserId().Returns((System.Guid?)null); diff --git a/backend/tests/CCE.Application.Tests/Content/Commands/DeleteEventCommandHandlerTests.cs b/backend/tests/CCE.Application.Tests/Content/Commands/DeleteEventCommandHandlerTests.cs index 12c5838f..5206af1e 100644 --- a/backend/tests/CCE.Application.Tests/Content/Commands/DeleteEventCommandHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Content/Commands/DeleteEventCommandHandlerTests.cs @@ -18,7 +18,7 @@ public class DeleteEventCommandHandlerTests [Fact] public async Task Throws_KeyNotFound_when_event_missing() { - var service = Substitute.For(); + var service = Substitute.For(); service.FindAsync(Arg.Any(), Arg.Any()).Returns((Event?)null); var currentUser = Substitute.For(); var sut = new DeleteEventCommandHandler(service, currentUser, new FakeSystemClock()); @@ -36,7 +36,7 @@ public async Task Throws_DomainException_when_actor_unknown() "ar", "en", "desc-ar", "desc-en", StartsOn, EndsOn, null, null, null, null, clock); - var service = Substitute.For(); + var service = Substitute.For(); service.FindAsync(ev.Id, Arg.Any()).Returns(ev); var currentUser = Substitute.For(); @@ -58,7 +58,7 @@ public async Task Soft_deletes_and_calls_UpdateAsync() "ar", "en", "desc-ar", "desc-en", StartsOn, EndsOn, null, null, null, null, clock); - var service = Substitute.For(); + var service = Substitute.For(); service.FindAsync(ev.Id, Arg.Any()).Returns(ev); var currentUser = Substitute.For(); diff --git a/backend/tests/CCE.Application.Tests/Content/Commands/DeleteHomepageSectionCommandHandlerTests.cs b/backend/tests/CCE.Application.Tests/Content/Commands/DeleteHomepageSectionCommandHandlerTests.cs index 6de725e0..b930e075 100644 --- a/backend/tests/CCE.Application.Tests/Content/Commands/DeleteHomepageSectionCommandHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Content/Commands/DeleteHomepageSectionCommandHandlerTests.cs @@ -12,7 +12,7 @@ public class DeleteHomepageSectionCommandHandlerTests [Fact] public async Task Throws_KeyNotFound_when_section_missing() { - var service = Substitute.For(); + var service = Substitute.For(); service.FindAsync(Arg.Any(), Arg.Any()).Returns((HomepageSection?)null); var currentUser = Substitute.For(); var sut = new DeleteHomepageSectionCommandHandler(service, currentUser, new FakeSystemClock()); @@ -27,7 +27,7 @@ public async Task Throws_DomainException_when_actor_unknown() { var section = HomepageSection.Create(HomepageSectionType.Hero, 0, "ar", "en"); - var service = Substitute.For(); + var service = Substitute.For(); service.FindAsync(section.Id, Arg.Any()).Returns(section); var currentUser = Substitute.For(); @@ -47,7 +47,7 @@ public async Task Soft_deletes_and_calls_UpdateAsync() var actorId = System.Guid.NewGuid(); var section = HomepageSection.Create(HomepageSectionType.Hero, 0, "ar", "en"); - var service = Substitute.For(); + var service = Substitute.For(); service.FindAsync(section.Id, Arg.Any()).Returns(section); var currentUser = Substitute.For(); diff --git a/backend/tests/CCE.Application.Tests/Content/Commands/DeleteNewsCommandHandlerTests.cs b/backend/tests/CCE.Application.Tests/Content/Commands/DeleteNewsCommandHandlerTests.cs index d9318b4d..be279435 100644 --- a/backend/tests/CCE.Application.Tests/Content/Commands/DeleteNewsCommandHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Content/Commands/DeleteNewsCommandHandlerTests.cs @@ -12,7 +12,7 @@ public class DeleteNewsCommandHandlerTests [Fact] public async Task Throws_KeyNotFound_when_news_missing() { - var service = Substitute.For(); + var service = Substitute.For(); service.FindAsync(Arg.Any(), Arg.Any()).Returns((News?)null); var currentUser = Substitute.For(); var sut = new DeleteNewsCommandHandler(service, currentUser, new FakeSystemClock()); @@ -28,7 +28,7 @@ public async Task Throws_DomainException_when_actor_unknown() var clock = new FakeSystemClock(); var news = News.Draft("ar", "en", "content-ar", "content-en", "slug", System.Guid.NewGuid(), null, clock); - var service = Substitute.For(); + var service = Substitute.For(); service.FindAsync(news.Id, Arg.Any()).Returns(news); var currentUser = Substitute.For(); @@ -48,7 +48,7 @@ public async Task Soft_deletes_and_calls_UpdateAsync() var actorId = System.Guid.NewGuid(); var news = News.Draft("ar", "en", "content-ar", "content-en", "slug", System.Guid.NewGuid(), null, clock); - var service = Substitute.For(); + var service = Substitute.For(); service.FindAsync(news.Id, Arg.Any()).Returns(news); var currentUser = Substitute.For(); diff --git a/backend/tests/CCE.Application.Tests/Content/Commands/DeletePageCommandHandlerTests.cs b/backend/tests/CCE.Application.Tests/Content/Commands/DeletePageCommandHandlerTests.cs index 86598131..0a71a3c2 100644 --- a/backend/tests/CCE.Application.Tests/Content/Commands/DeletePageCommandHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Content/Commands/DeletePageCommandHandlerTests.cs @@ -12,7 +12,7 @@ public class DeletePageCommandHandlerTests [Fact] public async Task Throws_KeyNotFound_when_page_missing() { - var service = Substitute.For(); + var service = Substitute.For(); service.FindAsync(Arg.Any(), Arg.Any()).Returns((Page?)null); var currentUser = Substitute.For(); var sut = new DeletePageCommandHandler(service, currentUser, new FakeSystemClock()); @@ -27,7 +27,7 @@ public async Task Throws_DomainException_when_actor_unknown() { var page = Page.Create("my-slug", PageType.Custom, "ar", "en", "content-ar", "content-en"); - var service = Substitute.For(); + var service = Substitute.For(); service.FindAsync(page.Id, Arg.Any()).Returns(page); var currentUser = Substitute.For(); @@ -47,7 +47,7 @@ public async Task Soft_deletes_and_calls_UpdateAsync() var actorId = System.Guid.NewGuid(); var page = Page.Create("my-slug", PageType.Custom, "ar", "en", "content-ar", "content-en"); - var service = Substitute.For(); + var service = Substitute.For(); service.FindAsync(page.Id, Arg.Any()).Returns(page); var currentUser = Substitute.For(); diff --git a/backend/tests/CCE.Application.Tests/Content/Commands/DeleteResourceCategoryCommandHandlerTests.cs b/backend/tests/CCE.Application.Tests/Content/Commands/DeleteResourceCategoryCommandHandlerTests.cs index 9dcfe2ea..aa85402e 100644 --- a/backend/tests/CCE.Application.Tests/Content/Commands/DeleteResourceCategoryCommandHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Content/Commands/DeleteResourceCategoryCommandHandlerTests.cs @@ -9,7 +9,7 @@ public class DeleteResourceCategoryCommandHandlerTests [Fact] public async Task Throws_KeyNotFoundException_when_category_not_found() { - var service = Substitute.For(); + var service = Substitute.For(); service.FindAsync(Arg.Any(), Arg.Any()).Returns((ResourceCategory?)null); var sut = new DeleteResourceCategoryCommandHandler(service); @@ -22,7 +22,7 @@ public async Task Throws_KeyNotFoundException_when_category_not_found() public async Task Deactivates_category_and_calls_UpdateAsync() { var category = ResourceCategory.Create("نشط", "Active", "active-del", null, 0); - var service = Substitute.For(); + var service = Substitute.For(); service.FindAsync(category.Id, Arg.Any()).Returns(category); var sut = new DeleteResourceCategoryCommandHandler(service); diff --git a/backend/tests/CCE.Application.Tests/Content/Commands/PublishNewsCommandHandlerTests.cs b/backend/tests/CCE.Application.Tests/Content/Commands/PublishNewsCommandHandlerTests.cs index 6036db35..8990293a 100644 --- a/backend/tests/CCE.Application.Tests/Content/Commands/PublishNewsCommandHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Content/Commands/PublishNewsCommandHandlerTests.cs @@ -10,7 +10,7 @@ public class PublishNewsCommandHandlerTests [Fact] public async Task Returns_null_when_news_not_found() { - var service = Substitute.For(); + var service = Substitute.For(); service.FindAsync(Arg.Any(), Arg.Any()).Returns((News?)null); var sut = new PublishNewsCommandHandler(service, new FakeSystemClock()); @@ -25,7 +25,7 @@ public async Task Publishes_and_returns_dto_when_valid() var clock = new FakeSystemClock(); var news = News.Draft("ar", "en", "content-ar", "content-en", "slug", System.Guid.NewGuid(), null, clock); - var service = Substitute.For(); + var service = Substitute.For(); service.FindAsync(news.Id, Arg.Any()).Returns(news); var sut = new PublishNewsCommandHandler(service, clock); @@ -46,7 +46,7 @@ public async Task Returns_dto_unchanged_when_already_published() news.Publish(clock); // already published var firstPublishedOn = news.PublishedOn; - var service = Substitute.For(); + var service = Substitute.For(); service.FindAsync(news.Id, Arg.Any()).Returns(news); var sut = new PublishNewsCommandHandler(service, clock); diff --git a/backend/tests/CCE.Application.Tests/Content/Commands/PublishResourceCommandHandlerTests.cs b/backend/tests/CCE.Application.Tests/Content/Commands/PublishResourceCommandHandlerTests.cs index 91bd9eaf..e1b2fd9b 100644 --- a/backend/tests/CCE.Application.Tests/Content/Commands/PublishResourceCommandHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Content/Commands/PublishResourceCommandHandlerTests.cs @@ -87,10 +87,10 @@ public async Task Returns_dto_unchanged_when_already_published() dto.PublishedOn.Should().Be(firstPublishedOn); } - private static (PublishResourceCommandHandler sut, IResourceService rs, IAssetService asset) BuildSut() + private static (PublishResourceCommandHandler sut, IResourceRepository rs, IAssetRepository asset) BuildSut() { - var rs = Substitute.For(); - var asset = Substitute.For(); + var rs = Substitute.For(); + var asset = Substitute.For(); var sut = new PublishResourceCommandHandler(rs, asset, new FakeSystemClock()); return (sut, rs, asset); } diff --git a/backend/tests/CCE.Application.Tests/Content/Commands/RejectCountryResourceRequestCommandHandlerTests.cs b/backend/tests/CCE.Application.Tests/Content/Commands/RejectCountryResourceRequestCommandHandlerTests.cs index d2c3ccc6..045e7d34 100644 --- a/backend/tests/CCE.Application.Tests/Content/Commands/RejectCountryResourceRequestCommandHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Content/Commands/RejectCountryResourceRequestCommandHandlerTests.cs @@ -13,7 +13,7 @@ public class RejectCountryResourceRequestCommandHandlerTests [Fact] public async Task Throws_KeyNotFound_when_request_missing() { - var service = Substitute.For(); + var service = Substitute.For(); service.FindIncludingDeletedAsync(Arg.Any(), Arg.Any()) .Returns((CountryResourceRequest?)null); @@ -32,7 +32,7 @@ public async Task Throws_DomainException_when_actor_unknown() var clock = new FakeSystemClock(); var entity = BuildPendingRequest(clock); - var service = Substitute.For(); + var service = Substitute.For(); service.FindIncludingDeletedAsync(Arg.Any(), Arg.Any()) .Returns(entity); @@ -55,7 +55,7 @@ public async Task Rejects_request_and_returns_dto_when_valid() var adminId = System.Guid.NewGuid(); var entity = BuildPendingRequest(clock); - var service = Substitute.For(); + var service = Substitute.For(); service.FindIncludingDeletedAsync(Arg.Any(), Arg.Any()) .Returns(entity); @@ -87,7 +87,7 @@ private static ICurrentUserAccessor BuildCurrentUser(System.Guid? userId = null) } private static RejectCountryResourceRequestCommandHandler BuildSut( - ICountryResourceRequestService service, + ICountryResourceRequestRepository service, ICurrentUserAccessor currentUser, FakeSystemClock? clock = null) => new(service, currentUser, clock ?? new FakeSystemClock()); diff --git a/backend/tests/CCE.Application.Tests/Content/Commands/ReorderHomepageSectionsCommandHandlerTests.cs b/backend/tests/CCE.Application.Tests/Content/Commands/ReorderHomepageSectionsCommandHandlerTests.cs index 2a432ab6..23385540 100644 --- a/backend/tests/CCE.Application.Tests/Content/Commands/ReorderHomepageSectionsCommandHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Content/Commands/ReorderHomepageSectionsCommandHandlerTests.cs @@ -8,7 +8,7 @@ public class ReorderHomepageSectionsCommandHandlerTests [Fact] public async Task Forwards_assignments_to_service() { - var service = Substitute.For(); + var service = Substitute.For(); var sut = new ReorderHomepageSectionsCommandHandler(service); var assignments = new[] { diff --git a/backend/tests/CCE.Application.Tests/Content/Commands/RescheduleEventCommandHandlerTests.cs b/backend/tests/CCE.Application.Tests/Content/Commands/RescheduleEventCommandHandlerTests.cs index 21b6c7b2..df9a3a1a 100644 --- a/backend/tests/CCE.Application.Tests/Content/Commands/RescheduleEventCommandHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Content/Commands/RescheduleEventCommandHandlerTests.cs @@ -17,7 +17,7 @@ public class RescheduleEventCommandHandlerTests [Fact] public async Task Returns_null_when_event_not_found() { - var service = Substitute.For(); + var service = Substitute.For(); service.FindAsync(Arg.Any(), Arg.Any()).Returns((Event?)null); var sut = new RescheduleEventCommandHandler(service); @@ -36,7 +36,7 @@ public async Task Reschedules_and_calls_UpdateAsync() "ar", "en", "desc-ar", "desc-en", StartsOn, EndsOn, null, null, null, null, clock); - var service = Substitute.For(); + var service = Substitute.For(); service.FindAsync(ev.Id, Arg.Any()).Returns(ev); var sut = new RescheduleEventCommandHandler(service); @@ -62,7 +62,7 @@ public async Task Propagates_ConcurrencyException_from_UpdateAsync() "ar", "en", "desc-ar", "desc-en", StartsOn, EndsOn, null, null, null, null, clock); - var service = Substitute.For(); + var service = Substitute.For(); service.FindAsync(ev.Id, Arg.Any()).Returns(ev); service.UpdateAsync(default!, default!, default).ReturnsForAnyArgs(_ => throw new ConcurrencyException("conflict")); diff --git a/backend/tests/CCE.Application.Tests/Content/Commands/UpdateEventCommandHandlerTests.cs b/backend/tests/CCE.Application.Tests/Content/Commands/UpdateEventCommandHandlerTests.cs index bac2d254..9feb5f04 100644 --- a/backend/tests/CCE.Application.Tests/Content/Commands/UpdateEventCommandHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Content/Commands/UpdateEventCommandHandlerTests.cs @@ -17,7 +17,7 @@ public class UpdateEventCommandHandlerTests [Fact] public async Task Returns_null_when_event_not_found() { - var service = Substitute.For(); + var service = Substitute.For(); service.FindAsync(Arg.Any(), Arg.Any()).Returns((Event?)null); var sut = new UpdateEventCommandHandler(service); @@ -34,7 +34,7 @@ public async Task Updates_content_and_calls_UpdateAsync_with_expected_rowversion "old-ar", "old-en", "old-desc-ar", "old-desc-en", StartsOn, EndsOn, null, null, null, null, clock); - var service = Substitute.For(); + var service = Substitute.For(); service.FindAsync(ev.Id, Arg.Any()).Returns(ev); var sut = new UpdateEventCommandHandler(service); @@ -63,7 +63,7 @@ public async Task Propagates_ConcurrencyException_from_UpdateAsync() "ar", "en", "desc-ar", "desc-en", StartsOn, EndsOn, null, null, null, null, clock); - var service = Substitute.For(); + var service = Substitute.For(); service.FindAsync(ev.Id, Arg.Any()).Returns(ev); service.UpdateAsync(default!, default!, default).ReturnsForAnyArgs(_ => throw new ConcurrencyException("conflict")); diff --git a/backend/tests/CCE.Application.Tests/Content/Commands/UpdateHomepageSectionCommandHandlerTests.cs b/backend/tests/CCE.Application.Tests/Content/Commands/UpdateHomepageSectionCommandHandlerTests.cs index 327f91cf..7d3d6424 100644 --- a/backend/tests/CCE.Application.Tests/Content/Commands/UpdateHomepageSectionCommandHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Content/Commands/UpdateHomepageSectionCommandHandlerTests.cs @@ -9,7 +9,7 @@ public class UpdateHomepageSectionCommandHandlerTests [Fact] public async Task Returns_null_when_section_not_found() { - var service = Substitute.For(); + var service = Substitute.For(); service.FindAsync(Arg.Any(), Arg.Any()).Returns((HomepageSection?)null); var sut = new UpdateHomepageSectionCommandHandler(service); @@ -26,7 +26,7 @@ public async Task Updates_content_and_activates_section() var section = HomepageSection.Create(HomepageSectionType.Hero, 0, "old-ar", "old-en"); section.Deactivate(); - var service = Substitute.For(); + var service = Substitute.For(); service.FindAsync(section.Id, Arg.Any()).Returns(section); var sut = new UpdateHomepageSectionCommandHandler(service); @@ -46,7 +46,7 @@ public async Task Deactivates_section_when_IsActive_false() { var section = HomepageSection.Create(HomepageSectionType.FeaturedNews, 1, "ar", "en"); - var service = Substitute.For(); + var service = Substitute.For(); service.FindAsync(section.Id, Arg.Any()).Returns(section); var sut = new UpdateHomepageSectionCommandHandler(service); diff --git a/backend/tests/CCE.Application.Tests/Content/Commands/UpdateNewsCommandHandlerTests.cs b/backend/tests/CCE.Application.Tests/Content/Commands/UpdateNewsCommandHandlerTests.cs index be4d442b..feeda1de 100644 --- a/backend/tests/CCE.Application.Tests/Content/Commands/UpdateNewsCommandHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Content/Commands/UpdateNewsCommandHandlerTests.cs @@ -11,7 +11,7 @@ public class UpdateNewsCommandHandlerTests [Fact] public async Task Returns_null_when_news_not_found() { - var service = Substitute.For(); + var service = Substitute.For(); service.FindAsync(Arg.Any(), Arg.Any()).Returns((News?)null); var sut = new UpdateNewsCommandHandler(service); @@ -27,7 +27,7 @@ public async Task Updates_content_and_calls_UpdateAsync_with_expected_rowversion var news = News.Draft("old-ar", "old-en", "old-content-ar", "old-content-en", "old-slug", System.Guid.NewGuid(), null, clock); - var service = Substitute.For(); + var service = Substitute.For(); service.FindAsync(news.Id, Arg.Any()).Returns(news); var sut = new UpdateNewsCommandHandler(service); @@ -53,7 +53,7 @@ public async Task Propagates_ConcurrencyException_from_UpdateAsync() var news = News.Draft("ar", "en", "content-ar", "content-en", "my-slug", System.Guid.NewGuid(), null, clock); - var service = Substitute.For(); + var service = Substitute.For(); service.FindAsync(news.Id, Arg.Any()).Returns(news); service.UpdateAsync(default!, default!, default).ReturnsForAnyArgs(_ => throw new ConcurrencyException("conflict")); diff --git a/backend/tests/CCE.Application.Tests/Content/Commands/UpdatePageCommandHandlerTests.cs b/backend/tests/CCE.Application.Tests/Content/Commands/UpdatePageCommandHandlerTests.cs index a34172a5..17958ced 100644 --- a/backend/tests/CCE.Application.Tests/Content/Commands/UpdatePageCommandHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Content/Commands/UpdatePageCommandHandlerTests.cs @@ -10,7 +10,7 @@ public class UpdatePageCommandHandlerTests [Fact] public async Task Returns_null_when_page_not_found() { - var service = Substitute.For(); + var service = Substitute.For(); service.FindAsync(Arg.Any(), Arg.Any()).Returns((Page?)null); var sut = new UpdatePageCommandHandler(service); @@ -24,7 +24,7 @@ public async Task Updates_content_and_calls_UpdateAsync_with_expected_rowversion { var page = Page.Create("test-slug", PageType.Custom, "old-ar", "old-en", "old-content-ar", "old-content-en"); - var service = Substitute.For(); + var service = Substitute.For(); service.FindAsync(page.Id, Arg.Any()).Returns(page); var sut = new UpdatePageCommandHandler(service); @@ -48,7 +48,7 @@ public async Task Propagates_ConcurrencyException_from_UpdateAsync() { var page = Page.Create("my-slug", PageType.Custom, "ar", "en", "content-ar", "content-en"); - var service = Substitute.For(); + var service = Substitute.For(); service.FindAsync(page.Id, Arg.Any()).Returns(page); service.UpdateAsync(default!, default!, default).ReturnsForAnyArgs(_ => throw new ConcurrencyException("conflict")); diff --git a/backend/tests/CCE.Application.Tests/Content/Commands/UpdateResourceCategoryCommandHandlerTests.cs b/backend/tests/CCE.Application.Tests/Content/Commands/UpdateResourceCategoryCommandHandlerTests.cs index a1e30f9a..fd145c4a 100644 --- a/backend/tests/CCE.Application.Tests/Content/Commands/UpdateResourceCategoryCommandHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Content/Commands/UpdateResourceCategoryCommandHandlerTests.cs @@ -9,7 +9,7 @@ public class UpdateResourceCategoryCommandHandlerTests [Fact] public async Task Returns_null_when_category_not_found() { - var service = Substitute.For(); + var service = Substitute.For(); service.FindAsync(Arg.Any(), Arg.Any()).Returns((ResourceCategory?)null); var sut = new UpdateResourceCategoryCommandHandler(service); @@ -22,7 +22,7 @@ public async Task Returns_null_when_category_not_found() public async Task Updates_names_reorder_and_calls_UpdateAsync() { var category = ResourceCategory.Create("قديم", "Old", "old-slug", null, 1); - var service = Substitute.For(); + var service = Substitute.For(); service.FindAsync(category.Id, Arg.Any()).Returns(category); var sut = new UpdateResourceCategoryCommandHandler(service); @@ -41,7 +41,7 @@ public async Task Updates_names_reorder_and_calls_UpdateAsync() public async Task Deactivates_when_IsActive_is_false() { var category = ResourceCategory.Create("نشط", "Active", "active-cat", null, 0); - var service = Substitute.For(); + var service = Substitute.For(); service.FindAsync(category.Id, Arg.Any()).Returns(category); var sut = new UpdateResourceCategoryCommandHandler(service); diff --git a/backend/tests/CCE.Application.Tests/Content/Commands/UpdateResourceCommandHandlerTests.cs b/backend/tests/CCE.Application.Tests/Content/Commands/UpdateResourceCommandHandlerTests.cs index 089d2884..b26d480f 100644 --- a/backend/tests/CCE.Application.Tests/Content/Commands/UpdateResourceCommandHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Content/Commands/UpdateResourceCommandHandlerTests.cs @@ -11,7 +11,7 @@ public class UpdateResourceCommandHandlerTests [Fact] public async Task Returns_null_when_resource_not_found() { - var service = Substitute.For(); + var service = Substitute.For(); service.FindAsync(Arg.Any(), Arg.Any()).Returns((Resource?)null); var sut = new UpdateResourceCommandHandler(service); @@ -29,7 +29,7 @@ public async Task Updates_content_and_calls_UpdateAsync_with_expected_rowversion ResourceType.Pdf, System.Guid.NewGuid(), null, System.Guid.NewGuid(), System.Guid.NewGuid(), clock); - var service = Substitute.For(); + var service = Substitute.For(); service.FindAsync(resource.Id, Arg.Any()).Returns(resource); var sut = new UpdateResourceCommandHandler(service); @@ -58,7 +58,7 @@ public async Task Propagates_DomainException_from_UpdateContent_when_title_empty ResourceType.Pdf, System.Guid.NewGuid(), null, System.Guid.NewGuid(), System.Guid.NewGuid(), clock); - var service = Substitute.For(); + var service = Substitute.For(); service.FindAsync(resource.Id, Arg.Any()).Returns(resource); var sut = new UpdateResourceCommandHandler(service); @@ -82,7 +82,7 @@ public async Task Propagates_ConcurrencyException_from_UpdateAsync() ResourceType.Pdf, System.Guid.NewGuid(), null, System.Guid.NewGuid(), System.Guid.NewGuid(), clock); - var service = Substitute.For(); + var service = Substitute.For(); service.FindAsync(resource.Id, Arg.Any()).Returns(resource); service.UpdateAsync(default!, default!, default).ReturnsForAnyArgs(_ => throw new ConcurrencyException("conflict")); diff --git a/backend/tests/CCE.Application.Tests/Content/Commands/UploadAssetCommandHandlerTests.cs b/backend/tests/CCE.Application.Tests/Content/Commands/UploadAssetCommandHandlerTests.cs index 278410a6..8d07d457 100644 --- a/backend/tests/CCE.Application.Tests/Content/Commands/UploadAssetCommandHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Content/Commands/UploadAssetCommandHandlerTests.cs @@ -78,7 +78,7 @@ public async Task Buffers_content_and_passes_size_through() private static UploadAssetCommandHandler BuildSut( out IFileStorage storage, out IClamAvScanner scanner, - out IAssetService service, + out IAssetRepository service, System.Guid? currentUserId) { storage = Substitute.For(); @@ -86,7 +86,7 @@ private static UploadAssetCommandHandler BuildSut( // Individual tests that need to verify DeleteAsync can override this. storage.SaveAsync(default!, default!, default).ReturnsForAnyArgs(Task.FromResult("uploads/default/key.bin")); scanner = Substitute.For(); - service = Substitute.For(); + service = Substitute.For(); var currentUser = Substitute.For(); currentUser.GetUserId().Returns(currentUserId); return new UploadAssetCommandHandler( diff --git a/backend/tests/CCE.Application.Tests/Content/Public/Queries/GetPublicEventByIdQueryHandlerTests.cs b/backend/tests/CCE.Application.Tests/Content/Public/Queries/GetPublicEventByIdQueryHandlerTests.cs index 310b0417..1e1d6dbb 100644 --- a/backend/tests/CCE.Application.Tests/Content/Public/Queries/GetPublicEventByIdQueryHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Content/Public/Queries/GetPublicEventByIdQueryHandlerTests.cs @@ -1,24 +1,23 @@ using CCE.Application.Common.Interfaces; using CCE.Application.Content.Public.Queries.GetPublicEventById; +using CCE.Domain.Content; using CCE.TestInfrastructure.Time; namespace CCE.Application.Tests.Content.Public.Queries; public class GetPublicEventByIdQueryHandlerTests { + private static readonly FakeSystemClock Clock = new(); private static readonly System.DateTimeOffset BaseTime = new(2026, 6, 1, 10, 0, 0, System.TimeSpan.Zero); [Fact] public async Task Returns_dto_when_event_found() { - var clock = new FakeSystemClock(); - var ev = CCE.Domain.Content.Event.Schedule( - "حدث", "Test Event", "وصف", "Description", - BaseTime, BaseTime.AddHours(2), - "الرياض", "Riyadh", null, null, clock); + var ev = Event.Schedule("حدث", "Test Event", "وصف", "Description", + BaseTime, BaseTime.AddHours(2), "الرياض", "Riyadh", null, null, Clock); - var db = BuildDb(new[] { ev }); + var db = BuildDb([ev]); var sut = new GetPublicEventByIdQueryHandler(db); var result = await sut.Handle(new GetPublicEventByIdQuery(ev.Id), CancellationToken.None); @@ -36,7 +35,7 @@ public async Task Returns_dto_when_event_found() [Fact] public async Task Returns_null_when_event_not_found() { - var db = BuildDb(System.Array.Empty()); + var db = BuildDb(Array.Empty()); var sut = new GetPublicEventByIdQueryHandler(db); var result = await sut.Handle(new GetPublicEventByIdQuery(System.Guid.NewGuid()), CancellationToken.None); @@ -44,14 +43,10 @@ public async Task Returns_null_when_event_not_found() result.Should().BeNull(); } - private static ICceDbContext BuildDb(IEnumerable events) + private static ICceDbContext BuildDb(IEnumerable events) { var db = Substitute.For(); db.Events.Returns(events.AsQueryable()); - db.Users.Returns(System.Array.Empty().AsQueryable()); - db.Roles.Returns(System.Array.Empty().AsQueryable()); - db.UserRoles.Returns(System.Array.Empty>().AsQueryable()); - db.Resources.Returns(System.Array.Empty().AsQueryable()); return db; } } diff --git a/backend/tests/CCE.Application.Tests/Content/Public/Queries/GetPublicNewsBySlugQueryHandlerTests.cs b/backend/tests/CCE.Application.Tests/Content/Public/Queries/GetPublicNewsBySlugQueryHandlerTests.cs index 839f829b..93cd82f3 100644 --- a/backend/tests/CCE.Application.Tests/Content/Public/Queries/GetPublicNewsBySlugQueryHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Content/Public/Queries/GetPublicNewsBySlugQueryHandlerTests.cs @@ -7,15 +7,15 @@ namespace CCE.Application.Tests.Content.Public.Queries; public class GetPublicNewsBySlugQueryHandlerTests { + private static readonly FakeSystemClock Clock = new(); + [Fact] public async Task Returns_dto_when_news_is_published_and_slug_matches() { - var clock = new FakeSystemClock(); - var authorId = System.Guid.NewGuid(); - var news = News.Draft("عنوان", "Published News", "محتوى", "Content", "published-slug", authorId, null, clock); - news.Publish(clock); + var news = News.Draft("عنوان", "Published News", "محتوى", "Content", "published-slug", System.Guid.NewGuid(), null, Clock); + news.Publish(Clock); - var db = BuildDb(new[] { news }); + var db = BuildDb([news]); var sut = new GetPublicNewsBySlugQueryHandler(db); var result = await sut.Handle(new GetPublicNewsBySlugQuery("published-slug"), CancellationToken.None); @@ -29,7 +29,7 @@ public async Task Returns_dto_when_news_is_published_and_slug_matches() [Fact] public async Task Returns_null_when_slug_not_found() { - var db = BuildDb(System.Array.Empty()); + var db = BuildDb(Array.Empty()); var sut = new GetPublicNewsBySlugQueryHandler(db); var result = await sut.Handle(new GetPublicNewsBySlugQuery("no-such-slug"), CancellationToken.None); @@ -40,12 +40,9 @@ public async Task Returns_null_when_slug_not_found() [Fact] public async Task Returns_null_when_news_found_but_not_published() { - var clock = new FakeSystemClock(); - var authorId = System.Guid.NewGuid(); - var draft = News.Draft("مسودة", "Draft News", "محتوى", "Content", "draft-slug", authorId, null, clock); - // Not published — PublishedOn is null + var news = News.Draft("مسودة", "Draft News", "محتوى", "Content", "draft-slug", System.Guid.NewGuid(), null, Clock); - var db = BuildDb(new[] { draft }); + var db = BuildDb([news]); var sut = new GetPublicNewsBySlugQueryHandler(db); var result = await sut.Handle(new GetPublicNewsBySlugQuery("draft-slug"), CancellationToken.None); @@ -57,10 +54,6 @@ private static ICceDbContext BuildDb(IEnumerable news) { var db = Substitute.For(); db.News.Returns(news.AsQueryable()); - db.Users.Returns(System.Array.Empty().AsQueryable()); - db.Roles.Returns(System.Array.Empty().AsQueryable()); - db.UserRoles.Returns(System.Array.Empty>().AsQueryable()); - db.Resources.Returns(System.Array.Empty().AsQueryable()); return db; } } diff --git a/backend/tests/CCE.Application.Tests/Content/Public/Queries/GetPublicPageBySlugQueryHandlerTests.cs b/backend/tests/CCE.Application.Tests/Content/Public/Queries/GetPublicPageBySlugQueryHandlerTests.cs index 1bdc9ea8..a43e9be9 100644 --- a/backend/tests/CCE.Application.Tests/Content/Public/Queries/GetPublicPageBySlugQueryHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Content/Public/Queries/GetPublicPageBySlugQueryHandlerTests.cs @@ -11,7 +11,7 @@ public async Task Returns_dto_when_page_exists_with_matching_slug() { var page = Page.Create("about-us", PageType.Custom, "عن الشركة", "About Us", "المحتوى", "Content"); - var db = BuildDb(new[] { page }); + var db = BuildDb([page]); var sut = new GetPublicPageBySlugQueryHandler(db); var result = await sut.Handle(new GetPublicPageBySlugQuery("about-us"), CancellationToken.None); @@ -26,7 +26,7 @@ public async Task Returns_dto_when_page_exists_with_matching_slug() [Fact] public async Task Returns_null_when_slug_not_found() { - var db = BuildDb(System.Array.Empty()); + var db = BuildDb(Array.Empty()); var sut = new GetPublicPageBySlugQueryHandler(db); var result = await sut.Handle(new GetPublicPageBySlugQuery("no-such-slug"), CancellationToken.None); @@ -38,10 +38,6 @@ private static ICceDbContext BuildDb(IEnumerable pages) { var db = Substitute.For(); db.Pages.Returns(pages.AsQueryable()); - db.Users.Returns(System.Array.Empty().AsQueryable()); - db.Roles.Returns(System.Array.Empty().AsQueryable()); - db.UserRoles.Returns(System.Array.Empty>().AsQueryable()); - db.Resources.Returns(System.Array.Empty().AsQueryable()); return db; } } diff --git a/backend/tests/CCE.Application.Tests/Content/Public/Queries/GetPublicResourceByIdQueryHandlerTests.cs b/backend/tests/CCE.Application.Tests/Content/Public/Queries/GetPublicResourceByIdQueryHandlerTests.cs index b459378c..f672d528 100644 --- a/backend/tests/CCE.Application.Tests/Content/Public/Queries/GetPublicResourceByIdQueryHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Content/Public/Queries/GetPublicResourceByIdQueryHandlerTests.cs @@ -7,19 +7,20 @@ namespace CCE.Application.Tests.Content.Public.Queries; public class GetPublicResourceByIdQueryHandlerTests { + private static readonly FakeSystemClock Clock = new(); + [Fact] public async Task Returns_dto_when_resource_is_published() { - var clock = new FakeSystemClock(); - var categoryId = System.Guid.NewGuid(); - var uploadedById = System.Guid.NewGuid(); - var assetFileId = System.Guid.NewGuid(); + var cat = System.Guid.NewGuid(); + var uploader = System.Guid.NewGuid(); + var asset = System.Guid.NewGuid(); var resource = Resource.Draft("عنوان", "Published Resource", "وصف", "Description", - ResourceType.Document, categoryId, null, uploadedById, assetFileId, clock); - resource.Publish(clock); + ResourceType.Document, cat, null, uploader, asset, Clock); + resource.Publish(Clock); - var db = BuildDb(new[] { resource }); + var db = BuildDb([resource]); var sut = new GetPublicResourceByIdQueryHandler(db); var result = await sut.Handle(new GetPublicResourceByIdQuery(resource.Id), CancellationToken.None); @@ -27,13 +28,12 @@ public async Task Returns_dto_when_resource_is_published() result.Should().NotBeNull(); result!.Id.Should().Be(resource.Id); result.TitleEn.Should().Be("Published Resource"); - result.PublishedOn.Should().Be(resource.PublishedOn!.Value); } [Fact] public async Task Returns_null_when_resource_not_found() { - var db = BuildDb(System.Array.Empty()); + var db = BuildDb(Array.Empty()); var sut = new GetPublicResourceByIdQueryHandler(db); var result = await sut.Handle(new GetPublicResourceByIdQuery(System.Guid.NewGuid()), CancellationToken.None); @@ -44,19 +44,17 @@ public async Task Returns_null_when_resource_not_found() [Fact] public async Task Returns_null_when_resource_exists_but_is_not_published() { - var clock = new FakeSystemClock(); - var categoryId = System.Guid.NewGuid(); - var uploadedById = System.Guid.NewGuid(); - var assetFileId = System.Guid.NewGuid(); + var cat = System.Guid.NewGuid(); + var uploader = System.Guid.NewGuid(); + var asset = System.Guid.NewGuid(); - var draft = Resource.Draft("مسودة", "Draft Resource", "وصف", "Description", - ResourceType.Document, categoryId, null, uploadedById, assetFileId, clock); - // intentionally NOT calling draft.Publish(clock) + var resource = Resource.Draft("مسودة", "Draft Resource", "وصف", "Description", + ResourceType.Document, cat, null, uploader, asset, Clock); - var db = BuildDb(new[] { draft }); + var db = BuildDb([resource]); var sut = new GetPublicResourceByIdQueryHandler(db); - var result = await sut.Handle(new GetPublicResourceByIdQuery(draft.Id), CancellationToken.None); + var result = await sut.Handle(new GetPublicResourceByIdQuery(resource.Id), CancellationToken.None); result.Should().BeNull(); } @@ -65,10 +63,6 @@ private static ICceDbContext BuildDb(IEnumerable resources) { var db = Substitute.For(); db.Resources.Returns(resources.AsQueryable()); - db.Users.Returns(System.Array.Empty().AsQueryable()); - db.Roles.Returns(System.Array.Empty().AsQueryable()); - db.UserRoles.Returns(System.Array.Empty>().AsQueryable()); - db.News.Returns(System.Array.Empty().AsQueryable()); return db; } } diff --git a/backend/tests/CCE.Application.Tests/Content/Public/Queries/ListPublicEventsQueryHandlerTests.cs b/backend/tests/CCE.Application.Tests/Content/Public/Queries/ListPublicEventsQueryHandlerTests.cs index 83066dac..2bce748e 100644 --- a/backend/tests/CCE.Application.Tests/Content/Public/Queries/ListPublicEventsQueryHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Content/Public/Queries/ListPublicEventsQueryHandlerTests.cs @@ -1,23 +1,24 @@ using CCE.Application.Common.Interfaces; using CCE.Application.Content.Public.Queries.ListPublicEvents; +using CCE.Domain.Content; using CCE.TestInfrastructure.Time; namespace CCE.Application.Tests.Content.Public.Queries; public class ListPublicEventsQueryHandlerTests { + private static readonly FakeSystemClock Clock = new(); private static readonly System.DateTimeOffset BaseTime = new(2026, 6, 1, 10, 0, 0, System.TimeSpan.Zero); [Fact] public async Task Returns_empty_paged_result_when_no_events_exist() { - var db = BuildDb(System.Array.Empty()); + var db = BuildDb(Array.Empty()); var sut = new ListPublicEventsQueryHandler(db); - var from = BaseTime; - var to = BaseTime.AddDays(30); - var result = await sut.Handle(new ListPublicEventsQuery(Page: 1, PageSize: 20, From: from, To: to), CancellationToken.None); + var result = await sut.Handle(new ListPublicEventsQuery(Page: 1, PageSize: 20, + From: BaseTime, To: BaseTime.AddDays(30)), CancellationToken.None); result.Items.Should().BeEmpty(); result.Total.Should().Be(0); @@ -28,24 +29,16 @@ public async Task Returns_empty_paged_result_when_no_events_exist() [Fact] public async Task Returns_events_sorted_by_StartsOn_ascending() { - var clock = new FakeSystemClock(); + var earlier = Event.Schedule("أ", "Earlier Event", "وصف", "Description A", + BaseTime, BaseTime.AddHours(2), null, null, null, null, Clock); + var later = Event.Schedule("ب", "Later Event", "وصف ب", "Description B", + BaseTime.AddDays(1), BaseTime.AddDays(1).AddHours(2), null, null, null, null, Clock); - var later = CCE.Domain.Content.Event.Schedule( - "ب", "Later Event", "وصف ب", "Description B", - BaseTime.AddDays(1), BaseTime.AddDays(1).AddHours(2), - null, null, null, null, clock); - - var earlier = CCE.Domain.Content.Event.Schedule( - "أ", "Earlier Event", "وصف", "Description A", - BaseTime, BaseTime.AddHours(2), - null, null, null, null, clock); - - var db = BuildDb(new[] { later, earlier }); + var db = BuildDb([earlier, later]); var sut = new ListPublicEventsQueryHandler(db); - var from = BaseTime.AddMinutes(-1); - var to = BaseTime.AddDays(2); - var result = await sut.Handle(new ListPublicEventsQuery(Page: 1, PageSize: 20, From: from, To: to), CancellationToken.None); + var result = await sut.Handle(new ListPublicEventsQuery(Page: 1, PageSize: 20, + From: BaseTime.AddMinutes(-1), To: BaseTime.AddDays(2)), CancellationToken.None); result.Total.Should().Be(2); result.Items.Should().HaveCount(2); @@ -56,37 +49,27 @@ public async Task Returns_events_sorted_by_StartsOn_ascending() [Fact] public async Task From_to_range_filter_returns_only_events_in_range() { - var clock = new FakeSystemClock(); - - var inRange = CCE.Domain.Content.Event.Schedule( - "داخل النطاق", "In Range", "وصف", "Description", - BaseTime.AddDays(5), BaseTime.AddDays(5).AddHours(1), - null, null, null, null, clock); - - var outOfRange = CCE.Domain.Content.Event.Schedule( - "خارج النطاق", "Out Of Range", "وصف", "Description", - BaseTime.AddDays(20), BaseTime.AddDays(20).AddHours(1), - null, null, null, null, clock); - - var db = BuildDb(new[] { inRange, outOfRange }); + var inRange = Event.Schedule("داخل النطاق", "In Range", "وصف", "Description", + BaseTime.AddDays(5), BaseTime.AddDays(5).AddHours(1), null, null, null, null, Clock); + var tooEarly = Event.Schedule("مبكر", "Too Early", "وصف", "Description", + BaseTime.AddDays(-1), BaseTime.AddDays(-1).AddHours(1), null, null, null, null, Clock); + var tooLate = Event.Schedule("متأخر", "Too Late", "وصف", "Description", + BaseTime.AddDays(12), BaseTime.AddDays(12).AddHours(1), null, null, null, null, Clock); + + var db = BuildDb([inRange, tooEarly, tooLate]); var sut = new ListPublicEventsQueryHandler(db); - var from = BaseTime; - var to = BaseTime.AddDays(10); - var result = await sut.Handle(new ListPublicEventsQuery(Page: 1, PageSize: 20, From: from, To: to), CancellationToken.None); + var result = await sut.Handle(new ListPublicEventsQuery(Page: 1, PageSize: 20, + From: BaseTime, To: BaseTime.AddDays(10)), CancellationToken.None); result.Total.Should().Be(1); result.Items.Single().TitleEn.Should().Be("In Range"); } - private static ICceDbContext BuildDb(IEnumerable events) + private static ICceDbContext BuildDb(IEnumerable events) { var db = Substitute.For(); db.Events.Returns(events.AsQueryable()); - db.Users.Returns(System.Array.Empty().AsQueryable()); - db.Roles.Returns(System.Array.Empty().AsQueryable()); - db.UserRoles.Returns(System.Array.Empty>().AsQueryable()); - db.Resources.Returns(System.Array.Empty().AsQueryable()); return db; } } diff --git a/backend/tests/CCE.Application.Tests/Content/Public/Queries/ListPublicHomepageSectionsQueryHandlerTests.cs b/backend/tests/CCE.Application.Tests/Content/Public/Queries/ListPublicHomepageSectionsQueryHandlerTests.cs index 418bc8cb..5742e318 100644 --- a/backend/tests/CCE.Application.Tests/Content/Public/Queries/ListPublicHomepageSectionsQueryHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Content/Public/Queries/ListPublicHomepageSectionsQueryHandlerTests.cs @@ -9,30 +9,28 @@ public class ListPublicHomepageSectionsQueryHandlerTests [Fact] public async Task Returns_active_sections_sorted_by_order_index() { - var section1 = HomepageSection.Create(HomepageSectionType.Hero, 2, "محتوى 1", "Content 1"); var section2 = HomepageSection.Create(HomepageSectionType.FeaturedNews, 1, "محتوى 2", "Content 2"); - var inactive = HomepageSection.Create(HomepageSectionType.UpcomingEvents, 0, "محتوى غير نشط", "Inactive Content"); - inactive.Deactivate(); + var section1 = HomepageSection.Create(HomepageSectionType.Hero, 0, "محتوى 1", "Content 1"); - var db = BuildDb(new[] { section1, section2, inactive }); + var db = BuildDb([section2, section1]); var sut = new ListPublicHomepageSectionsQueryHandler(db); var result = await sut.Handle(new ListPublicHomepageSectionsQuery(), CancellationToken.None); result.Should().HaveCount(2); - result[0].OrderIndex.Should().Be(1); - result[0].ContentEn.Should().Be("Content 2"); - result[1].OrderIndex.Should().Be(2); - result[1].ContentEn.Should().Be("Content 1"); + result[0].OrderIndex.Should().Be(0); + result[0].ContentEn.Should().Be("Content 1"); + result[1].OrderIndex.Should().Be(1); + result[1].ContentEn.Should().Be("Content 2"); } [Fact] public async Task Returns_empty_when_no_active_sections_exist() { - var inactive = HomepageSection.Create(HomepageSectionType.Hero, 1, "محتوى", "Content"); + var inactive = HomepageSection.Create(HomepageSectionType.Hero, 0, "ar", "en"); inactive.Deactivate(); - var db = BuildDb(new[] { inactive }); + var db = BuildDb([inactive]); var sut = new ListPublicHomepageSectionsQueryHandler(db); var result = await sut.Handle(new ListPublicHomepageSectionsQuery(), CancellationToken.None); @@ -40,14 +38,26 @@ public async Task Returns_empty_when_no_active_sections_exist() result.Should().BeEmpty(); } + [Fact] + public async Task Excludes_inactive_sections() + { + var active = HomepageSection.Create(HomepageSectionType.Hero, 0, "ar-active", "en-active"); + var inactive = HomepageSection.Create(HomepageSectionType.FeaturedNews, 1, "ar-inactive", "en-inactive"); + inactive.Deactivate(); + + var db = BuildDb([active, inactive]); + var sut = new ListPublicHomepageSectionsQueryHandler(db); + + var result = await sut.Handle(new ListPublicHomepageSectionsQuery(), CancellationToken.None); + + result.Should().HaveCount(1); + result[0].ContentEn.Should().Be("en-active"); + } + private static ICceDbContext BuildDb(IEnumerable sections) { var db = Substitute.For(); db.HomepageSections.Returns(sections.AsQueryable()); - db.Users.Returns(System.Array.Empty().AsQueryable()); - db.Roles.Returns(System.Array.Empty().AsQueryable()); - db.UserRoles.Returns(System.Array.Empty>().AsQueryable()); - db.Resources.Returns(System.Array.Empty().AsQueryable()); return db; } } diff --git a/backend/tests/CCE.Application.Tests/Content/Public/Queries/ListPublicNewsQueryHandlerTests.cs b/backend/tests/CCE.Application.Tests/Content/Public/Queries/ListPublicNewsQueryHandlerTests.cs index e417d901..8c23d3fa 100644 --- a/backend/tests/CCE.Application.Tests/Content/Public/Queries/ListPublicNewsQueryHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Content/Public/Queries/ListPublicNewsQueryHandlerTests.cs @@ -7,10 +7,12 @@ namespace CCE.Application.Tests.Content.Public.Queries; public class ListPublicNewsQueryHandlerTests { + private static readonly FakeSystemClock Clock = new(); + [Fact] public async Task Returns_empty_paged_result_when_no_news_exist() { - var db = BuildDb(System.Array.Empty()); + var db = BuildDb(Array.Empty()); var sut = new ListPublicNewsQueryHandler(db); var result = await sut.Handle(new ListPublicNewsQuery(Page: 1, PageSize: 20), CancellationToken.None); @@ -24,14 +26,12 @@ public async Task Returns_empty_paged_result_when_no_news_exist() [Fact] public async Task Only_published_news_are_returned() { - var clock = new FakeSystemClock(); - var authorId = System.Guid.NewGuid(); + var published = News.Draft("منشور", "Published", "محتوى", "Content", "published-slug", System.Guid.NewGuid(), null, Clock); + published.Publish(Clock); - var published = News.Draft("منشور", "Published", "محتوى", "Content", "published-slug", authorId, null, clock); - var draft = News.Draft("مسودة", "Draft", "محتوى", "Content", "draft-slug", authorId, null, clock); - published.Publish(clock); + var draft = News.Draft("مسودة", "Draft", "محتوى", "Content", "draft-slug", System.Guid.NewGuid(), null, Clock); - var db = BuildDb(new[] { published, draft }); + var db = BuildDb([published, draft]); var sut = new ListPublicNewsQueryHandler(db); var result = await sut.Handle(new ListPublicNewsQuery(Page: 1, PageSize: 20), CancellationToken.None); @@ -43,16 +43,14 @@ public async Task Only_published_news_are_returned() [Fact] public async Task IsFeatured_filter_returns_only_featured_published_news() { - var clock = new FakeSystemClock(); - var authorId = System.Guid.NewGuid(); - - var featured = News.Draft("مميز", "Featured", "محتوى", "Content", "featured-slug", authorId, null, clock); - var regular = News.Draft("عادي", "Regular", "محتوى", "Content", "regular-slug", authorId, null, clock); - featured.Publish(clock); + var featured = News.Draft("مميز", "Featured", "محتوى", "Content", "featured-slug", System.Guid.NewGuid(), null, Clock); + featured.Publish(Clock); featured.MarkFeatured(); - regular.Publish(clock); - var db = BuildDb(new[] { featured, regular }); + var notFeatured = News.Draft("عادي", "Regular", "محتوى", "Content", "regular-slug", System.Guid.NewGuid(), null, Clock); + notFeatured.Publish(Clock); + + var db = BuildDb([featured, notFeatured]); var sut = new ListPublicNewsQueryHandler(db); var result = await sut.Handle(new ListPublicNewsQuery(Page: 1, PageSize: 20, IsFeatured: true), CancellationToken.None); @@ -66,10 +64,6 @@ private static ICceDbContext BuildDb(IEnumerable news) { var db = Substitute.For(); db.News.Returns(news.AsQueryable()); - db.Users.Returns(System.Array.Empty().AsQueryable()); - db.Roles.Returns(System.Array.Empty().AsQueryable()); - db.UserRoles.Returns(System.Array.Empty>().AsQueryable()); - db.Resources.Returns(System.Array.Empty().AsQueryable()); return db; } } diff --git a/backend/tests/CCE.Application.Tests/Content/Public/Queries/ListPublicResourceCategoriesQueryHandlerTests.cs b/backend/tests/CCE.Application.Tests/Content/Public/Queries/ListPublicResourceCategoriesQueryHandlerTests.cs index 530f6a1f..9eb79106 100644 --- a/backend/tests/CCE.Application.Tests/Content/Public/Queries/ListPublicResourceCategoriesQueryHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Content/Public/Queries/ListPublicResourceCategoriesQueryHandlerTests.cs @@ -9,30 +9,28 @@ public class ListPublicResourceCategoriesQueryHandlerTests [Fact] public async Task Returns_active_categories_sorted_by_order_index() { - var cat1 = ResourceCategory.Create("تقارير", "Reports", "reports", null, 2); - var cat2 = ResourceCategory.Create("أدلة", "Guides", "guides", null, 1); - var inactive = ResourceCategory.Create("محفوظات", "Archives", "archives", null, 0); - inactive.Deactivate(); + var guides = ResourceCategory.Create("أدلة", "Guides", "guides", null, 2); + var reports = ResourceCategory.Create("تقارير", "Reports", "reports", null, 1); - var db = BuildDb(new[] { cat1, cat2, inactive }); + var db = BuildDb([guides, reports]); var sut = new ListPublicResourceCategoriesQueryHandler(db); var result = await sut.Handle(new ListPublicResourceCategoriesQuery(), CancellationToken.None); result.Should().HaveCount(2); result[0].OrderIndex.Should().Be(1); - result[0].NameEn.Should().Be("Guides"); + result[0].NameEn.Should().Be("Reports"); result[1].OrderIndex.Should().Be(2); - result[1].NameEn.Should().Be("Reports"); + result[1].NameEn.Should().Be("Guides"); } [Fact] public async Task Returns_empty_when_no_active_categories_exist() { - var inactive = ResourceCategory.Create("تقارير", "Reports", "reports", null, 1); + var inactive = ResourceCategory.Create("غير نشط", "Inactive", "inactive", null, 1); inactive.Deactivate(); - var db = BuildDb(new[] { inactive }); + var db = BuildDb([inactive]); var sut = new ListPublicResourceCategoriesQueryHandler(db); var result = await sut.Handle(new ListPublicResourceCategoriesQuery(), CancellationToken.None); @@ -40,14 +38,26 @@ public async Task Returns_empty_when_no_active_categories_exist() result.Should().BeEmpty(); } + [Fact] + public async Task Excludes_inactive_categories() + { + var active = ResourceCategory.Create("نشط", "Active", "active", null, 1); + var inactive = ResourceCategory.Create("غير نشط", "Inactive", "inactive", null, 2); + inactive.Deactivate(); + + var db = BuildDb([active, inactive]); + var sut = new ListPublicResourceCategoriesQueryHandler(db); + + var result = await sut.Handle(new ListPublicResourceCategoriesQuery(), CancellationToken.None); + + result.Should().HaveCount(1); + result[0].NameEn.Should().Be("Active"); + } + private static ICceDbContext BuildDb(IEnumerable categories) { var db = Substitute.For(); db.ResourceCategories.Returns(categories.AsQueryable()); - db.Users.Returns(System.Array.Empty().AsQueryable()); - db.Roles.Returns(System.Array.Empty().AsQueryable()); - db.UserRoles.Returns(System.Array.Empty>().AsQueryable()); - db.Resources.Returns(System.Array.Empty().AsQueryable()); return db; } } diff --git a/backend/tests/CCE.Application.Tests/Content/Public/Queries/ListPublicResourcesQueryHandlerTests.cs b/backend/tests/CCE.Application.Tests/Content/Public/Queries/ListPublicResourcesQueryHandlerTests.cs index 46687fb0..327c97c1 100644 --- a/backend/tests/CCE.Application.Tests/Content/Public/Queries/ListPublicResourcesQueryHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Content/Public/Queries/ListPublicResourcesQueryHandlerTests.cs @@ -7,10 +7,12 @@ namespace CCE.Application.Tests.Content.Public.Queries; public class ListPublicResourcesQueryHandlerTests { + private static readonly FakeSystemClock Clock = new(); + [Fact] public async Task Returns_empty_paged_result_when_no_resources_exist() { - var db = BuildDb(System.Array.Empty()); + var db = BuildDb(Array.Empty()); var sut = new ListPublicResourcesQueryHandler(db); var result = await sut.Handle(new ListPublicResourcesQuery(Page: 1, PageSize: 20), CancellationToken.None); @@ -24,18 +26,18 @@ public async Task Returns_empty_paged_result_when_no_resources_exist() [Fact] public async Task Only_published_resources_are_returned() { - var clock = new FakeSystemClock(); - var categoryId = System.Guid.NewGuid(); - var uploadedById = System.Guid.NewGuid(); - var assetFileId = System.Guid.NewGuid(); + var cat = System.Guid.NewGuid(); + var uploader = System.Guid.NewGuid(); + var asset = System.Guid.NewGuid(); var published = Resource.Draft("عنوان", "Published", "وصف", "Description", - ResourceType.Document, categoryId, null, uploadedById, assetFileId, clock); + ResourceType.Document, cat, null, uploader, asset, Clock); + published.Publish(Clock); + var draft = Resource.Draft("مسودة", "Draft", "وصف", "Description", - ResourceType.Document, categoryId, null, uploadedById, assetFileId, clock); - published.Publish(clock); + ResourceType.Document, cat, null, uploader, asset, Clock); - var db = BuildDb(new[] { published, draft }); + var db = BuildDb([published, draft]); var sut = new ListPublicResourcesQueryHandler(db); var result = await sut.Handle(new ListPublicResourcesQuery(Page: 1, PageSize: 20), CancellationToken.None); @@ -47,37 +49,57 @@ public async Task Only_published_resources_are_returned() [Fact] public async Task CategoryId_filter_returns_only_matching_published_resources() { - var clock = new FakeSystemClock(); - var categoryA = System.Guid.NewGuid(); - var categoryB = System.Guid.NewGuid(); - var uploadedById = System.Guid.NewGuid(); - var assetFileId = System.Guid.NewGuid(); - - var inCategoryA = Resource.Draft("فئة أ", "Category A", "وصف", "Description", - ResourceType.Document, categoryA, null, uploadedById, assetFileId, clock); - var inCategoryB = Resource.Draft("فئة ب", "Category B", "وصف", "Description", - ResourceType.Document, categoryB, null, uploadedById, assetFileId, clock); - inCategoryA.Publish(clock); - inCategoryB.Publish(clock); - - var db = BuildDb(new[] { inCategoryA, inCategoryB }); + var catA = System.Guid.NewGuid(); + var catB = System.Guid.NewGuid(); + var uploader = System.Guid.NewGuid(); + var asset = System.Guid.NewGuid(); + + var match = Resource.Draft("فئة أ", "Category A", "وصف", "Description", + ResourceType.Document, catA, null, uploader, asset, Clock); + match.Publish(Clock); + + var noMatch = Resource.Draft("فئة ب", "Category B", "وصف", "Description", + ResourceType.Document, catB, null, uploader, asset, Clock); + noMatch.Publish(Clock); + + var db = BuildDb([match, noMatch]); var sut = new ListPublicResourcesQueryHandler(db); - var result = await sut.Handle(new ListPublicResourcesQuery(Page: 1, PageSize: 20, CategoryId: categoryA), CancellationToken.None); + var result = await sut.Handle(new ListPublicResourcesQuery(Page: 1, PageSize: 20, CategoryId: catA), CancellationToken.None); result.Total.Should().Be(1); result.Items.Single().TitleEn.Should().Be("Category A"); - result.Items.Single().CategoryId.Should().Be(categoryA); + result.Items.Single().CategoryId.Should().Be(catA); + } + + [Fact] + public async Task ResourceType_filter_returns_only_matching_published_resources() + { + var cat = System.Guid.NewGuid(); + var uploader = System.Guid.NewGuid(); + var asset = System.Guid.NewGuid(); + + var doc = Resource.Draft("وثيقة", "Document", "وصف", "Description", + ResourceType.Document, cat, null, uploader, asset, Clock); + doc.Publish(Clock); + + var video = Resource.Draft("فيديو", "Video", "وصف", "Description", + ResourceType.Video, cat, null, uploader, asset, Clock); + video.Publish(Clock); + + var db = BuildDb([doc, video]); + var sut = new ListPublicResourcesQueryHandler(db); + + var result = await sut.Handle(new ListPublicResourcesQuery(Page: 1, PageSize: 20, ResourceType: ResourceType.Video), CancellationToken.None); + + result.Total.Should().Be(1); + result.Items.Single().TitleEn.Should().Be("Video"); } private static ICceDbContext BuildDb(IEnumerable resources) { var db = Substitute.For(); db.Resources.Returns(resources.AsQueryable()); - db.Users.Returns(System.Array.Empty().AsQueryable()); - db.Roles.Returns(System.Array.Empty().AsQueryable()); - db.UserRoles.Returns(System.Array.Empty>().AsQueryable()); - db.News.Returns(System.Array.Empty().AsQueryable()); return db; } } diff --git a/backend/tests/CCE.Application.Tests/Content/Queries/GetAssetByIdQueryHandlerTests.cs b/backend/tests/CCE.Application.Tests/Content/Queries/GetAssetByIdQueryHandlerTests.cs index 42eea704..a17ad57a 100644 --- a/backend/tests/CCE.Application.Tests/Content/Queries/GetAssetByIdQueryHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Content/Queries/GetAssetByIdQueryHandlerTests.cs @@ -1,4 +1,4 @@ -using CCE.Application.Content; +using CCE.Application.Common.Interfaces; using CCE.Application.Content.Queries.GetAssetById; using CCE.Domain.Content; using CCE.TestInfrastructure.Time; @@ -7,12 +7,13 @@ namespace CCE.Application.Tests.Content.Queries; public class GetAssetByIdQueryHandlerTests { + private static readonly FakeSystemClock Clock = new(); + [Fact] public async Task Returns_null_when_asset_not_found() { - var service = Substitute.For(); - service.FindAsync(Arg.Any(), Arg.Any()).Returns((AssetFile?)null); - var sut = new GetAssetByIdQueryHandler(service); + var db = BuildDb(Array.Empty()); + var sut = new GetAssetByIdQueryHandler(db); var result = await sut.Handle(new GetAssetByIdQuery(System.Guid.NewGuid()), CancellationToken.None); @@ -22,19 +23,17 @@ public async Task Returns_null_when_asset_not_found() [Fact] public async Task Returns_dto_when_asset_found() { - var clock = new FakeSystemClock(); var asset = AssetFile.Register( - url: "uploads/2026/04/abc.pdf", - originalFileName: "report.pdf", - sizeBytes: 1024, - mimeType: "application/pdf", - uploadedById: System.Guid.NewGuid(), - clock: clock); - asset.MarkClean(clock); - - var service = Substitute.For(); - service.FindAsync(asset.Id, Arg.Any()).Returns(asset); - var sut = new GetAssetByIdQueryHandler(service); + "uploads/2026/04/abc.pdf", + "report.pdf", + 1024, + "application/pdf", + System.Guid.NewGuid(), + Clock); + asset.MarkClean(Clock); + + var db = BuildDb([asset]); + var sut = new GetAssetByIdQueryHandler(db); var result = await sut.Handle(new GetAssetByIdQuery(asset.Id), CancellationToken.None); @@ -47,4 +46,11 @@ public async Task Returns_dto_when_asset_found() result.VirusScanStatus.Should().Be(VirusScanStatus.Clean); result.ScannedOn.Should().NotBeNull(); } + + private static ICceDbContext BuildDb(IEnumerable assets) + { + var db = Substitute.For(); + db.AssetFiles.Returns(assets.AsQueryable()); + return db; + } } diff --git a/backend/tests/CCE.Application.Tests/Content/Queries/GetEventByIdQueryHandlerTests.cs b/backend/tests/CCE.Application.Tests/Content/Queries/GetEventByIdQueryHandlerTests.cs index 3a8ba4a8..1b3d7c4e 100644 --- a/backend/tests/CCE.Application.Tests/Content/Queries/GetEventByIdQueryHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Content/Queries/GetEventByIdQueryHandlerTests.cs @@ -7,13 +7,14 @@ namespace CCE.Application.Tests.Content.Queries; public class GetEventByIdQueryHandlerTests { + private static readonly FakeSystemClock Clock = new(); private static readonly System.DateTimeOffset BaseTime = new(2026, 6, 1, 10, 0, 0, System.TimeSpan.Zero); [Fact] public async Task Returns_null_when_event_not_found() { - var db = BuildDb(System.Array.Empty()); + var db = BuildDb(Array.Empty()); var sut = new GetEventByIdQueryHandler(db); var result = await sut.Handle(new GetEventByIdQuery(System.Guid.NewGuid()), CancellationToken.None); @@ -24,20 +25,11 @@ public async Task Returns_null_when_event_not_found() [Fact] public async Task Returns_dto_with_all_fields_when_found() { - var clock = new FakeSystemClock(); - var ev = CCE.Domain.Content.Event.Schedule( - "حدث تجريبي", - "Test Event Title", - "وصف عربي", - "English description", - BaseTime, - BaseTime.AddHours(3), - "الرياض", "Riyadh", - "https://example.com/meeting", - "https://example.com/image.jpg", - clock); + var ev = Event.Schedule("حدث تجريبي", "Test Event Title", "وصف عربي", "English description", + BaseTime, BaseTime.AddHours(3), "الرياض", "Riyadh", + "https://example.com/meeting", "https://example.com/image.jpg", Clock); - var db = BuildDb(new[] { ev }); + var db = BuildDb([ev]); var sut = new GetEventByIdQueryHandler(db); var result = await sut.Handle(new GetEventByIdQuery(ev.Id), CancellationToken.None); @@ -58,14 +50,10 @@ public async Task Returns_dto_with_all_fields_when_found() result.RowVersion.Should().NotBeNull(); } - private static ICceDbContext BuildDb(IEnumerable events) + private static ICceDbContext BuildDb(IEnumerable events) { var db = Substitute.For(); db.Events.Returns(events.AsQueryable()); - db.Users.Returns(System.Array.Empty().AsQueryable()); - db.Roles.Returns(System.Array.Empty().AsQueryable()); - db.UserRoles.Returns(System.Array.Empty>().AsQueryable()); - db.Resources.Returns(System.Array.Empty().AsQueryable()); return db; } } diff --git a/backend/tests/CCE.Application.Tests/Content/Queries/GetNewsByIdQueryHandlerTests.cs b/backend/tests/CCE.Application.Tests/Content/Queries/GetNewsByIdQueryHandlerTests.cs index 150dbcd8..b8db6c2f 100644 --- a/backend/tests/CCE.Application.Tests/Content/Queries/GetNewsByIdQueryHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Content/Queries/GetNewsByIdQueryHandlerTests.cs @@ -7,10 +7,12 @@ namespace CCE.Application.Tests.Content.Queries; public class GetNewsByIdQueryHandlerTests { + private static readonly FakeSystemClock Clock = new(); + [Fact] public async Task Returns_null_when_news_not_found() { - var db = BuildDb(System.Array.Empty()); + var db = BuildDb(Array.Empty()); var sut = new GetNewsByIdQueryHandler(db); var result = await sut.Handle(new GetNewsByIdQuery(System.Guid.NewGuid()), CancellationToken.None); @@ -21,21 +23,13 @@ public async Task Returns_null_when_news_not_found() [Fact] public async Task Returns_dto_with_all_fields_when_found() { - var clock = new FakeSystemClock(); var authorId = System.Guid.NewGuid(); - var news = News.Draft( - "عنوان", - "Test News Title", - "المحتوى العربي", - "English content body", - "test-news-title", - authorId, - "https://example.com/image.jpg", - clock); - news.Publish(clock); + var news = News.Draft("عنوان", "Test News Title", "المحتوى العربي", "English content body", + "test-news-title", authorId, "https://example.com/image.jpg", Clock); + news.Publish(Clock); news.MarkFeatured(); - var db = BuildDb(new[] { news }); + var db = BuildDb([news]); var sut = new GetNewsByIdQueryHandler(db); var result = await sut.Handle(new GetNewsByIdQuery(news.Id), CancellationToken.None); @@ -59,10 +53,6 @@ private static ICceDbContext BuildDb(IEnumerable news) { var db = Substitute.For(); db.News.Returns(news.AsQueryable()); - db.Users.Returns(System.Array.Empty().AsQueryable()); - db.Roles.Returns(System.Array.Empty().AsQueryable()); - db.UserRoles.Returns(System.Array.Empty>().AsQueryable()); - db.Resources.Returns(System.Array.Empty().AsQueryable()); return db; } } diff --git a/backend/tests/CCE.Application.Tests/Content/Queries/GetPageByIdQueryHandlerTests.cs b/backend/tests/CCE.Application.Tests/Content/Queries/GetPageByIdQueryHandlerTests.cs index 462d884b..212b108d 100644 --- a/backend/tests/CCE.Application.Tests/Content/Queries/GetPageByIdQueryHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Content/Queries/GetPageByIdQueryHandlerTests.cs @@ -9,7 +9,7 @@ public class GetPageByIdQueryHandlerTests [Fact] public async Task Returns_null_when_page_not_found() { - var db = BuildDb(System.Array.Empty()); + var db = BuildDb(Array.Empty()); var sut = new GetPageByIdQueryHandler(db); var result = await sut.Handle(new GetPageByIdQuery(System.Guid.NewGuid()), CancellationToken.None); @@ -22,7 +22,7 @@ public async Task Returns_dto_with_all_fields_when_found() { var page = Page.Create("test-slug", PageType.Custom, "ar", "en", "content-ar", "content-en"); - var db = BuildDb(new[] { page }); + var db = BuildDb([page]); var sut = new GetPageByIdQueryHandler(db); var result = await sut.Handle(new GetPageByIdQuery(page.Id), CancellationToken.None); diff --git a/backend/tests/CCE.Application.Tests/Content/Queries/GetResourceCategoryByIdQueryHandlerTests.cs b/backend/tests/CCE.Application.Tests/Content/Queries/GetResourceCategoryByIdQueryHandlerTests.cs index f499231d..8e5e28b8 100644 --- a/backend/tests/CCE.Application.Tests/Content/Queries/GetResourceCategoryByIdQueryHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Content/Queries/GetResourceCategoryByIdQueryHandlerTests.cs @@ -9,7 +9,7 @@ public class GetResourceCategoryByIdQueryHandlerTests [Fact] public async Task Returns_null_when_category_not_found() { - var db = BuildDb(System.Array.Empty()); + var db = BuildDb(Array.Empty()); var sut = new GetResourceCategoryByIdQueryHandler(db); var result = await sut.Handle(new GetResourceCategoryByIdQuery(System.Guid.NewGuid()), CancellationToken.None); @@ -22,7 +22,7 @@ public async Task Returns_dto_with_all_fields_when_found() { var category = ResourceCategory.Create("تقنية", "Technology", "technology", null, 5); - var db = BuildDb(new[] { category }); + var db = BuildDb([category]); var sut = new GetResourceCategoryByIdQueryHandler(db); var result = await sut.Handle(new GetResourceCategoryByIdQuery(category.Id), CancellationToken.None); diff --git a/backend/tests/CCE.Application.Tests/Content/Queries/ListEventsQueryHandlerTests.cs b/backend/tests/CCE.Application.Tests/Content/Queries/ListEventsQueryHandlerTests.cs index 36043607..9c22a8b6 100644 --- a/backend/tests/CCE.Application.Tests/Content/Queries/ListEventsQueryHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Content/Queries/ListEventsQueryHandlerTests.cs @@ -7,13 +7,14 @@ namespace CCE.Application.Tests.Content.Queries; public class ListEventsQueryHandlerTests { + private static readonly FakeSystemClock Clock = new(); private static readonly System.DateTimeOffset BaseTime = new(2026, 6, 1, 10, 0, 0, System.TimeSpan.Zero); [Fact] public async Task Returns_empty_paged_result_when_no_events_exist() { - var db = BuildDb(System.Array.Empty()); + var db = BuildDb(Array.Empty()); var sut = new ListEventsQueryHandler(db); var result = await sut.Handle(new ListEventsQuery(Page: 1, PageSize: 20), CancellationToken.None); @@ -27,19 +28,12 @@ public async Task Returns_empty_paged_result_when_no_events_exist() [Fact] public async Task Returns_events_sorted_by_StartsOn_descending() { - var clock = new FakeSystemClock(); + var later = Event.Schedule("ب", "Later Event", "وصف ب", "Description B", + BaseTime.AddDays(1), BaseTime.AddDays(1).AddHours(2), null, null, null, null, Clock); + var earlier = Event.Schedule("أ", "Earlier Event", "وصف", "Description A", + BaseTime, BaseTime.AddHours(2), null, null, null, null, Clock); - var earlier = CCE.Domain.Content.Event.Schedule( - "أ", "Earlier Event", "وصف", "Description A", - BaseTime, BaseTime.AddHours(2), - null, null, null, null, clock); - - var later = CCE.Domain.Content.Event.Schedule( - "ب", "Later Event", "وصف ب", "Description B", - BaseTime.AddDays(1), BaseTime.AddDays(1).AddHours(2), - null, null, null, null, clock); - - var db = BuildDb(new[] { earlier, later }); + var db = BuildDb([later, earlier]); var sut = new ListEventsQueryHandler(db); var result = await sut.Handle(new ListEventsQuery(Page: 1, PageSize: 20), CancellationToken.None); @@ -53,19 +47,10 @@ public async Task Returns_events_sorted_by_StartsOn_descending() [Fact] public async Task Search_filter_matches_title_ar_or_title_en() { - var clock = new FakeSystemClock(); - - var match = CCE.Domain.Content.Event.Schedule( - "مطابق", "matching-event", "وصف", "Description", - BaseTime, BaseTime.AddHours(1), - null, null, null, null, clock); + var ev = Event.Schedule("مطابق", "matching-event", "وصف", "Description", + BaseTime, BaseTime.AddHours(1), null, null, null, null, Clock); - var noMatch = CCE.Domain.Content.Event.Schedule( - "آخر", "other-event", "وصف آخر", "Other description", - BaseTime.AddDays(1), BaseTime.AddDays(1).AddHours(1), - null, null, null, null, clock); - - var db = BuildDb(new[] { match, noMatch }); + var db = BuildDb([ev]); var sut = new ListEventsQueryHandler(db); var result = await sut.Handle(new ListEventsQuery(Search: "matching"), CancellationToken.None); @@ -74,14 +59,29 @@ public async Task Search_filter_matches_title_ar_or_title_en() result.Items.Single().TitleEn.Should().Be("matching-event"); } - private static ICceDbContext BuildDb(IEnumerable events) + [Fact] + public async Task FromDate_and_ToDate_filters_work() + { + var inRange = Event.Schedule("في النطاق", "InRange", "وصف", "Description", + BaseTime.AddDays(5), BaseTime.AddDays(5).AddHours(1), null, null, null, null, Clock); + var beforeRange = Event.Schedule("قبل", "Before", "وصف", "Description", + BaseTime.AddDays(-1), BaseTime.AddDays(-1).AddHours(1), null, null, null, null, Clock); + var afterRange = Event.Schedule("بعد", "After", "وصف", "Description", + BaseTime.AddDays(10), BaseTime.AddDays(10).AddHours(1), null, null, null, null, Clock); + + var db = BuildDb([inRange, beforeRange, afterRange]); + var sut = new ListEventsQueryHandler(db); + + var result = await sut.Handle(new ListEventsQuery(FromDate: BaseTime, ToDate: BaseTime.AddDays(7)), CancellationToken.None); + + result.Total.Should().Be(1); + result.Items.Single().TitleEn.Should().Be("InRange"); + } + + private static ICceDbContext BuildDb(IEnumerable events) { var db = Substitute.For(); db.Events.Returns(events.AsQueryable()); - db.Users.Returns(System.Array.Empty().AsQueryable()); - db.Roles.Returns(System.Array.Empty().AsQueryable()); - db.UserRoles.Returns(System.Array.Empty>().AsQueryable()); - db.Resources.Returns(System.Array.Empty().AsQueryable()); return db; } } diff --git a/backend/tests/CCE.Application.Tests/Content/Queries/ListHomepageSectionsQueryHandlerTests.cs b/backend/tests/CCE.Application.Tests/Content/Queries/ListHomepageSectionsQueryHandlerTests.cs index 6c4f556b..6808b97f 100644 --- a/backend/tests/CCE.Application.Tests/Content/Queries/ListHomepageSectionsQueryHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Content/Queries/ListHomepageSectionsQueryHandlerTests.cs @@ -9,7 +9,7 @@ public class ListHomepageSectionsQueryHandlerTests [Fact] public async Task Returns_empty_list_when_no_sections_exist() { - var db = BuildDb(System.Array.Empty()); + var db = BuildDb(Array.Empty()); var sut = new ListHomepageSectionsQueryHandler(db); var result = await sut.Handle(new ListHomepageSectionsQuery(), CancellationToken.None); @@ -20,10 +20,10 @@ public async Task Returns_empty_list_when_no_sections_exist() [Fact] public async Task Returns_sections_sorted_by_OrderIndex_ascending() { - var first = HomepageSection.Create(HomepageSectionType.Hero, 0, "ar-hero", "en-hero"); - var second = HomepageSection.Create(HomepageSectionType.FeaturedNews, 1, "ar-news", "en-news"); + var hero = HomepageSection.Create(HomepageSectionType.Hero, 0, "ar-hero", "en-hero"); + var news = HomepageSection.Create(HomepageSectionType.FeaturedNews, 1, "ar-news", "en-news"); - var db = BuildDb(new[] { second, first }); + var db = BuildDb([hero, news]); var sut = new ListHomepageSectionsQueryHandler(db); var result = await sut.Handle(new ListHomepageSectionsQuery(), CancellationToken.None); @@ -33,6 +33,23 @@ public async Task Returns_sections_sorted_by_OrderIndex_ascending() result[1].OrderIndex.Should().Be(1); } + [Fact] + public async Task Returns_both_active_and_inactive_sections() + { + var active = HomepageSection.Create(HomepageSectionType.Hero, 0, "ar-hero", "en-hero"); + var inactive = HomepageSection.Create(HomepageSectionType.FeaturedNews, 1, "ar-inactive", "en-inactive"); + inactive.Deactivate(); + + var db = BuildDb([active, inactive]); + var sut = new ListHomepageSectionsQueryHandler(db); + + var result = await sut.Handle(new ListHomepageSectionsQuery(), CancellationToken.None); + + result.Should().HaveCount(2); + result[0].IsActive.Should().BeTrue(); + result[1].IsActive.Should().BeFalse(); + } + private static ICceDbContext BuildDb(IEnumerable sections) { var db = Substitute.For(); diff --git a/backend/tests/CCE.Application.Tests/Content/Queries/ListNewsQueryHandlerTests.cs b/backend/tests/CCE.Application.Tests/Content/Queries/ListNewsQueryHandlerTests.cs index 3d26ad6b..e0388187 100644 --- a/backend/tests/CCE.Application.Tests/Content/Queries/ListNewsQueryHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Content/Queries/ListNewsQueryHandlerTests.cs @@ -7,13 +7,15 @@ namespace CCE.Application.Tests.Content.Queries; public class ListNewsQueryHandlerTests { + private static readonly FakeSystemClock Clock = new(); + [Fact] - public async Task Returns_empty_paged_result_when_no_news_exist() + public async Task Returns_empty_when_no_news() { - var db = BuildDb(System.Array.Empty()); + var db = BuildDb(Array.Empty()); var sut = new ListNewsQueryHandler(db); - var result = await sut.Handle(new ListNewsQuery(Page: 1, PageSize: 20), CancellationToken.None); + var result = await sut.Handle(new ListNewsQuery(), CancellationToken.None); result.Items.Should().BeEmpty(); result.Total.Should().Be(0); @@ -24,17 +26,13 @@ public async Task Returns_empty_paged_result_when_no_news_exist() [Fact] public async Task Returns_news_sorted_by_PublishedOn_descending() { - var clock = new FakeSystemClock(); - var authorId = System.Guid.NewGuid(); - - var older = News.Draft("أ", "Older", "محتوى", "Content A", "older-article", authorId, null, clock); - var newer = News.Draft("ب", "Newer", "محتوى ب", "Content B", "newer-article", authorId, null, clock); - - older.Publish(clock); - clock.Advance(System.TimeSpan.FromMinutes(5)); - newer.Publish(clock); + var older = News.Draft("أ", "Older", "محتوى", "Content A", "older-article", System.Guid.NewGuid(), null, Clock); + older.Publish(Clock); + Clock.Advance(System.TimeSpan.FromSeconds(1)); + var newer = News.Draft("ب", "Newer", "محتوى ب", "Content B", "newer-article", System.Guid.NewGuid(), null, Clock); + newer.Publish(Clock); - var db = BuildDb(new[] { older, newer }); + var db = BuildDb([newer, older]); var sut = new ListNewsQueryHandler(db); var result = await sut.Handle(new ListNewsQuery(Page: 1, PageSize: 20), CancellationToken.None); @@ -48,13 +46,9 @@ public async Task Returns_news_sorted_by_PublishedOn_descending() [Fact] public async Task Search_filter_matches_title_ar_title_en_or_slug() { - var clock = new FakeSystemClock(); - var authorId = System.Guid.NewGuid(); + var news = News.Draft("مطابق", "matching-title", "محتوى", "content", "matching-slug", System.Guid.NewGuid(), null, Clock); - var match = News.Draft("مطابق", "matching-title", "محتوى", "content", "matching-slug", authorId, null, clock); - var noMatch = News.Draft("آخر", "other-title", "محتوى آخر", "other content", "other-slug", authorId, null, clock); - - var db = BuildDb(new[] { match, noMatch }); + var db = BuildDb([news]); var sut = new ListNewsQueryHandler(db); var result = await sut.Handle(new ListNewsQuery(Search: "matching"), CancellationToken.None); @@ -66,18 +60,16 @@ public async Task Search_filter_matches_title_ar_title_en_or_slug() [Fact] public async Task IsPublished_and_IsFeatured_filters_work() { - var clock = new FakeSystemClock(); - var authorId = System.Guid.NewGuid(); - - var published = News.Draft("منشور", "published-news", "محتوى", "content", "published-news", authorId, null, clock); - var draft = News.Draft("مسودة", "draft-news", "محتوى", "content", "draft-news", authorId, null, clock); - var featured = News.Draft("مميز", "featured-news", "محتوى", "content", "featured-news", authorId, null, clock); + var published = News.Draft("منشور", "published-news", "محتوى", "content", "published-news", System.Guid.NewGuid(), null, Clock); + published.Publish(Clock); - published.Publish(clock); - featured.Publish(clock); + var featured = News.Draft("مميز", "featured-news", "محتوى", "content", "featured-news", System.Guid.NewGuid(), null, Clock); + featured.Publish(Clock); featured.MarkFeatured(); - var db = BuildDb(new[] { published, draft, featured }); + var draft = News.Draft("مسودة", "draft-news", "محتوى", "content", "draft-news", System.Guid.NewGuid(), null, Clock); + + var db = BuildDb([published, featured, draft]); var sut = new ListNewsQueryHandler(db); var publishedResult = await sut.Handle(new ListNewsQuery(IsPublished: true), CancellationToken.None); @@ -87,16 +79,16 @@ public async Task IsPublished_and_IsFeatured_filters_work() var featuredResult = await sut.Handle(new ListNewsQuery(IsFeatured: true), CancellationToken.None); featuredResult.Total.Should().Be(1); featuredResult.Items.Single().TitleEn.Should().Be("featured-news"); + + var draftResult = await sut.Handle(new ListNewsQuery(IsPublished: false), CancellationToken.None); + draftResult.Total.Should().Be(1); + draftResult.Items.Single().TitleEn.Should().Be("draft-news"); } private static ICceDbContext BuildDb(IEnumerable news) { var db = Substitute.For(); db.News.Returns(news.AsQueryable()); - db.Users.Returns(System.Array.Empty().AsQueryable()); - db.Roles.Returns(System.Array.Empty().AsQueryable()); - db.UserRoles.Returns(System.Array.Empty>().AsQueryable()); - db.Resources.Returns(System.Array.Empty().AsQueryable()); return db; } } diff --git a/backend/tests/CCE.Application.Tests/Content/Queries/ListPagesQueryHandlerTests.cs b/backend/tests/CCE.Application.Tests/Content/Queries/ListPagesQueryHandlerTests.cs index 51c4c364..354a34ea 100644 --- a/backend/tests/CCE.Application.Tests/Content/Queries/ListPagesQueryHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Content/Queries/ListPagesQueryHandlerTests.cs @@ -9,7 +9,7 @@ public class ListPagesQueryHandlerTests [Fact] public async Task Returns_empty_paged_result_when_no_pages_exist() { - var db = BuildDb(System.Array.Empty()); + var db = BuildDb(Array.Empty()); var sut = new ListPagesQueryHandler(db); var result = await sut.Handle(new ListPagesQuery(Page: 1, PageSize: 20), CancellationToken.None); @@ -26,7 +26,7 @@ public async Task Returns_pages_sorted_by_Slug_ascending() var alpha = Page.Create("alpha-page", PageType.Custom, "أ", "Alpha", "محتوى", "content"); var beta = Page.Create("beta-page", PageType.Custom, "ب", "Beta", "محتوى", "content"); - var db = BuildDb(new[] { beta, alpha }); + var db = BuildDb([alpha, beta]); var sut = new ListPagesQueryHandler(db); var result = await sut.Handle(new ListPagesQuery(Page: 1, PageSize: 20), CancellationToken.None); @@ -39,10 +39,9 @@ public async Task Returns_pages_sorted_by_Slug_ascending() [Fact] public async Task Search_filter_matches_slug_titleAr_or_titleEn() { - var match = Page.Create("test-slug", PageType.Custom, "ar", "matching-title", "content-ar", "content-en"); - var noMatch = Page.Create("other-slug", PageType.Custom, "ar", "other-title", "content-ar", "content-en"); + var page = Page.Create("test-slug", PageType.Custom, "ar", "matching-title", "content-ar", "content-en"); - var db = BuildDb(new[] { match, noMatch }); + var db = BuildDb([page]); var sut = new ListPagesQueryHandler(db); var result = await sut.Handle(new ListPagesQuery(Search: "matching"), CancellationToken.None); @@ -51,6 +50,21 @@ public async Task Search_filter_matches_slug_titleAr_or_titleEn() result.Items.Single().TitleEn.Should().Be("matching-title"); } + [Fact] + public async Task PageType_filter_returns_only_matching_types() + { + var custom = Page.Create("custom-page", PageType.Custom, "ar", "Custom", "content-ar", "content-en"); + var about = Page.Create("about-page", PageType.AboutPlatform, "ar", "About", "content-ar", "content-en"); + + var db = BuildDb([custom, about]); + var sut = new ListPagesQueryHandler(db); + + var result = await sut.Handle(new ListPagesQuery(PageType: PageType.AboutPlatform), CancellationToken.None); + + result.Total.Should().Be(1); + result.Items.Single().TitleEn.Should().Be("About"); + } + private static ICceDbContext BuildDb(IEnumerable pages) { var db = Substitute.For(); diff --git a/backend/tests/CCE.Application.Tests/Content/Queries/ListResourceCategoriesQueryHandlerTests.cs b/backend/tests/CCE.Application.Tests/Content/Queries/ListResourceCategoriesQueryHandlerTests.cs index 858892d8..4bf2d431 100644 --- a/backend/tests/CCE.Application.Tests/Content/Queries/ListResourceCategoriesQueryHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Content/Queries/ListResourceCategoriesQueryHandlerTests.cs @@ -9,7 +9,7 @@ public class ListResourceCategoriesQueryHandlerTests [Fact] public async Task Returns_empty_paged_result_when_no_categories_exist() { - var db = BuildDb(System.Array.Empty()); + var db = BuildDb(Array.Empty()); var sut = new ListResourceCategoriesQueryHandler(db); var result = await sut.Handle(new ListResourceCategoriesQuery(Page: 1, PageSize: 20), CancellationToken.None); @@ -27,7 +27,7 @@ public async Task IsActive_filter_returns_only_active_categories() var inactive = ResourceCategory.Create("غير نشط", "Inactive", "inactive", null, 2); inactive.Deactivate(); - var db = BuildDb(new[] { active, inactive }); + var db = BuildDb([active, inactive]); var sut = new ListResourceCategoriesQueryHandler(db); var result = await sut.Handle(new ListResourceCategoriesQuery(IsActive: true), CancellationToken.None); @@ -41,9 +41,9 @@ public async Task ParentId_filter_returns_only_children_of_given_parent() { var parentId = System.Guid.NewGuid(); var child = ResourceCategory.Create("فرعي", "Child", "child", parentId, 1); - var root = ResourceCategory.Create("جذر", "Root", "root", null, 0); + var unrelated = ResourceCategory.Create("مستقل", "Standalone", "standalone", null, 2); - var db = BuildDb(new[] { child, root }); + var db = BuildDb([child, unrelated]); var sut = new ListResourceCategoriesQueryHandler(db); var result = await sut.Handle(new ListResourceCategoriesQuery(ParentId: parentId), CancellationToken.None); @@ -52,6 +52,22 @@ public async Task ParentId_filter_returns_only_children_of_given_parent() result.Items.Single().NameEn.Should().Be("Child"); } + [Fact] + public async Task Returns_categories_sorted_by_OrderIndex() + { + var second = ResourceCategory.Create("ثاني", "Second", "second", null, 5); + var first = ResourceCategory.Create("أول", "First", "first", null, 1); + + var db = BuildDb([second, first]); + var sut = new ListResourceCategoriesQueryHandler(db); + + var result = await sut.Handle(new ListResourceCategoriesQuery(), CancellationToken.None); + + result.Total.Should().Be(2); + result.Items[0].NameEn.Should().Be("First"); + result.Items[1].NameEn.Should().Be("Second"); + } + private static ICceDbContext BuildDb(IEnumerable categories) { var db = Substitute.For(); diff --git a/backend/tests/CCE.Application.Tests/Content/Queries/ListResourcesQueryHandlerTests.cs b/backend/tests/CCE.Application.Tests/Content/Queries/ListResourcesQueryHandlerTests.cs index 6a3abd2c..91c9f9a9 100644 --- a/backend/tests/CCE.Application.Tests/Content/Queries/ListResourcesQueryHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Content/Queries/ListResourcesQueryHandlerTests.cs @@ -7,10 +7,12 @@ namespace CCE.Application.Tests.Content.Queries; public class ListResourcesQueryHandlerTests { + private static readonly FakeSystemClock Clock = new(); + [Fact] public async Task Returns_empty_paged_result_when_no_resources_exist() { - var db = BuildDb(System.Array.Empty()); + var db = BuildDb(Array.Empty()); var sut = new ListResourcesQueryHandler(db); var result = await sut.Handle(new ListResourcesQuery(Page: 1, PageSize: 20), CancellationToken.None); @@ -24,19 +26,19 @@ public async Task Returns_empty_paged_result_when_no_resources_exist() [Fact] public async Task Returns_resources_sorted_by_PublishedOn_descending() { - var clock = new FakeSystemClock(); var cat = System.Guid.NewGuid(); var uploader = System.Guid.NewGuid(); var asset = System.Guid.NewGuid(); - var older = Resource.Draft("أ", "A", "وصف أ", "Desc A", ResourceType.Pdf, cat, null, uploader, asset, clock); - var newer = Resource.Draft("ب", "B", "وصف ب", "Desc B", ResourceType.Video, cat, null, uploader, asset, clock); - - older.Publish(clock); - clock.Advance(System.TimeSpan.FromMinutes(5)); - newer.Publish(clock); + var older = Resource.Draft("أ", "A", "وصف أ", "Desc A", + ResourceType.Pdf, cat, null, uploader, asset, Clock); + older.Publish(Clock); + Clock.Advance(System.TimeSpan.FromSeconds(1)); + var newer = Resource.Draft("ب", "B", "وصف ب", "Desc B", + ResourceType.Video, cat, null, uploader, asset, Clock); + newer.Publish(Clock); - var db = BuildDb(new[] { older, newer }); + var db = BuildDb([newer, older]); var sut = new ListResourcesQueryHandler(db); var result = await sut.Handle(new ListResourcesQuery(Page: 1, PageSize: 20), CancellationToken.None); @@ -48,17 +50,16 @@ public async Task Returns_resources_sorted_by_PublishedOn_descending() } [Fact] - public async Task Search_filter_matches_title_ar_or_title_en() + public async Task Search_filter_matches_title_ar_title_en_description_ar_or_description_en() { - var clock = new FakeSystemClock(); var cat = System.Guid.NewGuid(); var uploader = System.Guid.NewGuid(); var asset = System.Guid.NewGuid(); - var match = Resource.Draft("مطابق", "matching", "وصف", "desc", ResourceType.Pdf, cat, null, uploader, asset, clock); - var noMatch = Resource.Draft("آخر", "other", "وصف آخر", "other desc", ResourceType.Pdf, cat, null, uploader, asset, clock); + var resource = Resource.Draft("مطابق", "matching", "وصف", "desc", + ResourceType.Pdf, cat, null, uploader, asset, Clock); - var db = BuildDb(new[] { match, noMatch }); + var db = BuildDb([resource]); var sut = new ListResourcesQueryHandler(db); var result = await sut.Handle(new ListResourcesQuery(Search: "matching"), CancellationToken.None); @@ -70,16 +71,18 @@ public async Task Search_filter_matches_title_ar_or_title_en() [Fact] public async Task IsPublished_filter_returns_only_published_resources() { - var clock = new FakeSystemClock(); var cat = System.Guid.NewGuid(); var uploader = System.Guid.NewGuid(); var asset = System.Guid.NewGuid(); - var published = Resource.Draft("منشور", "published", "وصف", "desc", ResourceType.Pdf, cat, null, uploader, asset, clock); - var draft = Resource.Draft("مسودة", "draft-resource", "وصف", "desc", ResourceType.Pdf, cat, null, uploader, asset, clock); - published.Publish(clock); + var published = Resource.Draft("منشور", "published", "وصف", "desc", + ResourceType.Pdf, cat, null, uploader, asset, Clock); + published.Publish(Clock); - var db = BuildDb(new[] { published, draft }); + var draft = Resource.Draft("مسودة", "draft", "وصف", "desc", + ResourceType.Pdf, cat, null, uploader, asset, Clock); + + var db = BuildDb([published, draft]); var sut = new ListResourcesQueryHandler(db); var result = await sut.Handle(new ListResourcesQuery(IsPublished: true), CancellationToken.None); @@ -89,14 +92,32 @@ public async Task IsPublished_filter_returns_only_published_resources() result.Items.Single().IsPublished.Should().BeTrue(); } + [Fact] + public async Task CategoryId_filter_returns_only_matching_resources() + { + var catA = System.Guid.NewGuid(); + var catB = System.Guid.NewGuid(); + var uploader = System.Guid.NewGuid(); + var asset = System.Guid.NewGuid(); + + var match = Resource.Draft("أ", "Match", "وصف", "desc", + ResourceType.Pdf, catA, null, uploader, asset, Clock); + var noMatch = Resource.Draft("ب", "NoMatch", "وصف", "desc", + ResourceType.Pdf, catB, null, uploader, asset, Clock); + + var db = BuildDb([match, noMatch]); + var sut = new ListResourcesQueryHandler(db); + + var result = await sut.Handle(new ListResourcesQuery(CategoryId: catA), CancellationToken.None); + + result.Total.Should().Be(1); + result.Items.Single().TitleEn.Should().Be("Match"); + } + private static ICceDbContext BuildDb(IEnumerable resources) { var db = Substitute.For(); db.Resources.Returns(resources.AsQueryable()); - db.Users.Returns(System.Array.Empty().AsQueryable()); - db.Roles.Returns(System.Array.Empty().AsQueryable()); - db.UserRoles.Returns(System.Array.Empty>().AsQueryable()); - db.AssetFiles.Returns(System.Array.Empty().AsQueryable()); return db; } } diff --git a/backend/tests/CCE.Application.Tests/Identity/Commands/ApproveExpertRequestCommandHandlerTests.cs b/backend/tests/CCE.Application.Tests/Identity/Commands/ApproveExpertRequestCommandHandlerTests.cs index 96498469..283bc8b2 100644 --- a/backend/tests/CCE.Application.Tests/Identity/Commands/ApproveExpertRequestCommandHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Identity/Commands/ApproveExpertRequestCommandHandlerTests.cs @@ -5,6 +5,7 @@ using CCE.Domain.Identity; using CCE.TestInfrastructure.Time; using Microsoft.AspNetCore.Identity; +using static CCE.Application.Tests.Identity.IdentityTestHelpers; namespace CCE.Application.Tests.Identity.Commands; @@ -13,17 +14,18 @@ public class ApproveExpertRequestCommandHandlerTests [Fact] public async Task Throws_KeyNotFound_when_request_missing() { - var service = Substitute.For(); + var service = Substitute.For(); service.FindIncludingDeletedAsync(Arg.Any(), Arg.Any()) .Returns((ExpertRegistrationRequest?)null); - var sut = new ApproveExpertRequestCommandHandler(service, BuildDb(), BuildCurrentUser(), new FakeSystemClock()); + var sut = new ApproveExpertRequestCommandHandler(service, BuildDb(), BuildCurrentUser(), new FakeSystemClock(), BuildErrors()); - var act = async () => await sut.Handle( + var result = await sut.Handle( new ApproveExpertRequestCommand(System.Guid.NewGuid(), "Dr.", "Dr."), CancellationToken.None); - await act.Should().ThrowAsync(); + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be("IDENTITY_EXPERT_REQUEST_NOT_FOUND"); } [Fact] @@ -32,19 +34,20 @@ public async Task Throws_DomainException_when_actor_unknown() var clock = new FakeSystemClock(); var registration = ExpertRegistrationRequest.Submit( System.Guid.NewGuid(), "bio-ar", "bio-en", new[] { "Hydrogen" }, clock); - var service = Substitute.For(); + var service = Substitute.For(); service.FindIncludingDeletedAsync(Arg.Any(), Arg.Any()) .Returns(registration); var currentUser = Substitute.For(); currentUser.GetUserId().Returns((System.Guid?)null); - var sut = new ApproveExpertRequestCommandHandler(service, BuildDb(), currentUser, clock); + var sut = new ApproveExpertRequestCommandHandler(service, BuildDb(), currentUser, clock, BuildErrors()); - var act = async () => await sut.Handle( + var result = await sut.Handle( new ApproveExpertRequestCommand(registration.Id, "Dr.", "Dr."), CancellationToken.None); - await act.Should().ThrowAsync(); + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be("IDENTITY_NOT_AUTHENTICATED"); } [Fact] @@ -56,11 +59,11 @@ public async Task Throws_DomainException_when_request_not_pending() var adminId = System.Guid.NewGuid(); registration.Approve(adminId, clock); // already approved - var service = Substitute.For(); + var service = Substitute.For(); service.FindIncludingDeletedAsync(Arg.Any(), Arg.Any()) .Returns(registration); - var sut = new ApproveExpertRequestCommandHandler(service, BuildDb(), BuildCurrentUser(adminId), clock); + var sut = new ApproveExpertRequestCommandHandler(service, BuildDb(), BuildCurrentUser(adminId), clock, BuildErrors()); var act = async () => await sut.Handle( new ApproveExpertRequestCommand(registration.Id, "Dr.", "Dr."), @@ -78,22 +81,22 @@ public async Task Approves_request_and_creates_profile_when_valid() var registration = ExpertRegistrationRequest.Submit( requesterId, "bio-ar", "bio-en", new[] { "Hydrogen", "CCS" }, clock); - var service = Substitute.For(); + var service = Substitute.For(); service.FindIncludingDeletedAsync(Arg.Any(), Arg.Any()) .Returns(registration); var users = new[] { BuildUser(requesterId, "alice@cce.local", "alice") }; - var sut = new ApproveExpertRequestCommandHandler(service, BuildDb(users), BuildCurrentUser(adminId), clock); + var sut = new ApproveExpertRequestCommandHandler(service, BuildDb(users), BuildCurrentUser(adminId), clock, BuildErrors()); - var dto = await sut.Handle( + var result = await sut.Handle( new ApproveExpertRequestCommand(registration.Id, "أستاذ مساعد", "Assistant Professor"), CancellationToken.None); - dto.UserId.Should().Be(requesterId); - dto.UserName.Should().Be("alice"); - dto.AcademicTitleEn.Should().Be("Assistant Professor"); - dto.ExpertiseTags.Should().BeEquivalentTo(new[] { "Hydrogen", "CCS" }); + result.Data!.UserId.Should().Be(requesterId); + result.Data!.UserName.Should().Be("alice"); + result.Data!.AcademicTitleEn.Should().Be("Assistant Professor"); + result.Data!.ExpertiseTags.Should().BeEquivalentTo(new[] { "Hydrogen", "CCS" }); registration.Status.Should().Be(ExpertRegistrationStatus.Approved); await service.Received(1).SaveAsync(registration, Arg.Any(), Arg.Any()); } diff --git a/backend/tests/CCE.Application.Tests/Identity/Commands/AssignUserRolesCommandHandlerTests.cs b/backend/tests/CCE.Application.Tests/Identity/Commands/AssignUserRolesCommandHandlerTests.cs index c38cb5cc..7b082158 100644 --- a/backend/tests/CCE.Application.Tests/Identity/Commands/AssignUserRolesCommandHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Identity/Commands/AssignUserRolesCommandHandlerTests.cs @@ -1,34 +1,37 @@ +using CCE.Application.Common; using CCE.Application.Identity; using CCE.Application.Identity.Commands.AssignUserRoles; using CCE.Application.Identity.Dtos; using CCE.Application.Identity.Queries.GetUserById; using CCE.Domain.Identity; using MediatR; +using static CCE.Application.Tests.Identity.IdentityTestHelpers; namespace CCE.Application.Tests.Identity.Commands; public class AssignUserRolesCommandHandlerTests { [Fact] - public async Task Returns_null_when_service_reports_user_missing() + public async Task Returns_failure_when_service_reports_user_missing() { - var service = Substitute.For(); + var service = Substitute.For(); service.ReplaceRolesAsync(Arg.Any(), Arg.Any>(), Arg.Any()) .Returns(false); var mediator = Substitute.For(); - var sut = new AssignUserRolesCommandHandler(service, mediator); + var sut = new AssignUserRolesCommandHandler(service, mediator, BuildErrors()); var result = await sut.Handle(new AssignUserRolesCommand(System.Guid.NewGuid(), new[] { "SuperAdmin" }), CancellationToken.None); - result.Should().BeNull(); - await mediator.DidNotReceiveWithAnyArgs().Send(default!, default); + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be("IDENTITY_USER_NOT_FOUND"); + await mediator.DidNotReceiveWithAnyArgs().Send>(default!, default); } [Fact] public async Task Returns_user_detail_when_service_succeeds() { var id = System.Guid.NewGuid(); - var service = Substitute.For(); + var service = Substitute.For(); service.ReplaceRolesAsync(id, Arg.Any>(), Arg.Any()) .Returns(true); @@ -37,23 +40,29 @@ public async Task Returns_user_detail_when_service_succeeds() new[] { "ContentManager" }, true); var mediator = Substitute.For(); mediator.Send(Arg.Is(q => q.Id == id), Arg.Any()) - .Returns((UserDetailDto?)dto); + .Returns(Result.Success(dto)); - var sut = new AssignUserRolesCommandHandler(service, mediator); + var sut = new AssignUserRolesCommandHandler(service, mediator, BuildErrors()); var result = await sut.Handle(new AssignUserRolesCommand(id, new[] { "ContentManager" }), CancellationToken.None); - result.Should().BeEquivalentTo(dto); + result.IsSuccess.Should().BeTrue(); + result.Data!.Should().BeEquivalentTo(dto); } [Fact] public async Task Forwards_role_list_to_service() { var id = System.Guid.NewGuid(); - var service = Substitute.For(); + var service = Substitute.For(); service.ReplaceRolesAsync(default, default!, default).ReturnsForAnyArgs(true); var mediator = Substitute.For(); - var sut = new AssignUserRolesCommandHandler(service, mediator); + mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Result.Success(new UserDetailDto( + id, "alice@cce.local", "alice", "ar", + KnowledgeLevel.Beginner, System.Array.Empty(), null, null, + new[] { "SuperAdmin", "ContentManager" }, true))); + var sut = new AssignUserRolesCommandHandler(service, mediator, BuildErrors()); var roles = new[] { "SuperAdmin", "ContentManager" }; await sut.Handle(new AssignUserRolesCommand(id, roles), CancellationToken.None); diff --git a/backend/tests/CCE.Application.Tests/Identity/Commands/CreateStateRepAssignmentCommandHandlerTests.cs b/backend/tests/CCE.Application.Tests/Identity/Commands/CreateStateRepAssignmentCommandHandlerTests.cs index 2bfba1b8..415d976f 100644 --- a/backend/tests/CCE.Application.Tests/Identity/Commands/CreateStateRepAssignmentCommandHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Identity/Commands/CreateStateRepAssignmentCommandHandlerTests.cs @@ -1,3 +1,4 @@ +using CCE.Application.Common; using CCE.Application.Common.Interfaces; using CCE.Application.Identity; using CCE.Application.Identity.Commands.CreateStateRepAssignment; @@ -5,43 +6,46 @@ using CCE.Domain.Identity; using CCE.TestInfrastructure.Time; using Microsoft.AspNetCore.Identity; +using static CCE.Application.Tests.Identity.IdentityTestHelpers; namespace CCE.Application.Tests.Identity.Commands; public class CreateStateRepAssignmentCommandHandlerTests { [Fact] - public async Task Throws_KeyNotFound_when_user_missing() + public async Task Returns_failure_when_user_missing() { var db = BuildDb(System.Array.Empty(), System.Array.Empty()); var sut = new CreateStateRepAssignmentCommandHandler( - db, Substitute.For(), BuildCurrentUser(), new FakeSystemClock()); + db, Substitute.For(), BuildCurrentUser(), new FakeSystemClock(), BuildErrors()); - var act = async () => await sut.Handle( + var result = await sut.Handle( new CreateStateRepAssignmentCommand(System.Guid.NewGuid(), System.Guid.NewGuid()), CancellationToken.None); - await act.Should().ThrowAsync(); + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be("IDENTITY_USER_NOT_FOUND"); } [Fact] - public async Task Throws_KeyNotFound_when_country_missing() + public async Task Returns_failure_when_country_missing() { var aliceId = System.Guid.NewGuid(); var users = new[] { BuildUser(aliceId, "alice@cce.local", "alice") }; var db = BuildDb(users, System.Array.Empty()); var sut = new CreateStateRepAssignmentCommandHandler( - db, Substitute.For(), BuildCurrentUser(), new FakeSystemClock()); + db, Substitute.For(), BuildCurrentUser(), new FakeSystemClock(), BuildErrors()); - var act = async () => await sut.Handle( + var result = await sut.Handle( new CreateStateRepAssignmentCommand(aliceId, System.Guid.NewGuid()), CancellationToken.None); - await act.Should().ThrowAsync(); + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be("COUNTRY_COUNTRY_NOT_FOUND"); } [Fact] - public async Task Throws_DomainException_when_actor_unknown() + public async Task Returns_failure_when_actor_unknown() { var aliceId = System.Guid.NewGuid(); var country = BuildCountry(); @@ -51,13 +55,14 @@ public async Task Throws_DomainException_when_actor_unknown() var db = BuildDb(users, new[] { country }); var sut = new CreateStateRepAssignmentCommandHandler( - db, Substitute.For(), currentUser, new FakeSystemClock()); + db, Substitute.For(), currentUser, new FakeSystemClock(), BuildErrors()); - var act = async () => await sut.Handle( + var result = await sut.Handle( new CreateStateRepAssignmentCommand(aliceId, country.Id), CancellationToken.None); - await act.Should().ThrowAsync(); + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be("IDENTITY_NOT_AUTHENTICATED"); } [Fact] @@ -66,21 +71,22 @@ public async Task Persists_assignment_and_returns_dto_when_inputs_valid() var aliceId = System.Guid.NewGuid(); var country = BuildCountry(); var users = new[] { BuildUser(aliceId, "alice@cce.local", "alice") }; - var service = Substitute.For(); + var service = Substitute.For(); var currentUser = BuildCurrentUser(); var clock = new FakeSystemClock(); var db = BuildDb(users, new[] { country }); - var sut = new CreateStateRepAssignmentCommandHandler(db, service, currentUser, clock); + var sut = new CreateStateRepAssignmentCommandHandler(db, service, currentUser, clock, BuildErrors()); - var dto = await sut.Handle( + var result = await sut.Handle( new CreateStateRepAssignmentCommand(aliceId, country.Id), CancellationToken.None); - dto.UserId.Should().Be(aliceId); - dto.CountryId.Should().Be(country.Id); - dto.UserName.Should().Be("alice"); - dto.IsActive.Should().BeTrue(); + result.IsSuccess.Should().BeTrue(); + result.Data!.UserId.Should().Be(aliceId); + result.Data!.CountryId.Should().Be(country.Id); + result.Data!.UserName.Should().Be("alice"); + result.Data!.IsActive.Should().BeTrue(); await service.Received(1).SaveAsync(Arg.Any(), Arg.Any()); } diff --git a/backend/tests/CCE.Application.Tests/Identity/Commands/RejectExpertRequestCommandHandlerTests.cs b/backend/tests/CCE.Application.Tests/Identity/Commands/RejectExpertRequestCommandHandlerTests.cs index e9155087..b534d016 100644 --- a/backend/tests/CCE.Application.Tests/Identity/Commands/RejectExpertRequestCommandHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Identity/Commands/RejectExpertRequestCommandHandlerTests.cs @@ -5,6 +5,7 @@ using CCE.Domain.Identity; using CCE.TestInfrastructure.Time; using Microsoft.AspNetCore.Identity; +using static CCE.Application.Tests.Identity.IdentityTestHelpers; namespace CCE.Application.Tests.Identity.Commands; @@ -13,17 +14,18 @@ public class RejectExpertRequestCommandHandlerTests [Fact] public async Task Throws_KeyNotFound_when_request_missing() { - var service = Substitute.For(); + var service = Substitute.For(); service.FindIncludingDeletedAsync(Arg.Any(), Arg.Any()) .Returns((ExpertRegistrationRequest?)null); - var sut = new RejectExpertRequestCommandHandler(service, BuildDb(), BuildCurrentUser(), new FakeSystemClock()); + var sut = new RejectExpertRequestCommandHandler(service, BuildDb(), BuildCurrentUser(), new FakeSystemClock(), BuildErrors()); - var act = async () => await sut.Handle( + var result = await sut.Handle( new RejectExpertRequestCommand(System.Guid.NewGuid(), "غير مؤهل", "Insufficient evidence."), CancellationToken.None); - await act.Should().ThrowAsync(); + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be("IDENTITY_EXPERT_REQUEST_NOT_FOUND"); } [Fact] @@ -32,19 +34,20 @@ public async Task Throws_DomainException_when_actor_unknown() var clock = new FakeSystemClock(); var registration = ExpertRegistrationRequest.Submit( System.Guid.NewGuid(), "bio-ar", "bio-en", new[] { "Hydrogen" }, clock); - var service = Substitute.For(); + var service = Substitute.For(); service.FindIncludingDeletedAsync(Arg.Any(), Arg.Any()) .Returns(registration); var currentUser = Substitute.For(); currentUser.GetUserId().Returns((System.Guid?)null); - var sut = new RejectExpertRequestCommandHandler(service, BuildDb(), currentUser, clock); + var sut = new RejectExpertRequestCommandHandler(service, BuildDb(), currentUser, clock, BuildErrors()); - var act = async () => await sut.Handle( + var result = await sut.Handle( new RejectExpertRequestCommand(registration.Id, "غير مؤهل", "Insufficient evidence."), CancellationToken.None); - await act.Should().ThrowAsync(); + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be("IDENTITY_NOT_AUTHENTICATED"); } [Fact] @@ -56,11 +59,11 @@ public async Task Throws_DomainException_when_request_not_pending() var adminId = System.Guid.NewGuid(); registration.Approve(adminId, clock); // already approved — not Pending - var service = Substitute.For(); + var service = Substitute.For(); service.FindIncludingDeletedAsync(Arg.Any(), Arg.Any()) .Returns(registration); - var sut = new RejectExpertRequestCommandHandler(service, BuildDb(), BuildCurrentUser(adminId), clock); + var sut = new RejectExpertRequestCommandHandler(service, BuildDb(), BuildCurrentUser(adminId), clock, BuildErrors()); var act = async () => await sut.Handle( new RejectExpertRequestCommand(registration.Id, "غير مؤهل", "Insufficient evidence."), @@ -78,21 +81,21 @@ public async Task Rejects_request_and_persists_when_valid() var registration = ExpertRegistrationRequest.Submit( requesterId, "bio-ar", "bio-en", new[] { "Hydrogen", "CCS" }, clock); - var service = Substitute.For(); + var service = Substitute.For(); service.FindIncludingDeletedAsync(Arg.Any(), Arg.Any()) .Returns(registration); var users = new[] { BuildUser(requesterId, "alice@cce.local", "alice") }; - var sut = new RejectExpertRequestCommandHandler(service, BuildDb(users), BuildCurrentUser(adminId), clock); + var sut = new RejectExpertRequestCommandHandler(service, BuildDb(users), BuildCurrentUser(adminId), clock, BuildErrors()); - var dto = await sut.Handle( + var result = await sut.Handle( new RejectExpertRequestCommand(registration.Id, "غير مؤهل", "Insufficient evidence."), CancellationToken.None); - dto.Status.Should().Be(ExpertRegistrationStatus.Rejected); - dto.RejectionReasonEn.Should().Be("Insufficient evidence."); - dto.RejectionReasonAr.Should().Be("غير مؤهل"); + result.Data!.Status.Should().Be(ExpertRegistrationStatus.Rejected); + result.Data!.RejectionReasonEn.Should().Be("Insufficient evidence."); + result.Data!.RejectionReasonAr.Should().Be("غير مؤهل"); registration.Status.Should().Be(ExpertRegistrationStatus.Rejected); await service.Received(1).SaveAsync(registration, null, Arg.Any()); } diff --git a/backend/tests/CCE.Application.Tests/Identity/Commands/RevokeStateRepAssignmentCommandHandlerTests.cs b/backend/tests/CCE.Application.Tests/Identity/Commands/RevokeStateRepAssignmentCommandHandlerTests.cs index 052d26e3..749663fd 100644 --- a/backend/tests/CCE.Application.Tests/Identity/Commands/RevokeStateRepAssignmentCommandHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Identity/Commands/RevokeStateRepAssignmentCommandHandlerTests.cs @@ -1,46 +1,50 @@ +using CCE.Application.Common; using CCE.Application.Common.Interfaces; using CCE.Application.Identity; using CCE.Application.Identity.Commands.RevokeStateRepAssignment; using CCE.Domain.Common; using CCE.Domain.Identity; using CCE.TestInfrastructure.Time; +using static CCE.Application.Tests.Identity.IdentityTestHelpers; namespace CCE.Application.Tests.Identity.Commands; public class RevokeStateRepAssignmentCommandHandlerTests { [Fact] - public async Task Throws_KeyNotFound_when_assignment_missing() + public async Task Returns_failure_when_assignment_missing() { - var service = Substitute.For(); + var service = Substitute.For(); service.FindIncludingRevokedAsync(Arg.Any(), Arg.Any()) .Returns((StateRepresentativeAssignment?)null); - var sut = new RevokeStateRepAssignmentCommandHandler(service, BuildCurrentUser(), new FakeSystemClock()); + var sut = new RevokeStateRepAssignmentCommandHandler(service, BuildCurrentUser(), new FakeSystemClock(), BuildErrors()); - var act = async () => await sut.Handle(new RevokeStateRepAssignmentCommand(System.Guid.NewGuid()), CancellationToken.None); + var result = await sut.Handle(new RevokeStateRepAssignmentCommand(System.Guid.NewGuid()), CancellationToken.None); - await act.Should().ThrowAsync(); + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be("IDENTITY_STATE_REP_ASSIGNMENT_NOT_FOUND"); } [Fact] - public async Task Throws_DomainException_when_actor_unknown() + public async Task Returns_failure_when_actor_unknown() { var clock = new FakeSystemClock(); var assignment = StateRepresentativeAssignment.Assign( System.Guid.NewGuid(), System.Guid.NewGuid(), System.Guid.NewGuid(), clock); - var service = Substitute.For(); + var service = Substitute.For(); service.FindIncludingRevokedAsync(Arg.Any(), Arg.Any()) .Returns(assignment); var currentUser = Substitute.For(); currentUser.GetUserId().Returns((System.Guid?)null); - var sut = new RevokeStateRepAssignmentCommandHandler(service, currentUser, clock); + var sut = new RevokeStateRepAssignmentCommandHandler(service, currentUser, clock, BuildErrors()); - var act = async () => await sut.Handle(new RevokeStateRepAssignmentCommand(assignment.Id), CancellationToken.None); + var result = await sut.Handle(new RevokeStateRepAssignmentCommand(assignment.Id), CancellationToken.None); - await act.Should().ThrowAsync(); + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be("IDENTITY_NOT_AUTHENTICATED"); } [Fact] @@ -52,11 +56,11 @@ public async Task Throws_DomainException_when_already_revoked() System.Guid.NewGuid(), System.Guid.NewGuid(), revokerId, clock); assignment.Revoke(revokerId, clock); // already revoked - var service = Substitute.For(); + var service = Substitute.For(); service.FindIncludingRevokedAsync(Arg.Any(), Arg.Any()) .Returns(assignment); - var sut = new RevokeStateRepAssignmentCommandHandler(service, BuildCurrentUser(revokerId), clock); + var sut = new RevokeStateRepAssignmentCommandHandler(service, BuildCurrentUser(revokerId), clock, BuildErrors()); var act = async () => await sut.Handle(new RevokeStateRepAssignmentCommand(assignment.Id), CancellationToken.None); @@ -71,14 +75,15 @@ public async Task Revokes_and_persists_when_valid() var assignment = StateRepresentativeAssignment.Assign( System.Guid.NewGuid(), System.Guid.NewGuid(), revokerId, clock); - var service = Substitute.For(); + var service = Substitute.For(); service.FindIncludingRevokedAsync(Arg.Any(), Arg.Any()) .Returns(assignment); - var sut = new RevokeStateRepAssignmentCommandHandler(service, BuildCurrentUser(revokerId), clock); + var sut = new RevokeStateRepAssignmentCommandHandler(service, BuildCurrentUser(revokerId), clock, BuildErrors()); - await sut.Handle(new RevokeStateRepAssignmentCommand(assignment.Id), CancellationToken.None); + var result = await sut.Handle(new RevokeStateRepAssignmentCommand(assignment.Id), CancellationToken.None); + result.IsSuccess.Should().BeTrue(); assignment.IsDeleted.Should().BeTrue(); assignment.RevokedOn.Should().NotBeNull(); assignment.RevokedById.Should().Be(revokerId); diff --git a/backend/tests/CCE.Application.Tests/Identity/IdentityTestHelpers.cs b/backend/tests/CCE.Application.Tests/Identity/IdentityTestHelpers.cs new file mode 100644 index 00000000..d18641d0 --- /dev/null +++ b/backend/tests/CCE.Application.Tests/Identity/IdentityTestHelpers.cs @@ -0,0 +1,25 @@ +using CCE.Application.Localization; +using NSubstitute; + +namespace CCE.Application.Tests.Identity; + +/// +/// Shared helpers for Identity handler tests that need a localized factory. +/// +public static class IdentityTestHelpers +{ + /// + /// Builds a instance backed by an + /// stub that returns the key as both Ar and En text. + /// + public static CCE.Application.Common.Errors BuildErrors() + { + var localization = Substitute.For(); + localization.GetLocalizedMessage(Arg.Any()) + .Returns(call => new LocalizedMessage( + Ar: call.Arg(), + En: call.Arg())); + + return new CCE.Application.Common.Errors(localization); + } +} diff --git a/backend/tests/CCE.Application.Tests/Identity/Public/Commands/SubmitExpertRequestCommandHandlerTests.cs b/backend/tests/CCE.Application.Tests/Identity/Public/Commands/SubmitExpertRequestCommandHandlerTests.cs index a0a03ba6..ce95bd85 100644 --- a/backend/tests/CCE.Application.Tests/Identity/Public/Commands/SubmitExpertRequestCommandHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Identity/Public/Commands/SubmitExpertRequestCommandHandlerTests.cs @@ -12,7 +12,7 @@ public class SubmitExpertRequestCommandHandlerTests public async Task Persists_request_and_returns_dto() { var clock = new FakeSystemClock(); - var service = Substitute.For(); + var service = Substitute.For(); var sut = new SubmitExpertRequestCommandHandler(service, clock); var requesterId = System.Guid.NewGuid(); @@ -22,15 +22,15 @@ public async Task Persists_request_and_returns_dto() "English bio", new[] { "Hydrogen", "Solar" }); - var dto = await sut.Handle(cmd, CancellationToken.None); + var result = await sut.Handle(cmd, CancellationToken.None); - dto.Should().NotBeNull(); - dto.RequestedById.Should().Be(requesterId); - dto.RequestedBioAr.Should().Be("سيرة ذاتية"); - dto.RequestedBioEn.Should().Be("English bio"); - dto.RequestedTags.Should().BeEquivalentTo(new[] { "Hydrogen", "Solar" }); - dto.Status.Should().Be(ExpertRegistrationStatus.Pending); - dto.ProcessedOn.Should().BeNull(); + result.IsSuccess.Should().BeTrue(); + result.Data!.RequestedById.Should().Be(requesterId); + result.Data.RequestedBioAr.Should().Be("سيرة ذاتية"); + result.Data.RequestedBioEn.Should().Be("English bio"); + result.Data.RequestedTags.Should().BeEquivalentTo(new[] { "Hydrogen", "Solar" }); + result.Data.Status.Should().Be(ExpertRegistrationStatus.Pending); + result.Data.ProcessedOn.Should().BeNull(); await service.Received(1).SaveAsync(Arg.Any(), Arg.Any()); } @@ -38,7 +38,7 @@ public async Task Persists_request_and_returns_dto() public async Task Domain_throws_when_bio_is_empty() { var clock = new FakeSystemClock(); - var service = Substitute.For(); + var service = Substitute.For(); var sut = new SubmitExpertRequestCommandHandler(service, clock); var cmd = new SubmitExpertRequestCommand( diff --git a/backend/tests/CCE.Application.Tests/Identity/Public/Commands/UpdateMyProfileCommandHandlerTests.cs b/backend/tests/CCE.Application.Tests/Identity/Public/Commands/UpdateMyProfileCommandHandlerTests.cs index 58d3b06b..6bd6bcaf 100644 --- a/backend/tests/CCE.Application.Tests/Identity/Public/Commands/UpdateMyProfileCommandHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Identity/Public/Commands/UpdateMyProfileCommandHandlerTests.cs @@ -1,6 +1,7 @@ using CCE.Application.Identity.Public; using CCE.Application.Identity.Public.Commands.UpdateMyProfile; using CCE.Domain.Identity; +using static CCE.Application.Tests.Identity.IdentityTestHelpers; namespace CCE.Application.Tests.Identity.Public.Commands; @@ -9,10 +10,10 @@ public class UpdateMyProfileCommandHandlerTests [Fact] public async Task Returns_null_when_user_not_found() { - var service = Substitute.For(); + var service = Substitute.For(); service.FindAsync(Arg.Any(), Arg.Any()) .Returns((User?)null); - var sut = new UpdateMyProfileCommandHandler(service); + var sut = new UpdateMyProfileCommandHandler(service, BuildErrors()); var cmd = new UpdateMyProfileCommand( System.Guid.NewGuid(), "en", KnowledgeLevel.Intermediate, @@ -20,7 +21,8 @@ public async Task Returns_null_when_user_not_found() var result = await sut.Handle(cmd, CancellationToken.None); - result.Should().BeNull(); + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be("IDENTITY_USER_NOT_FOUND"); await service.DidNotReceiveWithAnyArgs().UpdateAsync(default!, default); } @@ -31,10 +33,10 @@ public async Task Updates_and_returns_dto_when_user_found() var countryId = System.Guid.NewGuid(); var user = new User { Id = userId, Email = "alice@cce.local", UserName = "alice" }; - var service = Substitute.For(); + var service = Substitute.For(); service.FindAsync(userId, Arg.Any()).Returns(user); service.UpdateAsync(Arg.Any(), Arg.Any()).Returns(System.Threading.Tasks.Task.CompletedTask); - var sut = new UpdateMyProfileCommandHandler(service); + var sut = new UpdateMyProfileCommandHandler(service, BuildErrors()); var cmd = new UpdateMyProfileCommand( userId, "en", KnowledgeLevel.Advanced, @@ -45,11 +47,11 @@ public async Task Updates_and_returns_dto_when_user_found() var result = await sut.Handle(cmd, CancellationToken.None); result.Should().NotBeNull(); - result!.LocalePreference.Should().Be("en"); - result.KnowledgeLevel.Should().Be(KnowledgeLevel.Advanced); - result.Interests.Should().BeEquivalentTo(new[] { "Hydrogen", "Solar" }); - result.AvatarUrl.Should().Be("https://cdn.example.com/avatar.png"); - result.CountryId.Should().Be(countryId); + result.Data!.LocalePreference.Should().Be("en"); + result.Data.KnowledgeLevel.Should().Be(KnowledgeLevel.Advanced); + result.Data.Interests.Should().BeEquivalentTo(new[] { "Hydrogen", "Solar" }); + result.Data.AvatarUrl.Should().Be("https://cdn.example.com/avatar.png"); + result.Data.CountryId.Should().Be(countryId); await service.Received(1).UpdateAsync(user, Arg.Any()); } @@ -60,10 +62,10 @@ public async Task Clears_country_when_country_id_is_null() var user = new User { Id = userId }; user.AssignCountry(System.Guid.NewGuid()); - var service = Substitute.For(); + var service = Substitute.For(); service.FindAsync(userId, Arg.Any()).Returns(user); service.UpdateAsync(Arg.Any(), Arg.Any()).Returns(System.Threading.Tasks.Task.CompletedTask); - var sut = new UpdateMyProfileCommandHandler(service); + var sut = new UpdateMyProfileCommandHandler(service, BuildErrors()); var cmd = new UpdateMyProfileCommand( userId, "ar", KnowledgeLevel.Beginner, @@ -72,6 +74,6 @@ public async Task Clears_country_when_country_id_is_null() var result = await sut.Handle(cmd, CancellationToken.None); result.Should().NotBeNull(); - result!.CountryId.Should().BeNull(); + result.Data!.CountryId.Should().BeNull(); } } diff --git a/backend/tests/CCE.Application.Tests/Identity/Public/Queries/GetMyExpertStatusQueryHandlerTests.cs b/backend/tests/CCE.Application.Tests/Identity/Public/Queries/GetMyExpertStatusQueryHandlerTests.cs index f36d6435..70761dd6 100644 --- a/backend/tests/CCE.Application.Tests/Identity/Public/Queries/GetMyExpertStatusQueryHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Identity/Public/Queries/GetMyExpertStatusQueryHandlerTests.cs @@ -2,6 +2,7 @@ using CCE.Application.Identity.Public.Queries.GetMyExpertStatus; using CCE.Domain.Identity; using CCE.TestInfrastructure.Time; +using static CCE.Application.Tests.Identity.IdentityTestHelpers; namespace CCE.Application.Tests.Identity.Public.Queries; @@ -11,11 +12,13 @@ public class GetMyExpertStatusQueryHandlerTests public async Task Returns_null_when_no_request_exists() { var db = BuildDb(System.Array.Empty()); - var sut = new GetMyExpertStatusQueryHandler(db); + var sut = new GetMyExpertStatusQueryHandler(db, BuildErrors()); var result = await sut.Handle(new GetMyExpertStatusQuery(System.Guid.NewGuid()), CancellationToken.None); - result.Should().BeNull(); + result.IsSuccess.Should().BeFalse(); + result.Error.Should().NotBeNull(); + result.Error!.Code.Should().Be("IDENTITY_EXPERT_REQUEST_NOT_FOUND"); } [Fact] @@ -26,16 +29,16 @@ public async Task Returns_dto_when_request_exists() var request = ExpertRegistrationRequest.Submit(userId, "سيرة", "Bio", new[] { "Wind" }, clock); var db = BuildDb(new[] { request }); - var sut = new GetMyExpertStatusQueryHandler(db); + var sut = new GetMyExpertStatusQueryHandler(db, BuildErrors()); var result = await sut.Handle(new GetMyExpertStatusQuery(userId), CancellationToken.None); result.Should().NotBeNull(); - result!.RequestedById.Should().Be(userId); - result.RequestedBioAr.Should().Be("سيرة"); - result.RequestedBioEn.Should().Be("Bio"); - result.RequestedTags.Should().BeEquivalentTo(new[] { "Wind" }); - result.Status.Should().Be(ExpertRegistrationStatus.Pending); + result.Data!.RequestedById.Should().Be(userId); + result.Data.RequestedBioAr.Should().Be("سيرة"); + result.Data.RequestedBioEn.Should().Be("Bio"); + result.Data.RequestedTags.Should().BeEquivalentTo(new[] { "Wind" }); + result.Data.Status.Should().Be(ExpertRegistrationStatus.Pending); } [Fact] @@ -48,12 +51,12 @@ public async Task Returns_latest_when_multiple_requests_exist() var newer = ExpertRegistrationRequest.Submit(userId, "أحدث", "Newer bio", new[] { "Wind" }, clock); var db = BuildDb(new[] { older, newer }); - var sut = new GetMyExpertStatusQueryHandler(db); + var sut = new GetMyExpertStatusQueryHandler(db, BuildErrors()); var result = await sut.Handle(new GetMyExpertStatusQuery(userId), CancellationToken.None); result.Should().NotBeNull(); - result!.RequestedBioEn.Should().Be("Newer bio"); + result.Data!.RequestedBioEn.Should().Be("Newer bio"); } private static ICceDbContext BuildDb(IEnumerable requests) diff --git a/backend/tests/CCE.Application.Tests/Identity/Public/Queries/GetMyProfileQueryHandlerTests.cs b/backend/tests/CCE.Application.Tests/Identity/Public/Queries/GetMyProfileQueryHandlerTests.cs index 888391b0..8a222b3d 100644 --- a/backend/tests/CCE.Application.Tests/Identity/Public/Queries/GetMyProfileQueryHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Identity/Public/Queries/GetMyProfileQueryHandlerTests.cs @@ -1,6 +1,7 @@ using CCE.Application.Identity.Public; using CCE.Application.Identity.Public.Queries.GetMyProfile; using CCE.Domain.Identity; +using static CCE.Application.Tests.Identity.IdentityTestHelpers; namespace CCE.Application.Tests.Identity.Public.Queries; @@ -9,14 +10,16 @@ public class GetMyProfileQueryHandlerTests [Fact] public async Task Returns_null_when_user_not_found() { - var service = Substitute.For(); + var service = Substitute.For(); service.FindAsync(Arg.Any(), Arg.Any()) .Returns((User?)null); - var sut = new GetMyProfileQueryHandler(service); + var sut = new GetMyProfileQueryHandler(service, BuildErrors()); var result = await sut.Handle(new GetMyProfileQuery(System.Guid.NewGuid()), CancellationToken.None); - result.Should().BeNull(); + result.IsSuccess.Should().BeFalse(); + result.Error.Should().NotBeNull(); + result.Error!.Code.Should().Be("IDENTITY_USER_NOT_FOUND"); } [Fact] @@ -30,18 +33,18 @@ public async Task Returns_profile_dto_when_user_found() UserName = "alice", }; - var service = Substitute.For(); + var service = Substitute.For(); service.FindAsync(userId, Arg.Any()).Returns(user); - var sut = new GetMyProfileQueryHandler(service); + var sut = new GetMyProfileQueryHandler(service, BuildErrors()); var result = await sut.Handle(new GetMyProfileQuery(userId), CancellationToken.None); result.Should().NotBeNull(); - result!.Id.Should().Be(userId); - result.Email.Should().Be("alice@cce.local"); - result.UserName.Should().Be("alice"); - result.LocalePreference.Should().Be("ar"); - result.KnowledgeLevel.Should().Be(KnowledgeLevel.Beginner); - result.Interests.Should().BeEmpty(); + result.Data!.Id.Should().Be(userId); + result.Data.Email.Should().Be("alice@cce.local"); + result.Data.UserName.Should().Be("alice"); + result.Data.LocalePreference.Should().Be("ar"); + result.Data.KnowledgeLevel.Should().Be(KnowledgeLevel.Beginner); + result.Data.Interests.Should().BeEmpty(); } } diff --git a/backend/tests/CCE.Application.Tests/Identity/Queries/GetUserByIdQueryHandlerTests.cs b/backend/tests/CCE.Application.Tests/Identity/Queries/GetUserByIdQueryHandlerTests.cs index 39aa7113..ccc53451 100644 --- a/backend/tests/CCE.Application.Tests/Identity/Queries/GetUserByIdQueryHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Identity/Queries/GetUserByIdQueryHandlerTests.cs @@ -2,6 +2,7 @@ using CCE.Application.Identity.Queries.GetUserById; using CCE.Domain.Identity; using Microsoft.AspNetCore.Identity; +using static CCE.Application.Tests.Identity.IdentityTestHelpers; namespace CCE.Application.Tests.Identity.Queries; @@ -11,11 +12,13 @@ public class GetUserByIdQueryHandlerTests public async Task Returns_null_when_user_not_found() { var db = BuildDb(System.Array.Empty(), System.Array.Empty(), System.Array.Empty>()); - var sut = new GetUserByIdQueryHandler(db); + var sut = new GetUserByIdQueryHandler(db, BuildErrors()); var result = await sut.Handle(new GetUserByIdQuery(System.Guid.NewGuid()), CancellationToken.None); - result.Should().BeNull(); + result.IsSuccess.Should().BeFalse(); + result.Error.Should().NotBeNull(); + result.Error!.Code.Should().Be("IDENTITY_USER_NOT_FOUND"); } [Fact] @@ -28,17 +31,17 @@ public async Task Returns_user_detail_with_role_names_and_is_active_true() var userRoles = new[] { new IdentityUserRole { UserId = aliceId, RoleId = superAdminRoleId } }; var db = BuildDb(users, roles, userRoles); - var sut = new GetUserByIdQueryHandler(db); + var sut = new GetUserByIdQueryHandler(db, BuildErrors()); var result = await sut.Handle(new GetUserByIdQuery(aliceId), CancellationToken.None); result.Should().NotBeNull(); - result!.Id.Should().Be(aliceId); - result.UserName.Should().Be("alice"); - result.Email.Should().Be("alice@cce.local"); - result.Roles.Should().BeEquivalentTo(new[] { "SuperAdmin" }); - result.IsActive.Should().BeTrue(); - result.LocalePreference.Should().Be("ar"); + result.Data!.Id.Should().Be(aliceId); + result.Data.UserName.Should().Be("alice"); + result.Data.Email.Should().Be("alice@cce.local"); + result.Data.Roles.Should().BeEquivalentTo(new[] { "SuperAdmin" }); + result.Data.IsActive.Should().BeTrue(); + result.Data.LocalePreference.Should().Be("ar"); } [Fact] @@ -51,12 +54,12 @@ public async Task Returns_is_active_false_when_lockout_active() alice.LockoutEnd = future; var db = BuildDb(new[] { alice }, System.Array.Empty(), System.Array.Empty>()); - var sut = new GetUserByIdQueryHandler(db); + var sut = new GetUserByIdQueryHandler(db, BuildErrors()); var result = await sut.Handle(new GetUserByIdQuery(aliceId), CancellationToken.None); result.Should().NotBeNull(); - result!.IsActive.Should().BeFalse(); + result.Data!.IsActive.Should().BeFalse(); } private static ICceDbContext BuildDb( diff --git a/backend/tests/CCE.Application.Tests/Identity/Queries/ListExpertProfilesQueryHandlerTests.cs b/backend/tests/CCE.Application.Tests/Identity/Queries/ListExpertProfilesQueryHandlerTests.cs index 9f3623de..bf2d0bdd 100644 --- a/backend/tests/CCE.Application.Tests/Identity/Queries/ListExpertProfilesQueryHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Identity/Queries/ListExpertProfilesQueryHandlerTests.cs @@ -2,7 +2,6 @@ using CCE.Application.Identity.Queries.ListExpertProfiles; using CCE.Domain.Identity; using CCE.TestInfrastructure.Time; -using Microsoft.AspNetCore.Identity; namespace CCE.Application.Tests.Identity.Queries; @@ -103,11 +102,6 @@ private static ICceDbContext BuildDb( var db = Substitute.For(); db.ExpertProfiles.Returns(profiles.AsQueryable()); db.Users.Returns(users.AsQueryable()); - db.Roles.Returns(System.Array.Empty().AsQueryable()); - db.UserRoles.Returns(System.Array.Empty>().AsQueryable()); - db.Countries.Returns(System.Array.Empty().AsQueryable()); - db.StateRepresentativeAssignments.Returns(System.Array.Empty().AsQueryable()); - db.ExpertRegistrationRequests.Returns(System.Array.Empty().AsQueryable()); return db; } diff --git a/backend/tests/CCE.Application.Tests/Identity/Queries/ListExpertRequestsQueryHandlerTests.cs b/backend/tests/CCE.Application.Tests/Identity/Queries/ListExpertRequestsQueryHandlerTests.cs index 9ef767f5..ab053f4c 100644 --- a/backend/tests/CCE.Application.Tests/Identity/Queries/ListExpertRequestsQueryHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Identity/Queries/ListExpertRequestsQueryHandlerTests.cs @@ -2,7 +2,6 @@ using CCE.Application.Identity.Queries.ListExpertRequests; using CCE.Domain.Identity; using CCE.TestInfrastructure.Time; -using Microsoft.AspNetCore.Identity; namespace CCE.Application.Tests.Identity.Queries; @@ -113,11 +112,6 @@ private static ICceDbContext BuildDb( var db = Substitute.For(); db.ExpertRegistrationRequests.Returns(requests.AsQueryable()); db.Users.Returns(users.AsQueryable()); - db.Roles.Returns(System.Array.Empty().AsQueryable()); - db.UserRoles.Returns(System.Array.Empty>().AsQueryable()); - db.Countries.Returns(System.Array.Empty().AsQueryable()); - db.StateRepresentativeAssignments.Returns(System.Array.Empty().AsQueryable()); - db.ExpertProfiles.Returns(System.Array.Empty().AsQueryable()); return db; } diff --git a/backend/tests/CCE.Application.Tests/Identity/Queries/ListStateRepAssignmentsQueryHandlerTests.cs b/backend/tests/CCE.Application.Tests/Identity/Queries/ListStateRepAssignmentsQueryHandlerTests.cs index 98dd0549..02788182 100644 --- a/backend/tests/CCE.Application.Tests/Identity/Queries/ListStateRepAssignmentsQueryHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Identity/Queries/ListStateRepAssignmentsQueryHandlerTests.cs @@ -1,9 +1,7 @@ using CCE.Application.Common.Interfaces; using CCE.Application.Identity.Queries.ListStateRepAssignments; -using CCE.Domain.Common; using CCE.Domain.Identity; using CCE.TestInfrastructure.Time; -using Microsoft.AspNetCore.Identity; namespace CCE.Application.Tests.Identity.Queries; @@ -127,8 +125,6 @@ private static ICceDbContext BuildDb( var db = Substitute.For(); db.StateRepresentativeAssignments.Returns(assignments.AsQueryable()); db.Users.Returns(users.AsQueryable()); - db.Roles.Returns(System.Array.Empty().AsQueryable()); - db.UserRoles.Returns(System.Array.Empty>().AsQueryable()); return db; } diff --git a/backend/tests/CCE.Domain.Tests/Community/PostReplyTests.cs b/backend/tests/CCE.Domain.Tests/Community/PostReplyTests.cs index ed408684..29565631 100644 --- a/backend/tests/CCE.Domain.Tests/Community/PostReplyTests.cs +++ b/backend/tests/CCE.Domain.Tests/Community/PostReplyTests.cs @@ -58,9 +58,14 @@ public void Invalid_locale_throws() [Fact] public void EditContent_replaces_text() { - var r = NewReply(NewClock()); - r.EditContent("جديد"); + var clock = NewClock(); + var r = NewReply(clock); + var editor = System.Guid.NewGuid(); + clock.Advance(System.TimeSpan.FromMinutes(1)); + r.EditContent("جديد", editor, clock); r.Content.Should().Be("جديد"); + r.LastModifiedOn.Should().Be(clock.UtcNow); + r.LastModifiedById.Should().Be(editor); } [Fact] diff --git a/backend/tests/CCE.Domain.Tests/Community/PostTests.cs b/backend/tests/CCE.Domain.Tests/Community/PostTests.cs index 38a3a1f7..d62de4d2 100644 --- a/backend/tests/CCE.Domain.Tests/Community/PostTests.cs +++ b/backend/tests/CCE.Domain.Tests/Community/PostTests.cs @@ -76,8 +76,13 @@ public void ClearAnswer_unsets_AnsweredReplyId() [Fact] public void EditContent_updates_text() { - var p = NewQuestion(NewClock()); - p.EditContent("نص جديد"); + var clock = NewClock(); + var p = NewQuestion(clock); + var editor = System.Guid.NewGuid(); + clock.Advance(System.TimeSpan.FromMinutes(1)); + p.EditContent("نص جديد", editor, clock); p.Content.Should().Be("نص جديد"); + p.LastModifiedOn.Should().Be(clock.UtcNow); + p.LastModifiedById.Should().Be(editor); } } diff --git a/backend/tests/CCE.Domain.Tests/Country/CountryProfileTests.cs b/backend/tests/CCE.Domain.Tests/Country/CountryProfileTests.cs index 23f4db89..e2463976 100644 --- a/backend/tests/CCE.Domain.Tests/Country/CountryProfileTests.cs +++ b/backend/tests/CCE.Domain.Tests/Country/CountryProfileTests.cs @@ -18,7 +18,10 @@ public void Create_builds_profile() System.Guid.NewGuid(), clock); p.DescriptionAr.Should().Be("وصف"); - p.LastUpdatedOn.Should().Be(clock.UtcNow); + p.CreatedOn.Should().Be(clock.UtcNow); + p.CreatedById.Should().NotBe(Guid.Empty); + p.LastModifiedOn.Should().Be(clock.UtcNow); + p.LastModifiedById.Should().Be(p.CreatedById); p.RowVersion.Should().NotBeNull(); } @@ -43,8 +46,8 @@ public void Update_advances_LastUpdatedOn() p.Update("ج", "new", "ج", "new", "info", "info-en", updater, clock); p.DescriptionAr.Should().Be("ج"); - p.LastUpdatedOn.Should().Be(clock.UtcNow); - p.LastUpdatedById.Should().Be(updater); + p.LastModifiedOn.Should().Be(clock.UtcNow); + p.LastModifiedById.Should().Be(updater); p.ContactInfoAr.Should().Be("info"); } From 5a32b013e3269512e74f94ce58236a8c40265ce1 Mon Sep 17 00:00:00 2001 From: MOHAMED RASHED Date: Fri, 15 May 2026 14:56:47 +0300 Subject: [PATCH 05/22] feat: implement auditable and soft delete aggregate root base classes - Added AuditableAggregateRoot for creation and update tracking - Added SoftDeleteAggregateRoot for logical deletion support - Improved domain consistency across aggregates --- backend/src/CCE.Domain/Community/Post.cs | 25 ++---- backend/src/CCE.Domain/Community/PostReply.cs | 27 ++----- backend/src/CCE.Domain/Community/Topic.cs | 14 +--- backend/src/CCE.Domain/Content/Event.cs | 14 +--- .../src/CCE.Domain/Content/HomepageSection.cs | 14 +--- backend/src/CCE.Domain/Content/News.cs | 14 +--- backend/src/CCE.Domain/Content/Page.cs | 14 +--- backend/src/CCE.Domain/Content/Resource.cs | 21 +---- backend/src/CCE.Domain/Country/Country.cs | 14 +--- .../src/CCE.Domain/Country/CountryProfile.cs | 22 ++---- .../Country/CountryResourceRequest.cs | 6 +- .../src/CCE.Domain/Identity/ExpertProfile.cs | 8 +- .../Identity/ExpertRegistrationRequest.cs | 8 +- .../src/CCE.Domain/Identity/RefreshToken.cs | 78 +++++++++++++++++++ .../Identity/StateRepresentativeAssignment.cs | 18 +---- backend/src/CCE.Domain/Identity/User.cs | 43 ++++++++++ .../InteractiveCity/CityScenario.cs | 31 +++----- .../CCE.Domain/KnowledgeMaps/KnowledgeMap.cs | 14 +--- 18 files changed, 166 insertions(+), 219 deletions(-) create mode 100644 backend/src/CCE.Domain/Identity/RefreshToken.cs diff --git a/backend/src/CCE.Domain/Community/Post.cs b/backend/src/CCE.Domain/Community/Post.cs index af1c4d60..0b1e05de 100644 --- a/backend/src/CCE.Domain/Community/Post.cs +++ b/backend/src/CCE.Domain/Community/Post.cs @@ -10,7 +10,7 @@ namespace CCE.Domain.Community; /// Content max 8000 chars to keep the read-side cheap. ///
[Audited] -public sealed class Post : AggregateRoot, ISoftDeletable +public sealed class Post : SoftDeletableAggregateRoot { public const int MaxContentLength = 8000; @@ -20,15 +20,13 @@ private Post( System.Guid authorId, string content, string locale, - bool isAnswerable, - System.DateTimeOffset createdOn) : base(id) + bool isAnswerable) : base(id) { TopicId = topicId; AuthorId = authorId; Content = content; Locale = locale; IsAnswerable = isAnswerable; - CreatedOn = createdOn; } public System.Guid TopicId { get; private set; } @@ -37,10 +35,6 @@ private Post( public string Locale { get; private set; } public bool IsAnswerable { get; private set; } public System.Guid? AnsweredReplyId { get; private set; } - public System.DateTimeOffset CreatedOn { get; private set; } - public bool IsDeleted { get; private set; } - public System.DateTimeOffset? DeletedOn { get; private set; } - public System.Guid? DeletedById { get; private set; } public static Post Create( System.Guid topicId, @@ -61,7 +55,8 @@ public static Post Create( { throw new DomainException("locale must be 'ar' or 'en'."); } - var p = new Post(System.Guid.NewGuid(), topicId, authorId, content, locale, isAnswerable, clock.UtcNow); + var p = new Post(System.Guid.NewGuid(), topicId, authorId, content, locale, isAnswerable); + p.MarkAsCreated(authorId, clock); p.RaiseDomainEvent(new PostCreatedEvent(p.Id, topicId, authorId, locale, p.CreatedOn)); return p; } @@ -78,7 +73,7 @@ public void MarkAnswered(System.Guid replyId) public void ClearAnswer() => AnsweredReplyId = null; - public void EditContent(string content) + public void EditContent(string content, Guid by, ISystemClock clock) { if (string.IsNullOrWhiteSpace(content)) throw new DomainException("Content is required."); if (content.Length > MaxContentLength) @@ -86,14 +81,6 @@ public void EditContent(string content) throw new DomainException($"Content exceeds {MaxContentLength} chars (got {content.Length})."); } Content = content; - } - - public void SoftDelete(System.Guid deletedById, ISystemClock clock) - { - if (deletedById == System.Guid.Empty) throw new DomainException("DeletedById is required."); - if (IsDeleted) return; - IsDeleted = true; - DeletedById = deletedById; - DeletedOn = clock.UtcNow; + MarkAsModified(by, clock); } } diff --git a/backend/src/CCE.Domain/Community/PostReply.cs b/backend/src/CCE.Domain/Community/PostReply.cs index 73b7eedb..9448443f 100644 --- a/backend/src/CCE.Domain/Community/PostReply.cs +++ b/backend/src/CCE.Domain/Community/PostReply.cs @@ -3,19 +3,18 @@ namespace CCE.Domain.Community; [Audited] -public sealed class PostReply : Entity, ISoftDeletable +public sealed class PostReply : SoftDeletableEntity { public const int MaxContentLength = 8000; private PostReply( System.Guid id, System.Guid postId, System.Guid authorId, string content, string locale, System.Guid? parentReplyId, - bool isByExpert, System.DateTimeOffset createdOn) : base(id) + bool isByExpert) : base(id) { PostId = postId; AuthorId = authorId; Content = content; Locale = locale; ParentReplyId = parentReplyId; IsByExpert = isByExpert; - CreatedOn = createdOn; } public System.Guid PostId { get; private set; } @@ -24,10 +23,6 @@ private PostReply( public string Locale { get; private set; } public System.Guid? ParentReplyId { get; private set; } public bool IsByExpert { get; private set; } - public System.DateTimeOffset CreatedOn { get; private set; } - public bool IsDeleted { get; private set; } - public System.DateTimeOffset? DeletedOn { get; private set; } - public System.Guid? DeletedById { get; private set; } public static PostReply Create( System.Guid postId, System.Guid authorId, @@ -45,11 +40,13 @@ public static PostReply Create( { throw new DomainException("locale must be 'ar' or 'en'."); } - return new PostReply(System.Guid.NewGuid(), postId, authorId, - content, locale, parentReplyId, isByExpert, clock.UtcNow); + var r = new PostReply(System.Guid.NewGuid(), postId, authorId, + content, locale, parentReplyId, isByExpert); + r.MarkAsCreated(authorId, clock); + return r; } - public void EditContent(string content) + public void EditContent(string content, Guid by, ISystemClock clock) { if (string.IsNullOrWhiteSpace(content)) throw new DomainException("Content is required."); if (content.Length > MaxContentLength) @@ -57,14 +54,6 @@ public void EditContent(string content) throw new DomainException($"Content exceeds {MaxContentLength} chars."); } Content = content; - } - - public void SoftDelete(System.Guid deletedById, ISystemClock clock) - { - if (deletedById == System.Guid.Empty) throw new DomainException("DeletedById is required."); - if (IsDeleted) return; - IsDeleted = true; - DeletedById = deletedById; - DeletedOn = clock.UtcNow; + MarkAsModified(by, clock); } } diff --git a/backend/src/CCE.Domain/Community/Topic.cs b/backend/src/CCE.Domain/Community/Topic.cs index c04e842d..142607be 100644 --- a/backend/src/CCE.Domain/Community/Topic.cs +++ b/backend/src/CCE.Domain/Community/Topic.cs @@ -4,7 +4,7 @@ namespace CCE.Domain.Community; [Audited] -public sealed class Topic : Entity, ISoftDeletable +public sealed class Topic : SoftDeletableEntity { private static readonly Regex SlugPattern = new("^[a-z0-9]+(-[a-z0-9]+)*$", RegexOptions.Compiled); @@ -30,9 +30,6 @@ private Topic( public string? IconUrl { get; private set; } public int OrderIndex { get; private set; } public bool IsActive { get; private set; } - public bool IsDeleted { get; private set; } - public System.DateTimeOffset? DeletedOn { get; private set; } - public System.Guid? DeletedById { get; private set; } public static Topic Create( string nameAr, string nameEn, @@ -72,13 +69,4 @@ public void UpdateContent(string nameAr, string nameEn, string descriptionAr, st public void Deactivate() => IsActive = false; public void Activate() => IsActive = true; - - public void SoftDelete(System.Guid deletedById, ISystemClock clock) - { - if (deletedById == System.Guid.Empty) throw new DomainException("DeletedById is required."); - if (IsDeleted) return; - IsDeleted = true; - DeletedById = deletedById; - DeletedOn = clock.UtcNow; - } } diff --git a/backend/src/CCE.Domain/Content/Event.cs b/backend/src/CCE.Domain/Content/Event.cs index ba61fae1..c7fe4e5d 100644 --- a/backend/src/CCE.Domain/Content/Event.cs +++ b/backend/src/CCE.Domain/Content/Event.cs @@ -9,7 +9,7 @@ namespace CCE.Domain.Content; /// stable lets external calendar clients (.ics consumers) deduplicate updates by UID. /// [Audited] -public sealed class Event : AggregateRoot, ISoftDeletable +public sealed class Event : SoftDeletableAggregateRoot { private Event( System.Guid id, @@ -53,9 +53,6 @@ private Event( public string ICalUid { get; private set; } public byte[] RowVersion { get; private set; } = System.Array.Empty(); - public bool IsDeleted { get; private set; } - public System.DateTimeOffset? DeletedOn { get; private set; } - public System.Guid? DeletedById { get; private set; } public static Event Schedule( string titleAr, @@ -139,13 +136,4 @@ public void Reschedule(System.DateTimeOffset startsOn, System.DateTimeOffset end StartsOn = startsOn; EndsOn = endsOn; } - - public void SoftDelete(System.Guid deletedById, ISystemClock clock) - { - if (deletedById == System.Guid.Empty) throw new DomainException("DeletedById is required."); - if (IsDeleted) return; - IsDeleted = true; - DeletedById = deletedById; - DeletedOn = clock.UtcNow; - } } diff --git a/backend/src/CCE.Domain/Content/HomepageSection.cs b/backend/src/CCE.Domain/Content/HomepageSection.cs index 3bf0521f..cd567b4b 100644 --- a/backend/src/CCE.Domain/Content/HomepageSection.cs +++ b/backend/src/CCE.Domain/Content/HomepageSection.cs @@ -7,7 +7,7 @@ namespace CCE.Domain.Content; /// rendering layer queries WHERE IsActive = true ORDER BY OrderIndex. /// [Audited] -public sealed class HomepageSection : Entity, ISoftDeletable +public sealed class HomepageSection : SoftDeletableEntity { private HomepageSection( System.Guid id, @@ -28,9 +28,6 @@ private HomepageSection( public string ContentAr { get; private set; } public string ContentEn { get; private set; } public bool IsActive { get; private set; } - public bool IsDeleted { get; private set; } - public System.DateTimeOffset? DeletedOn { get; private set; } - public System.Guid? DeletedById { get; private set; } public static HomepageSection Create(HomepageSectionType type, int orderIndex, string contentAr, string contentEn) { @@ -49,13 +46,4 @@ public void UpdateContent(string contentAr, string contentEn) public void Activate() => IsActive = true; public void Deactivate() => IsActive = false; - - public void SoftDelete(System.Guid deletedById, ISystemClock clock) - { - if (deletedById == System.Guid.Empty) throw new DomainException("DeletedById is required."); - if (IsDeleted) return; - IsDeleted = true; - DeletedById = deletedById; - DeletedOn = clock.UtcNow; - } } diff --git a/backend/src/CCE.Domain/Content/News.cs b/backend/src/CCE.Domain/Content/News.cs index c9bbd97a..a6c1c066 100644 --- a/backend/src/CCE.Domain/Content/News.cs +++ b/backend/src/CCE.Domain/Content/News.cs @@ -9,7 +9,7 @@ namespace CCE.Domain.Content; /// Slug is unique (enforced in Phase 08 DB unique index). Soft-deletable, audited. /// [Audited] -public sealed class News : AggregateRoot, ISoftDeletable +public sealed class News : SoftDeletableAggregateRoot { private static readonly Regex SlugPattern = new("^[a-z0-9]+(-[a-z0-9]+)*$", RegexOptions.Compiled); @@ -42,9 +42,6 @@ private News( public System.DateTimeOffset? PublishedOn { get; private set; } public bool IsFeatured { get; private set; } public byte[] RowVersion { get; private set; } = System.Array.Empty(); - public bool IsDeleted { get; private set; } - public System.DateTimeOffset? DeletedOn { get; private set; } - public System.Guid? DeletedById { get; private set; } public bool IsPublished => PublishedOn is not null; @@ -123,13 +120,4 @@ public void Publish(ISystemClock clock) public void MarkFeatured() => IsFeatured = true; public void UnmarkFeatured() => IsFeatured = false; - - public void SoftDelete(System.Guid deletedById, ISystemClock clock) - { - if (deletedById == System.Guid.Empty) throw new DomainException("DeletedById is required."); - if (IsDeleted) return; - IsDeleted = true; - DeletedById = deletedById; - DeletedOn = clock.UtcNow; - } } diff --git a/backend/src/CCE.Domain/Content/Page.cs b/backend/src/CCE.Domain/Content/Page.cs index 58d43f0b..abddea57 100644 --- a/backend/src/CCE.Domain/Content/Page.cs +++ b/backend/src/CCE.Domain/Content/Page.cs @@ -8,7 +8,7 @@ namespace CCE.Domain.Content; /// composite unique index. Content is rich-text bilingual. /// [Audited] -public sealed class Page : AggregateRoot, ISoftDeletable +public sealed class Page : SoftDeletableAggregateRoot { private static readonly Regex SlugPattern = new("^[a-z0-9]+(-[a-z0-9]+)*$", RegexOptions.Compiled); @@ -36,9 +36,6 @@ private Page( public string ContentAr { get; private set; } public string ContentEn { get; private set; } public byte[] RowVersion { get; private set; } = System.Array.Empty(); - public bool IsDeleted { get; private set; } - public System.DateTimeOffset? DeletedOn { get; private set; } - public System.Guid? DeletedById { get; private set; } public static Page Create( string slug, @@ -70,13 +67,4 @@ public void UpdateContent(string titleAr, string titleEn, string contentAr, stri ContentAr = contentAr; ContentEn = contentEn; } - - public void SoftDelete(System.Guid deletedById, ISystemClock clock) - { - if (deletedById == System.Guid.Empty) throw new DomainException("DeletedById is required."); - if (IsDeleted) return; - IsDeleted = true; - DeletedById = deletedById; - DeletedOn = clock.UtcNow; - } } diff --git a/backend/src/CCE.Domain/Content/Resource.cs b/backend/src/CCE.Domain/Content/Resource.cs index c55cb7c8..f1f82696 100644 --- a/backend/src/CCE.Domain/Content/Resource.cs +++ b/backend/src/CCE.Domain/Content/Resource.cs @@ -11,7 +11,7 @@ namespace CCE.Domain.Content; /// [Timestamp] mapping in Phase 07. /// [Audited] -public sealed class Resource : AggregateRoot, ISoftDeletable +public sealed class Resource : SoftDeletableAggregateRoot { private Resource( System.Guid id, @@ -51,10 +51,6 @@ private Resource( /// EF-managed concurrency token (rowversion). public byte[] RowVersion { get; private set; } = System.Array.Empty(); - public bool IsDeleted { get; private set; } - public System.DateTimeOffset? DeletedOn { get; private set; } - public System.Guid? DeletedById { get; private set; } - /// True when no country owns this resource (center-managed). public bool IsCenterManaged => CountryId is null; @@ -133,19 +129,4 @@ public void UpdateContent( } public void IncrementViewCount() => ViewCount++; - - public void SoftDelete(System.Guid deletedById, ISystemClock clock) - { - if (deletedById == System.Guid.Empty) - { - throw new DomainException("DeletedById is required."); - } - if (IsDeleted) - { - return; - } - IsDeleted = true; - DeletedById = deletedById; - DeletedOn = clock.UtcNow; - } } diff --git a/backend/src/CCE.Domain/Country/Country.cs b/backend/src/CCE.Domain/Country/Country.cs index 9f131676..d687d57f 100644 --- a/backend/src/CCE.Domain/Country/Country.cs +++ b/backend/src/CCE.Domain/Country/Country.cs @@ -9,7 +9,7 @@ namespace CCE.Domain.Country; /// hides a country from public dropdowns without deleting historical references. /// [Audited] -public sealed class Country : AggregateRoot, ISoftDeletable +public sealed class Country : SoftDeletableAggregateRoot { private static readonly Regex Alpha3Pattern = new("^[A-Z]{3}$", RegexOptions.Compiled); private static readonly Regex Alpha2Pattern = new("^[A-Z]{2}$", RegexOptions.Compiled); @@ -43,9 +43,6 @@ private Country( public string FlagUrl { get; private set; } public System.Guid? LatestKapsarcSnapshotId { get; private set; } public bool IsActive { get; private set; } - public bool IsDeleted { get; private set; } - public System.DateTimeOffset? DeletedOn { get; private set; } - public System.Guid? DeletedById { get; private set; } public static Country Register( string isoAlpha3, @@ -101,13 +98,4 @@ public void UpdateNames(string nameAr, string nameEn, string regionAr, string re public void Deactivate() => IsActive = false; public void Activate() => IsActive = true; - - public void SoftDelete(System.Guid deletedById, ISystemClock clock) - { - if (deletedById == System.Guid.Empty) throw new DomainException("DeletedById is required."); - if (IsDeleted) return; - IsDeleted = true; - DeletedById = deletedById; - DeletedOn = clock.UtcNow; - } } diff --git a/backend/src/CCE.Domain/Country/CountryProfile.cs b/backend/src/CCE.Domain/Country/CountryProfile.cs index f594bb61..7da039c1 100644 --- a/backend/src/CCE.Domain/Country/CountryProfile.cs +++ b/backend/src/CCE.Domain/Country/CountryProfile.cs @@ -8,7 +8,7 @@ namespace CCE.Domain.Country; /// optimistic concurrency on edit. /// [Audited] -public sealed class CountryProfile : Entity +public sealed class CountryProfile : AuditableEntity { private CountryProfile( System.Guid id, @@ -18,9 +18,7 @@ private CountryProfile( string keyInitiativesAr, string keyInitiativesEn, string? contactInfoAr, - string? contactInfoEn, - System.Guid lastUpdatedById, - System.DateTimeOffset lastUpdatedOn) : base(id) + string? contactInfoEn) : base(id) { CountryId = countryId; DescriptionAr = descriptionAr; @@ -29,8 +27,6 @@ private CountryProfile( KeyInitiativesEn = keyInitiativesEn; ContactInfoAr = contactInfoAr; ContactInfoEn = contactInfoEn; - LastUpdatedById = lastUpdatedById; - LastUpdatedOn = lastUpdatedOn; } public System.Guid CountryId { get; private set; } @@ -40,8 +36,6 @@ private CountryProfile( public string KeyInitiativesEn { get; private set; } public string? ContactInfoAr { get; private set; } public string? ContactInfoEn { get; private set; } - public System.Guid LastUpdatedById { get; private set; } - public System.DateTimeOffset LastUpdatedOn { get; private set; } public byte[] RowVersion { get; private set; } = System.Array.Empty(); public static CountryProfile Create( @@ -61,7 +55,7 @@ public static CountryProfile Create( if (string.IsNullOrWhiteSpace(keyInitiativesAr)) throw new DomainException("KeyInitiativesAr is required."); if (string.IsNullOrWhiteSpace(keyInitiativesEn)) throw new DomainException("KeyInitiativesEn is required."); if (createdById == System.Guid.Empty) throw new DomainException("CreatedById is required."); - return new CountryProfile( + var p = new CountryProfile( id: System.Guid.NewGuid(), countryId: countryId, descriptionAr: descriptionAr, @@ -69,9 +63,10 @@ public static CountryProfile Create( keyInitiativesAr: keyInitiativesAr, keyInitiativesEn: keyInitiativesEn, contactInfoAr: contactInfoAr, - contactInfoEn: contactInfoEn, - lastUpdatedById: createdById, - lastUpdatedOn: clock.UtcNow); + contactInfoEn: contactInfoEn); + p.MarkAsCreated(createdById, clock); + p.MarkAsModified(createdById, clock); + return p; } public void Update( @@ -95,7 +90,6 @@ public void Update( KeyInitiativesEn = keyInitiativesEn; ContactInfoAr = contactInfoAr; ContactInfoEn = contactInfoEn; - LastUpdatedById = updatedById; - LastUpdatedOn = clock.UtcNow; + MarkAsModified(updatedById, clock); } } diff --git a/backend/src/CCE.Domain/Country/CountryResourceRequest.cs b/backend/src/CCE.Domain/Country/CountryResourceRequest.cs index 76bf7db3..fcdd2fe2 100644 --- a/backend/src/CCE.Domain/Country/CountryResourceRequest.cs +++ b/backend/src/CCE.Domain/Country/CountryResourceRequest.cs @@ -11,7 +11,7 @@ namespace CCE.Domain.Country; /// creates the actual Resource. /// [Audited] -public sealed class CountryResourceRequest : AggregateRoot, ISoftDeletable +public sealed class CountryResourceRequest : SoftDeletableAggregateRoot { private CountryResourceRequest( System.Guid id, @@ -51,10 +51,6 @@ private CountryResourceRequest( public string? AdminNotesEn { get; private set; } public System.Guid? ProcessedById { get; private set; } public System.DateTimeOffset? ProcessedOn { get; private set; } - public bool IsDeleted { get; private set; } - public System.DateTimeOffset? DeletedOn { get; private set; } - public System.Guid? DeletedById { get; private set; } - public static CountryResourceRequest Submit( System.Guid countryId, System.Guid requestedById, diff --git a/backend/src/CCE.Domain/Identity/ExpertProfile.cs b/backend/src/CCE.Domain/Identity/ExpertProfile.cs index 73c69233..73c3140f 100644 --- a/backend/src/CCE.Domain/Identity/ExpertProfile.cs +++ b/backend/src/CCE.Domain/Identity/ExpertProfile.cs @@ -9,7 +9,7 @@ namespace CCE.Domain.Identity; /// captured by and enforced by a unique index in Phase 08. /// [Audited] -public sealed class ExpertProfile : Entity, ISoftDeletable +public sealed class ExpertProfile : SoftDeletableEntity { private ExpertProfile( System.Guid id, @@ -48,12 +48,6 @@ private ExpertProfile( public System.Guid ApprovedById { get; private set; } - public bool IsDeleted { get; private set; } - - public System.DateTimeOffset? DeletedOn { get; private set; } - - public System.Guid? DeletedById { get; private set; } - /// /// Factory: build an from an /// that is in diff --git a/backend/src/CCE.Domain/Identity/ExpertRegistrationRequest.cs b/backend/src/CCE.Domain/Identity/ExpertRegistrationRequest.cs index 0efe2b58..b7ff8a57 100644 --- a/backend/src/CCE.Domain/Identity/ExpertRegistrationRequest.cs +++ b/backend/src/CCE.Domain/Identity/ExpertRegistrationRequest.cs @@ -10,7 +10,7 @@ namespace CCE.Domain.Identity; /// the corresponding ExpertProfile. Soft-deletable for admin recovery flows. /// [Audited] -public sealed class ExpertRegistrationRequest : AggregateRoot, ISoftDeletable +public sealed class ExpertRegistrationRequest : SoftDeletableAggregateRoot { private ExpertRegistrationRequest( System.Guid id, @@ -48,12 +48,6 @@ private ExpertRegistrationRequest( public string? RejectionReasonEn { get; private set; } - public bool IsDeleted { get; private set; } - - public System.DateTimeOffset? DeletedOn { get; private set; } - - public System.Guid? DeletedById { get; private set; } - /// /// Submit a new pending registration request. Validates inputs and records the submission moment. /// diff --git a/backend/src/CCE.Domain/Identity/RefreshToken.cs b/backend/src/CCE.Domain/Identity/RefreshToken.cs new file mode 100644 index 00000000..24f329e2 --- /dev/null +++ b/backend/src/CCE.Domain/Identity/RefreshToken.cs @@ -0,0 +1,78 @@ +using CCE.Domain.Common; + +namespace CCE.Domain.Identity; + +public sealed class RefreshToken : Entity +{ + private RefreshToken() : base(System.Guid.Empty) { } + + private RefreshToken( + System.Guid id, + System.Guid userId, + string tokenHash, + System.Guid tokenFamilyId, + DateTimeOffset createdAtUtc, + DateTimeOffset expiresAtUtc, + string? createdByIp, + string? userAgent) + : base(id) + { + UserId = userId; + TokenHash = tokenHash; + TokenFamilyId = tokenFamilyId; + CreatedAtUtc = createdAtUtc; + ExpiresAtUtc = expiresAtUtc; + CreatedByIp = createdByIp; + UserAgent = userAgent; + } + + public System.Guid UserId { get; private set; } + public string TokenHash { get; private set; } = string.Empty; + public System.Guid TokenFamilyId { get; private set; } + public DateTimeOffset CreatedAtUtc { get; private set; } + public DateTimeOffset ExpiresAtUtc { get; private set; } + public DateTimeOffset? RevokedAtUtc { get; private set; } + public string? ReplacedByTokenHash { get; private set; } + public string? CreatedByIp { get; private set; } + public string? RevokedByIp { get; private set; } + public string? UserAgent { get; private set; } + + public bool IsActive(DateTimeOffset now) => RevokedAtUtc is null && ExpiresAtUtc > now; + + public static RefreshToken Create( + System.Guid userId, + string tokenHash, + System.Guid tokenFamilyId, + DateTimeOffset createdAtUtc, + DateTimeOffset expiresAtUtc, + string? createdByIp, + string? userAgent) + { + if (userId == System.Guid.Empty) throw new DomainException("UserId is required."); + if (string.IsNullOrWhiteSpace(tokenHash)) throw new DomainException("TokenHash is required."); + if (tokenFamilyId == System.Guid.Empty) throw new DomainException("TokenFamilyId is required."); + if (expiresAtUtc <= createdAtUtc) throw new DomainException("Refresh token expiry must be after creation."); + + return new RefreshToken( + System.Guid.NewGuid(), + userId, + tokenHash, + tokenFamilyId, + createdAtUtc, + expiresAtUtc, + createdByIp, + userAgent); + } + + public void Revoke(DateTimeOffset revokedAtUtc, string? revokedByIp, string? replacedByTokenHash = null) + { + if (RevokedAtUtc is not null) + { + return; + } + + RevokedAtUtc = revokedAtUtc; + RevokedByIp = revokedByIp; + ReplacedByTokenHash = replacedByTokenHash; + } +} diff --git a/backend/src/CCE.Domain/Identity/StateRepresentativeAssignment.cs b/backend/src/CCE.Domain/Identity/StateRepresentativeAssignment.cs index 539db72f..11d1f6d3 100644 --- a/backend/src/CCE.Domain/Identity/StateRepresentativeAssignment.cs +++ b/backend/src/CCE.Domain/Identity/StateRepresentativeAssignment.cs @@ -8,7 +8,7 @@ namespace CCE.Domain.Identity; /// AND marks the row deleted (so the unique-active-assignment filtered index ignores it). /// [Audited] -public sealed class StateRepresentativeAssignment : Entity, ISoftDeletable +public sealed class StateRepresentativeAssignment : SoftDeletableEntity { private StateRepresentativeAssignment( System.Guid id, @@ -41,15 +41,6 @@ private StateRepresentativeAssignment( /// Admin User.Id who revoked; null if still active. public System.Guid? RevokedById { get; private set; } - /// - public bool IsDeleted { get; private set; } - - /// - public System.DateTimeOffset? DeletedOn { get; private set; } - - /// - public System.Guid? DeletedById { get; private set; } - /// /// Factory: create a new active assignment. The "unique active per (User, Country)" invariant /// is checked at the persistence layer (Phase 08 filtered unique index). @@ -94,11 +85,8 @@ public void Revoke(System.Guid revokedById, ISystemClock clock) { throw new DomainException("RevokedById is required."); } - var now = clock.UtcNow; - RevokedOn = now; + RevokedOn = clock.UtcNow; RevokedById = revokedById; - IsDeleted = true; - DeletedOn = now; - DeletedById = revokedById; + SoftDelete(revokedById, clock); } } diff --git a/backend/src/CCE.Domain/Identity/User.cs b/backend/src/CCE.Domain/Identity/User.cs index 7b67e97c..5cdd1e0d 100644 --- a/backend/src/CCE.Domain/Identity/User.cs +++ b/backend/src/CCE.Domain/Identity/User.cs @@ -11,6 +11,14 @@ namespace CCE.Domain.Identity; [Audited] public class User : IdentityUser { + public string FirstName { get; private set; } = string.Empty; + + public string LastName { get; private set; } = string.Empty; + + public string JobTitle { get; private set; } = string.Empty; + + public string OrganizationName { get; private set; } = string.Empty; + /// UI locale preference. Allowed values: "ar", "en". Default "ar". public string LocalePreference { get; private set; } = "ar"; @@ -67,6 +75,41 @@ public static User CreateStubFromEntraId(System.Guid objectId, string email, str }; } + public static User RegisterLocal( + string firstName, + string lastName, + string email, + string jobTitle, + string organizationName, + string phoneNumber) + { + var user = new User + { + Id = System.Guid.NewGuid(), + UserName = email, + NormalizedUserName = email.ToUpperInvariant(), + Email = email, + NormalizedEmail = email.ToUpperInvariant(), + PhoneNumber = phoneNumber, + EmailConfirmed = false, + }; + user.UpdateProfile(firstName, lastName, jobTitle, organizationName); + return user; + } + + public void UpdateProfile(string firstName, string lastName, string jobTitle, string organizationName) + { + if (string.IsNullOrWhiteSpace(firstName)) throw new DomainException("FirstName is required."); + if (string.IsNullOrWhiteSpace(lastName)) throw new DomainException("LastName is required."); + if (string.IsNullOrWhiteSpace(jobTitle)) throw new DomainException("JobTitle is required."); + if (string.IsNullOrWhiteSpace(organizationName)) throw new DomainException("OrganizationName is required."); + + FirstName = firstName.Trim(); + LastName = lastName.Trim(); + JobTitle = jobTitle.Trim(); + OrganizationName = organizationName.Trim(); + } + /// /// Updates the locale preference. Only "ar" and "en" are accepted. /// diff --git a/backend/src/CCE.Domain/InteractiveCity/CityScenario.cs b/backend/src/CCE.Domain/InteractiveCity/CityScenario.cs index b1ec83e4..3576d9f9 100644 --- a/backend/src/CCE.Domain/InteractiveCity/CityScenario.cs +++ b/backend/src/CCE.Domain/InteractiveCity/CityScenario.cs @@ -3,19 +3,17 @@ namespace CCE.Domain.InteractiveCity; [Audited] -public sealed class CityScenario : AggregateRoot, ISoftDeletable +public sealed class CityScenario : SoftDeletableAggregateRoot { public const int MinTargetYear = 2030; public const int MaxTargetYear = 2080; private CityScenario(System.Guid id, System.Guid userId, string nameAr, string nameEn, - CityType cityType, int targetYear, string configurationJson, - System.DateTimeOffset createdOn) : base(id) + CityType cityType, int targetYear, string configurationJson) : base(id) { UserId = userId; NameAr = nameAr; NameEn = nameEn; CityType = cityType; TargetYear = targetYear; ConfigurationJson = configurationJson; - CreatedOn = createdOn; LastModifiedOn = createdOn; } public System.Guid UserId { get; private set; } @@ -24,11 +22,6 @@ private CityScenario(System.Guid id, System.Guid userId, string nameAr, string n public CityType CityType { get; private set; } public int TargetYear { get; private set; } public string ConfigurationJson { get; private set; } - public System.DateTimeOffset CreatedOn { get; private set; } - public System.DateTimeOffset LastModifiedOn { get; private set; } - public bool IsDeleted { get; private set; } - public System.DateTimeOffset? DeletedOn { get; private set; } - public System.Guid? DeletedById { get; private set; } public static CityScenario Create(System.Guid userId, string nameAr, string nameEn, CityType cityType, int targetYear, string configurationJson, ISystemClock clock) @@ -40,8 +33,11 @@ public static CityScenario Create(System.Guid userId, string nameAr, string name throw new DomainException($"TargetYear must be between {MinTargetYear} and {MaxTargetYear}."); if (string.IsNullOrWhiteSpace(configurationJson)) throw new DomainException("ConfigurationJson is required."); - return new CityScenario(System.Guid.NewGuid(), userId, nameAr, nameEn, - cityType, targetYear, configurationJson, clock.UtcNow); + var s = new CityScenario(System.Guid.NewGuid(), userId, nameAr, nameEn, + cityType, targetYear, configurationJson); + s.MarkAsCreated(userId, clock); + s.MarkAsModified(userId, clock); + return s; } public void UpdateConfiguration(string configurationJson, ISystemClock clock) @@ -49,7 +45,7 @@ public void UpdateConfiguration(string configurationJson, ISystemClock clock) if (string.IsNullOrWhiteSpace(configurationJson)) throw new DomainException("ConfigurationJson is required."); ConfigurationJson = configurationJson; - LastModifiedOn = clock.UtcNow; + MarkAsModified(UserId, clock); } public void Rename(string nameAr, string nameEn, ISystemClock clock) @@ -57,15 +53,6 @@ public void Rename(string nameAr, string nameEn, ISystemClock clock) if (string.IsNullOrWhiteSpace(nameAr)) throw new DomainException("NameAr is required."); if (string.IsNullOrWhiteSpace(nameEn)) throw new DomainException("NameEn is required."); NameAr = nameAr; NameEn = nameEn; - LastModifiedOn = clock.UtcNow; - } - - public void SoftDelete(System.Guid deletedById, ISystemClock clock) - { - if (deletedById == System.Guid.Empty) throw new DomainException("DeletedById is required."); - if (IsDeleted) return; - IsDeleted = true; - DeletedById = deletedById; - DeletedOn = clock.UtcNow; + MarkAsModified(UserId, clock); } } diff --git a/backend/src/CCE.Domain/KnowledgeMaps/KnowledgeMap.cs b/backend/src/CCE.Domain/KnowledgeMaps/KnowledgeMap.cs index eafaed66..f8f95170 100644 --- a/backend/src/CCE.Domain/KnowledgeMaps/KnowledgeMap.cs +++ b/backend/src/CCE.Domain/KnowledgeMaps/KnowledgeMap.cs @@ -4,7 +4,7 @@ namespace CCE.Domain.KnowledgeMaps; [Audited] -public sealed class KnowledgeMap : AggregateRoot, ISoftDeletable +public sealed class KnowledgeMap : SoftDeletableAggregateRoot { private static readonly Regex SlugPattern = new("^[a-z0-9]+(-[a-z0-9]+)*$", RegexOptions.Compiled); @@ -23,9 +23,6 @@ private KnowledgeMap(System.Guid id, string nameAr, string nameEn, public string Slug { get; private set; } public bool IsActive { get; private set; } public byte[] RowVersion { get; private set; } = System.Array.Empty(); - public bool IsDeleted { get; private set; } - public System.DateTimeOffset? DeletedOn { get; private set; } - public System.Guid? DeletedById { get; private set; } public static KnowledgeMap Create(string nameAr, string nameEn, string descriptionAr, string descriptionEn, string slug) @@ -51,13 +48,4 @@ public void UpdateContent(string nameAr, string nameEn, string descriptionAr, st public void Activate() => IsActive = true; public void Deactivate() => IsActive = false; - - public void SoftDelete(System.Guid deletedById, ISystemClock clock) - { - if (deletedById == System.Guid.Empty) throw new DomainException("DeletedById is required."); - if (IsDeleted) return; - IsDeleted = true; - DeletedById = deletedById; - DeletedOn = clock.UtcNow; - } } From d231f5b9608cfcd52c07560613e698eadb9e0527 Mon Sep 17 00:00:00 2001 From: MOHAMED RASHED Date: Sat, 16 May 2026 23:40:13 +0300 Subject: [PATCH 06/22] =?UTF-8?q?refactor:=20centralize=20auth=20logic=20i?= =?UTF-8?q?nto=20IAuthService,=20align=20DDD=20hierarchy,=20unify=20reposi?= =?UTF-8?q?tory=20commit=20pattern=20-=20IAuthService=20(Login,=20RefreshT?= =?UTF-8?q?oken,=20Logout,=20Register,=20ForgotPassword,=20ResetPassword):?= =?UTF-8?q?=20all=206=20auth=20handlers=20reduced=20to=20thin=20wrappers?= =?UTF-8?q?=20(2=20deps=20each=20instead=20of=204=E2=80=936);=20duplicated?= =?UTF-8?q?=20token=20rotation/issuance=20logic=20eliminated;=20wrong=20Re?= =?UTF-8?q?setPassword=20domain=20keys=20fixed=20(INVALID=5FREFRESH=5FTOKE?= =?UTF-8?q?N=20=E2=86=92=20INVALID=5FRESET=5FTOKEN,=20REGISTRATION=5FFAILE?= =?UTF-8?q?D=20=E2=86=92=20RESET=5FFAILED)=20-=20Refresh=20token=20repos?= =?UTF-8?q?=20no=20longer=20own=20commits:=20SaveChangesAsync=20removed=20?= =?UTF-8?q?from=20IRefreshTokenRepository=20/=20RefreshTokenRepository=20?= =?UTF-8?q?=E2=80=94=20AuthService=20owns=20commit=20via=20ICceDbContext?= =?UTF-8?q?=20-=20DDD=20hierarchy=20aligned:=20Entity=20constrained?= =?UTF-8?q?=20to=20IEquatable,=20domain=20events=20moved=20to=20Aggre?= =?UTF-8?q?gateRoot=20(inherits=20SoftDeletableEntity),=20deleted=20A?= =?UTF-8?q?uditableAggregateRoot/SoftDeletableAggregateRoot,=20upgraded=20?= =?UTF-8?q?3=20entities=20to=20AggregateRoot,=20updated=2012=20entity=20ba?= =?UTF-8?q?se=20classes=20-=20Generic=20repository=20pattern:=20IRepositor?= =?UTF-8?q?y=20+=20Repository=20base=20for=20aggregate=20rep?= =?UTF-8?q?os=20-=20Handler=20commit=20ownership:=20IStateRepAssignmentRep?= =?UTF-8?q?ository,=20IExpertRequestSubmissionRepository,=20IExpertWorkflo?= =?UTF-8?q?wRepository,=20IUserProfileRepository=20=E2=80=94=20all=20SaveC?= =?UTF-8?q?hangesAsync=20calls=20moved=20to=20handl?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/docs/plans/DDD-Implementation-Plan.md | 354 +++ .../plans/system-messages-refactor-plan.md | 1250 ++++++++ .../Extensions/ResponseExtensions.cs | 50 + .../Localization/Resources.yaml | 110 + .../Middleware/ExceptionHandlingMiddleware.cs | 108 +- .../Behaviors/ResponseValidationBehavior.cs | 83 + .../src/CCE.Application/Common/FieldError.cs | 8 + .../Common/Interfaces/IRepository.cs | 13 + .../src/CCE.Application/Common/Response.cs | 84 + .../CCE.Application/DependencyInjection.cs | 3 + .../Identity/Auth/Common/IAuthService.cs | 20 + .../Auth/Common/IRefreshTokenRepository.cs | 2 - .../ForgotPassword/ForgotPasswordCommand.cs | 2 +- .../ForgotPasswordCommandHandler.cs | 28 +- .../Identity/Auth/Login/LoginCommand.cs | 2 +- .../Auth/Login/LoginCommandHandler.cs | 91 +- .../Identity/Auth/Logout/LogoutCommand.cs | 2 +- .../Auth/Logout/LogoutCommandHandler.cs | 33 +- .../Auth/RefreshToken/RefreshTokenCommand.cs | 2 +- .../RefreshTokenCommandHandler.cs | 80 +- .../Auth/Register/RegisterUserCommand.cs | 2 +- .../Register/RegisterUserCommandHandler.cs | 82 +- .../ResetPassword/ResetPasswordCommand.cs | 2 +- .../ResetPasswordCommandHandler.cs | 61 +- .../ApproveExpertRequestCommand.cs | 2 +- .../ApproveExpertRequestCommandHandler.cs | 28 +- .../AssignUserRoles/AssignUserRolesCommand.cs | 2 +- .../AssignUserRolesCommandHandler.cs | 17 +- .../CreateStateRepAssignmentCommand.cs | 2 +- .../CreateStateRepAssignmentCommandHandler.cs | 27 +- .../RejectExpertRequestCommand.cs | 2 +- .../RejectExpertRequestCommandHandler.cs | 27 +- .../RevokeStateRepAssignmentCommand.cs | 4 +- .../RevokeStateRepAssignmentCommandHandler.cs | 23 +- .../Identity/IExpertWorkflowRepository.cs | 13 +- .../Identity/IStateRepAssignmentRepository.cs | 19 +- .../SubmitExpertRequestCommand.cs | 2 +- .../SubmitExpertRequestCommandHandler.cs | 23 +- .../UpdateMyProfile/UpdateMyProfileCommand.cs | 2 +- .../UpdateMyProfileCommandHandler.cs | 23 +- .../IExpertRequestSubmissionRepository.cs | 4 +- .../Identity/Public/IUserProfileRepository.cs | 2 +- .../GetMyExpertStatusQuery.cs | 2 +- .../GetMyExpertStatusQueryHandler.cs | 17 +- .../Queries/GetMyProfile/GetMyProfileQuery.cs | 2 +- .../GetMyProfile/GetMyProfileQueryHandler.cs | 17 +- .../Queries/GetUserById/GetUserByIdQuery.cs | 4 +- .../GetUserById/GetUserByIdQueryHandler.cs | 17 +- .../Messages/MessageFactory.cs | 96 + .../CCE.Application/Messages/SystemCode.cs | 159 + .../CCE.Application/Messages/SystemCodeMap.cs | 151 + .../src/CCE.Domain/Common/AggregateRoot.cs | 14 +- .../Common/AuditableAggregateRoot.cs | 41 - .../src/CCE.Domain/Common/AuditableEntity.cs | 2 +- backend/src/CCE.Domain/Common/Entity.cs | 10 +- backend/src/CCE.Domain/Common/MessageType.cs | 16 + .../Common/SoftDeletableAggregateRoot.cs | 32 - .../CCE.Domain/Common/SoftDeletableEntity.cs | 15 +- backend/src/CCE.Domain/Community/Post.cs | 2 +- backend/src/CCE.Domain/Community/Topic.cs | 2 +- backend/src/CCE.Domain/Content/Event.cs | 2 +- .../src/CCE.Domain/Content/HomepageSection.cs | 2 +- backend/src/CCE.Domain/Content/News.cs | 2 +- .../Content/NewsletterSubscription.cs | 2 +- backend/src/CCE.Domain/Content/Page.cs | 2 +- backend/src/CCE.Domain/Content/Resource.cs | 2 +- backend/src/CCE.Domain/Country/Country.cs | 2 +- .../Country/CountryResourceRequest.cs | 2 +- .../src/CCE.Domain/Identity/ExpertProfile.cs | 2 +- .../Identity/ExpertRegistrationRequest.cs | 2 +- .../Identity/StateRepresentativeAssignment.cs | 2 +- .../InteractiveCity/CityScenario.cs | 2 +- .../CCE.Domain/KnowledgeMaps/KnowledgeMap.cs | 2 +- .../CCE.Infrastructure/DependencyInjection.cs | 1 + .../Identity/AuthService.cs | 178 ++ .../ExpertRequestSubmissionRepository.cs | 16 +- .../Identity/ExpertWorkflowRepository.cs | 20 +- .../Identity/RefreshTokenRepository.cs | 2 - .../Identity/StateRepAssignmentRepository.cs | 23 +- .../Identity/UserProfileRepository.cs | 4 +- .../Interceptors/DomainEventDispatcher.cs | 2 +- ...StandardizeCountryProfileAudit.Designer.cs | 2676 +++++++++++++++++ ...15121258_StandardizeCountryProfileAudit.cs | 664 ++++ .../Persistence/Repository.cs | 32 + ...ptionHandlingMiddlewareConcurrencyTests.cs | 25 +- .../ExceptionHandlingMiddlewareTests.cs | 23 +- .../DependencyInjectionTests.cs | 13 + ...ApproveExpertRequestCommandHandlerTests.cs | 21 +- .../AssignUserRolesCommandHandlerTests.cs | 23 +- ...teStateRepAssignmentCommandHandlerTests.cs | 26 +- .../RejectExpertRequestCommandHandlerTests.cs | 20 +- ...keStateRepAssignmentCommandHandlerTests.cs | 26 +- .../Identity/IdentityTestHelpers.cs | 12 +- .../SubmitExpertRequestCommandHandlerTests.cs | 17 +- .../UpdateMyProfileCommandHandlerTests.cs | 23 +- .../GetMyExpertStatusQueryHandlerTests.cs | 12 +- .../Queries/GetMyProfileQueryHandlerTests.cs | 10 +- .../Queries/GetUserByIdQueryHandlerTests.cs | 12 +- 98 files changed, 6454 insertions(+), 746 deletions(-) create mode 100644 backend/docs/plans/DDD-Implementation-Plan.md create mode 100644 backend/docs/plans/system-messages-refactor-plan.md create mode 100644 backend/src/CCE.Api.Common/Extensions/ResponseExtensions.cs create mode 100644 backend/src/CCE.Application/Common/Behaviors/ResponseValidationBehavior.cs create mode 100644 backend/src/CCE.Application/Common/FieldError.cs create mode 100644 backend/src/CCE.Application/Common/Interfaces/IRepository.cs create mode 100644 backend/src/CCE.Application/Common/Response.cs create mode 100644 backend/src/CCE.Application/Identity/Auth/Common/IAuthService.cs create mode 100644 backend/src/CCE.Application/Messages/MessageFactory.cs create mode 100644 backend/src/CCE.Application/Messages/SystemCode.cs create mode 100644 backend/src/CCE.Application/Messages/SystemCodeMap.cs delete mode 100644 backend/src/CCE.Domain/Common/AuditableAggregateRoot.cs create mode 100644 backend/src/CCE.Domain/Common/MessageType.cs delete mode 100644 backend/src/CCE.Domain/Common/SoftDeletableAggregateRoot.cs create mode 100644 backend/src/CCE.Infrastructure/Identity/AuthService.cs create mode 100644 backend/src/CCE.Infrastructure/Persistence/Migrations/20260515121258_StandardizeCountryProfileAudit.Designer.cs create mode 100644 backend/src/CCE.Infrastructure/Persistence/Migrations/20260515121258_StandardizeCountryProfileAudit.cs create mode 100644 backend/src/CCE.Infrastructure/Persistence/Repository.cs diff --git a/backend/docs/plans/DDD-Implementation-Plan.md b/backend/docs/plans/DDD-Implementation-Plan.md new file mode 100644 index 00000000..b8ec19c5 --- /dev/null +++ b/backend/docs/plans/DDD-Implementation-Plan.md @@ -0,0 +1,354 @@ +# DDD Implementation Plan + +## Overview + +This document defines the architecture, patterns, and rules for implementing Domain-Driven Design in a blog/social media platform with moderation. Every decision here was made based on the specific needs of this project — not theory for theory's sake. + +--- + +## Layer Structure + +``` +Domain → Aggregates, Entities, Value Objects, Events, Repository Interfaces +Application → Commands, Queries, DTOs, IAppDbContext +Infrastructure → Repository Implementations, AppDbContext, EF Configuration +API → Controllers, minimal pass-through to handlers +``` + +### Dependency Direction +``` +API → Application → Domain ← Infrastructure +``` +Infrastructure points inward toward Domain — never the other way around. + +--- + +## Base Class Hierarchy + +``` +Entity → Id + equality + └── AuditableEntity → + CreatedAt/By, UpdatedAt/By + └── SoftDeleteEntity → + IsDeleted, DeletedAt/By, Restore() + └── AggregateRoot → + DomainEvents +``` + +### What each level adds + +| Class | Responsibility | +|---|---| +| `Entity` | Identity and equality only | +| `AuditableEntity` | Who created/updated and when | +| `SoftDeleteEntity` | Soft delete + restore logic | +| `AggregateRoot` | Domain event dispatching | + +### Rules +- Every layer adds **one responsibility only** — this is intentional SRP +- `TId` is constrained to `IEquatable` — no unconstrained generic ids +- `SoftDeleteEntity.Delete()` automatically calls `SetUpdated()` — no manual audit on delete +- `SoftDeleteEntity.Restore()` clears delete fields and calls `SetUpdated()` — full consistency + +--- + +## Domain Layer + +### Aggregates → inherit `AggregateRoot` + +Use when the entity: +- Has its own lifecycle with meaningful stages +- Has its own repository +- Raises domain events +- Can be fetched independently + +``` +Post → Draft → UnderReview → Approved/Rejected → SoftDeleted +Comment → UnderReview → Approved/Rejected → SoftDeleted +Form → Created → Published → Archived → SoftDeleted +FormSubmission → Submitted → Reviewed → Closed +User → Registered → Activated → Deactivated +``` + +### Child Entities → inherit `AuditableEntity` + +Use when the entity: +- Only exists inside an aggregate +- Has no lifecycle of its own +- Is never fetched independently +- Is created/removed by the aggregate + +``` +PostTag → owned by Post +PostImage → owned by Post +PostLike → owned by Post +FormField → owned by Form +UserRole → owned by User +UserFollow → owned by User +``` + +### Special Case — ApplicationUser + +Cannot inherit `AggregateRoot` due to `IdentityUser` base class. Implements interfaces manually: + +```csharp +public class ApplicationUser : IdentityUser, ISoftDeletable, IAuditable +{ + // manual implementation — isolated exception, not a pattern +} +``` + +### Moderation Status + +Every content aggregate uses `ModerationStatus`: + +```csharp +public enum ModerationStatus +{ + Draft, + UnderReview, + Approved, + Rejected +} +``` + +### Domain Events + +Every meaningful state change raises a domain event: + +``` +PostCreatedEvent +PostSubmittedEvent +PostApprovedEvent +PostRejectedEvent +PostDeletedEvent +``` + +Events are dispatched automatically by the EF Core interceptor after `SaveChangesAsync` — handlers never dispatch manually. + +### Aggregate Rules + +- **Private setters** on all properties — domain owns its state +- **Factory method** (`Post.Create(...)`) instead of public constructor +- **Guard conditions** inside domain methods — fail fast, fail explicitly +- **Child entities created through aggregate** — never `new PostTag()` from outside +- **Reference other aggregates by Id** — never by navigation property + +```csharp +// ✅ Correct +public Guid AuthorId { get; private set; } + +// ❌ Wrong +public User Author { get; private set; } +``` + +--- + +## Repository Pattern + +### Generic Repository — kills duplication + +```csharp +public interface IRepository + where T : AggregateRoot + where TId : IEquatable +{ + Task GetByIdAsync(TId id); + Task AddAsync(T entity); + void Update(T entity); + void Delete(T entity); +} +``` + +### Specific Repository — only when aggregate needs extra queries + +```csharp +public interface IPostRepository : IRepository +{ + Task> GetPendingModerationAsync(); + Task ExistsByTitleAsync(string title); +} +``` + +### Decision tree + +``` +Does the aggregate need custom queries? + Yes → create specific repo extending generic + No → inject IRepository directly, no specific repo needed +``` + +### Rules +- **Repositories for Aggregates only** — never for child entities +- **Repository returns domain objects** — never DTOs +- **Repository has zero business logic** — fetch and save only +- **No `SaveChangesAsync` inside repository** — that belongs to the handler + +--- + +## Application Layer + +### CQRS Split + +``` +Write side → Command Handlers → use Repository +Read side → Query Handlers → use IAppDbContext directly +``` + +### Command Handler Pattern + +``` +1. Fetch aggregate via repository +2. Guard — throw if not found +3. Call domain method — business logic stays in domain +4. Persist via repository +5. SaveChangesAsync — commits everything +``` + +Domain events are dispatched automatically after step 5 — no manual dispatch. + +### Query Handler Pattern + +``` +1. Inject IAppDbContext directly — no repository +2. Write optimized LINQ with Select projection +3. Return DTO — never a domain object +``` + +### Rules + +- **Commands** use repository, return nothing or an Id +- **Queries** use `IAppDbContext` directly, return DTOs +- **No business logic in handlers** — handlers orchestrate, domain decides +- **No domain objects returned from queries** — always project to DTO +- **No service layer** — handlers call domain methods directly + +--- + +## Why No Service Layer + +A service layer between handler and domain adds indirection with zero value when logic touches a single aggregate: + +``` +❌ Handler → Service → Domain → Repository (pass-through service) +✅ Handler → Domain → Repository (direct, clean) +``` + +Domain Services are only justified when: +- Logic spans **multiple aggregates** +- No single aggregate owns the coordination + +```csharp +// ✅ Legitimate domain service — two aggregates involved +public class ModerationDomainService +{ + public void Approve(Post post, AdminProfile admin) + { + post.Approve(admin.Id); + admin.RecordModeration(post.Id); + } +} +``` + +--- + +## Infrastructure Layer + +### IAppDbContext — is the Unit of Work + +```csharp +public interface IAppDbContext +{ + DbSet Posts { get; } + DbSet Comments { get; } + DbSet
Forms { get; } + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} +``` + +`DbContext` already implements `IDisposable` — do not add it to `IAppDbContext`. DI handles disposal at end of request automatically. + +### EF Core Interceptor — auto audit + soft delete + +Interceptor runs on every `SaveChangesAsync`: +- Sets `CreatedAt/By` on new entities +- Sets `UpdatedAt/By` on modified entities +- Intercepts hard deletes and converts to soft delete +- Dispatches domain events after commit + +### Global Query Filters + +```csharp +// Applied to every query automatically +modelBuilder.Entity().HasQueryFilter(p => !p.IsDeleted); +``` + +No manual `!p.IsDeleted` in every query. + +--- + +## Audit Trail — How It Works + +Every admin action is automatically recorded: + +``` +Post created by author → CreatedBy = authorId, CreatedAt = timestamp +Post approved by admin → UpdatedBy = adminId, UpdatedAt = timestamp +Post deleted by admin → DeletedBy = adminId, DeletedAt = timestamp + → UpdatedBy = adminId, UpdatedAt = timestamp (automatic) +``` + +`SetUpdated` is called automatically inside `Delete()` and `Restore()` — no manual calls needed anywhere. + +--- + +## What Inherits What — Full Map + +```csharp +// Full chain — lifecycle + soft delete + audit + events +public class Post : AggregateRoot { } +public class Comment : AggregateRoot { } +public class Form : AggregateRoot { } +public class FormSubmission : AggregateRoot { } +public class User : AggregateRoot { } + +// Audit only — no lifecycle, no soft delete, no events +public class PostTag : AuditableEntity { } +public class PostImage : AuditableEntity { } +public class PostLike : AuditableEntity { } +public class FormField : AuditableEntity { } +public class UserRole : AuditableEntity { } +public class UserFollow : AuditableEntity { } + +// Special case +public class ApplicationUser : IdentityUser, ISoftDeletable, IAuditable { } +``` + +--- + +## Rules Summary + +| Rule | Reason | +|---|---| +| Repository for Aggregates only | Child entities have no independent lifecycle | +| Handler calls domain methods directly | No pass-through service layer | +| Queries use DbContext directly | Optimized projection, no full aggregate load | +| Domain objects never leave application layer | Queries always return DTOs | +| Business logic lives in domain only | Prevents scatter across services | +| Private setters on all aggregate properties | Domain owns its state | +| Factory methods instead of public constructors | Enforces invariants on creation | +| Guard conditions in every domain method | Fail fast, fail explicitly | +| Domain events raised in domain methods | Automatic dispatch, no manual wiring | +| SaveChangesAsync in handler only | Repository never commits | + +--- + +## Anti-Patterns to Avoid + +| Anti-Pattern | Why | +|---|---| +| Public setters on domain objects | Anyone sets anything, logic scatters | +| Business logic in services | Anemic domain, service becomes god class | +| Returning domain objects from queries | Couples read side to write model | +| Repository returning DTOs | Breaks separation of read/write | +| `new ChildEntity()` outside aggregate | Bypasses aggregate consistency boundary | +| Navigation properties to other aggregates | Creates hidden coupling between aggregates | +| SaveChangesAsync inside repository | Loses transactional control in handler | +| Hard delete on any aggregate | Loses audit trail and recoverability | diff --git a/backend/docs/plans/system-messages-refactor-plan.md b/backend/docs/plans/system-messages-refactor-plan.md new file mode 100644 index 00000000..b5a5743d --- /dev/null +++ b/backend/docs/plans/system-messages-refactor-plan.md @@ -0,0 +1,1250 @@ +# System Messages Refactor Plan — From Error Codes to Unified Response Envelope + +## Problem Statement + +The current system was designed around an **"error codes"** mindset, but in reality the codebase already uses codes for **success messages** too (`CON005`, `CON011`, `CON017`). This creates several fundamental problems: + +### 1. Naming Lie — "Error" used for success +```csharp +// Current: The Error record is used for BOTH success and failure +public sealed record Error(string Code, string MessageAr, string MessageEn, ErrorType Type, ...); + +// In ErrorCodeMapper — success codes live in an "error" mapper: +["IDENTITY_USER_CREATED"] = "CON017", // ← This isn't an error! +["IDENTITY_LOGOUT_SUCCESS"] = "CON015", // ← This isn't an error! +["GENERAL_SUCCESS_CREATED"] = "CON011", // ← This isn't an error! +``` + +### 2. No Success Message in the Response Envelope +```json +// Current success response — NO message for the frontend to display +{ + "isSuccess": true, + "data": { "id": "...", "email": "..." }, + "error": null // ← Where does "تم الإنشاء بنجاح" go? +} +``` + +The frontend gets **no code and no bilingual message** on success. It must hardcode its own toast messages. + +### 3. Duplicate/Ambiguous Numeric Codes +Many different errors share the same code — 15+ different "not found" errors all map to `ERR001`. Frontend can't distinguish between "User not found" and "News not found". Same code, different meaning. + +### 4. No `errors[]` Array for Validation +```json +// Current validation error — details buried inside the Error record +{ + "isSuccess": false, + "error": { + "code": "ERR013", + "details": { "Email": ["REQUIRED_FIELD"] } // ← keys are field names, values are code strings + } +} +``` + +The frontend wants a flat `errors[]` array with per-field codes it can map to inline messages. + +### 5. `Result` Only Carries One Error +Current `Result` has a single `Error?` property. There's no way to return multiple errors (e.g., "email is invalid AND phone is missing"). + +--- + +## Target Response Shape + +Every API endpoint returns this shape — success AND failure. The `code` field uses the **`ERR0xx` / `CON0xx` / `VAL0xx`** numbering convention, but every message now gets its own **unique** code (no more 15 things sharing `ERR001`). + +```json +// ─── Success ─── +{ + "success": true, + "code": "CON017", + "message": { + "ar": "تم إنشاء المستخدم بنجاح!", + "en": "User created successfully!" + }, + "data": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "email": "user@example.com" + }, + "errors": [], + "traceId": "00-abc123def456...", + "timestamp": "2026-05-15T16:00:00Z" +} + +// ─── Single Error ─── +{ + "success": false, + "code": "ERR019", + "message": { + "ar": "عذرًا، حدثت مشكلة أثناء إنشاء الحساب", + "en": "Sorry, a problem occurred while creating the account" + }, + "data": null, + "errors": [], + "traceId": "00-abc123def456...", + "timestamp": "2026-05-15T16:00:00Z" +} + +// ─── Validation Error (multiple field errors) ─── +{ + "success": false, + "code": "VAL001", + "message": { + "ar": "عذرًا، البيانات المدخلة غير صحيحة", + "en": "Sorry, the entered data is invalid" + }, + "data": null, + "errors": [ + { + "field": "email", + "code": "VAL003", + "message": { + "ar": "البريد الإلكتروني غير صالح", + "en": "Invalid email format" + } + }, + { + "field": "phoneNumber", + "code": "VAL002", + "message": { + "ar": "هذا الحقل مطلوب", + "en": "This field is required" + } + } + ], + "traceId": "00-abc123def456...", + "timestamp": "2026-05-15T16:00:00Z" +} +``` + +### Code Numbering Convention + +| Prefix | Range | Usage | +|---|---|---| +| `ERR` | `ERR001`–`ERR999` | Errors (not found, conflict, unauthorized, forbidden, business rule, internal) | +| `CON` | `CON001`–`CON999` | Confirmations / Success messages (created, updated, deleted, etc.) | +| `VAL` | `VAL001`–`VAL999` | Validation errors (required, format, length, etc.) | + +**Rule: Every distinct message gets its own unique number.** No more sharing `ERR001` across 15 different "not found" errors. + +### Key Design Decisions + +| Decision | Choice | Rationale | +|---|---|---| +| Code format | `ERR0xx` / `CON0xx` / `VAL0xx` | Compact, sortable, familiar to frontend team, distinguishes error/success/validation at a glance | +| Each message = unique code | Yes — no duplicates | Frontend can `switch` on code, support tickets reference exact code | +| `message` is always an object | `{ "ar": "...", "en": "..." }` | Frontend picks the locale it needs, no server-side content negotiation | +| `errors[]` always present | Empty array on success or non-validation failure | Frontend doesn't need `null` checks | +| `traceId` + `timestamp` | Always present | Debugging, logging, support tickets | +| `data` is `null` on failure | Always | Clean separation | + +--- + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Handler │ +│ │ +│ return Response.Success(dto, MessageCode.UserCreated); │ +│ return Response.Fail(MessageCode.UserNotFound, ...); │ +│ (never throw for expected failures) │ +└──────────────────────┬──────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ ValidationBehavior (MediatR Pipeline) │ +│ Catches FluentValidation failures → Response with errors[] │ +└──────────────────────┬──────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Endpoint │ +│ │ +│ var response = await mediator.Send(cmd, ct); │ +│ return response.ToHttpResult(); // one-liner │ +│ │ +│ Maps MessageType → HTTP status automatically: │ +│ Success → 200/201/204 │ +│ NotFound → 404 │ +│ Validation → 400 │ +│ Conflict → 409 │ +│ Forbidden → 403 │ +│ Unauthorized → 401 │ +│ BusinessRule → 422 │ +│ Internal → 500 │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## Phase 0 — New Core Types (Domain + Application Layer) + +### Step 0.1 — Rename `ErrorType` → `MessageType`, add `Success` + +**File:** `src/CCE.Domain/Common/MessageType.cs` (new — replaces `Error.cs`) + +```csharp +using System.Text.Json.Serialization; + +namespace CCE.Domain.Common; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum MessageType +{ + Success, + Validation, + NotFound, + Conflict, + Unauthorized, + Forbidden, + BusinessRule, + Internal +} +``` + +### Step 0.2 — Create `LocalizedMessage` Value Object + +**File:** `src/CCE.Domain/Common/LocalizedMessage.cs` (new) + +```csharp +namespace CCE.Domain.Common; + +/// +/// Bilingual message that serializes as { "ar": "...", "en": "..." }. +/// +public sealed record LocalizedMessage(string Ar, string En); +``` + +### Step 0.3 — Create `FieldError` Record + +**File:** `src/CCE.Domain/Common/FieldError.cs` (new) + +```csharp +namespace CCE.Domain.Common; + +/// +/// Per-field validation error for the errors[] array. +/// +public sealed record FieldError( + string Field, + string Code, + LocalizedMessage Message); +``` + +### Step 0.4 — Create the New `Response` Envelope + +**File:** `src/CCE.Application/Common/Response.cs` (new) + +```csharp +using CCE.Domain.Common; +using System.Text.Json.Serialization; + +namespace CCE.Application.Common; + +/// +/// Unified API response envelope. Every endpoint returns this shape. +/// Replaces with proper success messages and error arrays. +/// Code field uses ERR0xx/CON0xx/VAL0xx numbering. +/// +public sealed record Response +{ + [JsonInclude] public bool Success { get; private init; } + [JsonInclude] public string Code { get; private init; } = string.Empty; + [JsonInclude] public LocalizedMessage Message { get; private init; } = new("", ""); + [JsonInclude] public T? Data { get; private init; } + [JsonInclude] public IReadOnlyList Errors { get; private init; } = []; + [JsonInclude] public string TraceId { get; init; } = string.Empty; + [JsonInclude] public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow; + + /// Not serialized — used internally to select HTTP status. + [JsonIgnore] public MessageType Type { get; private init; } = MessageType.Success; + + public Response() { } + + // ─── Success Factories ─── + + public static Response Ok(T data, string code, LocalizedMessage message) => new() + { + Success = true, + Code = code, + Message = message, + Data = data, + Type = MessageType.Success, + }; + + /// Shorthand for void commands that return no data. + public static Response Ok(string code, LocalizedMessage message) => new() + { + Success = true, + Code = code, + Message = message, + Data = VoidData.Instance, + Type = MessageType.Success, + }; + + // ─── Failure Factories ─── + + public static Response Fail(string code, LocalizedMessage message, MessageType type) => new() + { + Success = false, + Code = code, + Message = message, + Type = type, + }; + + public static Response Fail( + string code, LocalizedMessage message, MessageType type, IReadOnlyList errors) => new() + { + Success = false, + Code = code, + Message = message, + Type = type, + Errors = errors, + }; + + // ─── Implicit conversions for clean handler returns ─── + // NOTE: Implicit conversion removed — every success must provide an explicit code. +} + +/// Placeholder type for commands that return no data. +public sealed record VoidData +{ + public static readonly VoidData Instance = new(); + private VoidData() { } +} + +/// Non-generic companion for void commands. +public static class Response +{ + public static Response Ok(string code, LocalizedMessage message) + => Response.Ok(code, message); + + public static Response Fail(string code, LocalizedMessage message, MessageType type) + => Response.Fail(code, message, type); +} +``` + +--- + +## Phase 1 — Unified Message Code System + +### Step 1.1 — Create `SystemCode` Constants (replaces `ApplicationErrors` + `ErrorCodeMapper`) + +The old system had two disconnected layers: domain keys (`IDENTITY_USER_NOT_FOUND`) mapped to numeric codes (`ERR001`) in `ErrorCodeMapper`. The problem: many domain keys shared the same numeric code, making debugging impossible. + +**New rule: every distinct message gets its own unique `ERR0xx` / `CON0xx` / `VAL0xx` code.** + +**File:** `src/CCE.Application/Messages/SystemCode.cs` (new) + +Each constant IS the numeric code. The same string is used as the key in `Resources.yaml`. + +```csharp +namespace CCE.Application.Messages; + +/// +/// Canonical system message codes. Each constant is the code sent in the API response +/// AND the lookup key in Resources.yaml. Codes are unique — no two messages share a code. +/// +/// Prefixes: +/// ERR = Error (failure responses) +/// CON = Confirmation (success responses) +/// VAL = Validation (field-level errors in errors[] array) +/// +public static class SystemCode +{ + // ════════════════════════════════════════════════════════════════ + // ERR — Error codes (failures) + // ════════════════════════════════════════════════════════════════ + + // ─── Identity Errors ─── + public const string ERR001 = "ERR001"; // User not found + public const string ERR002 = "ERR002"; // Expert request not found + public const string ERR003 = "ERR003"; // State rep assignment not found + + public const string ERR019 = "ERR019"; // Email already exists + public const string ERR020 = "ERR020"; // Invalid credentials + public const string ERR021 = "ERR021"; // Invalid / expired token + public const string ERR022 = "ERR022"; // Invalid refresh token + public const string ERR023 = "ERR023"; // Password recovery failed + public const string ERR024 = "ERR024"; // Logout failed + public const string ERR025 = "ERR025"; // Account deactivated + public const string ERR026 = "ERR026"; // Username already exists + public const string ERR027 = "ERR027"; // Registration failed + public const string ERR028 = "ERR028"; // Not authenticated + public const string ERR029 = "ERR029"; // Expert request already exists + public const string ERR030 = "ERR030"; // State rep assignment already exists + + // ─── Content Errors ─── + public const string ERR040 = "ERR040"; // News not found + public const string ERR041 = "ERR041"; // Event not found + public const string ERR042 = "ERR042"; // Resource not found + public const string ERR043 = "ERR043"; // Page not found + public const string ERR044 = "ERR044"; // Category not found + public const string ERR045 = "ERR045"; // Asset not found + public const string ERR046 = "ERR046"; // Homepage section not found + public const string ERR047 = "ERR047"; // Country resource request not found + public const string ERR048 = "ERR048"; // Resource duplicate (slug/title) + public const string ERR049 = "ERR049"; // Category duplicate + public const string ERR050 = "ERR050"; // Page duplicate + public const string ERR051 = "ERR051"; // News duplicate + public const string ERR052 = "ERR052"; // Event duplicate + + // ─── Community Errors ─── + public const string ERR060 = "ERR060"; // Topic not found + public const string ERR061 = "ERR061"; // Post not found + public const string ERR062 = "ERR062"; // Reply not found + public const string ERR063 = "ERR063"; // Rating not found + public const string ERR064 = "ERR064"; // Topic duplicate + public const string ERR065 = "ERR065"; // Already following + public const string ERR066 = "ERR066"; // Not following + public const string ERR067 = "ERR067"; // Cannot mark answered + public const string ERR068 = "ERR068"; // Edit window expired + + // ─── Country Errors ─── + public const string ERR070 = "ERR070"; // Country not found + public const string ERR071 = "ERR071"; // Country profile not found + + // ─── Notification Errors ─── + public const string ERR080 = "ERR080"; // Template not found + public const string ERR081 = "ERR081"; // Template duplicate + public const string ERR082 = "ERR082"; // Notification not found + + // ─── KnowledgeMap Errors ─── + public const string ERR090 = "ERR090"; // Map not found + public const string ERR091 = "ERR091"; // Node not found + public const string ERR092 = "ERR092"; // Edge not found + + // ─── InteractiveCity Errors ─── + public const string ERR100 = "ERR100"; // Scenario not found + public const string ERR101 = "ERR101"; // Technology not found + + // ─── General Errors ─── + public const string ERR900 = "ERR900"; // Internal server error + public const string ERR901 = "ERR901"; // Unauthorized access + public const string ERR902 = "ERR902"; // Forbidden access + public const string ERR903 = "ERR903"; // Resource not found (generic) + public const string ERR904 = "ERR904"; // Bad request (generic) + public const string ERR905 = "ERR905"; // External API error + public const string ERR906 = "ERR906"; // External API not configured + public const string ERR907 = "ERR907"; // Concurrency conflict + public const string ERR908 = "ERR908"; // Duplicate value (generic) + + // ════════════════════════════════════════════════════════════════ + // CON — Confirmation / Success codes + // ════════════════════════════════════════════════════════════════ + + // ─── Identity Success ─── + public const string CON001 = "CON001"; // Login success + public const string CON002 = "CON002"; // Register success + public const string CON003 = "CON003"; // Logout success + public const string CON004 = "CON004"; // Token refreshed + public const string CON005 = "CON005"; // User updated + public const string CON006 = "CON006"; // User created + public const string CON007 = "CON007"; // User deleted + public const string CON008 = "CON008"; // User activated + public const string CON009 = "CON009"; // User deactivated + public const string CON010 = "CON010"; // Roles assigned + public const string CON011 = "CON011"; // Password reset success + public const string CON012 = "CON012"; // Expert request submitted + public const string CON013 = "CON013"; // Expert request approved + public const string CON014 = "CON014"; // Expert request rejected + public const string CON015 = "CON015"; // State rep assignment created + public const string CON016 = "CON016"; // State rep assignment revoked + public const string CON017 = "CON017"; // Profile updated + + // ─── Content Success ─── + public const string CON020 = "CON020"; // Content created + public const string CON021 = "CON021"; // Content updated + public const string CON022 = "CON022"; // Content deleted + public const string CON023 = "CON023"; // Content published + public const string CON024 = "CON024"; // Content archived + public const string CON025 = "CON025"; // Resource created + public const string CON026 = "CON026"; // Resource updated + public const string CON027 = "CON027"; // Resource deleted + public const string CON028 = "CON028"; // Resource published + + // ─── Community Success ─── + public const string CON030 = "CON030"; // Topic created + public const string CON031 = "CON031"; // Post created + public const string CON032 = "CON032"; // Reply created + public const string CON033 = "CON033"; // Followed successfully + public const string CON034 = "CON034"; // Unfollowed successfully + public const string CON035 = "CON035"; // Marked as answered + + // ─── Notification Success ─── + public const string CON040 = "CON040"; // Notification created + public const string CON041 = "CON041"; // Notification marked read + public const string CON042 = "CON042"; // Notification deleted + + // ─── General Success ─── + public const string CON900 = "CON900"; // Operation completed successfully + public const string CON901 = "CON901"; // Created successfully (generic) + public const string CON902 = "CON902"; // Updated successfully (generic) + public const string CON903 = "CON903"; // Deleted successfully (generic) + + // ════════════════════════════════════════════════════════════════ + // VAL — Validation codes (used in errors[] array items) + // ════════════════════════════════════════════════════════════════ + + public const string VAL001 = "VAL001"; // Validation error (header-level) + public const string VAL002 = "VAL002"; // Required field + public const string VAL003 = "VAL003"; // Invalid email + public const string VAL004 = "VAL004"; // Invalid phone + public const string VAL005 = "VAL005"; // Min length violated + public const string VAL006 = "VAL006"; // Max length violated + public const string VAL007 = "VAL007"; // Invalid format + public const string VAL008 = "VAL008"; // Invalid enum value + public const string VAL009 = "VAL009"; // Password uppercase required + public const string VAL010 = "VAL010"; // Password lowercase required + public const string VAL011 = "VAL011"; // Password number required +} +``` + +### Step 1.2 — Create Mapping from Domain Keys → System Codes + +**File:** `src/CCE.Application/Messages/SystemCodeMap.cs` (new — replaces `ErrorCodeMapper.cs`) + +This maps the internal domain keys (used in `Resources.yaml` and handlers) to the `ERR`/`CON`/`VAL` codes sent to clients. Unlike the old mapper, **every entry is unique — no shared codes.** + +```csharp +namespace CCE.Application.Messages; + +/// +/// Maps domain keys (used internally and in Resources.yaml) to system codes (sent to clients). +/// Every domain key maps to a UNIQUE system code. +/// +public static class SystemCodeMap +{ + private static readonly Dictionary DomainToCode = new(StringComparer.OrdinalIgnoreCase) + { + // ─── Identity Errors ─── + ["USER_NOT_FOUND"] = SystemCode.ERR001, + ["EXPERT_REQUEST_NOT_FOUND"] = SystemCode.ERR002, + ["STATE_REP_ASSIGNMENT_NOT_FOUND"] = SystemCode.ERR003, + ["EMAIL_EXISTS"] = SystemCode.ERR019, + ["INVALID_CREDENTIALS"] = SystemCode.ERR020, + ["INVALID_TOKEN"] = SystemCode.ERR021, + ["INVALID_REFRESH_TOKEN"] = SystemCode.ERR022, + ["PASSWORD_RECOVERY_FAILED"] = SystemCode.ERR023, + ["LOGOUT_FAILED"] = SystemCode.ERR024, + ["ACCOUNT_DEACTIVATED"] = SystemCode.ERR025, + ["USERNAME_EXISTS"] = SystemCode.ERR026, + ["REGISTRATION_FAILED"] = SystemCode.ERR027, + ["NOT_AUTHENTICATED"] = SystemCode.ERR028, + ["EXPERT_REQUEST_ALREADY_EXISTS"] = SystemCode.ERR029, + ["STATE_REP_ASSIGNMENT_EXISTS"] = SystemCode.ERR030, + + // ─── Content Errors ─── + ["NEWS_NOT_FOUND"] = SystemCode.ERR040, + ["EVENT_NOT_FOUND"] = SystemCode.ERR041, + ["RESOURCE_NOT_FOUND"] = SystemCode.ERR042, + ["PAGE_NOT_FOUND"] = SystemCode.ERR043, + ["CATEGORY_NOT_FOUND"] = SystemCode.ERR044, + ["ASSET_NOT_FOUND"] = SystemCode.ERR045, + ["HOMEPAGE_SECTION_NOT_FOUND"] = SystemCode.ERR046, + ["COUNTRY_RESOURCE_REQUEST_NOT_FOUND"] = SystemCode.ERR047, + ["RESOURCE_DUPLICATE"] = SystemCode.ERR048, + ["CATEGORY_DUPLICATE"] = SystemCode.ERR049, + ["PAGE_DUPLICATE"] = SystemCode.ERR050, + ["NEWS_DUPLICATE"] = SystemCode.ERR051, + ["EVENT_DUPLICATE"] = SystemCode.ERR052, + + // ─── Community Errors ─── + ["TOPIC_NOT_FOUND"] = SystemCode.ERR060, + ["POST_NOT_FOUND"] = SystemCode.ERR061, + ["REPLY_NOT_FOUND"] = SystemCode.ERR062, + ["RATING_NOT_FOUND"] = SystemCode.ERR063, + ["TOPIC_DUPLICATE"] = SystemCode.ERR064, + ["ALREADY_FOLLOWING"] = SystemCode.ERR065, + ["NOT_FOLLOWING"] = SystemCode.ERR066, + ["CANNOT_MARK_ANSWERED"] = SystemCode.ERR067, + ["EDIT_WINDOW_EXPIRED"] = SystemCode.ERR068, + + // ─── Country Errors ─── + ["COUNTRY_NOT_FOUND"] = SystemCode.ERR070, + ["COUNTRY_PROFILE_NOT_FOUND"] = SystemCode.ERR071, + + // ─── Notification Errors ─── + ["TEMPLATE_NOT_FOUND"] = SystemCode.ERR080, + ["TEMPLATE_DUPLICATE"] = SystemCode.ERR081, + ["NOTIFICATION_NOT_FOUND"] = SystemCode.ERR082, + + // ─── KnowledgeMap Errors ─── + ["MAP_NOT_FOUND"] = SystemCode.ERR090, + ["NODE_NOT_FOUND"] = SystemCode.ERR091, + ["EDGE_NOT_FOUND"] = SystemCode.ERR092, + + // ─── InteractiveCity Errors ─── + ["SCENARIO_NOT_FOUND"] = SystemCode.ERR100, + ["TECHNOLOGY_NOT_FOUND"] = SystemCode.ERR101, + + // ─── General Errors ─── + ["INTERNAL_ERROR"] = SystemCode.ERR900, + ["UNAUTHORIZED_ACCESS"] = SystemCode.ERR901, + ["FORBIDDEN_ACCESS"] = SystemCode.ERR902, + ["RESOURCE_NOT_FOUND_GENERIC"] = SystemCode.ERR903, + ["BAD_REQUEST"] = SystemCode.ERR904, + ["EXTERNAL_API_ERROR"] = SystemCode.ERR905, + ["EXTERNAL_API_NOT_CONFIGURED"] = SystemCode.ERR906, + + // ─── Identity Success ─── + ["LOGIN_SUCCESS"] = SystemCode.CON001, + ["REGISTER_SUCCESS"] = SystemCode.CON002, + ["LOGOUT_SUCCESS"] = SystemCode.CON003, + ["TOKEN_REFRESHED"] = SystemCode.CON004, + ["USER_UPDATED"] = SystemCode.CON005, + ["USER_CREATED"] = SystemCode.CON006, + ["USER_DELETED"] = SystemCode.CON007, + ["USER_ACTIVATED"] = SystemCode.CON008, + ["USER_DEACTIVATED"] = SystemCode.CON009, + ["ROLES_ASSIGNED"] = SystemCode.CON010, + ["PASSWORD_RESET"] = SystemCode.CON011, + + // ─── Content Success ─── + ["CONTENT_CREATED"] = SystemCode.CON020, + ["CONTENT_UPDATED"] = SystemCode.CON021, + ["CONTENT_DELETED"] = SystemCode.CON022, + ["CONTENT_PUBLISHED"] = SystemCode.CON023, + ["CONTENT_ARCHIVED"] = SystemCode.CON024, + ["RESOURCE_CREATED"] = SystemCode.CON025, + ["RESOURCE_UPDATED"] = SystemCode.CON026, + ["RESOURCE_DELETED"] = SystemCode.CON027, + ["RESOURCE_PUBLISHED"] = SystemCode.CON028, + + // ─── Notification Success ─── + ["NOTIFICATION_CREATED"] = SystemCode.CON040, + ["NOTIFICATION_MARKED_READ"] = SystemCode.CON041, + ["NOTIFICATION_DELETED"] = SystemCode.CON042, + + // ─── General Success ─── + ["SUCCESS_OPERATION"] = SystemCode.CON900, + ["SUCCESS_CREATED"] = SystemCode.CON901, + ["SUCCESS_UPDATED"] = SystemCode.CON902, + ["SUCCESS_DELETED"] = SystemCode.CON903, + + // ─── Validation ─── + ["VALIDATION_ERROR"] = SystemCode.VAL001, + ["REQUIRED_FIELD"] = SystemCode.VAL002, + ["INVALID_EMAIL"] = SystemCode.VAL003, + ["INVALID_PHONE"] = SystemCode.VAL004, + ["MIN_LENGTH"] = SystemCode.VAL005, + ["MAX_LENGTH"] = SystemCode.VAL006, + ["INVALID_FORMAT"] = SystemCode.VAL007, + ["INVALID_ENUM"] = SystemCode.VAL008, + ["PASSWORD_UPPERCASE"] = SystemCode.VAL009, + ["PASSWORD_LOWERCASE"] = SystemCode.VAL010, + ["PASSWORD_NUMBER"] = SystemCode.VAL011, + }; + + private static readonly Dictionary CodeToDomain = + DomainToCode.ToDictionary(kv => kv.Value, kv => kv.Key, StringComparer.OrdinalIgnoreCase); + + /// Get the ERR/CON/VAL code for a domain key. Returns ERR900 if unmapped. + public static string ToSystemCode(string domainKey) + => DomainToCode.TryGetValue(domainKey, out var code) ? code : SystemCode.ERR900; + + /// Get the domain key from a system code. Returns null if unmapped. + public static string? ToDomainKey(string systemCode) + => CodeToDomain.TryGetValue(systemCode, out var key) ? key : null; + + /// True when the domain key has an explicit mapping. + public static bool HasMapping(string domainKey) => DomainToCode.ContainsKey(domainKey); +} +``` + +### Step 1.3 — Create `MessageFactory` (replaces `Errors` class) + +**File:** `src/CCE.Application/Messages/MessageFactory.cs` (new — replaces `Common/Errors.cs`) + +The factory takes **domain keys** (human-readable, used in YAML), resolves the localized message, and maps to `ERR`/`CON`/`VAL` codes for the response. + +```csharp +using CCE.Application.Common; +using CCE.Application.Localization; +using CCE.Domain.Common; + +namespace CCE.Application.Messages; + +/// +/// Factory for building instances with localized messages. +/// Takes domain keys (e.g. "USER_NOT_FOUND"), resolves bilingual message from Resources.yaml, +/// and maps to system codes (e.g. "ERR001") via . +/// +public sealed class MessageFactory +{ + private readonly ILocalizationService _l; + + public MessageFactory(ILocalizationService l) => _l = l; + + // ─── Success builders (domain key → CON0xx) ─── + + public Response Ok(T data, string domainKey) + { + var code = SystemCodeMap.ToSystemCode(domainKey); + var msg = Localize(domainKey); + return Response.Ok(data, code, msg); + } + + public Response Ok(string domainKey) + { + var code = SystemCodeMap.ToSystemCode(domainKey); + var msg = Localize(domainKey); + return Response.Ok(code, msg); + } + + // ─── Failure builders (domain key → ERR0xx) ─── + + public Response NotFound(string domainKey) + => Fail(domainKey, MessageType.NotFound); + + public Response Conflict(string domainKey) + => Fail(domainKey, MessageType.Conflict); + + public Response Unauthorized(string domainKey) + => Fail(domainKey, MessageType.Unauthorized); + + public Response Forbidden(string domainKey) + => Fail(domainKey, MessageType.Forbidden); + + public Response BusinessRule(string domainKey) + => Fail(domainKey, MessageType.BusinessRule); + + public Response ValidationError( + string domainKey, IReadOnlyList fieldErrors) + { + var code = SystemCodeMap.ToSystemCode(domainKey); + var msg = Localize(domainKey); + return Response.Fail(code, msg, MessageType.Validation, fieldErrors); + } + + // ─── Build FieldError with localization (domain key → VAL0xx) ─── + + public FieldError Field(string fieldName, string domainKey) + { + var code = SystemCodeMap.ToSystemCode(domainKey); + var msg = Localize(domainKey); + return new FieldError(fieldName, code, msg); + } + + // ─── Convenience shortcuts (Identity domain) ─── + + public Response UserNotFound() => NotFound("USER_NOT_FOUND"); + public Response EmailExists() => Conflict("EMAIL_EXISTS"); + public Response InvalidCredentials() => Unauthorized("INVALID_CREDENTIALS"); + public Response NotAuthenticated() => Unauthorized("NOT_AUTHENTICATED"); + + // ─── Convenience shortcuts (Content domain) ─── + + public Response NewsNotFound() => NotFound("NEWS_NOT_FOUND"); + public Response EventNotFound() => NotFound("EVENT_NOT_FOUND"); + public Response PageNotFound() => NotFound("PAGE_NOT_FOUND"); + public Response CategoryNotFound() => NotFound("CATEGORY_NOT_FOUND"); + + // ─── Private ─── + + private Response Fail(string domainKey, MessageType type) + { + var code = SystemCodeMap.ToSystemCode(domainKey); + var msg = Localize(domainKey); + return Response.Fail(code, msg, type); + } + + private LocalizedMessage Localize(string domainKey) + { + var raw = _l.GetLocalizedMessage(domainKey); + return new LocalizedMessage(raw.Ar, raw.En); + } +} +``` + +--- + +## Phase 2 — Update `ResponseExtensions` (API Layer) + +### Step 2.1 — Create `ResponseExtensions` + +**File:** `src/CCE.Api.Common/Extensions/ResponseExtensions.cs` (new — replaces `ResultExtensions.cs`) + +```csharp +using CCE.Application.Common; +using CCE.Domain.Common; +using Microsoft.AspNetCore.Http; +using System.Diagnostics; + +namespace CCE.Api.Common.Extensions; + +public static class ResponseExtensions +{ + /// + /// Maps a to an with correct HTTP status, + /// injecting traceId and timestamp. + /// + public static IResult ToHttpResult(this Response response, int successStatusCode = StatusCodes.Status200OK) + { + // Stamp traceId + timestamp + var stamped = response with + { + TraceId = Activity.Current?.Id ?? string.Empty, + Timestamp = DateTimeOffset.UtcNow, + }; + + if (stamped.Success) + { + return successStatusCode switch + { + StatusCodes.Status204NoContent => Results.NoContent(), + _ => Results.Json(stamped, statusCode: successStatusCode), + }; + } + + var statusCode = stamped.Type switch + { + MessageType.NotFound => StatusCodes.Status404NotFound, + MessageType.Validation => StatusCodes.Status400BadRequest, + MessageType.Conflict => StatusCodes.Status409Conflict, + MessageType.Unauthorized => StatusCodes.Status401Unauthorized, + MessageType.Forbidden => StatusCodes.Status403Forbidden, + MessageType.BusinessRule => StatusCodes.Status422UnprocessableEntity, + _ => StatusCodes.Status500InternalServerError, + }; + + return Results.Json(stamped, statusCode: statusCode); + } + + public static IResult ToCreatedHttpResult(this Response response) + => response.ToHttpResult(StatusCodes.Status201Created); + + public static IResult ToNoContentHttpResult(this Response response) + => response.ToHttpResult(StatusCodes.Status204NoContent); +} +``` + +### Step 2.2 — Update `ExceptionHandlingMiddleware` + +The middleware becomes a safety net that wraps unexpected exceptions into `Response`: + +```csharp +// Key changes: +// 1. Return Response shape instead of anonymous { isSuccess, data, error } +// 2. Use SystemCodeMap.ToSystemCode() to resolve ERR/CON/VAL codes +// 3. Validation errors produce errors[] array with FieldError items +// 4. Every response includes traceId + timestamp +``` + +--- + +## Phase 3 — Migrate Handlers (Feature-by-Feature) + +Each handler migration follows this pattern: + +### Before (current): +```csharp +public class RegisterUserCommandHandler + : IRequestHandler> +{ + private readonly Errors _errors; + + public async Task> Handle(...) + { + // On failure: + return _errors.EmailExists(); // returns Error record with code "ERR019" + // On success: + return dto; // implicit conversion, NO message, no code + } +} +``` + +### After (new): +```csharp +public class RegisterUserCommandHandler + : IRequestHandler> +{ + private readonly MessageFactory _msg; + + public async Task> Handle(...) + { + // On failure → response.code = "ERR019", response.message = { ar: "...", en: "..." } + return _msg.EmailExists(); + // or explicit: return _msg.Conflict("EMAIL_EXISTS"); + + // On success → response.code = "CON002", response.message = { ar: "تم إنشاء الحساب بنجاح", en: "Account created successfully" } + return _msg.Ok(dto, "REGISTER_SUCCESS"); + } +} +``` + +**What the frontend receives:** +```json +// Success case: +{ "success": true, "code": "CON002", "message": { "ar": "...", "en": "..." }, "data": {...}, "errors": [] } + +// Failure case: +{ "success": false, "code": "ERR019", "message": { "ar": "...", "en": "..." }, "data": null, "errors": [] } +``` + +### Migration Order (by domain): + +| # | Domain | Handlers | Priority | +|---|--------|----------|----------| +| 1 | Identity/Auth | Login, Register, Logout, RefreshToken, ForgotPassword, ResetPassword | 🔴 High | +| 2 | Identity/Commands | AssignRoles, ApproveExpert, RejectExpert, CreateStateRep, RevokeStateRep | 🔴 High | +| 3 | Identity/Queries | GetUserById, GetMyProfile, GetMyExpertStatus | 🟡 Medium | +| 4 | Identity/Public | SubmitExpertRequest, UpdateMyProfile | 🟡 Medium | +| 5 | Content/* | All news, events, resources, pages, categories, assets, homepage handlers | 🟡 Medium | +| 6 | Community/* | Topics, posts, replies, ratings, follows | 🟢 Low | +| 7 | Country/* | Countries, profiles | 🟢 Low | +| 8 | Notifications/* | Templates, user notifications | 🟢 Low | +| 9 | KnowledgeMap/* | Maps, nodes, edges | 🟢 Low | +| 10 | InteractiveCity/* | Scenarios, technologies | 🟢 Low | + +--- + +## Phase 4 — Update `ValidationBehavior` + +### Step 4.1 — New `ResponseValidationBehavior` + +**File:** `src/CCE.Application/Common/Behaviors/ResponseValidationBehavior.cs` (new) + +```csharp +using CCE.Application.Localization; +using CCE.Application.Messages; +using CCE.Domain.Common; +using FluentValidation; +using MediatR; + +namespace CCE.Application.Common.Behaviors; + +/// +/// MediatR pipeline behavior that catches FluentValidation failures +/// and converts them to Response{T} with errors[] array. +/// +public sealed class ResponseValidationBehavior + : IPipelineBehavior + where TRequest : notnull +{ + private readonly IEnumerable> _validators; + private readonly ILocalizationService _l; + + public ResponseValidationBehavior( + IEnumerable> validators, + ILocalizationService l) + { + _validators = validators; + _l = l; + } + + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken ct) + { + if (!_validators.Any()) + return await next().ConfigureAwait(false); + + var context = new ValidationContext(request); + var results = await Task.WhenAll( + _validators.Select(v => v.ValidateAsync(context, ct))).ConfigureAwait(false); + + var failures = results + .SelectMany(r => r.Errors) + .Where(f => f != null) + .ToList(); + + if (failures.Count == 0) + return await next().ConfigureAwait(false); + + // Check if TResponse is Response + var responseType = typeof(TResponse); + if (responseType.IsGenericType && + responseType.GetGenericTypeDefinition() == typeof(Response<>)) + { + var fieldErrors = failures.Select(f => + { + var domainKey = f.ErrorMessage; // We use domain key as ErrorMessage in validators + var valCode = SystemCodeMap.ToSystemCode(domainKey); // e.g. "REQUIRED_FIELD" → "VAL002" + var msg = _l.GetLocalizedMessage(domainKey); + return new FieldError( + ToCamelCase(f.PropertyName), + valCode, + new LocalizedMessage(msg.Ar, msg.En)); + }).ToList(); + + var headerDomainKey = "VALIDATION_ERROR"; + var headerCode = SystemCodeMap.ToSystemCode(headerDomainKey); // → "VAL001" + var headerMsg = _l.GetLocalizedMessage(headerDomainKey); + + // Build Response.Fail via reflection or known factory + var failMethod = responseType.GetMethod("Fail", + new[] { typeof(string), typeof(LocalizedMessage), typeof(MessageType), typeof(IReadOnlyList) }); + + return (TResponse)failMethod!.Invoke(null, new object[] + { + headerCode, // "VAL001" + new LocalizedMessage(headerMsg.Ar, headerMsg.En), + MessageType.Validation, + fieldErrors // Each item has its own VAL0xx code + })!; + } + + // Fallback for non-Response handlers — throw as before + throw new ValidationException(failures); + } + + private static string ToCamelCase(string name) + { + if (string.IsNullOrEmpty(name)) return name; + return char.ToLowerInvariant(name[0]) + name[1..]; + } +} +``` + +--- + +## Phase 5 — Update Resources.yaml + +`Resources.yaml` still uses **domain keys** (human-readable) as the lookup key. The `SystemCodeMap` resolves domain key → `ERR`/`CON`/`VAL` code. No changes to how YAML is structured. + +Ensure every domain key referenced by `SystemCodeMap` has a corresponding YAML entry. New keys to add: + +```yaml +# ─── New keys for domain keys that didn't exist in YAML before ─── +REGISTRATION_FAILED: + ar: "عذرًا، حدثت مشكلة أثناء إنشاء الحساب" + en: "Sorry, a problem occurred while creating the account" + +EXPERT_REQUEST_NOT_FOUND: + ar: "طلب الخبير غير موجود" + en: "Expert request not found" + +EXPERT_REQUEST_ALREADY_EXISTS: + ar: "لديك طلب خبير موجود بالفعل" + en: "You already have an existing expert request" + +STATE_REP_ASSIGNMENT_NOT_FOUND: + ar: "تعيين ممثل الولاية غير موجود" + en: "State representative assignment not found" + +STATE_REP_ASSIGNMENT_EXISTS: + ar: "تعيين ممثل الولاية موجود بالفعل" + en: "State representative assignment already exists" + +NEWS_NOT_FOUND: + ar: "الخبر غير موجود" + en: "News not found" + +EVENT_NOT_FOUND: + ar: "الفعالية غير موجودة" + en: "Event not found" + +PAGE_NOT_FOUND: + ar: "الصفحة غير موجودة" + en: "Page not found" + +CATEGORY_NOT_FOUND: + ar: "التصنيف غير موجود" + en: "Category not found" + +ASSET_NOT_FOUND: + ar: "الملف غير موجود" + en: "Asset not found" + +HOMEPAGE_SECTION_NOT_FOUND: + ar: "قسم الصفحة الرئيسية غير موجود" + en: "Homepage section not found" + +RESOURCE_DUPLICATE: + ar: "المورد بهذا العنوان موجود بالفعل" + en: "Resource with this title already exists" + +CATEGORY_DUPLICATE: + ar: "التصنيف بهذا الاسم موجود بالفعل" + en: "Category with this name already exists" + +PAGE_DUPLICATE: + ar: "الصفحة بهذا العنوان موجودة بالفعل" + en: "Page with this slug already exists" + +NEWS_DUPLICATE: + ar: "الخبر بهذا العنوان موجود بالفعل" + en: "News with this title already exists" + +EVENT_DUPLICATE: + ar: "الفعالية بهذا العنوان موجودة بالفعل" + en: "Event with this title already exists" + +TOPIC_NOT_FOUND: + ar: "الموضوع غير موجود" + en: "Topic not found" + +POST_NOT_FOUND: + ar: "المنشور غير موجود" + en: "Post not found" + +REPLY_NOT_FOUND: + ar: "الرد غير موجود" + en: "Reply not found" + +TOPIC_DUPLICATE: + ar: "الموضوع بهذا العنوان موجود بالفعل" + en: "Topic with this title already exists" + +ALREADY_FOLLOWING: + ar: "أنت تتابع هذا الموضوع بالفعل" + en: "You are already following this topic" + +NOT_FOLLOWING: + ar: "أنت لا تتابع هذا الموضوع" + en: "You are not following this topic" + +CANNOT_MARK_ANSWERED: + ar: "لا يمكنك تحديد هذا الرد كإجابة" + en: "You cannot mark this reply as answered" + +EDIT_WINDOW_EXPIRED: + ar: "انتهت فترة التعديل المسموح بها" + en: "Edit window has expired" + +COUNTRY_NOT_FOUND: + ar: "الدولة غير موجودة" + en: "Country not found" + +COUNTRY_PROFILE_NOT_FOUND: + ar: "ملف الدولة غير موجود" + en: "Country profile not found" + +# ... (ensure all domain keys in SystemCodeMap have a YAML entry) +``` + +### YAML ↔ Code Flow + +``` +Handler calls: _msg.NotFound("NEWS_NOT_FOUND") + ↓ +MessageFactory: + 1. SystemCodeMap.ToSystemCode("NEWS_NOT_FOUND") → "ERR040" + 2. _l.GetLocalizedMessage("NEWS_NOT_FOUND") → { Ar: "الخبر غير موجود", En: "News not found" } + ↓ +Response JSON: + { "success": false, "code": "ERR040", "message": { "ar": "الخبر غير موجود", "en": "News not found" }, ... } +``` + +--- + +## Phase 6 — Delete Deprecated Files + +After all handlers are migrated and tests pass: + +| File | Action | Replaced By | +|---|---|---| +| `src/CCE.Application/Errors/ErrorCodeMapper.cs` | 🗑️ Delete | `Messages/SystemCodeMap.cs` | +| `src/CCE.Application/Errors/ApplicationErrors.cs` | 🗑️ Delete | `Messages/SystemCode.cs` | +| `src/CCE.Application/Common/Errors.cs` | 🗑️ Delete | `Messages/MessageFactory.cs` | +| `src/CCE.Application/Common/Result.cs` | 🗑️ Delete | `Common/Response.cs` | +| `src/CCE.Domain/Common/Error.cs` | 🗑️ Delete | `Common/MessageType.cs` + `LocalizedMessage.cs` + `FieldError.cs` | +| `src/CCE.Api.Common/Extensions/ResultExtensions.cs` | 🗑️ Delete | `Extensions/ResponseExtensions.cs` | +| `src/CCE.Application/Common/Behaviors/ResultValidationBehavior.cs` | 🗑️ Delete | `Behaviors/ResponseValidationBehavior.cs` | + +--- + +## Phase 7 — Update Tests + +### Test changes: +1. **Unit tests** — Assert on `response.Success`, `response.Code`, `response.Errors.Count` +2. **Integration tests** — Deserialize to `Response` instead of `Result` +3. **Architecture tests** — Update any rules that reference old types + +### Example test: +```csharp +[Fact] +public async Task Register_DuplicateEmail_Returns_Conflict_With_ERR019() +{ + // Arrange ... + var response = await _mediator.Send(command, CancellationToken.None); + + response.Success.Should().BeFalse(); + response.Code.Should().Be("ERR019"); // Email already exists + response.Message.Ar.Should().NotBeNullOrWhiteSpace(); + response.Message.En.Should().NotBeNullOrWhiteSpace(); + response.Errors.Should().BeEmpty(); + response.Type.Should().Be(MessageType.Conflict); +} + +[Fact] +public async Task Register_Success_Returns_CON002() +{ + // Arrange ... + var response = await _mediator.Send(command, CancellationToken.None); + + response.Success.Should().BeTrue(); + response.Code.Should().Be("CON002"); // Register success + response.Data.Should().NotBeNull(); + response.Errors.Should().BeEmpty(); +} + +[Fact] +public async Task Register_InvalidData_Returns_VAL001_With_FieldErrors() +{ + // Arrange ... + var response = await _mediator.Send(command, CancellationToken.None); + + response.Success.Should().BeFalse(); + response.Code.Should().Be("VAL001"); // Validation error header + response.Errors.Should().Contain(e => e.Field == "email" && e.Code == "VAL003"); // Invalid email + response.Errors.Should().Contain(e => e.Field == "phoneNumber" && e.Code == "VAL002"); // Required field +} +``` + +--- + +## Migration Checklist Per Handler + +For each handler file, follow this checklist: + +- [ ] Change return type from `Result` → `Response` +- [ ] Change command/query `IRequest>` → `IRequest>` +- [ ] Replace `Errors _errors` injection → `MessageFactory _msg` injection +- [ ] Replace `return _errors.XxxNotFound()` → `return _msg.NotFound("XXX_NOT_FOUND")` (resolves to `ERR0xx`) +- [ ] Replace `return dto` (implicit success) → `return _msg.Ok(dto, "XXX_CREATED")` (resolves to `CON0xx`) +- [ ] Replace `return Result.Success()` → `return _msg.Ok("SUCCESS_OPERATION")` (resolves to `CON900`) +- [ ] Update endpoint: `.ToHttpResult()` stays the same (new extension method has same name) +- [ ] Update unit test assertions +- [ ] Build + run tests + +--- + +## Estimated Effort + +| Phase | Files | Effort | +|---|---|---| +| Phase 0 — Core types | 4 new files | 1 day | +| Phase 1 — MessageCodes + Factory | 2 new files | 0.5 day | +| Phase 2 — ResponseExtensions + Middleware | 2 files (new + update) | 0.5 day | +| Phase 3 — Migrate handlers | ~40 handler files | 3–4 days | +| Phase 4 — ValidationBehavior | 1 file | 0.5 day | +| Phase 5 — Resources.yaml | 1 file | 0.5 day | +| Phase 6 — Delete deprecated | 7 files | 0.5 day | +| Phase 7 — Update tests | ~20 test files | 2 days | +| **Total** | | **~8–9 days** | + +--- + +## Breaking Changes for Frontend + +| Before | After | +|---|---| +| `isSuccess` | `success` | +| `error.code` = `"ERR019"` (shared across many errors) | `code` = `"ERR019"` (top-level, **unique** per message) | +| `error.messageAr` / `error.messageEn` | `message.ar` / `message.en` (top-level, always present) | +| `error.details` = `{ "Email": ["REQUIRED_FIELD"] }` | `errors[]` = `[{ field, code, message }]` — codes are `VAL002`, `VAL003`, etc. | +| No success message | `code` = `"CON002"` + `message` always present on success too | +| No `traceId` / `timestamp` | Always present | +| Same `ERR001` for 15+ different not-found errors | Each entity gets its own code: `ERR001`=User, `ERR040`=News, `ERR060`=Topic, etc. | + +> **⚠️ Frontend must be updated simultaneously.** Coordinate with the frontend team on the new response shape. Consider versioning the API or deploying behind a feature flag. + +--- + +## Optional: Backward Compatibility Strategy + +If a hard cutover isn't possible, add a temporary `X-Response-Version: 2` header. The middleware checks this header and returns the new shape. Endpoints without the header return the old shape. Remove after frontend migration is complete. diff --git a/backend/src/CCE.Api.Common/Extensions/ResponseExtensions.cs b/backend/src/CCE.Api.Common/Extensions/ResponseExtensions.cs new file mode 100644 index 00000000..08bd1ffa --- /dev/null +++ b/backend/src/CCE.Api.Common/Extensions/ResponseExtensions.cs @@ -0,0 +1,50 @@ +using CCE.Application.Common; +using CCE.Domain.Common; +using Microsoft.AspNetCore.Http; +using System.Diagnostics; + +namespace CCE.Api.Common.Extensions; + +public static class ResponseExtensions +{ + /// + /// Maps a to an with correct HTTP status, + /// injecting traceId and timestamp. + /// + public static IResult ToHttpResult(this Response response, int successStatusCode = StatusCodes.Status200OK) + { + var stamped = response with + { + TraceId = Activity.Current?.Id ?? string.Empty, + Timestamp = DateTimeOffset.UtcNow, + }; + + if (stamped.Success) + { + return successStatusCode switch + { + StatusCodes.Status204NoContent => Results.NoContent(), + _ => Results.Json(stamped, statusCode: successStatusCode), + }; + } + + var statusCode = stamped.Type switch + { + MessageType.NotFound => StatusCodes.Status404NotFound, + MessageType.Validation => StatusCodes.Status400BadRequest, + MessageType.Conflict => StatusCodes.Status409Conflict, + MessageType.Unauthorized => StatusCodes.Status401Unauthorized, + MessageType.Forbidden => StatusCodes.Status403Forbidden, + MessageType.BusinessRule => StatusCodes.Status422UnprocessableEntity, + _ => StatusCodes.Status500InternalServerError, + }; + + return Results.Json(stamped, statusCode: statusCode); + } + + public static IResult ToCreatedHttpResult(this Response response) + => response.ToHttpResult(StatusCodes.Status201Created); + + public static IResult ToNoContentHttpResult(this Response response) + => response.ToHttpResult(StatusCodes.Status204NoContent); +} diff --git a/backend/src/CCE.Api.Common/Localization/Resources.yaml b/backend/src/CCE.Api.Common/Localization/Resources.yaml index a4e5b1b9..820803ae 100644 --- a/backend/src/CCE.Api.Common/Localization/Resources.yaml +++ b/backend/src/CCE.Api.Common/Localization/Resources.yaml @@ -182,6 +182,116 @@ VALIDATION_INVALID_ENUM: ar: "القيمة المحددة غير صالحة" en: "Selected value is invalid" +# ─── Identity Bare Keys (errors) ─── + +USER_NOT_FOUND: + ar: "عذرًا، لم يتم العثور على المستخدم" + en: "Sorry, user not found" + +EMAIL_EXISTS: + ar: "عذرًا، حدثت مشكلة أثناء إنشاء الحساب" + en: "Sorry, a problem occurred while creating the account" + +INVALID_CREDENTIALS: + ar: "عذرًا، حدثت مشكلة أثناء تسجيل الدخول" + en: "Sorry, a problem occurred during login" + +NOT_AUTHENTICATED: + ar: "المستخدم غير مصادق" + en: "User not authenticated" + +EXPERT_REQUEST_NOT_FOUND: + ar: "طلب الخبير غير موجود" + en: "Expert request not found" + +STATE_REP_ASSIGNMENT_NOT_FOUND: + ar: "التعيين غير موجود" + en: "Assignment not found" + +COUNTRY_NOT_FOUND: + ar: "الدولة غير موجودة" + en: "Country not found" + +INVALID_REFRESH_TOKEN: + ar: "رمز التحديث غير صالح" + en: "Invalid refresh token" + +REGISTRATION_FAILED: + ar: "عذرًا، فشل إنشاء الحساب" + en: "Sorry, registration failed" + +# ─── Identity Bare Keys (success) ─── + +REGISTER_SUCCESS: + ar: "تم إنشاء الحساب بنجاح" + en: "Account created successfully" + +LOGIN_SUCCESS: + ar: "تم تسجيل الدخول بنجاح" + en: "Logged in successfully" + +LOGOUT_SUCCESS: + ar: "تم تسجيل الخروج بنجاح" + en: "Logged out successfully" + +TOKEN_REFRESHED: + ar: "تم تحديث الرمز بنجاح" + en: "Token refreshed successfully" + +PASSWORD_RESET: + ar: "تم إعادة تعيين كلمة المرور بنجاح" + en: "Password reset successfully" + +ROLES_ASSIGNED: + ar: "تم تعيين الأدوار بنجاح" + en: "Roles assigned successfully" + +EXPERT_REQUEST_APPROVED: + ar: "تمت الموافقة على طلب الخبير" + en: "Expert request approved" + +EXPERT_REQUEST_REJECTED: + ar: "تم رفض طلب الخبير" + en: "Expert request rejected" + +EXPERT_REQUEST_SUBMITTED: + ar: "تم تقديم طلب الخبير بنجاح" + en: "Expert request submitted successfully" + +STATE_REP_ASSIGNMENT_CREATED: + ar: "تم إنشاء التعيين بنجاح" + en: "Assignment created successfully" + +STATE_REP_ASSIGNMENT_REVOKED: + ar: "تم إلغاء التعيين بنجاح" + en: "Assignment revoked successfully" + +PROFILE_UPDATED: + ar: "تم تحديث الملف الشخصي بنجاح" + en: "Profile updated successfully" + +SUCCESS_OPERATION: + ar: "تمت العملية بنجاح" + en: "Operation completed successfully" + +# ─── General Bare Keys (middleware) ─── + +VALIDATION_ERROR: + ar: "عذرًا، البيانات المدخلة غير صحيحة" + en: "Sorry, the entered data is invalid" + +INTERNAL_ERROR: + ar: "حدث خطأ غير متوقع" + en: "An unexpected error occurred" + +BAD_REQUEST: + ar: "عذرًا، البيانات المدخلة غير صحيحة" + en: "Sorry, the entered data is invalid" + +RESOURCE_NOT_FOUND_GENERIC: + ar: "المورد غير موجود" + en: "Resource not found" + CONCURRENCY_CONFLICT: ar: "تم تعديل هذا السجل من قبل مستخدم آخر. يرجى تحديث الصفحة والمحاولة مرة أخرى" en: "This record was modified by another user. Please refresh and try again" diff --git a/backend/src/CCE.Api.Common/Middleware/ExceptionHandlingMiddleware.cs b/backend/src/CCE.Api.Common/Middleware/ExceptionHandlingMiddleware.cs index a3e27400..cfb6df74 100644 --- a/backend/src/CCE.Api.Common/Middleware/ExceptionHandlingMiddleware.cs +++ b/backend/src/CCE.Api.Common/Middleware/ExceptionHandlingMiddleware.cs @@ -1,10 +1,12 @@ using CCE.Application.Common; using CCE.Application.Localization; +using CCE.Application.Messages; using CCE.Domain.Common; using FluentValidation; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using System.Diagnostics; using System.Text.Json; using System.Text.Json.Serialization; @@ -31,57 +33,55 @@ public async Task InvokeAsync(HttpContext context) { await WriteValidationResultAsync(context, ex).ConfigureAwait(false); } - // Expected business outcomes — not logged (not server errors). catch (ConcurrencyException ex) { - await WriteErrorResultAsync(context, StatusCodes.Status409Conflict, - "CONCURRENCY_CONFLICT", ErrorType.Conflict, ex.Message).ConfigureAwait(false); + await WriteErrorAsync(context, StatusCodes.Status409Conflict, + "CONCURRENCY_CONFLICT", MessageType.Conflict, ex.Message).ConfigureAwait(false); } catch (DuplicateException ex) { - await WriteErrorResultAsync(context, StatusCodes.Status409Conflict, - "DUPLICATE_VALUE", ErrorType.Conflict, ex.Message).ConfigureAwait(false); + await WriteErrorAsync(context, StatusCodes.Status409Conflict, + "DUPLICATE_VALUE", MessageType.Conflict, ex.Message).ConfigureAwait(false); } catch (DomainException ex) { - await WriteErrorResultAsync(context, StatusCodes.Status400BadRequest, - "GENERAL_BAD_REQUEST", ErrorType.BusinessRule, ex.Message).ConfigureAwait(false); + await WriteErrorAsync(context, StatusCodes.Status400BadRequest, + "BAD_REQUEST", MessageType.BusinessRule, ex.Message).ConfigureAwait(false); } catch (System.Collections.Generic.KeyNotFoundException ex) { - // Legacy — still caught for non-migrated handlers - await WriteErrorResultAsync(context, StatusCodes.Status404NotFound, - "GENERAL_NOT_FOUND", ErrorType.NotFound, ex.Message).ConfigureAwait(false); + await WriteErrorAsync(context, StatusCodes.Status404NotFound, + "RESOURCE_NOT_FOUND_GENERIC", MessageType.NotFound, ex.Message).ConfigureAwait(false); } catch (Exception ex) { _logger.LogError(ex, "Unhandled exception"); - await WriteErrorResultAsync(context, StatusCodes.Status500InternalServerError, - "GENERAL_INTERNAL_ERROR", ErrorType.Internal, null).ConfigureAwait(false); + await WriteErrorAsync(context, StatusCodes.Status500InternalServerError, + "INTERNAL_ERROR", MessageType.Internal, null).ConfigureAwait(false); } } - private static string GetCorrelationId(HttpContext ctx) => - ctx.Items[CorrelationIdMiddleware.ItemKey]?.ToString() ?? Guid.NewGuid().ToString(); - - /// - /// Writes a unified error response matching the shape, - /// so clients always see the same JSON structure regardless of whether - /// the error came from a handler or the middleware. - /// - private static async Task WriteErrorResultAsync( - HttpContext ctx, int statusCode, string code, ErrorType type, string? fallbackMessage) + private static async Task WriteErrorAsync( + HttpContext ctx, int statusCode, string domainKey, MessageType type, string? fallbackMessage) { var l = ctx.RequestServices.GetService(); - var msg = l?.GetLocalizedMessage(code); + var msg = l?.GetLocalizedMessage(domainKey); + var code = SystemCodeMap.ToSystemCode(domainKey); - var error = new Error( + var envelope = new + { + success = false, code, - msg?.Ar ?? fallbackMessage ?? "خطأ", - msg?.En ?? fallbackMessage ?? "Error", - type); - - var envelope = new { isSuccess = false, data = (object?)null, error }; + message = new + { + ar = msg?.Ar ?? fallbackMessage ?? "خطأ", + en = msg?.En ?? fallbackMessage ?? "Error" + }, + data = (object?)null, + errors = Array.Empty(), + traceId = Activity.Current?.Id ?? ctx.TraceIdentifier, + timestamp = DateTimeOffset.UtcNow, + }; ctx.Response.StatusCode = statusCode; ctx.Response.ContentType = "application/json"; @@ -91,21 +91,41 @@ await JsonSerializer.SerializeAsync(ctx.Response.Body, envelope, JsonOptions) private static async Task WriteValidationResultAsync(HttpContext ctx, ValidationException ex) { - var errors = ex.Errors - .GroupBy(e => e.PropertyName) - .ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray()); - var l = ctx.RequestServices.GetService(); - var msg = l?.GetLocalizedMessage("GENERAL_VALIDATION_ERROR"); + var headerMsg = l?.GetLocalizedMessage("VALIDATION_ERROR"); + var headerCode = SystemCodeMap.ToSystemCode("VALIDATION_ERROR"); - var error = new Error( - "GENERAL_VALIDATION_ERROR", - msg?.Ar ?? "عذرًا، البيانات المدخلة غير صحيحة", - msg?.En ?? "Sorry, the entered data is invalid", - ErrorType.Validation, - errors); + var fieldErrors = ex.Errors.Select(e => + { + var domainKey = e.ErrorMessage; + var valCode = SystemCodeMap.ToSystemCode(domainKey); + var valMsg = l?.GetLocalizedMessage(domainKey); + return new + { + field = ToCamelCase(e.PropertyName), + code = valCode, + message = new + { + ar = valMsg?.Ar ?? domainKey, + en = valMsg?.En ?? domainKey + } + }; + }).ToList(); - var envelope = new { isSuccess = false, data = (object?)null, error }; + var envelope = new + { + success = false, + code = headerCode, + message = new + { + ar = headerMsg?.Ar ?? "عذرًا، البيانات المدخلة غير صحيحة", + en = headerMsg?.En ?? "Sorry, the entered data is invalid" + }, + data = (object?)null, + errors = fieldErrors, + traceId = Activity.Current?.Id ?? ctx.TraceIdentifier, + timestamp = DateTimeOffset.UtcNow, + }; ctx.Response.StatusCode = StatusCodes.Status400BadRequest; ctx.Response.ContentType = "application/json"; @@ -113,6 +133,12 @@ await JsonSerializer.SerializeAsync(ctx.Response.Body, envelope, JsonOptions) .ConfigureAwait(false); } + private static string ToCamelCase(string name) + { + if (string.IsNullOrEmpty(name)) return name; + return char.ToLowerInvariant(name[0]) + name[1..]; + } + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, diff --git a/backend/src/CCE.Application/Common/Behaviors/ResponseValidationBehavior.cs b/backend/src/CCE.Application/Common/Behaviors/ResponseValidationBehavior.cs new file mode 100644 index 00000000..b67e28cf --- /dev/null +++ b/backend/src/CCE.Application/Common/Behaviors/ResponseValidationBehavior.cs @@ -0,0 +1,83 @@ +using CCE.Application.Localization; +using CCE.Application.Messages; +using CCE.Domain.Common; +using FluentValidation; +using MediatR; + +namespace CCE.Application.Common.Behaviors; + +public sealed class ResponseValidationBehavior + : IPipelineBehavior + where TRequest : notnull +{ + private readonly IEnumerable> _validators; + private readonly ILocalizationService _l; + + public ResponseValidationBehavior( + IEnumerable> validators, + ILocalizationService l) + { + _validators = validators; + _l = l; + } + + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + if (!_validators.Any()) + return await next().ConfigureAwait(false); + + var context = new ValidationContext(request); + var results = await Task.WhenAll( + _validators.Select(v => v.ValidateAsync(context, cancellationToken))).ConfigureAwait(false); + + var failures = results + .SelectMany(r => r.Errors) + .Where(f => f != null) + .ToList(); + + if (failures.Count == 0) + return await next().ConfigureAwait(false); + + var responseType = typeof(TResponse); + if (responseType.IsGenericType && + responseType.GetGenericTypeDefinition() == typeof(Response<>)) + { + var fieldErrors = failures.Select(f => + { + var domainKey = f.ErrorMessage; + var valCode = SystemCodeMap.ToSystemCode(domainKey); + var msg = _l.GetLocalizedMessage(domainKey); + return new FieldError( + ToCamelCase(f.PropertyName), + valCode, + new LocalizedMessage(msg.Ar, msg.En)); + }).ToList(); + + var headerDomainKey = "VALIDATION_ERROR"; + var headerCode = SystemCodeMap.ToSystemCode(headerDomainKey); + var headerMsg = _l.GetLocalizedMessage(headerDomainKey); + + var failMethod = responseType.GetMethod("Fail", + new[] { typeof(string), typeof(LocalizedMessage), typeof(MessageType), typeof(IReadOnlyList) }); + + return (TResponse)failMethod!.Invoke(null, new object[] + { + headerCode, + new LocalizedMessage(headerMsg.Ar, headerMsg.En), + MessageType.Validation, + fieldErrors + })!; + } + + throw new ValidationException(failures); + } + + private static string ToCamelCase(string name) + { + if (string.IsNullOrEmpty(name)) return name; + return char.ToLowerInvariant(name[0]) + name[1..]; + } +} diff --git a/backend/src/CCE.Application/Common/FieldError.cs b/backend/src/CCE.Application/Common/FieldError.cs new file mode 100644 index 00000000..caa6e7cc --- /dev/null +++ b/backend/src/CCE.Application/Common/FieldError.cs @@ -0,0 +1,8 @@ +using CCE.Application.Localization; + +namespace CCE.Application.Common; + +public sealed record FieldError( + string Field, + string Code, + LocalizedMessage Message); diff --git a/backend/src/CCE.Application/Common/Interfaces/IRepository.cs b/backend/src/CCE.Application/Common/Interfaces/IRepository.cs new file mode 100644 index 00000000..8ebfb122 --- /dev/null +++ b/backend/src/CCE.Application/Common/Interfaces/IRepository.cs @@ -0,0 +1,13 @@ +using CCE.Domain.Common; + +namespace CCE.Application.Common.Interfaces; + +public interface IRepository + where T : AggregateRoot + where TId : IEquatable +{ + Task GetByIdAsync(TId id, CancellationToken ct = default); + Task AddAsync(T entity, CancellationToken ct = default); + void Update(T entity); + void Delete(T entity); +} \ No newline at end of file diff --git a/backend/src/CCE.Application/Common/Response.cs b/backend/src/CCE.Application/Common/Response.cs new file mode 100644 index 00000000..65b458f2 --- /dev/null +++ b/backend/src/CCE.Application/Common/Response.cs @@ -0,0 +1,84 @@ +using CCE.Application.Localization; +using CCE.Domain.Common; +using System.Text.Json.Serialization; + +namespace CCE.Application.Common; + +/// +/// Unified API response envelope. Every endpoint returns this shape. +/// Replaces with proper success messages and error arrays. +/// Code field uses ERR0xx/CON0xx/VAL0xx numbering. +/// +public sealed record Response +{ + [JsonInclude] public bool Success { get; private init; } + [JsonInclude] public string Code { get; private init; } = string.Empty; + [JsonInclude] public LocalizedMessage Message { get; private init; } = new("", ""); + [JsonInclude] public T? Data { get; private init; } + [JsonInclude] public IReadOnlyList Errors { get; private init; } = []; + [JsonInclude] public string TraceId { get; init; } = string.Empty; + [JsonInclude] public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow; + + /// Not serialized — used internally to select HTTP status. + [JsonIgnore] public MessageType Type { get; private init; } = MessageType.Success; + + public Response() { } + + // ─── Success Factories ─── + + public static Response Ok(T data, string code, LocalizedMessage message) => new() + { + Success = true, + Code = code, + Message = message, + Data = data, + Type = MessageType.Success, + }; + + /// Shorthand for void commands that return no data. + public static Response Ok(string code, LocalizedMessage message) => new() + { + Success = true, + Code = code, + Message = message, + Data = VoidData.Instance, + Type = MessageType.Success, + }; + + // ─── Failure Factories ─── + + public static Response Fail(string code, LocalizedMessage message, MessageType type) => new() + { + Success = false, + Code = code, + Message = message, + Type = type, + }; + + public static Response Fail( + string code, LocalizedMessage message, MessageType type, IReadOnlyList errors) => new() + { + Success = false, + Code = code, + Message = message, + Type = type, + Errors = errors, + }; +} + +/// Placeholder type for commands that return no data. +public sealed record VoidData +{ + public static readonly VoidData Instance = new(); + private VoidData() { } +} + +/// Non-generic companion for void commands. +public static class Response +{ + public static Response Ok(string code, LocalizedMessage message) + => Response.Ok(code, message); + + public static Response Fail(string code, LocalizedMessage message, MessageType type) + => Response.Fail(code, message, type); +} diff --git a/backend/src/CCE.Application/DependencyInjection.cs b/backend/src/CCE.Application/DependencyInjection.cs index 0c3b7f46..9d9d10e5 100644 --- a/backend/src/CCE.Application/DependencyInjection.cs +++ b/backend/src/CCE.Application/DependencyInjection.cs @@ -1,4 +1,5 @@ using CCE.Application.Common.Behaviors; +using CCE.Application.Messages; using FluentValidation; using MediatR; using Microsoft.Extensions.DependencyInjection; @@ -16,6 +17,7 @@ public static IServiceCollection AddApplication(this IServiceCollection services { cfg.RegisterServicesFromAssembly(assembly); cfg.AddOpenBehavior(typeof(LoggingBehavior<,>)); + cfg.AddOpenBehavior(typeof(ResponseValidationBehavior<,>)); cfg.AddOpenBehavior(typeof(ResultValidationBehavior<,>)); cfg.AddOpenBehavior(typeof(ValidationBehavior<,>)); }); @@ -23,6 +25,7 @@ public static IServiceCollection AddApplication(this IServiceCollection services services.AddValidatorsFromAssembly(assembly); services.AddScoped(); + services.AddScoped(); services.AddSingleton(); diff --git a/backend/src/CCE.Application/Identity/Auth/Common/IAuthService.cs b/backend/src/CCE.Application/Identity/Auth/Common/IAuthService.cs new file mode 100644 index 00000000..0d806e59 --- /dev/null +++ b/backend/src/CCE.Application/Identity/Auth/Common/IAuthService.cs @@ -0,0 +1,20 @@ +using CCE.Domain.Identity; + +namespace CCE.Application.Identity.Auth.Common; + +public sealed record RegisterResult(User? User, bool EmailTaken); + +public interface IAuthService +{ + Task LoginAsync(string email, string password, LocalAuthApi api, string? ip, string? userAgent, CancellationToken ct); + + Task RefreshTokenAsync(string rawRefreshToken, LocalAuthApi api, string? ip, string? userAgent, CancellationToken ct); + + Task LogoutAsync(string rawRefreshToken, string? ip, CancellationToken ct); + + Task RegisterAsync(string firstName, string lastName, string email, string password, string? jobTitle, string? orgName, string? phone, CancellationToken ct); + + Task ForgotPasswordAsync(string email, CancellationToken ct); + + Task ResetPasswordAsync(string email, string encodedToken, string newPassword, string? ip, CancellationToken ct); +} diff --git a/backend/src/CCE.Application/Identity/Auth/Common/IRefreshTokenRepository.cs b/backend/src/CCE.Application/Identity/Auth/Common/IRefreshTokenRepository.cs index f41c123a..4d730c17 100644 --- a/backend/src/CCE.Application/Identity/Auth/Common/IRefreshTokenRepository.cs +++ b/backend/src/CCE.Application/Identity/Auth/Common/IRefreshTokenRepository.cs @@ -11,6 +11,4 @@ public interface IRefreshTokenRepository Task RevokeFamilyAsync(System.Guid tokenFamilyId, DateTimeOffset revokedAtUtc, string? revokedByIp, CancellationToken ct); Task RevokeAllForUserAsync(System.Guid userId, DateTimeOffset revokedAtUtc, string? revokedByIp, CancellationToken ct); - - Task SaveChangesAsync(CancellationToken ct); } diff --git a/backend/src/CCE.Application/Identity/Auth/ForgotPassword/ForgotPasswordCommand.cs b/backend/src/CCE.Application/Identity/Auth/ForgotPassword/ForgotPasswordCommand.cs index 6cd6e179..f53fc55a 100644 --- a/backend/src/CCE.Application/Identity/Auth/ForgotPassword/ForgotPasswordCommand.cs +++ b/backend/src/CCE.Application/Identity/Auth/ForgotPassword/ForgotPasswordCommand.cs @@ -5,4 +5,4 @@ namespace CCE.Application.Identity.Auth.ForgotPassword; public sealed record ForgotPasswordCommand(string EmailAddress) - : IRequest>; + : IRequest>; diff --git a/backend/src/CCE.Application/Identity/Auth/ForgotPassword/ForgotPasswordCommandHandler.cs b/backend/src/CCE.Application/Identity/Auth/ForgotPassword/ForgotPasswordCommandHandler.cs index 78e011fe..aac5f29f 100644 --- a/backend/src/CCE.Application/Identity/Auth/ForgotPassword/ForgotPasswordCommandHandler.cs +++ b/backend/src/CCE.Application/Identity/Auth/ForgotPassword/ForgotPasswordCommandHandler.cs @@ -1,33 +1,25 @@ using CCE.Application.Common; using CCE.Application.Identity.Auth.Common; -using CCE.Domain.Identity; +using CCE.Application.Messages; using MediatR; -using Microsoft.AspNetCore.Identity; -using AppErrorCodes = CCE.Application.Errors.ApplicationErrors; namespace CCE.Application.Identity.Auth.ForgotPassword; internal sealed class ForgotPasswordCommandHandler - : IRequestHandler> + : IRequestHandler> { - private readonly UserManager _userManager; - private readonly IPasswordResetEmailSender _emailSender; + private readonly IAuthService _auth; + private readonly MessageFactory _msg; - public ForgotPasswordCommandHandler(UserManager userManager, IPasswordResetEmailSender emailSender) + public ForgotPasswordCommandHandler(IAuthService auth, MessageFactory msg) { - _userManager = userManager; - _emailSender = emailSender; + _auth = auth; + _msg = msg; } - public async Task> Handle(ForgotPasswordCommand request, CancellationToken ct) + public async Task> Handle(ForgotPasswordCommand request, CancellationToken ct) { - var user = await _userManager.FindByEmailAsync(request.EmailAddress).ConfigureAwait(false); - if (user is not null) - { - var token = await _userManager.GeneratePasswordResetTokenAsync(user).ConfigureAwait(false); - await _emailSender.SendAsync(user, PasswordResetTokenCodec.Encode(token), ct).ConfigureAwait(false); - } - - return new AuthMessageDto(AppErrorCodes.Identity.PASSWORD_RESET); + await _auth.ForgotPasswordAsync(request.EmailAddress, ct).ConfigureAwait(false); + return _msg.Ok(new AuthMessageDto("PASSWORD_RESET"), "PASSWORD_RESET"); } } diff --git a/backend/src/CCE.Application/Identity/Auth/Login/LoginCommand.cs b/backend/src/CCE.Application/Identity/Auth/Login/LoginCommand.cs index f2ee1f26..1286d4d1 100644 --- a/backend/src/CCE.Application/Identity/Auth/Login/LoginCommand.cs +++ b/backend/src/CCE.Application/Identity/Auth/Login/LoginCommand.cs @@ -10,4 +10,4 @@ public sealed record LoginCommand( LocalAuthApi Api, string? IpAddress, string? UserAgent) - : IRequest>; + : IRequest>; diff --git a/backend/src/CCE.Application/Identity/Auth/Login/LoginCommandHandler.cs b/backend/src/CCE.Application/Identity/Auth/Login/LoginCommandHandler.cs index 73bebc97..045687a6 100644 --- a/backend/src/CCE.Application/Identity/Auth/Login/LoginCommandHandler.cs +++ b/backend/src/CCE.Application/Identity/Auth/Login/LoginCommandHandler.cs @@ -1,94 +1,27 @@ using CCE.Application.Common; using CCE.Application.Identity.Auth.Common; -using CCE.Domain.Common; -using CCE.Domain.Identity; +using CCE.Application.Messages; using MediatR; -using Microsoft.AspNetCore.Identity; -using Microsoft.Extensions.Options; -using AppErrors = CCE.Application.Common.Errors; namespace CCE.Application.Identity.Auth.Login; internal sealed class LoginCommandHandler - : IRequestHandler> + : IRequestHandler> { - private readonly UserManager _userManager; - private readonly ILocalTokenService _tokenService; - private readonly IRefreshTokenRepository _refreshTokens; - private readonly ISystemClock _clock; - private readonly IOptions _options; - private readonly AppErrors _errors; + private readonly IAuthService _auth; + private readonly MessageFactory _msg; - public LoginCommandHandler( - UserManager userManager, - ILocalTokenService tokenService, - IRefreshTokenRepository refreshTokens, - ISystemClock clock, - IOptions options, - AppErrors errors) + public LoginCommandHandler(IAuthService auth, MessageFactory msg) { - _userManager = userManager; - _tokenService = tokenService; - _refreshTokens = refreshTokens; - _clock = clock; - _options = options; - _errors = errors; + _auth = auth; + _msg = msg; } - public async Task> Handle(LoginCommand request, CancellationToken ct) + public async Task> Handle(LoginCommand request, CancellationToken ct) { - var user = await _userManager.FindByEmailAsync(request.EmailAddress).ConfigureAwait(false); - if (user is null) - { - return _errors.InvalidCredentials(); - } - - if (_options.Value.RequireConfirmedEmail && !await _userManager.IsEmailConfirmedAsync(user).ConfigureAwait(false)) - { - return _errors.InvalidCredentials(); - } - - var passwordValid = await _userManager.CheckPasswordAsync(user, request.Password).ConfigureAwait(false); - if (!passwordValid) - { - return _errors.InvalidCredentials(); - } - - return await IssueAndPersistAsync(user, request.Api, request.IpAddress, request.UserAgent, null, ct).ConfigureAwait(false); - } - - private async Task IssueAndPersistAsync( - User user, - LocalAuthApi api, - string? ipAddress, - string? userAgent, - Guid? tokenFamilyId, - CancellationToken ct) - { - var issued = await _tokenService.IssueAsync(user, api, ct).ConfigureAwait(false); - var familyId = tokenFamilyId ?? Guid.NewGuid(); - var refreshToken = CCE.Domain.Identity.RefreshToken.Create( - user.Id, - issued.RefreshTokenHash, - familyId, - _clock.UtcNow, - issued.RefreshTokenExpiresAtUtc, - ipAddress, - userAgent); - await _refreshTokens.AddAsync(refreshToken, ct).ConfigureAwait(false); - await _refreshTokens.SaveChangesAsync(ct).ConfigureAwait(false); - return await ToDtoAsync(user, issued, ct).ConfigureAwait(false); - } - - private async Task ToDtoAsync(User user, TokenIssueResult issued, CancellationToken ct) - { - var roles = await _userManager.GetRolesAsync(user).ConfigureAwait(false); - return new AuthTokenDto( - issued.AccessToken, - issued.AccessTokenExpiresAtUtc, - issued.RefreshToken, - issued.RefreshTokenExpiresAtUtc, - "Bearer", - new AuthUserDto(user.Id, user.Email ?? string.Empty, user.FirstName, user.LastName, roles.ToArray())); + var dto = await _auth.LoginAsync(request.EmailAddress, request.Password, request.Api, + request.IpAddress, request.UserAgent, ct).ConfigureAwait(false); + if (dto is null) return _msg.InvalidCredentials(); + return _msg.Ok(dto, "LOGIN_SUCCESS"); } } diff --git a/backend/src/CCE.Application/Identity/Auth/Logout/LogoutCommand.cs b/backend/src/CCE.Application/Identity/Auth/Logout/LogoutCommand.cs index 6a5d5315..d1d1004b 100644 --- a/backend/src/CCE.Application/Identity/Auth/Logout/LogoutCommand.cs +++ b/backend/src/CCE.Application/Identity/Auth/Logout/LogoutCommand.cs @@ -5,4 +5,4 @@ namespace CCE.Application.Identity.Auth.Logout; public sealed record LogoutCommand(string RefreshToken, string? IpAddress) - : IRequest>; + : IRequest>; diff --git a/backend/src/CCE.Application/Identity/Auth/Logout/LogoutCommandHandler.cs b/backend/src/CCE.Application/Identity/Auth/Logout/LogoutCommandHandler.cs index 912ef5c6..daa72103 100644 --- a/backend/src/CCE.Application/Identity/Auth/Logout/LogoutCommandHandler.cs +++ b/backend/src/CCE.Application/Identity/Auth/Logout/LogoutCommandHandler.cs @@ -1,38 +1,25 @@ using CCE.Application.Common; using CCE.Application.Identity.Auth.Common; -using CCE.Domain.Common; +using CCE.Application.Messages; using MediatR; -using AppErrorCodes = CCE.Application.Errors.ApplicationErrors; namespace CCE.Application.Identity.Auth.Logout; internal sealed class LogoutCommandHandler - : IRequestHandler> + : IRequestHandler> { - private readonly ILocalTokenService _tokenService; - private readonly IRefreshTokenRepository _refreshTokens; - private readonly ISystemClock _clock; + private readonly IAuthService _auth; + private readonly MessageFactory _msg; - public LogoutCommandHandler( - ILocalTokenService tokenService, - IRefreshTokenRepository refreshTokens, - ISystemClock clock) + public LogoutCommandHandler(IAuthService auth, MessageFactory msg) { - _tokenService = tokenService; - _refreshTokens = refreshTokens; - _clock = clock; + _auth = auth; + _msg = msg; } - public async Task> Handle(LogoutCommand request, CancellationToken ct) + public async Task> Handle(LogoutCommand request, CancellationToken ct) { - var tokenHash = _tokenService.HashRefreshToken(request.RefreshToken); - var existing = await _refreshTokens.FindByHashAsync(tokenHash, ct).ConfigureAwait(false); - if (existing is not null && existing.IsActive(_clock.UtcNow)) - { - existing.Revoke(_clock.UtcNow, request.IpAddress); - await _refreshTokens.SaveChangesAsync(ct).ConfigureAwait(false); - } - - return new AuthMessageDto(AppErrorCodes.Identity.LOGOUT_SUCCESS); + await _auth.LogoutAsync(request.RefreshToken, request.IpAddress, ct).ConfigureAwait(false); + return _msg.Ok(new AuthMessageDto("LOGOUT_SUCCESS"), "LOGOUT_SUCCESS"); } } diff --git a/backend/src/CCE.Application/Identity/Auth/RefreshToken/RefreshTokenCommand.cs b/backend/src/CCE.Application/Identity/Auth/RefreshToken/RefreshTokenCommand.cs index 2e7f2ceb..493e7a96 100644 --- a/backend/src/CCE.Application/Identity/Auth/RefreshToken/RefreshTokenCommand.cs +++ b/backend/src/CCE.Application/Identity/Auth/RefreshToken/RefreshTokenCommand.cs @@ -9,4 +9,4 @@ public sealed record RefreshTokenCommand( LocalAuthApi Api, string? IpAddress, string? UserAgent) - : IRequest>; + : IRequest>; diff --git a/backend/src/CCE.Application/Identity/Auth/RefreshToken/RefreshTokenCommandHandler.cs b/backend/src/CCE.Application/Identity/Auth/RefreshToken/RefreshTokenCommandHandler.cs index d97f77ca..fbcde08e 100644 --- a/backend/src/CCE.Application/Identity/Auth/RefreshToken/RefreshTokenCommandHandler.cs +++ b/backend/src/CCE.Application/Identity/Auth/RefreshToken/RefreshTokenCommandHandler.cs @@ -1,83 +1,27 @@ using CCE.Application.Common; using CCE.Application.Identity.Auth.Common; -using CCE.Domain.Common; -using CCE.Domain.Identity; +using CCE.Application.Messages; using MediatR; -using Microsoft.AspNetCore.Identity; -using AppErrors = CCE.Application.Common.Errors; namespace CCE.Application.Identity.Auth.RefreshToken; internal sealed class RefreshTokenCommandHandler - : IRequestHandler> + : IRequestHandler> { - private readonly UserManager _userManager; - private readonly ILocalTokenService _tokenService; - private readonly IRefreshTokenRepository _refreshTokens; - private readonly ISystemClock _clock; - private readonly AppErrors _errors; + private readonly IAuthService _auth; + private readonly MessageFactory _msg; - public RefreshTokenCommandHandler( - UserManager userManager, - ILocalTokenService tokenService, - IRefreshTokenRepository refreshTokens, - ISystemClock clock, - AppErrors errors) + public RefreshTokenCommandHandler(IAuthService auth, MessageFactory msg) { - _userManager = userManager; - _tokenService = tokenService; - _refreshTokens = refreshTokens; - _clock = clock; - _errors = errors; + _auth = auth; + _msg = msg; } - public async Task> Handle(RefreshTokenCommand request, CancellationToken ct) + public async Task> Handle(RefreshTokenCommand request, CancellationToken ct) { - var tokenHash = _tokenService.HashRefreshToken(request.RefreshToken); - var existing = await _refreshTokens.FindByHashAsync(tokenHash, ct).ConfigureAwait(false); - if (existing is null) - { - return _errors.InvalidRefreshToken(); - } - - if (!existing.IsActive(_clock.UtcNow)) - { - if (existing.RevokedAtUtc is not null) - { - await _refreshTokens.RevokeFamilyAsync(existing.TokenFamilyId, _clock.UtcNow, request.IpAddress, ct) - .ConfigureAwait(false); - await _refreshTokens.SaveChangesAsync(ct).ConfigureAwait(false); - } - return _errors.InvalidRefreshToken(); - } - - var user = await _userManager.FindByIdAsync(existing.UserId.ToString()).ConfigureAwait(false); - if (user is null) - { - return _errors.InvalidRefreshToken(); - } - - var issued = await _tokenService.IssueAsync(user, request.Api, ct).ConfigureAwait(false); - existing.Revoke(_clock.UtcNow, request.IpAddress, issued.RefreshTokenHash); - - var replacement = CCE.Domain.Identity.RefreshToken.Create( - user.Id, - issued.RefreshTokenHash, - existing.TokenFamilyId, - _clock.UtcNow, - issued.RefreshTokenExpiresAtUtc, - request.IpAddress, - request.UserAgent); - await _refreshTokens.AddAsync(replacement, ct).ConfigureAwait(false); - await _refreshTokens.SaveChangesAsync(ct).ConfigureAwait(false); - - var roles = await _userManager.GetRolesAsync(user).ConfigureAwait(false); - return new AuthTokenDto( - issued.AccessToken, - issued.AccessTokenExpiresAtUtc, - issued.RefreshToken, - issued.RefreshTokenExpiresAtUtc, - "Bearer", - new AuthUserDto(user.Id, user.Email ?? string.Empty, user.FirstName, user.LastName, roles.ToArray())); + var dto = await _auth.RefreshTokenAsync(request.RefreshToken, request.Api, + request.IpAddress, request.UserAgent, ct).ConfigureAwait(false); + if (dto is null) return _msg.Unauthorized("INVALID_REFRESH_TOKEN"); + return _msg.Ok(dto, "TOKEN_REFRESHED"); } } diff --git a/backend/src/CCE.Application/Identity/Auth/Register/RegisterUserCommand.cs b/backend/src/CCE.Application/Identity/Auth/Register/RegisterUserCommand.cs index d728498b..8f2eba21 100644 --- a/backend/src/CCE.Application/Identity/Auth/Register/RegisterUserCommand.cs +++ b/backend/src/CCE.Application/Identity/Auth/Register/RegisterUserCommand.cs @@ -13,4 +13,4 @@ public sealed record RegisterUserCommand( string PhoneNumber, string Password, string ConfirmPassword) - : IRequest>; + : IRequest>; diff --git a/backend/src/CCE.Application/Identity/Auth/Register/RegisterUserCommandHandler.cs b/backend/src/CCE.Application/Identity/Auth/Register/RegisterUserCommandHandler.cs index 797ff345..acc32cb5 100644 --- a/backend/src/CCE.Application/Identity/Auth/Register/RegisterUserCommandHandler.cs +++ b/backend/src/CCE.Application/Identity/Auth/Register/RegisterUserCommandHandler.cs @@ -1,76 +1,36 @@ using CCE.Application.Common; using CCE.Application.Identity.Auth.Common; -using CCE.Domain.Common; -using CCE.Domain.Identity; +using CCE.Application.Messages; using MediatR; -using Microsoft.AspNetCore.Identity; -using AppErrors = CCE.Application.Common.Errors; namespace CCE.Application.Identity.Auth.Register; internal sealed class RegisterUserCommandHandler - : IRequestHandler> + : IRequestHandler> { - private const string DefaultRole = "cce-user"; - private readonly UserManager _userManager; - private readonly RoleManager _roleManager; - private readonly AppErrors _errors; + private readonly IAuthService _auth; + private readonly MessageFactory _msg; - public RegisterUserCommandHandler(UserManager userManager, RoleManager roleManager, AppErrors errors) + public RegisterUserCommandHandler(IAuthService auth, MessageFactory msg) { - _userManager = userManager; - _roleManager = roleManager; - _errors = errors; + _auth = auth; + _msg = msg; } - public async Task> Handle(RegisterUserCommand request, CancellationToken ct) + public async Task> Handle(RegisterUserCommand request, CancellationToken ct) { - var existing = await _userManager.FindByEmailAsync(request.EmailAddress).ConfigureAwait(false); - if (existing is not null) - { - return _errors.EmailExists(); - } - - var user = User.RegisterLocal( - request.FirstName, - request.LastName, - request.EmailAddress, - request.JobTitle, - request.OrganizationName, - request.PhoneNumber); - - var createResult = await _userManager.CreateAsync(user, request.Password).ConfigureAwait(false); - if (!createResult.Succeeded) - { - return _errors.RegistrationFailed(ToDetails(createResult)); - } - - if (!await _roleManager.RoleExistsAsync(DefaultRole).ConfigureAwait(false)) - { - var roleResult = await _roleManager.CreateAsync(new Role(DefaultRole)).ConfigureAwait(false); - if (!roleResult.Succeeded) - { - return _errors.RegistrationFailed(ToDetails(roleResult)); - } - } - - var addRoleResult = await _userManager.AddToRoleAsync(user, DefaultRole).ConfigureAwait(false); - if (!addRoleResult.Succeeded) - { - return _errors.RegistrationFailed(ToDetails(addRoleResult)); - } - - return new AuthUserDto( - user.Id, - user.Email ?? request.EmailAddress, - user.FirstName, - user.LastName, - [DefaultRole]); + var result = await _auth.RegisterAsync(request.FirstName, request.LastName, + request.EmailAddress, request.Password, request.JobTitle, + request.OrganizationName, request.PhoneNumber, ct).ConfigureAwait(false); + + if (result.EmailTaken) return _msg.EmailExists(); + if (result.User is null) return _msg.BusinessRule("REGISTRATION_FAILED"); + + return _msg.Ok(new AuthUserDto( + result.User.Id, + result.User.Email ?? request.EmailAddress, + result.User.FirstName, + result.User.LastName, + ["cce-user"]), "REGISTER_SUCCESS"); } - - private static Dictionary ToDetails(IdentityResult result) - => new(StringComparer.Ordinal) - { - ["Identity"] = result.Errors.Select(e => e.Code).ToArray(), - }; } diff --git a/backend/src/CCE.Application/Identity/Auth/ResetPassword/ResetPasswordCommand.cs b/backend/src/CCE.Application/Identity/Auth/ResetPassword/ResetPasswordCommand.cs index de83f8d5..b0e36572 100644 --- a/backend/src/CCE.Application/Identity/Auth/ResetPassword/ResetPasswordCommand.cs +++ b/backend/src/CCE.Application/Identity/Auth/ResetPassword/ResetPasswordCommand.cs @@ -10,4 +10,4 @@ public sealed record ResetPasswordCommand( string NewPassword, string ConfirmPassword, string? IpAddress) - : IRequest>; + : IRequest>; diff --git a/backend/src/CCE.Application/Identity/Auth/ResetPassword/ResetPasswordCommandHandler.cs b/backend/src/CCE.Application/Identity/Auth/ResetPassword/ResetPasswordCommandHandler.cs index afe4efa4..8219f4f0 100644 --- a/backend/src/CCE.Application/Identity/Auth/ResetPassword/ResetPasswordCommandHandler.cs +++ b/backend/src/CCE.Application/Identity/Auth/ResetPassword/ResetPasswordCommandHandler.cs @@ -1,64 +1,37 @@ using CCE.Application.Common; using CCE.Application.Identity.Auth.Common; -using CCE.Domain.Common; -using CCE.Domain.Identity; +using CCE.Application.Messages; using MediatR; -using Microsoft.AspNetCore.Identity; -using AppErrorCodes = CCE.Application.Errors.ApplicationErrors; -using AppErrors = CCE.Application.Common.Errors; namespace CCE.Application.Identity.Auth.ResetPassword; internal sealed class ResetPasswordCommandHandler - : IRequestHandler> + : IRequestHandler> { - private readonly UserManager _userManager; - private readonly IRefreshTokenRepository _refreshTokens; - private readonly ISystemClock _clock; - private readonly AppErrors _errors; + private readonly IAuthService _auth; + private readonly MessageFactory _msg; - public ResetPasswordCommandHandler( - UserManager userManager, - IRefreshTokenRepository refreshTokens, - ISystemClock clock, - AppErrors errors) + public ResetPasswordCommandHandler(IAuthService auth, MessageFactory msg) { - _userManager = userManager; - _refreshTokens = refreshTokens; - _clock = clock; - _errors = errors; + _auth = auth; + _msg = msg; } - public async Task> Handle(ResetPasswordCommand request, CancellationToken ct) + public async Task> Handle(ResetPasswordCommand request, CancellationToken ct) { - var user = await _userManager.FindByEmailAsync(request.EmailAddress).ConfigureAwait(false); - if (user is null) - { - return _errors.UserNotFound(); - } - - string token; - try - { - token = PasswordResetTokenCodec.Decode(request.Token); - } - catch (FormatException) - { - return _errors.InvalidRefreshToken(); - } + var errorKey = await _auth.ResetPasswordAsync(request.EmailAddress, request.Token, + request.NewPassword, request.IpAddress, ct).ConfigureAwait(false); - var result = await _userManager.ResetPasswordAsync(user, token, request.NewPassword).ConfigureAwait(false); - if (!result.Succeeded) + if (errorKey is not null) { - return _errors.RegistrationFailed(new Dictionary(StringComparer.Ordinal) + return errorKey switch { - ["Identity"] = result.Errors.Select(e => e.Code).ToArray(), - }); + "USER_NOT_FOUND" => _msg.UserNotFound(), + "INVALID_RESET_TOKEN" => _msg.Unauthorized("INVALID_RESET_TOKEN"), + _ => _msg.BusinessRule(errorKey), + }; } - await _userManager.UpdateSecurityStampAsync(user).ConfigureAwait(false); - await _refreshTokens.RevokeAllForUserAsync(user.Id, _clock.UtcNow, request.IpAddress, ct).ConfigureAwait(false); - await _refreshTokens.SaveChangesAsync(ct).ConfigureAwait(false); - return new AuthMessageDto(AppErrorCodes.Identity.PASSWORD_RESET); + return _msg.Ok(new AuthMessageDto("PASSWORD_RESET"), "PASSWORD_RESET"); } } diff --git a/backend/src/CCE.Application/Identity/Commands/ApproveExpertRequest/ApproveExpertRequestCommand.cs b/backend/src/CCE.Application/Identity/Commands/ApproveExpertRequest/ApproveExpertRequestCommand.cs index 15bfab03..ba9d49e1 100644 --- a/backend/src/CCE.Application/Identity/Commands/ApproveExpertRequest/ApproveExpertRequestCommand.cs +++ b/backend/src/CCE.Application/Identity/Commands/ApproveExpertRequest/ApproveExpertRequestCommand.cs @@ -7,4 +7,4 @@ namespace CCE.Application.Identity.Commands.ApproveExpertRequest; public sealed record ApproveExpertRequestCommand( System.Guid Id, string AcademicTitleAr, - string AcademicTitleEn) : IRequest>; + string AcademicTitleEn) : IRequest>; diff --git a/backend/src/CCE.Application/Identity/Commands/ApproveExpertRequest/ApproveExpertRequestCommandHandler.cs b/backend/src/CCE.Application/Identity/Commands/ApproveExpertRequest/ApproveExpertRequestCommandHandler.cs index 78c73e85..76e2b555 100644 --- a/backend/src/CCE.Application/Identity/Commands/ApproveExpertRequest/ApproveExpertRequestCommandHandler.cs +++ b/backend/src/CCE.Application/Identity/Commands/ApproveExpertRequest/ApproveExpertRequestCommandHandler.cs @@ -2,6 +2,7 @@ using CCE.Application.Common.Interfaces; using CCE.Application.Common.Pagination; using CCE.Application.Identity.Dtos; +using CCE.Application.Messages; using CCE.Domain.Common; using CCE.Domain.Identity; using MediatR; @@ -9,52 +10,53 @@ namespace CCE.Application.Identity.Commands.ApproveExpertRequest; public sealed class ApproveExpertRequestCommandHandler - : IRequestHandler> + : IRequestHandler> { - private readonly IExpertWorkflowRepository _service; private readonly ICceDbContext _db; + private readonly IExpertWorkflowRepository _service; private readonly ICurrentUserAccessor _currentUser; private readonly ISystemClock _clock; - private readonly CCE.Application.Common.Errors _errors; + private readonly MessageFactory _msg; public ApproveExpertRequestCommandHandler( - IExpertWorkflowRepository service, ICceDbContext db, + IExpertWorkflowRepository service, ICurrentUserAccessor currentUser, ISystemClock clock, - CCE.Application.Common.Errors errors) + MessageFactory msg) { - _service = service; _db = db; + _service = service; _currentUser = currentUser; _clock = clock; - _errors = errors; + _msg = msg; } - public async Task> Handle( + public async Task> Handle( ApproveExpertRequestCommand request, CancellationToken cancellationToken) { var registration = await _service.FindIncludingDeletedAsync(request.Id, cancellationToken).ConfigureAwait(false); if (registration is null) { - return _errors.ExpertRequestNotFound(); + return _msg.NotFound("EXPERT_REQUEST_NOT_FOUND"); } var approvedById = _currentUser.GetUserId(); if (approvedById is null) { - return _errors.NotAuthenticated(); + return _msg.NotAuthenticated(); } registration.Approve(approvedById.Value, _clock); var profile = ExpertProfile.CreateFromApprovedRequest(registration, request.AcademicTitleAr, request.AcademicTitleEn, _clock); - await _service.SaveAsync(registration, profile, cancellationToken).ConfigureAwait(false); + _service.AddProfile(profile); + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); var userName = (await _db.Users.Where(u => u.Id == registration.RequestedById).Select(u => u.UserName) .ToListAsyncEither(cancellationToken).ConfigureAwait(false)).FirstOrDefault(); - return new ExpertProfileDto( + return _msg.Ok(new ExpertProfileDto( profile.Id, profile.UserId, userName, @@ -64,6 +66,6 @@ public async Task> Handle( profile.AcademicTitleAr, profile.AcademicTitleEn, profile.ApprovedOn, - profile.ApprovedById); + profile.ApprovedById), "EXPERT_REQUEST_APPROVED"); } } diff --git a/backend/src/CCE.Application/Identity/Commands/AssignUserRoles/AssignUserRolesCommand.cs b/backend/src/CCE.Application/Identity/Commands/AssignUserRoles/AssignUserRolesCommand.cs index 6340888e..c398206e 100644 --- a/backend/src/CCE.Application/Identity/Commands/AssignUserRoles/AssignUserRolesCommand.cs +++ b/backend/src/CCE.Application/Identity/Commands/AssignUserRoles/AssignUserRolesCommand.cs @@ -9,4 +9,4 @@ namespace CCE.Application.Identity.Commands.AssignUserRoles; /// public sealed record AssignUserRolesCommand( Guid Id, - IReadOnlyList Roles) : IRequest>; + IReadOnlyList Roles) : IRequest>; diff --git a/backend/src/CCE.Application/Identity/Commands/AssignUserRoles/AssignUserRolesCommandHandler.cs b/backend/src/CCE.Application/Identity/Commands/AssignUserRoles/AssignUserRolesCommandHandler.cs index fe9239eb..09e8a16d 100644 --- a/backend/src/CCE.Application/Identity/Commands/AssignUserRoles/AssignUserRolesCommandHandler.cs +++ b/backend/src/CCE.Application/Identity/Commands/AssignUserRoles/AssignUserRolesCommandHandler.cs @@ -1,40 +1,41 @@ using CCE.Application.Common; using CCE.Application.Identity.Dtos; using CCE.Application.Identity.Queries.GetUserById; +using CCE.Application.Messages; using MediatR; namespace CCE.Application.Identity.Commands.AssignUserRoles; -public sealed class AssignUserRolesCommandHandler : IRequestHandler> +public sealed class AssignUserRolesCommandHandler : IRequestHandler> { private readonly IUserRoleAssignmentRepository _service; private readonly IMediator _mediator; - private readonly CCE.Application.Common.Errors _errors; + private readonly MessageFactory _msg; public AssignUserRolesCommandHandler( IUserRoleAssignmentRepository service, IMediator mediator, - CCE.Application.Common.Errors errors) + MessageFactory msg) { _service = service; _mediator = mediator; - _errors = errors; + _msg = msg; } - public async Task> Handle(AssignUserRolesCommand request, CancellationToken cancellationToken) + public async Task> Handle(AssignUserRolesCommand request, CancellationToken cancellationToken) { var ok = await _service.ReplaceRolesAsync(request.Id, request.Roles, cancellationToken).ConfigureAwait(false); if (!ok) { - return _errors.UserNotFound(); + return _msg.UserNotFound(); } var result = await _mediator.Send(new GetUserByIdQuery(request.Id), cancellationToken).ConfigureAwait(false); - if (!result.IsSuccess) + if (!result.Success) { return result; } - return result.Data!; + return _msg.Ok(result.Data!, "ROLES_ASSIGNED"); } } diff --git a/backend/src/CCE.Application/Identity/Commands/CreateStateRepAssignment/CreateStateRepAssignmentCommand.cs b/backend/src/CCE.Application/Identity/Commands/CreateStateRepAssignment/CreateStateRepAssignmentCommand.cs index 4b24bc50..d8e575eb 100644 --- a/backend/src/CCE.Application/Identity/Commands/CreateStateRepAssignment/CreateStateRepAssignmentCommand.cs +++ b/backend/src/CCE.Application/Identity/Commands/CreateStateRepAssignment/CreateStateRepAssignmentCommand.cs @@ -6,4 +6,4 @@ namespace CCE.Application.Identity.Commands.CreateStateRepAssignment; public sealed record CreateStateRepAssignmentCommand( System.Guid UserId, - System.Guid CountryId) : IRequest>; + System.Guid CountryId) : IRequest>; diff --git a/backend/src/CCE.Application/Identity/Commands/CreateStateRepAssignment/CreateStateRepAssignmentCommandHandler.cs b/backend/src/CCE.Application/Identity/Commands/CreateStateRepAssignment/CreateStateRepAssignmentCommandHandler.cs index 76e3f88f..3dab4af0 100644 --- a/backend/src/CCE.Application/Identity/Commands/CreateStateRepAssignment/CreateStateRepAssignmentCommandHandler.cs +++ b/backend/src/CCE.Application/Identity/Commands/CreateStateRepAssignment/CreateStateRepAssignmentCommandHandler.cs @@ -2,6 +2,7 @@ using CCE.Application.Common.Interfaces; using CCE.Application.Common.Pagination; using CCE.Application.Identity.Dtos; +using CCE.Application.Messages; using CCE.Domain.Common; using CCE.Domain.Identity; using MediatR; @@ -9,56 +10,54 @@ namespace CCE.Application.Identity.Commands.CreateStateRepAssignment; public sealed class CreateStateRepAssignmentCommandHandler - : IRequestHandler> + : IRequestHandler> { private readonly ICceDbContext _db; private readonly IStateRepAssignmentRepository _service; private readonly ICurrentUserAccessor _currentUser; private readonly ISystemClock _clock; - private readonly CCE.Application.Common.Errors _errors; + private readonly MessageFactory _msg; public CreateStateRepAssignmentCommandHandler( ICceDbContext db, IStateRepAssignmentRepository service, ICurrentUserAccessor currentUser, ISystemClock clock, - CCE.Application.Common.Errors errors) + MessageFactory msg) { _db = db; _service = service; _currentUser = currentUser; _clock = clock; - _errors = errors; + _msg = msg; } - public async Task> Handle( + public async Task> Handle( CreateStateRepAssignmentCommand request, CancellationToken cancellationToken) { - // Verify user exists. var userExists = await ExistsAsync(_db.Users.Where(u => u.Id == request.UserId), cancellationToken).ConfigureAwait(false); if (!userExists) { - return _errors.UserNotFound(); + return _msg.UserNotFound(); } - // Verify country exists. var countryExists = await ExistsAsync(_db.Countries.Where(c => c.Id == request.CountryId), cancellationToken).ConfigureAwait(false); if (!countryExists) { - return _errors.CountryNotFound(); + return _msg.NotFound("COUNTRY_NOT_FOUND"); } var assignedById = _currentUser.GetUserId(); if (assignedById is null) { - return _errors.NotAuthenticated(); + return _msg.NotAuthenticated(); } var assignment = StateRepresentativeAssignment.Assign(request.UserId, request.CountryId, assignedById.Value, _clock); - await _service.SaveAsync(assignment, cancellationToken).ConfigureAwait(false); + await _service.AddAsync(assignment, cancellationToken).ConfigureAwait(false); + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - // Build the DTO — look up UserName for the assigned user. var userNames = await _db.Users .Where(u => u.Id == request.UserId) .Select(u => u.UserName) @@ -66,7 +65,7 @@ public async Task> Handle( .ConfigureAwait(false); var userName = userNames.FirstOrDefault(); - return new StateRepAssignmentDto( + return _msg.Ok(new StateRepAssignmentDto( assignment.Id, assignment.UserId, userName, @@ -75,7 +74,7 @@ public async Task> Handle( assignment.AssignedById, assignment.RevokedOn, assignment.RevokedById, - IsActive: true); + IsActive: true), "STATE_REP_ASSIGNMENT_CREATED"); } private static async Task ExistsAsync(IQueryable query, CancellationToken ct) diff --git a/backend/src/CCE.Application/Identity/Commands/RejectExpertRequest/RejectExpertRequestCommand.cs b/backend/src/CCE.Application/Identity/Commands/RejectExpertRequest/RejectExpertRequestCommand.cs index 9a209337..8147af0c 100644 --- a/backend/src/CCE.Application/Identity/Commands/RejectExpertRequest/RejectExpertRequestCommand.cs +++ b/backend/src/CCE.Application/Identity/Commands/RejectExpertRequest/RejectExpertRequestCommand.cs @@ -7,4 +7,4 @@ namespace CCE.Application.Identity.Commands.RejectExpertRequest; public sealed record RejectExpertRequestCommand( System.Guid Id, string RejectionReasonAr, - string RejectionReasonEn) : IRequest>; + string RejectionReasonEn) : IRequest>; diff --git a/backend/src/CCE.Application/Identity/Commands/RejectExpertRequest/RejectExpertRequestCommandHandler.cs b/backend/src/CCE.Application/Identity/Commands/RejectExpertRequest/RejectExpertRequestCommandHandler.cs index 31d19d3a..62448b45 100644 --- a/backend/src/CCE.Application/Identity/Commands/RejectExpertRequest/RejectExpertRequestCommandHandler.cs +++ b/backend/src/CCE.Application/Identity/Commands/RejectExpertRequest/RejectExpertRequestCommandHandler.cs @@ -2,57 +2,58 @@ using CCE.Application.Common.Interfaces; using CCE.Application.Common.Pagination; using CCE.Application.Identity.Dtos; +using CCE.Application.Messages; using CCE.Domain.Common; using MediatR; namespace CCE.Application.Identity.Commands.RejectExpertRequest; public sealed class RejectExpertRequestCommandHandler - : IRequestHandler> + : IRequestHandler> { - private readonly IExpertWorkflowRepository _service; private readonly ICceDbContext _db; + private readonly IExpertWorkflowRepository _service; private readonly ICurrentUserAccessor _currentUser; private readonly ISystemClock _clock; - private readonly CCE.Application.Common.Errors _errors; + private readonly MessageFactory _msg; public RejectExpertRequestCommandHandler( - IExpertWorkflowRepository service, ICceDbContext db, + IExpertWorkflowRepository service, ICurrentUserAccessor currentUser, ISystemClock clock, - CCE.Application.Common.Errors errors) + MessageFactory msg) { - _service = service; _db = db; + _service = service; _currentUser = currentUser; _clock = clock; - _errors = errors; + _msg = msg; } - public async Task> Handle( + public async Task> Handle( RejectExpertRequestCommand request, CancellationToken cancellationToken) { var registration = await _service.FindIncludingDeletedAsync(request.Id, cancellationToken).ConfigureAwait(false); if (registration is null) { - return _errors.ExpertRequestNotFound(); + return _msg.NotFound("EXPERT_REQUEST_NOT_FOUND"); } var rejectedById = _currentUser.GetUserId(); if (rejectedById is null) { - return _errors.NotAuthenticated(); + return _msg.NotAuthenticated(); } registration.Reject(rejectedById.Value, request.RejectionReasonAr, request.RejectionReasonEn, _clock); - await _service.SaveAsync(registration, newProfile: null, cancellationToken).ConfigureAwait(false); + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); var userName = (await _db.Users.Where(u => u.Id == registration.RequestedById).Select(u => u.UserName) .ToListAsyncEither(cancellationToken).ConfigureAwait(false)).FirstOrDefault(); - return new ExpertRequestDto( + return _msg.Ok(new ExpertRequestDto( registration.Id, registration.RequestedById, userName, @@ -64,6 +65,6 @@ public async Task> Handle( registration.ProcessedById, registration.ProcessedOn, registration.RejectionReasonAr, - registration.RejectionReasonEn); + registration.RejectionReasonEn), "EXPERT_REQUEST_REJECTED"); } } diff --git a/backend/src/CCE.Application/Identity/Commands/RevokeStateRepAssignment/RevokeStateRepAssignmentCommand.cs b/backend/src/CCE.Application/Identity/Commands/RevokeStateRepAssignment/RevokeStateRepAssignmentCommand.cs index ec6ad513..7d80970d 100644 --- a/backend/src/CCE.Application/Identity/Commands/RevokeStateRepAssignment/RevokeStateRepAssignmentCommand.cs +++ b/backend/src/CCE.Application/Identity/Commands/RevokeStateRepAssignment/RevokeStateRepAssignmentCommand.cs @@ -5,6 +5,6 @@ namespace CCE.Application.Identity.Commands.RevokeStateRepAssignment; /// /// Revokes (soft-deletes) the given state-rep assignment. -/// Returns so the endpoint can map to HTTP 204. +/// Returns so the endpoint can map to HTTP 204. /// -public sealed record RevokeStateRepAssignmentCommand(System.Guid Id) : IRequest>; +public sealed record RevokeStateRepAssignmentCommand(System.Guid Id) : IRequest>; diff --git a/backend/src/CCE.Application/Identity/Commands/RevokeStateRepAssignment/RevokeStateRepAssignmentCommandHandler.cs b/backend/src/CCE.Application/Identity/Commands/RevokeStateRepAssignment/RevokeStateRepAssignmentCommandHandler.cs index 06468088..105afcf0 100644 --- a/backend/src/CCE.Application/Identity/Commands/RevokeStateRepAssignment/RevokeStateRepAssignmentCommandHandler.cs +++ b/backend/src/CCE.Application/Identity/Commands/RevokeStateRepAssignment/RevokeStateRepAssignmentCommandHandler.cs @@ -1,46 +1,51 @@ using CCE.Application.Common; using CCE.Application.Common.Interfaces; +using CCE.Application.Messages; using CCE.Domain.Common; using MediatR; namespace CCE.Application.Identity.Commands.RevokeStateRepAssignment; -public sealed class RevokeStateRepAssignmentCommandHandler : IRequestHandler> +public sealed class RevokeStateRepAssignmentCommandHandler : IRequestHandler> { + private readonly ICceDbContext _db; private readonly IStateRepAssignmentRepository _service; private readonly ICurrentUserAccessor _currentUser; private readonly ISystemClock _clock; - private readonly CCE.Application.Common.Errors _errors; + private readonly MessageFactory _msg; public RevokeStateRepAssignmentCommandHandler( + ICceDbContext db, IStateRepAssignmentRepository service, ICurrentUserAccessor currentUser, ISystemClock clock, - CCE.Application.Common.Errors errors) + MessageFactory msg) { + _db = db; _service = service; _currentUser = currentUser; _clock = clock; - _errors = errors; + _msg = msg; } - public async Task> Handle(RevokeStateRepAssignmentCommand request, CancellationToken cancellationToken) + public async Task> Handle(RevokeStateRepAssignmentCommand request, CancellationToken cancellationToken) { var assignment = await _service.FindIncludingRevokedAsync(request.Id, cancellationToken).ConfigureAwait(false); if (assignment is null) { - return _errors.StateRepAssignmentNotFound(); + return _msg.NotFound("STATE_REP_ASSIGNMENT_NOT_FOUND"); } var revokedById = _currentUser.GetUserId(); if (revokedById is null) { - return _errors.NotAuthenticated(); + return _msg.NotAuthenticated(); } assignment.Revoke(revokedById.Value, _clock); - await _service.UpdateAsync(assignment, cancellationToken).ConfigureAwait(false); + _service.Update(assignment); + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - return Result.Success(); + return _msg.Ok("STATE_REP_ASSIGNMENT_REVOKED"); } } diff --git a/backend/src/CCE.Application/Identity/IExpertWorkflowRepository.cs b/backend/src/CCE.Application/Identity/IExpertWorkflowRepository.cs index 50154f45..4e9c304d 100644 --- a/backend/src/CCE.Application/Identity/IExpertWorkflowRepository.cs +++ b/backend/src/CCE.Application/Identity/IExpertWorkflowRepository.cs @@ -1,12 +1,13 @@ +using CCE.Application.Common.Interfaces; using CCE.Domain.Identity; namespace CCE.Application.Identity; /// -/// Persistence helper for the expert-registration workflow. Implemented in Infrastructure -/// (writes via CceDbContext); handlers stay clear of EF tracker calls. +/// Persistence helper for the expert-registration workflow. +/// Tracking-only — handlers call ICceDbContext.SaveChangesAsync to commit. /// -public interface IExpertWorkflowRepository +public interface IExpertWorkflowRepository : IRepository { /// /// Loads the request by Id, including soft-deleted rows. Returns null when missing. @@ -14,8 +15,8 @@ public interface IExpertWorkflowRepository Task FindIncludingDeletedAsync(System.Guid id, CancellationToken ct); /// - /// Persists in-memory mutations on a tracked request (Approve / Reject domain transitions) - /// AND adds the new if non-null. Single SaveChanges call. + /// Registers a new in the change tracker + /// (created as a side-effect of approving an expert request). /// - Task SaveAsync(ExpertRegistrationRequest request, ExpertProfile? newProfile, CancellationToken ct); + void AddProfile(ExpertProfile profile); } diff --git a/backend/src/CCE.Application/Identity/IStateRepAssignmentRepository.cs b/backend/src/CCE.Application/Identity/IStateRepAssignmentRepository.cs index 9b220f8c..02c792a3 100644 --- a/backend/src/CCE.Application/Identity/IStateRepAssignmentRepository.cs +++ b/backend/src/CCE.Application/Identity/IStateRepAssignmentRepository.cs @@ -1,30 +1,15 @@ +using CCE.Application.Common.Interfaces; using CCE.Domain.Identity; namespace CCE.Application.Identity; /// /// Persists new aggregates and revokes existing ones. -/// Implemented in Infrastructure (writes via CceDbContext). /// -public interface IStateRepAssignmentRepository +public interface IStateRepAssignmentRepository : IRepository { - /// - /// Persists the provided assignment. Caller is responsible for constructing it via - /// . Throws DuplicateException - /// if the (UserId, CountryId) pair already has an active assignment (filtered unique - /// index in the schema). - /// - Task SaveAsync(StateRepresentativeAssignment assignment, CancellationToken ct); - /// /// Loads the assignment by Id, including soft-deleted (revoked) rows. Returns null when missing. - /// Used by the revoke command to load before mutating. /// Task FindIncludingRevokedAsync(System.Guid id, CancellationToken ct); - - /// - /// Persists the in-memory state of the assignment after domain mutations - /// (e.g., ). - /// - Task UpdateAsync(StateRepresentativeAssignment assignment, CancellationToken ct); } diff --git a/backend/src/CCE.Application/Identity/Public/Commands/SubmitExpertRequest/SubmitExpertRequestCommand.cs b/backend/src/CCE.Application/Identity/Public/Commands/SubmitExpertRequest/SubmitExpertRequestCommand.cs index b5c76434..46fabe98 100644 --- a/backend/src/CCE.Application/Identity/Public/Commands/SubmitExpertRequest/SubmitExpertRequestCommand.cs +++ b/backend/src/CCE.Application/Identity/Public/Commands/SubmitExpertRequest/SubmitExpertRequestCommand.cs @@ -8,4 +8,4 @@ public sealed record SubmitExpertRequestCommand( System.Guid RequesterId, string RequestedBioAr, string RequestedBioEn, - IReadOnlyList RequestedTags) : IRequest>; + IReadOnlyList RequestedTags) : IRequest>; diff --git a/backend/src/CCE.Application/Identity/Public/Commands/SubmitExpertRequest/SubmitExpertRequestCommandHandler.cs b/backend/src/CCE.Application/Identity/Public/Commands/SubmitExpertRequest/SubmitExpertRequestCommandHandler.cs index adfa8585..27f3a74c 100644 --- a/backend/src/CCE.Application/Identity/Public/Commands/SubmitExpertRequest/SubmitExpertRequestCommandHandler.cs +++ b/backend/src/CCE.Application/Identity/Public/Commands/SubmitExpertRequest/SubmitExpertRequestCommandHandler.cs @@ -1,5 +1,7 @@ using CCE.Application.Common; +using CCE.Application.Common.Interfaces; using CCE.Application.Identity.Public.Dtos; +using CCE.Application.Messages; using CCE.Domain.Common; using CCE.Domain.Identity; using MediatR; @@ -7,18 +9,26 @@ namespace CCE.Application.Identity.Public.Commands.SubmitExpertRequest; public sealed class SubmitExpertRequestCommandHandler - : IRequestHandler> + : IRequestHandler> { + private readonly ICceDbContext _db; private readonly IExpertRequestSubmissionRepository _service; private readonly ISystemClock _clock; + private readonly MessageFactory _msg; - public SubmitExpertRequestCommandHandler(IExpertRequestSubmissionRepository service, ISystemClock clock) + public SubmitExpertRequestCommandHandler( + ICceDbContext db, + IExpertRequestSubmissionRepository service, + ISystemClock clock, + MessageFactory msg) { + _db = db; _service = service; _clock = clock; + _msg = msg; } - public async Task> Handle(SubmitExpertRequestCommand request, CancellationToken cancellationToken) + public async Task> Handle(SubmitExpertRequestCommand request, CancellationToken cancellationToken) { var entity = ExpertRegistrationRequest.Submit( request.RequesterId, @@ -26,9 +36,10 @@ public async Task> Handle(SubmitExpertRequestComm request.RequestedBioEn, request.RequestedTags, _clock); - await _service.SaveAsync(entity, cancellationToken).ConfigureAwait(false); + await _service.AddAsync(entity, cancellationToken).ConfigureAwait(false); + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - return new ExpertRequestStatusDto( + return _msg.Ok(new ExpertRequestStatusDto( entity.Id, entity.RequestedById, entity.RequestedBioAr, @@ -38,6 +49,6 @@ public async Task> Handle(SubmitExpertRequestComm entity.Status, entity.ProcessedOn, entity.RejectionReasonAr, - entity.RejectionReasonEn); + entity.RejectionReasonEn), "EXPERT_REQUEST_SUBMITTED"); } } diff --git a/backend/src/CCE.Application/Identity/Public/Commands/UpdateMyProfile/UpdateMyProfileCommand.cs b/backend/src/CCE.Application/Identity/Public/Commands/UpdateMyProfile/UpdateMyProfileCommand.cs index 30b9a74d..542635c0 100644 --- a/backend/src/CCE.Application/Identity/Public/Commands/UpdateMyProfile/UpdateMyProfileCommand.cs +++ b/backend/src/CCE.Application/Identity/Public/Commands/UpdateMyProfile/UpdateMyProfileCommand.cs @@ -11,4 +11,4 @@ public sealed record UpdateMyProfileCommand( KnowledgeLevel KnowledgeLevel, IReadOnlyList Interests, string? AvatarUrl, - System.Guid? CountryId) : IRequest>; + System.Guid? CountryId) : IRequest>; diff --git a/backend/src/CCE.Application/Identity/Public/Commands/UpdateMyProfile/UpdateMyProfileCommandHandler.cs b/backend/src/CCE.Application/Identity/Public/Commands/UpdateMyProfile/UpdateMyProfileCommandHandler.cs index e991f28a..9d75a3f0 100644 --- a/backend/src/CCE.Application/Identity/Public/Commands/UpdateMyProfile/UpdateMyProfileCommandHandler.cs +++ b/backend/src/CCE.Application/Identity/Public/Commands/UpdateMyProfile/UpdateMyProfileCommandHandler.cs @@ -1,26 +1,30 @@ using CCE.Application.Common; +using CCE.Application.Common.Interfaces; using CCE.Application.Identity.Public.Dtos; +using CCE.Application.Messages; using MediatR; namespace CCE.Application.Identity.Public.Commands.UpdateMyProfile; -public sealed class UpdateMyProfileCommandHandler : IRequestHandler> +public sealed class UpdateMyProfileCommandHandler : IRequestHandler> { + private readonly ICceDbContext _db; private readonly IUserProfileRepository _service; - private readonly CCE.Application.Common.Errors _errors; + private readonly MessageFactory _msg; - public UpdateMyProfileCommandHandler(IUserProfileRepository service, CCE.Application.Common.Errors errors) + public UpdateMyProfileCommandHandler(ICceDbContext db, IUserProfileRepository service, MessageFactory msg) { + _db = db; _service = service; - _errors = errors; + _msg = msg; } - public async Task> Handle(UpdateMyProfileCommand request, CancellationToken cancellationToken) + public async Task> Handle(UpdateMyProfileCommand request, CancellationToken cancellationToken) { var user = await _service.FindAsync(request.UserId, cancellationToken).ConfigureAwait(false); if (user is null) { - return _errors.UserNotFound(); + return _msg.UserNotFound(); } user.SetLocalePreference(request.LocalePreference); @@ -37,9 +41,10 @@ public async Task> Handle(UpdateMyProfileCommand request, user.AssignCountry(request.CountryId.Value); } - await _service.UpdateAsync(user, cancellationToken).ConfigureAwait(false); + _service.Update(user); + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - return new UserProfileDto( + return _msg.Ok(new UserProfileDto( user.Id, user.Email, user.UserName, @@ -47,6 +52,6 @@ public async Task> Handle(UpdateMyProfileCommand request, user.KnowledgeLevel, user.Interests, user.CountryId, - user.AvatarUrl); + user.AvatarUrl), "PROFILE_UPDATED"); } } diff --git a/backend/src/CCE.Application/Identity/Public/IExpertRequestSubmissionRepository.cs b/backend/src/CCE.Application/Identity/Public/IExpertRequestSubmissionRepository.cs index 13678af7..1968540a 100644 --- a/backend/src/CCE.Application/Identity/Public/IExpertRequestSubmissionRepository.cs +++ b/backend/src/CCE.Application/Identity/Public/IExpertRequestSubmissionRepository.cs @@ -1,8 +1,8 @@ +using CCE.Application.Common.Interfaces; using CCE.Domain.Identity; namespace CCE.Application.Identity.Public; -public interface IExpertRequestSubmissionRepository +public interface IExpertRequestSubmissionRepository : IRepository { - Task SaveAsync(ExpertRegistrationRequest request, CancellationToken ct); } diff --git a/backend/src/CCE.Application/Identity/Public/IUserProfileRepository.cs b/backend/src/CCE.Application/Identity/Public/IUserProfileRepository.cs index d3dd5394..5d3f89e8 100644 --- a/backend/src/CCE.Application/Identity/Public/IUserProfileRepository.cs +++ b/backend/src/CCE.Application/Identity/Public/IUserProfileRepository.cs @@ -5,5 +5,5 @@ namespace CCE.Application.Identity.Public; public interface IUserProfileRepository { Task FindAsync(System.Guid userId, CancellationToken ct); - Task UpdateAsync(User user, CancellationToken ct); + void Update(User user); } diff --git a/backend/src/CCE.Application/Identity/Public/Queries/GetMyExpertStatus/GetMyExpertStatusQuery.cs b/backend/src/CCE.Application/Identity/Public/Queries/GetMyExpertStatus/GetMyExpertStatusQuery.cs index 9ab7968a..8fd2c7b8 100644 --- a/backend/src/CCE.Application/Identity/Public/Queries/GetMyExpertStatus/GetMyExpertStatusQuery.cs +++ b/backend/src/CCE.Application/Identity/Public/Queries/GetMyExpertStatus/GetMyExpertStatusQuery.cs @@ -4,4 +4,4 @@ namespace CCE.Application.Identity.Public.Queries.GetMyExpertStatus; -public sealed record GetMyExpertStatusQuery(System.Guid UserId) : IRequest>; +public sealed record GetMyExpertStatusQuery(System.Guid UserId) : IRequest>; diff --git a/backend/src/CCE.Application/Identity/Public/Queries/GetMyExpertStatus/GetMyExpertStatusQueryHandler.cs b/backend/src/CCE.Application/Identity/Public/Queries/GetMyExpertStatus/GetMyExpertStatusQueryHandler.cs index 8e13007a..1cf5ffe6 100644 --- a/backend/src/CCE.Application/Identity/Public/Queries/GetMyExpertStatus/GetMyExpertStatusQueryHandler.cs +++ b/backend/src/CCE.Application/Identity/Public/Queries/GetMyExpertStatus/GetMyExpertStatusQueryHandler.cs @@ -2,22 +2,23 @@ using CCE.Application.Common.Interfaces; using CCE.Application.Common.Pagination; using CCE.Application.Identity.Public.Dtos; +using CCE.Application.Messages; using MediatR; namespace CCE.Application.Identity.Public.Queries.GetMyExpertStatus; -public sealed class GetMyExpertStatusQueryHandler : IRequestHandler> +public sealed class GetMyExpertStatusQueryHandler : IRequestHandler> { private readonly ICceDbContext _db; - private readonly CCE.Application.Common.Errors _errors; + private readonly MessageFactory _msg; - public GetMyExpertStatusQueryHandler(ICceDbContext db, CCE.Application.Common.Errors errors) + public GetMyExpertStatusQueryHandler(ICceDbContext db, MessageFactory msg) { _db = db; - _errors = errors; + _msg = msg; } - public async Task> Handle(GetMyExpertStatusQuery request, CancellationToken cancellationToken) + public async Task> Handle(GetMyExpertStatusQuery request, CancellationToken cancellationToken) { var rows = await _db.ExpertRegistrationRequests .Where(r => r.RequestedById == request.UserId) @@ -29,10 +30,10 @@ public async Task> Handle(GetMyExpertStatusQuery var entity = rows.FirstOrDefault(); if (entity is null) { - return _errors.ExpertRequestNotFound(); + return _msg.NotFound("EXPERT_REQUEST_NOT_FOUND"); } - return new ExpertRequestStatusDto( + return _msg.Ok(new ExpertRequestStatusDto( entity.Id, entity.RequestedById, entity.RequestedBioAr, @@ -42,6 +43,6 @@ public async Task> Handle(GetMyExpertStatusQuery entity.Status, entity.ProcessedOn, entity.RejectionReasonAr, - entity.RejectionReasonEn); + entity.RejectionReasonEn), "SUCCESS_OPERATION"); } } diff --git a/backend/src/CCE.Application/Identity/Public/Queries/GetMyProfile/GetMyProfileQuery.cs b/backend/src/CCE.Application/Identity/Public/Queries/GetMyProfile/GetMyProfileQuery.cs index 836203e6..50fa108c 100644 --- a/backend/src/CCE.Application/Identity/Public/Queries/GetMyProfile/GetMyProfileQuery.cs +++ b/backend/src/CCE.Application/Identity/Public/Queries/GetMyProfile/GetMyProfileQuery.cs @@ -4,4 +4,4 @@ namespace CCE.Application.Identity.Public.Queries.GetMyProfile; -public sealed record GetMyProfileQuery(System.Guid UserId) : IRequest>; +public sealed record GetMyProfileQuery(System.Guid UserId) : IRequest>; diff --git a/backend/src/CCE.Application/Identity/Public/Queries/GetMyProfile/GetMyProfileQueryHandler.cs b/backend/src/CCE.Application/Identity/Public/Queries/GetMyProfile/GetMyProfileQueryHandler.cs index 4062da26..7fa15a57 100644 --- a/backend/src/CCE.Application/Identity/Public/Queries/GetMyProfile/GetMyProfileQueryHandler.cs +++ b/backend/src/CCE.Application/Identity/Public/Queries/GetMyProfile/GetMyProfileQueryHandler.cs @@ -1,29 +1,30 @@ using CCE.Application.Common; using CCE.Application.Identity.Public.Dtos; +using CCE.Application.Messages; using MediatR; namespace CCE.Application.Identity.Public.Queries.GetMyProfile; -public sealed class GetMyProfileQueryHandler : IRequestHandler> +public sealed class GetMyProfileQueryHandler : IRequestHandler> { private readonly IUserProfileRepository _service; - private readonly CCE.Application.Common.Errors _errors; + private readonly MessageFactory _msg; - public GetMyProfileQueryHandler(IUserProfileRepository service, CCE.Application.Common.Errors errors) + public GetMyProfileQueryHandler(IUserProfileRepository service, MessageFactory msg) { _service = service; - _errors = errors; + _msg = msg; } - public async Task> Handle(GetMyProfileQuery request, CancellationToken cancellationToken) + public async Task> Handle(GetMyProfileQuery request, CancellationToken cancellationToken) { var user = await _service.FindAsync(request.UserId, cancellationToken).ConfigureAwait(false); if (user is null) { - return _errors.UserNotFound(); + return _msg.UserNotFound(); } - return new UserProfileDto( + return _msg.Ok(new UserProfileDto( user.Id, user.Email, user.UserName, @@ -31,6 +32,6 @@ public async Task> Handle(GetMyProfileQuery request, Canc user.KnowledgeLevel, user.Interests, user.CountryId, - user.AvatarUrl); + user.AvatarUrl), "SUCCESS_OPERATION"); } } diff --git a/backend/src/CCE.Application/Identity/Queries/GetUserById/GetUserByIdQuery.cs b/backend/src/CCE.Application/Identity/Queries/GetUserById/GetUserByIdQuery.cs index 35c0cac9..0a8482e0 100644 --- a/backend/src/CCE.Application/Identity/Queries/GetUserById/GetUserByIdQuery.cs +++ b/backend/src/CCE.Application/Identity/Queries/GetUserById/GetUserByIdQuery.cs @@ -5,7 +5,7 @@ namespace CCE.Application.Identity.Queries.GetUserById; /// -/// Loads a single user by Id. Returns so the endpoint +/// Loads a single user by Id. Returns so the endpoint /// can map failure to a localized 404 automatically. /// -public sealed record GetUserByIdQuery(System.Guid Id) : IRequest>; +public sealed record GetUserByIdQuery(System.Guid Id) : IRequest>; diff --git a/backend/src/CCE.Application/Identity/Queries/GetUserById/GetUserByIdQueryHandler.cs b/backend/src/CCE.Application/Identity/Queries/GetUserById/GetUserByIdQueryHandler.cs index d5ef567d..8435576d 100644 --- a/backend/src/CCE.Application/Identity/Queries/GetUserById/GetUserByIdQueryHandler.cs +++ b/backend/src/CCE.Application/Identity/Queries/GetUserById/GetUserByIdQueryHandler.cs @@ -2,28 +2,29 @@ using CCE.Application.Common.Interfaces; using CCE.Application.Common.Pagination; using CCE.Application.Identity.Dtos; +using CCE.Application.Messages; using MediatR; namespace CCE.Application.Identity.Queries.GetUserById; -public sealed class GetUserByIdQueryHandler : IRequestHandler> +public sealed class GetUserByIdQueryHandler : IRequestHandler> { private readonly ICceDbContext _db; - private readonly CCE.Application.Common.Errors _errors; + private readonly MessageFactory _msg; - public GetUserByIdQueryHandler(ICceDbContext db, CCE.Application.Common.Errors errors) + public GetUserByIdQueryHandler(ICceDbContext db, MessageFactory msg) { _db = db; - _errors = errors; + _msg = msg; } - public async Task> Handle(GetUserByIdQuery request, CancellationToken cancellationToken) + public async Task> Handle(GetUserByIdQuery request, CancellationToken cancellationToken) { var user = (await _db.Users.Where(u => u.Id == request.Id).ToListAsyncEither(cancellationToken).ConfigureAwait(false)) .SingleOrDefault(); if (user is null) { - return _errors.UserNotFound(); + return _msg.UserNotFound(); } var roleNames = @@ -36,7 +37,7 @@ join r in _db.Roles on ur.RoleId equals r.Id var now = DateTimeOffset.UtcNow; var isActive = !user.LockoutEnabled || user.LockoutEnd is null || user.LockoutEnd < now; - return new UserDetailDto( + return _msg.Ok(new UserDetailDto( user.Id, user.Email, user.UserName, @@ -46,6 +47,6 @@ join r in _db.Roles on ur.RoleId equals r.Id user.CountryId, user.AvatarUrl, roles, - isActive); + isActive), "SUCCESS_OPERATION"); } } diff --git a/backend/src/CCE.Application/Messages/MessageFactory.cs b/backend/src/CCE.Application/Messages/MessageFactory.cs new file mode 100644 index 00000000..1027d34f --- /dev/null +++ b/backend/src/CCE.Application/Messages/MessageFactory.cs @@ -0,0 +1,96 @@ +using CCE.Application.Common; +using CCE.Application.Localization; +using CCE.Domain.Common; + +namespace CCE.Application.Messages; + +/// +/// Factory for building instances with localized messages. +/// Takes domain keys (e.g. "USER_NOT_FOUND"), resolves bilingual message from Resources.yaml, +/// and maps to system codes (e.g. "ERR001") via . +/// +public sealed class MessageFactory +{ + private readonly ILocalizationService _l; + + public MessageFactory(ILocalizationService l) => _l = l; + + // ─── Success builders (domain key → CON0xx) ─── + + public Response Ok(T data, string domainKey) + { + var code = SystemCodeMap.ToSystemCode(domainKey); + var msg = Localize(domainKey); + return Response.Ok(data, code, msg); + } + + public Response Ok(string domainKey) + { + var code = SystemCodeMap.ToSystemCode(domainKey); + var msg = Localize(domainKey); + return Response.Ok(code, msg); + } + + // ─── Failure builders (domain key → ERR0xx) ─── + + public Response NotFound(string domainKey) + => Fail(domainKey, MessageType.NotFound); + + public Response Conflict(string domainKey) + => Fail(domainKey, MessageType.Conflict); + + public Response Unauthorized(string domainKey) + => Fail(domainKey, MessageType.Unauthorized); + + public Response Forbidden(string domainKey) + => Fail(domainKey, MessageType.Forbidden); + + public Response BusinessRule(string domainKey) + => Fail(domainKey, MessageType.BusinessRule); + + public Response ValidationError( + string domainKey, IReadOnlyList fieldErrors) + { + var code = SystemCodeMap.ToSystemCode(domainKey); + var msg = Localize(domainKey); + return Response.Fail(code, msg, MessageType.Validation, fieldErrors); + } + + // ─── Build FieldError with localization (domain key → VAL0xx) ─── + + public FieldError Field(string fieldName, string domainKey) + { + var code = SystemCodeMap.ToSystemCode(domainKey); + var msg = Localize(domainKey); + return new FieldError(fieldName, code, msg); + } + + // ─── Convenience shortcuts (Identity domain) ─── + + public Response UserNotFound() => NotFound("USER_NOT_FOUND"); + public Response EmailExists() => Conflict("EMAIL_EXISTS"); + public Response InvalidCredentials() => Unauthorized("INVALID_CREDENTIALS"); + public Response NotAuthenticated() => Unauthorized("NOT_AUTHENTICATED"); + + // ─── Convenience shortcuts (Content domain) ─── + + public Response NewsNotFound() => NotFound("NEWS_NOT_FOUND"); + public Response EventNotFound() => NotFound("EVENT_NOT_FOUND"); + public Response PageNotFound() => NotFound("PAGE_NOT_FOUND"); + public Response CategoryNotFound() => NotFound("CATEGORY_NOT_FOUND"); + + // ─── Private ─── + + private Response Fail(string domainKey, MessageType type) + { + var code = SystemCodeMap.ToSystemCode(domainKey); + var msg = Localize(domainKey); + return Response.Fail(code, msg, type); + } + + private LocalizedMessage Localize(string domainKey) + { + var raw = _l.GetLocalizedMessage(domainKey); + return new LocalizedMessage(raw.Ar, raw.En); + } +} diff --git a/backend/src/CCE.Application/Messages/SystemCode.cs b/backend/src/CCE.Application/Messages/SystemCode.cs new file mode 100644 index 00000000..12454092 --- /dev/null +++ b/backend/src/CCE.Application/Messages/SystemCode.cs @@ -0,0 +1,159 @@ +namespace CCE.Application.Messages; + +/// +/// Canonical system message codes. Each constant is the code sent in the API response +/// AND the lookup key in Resources.yaml. Codes are unique — no two messages share a code. +/// +/// Prefixes: +/// ERR = Error (failure responses) +/// CON = Confirmation (success responses) +/// VAL = Validation (field-level errors in errors[] array) +/// +public static class SystemCode +{ + // ════════════════════════════════════════════════════════════════ + // ERR — Error codes (failures) + // ════════════════════════════════════════════════════════════════ + + // ─── Identity Errors ─── + public const string ERR001 = "ERR001"; // User not found + public const string ERR002 = "ERR002"; // Expert request not found + public const string ERR003 = "ERR003"; // State rep assignment not found + + public const string ERR019 = "ERR019"; // Email already exists + public const string ERR020 = "ERR020"; // Invalid credentials + public const string ERR021 = "ERR021"; // Invalid / expired token + public const string ERR022 = "ERR022"; // Invalid refresh token + public const string ERR023 = "ERR023"; // Password recovery failed + public const string ERR024 = "ERR024"; // Logout failed + public const string ERR025 = "ERR025"; // Account deactivated + public const string ERR026 = "ERR026"; // Username already exists + public const string ERR027 = "ERR027"; // Registration failed + public const string ERR028 = "ERR028"; // Not authenticated + public const string ERR029 = "ERR029"; // Expert request already exists + public const string ERR030 = "ERR030"; // State rep assignment already exists + + // ─── Content Errors ─── + public const string ERR040 = "ERR040"; // News not found + public const string ERR041 = "ERR041"; // Event not found + public const string ERR042 = "ERR042"; // Resource not found + public const string ERR043 = "ERR043"; // Page not found + public const string ERR044 = "ERR044"; // Category not found + public const string ERR045 = "ERR045"; // Asset not found + public const string ERR046 = "ERR046"; // Homepage section not found + public const string ERR047 = "ERR047"; // Country resource request not found + public const string ERR048 = "ERR048"; // Resource duplicate (slug/title) + public const string ERR049 = "ERR049"; // Category duplicate + public const string ERR050 = "ERR050"; // Page duplicate + public const string ERR051 = "ERR051"; // News duplicate + public const string ERR052 = "ERR052"; // Event duplicate + + // ─── Community Errors ─── + public const string ERR060 = "ERR060"; // Topic not found + public const string ERR061 = "ERR061"; // Post not found + public const string ERR062 = "ERR062"; // Reply not found + public const string ERR063 = "ERR063"; // Rating not found + public const string ERR064 = "ERR064"; // Topic duplicate + public const string ERR065 = "ERR065"; // Already following + public const string ERR066 = "ERR066"; // Not following + public const string ERR067 = "ERR067"; // Cannot mark answered + public const string ERR068 = "ERR068"; // Edit window expired + + // ─── Country Errors ─── + public const string ERR070 = "ERR070"; // Country not found + public const string ERR071 = "ERR071"; // Country profile not found + + // ─── Notification Errors ─── + public const string ERR080 = "ERR080"; // Template not found + public const string ERR081 = "ERR081"; // Template duplicate + public const string ERR082 = "ERR082"; // Notification not found + + // ─── KnowledgeMap Errors ─── + public const string ERR090 = "ERR090"; // Map not found + public const string ERR091 = "ERR091"; // Node not found + public const string ERR092 = "ERR092"; // Edge not found + + // ─── InteractiveCity Errors ─── + public const string ERR100 = "ERR100"; // Scenario not found + public const string ERR101 = "ERR101"; // Technology not found + + // ─── General Errors ─── + public const string ERR900 = "ERR900"; // Internal server error + public const string ERR901 = "ERR901"; // Unauthorized access + public const string ERR902 = "ERR902"; // Forbidden access + public const string ERR903 = "ERR903"; // Resource not found (generic) + public const string ERR904 = "ERR904"; // Bad request (generic) + public const string ERR905 = "ERR905"; // External API error + public const string ERR906 = "ERR906"; // External API not configured + public const string ERR907 = "ERR907"; // Concurrency conflict + public const string ERR908 = "ERR908"; // Duplicate value (generic) + + // ════════════════════════════════════════════════════════════════ + // CON — Confirmation / Success codes + // ════════════════════════════════════════════════════════════════ + + // ─── Identity Success ─── + public const string CON001 = "CON001"; // Login success + public const string CON002 = "CON002"; // Register success + public const string CON003 = "CON003"; // Logout success + public const string CON004 = "CON004"; // Token refreshed + public const string CON005 = "CON005"; // User updated + public const string CON006 = "CON006"; // User created + public const string CON007 = "CON007"; // User deleted + public const string CON008 = "CON008"; // User activated + public const string CON009 = "CON009"; // User deactivated + public const string CON010 = "CON010"; // Roles assigned + public const string CON011 = "CON011"; // Password reset success + public const string CON012 = "CON012"; // Expert request submitted + public const string CON013 = "CON013"; // Expert request approved + public const string CON014 = "CON014"; // Expert request rejected + public const string CON015 = "CON015"; // State rep assignment created + public const string CON016 = "CON016"; // State rep assignment revoked + public const string CON017 = "CON017"; // Profile updated + + // ─── Content Success ─── + public const string CON020 = "CON020"; // Content created + public const string CON021 = "CON021"; // Content updated + public const string CON022 = "CON022"; // Content deleted + public const string CON023 = "CON023"; // Content published + public const string CON024 = "CON024"; // Content archived + public const string CON025 = "CON025"; // Resource created + public const string CON026 = "CON026"; // Resource updated + public const string CON027 = "CON027"; // Resource deleted + public const string CON028 = "CON028"; // Resource published + + // ─── Community Success ─── + public const string CON030 = "CON030"; // Topic created + public const string CON031 = "CON031"; // Post created + public const string CON032 = "CON032"; // Reply created + public const string CON033 = "CON033"; // Followed successfully + public const string CON034 = "CON034"; // Unfollowed successfully + public const string CON035 = "CON035"; // Marked as answered + + // ─── Notification Success ─── + public const string CON040 = "CON040"; // Notification created + public const string CON041 = "CON041"; // Notification marked read + public const string CON042 = "CON042"; // Notification deleted + + // ─── General Success ─── + public const string CON900 = "CON900"; // Operation completed successfully + public const string CON901 = "CON901"; // Created successfully (generic) + public const string CON902 = "CON902"; // Updated successfully (generic) + public const string CON903 = "CON903"; // Deleted successfully (generic) + + // ════════════════════════════════════════════════════════════════ + // VAL — Validation codes (used in errors[] array items) + // ════════════════════════════════════════════════════════════════ + + public const string VAL001 = "VAL001"; // Validation error (header-level) + public const string VAL002 = "VAL002"; // Required field + public const string VAL003 = "VAL003"; // Invalid email + public const string VAL004 = "VAL004"; // Invalid phone + public const string VAL005 = "VAL005"; // Min length violated + public const string VAL006 = "VAL006"; // Max length violated + public const string VAL007 = "VAL007"; // Invalid format + public const string VAL008 = "VAL008"; // Invalid enum value + public const string VAL009 = "VAL009"; // Password uppercase required + public const string VAL010 = "VAL010"; // Password lowercase required + public const string VAL011 = "VAL011"; // Password number required +} diff --git a/backend/src/CCE.Application/Messages/SystemCodeMap.cs b/backend/src/CCE.Application/Messages/SystemCodeMap.cs new file mode 100644 index 00000000..2a869e2f --- /dev/null +++ b/backend/src/CCE.Application/Messages/SystemCodeMap.cs @@ -0,0 +1,151 @@ +namespace CCE.Application.Messages; + +/// +/// Maps domain keys (used internally and in Resources.yaml) to system codes (sent to clients). +/// Every domain key maps to a UNIQUE system code. +/// +public static class SystemCodeMap +{ + private static readonly Dictionary DomainToCode = new(StringComparer.OrdinalIgnoreCase) + { + // ─── Identity Errors ─── + ["USER_NOT_FOUND"] = SystemCode.ERR001, + ["EXPERT_REQUEST_NOT_FOUND"] = SystemCode.ERR002, + ["STATE_REP_ASSIGNMENT_NOT_FOUND"] = SystemCode.ERR003, + ["EMAIL_EXISTS"] = SystemCode.ERR019, + ["INVALID_CREDENTIALS"] = SystemCode.ERR020, + ["INVALID_TOKEN"] = SystemCode.ERR021, + ["INVALID_REFRESH_TOKEN"] = SystemCode.ERR022, + ["PASSWORD_RECOVERY_FAILED"] = SystemCode.ERR023, + ["LOGOUT_FAILED"] = SystemCode.ERR024, + ["ACCOUNT_DEACTIVATED"] = SystemCode.ERR025, + ["USERNAME_EXISTS"] = SystemCode.ERR026, + ["REGISTRATION_FAILED"] = SystemCode.ERR027, + ["NOT_AUTHENTICATED"] = SystemCode.ERR028, + ["EXPERT_REQUEST_ALREADY_EXISTS"] = SystemCode.ERR029, + ["STATE_REP_ASSIGNMENT_EXISTS"] = SystemCode.ERR030, + + // ─── Content Errors ─── + ["NEWS_NOT_FOUND"] = SystemCode.ERR040, + ["EVENT_NOT_FOUND"] = SystemCode.ERR041, + ["RESOURCE_NOT_FOUND"] = SystemCode.ERR042, + ["PAGE_NOT_FOUND"] = SystemCode.ERR043, + ["CATEGORY_NOT_FOUND"] = SystemCode.ERR044, + ["ASSET_NOT_FOUND"] = SystemCode.ERR045, + ["HOMEPAGE_SECTION_NOT_FOUND"] = SystemCode.ERR046, + ["COUNTRY_RESOURCE_REQUEST_NOT_FOUND"] = SystemCode.ERR047, + ["RESOURCE_DUPLICATE"] = SystemCode.ERR048, + ["CATEGORY_DUPLICATE"] = SystemCode.ERR049, + ["PAGE_DUPLICATE"] = SystemCode.ERR050, + ["NEWS_DUPLICATE"] = SystemCode.ERR051, + ["EVENT_DUPLICATE"] = SystemCode.ERR052, + + // ─── Community Errors ─── + ["TOPIC_NOT_FOUND"] = SystemCode.ERR060, + ["POST_NOT_FOUND"] = SystemCode.ERR061, + ["REPLY_NOT_FOUND"] = SystemCode.ERR062, + ["RATING_NOT_FOUND"] = SystemCode.ERR063, + ["TOPIC_DUPLICATE"] = SystemCode.ERR064, + ["ALREADY_FOLLOWING"] = SystemCode.ERR065, + ["NOT_FOLLOWING"] = SystemCode.ERR066, + ["CANNOT_MARK_ANSWERED"] = SystemCode.ERR067, + ["EDIT_WINDOW_EXPIRED"] = SystemCode.ERR068, + + // ─── Country Errors ─── + ["COUNTRY_NOT_FOUND"] = SystemCode.ERR070, + ["COUNTRY_PROFILE_NOT_FOUND"] = SystemCode.ERR071, + + // ─── Notification Errors ─── + ["TEMPLATE_NOT_FOUND"] = SystemCode.ERR080, + ["TEMPLATE_DUPLICATE"] = SystemCode.ERR081, + ["NOTIFICATION_NOT_FOUND"] = SystemCode.ERR082, + + // ─── KnowledgeMap Errors ─── + ["MAP_NOT_FOUND"] = SystemCode.ERR090, + ["NODE_NOT_FOUND"] = SystemCode.ERR091, + ["EDGE_NOT_FOUND"] = SystemCode.ERR092, + + // ─── InteractiveCity Errors ─── + ["SCENARIO_NOT_FOUND"] = SystemCode.ERR100, + ["TECHNOLOGY_NOT_FOUND"] = SystemCode.ERR101, + + // ─── General Errors ─── + ["INTERNAL_ERROR"] = SystemCode.ERR900, + ["UNAUTHORIZED_ACCESS"] = SystemCode.ERR901, + ["FORBIDDEN_ACCESS"] = SystemCode.ERR902, + ["RESOURCE_NOT_FOUND_GENERIC"] = SystemCode.ERR903, + ["BAD_REQUEST"] = SystemCode.ERR904, + ["EXTERNAL_API_ERROR"] = SystemCode.ERR905, + ["EXTERNAL_API_NOT_CONFIGURED"] = SystemCode.ERR906, + ["CONCURRENCY_CONFLICT"] = SystemCode.ERR907, + ["DUPLICATE_VALUE"] = SystemCode.ERR908, + + // ─── Identity Success ─── + ["LOGIN_SUCCESS"] = SystemCode.CON001, + ["REGISTER_SUCCESS"] = SystemCode.CON002, + ["LOGOUT_SUCCESS"] = SystemCode.CON003, + ["TOKEN_REFRESHED"] = SystemCode.CON004, + ["USER_UPDATED"] = SystemCode.CON005, + ["USER_CREATED"] = SystemCode.CON006, + ["USER_DELETED"] = SystemCode.CON007, + ["USER_ACTIVATED"] = SystemCode.CON008, + ["USER_DEACTIVATED"] = SystemCode.CON009, + ["ROLES_ASSIGNED"] = SystemCode.CON010, + ["PASSWORD_RESET"] = SystemCode.CON011, + ["EXPERT_REQUEST_SUBMITTED"] = SystemCode.CON012, + ["EXPERT_REQUEST_APPROVED"] = SystemCode.CON013, + ["EXPERT_REQUEST_REJECTED"] = SystemCode.CON014, + ["STATE_REP_ASSIGNMENT_CREATED"] = SystemCode.CON015, + ["STATE_REP_ASSIGNMENT_REVOKED"] = SystemCode.CON016, + ["PROFILE_UPDATED"] = SystemCode.CON017, + + // ─── Content Success ─── + ["CONTENT_CREATED"] = SystemCode.CON020, + ["CONTENT_UPDATED"] = SystemCode.CON021, + ["CONTENT_DELETED"] = SystemCode.CON022, + ["CONTENT_PUBLISHED"] = SystemCode.CON023, + ["CONTENT_ARCHIVED"] = SystemCode.CON024, + ["RESOURCE_CREATED"] = SystemCode.CON025, + ["RESOURCE_UPDATED"] = SystemCode.CON026, + ["RESOURCE_DELETED"] = SystemCode.CON027, + ["RESOURCE_PUBLISHED"] = SystemCode.CON028, + + // ─── Notification Success ─── + ["NOTIFICATION_CREATED"] = SystemCode.CON040, + ["NOTIFICATION_MARKED_READ"] = SystemCode.CON041, + ["NOTIFICATION_DELETED"] = SystemCode.CON042, + + // ─── General Success ─── + ["SUCCESS_OPERATION"] = SystemCode.CON900, + ["SUCCESS_CREATED"] = SystemCode.CON901, + ["SUCCESS_UPDATED"] = SystemCode.CON902, + ["SUCCESS_DELETED"] = SystemCode.CON903, + + // ─── Validation ─── + ["VALIDATION_ERROR"] = SystemCode.VAL001, + ["REQUIRED_FIELD"] = SystemCode.VAL002, + ["INVALID_EMAIL"] = SystemCode.VAL003, + ["INVALID_PHONE"] = SystemCode.VAL004, + ["MIN_LENGTH"] = SystemCode.VAL005, + ["MAX_LENGTH"] = SystemCode.VAL006, + ["INVALID_FORMAT"] = SystemCode.VAL007, + ["INVALID_ENUM"] = SystemCode.VAL008, + ["PASSWORD_UPPERCASE"] = SystemCode.VAL009, + ["PASSWORD_LOWERCASE"] = SystemCode.VAL010, + ["PASSWORD_NUMBER"] = SystemCode.VAL011, + }; + + private static readonly Dictionary CodeToDomain = + DomainToCode.ToDictionary(kv => kv.Value, kv => kv.Key, StringComparer.OrdinalIgnoreCase); + + /// Get the ERR/CON/VAL code for a domain key. Returns ERR900 if unmapped. + public static string ToSystemCode(string domainKey) + => DomainToCode.TryGetValue(domainKey, out var code) ? code : SystemCode.ERR900; + + /// Get the domain key from a system code. Returns null if unmapped. + public static string? ToDomainKey(string systemCode) + => CodeToDomain.TryGetValue(systemCode, out var key) ? key : null; + + /// True when the domain key has an explicit mapping. + public static bool HasMapping(string domainKey) => DomainToCode.ContainsKey(domainKey); +} diff --git a/backend/src/CCE.Domain/Common/AggregateRoot.cs b/backend/src/CCE.Domain/Common/AggregateRoot.cs index 1af581e3..9beab452 100644 --- a/backend/src/CCE.Domain/Common/AggregateRoot.cs +++ b/backend/src/CCE.Domain/Common/AggregateRoot.cs @@ -3,10 +3,20 @@ namespace CCE.Domain.Common; /// /// Base class for DDD aggregate roots — entities that serve as the consistency boundary /// for a cluster of related entities and value objects. Repositories are per-aggregate. +/// Inherits so every aggregate root automatically +/// supports audit timestamps and soft delete. /// /// The aggregate root's ID type. -public abstract class AggregateRoot : Entity - where TId : notnull +public abstract class AggregateRoot : SoftDeletableEntity + where TId : IEquatable { + private readonly List _domainEvents = []; + protected AggregateRoot(TId id) : base(id) { } + + public IReadOnlyCollection DomainEvents => _domainEvents.AsReadOnly(); + + protected void RaiseDomainEvent(IDomainEvent domainEvent) => _domainEvents.Add(domainEvent); + + public void ClearDomainEvents() => _domainEvents.Clear(); } diff --git a/backend/src/CCE.Domain/Common/AuditableAggregateRoot.cs b/backend/src/CCE.Domain/Common/AuditableAggregateRoot.cs deleted file mode 100644 index 95c0a460..00000000 --- a/backend/src/CCE.Domain/Common/AuditableAggregateRoot.cs +++ /dev/null @@ -1,41 +0,0 @@ -namespace CCE.Domain.Common; - -/// -/// Base class for DDD aggregate roots that expose generic audit timestamps. -/// Concrete aggregates call and -/// from their own factory methods and mutators. -/// -/// The aggregate root's ID type. -public abstract class AuditableAggregateRoot : AggregateRoot, IAuditable - where TId : notnull -{ - protected AuditableAggregateRoot(TId id) : base(id) { } - - /// - public DateTimeOffset CreatedOn { get; protected set; } - - /// - public Guid CreatedById { get; protected set; } - - /// - public DateTimeOffset? LastModifiedOn { get; protected set; } - - /// - public Guid? LastModifiedById { get; protected set; } - - /// Records creation metadata. Call from factory methods. - protected void MarkAsCreated(Guid by, ISystemClock clock) - { - if (by == Guid.Empty) throw new DomainException("CreatedById is required."); - CreatedOn = clock.UtcNow; - CreatedById = by; - } - - /// Records modification metadata. Call from mutator methods. - protected void MarkAsModified(Guid by, ISystemClock clock) - { - if (by == Guid.Empty) throw new DomainException("ModifiedById is required."); - LastModifiedOn = clock.UtcNow; - LastModifiedById = by; - } -} diff --git a/backend/src/CCE.Domain/Common/AuditableEntity.cs b/backend/src/CCE.Domain/Common/AuditableEntity.cs index 4cc300c2..a1ab1f0c 100644 --- a/backend/src/CCE.Domain/Common/AuditableEntity.cs +++ b/backend/src/CCE.Domain/Common/AuditableEntity.cs @@ -7,7 +7,7 @@ namespace CCE.Domain.Common; /// /// The ID type. public abstract class AuditableEntity : Entity, IAuditable - where TId : notnull + where TId : IEquatable { protected AuditableEntity(TId id) : base(id) { } diff --git a/backend/src/CCE.Domain/Common/Entity.cs b/backend/src/CCE.Domain/Common/Entity.cs index 6f0d012e..da377b5b 100644 --- a/backend/src/CCE.Domain/Common/Entity.cs +++ b/backend/src/CCE.Domain/Common/Entity.cs @@ -6,20 +6,12 @@ namespace CCE.Domain.Common; /// /// The ID type (e.g., Guid, int, or a strongly-typed wrapper). public abstract class Entity - where TId : notnull + where TId : IEquatable { - private readonly List _domainEvents = []; - protected Entity(TId id) => Id = id; public TId Id { get; protected set; } - public IReadOnlyCollection DomainEvents => _domainEvents.AsReadOnly(); - - protected void RaiseDomainEvent(IDomainEvent domainEvent) => _domainEvents.Add(domainEvent); - - public void ClearDomainEvents() => _domainEvents.Clear(); - public override bool Equals(object? obj) { if (obj is not Entity other) return false; diff --git a/backend/src/CCE.Domain/Common/MessageType.cs b/backend/src/CCE.Domain/Common/MessageType.cs new file mode 100644 index 00000000..b7631353 --- /dev/null +++ b/backend/src/CCE.Domain/Common/MessageType.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace CCE.Domain.Common; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum MessageType +{ + Success, + Validation, + NotFound, + Conflict, + Unauthorized, + Forbidden, + BusinessRule, + Internal +} diff --git a/backend/src/CCE.Domain/Common/SoftDeletableAggregateRoot.cs b/backend/src/CCE.Domain/Common/SoftDeletableAggregateRoot.cs deleted file mode 100644 index 4c990071..00000000 --- a/backend/src/CCE.Domain/Common/SoftDeletableAggregateRoot.cs +++ /dev/null @@ -1,32 +0,0 @@ -namespace CCE.Domain.Common; - -/// -/// Base class for DDD aggregate roots that support soft delete and audit timestamps. -/// Inherits and absorbs -/// so concrete aggregates do not copy-paste the same soft-delete implementation. -/// -/// The aggregate root's ID type. -public abstract class SoftDeletableAggregateRoot : AuditableAggregateRoot, ISoftDeletable - where TId : notnull -{ - protected SoftDeletableAggregateRoot(TId id) : base(id) { } - - /// - public bool IsDeleted { get; protected set; } - - /// - public DateTimeOffset? DeletedOn { get; protected set; } - - /// - public Guid? DeletedById { get; protected set; } - - /// - public void SoftDelete(Guid by, ISystemClock clock) - { - if (by == Guid.Empty) throw new DomainException("DeletedById is required."); - if (IsDeleted) return; - IsDeleted = true; - DeletedById = by; - DeletedOn = clock.UtcNow; - } -} diff --git a/backend/src/CCE.Domain/Common/SoftDeletableEntity.cs b/backend/src/CCE.Domain/Common/SoftDeletableEntity.cs index bc4d4760..e2dda5ca 100644 --- a/backend/src/CCE.Domain/Common/SoftDeletableEntity.cs +++ b/backend/src/CCE.Domain/Common/SoftDeletableEntity.cs @@ -7,7 +7,7 @@ namespace CCE.Domain.Common; /// /// The ID type. public abstract class SoftDeletableEntity : AuditableEntity, ISoftDeletable - where TId : notnull + where TId : IEquatable { protected SoftDeletableEntity(TId id) : base(id) { } @@ -28,5 +28,18 @@ public void SoftDelete(Guid by, ISystemClock clock) IsDeleted = true; DeletedById = by; DeletedOn = clock.UtcNow; + MarkAsModified(by, clock); + } + + /// + /// Restores a soft-deleted entity. Clears delete fields and records the restoration as a modification. + /// + public void Restore(Guid by, ISystemClock clock) + { + if (!IsDeleted) return; + IsDeleted = false; + DeletedById = null; + DeletedOn = null; + MarkAsModified(by, clock); } } diff --git a/backend/src/CCE.Domain/Community/Post.cs b/backend/src/CCE.Domain/Community/Post.cs index 0b1e05de..33d153b1 100644 --- a/backend/src/CCE.Domain/Community/Post.cs +++ b/backend/src/CCE.Domain/Community/Post.cs @@ -10,7 +10,7 @@ namespace CCE.Domain.Community; /// Content max 8000 chars to keep the read-side cheap. /// [Audited] -public sealed class Post : SoftDeletableAggregateRoot +public sealed class Post : AggregateRoot { public const int MaxContentLength = 8000; diff --git a/backend/src/CCE.Domain/Community/Topic.cs b/backend/src/CCE.Domain/Community/Topic.cs index 142607be..44b2d971 100644 --- a/backend/src/CCE.Domain/Community/Topic.cs +++ b/backend/src/CCE.Domain/Community/Topic.cs @@ -4,7 +4,7 @@ namespace CCE.Domain.Community; [Audited] -public sealed class Topic : SoftDeletableEntity +public sealed class Topic : AggregateRoot { private static readonly Regex SlugPattern = new("^[a-z0-9]+(-[a-z0-9]+)*$", RegexOptions.Compiled); diff --git a/backend/src/CCE.Domain/Content/Event.cs b/backend/src/CCE.Domain/Content/Event.cs index c7fe4e5d..26fe909d 100644 --- a/backend/src/CCE.Domain/Content/Event.cs +++ b/backend/src/CCE.Domain/Content/Event.cs @@ -9,7 +9,7 @@ namespace CCE.Domain.Content; /// stable lets external calendar clients (.ics consumers) deduplicate updates by UID. /// [Audited] -public sealed class Event : SoftDeletableAggregateRoot +public sealed class Event : AggregateRoot { private Event( System.Guid id, diff --git a/backend/src/CCE.Domain/Content/HomepageSection.cs b/backend/src/CCE.Domain/Content/HomepageSection.cs index cd567b4b..d86f4c2a 100644 --- a/backend/src/CCE.Domain/Content/HomepageSection.cs +++ b/backend/src/CCE.Domain/Content/HomepageSection.cs @@ -7,7 +7,7 @@ namespace CCE.Domain.Content; /// rendering layer queries WHERE IsActive = true ORDER BY OrderIndex. /// [Audited] -public sealed class HomepageSection : SoftDeletableEntity +public sealed class HomepageSection : AggregateRoot { private HomepageSection( System.Guid id, diff --git a/backend/src/CCE.Domain/Content/News.cs b/backend/src/CCE.Domain/Content/News.cs index a6c1c066..a9154af5 100644 --- a/backend/src/CCE.Domain/Content/News.cs +++ b/backend/src/CCE.Domain/Content/News.cs @@ -9,7 +9,7 @@ namespace CCE.Domain.Content; /// Slug is unique (enforced in Phase 08 DB unique index). Soft-deletable, audited. /// [Audited] -public sealed class News : SoftDeletableAggregateRoot +public sealed class News : AggregateRoot { private static readonly Regex SlugPattern = new("^[a-z0-9]+(-[a-z0-9]+)*$", RegexOptions.Compiled); diff --git a/backend/src/CCE.Domain/Content/NewsletterSubscription.cs b/backend/src/CCE.Domain/Content/NewsletterSubscription.cs index c05503de..3eb042d8 100644 --- a/backend/src/CCE.Domain/Content/NewsletterSubscription.cs +++ b/backend/src/CCE.Domain/Content/NewsletterSubscription.cs @@ -10,7 +10,7 @@ namespace CCE.Domain.Content; /// active. Unsubscribing keeps the row but stamps . /// [Audited] -public sealed class NewsletterSubscription : Entity +public sealed class NewsletterSubscription : AggregateRoot { private static readonly Regex EmailPattern = new(@"^[^\s@]+@[^\s@]+\.[^\s@]+$", RegexOptions.Compiled); diff --git a/backend/src/CCE.Domain/Content/Page.cs b/backend/src/CCE.Domain/Content/Page.cs index abddea57..3affec1a 100644 --- a/backend/src/CCE.Domain/Content/Page.cs +++ b/backend/src/CCE.Domain/Content/Page.cs @@ -8,7 +8,7 @@ namespace CCE.Domain.Content; /// composite unique index. Content is rich-text bilingual. /// [Audited] -public sealed class Page : SoftDeletableAggregateRoot +public sealed class Page : AggregateRoot { private static readonly Regex SlugPattern = new("^[a-z0-9]+(-[a-z0-9]+)*$", RegexOptions.Compiled); diff --git a/backend/src/CCE.Domain/Content/Resource.cs b/backend/src/CCE.Domain/Content/Resource.cs index f1f82696..f07bf9bf 100644 --- a/backend/src/CCE.Domain/Content/Resource.cs +++ b/backend/src/CCE.Domain/Content/Resource.cs @@ -11,7 +11,7 @@ namespace CCE.Domain.Content; /// [Timestamp] mapping in Phase 07. /// [Audited] -public sealed class Resource : SoftDeletableAggregateRoot +public sealed class Resource : AggregateRoot { private Resource( System.Guid id, diff --git a/backend/src/CCE.Domain/Country/Country.cs b/backend/src/CCE.Domain/Country/Country.cs index d687d57f..1b1818c1 100644 --- a/backend/src/CCE.Domain/Country/Country.cs +++ b/backend/src/CCE.Domain/Country/Country.cs @@ -9,7 +9,7 @@ namespace CCE.Domain.Country; /// hides a country from public dropdowns without deleting historical references. /// [Audited] -public sealed class Country : SoftDeletableAggregateRoot +public sealed class Country : AggregateRoot { private static readonly Regex Alpha3Pattern = new("^[A-Z]{3}$", RegexOptions.Compiled); private static readonly Regex Alpha2Pattern = new("^[A-Z]{2}$", RegexOptions.Compiled); diff --git a/backend/src/CCE.Domain/Country/CountryResourceRequest.cs b/backend/src/CCE.Domain/Country/CountryResourceRequest.cs index fcdd2fe2..9e88d82f 100644 --- a/backend/src/CCE.Domain/Country/CountryResourceRequest.cs +++ b/backend/src/CCE.Domain/Country/CountryResourceRequest.cs @@ -11,7 +11,7 @@ namespace CCE.Domain.Country; /// creates the actual Resource. /// [Audited] -public sealed class CountryResourceRequest : SoftDeletableAggregateRoot +public sealed class CountryResourceRequest : AggregateRoot { private CountryResourceRequest( System.Guid id, diff --git a/backend/src/CCE.Domain/Identity/ExpertProfile.cs b/backend/src/CCE.Domain/Identity/ExpertProfile.cs index 73c3140f..8a4af95c 100644 --- a/backend/src/CCE.Domain/Identity/ExpertProfile.cs +++ b/backend/src/CCE.Domain/Identity/ExpertProfile.cs @@ -9,7 +9,7 @@ namespace CCE.Domain.Identity; /// captured by and enforced by a unique index in Phase 08. /// [Audited] -public sealed class ExpertProfile : SoftDeletableEntity +public sealed class ExpertProfile : AggregateRoot { private ExpertProfile( System.Guid id, diff --git a/backend/src/CCE.Domain/Identity/ExpertRegistrationRequest.cs b/backend/src/CCE.Domain/Identity/ExpertRegistrationRequest.cs index b7ff8a57..0aed6603 100644 --- a/backend/src/CCE.Domain/Identity/ExpertRegistrationRequest.cs +++ b/backend/src/CCE.Domain/Identity/ExpertRegistrationRequest.cs @@ -10,7 +10,7 @@ namespace CCE.Domain.Identity; /// the corresponding ExpertProfile. Soft-deletable for admin recovery flows. /// [Audited] -public sealed class ExpertRegistrationRequest : SoftDeletableAggregateRoot +public sealed class ExpertRegistrationRequest : AggregateRoot { private ExpertRegistrationRequest( System.Guid id, diff --git a/backend/src/CCE.Domain/Identity/StateRepresentativeAssignment.cs b/backend/src/CCE.Domain/Identity/StateRepresentativeAssignment.cs index 11d1f6d3..5fbd6338 100644 --- a/backend/src/CCE.Domain/Identity/StateRepresentativeAssignment.cs +++ b/backend/src/CCE.Domain/Identity/StateRepresentativeAssignment.cs @@ -8,7 +8,7 @@ namespace CCE.Domain.Identity; /// AND marks the row deleted (so the unique-active-assignment filtered index ignores it). /// [Audited] -public sealed class StateRepresentativeAssignment : SoftDeletableEntity +public sealed class StateRepresentativeAssignment : AggregateRoot { private StateRepresentativeAssignment( System.Guid id, diff --git a/backend/src/CCE.Domain/InteractiveCity/CityScenario.cs b/backend/src/CCE.Domain/InteractiveCity/CityScenario.cs index 3576d9f9..4bed1c5c 100644 --- a/backend/src/CCE.Domain/InteractiveCity/CityScenario.cs +++ b/backend/src/CCE.Domain/InteractiveCity/CityScenario.cs @@ -3,7 +3,7 @@ namespace CCE.Domain.InteractiveCity; [Audited] -public sealed class CityScenario : SoftDeletableAggregateRoot +public sealed class CityScenario : AggregateRoot { public const int MinTargetYear = 2030; public const int MaxTargetYear = 2080; diff --git a/backend/src/CCE.Domain/KnowledgeMaps/KnowledgeMap.cs b/backend/src/CCE.Domain/KnowledgeMaps/KnowledgeMap.cs index f8f95170..1a1983f3 100644 --- a/backend/src/CCE.Domain/KnowledgeMaps/KnowledgeMap.cs +++ b/backend/src/CCE.Domain/KnowledgeMaps/KnowledgeMap.cs @@ -4,7 +4,7 @@ namespace CCE.Domain.KnowledgeMaps; [Audited] -public sealed class KnowledgeMap : SoftDeletableAggregateRoot +public sealed class KnowledgeMap : AggregateRoot { private static readonly Regex SlugPattern = new("^[a-z0-9]+(-[a-z0-9]+)*$", RegexOptions.Compiled); diff --git a/backend/src/CCE.Infrastructure/DependencyInjection.cs b/backend/src/CCE.Infrastructure/DependencyInjection.cs index 76f39e76..145f86c7 100644 --- a/backend/src/CCE.Infrastructure/DependencyInjection.cs +++ b/backend/src/CCE.Infrastructure/DependencyInjection.cs @@ -116,6 +116,7 @@ public static IServiceCollection AddInfrastructure( services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // Sub-11 Phase 01 — Microsoft Graph user-create + CCE-side persist. // Factory is singleton (ClientSecretCredential is thread-safe and reusable); diff --git a/backend/src/CCE.Infrastructure/Identity/AuthService.cs b/backend/src/CCE.Infrastructure/Identity/AuthService.cs new file mode 100644 index 00000000..f71c4d65 --- /dev/null +++ b/backend/src/CCE.Infrastructure/Identity/AuthService.cs @@ -0,0 +1,178 @@ +using CCE.Application.Common.Interfaces; +using CCE.Application.Identity.Auth.Common; +using CCE.Domain.Common; +using CCE.Domain.Identity; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Options; + +namespace CCE.Infrastructure.Identity; + +public sealed class AuthService : IAuthService +{ + private const string DefaultRole = "cce-user"; + private readonly UserManager _userManager; + private readonly RoleManager _roleManager; + private readonly ILocalTokenService _tokenService; + private readonly IRefreshTokenRepository _refreshTokens; + private readonly ICceDbContext _db; + private readonly ISystemClock _clock; + private readonly IOptions _options; + private readonly IPasswordResetEmailSender _emailSender; + + public AuthService( + UserManager userManager, + RoleManager roleManager, + ILocalTokenService tokenService, + IRefreshTokenRepository refreshTokens, + ICceDbContext db, + ISystemClock clock, + IOptions options, + IPasswordResetEmailSender emailSender) + { + _userManager = userManager; + _roleManager = roleManager; + _tokenService = tokenService; + _refreshTokens = refreshTokens; + _db = db; + _clock = clock; + _options = options; + _emailSender = emailSender; + } + + public async Task LoginAsync(string email, string password, LocalAuthApi api, string? ip, string? userAgent, CancellationToken ct) + { + var user = await _userManager.FindByEmailAsync(email).ConfigureAwait(false); + if (user is null) return null; + + if (_options.Value.RequireConfirmedEmail && !await _userManager.IsEmailConfirmedAsync(user).ConfigureAwait(false)) + return null; + + if (!await _userManager.CheckPasswordAsync(user, password).ConfigureAwait(false)) + return null; + + return await IssueAndBuildDtoAsync(user, api, ip, userAgent, null, ct).ConfigureAwait(false); + } + + public async Task RefreshTokenAsync(string rawRefreshToken, LocalAuthApi api, string? ip, string? userAgent, CancellationToken ct) + { + var tokenHash = _tokenService.HashRefreshToken(rawRefreshToken); + var existing = await _refreshTokens.FindByHashAsync(tokenHash, ct).ConfigureAwait(false); + if (existing is null) return null; + + if (!existing.IsActive(_clock.UtcNow)) + { + if (existing.RevokedAtUtc is not null) + { + await _refreshTokens.RevokeFamilyAsync(existing.TokenFamilyId, _clock.UtcNow, ip, ct).ConfigureAwait(false); + await _db.SaveChangesAsync(ct).ConfigureAwait(false); + } + return null; + } + + var user = await _userManager.FindByIdAsync(existing.UserId.ToString()).ConfigureAwait(false); + if (user is null) return null; + + var issued = await _tokenService.IssueAsync(user, api, ct).ConfigureAwait(false); + existing.Revoke(_clock.UtcNow, ip, issued.RefreshTokenHash); + + var replacement = global::CCE.Domain.Identity.RefreshToken.Create( + user.Id, issued.RefreshTokenHash, existing.TokenFamilyId, + _clock.UtcNow, issued.RefreshTokenExpiresAtUtc, ip, userAgent); + await _refreshTokens.AddAsync(replacement, ct).ConfigureAwait(false); + await _db.SaveChangesAsync(ct).ConfigureAwait(false); + + return await BuildDtoAsync(user, issued).ConfigureAwait(false); + } + + public async Task LogoutAsync(string rawRefreshToken, string? ip, CancellationToken ct) + { + var tokenHash = _tokenService.HashRefreshToken(rawRefreshToken); + var existing = await _refreshTokens.FindByHashAsync(tokenHash, ct).ConfigureAwait(false); + if (existing is not null && existing.IsActive(_clock.UtcNow)) + { + existing.Revoke(_clock.UtcNow, ip); + await _db.SaveChangesAsync(ct).ConfigureAwait(false); + } + } + + public async Task RegisterAsync(string firstName, string lastName, string email, string password, string? jobTitle, string? orgName, string? phone, CancellationToken ct) + { + var existing = await _userManager.FindByEmailAsync(email).ConfigureAwait(false); + if (existing is not null) return new RegisterResult(null, true); + + var user = User.RegisterLocal(firstName, lastName, email, jobTitle ?? "", orgName ?? "", phone ?? ""); + + var createResult = await _userManager.CreateAsync(user, password).ConfigureAwait(false); + if (!createResult.Succeeded) return new RegisterResult(null, false); + + if (!await _roleManager.RoleExistsAsync(DefaultRole).ConfigureAwait(false)) + { + var roleResult = await _roleManager.CreateAsync(new Role(DefaultRole)).ConfigureAwait(false); + if (!roleResult.Succeeded) return new RegisterResult(null, false); + } + + var addRoleResult = await _userManager.AddToRoleAsync(user, DefaultRole).ConfigureAwait(false); + if (!addRoleResult.Succeeded) return new RegisterResult(null, false); + + return new RegisterResult(user, false); + } + + public async Task ForgotPasswordAsync(string email, CancellationToken ct) + { + var user = await _userManager.FindByEmailAsync(email).ConfigureAwait(false); + if (user is not null) + { + var token = await _userManager.GeneratePasswordResetTokenAsync(user).ConfigureAwait(false); + await _emailSender.SendAsync(user, PasswordResetTokenCodec.Encode(token), ct).ConfigureAwait(false); + } + } + + public async Task ResetPasswordAsync(string email, string encodedToken, string newPassword, string? ip, CancellationToken ct) + { + var user = await _userManager.FindByEmailAsync(email).ConfigureAwait(false); + if (user is null) return "USER_NOT_FOUND"; + + string token; + try + { + token = PasswordResetTokenCodec.Decode(encodedToken); + } + catch (FormatException) + { + return "INVALID_RESET_TOKEN"; + } + + var result = await _userManager.ResetPasswordAsync(user, token, newPassword).ConfigureAwait(false); + if (!result.Succeeded) return "RESET_FAILED"; + + await _userManager.UpdateSecurityStampAsync(user).ConfigureAwait(false); + await _refreshTokens.RevokeAllForUserAsync(user.Id, _clock.UtcNow, ip, ct).ConfigureAwait(false); + await _db.SaveChangesAsync(ct).ConfigureAwait(false); + + return null; + } + + private async Task IssueAndBuildDtoAsync(User user, LocalAuthApi api, string? ip, string? userAgent, Guid? tokenFamilyId, CancellationToken ct) + { + var issued = await _tokenService.IssueAsync(user, api, ct).ConfigureAwait(false); + var familyId = tokenFamilyId ?? Guid.NewGuid(); + var refreshToken = global::CCE.Domain.Identity.RefreshToken.Create( + user.Id, issued.RefreshTokenHash, familyId, + _clock.UtcNow, issued.RefreshTokenExpiresAtUtc, ip, userAgent); + await _refreshTokens.AddAsync(refreshToken, ct).ConfigureAwait(false); + await _db.SaveChangesAsync(ct).ConfigureAwait(false); + return await BuildDtoAsync(user, issued).ConfigureAwait(false); + } + + private async Task BuildDtoAsync(User user, TokenIssueResult issued) + { + var roles = await _userManager.GetRolesAsync(user).ConfigureAwait(false); + return new AuthTokenDto( + issued.AccessToken, + issued.AccessTokenExpiresAtUtc, + issued.RefreshToken, + issued.RefreshTokenExpiresAtUtc, + "Bearer", + new AuthUserDto(user.Id, user.Email ?? string.Empty, user.FirstName, user.LastName, roles.ToArray())); + } +} diff --git a/backend/src/CCE.Infrastructure/Identity/ExpertRequestSubmissionRepository.cs b/backend/src/CCE.Infrastructure/Identity/ExpertRequestSubmissionRepository.cs index 1847940a..2b08c95a 100644 --- a/backend/src/CCE.Infrastructure/Identity/ExpertRequestSubmissionRepository.cs +++ b/backend/src/CCE.Infrastructure/Identity/ExpertRequestSubmissionRepository.cs @@ -4,18 +4,8 @@ namespace CCE.Infrastructure.Identity; -public sealed class ExpertRequestSubmissionRepository : IExpertRequestSubmissionRepository +public sealed class ExpertRequestSubmissionRepository + : Repository, IExpertRequestSubmissionRepository { - private readonly CceDbContext _db; - - public ExpertRequestSubmissionRepository(CceDbContext db) - { - _db = db; - } - - public async Task SaveAsync(ExpertRegistrationRequest request, CancellationToken ct) - { - _db.ExpertRegistrationRequests.Add(request); - await _db.SaveChangesAsync(ct).ConfigureAwait(false); - } + public ExpertRequestSubmissionRepository(CceDbContext db) : base(db) { } } diff --git a/backend/src/CCE.Infrastructure/Identity/ExpertWorkflowRepository.cs b/backend/src/CCE.Infrastructure/Identity/ExpertWorkflowRepository.cs index 113bdb91..8c29b5f9 100644 --- a/backend/src/CCE.Infrastructure/Identity/ExpertWorkflowRepository.cs +++ b/backend/src/CCE.Infrastructure/Identity/ExpertWorkflowRepository.cs @@ -5,29 +5,21 @@ namespace CCE.Infrastructure.Identity; -public sealed class ExpertWorkflowRepository : IExpertWorkflowRepository +public sealed class ExpertWorkflowRepository + : Repository, IExpertWorkflowRepository { - private readonly CceDbContext _db; - - public ExpertWorkflowRepository(CceDbContext db) - { - _db = db; - } + public ExpertWorkflowRepository(CceDbContext db) : base(db) { } public async Task FindIncludingDeletedAsync(System.Guid id, CancellationToken ct) { - return await _db.ExpertRegistrationRequests + return await Db.ExpertRegistrationRequests .IgnoreQueryFilters() .FirstOrDefaultAsync(r => r.Id == id, ct) .ConfigureAwait(false); } - public async Task SaveAsync(ExpertRegistrationRequest request, ExpertProfile? newProfile, CancellationToken ct) + public void AddProfile(ExpertProfile profile) { - if (newProfile is not null) - { - _db.ExpertProfiles.Add(newProfile); - } - await _db.SaveChangesAsync(ct).ConfigureAwait(false); + Db.ExpertProfiles.Add(profile); } } diff --git a/backend/src/CCE.Infrastructure/Identity/RefreshTokenRepository.cs b/backend/src/CCE.Infrastructure/Identity/RefreshTokenRepository.cs index 8cc45149..5a14bb4a 100644 --- a/backend/src/CCE.Infrastructure/Identity/RefreshTokenRepository.cs +++ b/backend/src/CCE.Infrastructure/Identity/RefreshTokenRepository.cs @@ -45,6 +45,4 @@ public async Task RevokeAllForUserAsync(Guid userId, DateTimeOffset revokedAtUtc } } - public async Task SaveChangesAsync(CancellationToken ct) - => await _db.SaveChangesAsync(ct).ConfigureAwait(false); } diff --git a/backend/src/CCE.Infrastructure/Identity/StateRepAssignmentRepository.cs b/backend/src/CCE.Infrastructure/Identity/StateRepAssignmentRepository.cs index e301db0f..c8253485 100644 --- a/backend/src/CCE.Infrastructure/Identity/StateRepAssignmentRepository.cs +++ b/backend/src/CCE.Infrastructure/Identity/StateRepAssignmentRepository.cs @@ -5,32 +5,15 @@ namespace CCE.Infrastructure.Identity; -public sealed class StateRepAssignmentRepository : IStateRepAssignmentRepository +public sealed class StateRepAssignmentRepository : Repository, IStateRepAssignmentRepository { - private readonly CceDbContext _db; - - public StateRepAssignmentRepository(CceDbContext db) - { - _db = db; - } - - public async Task SaveAsync(StateRepresentativeAssignment assignment, CancellationToken ct) - { - _db.StateRepresentativeAssignments.Add(assignment); - await _db.SaveChangesAsync(ct).ConfigureAwait(false); - } + public StateRepAssignmentRepository(CceDbContext db) : base(db) { } public async Task FindIncludingRevokedAsync(System.Guid id, CancellationToken ct) { - return await _db.StateRepresentativeAssignments + return await Db.StateRepresentativeAssignments .IgnoreQueryFilters() .FirstOrDefaultAsync(a => a.Id == id, ct) .ConfigureAwait(false); } - - public async Task UpdateAsync(StateRepresentativeAssignment assignment, CancellationToken ct) - { - // Entity is already tracked from FindIncludingRevokedAsync; SaveChanges flushes. - await _db.SaveChangesAsync(ct).ConfigureAwait(false); - } } diff --git a/backend/src/CCE.Infrastructure/Identity/UserProfileRepository.cs b/backend/src/CCE.Infrastructure/Identity/UserProfileRepository.cs index 29c41d7c..8ceeb478 100644 --- a/backend/src/CCE.Infrastructure/Identity/UserProfileRepository.cs +++ b/backend/src/CCE.Infrastructure/Identity/UserProfileRepository.cs @@ -17,6 +17,6 @@ public UserProfileRepository(CceDbContext db) public async Task FindAsync(System.Guid userId, CancellationToken ct) => await _db.Users.FirstOrDefaultAsync(u => u.Id == userId, ct).ConfigureAwait(false); - public async Task UpdateAsync(User user, CancellationToken ct) - => await _db.SaveChangesAsync(ct).ConfigureAwait(false); + public void Update(User user) + => _db.Users.Update(user); } diff --git a/backend/src/CCE.Infrastructure/Persistence/Interceptors/DomainEventDispatcher.cs b/backend/src/CCE.Infrastructure/Persistence/Interceptors/DomainEventDispatcher.cs index 39e91ef2..5aa337f5 100644 --- a/backend/src/CCE.Infrastructure/Persistence/Interceptors/DomainEventDispatcher.cs +++ b/backend/src/CCE.Infrastructure/Persistence/Interceptors/DomainEventDispatcher.cs @@ -29,7 +29,7 @@ public override async ValueTask SavedChangesAsync( var entriesWithEvents = ctx.ChangeTracker.Entries() .Select(e => e.Entity) - .OfType>() + .OfType>() .Where(entity => entity.DomainEvents.Count > 0) .ToList(); diff --git a/backend/src/CCE.Infrastructure/Persistence/Migrations/20260515121258_StandardizeCountryProfileAudit.Designer.cs b/backend/src/CCE.Infrastructure/Persistence/Migrations/20260515121258_StandardizeCountryProfileAudit.Designer.cs new file mode 100644 index 00000000..341b094f --- /dev/null +++ b/backend/src/CCE.Infrastructure/Persistence/Migrations/20260515121258_StandardizeCountryProfileAudit.Designer.cs @@ -0,0 +1,2676 @@ +// +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("20260515121258_StandardizeCountryProfileAudit")] + partial class StandardizeCountryProfileAudit + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("CCE.Domain.Audit.AuditEvent", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("action"); + + b.Property("Actor") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("actor"); + + b.Property("CorrelationId") + .HasColumnType("uniqueidentifier") + .HasColumnName("correlation_id"); + + b.Property("Diff") + .HasColumnType("nvarchar(max)") + .HasColumnName("diff"); + + b.Property("OccurredOn") + .HasColumnType("datetimeoffset") + .HasColumnName("occurred_on"); + + b.Property("Resource") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("resource"); + + b.HasKey("Id") + .HasName("pk_audit_events"); + + b.HasIndex("CorrelationId") + .HasDatabaseName("ix_audit_events_correlation_id"); + + b.HasIndex("Actor", "OccurredOn") + .HasDatabaseName("ix_audit_events_actor_occurred_on"); + + b.ToTable("audit_events", null, t => + { + t.HasTrigger("trg_audit_events_no_update_delete"); + }); + + b.HasAnnotation("SqlServer:UseSqlOutputClause", false); + }); + + modelBuilder.Entity("CCE.Domain.Community.Post", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AnsweredReplyId") + .HasColumnType("uniqueidentifier") + .HasColumnName("answered_reply_id"); + + b.Property("AuthorId") + .HasColumnType("uniqueidentifier") + .HasColumnName("author_id"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)") + .HasColumnName("content"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsAnswerable") + .HasColumnType("bit") + .HasColumnName("is_answerable"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("locale"); + + b.Property("TopicId") + .HasColumnType("uniqueidentifier") + .HasColumnName("topic_id"); + + b.HasKey("Id") + .HasName("pk_posts"); + + b.HasIndex("TopicId") + .HasDatabaseName("ix_post_topic_id"); + + b.HasIndex("AuthorId", "CreatedOn") + .HasDatabaseName("ix_post_author_created"); + + b.ToTable("posts", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.PostFollow", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("FollowedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("followed_on"); + + b.Property("PostId") + .HasColumnType("uniqueidentifier") + .HasColumnName("post_id"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_post_follows"); + + b.HasIndex("PostId", "UserId") + .IsUnique() + .HasDatabaseName("ux_post_follow_post_user"); + + b.ToTable("post_follows", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.PostRating", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("PostId") + .HasColumnType("uniqueidentifier") + .HasColumnName("post_id"); + + b.Property("RatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("rated_on"); + + b.Property("Stars") + .HasColumnType("int") + .HasColumnName("stars"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_post_ratings"); + + b.HasIndex("PostId", "UserId") + .IsUnique() + .HasDatabaseName("ux_post_rating_post_user"); + + b.ToTable("post_ratings", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.PostReply", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AuthorId") + .HasColumnType("uniqueidentifier") + .HasColumnName("author_id"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)") + .HasColumnName("content"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsByExpert") + .HasColumnType("bit") + .HasColumnName("is_by_expert"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("locale"); + + b.Property("ParentReplyId") + .HasColumnType("uniqueidentifier") + .HasColumnName("parent_reply_id"); + + b.Property("PostId") + .HasColumnType("uniqueidentifier") + .HasColumnName("post_id"); + + b.HasKey("Id") + .HasName("pk_post_replies"); + + b.HasIndex("ParentReplyId") + .HasDatabaseName("ix_post_reply_parent_id"); + + b.HasIndex("PostId") + .HasDatabaseName("ix_post_reply_post_id"); + + b.ToTable("post_replies", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.Topic", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("DescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("IconUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("icon_url"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.Property("ParentId") + .HasColumnType("uniqueidentifier") + .HasColumnName("parent_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_topics"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ux_topic_slug_active") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("topics", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.TopicFollow", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("FollowedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("followed_on"); + + b.Property("TopicId") + .HasColumnType("uniqueidentifier") + .HasColumnName("topic_id"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_topic_follows"); + + b.HasIndex("TopicId", "UserId") + .IsUnique() + .HasDatabaseName("ux_topic_follow_topic_user"); + + b.ToTable("topic_follows", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.UserFollow", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("FollowedId") + .HasColumnType("uniqueidentifier") + .HasColumnName("followed_id"); + + b.Property("FollowedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("followed_on"); + + b.Property("FollowerId") + .HasColumnType("uniqueidentifier") + .HasColumnName("follower_id"); + + b.HasKey("Id") + .HasName("pk_user_follows"); + + b.HasIndex("FollowerId", "FollowedId") + .IsUnique() + .HasDatabaseName("ux_user_follow_follower_followed"); + + b.ToTable("user_follows", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.AssetFile", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("MimeType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("mime_type"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("original_file_name"); + + b.Property("ScannedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("scanned_on"); + + b.Property("SizeBytes") + .HasColumnType("bigint") + .HasColumnName("size_bytes"); + + b.Property("UploadedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("uploaded_by_id"); + + b.Property("UploadedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("uploaded_on"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("url"); + + b.Property("VirusScanStatus") + .HasColumnType("int") + .HasColumnName("virus_scan_status"); + + b.HasKey("Id") + .HasName("pk_asset_files"); + + b.HasIndex("VirusScanStatus") + .HasDatabaseName("ix_asset_file_scan_status"); + + b.ToTable("asset_files", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.Event", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("DescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("EndsOn") + .HasColumnType("datetimeoffset") + .HasColumnName("ends_on"); + + b.Property("FeaturedImageUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("featured_image_url"); + + b.Property("ICalUid") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("i_cal_uid"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("LocationAr") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("location_ar"); + + b.Property("LocationEn") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("location_en"); + + b.Property("OnlineMeetingUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("online_meeting_url"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.Property("StartsOn") + .HasColumnType("datetimeoffset") + .HasColumnName("starts_on"); + + b.Property("TitleAr") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_ar"); + + b.Property("TitleEn") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_en"); + + b.HasKey("Id") + .HasName("pk_events"); + + b.HasIndex("ICalUid") + .IsUnique() + .HasDatabaseName("ux_event_ical_uid"); + + b.HasIndex("StartsOn") + .HasDatabaseName("ix_event_starts_on"); + + b.ToTable("events", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.HomepageSection", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("ContentAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_ar"); + + b.Property("ContentEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_en"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.Property("SectionType") + .HasColumnType("int") + .HasColumnName("section_type"); + + b.HasKey("Id") + .HasName("pk_homepage_sections"); + + b.HasIndex("IsActive", "OrderIndex") + .HasDatabaseName("ix_homepage_section_active_order"); + + b.ToTable("homepage_sections", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.News", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AuthorId") + .HasColumnType("uniqueidentifier") + .HasColumnName("author_id"); + + b.Property("ContentAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_ar"); + + b.Property("ContentEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_en"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("FeaturedImageUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("featured_image_url"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("IsFeatured") + .HasColumnType("bit") + .HasColumnName("is_featured"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("PublishedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("published_on"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("slug"); + + b.Property("TitleAr") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_ar"); + + b.Property("TitleEn") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_en"); + + b.HasKey("Id") + .HasName("pk_news"); + + b.HasIndex("PublishedOn") + .HasDatabaseName("ix_news_published_on"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ux_news_slug_active") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("news", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.NewsletterSubscription", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("ConfirmationToken") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("confirmation_token"); + + b.Property("ConfirmedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("confirmed_on"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("nvarchar(320)") + .HasColumnName("email"); + + b.Property("IsConfirmed") + .HasColumnType("bit") + .HasColumnName("is_confirmed"); + + b.Property("LocalePreference") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("locale_preference"); + + b.Property("UnsubscribedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("unsubscribed_on"); + + b.HasKey("Id") + .HasName("pk_newsletter_subscriptions"); + + b.HasIndex("ConfirmationToken") + .HasDatabaseName("ix_newsletter_token"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("ux_newsletter_email"); + + b.ToTable("newsletter_subscriptions", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.Page", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("ContentAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_ar"); + + b.Property("ContentEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_en"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("PageType") + .HasColumnType("int") + .HasColumnName("page_type"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("slug"); + + b.Property("TitleAr") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_ar"); + + b.Property("TitleEn") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_en"); + + b.HasKey("Id") + .HasName("pk_pages"); + + b.HasIndex("PageType", "Slug") + .IsUnique() + .HasDatabaseName("ux_page_type_slug_active") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("pages", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.Resource", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AssetFileId") + .HasColumnType("uniqueidentifier") + .HasColumnName("asset_file_id"); + + b.Property("CategoryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("category_id"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("DescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("PublishedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("published_on"); + + b.Property("ResourceType") + .HasColumnType("int") + .HasColumnName("resource_type"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.Property("TitleAr") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_ar"); + + b.Property("TitleEn") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_en"); + + b.Property("UploadedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("uploaded_by_id"); + + b.Property("ViewCount") + .HasColumnType("bigint") + .HasColumnName("view_count"); + + b.HasKey("Id") + .HasName("pk_resources"); + + b.HasIndex("AssetFileId") + .HasDatabaseName("ix_resource_asset_file_id"); + + b.HasIndex("CountryId") + .HasDatabaseName("ix_resource_country_id"); + + b.HasIndex("CategoryId", "PublishedOn") + .HasDatabaseName("ix_resource_category_published"); + + b.ToTable("resources", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.ResourceCategory", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.Property("ParentId") + .HasColumnType("uniqueidentifier") + .HasColumnName("parent_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_resource_categories"); + + b.HasIndex("ParentId") + .HasDatabaseName("ix_resource_category_parent_id"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ux_resource_category_slug"); + + b.ToTable("resource_categories", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Country.Country", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("FlagUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("flag_url"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("IsoAlpha2") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("iso_alpha2"); + + b.Property("IsoAlpha3") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("nvarchar(3)") + .HasColumnName("iso_alpha3"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("LatestKapsarcSnapshotId") + .HasColumnType("uniqueidentifier") + .HasColumnName("latest_kapsarc_snapshot_id"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.Property("RegionAr") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("region_ar"); + + b.Property("RegionEn") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("region_en"); + + b.HasKey("Id") + .HasName("pk_countries"); + + b.HasIndex("IsoAlpha2") + .HasDatabaseName("ix_country_iso_alpha2"); + + b.HasIndex("IsoAlpha3") + .IsUnique() + .HasDatabaseName("ux_country_iso_alpha3_active") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("countries", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Country.CountryKapsarcSnapshot", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("Classification") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("classification"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("PerformanceScore") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)") + .HasColumnName("performance_score"); + + b.Property("SnapshotTakenOn") + .HasColumnType("datetimeoffset") + .HasColumnName("snapshot_taken_on"); + + b.Property("SourceVersion") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)") + .HasColumnName("source_version"); + + b.Property("TotalIndex") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)") + .HasColumnName("total_index"); + + b.HasKey("Id") + .HasName("pk_country_kapsarc_snapshots"); + + b.HasIndex("CountryId", "SnapshotTakenOn") + .HasDatabaseName("ix_kapsarc_snapshot_country_taken"); + + b.ToTable("country_kapsarc_snapshots", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Country.CountryProfile", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("ContactInfoAr") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("contact_info_ar"); + + b.Property("ContactInfoEn") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("contact_info_en"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("KeyInitiativesAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("key_initiatives_ar"); + + b.Property("KeyInitiativesEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("key_initiatives_en"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.HasKey("Id") + .HasName("pk_country_profiles"); + + b.HasIndex("CountryId") + .IsUnique() + .HasDatabaseName("ux_country_profile_country_id"); + + b.ToTable("country_profiles", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Country.CountryResourceRequest", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AdminNotesAr") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("admin_notes_ar"); + + b.Property("AdminNotesEn") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("admin_notes_en"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("ProcessedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("processed_by_id"); + + b.Property("ProcessedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("processed_on"); + + b.Property("ProposedAssetFileId") + .HasColumnType("uniqueidentifier") + .HasColumnName("proposed_asset_file_id"); + + b.Property("ProposedDescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("proposed_description_ar"); + + b.Property("ProposedDescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("proposed_description_en"); + + b.Property("ProposedResourceType") + .HasColumnType("int") + .HasColumnName("proposed_resource_type"); + + b.Property("ProposedTitleAr") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("proposed_title_ar"); + + b.Property("ProposedTitleEn") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("proposed_title_en"); + + b.Property("RequestedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("requested_by_id"); + + b.Property("Status") + .HasColumnType("int") + .HasColumnName("status"); + + b.Property("SubmittedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("submitted_on"); + + b.HasKey("Id") + .HasName("pk_country_resource_requests"); + + b.HasIndex("CountryId", "Status") + .HasDatabaseName("ix_country_request_country_status"); + + b.ToTable("country_resource_requests", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.ExpertProfile", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AcademicTitleAr") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("academic_title_ar"); + + b.Property("AcademicTitleEn") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("academic_title_en"); + + b.Property("ApprovedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("approved_by_id"); + + b.Property("ApprovedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("approved_on"); + + b.Property("BioAr") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("bio_ar"); + + b.Property("BioEn") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("bio_en"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.PrimitiveCollection("ExpertiseTags") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("expertise_tags"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_expert_profiles"); + + b.HasIndex("UserId") + .IsUnique() + .HasDatabaseName("ux_expert_profile_active_user") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("expert_profiles", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.ExpertRegistrationRequest", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("ProcessedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("processed_by_id"); + + b.Property("ProcessedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("processed_on"); + + b.Property("RejectionReasonAr") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("rejection_reason_ar"); + + b.Property("RejectionReasonEn") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("rejection_reason_en"); + + b.Property("RequestedBioAr") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("requested_bio_ar"); + + b.Property("RequestedBioEn") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("requested_bio_en"); + + b.Property("RequestedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("requested_by_id"); + + b.PrimitiveCollection("RequestedTags") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("requested_tags"); + + b.Property("Status") + .HasColumnType("int") + .HasColumnName("status"); + + b.Property("SubmittedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("submitted_on"); + + b.HasKey("Id") + .HasName("pk_expert_registration_requests"); + + b.HasIndex("RequestedById") + .HasDatabaseName("ix_expert_request_requested_by"); + + b.HasIndex("Status") + .HasDatabaseName("ix_expert_request_status"); + + b.ToTable("expert_registration_requests", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.RefreshToken", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset") + .HasColumnName("created_at_utc"); + + b.Property("CreatedByIp") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("created_by_ip"); + + b.Property("ExpiresAtUtc") + .HasColumnType("datetimeoffset") + .HasColumnName("expires_at_utc"); + + b.Property("ReplacedByTokenHash") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("replaced_by_token_hash"); + + b.Property("RevokedAtUtc") + .HasColumnType("datetimeoffset") + .HasColumnName("revoked_at_utc"); + + b.Property("RevokedByIp") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("revoked_by_ip"); + + b.Property("TokenFamilyId") + .HasColumnType("uniqueidentifier") + .HasColumnName("token_family_id"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("token_hash"); + + b.Property("UserAgent") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_refresh_tokens"); + + b.HasIndex("TokenFamilyId") + .HasDatabaseName("ix_refresh_tokens_token_family_id"); + + b.HasIndex("TokenHash") + .IsUnique() + .HasDatabaseName("ux_refresh_tokens_token_hash"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_refresh_tokens_user_id"); + + b.ToTable("refresh_tokens", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)") + .HasColumnName("concurrency_stamp"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("normalized_name"); + + b.HasKey("Id") + .HasName("pk_asp_net_roles"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[normalized_name] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.StateRepresentativeAssignment", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AssignedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("assigned_by_id"); + + b.Property("AssignedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("assigned_on"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("RevokedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("revoked_by_id"); + + b.Property("RevokedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("revoked_on"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_state_representative_assignments"); + + b.HasIndex("CountryId") + .HasDatabaseName("ix_state_rep_country_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_state_rep_user_id"); + + b.HasIndex("UserId", "CountryId") + .IsUnique() + .HasDatabaseName("ux_state_rep_active_user_country") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("state_representative_assignments", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AccessFailedCount") + .HasColumnType("int") + .HasColumnName("access_failed_count"); + + b.Property("AvatarUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("avatar_url"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)") + .HasColumnName("concurrency_stamp"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("email"); + + b.Property("EmailConfirmed") + .HasColumnType("bit") + .HasColumnName("email_confirmed"); + + b.Property("EntraIdObjectId") + .HasColumnType("uniqueidentifier") + .HasColumnName("entra_id_object_id"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)") + .HasColumnName("first_name"); + + b.PrimitiveCollection("Interests") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("interests"); + + b.Property("JobTitle") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)") + .HasColumnName("job_title"); + + b.Property("KnowledgeLevel") + .HasColumnType("int") + .HasColumnName("knowledge_level"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)") + .HasColumnName("last_name"); + + b.Property("LocalePreference") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("locale_preference"); + + b.Property("LockoutEnabled") + .HasColumnType("bit") + .HasColumnName("lockout_enabled"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset") + .HasColumnName("lockout_end"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("normalized_email"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("normalized_user_name"); + + b.Property("OrganizationName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("organization_name"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)") + .HasColumnName("password_hash"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)") + .HasColumnName("phone_number"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit") + .HasColumnName("phone_number_confirmed"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)") + .HasColumnName("security_stamp"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit") + .HasColumnName("two_factor_enabled"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("user_name"); + + b.HasKey("Id") + .HasName("pk_asp_net_users"); + + b.HasIndex("CountryId") + .HasDatabaseName("ix_users_country_id"); + + b.HasIndex("EntraIdObjectId") + .IsUnique() + .HasDatabaseName("ix_asp_net_users_entra_id_object_id") + .HasFilter("[entra_id_object_id] IS NOT NULL"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[normalized_user_name] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.InteractiveCity.CityScenario", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CityType") + .HasColumnType("int") + .HasColumnName("city_type"); + + b.Property("ConfigurationJson") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("configuration_json"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.Property("TargetYear") + .HasColumnType("int") + .HasColumnName("target_year"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_city_scenarios"); + + b.HasIndex("UserId", "LastModifiedOn") + .HasDatabaseName("ix_city_scenario_user_modified"); + + b.ToTable("city_scenarios", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.InteractiveCity.CityScenarioResult", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("ComputedAt") + .HasColumnType("datetimeoffset") + .HasColumnName("computed_at"); + + b.Property("ComputedCarbonNeutralityYear") + .HasColumnType("int") + .HasColumnName("computed_carbon_neutrality_year"); + + b.Property("ComputedTotalCostUsd") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)") + .HasColumnName("computed_total_cost_usd"); + + b.Property("EngineVersion") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("engine_version"); + + b.Property("ScenarioId") + .HasColumnType("uniqueidentifier") + .HasColumnName("scenario_id"); + + b.HasKey("Id") + .HasName("pk_city_scenario_results"); + + b.HasIndex("ScenarioId", "ComputedAt") + .HasDatabaseName("ix_city_result_scenario_at"); + + b.ToTable("city_scenario_results", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.InteractiveCity.CityTechnology", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CarbonImpactKgPerYear") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)") + .HasColumnName("carbon_impact_kg_per_year"); + + b.Property("CategoryAr") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("category_ar"); + + b.Property("CategoryEn") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("category_en"); + + b.Property("CostUsd") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)") + .HasColumnName("cost_usd"); + + b.Property("DescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("IconUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("icon_url"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.HasKey("Id") + .HasName("pk_city_technologies"); + + b.HasIndex("IsActive") + .HasDatabaseName("ix_city_tech_is_active"); + + b.ToTable("city_technologies", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.KnowledgeMaps.KnowledgeMap", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("DescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_knowledge_maps"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ux_knowledge_map_slug_active") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("knowledge_maps", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.KnowledgeMaps.KnowledgeMapAssociation", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AssociatedId") + .HasColumnType("uniqueidentifier") + .HasColumnName("associated_id"); + + b.Property("AssociatedType") + .HasColumnType("int") + .HasColumnName("associated_type"); + + b.Property("NodeId") + .HasColumnType("uniqueidentifier") + .HasColumnName("node_id"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.HasKey("Id") + .HasName("pk_knowledge_map_associations"); + + b.HasIndex("NodeId", "AssociatedType", "AssociatedId") + .IsUnique() + .HasDatabaseName("ux_km_assoc_node_type_id"); + + b.ToTable("knowledge_map_associations", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.KnowledgeMaps.KnowledgeMapEdge", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("FromNodeId") + .HasColumnType("uniqueidentifier") + .HasColumnName("from_node_id"); + + b.Property("MapId") + .HasColumnType("uniqueidentifier") + .HasColumnName("map_id"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.Property("RelationshipType") + .HasColumnType("int") + .HasColumnName("relationship_type"); + + b.Property("ToNodeId") + .HasColumnType("uniqueidentifier") + .HasColumnName("to_node_id"); + + b.HasKey("Id") + .HasName("pk_knowledge_map_edges"); + + b.HasIndex("FromNodeId") + .HasDatabaseName("ix_km_edge_from_node"); + + b.HasIndex("ToNodeId") + .HasDatabaseName("ix_km_edge_to_node"); + + b.HasIndex("MapId", "FromNodeId", "ToNodeId", "RelationshipType") + .IsUnique() + .HasDatabaseName("ux_km_edge_map_from_to_relation"); + + b.ToTable("knowledge_map_edges", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.KnowledgeMaps.KnowledgeMapNode", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("DescriptionAr") + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("IconUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("icon_url"); + + b.Property("LayoutX") + .HasColumnType("float") + .HasColumnName("layout_x"); + + b.Property("LayoutY") + .HasColumnType("float") + .HasColumnName("layout_y"); + + b.Property("MapId") + .HasColumnType("uniqueidentifier") + .HasColumnName("map_id"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.Property("NodeType") + .HasColumnType("int") + .HasColumnName("node_type"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.HasKey("Id") + .HasName("pk_knowledge_map_nodes"); + + b.HasIndex("MapId", "OrderIndex") + .HasDatabaseName("ix_km_node_map_order"); + + b.ToTable("knowledge_map_nodes", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.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") + .IsUnique() + .HasDatabaseName("ux_notification_template_code"); + + b.ToTable("notification_templates", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Notifications.UserNotification", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("Channel") + .HasColumnType("int") + .HasColumnName("channel"); + + b.Property("ReadOn") + .HasColumnType("datetimeoffset") + .HasColumnName("read_on"); + + b.Property("RenderedBody") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("rendered_body"); + + b.Property("RenderedLocale") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("rendered_locale"); + + b.Property("RenderedSubjectAr") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("rendered_subject_ar"); + + b.Property("RenderedSubjectEn") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("rendered_subject_en"); + + b.Property("SentOn") + .HasColumnType("datetimeoffset") + .HasColumnName("sent_on"); + + b.Property("Status") + .HasColumnType("int") + .HasColumnName("status"); + + b.Property("TemplateId") + .HasColumnType("uniqueidentifier") + .HasColumnName("template_id"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_user_notifications"); + + b.HasIndex("UserId", "Status") + .HasDatabaseName("ix_user_notification_user_status"); + + b.ToTable("user_notifications", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Surveys.SearchQueryLog", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("locale"); + + b.Property("QueryText") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("query_text"); + + b.Property("ResponseTimeMs") + .HasColumnType("int") + .HasColumnName("response_time_ms"); + + b.Property("ResultsCount") + .HasColumnType("int") + .HasColumnName("results_count"); + + b.Property("SubmittedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("submitted_on"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_search_query_logs"); + + b.HasIndex("SubmittedOn") + .HasDatabaseName("ix_search_query_log_submitted_on"); + + b.ToTable("search_query_logs", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Surveys.ServiceRating", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CommentAr") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("comment_ar"); + + b.Property("CommentEn") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("comment_en"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("locale"); + + b.Property("Page") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("page"); + + b.Property("Rating") + .HasColumnType("int") + .HasColumnName("rating"); + + b.Property("SubmittedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("submitted_on"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_service_ratings"); + + b.HasIndex("SubmittedOn") + .HasDatabaseName("ix_service_rating_submitted_on"); + + b.ToTable("service_ratings", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)") + .HasColumnName("claim_type"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)") + .HasColumnName("claim_value"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier") + .HasColumnName("role_id"); + + b.HasKey("Id") + .HasName("pk_asp_net_role_claims"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_asp_net_role_claims_role_id"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)") + .HasColumnName("claim_type"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)") + .HasColumnName("claim_value"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_asp_net_user_claims"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_asp_net_user_claims_user_id"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)") + .HasColumnName("login_provider"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)") + .HasColumnName("provider_key"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)") + .HasColumnName("provider_display_name"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("LoginProvider", "ProviderKey") + .HasName("pk_asp_net_user_logins"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_asp_net_user_logins_user_id"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier") + .HasColumnName("role_id"); + + b.HasKey("UserId", "RoleId") + .HasName("pk_asp_net_user_roles"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_asp_net_user_roles_role_id"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)") + .HasColumnName("login_provider"); + + b.Property("Name") + .HasColumnType("nvarchar(450)") + .HasColumnName("name"); + + b.Property("Value") + .HasColumnType("nvarchar(max)") + .HasColumnName("value"); + + b.HasKey("UserId", "LoginProvider", "Name") + .HasName("pk_asp_net_user_tokens"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.RefreshToken", b => + { + b.HasOne("CCE.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_refresh_tokens_asp_net_users_user_id"); + }); + + modelBuilder.Entity("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"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/CCE.Infrastructure/Persistence/Migrations/20260515121258_StandardizeCountryProfileAudit.cs b/backend/src/CCE.Infrastructure/Persistence/Migrations/20260515121258_StandardizeCountryProfileAudit.cs new file mode 100644 index 00000000..ff7cb93d --- /dev/null +++ b/backend/src/CCE.Infrastructure/Persistence/Migrations/20260515121258_StandardizeCountryProfileAudit.cs @@ -0,0 +1,664 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CCE.Infrastructure.Persistence.Migrations +{ + /// + public partial class StandardizeCountryProfileAudit : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "last_updated_on", + table: "country_profiles", + newName: "created_on"); + + migrationBuilder.RenameColumn( + name: "last_updated_by_id", + table: "country_profiles", + newName: "created_by_id"); + + migrationBuilder.AddColumn( + name: "created_by_id", + table: "topics", + type: "uniqueidentifier", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.AddColumn( + name: "created_on", + table: "topics", + type: "datetimeoffset", + nullable: false, + defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))); + + migrationBuilder.AddColumn( + name: "last_modified_by_id", + table: "topics", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.AddColumn( + name: "last_modified_on", + table: "topics", + type: "datetimeoffset", + nullable: true); + + migrationBuilder.AddColumn( + name: "created_by_id", + table: "state_representative_assignments", + type: "uniqueidentifier", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.AddColumn( + name: "created_on", + table: "state_representative_assignments", + type: "datetimeoffset", + nullable: false, + defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))); + + migrationBuilder.AddColumn( + name: "last_modified_by_id", + table: "state_representative_assignments", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.AddColumn( + name: "last_modified_on", + table: "state_representative_assignments", + type: "datetimeoffset", + nullable: true); + + migrationBuilder.AddColumn( + name: "created_by_id", + table: "resources", + type: "uniqueidentifier", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.AddColumn( + name: "created_on", + table: "resources", + type: "datetimeoffset", + nullable: false, + defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))); + + migrationBuilder.AddColumn( + name: "last_modified_by_id", + table: "resources", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.AddColumn( + name: "last_modified_on", + table: "resources", + type: "datetimeoffset", + nullable: true); + + migrationBuilder.AddColumn( + name: "created_by_id", + table: "posts", + type: "uniqueidentifier", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.AddColumn( + name: "last_modified_by_id", + table: "posts", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.AddColumn( + name: "last_modified_on", + table: "posts", + type: "datetimeoffset", + nullable: true); + + migrationBuilder.AddColumn( + name: "created_by_id", + table: "post_replies", + type: "uniqueidentifier", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.AddColumn( + name: "last_modified_by_id", + table: "post_replies", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.AddColumn( + name: "last_modified_on", + table: "post_replies", + type: "datetimeoffset", + nullable: true); + + migrationBuilder.AddColumn( + name: "created_by_id", + table: "pages", + type: "uniqueidentifier", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.AddColumn( + name: "created_on", + table: "pages", + type: "datetimeoffset", + nullable: false, + defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))); + + migrationBuilder.AddColumn( + name: "last_modified_by_id", + table: "pages", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.AddColumn( + name: "last_modified_on", + table: "pages", + type: "datetimeoffset", + nullable: true); + + migrationBuilder.AddColumn( + name: "created_by_id", + table: "news", + type: "uniqueidentifier", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.AddColumn( + name: "created_on", + table: "news", + type: "datetimeoffset", + nullable: false, + defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))); + + migrationBuilder.AddColumn( + name: "last_modified_by_id", + table: "news", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.AddColumn( + name: "last_modified_on", + table: "news", + type: "datetimeoffset", + nullable: true); + + migrationBuilder.AddColumn( + name: "created_by_id", + table: "knowledge_maps", + type: "uniqueidentifier", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.AddColumn( + name: "created_on", + table: "knowledge_maps", + type: "datetimeoffset", + nullable: false, + defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))); + + migrationBuilder.AddColumn( + name: "last_modified_by_id", + table: "knowledge_maps", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.AddColumn( + name: "last_modified_on", + table: "knowledge_maps", + type: "datetimeoffset", + nullable: true); + + migrationBuilder.AddColumn( + name: "created_by_id", + table: "homepage_sections", + type: "uniqueidentifier", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.AddColumn( + name: "created_on", + table: "homepage_sections", + type: "datetimeoffset", + nullable: false, + defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))); + + migrationBuilder.AddColumn( + name: "last_modified_by_id", + table: "homepage_sections", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.AddColumn( + name: "last_modified_on", + table: "homepage_sections", + type: "datetimeoffset", + nullable: true); + + migrationBuilder.AddColumn( + name: "created_by_id", + table: "expert_registration_requests", + type: "uniqueidentifier", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.AddColumn( + name: "created_on", + table: "expert_registration_requests", + type: "datetimeoffset", + nullable: false, + defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))); + + migrationBuilder.AddColumn( + name: "last_modified_by_id", + table: "expert_registration_requests", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.AddColumn( + name: "last_modified_on", + table: "expert_registration_requests", + type: "datetimeoffset", + nullable: true); + + migrationBuilder.AddColumn( + name: "created_by_id", + table: "expert_profiles", + type: "uniqueidentifier", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.AddColumn( + name: "created_on", + table: "expert_profiles", + type: "datetimeoffset", + nullable: false, + defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))); + + migrationBuilder.AddColumn( + name: "last_modified_by_id", + table: "expert_profiles", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.AddColumn( + name: "last_modified_on", + table: "expert_profiles", + type: "datetimeoffset", + nullable: true); + + migrationBuilder.AddColumn( + name: "created_by_id", + table: "events", + type: "uniqueidentifier", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.AddColumn( + name: "created_on", + table: "events", + type: "datetimeoffset", + nullable: false, + defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))); + + migrationBuilder.AddColumn( + name: "last_modified_by_id", + table: "events", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.AddColumn( + name: "last_modified_on", + table: "events", + type: "datetimeoffset", + nullable: true); + + migrationBuilder.AddColumn( + name: "created_by_id", + table: "country_resource_requests", + type: "uniqueidentifier", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.AddColumn( + name: "created_on", + table: "country_resource_requests", + type: "datetimeoffset", + nullable: false, + defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))); + + migrationBuilder.AddColumn( + name: "last_modified_by_id", + table: "country_resource_requests", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.AddColumn( + name: "last_modified_on", + table: "country_resource_requests", + type: "datetimeoffset", + nullable: true); + + migrationBuilder.AddColumn( + name: "last_modified_by_id", + table: "country_profiles", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.AddColumn( + name: "last_modified_on", + table: "country_profiles", + type: "datetimeoffset", + nullable: true); + + migrationBuilder.AddColumn( + name: "created_by_id", + table: "countries", + type: "uniqueidentifier", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.AddColumn( + name: "created_on", + table: "countries", + type: "datetimeoffset", + nullable: false, + defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))); + + migrationBuilder.AddColumn( + name: "last_modified_by_id", + table: "countries", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.AddColumn( + name: "last_modified_on", + table: "countries", + type: "datetimeoffset", + nullable: true); + + migrationBuilder.AlterColumn( + name: "last_modified_on", + table: "city_scenarios", + type: "datetimeoffset", + nullable: true, + oldClrType: typeof(DateTimeOffset), + oldType: "datetimeoffset"); + + migrationBuilder.AddColumn( + name: "created_by_id", + table: "city_scenarios", + type: "uniqueidentifier", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.AddColumn( + name: "last_modified_by_id", + table: "city_scenarios", + type: "uniqueidentifier", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "created_by_id", + table: "topics"); + + migrationBuilder.DropColumn( + name: "created_on", + table: "topics"); + + migrationBuilder.DropColumn( + name: "last_modified_by_id", + table: "topics"); + + migrationBuilder.DropColumn( + name: "last_modified_on", + table: "topics"); + + migrationBuilder.DropColumn( + name: "created_by_id", + table: "state_representative_assignments"); + + migrationBuilder.DropColumn( + name: "created_on", + table: "state_representative_assignments"); + + migrationBuilder.DropColumn( + name: "last_modified_by_id", + table: "state_representative_assignments"); + + migrationBuilder.DropColumn( + name: "last_modified_on", + table: "state_representative_assignments"); + + migrationBuilder.DropColumn( + name: "created_by_id", + table: "resources"); + + migrationBuilder.DropColumn( + name: "created_on", + table: "resources"); + + migrationBuilder.DropColumn( + name: "last_modified_by_id", + table: "resources"); + + migrationBuilder.DropColumn( + name: "last_modified_on", + table: "resources"); + + migrationBuilder.DropColumn( + name: "created_by_id", + table: "posts"); + + migrationBuilder.DropColumn( + name: "last_modified_by_id", + table: "posts"); + + migrationBuilder.DropColumn( + name: "last_modified_on", + table: "posts"); + + migrationBuilder.DropColumn( + name: "created_by_id", + table: "post_replies"); + + migrationBuilder.DropColumn( + name: "last_modified_by_id", + table: "post_replies"); + + migrationBuilder.DropColumn( + name: "last_modified_on", + table: "post_replies"); + + migrationBuilder.DropColumn( + name: "created_by_id", + table: "pages"); + + migrationBuilder.DropColumn( + name: "created_on", + table: "pages"); + + migrationBuilder.DropColumn( + name: "last_modified_by_id", + table: "pages"); + + migrationBuilder.DropColumn( + name: "last_modified_on", + table: "pages"); + + migrationBuilder.DropColumn( + name: "created_by_id", + table: "news"); + + migrationBuilder.DropColumn( + name: "created_on", + table: "news"); + + migrationBuilder.DropColumn( + name: "last_modified_by_id", + table: "news"); + + migrationBuilder.DropColumn( + name: "last_modified_on", + table: "news"); + + migrationBuilder.DropColumn( + name: "created_by_id", + table: "knowledge_maps"); + + migrationBuilder.DropColumn( + name: "created_on", + table: "knowledge_maps"); + + migrationBuilder.DropColumn( + name: "last_modified_by_id", + table: "knowledge_maps"); + + migrationBuilder.DropColumn( + name: "last_modified_on", + table: "knowledge_maps"); + + migrationBuilder.DropColumn( + name: "created_by_id", + table: "homepage_sections"); + + migrationBuilder.DropColumn( + name: "created_on", + table: "homepage_sections"); + + migrationBuilder.DropColumn( + name: "last_modified_by_id", + table: "homepage_sections"); + + migrationBuilder.DropColumn( + name: "last_modified_on", + table: "homepage_sections"); + + migrationBuilder.DropColumn( + name: "created_by_id", + table: "expert_registration_requests"); + + migrationBuilder.DropColumn( + name: "created_on", + table: "expert_registration_requests"); + + migrationBuilder.DropColumn( + name: "last_modified_by_id", + table: "expert_registration_requests"); + + migrationBuilder.DropColumn( + name: "last_modified_on", + table: "expert_registration_requests"); + + migrationBuilder.DropColumn( + name: "created_by_id", + table: "expert_profiles"); + + migrationBuilder.DropColumn( + name: "created_on", + table: "expert_profiles"); + + migrationBuilder.DropColumn( + name: "last_modified_by_id", + table: "expert_profiles"); + + migrationBuilder.DropColumn( + name: "last_modified_on", + table: "expert_profiles"); + + migrationBuilder.DropColumn( + name: "created_by_id", + table: "events"); + + migrationBuilder.DropColumn( + name: "created_on", + table: "events"); + + migrationBuilder.DropColumn( + name: "last_modified_by_id", + table: "events"); + + migrationBuilder.DropColumn( + name: "last_modified_on", + table: "events"); + + migrationBuilder.DropColumn( + name: "created_by_id", + table: "country_resource_requests"); + + migrationBuilder.DropColumn( + name: "created_on", + table: "country_resource_requests"); + + migrationBuilder.DropColumn( + name: "last_modified_by_id", + table: "country_resource_requests"); + + migrationBuilder.DropColumn( + name: "last_modified_on", + table: "country_resource_requests"); + + migrationBuilder.DropColumn( + name: "last_modified_by_id", + table: "country_profiles"); + + migrationBuilder.DropColumn( + name: "last_modified_on", + table: "country_profiles"); + + migrationBuilder.DropColumn( + name: "created_by_id", + table: "countries"); + + migrationBuilder.DropColumn( + name: "created_on", + table: "countries"); + + migrationBuilder.DropColumn( + name: "last_modified_by_id", + table: "countries"); + + migrationBuilder.DropColumn( + name: "last_modified_on", + table: "countries"); + + migrationBuilder.DropColumn( + name: "created_by_id", + table: "city_scenarios"); + + migrationBuilder.DropColumn( + name: "last_modified_by_id", + table: "city_scenarios"); + + migrationBuilder.RenameColumn( + name: "created_on", + table: "country_profiles", + newName: "last_updated_on"); + + migrationBuilder.RenameColumn( + name: "created_by_id", + table: "country_profiles", + newName: "last_updated_by_id"); + + migrationBuilder.AlterColumn( + name: "last_modified_on", + table: "city_scenarios", + type: "datetimeoffset", + nullable: false, + defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + oldClrType: typeof(DateTimeOffset), + oldType: "datetimeoffset", + oldNullable: true); + } + } +} diff --git a/backend/src/CCE.Infrastructure/Persistence/Repository.cs b/backend/src/CCE.Infrastructure/Persistence/Repository.cs new file mode 100644 index 00000000..536ccc0c --- /dev/null +++ b/backend/src/CCE.Infrastructure/Persistence/Repository.cs @@ -0,0 +1,32 @@ +using CCE.Application.Common.Interfaces; +using CCE.Domain.Common; +using Microsoft.EntityFrameworkCore; + +namespace CCE.Infrastructure.Persistence; + +public class Repository : IRepository + where T : AggregateRoot + where TId : IEquatable +{ + protected CceDbContext Db { get; } + + public Repository(CceDbContext db) => Db = db; + + public virtual async Task GetByIdAsync(TId id, CancellationToken ct) + => await Db.Set().FindAsync(new object[] { id }, ct).ConfigureAwait(false); + + public virtual async Task AddAsync(T entity, CancellationToken ct) + => await Db.Set().AddAsync(entity, ct).ConfigureAwait(false); + + public virtual void Update(T entity) + { + if (Db.Entry(entity).State == EntityState.Detached) + { + Db.Set().Attach(entity); + Db.Entry(entity).State = EntityState.Modified; + } + } + + public virtual void Delete(T entity) + => Db.Set().Remove(entity); +} \ No newline at end of file diff --git a/backend/tests/CCE.Api.IntegrationTests/Middleware/ExceptionHandlingMiddlewareConcurrencyTests.cs b/backend/tests/CCE.Api.IntegrationTests/Middleware/ExceptionHandlingMiddlewareConcurrencyTests.cs index 135b6149..b5edf679 100644 --- a/backend/tests/CCE.Api.IntegrationTests/Middleware/ExceptionHandlingMiddlewareConcurrencyTests.cs +++ b/backend/tests/CCE.Api.IntegrationTests/Middleware/ExceptionHandlingMiddlewareConcurrencyTests.cs @@ -27,7 +27,7 @@ private static IHost BuildHost(Exception toThrow) => .Start(); [Fact] - public async Task ConcurrencyException_returns_409_problem_details() + public async Task ConcurrencyException_returns_409_response() { using var host = BuildHost(new ConcurrencyException("test conflict")); var client = host.GetTestClient(); @@ -35,17 +35,15 @@ public async Task ConcurrencyException_returns_409_problem_details() var resp = await client.GetAsync(new Uri("/", UriKind.Relative)); resp.StatusCode.Should().Be(HttpStatusCode.Conflict); - resp.Content.Headers.ContentType!.MediaType.Should().Be("application/problem+json"); + resp.Content.Headers.ContentType!.MediaType.Should().Be("application/json"); var body = await resp.Content.ReadAsStringAsync(); var doc = JsonDocument.Parse(body).RootElement; - doc.GetProperty("status").GetInt32().Should().Be(409); - doc.GetProperty("title").GetString().Should().Be("Concurrent edit"); - doc.GetProperty("type").GetString().Should().Be("https://cce.moenergy.gov.sa/problems/concurrency"); - doc.GetProperty("detail").GetString().Should().Be("test conflict"); + doc.GetProperty("success").GetBoolean().Should().BeFalse(); + doc.GetProperty("code").GetString().Should().Be("ERR907"); } [Fact] - public async Task DuplicateException_returns_409_problem_details() + public async Task DuplicateException_returns_409_response() { using var host = BuildHost(new DuplicateException("dup conflict")); var client = host.GetTestClient(); @@ -55,14 +53,12 @@ public async Task DuplicateException_returns_409_problem_details() resp.StatusCode.Should().Be(HttpStatusCode.Conflict); var body = await resp.Content.ReadAsStringAsync(); var doc = JsonDocument.Parse(body).RootElement; - doc.GetProperty("status").GetInt32().Should().Be(409); - doc.GetProperty("title").GetString().Should().Be("Duplicate value"); - doc.GetProperty("type").GetString().Should().Be("https://cce.moenergy.gov.sa/problems/duplicate"); - doc.GetProperty("detail").GetString().Should().Be("dup conflict"); + doc.GetProperty("success").GetBoolean().Should().BeFalse(); + doc.GetProperty("code").GetString().Should().Be("ERR908"); } [Fact] - public async Task DomainException_returns_400_problem_details() + public async Task DomainException_returns_400_response() { using var host = BuildHost(new DomainException("invariant violated")); var client = host.GetTestClient(); @@ -72,8 +68,7 @@ public async Task DomainException_returns_400_problem_details() resp.StatusCode.Should().Be(HttpStatusCode.BadRequest); var body = await resp.Content.ReadAsStringAsync(); var doc = JsonDocument.Parse(body).RootElement; - doc.GetProperty("status").GetInt32().Should().Be(400); - doc.GetProperty("title").GetString().Should().Be("Invariant violated"); - doc.GetProperty("type").GetString().Should().Be("https://cce.moenergy.gov.sa/problems/invariant"); + doc.GetProperty("success").GetBoolean().Should().BeFalse(); + doc.GetProperty("code").GetString().Should().Be("ERR904"); } } diff --git a/backend/tests/CCE.Api.IntegrationTests/Middleware/ExceptionHandlingMiddlewareTests.cs b/backend/tests/CCE.Api.IntegrationTests/Middleware/ExceptionHandlingMiddlewareTests.cs index e6f29ea4..0cd34b57 100644 --- a/backend/tests/CCE.Api.IntegrationTests/Middleware/ExceptionHandlingMiddlewareTests.cs +++ b/backend/tests/CCE.Api.IntegrationTests/Middleware/ExceptionHandlingMiddlewareTests.cs @@ -28,7 +28,7 @@ private static IHost BuildHost(RequestDelegate handler) => .Start(); [Fact] - public async Task Returns_500_problem_details_on_unhandled_exception() + public async Task Returns_500_response_on_unhandled_exception() { using var host = BuildHost(_ => throw new InvalidOperationException("boom")); var client = host.GetTestClient(); @@ -36,15 +36,16 @@ public async Task Returns_500_problem_details_on_unhandled_exception() var resp = await client.GetAsync(new Uri("/", UriKind.Relative)); resp.StatusCode.Should().Be(HttpStatusCode.InternalServerError); - resp.Content.Headers.ContentType!.MediaType.Should().Be("application/problem+json"); + resp.Content.Headers.ContentType!.MediaType.Should().Be("application/json"); var body = await resp.Content.ReadAsStringAsync(); var doc = JsonDocument.Parse(body).RootElement; - doc.GetProperty("status").GetInt32().Should().Be(500); - doc.GetProperty("correlationId").GetString().Should().NotBeNullOrEmpty(); + doc.GetProperty("success").GetBoolean().Should().BeFalse(); + doc.GetProperty("code").GetString().Should().Be("ERR900"); + doc.GetProperty("traceId").GetString().Should().NotBeNullOrEmpty(); } [Fact] - public async Task Returns_400_problem_details_on_validation_exception() + public async Task Returns_400_response_on_validation_exception() { var failures = new List { @@ -59,23 +60,21 @@ public async Task Returns_400_problem_details_on_validation_exception() resp.StatusCode.Should().Be(HttpStatusCode.BadRequest); var body = await resp.Content.ReadAsStringAsync(); var doc = JsonDocument.Parse(body).RootElement; - doc.GetProperty("status").GetInt32().Should().Be(400); - doc.GetProperty("errors").GetProperty("Name").EnumerateArray().First().GetString().Should().Be("must not be empty"); - doc.GetProperty("errors").GetProperty("Age").EnumerateArray().First().GetString().Should().Be("must be positive"); + doc.GetProperty("success").GetBoolean().Should().BeFalse(); + doc.GetProperty("code").GetString().Should().Be("VAL001"); + doc.GetProperty("errors").GetArrayLength().Should().Be(2); } [Fact] - public async Task Includes_correlation_id_in_response_body() + public async Task Includes_trace_id_in_response_body() { using var host = BuildHost(_ => throw new InvalidOperationException("x")); var client = host.GetTestClient(); - var sent = Guid.NewGuid().ToString(); - client.DefaultRequestHeaders.Add("X-Correlation-Id", sent); var resp = await client.GetAsync(new Uri("/", UriKind.Relative)); var body = await resp.Content.ReadAsStringAsync(); var doc = JsonDocument.Parse(body).RootElement; - doc.GetProperty("correlationId").GetString().Should().Be(sent); + doc.GetProperty("traceId").GetString().Should().NotBeNullOrEmpty(); } } diff --git a/backend/tests/CCE.Application.Tests/DependencyInjectionTests.cs b/backend/tests/CCE.Application.Tests/DependencyInjectionTests.cs index 77ddf0b2..1e243028 100644 --- a/backend/tests/CCE.Application.Tests/DependencyInjectionTests.cs +++ b/backend/tests/CCE.Application.Tests/DependencyInjectionTests.cs @@ -1,4 +1,5 @@ using CCE.Application.Health; +using CCE.Application.Localization; using CCE.Domain.Common; using CCE.TestInfrastructure.Time; using MediatR; @@ -15,6 +16,12 @@ public async Task Mediator_resolves_HealthQuery_handler_through_pipeline() var services = new ServiceCollection(); services.AddLogging(); services.AddSingleton(new FakeSystemClock()); + services.AddSingleton(_ => + { + var l = NSubstitute.Substitute.For(); + l.GetLocalizedMessage(Arg.Any()).Returns(new LocalizedMessage("ar", "en")); + return l; + }); services.AddApplication(); await using var sp = services.BuildServiceProvider(); @@ -32,6 +39,12 @@ public async Task Mediator_resolves_AuthenticatedHealthQuery_handler_through_pip var services = new ServiceCollection(); services.AddLogging(); services.AddSingleton(new FakeSystemClock()); + services.AddSingleton(_ => + { + var l = NSubstitute.Substitute.For(); + l.GetLocalizedMessage(Arg.Any()).Returns(new LocalizedMessage("ar", "en")); + return l; + }); services.AddApplication(); await using var sp = services.BuildServiceProvider(); diff --git a/backend/tests/CCE.Application.Tests/Identity/Commands/ApproveExpertRequestCommandHandlerTests.cs b/backend/tests/CCE.Application.Tests/Identity/Commands/ApproveExpertRequestCommandHandlerTests.cs index 283bc8b2..a8a0a89a 100644 --- a/backend/tests/CCE.Application.Tests/Identity/Commands/ApproveExpertRequestCommandHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Identity/Commands/ApproveExpertRequestCommandHandlerTests.cs @@ -1,6 +1,7 @@ using CCE.Application.Common.Interfaces; using CCE.Application.Identity; using CCE.Application.Identity.Commands.ApproveExpertRequest; +using CCE.Application.Messages; using CCE.Domain.Common; using CCE.Domain.Identity; using CCE.TestInfrastructure.Time; @@ -18,14 +19,14 @@ public async Task Throws_KeyNotFound_when_request_missing() service.FindIncludingDeletedAsync(Arg.Any(), Arg.Any()) .Returns((ExpertRegistrationRequest?)null); - var sut = new ApproveExpertRequestCommandHandler(service, BuildDb(), BuildCurrentUser(), new FakeSystemClock(), BuildErrors()); + var sut = new ApproveExpertRequestCommandHandler(BuildDb(), service, BuildCurrentUser(), new FakeSystemClock(), BuildMsg()); var result = await sut.Handle( new ApproveExpertRequestCommand(System.Guid.NewGuid(), "Dr.", "Dr."), CancellationToken.None); - result.IsSuccess.Should().BeFalse(); - result.Error!.Code.Should().Be("IDENTITY_EXPERT_REQUEST_NOT_FOUND"); + result.Success.Should().BeFalse(); + result.Code.Should().Be(SystemCode.ERR002); } [Fact] @@ -40,14 +41,14 @@ public async Task Throws_DomainException_when_actor_unknown() var currentUser = Substitute.For(); currentUser.GetUserId().Returns((System.Guid?)null); - var sut = new ApproveExpertRequestCommandHandler(service, BuildDb(), currentUser, clock, BuildErrors()); + var sut = new ApproveExpertRequestCommandHandler(BuildDb(), service, currentUser, clock, BuildMsg()); var result = await sut.Handle( new ApproveExpertRequestCommand(registration.Id, "Dr.", "Dr."), CancellationToken.None); - result.IsSuccess.Should().BeFalse(); - result.Error!.Code.Should().Be("IDENTITY_NOT_AUTHENTICATED"); + result.Success.Should().BeFalse(); + result.Code.Should().Be(SystemCode.ERR028); } [Fact] @@ -63,7 +64,7 @@ public async Task Throws_DomainException_when_request_not_pending() service.FindIncludingDeletedAsync(Arg.Any(), Arg.Any()) .Returns(registration); - var sut = new ApproveExpertRequestCommandHandler(service, BuildDb(), BuildCurrentUser(adminId), clock, BuildErrors()); + var sut = new ApproveExpertRequestCommandHandler(BuildDb(), service, BuildCurrentUser(adminId), clock, BuildMsg()); var act = async () => await sut.Handle( new ApproveExpertRequestCommand(registration.Id, "Dr.", "Dr."), @@ -86,8 +87,9 @@ public async Task Approves_request_and_creates_profile_when_valid() .Returns(registration); var users = new[] { BuildUser(requesterId, "alice@cce.local", "alice") }; + var db = BuildDb(users); - var sut = new ApproveExpertRequestCommandHandler(service, BuildDb(users), BuildCurrentUser(adminId), clock, BuildErrors()); + var sut = new ApproveExpertRequestCommandHandler(db, service, BuildCurrentUser(adminId), clock, BuildMsg()); var result = await sut.Handle( new ApproveExpertRequestCommand(registration.Id, "أستاذ مساعد", "Assistant Professor"), @@ -98,7 +100,8 @@ public async Task Approves_request_and_creates_profile_when_valid() result.Data!.AcademicTitleEn.Should().Be("Assistant Professor"); result.Data!.ExpertiseTags.Should().BeEquivalentTo(new[] { "Hydrogen", "CCS" }); registration.Status.Should().Be(ExpertRegistrationStatus.Approved); - await service.Received(1).SaveAsync(registration, Arg.Any(), Arg.Any()); + service.Received(1).AddProfile(Arg.Is(p => p.UserId == requesterId)); + await db.Received(1).SaveChangesAsync(Arg.Any()); } private static ICurrentUserAccessor BuildCurrentUser(System.Guid? userId = null) diff --git a/backend/tests/CCE.Application.Tests/Identity/Commands/AssignUserRolesCommandHandlerTests.cs b/backend/tests/CCE.Application.Tests/Identity/Commands/AssignUserRolesCommandHandlerTests.cs index 7b082158..fb6ffc53 100644 --- a/backend/tests/CCE.Application.Tests/Identity/Commands/AssignUserRolesCommandHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Identity/Commands/AssignUserRolesCommandHandlerTests.cs @@ -3,6 +3,9 @@ using CCE.Application.Identity.Commands.AssignUserRoles; using CCE.Application.Identity.Dtos; using CCE.Application.Identity.Queries.GetUserById; +using CCE.Application.Localization; +using CCE.Application.Messages; +using CCE.Domain.Common; using CCE.Domain.Identity; using MediatR; using static CCE.Application.Tests.Identity.IdentityTestHelpers; @@ -18,13 +21,13 @@ public async Task Returns_failure_when_service_reports_user_missing() service.ReplaceRolesAsync(Arg.Any(), Arg.Any>(), Arg.Any()) .Returns(false); var mediator = Substitute.For(); - var sut = new AssignUserRolesCommandHandler(service, mediator, BuildErrors()); + var sut = new AssignUserRolesCommandHandler(service, mediator, BuildMsg()); var result = await sut.Handle(new AssignUserRolesCommand(System.Guid.NewGuid(), new[] { "SuperAdmin" }), CancellationToken.None); - result.IsSuccess.Should().BeFalse(); - result.Error!.Code.Should().Be("IDENTITY_USER_NOT_FOUND"); - await mediator.DidNotReceiveWithAnyArgs().Send>(default!, default); + result.Success.Should().BeFalse(); + result.Code.Should().Be(SystemCode.ERR001); + await mediator.DidNotReceiveWithAnyArgs().Send>(default!, default); } [Fact] @@ -40,13 +43,13 @@ public async Task Returns_user_detail_when_service_succeeds() new[] { "ContentManager" }, true); var mediator = Substitute.For(); mediator.Send(Arg.Is(q => q.Id == id), Arg.Any()) - .Returns(Result.Success(dto)); + .Returns(Response.Ok(dto, SystemCode.CON900, new LocalizedMessage("ar", "en"))); - var sut = new AssignUserRolesCommandHandler(service, mediator, BuildErrors()); + var sut = new AssignUserRolesCommandHandler(service, mediator, BuildMsg()); var result = await sut.Handle(new AssignUserRolesCommand(id, new[] { "ContentManager" }), CancellationToken.None); - result.IsSuccess.Should().BeTrue(); + result.Success.Should().BeTrue(); result.Data!.Should().BeEquivalentTo(dto); } @@ -58,11 +61,11 @@ public async Task Forwards_role_list_to_service() service.ReplaceRolesAsync(default, default!, default).ReturnsForAnyArgs(true); var mediator = Substitute.For(); mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Success(new UserDetailDto( + .Returns(Response.Ok(new UserDetailDto( id, "alice@cce.local", "alice", "ar", KnowledgeLevel.Beginner, System.Array.Empty(), null, null, - new[] { "SuperAdmin", "ContentManager" }, true))); - var sut = new AssignUserRolesCommandHandler(service, mediator, BuildErrors()); + new[] { "SuperAdmin", "ContentManager" }, true), SystemCode.CON900, new LocalizedMessage("ar", "en"))); + var sut = new AssignUserRolesCommandHandler(service, mediator, BuildMsg()); var roles = new[] { "SuperAdmin", "ContentManager" }; await sut.Handle(new AssignUserRolesCommand(id, roles), CancellationToken.None); diff --git a/backend/tests/CCE.Application.Tests/Identity/Commands/CreateStateRepAssignmentCommandHandlerTests.cs b/backend/tests/CCE.Application.Tests/Identity/Commands/CreateStateRepAssignmentCommandHandlerTests.cs index 415d976f..a2452131 100644 --- a/backend/tests/CCE.Application.Tests/Identity/Commands/CreateStateRepAssignmentCommandHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Identity/Commands/CreateStateRepAssignmentCommandHandlerTests.cs @@ -2,6 +2,7 @@ using CCE.Application.Common.Interfaces; using CCE.Application.Identity; using CCE.Application.Identity.Commands.CreateStateRepAssignment; +using CCE.Application.Messages; using CCE.Domain.Common; using CCE.Domain.Identity; using CCE.TestInfrastructure.Time; @@ -17,14 +18,14 @@ public async Task Returns_failure_when_user_missing() { var db = BuildDb(System.Array.Empty(), System.Array.Empty()); var sut = new CreateStateRepAssignmentCommandHandler( - db, Substitute.For(), BuildCurrentUser(), new FakeSystemClock(), BuildErrors()); + db, Substitute.For(), BuildCurrentUser(), new FakeSystemClock(), BuildMsg()); var result = await sut.Handle( new CreateStateRepAssignmentCommand(System.Guid.NewGuid(), System.Guid.NewGuid()), CancellationToken.None); - result.IsSuccess.Should().BeFalse(); - result.Error!.Code.Should().Be("IDENTITY_USER_NOT_FOUND"); + result.Success.Should().BeFalse(); + result.Code.Should().Be(SystemCode.ERR001); } [Fact] @@ -34,14 +35,14 @@ public async Task Returns_failure_when_country_missing() var users = new[] { BuildUser(aliceId, "alice@cce.local", "alice") }; var db = BuildDb(users, System.Array.Empty()); var sut = new CreateStateRepAssignmentCommandHandler( - db, Substitute.For(), BuildCurrentUser(), new FakeSystemClock(), BuildErrors()); + db, Substitute.For(), BuildCurrentUser(), new FakeSystemClock(), BuildMsg()); var result = await sut.Handle( new CreateStateRepAssignmentCommand(aliceId, System.Guid.NewGuid()), CancellationToken.None); - result.IsSuccess.Should().BeFalse(); - result.Error!.Code.Should().Be("COUNTRY_COUNTRY_NOT_FOUND"); + result.Success.Should().BeFalse(); + result.Code.Should().Be(SystemCode.ERR070); } [Fact] @@ -55,14 +56,14 @@ public async Task Returns_failure_when_actor_unknown() var db = BuildDb(users, new[] { country }); var sut = new CreateStateRepAssignmentCommandHandler( - db, Substitute.For(), currentUser, new FakeSystemClock(), BuildErrors()); + db, Substitute.For(), currentUser, new FakeSystemClock(), BuildMsg()); var result = await sut.Handle( new CreateStateRepAssignmentCommand(aliceId, country.Id), CancellationToken.None); - result.IsSuccess.Should().BeFalse(); - result.Error!.Code.Should().Be("IDENTITY_NOT_AUTHENTICATED"); + result.Success.Should().BeFalse(); + result.Code.Should().Be(SystemCode.ERR028); } [Fact] @@ -76,18 +77,19 @@ public async Task Persists_assignment_and_returns_dto_when_inputs_valid() var clock = new FakeSystemClock(); var db = BuildDb(users, new[] { country }); - var sut = new CreateStateRepAssignmentCommandHandler(db, service, currentUser, clock, BuildErrors()); + var sut = new CreateStateRepAssignmentCommandHandler(db, service, currentUser, clock, BuildMsg()); var result = await sut.Handle( new CreateStateRepAssignmentCommand(aliceId, country.Id), CancellationToken.None); - result.IsSuccess.Should().BeTrue(); + result.Success.Should().BeTrue(); result.Data!.UserId.Should().Be(aliceId); result.Data!.CountryId.Should().Be(country.Id); result.Data!.UserName.Should().Be("alice"); result.Data!.IsActive.Should().BeTrue(); - await service.Received(1).SaveAsync(Arg.Any(), Arg.Any()); + await service.Received(1).AddAsync(Arg.Any(), Arg.Any()); + await db.Received(1).SaveChangesAsync(Arg.Any()); } private static ICurrentUserAccessor BuildCurrentUser(System.Guid? userId = null) diff --git a/backend/tests/CCE.Application.Tests/Identity/Commands/RejectExpertRequestCommandHandlerTests.cs b/backend/tests/CCE.Application.Tests/Identity/Commands/RejectExpertRequestCommandHandlerTests.cs index b534d016..812dc58e 100644 --- a/backend/tests/CCE.Application.Tests/Identity/Commands/RejectExpertRequestCommandHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Identity/Commands/RejectExpertRequestCommandHandlerTests.cs @@ -1,6 +1,7 @@ using CCE.Application.Common.Interfaces; using CCE.Application.Identity; using CCE.Application.Identity.Commands.RejectExpertRequest; +using CCE.Application.Messages; using CCE.Domain.Common; using CCE.Domain.Identity; using CCE.TestInfrastructure.Time; @@ -18,14 +19,14 @@ public async Task Throws_KeyNotFound_when_request_missing() service.FindIncludingDeletedAsync(Arg.Any(), Arg.Any()) .Returns((ExpertRegistrationRequest?)null); - var sut = new RejectExpertRequestCommandHandler(service, BuildDb(), BuildCurrentUser(), new FakeSystemClock(), BuildErrors()); + var sut = new RejectExpertRequestCommandHandler(BuildDb(), service, BuildCurrentUser(), new FakeSystemClock(), BuildMsg()); var result = await sut.Handle( new RejectExpertRequestCommand(System.Guid.NewGuid(), "غير مؤهل", "Insufficient evidence."), CancellationToken.None); - result.IsSuccess.Should().BeFalse(); - result.Error!.Code.Should().Be("IDENTITY_EXPERT_REQUEST_NOT_FOUND"); + result.Success.Should().BeFalse(); + result.Code.Should().Be(SystemCode.ERR002); } [Fact] @@ -40,14 +41,14 @@ public async Task Throws_DomainException_when_actor_unknown() var currentUser = Substitute.For(); currentUser.GetUserId().Returns((System.Guid?)null); - var sut = new RejectExpertRequestCommandHandler(service, BuildDb(), currentUser, clock, BuildErrors()); + var sut = new RejectExpertRequestCommandHandler(BuildDb(), service, currentUser, clock, BuildMsg()); var result = await sut.Handle( new RejectExpertRequestCommand(registration.Id, "غير مؤهل", "Insufficient evidence."), CancellationToken.None); - result.IsSuccess.Should().BeFalse(); - result.Error!.Code.Should().Be("IDENTITY_NOT_AUTHENTICATED"); + result.Success.Should().BeFalse(); + result.Code.Should().Be(SystemCode.ERR028); } [Fact] @@ -63,7 +64,7 @@ public async Task Throws_DomainException_when_request_not_pending() service.FindIncludingDeletedAsync(Arg.Any(), Arg.Any()) .Returns(registration); - var sut = new RejectExpertRequestCommandHandler(service, BuildDb(), BuildCurrentUser(adminId), clock, BuildErrors()); + var sut = new RejectExpertRequestCommandHandler(BuildDb(), service, BuildCurrentUser(adminId), clock, BuildMsg()); var act = async () => await sut.Handle( new RejectExpertRequestCommand(registration.Id, "غير مؤهل", "Insufficient evidence."), @@ -86,8 +87,9 @@ public async Task Rejects_request_and_persists_when_valid() .Returns(registration); var users = new[] { BuildUser(requesterId, "alice@cce.local", "alice") }; + var db = BuildDb(users); - var sut = new RejectExpertRequestCommandHandler(service, BuildDb(users), BuildCurrentUser(adminId), clock, BuildErrors()); + var sut = new RejectExpertRequestCommandHandler(db, service, BuildCurrentUser(adminId), clock, BuildMsg()); var result = await sut.Handle( new RejectExpertRequestCommand(registration.Id, "غير مؤهل", "Insufficient evidence."), @@ -97,7 +99,7 @@ public async Task Rejects_request_and_persists_when_valid() result.Data!.RejectionReasonEn.Should().Be("Insufficient evidence."); result.Data!.RejectionReasonAr.Should().Be("غير مؤهل"); registration.Status.Should().Be(ExpertRegistrationStatus.Rejected); - await service.Received(1).SaveAsync(registration, null, Arg.Any()); + await db.Received(1).SaveChangesAsync(Arg.Any()); } private static ICurrentUserAccessor BuildCurrentUser(System.Guid? userId = null) diff --git a/backend/tests/CCE.Application.Tests/Identity/Commands/RevokeStateRepAssignmentCommandHandlerTests.cs b/backend/tests/CCE.Application.Tests/Identity/Commands/RevokeStateRepAssignmentCommandHandlerTests.cs index 749663fd..21fb083c 100644 --- a/backend/tests/CCE.Application.Tests/Identity/Commands/RevokeStateRepAssignmentCommandHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Identity/Commands/RevokeStateRepAssignmentCommandHandlerTests.cs @@ -2,6 +2,7 @@ using CCE.Application.Common.Interfaces; using CCE.Application.Identity; using CCE.Application.Identity.Commands.RevokeStateRepAssignment; +using CCE.Application.Messages; using CCE.Domain.Common; using CCE.Domain.Identity; using CCE.TestInfrastructure.Time; @@ -14,16 +15,17 @@ public class RevokeStateRepAssignmentCommandHandlerTests [Fact] public async Task Returns_failure_when_assignment_missing() { + var db = Substitute.For(); var service = Substitute.For(); service.FindIncludingRevokedAsync(Arg.Any(), Arg.Any()) .Returns((StateRepresentativeAssignment?)null); - var sut = new RevokeStateRepAssignmentCommandHandler(service, BuildCurrentUser(), new FakeSystemClock(), BuildErrors()); + var sut = new RevokeStateRepAssignmentCommandHandler(db, service, BuildCurrentUser(), new FakeSystemClock(), BuildMsg()); var result = await sut.Handle(new RevokeStateRepAssignmentCommand(System.Guid.NewGuid()), CancellationToken.None); - result.IsSuccess.Should().BeFalse(); - result.Error!.Code.Should().Be("IDENTITY_STATE_REP_ASSIGNMENT_NOT_FOUND"); + result.Success.Should().BeFalse(); + result.Code.Should().Be(SystemCode.ERR003); } [Fact] @@ -33,18 +35,19 @@ public async Task Returns_failure_when_actor_unknown() var assignment = StateRepresentativeAssignment.Assign( System.Guid.NewGuid(), System.Guid.NewGuid(), System.Guid.NewGuid(), clock); + var db = Substitute.For(); var service = Substitute.For(); service.FindIncludingRevokedAsync(Arg.Any(), Arg.Any()) .Returns(assignment); var currentUser = Substitute.For(); currentUser.GetUserId().Returns((System.Guid?)null); - var sut = new RevokeStateRepAssignmentCommandHandler(service, currentUser, clock, BuildErrors()); + var sut = new RevokeStateRepAssignmentCommandHandler(db, service, currentUser, clock, BuildMsg()); var result = await sut.Handle(new RevokeStateRepAssignmentCommand(assignment.Id), CancellationToken.None); - result.IsSuccess.Should().BeFalse(); - result.Error!.Code.Should().Be("IDENTITY_NOT_AUTHENTICATED"); + result.Success.Should().BeFalse(); + result.Code.Should().Be(SystemCode.ERR028); } [Fact] @@ -56,11 +59,12 @@ public async Task Throws_DomainException_when_already_revoked() System.Guid.NewGuid(), System.Guid.NewGuid(), revokerId, clock); assignment.Revoke(revokerId, clock); // already revoked + var db = Substitute.For(); var service = Substitute.For(); service.FindIncludingRevokedAsync(Arg.Any(), Arg.Any()) .Returns(assignment); - var sut = new RevokeStateRepAssignmentCommandHandler(service, BuildCurrentUser(revokerId), clock, BuildErrors()); + var sut = new RevokeStateRepAssignmentCommandHandler(db, service, BuildCurrentUser(revokerId), clock, BuildMsg()); var act = async () => await sut.Handle(new RevokeStateRepAssignmentCommand(assignment.Id), CancellationToken.None); @@ -75,19 +79,21 @@ public async Task Revokes_and_persists_when_valid() var assignment = StateRepresentativeAssignment.Assign( System.Guid.NewGuid(), System.Guid.NewGuid(), revokerId, clock); + var db = Substitute.For(); var service = Substitute.For(); service.FindIncludingRevokedAsync(Arg.Any(), Arg.Any()) .Returns(assignment); - var sut = new RevokeStateRepAssignmentCommandHandler(service, BuildCurrentUser(revokerId), clock, BuildErrors()); + var sut = new RevokeStateRepAssignmentCommandHandler(db, service, BuildCurrentUser(revokerId), clock, BuildMsg()); var result = await sut.Handle(new RevokeStateRepAssignmentCommand(assignment.Id), CancellationToken.None); - result.IsSuccess.Should().BeTrue(); + result.Success.Should().BeTrue(); assignment.IsDeleted.Should().BeTrue(); assignment.RevokedOn.Should().NotBeNull(); assignment.RevokedById.Should().Be(revokerId); - await service.Received(1).UpdateAsync(assignment, Arg.Any()); + service.Received(1).Update(assignment); + await db.Received(1).SaveChangesAsync(Arg.Any()); } private static ICurrentUserAccessor BuildCurrentUser(System.Guid? userId = null) diff --git a/backend/tests/CCE.Application.Tests/Identity/IdentityTestHelpers.cs b/backend/tests/CCE.Application.Tests/Identity/IdentityTestHelpers.cs index d18641d0..495ad221 100644 --- a/backend/tests/CCE.Application.Tests/Identity/IdentityTestHelpers.cs +++ b/backend/tests/CCE.Application.Tests/Identity/IdentityTestHelpers.cs @@ -1,18 +1,12 @@ using CCE.Application.Localization; +using CCE.Application.Messages; using NSubstitute; namespace CCE.Application.Tests.Identity; -/// -/// Shared helpers for Identity handler tests that need a localized factory. -/// public static class IdentityTestHelpers { - /// - /// Builds a instance backed by an - /// stub that returns the key as both Ar and En text. - /// - public static CCE.Application.Common.Errors BuildErrors() + public static MessageFactory BuildMsg() { var localization = Substitute.For(); localization.GetLocalizedMessage(Arg.Any()) @@ -20,6 +14,6 @@ public static CCE.Application.Common.Errors BuildErrors() Ar: call.Arg(), En: call.Arg())); - return new CCE.Application.Common.Errors(localization); + return new MessageFactory(localization); } } diff --git a/backend/tests/CCE.Application.Tests/Identity/Public/Commands/SubmitExpertRequestCommandHandlerTests.cs b/backend/tests/CCE.Application.Tests/Identity/Public/Commands/SubmitExpertRequestCommandHandlerTests.cs index ce95bd85..d943110f 100644 --- a/backend/tests/CCE.Application.Tests/Identity/Public/Commands/SubmitExpertRequestCommandHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Identity/Public/Commands/SubmitExpertRequestCommandHandlerTests.cs @@ -1,8 +1,11 @@ +using CCE.Application.Common.Interfaces; using CCE.Application.Identity.Public; using CCE.Application.Identity.Public.Commands.SubmitExpertRequest; +using CCE.Application.Messages; using CCE.Domain.Common; using CCE.Domain.Identity; using CCE.TestInfrastructure.Time; +using static CCE.Application.Tests.Identity.IdentityTestHelpers; namespace CCE.Application.Tests.Identity.Public.Commands; @@ -12,8 +15,9 @@ public class SubmitExpertRequestCommandHandlerTests public async Task Persists_request_and_returns_dto() { var clock = new FakeSystemClock(); + var db = Substitute.For(); var service = Substitute.For(); - var sut = new SubmitExpertRequestCommandHandler(service, clock); + var sut = new SubmitExpertRequestCommandHandler(db, service, clock, BuildMsg()); var requesterId = System.Guid.NewGuid(); var cmd = new SubmitExpertRequestCommand( @@ -24,22 +28,24 @@ public async Task Persists_request_and_returns_dto() var result = await sut.Handle(cmd, CancellationToken.None); - result.IsSuccess.Should().BeTrue(); + result.Success.Should().BeTrue(); result.Data!.RequestedById.Should().Be(requesterId); result.Data.RequestedBioAr.Should().Be("سيرة ذاتية"); result.Data.RequestedBioEn.Should().Be("English bio"); result.Data.RequestedTags.Should().BeEquivalentTo(new[] { "Hydrogen", "Solar" }); result.Data.Status.Should().Be(ExpertRegistrationStatus.Pending); result.Data.ProcessedOn.Should().BeNull(); - await service.Received(1).SaveAsync(Arg.Any(), Arg.Any()); + await service.Received(1).AddAsync(Arg.Any(), Arg.Any()); + await db.Received(1).SaveChangesAsync(Arg.Any()); } [Fact] public async Task Domain_throws_when_bio_is_empty() { var clock = new FakeSystemClock(); + var db = Substitute.For(); var service = Substitute.For(); - var sut = new SubmitExpertRequestCommandHandler(service, clock); + var sut = new SubmitExpertRequestCommandHandler(db, service, clock, BuildMsg()); var cmd = new SubmitExpertRequestCommand( System.Guid.NewGuid(), @@ -50,6 +56,7 @@ public async Task Domain_throws_when_bio_is_empty() var act = async () => await sut.Handle(cmd, CancellationToken.None); await act.Should().ThrowAsync(); - await service.DidNotReceiveWithAnyArgs().SaveAsync(default!, default); + await service.DidNotReceiveWithAnyArgs().AddAsync(default!, default); + await db.DidNotReceiveWithAnyArgs().SaveChangesAsync(default); } } diff --git a/backend/tests/CCE.Application.Tests/Identity/Public/Commands/UpdateMyProfileCommandHandlerTests.cs b/backend/tests/CCE.Application.Tests/Identity/Public/Commands/UpdateMyProfileCommandHandlerTests.cs index 6bd6bcaf..b1ad0a6d 100644 --- a/backend/tests/CCE.Application.Tests/Identity/Public/Commands/UpdateMyProfileCommandHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Identity/Public/Commands/UpdateMyProfileCommandHandlerTests.cs @@ -1,5 +1,7 @@ +using CCE.Application.Common.Interfaces; using CCE.Application.Identity.Public; using CCE.Application.Identity.Public.Commands.UpdateMyProfile; +using CCE.Application.Messages; using CCE.Domain.Identity; using static CCE.Application.Tests.Identity.IdentityTestHelpers; @@ -10,10 +12,11 @@ public class UpdateMyProfileCommandHandlerTests [Fact] public async Task Returns_null_when_user_not_found() { + var db = Substitute.For(); var service = Substitute.For(); service.FindAsync(Arg.Any(), Arg.Any()) .Returns((User?)null); - var sut = new UpdateMyProfileCommandHandler(service, BuildErrors()); + var sut = new UpdateMyProfileCommandHandler(db, service, BuildMsg()); var cmd = new UpdateMyProfileCommand( System.Guid.NewGuid(), "en", KnowledgeLevel.Intermediate, @@ -21,9 +24,10 @@ public async Task Returns_null_when_user_not_found() var result = await sut.Handle(cmd, CancellationToken.None); - result.IsSuccess.Should().BeFalse(); - result.Error!.Code.Should().Be("IDENTITY_USER_NOT_FOUND"); - await service.DidNotReceiveWithAnyArgs().UpdateAsync(default!, default); + result.Success.Should().BeFalse(); + result.Code.Should().Be(SystemCode.ERR001); + service.DidNotReceiveWithAnyArgs().Update(default!); + await db.DidNotReceiveWithAnyArgs().SaveChangesAsync(default); } [Fact] @@ -33,10 +37,10 @@ public async Task Updates_and_returns_dto_when_user_found() var countryId = System.Guid.NewGuid(); var user = new User { Id = userId, Email = "alice@cce.local", UserName = "alice" }; + var db = Substitute.For(); var service = Substitute.For(); service.FindAsync(userId, Arg.Any()).Returns(user); - service.UpdateAsync(Arg.Any(), Arg.Any()).Returns(System.Threading.Tasks.Task.CompletedTask); - var sut = new UpdateMyProfileCommandHandler(service, BuildErrors()); + var sut = new UpdateMyProfileCommandHandler(db, service, BuildMsg()); var cmd = new UpdateMyProfileCommand( userId, "en", KnowledgeLevel.Advanced, @@ -52,7 +56,8 @@ public async Task Updates_and_returns_dto_when_user_found() result.Data.Interests.Should().BeEquivalentTo(new[] { "Hydrogen", "Solar" }); result.Data.AvatarUrl.Should().Be("https://cdn.example.com/avatar.png"); result.Data.CountryId.Should().Be(countryId); - await service.Received(1).UpdateAsync(user, Arg.Any()); + service.Received(1).Update(user); + await db.Received(1).SaveChangesAsync(Arg.Any()); } [Fact] @@ -62,10 +67,10 @@ public async Task Clears_country_when_country_id_is_null() var user = new User { Id = userId }; user.AssignCountry(System.Guid.NewGuid()); + var db = Substitute.For(); var service = Substitute.For(); service.FindAsync(userId, Arg.Any()).Returns(user); - service.UpdateAsync(Arg.Any(), Arg.Any()).Returns(System.Threading.Tasks.Task.CompletedTask); - var sut = new UpdateMyProfileCommandHandler(service, BuildErrors()); + var sut = new UpdateMyProfileCommandHandler(db, service, BuildMsg()); var cmd = new UpdateMyProfileCommand( userId, "ar", KnowledgeLevel.Beginner, diff --git a/backend/tests/CCE.Application.Tests/Identity/Public/Queries/GetMyExpertStatusQueryHandlerTests.cs b/backend/tests/CCE.Application.Tests/Identity/Public/Queries/GetMyExpertStatusQueryHandlerTests.cs index 70761dd6..ce0dd608 100644 --- a/backend/tests/CCE.Application.Tests/Identity/Public/Queries/GetMyExpertStatusQueryHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Identity/Public/Queries/GetMyExpertStatusQueryHandlerTests.cs @@ -1,5 +1,6 @@ using CCE.Application.Common.Interfaces; using CCE.Application.Identity.Public.Queries.GetMyExpertStatus; +using CCE.Application.Messages; using CCE.Domain.Identity; using CCE.TestInfrastructure.Time; using static CCE.Application.Tests.Identity.IdentityTestHelpers; @@ -12,13 +13,12 @@ public class GetMyExpertStatusQueryHandlerTests public async Task Returns_null_when_no_request_exists() { var db = BuildDb(System.Array.Empty()); - var sut = new GetMyExpertStatusQueryHandler(db, BuildErrors()); + var sut = new GetMyExpertStatusQueryHandler(db, BuildMsg()); var result = await sut.Handle(new GetMyExpertStatusQuery(System.Guid.NewGuid()), CancellationToken.None); - result.IsSuccess.Should().BeFalse(); - result.Error.Should().NotBeNull(); - result.Error!.Code.Should().Be("IDENTITY_EXPERT_REQUEST_NOT_FOUND"); + result.Success.Should().BeFalse(); + result.Code.Should().Be(SystemCode.ERR002); } [Fact] @@ -29,7 +29,7 @@ public async Task Returns_dto_when_request_exists() var request = ExpertRegistrationRequest.Submit(userId, "سيرة", "Bio", new[] { "Wind" }, clock); var db = BuildDb(new[] { request }); - var sut = new GetMyExpertStatusQueryHandler(db, BuildErrors()); + var sut = new GetMyExpertStatusQueryHandler(db, BuildMsg()); var result = await sut.Handle(new GetMyExpertStatusQuery(userId), CancellationToken.None); @@ -51,7 +51,7 @@ public async Task Returns_latest_when_multiple_requests_exist() var newer = ExpertRegistrationRequest.Submit(userId, "أحدث", "Newer bio", new[] { "Wind" }, clock); var db = BuildDb(new[] { older, newer }); - var sut = new GetMyExpertStatusQueryHandler(db, BuildErrors()); + var sut = new GetMyExpertStatusQueryHandler(db, BuildMsg()); var result = await sut.Handle(new GetMyExpertStatusQuery(userId), CancellationToken.None); diff --git a/backend/tests/CCE.Application.Tests/Identity/Public/Queries/GetMyProfileQueryHandlerTests.cs b/backend/tests/CCE.Application.Tests/Identity/Public/Queries/GetMyProfileQueryHandlerTests.cs index 8a222b3d..864ab0d4 100644 --- a/backend/tests/CCE.Application.Tests/Identity/Public/Queries/GetMyProfileQueryHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Identity/Public/Queries/GetMyProfileQueryHandlerTests.cs @@ -1,5 +1,6 @@ using CCE.Application.Identity.Public; using CCE.Application.Identity.Public.Queries.GetMyProfile; +using CCE.Application.Messages; using CCE.Domain.Identity; using static CCE.Application.Tests.Identity.IdentityTestHelpers; @@ -13,13 +14,12 @@ public async Task Returns_null_when_user_not_found() var service = Substitute.For(); service.FindAsync(Arg.Any(), Arg.Any()) .Returns((User?)null); - var sut = new GetMyProfileQueryHandler(service, BuildErrors()); + var sut = new GetMyProfileQueryHandler(service, BuildMsg()); var result = await sut.Handle(new GetMyProfileQuery(System.Guid.NewGuid()), CancellationToken.None); - result.IsSuccess.Should().BeFalse(); - result.Error.Should().NotBeNull(); - result.Error!.Code.Should().Be("IDENTITY_USER_NOT_FOUND"); + result.Success.Should().BeFalse(); + result.Code.Should().Be(SystemCode.ERR001); } [Fact] @@ -35,7 +35,7 @@ public async Task Returns_profile_dto_when_user_found() var service = Substitute.For(); service.FindAsync(userId, Arg.Any()).Returns(user); - var sut = new GetMyProfileQueryHandler(service, BuildErrors()); + var sut = new GetMyProfileQueryHandler(service, BuildMsg()); var result = await sut.Handle(new GetMyProfileQuery(userId), CancellationToken.None); diff --git a/backend/tests/CCE.Application.Tests/Identity/Queries/GetUserByIdQueryHandlerTests.cs b/backend/tests/CCE.Application.Tests/Identity/Queries/GetUserByIdQueryHandlerTests.cs index ccc53451..e8a92c9d 100644 --- a/backend/tests/CCE.Application.Tests/Identity/Queries/GetUserByIdQueryHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Identity/Queries/GetUserByIdQueryHandlerTests.cs @@ -1,5 +1,6 @@ using CCE.Application.Common.Interfaces; using CCE.Application.Identity.Queries.GetUserById; +using CCE.Application.Messages; using CCE.Domain.Identity; using Microsoft.AspNetCore.Identity; using static CCE.Application.Tests.Identity.IdentityTestHelpers; @@ -12,13 +13,12 @@ public class GetUserByIdQueryHandlerTests public async Task Returns_null_when_user_not_found() { var db = BuildDb(System.Array.Empty(), System.Array.Empty(), System.Array.Empty>()); - var sut = new GetUserByIdQueryHandler(db, BuildErrors()); + var sut = new GetUserByIdQueryHandler(db, BuildMsg()); var result = await sut.Handle(new GetUserByIdQuery(System.Guid.NewGuid()), CancellationToken.None); - result.IsSuccess.Should().BeFalse(); - result.Error.Should().NotBeNull(); - result.Error!.Code.Should().Be("IDENTITY_USER_NOT_FOUND"); + result.Success.Should().BeFalse(); + result.Code.Should().Be(SystemCode.ERR001); } [Fact] @@ -31,7 +31,7 @@ public async Task Returns_user_detail_with_role_names_and_is_active_true() var userRoles = new[] { new IdentityUserRole { UserId = aliceId, RoleId = superAdminRoleId } }; var db = BuildDb(users, roles, userRoles); - var sut = new GetUserByIdQueryHandler(db, BuildErrors()); + var sut = new GetUserByIdQueryHandler(db, BuildMsg()); var result = await sut.Handle(new GetUserByIdQuery(aliceId), CancellationToken.None); @@ -54,7 +54,7 @@ public async Task Returns_is_active_false_when_lockout_active() alice.LockoutEnd = future; var db = BuildDb(new[] { alice }, System.Array.Empty(), System.Array.Empty>()); - var sut = new GetUserByIdQueryHandler(db, BuildErrors()); + var sut = new GetUserByIdQueryHandler(db, BuildMsg()); var result = await sut.Handle(new GetUserByIdQuery(aliceId), CancellationToken.None); From c0a8463fe0b9a0c034f0ba11789e31c4303d2873 Mon Sep 17 00:00:00 2001 From: MOHAMED RASHED Date: Sun, 17 May 2026 00:34:45 +0300 Subject: [PATCH 07/22] fix/ local dev token validation --- .../Auth/CceJwtAuthRegistration.cs | 1 + .../src/CCE.Api.Common/Auth/DevAuthHandler.cs | 63 ++++++++++++++++++- 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/backend/src/CCE.Api.Common/Auth/CceJwtAuthRegistration.cs b/backend/src/CCE.Api.Common/Auth/CceJwtAuthRegistration.cs index 9de4c78d..34fcfa44 100644 --- a/backend/src/CCE.Api.Common/Auth/CceJwtAuthRegistration.cs +++ b/backend/src/CCE.Api.Common/Auth/CceJwtAuthRegistration.cs @@ -33,6 +33,7 @@ public static IServiceCollection AddCceJwtAuth( }) .AddScheme( DevAuthHandler.SchemeName, _ => { }); + services.Configure(configuration.GetSection(LocalAuthOptions.SectionName)); services.AddHostedService(); services.Configure(configuration.GetSection(EntraIdOptions.SectionName)); services.AddAuthorization(); diff --git a/backend/src/CCE.Api.Common/Auth/DevAuthHandler.cs b/backend/src/CCE.Api.Common/Auth/DevAuthHandler.cs index d4b1ba47..74cd06e3 100644 --- a/backend/src/CCE.Api.Common/Auth/DevAuthHandler.cs +++ b/backend/src/CCE.Api.Common/Auth/DevAuthHandler.cs @@ -1,8 +1,12 @@ +using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; +using System.Text; using System.Text.Encodings.Web; +using CCE.Application.Identity.Auth.Common; using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; namespace CCE.Api.Common.Auth; @@ -51,11 +55,17 @@ public sealed class DevAuthHandler : AuthenticationHandler _localAuthOptions; + public DevAuthHandler( IOptionsMonitor options, ILoggerFactory logger, - UrlEncoder encoder) - : base(options, logger, encoder) { } + UrlEncoder encoder, + IOptions localAuthOptions) + : base(options, logger, encoder) + { + _localAuthOptions = localAuthOptions; + } protected override Task HandleAuthenticateAsync() { @@ -96,11 +106,60 @@ protected override Task HandleAuthenticateAsync() if (Request.Headers.TryGetValue("Authorization", out var auth)) { var raw = auth.ToString(); + const string devPrefix = "Bearer dev:"; if (raw.StartsWith(devPrefix, StringComparison.OrdinalIgnoreCase)) { return raw.Substring(devPrefix.Length).Trim(); } + + // Fallback: try to decode as a real JWT (e.g. issued by /api/auth/login) + const string bearerPrefix = "Bearer "; + if (raw.StartsWith(bearerPrefix, StringComparison.OrdinalIgnoreCase)) + { + var token = raw.Substring(bearerPrefix.Length).Trim(); + return TryReadRoleFromJwt(token); + } + } + + return null; + } + + private string? TryReadRoleFromJwt(string token) + { + try + { + var opts = _localAuthOptions.Value; + var profiles = new[] { opts.External, opts.Internal }; + var handler = new JwtSecurityTokenHandler { MapInboundClaims = false }; + + foreach (var profile in profiles) + { + if (string.IsNullOrWhiteSpace(profile.SigningKey)) + continue; + + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(profile.SigningKey)); + var parameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = profile.Issuer, + ValidateAudience = true, + ValidAudience = profile.Audience, + ValidateIssuerSigningKey = true, + IssuerSigningKey = key, + ValidateLifetime = true, + ClockSkew = TimeSpan.FromMinutes(2), + }; + + var principal = handler.ValidateToken(token, parameters, out _); + var role = principal.FindFirst("roles")?.Value; + if (!string.IsNullOrEmpty(role)) + return role; + } + } + catch (Exception ex) + { + Logger.LogDebug(ex, "Failed to validate JWT in DevAuthHandler fallback"); } return null; From d1c63483af53971f38371d8b27ec2ce75312389a Mon Sep 17 00:00:00 2001 From: MOHAMED RASHED Date: Sun, 17 May 2026 11:06:27 +0300 Subject: [PATCH 08/22] =?UTF-8?q?feat:=20dynamic=20single-language=20messa?= =?UTF-8?q?ges=20based=20on=20Accept-Language=20header=20BREAKING=20CHANGE?= =?UTF-8?q?:=20Response.message=20is=20now=20a=20plain=20string=20instead?= =?UTF-8?q?=20of=20{=20ar,=20en=20}=20bilingual=20object,=20and=20FieldErr?= =?UTF-8?q?or.message=20is=20also=20a=20string.=20LocalizationService.GetS?= =?UTF-8?q?tring()=20now=20defaults=20to=20CultureInfo.CurrentUICulture=20?= =?UTF-8?q?(set=20by=20LocalizationMiddleware=20from=20the=20Accept-Langua?= =?UTF-8?q?ge=20header)=20instead=20of=20hardcoded=20"ar".=20Changes:=20-?= =?UTF-8?q?=20Response.Message:=20LocalizedMessage=20=E2=86=92=20string?= =?UTF-8?q?=20-=20FieldError.Message:=20LocalizedMessage=20=E2=86=92=20str?= =?UTF-8?q?ing=20-=20MessageFactory=20uses=20=5Fl.GetString()=20instead=20?= =?UTF-8?q?of=20GetLocalizedMessage()=20-=20ExceptionHandlingMiddleware=20?= =?UTF-8?q?returns=20single=20message=20string=20-=20ResponseValidationBeh?= =?UTF-8?q?avior=20uses=20GetString()=20for=20validation=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Localization/Resources.yaml | 24 ++--- .../Middleware/ExceptionHandlingMiddleware.cs | 24 ++--- .../Behaviors/ResponseValidationBehavior.cs | 10 +-- .../src/CCE.Application/Common/FieldError.cs | 4 +- .../src/CCE.Application/Common/Response.cs | 16 ++-- .../Register/RegisterUserCommandValidator.cs | 4 +- .../Messages/MessageFactory.cs | 10 +-- .../CCE.Application/Messages/SystemCode.cs | 90 +++++++++++-------- .../CCE.Application/Messages/SystemCodeMap.cs | 53 ++++++----- .../Localization/LocalizationService.cs | 12 ++- .../DependencyInjectionTests.cs | 4 +- ...ApproveExpertRequestCommandHandlerTests.cs | 4 +- .../AssignUserRolesCommandHandlerTests.cs | 14 +-- ...teStateRepAssignmentCommandHandlerTests.cs | 2 +- .../RejectExpertRequestCommandHandlerTests.cs | 4 +- ...keStateRepAssignmentCommandHandlerTests.cs | 4 +- .../Identity/IdentityTestHelpers.cs | 6 +- .../GetMyExpertStatusQueryHandlerTests.cs | 2 +- 18 files changed, 150 insertions(+), 137 deletions(-) diff --git a/backend/src/CCE.Api.Common/Localization/Resources.yaml b/backend/src/CCE.Api.Common/Localization/Resources.yaml index 820803ae..3bac7e4a 100644 --- a/backend/src/CCE.Api.Common/Localization/Resources.yaml +++ b/backend/src/CCE.Api.Common/Localization/Resources.yaml @@ -223,24 +223,24 @@ REGISTRATION_FAILED: # ─── Identity Bare Keys (success) ─── REGISTER_SUCCESS: - ar: "تم إنشاء الحساب بنجاح" - en: "Account created successfully" + ar: "تم إنشاء المستخدم بنجاح!" + en: "User created successfully!" LOGIN_SUCCESS: ar: "تم تسجيل الدخول بنجاح" en: "Logged in successfully" LOGOUT_SUCCESS: - ar: "تم تسجيل الخروج بنجاح" - en: "Logged out successfully" + ar: "تم تسجيل الخروج بنجاح." + en: "Logged out successfully." TOKEN_REFRESHED: ar: "تم تحديث الرمز بنجاح" en: "Token refreshed successfully" PASSWORD_RESET: - ar: "تم إعادة تعيين كلمة المرور بنجاح" - en: "Password reset successfully" + ar: "تمت استعادة كلمة المرور بنجاح!" + en: "Password recovered successfully!" ROLES_ASSIGNED: ar: "تم تعيين الأدوار بنجاح" @@ -255,20 +255,20 @@ EXPERT_REQUEST_REJECTED: en: "Expert request rejected" EXPERT_REQUEST_SUBMITTED: - ar: "تم تقديم طلب الخبير بنجاح" - en: "Expert request submitted successfully" + ar: "تم تقديم طلبك بنجاح لتسجيلك كخبير في مجتمع المعرفة. سيتم مراجعة طلبك قريباً." + en: "Your request to register as an expert in the Knowledge Community has been submitted successfully. It will be reviewed shortly." STATE_REP_ASSIGNMENT_CREATED: - ar: "تم إنشاء التعيين بنجاح" - en: "Assignment created successfully" + ar: "تم إرسال طلبك بنجاح. سيتم مراجعته من قبل المشرف قريباً. شكراً لمساهمتك!" + en: "Your request has been sent successfully. It will be reviewed by the admin shortly. Thank you for your contribution!" STATE_REP_ASSIGNMENT_REVOKED: ar: "تم إلغاء التعيين بنجاح" en: "Assignment revoked successfully" PROFILE_UPDATED: - ar: "تم تحديث الملف الشخصي بنجاح" - en: "Profile updated successfully" + ar: "تم تحديث بيانات الملف الشخصي بنجاح. يمكنك الآن الاطلاع على المعلومات المحدثة في ملفك الشخصي." + en: "Profile data updated successfully. You can now view the updated information in your profile." SUCCESS_OPERATION: ar: "تمت العملية بنجاح" diff --git a/backend/src/CCE.Api.Common/Middleware/ExceptionHandlingMiddleware.cs b/backend/src/CCE.Api.Common/Middleware/ExceptionHandlingMiddleware.cs index cfb6df74..77a2eb63 100644 --- a/backend/src/CCE.Api.Common/Middleware/ExceptionHandlingMiddleware.cs +++ b/backend/src/CCE.Api.Common/Middleware/ExceptionHandlingMiddleware.cs @@ -65,18 +65,14 @@ private static async Task WriteErrorAsync( HttpContext ctx, int statusCode, string domainKey, MessageType type, string? fallbackMessage) { var l = ctx.RequestServices.GetService(); - var msg = l?.GetLocalizedMessage(domainKey); + var msg = l?.GetString(domainKey) ?? fallbackMessage ?? "خطأ"; var code = SystemCodeMap.ToSystemCode(domainKey); var envelope = new { success = false, code, - message = new - { - ar = msg?.Ar ?? fallbackMessage ?? "خطأ", - en = msg?.En ?? fallbackMessage ?? "Error" - }, + message = msg, data = (object?)null, errors = Array.Empty(), traceId = Activity.Current?.Id ?? ctx.TraceIdentifier, @@ -92,23 +88,19 @@ await JsonSerializer.SerializeAsync(ctx.Response.Body, envelope, JsonOptions) private static async Task WriteValidationResultAsync(HttpContext ctx, ValidationException ex) { var l = ctx.RequestServices.GetService(); - var headerMsg = l?.GetLocalizedMessage("VALIDATION_ERROR"); + var headerMsg = l?.GetString("VALIDATION_ERROR") ?? "عذرًا، البيانات المدخلة غير صحيحة"; var headerCode = SystemCodeMap.ToSystemCode("VALIDATION_ERROR"); var fieldErrors = ex.Errors.Select(e => { var domainKey = e.ErrorMessage; var valCode = SystemCodeMap.ToSystemCode(domainKey); - var valMsg = l?.GetLocalizedMessage(domainKey); + var valMsg = l?.GetString(domainKey) ?? domainKey; return new { field = ToCamelCase(e.PropertyName), code = valCode, - message = new - { - ar = valMsg?.Ar ?? domainKey, - en = valMsg?.En ?? domainKey - } + message = valMsg }; }).ToList(); @@ -116,11 +108,7 @@ private static async Task WriteValidationResultAsync(HttpContext ctx, Validation { success = false, code = headerCode, - message = new - { - ar = headerMsg?.Ar ?? "عذرًا، البيانات المدخلة غير صحيحة", - en = headerMsg?.En ?? "Sorry, the entered data is invalid" - }, + message = headerMsg, data = (object?)null, errors = fieldErrors, traceId = Activity.Current?.Id ?? ctx.TraceIdentifier, diff --git a/backend/src/CCE.Application/Common/Behaviors/ResponseValidationBehavior.cs b/backend/src/CCE.Application/Common/Behaviors/ResponseValidationBehavior.cs index b67e28cf..920459b4 100644 --- a/backend/src/CCE.Application/Common/Behaviors/ResponseValidationBehavior.cs +++ b/backend/src/CCE.Application/Common/Behaviors/ResponseValidationBehavior.cs @@ -49,24 +49,24 @@ public async Task Handle( { var domainKey = f.ErrorMessage; var valCode = SystemCodeMap.ToSystemCode(domainKey); - var msg = _l.GetLocalizedMessage(domainKey); + var msg = _l.GetString(domainKey); return new FieldError( ToCamelCase(f.PropertyName), valCode, - new LocalizedMessage(msg.Ar, msg.En)); + msg); }).ToList(); var headerDomainKey = "VALIDATION_ERROR"; var headerCode = SystemCodeMap.ToSystemCode(headerDomainKey); - var headerMsg = _l.GetLocalizedMessage(headerDomainKey); + var headerMsg = _l.GetString(headerDomainKey); var failMethod = responseType.GetMethod("Fail", - new[] { typeof(string), typeof(LocalizedMessage), typeof(MessageType), typeof(IReadOnlyList) }); + new[] { typeof(string), typeof(string), typeof(MessageType), typeof(IReadOnlyList) }); return (TResponse)failMethod!.Invoke(null, new object[] { headerCode, - new LocalizedMessage(headerMsg.Ar, headerMsg.En), + headerMsg, MessageType.Validation, fieldErrors })!; diff --git a/backend/src/CCE.Application/Common/FieldError.cs b/backend/src/CCE.Application/Common/FieldError.cs index caa6e7cc..b5448d19 100644 --- a/backend/src/CCE.Application/Common/FieldError.cs +++ b/backend/src/CCE.Application/Common/FieldError.cs @@ -1,8 +1,6 @@ -using CCE.Application.Localization; - namespace CCE.Application.Common; public sealed record FieldError( string Field, string Code, - LocalizedMessage Message); + string Message); diff --git a/backend/src/CCE.Application/Common/Response.cs b/backend/src/CCE.Application/Common/Response.cs index 65b458f2..05802b6f 100644 --- a/backend/src/CCE.Application/Common/Response.cs +++ b/backend/src/CCE.Application/Common/Response.cs @@ -1,4 +1,3 @@ -using CCE.Application.Localization; using CCE.Domain.Common; using System.Text.Json.Serialization; @@ -8,12 +7,13 @@ namespace CCE.Application.Common; /// Unified API response envelope. Every endpoint returns this shape. /// Replaces with proper success messages and error arrays. /// Code field uses ERR0xx/CON0xx/VAL0xx numbering. +/// Message is a single string in the language requested via Accept-Language header. /// public sealed record Response { [JsonInclude] public bool Success { get; private init; } [JsonInclude] public string Code { get; private init; } = string.Empty; - [JsonInclude] public LocalizedMessage Message { get; private init; } = new("", ""); + [JsonInclude] public string Message { get; private init; } = string.Empty; [JsonInclude] public T? Data { get; private init; } [JsonInclude] public IReadOnlyList Errors { get; private init; } = []; [JsonInclude] public string TraceId { get; init; } = string.Empty; @@ -26,7 +26,7 @@ public Response() { } // ─── Success Factories ─── - public static Response Ok(T data, string code, LocalizedMessage message) => new() + public static Response Ok(T data, string code, string message) => new() { Success = true, Code = code, @@ -36,7 +36,7 @@ public Response() { } }; /// Shorthand for void commands that return no data. - public static Response Ok(string code, LocalizedMessage message) => new() + public static Response Ok(string code, string message) => new() { Success = true, Code = code, @@ -47,7 +47,7 @@ public Response() { } // ─── Failure Factories ─── - public static Response Fail(string code, LocalizedMessage message, MessageType type) => new() + public static Response Fail(string code, string message, MessageType type) => new() { Success = false, Code = code, @@ -56,7 +56,7 @@ public Response() { } }; public static Response Fail( - string code, LocalizedMessage message, MessageType type, IReadOnlyList errors) => new() + string code, string message, MessageType type, IReadOnlyList errors) => new() { Success = false, Code = code, @@ -76,9 +76,9 @@ private VoidData() { } /// Non-generic companion for void commands. public static class Response { - public static Response Ok(string code, LocalizedMessage message) + public static Response Ok(string code, string message) => Response.Ok(code, message); - public static Response Fail(string code, LocalizedMessage message, MessageType type) + public static Response Fail(string code, string message, MessageType type) => Response.Fail(code, message, type); } diff --git a/backend/src/CCE.Application/Identity/Auth/Register/RegisterUserCommandValidator.cs b/backend/src/CCE.Application/Identity/Auth/Register/RegisterUserCommandValidator.cs index 7bab1917..05c40a72 100644 --- a/backend/src/CCE.Application/Identity/Auth/Register/RegisterUserCommandValidator.cs +++ b/backend/src/CCE.Application/Identity/Auth/Register/RegisterUserCommandValidator.cs @@ -11,13 +11,15 @@ public RegisterUserCommandValidator() RuleFor(x => x.EmailAddress).NotEmpty().EmailAddress().MaximumLength(100); RuleFor(x => x.JobTitle).NotEmpty().MaximumLength(50); RuleFor(x => x.OrganizationName).NotEmpty().MaximumLength(100); - RuleFor(x => x.PhoneNumber).NotEmpty().MaximumLength(15); + RuleFor(x => x.PhoneNumber).NotEmpty().MaximumLength(15).Must(BenumbersOnly); RuleFor(x => x.Password).Must(MatchStoryPasswordPolicy).WithMessage("PASSWORD_POLICY"); RuleFor(x => x.ConfirmPassword).Equal(x => x.Password); } private static bool BeLettersOnly(string value) => !string.IsNullOrWhiteSpace(value) && value.All(char.IsLetter); + private static bool BenumbersOnly(string value) + => !string.IsNullOrWhiteSpace(value) && value.All(char.IsNumber); internal static bool MatchStoryPasswordPolicy(string value) => !string.IsNullOrWhiteSpace(value) diff --git a/backend/src/CCE.Application/Messages/MessageFactory.cs b/backend/src/CCE.Application/Messages/MessageFactory.cs index 1027d34f..6ba4868a 100644 --- a/backend/src/CCE.Application/Messages/MessageFactory.cs +++ b/backend/src/CCE.Application/Messages/MessageFactory.cs @@ -6,8 +6,8 @@ namespace CCE.Application.Messages; /// /// Factory for building instances with localized messages. -/// Takes domain keys (e.g. "USER_NOT_FOUND"), resolves bilingual message from Resources.yaml, -/// and maps to system codes (e.g. "ERR001") via . +/// Takes domain keys (e.g. "USER_NOT_FOUND"), resolves message in the request language +/// from Resources.yaml, and maps to system codes (e.g. "ERR001") via . /// public sealed class MessageFactory { @@ -88,9 +88,5 @@ private Response Fail(string domainKey, MessageType type) return Response.Fail(code, msg, type); } - private LocalizedMessage Localize(string domainKey) - { - var raw = _l.GetLocalizedMessage(domainKey); - return new LocalizedMessage(raw.Ar, raw.En); - } + private string Localize(string domainKey) => _l.GetString(domainKey); } diff --git a/backend/src/CCE.Application/Messages/SystemCode.cs b/backend/src/CCE.Application/Messages/SystemCode.cs index 12454092..beb7aabc 100644 --- a/backend/src/CCE.Application/Messages/SystemCode.cs +++ b/backend/src/CCE.Application/Messages/SystemCode.cs @@ -15,23 +15,36 @@ public static class SystemCode // ERR — Error codes (failures) // ════════════════════════════════════════════════════════════════ - // ─── Identity Errors ─── - public const string ERR001 = "ERR001"; // User not found - public const string ERR002 = "ERR002"; // Expert request not found - public const string ERR003 = "ERR003"; // State rep assignment not found - - public const string ERR019 = "ERR019"; // Email already exists - public const string ERR020 = "ERR020"; // Invalid credentials - public const string ERR021 = "ERR021"; // Invalid / expired token - public const string ERR022 = "ERR022"; // Invalid refresh token - public const string ERR023 = "ERR023"; // Password recovery failed - public const string ERR024 = "ERR024"; // Logout failed - public const string ERR025 = "ERR025"; // Account deactivated - public const string ERR026 = "ERR026"; // Username already exists - public const string ERR027 = "ERR027"; // Registration failed - public const string ERR028 = "ERR028"; // Not authenticated - public const string ERR029 = "ERR029"; // Expert request already exists - public const string ERR030 = "ERR030"; // State rep assignment already exists + // ─── Identity Errors (appendix-aligned) ─── + // ERR001-ERR018 reserved for appendix frontend codes + public const string ERR001 = "ERR001"; // User not found (also used as ERR001 in appendix — keep) + public const string ERR002 = "ERR002"; // Resource download failure (appendix) + public const string ERR003 = "ERR003"; // Resource share failure (appendix) + + public const string ERR019 = "ERR019"; // Email already exists / Account creation failure (appendix) + public const string ERR020 = "ERR020"; // Invalid credentials (appendix) + public const string ERR021 = "ERR021"; // Login system error (appendix) + public const string ERR022 = "ERR022"; // Email not found in password recovery (appendix) + public const string ERR023 = "ERR023"; // Password recovery system error + public const string ERR024 = "ERR024"; // Logout failure + public const string ERR025 = "ERR025"; // Content update failure (appendix) + public const string ERR026 = "ERR026"; // User deletion failure (appendix) + public const string ERR027 = "ERR027"; // News/event upload failure (appendix) + public const string ERR028 = "ERR028"; // News/event deletion failure (appendix) + public const string ERR029 = "ERR029"; // Resource upload failure (appendix) + public const string ERR030 = "ERR030"; // Resource deletion failure (appendix) + + // ─── Backend-only Identity Errors (moved to free appendix numbers) ─── + public const string ERR400 = "ERR400"; // Expert request not found + public const string ERR401 = "ERR401"; // State rep assignment not found + public const string ERR402 = "ERR402"; // Invalid / expired token + public const string ERR403 = "ERR403"; // Invalid refresh token + public const string ERR404 = "ERR404"; // Account deactivated + public const string ERR405 = "ERR405"; // Username already exists + public const string ERR406 = "ERR406"; // Registration failed + public const string ERR407 = "ERR407"; // Not authenticated + public const string ERR408 = "ERR408"; // Expert request already exists + public const string ERR409 = "ERR409"; // State rep assignment already exists // ─── Content Errors ─── public const string ERR040 = "ERR040"; // News not found @@ -92,24 +105,31 @@ public static class SystemCode // CON — Confirmation / Success codes // ════════════════════════════════════════════════════════════════ - // ─── Identity Success ─── - public const string CON001 = "CON001"; // Login success - public const string CON002 = "CON002"; // Register success - public const string CON003 = "CON003"; // Logout success - public const string CON004 = "CON004"; // Token refreshed - public const string CON005 = "CON005"; // User updated - public const string CON006 = "CON006"; // User created - public const string CON007 = "CON007"; // User deleted - public const string CON008 = "CON008"; // User activated - public const string CON009 = "CON009"; // User deactivated - public const string CON010 = "CON010"; // Roles assigned - public const string CON011 = "CON011"; // Password reset success - public const string CON012 = "CON012"; // Expert request submitted - public const string CON013 = "CON013"; // Expert request approved - public const string CON014 = "CON014"; // Expert request rejected - public const string CON015 = "CON015"; // State rep assignment created - public const string CON016 = "CON016"; // State rep assignment revoked - public const string CON017 = "CON017"; // Profile updated + // ─── Identity Success (appendix-aligned) ─── + public const string CON001 = "CON001"; // Resource download success (appendix) + public const string CON002 = "CON002"; // Resource share success (appendix) + public const string CON003 = "CON003"; // Generic share success (appendix) + public const string CON004 = "CON004"; // Event added to calendar (appendix) + public const string CON005 = "CON005"; // Profile update success (appendix) + public const string CON006 = "CON006"; // Expert registration request submitted (appendix) + public const string CON007 = "CON007"; // Admin notified of expert request (appendix) + public const string CON008 = "CON008"; // Service evaluation submitted (appendix) + public const string CON009 = "CON009"; // Personalized suggestions submitted (appendix) + public const string CON010 = "CON010"; // Topic follow success (appendix) + public const string CON011 = "CON011"; // Post created (appendix) + public const string CON012 = "CON012"; // Post follow success (appendix) + public const string CON013 = "CON013"; // Reply submitted (appendix) + public const string CON014 = "CON014"; // Password recovery success (appendix) + public const string CON015 = "CON015"; // Logout success (appendix) + public const string CON016 = "CON016"; // Content update success (appendix) + public const string CON017 = "CON017"; // User creation success (appendix) + + // ─── Backend-only Identity Success (appendix numbers already taken) ─── + public const string CON050 = "CON050"; // Expert request approved + public const string CON051 = "CON051"; // Expert request rejected + public const string CON052 = "CON052"; // State rep assignment created + public const string CON053 = "CON053"; // State rep assignment revoked + public const string CON054 = "CON054"; // Roles assigned // ─── Content Success ─── public const string CON020 = "CON020"; // Content created diff --git a/backend/src/CCE.Application/Messages/SystemCodeMap.cs b/backend/src/CCE.Application/Messages/SystemCodeMap.cs index 2a869e2f..f53ae1a5 100644 --- a/backend/src/CCE.Application/Messages/SystemCodeMap.cs +++ b/backend/src/CCE.Application/Messages/SystemCodeMap.cs @@ -8,22 +8,24 @@ public static class SystemCodeMap { private static readonly Dictionary DomainToCode = new(StringComparer.OrdinalIgnoreCase) { - // ─── Identity Errors ─── + // ─── Identity Errors (appendix-aligned) ─── ["USER_NOT_FOUND"] = SystemCode.ERR001, - ["EXPERT_REQUEST_NOT_FOUND"] = SystemCode.ERR002, - ["STATE_REP_ASSIGNMENT_NOT_FOUND"] = SystemCode.ERR003, ["EMAIL_EXISTS"] = SystemCode.ERR019, ["INVALID_CREDENTIALS"] = SystemCode.ERR020, - ["INVALID_TOKEN"] = SystemCode.ERR021, - ["INVALID_REFRESH_TOKEN"] = SystemCode.ERR022, ["PASSWORD_RECOVERY_FAILED"] = SystemCode.ERR023, ["LOGOUT_FAILED"] = SystemCode.ERR024, - ["ACCOUNT_DEACTIVATED"] = SystemCode.ERR025, - ["USERNAME_EXISTS"] = SystemCode.ERR026, - ["REGISTRATION_FAILED"] = SystemCode.ERR027, - ["NOT_AUTHENTICATED"] = SystemCode.ERR028, - ["EXPERT_REQUEST_ALREADY_EXISTS"] = SystemCode.ERR029, - ["STATE_REP_ASSIGNMENT_EXISTS"] = SystemCode.ERR030, + + // ─── Backend-only Identity Errors (moved to free appendix numbers) ─── + ["EXPERT_REQUEST_NOT_FOUND"] = SystemCode.ERR400, + ["STATE_REP_ASSIGNMENT_NOT_FOUND"] = SystemCode.ERR401, + ["INVALID_TOKEN"] = SystemCode.ERR402, + ["INVALID_REFRESH_TOKEN"] = SystemCode.ERR403, + ["ACCOUNT_DEACTIVATED"] = SystemCode.ERR404, + ["USERNAME_EXISTS"] = SystemCode.ERR405, + ["REGISTRATION_FAILED"] = SystemCode.ERR406, + ["NOT_AUTHENTICATED"] = SystemCode.ERR407, + ["EXPERT_REQUEST_ALREADY_EXISTS"] = SystemCode.ERR408, + ["STATE_REP_ASSIGNMENT_EXISTS"] = SystemCode.ERR409, // ─── Content Errors ─── ["NEWS_NOT_FOUND"] = SystemCode.ERR040, @@ -80,24 +82,21 @@ public static class SystemCodeMap ["CONCURRENCY_CONFLICT"] = SystemCode.ERR907, ["DUPLICATE_VALUE"] = SystemCode.ERR908, - // ─── Identity Success ─── + // ─── Identity Success (appendix-aligned) ─── ["LOGIN_SUCCESS"] = SystemCode.CON001, - ["REGISTER_SUCCESS"] = SystemCode.CON002, - ["LOGOUT_SUCCESS"] = SystemCode.CON003, ["TOKEN_REFRESHED"] = SystemCode.CON004, - ["USER_UPDATED"] = SystemCode.CON005, - ["USER_CREATED"] = SystemCode.CON006, - ["USER_DELETED"] = SystemCode.CON007, - ["USER_ACTIVATED"] = SystemCode.CON008, - ["USER_DEACTIVATED"] = SystemCode.CON009, - ["ROLES_ASSIGNED"] = SystemCode.CON010, - ["PASSWORD_RESET"] = SystemCode.CON011, - ["EXPERT_REQUEST_SUBMITTED"] = SystemCode.CON012, - ["EXPERT_REQUEST_APPROVED"] = SystemCode.CON013, - ["EXPERT_REQUEST_REJECTED"] = SystemCode.CON014, - ["STATE_REP_ASSIGNMENT_CREATED"] = SystemCode.CON015, - ["STATE_REP_ASSIGNMENT_REVOKED"] = SystemCode.CON016, - ["PROFILE_UPDATED"] = SystemCode.CON017, + ["PROFILE_UPDATED"] = SystemCode.CON005, + ["EXPERT_REQUEST_SUBMITTED"] = SystemCode.CON006, + ["PASSWORD_RESET"] = SystemCode.CON014, + ["LOGOUT_SUCCESS"] = SystemCode.CON015, + ["REGISTER_SUCCESS"] = SystemCode.CON017, + + // ─── Backend-only Identity Success (appendix numbers already taken) ─── + ["EXPERT_REQUEST_APPROVED"] = SystemCode.CON050, + ["EXPERT_REQUEST_REJECTED"] = SystemCode.CON051, + ["STATE_REP_ASSIGNMENT_CREATED"] = SystemCode.CON052, + ["STATE_REP_ASSIGNMENT_REVOKED"] = SystemCode.CON053, + ["ROLES_ASSIGNED"] = SystemCode.CON054, // ─── Content Success ─── ["CONTENT_CREATED"] = SystemCode.CON020, diff --git a/backend/src/CCE.Infrastructure/Localization/LocalizationService.cs b/backend/src/CCE.Infrastructure/Localization/LocalizationService.cs index ee109d9e..ebfbdfde 100644 --- a/backend/src/CCE.Infrastructure/Localization/LocalizationService.cs +++ b/backend/src/CCE.Infrastructure/Localization/LocalizationService.cs @@ -46,7 +46,17 @@ public LocalizedMessage GetLocalizedMessage(string key) private static string GetTwoLetterCode(string? culture) { - if (string.IsNullOrWhiteSpace(culture)) return "ar"; + if (string.IsNullOrWhiteSpace(culture)) + { + try + { + return CultureInfo.CurrentUICulture.TwoLetterISOLanguageName; + } + catch (CultureNotFoundException) + { + return "ar"; + } + } try { return new CultureInfo(culture).TwoLetterISOLanguageName; diff --git a/backend/tests/CCE.Application.Tests/DependencyInjectionTests.cs b/backend/tests/CCE.Application.Tests/DependencyInjectionTests.cs index 1e243028..a724e3e9 100644 --- a/backend/tests/CCE.Application.Tests/DependencyInjectionTests.cs +++ b/backend/tests/CCE.Application.Tests/DependencyInjectionTests.cs @@ -19,7 +19,7 @@ public async Task Mediator_resolves_HealthQuery_handler_through_pipeline() services.AddSingleton(_ => { var l = NSubstitute.Substitute.For(); - l.GetLocalizedMessage(Arg.Any()).Returns(new LocalizedMessage("ar", "en")); + l.GetString(Arg.Any(), Arg.Any()).Returns("ar"); return l; }); services.AddApplication(); @@ -42,7 +42,7 @@ public async Task Mediator_resolves_AuthenticatedHealthQuery_handler_through_pip services.AddSingleton(_ => { var l = NSubstitute.Substitute.For(); - l.GetLocalizedMessage(Arg.Any()).Returns(new LocalizedMessage("ar", "en")); + l.GetString(Arg.Any(), Arg.Any()).Returns("ar"); return l; }); services.AddApplication(); diff --git a/backend/tests/CCE.Application.Tests/Identity/Commands/ApproveExpertRequestCommandHandlerTests.cs b/backend/tests/CCE.Application.Tests/Identity/Commands/ApproveExpertRequestCommandHandlerTests.cs index a8a0a89a..86461bd4 100644 --- a/backend/tests/CCE.Application.Tests/Identity/Commands/ApproveExpertRequestCommandHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Identity/Commands/ApproveExpertRequestCommandHandlerTests.cs @@ -26,7 +26,7 @@ public async Task Throws_KeyNotFound_when_request_missing() CancellationToken.None); result.Success.Should().BeFalse(); - result.Code.Should().Be(SystemCode.ERR002); + result.Code.Should().Be(SystemCode.ERR400); } [Fact] @@ -48,7 +48,7 @@ public async Task Throws_DomainException_when_actor_unknown() CancellationToken.None); result.Success.Should().BeFalse(); - result.Code.Should().Be(SystemCode.ERR028); + result.Code.Should().Be(SystemCode.ERR407); } [Fact] diff --git a/backend/tests/CCE.Application.Tests/Identity/Commands/AssignUserRolesCommandHandlerTests.cs b/backend/tests/CCE.Application.Tests/Identity/Commands/AssignUserRolesCommandHandlerTests.cs index fb6ffc53..f7a5686c 100644 --- a/backend/tests/CCE.Application.Tests/Identity/Commands/AssignUserRolesCommandHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Identity/Commands/AssignUserRolesCommandHandlerTests.cs @@ -3,7 +3,6 @@ using CCE.Application.Identity.Commands.AssignUserRoles; using CCE.Application.Identity.Dtos; using CCE.Application.Identity.Queries.GetUserById; -using CCE.Application.Localization; using CCE.Application.Messages; using CCE.Domain.Common; using CCE.Domain.Identity; @@ -43,7 +42,7 @@ public async Task Returns_user_detail_when_service_succeeds() new[] { "ContentManager" }, true); var mediator = Substitute.For(); mediator.Send(Arg.Is(q => q.Id == id), Arg.Any()) - .Returns(Response.Ok(dto, SystemCode.CON900, new LocalizedMessage("ar", "en"))); + .Returns(Response.Ok(dto, SystemCode.CON900, "ar")); var sut = new AssignUserRolesCommandHandler(service, mediator, BuildMsg()); @@ -60,11 +59,14 @@ public async Task Forwards_role_list_to_service() var service = Substitute.For(); service.ReplaceRolesAsync(default, default!, default).ReturnsForAnyArgs(true); var mediator = Substitute.For(); + + var dto = new UserDetailDto( + id, "alice@cce.local", "alice", "ar", + KnowledgeLevel.Beginner, System.Array.Empty(), null, null, + new[] { "SuperAdmin", "ContentManager" }, true); mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Response.Ok(new UserDetailDto( - id, "alice@cce.local", "alice", "ar", - KnowledgeLevel.Beginner, System.Array.Empty(), null, null, - new[] { "SuperAdmin", "ContentManager" }, true), SystemCode.CON900, new LocalizedMessage("ar", "en"))); + .Returns(Response.Ok(dto, SystemCode.CON900, "ar")); + var sut = new AssignUserRolesCommandHandler(service, mediator, BuildMsg()); var roles = new[] { "SuperAdmin", "ContentManager" }; diff --git a/backend/tests/CCE.Application.Tests/Identity/Commands/CreateStateRepAssignmentCommandHandlerTests.cs b/backend/tests/CCE.Application.Tests/Identity/Commands/CreateStateRepAssignmentCommandHandlerTests.cs index a2452131..2652ab74 100644 --- a/backend/tests/CCE.Application.Tests/Identity/Commands/CreateStateRepAssignmentCommandHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Identity/Commands/CreateStateRepAssignmentCommandHandlerTests.cs @@ -63,7 +63,7 @@ public async Task Returns_failure_when_actor_unknown() CancellationToken.None); result.Success.Should().BeFalse(); - result.Code.Should().Be(SystemCode.ERR028); + result.Code.Should().Be(SystemCode.ERR407); } [Fact] diff --git a/backend/tests/CCE.Application.Tests/Identity/Commands/RejectExpertRequestCommandHandlerTests.cs b/backend/tests/CCE.Application.Tests/Identity/Commands/RejectExpertRequestCommandHandlerTests.cs index 812dc58e..f8e45970 100644 --- a/backend/tests/CCE.Application.Tests/Identity/Commands/RejectExpertRequestCommandHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Identity/Commands/RejectExpertRequestCommandHandlerTests.cs @@ -26,7 +26,7 @@ public async Task Throws_KeyNotFound_when_request_missing() CancellationToken.None); result.Success.Should().BeFalse(); - result.Code.Should().Be(SystemCode.ERR002); + result.Code.Should().Be(SystemCode.ERR400); } [Fact] @@ -48,7 +48,7 @@ public async Task Throws_DomainException_when_actor_unknown() CancellationToken.None); result.Success.Should().BeFalse(); - result.Code.Should().Be(SystemCode.ERR028); + result.Code.Should().Be(SystemCode.ERR407); } [Fact] diff --git a/backend/tests/CCE.Application.Tests/Identity/Commands/RevokeStateRepAssignmentCommandHandlerTests.cs b/backend/tests/CCE.Application.Tests/Identity/Commands/RevokeStateRepAssignmentCommandHandlerTests.cs index 21fb083c..ec3203bb 100644 --- a/backend/tests/CCE.Application.Tests/Identity/Commands/RevokeStateRepAssignmentCommandHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Identity/Commands/RevokeStateRepAssignmentCommandHandlerTests.cs @@ -25,7 +25,7 @@ public async Task Returns_failure_when_assignment_missing() var result = await sut.Handle(new RevokeStateRepAssignmentCommand(System.Guid.NewGuid()), CancellationToken.None); result.Success.Should().BeFalse(); - result.Code.Should().Be(SystemCode.ERR003); + result.Code.Should().Be(SystemCode.ERR401); } [Fact] @@ -47,7 +47,7 @@ public async Task Returns_failure_when_actor_unknown() var result = await sut.Handle(new RevokeStateRepAssignmentCommand(assignment.Id), CancellationToken.None); result.Success.Should().BeFalse(); - result.Code.Should().Be(SystemCode.ERR028); + result.Code.Should().Be(SystemCode.ERR407); } [Fact] diff --git a/backend/tests/CCE.Application.Tests/Identity/IdentityTestHelpers.cs b/backend/tests/CCE.Application.Tests/Identity/IdentityTestHelpers.cs index 495ad221..8a91b783 100644 --- a/backend/tests/CCE.Application.Tests/Identity/IdentityTestHelpers.cs +++ b/backend/tests/CCE.Application.Tests/Identity/IdentityTestHelpers.cs @@ -9,10 +9,8 @@ public static class IdentityTestHelpers public static MessageFactory BuildMsg() { var localization = Substitute.For(); - localization.GetLocalizedMessage(Arg.Any()) - .Returns(call => new LocalizedMessage( - Ar: call.Arg(), - En: call.Arg())); + localization.GetString(Arg.Any(), Arg.Any()) + .Returns(call => call.ArgAt(0)); return new MessageFactory(localization); } diff --git a/backend/tests/CCE.Application.Tests/Identity/Public/Queries/GetMyExpertStatusQueryHandlerTests.cs b/backend/tests/CCE.Application.Tests/Identity/Public/Queries/GetMyExpertStatusQueryHandlerTests.cs index ce0dd608..dd108d5e 100644 --- a/backend/tests/CCE.Application.Tests/Identity/Public/Queries/GetMyExpertStatusQueryHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Identity/Public/Queries/GetMyExpertStatusQueryHandlerTests.cs @@ -18,7 +18,7 @@ public async Task Returns_null_when_no_request_exists() var result = await sut.Handle(new GetMyExpertStatusQuery(System.Guid.NewGuid()), CancellationToken.None); result.Success.Should().BeFalse(); - result.Code.Should().Be(SystemCode.ERR002); + result.Code.Should().Be(SystemCode.ERR400); } [Fact] From efca1c9b4f3efdf48621e0db5a17ec884eec13d3 Mon Sep 17 00:00:00 2001 From: MOHAMED RASHED Date: Tue, 19 May 2026 18:40:24 +0300 Subject: [PATCH 09/22] feat:add refit implementation --- .../src/CCE.Api.Common/Auth/DevAuthHandler.cs | 11 +- .../RoleToPermissionClaimsTransformer.cs | 16 +-- backend/src/CCE.Api.External/Dockerfile | 6 +- .../site69824-WebDeploy.pubxml | 25 ++++ .../appsettings.Development.json | 12 +- .../appsettings.Production.json | 76 +++++++++++++ .../src/CCE.Api.External/dotnet-tools.json | 13 +++ backend/src/CCE.Api.Internal/Dockerfile | 6 +- .../site69834-WebDeploy.pubxml | 25 ++++ .../appsettings.Development.json | 12 +- .../appsettings.Production.json | 63 +++++++++++ .../ExternalApis/ExternalApiAuthConfig.cs | 27 +++++ .../ExternalApis/ExternalApiAuthType.cs | 10 ++ .../ExternalApis/ExternalApiClientConfig.cs | 12 ++ .../PermissionsGenerator.cs | 4 +- .../CCE.Infrastructure.csproj | 4 + .../Communication/GatewayEmailSender.cs | 39 +++++++ .../CCE.Infrastructure/DependencyInjection.cs | 13 ++- .../ExternalApis/Auth/ApiKeyAuthHandler.cs | 36 ++++++ .../ExternalApis/Auth/BasicAuthHandler.cs | 26 +++++ .../Auth/BearerTokenAuthHandler.cs | 19 ++++ .../Auth/ExternalApiAuthHandlerFactory.cs | 37 ++++++ .../Auth/NoOpDelegatingHandler.cs | 8 ++ .../Auth/OAuth2ClientCredentialsHandler.cs | 107 ++++++++++++++++++ .../ExternalApiServiceCollectionExtensions.cs | 61 ++++++++++ .../CCE.Integration/CCE.Integration.csproj | 2 +- .../Communication/GatewayResponse.cs | 6 + .../ICommunicationGatewayClient.cs | 17 +++ .../Communication/SendEmailRequest.cs | 6 + .../Communication/SendSmsRequest.cs | 5 + backend/src/CCE.Seeder/Program.cs | 8 +- .../src/CCE.Seeder/Seeders/DemoUsersSeeder.cs | 74 ++++++++++++ .../Seeders/RolesAndPermissionsSeeder.cs | 18 +-- 33 files changed, 765 insertions(+), 39 deletions(-) create mode 100644 backend/src/CCE.Api.External/Properties/PublishProfiles/site69824-WebDeploy.pubxml create mode 100644 backend/src/CCE.Api.External/appsettings.Production.json create mode 100644 backend/src/CCE.Api.External/dotnet-tools.json create mode 100644 backend/src/CCE.Api.Internal/Properties/PublishProfiles/site69834-WebDeploy.pubxml create mode 100644 backend/src/CCE.Api.Internal/appsettings.Production.json create mode 100644 backend/src/CCE.Application/ExternalApis/ExternalApiAuthConfig.cs create mode 100644 backend/src/CCE.Application/ExternalApis/ExternalApiAuthType.cs create mode 100644 backend/src/CCE.Application/ExternalApis/ExternalApiClientConfig.cs create mode 100644 backend/src/CCE.Infrastructure/Communication/GatewayEmailSender.cs create mode 100644 backend/src/CCE.Infrastructure/ExternalApis/Auth/ApiKeyAuthHandler.cs create mode 100644 backend/src/CCE.Infrastructure/ExternalApis/Auth/BasicAuthHandler.cs create mode 100644 backend/src/CCE.Infrastructure/ExternalApis/Auth/BearerTokenAuthHandler.cs create mode 100644 backend/src/CCE.Infrastructure/ExternalApis/Auth/ExternalApiAuthHandlerFactory.cs create mode 100644 backend/src/CCE.Infrastructure/ExternalApis/Auth/NoOpDelegatingHandler.cs create mode 100644 backend/src/CCE.Infrastructure/ExternalApis/Auth/OAuth2ClientCredentialsHandler.cs create mode 100644 backend/src/CCE.Infrastructure/ExternalApis/ExternalApiServiceCollectionExtensions.cs create mode 100644 backend/src/CCE.Integration/Communication/GatewayResponse.cs create mode 100644 backend/src/CCE.Integration/Communication/ICommunicationGatewayClient.cs create mode 100644 backend/src/CCE.Integration/Communication/SendEmailRequest.cs create mode 100644 backend/src/CCE.Integration/Communication/SendSmsRequest.cs create mode 100644 backend/src/CCE.Seeder/Seeders/DemoUsersSeeder.cs diff --git a/backend/src/CCE.Api.Common/Auth/DevAuthHandler.cs b/backend/src/CCE.Api.Common/Auth/DevAuthHandler.cs index 74cd06e3..8bfa1ac3 100644 --- a/backend/src/CCE.Api.Common/Auth/DevAuthHandler.cs +++ b/backend/src/CCE.Api.Common/Auth/DevAuthHandler.cs @@ -48,11 +48,12 @@ public sealed class DevAuthHandler : AuthenticationHandler public static readonly Dictionary RoleToUserId = new(StringComparer.OrdinalIgnoreCase) { - ["cce-admin"] = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-000000000001"), - ["cce-editor"] = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-000000000002"), - ["cce-reviewer"] = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-000000000003"), - ["cce-expert"] = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-000000000004"), - ["cce-user"] = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-000000000005"), + ["cce-admin"] = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-000000000001"), + ["cce-content-manager"] = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-000000000002"), + ["cce-state-representative"] = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-000000000006"), + ["cce-reviewer"] = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-000000000003"), + ["cce-expert"] = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-000000000004"), + ["cce-user"] = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-000000000005"), }; private readonly IOptions _localAuthOptions; diff --git a/backend/src/CCE.Api.Common/Authorization/RoleToPermissionClaimsTransformer.cs b/backend/src/CCE.Api.Common/Authorization/RoleToPermissionClaimsTransformer.cs index fbdadb08..f7751f71 100644 --- a/backend/src/CCE.Api.Common/Authorization/RoleToPermissionClaimsTransformer.cs +++ b/backend/src/CCE.Api.Common/Authorization/RoleToPermissionClaimsTransformer.cs @@ -64,12 +64,14 @@ public Task TransformAsync(ClaimsPrincipal principal) private static IReadOnlyList ResolveRolePermissions(string role) => role switch { - "cce-admin" => RolePermissionMap.CceAdmin, - "cce-editor" => RolePermissionMap.CceEditor, - "cce-reviewer" => RolePermissionMap.CceReviewer, - "cce-expert" => RolePermissionMap.CceExpert, - "cce-user" => RolePermissionMap.CceUser, - "Anonymous" => RolePermissionMap.Anonymous, - _ => System.Array.Empty(), + "cce-super-admin" => RolePermissionMap.CceSuperAdmin, + "cce-admin" => RolePermissionMap.CceAdmin, + "cce-content-manager" => RolePermissionMap.CceContentManager, + "cce-state-representative" => RolePermissionMap.CceStateRepresentative, + "cce-reviewer" => RolePermissionMap.CceReviewer, + "cce-expert" => RolePermissionMap.CceExpert, + "cce-user" => RolePermissionMap.CceUser, + "Anonymous" => RolePermissionMap.Anonymous, + _ => System.Array.Empty(), }; } diff --git a/backend/src/CCE.Api.External/Dockerfile b/backend/src/CCE.Api.External/Dockerfile index ec161ef3..232d8220 100644 --- a/backend/src/CCE.Api.External/Dockerfile +++ b/backend/src/CCE.Api.External/Dockerfile @@ -36,11 +36,7 @@ USER app COPY --from=build --chown=app:app /app/publish . -ENV ASPNETCORE_ENVIRONMENT=Production \ - ASPNETCORE_URLS=http://+:8080 +ENV ASPNETCORE_ENVIRONMENT=Production EXPOSE 8080 -HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ - CMD curl -fsS http://localhost:8080/health || exit 1 - ENTRYPOINT ["dotnet", "CCE.Api.External.dll"] diff --git a/backend/src/CCE.Api.External/Properties/PublishProfiles/site69824-WebDeploy.pubxml b/backend/src/CCE.Api.External/Properties/PublishProfiles/site69824-WebDeploy.pubxml new file mode 100644 index 00000000..37f26dfc --- /dev/null +++ b/backend/src/CCE.Api.External/Properties/PublishProfiles/site69824-WebDeploy.pubxml @@ -0,0 +1,25 @@ + + + + + MSDeploy + Release + Any CPU + http://cce-external-api.runasp.net/ + true + false + fd78ba15-546a-4493-93ba-998674929ed8 + site69824.siteasp.net + site69824 + + true + WMSVC + true + true + site69824 + <_SavePWD>true + + \ No newline at end of file diff --git a/backend/src/CCE.Api.External/appsettings.Development.json b/backend/src/CCE.Api.External/appsettings.Development.json index ebf833ba..ddacb366 100644 --- a/backend/src/CCE.Api.External/appsettings.Development.json +++ b/backend/src/CCE.Api.External/appsettings.Development.json @@ -7,7 +7,7 @@ }, "Infrastructure": { "SqlConnectionString": "Server=db52197.public.databaseasp.net; Database=db52197; User Id=db52197; Password=3Mm!x5#Y?rR9; Encrypt=True; TrustServerCertificate=True; MultipleActiveResultSets=True;", - "RedisConnectionString": "localhost:6379", + "RedisConnectionString": "rediss://default:gQAAAAAAAYY8AAIgcDIwYmNkMjFmM2Q0NDk0MGRiOWZhZjczNDE1NmMwZjFlMw@game-elk-99900.upstash.io:6379", "MeilisearchUrl": "http://localhost:7700", "MeilisearchMasterKey": "dev-meili-master-key-change-me", "OutputCacheTtlSeconds": 60 @@ -72,5 +72,15 @@ "Username": "", "Password": "", "EnableSsl": false + }, + "ExternalApis": { + "CommunicationGateway": { + "BaseUrl": "https://gateway.example.com", + "TimeoutSeconds": 30, + "Auth": { + "Type": "Bearer", + "Token": "dev-gateway-token-change-me" + } + } } } diff --git a/backend/src/CCE.Api.External/appsettings.Production.json b/backend/src/CCE.Api.External/appsettings.Production.json new file mode 100644 index 00000000..0126fa07 --- /dev/null +++ b/backend/src/CCE.Api.External/appsettings.Production.json @@ -0,0 +1,76 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "Microsoft.AspNetCore": "Information" + } + }, + "Infrastructure": { + "SqlConnectionString": "Server=db52197.public.databaseasp.net; Database=db52197; User Id=db52197; Password=3Mm!x5#Y?rR9; Encrypt=True; TrustServerCertificate=True; MultipleActiveResultSets=True;", + "RedisConnectionString": "rediss://default:gQAAAAAAAYY8AAIgcDIwYmNkMjFmM2Q0NDk0MGRiOWZhZjczNDE1NmMwZjFlMw@game-elk-99900.upstash.io:6379", + "MeilisearchUrl": "http://localhost:7700", + "MeilisearchMasterKey": "dev-meili-master-key-change-me", + "OutputCacheTtlSeconds": 60 + }, + "RateLimit": { + "Anonymous": { "RequestsPerMinute": 120 }, + "Authenticated": { "RequestsPerMinute": 600 }, + "SearchAndWrite": { "RequestsPerMinute": 30 } + }, + "Bff": { + "KeycloakRealm": "cce-public", + "KeycloakClientId": "cce-public-portal", + "KeycloakClientSecret": "dev-public-secret-change-me", + "CookieDomain": "localhost", + "SessionLifetimeMinutes": 30, + "KeycloakBaseUrl": "http://localhost:8080" + }, + "Keycloak": { + "Authority": "http://localhost:8080/realms/cce-external", + "Audience": "cce-web-portal", + "RequireHttpsMetadata": false, + "AdditionalValidIssuers": [ + "http://host.docker.internal:8080/realms/cce-external" + ] + }, + "Auth": { + "DevMode": true, + "DefaultDevRole": "cce-user" + }, + "EntraId": { + "Instance": "https://login.microsoftonline.com/", + "TenantId": "common", + "ClientId": "00000000-0000-0000-0000-000000000000", + "ClientSecret": "dev-entra-secret-change-me", + "Audience": "api://00000000-0000-0000-0000-000000000000", + "GraphTenantId": "00000000-0000-0000-0000-000000000000", + "GraphTenantDomain": "cce.local", + "CallbackPath": "/signin-oidc" + }, + "LocalAuth": { + "External": { + "Issuer": "cce-api-external-dev", + "Audience": "cce-public-dev", + "SigningKey": "dev-external-local-auth-signing-key-change-me-12345" + }, + "Internal": { + "Issuer": "cce-api-internal-dev", + "Audience": "cce-admin-dev", + "SigningKey": "dev-internal-local-auth-signing-key-change-me-12345" + }, + "AccessTokenMinutes": 10, + "RefreshTokenDays": 30, + "PasswordResetTokenHours": 2, + "RequireConfirmedEmail": false + }, + "Email": { + "Provider": "smtp", + "Host": "localhost", + "Port": 1025, + "FromAddress": "no-reply@cce.local", + "FromName": "CCE Knowledge Center", + "Username": "", + "Password": "", + "EnableSsl": false + } +} diff --git a/backend/src/CCE.Api.External/dotnet-tools.json b/backend/src/CCE.Api.External/dotnet-tools.json new file mode 100644 index 00000000..7dcefc33 --- /dev/null +++ b/backend/src/CCE.Api.External/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "10.0.8", + "commands": [ + "dotnet-ef" + ], + "rollForward": false + } + } +} \ No newline at end of file diff --git a/backend/src/CCE.Api.Internal/Dockerfile b/backend/src/CCE.Api.Internal/Dockerfile index 590beecb..9fb36864 100644 --- a/backend/src/CCE.Api.Internal/Dockerfile +++ b/backend/src/CCE.Api.Internal/Dockerfile @@ -28,11 +28,7 @@ USER app COPY --from=build --chown=app:app /app/publish . -ENV ASPNETCORE_ENVIRONMENT=Production \ - ASPNETCORE_URLS=http://+:8080 +ENV ASPNETCORE_ENVIRONMENT=Production EXPOSE 8080 -HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ - CMD curl -fsS http://localhost:8080/health || exit 1 - ENTRYPOINT ["dotnet", "CCE.Api.Internal.dll"] diff --git a/backend/src/CCE.Api.Internal/Properties/PublishProfiles/site69834-WebDeploy.pubxml b/backend/src/CCE.Api.Internal/Properties/PublishProfiles/site69834-WebDeploy.pubxml new file mode 100644 index 00000000..5c7548cd --- /dev/null +++ b/backend/src/CCE.Api.Internal/Properties/PublishProfiles/site69834-WebDeploy.pubxml @@ -0,0 +1,25 @@ + + + + + MSDeploy + Release + Any CPU + http://cce-internal-api.runasp.net/ + true + false + e141d16f-af2a-4a5e-a956-1179746c9e5c + site69834.siteasp.net + site69834 + + true + WMSVC + true + true + site69834 + <_SavePWD>true + + \ No newline at end of file diff --git a/backend/src/CCE.Api.Internal/appsettings.Development.json b/backend/src/CCE.Api.Internal/appsettings.Development.json index 09425390..3e767f11 100644 --- a/backend/src/CCE.Api.Internal/appsettings.Development.json +++ b/backend/src/CCE.Api.Internal/appsettings.Development.json @@ -7,7 +7,7 @@ }, "Infrastructure": { "SqlConnectionString": "Server=db52197.public.databaseasp.net; Database=db52197; User Id=db52197; Password=3Mm!x5#Y?rR9; Encrypt=True; TrustServerCertificate=True; MultipleActiveResultSets=True;", - "RedisConnectionString": "localhost:6379", + "RedisConnectionString": "rediss://default:gQAAAAAAAYY8AAIgcDIwYmNkMjFmM2Q0NDk0MGRiOWZhZjczNDE1NmMwZjFlMw@game-elk-99900.upstash.io:6379", "LocalUploadsRoot": "./backend/uploads/", "ClamAvHost": "localhost", "ClamAvPort": 3310 @@ -59,5 +59,15 @@ "Username": "", "Password": "", "EnableSsl": false + }, + "ExternalApis": { + "CommunicationGateway": { + "BaseUrl": "https://gateway.example.com", + "TimeoutSeconds": 30, + "Auth": { + "Type": "Bearer", + "Token": "dev-gateway-token-change-me" + } + } } } diff --git a/backend/src/CCE.Api.Internal/appsettings.Production.json b/backend/src/CCE.Api.Internal/appsettings.Production.json new file mode 100644 index 00000000..56210d7b --- /dev/null +++ b/backend/src/CCE.Api.Internal/appsettings.Production.json @@ -0,0 +1,63 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "Microsoft.AspNetCore": "Information" + } + }, + "Infrastructure": { + "SqlConnectionString": "Server=db52197.public.databaseasp.net; Database=db52197; User Id=db52197; Password=3Mm!x5#Y?rR9; Encrypt=True; TrustServerCertificate=True; MultipleActiveResultSets=True;", + "RedisConnectionString": "rediss://default:gQAAAAAAAYY8AAIgcDIwYmNkMjFmM2Q0NDk0MGRiOWZhZjczNDE1NmMwZjFlMw@game-elk-99900.upstash.io:6379", + "LocalUploadsRoot": "./backend/uploads/", + "ClamAvHost": "localhost", + "ClamAvPort": 3310 + }, + "Keycloak": { + "Authority": "http://localhost:8080/realms/cce-internal", + "Audience": "cce-admin-cms", + "RequireHttpsMetadata": false, + "AdditionalValidIssuers": [ + "http://host.docker.internal:8080/realms/cce-internal" + ] + }, + "Auth": { + "DevMode": true, + "DefaultDevRole": "cce-admin" + }, + "EntraId": { + "Instance": "https://login.microsoftonline.com/", + "TenantId": "common", + "ClientId": "00000000-0000-0000-0000-000000000000", + "ClientSecret": "dev-entra-secret-change-me", + "Audience": "api://00000000-0000-0000-0000-000000000000", + "GraphTenantId": "00000000-0000-0000-0000-000000000000", + "GraphTenantDomain": "cce.local", + "CallbackPath": "/signin-oidc" + }, + "LocalAuth": { + "External": { + "Issuer": "cce-api-external-dev", + "Audience": "cce-public-dev", + "SigningKey": "dev-external-local-auth-signing-key-change-me-12345" + }, + "Internal": { + "Issuer": "cce-api-internal-dev", + "Audience": "cce-admin-dev", + "SigningKey": "dev-internal-local-auth-signing-key-change-me-12345" + }, + "AccessTokenMinutes": 10, + "RefreshTokenDays": 30, + "PasswordResetTokenHours": 2, + "RequireConfirmedEmail": false + }, + "Email": { + "Provider": "smtp", + "Host": "localhost", + "Port": 1025, + "FromAddress": "no-reply@cce.local", + "FromName": "CCE Knowledge Center", + "Username": "", + "Password": "", + "EnableSsl": false + } +} diff --git a/backend/src/CCE.Application/ExternalApis/ExternalApiAuthConfig.cs b/backend/src/CCE.Application/ExternalApis/ExternalApiAuthConfig.cs new file mode 100644 index 00000000..ad723a0c --- /dev/null +++ b/backend/src/CCE.Application/ExternalApis/ExternalApiAuthConfig.cs @@ -0,0 +1,27 @@ +namespace CCE.Application.ExternalApis; + +/// +/// Authentication configuration for an external API client. +/// Only the fields relevant to need to be populated. +/// +public sealed class ExternalApiAuthConfig +{ + public ExternalApiAuthType Type { get; init; } = ExternalApiAuthType.None; + + // ApiKey + public string KeyName { get; init; } = string.Empty; + public string KeyLocation { get; init; } = "Header"; + public string Value { get; init; } = string.Empty; + + // Bearer + public string Token { get; init; } = string.Empty; + + // Basic & OAuth2 shared + public string ClientId { get; init; } = string.Empty; + public string ClientSecret { get; init; } = string.Empty; + + // OAuth2 + public string TokenUrl { get; init; } = string.Empty; + public string Scope { get; init; } = string.Empty; + public bool AutoRefresh { get; init; } = true; +} diff --git a/backend/src/CCE.Application/ExternalApis/ExternalApiAuthType.cs b/backend/src/CCE.Application/ExternalApis/ExternalApiAuthType.cs new file mode 100644 index 00000000..3058b145 --- /dev/null +++ b/backend/src/CCE.Application/ExternalApis/ExternalApiAuthType.cs @@ -0,0 +1,10 @@ +namespace CCE.Application.ExternalApis; + +public enum ExternalApiAuthType +{ + None, + ApiKey, + Bearer, + Basic, + OAuth2 +} diff --git a/backend/src/CCE.Application/ExternalApis/ExternalApiClientConfig.cs b/backend/src/CCE.Application/ExternalApis/ExternalApiClientConfig.cs new file mode 100644 index 00000000..3e98c23d --- /dev/null +++ b/backend/src/CCE.Application/ExternalApis/ExternalApiClientConfig.cs @@ -0,0 +1,12 @@ +namespace CCE.Application.ExternalApis; + +/// +/// Per-client configuration used by AddExternalApiClient<TClient>. +/// Bound from ExternalApis:{ApiName} in appsettings. +/// +public sealed class ExternalApiClientConfig +{ + public string BaseUrl { get; init; } = string.Empty; + public int TimeoutSeconds { get; init; } = 30; + public ExternalApiAuthConfig Auth { get; init; } = new(); +} diff --git a/backend/src/CCE.Domain.SourceGenerators/PermissionsGenerator.cs b/backend/src/CCE.Domain.SourceGenerators/PermissionsGenerator.cs index 1c0407b7..c04c25e5 100644 --- a/backend/src/CCE.Domain.SourceGenerators/PermissionsGenerator.cs +++ b/backend/src/CCE.Domain.SourceGenerators/PermissionsGenerator.cs @@ -35,8 +35,10 @@ public sealed class PermissionsGenerator : IIncrementalGenerator // SuperAdmin-style names to Entra ID app-role values. private static readonly string[] KnownRoles = { + "cce-super-admin", "cce-admin", - "cce-editor", + "cce-content-manager", + "cce-state-representative", "cce-reviewer", "cce-expert", "cce-user", diff --git a/backend/src/CCE.Infrastructure/CCE.Infrastructure.csproj b/backend/src/CCE.Infrastructure/CCE.Infrastructure.csproj index 41baad60..597334ee 100644 --- a/backend/src/CCE.Infrastructure/CCE.Infrastructure.csproj +++ b/backend/src/CCE.Infrastructure/CCE.Infrastructure.csproj @@ -39,6 +39,9 @@ + + + @@ -49,6 +52,7 @@ + diff --git a/backend/src/CCE.Infrastructure/Communication/GatewayEmailSender.cs b/backend/src/CCE.Infrastructure/Communication/GatewayEmailSender.cs new file mode 100644 index 00000000..2366f7f3 --- /dev/null +++ b/backend/src/CCE.Infrastructure/Communication/GatewayEmailSender.cs @@ -0,0 +1,39 @@ +using CCE.Application.Common.Interfaces; +using CCE.Integration.Communication; +using Microsoft.Extensions.Logging; + +namespace CCE.Infrastructure.Communication; + +/// +/// implementation that delegates to the +/// integration gateway via . +/// +public sealed class GatewayEmailSender : IEmailSender +{ + private readonly ICommunicationGatewayClient _client; + private readonly ILogger _logger; + + public GatewayEmailSender(ICommunicationGatewayClient client, ILogger logger) + { + _client = client; + _logger = logger; + } + + public async Task SendAsync(string to, string subject, string htmlBody, CancellationToken ct = default) + { + var request = new SendEmailRequest(to, subject, htmlBody); + var response = await _client.SendEmailAsync(request, ct).ConfigureAwait(false); + + if (!response.Success) + { + _logger.LogError( + "Gateway email send failed for {To} with subject {Subject}: {Error}", + to, subject, response.Error); + throw new InvalidOperationException($"Gateway email send failed: {response.Error}"); + } + + _logger.LogInformation( + "Sent email via gateway to {To} with subject {Subject} (messageId {MessageId})", + to, subject, response.MessageId); + } +} diff --git a/backend/src/CCE.Infrastructure/DependencyInjection.cs b/backend/src/CCE.Infrastructure/DependencyInjection.cs index 145f86c7..f3e25bca 100644 --- a/backend/src/CCE.Infrastructure/DependencyInjection.cs +++ b/backend/src/CCE.Infrastructure/DependencyInjection.cs @@ -26,7 +26,9 @@ using CCE.Infrastructure.Surveys; using CCE.Application.Localization; using CCE.Domain.Common; +using CCE.Integration.Communication; using CCE.Infrastructure.Email; +using CCE.Infrastructure.ExternalApis; using CCE.Infrastructure.Files; using CCE.Infrastructure.Identity; using CCE.Infrastructure.Localization; @@ -127,17 +129,20 @@ public static IServiceCollection AddInfrastructure( services.AddScoped(); // Sub-11d — outbound email transport. SMTP-backed when - // Email:Provider=smtp; otherwise NullEmailSender (logs + discards). - // Singleton because both impls are stateless + thread-safe. + // Email:Provider=smtp; gateway-backed when Email:Provider=gateway; + // otherwise NullEmailSender (logs + discards). + // Singleton because all impls are stateless + thread-safe. services.Configure(configuration.GetSection(EmailOptions.SectionName)); + services.AddExternalApiClient("CommunicationGateway"); services.AddSingleton(sp => { var opts = sp.GetRequiredService>(); var provider = (opts.Value.Provider ?? "null").ToLowerInvariant(); return provider switch { - "smtp" => ActivatorUtilities.CreateInstance(sp), - _ => ActivatorUtilities.CreateInstance(sp), + "smtp" => ActivatorUtilities.CreateInstance(sp), + "gateway" => ActivatorUtilities.CreateInstance(sp), + _ => ActivatorUtilities.CreateInstance(sp), }; }); services.AddScoped(); diff --git a/backend/src/CCE.Infrastructure/ExternalApis/Auth/ApiKeyAuthHandler.cs b/backend/src/CCE.Infrastructure/ExternalApis/Auth/ApiKeyAuthHandler.cs new file mode 100644 index 00000000..f56df566 --- /dev/null +++ b/backend/src/CCE.Infrastructure/ExternalApis/Auth/ApiKeyAuthHandler.cs @@ -0,0 +1,36 @@ +namespace CCE.Infrastructure.ExternalApis.Auth; + +/// +/// Injects an API key as a header or query parameter. +/// +public sealed class ApiKeyAuthHandler : DelegatingHandler +{ + private readonly string _keyName; + private readonly string _keyValue; + private readonly string _keyLocation; + + public ApiKeyAuthHandler(string keyName, string keyValue, string keyLocation) + { + _keyName = keyName; + _keyValue = keyValue; + _keyLocation = keyLocation; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (_keyLocation.Equals("Query", StringComparison.OrdinalIgnoreCase)) + { + var uriBuilder = new UriBuilder(request.RequestUri!); + var query = System.Web.HttpUtility.ParseQueryString(uriBuilder.Query); + query[_keyName] = _keyValue; + uriBuilder.Query = query.ToString(); + request.RequestUri = uriBuilder.Uri; + } + else + { + request.Headers.TryAddWithoutValidation(_keyName, _keyValue); + } + + return base.SendAsync(request, cancellationToken); + } +} diff --git a/backend/src/CCE.Infrastructure/ExternalApis/Auth/BasicAuthHandler.cs b/backend/src/CCE.Infrastructure/ExternalApis/Auth/BasicAuthHandler.cs new file mode 100644 index 00000000..cde5b566 --- /dev/null +++ b/backend/src/CCE.Infrastructure/ExternalApis/Auth/BasicAuthHandler.cs @@ -0,0 +1,26 @@ +using System.Net.Http.Headers; +using System.Text; + +namespace CCE.Infrastructure.ExternalApis.Auth; + +/// +/// Sets an Authorization: Basic … header on every request. +/// +public sealed class BasicAuthHandler : DelegatingHandler +{ + private readonly string _username; + private readonly string _password; + + public BasicAuthHandler(string username, string password) + { + _username = username; + _password = password; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{_username}:{_password}")); + request.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials); + return base.SendAsync(request, cancellationToken); + } +} diff --git a/backend/src/CCE.Infrastructure/ExternalApis/Auth/BearerTokenAuthHandler.cs b/backend/src/CCE.Infrastructure/ExternalApis/Auth/BearerTokenAuthHandler.cs new file mode 100644 index 00000000..8d18b598 --- /dev/null +++ b/backend/src/CCE.Infrastructure/ExternalApis/Auth/BearerTokenAuthHandler.cs @@ -0,0 +1,19 @@ +using System.Net.Http.Headers; + +namespace CCE.Infrastructure.ExternalApis.Auth; + +/// +/// Sets an Authorization: Bearer … header on every request. +/// +public sealed class BearerTokenAuthHandler : DelegatingHandler +{ + private readonly string _token; + + public BearerTokenAuthHandler(string token) => _token = token; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token); + return base.SendAsync(request, cancellationToken); + } +} diff --git a/backend/src/CCE.Infrastructure/ExternalApis/Auth/ExternalApiAuthHandlerFactory.cs b/backend/src/CCE.Infrastructure/ExternalApis/Auth/ExternalApiAuthHandlerFactory.cs new file mode 100644 index 00000000..de7d3dd6 --- /dev/null +++ b/backend/src/CCE.Infrastructure/ExternalApis/Auth/ExternalApiAuthHandlerFactory.cs @@ -0,0 +1,37 @@ +using CCE.Application.ExternalApis; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace CCE.Infrastructure.ExternalApis.Auth; + +/// +/// Factory that creates the correct for an +/// external API based on its . +/// +public static class ExternalApiAuthHandlerFactory +{ + public static DelegatingHandler? Create(ExternalApiAuthConfig? authConfig, ILoggerFactory? loggerFactory = null) + { + if (authConfig is null || authConfig.Type == ExternalApiAuthType.None) + { + return null; + } + + var logger = loggerFactory ?? NullLoggerFactory.Instance; + + return authConfig.Type switch + { + ExternalApiAuthType.ApiKey => new ApiKeyAuthHandler(authConfig.KeyName, authConfig.Value, authConfig.KeyLocation), + ExternalApiAuthType.Bearer => new BearerTokenAuthHandler(authConfig.Token), + ExternalApiAuthType.Basic => new BasicAuthHandler(authConfig.ClientId, authConfig.ClientSecret), + ExternalApiAuthType.OAuth2 => new OAuth2ClientCredentialsHandler( + authConfig.TokenUrl, + authConfig.ClientId, + authConfig.ClientSecret, + authConfig.Scope, + authConfig.AutoRefresh, + logger.CreateLogger()), + _ => null + }; + } +} diff --git a/backend/src/CCE.Infrastructure/ExternalApis/Auth/NoOpDelegatingHandler.cs b/backend/src/CCE.Infrastructure/ExternalApis/Auth/NoOpDelegatingHandler.cs new file mode 100644 index 00000000..43a8cdfc --- /dev/null +++ b/backend/src/CCE.Infrastructure/ExternalApis/Auth/NoOpDelegatingHandler.cs @@ -0,0 +1,8 @@ +namespace CCE.Infrastructure.ExternalApis.Auth; + +/// +/// Pass-through handler used when no authentication is required. +/// +public sealed class NoOpDelegatingHandler : DelegatingHandler +{ +} diff --git a/backend/src/CCE.Infrastructure/ExternalApis/Auth/OAuth2ClientCredentialsHandler.cs b/backend/src/CCE.Infrastructure/ExternalApis/Auth/OAuth2ClientCredentialsHandler.cs new file mode 100644 index 00000000..65ab979f --- /dev/null +++ b/backend/src/CCE.Infrastructure/ExternalApis/Auth/OAuth2ClientCredentialsHandler.cs @@ -0,0 +1,107 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace CCE.Infrastructure.ExternalApis.Auth; + +/// +/// Acquires and caches an OAuth2 client-credentials token, auto-refreshing +/// before expiry. Safe for singleton use; the underlying +/// is short-lived inside token acquisition only. +/// +public sealed class OAuth2ClientCredentialsHandler : DelegatingHandler +{ + private static readonly JsonSerializerOptions s_jsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + private readonly string _tokenUrl; + private readonly string _clientId; + private readonly string _clientSecret; + private readonly string _scope; + private readonly bool _autoRefresh; + private readonly ILogger _logger; + + private string? _accessToken; + private DateTime _tokenExpiry = DateTime.MinValue; + + public OAuth2ClientCredentialsHandler( + string tokenUrl, + string clientId, + string clientSecret, + string scope, + bool autoRefresh, + ILogger? logger = null) + { + _tokenUrl = tokenUrl; + _clientId = clientId; + _clientSecret = clientSecret; + _scope = scope; + _autoRefresh = autoRefresh; + _logger = logger ?? NullLogger.Instance; + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(_accessToken) || (_autoRefresh && DateTime.UtcNow >= _tokenExpiry.AddSeconds(-60))) + { + await AcquireTokenAsync(cancellationToken).ConfigureAwait(false); + } + + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken); + return await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + } + + private async Task AcquireTokenAsync(CancellationToken cancellationToken) + { + try + { + using var httpClient = new HttpClient(); + var requestContent = new Dictionary + { + ["grant_type"] = "client_credentials", + ["client_id"] = _clientId, + ["client_secret"] = _clientSecret + }; + + if (!string.IsNullOrEmpty(_scope)) + { + requestContent["scope"] = _scope; + } + + using var tokenRequest = new HttpRequestMessage(HttpMethod.Post, _tokenUrl) + { + Content = new FormUrlEncodedContent(requestContent) + }; + + var response = await httpClient.SendAsync(tokenRequest, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + var tokenResponse = JsonSerializer.Deserialize(json, s_jsonOptions); + + if (tokenResponse is not null) + { + _accessToken = tokenResponse.AccessToken; + _tokenExpiry = DateTime.UtcNow.AddSeconds(tokenResponse.ExpiresIn - 60); + _logger.LogDebug("OAuth2 token acquired, expires at {Expiry}", _tokenExpiry); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to acquire OAuth2 token from {TokenUrl}", _tokenUrl); + throw; + } + } +} + +public sealed class OAuthTokenResponse +{ + public string AccessToken { get; set; } = string.Empty; + public string TokenType { get; set; } = "Bearer"; + public int ExpiresIn { get; set; } = 3600; + public string? Scope { get; set; } +} diff --git a/backend/src/CCE.Infrastructure/ExternalApis/ExternalApiServiceCollectionExtensions.cs b/backend/src/CCE.Infrastructure/ExternalApis/ExternalApiServiceCollectionExtensions.cs new file mode 100644 index 00000000..b61881e4 --- /dev/null +++ b/backend/src/CCE.Infrastructure/ExternalApis/ExternalApiServiceCollectionExtensions.cs @@ -0,0 +1,61 @@ +using CCE.Application.ExternalApis; +using CCE.Infrastructure.ExternalApis.Auth; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Http.Resilience; +using Microsoft.Extensions.Logging; +using Refit; + +namespace CCE.Infrastructure.ExternalApis; + +/// +/// Extensions for registering Refit-based external API clients with +/// per-client auth handlers and standard resilience policies. +/// +public static class ExternalApiServiceCollectionExtensions +{ + /// + /// Registers a Refit client whose base URL, + /// timeout and auth scheme are read from ExternalApis:{apiName}. + /// + public static IServiceCollection AddExternalApiClient( + this IServiceCollection services, + string apiName) + where TClient : class + { + var refitSettings = new RefitSettings + { + ContentSerializer = new SystemTextJsonContentSerializer( + new System.Text.Json.JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }) + }; + + services.AddRefitClient(refitSettings) + .ConfigureHttpClient((sp, client) => + { + var config = sp.GetRequiredService() + .GetSection($"ExternalApis:{apiName}") + .Get(); + + if (config is not null && !string.IsNullOrWhiteSpace(config.BaseUrl)) + { + client.BaseAddress = new Uri(config.BaseUrl); + client.Timeout = TimeSpan.FromSeconds(config.TimeoutSeconds > 0 ? config.TimeoutSeconds : 30); + } + }) + .AddHttpMessageHandler(sp => + { + var authConfig = sp.GetRequiredService() + .GetSection($"ExternalApis:{apiName}:Auth") + .Get(); + + var handler = ExternalApiAuthHandlerFactory.Create(authConfig, sp.GetService()); + return handler ?? new NoOpDelegatingHandler(); + }) + .AddStandardResilienceHandler(); + + return services; + } +} diff --git a/backend/src/CCE.Integration/CCE.Integration.csproj b/backend/src/CCE.Integration/CCE.Integration.csproj index 8e4f625e..470ed1ee 100644 --- a/backend/src/CCE.Integration/CCE.Integration.csproj +++ b/backend/src/CCE.Integration/CCE.Integration.csproj @@ -5,7 +5,7 @@ - + diff --git a/backend/src/CCE.Integration/Communication/GatewayResponse.cs b/backend/src/CCE.Integration/Communication/GatewayResponse.cs new file mode 100644 index 00000000..e34d8811 --- /dev/null +++ b/backend/src/CCE.Integration/Communication/GatewayResponse.cs @@ -0,0 +1,6 @@ +namespace CCE.Integration.Communication; + +public sealed record GatewayResponse( + bool Success, + string? MessageId = null, + string? Error = null); diff --git a/backend/src/CCE.Integration/Communication/ICommunicationGatewayClient.cs b/backend/src/CCE.Integration/Communication/ICommunicationGatewayClient.cs new file mode 100644 index 00000000..7391fdf2 --- /dev/null +++ b/backend/src/CCE.Integration/Communication/ICommunicationGatewayClient.cs @@ -0,0 +1,17 @@ +using Refit; + +namespace CCE.Integration.Communication; + +/// +/// Refit client for the central email / SMS integration gateway. +/// Contract is generic — actual gateway paths and payloads can be +/// remapped via a custom if needed. +/// +public interface ICommunicationGatewayClient +{ + [Post("/api/v1/email/send")] + Task SendEmailAsync([Body] SendEmailRequest request, CancellationToken cancellationToken = default); + + [Post("/api/v1/sms/send")] + Task SendSmsAsync([Body] SendSmsRequest request, CancellationToken cancellationToken = default); +} diff --git a/backend/src/CCE.Integration/Communication/SendEmailRequest.cs b/backend/src/CCE.Integration/Communication/SendEmailRequest.cs new file mode 100644 index 00000000..a299ad26 --- /dev/null +++ b/backend/src/CCE.Integration/Communication/SendEmailRequest.cs @@ -0,0 +1,6 @@ +namespace CCE.Integration.Communication; + +public sealed record SendEmailRequest( + string To, + string Subject, + string Body); diff --git a/backend/src/CCE.Integration/Communication/SendSmsRequest.cs b/backend/src/CCE.Integration/Communication/SendSmsRequest.cs new file mode 100644 index 00000000..0850dea7 --- /dev/null +++ b/backend/src/CCE.Integration/Communication/SendSmsRequest.cs @@ -0,0 +1,5 @@ +namespace CCE.Integration.Communication; + +public sealed record SendSmsRequest( + string To, + string Message); diff --git a/backend/src/CCE.Seeder/Program.cs b/backend/src/CCE.Seeder/Program.cs index f3ab8e4d..42d03c09 100644 --- a/backend/src/CCE.Seeder/Program.cs +++ b/backend/src/CCE.Seeder/Program.cs @@ -66,9 +66,15 @@ static string FindApiAppSettingsDir() builder.Services.AddApplication(); builder.Services.AddInfrastructure(builder.Configuration); +// UserManager (pulled in by AddInfrastructure's AddIdentityCore) requires +// IDataProtectionProvider for its default token providers. AddDataProtection +// satisfies this in a non-web host. +builder.Services.AddDataProtection(); + // Register seeders. -builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/backend/src/CCE.Seeder/Seeders/DemoUsersSeeder.cs b/backend/src/CCE.Seeder/Seeders/DemoUsersSeeder.cs new file mode 100644 index 00000000..a00a1eea --- /dev/null +++ b/backend/src/CCE.Seeder/Seeders/DemoUsersSeeder.cs @@ -0,0 +1,74 @@ +using CCE.Domain.Identity; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; + +namespace CCE.Seeder.Seeders; + +/// +/// Seeds one deterministic demo user per CCE role (cce-admin, cce-content-manager, +/// cce-reviewer, cce-expert, cce-user) with a known password. +/// +/// Runs in all environments and is idempotent — skips users that +/// already exist by email address. +/// +/// Order = 15 ensures roles are already present (RolesAndPermissionsSeeder = 10). +/// +public sealed class DemoUsersSeeder : ISeeder +{ + private readonly UserManager _userManager; + private readonly ILogger _logger; + + public DemoUsersSeeder(UserManager userManager, ILogger logger) + { + _userManager = userManager; + _logger = logger; + } + + public int Order => 15; + + private static readonly (string Email, string Password, string Role, string FirstName, string LastName)[] Users = + { + ("superadmin@cce.local", "SuperAdminPass123!", "cce-super-admin", "Super", "Admin"), + ("admin@cce.local", "AdminPass123!", "cce-admin", "System", "Admin"), + ("contentmgr@cce.local", "ContentMgrPass123!", "cce-content-manager", "Content", "Manager"), + ("staterep@cce.local", "StateRepPass123!", "cce-state-representative", "State", "Representative"), + ("reviewer@cce.local", "ReviewerPass1!", "cce-reviewer", "Content", "Reviewer"), + ("expert@cce.local", "ExpertPass123!", "cce-expert", "Domain", "Expert"), + ("user@cce.local", "UserPass12345!", "cce-user", "Regular", "User"), + }; + + public async Task SeedAsync(CancellationToken cancellationToken = default) + { + foreach (var (email, password, role, firstName, lastName) in Users) + { + var existing = await _userManager.FindByEmailAsync(email).ConfigureAwait(false); + if (existing is not null) + { + _logger.LogInformation("Demo user {Email} already exists — skipping.", email); + continue; + } + + var user = User.RegisterLocal(firstName, lastName, email, "Demo", "CCE", ""); + user.EmailConfirmed = true; + + var createResult = await _userManager.CreateAsync(user, password).ConfigureAwait(false); + if (!createResult.Succeeded) + { + var errors = string.Join(", ", createResult.Errors.Select(static e => e.Description)); + _logger.LogError("Failed to create demo user {Email}: {Errors}", email, errors); + continue; + } + + var roleResult = await _userManager.AddToRoleAsync(user, role).ConfigureAwait(false); + if (!roleResult.Succeeded) + { + var errors = string.Join(", ", roleResult.Errors.Select(static e => e.Description)); + _logger.LogError("Failed to assign role {Role} to {Email}: {Errors}", role, email, errors); + } + else + { + _logger.LogInformation("Created demo user {Email} with role {Role}.", email, role); + } + } + } +} diff --git a/backend/src/CCE.Seeder/Seeders/RolesAndPermissionsSeeder.cs b/backend/src/CCE.Seeder/Seeders/RolesAndPermissionsSeeder.cs index 94ad95a6..c3fca14f 100644 --- a/backend/src/CCE.Seeder/Seeders/RolesAndPermissionsSeeder.cs +++ b/backend/src/CCE.Seeder/Seeders/RolesAndPermissionsSeeder.cs @@ -11,8 +11,8 @@ public sealed class RolesAndPermissionsSeeder : ISeeder { private static readonly string[] SeededRoleNames = { - "cce-admin", "cce-editor", "cce-reviewer", - "cce-expert", "cce-user", + "cce-super-admin", "cce-admin", "cce-content-manager", "cce-state-representative", + "cce-reviewer", "cce-expert", "cce-user", }; private readonly CceDbContext _ctx; @@ -66,11 +66,13 @@ public async Task SeedAsync(CancellationToken cancellationToken = default) private static IReadOnlyList GetPermissionsForRole(string roleName) => roleName switch { - "cce-admin" => RolePermissionMap.CceAdmin, - "cce-editor" => RolePermissionMap.CceEditor, - "cce-reviewer" => RolePermissionMap.CceReviewer, - "cce-expert" => RolePermissionMap.CceExpert, - "cce-user" => RolePermissionMap.CceUser, - _ => System.Array.Empty(), + "cce-super-admin" => RolePermissionMap.CceSuperAdmin, + "cce-admin" => RolePermissionMap.CceAdmin, + "cce-content-manager" => RolePermissionMap.CceContentManager, + "cce-state-representative" => RolePermissionMap.CceStateRepresentative, + "cce-reviewer" => RolePermissionMap.CceReviewer, + "cce-expert" => RolePermissionMap.CceExpert, + "cce-user" => RolePermissionMap.CceUser, + _ => System.Array.Empty(), }; } From 3de72df163ef27af263cff06d48dd0cb44903e53 Mon Sep 17 00:00:00 2001 From: MOHAMED RASHED Date: Tue, 19 May 2026 18:41:03 +0300 Subject: [PATCH 10/22] test files --- backend/Directory.Packages.props | 6 ++ backend/permissions.yaml | 93 ++++++++++--------- .../Identity/TestAuthHandler.cs | 6 +- .../Personas/PersonaMatrixTests.cs | 51 +++++----- .../RolePermissionMapGeneratorTests.cs | 12 ++- .../Seeder/RolesAndPermissionsSeederTests.cs | 8 +- 6 files changed, 97 insertions(+), 79 deletions(-) diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index 034f4224..a998dc31 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -36,6 +36,12 @@ + + + + + + diff --git a/backend/permissions.yaml b/backend/permissions.yaml index 210f33a5..4e29c7ca 100644 --- a/backend/permissions.yaml +++ b/backend/permissions.yaml @@ -17,14 +17,15 @@ # - Stable: never rename — deprecate old + add new instead. # # Known roles (defined in PermissionsGenerator.KnownRoles): -# cce-admin, cce-editor, cce-reviewer, cce-expert, cce-user, Anonymous +# cce-super-admin, cce-admin, cce-content-manager, cce-state-representative, +# cce-reviewer, cce-expert, cce-user, Anonymous # These match the appRoles[].value entries in # infra/entra/app-registration-manifest.json (Sub-11 Phase 02). # Sub-11 Phase 03 mapping from legacy Keycloak names: -# SuperAdmin → cce-admin -# ContentManager → cce-editor -# StateRepresentative → cce-editor (merged — content authoring is broad -# enough to cover country resources) +# SuperAdmin → cce-super-admin +# Admin → cce-admin +# ContentManager → cce-content-manager +# StateRepresentative → cce-state-representative # CommunityExpert → cce-expert # RegisteredUser → cce-user # (new in Sub-11) → cce-reviewer (review queue + read-only on content) @@ -34,146 +35,146 @@ groups: Health: Read: description: Read system health probe - roles: [cce-admin] + roles: [cce-super-admin, cce-admin] User: Read: description: Read user profiles - roles: [cce-admin, cce-editor, cce-reviewer] + roles: [cce-super-admin, cce-admin, cce-reviewer] Create: description: Create user accounts (admin path) - roles: [cce-admin] + roles: [cce-super-admin, cce-admin] Update: description: Update user profile fields (admin path) - roles: [cce-admin] + roles: [cce-super-admin, cce-admin] Delete: description: Soft-delete a user - roles: [cce-admin] + roles: [cce-super-admin, cce-admin] Restore: description: Undelete a previously soft-deleted user - roles: [cce-admin] + roles: [cce-super-admin, cce-admin] Role: Assign: description: Assign a role to a user - roles: [cce-admin] + roles: [cce-super-admin, cce-admin] Resource: Center: Upload: description: Upload a center-managed resource - roles: [cce-admin, cce-editor] + roles: [cce-super-admin, cce-admin, cce-content-manager] Update: description: Edit a center-managed resource - roles: [cce-admin, cce-editor] + roles: [cce-super-admin, cce-admin, cce-content-manager] Delete: description: Soft-delete a center resource - roles: [cce-admin, cce-editor] + roles: [cce-super-admin, cce-admin, cce-content-manager] Country: Approve: description: Approve a country resource request - roles: [cce-admin, cce-editor] + roles: [cce-super-admin, cce-admin, cce-content-manager] Reject: description: Reject a country resource request - roles: [cce-admin, cce-editor] + roles: [cce-super-admin, cce-admin, cce-content-manager] Submit: description: Submit a country resource for approval - roles: [cce-editor] + roles: [cce-state-representative] News: Publish: description: Publish news articles - roles: [cce-admin, cce-editor] + roles: [cce-super-admin, cce-admin, cce-content-manager] Update: description: Edit news article - roles: [cce-admin, cce-editor] + roles: [cce-super-admin, cce-admin, cce-content-manager] Delete: description: Soft-delete news article - roles: [cce-admin, cce-editor] + roles: [cce-super-admin, cce-admin, cce-content-manager] Event: Manage: description: Create/update/delete events - roles: [cce-admin, cce-editor] + roles: [cce-super-admin, cce-admin, cce-content-manager] Page: Edit: description: Edit static pages (about, terms, privacy) - roles: [cce-admin, cce-editor] + roles: [cce-super-admin, cce-admin, cce-content-manager] Country: Profile: Update: description: Edit country profile content - roles: [cce-admin, cce-editor] + roles: [cce-super-admin, cce-admin, cce-state-representative] Community: Post: Create: description: Create a community post - roles: [cce-user, cce-expert, cce-editor, cce-admin] + roles: [cce-user, cce-expert, cce-content-manager, cce-state-representative, cce-admin, cce-super-admin] Reply: description: Reply to a community post - roles: [cce-user, cce-expert, cce-editor, cce-admin] + roles: [cce-user, cce-expert, cce-content-manager, cce-state-representative, cce-admin, cce-super-admin] Rate: description: Rate a community post - roles: [cce-user, cce-expert, cce-editor, cce-admin] + roles: [cce-user, cce-expert, cce-content-manager, cce-state-representative, cce-admin, cce-super-admin] Moderate: description: Soft-delete or restore a community post (moderation) - roles: [cce-admin, cce-editor] + roles: [cce-super-admin, cce-admin, cce-content-manager] Follow: description: Follow posts/topics/users - roles: [cce-user, cce-expert, cce-editor, cce-admin] + roles: [cce-user, cce-expert, cce-content-manager, cce-state-representative, cce-admin, cce-super-admin] Expert: RegisterRequest: description: Submit expert registration request roles: [cce-user] ApproveRequest: description: Approve or reject an expert registration request - roles: [cce-admin, cce-editor, cce-reviewer] + roles: [cce-super-admin, cce-admin, cce-content-manager, cce-reviewer] KnowledgeMap: View: description: View knowledge maps - roles: [Anonymous, cce-user, cce-expert, cce-editor, cce-reviewer, cce-admin] + roles: [Anonymous, cce-user, cce-expert, cce-content-manager, cce-state-representative, cce-reviewer, cce-admin, cce-super-admin] Manage: description: Create/update/delete knowledge maps - roles: [cce-admin, cce-editor] + roles: [cce-super-admin, cce-admin, cce-content-manager] InteractiveCity: Run: description: Run an Interactive City simulation - roles: [Anonymous, cce-user, cce-expert, cce-editor, cce-admin] + roles: [Anonymous, cce-user, cce-expert, cce-content-manager, cce-state-representative, cce-admin, cce-super-admin] SaveScenario: description: Save a scenario to user profile - roles: [cce-user, cce-expert, cce-editor, cce-admin] + roles: [cce-user, cce-expert, cce-content-manager, cce-state-representative, cce-admin, cce-super-admin] Survey: Submit: description: Submit a service rating - roles: [Anonymous, cce-user, cce-expert, cce-editor, cce-reviewer, cce-admin] + roles: [Anonymous, cce-user, cce-expert, cce-content-manager, cce-state-representative, cce-reviewer, cce-admin, cce-super-admin] ReadAll: description: Read all survey responses - roles: [cce-admin] + roles: [cce-super-admin, cce-admin] Notification: TemplateManage: description: Manage notification templates - roles: [cce-admin] + roles: [cce-super-admin, cce-admin] Audit: Read: description: Query the audit-event log - roles: [cce-admin] + roles: [cce-super-admin, cce-admin] Report: UserRegistrations: description: Generate user-registration report - roles: [cce-admin] + roles: [cce-super-admin, cce-admin] ExpertList: description: Generate community-experts report - roles: [cce-admin] + roles: [cce-super-admin, cce-admin] SatisfactionSurvey: description: Generate satisfaction-survey report - roles: [cce-admin] + roles: [cce-super-admin, cce-admin] CommunityPosts: description: Generate community-posts report - roles: [cce-admin] + roles: [cce-super-admin, cce-admin] News: description: Generate news report - roles: [cce-admin] + roles: [cce-super-admin, cce-admin] Events: description: Generate events report - roles: [cce-admin] + roles: [cce-super-admin, cce-admin] Resources: description: Generate resources report - roles: [cce-admin] + roles: [cce-super-admin, cce-admin] CountryProfiles: description: Generate country profiles report - roles: [cce-admin] + roles: [cce-super-admin, cce-admin] diff --git a/backend/tests/CCE.Api.IntegrationTests/Identity/TestAuthHandler.cs b/backend/tests/CCE.Api.IntegrationTests/Identity/TestAuthHandler.cs index 4a139661..f56f49f1 100644 --- a/backend/tests/CCE.Api.IntegrationTests/Identity/TestAuthHandler.cs +++ b/backend/tests/CCE.Api.IntegrationTests/Identity/TestAuthHandler.cs @@ -17,8 +17,10 @@ namespace CCE.Api.IntegrationTests.Identity; /// to a with roles=cce-admin, the role /// name doubling as the bearer-token value. Useful tokens: /// -/// cce-admin — full admin permissions -/// cce-editor — content-authoring permissions +/// cce-super-admin — full system permissions +/// cce-admin — admin permissions +/// cce-content-manager — content authoring permissions +/// cce-state-representative — country resource upload permissions /// cce-reviewer — review-queue access /// cce-expert — expert-only access /// cce-user — base end-user role diff --git a/backend/tests/CCE.Api.IntegrationTests/Personas/PersonaMatrixTests.cs b/backend/tests/CCE.Api.IntegrationTests/Personas/PersonaMatrixTests.cs index 3e9ede76..84a2775f 100644 --- a/backend/tests/CCE.Api.IntegrationTests/Personas/PersonaMatrixTests.cs +++ b/backend/tests/CCE.Api.IntegrationTests/Personas/PersonaMatrixTests.cs @@ -44,7 +44,8 @@ public enum ApiHost { Internal, External } public static readonly string[] Personas = { - "anonymous", "cce-admin", "cce-editor", "cce-reviewer", "cce-expert", "cce-user", + "anonymous", "cce-super-admin", "cce-admin", "cce-content-manager", "cce-state-representative", + "cce-reviewer", "cce-expert", "cce-user", }; /// @@ -53,37 +54,43 @@ public enum ApiHost { Internal, External } /// private static readonly (string Label, ApiHost Host, string Path, Dictionary Expected)[] Probes = { - // GET /api/admin/users (User.Read) — admin/editor/reviewer allowed + // GET /api/admin/users (User.Read) — super-admin/admin/reviewer allowed ("GET /api/admin/users", ApiHost.Internal, "/api/admin/users", new() { - ["anonymous"] = PersonaOutcome.AnonymousUnauthorized, - ["cce-admin"] = PersonaOutcome.Allowed, - ["cce-editor"] = PersonaOutcome.Allowed, - ["cce-reviewer"] = PersonaOutcome.Allowed, - ["cce-expert"] = PersonaOutcome.Forbidden, - ["cce-user"] = PersonaOutcome.Forbidden, + ["anonymous"] = PersonaOutcome.AnonymousUnauthorized, + ["cce-super-admin"] = PersonaOutcome.Allowed, + ["cce-admin"] = PersonaOutcome.Allowed, + ["cce-content-manager"] = PersonaOutcome.Forbidden, + ["cce-state-representative"] = PersonaOutcome.Forbidden, + ["cce-reviewer"] = PersonaOutcome.Allowed, + ["cce-expert"] = PersonaOutcome.Forbidden, + ["cce-user"] = PersonaOutcome.Forbidden, }), - // GET /api/admin/audit-events (Audit.Read) — admin only + // GET /api/admin/audit-events (Audit.Read) — super-admin/admin only ("GET /api/admin/audit-events", ApiHost.Internal, "/api/admin/audit-events", new() { - ["anonymous"] = PersonaOutcome.AnonymousUnauthorized, - ["cce-admin"] = PersonaOutcome.Allowed, - ["cce-editor"] = PersonaOutcome.Forbidden, - ["cce-reviewer"] = PersonaOutcome.Forbidden, - ["cce-expert"] = PersonaOutcome.Forbidden, - ["cce-user"] = PersonaOutcome.Forbidden, + ["anonymous"] = PersonaOutcome.AnonymousUnauthorized, + ["cce-super-admin"] = PersonaOutcome.Allowed, + ["cce-admin"] = PersonaOutcome.Allowed, + ["cce-content-manager"] = PersonaOutcome.Forbidden, + ["cce-state-representative"] = PersonaOutcome.Forbidden, + ["cce-reviewer"] = PersonaOutcome.Forbidden, + ["cce-expert"] = PersonaOutcome.Forbidden, + ["cce-user"] = PersonaOutcome.Forbidden, }), - // GET /api/admin/expert-requests (Community.Expert.ApproveRequest) — admin/editor/reviewer + // GET /api/admin/expert-requests (Community.Expert.ApproveRequest) — super-admin/admin/content-manager/reviewer ("GET /api/admin/expert-requests", ApiHost.Internal, "/api/admin/expert-requests", new() { - ["anonymous"] = PersonaOutcome.AnonymousUnauthorized, - ["cce-admin"] = PersonaOutcome.Allowed, - ["cce-editor"] = PersonaOutcome.Allowed, - ["cce-reviewer"] = PersonaOutcome.Allowed, - ["cce-expert"] = PersonaOutcome.Forbidden, - ["cce-user"] = PersonaOutcome.Forbidden, + ["anonymous"] = PersonaOutcome.AnonymousUnauthorized, + ["cce-super-admin"] = PersonaOutcome.Allowed, + ["cce-admin"] = PersonaOutcome.Allowed, + ["cce-content-manager"] = PersonaOutcome.Allowed, + ["cce-state-representative"] = PersonaOutcome.Forbidden, + ["cce-reviewer"] = PersonaOutcome.Allowed, + ["cce-expert"] = PersonaOutcome.Forbidden, + ["cce-user"] = PersonaOutcome.Forbidden, }), // /api/me + /api/admin/reports/* probes deferred — those endpoints diff --git a/backend/tests/CCE.Domain.SourceGenerators.Tests/RolePermissionMapGeneratorTests.cs b/backend/tests/CCE.Domain.SourceGenerators.Tests/RolePermissionMapGeneratorTests.cs index d0fe54c2..b783bc06 100644 --- a/backend/tests/CCE.Domain.SourceGenerators.Tests/RolePermissionMapGeneratorTests.cs +++ b/backend/tests/CCE.Domain.SourceGenerators.Tests/RolePermissionMapGeneratorTests.cs @@ -45,20 +45,20 @@ public void Permission_assigned_to_multiple_roles_appears_in_each_role_collectio Page: Edit: description: x - roles: [cce-admin, cce-editor] + roles: [cce-admin, cce-content-manager] """; var generated = GeneratorTestHarness.Run(yaml); var cceAdminBlock = ExtractRoleBlock(generated, "CceAdmin"); - var cceEditorBlock = ExtractRoleBlock(generated, "CceEditor"); + var cceContentManagerBlock = ExtractRoleBlock(generated, "CceContentManager"); cceAdminBlock.Should().Contain("\"Page.Edit\""); - cceEditorBlock.Should().Contain("\"Page.Edit\""); + cceContentManagerBlock.Should().Contain("\"Page.Edit\""); } [Fact] - public void All_six_roles_are_emitted_even_when_some_have_no_permissions() + public void All_eight_roles_are_emitted_even_when_some_have_no_permissions() { const string yaml = """ groups: @@ -72,8 +72,10 @@ public void All_six_roles_are_emitted_even_when_some_have_no_permissions() // Sub-11 Phase 03 Entra ID app-role values, PascalCased for the // generated C# property names. + generated.Should().Contain("public static IReadOnlyList CceSuperAdmin { get; }"); generated.Should().Contain("public static IReadOnlyList CceAdmin { get; }"); - generated.Should().Contain("public static IReadOnlyList CceEditor { get; }"); + generated.Should().Contain("public static IReadOnlyList CceContentManager { get; }"); + generated.Should().Contain("public static IReadOnlyList CceStateRepresentative { get; }"); generated.Should().Contain("public static IReadOnlyList CceReviewer { get; }"); generated.Should().Contain("public static IReadOnlyList CceExpert { get; }"); generated.Should().Contain("public static IReadOnlyList CceUser { get; }"); diff --git a/backend/tests/CCE.Infrastructure.Tests/Seeder/RolesAndPermissionsSeederTests.cs b/backend/tests/CCE.Infrastructure.Tests/Seeder/RolesAndPermissionsSeederTests.cs index e54f7b10..1f8d733f 100644 --- a/backend/tests/CCE.Infrastructure.Tests/Seeder/RolesAndPermissionsSeederTests.cs +++ b/backend/tests/CCE.Infrastructure.Tests/Seeder/RolesAndPermissionsSeederTests.cs @@ -16,7 +16,7 @@ private static CceDbContext NewContext() => .Options); [Fact] - public async Task First_run_creates_5_roles_with_permissions() + public async Task First_run_creates_7_roles_with_permissions() { using var ctx = NewContext(); var seeder = new RolesAndPermissionsSeeder(ctx, NullLogger.Instance); @@ -24,9 +24,9 @@ public async Task First_run_creates_5_roles_with_permissions() await seeder.SeedAsync(); var roles = await ctx.Set().ToListAsync(); - roles.Should().HaveCount(5); - roles.Select(r => r.Name).Should().Contain(new[] { "cce-admin", "cce-editor", - "cce-reviewer", "cce-expert", "cce-user" }); + roles.Should().HaveCount(7); + roles.Select(r => r.Name).Should().Contain(new[] { "cce-super-admin", "cce-admin", + "cce-content-manager", "cce-state-representative", "cce-reviewer", "cce-expert", "cce-user" }); var claims = await ctx.Set>().ToListAsync(); claims.Should().NotBeEmpty(); From 561178c53b60dea9bee24f1ae0fb04ff49dc75d5 Mon Sep 17 00:00:00 2001 From: MOHAMED RASHED Date: Wed, 20 May 2026 00:37:33 +0300 Subject: [PATCH 11/22] feat/integration gateway email fix --- .../appsettings.Development.json | 10 +++----- .../appsettings.Production.json | 8 +++++- .../appsettings.Development.json | 10 +++----- .../appsettings.Production.json | 8 +++++- .../Common/Interfaces/IEmailSender.cs | 3 ++- .../Communication/GatewayEmailSender.cs | 25 ++++++++++++++----- .../Email/NullEmailSender.cs | 2 +- .../Email/SmtpEmailSender.cs | 2 +- .../ExternalApiServiceCollectionExtensions.cs | 3 ++- .../Identity/EntraIdRegistrationService.cs | 2 +- .../Identity/PasswordResetEmailSender.cs | 2 +- .../Communication/GatewayResponse.cs | 4 +-- .../ICommunicationGatewayClient.cs | 2 +- .../Communication/SendEmailRequest.cs | 4 ++- 14 files changed, 53 insertions(+), 32 deletions(-) diff --git a/backend/src/CCE.Api.External/appsettings.Development.json b/backend/src/CCE.Api.External/appsettings.Development.json index ddacb366..0af6ffeb 100644 --- a/backend/src/CCE.Api.External/appsettings.Development.json +++ b/backend/src/CCE.Api.External/appsettings.Development.json @@ -64,7 +64,7 @@ "RequireConfirmedEmail": false }, "Email": { - "Provider": "smtp", + "Provider": "gateway", "Host": "localhost", "Port": 1025, "FromAddress": "no-reply@cce.local", @@ -75,12 +75,8 @@ }, "ExternalApis": { "CommunicationGateway": { - "BaseUrl": "https://gateway.example.com", - "TimeoutSeconds": 30, - "Auth": { - "Type": "Bearer", - "Token": "dev-gateway-token-change-me" - } + "BaseUrl": "http://localhost:3001", + "TimeoutSeconds": 30 } } } diff --git a/backend/src/CCE.Api.External/appsettings.Production.json b/backend/src/CCE.Api.External/appsettings.Production.json index 0126fa07..5ea91d91 100644 --- a/backend/src/CCE.Api.External/appsettings.Production.json +++ b/backend/src/CCE.Api.External/appsettings.Production.json @@ -64,7 +64,7 @@ "RequireConfirmedEmail": false }, "Email": { - "Provider": "smtp", + "Provider": "gateway", "Host": "localhost", "Port": 1025, "FromAddress": "no-reply@cce.local", @@ -72,5 +72,11 @@ "Username": "", "Password": "", "EnableSsl": false + }, + "ExternalApis": { + "CommunicationGateway": { + "BaseUrl": "https://cce-mocks.bonto.run", + "TimeoutSeconds": 30 + } } } diff --git a/backend/src/CCE.Api.Internal/appsettings.Development.json b/backend/src/CCE.Api.Internal/appsettings.Development.json index 3e767f11..c0c54fc8 100644 --- a/backend/src/CCE.Api.Internal/appsettings.Development.json +++ b/backend/src/CCE.Api.Internal/appsettings.Development.json @@ -51,7 +51,7 @@ "RequireConfirmedEmail": false }, "Email": { - "Provider": "smtp", + "Provider": "gateway", "Host": "localhost", "Port": 1025, "FromAddress": "no-reply@cce.local", @@ -62,12 +62,8 @@ }, "ExternalApis": { "CommunicationGateway": { - "BaseUrl": "https://gateway.example.com", - "TimeoutSeconds": 30, - "Auth": { - "Type": "Bearer", - "Token": "dev-gateway-token-change-me" - } + "BaseUrl": "http://localhost:3001", + "TimeoutSeconds": 30 } } } diff --git a/backend/src/CCE.Api.Internal/appsettings.Production.json b/backend/src/CCE.Api.Internal/appsettings.Production.json index 56210d7b..8857dd45 100644 --- a/backend/src/CCE.Api.Internal/appsettings.Production.json +++ b/backend/src/CCE.Api.Internal/appsettings.Production.json @@ -51,7 +51,7 @@ "RequireConfirmedEmail": false }, "Email": { - "Provider": "smtp", + "Provider": "gateway", "Host": "localhost", "Port": 1025, "FromAddress": "no-reply@cce.local", @@ -59,5 +59,11 @@ "Username": "", "Password": "", "EnableSsl": false + }, + "ExternalApis": { + "CommunicationGateway": { + "BaseUrl": "https://cce-mocks.bonto.run", + "TimeoutSeconds": 30 + } } } diff --git a/backend/src/CCE.Application/Common/Interfaces/IEmailSender.cs b/backend/src/CCE.Application/Common/Interfaces/IEmailSender.cs index 45fafa7f..889edd48 100644 --- a/backend/src/CCE.Application/Common/Interfaces/IEmailSender.cs +++ b/backend/src/CCE.Application/Common/Interfaces/IEmailSender.cs @@ -29,6 +29,7 @@ public interface IEmailSender /// Recipient address. Must be a valid RFC-5322 address. /// Subject line. Plain text; no formatting. /// HTML body. Sanitized HTML allowed. + /// Optional gateway template identifier. /// Cancellation token. - Task SendAsync(string to, string subject, string htmlBody, CancellationToken ct = default); + Task SendAsync(string to, string subject, string htmlBody, string? templateId = null, CancellationToken ct = default); } diff --git a/backend/src/CCE.Infrastructure/Communication/GatewayEmailSender.cs b/backend/src/CCE.Infrastructure/Communication/GatewayEmailSender.cs index 2366f7f3..2cd833c7 100644 --- a/backend/src/CCE.Infrastructure/Communication/GatewayEmailSender.cs +++ b/backend/src/CCE.Infrastructure/Communication/GatewayEmailSender.cs @@ -1,6 +1,8 @@ using CCE.Application.Common.Interfaces; +using CCE.Infrastructure.Email; using CCE.Integration.Communication; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; namespace CCE.Infrastructure.Communication; @@ -11,20 +13,31 @@ namespace CCE.Infrastructure.Communication; public sealed class GatewayEmailSender : IEmailSender { private readonly ICommunicationGatewayClient _client; + private readonly IOptions _options; private readonly ILogger _logger; - public GatewayEmailSender(ICommunicationGatewayClient client, ILogger logger) + public GatewayEmailSender( + ICommunicationGatewayClient client, + IOptions options, + ILogger logger) { _client = client; + _options = options; _logger = logger; } - public async Task SendAsync(string to, string subject, string htmlBody, CancellationToken ct = default) + public async Task SendAsync(string to, string subject, string htmlBody, string? templateId = null, CancellationToken ct = default) { - var request = new SendEmailRequest(to, subject, htmlBody); + var request = new SendEmailRequest( + To: to, + From: _options.Value.FromAddress, + Subject: subject, + Html: htmlBody, + TemplateId: templateId); + var response = await _client.SendEmailAsync(request, ct).ConfigureAwait(false); - if (!response.Success) + if (!"success".Equals(response.Status, StringComparison.OrdinalIgnoreCase)) { _logger.LogError( "Gateway email send failed for {To} with subject {Subject}: {Error}", @@ -33,7 +46,7 @@ public async Task SendAsync(string to, string subject, string htmlBody, Cancella } _logger.LogInformation( - "Sent email via gateway to {To} with subject {Subject} (messageId {MessageId})", - to, subject, response.MessageId); + "Sent email via gateway to {To} with subject {Subject} (id {Id})", + to, subject, response.Id); } } diff --git a/backend/src/CCE.Infrastructure/Email/NullEmailSender.cs b/backend/src/CCE.Infrastructure/Email/NullEmailSender.cs index c30e9acd..7de592d7 100644 --- a/backend/src/CCE.Infrastructure/Email/NullEmailSender.cs +++ b/backend/src/CCE.Infrastructure/Email/NullEmailSender.cs @@ -18,7 +18,7 @@ public sealed class NullEmailSender : IEmailSender public NullEmailSender(ILogger logger) => _logger = logger; - public Task SendAsync(string to, string subject, string htmlBody, CancellationToken ct = default) + public Task SendAsync(string to, string subject, string htmlBody, string? templateId = null, CancellationToken ct = default) { _logger.LogInformation( "[NullEmailSender] Would have sent email to {To} with subject {Subject} (body suppressed)", diff --git a/backend/src/CCE.Infrastructure/Email/SmtpEmailSender.cs b/backend/src/CCE.Infrastructure/Email/SmtpEmailSender.cs index c62ecf12..ae64ca81 100644 --- a/backend/src/CCE.Infrastructure/Email/SmtpEmailSender.cs +++ b/backend/src/CCE.Infrastructure/Email/SmtpEmailSender.cs @@ -23,7 +23,7 @@ public SmtpEmailSender(IOptions options, ILogger _logger = logger; } - public async Task SendAsync(string to, string subject, string htmlBody, CancellationToken ct = default) + public async Task SendAsync(string to, string subject, string htmlBody, string? templateId = null, CancellationToken ct = default) { var opts = _options.Value; using var message = new MimeMessage(); diff --git a/backend/src/CCE.Infrastructure/ExternalApis/ExternalApiServiceCollectionExtensions.cs b/backend/src/CCE.Infrastructure/ExternalApis/ExternalApiServiceCollectionExtensions.cs index b61881e4..8dbf0962 100644 --- a/backend/src/CCE.Infrastructure/ExternalApis/ExternalApiServiceCollectionExtensions.cs +++ b/backend/src/CCE.Infrastructure/ExternalApis/ExternalApiServiceCollectionExtensions.cs @@ -28,7 +28,8 @@ public static IServiceCollection AddExternalApiClient( ContentSerializer = new SystemTextJsonContentSerializer( new System.Text.Json.JsonSerializerOptions { - PropertyNameCaseInsensitive = true + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase }) }; diff --git a/backend/src/CCE.Infrastructure/Identity/EntraIdRegistrationService.cs b/backend/src/CCE.Infrastructure/Identity/EntraIdRegistrationService.cs index 7f431890..215c26fc 100644 --- a/backend/src/CCE.Infrastructure/Identity/EntraIdRegistrationService.cs +++ b/backend/src/CCE.Infrastructure/Identity/EntraIdRegistrationService.cs @@ -103,7 +103,7 @@ public async Task CreateUserAsync(RegistrationRequest dto, C { var subject = "Welcome to CCE — your account is ready"; var body = BuildWelcomeEmailHtml(dto, tempPassword); - await _emailSender.SendAsync(created.UserPrincipalName!, subject, body, ct).ConfigureAwait(false); + await _emailSender.SendAsync(created.UserPrincipalName!, subject, body, ct: ct).ConfigureAwait(false); } catch (Exception ex) { diff --git a/backend/src/CCE.Infrastructure/Identity/PasswordResetEmailSender.cs b/backend/src/CCE.Infrastructure/Identity/PasswordResetEmailSender.cs index 0057ff7a..d78cf6e7 100644 --- a/backend/src/CCE.Infrastructure/Identity/PasswordResetEmailSender.cs +++ b/backend/src/CCE.Infrastructure/Identity/PasswordResetEmailSender.cs @@ -36,7 +36,7 @@ public async Task SendAsync(User user, string resetToken, CancellationToken ct) """; - await _emailSender.SendAsync(user.Email ?? string.Empty, "Reset your CCE password", body, ct) + await _emailSender.SendAsync(user.Email ?? string.Empty, "Reset your CCE password", body, ct: ct) .ConfigureAwait(false); } } diff --git a/backend/src/CCE.Integration/Communication/GatewayResponse.cs b/backend/src/CCE.Integration/Communication/GatewayResponse.cs index e34d8811..cd6e731e 100644 --- a/backend/src/CCE.Integration/Communication/GatewayResponse.cs +++ b/backend/src/CCE.Integration/Communication/GatewayResponse.cs @@ -1,6 +1,6 @@ namespace CCE.Integration.Communication; public sealed record GatewayResponse( - bool Success, - string? MessageId = null, + string Status, + string? Id = null, string? Error = null); diff --git a/backend/src/CCE.Integration/Communication/ICommunicationGatewayClient.cs b/backend/src/CCE.Integration/Communication/ICommunicationGatewayClient.cs index 7391fdf2..62e4e585 100644 --- a/backend/src/CCE.Integration/Communication/ICommunicationGatewayClient.cs +++ b/backend/src/CCE.Integration/Communication/ICommunicationGatewayClient.cs @@ -9,7 +9,7 @@ namespace CCE.Integration.Communication; /// public interface ICommunicationGatewayClient { - [Post("/api/v1/email/send")] + [Post("/integrationgateway/email/send")] Task SendEmailAsync([Body] SendEmailRequest request, CancellationToken cancellationToken = default); [Post("/api/v1/sms/send")] diff --git a/backend/src/CCE.Integration/Communication/SendEmailRequest.cs b/backend/src/CCE.Integration/Communication/SendEmailRequest.cs index a299ad26..e3cfb230 100644 --- a/backend/src/CCE.Integration/Communication/SendEmailRequest.cs +++ b/backend/src/CCE.Integration/Communication/SendEmailRequest.cs @@ -2,5 +2,7 @@ namespace CCE.Integration.Communication; public sealed record SendEmailRequest( string To, + string From, string Subject, - string Body); + string Html, + string? TemplateId = null); From 30e0ca9f0b2db97424ec8fd708e37c82a8113ab3 Mon Sep 17 00:00:00 2001 From: MOHAMED RASHED Date: Wed, 20 May 2026 12:32:15 +0300 Subject: [PATCH 12/22] feat/active directory feature with integration gateway --- .../appsettings.Development.json | 4 ++ .../appsettings.Production.json | 4 ++ .../Endpoints/AdminAuthEndpoints.cs | 35 ++++++++++ backend/src/CCE.Api.Internal/Program.cs | 5 +- .../appsettings.Development.json | 4 ++ .../appsettings.Production.json | 4 ++ .../Identity/Auth/AdLogin/AdLoginCommand.cs | 12 ++++ .../Auth/AdLogin/AdLoginCommandHandler.cs | 36 ++++++++++ .../Auth/AdLogin/AdLoginCommandValidator.cs | 17 +++++ .../Identity/Auth/AdLogin/AdLoginRequest.cs | 5 ++ .../Identity/Auth/Common/IAuthService.cs | 2 + backend/src/CCE.Domain/Identity/User.cs | 25 +++++++ .../CCE.Infrastructure/DependencyInjection.cs | 1 + .../Identity/AdRoleMapper.cs | 19 +++++ .../Identity/AuthService.cs | 70 ++++++++++++++++++- .../AdminAuth/AdAuthRequest.cs | 5 ++ .../AdminAuth/AdAuthResponse.cs | 10 +++ .../AdminAuth/IAdminAuthGatewayClient.cs | 9 +++ 18 files changed, 264 insertions(+), 3 deletions(-) create mode 100644 backend/src/CCE.Api.Internal/Endpoints/AdminAuthEndpoints.cs create mode 100644 backend/src/CCE.Application/Identity/Auth/AdLogin/AdLoginCommand.cs create mode 100644 backend/src/CCE.Application/Identity/Auth/AdLogin/AdLoginCommandHandler.cs create mode 100644 backend/src/CCE.Application/Identity/Auth/AdLogin/AdLoginCommandValidator.cs create mode 100644 backend/src/CCE.Application/Identity/Auth/AdLogin/AdLoginRequest.cs create mode 100644 backend/src/CCE.Infrastructure/Identity/AdRoleMapper.cs create mode 100644 backend/src/CCE.Integration/AdminAuth/AdAuthRequest.cs create mode 100644 backend/src/CCE.Integration/AdminAuth/AdAuthResponse.cs create mode 100644 backend/src/CCE.Integration/AdminAuth/IAdminAuthGatewayClient.cs diff --git a/backend/src/CCE.Api.External/appsettings.Development.json b/backend/src/CCE.Api.External/appsettings.Development.json index 0af6ffeb..833095db 100644 --- a/backend/src/CCE.Api.External/appsettings.Development.json +++ b/backend/src/CCE.Api.External/appsettings.Development.json @@ -77,6 +77,10 @@ "CommunicationGateway": { "BaseUrl": "http://localhost:3001", "TimeoutSeconds": 30 + }, + "AdminAuthGateway": { + "BaseUrl": "http://localhost:3001", + "TimeoutSeconds": 30 } } } diff --git a/backend/src/CCE.Api.External/appsettings.Production.json b/backend/src/CCE.Api.External/appsettings.Production.json index 5ea91d91..aac05caa 100644 --- a/backend/src/CCE.Api.External/appsettings.Production.json +++ b/backend/src/CCE.Api.External/appsettings.Production.json @@ -77,6 +77,10 @@ "CommunicationGateway": { "BaseUrl": "https://cce-mocks.bonto.run", "TimeoutSeconds": 30 + }, + "AdminAuthGateway": { + "BaseUrl": "https://cce-mocks.bonto.run", + "TimeoutSeconds": 30 } } } diff --git a/backend/src/CCE.Api.Internal/Endpoints/AdminAuthEndpoints.cs b/backend/src/CCE.Api.Internal/Endpoints/AdminAuthEndpoints.cs new file mode 100644 index 00000000..ad239100 --- /dev/null +++ b/backend/src/CCE.Api.Internal/Endpoints/AdminAuthEndpoints.cs @@ -0,0 +1,35 @@ +using CCE.Api.Common.Extensions; +using CCE.Application.Identity.Auth.AdLogin; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace CCE.Api.Internal.Endpoints; + +public static class AdminAuthEndpoints +{ + public static IEndpointRouteBuilder MapAdminAuthEndpoints(this IEndpointRouteBuilder app) + { + var auth = app.MapGroup("/api/auth").WithTags("Auth"); + + auth.MapPost("/ad-login", async ( + AdLoginRequest body, + HttpContext ctx, + IMediator mediator, + CancellationToken ct) => + { + var result = await mediator.Send(new AdLoginCommand( + body.Username, + body.Password, + ctx.Connection.RemoteIpAddress?.ToString(), + ctx.Request.Headers.UserAgent.ToString()), ct).ConfigureAwait(false); + + return result.ToHttpResult(); + }) + .AllowAnonymous() + .WithName("InternalAdLogin"); + + return app; + } +} diff --git a/backend/src/CCE.Api.Internal/Program.cs b/backend/src/CCE.Api.Internal/Program.cs index 159a1a42..073a5518 100644 --- a/backend/src/CCE.Api.Internal/Program.cs +++ b/backend/src/CCE.Api.Internal/Program.cs @@ -59,8 +59,9 @@ app.UseCceOpenApi(apiTag: "internal"); -app.MapAuthEndpoints(CCE.Application.Identity.Auth.Common.LocalAuthApi.Internal); -app.MapIdentityEndpoints(); + app.MapAuthEndpoints(CCE.Application.Identity.Auth.Common.LocalAuthApi.Internal); + app.MapAdminAuthEndpoints(); + app.MapIdentityEndpoints(); app.MapExpertEndpoints(); app.MapAssetEndpoints(); app.MapResourceEndpoints(); diff --git a/backend/src/CCE.Api.Internal/appsettings.Development.json b/backend/src/CCE.Api.Internal/appsettings.Development.json index c0c54fc8..3d766801 100644 --- a/backend/src/CCE.Api.Internal/appsettings.Development.json +++ b/backend/src/CCE.Api.Internal/appsettings.Development.json @@ -64,6 +64,10 @@ "CommunicationGateway": { "BaseUrl": "http://localhost:3001", "TimeoutSeconds": 30 + }, + "AdminAuthGateway": { + "BaseUrl": "http://localhost:3001", + "TimeoutSeconds": 30 } } } diff --git a/backend/src/CCE.Api.Internal/appsettings.Production.json b/backend/src/CCE.Api.Internal/appsettings.Production.json index 8857dd45..35cf5eae 100644 --- a/backend/src/CCE.Api.Internal/appsettings.Production.json +++ b/backend/src/CCE.Api.Internal/appsettings.Production.json @@ -64,6 +64,10 @@ "CommunicationGateway": { "BaseUrl": "https://cce-mocks.bonto.run", "TimeoutSeconds": 30 + }, + "AdminAuthGateway": { + "BaseUrl": "https://cce-mocks.bonto.run", + "TimeoutSeconds": 30 } } } diff --git a/backend/src/CCE.Application/Identity/Auth/AdLogin/AdLoginCommand.cs b/backend/src/CCE.Application/Identity/Auth/AdLogin/AdLoginCommand.cs new file mode 100644 index 00000000..a33135ec --- /dev/null +++ b/backend/src/CCE.Application/Identity/Auth/AdLogin/AdLoginCommand.cs @@ -0,0 +1,12 @@ +using CCE.Application.Common; +using CCE.Application.Identity.Auth.Common; +using MediatR; + +namespace CCE.Application.Identity.Auth.AdLogin; + +public sealed record AdLoginCommand( + string Username, + string Password, + string? Ip, + string? UserAgent) + : IRequest>; diff --git a/backend/src/CCE.Application/Identity/Auth/AdLogin/AdLoginCommandHandler.cs b/backend/src/CCE.Application/Identity/Auth/AdLogin/AdLoginCommandHandler.cs new file mode 100644 index 00000000..4623d15c --- /dev/null +++ b/backend/src/CCE.Application/Identity/Auth/AdLogin/AdLoginCommandHandler.cs @@ -0,0 +1,36 @@ +using CCE.Application.Common; +using CCE.Application.Identity.Auth.Common; +using CCE.Application.Messages; +using MediatR; + +namespace CCE.Application.Identity.Auth.AdLogin; + +internal sealed class AdLoginCommandHandler + : IRequestHandler> +{ + private readonly IAuthService _auth; + private readonly MessageFactory _msg; + + public AdLoginCommandHandler(IAuthService auth, MessageFactory msg) + { + _auth = auth; + _msg = msg; + } + + public async Task> Handle(AdLoginCommand request, CancellationToken ct) + { + var dto = await _auth.AdLoginAsync( + request.Username, + request.Password, + request.Ip, + request.UserAgent, + ct).ConfigureAwait(false); + + if (dto is null) + { + return _msg.InvalidCredentials(); + } + + return _msg.Ok(dto, "AD_LOGIN_SUCCESS"); + } +} diff --git a/backend/src/CCE.Application/Identity/Auth/AdLogin/AdLoginCommandValidator.cs b/backend/src/CCE.Application/Identity/Auth/AdLogin/AdLoginCommandValidator.cs new file mode 100644 index 00000000..d14074ad --- /dev/null +++ b/backend/src/CCE.Application/Identity/Auth/AdLogin/AdLoginCommandValidator.cs @@ -0,0 +1,17 @@ +using FluentValidation; + +namespace CCE.Application.Identity.Auth.AdLogin; + +public sealed class AdLoginCommandValidator : AbstractValidator +{ + public AdLoginCommandValidator() + { + RuleFor(x => x.Username) + .NotEmpty() + .WithMessage("Username is required."); + + RuleFor(x => x.Password) + .NotEmpty() + .WithMessage("Password is required."); + } +} diff --git a/backend/src/CCE.Application/Identity/Auth/AdLogin/AdLoginRequest.cs b/backend/src/CCE.Application/Identity/Auth/AdLogin/AdLoginRequest.cs new file mode 100644 index 00000000..6b6eea44 --- /dev/null +++ b/backend/src/CCE.Application/Identity/Auth/AdLogin/AdLoginRequest.cs @@ -0,0 +1,5 @@ +namespace CCE.Application.Identity.Auth.AdLogin; + +public sealed record AdLoginRequest( + string Username, + string Password); diff --git a/backend/src/CCE.Application/Identity/Auth/Common/IAuthService.cs b/backend/src/CCE.Application/Identity/Auth/Common/IAuthService.cs index 0d806e59..22c2cbbb 100644 --- a/backend/src/CCE.Application/Identity/Auth/Common/IAuthService.cs +++ b/backend/src/CCE.Application/Identity/Auth/Common/IAuthService.cs @@ -17,4 +17,6 @@ public interface IAuthService Task ForgotPasswordAsync(string email, CancellationToken ct); Task ResetPasswordAsync(string email, string encodedToken, string newPassword, string? ip, CancellationToken ct); + + Task AdLoginAsync(string username, string password, string? ip, string? userAgent, CancellationToken ct); } diff --git a/backend/src/CCE.Domain/Identity/User.cs b/backend/src/CCE.Domain/Identity/User.cs index 5cdd1e0d..3ae20483 100644 --- a/backend/src/CCE.Domain/Identity/User.cs +++ b/backend/src/CCE.Domain/Identity/User.cs @@ -75,6 +75,31 @@ public static User CreateStubFromEntraId(System.Guid objectId, string email, str }; } + /// + /// Factory for stub User rows created on first AD login via the integration gateway. + /// Profile fields default to empty; operator/admin should prompt for completion. + /// + public static User CreateStubFromAd( + string email, + string? firstName, + string? lastName, + string? displayName) + { + return new User + { + Id = System.Guid.NewGuid(), + Email = email, + UserName = email, + NormalizedEmail = email.ToUpperInvariant(), + NormalizedUserName = email.ToUpperInvariant(), + EmailConfirmed = true, + FirstName = firstName ?? displayName ?? string.Empty, + LastName = lastName ?? string.Empty, + JobTitle = string.Empty, + OrganizationName = string.Empty, + }; + } + public static User RegisterLocal( string firstName, string lastName, diff --git a/backend/src/CCE.Infrastructure/DependencyInjection.cs b/backend/src/CCE.Infrastructure/DependencyInjection.cs index f3e25bca..8466c9e8 100644 --- a/backend/src/CCE.Infrastructure/DependencyInjection.cs +++ b/backend/src/CCE.Infrastructure/DependencyInjection.cs @@ -134,6 +134,7 @@ public static IServiceCollection AddInfrastructure( // Singleton because all impls are stateless + thread-safe. services.Configure(configuration.GetSection(EmailOptions.SectionName)); services.AddExternalApiClient("CommunicationGateway"); + services.AddExternalApiClient("AdminAuthGateway"); services.AddSingleton(sp => { var opts = sp.GetRequiredService>(); diff --git a/backend/src/CCE.Infrastructure/Identity/AdRoleMapper.cs b/backend/src/CCE.Infrastructure/Identity/AdRoleMapper.cs new file mode 100644 index 00000000..e23c0475 --- /dev/null +++ b/backend/src/CCE.Infrastructure/Identity/AdRoleMapper.cs @@ -0,0 +1,19 @@ +namespace CCE.Infrastructure.Identity; + +public static class AdRoleMapper +{ + public static string? ToCceRole(string adGroup) + { + return adGroup switch + { + "CCE-SuperAdmins" => "cce-super-admin", + "CCE-Admins" => "cce-admin", + "CCE-ContentManagers" => "cce-content-manager", + "CCE-StateRepresentatives" => "cce-state-representative", + "CCE-Reviewers" => "cce-reviewer", + "CCE-Experts" => "cce-expert", + "CCE-Users" => "cce-user", + _ => null, + }; + } +} diff --git a/backend/src/CCE.Infrastructure/Identity/AuthService.cs b/backend/src/CCE.Infrastructure/Identity/AuthService.cs index f71c4d65..7dceca54 100644 --- a/backend/src/CCE.Infrastructure/Identity/AuthService.cs +++ b/backend/src/CCE.Infrastructure/Identity/AuthService.cs @@ -2,6 +2,7 @@ using CCE.Application.Identity.Auth.Common; using CCE.Domain.Common; using CCE.Domain.Identity; +using CCE.Integration.AdminAuth; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Options; @@ -18,6 +19,7 @@ public sealed class AuthService : IAuthService private readonly ISystemClock _clock; private readonly IOptions _options; private readonly IPasswordResetEmailSender _emailSender; + private readonly IAdminAuthGatewayClient _adGateway; public AuthService( UserManager userManager, @@ -27,7 +29,8 @@ public AuthService( ICceDbContext db, ISystemClock clock, IOptions options, - IPasswordResetEmailSender emailSender) + IPasswordResetEmailSender emailSender, + IAdminAuthGatewayClient adGateway) { _userManager = userManager; _roleManager = roleManager; @@ -37,6 +40,7 @@ public AuthService( _clock = clock; _options = options; _emailSender = emailSender; + _adGateway = adGateway; } public async Task LoginAsync(string email, string password, LocalAuthApi api, string? ip, string? userAgent, CancellationToken ct) @@ -152,6 +156,70 @@ public async Task ForgotPasswordAsync(string email, CancellationToken ct) return null; } + public async Task AdLoginAsync(string username, string password, string? ip, string? userAgent, CancellationToken ct) + { + var gatewayResponse = await _adGateway.LoginAsync( + new AdAuthRequest(username, password), ct).ConfigureAwait(false); + + if (!"success".Equals(gatewayResponse.Status, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var email = gatewayResponse.Email!; + var user = await _userManager.FindByEmailAsync(email).ConfigureAwait(false); + + if (user is null) + { + user = User.CreateStubFromAd( + email, + gatewayResponse.FirstName, + gatewayResponse.LastName, + gatewayResponse.DisplayName); + + var createResult = await _userManager.CreateAsync(user).ConfigureAwait(false); + if (!createResult.Succeeded) + { + return null; + } + } + + await SyncAdRolesAsync(user, gatewayResponse.Groups).ConfigureAwait(false); + + return await IssueAndBuildDtoAsync(user, LocalAuthApi.Internal, ip, userAgent, null, ct).ConfigureAwait(false); + } + + private async Task SyncAdRolesAsync(User user, IReadOnlyList? adGroups) + { + if (adGroups is null || adGroups.Count == 0) + { + return; + } + + var currentRoles = await _userManager.GetRolesAsync(user).ConfigureAwait(false); + var desiredRoles = adGroups + .Select(static g => AdRoleMapper.ToCceRole(g)) + .OfType() + .Distinct() + .ToList(); + + var rolesToAdd = desiredRoles.Except(currentRoles).ToList(); + var rolesToRemove = currentRoles.Except(desiredRoles).ToList(); + + foreach (var role in rolesToAdd) + { + if (!await _userManager.IsInRoleAsync(user, role!).ConfigureAwait(false)) + { + await _userManager.AddToRoleAsync(user, role!).ConfigureAwait(false); + } + } + + foreach (var role in rolesToRemove) + { + await _userManager.RemoveFromRoleAsync(user, role).ConfigureAwait(false); + } + } + private async Task IssueAndBuildDtoAsync(User user, LocalAuthApi api, string? ip, string? userAgent, Guid? tokenFamilyId, CancellationToken ct) { var issued = await _tokenService.IssueAsync(user, api, ct).ConfigureAwait(false); diff --git a/backend/src/CCE.Integration/AdminAuth/AdAuthRequest.cs b/backend/src/CCE.Integration/AdminAuth/AdAuthRequest.cs new file mode 100644 index 00000000..ea96802f --- /dev/null +++ b/backend/src/CCE.Integration/AdminAuth/AdAuthRequest.cs @@ -0,0 +1,5 @@ +namespace CCE.Integration.AdminAuth; + +public sealed record AdAuthRequest( + string Username, + string Password); diff --git a/backend/src/CCE.Integration/AdminAuth/AdAuthResponse.cs b/backend/src/CCE.Integration/AdminAuth/AdAuthResponse.cs new file mode 100644 index 00000000..5f0c8b28 --- /dev/null +++ b/backend/src/CCE.Integration/AdminAuth/AdAuthResponse.cs @@ -0,0 +1,10 @@ +namespace CCE.Integration.AdminAuth; + +public sealed record AdAuthResponse( + string Status, + string? Email = null, + string? FirstName = null, + string? LastName = null, + string? DisplayName = null, + IReadOnlyList? Groups = null, + string? Error = null); diff --git a/backend/src/CCE.Integration/AdminAuth/IAdminAuthGatewayClient.cs b/backend/src/CCE.Integration/AdminAuth/IAdminAuthGatewayClient.cs new file mode 100644 index 00000000..81a292c6 --- /dev/null +++ b/backend/src/CCE.Integration/AdminAuth/IAdminAuthGatewayClient.cs @@ -0,0 +1,9 @@ +using Refit; + +namespace CCE.Integration.AdminAuth; + +public interface IAdminAuthGatewayClient +{ + [Post("/integrationgateway/auth/ad/login")] + Task LoginAsync([Body] AdAuthRequest request, CancellationToken cancellationToken = default); +} From 21d845e941368994c3b93905406192469c7cc6ba Mon Sep 17 00:00:00 2001 From: MOHAMED RASHED Date: Wed, 20 May 2026 15:45:31 +0300 Subject: [PATCH 13/22] feat(admin): user management queries & auth token fix -manage user status - Optimize ListUsersQuery: single-projection with inline role sub-select, replace join-based role filter with EXISTS subquery - Optimize GetUserByIdQuery: collapse two DB round-trips into one Select projection, replace ToList+SingleOrDefault with FirstOrDefaultAsync - Fix auth token handling in user management flow --- .../src/CCE.Api.Common/Auth/DevAuthHandler.cs | 99 +- .../Localization/Resources.yaml | 8 + .../Endpoints/IdentityEndpoints.cs | 47 + .../Identity/Auth/Common/IAuthService.cs | 4 + .../ChangeUserStatusCommand.cs | 9 + .../ChangeUserStatusCommandHandler.cs | 53 + .../ChangeUserStatusCommandValidator.cs | 11 + .../Commands/CreateUser/CreateUserCommand.cs | 14 + .../CreateUser/CreateUserCommandHandler.cs | 37 + .../CreateUser/CreateUserCommandValidator.cs | 26 + .../Commands/DeleteUser/DeleteUserCommand.cs | 7 + .../DeleteUser/DeleteUserCommandHandler.cs | 57 + .../DeleteUser/DeleteUserCommandValidator.cs | 11 + .../GetUserById/GetUserByIdQueryHandler.cs | 53 +- .../ListUsers/ListUsersQueryHandler.cs | 55 +- .../CCE.Application/Messages/SystemCode.cs | 3 + .../CCE.Application/Messages/SystemCodeMap.cs | 2 + backend/src/CCE.Domain/Identity/User.cs | 41 + backend/src/CCE.Domain/Identity/UserStatus.cs | 7 + .../Identity/AuthService.cs | 25 + .../Identity/UserConfiguration.cs | 1 + ...15121258_StandardizeCountryProfileAudit.cs | 38 +- .../20260520101638_AddUserStatus.Designer.cs | 2708 ++++++++++++++++ .../20260520101638_AddUserStatus.cs | 748 +++++ ...260520111756_AddUserSoftDelete.Designer.cs | 2720 +++++++++++++++++ .../20260520111756_AddUserSoftDelete.cs | 50 + .../Migrations/CceDbContextModelSnapshot.cs | 286 +- .../Reports/UserRegistrationsReportService.cs | 6 +- .../Endpoints/UsersEndpointTests.cs | 64 + .../ChangeUserStatusCommandHandlerTests.cs | 107 + .../ChangeUserStatusCommandValidatorTests.cs | 40 + .../CreateUserCommandHandlerTests.cs | 95 + .../CreateUserCommandValidatorTests.cs | 89 + .../DeleteUserCommandHandlerTests.cs | 88 + .../DeleteUserCommandValidatorTests.cs | 29 + .../Queries/GetUserByIdQueryHandlerTests.cs | 6 +- 36 files changed, 7515 insertions(+), 129 deletions(-) create mode 100644 backend/src/CCE.Application/Identity/Commands/ChangeUserStatus/ChangeUserStatusCommand.cs create mode 100644 backend/src/CCE.Application/Identity/Commands/ChangeUserStatus/ChangeUserStatusCommandHandler.cs create mode 100644 backend/src/CCE.Application/Identity/Commands/ChangeUserStatus/ChangeUserStatusCommandValidator.cs create mode 100644 backend/src/CCE.Application/Identity/Commands/CreateUser/CreateUserCommand.cs create mode 100644 backend/src/CCE.Application/Identity/Commands/CreateUser/CreateUserCommandHandler.cs create mode 100644 backend/src/CCE.Application/Identity/Commands/CreateUser/CreateUserCommandValidator.cs create mode 100644 backend/src/CCE.Application/Identity/Commands/DeleteUser/DeleteUserCommand.cs create mode 100644 backend/src/CCE.Application/Identity/Commands/DeleteUser/DeleteUserCommandHandler.cs create mode 100644 backend/src/CCE.Application/Identity/Commands/DeleteUser/DeleteUserCommandValidator.cs create mode 100644 backend/src/CCE.Domain/Identity/UserStatus.cs create mode 100644 backend/src/CCE.Infrastructure/Persistence/Migrations/20260520101638_AddUserStatus.Designer.cs create mode 100644 backend/src/CCE.Infrastructure/Persistence/Migrations/20260520101638_AddUserStatus.cs create mode 100644 backend/src/CCE.Infrastructure/Persistence/Migrations/20260520111756_AddUserSoftDelete.Designer.cs create mode 100644 backend/src/CCE.Infrastructure/Persistence/Migrations/20260520111756_AddUserSoftDelete.cs create mode 100644 backend/tests/CCE.Application.Tests/Identity/Commands/ChangeUserStatus/ChangeUserStatusCommandHandlerTests.cs create mode 100644 backend/tests/CCE.Application.Tests/Identity/Commands/ChangeUserStatus/ChangeUserStatusCommandValidatorTests.cs create mode 100644 backend/tests/CCE.Application.Tests/Identity/Commands/CreateUser/CreateUserCommandHandlerTests.cs create mode 100644 backend/tests/CCE.Application.Tests/Identity/Commands/CreateUser/CreateUserCommandValidatorTests.cs create mode 100644 backend/tests/CCE.Application.Tests/Identity/Commands/DeleteUser/DeleteUserCommandHandlerTests.cs create mode 100644 backend/tests/CCE.Application.Tests/Identity/Commands/DeleteUser/DeleteUserCommandValidatorTests.cs diff --git a/backend/src/CCE.Api.Common/Auth/DevAuthHandler.cs b/backend/src/CCE.Api.Common/Auth/DevAuthHandler.cs index 8bfa1ac3..23299a7a 100644 --- a/backend/src/CCE.Api.Common/Auth/DevAuthHandler.cs +++ b/backend/src/CCE.Api.Common/Auth/DevAuthHandler.cs @@ -48,6 +48,7 @@ public sealed class DevAuthHandler : AuthenticationHandler public static readonly Dictionary RoleToUserId = new(StringComparer.OrdinalIgnoreCase) { + ["cce-super-admin"] = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-000000000000"), ["cce-admin"] = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-000000000001"), ["cce-content-manager"] = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-000000000002"), ["cce-state-representative"] = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-000000000006"), @@ -70,38 +71,42 @@ public DevAuthHandler( protected override Task HandleAuthenticateAsync() { - var role = ReadRole(); - if (string.IsNullOrEmpty(role)) + var roles = ReadRoles(); + if (roles is null || roles.Count == 0) { return Task.FromResult(AuthenticateResult.NoResult()); } - if (!RoleToUserId.TryGetValue(role, out var userId)) + // Use the first recognised role for the deterministic userId lookup. + var primaryRole = roles.FirstOrDefault(r => RoleToUserId.ContainsKey(r)) + ?? roles[0]; + if (!RoleToUserId.TryGetValue(primaryRole, out var userId)) { - return Task.FromResult(AuthenticateResult.Fail($"Unknown dev role '{role}'")); + return Task.FromResult(AuthenticateResult.Fail($"Unknown dev role '{primaryRole}'")); } - var claims = new[] + var claims = new List { - new Claim("sub", userId.ToString()), - new Claim("oid", userId.ToString()), - new Claim("preferred_username", $"{role}@cce.local"), - new Claim("name", $"Dev {role}"), - new Claim("roles", role), - new Claim("email", $"{role}@cce.local"), + new("sub", userId.ToString()), + new("oid", userId.ToString()), + new("preferred_username", $"{primaryRole}@cce.local"), + new("name", $"Dev {primaryRole}"), + new("email", $"{primaryRole}@cce.local"), }; + claims.AddRange(roles.Select(role => new Claim("roles", role))); + var identity = new ClaimsIdentity(claims, SchemeName, "preferred_username", "roles"); var principal = new ClaimsPrincipal(identity); var ticket = new AuthenticationTicket(principal, SchemeName); return Task.FromResult(AuthenticateResult.Success(ticket)); } - private string? ReadRole() + private List? ReadRoles() { // Prefer cookie (browser path); fall back to bearer header (curl / Postman). if (Request.Cookies.TryGetValue(DevCookieName, out var cookieValue) && !string.IsNullOrEmpty(cookieValue)) { - return cookieValue.Trim(); + return new List { cookieValue.Trim() }; } if (Request.Headers.TryGetValue("Authorization", out var auth)) @@ -111,7 +116,7 @@ protected override Task HandleAuthenticateAsync() const string devPrefix = "Bearer dev:"; if (raw.StartsWith(devPrefix, StringComparison.OrdinalIgnoreCase)) { - return raw.Substring(devPrefix.Length).Trim(); + return new List { raw.Substring(devPrefix.Length).Trim() }; } // Fallback: try to decode as a real JWT (e.g. issued by /api/auth/login) @@ -119,50 +124,54 @@ protected override Task HandleAuthenticateAsync() if (raw.StartsWith(bearerPrefix, StringComparison.OrdinalIgnoreCase)) { var token = raw.Substring(bearerPrefix.Length).Trim(); - return TryReadRoleFromJwt(token); + return TryReadRolesFromJwt(token); } } return null; } - private string? TryReadRoleFromJwt(string token) + private List? TryReadRolesFromJwt(string token) { - try + var opts = _localAuthOptions.Value; + var profiles = new[] { opts.External, opts.Internal }; + var handler = new JwtSecurityTokenHandler { MapInboundClaims = false }; + + foreach (var profile in profiles) { - var opts = _localAuthOptions.Value; - var profiles = new[] { opts.External, opts.Internal }; - var handler = new JwtSecurityTokenHandler { MapInboundClaims = false }; + if (string.IsNullOrWhiteSpace(profile.SigningKey)) + continue; - foreach (var profile in profiles) + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(profile.SigningKey)); + var parameters = new TokenValidationParameters { - if (string.IsNullOrWhiteSpace(profile.SigningKey)) - continue; - - var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(profile.SigningKey)); - var parameters = new TokenValidationParameters - { - ValidateIssuer = true, - ValidIssuer = profile.Issuer, - ValidateAudience = true, - ValidAudience = profile.Audience, - ValidateIssuerSigningKey = true, - IssuerSigningKey = key, - ValidateLifetime = true, - ClockSkew = TimeSpan.FromMinutes(2), - }; - - var principal = handler.ValidateToken(token, parameters, out _); - var role = principal.FindFirst("roles")?.Value; - if (!string.IsNullOrEmpty(role)) - return role; + ValidateIssuer = true, + ValidIssuer = profile.Issuer, + ValidateAudience = true, + ValidAudience = profile.Audience, + ValidateIssuerSigningKey = true, + IssuerSigningKey = key, + ValidateLifetime = true, + ClockSkew = TimeSpan.FromMinutes(2), + }; + + ClaimsPrincipal? principal; + try + { + principal = handler.ValidateToken(token, parameters, out _); } - } - catch (Exception ex) - { - Logger.LogDebug(ex, "Failed to validate JWT in DevAuthHandler fallback"); + catch (Exception ex) + { + Logger.LogDebug(ex, "JWT validation failed for profile {Issuer} in DevAuthHandler", profile.Issuer); + continue; + } + + var roles = principal.FindAll("roles").Select(c => c.Value).ToList(); + if (roles.Count > 0) + return roles; } + Logger.LogWarning("JWT validation failed for all profiles in DevAuthHandler"); return null; } } diff --git a/backend/src/CCE.Api.Common/Localization/Resources.yaml b/backend/src/CCE.Api.Common/Localization/Resources.yaml index 3bac7e4a..485081ff 100644 --- a/backend/src/CCE.Api.Common/Localization/Resources.yaml +++ b/backend/src/CCE.Api.Common/Localization/Resources.yaml @@ -246,6 +246,14 @@ ROLES_ASSIGNED: ar: "تم تعيين الأدوار بنجاح" en: "Roles assigned successfully" +USER_STATUS_CHANGED: + ar: "تم تغيير حالة المستخدم بنجاح" + en: "User status changed successfully" + +USER_DELETED: + ar: "تم حذف المستخدم بنجاح" + en: "User deleted successfully" + EXPERT_REQUEST_APPROVED: ar: "تمت الموافقة على طلب الخبير" en: "Expert request approved" diff --git a/backend/src/CCE.Api.Internal/Endpoints/IdentityEndpoints.cs b/backend/src/CCE.Api.Internal/Endpoints/IdentityEndpoints.cs index a89ab039..c194db46 100644 --- a/backend/src/CCE.Api.Internal/Endpoints/IdentityEndpoints.cs +++ b/backend/src/CCE.Api.Internal/Endpoints/IdentityEndpoints.cs @@ -1,6 +1,9 @@ using CCE.Api.Common.Extensions; using CCE.Application.Identity.Commands.AssignUserRoles; +using CCE.Application.Identity.Commands.ChangeUserStatus; using CCE.Application.Identity.Commands.CreateStateRepAssignment; +using CCE.Application.Identity.Commands.CreateUser; +using CCE.Application.Identity.Commands.DeleteUser; using CCE.Application.Identity.Commands.RevokeStateRepAssignment; using CCE.Application.Identity.Queries.GetUserById; using CCE.Application.Identity.Queries.ListStateRepAssignments; @@ -49,6 +52,19 @@ public static IEndpointRouteBuilder MapIdentityEndpoints(this IEndpointRouteBuil .RequireAuthorization(Permissions.User_Read) .WithName("GetUserById"); + users.MapPost("", async ( + CreateUserRequest body, + IMediator mediator, CancellationToken ct) => + { + var cmd = new CreateUserCommand( + body.FirstName, body.LastName, body.Email, body.Password, + body.PhoneNumber, body.CountryId, body.Role); + var result = await mediator.Send(cmd, ct).ConfigureAwait(false); + return result.ToCreatedHttpResult(); + }) + .RequireAuthorization(Permissions.User_Create) + .WithName("CreateUser"); + users.MapPut("/{id:guid}/roles", async ( System.Guid id, AssignUserRolesRequest body, @@ -61,6 +77,28 @@ public static IEndpointRouteBuilder MapIdentityEndpoints(this IEndpointRouteBuil .RequireAuthorization(Permissions.Role_Assign) .WithName("AssignUserRoles"); + users.MapDelete("/{id:guid}", async ( + System.Guid id, + IMediator mediator, CancellationToken ct) => + { + var result = await mediator.Send(new DeleteUserCommand(id), ct).ConfigureAwait(false); + return result.ToHttpResult(); + }) + .RequireAuthorization(Permissions.User_Delete) + .WithName("DeleteUser"); + + users.MapPut("/{id:guid}/status", async ( + System.Guid id, + ChangeUserStatusRequest body, + IMediator mediator, CancellationToken ct) => + { + var cmd = new ChangeUserStatusCommand(id, body.IsActive); + var result = await mediator.Send(cmd, ct).ConfigureAwait(false); + return result.ToHttpResult(); + }) + .RequireAuthorization(Permissions.User_Update) + .WithName("ChangeUserStatus"); + // Sub-11d Task D — batch UPN→EntraIdObjectId backfill. Admin-only; // referenced by docs/runbooks/entra-id-cutover.md step 7. Lazy // resolution per-user already happens on first sign-in via @@ -119,4 +157,13 @@ public static IEndpointRouteBuilder MapIdentityEndpoints(this IEndpointRouteBuil } } +public sealed record ChangeUserStatusRequest(bool IsActive); +public sealed record CreateUserRequest( + string FirstName, + string LastName, + string Email, + string Password, + string PhoneNumber, + Guid? CountryId, + string Role); \ No newline at end of file diff --git a/backend/src/CCE.Application/Identity/Auth/Common/IAuthService.cs b/backend/src/CCE.Application/Identity/Auth/Common/IAuthService.cs index 22c2cbbb..125d429b 100644 --- a/backend/src/CCE.Application/Identity/Auth/Common/IAuthService.cs +++ b/backend/src/CCE.Application/Identity/Auth/Common/IAuthService.cs @@ -4,6 +4,8 @@ namespace CCE.Application.Identity.Auth.Common; public sealed record RegisterResult(User? User, bool EmailTaken); +public sealed record AdminCreateResult(User? User, bool EmailTaken, bool Failed); + public interface IAuthService { Task LoginAsync(string email, string password, LocalAuthApi api, string? ip, string? userAgent, CancellationToken ct); @@ -14,6 +16,8 @@ public interface IAuthService Task RegisterAsync(string firstName, string lastName, string email, string password, string? jobTitle, string? orgName, string? phone, CancellationToken ct); + Task AdminCreateUserAsync(string firstName, string lastName, string email, string password, string phone, Guid? countryId, string role, CancellationToken ct); + Task ForgotPasswordAsync(string email, CancellationToken ct); Task ResetPasswordAsync(string email, string encodedToken, string newPassword, string? ip, CancellationToken ct); diff --git a/backend/src/CCE.Application/Identity/Commands/ChangeUserStatus/ChangeUserStatusCommand.cs b/backend/src/CCE.Application/Identity/Commands/ChangeUserStatus/ChangeUserStatusCommand.cs new file mode 100644 index 00000000..2c28df76 --- /dev/null +++ b/backend/src/CCE.Application/Identity/Commands/ChangeUserStatus/ChangeUserStatusCommand.cs @@ -0,0 +1,9 @@ +using CCE.Application.Common; +using CCE.Application.Identity.Dtos; +using MediatR; + +namespace CCE.Application.Identity.Commands.ChangeUserStatus; + +public sealed record ChangeUserStatusCommand( + Guid UserId, + bool IsActive) : IRequest>; diff --git a/backend/src/CCE.Application/Identity/Commands/ChangeUserStatus/ChangeUserStatusCommandHandler.cs b/backend/src/CCE.Application/Identity/Commands/ChangeUserStatus/ChangeUserStatusCommandHandler.cs new file mode 100644 index 00000000..2811e955 --- /dev/null +++ b/backend/src/CCE.Application/Identity/Commands/ChangeUserStatus/ChangeUserStatusCommandHandler.cs @@ -0,0 +1,53 @@ +using CCE.Application.Common; +using CCE.Application.Common.Interfaces; +using CCE.Application.Identity.Dtos; +using CCE.Application.Identity.Public; +using CCE.Application.Identity.Queries.GetUserById; +using CCE.Application.Messages; +using CCE.Domain.Identity; +using MediatR; + +namespace CCE.Application.Identity.Commands.ChangeUserStatus; + +public sealed class ChangeUserStatusCommandHandler : IRequestHandler> +{ + private readonly ICceDbContext _db; + private readonly IUserProfileRepository _service; + private readonly IMediator _mediator; + private readonly MessageFactory _msg; + + public ChangeUserStatusCommandHandler( + ICceDbContext db, + IUserProfileRepository service, + IMediator mediator, + MessageFactory msg) + { + _db = db; + _service = service; + _mediator = mediator; + _msg = msg; + } + + public async Task> Handle(ChangeUserStatusCommand request, CancellationToken cancellationToken) + { + var user = await _service.FindAsync(request.UserId, cancellationToken).ConfigureAwait(false); + if (user is null) + { + return _msg.UserNotFound(); + } + + var newStatus = request.IsActive ? UserStatus.Active : UserStatus.Inactive; + user.ChangeStatus(newStatus); + + _service.Update(user); + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + var result = await _mediator.Send(new GetUserByIdQuery(request.UserId), cancellationToken).ConfigureAwait(false); + if (!result.Success) + { + return result; + } + + return _msg.Ok(result.Data!, "USER_STATUS_CHANGED"); + } +} diff --git a/backend/src/CCE.Application/Identity/Commands/ChangeUserStatus/ChangeUserStatusCommandValidator.cs b/backend/src/CCE.Application/Identity/Commands/ChangeUserStatus/ChangeUserStatusCommandValidator.cs new file mode 100644 index 00000000..5eba526b --- /dev/null +++ b/backend/src/CCE.Application/Identity/Commands/ChangeUserStatus/ChangeUserStatusCommandValidator.cs @@ -0,0 +1,11 @@ +using FluentValidation; + +namespace CCE.Application.Identity.Commands.ChangeUserStatus; + +public sealed class ChangeUserStatusCommandValidator : AbstractValidator +{ + public ChangeUserStatusCommandValidator() + { + RuleFor(c => c.UserId).NotEmpty(); + } +} diff --git a/backend/src/CCE.Application/Identity/Commands/CreateUser/CreateUserCommand.cs b/backend/src/CCE.Application/Identity/Commands/CreateUser/CreateUserCommand.cs new file mode 100644 index 00000000..b79772f3 --- /dev/null +++ b/backend/src/CCE.Application/Identity/Commands/CreateUser/CreateUserCommand.cs @@ -0,0 +1,14 @@ +using CCE.Application.Common; +using CCE.Application.Identity.Dtos; +using MediatR; + +namespace CCE.Application.Identity.Commands.CreateUser; + +public sealed record CreateUserCommand( + string FirstName, + string LastName, + string Email, + string Password, + string PhoneNumber, + Guid? CountryId, + string Role) : IRequest>; diff --git a/backend/src/CCE.Application/Identity/Commands/CreateUser/CreateUserCommandHandler.cs b/backend/src/CCE.Application/Identity/Commands/CreateUser/CreateUserCommandHandler.cs new file mode 100644 index 00000000..6ebb3ecc --- /dev/null +++ b/backend/src/CCE.Application/Identity/Commands/CreateUser/CreateUserCommandHandler.cs @@ -0,0 +1,37 @@ +using CCE.Application.Common; +using CCE.Application.Identity.Auth.Common; +using CCE.Application.Identity.Dtos; +using CCE.Application.Identity.Queries.GetUserById; +using CCE.Application.Messages; +using MediatR; + +namespace CCE.Application.Identity.Commands.CreateUser; + +public sealed class CreateUserCommandHandler : IRequestHandler> +{ + private readonly IAuthService _auth; + private readonly IMediator _mediator; + private readonly MessageFactory _msg; + + public CreateUserCommandHandler(IAuthService auth, IMediator mediator, MessageFactory msg) + { + _auth = auth; + _mediator = mediator; + _msg = msg; + } + + public async Task> Handle(CreateUserCommand request, CancellationToken cancellationToken) + { + var result = await _auth.AdminCreateUserAsync( + request.FirstName, request.LastName, request.Email, request.Password, + request.PhoneNumber, request.CountryId, request.Role, cancellationToken).ConfigureAwait(false); + + if (result.EmailTaken) return _msg.EmailExists(); + if (result.Failed || result.User is null) return _msg.BusinessRule("REGISTRATION_FAILED"); + + var detail = await _mediator.Send(new GetUserByIdQuery(result.User.Id), cancellationToken).ConfigureAwait(false); + if (!detail.Success) return detail; + + return _msg.Ok(detail.Data!, "REGISTER_SUCCESS"); + } +} diff --git a/backend/src/CCE.Application/Identity/Commands/CreateUser/CreateUserCommandValidator.cs b/backend/src/CCE.Application/Identity/Commands/CreateUser/CreateUserCommandValidator.cs new file mode 100644 index 00000000..2ca11f9e --- /dev/null +++ b/backend/src/CCE.Application/Identity/Commands/CreateUser/CreateUserCommandValidator.cs @@ -0,0 +1,26 @@ +using FluentValidation; + +namespace CCE.Application.Identity.Commands.CreateUser; + +public sealed class CreateUserCommandValidator : AbstractValidator +{ + private static readonly HashSet AllowedRoles = new(StringComparer.OrdinalIgnoreCase) + { + "cce-admin", + "cce-content-manager", + "cce-state-representative", + }; + + public CreateUserCommandValidator() + { + RuleFor(c => c.FirstName).NotEmpty().MaximumLength(50) + .Matches(@"^\p{L}+$").WithMessage("First name must contain letters only."); + RuleFor(c => c.LastName).NotEmpty().MaximumLength(50) + .Matches(@"^\p{L}+$").WithMessage("Last name must contain letters only."); + RuleFor(c => c.Email).NotEmpty().MaximumLength(100).EmailAddress(); + RuleFor(c => c.Password).NotEmpty().MinimumLength(8); + RuleFor(c => c.PhoneNumber).NotEmpty().MaximumLength(15); + RuleFor(c => c.Role).NotEmpty().Must(r => AllowedRoles.Contains(r)) + .WithMessage($"Role must be one of: {string.Join(", ", AllowedRoles)}."); + } +} diff --git a/backend/src/CCE.Application/Identity/Commands/DeleteUser/DeleteUserCommand.cs b/backend/src/CCE.Application/Identity/Commands/DeleteUser/DeleteUserCommand.cs new file mode 100644 index 00000000..7c6ee49a --- /dev/null +++ b/backend/src/CCE.Application/Identity/Commands/DeleteUser/DeleteUserCommand.cs @@ -0,0 +1,7 @@ +using CCE.Application.Common; +using CCE.Application.Identity.Dtos; +using MediatR; + +namespace CCE.Application.Identity.Commands.DeleteUser; + +public sealed record DeleteUserCommand(Guid UserId) : IRequest>; diff --git a/backend/src/CCE.Application/Identity/Commands/DeleteUser/DeleteUserCommandHandler.cs b/backend/src/CCE.Application/Identity/Commands/DeleteUser/DeleteUserCommandHandler.cs new file mode 100644 index 00000000..9926cc53 --- /dev/null +++ b/backend/src/CCE.Application/Identity/Commands/DeleteUser/DeleteUserCommandHandler.cs @@ -0,0 +1,57 @@ +using CCE.Application.Common; +using CCE.Application.Common.Interfaces; +using CCE.Application.Identity.Dtos; +using CCE.Application.Identity.Public; +using CCE.Application.Messages; +using MediatR; + +namespace CCE.Application.Identity.Commands.DeleteUser; + +public sealed class DeleteUserCommandHandler : IRequestHandler> +{ + private readonly ICceDbContext _db; + private readonly IUserProfileRepository _service; + private readonly ICurrentUserAccessor _currentUser; + private readonly MessageFactory _msg; + + public DeleteUserCommandHandler( + ICceDbContext db, + IUserProfileRepository service, + ICurrentUserAccessor currentUser, + MessageFactory msg) + { + _db = db; + _service = service; + _currentUser = currentUser; + _msg = msg; + } + + public async Task> Handle(DeleteUserCommand request, CancellationToken cancellationToken) + { + var user = await _service.FindAsync(request.UserId, cancellationToken).ConfigureAwait(false); + if (user is null || user.IsDeleted) + { + return _msg.UserNotFound(); + } + + var deletedById = _currentUser.GetUserId() + ?? throw new Domain.Common.DomainException("Cannot delete user without a user identity."); + + user.SoftDelete(deletedById, System.DateTimeOffset.UtcNow); + + _service.Update(user); + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + return _msg.Ok(new UserDetailDto( + user.Id, + user.Email, + user.UserName, + user.LocalePreference, + user.KnowledgeLevel, + user.Interests, + user.CountryId, + user.AvatarUrl, + System.Array.Empty(), + user.Status == Domain.Identity.UserStatus.Active), "USER_DELETED"); + } +} diff --git a/backend/src/CCE.Application/Identity/Commands/DeleteUser/DeleteUserCommandValidator.cs b/backend/src/CCE.Application/Identity/Commands/DeleteUser/DeleteUserCommandValidator.cs new file mode 100644 index 00000000..06a6a7df --- /dev/null +++ b/backend/src/CCE.Application/Identity/Commands/DeleteUser/DeleteUserCommandValidator.cs @@ -0,0 +1,11 @@ +using FluentValidation; + +namespace CCE.Application.Identity.Commands.DeleteUser; + +public sealed class DeleteUserCommandValidator : AbstractValidator +{ + public DeleteUserCommandValidator() + { + RuleFor(c => c.UserId).NotEmpty(); + } +} diff --git a/backend/src/CCE.Application/Identity/Queries/GetUserById/GetUserByIdQueryHandler.cs b/backend/src/CCE.Application/Identity/Queries/GetUserById/GetUserByIdQueryHandler.cs index 8435576d..0f56dd39 100644 --- a/backend/src/CCE.Application/Identity/Queries/GetUserById/GetUserByIdQueryHandler.cs +++ b/backend/src/CCE.Application/Identity/Queries/GetUserById/GetUserByIdQueryHandler.cs @@ -4,6 +4,7 @@ using CCE.Application.Identity.Dtos; using CCE.Application.Messages; using MediatR; +using Microsoft.EntityFrameworkCore; namespace CCE.Application.Identity.Queries.GetUserById; @@ -18,35 +19,31 @@ public GetUserByIdQueryHandler(ICceDbContext db, MessageFactory msg) _msg = msg; } - public async Task> Handle(GetUserByIdQuery request, CancellationToken cancellationToken) + public async Task> Handle( + GetUserByIdQuery request, CancellationToken cancellationToken) { - var user = (await _db.Users.Where(u => u.Id == request.Id).ToListAsyncEither(cancellationToken).ConfigureAwait(false)) - .SingleOrDefault(); - if (user is null) - { - return _msg.UserNotFound(); - } + var dto = await _db.Users + .Where(u => u.Id == request.Id && !u.IsDeleted) + .Select(u => new UserDetailDto( + u.Id, + u.Email, + u.UserName, + u.LocalePreference, + u.KnowledgeLevel, + u.Interests, + u.CountryId, + u.AvatarUrl, + _db.UserRoles + .Join(_db.Roles, ur => ur.RoleId, r => r.Id, (ur, r) => new { ur.UserId, r.Name }) + .Where(x => x.UserId == u.Id && x.Name != null) + .Select(x => x.Name!) + .ToList(), + u.Status == Domain.Identity.UserStatus.Active)) + .FirstOrDefaultAsync(cancellationToken) + .ConfigureAwait(false); - var roleNames = - from ur in _db.UserRoles - join r in _db.Roles on ur.RoleId equals r.Id - where ur.UserId == request.Id && r.Name != null - select r.Name!; - var roles = await roleNames.ToListAsyncEither(cancellationToken).ConfigureAwait(false); - - var now = DateTimeOffset.UtcNow; - var isActive = !user.LockoutEnabled || user.LockoutEnd is null || user.LockoutEnd < now; - - return _msg.Ok(new UserDetailDto( - user.Id, - user.Email, - user.UserName, - user.LocalePreference, - user.KnowledgeLevel, - user.Interests, - user.CountryId, - user.AvatarUrl, - roles, - isActive), "SUCCESS_OPERATION"); + return dto is null + ? _msg.UserNotFound() + : _msg.Ok(dto, "SUCCESS_OPERATION"); } } diff --git a/backend/src/CCE.Application/Identity/Queries/ListUsers/ListUsersQueryHandler.cs b/backend/src/CCE.Application/Identity/Queries/ListUsers/ListUsersQueryHandler.cs index a96b4560..466a6dc1 100644 --- a/backend/src/CCE.Application/Identity/Queries/ListUsers/ListUsersQueryHandler.cs +++ b/backend/src/CCE.Application/Identity/Queries/ListUsers/ListUsersQueryHandler.cs @@ -12,9 +12,10 @@ public sealed class ListUsersQueryHandler : IRequestHandler _db = db; - public async Task> Handle(ListUsersQuery request, CancellationToken cancellationToken) + public async Task> Handle( + ListUsersQuery request, CancellationToken cancellationToken) { - var query = _db.Users.AsQueryable(); + var query = _db.Users.Where(u => !u.IsDeleted); if (!string.IsNullOrWhiteSpace(request.Search)) { @@ -27,45 +28,29 @@ public async Task> Handle(ListUsersQuery request, C if (!string.IsNullOrWhiteSpace(request.Role)) { var role = request.Role.Trim(); - query = from u in query - join ur in _db.UserRoles on u.Id equals ur.UserId - join r in _db.Roles on ur.RoleId equals r.Id - where r.Name == role - select u; + // Distinct prevents duplicates when a user has the role assigned more than once + query = query + .Where(u => _db.UserRoles + .Join(_db.Roles, ur => ur.RoleId, r => r.Id, (ur, r) => new { ur.UserId, r.Name }) + .Any(x => x.UserId == u.Id && x.Name == role)); } query = query.OrderBy(u => u.UserName); - var paged = await query.ToPagedResultAsync(request.Page, request.PageSize, cancellationToken).ConfigureAwait(false); - - if (paged.Items.Count == 0) - { - return new PagedResult( - Array.Empty(), paged.Page, paged.PageSize, paged.Total); - } - - var userIds = paged.Items.Select(u => u.Id).ToList(); - var pairs = - from ur in _db.UserRoles - join r in _db.Roles on ur.RoleId equals r.Id - where userIds.Contains(ur.UserId) && r.Name != null - select new RoleAssignmentRow(ur.UserId, r.Name!); - var pairsList = await pairs.ToListAsyncEither(cancellationToken).ConfigureAwait(false); - - var rolesByUser = pairsList - .GroupBy(p => p.UserId) - .ToDictionary(g => g.Key, g => (IReadOnlyList)g.Select(p => p.RoleName).ToList()); - - var now = DateTimeOffset.UtcNow; - var items = paged.Items.Select(u => new UserListItemDto( + // Single projection — roles are fetched in the same query, no second round-trip + var projected = query.Select(u => new UserListItemDto( u.Id, u.Email, u.UserName, - rolesByUser.TryGetValue(u.Id, out var roles) ? roles : Array.Empty(), - !u.LockoutEnabled || u.LockoutEnd is null || u.LockoutEnd < now)).ToList(); - - return new PagedResult(items, paged.Page, paged.PageSize, paged.Total); + _db.UserRoles + .Join(_db.Roles, ur => ur.RoleId, r => r.Id, (ur, r) => new { ur.UserId, r.Name }) + .Where(x => x.UserId == u.Id && x.Name != null) + .Select(x => x.Name!) + .ToList(), + u.Status == Domain.Identity.UserStatus.Active)); + + return await projected + .ToPagedResultAsync(request.Page, request.PageSize, cancellationToken) + .ConfigureAwait(false); } - - private sealed record RoleAssignmentRow(Guid UserId, string RoleName); } diff --git a/backend/src/CCE.Application/Messages/SystemCode.cs b/backend/src/CCE.Application/Messages/SystemCode.cs index beb7aabc..594047fb 100644 --- a/backend/src/CCE.Application/Messages/SystemCode.cs +++ b/backend/src/CCE.Application/Messages/SystemCode.cs @@ -20,6 +20,7 @@ public static class SystemCode public const string ERR001 = "ERR001"; // User not found (also used as ERR001 in appendix — keep) public const string ERR002 = "ERR002"; // Resource download failure (appendix) public const string ERR003 = "ERR003"; // Resource share failure (appendix) + public const string ERR013 = "ERR013"; // Required fields empty (appendix) public const string ERR019 = "ERR019"; // Email already exists / Account creation failure (appendix) public const string ERR020 = "ERR020"; // Invalid credentials (appendix) @@ -123,6 +124,7 @@ public static class SystemCode public const string CON015 = "CON015"; // Logout success (appendix) public const string CON016 = "CON016"; // Content update success (appendix) public const string CON017 = "CON017"; // User creation success (appendix) + public const string CON018 = "CON018"; // User deleted successfully (appendix) // ─── Backend-only Identity Success (appendix numbers already taken) ─── public const string CON050 = "CON050"; // Expert request approved @@ -130,6 +132,7 @@ public static class SystemCode public const string CON052 = "CON052"; // State rep assignment created public const string CON053 = "CON053"; // State rep assignment revoked public const string CON054 = "CON054"; // Roles assigned + public const string CON055 = "CON055"; // User status changed // ─── Content Success ─── public const string CON020 = "CON020"; // Content created diff --git a/backend/src/CCE.Application/Messages/SystemCodeMap.cs b/backend/src/CCE.Application/Messages/SystemCodeMap.cs index f53ae1a5..d59fdcf1 100644 --- a/backend/src/CCE.Application/Messages/SystemCodeMap.cs +++ b/backend/src/CCE.Application/Messages/SystemCodeMap.cs @@ -90,6 +90,7 @@ public static class SystemCodeMap ["PASSWORD_RESET"] = SystemCode.CON014, ["LOGOUT_SUCCESS"] = SystemCode.CON015, ["REGISTER_SUCCESS"] = SystemCode.CON017, + ["USER_DELETED"] = SystemCode.CON018, // ─── Backend-only Identity Success (appendix numbers already taken) ─── ["EXPERT_REQUEST_APPROVED"] = SystemCode.CON050, @@ -97,6 +98,7 @@ public static class SystemCodeMap ["STATE_REP_ASSIGNMENT_CREATED"] = SystemCode.CON052, ["STATE_REP_ASSIGNMENT_REVOKED"] = SystemCode.CON053, ["ROLES_ASSIGNED"] = SystemCode.CON054, + ["USER_STATUS_CHANGED"] = SystemCode.CON055, // ─── Content Success ─── ["CONTENT_CREATED"] = SystemCode.CON020, diff --git a/backend/src/CCE.Domain/Identity/User.cs b/backend/src/CCE.Domain/Identity/User.cs index 3ae20483..8f8a8f45 100644 --- a/backend/src/CCE.Domain/Identity/User.cs +++ b/backend/src/CCE.Domain/Identity/User.cs @@ -34,6 +34,9 @@ public class User : IdentityUser /// Optional avatar URL (CDN-served). public string? AvatarUrl { get; private set; } + /// Admin-managed account status. Default . + public UserStatus Status { get; private set; } = UserStatus.Active; + /// /// Sub-11: stable Entra ID Object ID (oid claim) for this user. Populated lazily on /// first sign-in by EntraIdUserResolver. Null until the user signs in via Entra ID @@ -122,6 +125,24 @@ public static User RegisterLocal( return user; } + public static User CreateByAdmin(string firstName, string lastName, string email, string phone) + { + return new User + { + Id = System.Guid.NewGuid(), + UserName = email, + NormalizedUserName = email.ToUpperInvariant(), + Email = email, + NormalizedEmail = email.ToUpperInvariant(), + PhoneNumber = phone, + EmailConfirmed = true, + FirstName = firstName.Trim(), + LastName = lastName.Trim(), + JobTitle = string.Empty, + OrganizationName = string.Empty, + }; + } + public void UpdateProfile(string firstName, string lastName, string jobTitle, string organizationName) { if (string.IsNullOrWhiteSpace(firstName)) throw new DomainException("FirstName is required."); @@ -169,6 +190,20 @@ public void UpdateInterests(IEnumerable interests) .ToList(); } + public bool IsDeleted { get; private set; } + + public DateTimeOffset? DeletedOn { get; private set; } + + public Guid? DeletedById { get; private set; } + + public void SoftDelete(Guid by, DateTimeOffset now) + { + if (IsDeleted) return; + IsDeleted = true; + DeletedOn = now; + DeletedById = by; + } + public void AssignCountry(System.Guid countryId) => CountryId = countryId; public void ClearCountry() => CountryId = null; @@ -189,4 +224,10 @@ public void SetAvatarUrl(string? url) } AvatarUrl = url; } + + public void ChangeStatus(UserStatus newStatus) => Status = newStatus; + + public void Activate() => Status = UserStatus.Active; + + public void Deactivate() => Status = UserStatus.Inactive; } diff --git a/backend/src/CCE.Domain/Identity/UserStatus.cs b/backend/src/CCE.Domain/Identity/UserStatus.cs new file mode 100644 index 00000000..4044ea71 --- /dev/null +++ b/backend/src/CCE.Domain/Identity/UserStatus.cs @@ -0,0 +1,7 @@ +namespace CCE.Domain.Identity; + +public enum UserStatus +{ + Active = 0, + Inactive = 1, +} diff --git a/backend/src/CCE.Infrastructure/Identity/AuthService.cs b/backend/src/CCE.Infrastructure/Identity/AuthService.cs index 7dceca54..d90ecd8f 100644 --- a/backend/src/CCE.Infrastructure/Identity/AuthService.cs +++ b/backend/src/CCE.Infrastructure/Identity/AuthService.cs @@ -121,6 +121,31 @@ public async Task RegisterAsync(string firstName, string lastNam return new RegisterResult(user, false); } + public async Task AdminCreateUserAsync( + string firstName, string lastName, string email, string password, + string phone, Guid? countryId, string role, CancellationToken ct) + { + var existing = await _userManager.FindByEmailAsync(email).ConfigureAwait(false); + if (existing is not null) return new AdminCreateResult(null, true, false); + + var user = User.CreateByAdmin(firstName, lastName, email, phone); + if (countryId.HasValue) user.AssignCountry(countryId.Value); + + var createResult = await _userManager.CreateAsync(user, password).ConfigureAwait(false); + if (!createResult.Succeeded) return new AdminCreateResult(null, false, true); + + if (!await _roleManager.RoleExistsAsync(role).ConfigureAwait(false)) + { + var roleResult = await _roleManager.CreateAsync(new Role(role)).ConfigureAwait(false); + if (!roleResult.Succeeded) return new AdminCreateResult(null, false, true); + } + + var addResult = await _userManager.AddToRoleAsync(user, role).ConfigureAwait(false); + if (!addResult.Succeeded) return new AdminCreateResult(null, false, true); + + return new AdminCreateResult(user, false, false); + } + public async Task ForgotPasswordAsync(string email, CancellationToken ct) { var user = await _userManager.FindByEmailAsync(email).ConfigureAwait(false); diff --git a/backend/src/CCE.Infrastructure/Persistence/Configurations/Identity/UserConfiguration.cs b/backend/src/CCE.Infrastructure/Persistence/Configurations/Identity/UserConfiguration.cs index 05db4019..763ff8c1 100644 --- a/backend/src/CCE.Infrastructure/Persistence/Configurations/Identity/UserConfiguration.cs +++ b/backend/src/CCE.Infrastructure/Persistence/Configurations/Identity/UserConfiguration.cs @@ -16,6 +16,7 @@ public void Configure(EntityTypeBuilder builder) builder.Property(u => u.AvatarUrl).HasMaxLength(2048); builder.Property(u => u.Interests).HasColumnType("nvarchar(max)"); builder.Property(u => u.KnowledgeLevel).HasConversion(); + builder.Property(u => u.Status).HasConversion(); builder.HasIndex(u => u.CountryId).HasDatabaseName("ix_users_country_id"); // Sub-11: filtered unique index on EntraIdObjectId. Only enforces uniqueness on diff --git a/backend/src/CCE.Infrastructure/Persistence/Migrations/20260515121258_StandardizeCountryProfileAudit.cs b/backend/src/CCE.Infrastructure/Persistence/Migrations/20260515121258_StandardizeCountryProfileAudit.cs index ff7cb93d..459467e3 100644 --- a/backend/src/CCE.Infrastructure/Persistence/Migrations/20260515121258_StandardizeCountryProfileAudit.cs +++ b/backend/src/CCE.Infrastructure/Persistence/Migrations/20260515121258_StandardizeCountryProfileAudit.cs @@ -11,15 +11,35 @@ public partial class StandardizeCountryProfileAudit : Migration /// protected override void Up(MigrationBuilder migrationBuilder) { - migrationBuilder.RenameColumn( - name: "last_updated_on", - table: "country_profiles", - newName: "created_on"); - - migrationBuilder.RenameColumn( - name: "last_updated_by_id", - table: "country_profiles", - newName: "created_by_id"); + migrationBuilder.Sql(@" + IF EXISTS ( + SELECT 1 FROM sys.columns c + JOIN sys.tables t ON c.object_id = t.object_id + WHERE t.name = 'country_profiles' AND c.name = 'last_updated_on' + ) + BEGIN + EXEC sp_rename N'[country_profiles].[last_updated_on]', N'created_on', 'COLUMN'; + END + + IF EXISTS ( + SELECT 1 FROM sys.columns c + JOIN sys.tables t ON c.object_id = t.object_id + WHERE t.name = 'country_profiles' AND c.name = 'last_updated_by_id' + ) + BEGIN + EXEC sp_rename N'[country_profiles].[last_updated_by_id]', N'created_by_id', 'COLUMN'; + END + "); + + // migrationBuilder.RenameColumn( + // name: "last_updated_on", + // table: "country_profiles", + // newName: "created_on"); + // + // migrationBuilder.RenameColumn( + // name: "last_updated_by_id", + // table: "country_profiles", + // newName: "created_by_id"); migrationBuilder.AddColumn( name: "created_by_id", diff --git a/backend/src/CCE.Infrastructure/Persistence/Migrations/20260520101638_AddUserStatus.Designer.cs b/backend/src/CCE.Infrastructure/Persistence/Migrations/20260520101638_AddUserStatus.Designer.cs new file mode 100644 index 00000000..5e55378c --- /dev/null +++ b/backend/src/CCE.Infrastructure/Persistence/Migrations/20260520101638_AddUserStatus.Designer.cs @@ -0,0 +1,2708 @@ +// +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("20260520101638_AddUserStatus")] + partial class AddUserStatus + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("CCE.Domain.Audit.AuditEvent", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("action"); + + b.Property("Actor") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("actor"); + + b.Property("CorrelationId") + .HasColumnType("uniqueidentifier") + .HasColumnName("correlation_id"); + + b.Property("Diff") + .HasColumnType("nvarchar(max)") + .HasColumnName("diff"); + + b.Property("OccurredOn") + .HasColumnType("datetimeoffset") + .HasColumnName("occurred_on"); + + b.Property("Resource") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("resource"); + + b.HasKey("Id") + .HasName("pk_audit_events"); + + b.HasIndex("CorrelationId") + .HasDatabaseName("ix_audit_events_correlation_id"); + + b.HasIndex("Actor", "OccurredOn") + .HasDatabaseName("ix_audit_events_actor_occurred_on"); + + b.ToTable("audit_events", null, t => + { + t.HasTrigger("trg_audit_events_no_update_delete"); + }); + + b.HasAnnotation("SqlServer:UseSqlOutputClause", false); + }); + + modelBuilder.Entity("CCE.Domain.Community.Post", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AnsweredReplyId") + .HasColumnType("uniqueidentifier") + .HasColumnName("answered_reply_id"); + + b.Property("AuthorId") + .HasColumnType("uniqueidentifier") + .HasColumnName("author_id"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)") + .HasColumnName("content"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsAnswerable") + .HasColumnType("bit") + .HasColumnName("is_answerable"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("locale"); + + b.Property("TopicId") + .HasColumnType("uniqueidentifier") + .HasColumnName("topic_id"); + + b.HasKey("Id") + .HasName("pk_posts"); + + b.HasIndex("TopicId") + .HasDatabaseName("ix_post_topic_id"); + + b.HasIndex("AuthorId", "CreatedOn") + .HasDatabaseName("ix_post_author_created"); + + b.ToTable("posts", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.PostFollow", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("FollowedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("followed_on"); + + b.Property("PostId") + .HasColumnType("uniqueidentifier") + .HasColumnName("post_id"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_post_follows"); + + b.HasIndex("PostId", "UserId") + .IsUnique() + .HasDatabaseName("ux_post_follow_post_user"); + + b.ToTable("post_follows", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.PostRating", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("PostId") + .HasColumnType("uniqueidentifier") + .HasColumnName("post_id"); + + b.Property("RatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("rated_on"); + + b.Property("Stars") + .HasColumnType("int") + .HasColumnName("stars"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_post_ratings"); + + b.HasIndex("PostId", "UserId") + .IsUnique() + .HasDatabaseName("ux_post_rating_post_user"); + + b.ToTable("post_ratings", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.PostReply", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AuthorId") + .HasColumnType("uniqueidentifier") + .HasColumnName("author_id"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)") + .HasColumnName("content"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsByExpert") + .HasColumnType("bit") + .HasColumnName("is_by_expert"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("locale"); + + b.Property("ParentReplyId") + .HasColumnType("uniqueidentifier") + .HasColumnName("parent_reply_id"); + + b.Property("PostId") + .HasColumnType("uniqueidentifier") + .HasColumnName("post_id"); + + b.HasKey("Id") + .HasName("pk_post_replies"); + + b.HasIndex("ParentReplyId") + .HasDatabaseName("ix_post_reply_parent_id"); + + b.HasIndex("PostId") + .HasDatabaseName("ix_post_reply_post_id"); + + b.ToTable("post_replies", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.Topic", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("DescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("IconUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("icon_url"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.Property("ParentId") + .HasColumnType("uniqueidentifier") + .HasColumnName("parent_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_topics"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ux_topic_slug_active") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("topics", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.TopicFollow", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("FollowedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("followed_on"); + + b.Property("TopicId") + .HasColumnType("uniqueidentifier") + .HasColumnName("topic_id"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_topic_follows"); + + b.HasIndex("TopicId", "UserId") + .IsUnique() + .HasDatabaseName("ux_topic_follow_topic_user"); + + b.ToTable("topic_follows", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.UserFollow", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("FollowedId") + .HasColumnType("uniqueidentifier") + .HasColumnName("followed_id"); + + b.Property("FollowedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("followed_on"); + + b.Property("FollowerId") + .HasColumnType("uniqueidentifier") + .HasColumnName("follower_id"); + + b.HasKey("Id") + .HasName("pk_user_follows"); + + b.HasIndex("FollowerId", "FollowedId") + .IsUnique() + .HasDatabaseName("ux_user_follow_follower_followed"); + + b.ToTable("user_follows", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.AssetFile", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("MimeType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("mime_type"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("original_file_name"); + + b.Property("ScannedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("scanned_on"); + + b.Property("SizeBytes") + .HasColumnType("bigint") + .HasColumnName("size_bytes"); + + b.Property("UploadedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("uploaded_by_id"); + + b.Property("UploadedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("uploaded_on"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("url"); + + b.Property("VirusScanStatus") + .HasColumnType("int") + .HasColumnName("virus_scan_status"); + + b.HasKey("Id") + .HasName("pk_asset_files"); + + b.HasIndex("VirusScanStatus") + .HasDatabaseName("ix_asset_file_scan_status"); + + b.ToTable("asset_files", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.Event", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("DescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("EndsOn") + .HasColumnType("datetimeoffset") + .HasColumnName("ends_on"); + + b.Property("FeaturedImageUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("featured_image_url"); + + b.Property("ICalUid") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("i_cal_uid"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("LocationAr") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("location_ar"); + + b.Property("LocationEn") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("location_en"); + + b.Property("OnlineMeetingUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("online_meeting_url"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.Property("StartsOn") + .HasColumnType("datetimeoffset") + .HasColumnName("starts_on"); + + b.Property("TitleAr") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_ar"); + + b.Property("TitleEn") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_en"); + + b.HasKey("Id") + .HasName("pk_events"); + + b.HasIndex("ICalUid") + .IsUnique() + .HasDatabaseName("ux_event_ical_uid"); + + b.HasIndex("StartsOn") + .HasDatabaseName("ix_event_starts_on"); + + b.ToTable("events", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.HomepageSection", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("ContentAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_ar"); + + b.Property("ContentEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_en"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.Property("SectionType") + .HasColumnType("int") + .HasColumnName("section_type"); + + b.HasKey("Id") + .HasName("pk_homepage_sections"); + + b.HasIndex("IsActive", "OrderIndex") + .HasDatabaseName("ix_homepage_section_active_order"); + + b.ToTable("homepage_sections", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.News", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AuthorId") + .HasColumnType("uniqueidentifier") + .HasColumnName("author_id"); + + b.Property("ContentAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_ar"); + + b.Property("ContentEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_en"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("FeaturedImageUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("featured_image_url"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("IsFeatured") + .HasColumnType("bit") + .HasColumnName("is_featured"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("PublishedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("published_on"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("slug"); + + b.Property("TitleAr") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_ar"); + + b.Property("TitleEn") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_en"); + + b.HasKey("Id") + .HasName("pk_news"); + + b.HasIndex("PublishedOn") + .HasDatabaseName("ix_news_published_on"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ux_news_slug_active") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("news", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.NewsletterSubscription", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("ConfirmationToken") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("confirmation_token"); + + b.Property("ConfirmedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("confirmed_on"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("nvarchar(320)") + .HasColumnName("email"); + + b.Property("IsConfirmed") + .HasColumnType("bit") + .HasColumnName("is_confirmed"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("LocalePreference") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("locale_preference"); + + b.Property("UnsubscribedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("unsubscribed_on"); + + b.HasKey("Id") + .HasName("pk_newsletter_subscriptions"); + + b.HasIndex("ConfirmationToken") + .HasDatabaseName("ix_newsletter_token"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("ux_newsletter_email"); + + b.ToTable("newsletter_subscriptions", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.Page", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("ContentAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_ar"); + + b.Property("ContentEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_en"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("PageType") + .HasColumnType("int") + .HasColumnName("page_type"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("slug"); + + b.Property("TitleAr") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_ar"); + + b.Property("TitleEn") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_en"); + + b.HasKey("Id") + .HasName("pk_pages"); + + b.HasIndex("PageType", "Slug") + .IsUnique() + .HasDatabaseName("ux_page_type_slug_active") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("pages", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.Resource", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AssetFileId") + .HasColumnType("uniqueidentifier") + .HasColumnName("asset_file_id"); + + b.Property("CategoryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("category_id"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("DescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("PublishedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("published_on"); + + b.Property("ResourceType") + .HasColumnType("int") + .HasColumnName("resource_type"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.Property("TitleAr") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_ar"); + + b.Property("TitleEn") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_en"); + + b.Property("UploadedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("uploaded_by_id"); + + b.Property("ViewCount") + .HasColumnType("bigint") + .HasColumnName("view_count"); + + b.HasKey("Id") + .HasName("pk_resources"); + + b.HasIndex("AssetFileId") + .HasDatabaseName("ix_resource_asset_file_id"); + + b.HasIndex("CountryId") + .HasDatabaseName("ix_resource_country_id"); + + b.HasIndex("CategoryId", "PublishedOn") + .HasDatabaseName("ix_resource_category_published"); + + b.ToTable("resources", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.ResourceCategory", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.Property("ParentId") + .HasColumnType("uniqueidentifier") + .HasColumnName("parent_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_resource_categories"); + + b.HasIndex("ParentId") + .HasDatabaseName("ix_resource_category_parent_id"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ux_resource_category_slug"); + + b.ToTable("resource_categories", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Country.Country", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("FlagUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("flag_url"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("IsoAlpha2") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("iso_alpha2"); + + b.Property("IsoAlpha3") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("nvarchar(3)") + .HasColumnName("iso_alpha3"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("LatestKapsarcSnapshotId") + .HasColumnType("uniqueidentifier") + .HasColumnName("latest_kapsarc_snapshot_id"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.Property("RegionAr") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("region_ar"); + + b.Property("RegionEn") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("region_en"); + + b.HasKey("Id") + .HasName("pk_countries"); + + b.HasIndex("IsoAlpha2") + .HasDatabaseName("ix_country_iso_alpha2"); + + b.HasIndex("IsoAlpha3") + .IsUnique() + .HasDatabaseName("ux_country_iso_alpha3_active") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("countries", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Country.CountryKapsarcSnapshot", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("Classification") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("classification"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("PerformanceScore") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)") + .HasColumnName("performance_score"); + + b.Property("SnapshotTakenOn") + .HasColumnType("datetimeoffset") + .HasColumnName("snapshot_taken_on"); + + b.Property("SourceVersion") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)") + .HasColumnName("source_version"); + + b.Property("TotalIndex") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)") + .HasColumnName("total_index"); + + b.HasKey("Id") + .HasName("pk_country_kapsarc_snapshots"); + + b.HasIndex("CountryId", "SnapshotTakenOn") + .HasDatabaseName("ix_kapsarc_snapshot_country_taken"); + + b.ToTable("country_kapsarc_snapshots", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Country.CountryProfile", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("ContactInfoAr") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("contact_info_ar"); + + b.Property("ContactInfoEn") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("contact_info_en"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("KeyInitiativesAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("key_initiatives_ar"); + + b.Property("KeyInitiativesEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("key_initiatives_en"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.HasKey("Id") + .HasName("pk_country_profiles"); + + b.HasIndex("CountryId") + .IsUnique() + .HasDatabaseName("ux_country_profile_country_id"); + + b.ToTable("country_profiles", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Country.CountryResourceRequest", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AdminNotesAr") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("admin_notes_ar"); + + b.Property("AdminNotesEn") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("admin_notes_en"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("ProcessedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("processed_by_id"); + + b.Property("ProcessedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("processed_on"); + + b.Property("ProposedAssetFileId") + .HasColumnType("uniqueidentifier") + .HasColumnName("proposed_asset_file_id"); + + b.Property("ProposedDescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("proposed_description_ar"); + + b.Property("ProposedDescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("proposed_description_en"); + + b.Property("ProposedResourceType") + .HasColumnType("int") + .HasColumnName("proposed_resource_type"); + + b.Property("ProposedTitleAr") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("proposed_title_ar"); + + b.Property("ProposedTitleEn") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("proposed_title_en"); + + b.Property("RequestedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("requested_by_id"); + + b.Property("Status") + .HasColumnType("int") + .HasColumnName("status"); + + b.Property("SubmittedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("submitted_on"); + + b.HasKey("Id") + .HasName("pk_country_resource_requests"); + + b.HasIndex("CountryId", "Status") + .HasDatabaseName("ix_country_request_country_status"); + + b.ToTable("country_resource_requests", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.ExpertProfile", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AcademicTitleAr") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("academic_title_ar"); + + b.Property("AcademicTitleEn") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("academic_title_en"); + + b.Property("ApprovedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("approved_by_id"); + + b.Property("ApprovedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("approved_on"); + + b.Property("BioAr") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("bio_ar"); + + b.Property("BioEn") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("bio_en"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.PrimitiveCollection("ExpertiseTags") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("expertise_tags"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_expert_profiles"); + + b.HasIndex("UserId") + .IsUnique() + .HasDatabaseName("ux_expert_profile_active_user") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("expert_profiles", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.ExpertRegistrationRequest", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("ProcessedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("processed_by_id"); + + b.Property("ProcessedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("processed_on"); + + b.Property("RejectionReasonAr") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("rejection_reason_ar"); + + b.Property("RejectionReasonEn") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("rejection_reason_en"); + + b.Property("RequestedBioAr") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("requested_bio_ar"); + + b.Property("RequestedBioEn") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("requested_bio_en"); + + b.Property("RequestedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("requested_by_id"); + + b.PrimitiveCollection("RequestedTags") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("requested_tags"); + + b.Property("Status") + .HasColumnType("int") + .HasColumnName("status"); + + b.Property("SubmittedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("submitted_on"); + + b.HasKey("Id") + .HasName("pk_expert_registration_requests"); + + b.HasIndex("RequestedById") + .HasDatabaseName("ix_expert_request_requested_by"); + + b.HasIndex("Status") + .HasDatabaseName("ix_expert_request_status"); + + b.ToTable("expert_registration_requests", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.RefreshToken", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset") + .HasColumnName("created_at_utc"); + + b.Property("CreatedByIp") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("created_by_ip"); + + b.Property("ExpiresAtUtc") + .HasColumnType("datetimeoffset") + .HasColumnName("expires_at_utc"); + + b.Property("ReplacedByTokenHash") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("replaced_by_token_hash"); + + b.Property("RevokedAtUtc") + .HasColumnType("datetimeoffset") + .HasColumnName("revoked_at_utc"); + + b.Property("RevokedByIp") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("revoked_by_ip"); + + b.Property("TokenFamilyId") + .HasColumnType("uniqueidentifier") + .HasColumnName("token_family_id"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("token_hash"); + + b.Property("UserAgent") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_refresh_tokens"); + + b.HasIndex("TokenFamilyId") + .HasDatabaseName("ix_refresh_tokens_token_family_id"); + + b.HasIndex("TokenHash") + .IsUnique() + .HasDatabaseName("ux_refresh_tokens_token_hash"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_refresh_tokens_user_id"); + + b.ToTable("refresh_tokens", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)") + .HasColumnName("concurrency_stamp"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("normalized_name"); + + b.HasKey("Id") + .HasName("pk_asp_net_roles"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[normalized_name] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.StateRepresentativeAssignment", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AssignedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("assigned_by_id"); + + b.Property("AssignedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("assigned_on"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("RevokedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("revoked_by_id"); + + b.Property("RevokedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("revoked_on"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_state_representative_assignments"); + + b.HasIndex("CountryId") + .HasDatabaseName("ix_state_rep_country_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_state_rep_user_id"); + + b.HasIndex("UserId", "CountryId") + .IsUnique() + .HasDatabaseName("ux_state_rep_active_user_country") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("state_representative_assignments", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AccessFailedCount") + .HasColumnType("int") + .HasColumnName("access_failed_count"); + + b.Property("AvatarUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("avatar_url"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)") + .HasColumnName("concurrency_stamp"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("email"); + + b.Property("EmailConfirmed") + .HasColumnType("bit") + .HasColumnName("email_confirmed"); + + b.Property("EntraIdObjectId") + .HasColumnType("uniqueidentifier") + .HasColumnName("entra_id_object_id"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)") + .HasColumnName("first_name"); + + b.PrimitiveCollection("Interests") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("interests"); + + b.Property("JobTitle") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)") + .HasColumnName("job_title"); + + b.Property("KnowledgeLevel") + .HasColumnType("int") + .HasColumnName("knowledge_level"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)") + .HasColumnName("last_name"); + + b.Property("LocalePreference") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("locale_preference"); + + b.Property("LockoutEnabled") + .HasColumnType("bit") + .HasColumnName("lockout_enabled"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset") + .HasColumnName("lockout_end"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("normalized_email"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("normalized_user_name"); + + b.Property("OrganizationName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("organization_name"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)") + .HasColumnName("password_hash"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)") + .HasColumnName("phone_number"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit") + .HasColumnName("phone_number_confirmed"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)") + .HasColumnName("security_stamp"); + + b.Property("Status") + .HasColumnType("int") + .HasColumnName("status"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit") + .HasColumnName("two_factor_enabled"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("user_name"); + + b.HasKey("Id") + .HasName("pk_asp_net_users"); + + b.HasIndex("CountryId") + .HasDatabaseName("ix_users_country_id"); + + b.HasIndex("EntraIdObjectId") + .IsUnique() + .HasDatabaseName("ix_asp_net_users_entra_id_object_id") + .HasFilter("[entra_id_object_id] IS NOT NULL"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[normalized_user_name] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.InteractiveCity.CityScenario", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CityType") + .HasColumnType("int") + .HasColumnName("city_type"); + + b.Property("ConfigurationJson") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("configuration_json"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.Property("TargetYear") + .HasColumnType("int") + .HasColumnName("target_year"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_city_scenarios"); + + b.HasIndex("UserId", "LastModifiedOn") + .HasDatabaseName("ix_city_scenario_user_modified"); + + b.ToTable("city_scenarios", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.InteractiveCity.CityScenarioResult", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("ComputedAt") + .HasColumnType("datetimeoffset") + .HasColumnName("computed_at"); + + b.Property("ComputedCarbonNeutralityYear") + .HasColumnType("int") + .HasColumnName("computed_carbon_neutrality_year"); + + b.Property("ComputedTotalCostUsd") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)") + .HasColumnName("computed_total_cost_usd"); + + b.Property("EngineVersion") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("engine_version"); + + b.Property("ScenarioId") + .HasColumnType("uniqueidentifier") + .HasColumnName("scenario_id"); + + b.HasKey("Id") + .HasName("pk_city_scenario_results"); + + b.HasIndex("ScenarioId", "ComputedAt") + .HasDatabaseName("ix_city_result_scenario_at"); + + b.ToTable("city_scenario_results", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.InteractiveCity.CityTechnology", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CarbonImpactKgPerYear") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)") + .HasColumnName("carbon_impact_kg_per_year"); + + b.Property("CategoryAr") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("category_ar"); + + b.Property("CategoryEn") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("category_en"); + + b.Property("CostUsd") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)") + .HasColumnName("cost_usd"); + + b.Property("DescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("IconUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("icon_url"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.HasKey("Id") + .HasName("pk_city_technologies"); + + b.HasIndex("IsActive") + .HasDatabaseName("ix_city_tech_is_active"); + + b.ToTable("city_technologies", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.KnowledgeMaps.KnowledgeMap", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("DescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_knowledge_maps"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ux_knowledge_map_slug_active") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("knowledge_maps", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.KnowledgeMaps.KnowledgeMapAssociation", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AssociatedId") + .HasColumnType("uniqueidentifier") + .HasColumnName("associated_id"); + + b.Property("AssociatedType") + .HasColumnType("int") + .HasColumnName("associated_type"); + + b.Property("NodeId") + .HasColumnType("uniqueidentifier") + .HasColumnName("node_id"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.HasKey("Id") + .HasName("pk_knowledge_map_associations"); + + b.HasIndex("NodeId", "AssociatedType", "AssociatedId") + .IsUnique() + .HasDatabaseName("ux_km_assoc_node_type_id"); + + b.ToTable("knowledge_map_associations", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.KnowledgeMaps.KnowledgeMapEdge", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("FromNodeId") + .HasColumnType("uniqueidentifier") + .HasColumnName("from_node_id"); + + b.Property("MapId") + .HasColumnType("uniqueidentifier") + .HasColumnName("map_id"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.Property("RelationshipType") + .HasColumnType("int") + .HasColumnName("relationship_type"); + + b.Property("ToNodeId") + .HasColumnType("uniqueidentifier") + .HasColumnName("to_node_id"); + + b.HasKey("Id") + .HasName("pk_knowledge_map_edges"); + + b.HasIndex("FromNodeId") + .HasDatabaseName("ix_km_edge_from_node"); + + b.HasIndex("ToNodeId") + .HasDatabaseName("ix_km_edge_to_node"); + + b.HasIndex("MapId", "FromNodeId", "ToNodeId", "RelationshipType") + .IsUnique() + .HasDatabaseName("ux_km_edge_map_from_to_relation"); + + b.ToTable("knowledge_map_edges", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.KnowledgeMaps.KnowledgeMapNode", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("DescriptionAr") + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("IconUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("icon_url"); + + b.Property("LayoutX") + .HasColumnType("float") + .HasColumnName("layout_x"); + + b.Property("LayoutY") + .HasColumnType("float") + .HasColumnName("layout_y"); + + b.Property("MapId") + .HasColumnType("uniqueidentifier") + .HasColumnName("map_id"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.Property("NodeType") + .HasColumnType("int") + .HasColumnName("node_type"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.HasKey("Id") + .HasName("pk_knowledge_map_nodes"); + + b.HasIndex("MapId", "OrderIndex") + .HasDatabaseName("ix_km_node_map_order"); + + b.ToTable("knowledge_map_nodes", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.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") + .IsUnique() + .HasDatabaseName("ux_notification_template_code"); + + b.ToTable("notification_templates", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Notifications.UserNotification", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("Channel") + .HasColumnType("int") + .HasColumnName("channel"); + + b.Property("ReadOn") + .HasColumnType("datetimeoffset") + .HasColumnName("read_on"); + + b.Property("RenderedBody") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("rendered_body"); + + b.Property("RenderedLocale") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("rendered_locale"); + + b.Property("RenderedSubjectAr") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("rendered_subject_ar"); + + b.Property("RenderedSubjectEn") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("rendered_subject_en"); + + b.Property("SentOn") + .HasColumnType("datetimeoffset") + .HasColumnName("sent_on"); + + b.Property("Status") + .HasColumnType("int") + .HasColumnName("status"); + + b.Property("TemplateId") + .HasColumnType("uniqueidentifier") + .HasColumnName("template_id"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_user_notifications"); + + b.HasIndex("UserId", "Status") + .HasDatabaseName("ix_user_notification_user_status"); + + b.ToTable("user_notifications", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Surveys.SearchQueryLog", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("locale"); + + b.Property("QueryText") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("query_text"); + + b.Property("ResponseTimeMs") + .HasColumnType("int") + .HasColumnName("response_time_ms"); + + b.Property("ResultsCount") + .HasColumnType("int") + .HasColumnName("results_count"); + + b.Property("SubmittedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("submitted_on"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_search_query_logs"); + + b.HasIndex("SubmittedOn") + .HasDatabaseName("ix_search_query_log_submitted_on"); + + b.ToTable("search_query_logs", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Surveys.ServiceRating", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CommentAr") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("comment_ar"); + + b.Property("CommentEn") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("comment_en"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("locale"); + + b.Property("Page") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("page"); + + b.Property("Rating") + .HasColumnType("int") + .HasColumnName("rating"); + + b.Property("SubmittedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("submitted_on"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_service_ratings"); + + b.HasIndex("SubmittedOn") + .HasDatabaseName("ix_service_rating_submitted_on"); + + b.ToTable("service_ratings", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)") + .HasColumnName("claim_type"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)") + .HasColumnName("claim_value"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier") + .HasColumnName("role_id"); + + b.HasKey("Id") + .HasName("pk_asp_net_role_claims"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_asp_net_role_claims_role_id"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)") + .HasColumnName("claim_type"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)") + .HasColumnName("claim_value"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_asp_net_user_claims"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_asp_net_user_claims_user_id"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)") + .HasColumnName("login_provider"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)") + .HasColumnName("provider_key"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)") + .HasColumnName("provider_display_name"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("LoginProvider", "ProviderKey") + .HasName("pk_asp_net_user_logins"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_asp_net_user_logins_user_id"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier") + .HasColumnName("role_id"); + + b.HasKey("UserId", "RoleId") + .HasName("pk_asp_net_user_roles"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_asp_net_user_roles_role_id"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)") + .HasColumnName("login_provider"); + + b.Property("Name") + .HasColumnType("nvarchar(450)") + .HasColumnName("name"); + + b.Property("Value") + .HasColumnType("nvarchar(max)") + .HasColumnName("value"); + + b.HasKey("UserId", "LoginProvider", "Name") + .HasName("pk_asp_net_user_tokens"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.RefreshToken", b => + { + b.HasOne("CCE.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_refresh_tokens_asp_net_users_user_id"); + }); + + modelBuilder.Entity("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"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/CCE.Infrastructure/Persistence/Migrations/20260520101638_AddUserStatus.cs b/backend/src/CCE.Infrastructure/Persistence/Migrations/20260520101638_AddUserStatus.cs new file mode 100644 index 00000000..98cf416c --- /dev/null +++ b/backend/src/CCE.Infrastructure/Persistence/Migrations/20260520101638_AddUserStatus.cs @@ -0,0 +1,748 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CCE.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddUserStatus : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "last_updated_on", + table: "country_profiles", + newName: "created_on"); + + migrationBuilder.RenameColumn( + name: "last_updated_by_id", + table: "country_profiles", + newName: "created_by_id"); + + migrationBuilder.AddColumn( + name: "created_by_id", + table: "topics", + type: "uniqueidentifier", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.AddColumn( + name: "created_on", + table: "topics", + type: "datetimeoffset", + nullable: false, + defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))); + + migrationBuilder.AddColumn( + name: "last_modified_by_id", + table: "topics", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.AddColumn( + name: "last_modified_on", + table: "topics", + type: "datetimeoffset", + nullable: true); + + migrationBuilder.AddColumn( + name: "created_by_id", + table: "state_representative_assignments", + type: "uniqueidentifier", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.AddColumn( + name: "created_on", + table: "state_representative_assignments", + type: "datetimeoffset", + nullable: false, + defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))); + + migrationBuilder.AddColumn( + name: "last_modified_by_id", + table: "state_representative_assignments", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.AddColumn( + name: "last_modified_on", + table: "state_representative_assignments", + type: "datetimeoffset", + nullable: true); + + migrationBuilder.AddColumn( + name: "created_by_id", + table: "resources", + type: "uniqueidentifier", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.AddColumn( + name: "created_on", + table: "resources", + type: "datetimeoffset", + nullable: false, + defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))); + + migrationBuilder.AddColumn( + name: "last_modified_by_id", + table: "resources", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.AddColumn( + name: "last_modified_on", + table: "resources", + type: "datetimeoffset", + nullable: true); + + migrationBuilder.AddColumn( + name: "created_by_id", + table: "posts", + type: "uniqueidentifier", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.AddColumn( + name: "last_modified_by_id", + table: "posts", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.AddColumn( + name: "last_modified_on", + table: "posts", + type: "datetimeoffset", + nullable: true); + + migrationBuilder.AddColumn( + name: "created_by_id", + table: "post_replies", + type: "uniqueidentifier", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.AddColumn( + name: "last_modified_by_id", + table: "post_replies", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.AddColumn( + name: "last_modified_on", + table: "post_replies", + type: "datetimeoffset", + nullable: true); + + migrationBuilder.AddColumn( + name: "created_by_id", + table: "pages", + type: "uniqueidentifier", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.AddColumn( + name: "created_on", + table: "pages", + type: "datetimeoffset", + nullable: false, + defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))); + + migrationBuilder.AddColumn( + name: "last_modified_by_id", + table: "pages", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.AddColumn( + name: "last_modified_on", + table: "pages", + type: "datetimeoffset", + nullable: true); + + migrationBuilder.AddColumn( + name: "created_by_id", + table: "newsletter_subscriptions", + type: "uniqueidentifier", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.AddColumn( + name: "created_on", + table: "newsletter_subscriptions", + type: "datetimeoffset", + nullable: false, + defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))); + + migrationBuilder.AddColumn( + name: "deleted_by_id", + table: "newsletter_subscriptions", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.AddColumn( + name: "deleted_on", + table: "newsletter_subscriptions", + type: "datetimeoffset", + nullable: true); + + migrationBuilder.AddColumn( + name: "is_deleted", + table: "newsletter_subscriptions", + type: "bit", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "last_modified_by_id", + table: "newsletter_subscriptions", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.AddColumn( + name: "last_modified_on", + table: "newsletter_subscriptions", + type: "datetimeoffset", + nullable: true); + + migrationBuilder.AddColumn( + name: "created_by_id", + table: "news", + type: "uniqueidentifier", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.AddColumn( + name: "created_on", + table: "news", + type: "datetimeoffset", + nullable: false, + defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))); + + migrationBuilder.AddColumn( + name: "last_modified_by_id", + table: "news", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.AddColumn( + name: "last_modified_on", + table: "news", + type: "datetimeoffset", + nullable: true); + + migrationBuilder.AddColumn( + name: "created_by_id", + table: "knowledge_maps", + type: "uniqueidentifier", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.AddColumn( + name: "created_on", + table: "knowledge_maps", + type: "datetimeoffset", + nullable: false, + defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))); + + migrationBuilder.AddColumn( + name: "last_modified_by_id", + table: "knowledge_maps", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.AddColumn( + name: "last_modified_on", + table: "knowledge_maps", + type: "datetimeoffset", + nullable: true); + + migrationBuilder.AddColumn( + name: "created_by_id", + table: "homepage_sections", + type: "uniqueidentifier", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.AddColumn( + name: "created_on", + table: "homepage_sections", + type: "datetimeoffset", + nullable: false, + defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))); + + migrationBuilder.AddColumn( + name: "last_modified_by_id", + table: "homepage_sections", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.AddColumn( + name: "last_modified_on", + table: "homepage_sections", + type: "datetimeoffset", + nullable: true); + + migrationBuilder.AddColumn( + name: "created_by_id", + table: "expert_registration_requests", + type: "uniqueidentifier", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.AddColumn( + name: "created_on", + table: "expert_registration_requests", + type: "datetimeoffset", + nullable: false, + defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))); + + migrationBuilder.AddColumn( + name: "last_modified_by_id", + table: "expert_registration_requests", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.AddColumn( + name: "last_modified_on", + table: "expert_registration_requests", + type: "datetimeoffset", + nullable: true); + + migrationBuilder.AddColumn( + name: "created_by_id", + table: "expert_profiles", + type: "uniqueidentifier", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.AddColumn( + name: "created_on", + table: "expert_profiles", + type: "datetimeoffset", + nullable: false, + defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))); + + migrationBuilder.AddColumn( + name: "last_modified_by_id", + table: "expert_profiles", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.AddColumn( + name: "last_modified_on", + table: "expert_profiles", + type: "datetimeoffset", + nullable: true); + + migrationBuilder.AddColumn( + name: "created_by_id", + table: "events", + type: "uniqueidentifier", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.AddColumn( + name: "created_on", + table: "events", + type: "datetimeoffset", + nullable: false, + defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))); + + migrationBuilder.AddColumn( + name: "last_modified_by_id", + table: "events", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.AddColumn( + name: "last_modified_on", + table: "events", + type: "datetimeoffset", + nullable: true); + + migrationBuilder.AddColumn( + name: "created_by_id", + table: "country_resource_requests", + type: "uniqueidentifier", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.AddColumn( + name: "created_on", + table: "country_resource_requests", + type: "datetimeoffset", + nullable: false, + defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))); + + migrationBuilder.AddColumn( + name: "last_modified_by_id", + table: "country_resource_requests", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.AddColumn( + name: "last_modified_on", + table: "country_resource_requests", + type: "datetimeoffset", + nullable: true); + + migrationBuilder.AddColumn( + name: "last_modified_by_id", + table: "country_profiles", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.AddColumn( + name: "last_modified_on", + table: "country_profiles", + type: "datetimeoffset", + nullable: true); + + migrationBuilder.AddColumn( + name: "created_by_id", + table: "countries", + type: "uniqueidentifier", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.AddColumn( + name: "created_on", + table: "countries", + type: "datetimeoffset", + nullable: false, + defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))); + + migrationBuilder.AddColumn( + name: "last_modified_by_id", + table: "countries", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.AddColumn( + name: "last_modified_on", + table: "countries", + type: "datetimeoffset", + nullable: true); + + migrationBuilder.AlterColumn( + name: "last_modified_on", + table: "city_scenarios", + type: "datetimeoffset", + nullable: true, + oldClrType: typeof(DateTimeOffset), + oldType: "datetimeoffset"); + + migrationBuilder.AddColumn( + name: "created_by_id", + table: "city_scenarios", + type: "uniqueidentifier", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.AddColumn( + name: "last_modified_by_id", + table: "city_scenarios", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.AddColumn( + name: "status", + table: "AspNetUsers", + type: "int", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "created_by_id", + table: "topics"); + + migrationBuilder.DropColumn( + name: "created_on", + table: "topics"); + + migrationBuilder.DropColumn( + name: "last_modified_by_id", + table: "topics"); + + migrationBuilder.DropColumn( + name: "last_modified_on", + table: "topics"); + + migrationBuilder.DropColumn( + name: "created_by_id", + table: "state_representative_assignments"); + + migrationBuilder.DropColumn( + name: "created_on", + table: "state_representative_assignments"); + + migrationBuilder.DropColumn( + name: "last_modified_by_id", + table: "state_representative_assignments"); + + migrationBuilder.DropColumn( + name: "last_modified_on", + table: "state_representative_assignments"); + + migrationBuilder.DropColumn( + name: "created_by_id", + table: "resources"); + + migrationBuilder.DropColumn( + name: "created_on", + table: "resources"); + + migrationBuilder.DropColumn( + name: "last_modified_by_id", + table: "resources"); + + migrationBuilder.DropColumn( + name: "last_modified_on", + table: "resources"); + + migrationBuilder.DropColumn( + name: "created_by_id", + table: "posts"); + + migrationBuilder.DropColumn( + name: "last_modified_by_id", + table: "posts"); + + migrationBuilder.DropColumn( + name: "last_modified_on", + table: "posts"); + + migrationBuilder.DropColumn( + name: "created_by_id", + table: "post_replies"); + + migrationBuilder.DropColumn( + name: "last_modified_by_id", + table: "post_replies"); + + migrationBuilder.DropColumn( + name: "last_modified_on", + table: "post_replies"); + + migrationBuilder.DropColumn( + name: "created_by_id", + table: "pages"); + + migrationBuilder.DropColumn( + name: "created_on", + table: "pages"); + + migrationBuilder.DropColumn( + name: "last_modified_by_id", + table: "pages"); + + migrationBuilder.DropColumn( + name: "last_modified_on", + table: "pages"); + + migrationBuilder.DropColumn( + name: "created_by_id", + table: "newsletter_subscriptions"); + + migrationBuilder.DropColumn( + name: "created_on", + table: "newsletter_subscriptions"); + + migrationBuilder.DropColumn( + name: "deleted_by_id", + table: "newsletter_subscriptions"); + + migrationBuilder.DropColumn( + name: "deleted_on", + table: "newsletter_subscriptions"); + + migrationBuilder.DropColumn( + name: "is_deleted", + table: "newsletter_subscriptions"); + + migrationBuilder.DropColumn( + name: "last_modified_by_id", + table: "newsletter_subscriptions"); + + migrationBuilder.DropColumn( + name: "last_modified_on", + table: "newsletter_subscriptions"); + + migrationBuilder.DropColumn( + name: "created_by_id", + table: "news"); + + migrationBuilder.DropColumn( + name: "created_on", + table: "news"); + + migrationBuilder.DropColumn( + name: "last_modified_by_id", + table: "news"); + + migrationBuilder.DropColumn( + name: "last_modified_on", + table: "news"); + + migrationBuilder.DropColumn( + name: "created_by_id", + table: "knowledge_maps"); + + migrationBuilder.DropColumn( + name: "created_on", + table: "knowledge_maps"); + + migrationBuilder.DropColumn( + name: "last_modified_by_id", + table: "knowledge_maps"); + + migrationBuilder.DropColumn( + name: "last_modified_on", + table: "knowledge_maps"); + + migrationBuilder.DropColumn( + name: "created_by_id", + table: "homepage_sections"); + + migrationBuilder.DropColumn( + name: "created_on", + table: "homepage_sections"); + + migrationBuilder.DropColumn( + name: "last_modified_by_id", + table: "homepage_sections"); + + migrationBuilder.DropColumn( + name: "last_modified_on", + table: "homepage_sections"); + + migrationBuilder.DropColumn( + name: "created_by_id", + table: "expert_registration_requests"); + + migrationBuilder.DropColumn( + name: "created_on", + table: "expert_registration_requests"); + + migrationBuilder.DropColumn( + name: "last_modified_by_id", + table: "expert_registration_requests"); + + migrationBuilder.DropColumn( + name: "last_modified_on", + table: "expert_registration_requests"); + + migrationBuilder.DropColumn( + name: "created_by_id", + table: "expert_profiles"); + + migrationBuilder.DropColumn( + name: "created_on", + table: "expert_profiles"); + + migrationBuilder.DropColumn( + name: "last_modified_by_id", + table: "expert_profiles"); + + migrationBuilder.DropColumn( + name: "last_modified_on", + table: "expert_profiles"); + + migrationBuilder.DropColumn( + name: "created_by_id", + table: "events"); + + migrationBuilder.DropColumn( + name: "created_on", + table: "events"); + + migrationBuilder.DropColumn( + name: "last_modified_by_id", + table: "events"); + + migrationBuilder.DropColumn( + name: "last_modified_on", + table: "events"); + + migrationBuilder.DropColumn( + name: "created_by_id", + table: "country_resource_requests"); + + migrationBuilder.DropColumn( + name: "created_on", + table: "country_resource_requests"); + + migrationBuilder.DropColumn( + name: "last_modified_by_id", + table: "country_resource_requests"); + + migrationBuilder.DropColumn( + name: "last_modified_on", + table: "country_resource_requests"); + + migrationBuilder.DropColumn( + name: "last_modified_by_id", + table: "country_profiles"); + + migrationBuilder.DropColumn( + name: "last_modified_on", + table: "country_profiles"); + + migrationBuilder.DropColumn( + name: "created_by_id", + table: "countries"); + + migrationBuilder.DropColumn( + name: "created_on", + table: "countries"); + + migrationBuilder.DropColumn( + name: "last_modified_by_id", + table: "countries"); + + migrationBuilder.DropColumn( + name: "last_modified_on", + table: "countries"); + + migrationBuilder.DropColumn( + name: "created_by_id", + table: "city_scenarios"); + + migrationBuilder.DropColumn( + name: "last_modified_by_id", + table: "city_scenarios"); + + migrationBuilder.DropColumn( + name: "status", + table: "AspNetUsers"); + + migrationBuilder.RenameColumn( + name: "created_on", + table: "country_profiles", + newName: "last_updated_on"); + + migrationBuilder.RenameColumn( + name: "created_by_id", + table: "country_profiles", + newName: "last_updated_by_id"); + + migrationBuilder.AlterColumn( + name: "last_modified_on", + table: "city_scenarios", + type: "datetimeoffset", + nullable: false, + defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + oldClrType: typeof(DateTimeOffset), + oldType: "datetimeoffset", + oldNullable: true); + } + } +} diff --git a/backend/src/CCE.Infrastructure/Persistence/Migrations/20260520111756_AddUserSoftDelete.Designer.cs b/backend/src/CCE.Infrastructure/Persistence/Migrations/20260520111756_AddUserSoftDelete.Designer.cs new file mode 100644 index 00000000..2c7685da --- /dev/null +++ b/backend/src/CCE.Infrastructure/Persistence/Migrations/20260520111756_AddUserSoftDelete.Designer.cs @@ -0,0 +1,2720 @@ +// +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("20260520111756_AddUserSoftDelete")] + partial class AddUserSoftDelete + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("CCE.Domain.Audit.AuditEvent", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("action"); + + b.Property("Actor") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("actor"); + + b.Property("CorrelationId") + .HasColumnType("uniqueidentifier") + .HasColumnName("correlation_id"); + + b.Property("Diff") + .HasColumnType("nvarchar(max)") + .HasColumnName("diff"); + + b.Property("OccurredOn") + .HasColumnType("datetimeoffset") + .HasColumnName("occurred_on"); + + b.Property("Resource") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("resource"); + + b.HasKey("Id") + .HasName("pk_audit_events"); + + b.HasIndex("CorrelationId") + .HasDatabaseName("ix_audit_events_correlation_id"); + + b.HasIndex("Actor", "OccurredOn") + .HasDatabaseName("ix_audit_events_actor_occurred_on"); + + b.ToTable("audit_events", null, t => + { + t.HasTrigger("trg_audit_events_no_update_delete"); + }); + + b.HasAnnotation("SqlServer:UseSqlOutputClause", false); + }); + + modelBuilder.Entity("CCE.Domain.Community.Post", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AnsweredReplyId") + .HasColumnType("uniqueidentifier") + .HasColumnName("answered_reply_id"); + + b.Property("AuthorId") + .HasColumnType("uniqueidentifier") + .HasColumnName("author_id"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)") + .HasColumnName("content"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsAnswerable") + .HasColumnType("bit") + .HasColumnName("is_answerable"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("locale"); + + b.Property("TopicId") + .HasColumnType("uniqueidentifier") + .HasColumnName("topic_id"); + + b.HasKey("Id") + .HasName("pk_posts"); + + b.HasIndex("TopicId") + .HasDatabaseName("ix_post_topic_id"); + + b.HasIndex("AuthorId", "CreatedOn") + .HasDatabaseName("ix_post_author_created"); + + b.ToTable("posts", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.PostFollow", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("FollowedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("followed_on"); + + b.Property("PostId") + .HasColumnType("uniqueidentifier") + .HasColumnName("post_id"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_post_follows"); + + b.HasIndex("PostId", "UserId") + .IsUnique() + .HasDatabaseName("ux_post_follow_post_user"); + + b.ToTable("post_follows", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.PostRating", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("PostId") + .HasColumnType("uniqueidentifier") + .HasColumnName("post_id"); + + b.Property("RatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("rated_on"); + + b.Property("Stars") + .HasColumnType("int") + .HasColumnName("stars"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_post_ratings"); + + b.HasIndex("PostId", "UserId") + .IsUnique() + .HasDatabaseName("ux_post_rating_post_user"); + + b.ToTable("post_ratings", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.PostReply", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AuthorId") + .HasColumnType("uniqueidentifier") + .HasColumnName("author_id"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)") + .HasColumnName("content"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsByExpert") + .HasColumnType("bit") + .HasColumnName("is_by_expert"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("locale"); + + b.Property("ParentReplyId") + .HasColumnType("uniqueidentifier") + .HasColumnName("parent_reply_id"); + + b.Property("PostId") + .HasColumnType("uniqueidentifier") + .HasColumnName("post_id"); + + b.HasKey("Id") + .HasName("pk_post_replies"); + + b.HasIndex("ParentReplyId") + .HasDatabaseName("ix_post_reply_parent_id"); + + b.HasIndex("PostId") + .HasDatabaseName("ix_post_reply_post_id"); + + b.ToTable("post_replies", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.Topic", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("DescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("IconUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("icon_url"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.Property("ParentId") + .HasColumnType("uniqueidentifier") + .HasColumnName("parent_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_topics"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ux_topic_slug_active") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("topics", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.TopicFollow", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("FollowedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("followed_on"); + + b.Property("TopicId") + .HasColumnType("uniqueidentifier") + .HasColumnName("topic_id"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_topic_follows"); + + b.HasIndex("TopicId", "UserId") + .IsUnique() + .HasDatabaseName("ux_topic_follow_topic_user"); + + b.ToTable("topic_follows", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.UserFollow", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("FollowedId") + .HasColumnType("uniqueidentifier") + .HasColumnName("followed_id"); + + b.Property("FollowedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("followed_on"); + + b.Property("FollowerId") + .HasColumnType("uniqueidentifier") + .HasColumnName("follower_id"); + + b.HasKey("Id") + .HasName("pk_user_follows"); + + b.HasIndex("FollowerId", "FollowedId") + .IsUnique() + .HasDatabaseName("ux_user_follow_follower_followed"); + + b.ToTable("user_follows", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.AssetFile", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("MimeType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("mime_type"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("original_file_name"); + + b.Property("ScannedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("scanned_on"); + + b.Property("SizeBytes") + .HasColumnType("bigint") + .HasColumnName("size_bytes"); + + b.Property("UploadedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("uploaded_by_id"); + + b.Property("UploadedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("uploaded_on"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("url"); + + b.Property("VirusScanStatus") + .HasColumnType("int") + .HasColumnName("virus_scan_status"); + + b.HasKey("Id") + .HasName("pk_asset_files"); + + b.HasIndex("VirusScanStatus") + .HasDatabaseName("ix_asset_file_scan_status"); + + b.ToTable("asset_files", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.Event", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("DescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("EndsOn") + .HasColumnType("datetimeoffset") + .HasColumnName("ends_on"); + + b.Property("FeaturedImageUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("featured_image_url"); + + b.Property("ICalUid") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("i_cal_uid"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("LocationAr") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("location_ar"); + + b.Property("LocationEn") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("location_en"); + + b.Property("OnlineMeetingUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("online_meeting_url"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.Property("StartsOn") + .HasColumnType("datetimeoffset") + .HasColumnName("starts_on"); + + b.Property("TitleAr") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_ar"); + + b.Property("TitleEn") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_en"); + + b.HasKey("Id") + .HasName("pk_events"); + + b.HasIndex("ICalUid") + .IsUnique() + .HasDatabaseName("ux_event_ical_uid"); + + b.HasIndex("StartsOn") + .HasDatabaseName("ix_event_starts_on"); + + b.ToTable("events", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.HomepageSection", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("ContentAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_ar"); + + b.Property("ContentEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_en"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.Property("SectionType") + .HasColumnType("int") + .HasColumnName("section_type"); + + b.HasKey("Id") + .HasName("pk_homepage_sections"); + + b.HasIndex("IsActive", "OrderIndex") + .HasDatabaseName("ix_homepage_section_active_order"); + + b.ToTable("homepage_sections", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.News", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AuthorId") + .HasColumnType("uniqueidentifier") + .HasColumnName("author_id"); + + b.Property("ContentAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_ar"); + + b.Property("ContentEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_en"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("FeaturedImageUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("featured_image_url"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("IsFeatured") + .HasColumnType("bit") + .HasColumnName("is_featured"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("PublishedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("published_on"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("slug"); + + b.Property("TitleAr") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_ar"); + + b.Property("TitleEn") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_en"); + + b.HasKey("Id") + .HasName("pk_news"); + + b.HasIndex("PublishedOn") + .HasDatabaseName("ix_news_published_on"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ux_news_slug_active") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("news", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.NewsletterSubscription", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("ConfirmationToken") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("confirmation_token"); + + b.Property("ConfirmedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("confirmed_on"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("nvarchar(320)") + .HasColumnName("email"); + + b.Property("IsConfirmed") + .HasColumnType("bit") + .HasColumnName("is_confirmed"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("LocalePreference") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("locale_preference"); + + b.Property("UnsubscribedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("unsubscribed_on"); + + b.HasKey("Id") + .HasName("pk_newsletter_subscriptions"); + + b.HasIndex("ConfirmationToken") + .HasDatabaseName("ix_newsletter_token"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("ux_newsletter_email"); + + b.ToTable("newsletter_subscriptions", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.Page", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("ContentAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_ar"); + + b.Property("ContentEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_en"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("PageType") + .HasColumnType("int") + .HasColumnName("page_type"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("slug"); + + b.Property("TitleAr") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_ar"); + + b.Property("TitleEn") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_en"); + + b.HasKey("Id") + .HasName("pk_pages"); + + b.HasIndex("PageType", "Slug") + .IsUnique() + .HasDatabaseName("ux_page_type_slug_active") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("pages", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.Resource", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AssetFileId") + .HasColumnType("uniqueidentifier") + .HasColumnName("asset_file_id"); + + b.Property("CategoryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("category_id"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("DescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("PublishedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("published_on"); + + b.Property("ResourceType") + .HasColumnType("int") + .HasColumnName("resource_type"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.Property("TitleAr") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_ar"); + + b.Property("TitleEn") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_en"); + + b.Property("UploadedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("uploaded_by_id"); + + b.Property("ViewCount") + .HasColumnType("bigint") + .HasColumnName("view_count"); + + b.HasKey("Id") + .HasName("pk_resources"); + + b.HasIndex("AssetFileId") + .HasDatabaseName("ix_resource_asset_file_id"); + + b.HasIndex("CountryId") + .HasDatabaseName("ix_resource_country_id"); + + b.HasIndex("CategoryId", "PublishedOn") + .HasDatabaseName("ix_resource_category_published"); + + b.ToTable("resources", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.ResourceCategory", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.Property("ParentId") + .HasColumnType("uniqueidentifier") + .HasColumnName("parent_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_resource_categories"); + + b.HasIndex("ParentId") + .HasDatabaseName("ix_resource_category_parent_id"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ux_resource_category_slug"); + + b.ToTable("resource_categories", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Country.Country", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("FlagUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("flag_url"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("IsoAlpha2") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("iso_alpha2"); + + b.Property("IsoAlpha3") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("nvarchar(3)") + .HasColumnName("iso_alpha3"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("LatestKapsarcSnapshotId") + .HasColumnType("uniqueidentifier") + .HasColumnName("latest_kapsarc_snapshot_id"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.Property("RegionAr") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("region_ar"); + + b.Property("RegionEn") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("region_en"); + + b.HasKey("Id") + .HasName("pk_countries"); + + b.HasIndex("IsoAlpha2") + .HasDatabaseName("ix_country_iso_alpha2"); + + b.HasIndex("IsoAlpha3") + .IsUnique() + .HasDatabaseName("ux_country_iso_alpha3_active") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("countries", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Country.CountryKapsarcSnapshot", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("Classification") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("classification"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("PerformanceScore") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)") + .HasColumnName("performance_score"); + + b.Property("SnapshotTakenOn") + .HasColumnType("datetimeoffset") + .HasColumnName("snapshot_taken_on"); + + b.Property("SourceVersion") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)") + .HasColumnName("source_version"); + + b.Property("TotalIndex") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)") + .HasColumnName("total_index"); + + b.HasKey("Id") + .HasName("pk_country_kapsarc_snapshots"); + + b.HasIndex("CountryId", "SnapshotTakenOn") + .HasDatabaseName("ix_kapsarc_snapshot_country_taken"); + + b.ToTable("country_kapsarc_snapshots", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Country.CountryProfile", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("ContactInfoAr") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("contact_info_ar"); + + b.Property("ContactInfoEn") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("contact_info_en"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("KeyInitiativesAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("key_initiatives_ar"); + + b.Property("KeyInitiativesEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("key_initiatives_en"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.HasKey("Id") + .HasName("pk_country_profiles"); + + b.HasIndex("CountryId") + .IsUnique() + .HasDatabaseName("ux_country_profile_country_id"); + + b.ToTable("country_profiles", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Country.CountryResourceRequest", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AdminNotesAr") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("admin_notes_ar"); + + b.Property("AdminNotesEn") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("admin_notes_en"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("ProcessedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("processed_by_id"); + + b.Property("ProcessedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("processed_on"); + + b.Property("ProposedAssetFileId") + .HasColumnType("uniqueidentifier") + .HasColumnName("proposed_asset_file_id"); + + b.Property("ProposedDescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("proposed_description_ar"); + + b.Property("ProposedDescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("proposed_description_en"); + + b.Property("ProposedResourceType") + .HasColumnType("int") + .HasColumnName("proposed_resource_type"); + + b.Property("ProposedTitleAr") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("proposed_title_ar"); + + b.Property("ProposedTitleEn") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("proposed_title_en"); + + b.Property("RequestedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("requested_by_id"); + + b.Property("Status") + .HasColumnType("int") + .HasColumnName("status"); + + b.Property("SubmittedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("submitted_on"); + + b.HasKey("Id") + .HasName("pk_country_resource_requests"); + + b.HasIndex("CountryId", "Status") + .HasDatabaseName("ix_country_request_country_status"); + + b.ToTable("country_resource_requests", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.ExpertProfile", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AcademicTitleAr") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("academic_title_ar"); + + b.Property("AcademicTitleEn") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("academic_title_en"); + + b.Property("ApprovedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("approved_by_id"); + + b.Property("ApprovedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("approved_on"); + + b.Property("BioAr") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("bio_ar"); + + b.Property("BioEn") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("bio_en"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.PrimitiveCollection("ExpertiseTags") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("expertise_tags"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_expert_profiles"); + + b.HasIndex("UserId") + .IsUnique() + .HasDatabaseName("ux_expert_profile_active_user") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("expert_profiles", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.ExpertRegistrationRequest", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("ProcessedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("processed_by_id"); + + b.Property("ProcessedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("processed_on"); + + b.Property("RejectionReasonAr") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("rejection_reason_ar"); + + b.Property("RejectionReasonEn") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("rejection_reason_en"); + + b.Property("RequestedBioAr") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("requested_bio_ar"); + + b.Property("RequestedBioEn") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("requested_bio_en"); + + b.Property("RequestedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("requested_by_id"); + + b.PrimitiveCollection("RequestedTags") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("requested_tags"); + + b.Property("Status") + .HasColumnType("int") + .HasColumnName("status"); + + b.Property("SubmittedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("submitted_on"); + + b.HasKey("Id") + .HasName("pk_expert_registration_requests"); + + b.HasIndex("RequestedById") + .HasDatabaseName("ix_expert_request_requested_by"); + + b.HasIndex("Status") + .HasDatabaseName("ix_expert_request_status"); + + b.ToTable("expert_registration_requests", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.RefreshToken", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset") + .HasColumnName("created_at_utc"); + + b.Property("CreatedByIp") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("created_by_ip"); + + b.Property("ExpiresAtUtc") + .HasColumnType("datetimeoffset") + .HasColumnName("expires_at_utc"); + + b.Property("ReplacedByTokenHash") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("replaced_by_token_hash"); + + b.Property("RevokedAtUtc") + .HasColumnType("datetimeoffset") + .HasColumnName("revoked_at_utc"); + + b.Property("RevokedByIp") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("revoked_by_ip"); + + b.Property("TokenFamilyId") + .HasColumnType("uniqueidentifier") + .HasColumnName("token_family_id"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("token_hash"); + + b.Property("UserAgent") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_refresh_tokens"); + + b.HasIndex("TokenFamilyId") + .HasDatabaseName("ix_refresh_tokens_token_family_id"); + + b.HasIndex("TokenHash") + .IsUnique() + .HasDatabaseName("ux_refresh_tokens_token_hash"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_refresh_tokens_user_id"); + + b.ToTable("refresh_tokens", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)") + .HasColumnName("concurrency_stamp"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("normalized_name"); + + b.HasKey("Id") + .HasName("pk_asp_net_roles"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[normalized_name] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.StateRepresentativeAssignment", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AssignedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("assigned_by_id"); + + b.Property("AssignedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("assigned_on"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("RevokedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("revoked_by_id"); + + b.Property("RevokedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("revoked_on"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_state_representative_assignments"); + + b.HasIndex("CountryId") + .HasDatabaseName("ix_state_rep_country_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_state_rep_user_id"); + + b.HasIndex("UserId", "CountryId") + .IsUnique() + .HasDatabaseName("ux_state_rep_active_user_country") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("state_representative_assignments", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AccessFailedCount") + .HasColumnType("int") + .HasColumnName("access_failed_count"); + + b.Property("AvatarUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("avatar_url"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)") + .HasColumnName("concurrency_stamp"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("email"); + + b.Property("EmailConfirmed") + .HasColumnType("bit") + .HasColumnName("email_confirmed"); + + b.Property("EntraIdObjectId") + .HasColumnType("uniqueidentifier") + .HasColumnName("entra_id_object_id"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)") + .HasColumnName("first_name"); + + b.PrimitiveCollection("Interests") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("interests"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("JobTitle") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)") + .HasColumnName("job_title"); + + b.Property("KnowledgeLevel") + .HasColumnType("int") + .HasColumnName("knowledge_level"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)") + .HasColumnName("last_name"); + + b.Property("LocalePreference") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("locale_preference"); + + b.Property("LockoutEnabled") + .HasColumnType("bit") + .HasColumnName("lockout_enabled"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset") + .HasColumnName("lockout_end"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("normalized_email"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("normalized_user_name"); + + b.Property("OrganizationName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("organization_name"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)") + .HasColumnName("password_hash"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)") + .HasColumnName("phone_number"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit") + .HasColumnName("phone_number_confirmed"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)") + .HasColumnName("security_stamp"); + + b.Property("Status") + .HasColumnType("int") + .HasColumnName("status"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit") + .HasColumnName("two_factor_enabled"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("user_name"); + + b.HasKey("Id") + .HasName("pk_asp_net_users"); + + b.HasIndex("CountryId") + .HasDatabaseName("ix_users_country_id"); + + b.HasIndex("EntraIdObjectId") + .IsUnique() + .HasDatabaseName("ix_asp_net_users_entra_id_object_id") + .HasFilter("[entra_id_object_id] IS NOT NULL"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[normalized_user_name] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.InteractiveCity.CityScenario", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CityType") + .HasColumnType("int") + .HasColumnName("city_type"); + + b.Property("ConfigurationJson") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("configuration_json"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.Property("TargetYear") + .HasColumnType("int") + .HasColumnName("target_year"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_city_scenarios"); + + b.HasIndex("UserId", "LastModifiedOn") + .HasDatabaseName("ix_city_scenario_user_modified"); + + b.ToTable("city_scenarios", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.InteractiveCity.CityScenarioResult", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("ComputedAt") + .HasColumnType("datetimeoffset") + .HasColumnName("computed_at"); + + b.Property("ComputedCarbonNeutralityYear") + .HasColumnType("int") + .HasColumnName("computed_carbon_neutrality_year"); + + b.Property("ComputedTotalCostUsd") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)") + .HasColumnName("computed_total_cost_usd"); + + b.Property("EngineVersion") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("engine_version"); + + b.Property("ScenarioId") + .HasColumnType("uniqueidentifier") + .HasColumnName("scenario_id"); + + b.HasKey("Id") + .HasName("pk_city_scenario_results"); + + b.HasIndex("ScenarioId", "ComputedAt") + .HasDatabaseName("ix_city_result_scenario_at"); + + b.ToTable("city_scenario_results", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.InteractiveCity.CityTechnology", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CarbonImpactKgPerYear") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)") + .HasColumnName("carbon_impact_kg_per_year"); + + b.Property("CategoryAr") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("category_ar"); + + b.Property("CategoryEn") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("category_en"); + + b.Property("CostUsd") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)") + .HasColumnName("cost_usd"); + + b.Property("DescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("IconUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("icon_url"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.HasKey("Id") + .HasName("pk_city_technologies"); + + b.HasIndex("IsActive") + .HasDatabaseName("ix_city_tech_is_active"); + + b.ToTable("city_technologies", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.KnowledgeMaps.KnowledgeMap", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("DescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_knowledge_maps"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ux_knowledge_map_slug_active") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("knowledge_maps", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.KnowledgeMaps.KnowledgeMapAssociation", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AssociatedId") + .HasColumnType("uniqueidentifier") + .HasColumnName("associated_id"); + + b.Property("AssociatedType") + .HasColumnType("int") + .HasColumnName("associated_type"); + + b.Property("NodeId") + .HasColumnType("uniqueidentifier") + .HasColumnName("node_id"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.HasKey("Id") + .HasName("pk_knowledge_map_associations"); + + b.HasIndex("NodeId", "AssociatedType", "AssociatedId") + .IsUnique() + .HasDatabaseName("ux_km_assoc_node_type_id"); + + b.ToTable("knowledge_map_associations", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.KnowledgeMaps.KnowledgeMapEdge", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("FromNodeId") + .HasColumnType("uniqueidentifier") + .HasColumnName("from_node_id"); + + b.Property("MapId") + .HasColumnType("uniqueidentifier") + .HasColumnName("map_id"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.Property("RelationshipType") + .HasColumnType("int") + .HasColumnName("relationship_type"); + + b.Property("ToNodeId") + .HasColumnType("uniqueidentifier") + .HasColumnName("to_node_id"); + + b.HasKey("Id") + .HasName("pk_knowledge_map_edges"); + + b.HasIndex("FromNodeId") + .HasDatabaseName("ix_km_edge_from_node"); + + b.HasIndex("ToNodeId") + .HasDatabaseName("ix_km_edge_to_node"); + + b.HasIndex("MapId", "FromNodeId", "ToNodeId", "RelationshipType") + .IsUnique() + .HasDatabaseName("ux_km_edge_map_from_to_relation"); + + b.ToTable("knowledge_map_edges", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.KnowledgeMaps.KnowledgeMapNode", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("DescriptionAr") + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("IconUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("icon_url"); + + b.Property("LayoutX") + .HasColumnType("float") + .HasColumnName("layout_x"); + + b.Property("LayoutY") + .HasColumnType("float") + .HasColumnName("layout_y"); + + b.Property("MapId") + .HasColumnType("uniqueidentifier") + .HasColumnName("map_id"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.Property("NodeType") + .HasColumnType("int") + .HasColumnName("node_type"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.HasKey("Id") + .HasName("pk_knowledge_map_nodes"); + + b.HasIndex("MapId", "OrderIndex") + .HasDatabaseName("ix_km_node_map_order"); + + b.ToTable("knowledge_map_nodes", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.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") + .IsUnique() + .HasDatabaseName("ux_notification_template_code"); + + b.ToTable("notification_templates", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Notifications.UserNotification", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("Channel") + .HasColumnType("int") + .HasColumnName("channel"); + + b.Property("ReadOn") + .HasColumnType("datetimeoffset") + .HasColumnName("read_on"); + + b.Property("RenderedBody") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("rendered_body"); + + b.Property("RenderedLocale") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("rendered_locale"); + + b.Property("RenderedSubjectAr") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("rendered_subject_ar"); + + b.Property("RenderedSubjectEn") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("rendered_subject_en"); + + b.Property("SentOn") + .HasColumnType("datetimeoffset") + .HasColumnName("sent_on"); + + b.Property("Status") + .HasColumnType("int") + .HasColumnName("status"); + + b.Property("TemplateId") + .HasColumnType("uniqueidentifier") + .HasColumnName("template_id"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_user_notifications"); + + b.HasIndex("UserId", "Status") + .HasDatabaseName("ix_user_notification_user_status"); + + b.ToTable("user_notifications", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Surveys.SearchQueryLog", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("locale"); + + b.Property("QueryText") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("query_text"); + + b.Property("ResponseTimeMs") + .HasColumnType("int") + .HasColumnName("response_time_ms"); + + b.Property("ResultsCount") + .HasColumnType("int") + .HasColumnName("results_count"); + + b.Property("SubmittedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("submitted_on"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_search_query_logs"); + + b.HasIndex("SubmittedOn") + .HasDatabaseName("ix_search_query_log_submitted_on"); + + b.ToTable("search_query_logs", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Surveys.ServiceRating", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CommentAr") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("comment_ar"); + + b.Property("CommentEn") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("comment_en"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("locale"); + + b.Property("Page") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("page"); + + b.Property("Rating") + .HasColumnType("int") + .HasColumnName("rating"); + + b.Property("SubmittedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("submitted_on"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_service_ratings"); + + b.HasIndex("SubmittedOn") + .HasDatabaseName("ix_service_rating_submitted_on"); + + b.ToTable("service_ratings", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)") + .HasColumnName("claim_type"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)") + .HasColumnName("claim_value"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier") + .HasColumnName("role_id"); + + b.HasKey("Id") + .HasName("pk_asp_net_role_claims"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_asp_net_role_claims_role_id"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)") + .HasColumnName("claim_type"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)") + .HasColumnName("claim_value"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_asp_net_user_claims"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_asp_net_user_claims_user_id"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)") + .HasColumnName("login_provider"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)") + .HasColumnName("provider_key"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)") + .HasColumnName("provider_display_name"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("LoginProvider", "ProviderKey") + .HasName("pk_asp_net_user_logins"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_asp_net_user_logins_user_id"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier") + .HasColumnName("role_id"); + + b.HasKey("UserId", "RoleId") + .HasName("pk_asp_net_user_roles"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_asp_net_user_roles_role_id"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)") + .HasColumnName("login_provider"); + + b.Property("Name") + .HasColumnType("nvarchar(450)") + .HasColumnName("name"); + + b.Property("Value") + .HasColumnType("nvarchar(max)") + .HasColumnName("value"); + + b.HasKey("UserId", "LoginProvider", "Name") + .HasName("pk_asp_net_user_tokens"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.RefreshToken", b => + { + b.HasOne("CCE.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_refresh_tokens_asp_net_users_user_id"); + }); + + modelBuilder.Entity("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"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/CCE.Infrastructure/Persistence/Migrations/20260520111756_AddUserSoftDelete.cs b/backend/src/CCE.Infrastructure/Persistence/Migrations/20260520111756_AddUserSoftDelete.cs new file mode 100644 index 00000000..784791f9 --- /dev/null +++ b/backend/src/CCE.Infrastructure/Persistence/Migrations/20260520111756_AddUserSoftDelete.cs @@ -0,0 +1,50 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CCE.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddUserSoftDelete : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "deleted_by_id", + table: "AspNetUsers", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.AddColumn( + name: "deleted_on", + table: "AspNetUsers", + type: "datetimeoffset", + nullable: true); + + migrationBuilder.AddColumn( + name: "is_deleted", + table: "AspNetUsers", + type: "bit", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "deleted_by_id", + table: "AspNetUsers"); + + migrationBuilder.DropColumn( + name: "deleted_on", + table: "AspNetUsers"); + + migrationBuilder.DropColumn( + name: "is_deleted", + table: "AspNetUsers"); + } + } +} diff --git a/backend/src/CCE.Infrastructure/Persistence/Migrations/CceDbContextModelSnapshot.cs b/backend/src/CCE.Infrastructure/Persistence/Migrations/CceDbContextModelSnapshot.cs index 8f671e33..063f9753 100644 --- a/backend/src/CCE.Infrastructure/Persistence/Migrations/CceDbContextModelSnapshot.cs +++ b/backend/src/CCE.Infrastructure/Persistence/Migrations/CceDbContextModelSnapshot.cs @@ -95,6 +95,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("nvarchar(max)") .HasColumnName("content"); + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + b.Property("CreatedOn") .HasColumnType("datetimeoffset") .HasColumnName("created_on"); @@ -115,6 +119,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) .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) @@ -213,6 +225,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("nvarchar(max)") .HasColumnName("content"); + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + b.Property("CreatedOn") .HasColumnType("datetimeoffset") .HasColumnName("created_on"); @@ -233,6 +249,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) .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) @@ -265,6 +289,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) .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"); @@ -296,6 +328,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) .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) @@ -448,6 +488,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) .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"); @@ -485,6 +533,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("bit") .HasColumnName("is_deleted"); + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + b.Property("LocationAr") .HasMaxLength(512) .HasColumnType("nvarchar(512)") @@ -552,6 +608,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) .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"); @@ -568,6 +632,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) .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"); @@ -605,6 +677,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) .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"); @@ -626,6 +706,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("bit") .HasColumnName("is_featured"); + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + b.Property("PublishedOn") .HasColumnType("datetimeoffset") .HasColumnName("published_on"); @@ -685,6 +773,22 @@ protected override void BuildModel(ModelBuilder modelBuilder) .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) @@ -695,6 +799,18 @@ protected override void BuildModel(ModelBuilder modelBuilder) .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) @@ -734,6 +850,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) .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"); @@ -746,6 +870,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) .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"); @@ -804,6 +936,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) .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"); @@ -826,6 +966,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("bit") .HasColumnName("is_deleted"); + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + b.Property("PublishedOn") .HasColumnType("datetimeoffset") .HasColumnName("published_on"); @@ -931,6 +1079,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) .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"); @@ -965,6 +1121,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) .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"); @@ -1071,6 +1235,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) .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)") @@ -1091,13 +1263,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("nvarchar(max)") .HasColumnName("key_initiatives_en"); - b.Property("LastUpdatedById") + b.Property("LastModifiedById") .HasColumnType("uniqueidentifier") - .HasColumnName("last_updated_by_id"); + .HasColumnName("last_modified_by_id"); - b.Property("LastUpdatedOn") + b.Property("LastModifiedOn") .HasColumnType("datetimeoffset") - .HasColumnName("last_updated_on"); + .HasColumnName("last_modified_on"); b.Property("RowVersion") .IsConcurrencyToken() @@ -1136,6 +1308,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) .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"); @@ -1148,6 +1328,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) .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"); @@ -1245,6 +1433,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) .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"); @@ -1262,6 +1458,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) .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"); @@ -1283,6 +1487,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) .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"); @@ -1295,6 +1507,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) .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"); @@ -1473,6 +1693,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) .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"); @@ -1485,6 +1713,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) .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"); @@ -1539,6 +1775,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("uniqueidentifier") .HasColumnName("country_id"); + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + b.Property("Email") .HasMaxLength(256) .HasColumnType("nvarchar(256)") @@ -1563,6 +1807,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("nvarchar(max)") .HasColumnName("interests"); + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + b.Property("JobTitle") .IsRequired() .HasMaxLength(50) @@ -1625,6 +1873,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("nvarchar(max)") .HasColumnName("security_stamp"); + b.Property("Status") + .HasColumnType("int") + .HasColumnName("status"); + b.Property("TwoFactorEnabled") .HasColumnType("bit") .HasColumnName("two_factor_enabled"); @@ -1671,6 +1923,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("nvarchar(max)") .HasColumnName("configuration_json"); + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + b.Property("CreatedOn") .HasColumnType("datetimeoffset") .HasColumnName("created_on"); @@ -1687,7 +1943,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("bit") .HasColumnName("is_deleted"); - b.Property("LastModifiedOn") + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") .HasColumnType("datetimeoffset") .HasColumnName("last_modified_on"); @@ -1832,6 +2092,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) .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"); @@ -1858,6 +2126,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) .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) diff --git a/backend/src/CCE.Infrastructure/Reports/UserRegistrationsReportService.cs b/backend/src/CCE.Infrastructure/Reports/UserRegistrationsReportService.cs index 8799dbe8..c9a06bd2 100644 --- a/backend/src/CCE.Infrastructure/Reports/UserRegistrationsReportService.cs +++ b/backend/src/CCE.Infrastructure/Reports/UserRegistrationsReportService.cs @@ -30,9 +30,10 @@ public async System.Collections.Generic.IAsyncEnumerable Qu // userIds into a hash and fan out: but a streaming join requires a single SQL query. // Pragma: build the IAsyncEnumerable from a LINQ projection that EF translates. var query = from u in _db.Users + where !u.IsDeleted select new { - u.Id, u.Email, u.UserName, u.LockoutEnabled, u.LockoutEnd, + u.Id, u.Email, u.UserName, u.Status, u.LocalePreference, u.CountryId, Roles = (from ur in _db.UserRoles join r in _db.Roles on ur.RoleId equals r.Id @@ -40,7 +41,6 @@ join r in _db.Roles on ur.RoleId equals r.Id select r.Name).ToList() }; - var now = System.DateTimeOffset.UtcNow; await foreach (var row in StreamAsAsyncEnumerable(query).WithCancellation(ct).ConfigureAwait(false)) { yield return new UserRegistrationRow @@ -49,7 +49,7 @@ join r in _db.Roles on ur.RoleId equals r.Id Email = row.Email, UserName = row.UserName, Roles = string.Join("; ", row.Roles.Where(r => r != null)), - IsActive = !row.LockoutEnabled || row.LockoutEnd is null || row.LockoutEnd < now, + IsActive = row.Status == CCE.Domain.Identity.UserStatus.Active, LocalePreference = row.LocalePreference, CountryId = row.CountryId?.ToString(), }; diff --git a/backend/tests/CCE.Api.IntegrationTests/Endpoints/UsersEndpointTests.cs b/backend/tests/CCE.Api.IntegrationTests/Endpoints/UsersEndpointTests.cs index 8bfbdc2f..e57197a1 100644 --- a/backend/tests/CCE.Api.IntegrationTests/Endpoints/UsersEndpointTests.cs +++ b/backend/tests/CCE.Api.IntegrationTests/Endpoints/UsersEndpointTests.cs @@ -110,4 +110,68 @@ public async Task Sync_anonymous_returns_401() resp.StatusCode.Should().Be(HttpStatusCode.Unauthorized); } + + [Fact] + public async Task Put_status_anonymous_returns_401() + { + using var client = _factory.CreateClient(); + using var body = JsonContent.Create(new { isActive = true }); + + var resp = await client.PutAsync(new Uri($"/api/admin/users/{System.Guid.NewGuid()}/status", UriKind.Relative), body); + + resp.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Put_status_with_unknown_user_returns_404() + { + using var client = _factory.CreateClient(); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _auth.AccessToken); + using var body = JsonContent.Create(new { isActive = true }); + + var resp = await client.PutAsync(new Uri($"/api/admin/users/{System.Guid.NewGuid()}/status", UriKind.Relative), body); + + resp.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task Post_create_user_anonymous_returns_401() + { + using var client = _factory.CreateClient(); + using var body = JsonContent.Create(new + { + firstName = "Ali", + lastName = "Ahmed", + email = "test@cce.local", + password = "pass1234", + phoneNumber = "1234567890", + countryId = (Guid?)null, + role = "cce-admin", + }); + + var resp = await client.PostAsync(new Uri("/api/admin/users", UriKind.Relative), body); + + resp.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Delete_user_anonymous_returns_401() + { + using var client = _factory.CreateClient(); + + var resp = await client.DeleteAsync(new Uri($"/api/admin/users/{System.Guid.NewGuid()}", UriKind.Relative)); + + resp.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Delete_user_with_unknown_id_returns_404() + { + using var client = _factory.CreateClient(); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _auth.AccessToken); + + var resp = await client.DeleteAsync(new Uri($"/api/admin/users/{System.Guid.NewGuid()}", UriKind.Relative)); + + resp.StatusCode.Should().Be(HttpStatusCode.NotFound); + } } diff --git a/backend/tests/CCE.Application.Tests/Identity/Commands/ChangeUserStatus/ChangeUserStatusCommandHandlerTests.cs b/backend/tests/CCE.Application.Tests/Identity/Commands/ChangeUserStatus/ChangeUserStatusCommandHandlerTests.cs new file mode 100644 index 00000000..5b7f5449 --- /dev/null +++ b/backend/tests/CCE.Application.Tests/Identity/Commands/ChangeUserStatus/ChangeUserStatusCommandHandlerTests.cs @@ -0,0 +1,107 @@ +using CCE.Application.Common; +using CCE.Application.Common.Interfaces; +using CCE.Application.Identity.Commands.ChangeUserStatus; +using CCE.Application.Identity.Dtos; +using CCE.Application.Identity.Public; +using CCE.Application.Identity.Queries.GetUserById; +using CCE.Application.Messages; +using CCE.Domain.Identity; +using MediatR; +using NSubstitute; +using static CCE.Application.Tests.Identity.IdentityTestHelpers; + +namespace CCE.Application.Tests.Identity.Commands.ChangeUserStatus; + +public class ChangeUserStatusCommandHandlerTests +{ + [Fact] + public async Task Returns_not_found_when_user_does_not_exist() + { + var service = Substitute.For(); + service.FindAsync(Arg.Any(), Arg.Any()) + .Returns((User?)null); + + var db = Substitute.For(); + var mediator = Substitute.For(); + var sut = new ChangeUserStatusCommandHandler(db, service, mediator, BuildMsg()); + + var result = await sut.Handle(new ChangeUserStatusCommand(System.Guid.NewGuid(), true), CancellationToken.None); + + result.Success.Should().BeFalse(); + result.Type.Should().Be(Domain.Common.MessageType.NotFound); + } + + [Fact] + public async Task Activate_sets_status_to_active_and_returns_user_detail() + { + var userId = System.Guid.NewGuid(); + var user = BuildUser(userId, "a@b.c", "a"); + + var service = Substitute.For(); + service.FindAsync(userId, Arg.Any()) + .Returns(user); + + var db = Substitute.For(); + db.SaveChangesAsync(Arg.Any()).Returns(1); + + var expectedDto = new UserDetailDto( + userId, "a@b.c", "a", "ar", KnowledgeLevel.Beginner, + new List(), null, null, Array.Empty(), true); + + var mediator = Substitute.For(); + mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Response.Ok(expectedDto, "SUCCESS_OPERATION", "")); + + var sut = new ChangeUserStatusCommandHandler(db, service, mediator, BuildMsg()); + + var result = await sut.Handle(new ChangeUserStatusCommand(userId, true), CancellationToken.None); + + result.Success.Should().BeTrue(); + result.Data!.IsActive.Should().BeTrue(); + user.Status.Should().Be(UserStatus.Active); + service.Received(1).Update(user); + await db.Received(1).SaveChangesAsync(Arg.Any()); + } + + [Fact] + public async Task Deactivate_sets_status_to_inactive_and_returns_user_detail() + { + var userId = System.Guid.NewGuid(); + var user = BuildUser(userId, "a@b.c", "a"); + + var service = Substitute.For(); + service.FindAsync(userId, Arg.Any()) + .Returns(user); + + var db = Substitute.For(); + db.SaveChangesAsync(Arg.Any()).Returns(1); + + var expectedDto = new UserDetailDto( + userId, "a@b.c", "a", "ar", KnowledgeLevel.Beginner, + new List(), null, null, Array.Empty(), false); + + var mediator = Substitute.For(); + mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Response.Ok(expectedDto, "SUCCESS_OPERATION", "")); + + var sut = new ChangeUserStatusCommandHandler(db, service, mediator, BuildMsg()); + + var result = await sut.Handle(new ChangeUserStatusCommand(userId, false), CancellationToken.None); + + result.Success.Should().BeTrue(); + result.Data!.IsActive.Should().BeFalse(); + user.Status.Should().Be(UserStatus.Inactive); + service.Received(1).Update(user); + await db.Received(1).SaveChangesAsync(Arg.Any()); + } + + private static User BuildUser(System.Guid id, string email, string userName) => + new() + { + Id = id, + Email = email, + UserName = userName, + NormalizedEmail = email.ToUpperInvariant(), + NormalizedUserName = userName.ToUpperInvariant(), + }; +} diff --git a/backend/tests/CCE.Application.Tests/Identity/Commands/ChangeUserStatus/ChangeUserStatusCommandValidatorTests.cs b/backend/tests/CCE.Application.Tests/Identity/Commands/ChangeUserStatus/ChangeUserStatusCommandValidatorTests.cs new file mode 100644 index 00000000..aadda2ba --- /dev/null +++ b/backend/tests/CCE.Application.Tests/Identity/Commands/ChangeUserStatus/ChangeUserStatusCommandValidatorTests.cs @@ -0,0 +1,40 @@ +using CCE.Application.Identity.Commands.ChangeUserStatus; + +namespace CCE.Application.Tests.Identity.Commands.ChangeUserStatus; + +public class ChangeUserStatusCommandValidatorTests +{ + [Fact] + public void Valid_command_passes() + { + var sut = new ChangeUserStatusCommandValidator(); + var cmd = new ChangeUserStatusCommand(System.Guid.NewGuid(), true); + + var result = sut.Validate(cmd); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void Deactivate_command_passes() + { + var sut = new ChangeUserStatusCommandValidator(); + var cmd = new ChangeUserStatusCommand(System.Guid.NewGuid(), false); + + var result = sut.Validate(cmd); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void Empty_id_is_rejected() + { + var sut = new ChangeUserStatusCommandValidator(); + var cmd = new ChangeUserStatusCommand(System.Guid.Empty, true); + + var result = sut.Validate(cmd); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == nameof(ChangeUserStatusCommand.UserId)); + } +} diff --git a/backend/tests/CCE.Application.Tests/Identity/Commands/CreateUser/CreateUserCommandHandlerTests.cs b/backend/tests/CCE.Application.Tests/Identity/Commands/CreateUser/CreateUserCommandHandlerTests.cs new file mode 100644 index 00000000..d1eb3bc2 --- /dev/null +++ b/backend/tests/CCE.Application.Tests/Identity/Commands/CreateUser/CreateUserCommandHandlerTests.cs @@ -0,0 +1,95 @@ +using CCE.Application.Common; +using CCE.Application.Identity.Auth.Common; +using CCE.Application.Identity.Commands.CreateUser; +using CCE.Application.Identity.Dtos; +using CCE.Application.Identity.Queries.GetUserById; +using CCE.Application.Messages; +using CCE.Domain.Identity; +using MediatR; +using NSubstitute; +using static CCE.Application.Tests.Identity.IdentityTestHelpers; + +namespace CCE.Application.Tests.Identity.Commands.CreateUser; + +public class CreateUserCommandHandlerTests +{ + [Fact] + public async Task Returns_conflict_when_email_already_exists() + { + var auth = Substitute.For(); + auth.AdminCreateUserAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()) + .Returns(new AdminCreateResult(null, true, false)); + + var mediator = Substitute.For(); + var sut = new CreateUserCommandHandler(auth, mediator, BuildMsg()); + + var result = await sut.Handle( + new CreateUserCommand("A", "B", "a@b.c", "pass1234", "123", null, "cce-admin"), + CancellationToken.None); + + result.Success.Should().BeFalse(); + result.Type.Should().Be(Domain.Common.MessageType.Conflict); + } + + [Fact] + public async Task Returns_business_rule_on_creation_failure() + { + var auth = Substitute.For(); + auth.AdminCreateUserAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()) + .Returns(new AdminCreateResult(null, false, true)); + + var mediator = Substitute.For(); + var sut = new CreateUserCommandHandler(auth, mediator, BuildMsg()); + + var result = await sut.Handle( + new CreateUserCommand("A", "B", "a@b.c", "pass1234", "123", null, "cce-admin"), + CancellationToken.None); + + result.Success.Should().BeFalse(); + result.Type.Should().Be(Domain.Common.MessageType.BusinessRule); + } + + [Fact] + public async Task Creates_user_and_returns_detail() + { + var userId = System.Guid.NewGuid(); + var user = new User + { + Id = userId, + Email = "a@b.c", + UserName = "a@b.c", + NormalizedEmail = "A@B.C", + NormalizedUserName = "A@B.C", + }; + + var auth = Substitute.For(); + auth.AdminCreateUserAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()) + .Returns(new AdminCreateResult(user, false, false)); + + var expectedDto = new UserDetailDto( + userId, "a@b.c", "a@b.c", "ar", KnowledgeLevel.Beginner, + new List(), null, null, new[] { "cce-admin" }, true); + + var mediator = Substitute.For(); + mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Response.Ok(expectedDto, "SUCCESS_OPERATION", "")); + + var sut = new CreateUserCommandHandler(auth, mediator, BuildMsg()); + + var result = await sut.Handle( + new CreateUserCommand("A", "B", "a@b.c", "pass1234", "123", null, "cce-admin"), + CancellationToken.None); + + result.Success.Should().BeTrue(); + result.Data!.Id.Should().Be(userId); + } +} diff --git a/backend/tests/CCE.Application.Tests/Identity/Commands/CreateUser/CreateUserCommandValidatorTests.cs b/backend/tests/CCE.Application.Tests/Identity/Commands/CreateUser/CreateUserCommandValidatorTests.cs new file mode 100644 index 00000000..24993d36 --- /dev/null +++ b/backend/tests/CCE.Application.Tests/Identity/Commands/CreateUser/CreateUserCommandValidatorTests.cs @@ -0,0 +1,89 @@ +using CCE.Application.Identity.Commands.CreateUser; + +namespace CCE.Application.Tests.Identity.Commands.CreateUser; + +public class CreateUserCommandValidatorTests +{ + [Fact] + public void Valid_command_passes() + { + var sut = new CreateUserCommandValidator(); + var cmd = new CreateUserCommand("Ali", "Ahmed", "a@b.c", "pass1234", "1234567890", null, "cce-admin"); + + var result = sut.Validate(cmd); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void Missing_first_name_is_rejected() + { + var sut = new CreateUserCommandValidator(); + var cmd = new CreateUserCommand("", "B", "a@b.c", "pass1234", "123", null, "cce-admin"); + + var result = sut.Validate(cmd); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == nameof(CreateUserCommand.FirstName)); + } + + [Fact] + public void First_name_with_numbers_is_rejected() + { + var sut = new CreateUserCommandValidator(); + var cmd = new CreateUserCommand("Ali123", "B", "a@b.c", "pass1234", "123", null, "cce-admin"); + + var result = sut.Validate(cmd); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == nameof(CreateUserCommand.FirstName)); + } + + [Fact] + public void Invalid_email_is_rejected() + { + var sut = new CreateUserCommandValidator(); + var cmd = new CreateUserCommand("Ali", "Ahmed", "not-an-email", "pass1234", "123", null, "cce-admin"); + + var result = sut.Validate(cmd); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == nameof(CreateUserCommand.Email)); + } + + [Fact] + public void Password_too_short_is_rejected() + { + var sut = new CreateUserCommandValidator(); + var cmd = new CreateUserCommand("Ali", "Ahmed", "a@b.c", "123", "123", null, "cce-admin"); + + var result = sut.Validate(cmd); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == nameof(CreateUserCommand.Password)); + } + + [Fact] + public void Unknown_role_is_rejected() + { + var sut = new CreateUserCommandValidator(); + var cmd = new CreateUserCommand("Ali", "Ahmed", "a@b.c", "pass1234", "123", null, "cce-user"); + + var result = sut.Validate(cmd); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == nameof(CreateUserCommand.Role)); + } + + [Fact] + public void Empty_role_is_rejected() + { + var sut = new CreateUserCommandValidator(); + var cmd = new CreateUserCommand("Ali", "Ahmed", "a@b.c", "pass1234", "123", null, ""); + + var result = sut.Validate(cmd); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == nameof(CreateUserCommand.Role)); + } +} diff --git a/backend/tests/CCE.Application.Tests/Identity/Commands/DeleteUser/DeleteUserCommandHandlerTests.cs b/backend/tests/CCE.Application.Tests/Identity/Commands/DeleteUser/DeleteUserCommandHandlerTests.cs new file mode 100644 index 00000000..b1560454 --- /dev/null +++ b/backend/tests/CCE.Application.Tests/Identity/Commands/DeleteUser/DeleteUserCommandHandlerTests.cs @@ -0,0 +1,88 @@ +using CCE.Application.Common.Interfaces; +using CCE.Application.Identity.Commands.DeleteUser; +using CCE.Application.Identity.Dtos; +using CCE.Application.Identity.Public; +using CCE.Application.Messages; +using CCE.Domain.Identity; +using NSubstitute; +using static CCE.Application.Tests.Identity.IdentityTestHelpers; + +namespace CCE.Application.Tests.Identity.Commands.DeleteUser; + +public class DeleteUserCommandHandlerTests +{ + [Fact] + public async Task Returns_not_found_when_user_does_not_exist() + { + var service = Substitute.For(); + service.FindAsync(Arg.Any(), Arg.Any()) + .Returns((User?)null); + + var db = Substitute.For(); + var currentUser = Substitute.For(); + var sut = new DeleteUserCommandHandler(db, service, currentUser, BuildMsg()); + + var result = await sut.Handle(new DeleteUserCommand(System.Guid.NewGuid()), CancellationToken.None); + + result.Success.Should().BeFalse(); + result.Type.Should().Be(Domain.Common.MessageType.NotFound); + } + + [Fact] + public async Task Returns_not_found_when_user_already_deleted() + { + var userId = System.Guid.NewGuid(); + var user = BuildUser(userId, "a@b.c", "a"); + user.SoftDelete(System.Guid.NewGuid(), System.DateTimeOffset.UtcNow); + + var service = Substitute.For(); + service.FindAsync(userId, Arg.Any()).Returns(user); + + var db = Substitute.For(); + var currentUser = Substitute.For(); + var sut = new DeleteUserCommandHandler(db, service, currentUser, BuildMsg()); + + var result = await sut.Handle(new DeleteUserCommand(userId), CancellationToken.None); + + result.Success.Should().BeFalse(); + result.Type.Should().Be(Domain.Common.MessageType.NotFound); + } + + [Fact] + public async Task Soft_deletes_user_and_returns_detail() + { + var userId = System.Guid.NewGuid(); + var actorId = System.Guid.NewGuid(); + var user = BuildUser(userId, "a@b.c", "a"); + + var service = Substitute.For(); + service.FindAsync(userId, Arg.Any()).Returns(user); + + var db = Substitute.For(); + db.SaveChangesAsync(Arg.Any()).Returns(1); + + var currentUser = Substitute.For(); + currentUser.GetUserId().Returns(actorId); + + var sut = new DeleteUserCommandHandler(db, service, currentUser, BuildMsg()); + + var result = await sut.Handle(new DeleteUserCommand(userId), CancellationToken.None); + + result.Success.Should().BeTrue(); + result.Data!.Id.Should().Be(userId); + user.IsDeleted.Should().BeTrue(); + user.DeletedById.Should().Be(actorId); + service.Received(1).Update(user); + await db.Received(1).SaveChangesAsync(Arg.Any()); + } + + private static User BuildUser(System.Guid id, string email, string userName) => + new() + { + Id = id, + Email = email, + UserName = userName, + NormalizedEmail = email.ToUpperInvariant(), + NormalizedUserName = userName.ToUpperInvariant(), + }; +} diff --git a/backend/tests/CCE.Application.Tests/Identity/Commands/DeleteUser/DeleteUserCommandValidatorTests.cs b/backend/tests/CCE.Application.Tests/Identity/Commands/DeleteUser/DeleteUserCommandValidatorTests.cs new file mode 100644 index 00000000..7b70b571 --- /dev/null +++ b/backend/tests/CCE.Application.Tests/Identity/Commands/DeleteUser/DeleteUserCommandValidatorTests.cs @@ -0,0 +1,29 @@ +using CCE.Application.Identity.Commands.DeleteUser; + +namespace CCE.Application.Tests.Identity.Commands.DeleteUser; + +public class DeleteUserCommandValidatorTests +{ + [Fact] + public void Valid_command_passes() + { + var sut = new DeleteUserCommandValidator(); + var cmd = new DeleteUserCommand(System.Guid.NewGuid()); + + var result = sut.Validate(cmd); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void Empty_id_is_rejected() + { + var sut = new DeleteUserCommandValidator(); + var cmd = new DeleteUserCommand(System.Guid.Empty); + + var result = sut.Validate(cmd); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == nameof(DeleteUserCommand.UserId)); + } +} diff --git a/backend/tests/CCE.Application.Tests/Identity/Queries/GetUserByIdQueryHandlerTests.cs b/backend/tests/CCE.Application.Tests/Identity/Queries/GetUserByIdQueryHandlerTests.cs index e8a92c9d..c8030bc5 100644 --- a/backend/tests/CCE.Application.Tests/Identity/Queries/GetUserByIdQueryHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Identity/Queries/GetUserByIdQueryHandlerTests.cs @@ -45,13 +45,11 @@ public async Task Returns_user_detail_with_role_names_and_is_active_true() } [Fact] - public async Task Returns_is_active_false_when_lockout_active() + public async Task Returns_is_active_false_when_user_is_inactive() { var aliceId = System.Guid.NewGuid(); - var future = System.DateTimeOffset.UtcNow.AddYears(1); var alice = BuildUser(aliceId, "alice@cce.local", "alice"); - alice.LockoutEnabled = true; - alice.LockoutEnd = future; + alice.Deactivate(); var db = BuildDb(new[] { alice }, System.Array.Empty(), System.Array.Empty>()); var sut = new GetUserByIdQueryHandler(db, BuildMsg()); From 0abed39806fde31b0be490d058f88024ed28caac Mon Sep 17 00:00:00 2001 From: MOHAMED RASHED Date: Thu, 21 May 2026 11:48:37 +0300 Subject: [PATCH 14/22] refactor/ use respons --- .../Localization/Resources.yaml | 4 +++ .../Endpoints/IdentityEndpoints.cs | 2 +- .../Queries/ListUsers/ListUsersQuery.cs | 3 +- .../ListUsers/ListUsersQueryHandler.cs | 17 ++++++--- .../CCE.Application/Messages/SystemCode.cs | 1 + .../CCE.Application/Messages/SystemCodeMap.cs | 1 + .../Endpoints/UsersEndpointTests.cs | 11 +++--- .../Queries/ListUsersQueryHandlerTests.cs | 35 ++++++++++--------- 8 files changed, 48 insertions(+), 26 deletions(-) diff --git a/backend/src/CCE.Api.Common/Localization/Resources.yaml b/backend/src/CCE.Api.Common/Localization/Resources.yaml index 485081ff..31cc3195 100644 --- a/backend/src/CCE.Api.Common/Localization/Resources.yaml +++ b/backend/src/CCE.Api.Common/Localization/Resources.yaml @@ -278,6 +278,10 @@ PROFILE_UPDATED: ar: "تم تحديث بيانات الملف الشخصي بنجاح. يمكنك الآن الاطلاع على المعلومات المحدثة في ملفك الشخصي." en: "Profile data updated successfully. You can now view the updated information in your profile." +ITEMS_LISTED: + ar: "تم جلب العناصر بنجاح" + en: "Items listed successfully" + SUCCESS_OPERATION: ar: "تمت العملية بنجاح" en: "Operation completed successfully" diff --git a/backend/src/CCE.Api.Internal/Endpoints/IdentityEndpoints.cs b/backend/src/CCE.Api.Internal/Endpoints/IdentityEndpoints.cs index c194db46..dc805168 100644 --- a/backend/src/CCE.Api.Internal/Endpoints/IdentityEndpoints.cs +++ b/backend/src/CCE.Api.Internal/Endpoints/IdentityEndpoints.cs @@ -37,7 +37,7 @@ public static IEndpointRouteBuilder MapIdentityEndpoints(this IEndpointRouteBuil Search: search, Role: role); var result = await mediator.Send(query, ct).ConfigureAwait(false); - return Results.Ok(result); + return result.ToHttpResult(); }) .RequireAuthorization(Permissions.User_Read) .WithName("ListUsers"); diff --git a/backend/src/CCE.Application/Identity/Queries/ListUsers/ListUsersQuery.cs b/backend/src/CCE.Application/Identity/Queries/ListUsers/ListUsersQuery.cs index 3b1c2982..4ad461a7 100644 --- a/backend/src/CCE.Application/Identity/Queries/ListUsers/ListUsersQuery.cs +++ b/backend/src/CCE.Application/Identity/Queries/ListUsers/ListUsersQuery.cs @@ -1,3 +1,4 @@ +using CCE.Application.Common; using CCE.Application.Common.Pagination; using CCE.Application.Identity.Dtos; using MediatR; @@ -14,4 +15,4 @@ public sealed record ListUsersQuery( int Page = 1, int PageSize = 20, string? Search = null, - string? Role = null) : IRequest>; + string? Role = null) : IRequest>>; diff --git a/backend/src/CCE.Application/Identity/Queries/ListUsers/ListUsersQueryHandler.cs b/backend/src/CCE.Application/Identity/Queries/ListUsers/ListUsersQueryHandler.cs index 466a6dc1..ef5bf635 100644 --- a/backend/src/CCE.Application/Identity/Queries/ListUsers/ListUsersQueryHandler.cs +++ b/backend/src/CCE.Application/Identity/Queries/ListUsers/ListUsersQueryHandler.cs @@ -1,18 +1,25 @@ +using CCE.Application.Common; using CCE.Application.Common.Interfaces; using CCE.Application.Common.Pagination; using CCE.Application.Identity.Dtos; +using CCE.Application.Messages; using MediatR; using Microsoft.AspNetCore.Identity; namespace CCE.Application.Identity.Queries.ListUsers; -public sealed class ListUsersQueryHandler : IRequestHandler> +public sealed class ListUsersQueryHandler : IRequestHandler>> { private readonly ICceDbContext _db; + private readonly MessageFactory _msg; - public ListUsersQueryHandler(ICceDbContext db) => _db = db; + public ListUsersQueryHandler(ICceDbContext db, MessageFactory msg) + { + _db = db; + _msg = msg; + } - public async Task> Handle( + public async Task>> Handle( ListUsersQuery request, CancellationToken cancellationToken) { var query = _db.Users.Where(u => !u.IsDeleted); @@ -49,8 +56,10 @@ public async Task> Handle( .ToList(), u.Status == Domain.Identity.UserStatus.Active)); - return await projected + var paged = await projected .ToPagedResultAsync(request.Page, request.PageSize, cancellationToken) .ConfigureAwait(false); + + return _msg.Ok(paged, "ITEMS_LISTED"); } } diff --git a/backend/src/CCE.Application/Messages/SystemCode.cs b/backend/src/CCE.Application/Messages/SystemCode.cs index 594047fb..3adbc6ae 100644 --- a/backend/src/CCE.Application/Messages/SystemCode.cs +++ b/backend/src/CCE.Application/Messages/SystemCode.cs @@ -159,6 +159,7 @@ public static class SystemCode public const string CON042 = "CON042"; // Notification deleted // ─── General Success ─── + public const string CON100 = "CON100"; // Items listed successfully public const string CON900 = "CON900"; // Operation completed successfully public const string CON901 = "CON901"; // Created successfully (generic) public const string CON902 = "CON902"; // Updated successfully (generic) diff --git a/backend/src/CCE.Application/Messages/SystemCodeMap.cs b/backend/src/CCE.Application/Messages/SystemCodeMap.cs index d59fdcf1..206dcfca 100644 --- a/backend/src/CCE.Application/Messages/SystemCodeMap.cs +++ b/backend/src/CCE.Application/Messages/SystemCodeMap.cs @@ -117,6 +117,7 @@ public static class SystemCodeMap ["NOTIFICATION_DELETED"] = SystemCode.CON042, // ─── General Success ─── + ["ITEMS_LISTED"] = SystemCode.CON100, ["SUCCESS_OPERATION"] = SystemCode.CON900, ["SUCCESS_CREATED"] = SystemCode.CON901, ["SUCCESS_UPDATED"] = SystemCode.CON902, diff --git a/backend/tests/CCE.Api.IntegrationTests/Endpoints/UsersEndpointTests.cs b/backend/tests/CCE.Api.IntegrationTests/Endpoints/UsersEndpointTests.cs index e57197a1..b3e607e5 100644 --- a/backend/tests/CCE.Api.IntegrationTests/Endpoints/UsersEndpointTests.cs +++ b/backend/tests/CCE.Api.IntegrationTests/Endpoints/UsersEndpointTests.cs @@ -48,10 +48,13 @@ public async Task SuperAdmin_request_returns_200_with_paged_user_shape() resp.StatusCode.Should().Be(HttpStatusCode.OK); var body = await resp.Content.ReadAsStringAsync(); var doc = JsonDocument.Parse(body).RootElement; - doc.GetProperty("items").ValueKind.Should().Be(JsonValueKind.Array); - doc.GetProperty("page").GetInt32().Should().Be(1); - doc.GetProperty("pageSize").GetInt32().Should().Be(20); - doc.GetProperty("total").GetInt64().Should().BeGreaterThanOrEqualTo(0); + doc.GetProperty("success").GetBoolean().Should().BeTrue(); + doc.GetProperty("code").GetString().Should().Be("CON100"); + var data = doc.GetProperty("data"); + data.GetProperty("items").ValueKind.Should().Be(JsonValueKind.Array); + data.GetProperty("page").GetInt32().Should().Be(1); + data.GetProperty("pageSize").GetInt32().Should().Be(20); + data.GetProperty("total").GetInt64().Should().BeGreaterThanOrEqualTo(0); } [Fact] diff --git a/backend/tests/CCE.Application.Tests/Identity/Queries/ListUsersQueryHandlerTests.cs b/backend/tests/CCE.Application.Tests/Identity/Queries/ListUsersQueryHandlerTests.cs index 86805023..3a461c8a 100644 --- a/backend/tests/CCE.Application.Tests/Identity/Queries/ListUsersQueryHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Identity/Queries/ListUsersQueryHandlerTests.cs @@ -11,14 +11,16 @@ public class ListUsersQueryHandlerTests public async Task Returns_empty_paged_result_when_no_users_exist() { var db = BuildDb(users: System.Array.Empty(), roles: System.Array.Empty(), userRoles: System.Array.Empty>()); - var sut = new ListUsersQueryHandler(db); + var sut = new ListUsersQueryHandler(db, IdentityTestHelpers.BuildMsg()); var result = await sut.Handle(new ListUsersQuery(Page: 1, PageSize: 20), CancellationToken.None); - result.Items.Should().BeEmpty(); - result.Total.Should().Be(0); - result.Page.Should().Be(1); - result.PageSize.Should().Be(20); + result.Success.Should().BeTrue(); + result.Code.Should().Be("CON100"); + result.Data!.Items.Should().BeEmpty(); + result.Data.Total.Should().Be(0); + result.Data.Page.Should().Be(1); + result.Data.PageSize.Should().Be(20); } [Fact] @@ -47,18 +49,19 @@ public async Task Returns_users_with_their_role_names() }; var db = BuildDb(users, roles, userRoles); - var sut = new ListUsersQueryHandler(db); + var sut = new ListUsersQueryHandler(db, IdentityTestHelpers.BuildMsg()); var result = await sut.Handle(new ListUsersQuery(Page: 1, PageSize: 20), CancellationToken.None); - result.Total.Should().Be(2); - result.Items.Should().HaveCount(2); + result.Success.Should().BeTrue(); + result.Data!.Total.Should().Be(2); + result.Data.Items.Should().HaveCount(2); - var alice = result.Items.Single(u => u.UserName == "alice"); + var alice = result.Data.Items.Single(u => u.UserName == "alice"); alice.Roles.Should().BeEquivalentTo(new[] { "SuperAdmin", "ContentManager" }); alice.IsActive.Should().BeTrue(); - var bob = result.Items.Single(u => u.UserName == "bob"); + var bob = result.Data.Items.Single(u => u.UserName == "bob"); bob.Roles.Should().BeEquivalentTo(new[] { "ContentManager" }); } @@ -71,12 +74,12 @@ public async Task Search_filters_by_username_or_email_substring() BuildUser(System.Guid.NewGuid(), "bob@example.com", "bob"), }; var db = BuildDb(users, System.Array.Empty(), System.Array.Empty>()); - var sut = new ListUsersQueryHandler(db); + var sut = new ListUsersQueryHandler(db, IdentityTestHelpers.BuildMsg()); var result = await sut.Handle(new ListUsersQuery(Search: "cce.local"), CancellationToken.None); - result.Total.Should().Be(1); - result.Items.Single().UserName.Should().Be("alice"); + result.Data!.Total.Should().Be(1); + result.Data.Items.Single().UserName.Should().Be("alice"); } [Fact] @@ -104,12 +107,12 @@ public async Task Role_filter_restricts_to_users_in_that_role() }; var db = BuildDb(users, roles, userRoles); - var sut = new ListUsersQueryHandler(db); + var sut = new ListUsersQueryHandler(db, IdentityTestHelpers.BuildMsg()); var result = await sut.Handle(new ListUsersQuery(Role: "SuperAdmin"), CancellationToken.None); - result.Total.Should().Be(1); - result.Items.Single().UserName.Should().Be("alice"); + result.Data!.Total.Should().Be(1); + result.Data.Items.Single().UserName.Should().Be("alice"); } private static ICceDbContext BuildDb( From ff201c9b80a8ef6b951377d7372d976f02fe0b12 Mon Sep 17 00:00:00 2001 From: MOHAMED RASHED Date: Thu, 21 May 2026 12:56:07 +0300 Subject: [PATCH 15/22] feat(platform-settings): restructure CMS settings with child entity tables - Replace bilingual video_url with single field on HomepageSettings - Extract KnowledgePartner as separate aggregate from AboutSettings - Extract PolicySection with PolicySectionType enum from PoliciesSettings - Promote GlossaryEntry, HomepageCountry to AggregateRoot - Add order_index to HomepageCountry and all collection tables - Switch all handlers to Response + MessageFactory pattern - Add ICceDbContext.Add/Delete/DeleteRange generic write methods - Add 12 new admin + public endpoints for CRUD (glossary, partners, sections) - Register new repos, EF configs, DI, SystemCode mappings, Resources.yaml keys - Regenerate AddPlatformSettings migration with updated schema (7 tables) --- backend/permissions.yaml | 3 + .../Localization/Resources.yaml | 34 + .../Endpoints/AboutSettingsPublicEndpoints.cs | 26 + .../HomepageSettingsPublicEndpoints.cs | 26 + .../PoliciesSettingsPublicEndpoints.cs | 26 + backend/src/CCE.Api.External/Program.cs | 3 + .../Endpoints/AboutSettingsEndpoints.cs | 153 + .../Endpoints/HomepageSettingsEndpoints.cs | 57 + .../Endpoints/PoliciesSettingsEndpoints.cs | 95 + backend/src/CCE.Api.Internal/Program.cs | 3 + .../Common/Interfaces/ICceDbContext.cs | 13 + .../Messages/MessageFactory.cs | 10 + .../CCE.Application/Messages/SystemCode.cs | 8 + .../CCE.Application/Messages/SystemCodeMap.cs | 12 + .../CreateGlossaryEntryCommand.cs | 11 + .../CreateGlossaryEntryCommandHandler.cs | 51 + .../CreateGlossaryEntryCommandValidator.cs | 15 + .../CreateKnowledgePartnerCommand.cs | 13 + .../CreateKnowledgePartnerCommandHandler.cs | 52 + .../CreateKnowledgePartnerCommandValidator.cs | 15 + .../CreatePolicySectionCommand.cs | 12 + .../CreatePolicySectionCommandHandler.cs | 53 + .../CreatePolicySectionCommandValidator.cs | 15 + .../DeleteGlossaryEntryCommand.cs | 6 + .../DeleteGlossaryEntryCommandHandler.cs | 36 + .../DeleteKnowledgePartnerCommand.cs | 6 + .../DeleteKnowledgePartnerCommandHandler.cs | 36 + .../DeletePolicySectionCommand.cs | 6 + .../DeletePolicySectionCommandHandler.cs | 36 + .../UpdateAboutSettingsCommand.cs | 11 + .../UpdateAboutSettingsCommandHandler.cs | 61 + .../UpdateAboutSettingsCommandValidator.cs | 15 + .../UpdateGlossaryEntryCommand.cs | 12 + .../UpdateGlossaryEntryCommandHandler.cs | 42 + .../UpdateGlossaryEntryCommandValidator.cs | 16 + .../UpdateHomepageSettingsCommand.cs | 14 + .../UpdateHomepageSettingsCommandHandler.cs | 67 + .../UpdateHomepageSettingsCommandValidator.cs | 15 + .../UpdateKnowledgePartnerCommand.cs | 14 + .../UpdateKnowledgePartnerCommandHandler.cs | 42 + .../UpdateKnowledgePartnerCommandValidator.cs | 16 + .../UpdatePoliciesSettingsCommand.cs | 8 + .../UpdatePoliciesSettingsCommandHandler.cs | 48 + .../UpdatePoliciesSettingsCommandValidator.cs | 13 + .../UpdatePolicySectionCommand.cs | 12 + .../UpdatePolicySectionCommandHandler.cs | 42 + .../UpdatePolicySectionCommandValidator.cs | 16 + .../PlatformSettings/Dtos/AboutSettingsDto.cs | 10 + .../PlatformSettings/Dtos/GlossaryEntryDto.cs | 9 + .../Dtos/HomepageSettingsDto.cs | 16 + .../Dtos/KnowledgePartnerDto.cs | 11 + .../Dtos/PoliciesSettingsDto.cs | 6 + .../PlatformSettings/Dtos/PolicySectionDto.cs | 10 + .../IAboutSettingsRepository.cs | 8 + .../IGlossaryEntryRepository.cs | 8 + .../IHomepageSettingsRepository.cs | 8 + .../IKnowledgePartnerRepository.cs | 8 + .../IPoliciesSettingsRepository.cs | 8 + .../IPolicySectionRepository.cs | 8 + .../Public/Dtos/PublicAboutSettingsDto.cs | 8 + .../Public/Dtos/PublicGlossaryEntryDto.cs | 7 + .../Public/Dtos/PublicHomepageCountryDto.cs | 9 + .../Public/Dtos/PublicHomepageDto.cs | 12 + .../Public/Dtos/PublicKnowledgePartnerDto.cs | 9 + .../Public/Dtos/PublicPoliciesSettingsDto.cs | 4 + .../Public/Dtos/PublicPolicySectionDto.cs | 8 + .../GetPublicAboutSettingsQuery.cs | 7 + .../GetPublicAboutSettingsQueryHandler.cs | 52 + .../GetPublicHomepageQuery.cs | 7 + .../GetPublicHomepageQueryHandler.cs | 57 + .../GetPublicPoliciesSettingsQuery.cs | 7 + .../GetPublicPoliciesSettingsQueryHandler.cs | 42 + .../GetAboutSettings/GetAboutSettingsQuery.cs | 7 + .../GetAboutSettingsQueryHandler.cs | 55 + .../GetHomepageSettingsQuery.cs | 7 + .../GetHomepageSettingsQueryHandler.cs | 48 + .../GetPoliciesSettingsQuery.cs | 7 + .../GetPoliciesSettingsQueryHandler.cs | 44 + .../PlatformSettings/AboutSettings.cs | 44 + .../PlatformSettings/GlossaryEntry.cs | 75 + .../PlatformSettings/HomepageCountry.cs | 27 + .../PlatformSettings/HomepageSettings.cs | 46 + .../PlatformSettings/KnowledgePartner.cs | 80 + .../PlatformSettings/PoliciesSettings.cs | 16 + .../PlatformSettings/PolicySection.cs | 79 + .../PlatformSettings/PolicySectionType.cs | 10 + .../CCE.Infrastructure/DependencyInjection.cs | 8 + .../Persistence/CceDbContext.cs | 22 + .../AboutSettingsConfiguration.cs | 19 + .../GlossaryEntryConfiguration.cs | 19 + .../HomepageCountryConfiguration.cs | 17 + .../HomepageSettingsConfiguration.cs | 21 + .../KnowledgePartnerConfiguration.cs | 21 + .../PoliciesSettingsConfiguration.cs | 16 + .../PolicySectionConfiguration.cs | 20 + ...0521094531_AddPlatformSettings.Designer.cs | 3155 +++++++++++++++++ .../20260521094531_AddPlatformSettings.cs | 200 ++ .../Migrations/CceDbContextModelSnapshot.cs | 435 +++ .../AboutSettingsRepository.cs | 16 + .../GlossaryEntryRepository.cs | 16 + .../HomepageSettingsRepository.cs | 16 + .../KnowledgePartnerRepository.cs | 16 + .../PoliciesSettingsRepository.cs | 16 + .../PolicySectionRepository.cs | 16 + .../CCE.Seeder/Seeders/ReferenceDataSeeder.cs | 30 + 105 files changed, 6259 insertions(+) create mode 100644 backend/src/CCE.Api.External/Endpoints/AboutSettingsPublicEndpoints.cs create mode 100644 backend/src/CCE.Api.External/Endpoints/HomepageSettingsPublicEndpoints.cs create mode 100644 backend/src/CCE.Api.External/Endpoints/PoliciesSettingsPublicEndpoints.cs create mode 100644 backend/src/CCE.Api.Internal/Endpoints/AboutSettingsEndpoints.cs create mode 100644 backend/src/CCE.Api.Internal/Endpoints/HomepageSettingsEndpoints.cs create mode 100644 backend/src/CCE.Api.Internal/Endpoints/PoliciesSettingsEndpoints.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/Commands/CreateGlossaryEntry/CreateGlossaryEntryCommand.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/Commands/CreateGlossaryEntry/CreateGlossaryEntryCommandHandler.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/Commands/CreateGlossaryEntry/CreateGlossaryEntryCommandValidator.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/Commands/CreateKnowledgePartner/CreateKnowledgePartnerCommand.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/Commands/CreateKnowledgePartner/CreateKnowledgePartnerCommandHandler.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/Commands/CreateKnowledgePartner/CreateKnowledgePartnerCommandValidator.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/Commands/CreatePolicySection/CreatePolicySectionCommand.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/Commands/CreatePolicySection/CreatePolicySectionCommandHandler.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/Commands/CreatePolicySection/CreatePolicySectionCommandValidator.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/Commands/DeleteGlossaryEntry/DeleteGlossaryEntryCommand.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/Commands/DeleteGlossaryEntry/DeleteGlossaryEntryCommandHandler.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/Commands/DeleteKnowledgePartner/DeleteKnowledgePartnerCommand.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/Commands/DeleteKnowledgePartner/DeleteKnowledgePartnerCommandHandler.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/Commands/DeletePolicySection/DeletePolicySectionCommand.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/Commands/DeletePolicySection/DeletePolicySectionCommandHandler.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/Commands/UpdateAboutSettings/UpdateAboutSettingsCommand.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/Commands/UpdateAboutSettings/UpdateAboutSettingsCommandHandler.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/Commands/UpdateAboutSettings/UpdateAboutSettingsCommandValidator.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/Commands/UpdateGlossaryEntry/UpdateGlossaryEntryCommand.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/Commands/UpdateGlossaryEntry/UpdateGlossaryEntryCommandHandler.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/Commands/UpdateGlossaryEntry/UpdateGlossaryEntryCommandValidator.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/Commands/UpdateHomepageSettings/UpdateHomepageSettingsCommand.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/Commands/UpdateHomepageSettings/UpdateHomepageSettingsCommandHandler.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/Commands/UpdateHomepageSettings/UpdateHomepageSettingsCommandValidator.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/Commands/UpdateKnowledgePartner/UpdateKnowledgePartnerCommand.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/Commands/UpdateKnowledgePartner/UpdateKnowledgePartnerCommandHandler.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/Commands/UpdateKnowledgePartner/UpdateKnowledgePartnerCommandValidator.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/Commands/UpdatePoliciesSettings/UpdatePoliciesSettingsCommand.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/Commands/UpdatePoliciesSettings/UpdatePoliciesSettingsCommandHandler.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/Commands/UpdatePoliciesSettings/UpdatePoliciesSettingsCommandValidator.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/Commands/UpdatePolicySection/UpdatePolicySectionCommand.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/Commands/UpdatePolicySection/UpdatePolicySectionCommandHandler.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/Commands/UpdatePolicySection/UpdatePolicySectionCommandValidator.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/Dtos/AboutSettingsDto.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/Dtos/GlossaryEntryDto.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/Dtos/HomepageSettingsDto.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/Dtos/KnowledgePartnerDto.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/Dtos/PoliciesSettingsDto.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/Dtos/PolicySectionDto.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/IAboutSettingsRepository.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/IGlossaryEntryRepository.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/IHomepageSettingsRepository.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/IKnowledgePartnerRepository.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/IPoliciesSettingsRepository.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/IPolicySectionRepository.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/Public/Dtos/PublicAboutSettingsDto.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/Public/Dtos/PublicGlossaryEntryDto.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/Public/Dtos/PublicHomepageCountryDto.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/Public/Dtos/PublicHomepageDto.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/Public/Dtos/PublicKnowledgePartnerDto.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/Public/Dtos/PublicPoliciesSettingsDto.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/Public/Dtos/PublicPolicySectionDto.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/Public/Queries/GetPublicAboutSettings/GetPublicAboutSettingsQuery.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/Public/Queries/GetPublicAboutSettings/GetPublicAboutSettingsQueryHandler.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/Public/Queries/GetPublicHomepage/GetPublicHomepageQuery.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/Public/Queries/GetPublicHomepage/GetPublicHomepageQueryHandler.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/Public/Queries/GetPublicPoliciesSettings/GetPublicPoliciesSettingsQuery.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/Public/Queries/GetPublicPoliciesSettings/GetPublicPoliciesSettingsQueryHandler.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/Queries/GetAboutSettings/GetAboutSettingsQuery.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/Queries/GetAboutSettings/GetAboutSettingsQueryHandler.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/Queries/GetHomepageSettings/GetHomepageSettingsQuery.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/Queries/GetHomepageSettings/GetHomepageSettingsQueryHandler.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/Queries/GetPoliciesSettings/GetPoliciesSettingsQuery.cs create mode 100644 backend/src/CCE.Application/PlatformSettings/Queries/GetPoliciesSettings/GetPoliciesSettingsQueryHandler.cs create mode 100644 backend/src/CCE.Domain/PlatformSettings/AboutSettings.cs create mode 100644 backend/src/CCE.Domain/PlatformSettings/GlossaryEntry.cs create mode 100644 backend/src/CCE.Domain/PlatformSettings/HomepageCountry.cs create mode 100644 backend/src/CCE.Domain/PlatformSettings/HomepageSettings.cs create mode 100644 backend/src/CCE.Domain/PlatformSettings/KnowledgePartner.cs create mode 100644 backend/src/CCE.Domain/PlatformSettings/PoliciesSettings.cs create mode 100644 backend/src/CCE.Domain/PlatformSettings/PolicySection.cs create mode 100644 backend/src/CCE.Domain/PlatformSettings/PolicySectionType.cs create mode 100644 backend/src/CCE.Infrastructure/Persistence/Configurations/PlatformSettings/AboutSettingsConfiguration.cs create mode 100644 backend/src/CCE.Infrastructure/Persistence/Configurations/PlatformSettings/GlossaryEntryConfiguration.cs create mode 100644 backend/src/CCE.Infrastructure/Persistence/Configurations/PlatformSettings/HomepageCountryConfiguration.cs create mode 100644 backend/src/CCE.Infrastructure/Persistence/Configurations/PlatformSettings/HomepageSettingsConfiguration.cs create mode 100644 backend/src/CCE.Infrastructure/Persistence/Configurations/PlatformSettings/KnowledgePartnerConfiguration.cs create mode 100644 backend/src/CCE.Infrastructure/Persistence/Configurations/PlatformSettings/PoliciesSettingsConfiguration.cs create mode 100644 backend/src/CCE.Infrastructure/Persistence/Configurations/PlatformSettings/PolicySectionConfiguration.cs create mode 100644 backend/src/CCE.Infrastructure/Persistence/Migrations/20260521094531_AddPlatformSettings.Designer.cs create mode 100644 backend/src/CCE.Infrastructure/Persistence/Migrations/20260521094531_AddPlatformSettings.cs create mode 100644 backend/src/CCE.Infrastructure/PlatformSettings/AboutSettingsRepository.cs create mode 100644 backend/src/CCE.Infrastructure/PlatformSettings/GlossaryEntryRepository.cs create mode 100644 backend/src/CCE.Infrastructure/PlatformSettings/HomepageSettingsRepository.cs create mode 100644 backend/src/CCE.Infrastructure/PlatformSettings/KnowledgePartnerRepository.cs create mode 100644 backend/src/CCE.Infrastructure/PlatformSettings/PoliciesSettingsRepository.cs create mode 100644 backend/src/CCE.Infrastructure/PlatformSettings/PolicySectionRepository.cs diff --git a/backend/permissions.yaml b/backend/permissions.yaml index 4e29c7ca..b98988af 100644 --- a/backend/permissions.yaml +++ b/backend/permissions.yaml @@ -95,6 +95,9 @@ groups: Edit: description: Edit static pages (about, terms, privacy) roles: [cce-super-admin, cce-admin, cce-content-manager] + PolicyEdit: + description: Edit policies & terms settings (restricted) + roles: [cce-super-admin] Country: Profile: Update: diff --git a/backend/src/CCE.Api.Common/Localization/Resources.yaml b/backend/src/CCE.Api.Common/Localization/Resources.yaml index 31cc3195..eead7f4c 100644 --- a/backend/src/CCE.Api.Common/Localization/Resources.yaml +++ b/backend/src/CCE.Api.Common/Localization/Resources.yaml @@ -347,3 +347,37 @@ SCENARIO_NOT_FOUND: TECHNOLOGY_NOT_FOUND: ar: "التقنية غير موجودة" en: "Technology not found" + +# ─── Platform Settings ─── + +HOMEPAGE_SETTINGS_NOT_FOUND: + ar: "لم يتم العثور على إعدادات الصفحة الرئيسية" + en: "Homepage settings not found" + +ABOUT_SETTINGS_NOT_FOUND: + ar: "لم يتم العثور على إعدادات عن المنصة" + en: "About settings not found" + +POLICIES_SETTINGS_NOT_FOUND: + ar: "لم يتم العثور على إعدادات السياسات" + en: "Policies settings not found" + +GLOSSARY_ENTRY_NOT_FOUND: + ar: "لم يتم العثور على المصطلح" + en: "Glossary entry not found" + +KNOWLEDGE_PARTNER_NOT_FOUND: + ar: "لم يتم العثور على شريك المعرفة" + en: "Knowledge partner not found" + +POLICY_SECTION_NOT_FOUND: + ar: "لم يتم العثور على القسم" + en: "Policy section not found" + +SETTINGS_UPDATED: + ar: "تمت عملية التحديث بنجاح" + en: "Content update success" + +CONTENT_UPDATE_FAILED: + ar: "عذراً، حدثت مشكلة أثناء تحديث المحتوى" + en: "Sorry, a problem occurred while updating the content" diff --git a/backend/src/CCE.Api.External/Endpoints/AboutSettingsPublicEndpoints.cs b/backend/src/CCE.Api.External/Endpoints/AboutSettingsPublicEndpoints.cs new file mode 100644 index 00000000..4b9dadcf --- /dev/null +++ b/backend/src/CCE.Api.External/Endpoints/AboutSettingsPublicEndpoints.cs @@ -0,0 +1,26 @@ +using CCE.Api.Common.Extensions; +using CCE.Application.PlatformSettings.Public.Queries.GetPublicAboutSettings; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace CCE.Api.External.Endpoints; + +public static class AboutSettingsPublicEndpoints +{ + public static IEndpointRouteBuilder MapAboutSettingsPublicEndpoints(this IEndpointRouteBuilder app) + { + var about = app.MapGroup("/api/about").WithTags("About"); + + about.MapGet("", async (IMediator mediator, CancellationToken ct) => + { + var result = await mediator.Send(new GetPublicAboutSettingsQuery(), ct).ConfigureAwait(false); + return result.ToHttpResult(); + }) + .AllowAnonymous() + .WithName("GetPublicAboutSettings"); + + return app; + } +} diff --git a/backend/src/CCE.Api.External/Endpoints/HomepageSettingsPublicEndpoints.cs b/backend/src/CCE.Api.External/Endpoints/HomepageSettingsPublicEndpoints.cs new file mode 100644 index 00000000..6132426d --- /dev/null +++ b/backend/src/CCE.Api.External/Endpoints/HomepageSettingsPublicEndpoints.cs @@ -0,0 +1,26 @@ +using CCE.Api.Common.Extensions; +using CCE.Application.PlatformSettings.Public.Queries.GetPublicHomepage; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace CCE.Api.External.Endpoints; + +public static class HomepageSettingsPublicEndpoints +{ + public static IEndpointRouteBuilder MapHomepageSettingsPublicEndpoints(this IEndpointRouteBuilder app) + { + var homepage = app.MapGroup("/api/homepage").WithTags("Homepage"); + + homepage.MapGet("", async (IMediator mediator, CancellationToken ct) => + { + var result = await mediator.Send(new GetPublicHomepageQuery(), ct).ConfigureAwait(false); + return result.ToHttpResult(); + }) + .AllowAnonymous() + .WithName("GetPublicHomepage"); + + return app; + } +} diff --git a/backend/src/CCE.Api.External/Endpoints/PoliciesSettingsPublicEndpoints.cs b/backend/src/CCE.Api.External/Endpoints/PoliciesSettingsPublicEndpoints.cs new file mode 100644 index 00000000..c04d8763 --- /dev/null +++ b/backend/src/CCE.Api.External/Endpoints/PoliciesSettingsPublicEndpoints.cs @@ -0,0 +1,26 @@ +using CCE.Api.Common.Extensions; +using CCE.Application.PlatformSettings.Public.Queries.GetPublicPoliciesSettings; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace CCE.Api.External.Endpoints; + +public static class PoliciesSettingsPublicEndpoints +{ + public static IEndpointRouteBuilder MapPoliciesSettingsPublicEndpoints(this IEndpointRouteBuilder app) + { + var policies = app.MapGroup("/api/policies").WithTags("Policies"); + + policies.MapGet("", async (IMediator mediator, CancellationToken ct) => + { + var result = await mediator.Send(new GetPublicPoliciesSettingsQuery(), ct).ConfigureAwait(false); + return result.ToHttpResult(); + }) + .AllowAnonymous() + .WithName("GetPublicPoliciesSettings"); + + return app; + } +} diff --git a/backend/src/CCE.Api.External/Program.cs b/backend/src/CCE.Api.External/Program.cs index f2439ee3..7e461324 100644 --- a/backend/src/CCE.Api.External/Program.cs +++ b/backend/src/CCE.Api.External/Program.cs @@ -103,6 +103,9 @@ app.MapAssistantEndpoints(); app.MapKapsarcEndpoints(); app.MapSurveysEndpoints(); +app.MapHomepageSettingsPublicEndpoints(); +app.MapAboutSettingsPublicEndpoints(); +app.MapPoliciesSettingsPublicEndpoints(); app.MapGet("/health", async (IMediator mediator) => { diff --git a/backend/src/CCE.Api.Internal/Endpoints/AboutSettingsEndpoints.cs b/backend/src/CCE.Api.Internal/Endpoints/AboutSettingsEndpoints.cs new file mode 100644 index 00000000..81246de5 --- /dev/null +++ b/backend/src/CCE.Api.Internal/Endpoints/AboutSettingsEndpoints.cs @@ -0,0 +1,153 @@ +using CCE.Api.Common.Extensions; +using CCE.Application.Common; +using CCE.Application.PlatformSettings.Commands.CreateGlossaryEntry; +using CCE.Application.PlatformSettings.Commands.CreateKnowledgePartner; +using CCE.Application.PlatformSettings.Commands.DeleteGlossaryEntry; +using CCE.Application.PlatformSettings.Commands.DeleteKnowledgePartner; +using CCE.Application.PlatformSettings.Commands.UpdateAboutSettings; +using CCE.Application.PlatformSettings.Commands.UpdateGlossaryEntry; +using CCE.Application.PlatformSettings.Commands.UpdateKnowledgePartner; +using CCE.Application.PlatformSettings.Queries.GetAboutSettings; +using CCE.Domain; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace CCE.Api.Internal.Endpoints; + +public static class AboutSettingsEndpoints +{ + public static IEndpointRouteBuilder MapAboutSettingsEndpoints(this IEndpointRouteBuilder app) + { + var about = app.MapGroup("/api/admin/settings/about").WithTags("PlatformSettings"); + + about.MapGet("", async (IMediator mediator, CancellationToken ct) => + { + var result = await mediator.Send(new GetAboutSettingsQuery(), ct).ConfigureAwait(false); + return result.ToHttpResult(); + }) + .RequireAuthorization(Permissions.Page_Edit) + .WithName("GetAboutSettings"); + + about.MapPut("", async (UpdateAboutSettingsRequest body, IMediator mediator, CancellationToken ct) => + { + var rowVersion = string.IsNullOrEmpty(body.RowVersion) + ? System.Array.Empty() + : System.Convert.FromBase64String(body.RowVersion); + var cmd = new UpdateAboutSettingsCommand( + body.DescriptionAr, body.DescriptionEn, + body.HowToUseVideoUrl, rowVersion); + var result = await mediator.Send(cmd, ct).ConfigureAwait(false); + return result.ToHttpResult(); + }) + .RequireAuthorization(Permissions.Page_Edit) + .WithName("UpdateAboutSettings"); + + about.MapPost("/glossary", async (CreateGlossaryEntryRequest body, IMediator mediator, CancellationToken ct) => + { + var cmd = new CreateGlossaryEntryCommand( + body.TermAr, body.TermEn, body.DefinitionAr, body.DefinitionEn); + var result = await mediator.Send(cmd, ct).ConfigureAwait(false); + return result.ToCreatedHttpResult(); + }) + .RequireAuthorization(Permissions.Page_Edit) + .WithName("CreateGlossaryEntry"); + + about.MapPut("/glossary/{id:guid}", async ( + System.Guid id, + UpdateGlossaryEntryRequest body, + IMediator mediator, CancellationToken ct) => + { + var cmd = new UpdateGlossaryEntryCommand( + id, body.TermAr, body.TermEn, body.DefinitionAr, body.DefinitionEn); + var result = await mediator.Send(cmd, ct).ConfigureAwait(false); + return result.ToHttpResult(); + }) + .RequireAuthorization(Permissions.Page_Edit) + .WithName("UpdateGlossaryEntry"); + + about.MapDelete("/glossary/{id:guid}", async ( + System.Guid id, + IMediator mediator, CancellationToken ct) => + { + var result = await mediator.Send(new DeleteGlossaryEntryCommand(id), ct).ConfigureAwait(false); + return result.ToNoContentHttpResult(); + }) + .RequireAuthorization(Permissions.Page_Edit) + .WithName("DeleteGlossaryEntry"); + + about.MapPost("/knowledge-partners", async ( + CreateKnowledgePartnerRequest body, + IMediator mediator, CancellationToken ct) => + { + var cmd = new CreateKnowledgePartnerCommand( + body.NameAr, body.NameEn, body.LogoUrl, body.WebsiteUrl, + body.DescriptionAr, body.DescriptionEn); + var result = await mediator.Send(cmd, ct).ConfigureAwait(false); + return result.ToCreatedHttpResult(); + }) + .RequireAuthorization(Permissions.Page_Edit) + .WithName("CreateKnowledgePartner"); + + about.MapPut("/knowledge-partners/{id:guid}", async ( + System.Guid id, + UpdateKnowledgePartnerRequest body, + IMediator mediator, CancellationToken ct) => + { + var cmd = new UpdateKnowledgePartnerCommand( + id, body.NameAr, body.NameEn, body.LogoUrl, body.WebsiteUrl, + body.DescriptionAr, body.DescriptionEn); + var result = await mediator.Send(cmd, ct).ConfigureAwait(false); + return result.ToHttpResult(); + }) + .RequireAuthorization(Permissions.Page_Edit) + .WithName("UpdateKnowledgePartner"); + + about.MapDelete("/knowledge-partners/{id:guid}", async ( + System.Guid id, + IMediator mediator, CancellationToken ct) => + { + var result = await mediator.Send(new DeleteKnowledgePartnerCommand(id), ct).ConfigureAwait(false); + return result.ToNoContentHttpResult(); + }) + .RequireAuthorization(Permissions.Page_Edit) + .WithName("DeleteKnowledgePartner"); + + return app; + } +} + +public sealed record UpdateAboutSettingsRequest( + string DescriptionAr, + string DescriptionEn, + string? HowToUseVideoUrl, + string RowVersion); + +public sealed record CreateGlossaryEntryRequest( + string TermAr, + string TermEn, + string DefinitionAr, + string DefinitionEn); + +public sealed record UpdateGlossaryEntryRequest( + string TermAr, + string TermEn, + string DefinitionAr, + string DefinitionEn); + +public sealed record CreateKnowledgePartnerRequest( + string NameAr, + string NameEn, + string? LogoUrl, + string? WebsiteUrl, + string? DescriptionAr, + string? DescriptionEn); + +public sealed record UpdateKnowledgePartnerRequest( + string NameAr, + string NameEn, + string? LogoUrl, + string? WebsiteUrl, + string? DescriptionAr, + string? DescriptionEn); diff --git a/backend/src/CCE.Api.Internal/Endpoints/HomepageSettingsEndpoints.cs b/backend/src/CCE.Api.Internal/Endpoints/HomepageSettingsEndpoints.cs new file mode 100644 index 00000000..fa4d4569 --- /dev/null +++ b/backend/src/CCE.Api.Internal/Endpoints/HomepageSettingsEndpoints.cs @@ -0,0 +1,57 @@ +using CCE.Api.Common.Extensions; +using CCE.Application.Common; +using CCE.Application.PlatformSettings.Commands.UpdateHomepageSettings; +using CCE.Application.PlatformSettings.Queries.GetHomepageSettings; +using CCE.Domain; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace CCE.Api.Internal.Endpoints; + +public static class HomepageSettingsEndpoints +{ + public static IEndpointRouteBuilder MapHomepageSettingsEndpoints(this IEndpointRouteBuilder app) + { + var settings = app.MapGroup("/api/admin/settings/homepage").WithTags("PlatformSettings"); + + settings.MapGet("", async (IMediator mediator, CancellationToken ct) => + { + var result = await mediator.Send(new GetHomepageSettingsQuery(), ct).ConfigureAwait(false); + return result.ToHttpResult(); + }) + .RequireAuthorization(Permissions.Page_Edit) + .WithName("GetHomepageSettings"); + + settings.MapPut("", async (UpdateHomepageSettingsRequest body, IMediator mediator, CancellationToken ct) => + { + var rowVersion = string.IsNullOrEmpty(body.RowVersion) + ? System.Array.Empty() + : System.Convert.FromBase64String(body.RowVersion); + var cmd = new UpdateHomepageSettingsCommand( + body.VideoUrl, + body.ObjectiveAr, + body.ObjectiveEn, + body.CceConceptsAr, + body.CceConceptsEn, + body.ParticipatingCountryIds, + rowVersion); + var result = await mediator.Send(cmd, ct).ConfigureAwait(false); + return result.ToHttpResult(); + }) + .RequireAuthorization(Permissions.Page_Edit) + .WithName("UpdateHomepageSettings"); + + return app; + } +} + +public sealed record UpdateHomepageSettingsRequest( + string? VideoUrl, + string ObjectiveAr, + string ObjectiveEn, + string CceConceptsAr, + string CceConceptsEn, + System.Collections.Generic.IReadOnlyList ParticipatingCountryIds, + string RowVersion); diff --git a/backend/src/CCE.Api.Internal/Endpoints/PoliciesSettingsEndpoints.cs b/backend/src/CCE.Api.Internal/Endpoints/PoliciesSettingsEndpoints.cs new file mode 100644 index 00000000..60fa7a13 --- /dev/null +++ b/backend/src/CCE.Api.Internal/Endpoints/PoliciesSettingsEndpoints.cs @@ -0,0 +1,95 @@ +using CCE.Api.Common.Extensions; +using CCE.Application.Common; +using CCE.Application.PlatformSettings.Commands.CreatePolicySection; +using CCE.Application.PlatformSettings.Commands.DeletePolicySection; +using CCE.Application.PlatformSettings.Commands.UpdatePoliciesSettings; +using CCE.Application.PlatformSettings.Commands.UpdatePolicySection; +using CCE.Application.PlatformSettings.Queries.GetPoliciesSettings; +using CCE.Domain; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace CCE.Api.Internal.Endpoints; + +public static class PoliciesSettingsEndpoints +{ + public static IEndpointRouteBuilder MapPoliciesSettingsEndpoints(this IEndpointRouteBuilder app) + { + var policies = app.MapGroup("/api/admin/settings/policies").WithTags("PlatformSettings"); + + policies.MapGet("", async (IMediator mediator, CancellationToken ct) => + { + var result = await mediator.Send(new GetPoliciesSettingsQuery(), ct).ConfigureAwait(false); + return result.ToHttpResult(); + }) + .RequireAuthorization(Permissions.Page_PolicyEdit) + .WithName("GetPoliciesSettings"); + + policies.MapPut("", async (UpdatePoliciesSettingsRequest body, IMediator mediator, CancellationToken ct) => + { + var rowVersion = string.IsNullOrEmpty(body.RowVersion) + ? System.Array.Empty() + : System.Convert.FromBase64String(body.RowVersion); + var cmd = new UpdatePoliciesSettingsCommand(rowVersion); + var result = await mediator.Send(cmd, ct).ConfigureAwait(false); + return result.ToHttpResult(); + }) + .RequireAuthorization(Permissions.Page_PolicyEdit) + .WithName("UpdatePoliciesSettings"); + + policies.MapPost("/sections", async ( + CreatePolicySectionRequest body, + IMediator mediator, CancellationToken ct) => + { + var cmd = new CreatePolicySectionCommand( + body.Type, body.TitleAr, body.TitleEn, body.ContentAr, body.ContentEn); + var result = await mediator.Send(cmd, ct).ConfigureAwait(false); + return result.ToCreatedHttpResult(); + }) + .RequireAuthorization(Permissions.Page_PolicyEdit) + .WithName("CreatePolicySection"); + + policies.MapPut("/sections/{id:guid}", async ( + System.Guid id, + UpdatePolicySectionRequest body, + IMediator mediator, CancellationToken ct) => + { + var cmd = new UpdatePolicySectionCommand( + id, body.TitleAr, body.TitleEn, body.ContentAr, body.ContentEn); + var result = await mediator.Send(cmd, ct).ConfigureAwait(false); + return result.ToHttpResult(); + }) + .RequireAuthorization(Permissions.Page_PolicyEdit) + .WithName("UpdatePolicySection"); + + policies.MapDelete("/sections/{id:guid}", async ( + System.Guid id, + IMediator mediator, CancellationToken ct) => + { + var result = await mediator.Send(new DeletePolicySectionCommand(id), ct).ConfigureAwait(false); + return result.ToNoContentHttpResult(); + }) + .RequireAuthorization(Permissions.Page_PolicyEdit) + .WithName("DeletePolicySection"); + + return app; + } +} + +public sealed record UpdatePoliciesSettingsRequest( + string RowVersion); + +public sealed record CreatePolicySectionRequest( + int Type, + string TitleAr, + string TitleEn, + string ContentAr, + string ContentEn); + +public sealed record UpdatePolicySectionRequest( + string TitleAr, + string TitleEn, + string ContentAr, + string ContentEn); diff --git a/backend/src/CCE.Api.Internal/Program.cs b/backend/src/CCE.Api.Internal/Program.cs index 073a5518..931288f6 100644 --- a/backend/src/CCE.Api.Internal/Program.cs +++ b/backend/src/CCE.Api.Internal/Program.cs @@ -78,6 +78,9 @@ app.MapNotificationTemplateEndpoints(); app.MapReportEndpoints(); app.MapAuditEndpoints(); +app.MapHomepageSettingsEndpoints(); +app.MapAboutSettingsEndpoints(); +app.MapPoliciesSettingsEndpoints(); // Sub-11d follow-up — dev sign-in shim. Mounts /dev/sign-in, // /dev/sign-out, /dev/whoami when Auth:DevMode=true. Production diff --git a/backend/src/CCE.Application/Common/Interfaces/ICceDbContext.cs b/backend/src/CCE.Application/Common/Interfaces/ICceDbContext.cs index 08baabcf..c924dd65 100644 --- a/backend/src/CCE.Application/Common/Interfaces/ICceDbContext.cs +++ b/backend/src/CCE.Application/Common/Interfaces/ICceDbContext.cs @@ -6,6 +6,7 @@ using CCE.Domain.InteractiveCity; using CCE.Domain.KnowledgeMaps; using CCE.Domain.Notifications; +using CCE.Domain.PlatformSettings; using CCE.Domain.Surveys; using Microsoft.AspNetCore.Identity; using DomainCountry = CCE.Domain.Country; @@ -58,6 +59,18 @@ public interface ICceDbContext IQueryable CityScenarios { get; } IQueryable CityTechnologies { get; } IQueryable CityScenarioResults { get; } + IQueryable HomepageSettings { get; } + IQueryable HomepageCountries { get; } + IQueryable AboutSettings { get; } + IQueryable GlossaryEntries { get; } + IQueryable PoliciesSettings { get; } + IQueryable KnowledgePartners { get; } + IQueryable PolicySections { get; } + + // Write operations + void Add(T entity) where T : class; + void Delete(T entity) where T : class; + void DeleteRange(System.Collections.Generic.IEnumerable entities) where T : class; Task SaveChangesAsync(CancellationToken cancellationToken = default); } diff --git a/backend/src/CCE.Application/Messages/MessageFactory.cs b/backend/src/CCE.Application/Messages/MessageFactory.cs index 6ba4868a..6c000498 100644 --- a/backend/src/CCE.Application/Messages/MessageFactory.cs +++ b/backend/src/CCE.Application/Messages/MessageFactory.cs @@ -79,6 +79,16 @@ public FieldError Field(string fieldName, string domainKey) public Response PageNotFound() => NotFound("PAGE_NOT_FOUND"); public Response CategoryNotFound() => NotFound("CATEGORY_NOT_FOUND"); + // ─── Convenience shortcuts (Platform Settings domain) ─── + + public Response HomepageSettingsNotFound() => NotFound("HOMEPAGE_SETTINGS_NOT_FOUND"); + public Response AboutSettingsNotFound() => NotFound("ABOUT_SETTINGS_NOT_FOUND"); + public Response PoliciesSettingsNotFound() => NotFound("POLICIES_SETTINGS_NOT_FOUND"); + public Response GlossaryEntryNotFound() => NotFound("GLOSSARY_ENTRY_NOT_FOUND"); + public Response KnowledgePartnerNotFound() => NotFound("KNOWLEDGE_PARTNER_NOT_FOUND"); + public Response PolicySectionNotFound() => NotFound("POLICY_SECTION_NOT_FOUND"); + public Response ContentUpdateFailed() => BusinessRule("CONTENT_UPDATE_FAILED"); + // ─── Private ─── private Response Fail(string domainKey, MessageType type) diff --git a/backend/src/CCE.Application/Messages/SystemCode.cs b/backend/src/CCE.Application/Messages/SystemCode.cs index 3adbc6ae..a8c41490 100644 --- a/backend/src/CCE.Application/Messages/SystemCode.cs +++ b/backend/src/CCE.Application/Messages/SystemCode.cs @@ -91,6 +91,14 @@ public static class SystemCode public const string ERR100 = "ERR100"; // Scenario not found public const string ERR101 = "ERR101"; // Technology not found + // ─── Platform Settings Errors ─── + public const string ERR053 = "ERR053"; // Homepage settings not found + public const string ERR054 = "ERR054"; // About settings not found + public const string ERR055 = "ERR055"; // Policies settings not found + public const string ERR056 = "ERR056"; // Glossary entry not found + public const string ERR057 = "ERR057"; // Knowledge partner not found + public const string ERR058 = "ERR058"; // Policy section not found + // ─── General Errors ─── public const string ERR900 = "ERR900"; // Internal server error public const string ERR901 = "ERR901"; // Unauthorized access diff --git a/backend/src/CCE.Application/Messages/SystemCodeMap.cs b/backend/src/CCE.Application/Messages/SystemCodeMap.cs index 206dcfca..8ed204c8 100644 --- a/backend/src/CCE.Application/Messages/SystemCodeMap.cs +++ b/backend/src/CCE.Application/Messages/SystemCodeMap.cs @@ -71,6 +71,14 @@ public static class SystemCodeMap ["SCENARIO_NOT_FOUND"] = SystemCode.ERR100, ["TECHNOLOGY_NOT_FOUND"] = SystemCode.ERR101, + // ─── Platform Settings Errors ─── + ["HOMEPAGE_SETTINGS_NOT_FOUND"] = SystemCode.ERR053, + ["ABOUT_SETTINGS_NOT_FOUND"] = SystemCode.ERR054, + ["POLICIES_SETTINGS_NOT_FOUND"] = SystemCode.ERR055, + ["GLOSSARY_ENTRY_NOT_FOUND"] = SystemCode.ERR056, + ["KNOWLEDGE_PARTNER_NOT_FOUND"] = SystemCode.ERR057, + ["POLICY_SECTION_NOT_FOUND"] = SystemCode.ERR058, + // ─── General Errors ─── ["INTERNAL_ERROR"] = SystemCode.ERR900, ["UNAUTHORIZED_ACCESS"] = SystemCode.ERR901, @@ -100,6 +108,10 @@ public static class SystemCodeMap ["ROLES_ASSIGNED"] = SystemCode.CON054, ["USER_STATUS_CHANGED"] = SystemCode.CON055, + // ─── Platform Settings Success ─── + ["SETTINGS_UPDATED"] = SystemCode.CON016, + ["CONTENT_UPDATE_FAILED"] = SystemCode.ERR025, + // ─── Content Success ─── ["CONTENT_CREATED"] = SystemCode.CON020, ["CONTENT_UPDATED"] = SystemCode.CON021, diff --git a/backend/src/CCE.Application/PlatformSettings/Commands/CreateGlossaryEntry/CreateGlossaryEntryCommand.cs b/backend/src/CCE.Application/PlatformSettings/Commands/CreateGlossaryEntry/CreateGlossaryEntryCommand.cs new file mode 100644 index 00000000..275990d4 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Commands/CreateGlossaryEntry/CreateGlossaryEntryCommand.cs @@ -0,0 +1,11 @@ +using CCE.Application.Common; +using CCE.Application.PlatformSettings.Dtos; +using MediatR; + +namespace CCE.Application.PlatformSettings.Commands.CreateGlossaryEntry; + +public sealed record CreateGlossaryEntryCommand( + string TermAr, + string TermEn, + string DefinitionAr, + string DefinitionEn) : IRequest>; diff --git a/backend/src/CCE.Application/PlatformSettings/Commands/CreateGlossaryEntry/CreateGlossaryEntryCommandHandler.cs b/backend/src/CCE.Application/PlatformSettings/Commands/CreateGlossaryEntry/CreateGlossaryEntryCommandHandler.cs new file mode 100644 index 00000000..75ce96f6 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Commands/CreateGlossaryEntry/CreateGlossaryEntryCommandHandler.cs @@ -0,0 +1,51 @@ +using CCE.Application.Common; +using CCE.Application.Common.Interfaces; +using CCE.Application.Common.Pagination; +using CCE.Application.Messages; +using CCE.Application.PlatformSettings.Dtos; +using CCE.Domain.PlatformSettings; +using MediatR; + +namespace CCE.Application.PlatformSettings.Commands.CreateGlossaryEntry; + +public sealed class CreateGlossaryEntryCommandHandler + : IRequestHandler> +{ + private readonly IAboutSettingsRepository _aboutRepo; + private readonly ICceDbContext _db; + private readonly MessageFactory _msg; + + public CreateGlossaryEntryCommandHandler( + IAboutSettingsRepository aboutRepo, ICceDbContext db, MessageFactory msg) + { + _aboutRepo = aboutRepo; + _db = db; + _msg = msg; + } + + public async Task> Handle( + CreateGlossaryEntryCommand request, CancellationToken cancellationToken) + { + var about = await _aboutRepo.GetAsync(cancellationToken).ConfigureAwait(false); + if (about is null) + return _msg.AboutSettingsNotFound(); + + var maxOrder = await _db.GlossaryEntries + .Where(e => e.AboutSettingsId == about.Id) + .Select(e => (int?)e.OrderIndex) + .ToListAsyncEither(cancellationToken) + .ConfigureAwait(false); + var nextOrder = (maxOrder.FirstOrDefault() ?? -1) + 1; + + var entry = GlossaryEntry.Create( + about.Id, request.TermAr, request.TermEn, + request.DefinitionAr, request.DefinitionEn, nextOrder); + + _db.Add(entry); + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + return _msg.Ok(new GlossaryEntryDto( + entry.Id, entry.TermAr, entry.TermEn, + entry.DefinitionAr, entry.DefinitionEn, entry.OrderIndex), "CONTENT_CREATED"); + } +} diff --git a/backend/src/CCE.Application/PlatformSettings/Commands/CreateGlossaryEntry/CreateGlossaryEntryCommandValidator.cs b/backend/src/CCE.Application/PlatformSettings/Commands/CreateGlossaryEntry/CreateGlossaryEntryCommandValidator.cs new file mode 100644 index 00000000..8a5cca55 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Commands/CreateGlossaryEntry/CreateGlossaryEntryCommandValidator.cs @@ -0,0 +1,15 @@ +using FluentValidation; + +namespace CCE.Application.PlatformSettings.Commands.CreateGlossaryEntry; + +public sealed class CreateGlossaryEntryCommandValidator + : AbstractValidator +{ + public CreateGlossaryEntryCommandValidator() + { + RuleFor(x => x.TermAr).NotEmpty().MaximumLength(100); + RuleFor(x => x.TermEn).NotEmpty().MaximumLength(100); + RuleFor(x => x.DefinitionAr).NotEmpty().MaximumLength(1000); + RuleFor(x => x.DefinitionEn).NotEmpty().MaximumLength(1000); + } +} diff --git a/backend/src/CCE.Application/PlatformSettings/Commands/CreateKnowledgePartner/CreateKnowledgePartnerCommand.cs b/backend/src/CCE.Application/PlatformSettings/Commands/CreateKnowledgePartner/CreateKnowledgePartnerCommand.cs new file mode 100644 index 00000000..aae6cf28 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Commands/CreateKnowledgePartner/CreateKnowledgePartnerCommand.cs @@ -0,0 +1,13 @@ +using CCE.Application.Common; +using CCE.Application.PlatformSettings.Dtos; +using MediatR; + +namespace CCE.Application.PlatformSettings.Commands.CreateKnowledgePartner; + +public sealed record CreateKnowledgePartnerCommand( + string NameAr, + string NameEn, + string? LogoUrl, + string? WebsiteUrl, + string? DescriptionAr, + string? DescriptionEn) : IRequest>; diff --git a/backend/src/CCE.Application/PlatformSettings/Commands/CreateKnowledgePartner/CreateKnowledgePartnerCommandHandler.cs b/backend/src/CCE.Application/PlatformSettings/Commands/CreateKnowledgePartner/CreateKnowledgePartnerCommandHandler.cs new file mode 100644 index 00000000..22448117 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Commands/CreateKnowledgePartner/CreateKnowledgePartnerCommandHandler.cs @@ -0,0 +1,52 @@ +using CCE.Application.Common; +using CCE.Application.Common.Interfaces; +using CCE.Application.Common.Pagination; +using CCE.Application.Messages; +using CCE.Application.PlatformSettings.Dtos; +using CCE.Domain.PlatformSettings; +using MediatR; + +namespace CCE.Application.PlatformSettings.Commands.CreateKnowledgePartner; + +public sealed class CreateKnowledgePartnerCommandHandler + : IRequestHandler> +{ + private readonly IAboutSettingsRepository _aboutRepo; + private readonly ICceDbContext _db; + private readonly MessageFactory _msg; + + public CreateKnowledgePartnerCommandHandler( + IAboutSettingsRepository aboutRepo, ICceDbContext db, MessageFactory msg) + { + _aboutRepo = aboutRepo; + _db = db; + _msg = msg; + } + + public async Task> Handle( + CreateKnowledgePartnerCommand request, CancellationToken cancellationToken) + { + var about = await _aboutRepo.GetAsync(cancellationToken).ConfigureAwait(false); + if (about is null) + return _msg.AboutSettingsNotFound(); + + var maxOrder = await _db.KnowledgePartners + .Where(p => p.AboutSettingsId == about.Id) + .Select(p => (int?)p.OrderIndex) + .ToListAsyncEither(cancellationToken) + .ConfigureAwait(false); + var nextOrder = (maxOrder.FirstOrDefault() ?? -1) + 1; + + var partner = KnowledgePartner.Create( + about.Id, request.NameAr, request.NameEn, + request.LogoUrl, request.WebsiteUrl, + request.DescriptionAr, request.DescriptionEn, nextOrder); + + _db.Add(partner); + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + return _msg.Ok(new KnowledgePartnerDto( + partner.Id, partner.NameAr, partner.NameEn, partner.LogoUrl, partner.WebsiteUrl, + partner.DescriptionAr, partner.DescriptionEn, partner.OrderIndex), "CONTENT_CREATED"); + } +} diff --git a/backend/src/CCE.Application/PlatformSettings/Commands/CreateKnowledgePartner/CreateKnowledgePartnerCommandValidator.cs b/backend/src/CCE.Application/PlatformSettings/Commands/CreateKnowledgePartner/CreateKnowledgePartnerCommandValidator.cs new file mode 100644 index 00000000..cc584595 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Commands/CreateKnowledgePartner/CreateKnowledgePartnerCommandValidator.cs @@ -0,0 +1,15 @@ +using FluentValidation; + +namespace CCE.Application.PlatformSettings.Commands.CreateKnowledgePartner; + +public sealed class CreateKnowledgePartnerCommandValidator + : AbstractValidator +{ + public CreateKnowledgePartnerCommandValidator() + { + RuleFor(x => x.NameAr).NotEmpty().MaximumLength(200); + RuleFor(x => x.NameEn).NotEmpty().MaximumLength(200); + RuleFor(x => x.DescriptionAr).MaximumLength(1000); + RuleFor(x => x.DescriptionEn).MaximumLength(1000); + } +} diff --git a/backend/src/CCE.Application/PlatformSettings/Commands/CreatePolicySection/CreatePolicySectionCommand.cs b/backend/src/CCE.Application/PlatformSettings/Commands/CreatePolicySection/CreatePolicySectionCommand.cs new file mode 100644 index 00000000..5b059d50 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Commands/CreatePolicySection/CreatePolicySectionCommand.cs @@ -0,0 +1,12 @@ +using CCE.Application.Common; +using CCE.Application.PlatformSettings.Dtos; +using MediatR; + +namespace CCE.Application.PlatformSettings.Commands.CreatePolicySection; + +public sealed record CreatePolicySectionCommand( + int Type, + string TitleAr, + string TitleEn, + string ContentAr, + string ContentEn) : IRequest>; diff --git a/backend/src/CCE.Application/PlatformSettings/Commands/CreatePolicySection/CreatePolicySectionCommandHandler.cs b/backend/src/CCE.Application/PlatformSettings/Commands/CreatePolicySection/CreatePolicySectionCommandHandler.cs new file mode 100644 index 00000000..9959e7fc --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Commands/CreatePolicySection/CreatePolicySectionCommandHandler.cs @@ -0,0 +1,53 @@ +using CCE.Application.Common; +using CCE.Application.Common.Interfaces; +using CCE.Application.Common.Pagination; +using CCE.Application.Messages; +using CCE.Application.PlatformSettings.Dtos; +using CCE.Domain.PlatformSettings; +using MediatR; + +namespace CCE.Application.PlatformSettings.Commands.CreatePolicySection; + +public sealed class CreatePolicySectionCommandHandler + : IRequestHandler> +{ + private readonly IPoliciesSettingsRepository _policiesRepo; + private readonly ICceDbContext _db; + private readonly MessageFactory _msg; + + public CreatePolicySectionCommandHandler( + IPoliciesSettingsRepository policiesRepo, ICceDbContext db, MessageFactory msg) + { + _policiesRepo = policiesRepo; + _db = db; + _msg = msg; + } + + public async Task> Handle( + CreatePolicySectionCommand request, CancellationToken cancellationToken) + { + var settings = await _policiesRepo.GetAsync(cancellationToken).ConfigureAwait(false); + if (settings is null) + return _msg.PoliciesSettingsNotFound(); + + var type = (PolicySectionType)request.Type; + + var maxOrder = await _db.PolicySections + .Where(s => s.PoliciesSettingsId == settings.Id) + .Select(s => (int?)s.OrderIndex) + .ToListAsyncEither(cancellationToken) + .ConfigureAwait(false); + var nextOrder = (maxOrder.FirstOrDefault() ?? -1) + 1; + + var section = PolicySection.Create( + settings.Id, type, request.TitleAr, request.TitleEn, + request.ContentAr, request.ContentEn, nextOrder); + + _db.Add(section); + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + return _msg.Ok(new PolicySectionDto( + section.Id, (int)section.Type, section.TitleAr, section.TitleEn, + section.ContentAr, section.ContentEn, section.OrderIndex), "CONTENT_CREATED"); + } +} diff --git a/backend/src/CCE.Application/PlatformSettings/Commands/CreatePolicySection/CreatePolicySectionCommandValidator.cs b/backend/src/CCE.Application/PlatformSettings/Commands/CreatePolicySection/CreatePolicySectionCommandValidator.cs new file mode 100644 index 00000000..f44fd2b0 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Commands/CreatePolicySection/CreatePolicySectionCommandValidator.cs @@ -0,0 +1,15 @@ +using FluentValidation; + +namespace CCE.Application.PlatformSettings.Commands.CreatePolicySection; + +public sealed class CreatePolicySectionCommandValidator + : AbstractValidator +{ + public CreatePolicySectionCommandValidator() + { + RuleFor(x => x.TitleAr).NotEmpty().MaximumLength(500); + RuleFor(x => x.TitleEn).NotEmpty().MaximumLength(500); + RuleFor(x => x.ContentAr).NotEmpty(); + RuleFor(x => x.ContentEn).NotEmpty(); + } +} diff --git a/backend/src/CCE.Application/PlatformSettings/Commands/DeleteGlossaryEntry/DeleteGlossaryEntryCommand.cs b/backend/src/CCE.Application/PlatformSettings/Commands/DeleteGlossaryEntry/DeleteGlossaryEntryCommand.cs new file mode 100644 index 00000000..a15659af --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Commands/DeleteGlossaryEntry/DeleteGlossaryEntryCommand.cs @@ -0,0 +1,6 @@ +using CCE.Application.Common; +using MediatR; + +namespace CCE.Application.PlatformSettings.Commands.DeleteGlossaryEntry; + +public sealed record DeleteGlossaryEntryCommand(System.Guid Id) : IRequest>; diff --git a/backend/src/CCE.Application/PlatformSettings/Commands/DeleteGlossaryEntry/DeleteGlossaryEntryCommandHandler.cs b/backend/src/CCE.Application/PlatformSettings/Commands/DeleteGlossaryEntry/DeleteGlossaryEntryCommandHandler.cs new file mode 100644 index 00000000..821699c3 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Commands/DeleteGlossaryEntry/DeleteGlossaryEntryCommandHandler.cs @@ -0,0 +1,36 @@ +using CCE.Application.Common; +using CCE.Application.Common.Interfaces; +using CCE.Application.Messages; +using CCE.Domain.PlatformSettings; +using MediatR; + +namespace CCE.Application.PlatformSettings.Commands.DeleteGlossaryEntry; + +public sealed class DeleteGlossaryEntryCommandHandler + : IRequestHandler> +{ + private readonly IGlossaryEntryRepository _repo; + private readonly ICceDbContext _db; + private readonly MessageFactory _msg; + + public DeleteGlossaryEntryCommandHandler( + IGlossaryEntryRepository repo, ICceDbContext db, MessageFactory msg) + { + _repo = repo; + _db = db; + _msg = msg; + } + + public async Task> Handle( + DeleteGlossaryEntryCommand request, CancellationToken cancellationToken) + { + var entry = await _repo.FindAsync(request.Id, cancellationToken).ConfigureAwait(false); + if (entry is null) + return _msg.GlossaryEntryNotFound(); + + _db.Delete(entry); + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + return _msg.Ok("CONTENT_DELETED"); + } +} diff --git a/backend/src/CCE.Application/PlatformSettings/Commands/DeleteKnowledgePartner/DeleteKnowledgePartnerCommand.cs b/backend/src/CCE.Application/PlatformSettings/Commands/DeleteKnowledgePartner/DeleteKnowledgePartnerCommand.cs new file mode 100644 index 00000000..04047c3e --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Commands/DeleteKnowledgePartner/DeleteKnowledgePartnerCommand.cs @@ -0,0 +1,6 @@ +using CCE.Application.Common; +using MediatR; + +namespace CCE.Application.PlatformSettings.Commands.DeleteKnowledgePartner; + +public sealed record DeleteKnowledgePartnerCommand(System.Guid Id) : IRequest>; diff --git a/backend/src/CCE.Application/PlatformSettings/Commands/DeleteKnowledgePartner/DeleteKnowledgePartnerCommandHandler.cs b/backend/src/CCE.Application/PlatformSettings/Commands/DeleteKnowledgePartner/DeleteKnowledgePartnerCommandHandler.cs new file mode 100644 index 00000000..a70d21af --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Commands/DeleteKnowledgePartner/DeleteKnowledgePartnerCommandHandler.cs @@ -0,0 +1,36 @@ +using CCE.Application.Common; +using CCE.Application.Common.Interfaces; +using CCE.Application.Messages; +using CCE.Domain.PlatformSettings; +using MediatR; + +namespace CCE.Application.PlatformSettings.Commands.DeleteKnowledgePartner; + +public sealed class DeleteKnowledgePartnerCommandHandler + : IRequestHandler> +{ + private readonly IKnowledgePartnerRepository _repo; + private readonly ICceDbContext _db; + private readonly MessageFactory _msg; + + public DeleteKnowledgePartnerCommandHandler( + IKnowledgePartnerRepository repo, ICceDbContext db, MessageFactory msg) + { + _repo = repo; + _db = db; + _msg = msg; + } + + public async Task> Handle( + DeleteKnowledgePartnerCommand request, CancellationToken cancellationToken) + { + var partner = await _repo.FindAsync(request.Id, cancellationToken).ConfigureAwait(false); + if (partner is null) + return _msg.KnowledgePartnerNotFound(); + + _db.Delete(partner); + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + return _msg.Ok("CONTENT_DELETED"); + } +} diff --git a/backend/src/CCE.Application/PlatformSettings/Commands/DeletePolicySection/DeletePolicySectionCommand.cs b/backend/src/CCE.Application/PlatformSettings/Commands/DeletePolicySection/DeletePolicySectionCommand.cs new file mode 100644 index 00000000..6b6013b0 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Commands/DeletePolicySection/DeletePolicySectionCommand.cs @@ -0,0 +1,6 @@ +using CCE.Application.Common; +using MediatR; + +namespace CCE.Application.PlatformSettings.Commands.DeletePolicySection; + +public sealed record DeletePolicySectionCommand(System.Guid Id) : IRequest>; diff --git a/backend/src/CCE.Application/PlatformSettings/Commands/DeletePolicySection/DeletePolicySectionCommandHandler.cs b/backend/src/CCE.Application/PlatformSettings/Commands/DeletePolicySection/DeletePolicySectionCommandHandler.cs new file mode 100644 index 00000000..592473f1 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Commands/DeletePolicySection/DeletePolicySectionCommandHandler.cs @@ -0,0 +1,36 @@ +using CCE.Application.Common; +using CCE.Application.Common.Interfaces; +using CCE.Application.Messages; +using CCE.Domain.PlatformSettings; +using MediatR; + +namespace CCE.Application.PlatformSettings.Commands.DeletePolicySection; + +public sealed class DeletePolicySectionCommandHandler + : IRequestHandler> +{ + private readonly IPolicySectionRepository _repo; + private readonly ICceDbContext _db; + private readonly MessageFactory _msg; + + public DeletePolicySectionCommandHandler( + IPolicySectionRepository repo, ICceDbContext db, MessageFactory msg) + { + _repo = repo; + _db = db; + _msg = msg; + } + + public async Task> Handle( + DeletePolicySectionCommand request, CancellationToken cancellationToken) + { + var section = await _repo.FindAsync(request.Id, cancellationToken).ConfigureAwait(false); + if (section is null) + return _msg.PolicySectionNotFound(); + + _db.Delete(section); + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + return _msg.Ok("CONTENT_DELETED"); + } +} diff --git a/backend/src/CCE.Application/PlatformSettings/Commands/UpdateAboutSettings/UpdateAboutSettingsCommand.cs b/backend/src/CCE.Application/PlatformSettings/Commands/UpdateAboutSettings/UpdateAboutSettingsCommand.cs new file mode 100644 index 00000000..aa289d0a --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Commands/UpdateAboutSettings/UpdateAboutSettingsCommand.cs @@ -0,0 +1,11 @@ +using CCE.Application.Common; +using CCE.Application.PlatformSettings.Dtos; +using MediatR; + +namespace CCE.Application.PlatformSettings.Commands.UpdateAboutSettings; + +public sealed record UpdateAboutSettingsCommand( + string DescriptionAr, + string DescriptionEn, + string? HowToUseVideoUrl, + byte[] RowVersion) : IRequest>; diff --git a/backend/src/CCE.Application/PlatformSettings/Commands/UpdateAboutSettings/UpdateAboutSettingsCommandHandler.cs b/backend/src/CCE.Application/PlatformSettings/Commands/UpdateAboutSettings/UpdateAboutSettingsCommandHandler.cs new file mode 100644 index 00000000..0de3e390 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Commands/UpdateAboutSettings/UpdateAboutSettingsCommandHandler.cs @@ -0,0 +1,61 @@ +using CCE.Application.Common; +using CCE.Application.Common.Interfaces; +using CCE.Application.Common.Pagination; +using CCE.Application.Messages; +using CCE.Application.PlatformSettings.Dtos; +using CCE.Domain.PlatformSettings; +using MediatR; + +namespace CCE.Application.PlatformSettings.Commands.UpdateAboutSettings; + +public sealed class UpdateAboutSettingsCommandHandler + : IRequestHandler> +{ + private readonly IAboutSettingsRepository _repo; + private readonly ICceDbContext _db; + private readonly MessageFactory _msg; + + public UpdateAboutSettingsCommandHandler( + IAboutSettingsRepository repo, ICceDbContext db, MessageFactory msg) + { + _repo = repo; + _db = db; + _msg = msg; + } + + public async Task> Handle( + UpdateAboutSettingsCommand request, CancellationToken cancellationToken) + { + var settings = await _repo.GetAsync(cancellationToken).ConfigureAwait(false); + if (settings is null) + return _msg.AboutSettingsNotFound(); + + settings.UpdateContent(request.DescriptionAr, request.DescriptionEn, request.HowToUseVideoUrl); + + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + var glossary = await _db.GlossaryEntries + .Where(e => e.AboutSettingsId == settings.Id) + .OrderBy(e => e.OrderIndex) + .ToListAsyncEither(cancellationToken) + .ConfigureAwait(false); + + var partners = await _db.KnowledgePartners + .Where(p => p.AboutSettingsId == settings.Id) + .OrderBy(p => p.OrderIndex) + .ToListAsyncEither(cancellationToken) + .ConfigureAwait(false); + + return _msg.Ok(new AboutSettingsDto( + settings.Id, + settings.DescriptionAr, + settings.DescriptionEn, + settings.HowToUseVideoUrl, + glossary.Select(e => new GlossaryEntryDto( + e.Id, e.TermAr, e.TermEn, e.DefinitionAr, e.DefinitionEn, e.OrderIndex)).ToList(), + partners.Select(p => new KnowledgePartnerDto( + p.Id, p.NameAr, p.NameEn, p.LogoUrl, p.WebsiteUrl, + p.DescriptionAr, p.DescriptionEn, p.OrderIndex)).ToList(), + Convert.ToBase64String(settings.RowVersion)), "SETTINGS_UPDATED"); + } +} diff --git a/backend/src/CCE.Application/PlatformSettings/Commands/UpdateAboutSettings/UpdateAboutSettingsCommandValidator.cs b/backend/src/CCE.Application/PlatformSettings/Commands/UpdateAboutSettings/UpdateAboutSettingsCommandValidator.cs new file mode 100644 index 00000000..439be801 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Commands/UpdateAboutSettings/UpdateAboutSettingsCommandValidator.cs @@ -0,0 +1,15 @@ +using FluentValidation; + +namespace CCE.Application.PlatformSettings.Commands.UpdateAboutSettings; + +public sealed class UpdateAboutSettingsCommandValidator + : AbstractValidator +{ + public UpdateAboutSettingsCommandValidator() + { + RuleFor(x => x.DescriptionAr).NotEmpty().MaximumLength(1000); + RuleFor(x => x.DescriptionEn).NotEmpty().MaximumLength(1000); + RuleFor(x => x.RowVersion).NotNull().Must(rv => rv.Length == 8) + .WithMessage("RowVersion must be exactly 8 bytes."); + } +} diff --git a/backend/src/CCE.Application/PlatformSettings/Commands/UpdateGlossaryEntry/UpdateGlossaryEntryCommand.cs b/backend/src/CCE.Application/PlatformSettings/Commands/UpdateGlossaryEntry/UpdateGlossaryEntryCommand.cs new file mode 100644 index 00000000..09b232bc --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Commands/UpdateGlossaryEntry/UpdateGlossaryEntryCommand.cs @@ -0,0 +1,12 @@ +using CCE.Application.Common; +using CCE.Application.PlatformSettings.Dtos; +using MediatR; + +namespace CCE.Application.PlatformSettings.Commands.UpdateGlossaryEntry; + +public sealed record UpdateGlossaryEntryCommand( + System.Guid Id, + string TermAr, + string TermEn, + string DefinitionAr, + string DefinitionEn) : IRequest>; diff --git a/backend/src/CCE.Application/PlatformSettings/Commands/UpdateGlossaryEntry/UpdateGlossaryEntryCommandHandler.cs b/backend/src/CCE.Application/PlatformSettings/Commands/UpdateGlossaryEntry/UpdateGlossaryEntryCommandHandler.cs new file mode 100644 index 00000000..c3a200ae --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Commands/UpdateGlossaryEntry/UpdateGlossaryEntryCommandHandler.cs @@ -0,0 +1,42 @@ +using CCE.Application.Common; +using CCE.Application.Common.Interfaces; +using CCE.Application.Messages; +using CCE.Application.PlatformSettings.Dtos; +using CCE.Domain.PlatformSettings; +using MediatR; + +namespace CCE.Application.PlatformSettings.Commands.UpdateGlossaryEntry; + +public sealed class UpdateGlossaryEntryCommandHandler + : IRequestHandler> +{ + private readonly IGlossaryEntryRepository _repo; + private readonly ICceDbContext _db; + private readonly MessageFactory _msg; + + public UpdateGlossaryEntryCommandHandler( + IGlossaryEntryRepository repo, ICceDbContext db, MessageFactory msg) + { + _repo = repo; + _db = db; + _msg = msg; + } + + public async Task> Handle( + UpdateGlossaryEntryCommand request, CancellationToken cancellationToken) + { + var entry = await _repo.FindAsync(request.Id, cancellationToken).ConfigureAwait(false); + if (entry is null) + return _msg.GlossaryEntryNotFound(); + + entry.UpdateContent( + request.TermAr, request.TermEn, + request.DefinitionAr, request.DefinitionEn); + + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + return _msg.Ok(new GlossaryEntryDto( + entry.Id, entry.TermAr, entry.TermEn, + entry.DefinitionAr, entry.DefinitionEn, entry.OrderIndex), "CONTENT_UPDATED"); + } +} diff --git a/backend/src/CCE.Application/PlatformSettings/Commands/UpdateGlossaryEntry/UpdateGlossaryEntryCommandValidator.cs b/backend/src/CCE.Application/PlatformSettings/Commands/UpdateGlossaryEntry/UpdateGlossaryEntryCommandValidator.cs new file mode 100644 index 00000000..9d51d369 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Commands/UpdateGlossaryEntry/UpdateGlossaryEntryCommandValidator.cs @@ -0,0 +1,16 @@ +using FluentValidation; + +namespace CCE.Application.PlatformSettings.Commands.UpdateGlossaryEntry; + +public sealed class UpdateGlossaryEntryCommandValidator + : AbstractValidator +{ + public UpdateGlossaryEntryCommandValidator() + { + RuleFor(x => x.Id).NotEmpty(); + RuleFor(x => x.TermAr).NotEmpty().MaximumLength(100); + RuleFor(x => x.TermEn).NotEmpty().MaximumLength(100); + RuleFor(x => x.DefinitionAr).NotEmpty().MaximumLength(1000); + RuleFor(x => x.DefinitionEn).NotEmpty().MaximumLength(1000); + } +} diff --git a/backend/src/CCE.Application/PlatformSettings/Commands/UpdateHomepageSettings/UpdateHomepageSettingsCommand.cs b/backend/src/CCE.Application/PlatformSettings/Commands/UpdateHomepageSettings/UpdateHomepageSettingsCommand.cs new file mode 100644 index 00000000..5248ce3d --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Commands/UpdateHomepageSettings/UpdateHomepageSettingsCommand.cs @@ -0,0 +1,14 @@ +using CCE.Application.Common; +using CCE.Application.PlatformSettings.Dtos; +using MediatR; + +namespace CCE.Application.PlatformSettings.Commands.UpdateHomepageSettings; + +public sealed record UpdateHomepageSettingsCommand( + string? VideoUrl, + string ObjectiveAr, + string ObjectiveEn, + string CceConceptsAr, + string CceConceptsEn, + System.Collections.Generic.IReadOnlyList ParticipatingCountryIds, + byte[] RowVersion) : IRequest>; diff --git a/backend/src/CCE.Application/PlatformSettings/Commands/UpdateHomepageSettings/UpdateHomepageSettingsCommandHandler.cs b/backend/src/CCE.Application/PlatformSettings/Commands/UpdateHomepageSettings/UpdateHomepageSettingsCommandHandler.cs new file mode 100644 index 00000000..cc3e8e47 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Commands/UpdateHomepageSettings/UpdateHomepageSettingsCommandHandler.cs @@ -0,0 +1,67 @@ +using CCE.Application.Common; +using CCE.Application.Common.Interfaces; +using CCE.Application.Messages; +using CCE.Application.PlatformSettings.Dtos; +using CCE.Domain.PlatformSettings; +using MediatR; + +namespace CCE.Application.PlatformSettings.Commands.UpdateHomepageSettings; + +public sealed class UpdateHomepageSettingsCommandHandler + : IRequestHandler> +{ + private readonly IHomepageSettingsRepository _repo; + private readonly ICceDbContext _db; + private readonly MessageFactory _msg; + + public UpdateHomepageSettingsCommandHandler( + IHomepageSettingsRepository repo, ICceDbContext db, MessageFactory msg) + { + _repo = repo; + _db = db; + _msg = msg; + } + + public async Task> Handle( + UpdateHomepageSettingsCommand request, CancellationToken cancellationToken) + { + var settings = await _repo.GetAsync(cancellationToken).ConfigureAwait(false); + if (settings is null) + return _msg.HomepageSettingsNotFound(); + + settings.UpdateContent( + request.VideoUrl, + request.ObjectiveAr, + request.ObjectiveEn, + request.CceConceptsAr, + request.CceConceptsEn); + + var existing = _db.HomepageCountries + .Where(hc => hc.HomepageSettingsId == settings.Id); + _db.DeleteRange(existing); + + var order = 0; + foreach (var countryId in request.ParticipatingCountryIds) + { + _db.Add(HomepageCountry.Create(settings.Id, countryId, order++)); + } + + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + var countries = _db.HomepageCountries + .Where(hc => hc.HomepageSettingsId == settings.Id) + .OrderBy(hc => hc.OrderIndex) + .Select(hc => new HomepageCountryDto(hc.Id, hc.CountryId, hc.OrderIndex)) + .ToList(); + + return _msg.Ok(new HomepageSettingsDto( + settings.Id, + settings.VideoUrl, + settings.ObjectiveAr, + settings.ObjectiveEn, + settings.CceConceptsAr, + settings.CceConceptsEn, + countries, + Convert.ToBase64String(settings.RowVersion)), "SETTINGS_UPDATED"); + } +} diff --git a/backend/src/CCE.Application/PlatformSettings/Commands/UpdateHomepageSettings/UpdateHomepageSettingsCommandValidator.cs b/backend/src/CCE.Application/PlatformSettings/Commands/UpdateHomepageSettings/UpdateHomepageSettingsCommandValidator.cs new file mode 100644 index 00000000..7c6efd1e --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Commands/UpdateHomepageSettings/UpdateHomepageSettingsCommandValidator.cs @@ -0,0 +1,15 @@ +using FluentValidation; + +namespace CCE.Application.PlatformSettings.Commands.UpdateHomepageSettings; + +public sealed class UpdateHomepageSettingsCommandValidator + : AbstractValidator +{ + public UpdateHomepageSettingsCommandValidator() + { + RuleFor(x => x.ObjectiveAr).NotEmpty().MaximumLength(1000); + RuleFor(x => x.ObjectiveEn).NotEmpty().MaximumLength(1000); + RuleFor(x => x.RowVersion).NotNull().Must(rv => rv.Length == 8) + .WithMessage("RowVersion must be exactly 8 bytes."); + } +} diff --git a/backend/src/CCE.Application/PlatformSettings/Commands/UpdateKnowledgePartner/UpdateKnowledgePartnerCommand.cs b/backend/src/CCE.Application/PlatformSettings/Commands/UpdateKnowledgePartner/UpdateKnowledgePartnerCommand.cs new file mode 100644 index 00000000..9c78b9bb --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Commands/UpdateKnowledgePartner/UpdateKnowledgePartnerCommand.cs @@ -0,0 +1,14 @@ +using CCE.Application.Common; +using CCE.Application.PlatformSettings.Dtos; +using MediatR; + +namespace CCE.Application.PlatformSettings.Commands.UpdateKnowledgePartner; + +public sealed record UpdateKnowledgePartnerCommand( + System.Guid Id, + string NameAr, + string NameEn, + string? LogoUrl, + string? WebsiteUrl, + string? DescriptionAr, + string? DescriptionEn) : IRequest>; diff --git a/backend/src/CCE.Application/PlatformSettings/Commands/UpdateKnowledgePartner/UpdateKnowledgePartnerCommandHandler.cs b/backend/src/CCE.Application/PlatformSettings/Commands/UpdateKnowledgePartner/UpdateKnowledgePartnerCommandHandler.cs new file mode 100644 index 00000000..add86a9b --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Commands/UpdateKnowledgePartner/UpdateKnowledgePartnerCommandHandler.cs @@ -0,0 +1,42 @@ +using CCE.Application.Common; +using CCE.Application.Common.Interfaces; +using CCE.Application.Messages; +using CCE.Application.PlatformSettings.Dtos; +using CCE.Domain.PlatformSettings; +using MediatR; + +namespace CCE.Application.PlatformSettings.Commands.UpdateKnowledgePartner; + +public sealed class UpdateKnowledgePartnerCommandHandler + : IRequestHandler> +{ + private readonly IKnowledgePartnerRepository _repo; + private readonly ICceDbContext _db; + private readonly MessageFactory _msg; + + public UpdateKnowledgePartnerCommandHandler( + IKnowledgePartnerRepository repo, ICceDbContext db, MessageFactory msg) + { + _repo = repo; + _db = db; + _msg = msg; + } + + public async Task> Handle( + UpdateKnowledgePartnerCommand request, CancellationToken cancellationToken) + { + var partner = await _repo.FindAsync(request.Id, cancellationToken).ConfigureAwait(false); + if (partner is null) + return _msg.KnowledgePartnerNotFound(); + + partner.UpdateContent( + request.NameAr, request.NameEn, request.LogoUrl, + request.WebsiteUrl, request.DescriptionAr, request.DescriptionEn); + + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + return _msg.Ok(new KnowledgePartnerDto( + partner.Id, partner.NameAr, partner.NameEn, partner.LogoUrl, partner.WebsiteUrl, + partner.DescriptionAr, partner.DescriptionEn, partner.OrderIndex), "CONTENT_UPDATED"); + } +} diff --git a/backend/src/CCE.Application/PlatformSettings/Commands/UpdateKnowledgePartner/UpdateKnowledgePartnerCommandValidator.cs b/backend/src/CCE.Application/PlatformSettings/Commands/UpdateKnowledgePartner/UpdateKnowledgePartnerCommandValidator.cs new file mode 100644 index 00000000..9f821d17 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Commands/UpdateKnowledgePartner/UpdateKnowledgePartnerCommandValidator.cs @@ -0,0 +1,16 @@ +using FluentValidation; + +namespace CCE.Application.PlatformSettings.Commands.UpdateKnowledgePartner; + +public sealed class UpdateKnowledgePartnerCommandValidator + : AbstractValidator +{ + public UpdateKnowledgePartnerCommandValidator() + { + RuleFor(x => x.Id).NotEmpty(); + RuleFor(x => x.NameAr).NotEmpty().MaximumLength(200); + RuleFor(x => x.NameEn).NotEmpty().MaximumLength(200); + RuleFor(x => x.DescriptionAr).MaximumLength(1000); + RuleFor(x => x.DescriptionEn).MaximumLength(1000); + } +} diff --git a/backend/src/CCE.Application/PlatformSettings/Commands/UpdatePoliciesSettings/UpdatePoliciesSettingsCommand.cs b/backend/src/CCE.Application/PlatformSettings/Commands/UpdatePoliciesSettings/UpdatePoliciesSettingsCommand.cs new file mode 100644 index 00000000..b9b04033 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Commands/UpdatePoliciesSettings/UpdatePoliciesSettingsCommand.cs @@ -0,0 +1,8 @@ +using CCE.Application.Common; +using CCE.Application.PlatformSettings.Dtos; +using MediatR; + +namespace CCE.Application.PlatformSettings.Commands.UpdatePoliciesSettings; + +public sealed record UpdatePoliciesSettingsCommand( + byte[] RowVersion) : IRequest>; diff --git a/backend/src/CCE.Application/PlatformSettings/Commands/UpdatePoliciesSettings/UpdatePoliciesSettingsCommandHandler.cs b/backend/src/CCE.Application/PlatformSettings/Commands/UpdatePoliciesSettings/UpdatePoliciesSettingsCommandHandler.cs new file mode 100644 index 00000000..09d9a0e6 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Commands/UpdatePoliciesSettings/UpdatePoliciesSettingsCommandHandler.cs @@ -0,0 +1,48 @@ +using CCE.Application.Common; +using CCE.Application.Common.Interfaces; +using CCE.Application.Common.Pagination; +using CCE.Application.Messages; +using CCE.Application.PlatformSettings.Dtos; +using CCE.Domain.PlatformSettings; +using MediatR; + +namespace CCE.Application.PlatformSettings.Commands.UpdatePoliciesSettings; + +public sealed class UpdatePoliciesSettingsCommandHandler + : IRequestHandler> +{ + private readonly IPoliciesSettingsRepository _repo; + private readonly ICceDbContext _db; + private readonly MessageFactory _msg; + + public UpdatePoliciesSettingsCommandHandler( + IPoliciesSettingsRepository repo, ICceDbContext db, MessageFactory msg) + { + _repo = repo; + _db = db; + _msg = msg; + } + + public async Task> Handle( + UpdatePoliciesSettingsCommand request, CancellationToken cancellationToken) + { + var settings = await _repo.GetAsync(cancellationToken).ConfigureAwait(false); + if (settings is null) + return _msg.PoliciesSettingsNotFound(); + + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + var sections = await _db.PolicySections + .Where(s => s.PoliciesSettingsId == settings.Id) + .OrderBy(s => s.OrderIndex) + .ToListAsyncEither(cancellationToken) + .ConfigureAwait(false); + + return _msg.Ok(new PoliciesSettingsDto( + settings.Id, + sections.Select(s => new PolicySectionDto( + s.Id, (int)s.Type, s.TitleAr, s.TitleEn, + s.ContentAr, s.ContentEn, s.OrderIndex)).ToList(), + Convert.ToBase64String(settings.RowVersion)), "SETTINGS_UPDATED"); + } +} diff --git a/backend/src/CCE.Application/PlatformSettings/Commands/UpdatePoliciesSettings/UpdatePoliciesSettingsCommandValidator.cs b/backend/src/CCE.Application/PlatformSettings/Commands/UpdatePoliciesSettings/UpdatePoliciesSettingsCommandValidator.cs new file mode 100644 index 00000000..eb11b63f --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Commands/UpdatePoliciesSettings/UpdatePoliciesSettingsCommandValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; + +namespace CCE.Application.PlatformSettings.Commands.UpdatePoliciesSettings; + +public sealed class UpdatePoliciesSettingsCommandValidator + : AbstractValidator +{ + public UpdatePoliciesSettingsCommandValidator() + { + RuleFor(x => x.RowVersion).NotNull().Must(rv => rv.Length == 8) + .WithMessage("RowVersion must be exactly 8 bytes."); + } +} diff --git a/backend/src/CCE.Application/PlatformSettings/Commands/UpdatePolicySection/UpdatePolicySectionCommand.cs b/backend/src/CCE.Application/PlatformSettings/Commands/UpdatePolicySection/UpdatePolicySectionCommand.cs new file mode 100644 index 00000000..ddfe19d8 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Commands/UpdatePolicySection/UpdatePolicySectionCommand.cs @@ -0,0 +1,12 @@ +using CCE.Application.Common; +using CCE.Application.PlatformSettings.Dtos; +using MediatR; + +namespace CCE.Application.PlatformSettings.Commands.UpdatePolicySection; + +public sealed record UpdatePolicySectionCommand( + System.Guid Id, + string TitleAr, + string TitleEn, + string ContentAr, + string ContentEn) : IRequest>; diff --git a/backend/src/CCE.Application/PlatformSettings/Commands/UpdatePolicySection/UpdatePolicySectionCommandHandler.cs b/backend/src/CCE.Application/PlatformSettings/Commands/UpdatePolicySection/UpdatePolicySectionCommandHandler.cs new file mode 100644 index 00000000..91daf0aa --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Commands/UpdatePolicySection/UpdatePolicySectionCommandHandler.cs @@ -0,0 +1,42 @@ +using CCE.Application.Common; +using CCE.Application.Common.Interfaces; +using CCE.Application.Messages; +using CCE.Application.PlatformSettings.Dtos; +using CCE.Domain.PlatformSettings; +using MediatR; + +namespace CCE.Application.PlatformSettings.Commands.UpdatePolicySection; + +public sealed class UpdatePolicySectionCommandHandler + : IRequestHandler> +{ + private readonly IPolicySectionRepository _repo; + private readonly ICceDbContext _db; + private readonly MessageFactory _msg; + + public UpdatePolicySectionCommandHandler( + IPolicySectionRepository repo, ICceDbContext db, MessageFactory msg) + { + _repo = repo; + _db = db; + _msg = msg; + } + + public async Task> Handle( + UpdatePolicySectionCommand request, CancellationToken cancellationToken) + { + var section = await _repo.FindAsync(request.Id, cancellationToken).ConfigureAwait(false); + if (section is null) + return _msg.PolicySectionNotFound(); + + section.UpdateContent( + request.TitleAr, request.TitleEn, + request.ContentAr, request.ContentEn); + + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + return _msg.Ok(new PolicySectionDto( + section.Id, (int)section.Type, section.TitleAr, section.TitleEn, + section.ContentAr, section.ContentEn, section.OrderIndex), "CONTENT_UPDATED"); + } +} diff --git a/backend/src/CCE.Application/PlatformSettings/Commands/UpdatePolicySection/UpdatePolicySectionCommandValidator.cs b/backend/src/CCE.Application/PlatformSettings/Commands/UpdatePolicySection/UpdatePolicySectionCommandValidator.cs new file mode 100644 index 00000000..34601714 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Commands/UpdatePolicySection/UpdatePolicySectionCommandValidator.cs @@ -0,0 +1,16 @@ +using FluentValidation; + +namespace CCE.Application.PlatformSettings.Commands.UpdatePolicySection; + +public sealed class UpdatePolicySectionCommandValidator + : AbstractValidator +{ + public UpdatePolicySectionCommandValidator() + { + RuleFor(x => x.Id).NotEmpty(); + RuleFor(x => x.TitleAr).NotEmpty().MaximumLength(500); + RuleFor(x => x.TitleEn).NotEmpty().MaximumLength(500); + RuleFor(x => x.ContentAr).NotEmpty(); + RuleFor(x => x.ContentEn).NotEmpty(); + } +} diff --git a/backend/src/CCE.Application/PlatformSettings/Dtos/AboutSettingsDto.cs b/backend/src/CCE.Application/PlatformSettings/Dtos/AboutSettingsDto.cs new file mode 100644 index 00000000..c69e3e7a --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Dtos/AboutSettingsDto.cs @@ -0,0 +1,10 @@ +namespace CCE.Application.PlatformSettings.Dtos; + +public sealed record AboutSettingsDto( + System.Guid Id, + string DescriptionAr, + string DescriptionEn, + string? HowToUseVideoUrl, + System.Collections.Generic.IReadOnlyList GlossaryEntries, + System.Collections.Generic.IReadOnlyList KnowledgePartners, + string RowVersion); diff --git a/backend/src/CCE.Application/PlatformSettings/Dtos/GlossaryEntryDto.cs b/backend/src/CCE.Application/PlatformSettings/Dtos/GlossaryEntryDto.cs new file mode 100644 index 00000000..072e3866 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Dtos/GlossaryEntryDto.cs @@ -0,0 +1,9 @@ +namespace CCE.Application.PlatformSettings.Dtos; + +public sealed record GlossaryEntryDto( + System.Guid Id, + string TermAr, + string TermEn, + string DefinitionAr, + string DefinitionEn, + int OrderIndex); diff --git a/backend/src/CCE.Application/PlatformSettings/Dtos/HomepageSettingsDto.cs b/backend/src/CCE.Application/PlatformSettings/Dtos/HomepageSettingsDto.cs new file mode 100644 index 00000000..34272df0 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Dtos/HomepageSettingsDto.cs @@ -0,0 +1,16 @@ +namespace CCE.Application.PlatformSettings.Dtos; + +public sealed record HomepageSettingsDto( + System.Guid Id, + string? VideoUrl, + string ObjectiveAr, + string ObjectiveEn, + string CceConceptsAr, + string CceConceptsEn, + System.Collections.Generic.IReadOnlyList ParticipatingCountries, + string RowVersion); + +public sealed record HomepageCountryDto( + System.Guid Id, + System.Guid CountryId, + int OrderIndex); diff --git a/backend/src/CCE.Application/PlatformSettings/Dtos/KnowledgePartnerDto.cs b/backend/src/CCE.Application/PlatformSettings/Dtos/KnowledgePartnerDto.cs new file mode 100644 index 00000000..da37eae8 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Dtos/KnowledgePartnerDto.cs @@ -0,0 +1,11 @@ +namespace CCE.Application.PlatformSettings.Dtos; + +public sealed record KnowledgePartnerDto( + System.Guid Id, + string NameAr, + string NameEn, + string? LogoUrl, + string? WebsiteUrl, + string? DescriptionAr, + string? DescriptionEn, + int OrderIndex); diff --git a/backend/src/CCE.Application/PlatformSettings/Dtos/PoliciesSettingsDto.cs b/backend/src/CCE.Application/PlatformSettings/Dtos/PoliciesSettingsDto.cs new file mode 100644 index 00000000..edb02839 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Dtos/PoliciesSettingsDto.cs @@ -0,0 +1,6 @@ +namespace CCE.Application.PlatformSettings.Dtos; + +public sealed record PoliciesSettingsDto( + System.Guid Id, + System.Collections.Generic.IReadOnlyList Sections, + string RowVersion); diff --git a/backend/src/CCE.Application/PlatformSettings/Dtos/PolicySectionDto.cs b/backend/src/CCE.Application/PlatformSettings/Dtos/PolicySectionDto.cs new file mode 100644 index 00000000..3faf85c7 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Dtos/PolicySectionDto.cs @@ -0,0 +1,10 @@ +namespace CCE.Application.PlatformSettings.Dtos; + +public sealed record PolicySectionDto( + System.Guid Id, + int Type, + string TitleAr, + string TitleEn, + string ContentAr, + string ContentEn, + int OrderIndex); diff --git a/backend/src/CCE.Application/PlatformSettings/IAboutSettingsRepository.cs b/backend/src/CCE.Application/PlatformSettings/IAboutSettingsRepository.cs new file mode 100644 index 00000000..709fec5e --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/IAboutSettingsRepository.cs @@ -0,0 +1,8 @@ +using CCE.Domain.PlatformSettings; + +namespace CCE.Application.PlatformSettings; + +public interface IAboutSettingsRepository +{ + Task GetAsync(CancellationToken ct); +} diff --git a/backend/src/CCE.Application/PlatformSettings/IGlossaryEntryRepository.cs b/backend/src/CCE.Application/PlatformSettings/IGlossaryEntryRepository.cs new file mode 100644 index 00000000..fe74e172 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/IGlossaryEntryRepository.cs @@ -0,0 +1,8 @@ +using CCE.Domain.PlatformSettings; + +namespace CCE.Application.PlatformSettings; + +public interface IGlossaryEntryRepository +{ + Task FindAsync(System.Guid id, CancellationToken ct); +} diff --git a/backend/src/CCE.Application/PlatformSettings/IHomepageSettingsRepository.cs b/backend/src/CCE.Application/PlatformSettings/IHomepageSettingsRepository.cs new file mode 100644 index 00000000..9547811b --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/IHomepageSettingsRepository.cs @@ -0,0 +1,8 @@ +using CCE.Domain.PlatformSettings; + +namespace CCE.Application.PlatformSettings; + +public interface IHomepageSettingsRepository +{ + Task GetAsync(CancellationToken ct); +} diff --git a/backend/src/CCE.Application/PlatformSettings/IKnowledgePartnerRepository.cs b/backend/src/CCE.Application/PlatformSettings/IKnowledgePartnerRepository.cs new file mode 100644 index 00000000..b8a02011 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/IKnowledgePartnerRepository.cs @@ -0,0 +1,8 @@ +using CCE.Domain.PlatformSettings; + +namespace CCE.Application.PlatformSettings; + +public interface IKnowledgePartnerRepository +{ + Task FindAsync(System.Guid id, CancellationToken ct); +} diff --git a/backend/src/CCE.Application/PlatformSettings/IPoliciesSettingsRepository.cs b/backend/src/CCE.Application/PlatformSettings/IPoliciesSettingsRepository.cs new file mode 100644 index 00000000..15156414 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/IPoliciesSettingsRepository.cs @@ -0,0 +1,8 @@ +using CCE.Domain.PlatformSettings; + +namespace CCE.Application.PlatformSettings; + +public interface IPoliciesSettingsRepository +{ + Task GetAsync(CancellationToken ct); +} diff --git a/backend/src/CCE.Application/PlatformSettings/IPolicySectionRepository.cs b/backend/src/CCE.Application/PlatformSettings/IPolicySectionRepository.cs new file mode 100644 index 00000000..d899e017 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/IPolicySectionRepository.cs @@ -0,0 +1,8 @@ +using CCE.Domain.PlatformSettings; + +namespace CCE.Application.PlatformSettings; + +public interface IPolicySectionRepository +{ + Task FindAsync(System.Guid id, CancellationToken ct); +} diff --git a/backend/src/CCE.Application/PlatformSettings/Public/Dtos/PublicAboutSettingsDto.cs b/backend/src/CCE.Application/PlatformSettings/Public/Dtos/PublicAboutSettingsDto.cs new file mode 100644 index 00000000..af827df2 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Public/Dtos/PublicAboutSettingsDto.cs @@ -0,0 +1,8 @@ +namespace CCE.Application.PlatformSettings.Public.Dtos; + +public sealed record PublicAboutSettingsDto( + string DescriptionAr, + string DescriptionEn, + string? HowToUseVideoUrl, + System.Collections.Generic.IReadOnlyList Glossary, + System.Collections.Generic.IReadOnlyList KnowledgePartners); diff --git a/backend/src/CCE.Application/PlatformSettings/Public/Dtos/PublicGlossaryEntryDto.cs b/backend/src/CCE.Application/PlatformSettings/Public/Dtos/PublicGlossaryEntryDto.cs new file mode 100644 index 00000000..8aa245f0 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Public/Dtos/PublicGlossaryEntryDto.cs @@ -0,0 +1,7 @@ +namespace CCE.Application.PlatformSettings.Public.Dtos; + +public sealed record PublicGlossaryEntryDto( + string TermAr, + string TermEn, + string DefinitionAr, + string DefinitionEn); diff --git a/backend/src/CCE.Application/PlatformSettings/Public/Dtos/PublicHomepageCountryDto.cs b/backend/src/CCE.Application/PlatformSettings/Public/Dtos/PublicHomepageCountryDto.cs new file mode 100644 index 00000000..5a7b2ac4 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Public/Dtos/PublicHomepageCountryDto.cs @@ -0,0 +1,9 @@ +namespace CCE.Application.PlatformSettings.Public.Dtos; + +public sealed record PublicHomepageCountryDto( + System.Guid Id, + string IsoAlpha3, + string NameAr, + string NameEn, + string FlagUrl, + int OrderIndex); diff --git a/backend/src/CCE.Application/PlatformSettings/Public/Dtos/PublicHomepageDto.cs b/backend/src/CCE.Application/PlatformSettings/Public/Dtos/PublicHomepageDto.cs new file mode 100644 index 00000000..e34917df --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Public/Dtos/PublicHomepageDto.cs @@ -0,0 +1,12 @@ +using CCE.Application.Content.Public.Dtos; + +namespace CCE.Application.PlatformSettings.Public.Dtos; + +public sealed record PublicHomepageDto( + string? VideoUrl, + string ObjectiveAr, + string ObjectiveEn, + string CceConceptsAr, + string CceConceptsEn, + System.Collections.Generic.IReadOnlyList ParticipatingCountries, + System.Collections.Generic.IReadOnlyList Sections); diff --git a/backend/src/CCE.Application/PlatformSettings/Public/Dtos/PublicKnowledgePartnerDto.cs b/backend/src/CCE.Application/PlatformSettings/Public/Dtos/PublicKnowledgePartnerDto.cs new file mode 100644 index 00000000..3fe6a883 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Public/Dtos/PublicKnowledgePartnerDto.cs @@ -0,0 +1,9 @@ +namespace CCE.Application.PlatformSettings.Public.Dtos; + +public sealed record PublicKnowledgePartnerDto( + string NameAr, + string NameEn, + string? LogoUrl, + string? WebsiteUrl, + string? DescriptionAr, + string? DescriptionEn); diff --git a/backend/src/CCE.Application/PlatformSettings/Public/Dtos/PublicPoliciesSettingsDto.cs b/backend/src/CCE.Application/PlatformSettings/Public/Dtos/PublicPoliciesSettingsDto.cs new file mode 100644 index 00000000..fe2b5abc --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Public/Dtos/PublicPoliciesSettingsDto.cs @@ -0,0 +1,4 @@ +namespace CCE.Application.PlatformSettings.Public.Dtos; + +public sealed record PublicPoliciesSettingsDto( + System.Collections.Generic.IReadOnlyList Sections); diff --git a/backend/src/CCE.Application/PlatformSettings/Public/Dtos/PublicPolicySectionDto.cs b/backend/src/CCE.Application/PlatformSettings/Public/Dtos/PublicPolicySectionDto.cs new file mode 100644 index 00000000..081c84d2 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Public/Dtos/PublicPolicySectionDto.cs @@ -0,0 +1,8 @@ +namespace CCE.Application.PlatformSettings.Public.Dtos; + +public sealed record PublicPolicySectionDto( + int Type, + string TitleAr, + string TitleEn, + string ContentAr, + string ContentEn); diff --git a/backend/src/CCE.Application/PlatformSettings/Public/Queries/GetPublicAboutSettings/GetPublicAboutSettingsQuery.cs b/backend/src/CCE.Application/PlatformSettings/Public/Queries/GetPublicAboutSettings/GetPublicAboutSettingsQuery.cs new file mode 100644 index 00000000..dc86e795 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Public/Queries/GetPublicAboutSettings/GetPublicAboutSettingsQuery.cs @@ -0,0 +1,7 @@ +using CCE.Application.Common; +using CCE.Application.PlatformSettings.Public.Dtos; +using MediatR; + +namespace CCE.Application.PlatformSettings.Public.Queries.GetPublicAboutSettings; + +public sealed record GetPublicAboutSettingsQuery() : IRequest>; diff --git a/backend/src/CCE.Application/PlatformSettings/Public/Queries/GetPublicAboutSettings/GetPublicAboutSettingsQueryHandler.cs b/backend/src/CCE.Application/PlatformSettings/Public/Queries/GetPublicAboutSettings/GetPublicAboutSettingsQueryHandler.cs new file mode 100644 index 00000000..598fa151 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Public/Queries/GetPublicAboutSettings/GetPublicAboutSettingsQueryHandler.cs @@ -0,0 +1,52 @@ +using CCE.Application.Common; +using CCE.Application.Common.Interfaces; +using CCE.Application.Common.Pagination; +using CCE.Application.Messages; +using CCE.Application.PlatformSettings.Public.Dtos; +using CCE.Domain.PlatformSettings; +using MediatR; + +namespace CCE.Application.PlatformSettings.Public.Queries.GetPublicAboutSettings; + +public sealed class GetPublicAboutSettingsQueryHandler + : IRequestHandler> +{ + private readonly ICceDbContext _db; + private readonly MessageFactory _msg; + + public GetPublicAboutSettingsQueryHandler(ICceDbContext db, MessageFactory msg) + { + _db = db; + _msg = msg; + } + + public async Task> Handle( + GetPublicAboutSettingsQuery request, CancellationToken cancellationToken) + { + var list = await _db.AboutSettings.ToListAsyncEither(cancellationToken).ConfigureAwait(false); + var settings = list.FirstOrDefault(); + if (settings is null) + return _msg.AboutSettingsNotFound(); + + var glossary = await _db.GlossaryEntries + .Where(e => e.AboutSettingsId == settings.Id) + .OrderBy(e => e.OrderIndex) + .ToListAsyncEither(cancellationToken) + .ConfigureAwait(false); + + var partners = await _db.KnowledgePartners + .Where(p => p.AboutSettingsId == settings.Id) + .OrderBy(p => p.OrderIndex) + .ToListAsyncEither(cancellationToken) + .ConfigureAwait(false); + + return _msg.Ok(new PublicAboutSettingsDto( + settings.DescriptionAr, + settings.DescriptionEn, + settings.HowToUseVideoUrl, + glossary.Select(e => new PublicGlossaryEntryDto(e.TermAr, e.TermEn, e.DefinitionAr, e.DefinitionEn)).ToList(), + partners.Select(p => new PublicKnowledgePartnerDto( + p.NameAr, p.NameEn, p.LogoUrl, p.WebsiteUrl, + p.DescriptionAr, p.DescriptionEn)).ToList()), "ITEMS_LISTED"); + } +} diff --git a/backend/src/CCE.Application/PlatformSettings/Public/Queries/GetPublicHomepage/GetPublicHomepageQuery.cs b/backend/src/CCE.Application/PlatformSettings/Public/Queries/GetPublicHomepage/GetPublicHomepageQuery.cs new file mode 100644 index 00000000..18f12468 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Public/Queries/GetPublicHomepage/GetPublicHomepageQuery.cs @@ -0,0 +1,7 @@ +using CCE.Application.Common; +using CCE.Application.PlatformSettings.Public.Dtos; +using MediatR; + +namespace CCE.Application.PlatformSettings.Public.Queries.GetPublicHomepage; + +public sealed record GetPublicHomepageQuery() : IRequest>; diff --git a/backend/src/CCE.Application/PlatformSettings/Public/Queries/GetPublicHomepage/GetPublicHomepageQueryHandler.cs b/backend/src/CCE.Application/PlatformSettings/Public/Queries/GetPublicHomepage/GetPublicHomepageQueryHandler.cs new file mode 100644 index 00000000..d071c241 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Public/Queries/GetPublicHomepage/GetPublicHomepageQueryHandler.cs @@ -0,0 +1,57 @@ +using CCE.Application.Common; +using CCE.Application.Common.Interfaces; +using CCE.Application.Common.Pagination; +using CCE.Application.Content.Public.Dtos; +using CCE.Application.Messages; +using CCE.Application.PlatformSettings.Public.Dtos; +using CCE.Domain.Content; +using CCE.Domain.PlatformSettings; +using MediatR; + +namespace CCE.Application.PlatformSettings.Public.Queries.GetPublicHomepage; + +public sealed class GetPublicHomepageQueryHandler + : IRequestHandler> +{ + private readonly ICceDbContext _db; + private readonly MessageFactory _msg; + + public GetPublicHomepageQueryHandler(ICceDbContext db, MessageFactory msg) + { + _db = db; + _msg = msg; + } + + public async Task> Handle( + GetPublicHomepageQuery request, CancellationToken cancellationToken) + { + var settingsList = await _db.HomepageSettings.ToListAsyncEither(cancellationToken).ConfigureAwait(false); + var settings = settingsList.FirstOrDefault(); + if (settings is null) + return _msg.HomepageSettingsNotFound(); + + var countries = await ( + from hc in _db.HomepageCountries + join c in _db.Countries on hc.CountryId equals c.Id + where hc.HomepageSettingsId == settings.Id + orderby hc.OrderIndex + select new PublicHomepageCountryDto(c.Id, c.IsoAlpha3, c.NameAr, c.NameEn, c.FlagUrl, hc.OrderIndex) + ).ToListAsyncEither(cancellationToken).ConfigureAwait(false); + + var sections = await _db.HomepageSections + .Where(s => s.IsActive) + .OrderBy(s => s.OrderIndex) + .ToListAsyncEither(cancellationToken) + .ConfigureAwait(false); + + return _msg.Ok(new PublicHomepageDto( + settings.VideoUrl, + settings.ObjectiveAr, + settings.ObjectiveEn, + settings.CceConceptsAr, + settings.CceConceptsEn, + countries, + sections.Select(s => new PublicHomepageSectionDto( + s.Id, s.SectionType, s.OrderIndex, s.ContentAr, s.ContentEn)).ToList()), "ITEMS_LISTED"); + } +} diff --git a/backend/src/CCE.Application/PlatformSettings/Public/Queries/GetPublicPoliciesSettings/GetPublicPoliciesSettingsQuery.cs b/backend/src/CCE.Application/PlatformSettings/Public/Queries/GetPublicPoliciesSettings/GetPublicPoliciesSettingsQuery.cs new file mode 100644 index 00000000..10267858 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Public/Queries/GetPublicPoliciesSettings/GetPublicPoliciesSettingsQuery.cs @@ -0,0 +1,7 @@ +using CCE.Application.Common; +using CCE.Application.PlatformSettings.Public.Dtos; +using MediatR; + +namespace CCE.Application.PlatformSettings.Public.Queries.GetPublicPoliciesSettings; + +public sealed record GetPublicPoliciesSettingsQuery() : IRequest>; diff --git a/backend/src/CCE.Application/PlatformSettings/Public/Queries/GetPublicPoliciesSettings/GetPublicPoliciesSettingsQueryHandler.cs b/backend/src/CCE.Application/PlatformSettings/Public/Queries/GetPublicPoliciesSettings/GetPublicPoliciesSettingsQueryHandler.cs new file mode 100644 index 00000000..13af933f --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Public/Queries/GetPublicPoliciesSettings/GetPublicPoliciesSettingsQueryHandler.cs @@ -0,0 +1,42 @@ +using CCE.Application.Common; +using CCE.Application.Common.Interfaces; +using CCE.Application.Common.Pagination; +using CCE.Application.Messages; +using CCE.Application.PlatformSettings.Public.Dtos; +using CCE.Domain.PlatformSettings; +using MediatR; + +namespace CCE.Application.PlatformSettings.Public.Queries.GetPublicPoliciesSettings; + +public sealed class GetPublicPoliciesSettingsQueryHandler + : IRequestHandler> +{ + private readonly ICceDbContext _db; + private readonly MessageFactory _msg; + + public GetPublicPoliciesSettingsQueryHandler(ICceDbContext db, MessageFactory msg) + { + _db = db; + _msg = msg; + } + + public async Task> Handle( + GetPublicPoliciesSettingsQuery request, CancellationToken cancellationToken) + { + var list = await _db.PoliciesSettings.ToListAsyncEither(cancellationToken).ConfigureAwait(false); + var settings = list.FirstOrDefault(); + if (settings is null) + return _msg.PoliciesSettingsNotFound(); + + var sections = await _db.PolicySections + .Where(s => s.PoliciesSettingsId == settings.Id) + .OrderBy(s => s.OrderIndex) + .ToListAsyncEither(cancellationToken) + .ConfigureAwait(false); + + return _msg.Ok(new PublicPoliciesSettingsDto( + sections.Select(s => new PublicPolicySectionDto( + (int)s.Type, s.TitleAr, s.TitleEn, + s.ContentAr, s.ContentEn)).ToList()), "ITEMS_LISTED"); + } +} diff --git a/backend/src/CCE.Application/PlatformSettings/Queries/GetAboutSettings/GetAboutSettingsQuery.cs b/backend/src/CCE.Application/PlatformSettings/Queries/GetAboutSettings/GetAboutSettingsQuery.cs new file mode 100644 index 00000000..e4b03467 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Queries/GetAboutSettings/GetAboutSettingsQuery.cs @@ -0,0 +1,7 @@ +using CCE.Application.Common; +using CCE.Application.PlatformSettings.Dtos; +using MediatR; + +namespace CCE.Application.PlatformSettings.Queries.GetAboutSettings; + +public sealed record GetAboutSettingsQuery() : IRequest>; diff --git a/backend/src/CCE.Application/PlatformSettings/Queries/GetAboutSettings/GetAboutSettingsQueryHandler.cs b/backend/src/CCE.Application/PlatformSettings/Queries/GetAboutSettings/GetAboutSettingsQueryHandler.cs new file mode 100644 index 00000000..ab4b6534 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Queries/GetAboutSettings/GetAboutSettingsQueryHandler.cs @@ -0,0 +1,55 @@ +using CCE.Application.Common; +using CCE.Application.Common.Interfaces; +using CCE.Application.Common.Pagination; +using CCE.Application.Messages; +using CCE.Application.PlatformSettings.Dtos; +using CCE.Domain.PlatformSettings; +using MediatR; + +namespace CCE.Application.PlatformSettings.Queries.GetAboutSettings; + +public sealed class GetAboutSettingsQueryHandler + : IRequestHandler> +{ + private readonly ICceDbContext _db; + private readonly MessageFactory _msg; + + public GetAboutSettingsQueryHandler(ICceDbContext db, MessageFactory msg) + { + _db = db; + _msg = msg; + } + + public async Task> Handle( + GetAboutSettingsQuery request, CancellationToken cancellationToken) + { + var list = await _db.AboutSettings.ToListAsyncEither(cancellationToken).ConfigureAwait(false); + var settings = list.FirstOrDefault(); + if (settings is null) + return _msg.AboutSettingsNotFound(); + + var glossary = await _db.GlossaryEntries + .Where(e => e.AboutSettingsId == settings.Id) + .OrderBy(e => e.OrderIndex) + .ToListAsyncEither(cancellationToken) + .ConfigureAwait(false); + + var partners = await _db.KnowledgePartners + .Where(p => p.AboutSettingsId == settings.Id) + .OrderBy(p => p.OrderIndex) + .ToListAsyncEither(cancellationToken) + .ConfigureAwait(false); + + return _msg.Ok(new AboutSettingsDto( + settings.Id, + settings.DescriptionAr, + settings.DescriptionEn, + settings.HowToUseVideoUrl, + glossary.Select(e => new GlossaryEntryDto( + e.Id, e.TermAr, e.TermEn, e.DefinitionAr, e.DefinitionEn, e.OrderIndex)).ToList(), + partners.Select(p => new KnowledgePartnerDto( + p.Id, p.NameAr, p.NameEn, p.LogoUrl, p.WebsiteUrl, + p.DescriptionAr, p.DescriptionEn, p.OrderIndex)).ToList(), + Convert.ToBase64String(settings.RowVersion)), "ITEMS_LISTED"); + } +} diff --git a/backend/src/CCE.Application/PlatformSettings/Queries/GetHomepageSettings/GetHomepageSettingsQuery.cs b/backend/src/CCE.Application/PlatformSettings/Queries/GetHomepageSettings/GetHomepageSettingsQuery.cs new file mode 100644 index 00000000..39c97d90 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Queries/GetHomepageSettings/GetHomepageSettingsQuery.cs @@ -0,0 +1,7 @@ +using CCE.Application.Common; +using CCE.Application.PlatformSettings.Dtos; +using MediatR; + +namespace CCE.Application.PlatformSettings.Queries.GetHomepageSettings; + +public sealed record GetHomepageSettingsQuery() : IRequest>; diff --git a/backend/src/CCE.Application/PlatformSettings/Queries/GetHomepageSettings/GetHomepageSettingsQueryHandler.cs b/backend/src/CCE.Application/PlatformSettings/Queries/GetHomepageSettings/GetHomepageSettingsQueryHandler.cs new file mode 100644 index 00000000..ce49bc83 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Queries/GetHomepageSettings/GetHomepageSettingsQueryHandler.cs @@ -0,0 +1,48 @@ +using CCE.Application.Common; +using CCE.Application.Common.Interfaces; +using CCE.Application.Common.Pagination; +using CCE.Application.Messages; +using CCE.Application.PlatformSettings.Dtos; +using CCE.Domain.PlatformSettings; +using MediatR; + +namespace CCE.Application.PlatformSettings.Queries.GetHomepageSettings; + +public sealed class GetHomepageSettingsQueryHandler + : IRequestHandler> +{ + private readonly ICceDbContext _db; + private readonly MessageFactory _msg; + + public GetHomepageSettingsQueryHandler(ICceDbContext db, MessageFactory msg) + { + _db = db; + _msg = msg; + } + + public async Task> Handle( + GetHomepageSettingsQuery request, CancellationToken cancellationToken) + { + var list = await _db.HomepageSettings.ToListAsyncEither(cancellationToken).ConfigureAwait(false); + var settings = list.FirstOrDefault(); + if (settings is null) + return _msg.HomepageSettingsNotFound(); + + var countries = await _db.HomepageCountries + .Where(hc => hc.HomepageSettingsId == settings.Id) + .OrderBy(hc => hc.OrderIndex) + .Select(hc => new HomepageCountryDto(hc.Id, hc.CountryId, hc.OrderIndex)) + .ToListAsyncEither(cancellationToken) + .ConfigureAwait(false); + + return _msg.Ok(new HomepageSettingsDto( + settings.Id, + settings.VideoUrl, + settings.ObjectiveAr, + settings.ObjectiveEn, + settings.CceConceptsAr, + settings.CceConceptsEn, + countries, + Convert.ToBase64String(settings.RowVersion)), "ITEMS_LISTED"); + } +} diff --git a/backend/src/CCE.Application/PlatformSettings/Queries/GetPoliciesSettings/GetPoliciesSettingsQuery.cs b/backend/src/CCE.Application/PlatformSettings/Queries/GetPoliciesSettings/GetPoliciesSettingsQuery.cs new file mode 100644 index 00000000..86ff08b2 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Queries/GetPoliciesSettings/GetPoliciesSettingsQuery.cs @@ -0,0 +1,7 @@ +using CCE.Application.Common; +using CCE.Application.PlatformSettings.Dtos; +using MediatR; + +namespace CCE.Application.PlatformSettings.Queries.GetPoliciesSettings; + +public sealed record GetPoliciesSettingsQuery() : IRequest>; diff --git a/backend/src/CCE.Application/PlatformSettings/Queries/GetPoliciesSettings/GetPoliciesSettingsQueryHandler.cs b/backend/src/CCE.Application/PlatformSettings/Queries/GetPoliciesSettings/GetPoliciesSettingsQueryHandler.cs new file mode 100644 index 00000000..a747475c --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Queries/GetPoliciesSettings/GetPoliciesSettingsQueryHandler.cs @@ -0,0 +1,44 @@ +using CCE.Application.Common; +using CCE.Application.Common.Interfaces; +using CCE.Application.Common.Pagination; +using CCE.Application.Messages; +using CCE.Application.PlatformSettings.Dtos; +using CCE.Domain.PlatformSettings; +using MediatR; + +namespace CCE.Application.PlatformSettings.Queries.GetPoliciesSettings; + +public sealed class GetPoliciesSettingsQueryHandler + : IRequestHandler> +{ + private readonly ICceDbContext _db; + private readonly MessageFactory _msg; + + public GetPoliciesSettingsQueryHandler(ICceDbContext db, MessageFactory msg) + { + _db = db; + _msg = msg; + } + + public async Task> Handle( + GetPoliciesSettingsQuery request, CancellationToken cancellationToken) + { + var list = await _db.PoliciesSettings.ToListAsyncEither(cancellationToken).ConfigureAwait(false); + var settings = list.FirstOrDefault(); + if (settings is null) + return _msg.PoliciesSettingsNotFound(); + + var sections = await _db.PolicySections + .Where(s => s.PoliciesSettingsId == settings.Id) + .OrderBy(s => s.OrderIndex) + .ToListAsyncEither(cancellationToken) + .ConfigureAwait(false); + + return _msg.Ok(new PoliciesSettingsDto( + settings.Id, + sections.Select(s => new PolicySectionDto( + s.Id, (int)s.Type, s.TitleAr, s.TitleEn, + s.ContentAr, s.ContentEn, s.OrderIndex)).ToList(), + Convert.ToBase64String(settings.RowVersion)), "ITEMS_LISTED"); + } +} diff --git a/backend/src/CCE.Domain/PlatformSettings/AboutSettings.cs b/backend/src/CCE.Domain/PlatformSettings/AboutSettings.cs new file mode 100644 index 00000000..40ec764e --- /dev/null +++ b/backend/src/CCE.Domain/PlatformSettings/AboutSettings.cs @@ -0,0 +1,44 @@ +using CCE.Domain.Common; + +namespace CCE.Domain.PlatformSettings; + +[Audited] +public sealed class AboutSettings : AggregateRoot +{ + private AboutSettings( + System.Guid id, + string descriptionAr, + string descriptionEn) : base(id) + { + DescriptionAr = descriptionAr; + DescriptionEn = descriptionEn; + } + + public string DescriptionAr { get; private set; } + public string DescriptionEn { get; private set; } + public string? HowToUseVideoUrl { get; private set; } + public byte[] RowVersion { get; private set; } = System.Array.Empty(); + + public static AboutSettings Create(string descriptionAr, string descriptionEn) + { + if (string.IsNullOrWhiteSpace(descriptionAr)) + throw new DomainException("DescriptionAr is required."); + if (string.IsNullOrWhiteSpace(descriptionEn)) + throw new DomainException("DescriptionEn is required."); + return new AboutSettings(System.Guid.NewGuid(), descriptionAr, descriptionEn); + } + + public void UpdateContent( + string descriptionAr, + string descriptionEn, + string? howToUseVideoUrl) + { + if (string.IsNullOrWhiteSpace(descriptionAr)) + throw new DomainException("DescriptionAr is required."); + if (string.IsNullOrWhiteSpace(descriptionEn)) + throw new DomainException("DescriptionEn is required."); + DescriptionAr = descriptionAr; + DescriptionEn = descriptionEn; + HowToUseVideoUrl = howToUseVideoUrl; + } +} diff --git a/backend/src/CCE.Domain/PlatformSettings/GlossaryEntry.cs b/backend/src/CCE.Domain/PlatformSettings/GlossaryEntry.cs new file mode 100644 index 00000000..c1c6a63e --- /dev/null +++ b/backend/src/CCE.Domain/PlatformSettings/GlossaryEntry.cs @@ -0,0 +1,75 @@ +using CCE.Domain.Common; + +namespace CCE.Domain.PlatformSettings; + +public sealed class GlossaryEntry : AggregateRoot +{ + private GlossaryEntry( + System.Guid id, + System.Guid aboutSettingsId, + string termAr, + string termEn, + string definitionAr, + string definitionEn, + int orderIndex) : base(id) + { + AboutSettingsId = aboutSettingsId; + TermAr = termAr; + TermEn = termEn; + DefinitionAr = definitionAr; + DefinitionEn = definitionEn; + OrderIndex = orderIndex; + } + + public System.Guid AboutSettingsId { get; private set; } + public string TermAr { get; private set; } + public string TermEn { get; private set; } + public string DefinitionAr { get; private set; } + public string DefinitionEn { get; private set; } + public int OrderIndex { get; private set; } + + public static GlossaryEntry Create( + System.Guid aboutSettingsId, + string termAr, + string termEn, + string definitionAr, + string definitionEn, + int orderIndex) + { + if (aboutSettingsId == System.Guid.Empty) + throw new DomainException("AboutSettingsId is required."); + if (string.IsNullOrWhiteSpace(termAr)) + throw new DomainException("TermAr is required."); + if (string.IsNullOrWhiteSpace(termEn)) + throw new DomainException("TermEn is required."); + if (string.IsNullOrWhiteSpace(definitionAr)) + throw new DomainException("DefinitionAr is required."); + if (string.IsNullOrWhiteSpace(definitionEn)) + throw new DomainException("DefinitionEn is required."); + return new GlossaryEntry( + System.Guid.NewGuid(), aboutSettingsId, + termAr, termEn, definitionAr, definitionEn, orderIndex); + } + + public void UpdateContent( + string termAr, + string termEn, + string definitionAr, + string definitionEn) + { + if (string.IsNullOrWhiteSpace(termAr)) + throw new DomainException("TermAr is required."); + if (string.IsNullOrWhiteSpace(termEn)) + throw new DomainException("TermEn is required."); + if (string.IsNullOrWhiteSpace(definitionAr)) + throw new DomainException("DefinitionAr is required."); + if (string.IsNullOrWhiteSpace(definitionEn)) + throw new DomainException("DefinitionEn is required."); + TermAr = termAr; + TermEn = termEn; + DefinitionAr = definitionAr; + DefinitionEn = definitionEn; + } + + public void Reorder(int orderIndex) => OrderIndex = orderIndex; +} diff --git a/backend/src/CCE.Domain/PlatformSettings/HomepageCountry.cs b/backend/src/CCE.Domain/PlatformSettings/HomepageCountry.cs new file mode 100644 index 00000000..31fe83d5 --- /dev/null +++ b/backend/src/CCE.Domain/PlatformSettings/HomepageCountry.cs @@ -0,0 +1,27 @@ +using CCE.Domain.Common; + +namespace CCE.Domain.PlatformSettings; + +public sealed class HomepageCountry : Entity +{ + private HomepageCountry(System.Guid id, System.Guid homepageSettingsId, System.Guid countryId, int orderIndex) + : base(id) + { + HomepageSettingsId = homepageSettingsId; + CountryId = countryId; + OrderIndex = orderIndex; + } + + public System.Guid HomepageSettingsId { get; private set; } + public System.Guid CountryId { get; private set; } + public int OrderIndex { get; private set; } + + public static HomepageCountry Create(System.Guid homepageSettingsId, System.Guid countryId, int orderIndex = 0) + { + if (homepageSettingsId == System.Guid.Empty) + throw new DomainException("HomepageSettingsId is required."); + if (countryId == System.Guid.Empty) + throw new DomainException("CountryId is required."); + return new HomepageCountry(System.Guid.NewGuid(), homepageSettingsId, countryId, orderIndex); + } +} diff --git a/backend/src/CCE.Domain/PlatformSettings/HomepageSettings.cs b/backend/src/CCE.Domain/PlatformSettings/HomepageSettings.cs new file mode 100644 index 00000000..83ba1839 --- /dev/null +++ b/backend/src/CCE.Domain/PlatformSettings/HomepageSettings.cs @@ -0,0 +1,46 @@ +using CCE.Domain.Common; + +namespace CCE.Domain.PlatformSettings; + +[Audited] +public sealed class HomepageSettings : AggregateRoot +{ + private HomepageSettings( + System.Guid id, + string objectiveAr, + string objectiveEn) : base(id) + { + ObjectiveAr = objectiveAr; + ObjectiveEn = objectiveEn; + } + + public string? VideoUrl { get; private set; } + public string ObjectiveAr { get; private set; } + public string ObjectiveEn { get; private set; } + public string CceConceptsAr { get; private set; } = string.Empty; + public string CceConceptsEn { get; private set; } = string.Empty; + public byte[] RowVersion { get; private set; } = System.Array.Empty(); + + public static HomepageSettings Create(string objectiveAr, string objectiveEn) + { + if (string.IsNullOrWhiteSpace(objectiveAr)) throw new DomainException("ObjectiveAr is required."); + if (string.IsNullOrWhiteSpace(objectiveEn)) throw new DomainException("ObjectiveEn is required."); + return new HomepageSettings(System.Guid.NewGuid(), objectiveAr, objectiveEn); + } + + public void UpdateContent( + string? videoUrl, + string objectiveAr, + string objectiveEn, + string cceConceptsAr, + string cceConceptsEn) + { + if (string.IsNullOrWhiteSpace(objectiveAr)) throw new DomainException("ObjectiveAr is required."); + if (string.IsNullOrWhiteSpace(objectiveEn)) throw new DomainException("ObjectiveEn is required."); + VideoUrl = videoUrl; + ObjectiveAr = objectiveAr; + ObjectiveEn = objectiveEn; + CceConceptsAr = cceConceptsAr ?? string.Empty; + CceConceptsEn = cceConceptsEn ?? string.Empty; + } +} diff --git a/backend/src/CCE.Domain/PlatformSettings/KnowledgePartner.cs b/backend/src/CCE.Domain/PlatformSettings/KnowledgePartner.cs new file mode 100644 index 00000000..6ebafe80 --- /dev/null +++ b/backend/src/CCE.Domain/PlatformSettings/KnowledgePartner.cs @@ -0,0 +1,80 @@ +using CCE.Domain.Common; + +namespace CCE.Domain.PlatformSettings; + +public sealed class KnowledgePartner : AggregateRoot +{ + private KnowledgePartner( + System.Guid id, + System.Guid aboutSettingsId, + string nameAr, + string nameEn, + string? logoUrl, + string? websiteUrl, + string? descriptionAr, + string? descriptionEn, + int orderIndex) : base(id) + { + AboutSettingsId = aboutSettingsId; + NameAr = nameAr; + NameEn = nameEn; + LogoUrl = logoUrl; + WebsiteUrl = websiteUrl; + DescriptionAr = descriptionAr; + DescriptionEn = descriptionEn; + OrderIndex = orderIndex; + } + + public System.Guid AboutSettingsId { get; private set; } + public string NameAr { get; private set; } + public string NameEn { get; private set; } + public string? LogoUrl { get; private set; } + public string? WebsiteUrl { get; private set; } + public string? DescriptionAr { get; private set; } + public string? DescriptionEn { get; private set; } + public int OrderIndex { get; private set; } + + public static KnowledgePartner Create( + System.Guid aboutSettingsId, + string nameAr, + string nameEn, + string? logoUrl, + string? websiteUrl, + string? descriptionAr, + string? descriptionEn, + int orderIndex = 0) + { + if (aboutSettingsId == System.Guid.Empty) + throw new DomainException("AboutSettingsId is required."); + if (string.IsNullOrWhiteSpace(nameAr)) + throw new DomainException("NameAr is required."); + if (string.IsNullOrWhiteSpace(nameEn)) + throw new DomainException("NameEn is required."); + return new KnowledgePartner( + System.Guid.NewGuid(), aboutSettingsId, + nameAr, nameEn, logoUrl, websiteUrl, + descriptionAr, descriptionEn, orderIndex); + } + + public void UpdateContent( + string nameAr, + string nameEn, + string? logoUrl, + string? websiteUrl, + string? descriptionAr, + string? descriptionEn) + { + if (string.IsNullOrWhiteSpace(nameAr)) + throw new DomainException("NameAr is required."); + if (string.IsNullOrWhiteSpace(nameEn)) + throw new DomainException("NameEn is required."); + NameAr = nameAr; + NameEn = nameEn; + LogoUrl = logoUrl; + WebsiteUrl = websiteUrl; + DescriptionAr = descriptionAr; + DescriptionEn = descriptionEn; + } + + public void Reorder(int orderIndex) => OrderIndex = orderIndex; +} diff --git a/backend/src/CCE.Domain/PlatformSettings/PoliciesSettings.cs b/backend/src/CCE.Domain/PlatformSettings/PoliciesSettings.cs new file mode 100644 index 00000000..8ef866a7 --- /dev/null +++ b/backend/src/CCE.Domain/PlatformSettings/PoliciesSettings.cs @@ -0,0 +1,16 @@ +using CCE.Domain.Common; + +namespace CCE.Domain.PlatformSettings; + +[Audited] +public sealed class PoliciesSettings : AggregateRoot +{ + private PoliciesSettings(System.Guid id) : base(id) + { + } + + public byte[] RowVersion { get; private set; } = System.Array.Empty(); + + public static PoliciesSettings Create() => + new(System.Guid.NewGuid()); +} diff --git a/backend/src/CCE.Domain/PlatformSettings/PolicySection.cs b/backend/src/CCE.Domain/PlatformSettings/PolicySection.cs new file mode 100644 index 00000000..577555df --- /dev/null +++ b/backend/src/CCE.Domain/PlatformSettings/PolicySection.cs @@ -0,0 +1,79 @@ +using CCE.Domain.Common; + +namespace CCE.Domain.PlatformSettings; + +public sealed class PolicySection : AggregateRoot +{ + private PolicySection( + System.Guid id, + System.Guid policiesSettingsId, + PolicySectionType type, + string titleAr, + string titleEn, + string contentAr, + string contentEn, + int orderIndex) : base(id) + { + PoliciesSettingsId = policiesSettingsId; + Type = type; + TitleAr = titleAr; + TitleEn = titleEn; + ContentAr = contentAr; + ContentEn = contentEn; + OrderIndex = orderIndex; + } + + public System.Guid PoliciesSettingsId { get; private set; } + public PolicySectionType Type { get; private set; } + public string TitleAr { get; private set; } + public string TitleEn { get; private set; } + public string ContentAr { get; private set; } + public string ContentEn { get; private set; } + public int OrderIndex { get; private set; } + + public static PolicySection Create( + System.Guid policiesSettingsId, + PolicySectionType type, + string titleAr, + string titleEn, + string contentAr, + string contentEn, + int orderIndex = 0) + { + if (policiesSettingsId == System.Guid.Empty) + throw new DomainException("PoliciesSettingsId is required."); + if (string.IsNullOrWhiteSpace(titleAr)) + throw new DomainException("TitleAr is required."); + if (string.IsNullOrWhiteSpace(titleEn)) + throw new DomainException("TitleEn is required."); + if (string.IsNullOrWhiteSpace(contentAr)) + throw new DomainException("ContentAr is required."); + if (string.IsNullOrWhiteSpace(contentEn)) + throw new DomainException("ContentEn is required."); + return new PolicySection( + System.Guid.NewGuid(), policiesSettingsId, + type, titleAr, titleEn, contentAr, contentEn, orderIndex); + } + + public void UpdateContent( + string titleAr, + string titleEn, + string contentAr, + string contentEn) + { + if (string.IsNullOrWhiteSpace(titleAr)) + throw new DomainException("TitleAr is required."); + if (string.IsNullOrWhiteSpace(titleEn)) + throw new DomainException("TitleEn is required."); + if (string.IsNullOrWhiteSpace(contentAr)) + throw new DomainException("ContentAr is required."); + if (string.IsNullOrWhiteSpace(contentEn)) + throw new DomainException("ContentEn is required."); + TitleAr = titleAr; + TitleEn = titleEn; + ContentAr = contentAr; + ContentEn = contentEn; + } + + public void Reorder(int orderIndex) => OrderIndex = orderIndex; +} diff --git a/backend/src/CCE.Domain/PlatformSettings/PolicySectionType.cs b/backend/src/CCE.Domain/PlatformSettings/PolicySectionType.cs new file mode 100644 index 00000000..973745c3 --- /dev/null +++ b/backend/src/CCE.Domain/PlatformSettings/PolicySectionType.cs @@ -0,0 +1,10 @@ +namespace CCE.Domain.PlatformSettings; + +public enum PolicySectionType +{ + None = 0, + Policy = 1, + Terms = 2, + Privacy = 3, + FAQ = 4, +} diff --git a/backend/src/CCE.Infrastructure/DependencyInjection.cs b/backend/src/CCE.Infrastructure/DependencyInjection.cs index 8466c9e8..1f9c23a6 100644 --- a/backend/src/CCE.Infrastructure/DependencyInjection.cs +++ b/backend/src/CCE.Infrastructure/DependencyInjection.cs @@ -5,6 +5,7 @@ using CCE.Application.Community; using CCE.Application.Content; using CCE.Application.Content.Public; +using CCE.Application.PlatformSettings; using CCE.Application.Country; using CCE.Application.Identity; using CCE.Application.Identity.Auth.Common; @@ -34,6 +35,7 @@ using CCE.Infrastructure.Localization; using CCE.Infrastructure.Persistence; using CCE.Infrastructure.Persistence.Interceptors; +using CCE.Infrastructure.PlatformSettings; using CCE.Infrastructure.Search; using Microsoft.EntityFrameworkCore; using Microsoft.AspNetCore.Identity; @@ -168,6 +170,12 @@ public static IServiceCollection AddInfrastructure( services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/backend/src/CCE.Infrastructure/Persistence/CceDbContext.cs b/backend/src/CCE.Infrastructure/Persistence/CceDbContext.cs index 7198d594..c8b38da0 100644 --- a/backend/src/CCE.Infrastructure/Persistence/CceDbContext.cs +++ b/backend/src/CCE.Infrastructure/Persistence/CceDbContext.cs @@ -10,6 +10,7 @@ using CCE.Domain.InteractiveCity; using CCE.Domain.KnowledgeMaps; using CCE.Domain.Notifications; +using CCE.Domain.PlatformSettings; using CCE.Domain.Surveys; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity.EntityFrameworkCore; @@ -81,6 +82,15 @@ public CceDbContext(DbContextOptions options) : base(options) { } public DbSet ServiceRatings => Set(); public DbSet SearchQueryLogs => Set(); + // ─── Platform Settings ─── + public DbSet HomepageSettings => Set(); + public DbSet HomepageCountries => Set(); + public DbSet AboutSettings => Set(); + public DbSet GlossaryEntries => Set(); + public DbSet PoliciesSettings => Set(); + public DbSet KnowledgePartners => Set(); + public DbSet PolicySections => Set(); + // ─── ICceDbContext (read-only queryables — no tracking) ─── IQueryable ICceDbContext.Users => Users.AsNoTracking(); IQueryable ICceDbContext.Roles => Roles.AsNoTracking(); @@ -118,6 +128,18 @@ public CceDbContext(DbContextOptions options) : base(options) { } IQueryable ICceDbContext.CityScenarios => CityScenarios.AsNoTracking(); IQueryable ICceDbContext.CityTechnologies => CityTechnologies.AsNoTracking(); IQueryable ICceDbContext.CityScenarioResults => CityScenarioResults.AsNoTracking(); + IQueryable ICceDbContext.HomepageSettings => HomepageSettings.AsNoTracking(); + IQueryable ICceDbContext.HomepageCountries => HomepageCountries.AsNoTracking(); + IQueryable ICceDbContext.AboutSettings => AboutSettings.AsNoTracking(); + IQueryable ICceDbContext.GlossaryEntries => GlossaryEntries.AsNoTracking(); + IQueryable ICceDbContext.PoliciesSettings => PoliciesSettings.AsNoTracking(); + IQueryable ICceDbContext.KnowledgePartners => KnowledgePartners.AsNoTracking(); + IQueryable ICceDbContext.PolicySections => PolicySections.AsNoTracking(); + + void ICceDbContext.Add(T entity) where T : class => Set().Add(entity); + void ICceDbContext.Delete(T entity) where T : class => Set().Remove(entity); + void ICceDbContext.DeleteRange(System.Collections.Generic.IEnumerable entities) where T : class + => Set().RemoveRange(entities); protected override void OnModelCreating(ModelBuilder builder) { diff --git a/backend/src/CCE.Infrastructure/Persistence/Configurations/PlatformSettings/AboutSettingsConfiguration.cs b/backend/src/CCE.Infrastructure/Persistence/Configurations/PlatformSettings/AboutSettingsConfiguration.cs new file mode 100644 index 00000000..322f5138 --- /dev/null +++ b/backend/src/CCE.Infrastructure/Persistence/Configurations/PlatformSettings/AboutSettingsConfiguration.cs @@ -0,0 +1,19 @@ +using CCE.Domain.PlatformSettings; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CCE.Infrastructure.Persistence.Configurations.PlatformSettings; + +internal sealed class AboutSettingsConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(s => s.Id); + builder.Property(s => s.Id).ValueGeneratedNever(); + builder.Property(s => s.DescriptionAr).HasMaxLength(1000).IsRequired(); + builder.Property(s => s.DescriptionEn).HasMaxLength(1000).IsRequired(); + builder.Property(s => s.HowToUseVideoUrl).HasColumnType("nvarchar(max)"); + builder.Property(s => s.RowVersion).IsRowVersion(); + builder.Ignore(s => s.DomainEvents); + } +} diff --git a/backend/src/CCE.Infrastructure/Persistence/Configurations/PlatformSettings/GlossaryEntryConfiguration.cs b/backend/src/CCE.Infrastructure/Persistence/Configurations/PlatformSettings/GlossaryEntryConfiguration.cs new file mode 100644 index 00000000..ecc55e5a --- /dev/null +++ b/backend/src/CCE.Infrastructure/Persistence/Configurations/PlatformSettings/GlossaryEntryConfiguration.cs @@ -0,0 +1,19 @@ +using CCE.Domain.PlatformSettings; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CCE.Infrastructure.Persistence.Configurations.PlatformSettings; + +internal sealed class GlossaryEntryConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(e => e.Id); + builder.Property(e => e.Id).ValueGeneratedNever(); + builder.Property(e => e.TermAr).HasMaxLength(100).IsRequired(); + builder.Property(e => e.TermEn).HasMaxLength(100).IsRequired(); + builder.Property(e => e.DefinitionAr).HasMaxLength(1000).IsRequired(); + builder.Property(e => e.DefinitionEn).HasMaxLength(1000).IsRequired(); + builder.Ignore(e => e.DomainEvents); + } +} diff --git a/backend/src/CCE.Infrastructure/Persistence/Configurations/PlatformSettings/HomepageCountryConfiguration.cs b/backend/src/CCE.Infrastructure/Persistence/Configurations/PlatformSettings/HomepageCountryConfiguration.cs new file mode 100644 index 00000000..a40bb944 --- /dev/null +++ b/backend/src/CCE.Infrastructure/Persistence/Configurations/PlatformSettings/HomepageCountryConfiguration.cs @@ -0,0 +1,17 @@ +using CCE.Domain.PlatformSettings; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CCE.Infrastructure.Persistence.Configurations.PlatformSettings; + +internal sealed class HomepageCountryConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(c => c.Id); + builder.Property(c => c.Id).ValueGeneratedNever(); + builder.HasIndex(c => new { c.HomepageSettingsId, c.CountryId }) + .IsUnique() + .HasDatabaseName("ix_homepage_country_settings_country"); + } +} diff --git a/backend/src/CCE.Infrastructure/Persistence/Configurations/PlatformSettings/HomepageSettingsConfiguration.cs b/backend/src/CCE.Infrastructure/Persistence/Configurations/PlatformSettings/HomepageSettingsConfiguration.cs new file mode 100644 index 00000000..8353eb52 --- /dev/null +++ b/backend/src/CCE.Infrastructure/Persistence/Configurations/PlatformSettings/HomepageSettingsConfiguration.cs @@ -0,0 +1,21 @@ +using CCE.Domain.PlatformSettings; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CCE.Infrastructure.Persistence.Configurations.PlatformSettings; + +internal sealed class HomepageSettingsConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(s => s.Id); + builder.Property(s => s.Id).ValueGeneratedNever(); + builder.Property(s => s.VideoUrl).HasColumnType("nvarchar(max)"); + builder.Property(s => s.ObjectiveAr).HasMaxLength(1000).IsRequired(); + builder.Property(s => s.ObjectiveEn).HasMaxLength(1000).IsRequired(); + builder.Property(s => s.CceConceptsAr).HasColumnType("nvarchar(max)"); + builder.Property(s => s.CceConceptsEn).HasColumnType("nvarchar(max)"); + builder.Property(s => s.RowVersion).IsRowVersion(); + builder.Ignore(s => s.DomainEvents); + } +} diff --git a/backend/src/CCE.Infrastructure/Persistence/Configurations/PlatformSettings/KnowledgePartnerConfiguration.cs b/backend/src/CCE.Infrastructure/Persistence/Configurations/PlatformSettings/KnowledgePartnerConfiguration.cs new file mode 100644 index 00000000..c768fd7b --- /dev/null +++ b/backend/src/CCE.Infrastructure/Persistence/Configurations/PlatformSettings/KnowledgePartnerConfiguration.cs @@ -0,0 +1,21 @@ +using CCE.Domain.PlatformSettings; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CCE.Infrastructure.Persistence.Configurations.PlatformSettings; + +internal sealed class KnowledgePartnerConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(p => p.Id); + builder.Property(p => p.Id).ValueGeneratedNever(); + builder.Property(p => p.NameAr).HasMaxLength(200).IsRequired(); + builder.Property(p => p.NameEn).HasMaxLength(200).IsRequired(); + builder.Property(p => p.LogoUrl).HasColumnType("nvarchar(max)"); + builder.Property(p => p.WebsiteUrl).HasColumnType("nvarchar(max)"); + builder.Property(p => p.DescriptionAr).HasMaxLength(1000); + builder.Property(p => p.DescriptionEn).HasMaxLength(1000); + builder.Ignore(p => p.DomainEvents); + } +} diff --git a/backend/src/CCE.Infrastructure/Persistence/Configurations/PlatformSettings/PoliciesSettingsConfiguration.cs b/backend/src/CCE.Infrastructure/Persistence/Configurations/PlatformSettings/PoliciesSettingsConfiguration.cs new file mode 100644 index 00000000..3cdf3241 --- /dev/null +++ b/backend/src/CCE.Infrastructure/Persistence/Configurations/PlatformSettings/PoliciesSettingsConfiguration.cs @@ -0,0 +1,16 @@ +using CCE.Domain.PlatformSettings; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CCE.Infrastructure.Persistence.Configurations.PlatformSettings; + +internal sealed class PoliciesSettingsConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(s => s.Id); + builder.Property(s => s.Id).ValueGeneratedNever(); + builder.Property(s => s.RowVersion).IsRowVersion(); + builder.Ignore(s => s.DomainEvents); + } +} diff --git a/backend/src/CCE.Infrastructure/Persistence/Configurations/PlatformSettings/PolicySectionConfiguration.cs b/backend/src/CCE.Infrastructure/Persistence/Configurations/PlatformSettings/PolicySectionConfiguration.cs new file mode 100644 index 00000000..158eaa99 --- /dev/null +++ b/backend/src/CCE.Infrastructure/Persistence/Configurations/PlatformSettings/PolicySectionConfiguration.cs @@ -0,0 +1,20 @@ +using CCE.Domain.PlatformSettings; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CCE.Infrastructure.Persistence.Configurations.PlatformSettings; + +internal sealed class PolicySectionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(s => s.Id); + builder.Property(s => s.Id).ValueGeneratedNever(); + builder.Property(s => s.Type).IsRequired(); + builder.Property(s => s.TitleAr).HasMaxLength(500).IsRequired(); + builder.Property(s => s.TitleEn).HasMaxLength(500).IsRequired(); + builder.Property(s => s.ContentAr).HasColumnType("nvarchar(max)").IsRequired(); + builder.Property(s => s.ContentEn).HasColumnType("nvarchar(max)").IsRequired(); + builder.Ignore(s => s.DomainEvents); + } +} diff --git a/backend/src/CCE.Infrastructure/Persistence/Migrations/20260521094531_AddPlatformSettings.Designer.cs b/backend/src/CCE.Infrastructure/Persistence/Migrations/20260521094531_AddPlatformSettings.Designer.cs new file mode 100644 index 00000000..122654cc --- /dev/null +++ b/backend/src/CCE.Infrastructure/Persistence/Migrations/20260521094531_AddPlatformSettings.Designer.cs @@ -0,0 +1,3155 @@ +// +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("20260521094531_AddPlatformSettings")] + partial class AddPlatformSettings + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("CCE.Domain.Audit.AuditEvent", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("action"); + + b.Property("Actor") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("actor"); + + b.Property("CorrelationId") + .HasColumnType("uniqueidentifier") + .HasColumnName("correlation_id"); + + b.Property("Diff") + .HasColumnType("nvarchar(max)") + .HasColumnName("diff"); + + b.Property("OccurredOn") + .HasColumnType("datetimeoffset") + .HasColumnName("occurred_on"); + + b.Property("Resource") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("resource"); + + b.HasKey("Id") + .HasName("pk_audit_events"); + + b.HasIndex("CorrelationId") + .HasDatabaseName("ix_audit_events_correlation_id"); + + b.HasIndex("Actor", "OccurredOn") + .HasDatabaseName("ix_audit_events_actor_occurred_on"); + + b.ToTable("audit_events", null, t => + { + t.HasTrigger("trg_audit_events_no_update_delete"); + }); + + b.HasAnnotation("SqlServer:UseSqlOutputClause", false); + }); + + modelBuilder.Entity("CCE.Domain.Community.Post", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AnsweredReplyId") + .HasColumnType("uniqueidentifier") + .HasColumnName("answered_reply_id"); + + b.Property("AuthorId") + .HasColumnType("uniqueidentifier") + .HasColumnName("author_id"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)") + .HasColumnName("content"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsAnswerable") + .HasColumnType("bit") + .HasColumnName("is_answerable"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("locale"); + + b.Property("TopicId") + .HasColumnType("uniqueidentifier") + .HasColumnName("topic_id"); + + b.HasKey("Id") + .HasName("pk_posts"); + + b.HasIndex("TopicId") + .HasDatabaseName("ix_post_topic_id"); + + b.HasIndex("AuthorId", "CreatedOn") + .HasDatabaseName("ix_post_author_created"); + + b.ToTable("posts", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.PostFollow", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("FollowedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("followed_on"); + + b.Property("PostId") + .HasColumnType("uniqueidentifier") + .HasColumnName("post_id"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_post_follows"); + + b.HasIndex("PostId", "UserId") + .IsUnique() + .HasDatabaseName("ux_post_follow_post_user"); + + b.ToTable("post_follows", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.PostRating", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("PostId") + .HasColumnType("uniqueidentifier") + .HasColumnName("post_id"); + + b.Property("RatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("rated_on"); + + b.Property("Stars") + .HasColumnType("int") + .HasColumnName("stars"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_post_ratings"); + + b.HasIndex("PostId", "UserId") + .IsUnique() + .HasDatabaseName("ux_post_rating_post_user"); + + b.ToTable("post_ratings", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.PostReply", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AuthorId") + .HasColumnType("uniqueidentifier") + .HasColumnName("author_id"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)") + .HasColumnName("content"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsByExpert") + .HasColumnType("bit") + .HasColumnName("is_by_expert"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("locale"); + + b.Property("ParentReplyId") + .HasColumnType("uniqueidentifier") + .HasColumnName("parent_reply_id"); + + b.Property("PostId") + .HasColumnType("uniqueidentifier") + .HasColumnName("post_id"); + + b.HasKey("Id") + .HasName("pk_post_replies"); + + b.HasIndex("ParentReplyId") + .HasDatabaseName("ix_post_reply_parent_id"); + + b.HasIndex("PostId") + .HasDatabaseName("ix_post_reply_post_id"); + + b.ToTable("post_replies", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.Topic", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("DescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("IconUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("icon_url"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.Property("ParentId") + .HasColumnType("uniqueidentifier") + .HasColumnName("parent_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_topics"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ux_topic_slug_active") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("topics", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.TopicFollow", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("FollowedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("followed_on"); + + b.Property("TopicId") + .HasColumnType("uniqueidentifier") + .HasColumnName("topic_id"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_topic_follows"); + + b.HasIndex("TopicId", "UserId") + .IsUnique() + .HasDatabaseName("ux_topic_follow_topic_user"); + + b.ToTable("topic_follows", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.UserFollow", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("FollowedId") + .HasColumnType("uniqueidentifier") + .HasColumnName("followed_id"); + + b.Property("FollowedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("followed_on"); + + b.Property("FollowerId") + .HasColumnType("uniqueidentifier") + .HasColumnName("follower_id"); + + b.HasKey("Id") + .HasName("pk_user_follows"); + + b.HasIndex("FollowerId", "FollowedId") + .IsUnique() + .HasDatabaseName("ux_user_follow_follower_followed"); + + b.ToTable("user_follows", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.AssetFile", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("MimeType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("mime_type"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("original_file_name"); + + b.Property("ScannedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("scanned_on"); + + b.Property("SizeBytes") + .HasColumnType("bigint") + .HasColumnName("size_bytes"); + + b.Property("UploadedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("uploaded_by_id"); + + b.Property("UploadedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("uploaded_on"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("url"); + + b.Property("VirusScanStatus") + .HasColumnType("int") + .HasColumnName("virus_scan_status"); + + b.HasKey("Id") + .HasName("pk_asset_files"); + + b.HasIndex("VirusScanStatus") + .HasDatabaseName("ix_asset_file_scan_status"); + + b.ToTable("asset_files", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.Event", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("DescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("EndsOn") + .HasColumnType("datetimeoffset") + .HasColumnName("ends_on"); + + b.Property("FeaturedImageUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("featured_image_url"); + + b.Property("ICalUid") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("i_cal_uid"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("LocationAr") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("location_ar"); + + b.Property("LocationEn") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("location_en"); + + b.Property("OnlineMeetingUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("online_meeting_url"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.Property("StartsOn") + .HasColumnType("datetimeoffset") + .HasColumnName("starts_on"); + + b.Property("TitleAr") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_ar"); + + b.Property("TitleEn") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_en"); + + b.HasKey("Id") + .HasName("pk_events"); + + b.HasIndex("ICalUid") + .IsUnique() + .HasDatabaseName("ux_event_ical_uid"); + + b.HasIndex("StartsOn") + .HasDatabaseName("ix_event_starts_on"); + + b.ToTable("events", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.HomepageSection", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("ContentAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_ar"); + + b.Property("ContentEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_en"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.Property("SectionType") + .HasColumnType("int") + .HasColumnName("section_type"); + + b.HasKey("Id") + .HasName("pk_homepage_sections"); + + b.HasIndex("IsActive", "OrderIndex") + .HasDatabaseName("ix_homepage_section_active_order"); + + b.ToTable("homepage_sections", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.News", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AuthorId") + .HasColumnType("uniqueidentifier") + .HasColumnName("author_id"); + + b.Property("ContentAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_ar"); + + b.Property("ContentEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_en"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("FeaturedImageUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("featured_image_url"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("IsFeatured") + .HasColumnType("bit") + .HasColumnName("is_featured"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("PublishedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("published_on"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("slug"); + + b.Property("TitleAr") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_ar"); + + b.Property("TitleEn") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_en"); + + b.HasKey("Id") + .HasName("pk_news"); + + b.HasIndex("PublishedOn") + .HasDatabaseName("ix_news_published_on"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ux_news_slug_active") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("news", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.NewsletterSubscription", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("ConfirmationToken") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("confirmation_token"); + + b.Property("ConfirmedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("confirmed_on"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("nvarchar(320)") + .HasColumnName("email"); + + b.Property("IsConfirmed") + .HasColumnType("bit") + .HasColumnName("is_confirmed"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("LocalePreference") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("locale_preference"); + + b.Property("UnsubscribedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("unsubscribed_on"); + + b.HasKey("Id") + .HasName("pk_newsletter_subscriptions"); + + b.HasIndex("ConfirmationToken") + .HasDatabaseName("ix_newsletter_token"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("ux_newsletter_email"); + + b.ToTable("newsletter_subscriptions", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.Page", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("ContentAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_ar"); + + b.Property("ContentEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_en"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("PageType") + .HasColumnType("int") + .HasColumnName("page_type"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("slug"); + + b.Property("TitleAr") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_ar"); + + b.Property("TitleEn") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_en"); + + b.HasKey("Id") + .HasName("pk_pages"); + + b.HasIndex("PageType", "Slug") + .IsUnique() + .HasDatabaseName("ux_page_type_slug_active") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("pages", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.Resource", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AssetFileId") + .HasColumnType("uniqueidentifier") + .HasColumnName("asset_file_id"); + + b.Property("CategoryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("category_id"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("DescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("PublishedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("published_on"); + + b.Property("ResourceType") + .HasColumnType("int") + .HasColumnName("resource_type"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.Property("TitleAr") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_ar"); + + b.Property("TitleEn") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_en"); + + b.Property("UploadedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("uploaded_by_id"); + + b.Property("ViewCount") + .HasColumnType("bigint") + .HasColumnName("view_count"); + + b.HasKey("Id") + .HasName("pk_resources"); + + b.HasIndex("AssetFileId") + .HasDatabaseName("ix_resource_asset_file_id"); + + b.HasIndex("CountryId") + .HasDatabaseName("ix_resource_country_id"); + + b.HasIndex("CategoryId", "PublishedOn") + .HasDatabaseName("ix_resource_category_published"); + + b.ToTable("resources", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.ResourceCategory", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.Property("ParentId") + .HasColumnType("uniqueidentifier") + .HasColumnName("parent_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_resource_categories"); + + b.HasIndex("ParentId") + .HasDatabaseName("ix_resource_category_parent_id"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ux_resource_category_slug"); + + b.ToTable("resource_categories", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Country.Country", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("FlagUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("flag_url"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("IsoAlpha2") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("iso_alpha2"); + + b.Property("IsoAlpha3") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("nvarchar(3)") + .HasColumnName("iso_alpha3"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("LatestKapsarcSnapshotId") + .HasColumnType("uniqueidentifier") + .HasColumnName("latest_kapsarc_snapshot_id"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.Property("RegionAr") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("region_ar"); + + b.Property("RegionEn") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("region_en"); + + b.HasKey("Id") + .HasName("pk_countries"); + + b.HasIndex("IsoAlpha2") + .HasDatabaseName("ix_country_iso_alpha2"); + + b.HasIndex("IsoAlpha3") + .IsUnique() + .HasDatabaseName("ux_country_iso_alpha3_active") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("countries", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Country.CountryKapsarcSnapshot", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("Classification") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("classification"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("PerformanceScore") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)") + .HasColumnName("performance_score"); + + b.Property("SnapshotTakenOn") + .HasColumnType("datetimeoffset") + .HasColumnName("snapshot_taken_on"); + + b.Property("SourceVersion") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)") + .HasColumnName("source_version"); + + b.Property("TotalIndex") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)") + .HasColumnName("total_index"); + + b.HasKey("Id") + .HasName("pk_country_kapsarc_snapshots"); + + b.HasIndex("CountryId", "SnapshotTakenOn") + .HasDatabaseName("ix_kapsarc_snapshot_country_taken"); + + b.ToTable("country_kapsarc_snapshots", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Country.CountryProfile", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("ContactInfoAr") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("contact_info_ar"); + + b.Property("ContactInfoEn") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("contact_info_en"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("KeyInitiativesAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("key_initiatives_ar"); + + b.Property("KeyInitiativesEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("key_initiatives_en"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.HasKey("Id") + .HasName("pk_country_profiles"); + + b.HasIndex("CountryId") + .IsUnique() + .HasDatabaseName("ux_country_profile_country_id"); + + b.ToTable("country_profiles", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Country.CountryResourceRequest", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AdminNotesAr") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("admin_notes_ar"); + + b.Property("AdminNotesEn") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("admin_notes_en"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("ProcessedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("processed_by_id"); + + b.Property("ProcessedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("processed_on"); + + b.Property("ProposedAssetFileId") + .HasColumnType("uniqueidentifier") + .HasColumnName("proposed_asset_file_id"); + + b.Property("ProposedDescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("proposed_description_ar"); + + b.Property("ProposedDescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("proposed_description_en"); + + b.Property("ProposedResourceType") + .HasColumnType("int") + .HasColumnName("proposed_resource_type"); + + b.Property("ProposedTitleAr") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("proposed_title_ar"); + + b.Property("ProposedTitleEn") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("proposed_title_en"); + + b.Property("RequestedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("requested_by_id"); + + b.Property("Status") + .HasColumnType("int") + .HasColumnName("status"); + + b.Property("SubmittedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("submitted_on"); + + b.HasKey("Id") + .HasName("pk_country_resource_requests"); + + b.HasIndex("CountryId", "Status") + .HasDatabaseName("ix_country_request_country_status"); + + b.ToTable("country_resource_requests", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.ExpertProfile", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AcademicTitleAr") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("academic_title_ar"); + + b.Property("AcademicTitleEn") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("academic_title_en"); + + b.Property("ApprovedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("approved_by_id"); + + b.Property("ApprovedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("approved_on"); + + b.Property("BioAr") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("bio_ar"); + + b.Property("BioEn") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("bio_en"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.PrimitiveCollection("ExpertiseTags") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("expertise_tags"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_expert_profiles"); + + b.HasIndex("UserId") + .IsUnique() + .HasDatabaseName("ux_expert_profile_active_user") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("expert_profiles", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.ExpertRegistrationRequest", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("ProcessedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("processed_by_id"); + + b.Property("ProcessedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("processed_on"); + + b.Property("RejectionReasonAr") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("rejection_reason_ar"); + + b.Property("RejectionReasonEn") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("rejection_reason_en"); + + b.Property("RequestedBioAr") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("requested_bio_ar"); + + b.Property("RequestedBioEn") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("requested_bio_en"); + + b.Property("RequestedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("requested_by_id"); + + b.PrimitiveCollection("RequestedTags") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("requested_tags"); + + b.Property("Status") + .HasColumnType("int") + .HasColumnName("status"); + + b.Property("SubmittedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("submitted_on"); + + b.HasKey("Id") + .HasName("pk_expert_registration_requests"); + + b.HasIndex("RequestedById") + .HasDatabaseName("ix_expert_request_requested_by"); + + b.HasIndex("Status") + .HasDatabaseName("ix_expert_request_status"); + + b.ToTable("expert_registration_requests", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.RefreshToken", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset") + .HasColumnName("created_at_utc"); + + b.Property("CreatedByIp") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("created_by_ip"); + + b.Property("ExpiresAtUtc") + .HasColumnType("datetimeoffset") + .HasColumnName("expires_at_utc"); + + b.Property("ReplacedByTokenHash") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("replaced_by_token_hash"); + + b.Property("RevokedAtUtc") + .HasColumnType("datetimeoffset") + .HasColumnName("revoked_at_utc"); + + b.Property("RevokedByIp") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("revoked_by_ip"); + + b.Property("TokenFamilyId") + .HasColumnType("uniqueidentifier") + .HasColumnName("token_family_id"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("token_hash"); + + b.Property("UserAgent") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_refresh_tokens"); + + b.HasIndex("TokenFamilyId") + .HasDatabaseName("ix_refresh_tokens_token_family_id"); + + b.HasIndex("TokenHash") + .IsUnique() + .HasDatabaseName("ux_refresh_tokens_token_hash"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_refresh_tokens_user_id"); + + b.ToTable("refresh_tokens", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)") + .HasColumnName("concurrency_stamp"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("normalized_name"); + + b.HasKey("Id") + .HasName("pk_asp_net_roles"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[normalized_name] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.StateRepresentativeAssignment", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AssignedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("assigned_by_id"); + + b.Property("AssignedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("assigned_on"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("RevokedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("revoked_by_id"); + + b.Property("RevokedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("revoked_on"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_state_representative_assignments"); + + b.HasIndex("CountryId") + .HasDatabaseName("ix_state_rep_country_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_state_rep_user_id"); + + b.HasIndex("UserId", "CountryId") + .IsUnique() + .HasDatabaseName("ux_state_rep_active_user_country") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("state_representative_assignments", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AccessFailedCount") + .HasColumnType("int") + .HasColumnName("access_failed_count"); + + b.Property("AvatarUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("avatar_url"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)") + .HasColumnName("concurrency_stamp"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("email"); + + b.Property("EmailConfirmed") + .HasColumnType("bit") + .HasColumnName("email_confirmed"); + + b.Property("EntraIdObjectId") + .HasColumnType("uniqueidentifier") + .HasColumnName("entra_id_object_id"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)") + .HasColumnName("first_name"); + + b.PrimitiveCollection("Interests") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("interests"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("JobTitle") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)") + .HasColumnName("job_title"); + + b.Property("KnowledgeLevel") + .HasColumnType("int") + .HasColumnName("knowledge_level"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)") + .HasColumnName("last_name"); + + b.Property("LocalePreference") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("locale_preference"); + + b.Property("LockoutEnabled") + .HasColumnType("bit") + .HasColumnName("lockout_enabled"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset") + .HasColumnName("lockout_end"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("normalized_email"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("normalized_user_name"); + + b.Property("OrganizationName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("organization_name"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)") + .HasColumnName("password_hash"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)") + .HasColumnName("phone_number"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit") + .HasColumnName("phone_number_confirmed"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)") + .HasColumnName("security_stamp"); + + b.Property("Status") + .HasColumnType("int") + .HasColumnName("status"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit") + .HasColumnName("two_factor_enabled"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("user_name"); + + b.HasKey("Id") + .HasName("pk_asp_net_users"); + + b.HasIndex("CountryId") + .HasDatabaseName("ix_users_country_id"); + + b.HasIndex("EntraIdObjectId") + .IsUnique() + .HasDatabaseName("ix_asp_net_users_entra_id_object_id") + .HasFilter("[entra_id_object_id] IS NOT NULL"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[normalized_user_name] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.InteractiveCity.CityScenario", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CityType") + .HasColumnType("int") + .HasColumnName("city_type"); + + b.Property("ConfigurationJson") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("configuration_json"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.Property("TargetYear") + .HasColumnType("int") + .HasColumnName("target_year"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_city_scenarios"); + + b.HasIndex("UserId", "LastModifiedOn") + .HasDatabaseName("ix_city_scenario_user_modified"); + + b.ToTable("city_scenarios", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.InteractiveCity.CityScenarioResult", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("ComputedAt") + .HasColumnType("datetimeoffset") + .HasColumnName("computed_at"); + + b.Property("ComputedCarbonNeutralityYear") + .HasColumnType("int") + .HasColumnName("computed_carbon_neutrality_year"); + + b.Property("ComputedTotalCostUsd") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)") + .HasColumnName("computed_total_cost_usd"); + + b.Property("EngineVersion") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("engine_version"); + + b.Property("ScenarioId") + .HasColumnType("uniqueidentifier") + .HasColumnName("scenario_id"); + + b.HasKey("Id") + .HasName("pk_city_scenario_results"); + + b.HasIndex("ScenarioId", "ComputedAt") + .HasDatabaseName("ix_city_result_scenario_at"); + + b.ToTable("city_scenario_results", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.InteractiveCity.CityTechnology", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CarbonImpactKgPerYear") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)") + .HasColumnName("carbon_impact_kg_per_year"); + + b.Property("CategoryAr") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("category_ar"); + + b.Property("CategoryEn") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("category_en"); + + b.Property("CostUsd") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)") + .HasColumnName("cost_usd"); + + b.Property("DescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("IconUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("icon_url"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.HasKey("Id") + .HasName("pk_city_technologies"); + + b.HasIndex("IsActive") + .HasDatabaseName("ix_city_tech_is_active"); + + b.ToTable("city_technologies", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.KnowledgeMaps.KnowledgeMap", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("DescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_knowledge_maps"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ux_knowledge_map_slug_active") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("knowledge_maps", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.KnowledgeMaps.KnowledgeMapAssociation", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AssociatedId") + .HasColumnType("uniqueidentifier") + .HasColumnName("associated_id"); + + b.Property("AssociatedType") + .HasColumnType("int") + .HasColumnName("associated_type"); + + b.Property("NodeId") + .HasColumnType("uniqueidentifier") + .HasColumnName("node_id"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.HasKey("Id") + .HasName("pk_knowledge_map_associations"); + + b.HasIndex("NodeId", "AssociatedType", "AssociatedId") + .IsUnique() + .HasDatabaseName("ux_km_assoc_node_type_id"); + + b.ToTable("knowledge_map_associations", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.KnowledgeMaps.KnowledgeMapEdge", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("FromNodeId") + .HasColumnType("uniqueidentifier") + .HasColumnName("from_node_id"); + + b.Property("MapId") + .HasColumnType("uniqueidentifier") + .HasColumnName("map_id"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.Property("RelationshipType") + .HasColumnType("int") + .HasColumnName("relationship_type"); + + b.Property("ToNodeId") + .HasColumnType("uniqueidentifier") + .HasColumnName("to_node_id"); + + b.HasKey("Id") + .HasName("pk_knowledge_map_edges"); + + b.HasIndex("FromNodeId") + .HasDatabaseName("ix_km_edge_from_node"); + + b.HasIndex("ToNodeId") + .HasDatabaseName("ix_km_edge_to_node"); + + b.HasIndex("MapId", "FromNodeId", "ToNodeId", "RelationshipType") + .IsUnique() + .HasDatabaseName("ux_km_edge_map_from_to_relation"); + + b.ToTable("knowledge_map_edges", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.KnowledgeMaps.KnowledgeMapNode", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("DescriptionAr") + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("IconUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("icon_url"); + + b.Property("LayoutX") + .HasColumnType("float") + .HasColumnName("layout_x"); + + b.Property("LayoutY") + .HasColumnType("float") + .HasColumnName("layout_y"); + + b.Property("MapId") + .HasColumnType("uniqueidentifier") + .HasColumnName("map_id"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.Property("NodeType") + .HasColumnType("int") + .HasColumnName("node_type"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.HasKey("Id") + .HasName("pk_knowledge_map_nodes"); + + b.HasIndex("MapId", "OrderIndex") + .HasDatabaseName("ix_km_node_map_order"); + + b.ToTable("knowledge_map_nodes", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.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") + .IsUnique() + .HasDatabaseName("ux_notification_template_code"); + + b.ToTable("notification_templates", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Notifications.UserNotification", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("Channel") + .HasColumnType("int") + .HasColumnName("channel"); + + b.Property("ReadOn") + .HasColumnType("datetimeoffset") + .HasColumnName("read_on"); + + b.Property("RenderedBody") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("rendered_body"); + + b.Property("RenderedLocale") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("rendered_locale"); + + b.Property("RenderedSubjectAr") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("rendered_subject_ar"); + + b.Property("RenderedSubjectEn") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("rendered_subject_en"); + + b.Property("SentOn") + .HasColumnType("datetimeoffset") + .HasColumnName("sent_on"); + + b.Property("Status") + .HasColumnType("int") + .HasColumnName("status"); + + b.Property("TemplateId") + .HasColumnType("uniqueidentifier") + .HasColumnName("template_id"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_user_notifications"); + + b.HasIndex("UserId", "Status") + .HasDatabaseName("ix_user_notification_user_status"); + + b.ToTable("user_notifications", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.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("DescriptionAr") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("description_en"); + + 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("DefinitionAr") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("definition_ar"); + + b.Property("DefinitionEn") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("definition_en"); + + 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("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.Property("TermAr") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("term_ar"); + + b.Property("TermEn") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("term_en"); + + b.HasKey("Id") + .HasName("pk_glossary_entries"); + + 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("HomepageSettingsId") + .HasColumnType("uniqueidentifier") + .HasColumnName("homepage_settings_id"); + + 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("ObjectiveAr") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("objective_ar"); + + b.Property("ObjectiveEn") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("objective_en"); + + 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("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("DescriptionAr") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("description_en"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("LogoUrl") + .HasColumnType("nvarchar(max)") + .HasColumnName("logo_url"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)") + .HasColumnName("name_en"); + + 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.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("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("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.Property("PoliciesSettingsId") + .HasColumnType("uniqueidentifier") + .HasColumnName("policies_settings_id"); + + b.Property("TitleAr") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasColumnName("title_ar"); + + b.Property("TitleEn") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasColumnName("title_en"); + + b.Property("Type") + .HasColumnType("int") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_policy_sections"); + + b.ToTable("policy_sections", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Surveys.SearchQueryLog", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("locale"); + + b.Property("QueryText") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("query_text"); + + b.Property("ResponseTimeMs") + .HasColumnType("int") + .HasColumnName("response_time_ms"); + + b.Property("ResultsCount") + .HasColumnType("int") + .HasColumnName("results_count"); + + b.Property("SubmittedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("submitted_on"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_search_query_logs"); + + b.HasIndex("SubmittedOn") + .HasDatabaseName("ix_search_query_log_submitted_on"); + + b.ToTable("search_query_logs", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Surveys.ServiceRating", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CommentAr") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("comment_ar"); + + b.Property("CommentEn") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("comment_en"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("locale"); + + b.Property("Page") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("page"); + + b.Property("Rating") + .HasColumnType("int") + .HasColumnName("rating"); + + b.Property("SubmittedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("submitted_on"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_service_ratings"); + + b.HasIndex("SubmittedOn") + .HasDatabaseName("ix_service_rating_submitted_on"); + + b.ToTable("service_ratings", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)") + .HasColumnName("claim_type"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)") + .HasColumnName("claim_value"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier") + .HasColumnName("role_id"); + + b.HasKey("Id") + .HasName("pk_asp_net_role_claims"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_asp_net_role_claims_role_id"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)") + .HasColumnName("claim_type"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)") + .HasColumnName("claim_value"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_asp_net_user_claims"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_asp_net_user_claims_user_id"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)") + .HasColumnName("login_provider"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)") + .HasColumnName("provider_key"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)") + .HasColumnName("provider_display_name"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("LoginProvider", "ProviderKey") + .HasName("pk_asp_net_user_logins"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_asp_net_user_logins_user_id"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier") + .HasColumnName("role_id"); + + b.HasKey("UserId", "RoleId") + .HasName("pk_asp_net_user_roles"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_asp_net_user_roles_role_id"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)") + .HasColumnName("login_provider"); + + b.Property("Name") + .HasColumnType("nvarchar(450)") + .HasColumnName("name"); + + b.Property("Value") + .HasColumnType("nvarchar(max)") + .HasColumnName("value"); + + b.HasKey("UserId", "LoginProvider", "Name") + .HasName("pk_asp_net_user_tokens"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.RefreshToken", b => + { + b.HasOne("CCE.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_refresh_tokens_asp_net_users_user_id"); + }); + + modelBuilder.Entity("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"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/CCE.Infrastructure/Persistence/Migrations/20260521094531_AddPlatformSettings.cs b/backend/src/CCE.Infrastructure/Persistence/Migrations/20260521094531_AddPlatformSettings.cs new file mode 100644 index 00000000..9c4cf65c --- /dev/null +++ b/backend/src/CCE.Infrastructure/Persistence/Migrations/20260521094531_AddPlatformSettings.cs @@ -0,0 +1,200 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CCE.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddPlatformSettings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "about_settings", + columns: table => new + { + id = table.Column(type: "uniqueidentifier", nullable: false), + description_ar = table.Column(type: "nvarchar(1000)", maxLength: 1000, nullable: false), + description_en = table.Column(type: "nvarchar(1000)", maxLength: 1000, nullable: false), + how_to_use_video_url = table.Column(type: "nvarchar(max)", nullable: true), + row_version = table.Column(type: "rowversion", rowVersion: true, nullable: false), + created_on = table.Column(type: "datetimeoffset", nullable: false), + created_by_id = table.Column(type: "uniqueidentifier", nullable: false), + last_modified_on = table.Column(type: "datetimeoffset", nullable: true), + last_modified_by_id = table.Column(type: "uniqueidentifier", nullable: true), + is_deleted = table.Column(type: "bit", nullable: false), + deleted_on = table.Column(type: "datetimeoffset", nullable: true), + deleted_by_id = table.Column(type: "uniqueidentifier", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_about_settings", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "glossary_entries", + columns: table => new + { + id = table.Column(type: "uniqueidentifier", nullable: false), + about_settings_id = table.Column(type: "uniqueidentifier", nullable: false), + term_ar = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + term_en = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + definition_ar = table.Column(type: "nvarchar(1000)", maxLength: 1000, nullable: false), + definition_en = table.Column(type: "nvarchar(1000)", maxLength: 1000, nullable: false), + order_index = table.Column(type: "int", nullable: false), + created_on = table.Column(type: "datetimeoffset", nullable: false), + created_by_id = table.Column(type: "uniqueidentifier", nullable: false), + last_modified_on = table.Column(type: "datetimeoffset", nullable: true), + last_modified_by_id = table.Column(type: "uniqueidentifier", nullable: true), + is_deleted = table.Column(type: "bit", nullable: false), + deleted_on = table.Column(type: "datetimeoffset", nullable: true), + deleted_by_id = table.Column(type: "uniqueidentifier", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_glossary_entries", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "homepage_countries", + columns: table => new + { + id = table.Column(type: "uniqueidentifier", nullable: false), + homepage_settings_id = table.Column(type: "uniqueidentifier", nullable: false), + country_id = table.Column(type: "uniqueidentifier", nullable: false), + order_index = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_homepage_countries", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "homepage_settings", + columns: table => new + { + id = table.Column(type: "uniqueidentifier", nullable: false), + video_url = table.Column(type: "nvarchar(max)", nullable: true), + objective_ar = table.Column(type: "nvarchar(1000)", maxLength: 1000, nullable: false), + objective_en = table.Column(type: "nvarchar(1000)", maxLength: 1000, nullable: false), + cce_concepts_ar = table.Column(type: "nvarchar(max)", nullable: false), + cce_concepts_en = table.Column(type: "nvarchar(max)", nullable: false), + row_version = table.Column(type: "rowversion", rowVersion: true, nullable: false), + created_on = table.Column(type: "datetimeoffset", nullable: false), + created_by_id = table.Column(type: "uniqueidentifier", nullable: false), + last_modified_on = table.Column(type: "datetimeoffset", nullable: true), + last_modified_by_id = table.Column(type: "uniqueidentifier", nullable: true), + is_deleted = table.Column(type: "bit", nullable: false), + deleted_on = table.Column(type: "datetimeoffset", nullable: true), + deleted_by_id = table.Column(type: "uniqueidentifier", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_homepage_settings", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "knowledge_partners", + columns: table => new + { + id = table.Column(type: "uniqueidentifier", nullable: false), + about_settings_id = table.Column(type: "uniqueidentifier", nullable: false), + name_ar = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + name_en = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + logo_url = table.Column(type: "nvarchar(max)", nullable: true), + website_url = table.Column(type: "nvarchar(max)", nullable: true), + description_ar = table.Column(type: "nvarchar(1000)", maxLength: 1000, nullable: true), + description_en = table.Column(type: "nvarchar(1000)", maxLength: 1000, nullable: true), + order_index = table.Column(type: "int", nullable: false), + created_on = table.Column(type: "datetimeoffset", nullable: false), + created_by_id = table.Column(type: "uniqueidentifier", nullable: false), + last_modified_on = table.Column(type: "datetimeoffset", nullable: true), + last_modified_by_id = table.Column(type: "uniqueidentifier", nullable: true), + is_deleted = table.Column(type: "bit", nullable: false), + deleted_on = table.Column(type: "datetimeoffset", nullable: true), + deleted_by_id = table.Column(type: "uniqueidentifier", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_knowledge_partners", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "policies_settings", + columns: table => new + { + id = table.Column(type: "uniqueidentifier", nullable: false), + row_version = table.Column(type: "rowversion", rowVersion: true, nullable: false), + created_on = table.Column(type: "datetimeoffset", nullable: false), + created_by_id = table.Column(type: "uniqueidentifier", nullable: false), + last_modified_on = table.Column(type: "datetimeoffset", nullable: true), + last_modified_by_id = table.Column(type: "uniqueidentifier", nullable: true), + is_deleted = table.Column(type: "bit", nullable: false), + deleted_on = table.Column(type: "datetimeoffset", nullable: true), + deleted_by_id = table.Column(type: "uniqueidentifier", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_policies_settings", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "policy_sections", + columns: table => new + { + id = table.Column(type: "uniqueidentifier", nullable: false), + policies_settings_id = table.Column(type: "uniqueidentifier", nullable: false), + type = table.Column(type: "int", nullable: false), + title_ar = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: false), + title_en = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: false), + content_ar = table.Column(type: "nvarchar(max)", nullable: false), + content_en = table.Column(type: "nvarchar(max)", nullable: false), + order_index = table.Column(type: "int", nullable: false), + created_on = table.Column(type: "datetimeoffset", nullable: false), + created_by_id = table.Column(type: "uniqueidentifier", nullable: false), + last_modified_on = table.Column(type: "datetimeoffset", nullable: true), + last_modified_by_id = table.Column(type: "uniqueidentifier", nullable: true), + is_deleted = table.Column(type: "bit", nullable: false), + deleted_on = table.Column(type: "datetimeoffset", nullable: true), + deleted_by_id = table.Column(type: "uniqueidentifier", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_policy_sections", x => x.id); + }); + + migrationBuilder.CreateIndex( + name: "ix_homepage_country_settings_country", + table: "homepage_countries", + columns: new[] { "homepage_settings_id", "country_id" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "about_settings"); + + migrationBuilder.DropTable( + name: "glossary_entries"); + + migrationBuilder.DropTable( + name: "homepage_countries"); + + migrationBuilder.DropTable( + name: "homepage_settings"); + + migrationBuilder.DropTable( + name: "knowledge_partners"); + + migrationBuilder.DropTable( + name: "policies_settings"); + + migrationBuilder.DropTable( + name: "policy_sections"); + } + } +} diff --git a/backend/src/CCE.Infrastructure/Persistence/Migrations/CceDbContextModelSnapshot.cs b/backend/src/CCE.Infrastructure/Persistence/Migrations/CceDbContextModelSnapshot.cs index 063f9753..d84b196c 100644 --- a/backend/src/CCE.Infrastructure/Persistence/Migrations/CceDbContextModelSnapshot.cs +++ b/backend/src/CCE.Infrastructure/Persistence/Migrations/CceDbContextModelSnapshot.cs @@ -2423,6 +2423,441 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("user_notifications", (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("DescriptionAr") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("description_en"); + + 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("DefinitionAr") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("definition_ar"); + + b.Property("DefinitionEn") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("definition_en"); + + 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("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.Property("TermAr") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("term_ar"); + + b.Property("TermEn") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("term_en"); + + b.HasKey("Id") + .HasName("pk_glossary_entries"); + + 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("HomepageSettingsId") + .HasColumnType("uniqueidentifier") + .HasColumnName("homepage_settings_id"); + + 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("ObjectiveAr") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("objective_ar"); + + b.Property("ObjectiveEn") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("objective_en"); + + 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("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("DescriptionAr") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("description_en"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("LogoUrl") + .HasColumnType("nvarchar(max)") + .HasColumnName("logo_url"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)") + .HasColumnName("name_en"); + + 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.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("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("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.Property("PoliciesSettingsId") + .HasColumnType("uniqueidentifier") + .HasColumnName("policies_settings_id"); + + b.Property("TitleAr") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasColumnName("title_ar"); + + b.Property("TitleEn") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasColumnName("title_en"); + + b.Property("Type") + .HasColumnType("int") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_policy_sections"); + + b.ToTable("policy_sections", (string)null); + }); + modelBuilder.Entity("CCE.Domain.Surveys.SearchQueryLog", b => { b.Property("Id") diff --git a/backend/src/CCE.Infrastructure/PlatformSettings/AboutSettingsRepository.cs b/backend/src/CCE.Infrastructure/PlatformSettings/AboutSettingsRepository.cs new file mode 100644 index 00000000..90ea518e --- /dev/null +++ b/backend/src/CCE.Infrastructure/PlatformSettings/AboutSettingsRepository.cs @@ -0,0 +1,16 @@ +using CCE.Application.PlatformSettings; +using CCE.Domain.PlatformSettings; +using CCE.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace CCE.Infrastructure.PlatformSettings; + +public sealed class AboutSettingsRepository : IAboutSettingsRepository +{ + private readonly CceDbContext _db; + + public AboutSettingsRepository(CceDbContext db) => _db = db; + + public async Task GetAsync(CancellationToken ct) + => await _db.AboutSettings.FirstOrDefaultAsync(ct).ConfigureAwait(false); +} diff --git a/backend/src/CCE.Infrastructure/PlatformSettings/GlossaryEntryRepository.cs b/backend/src/CCE.Infrastructure/PlatformSettings/GlossaryEntryRepository.cs new file mode 100644 index 00000000..22d85ed0 --- /dev/null +++ b/backend/src/CCE.Infrastructure/PlatformSettings/GlossaryEntryRepository.cs @@ -0,0 +1,16 @@ +using CCE.Application.PlatformSettings; +using CCE.Domain.PlatformSettings; +using CCE.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace CCE.Infrastructure.PlatformSettings; + +public sealed class GlossaryEntryRepository : IGlossaryEntryRepository +{ + private readonly CceDbContext _db; + + public GlossaryEntryRepository(CceDbContext db) => _db = db; + + public async Task FindAsync(System.Guid id, CancellationToken ct) + => await _db.GlossaryEntries.FirstOrDefaultAsync(e => e.Id == id, ct).ConfigureAwait(false); +} diff --git a/backend/src/CCE.Infrastructure/PlatformSettings/HomepageSettingsRepository.cs b/backend/src/CCE.Infrastructure/PlatformSettings/HomepageSettingsRepository.cs new file mode 100644 index 00000000..b17167ff --- /dev/null +++ b/backend/src/CCE.Infrastructure/PlatformSettings/HomepageSettingsRepository.cs @@ -0,0 +1,16 @@ +using CCE.Application.PlatformSettings; +using CCE.Domain.PlatformSettings; +using CCE.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace CCE.Infrastructure.PlatformSettings; + +public sealed class HomepageSettingsRepository : IHomepageSettingsRepository +{ + private readonly CceDbContext _db; + + public HomepageSettingsRepository(CceDbContext db) => _db = db; + + public async Task GetAsync(CancellationToken ct) + => await _db.HomepageSettings.FirstOrDefaultAsync(ct).ConfigureAwait(false); +} diff --git a/backend/src/CCE.Infrastructure/PlatformSettings/KnowledgePartnerRepository.cs b/backend/src/CCE.Infrastructure/PlatformSettings/KnowledgePartnerRepository.cs new file mode 100644 index 00000000..aff8c8f7 --- /dev/null +++ b/backend/src/CCE.Infrastructure/PlatformSettings/KnowledgePartnerRepository.cs @@ -0,0 +1,16 @@ +using CCE.Application.PlatformSettings; +using CCE.Domain.PlatformSettings; +using CCE.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace CCE.Infrastructure.PlatformSettings; + +public sealed class KnowledgePartnerRepository : IKnowledgePartnerRepository +{ + private readonly CceDbContext _db; + + public KnowledgePartnerRepository(CceDbContext db) => _db = db; + + public async Task FindAsync(System.Guid id, CancellationToken ct) + => await _db.KnowledgePartners.FirstOrDefaultAsync(p => p.Id == id, ct).ConfigureAwait(false); +} diff --git a/backend/src/CCE.Infrastructure/PlatformSettings/PoliciesSettingsRepository.cs b/backend/src/CCE.Infrastructure/PlatformSettings/PoliciesSettingsRepository.cs new file mode 100644 index 00000000..e947ad08 --- /dev/null +++ b/backend/src/CCE.Infrastructure/PlatformSettings/PoliciesSettingsRepository.cs @@ -0,0 +1,16 @@ +using CCE.Application.PlatformSettings; +using CCE.Domain.PlatformSettings; +using CCE.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace CCE.Infrastructure.PlatformSettings; + +public sealed class PoliciesSettingsRepository : IPoliciesSettingsRepository +{ + private readonly CceDbContext _db; + + public PoliciesSettingsRepository(CceDbContext db) => _db = db; + + public async Task GetAsync(CancellationToken ct) + => await _db.PoliciesSettings.FirstOrDefaultAsync(ct).ConfigureAwait(false); +} diff --git a/backend/src/CCE.Infrastructure/PlatformSettings/PolicySectionRepository.cs b/backend/src/CCE.Infrastructure/PlatformSettings/PolicySectionRepository.cs new file mode 100644 index 00000000..c814d72e --- /dev/null +++ b/backend/src/CCE.Infrastructure/PlatformSettings/PolicySectionRepository.cs @@ -0,0 +1,16 @@ +using CCE.Application.PlatformSettings; +using CCE.Domain.PlatformSettings; +using CCE.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace CCE.Infrastructure.PlatformSettings; + +public sealed class PolicySectionRepository : IPolicySectionRepository +{ + private readonly CceDbContext _db; + + public PolicySectionRepository(CceDbContext db) => _db = db; + + public async Task FindAsync(System.Guid id, CancellationToken ct) + => await _db.PolicySections.FirstOrDefaultAsync(s => s.Id == id, ct).ConfigureAwait(false); +} diff --git a/backend/src/CCE.Seeder/Seeders/ReferenceDataSeeder.cs b/backend/src/CCE.Seeder/Seeders/ReferenceDataSeeder.cs index 5d81d5a9..14c2398d 100644 --- a/backend/src/CCE.Seeder/Seeders/ReferenceDataSeeder.cs +++ b/backend/src/CCE.Seeder/Seeders/ReferenceDataSeeder.cs @@ -1,6 +1,7 @@ using CCE.Domain.Common; using CCE.Domain.Community; using CCE.Domain.Content; +using CCE.Domain.PlatformSettings; using CCE.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -36,6 +37,7 @@ public async Task SeedAsync(CancellationToken cancellationToken = default) await SeedNotificationTemplatesAsync(cancellationToken).ConfigureAwait(false); await SeedStaticPagesAsync(cancellationToken).ConfigureAwait(false); await SeedHomepageSectionsAsync(cancellationToken).ConfigureAwait(false); + await SeedPlatformSettingsAsync(cancellationToken).ConfigureAwait(false); await _ctx.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } @@ -251,4 +253,32 @@ private async Task SeedHomepageSectionsAsync(CancellationToken ct) _ctx.HomepageSections.Add(section); } } + + // ─── Platform Settings (singleton rows) ─── + private async Task SeedPlatformSettingsAsync(CancellationToken ct) + { + var hcId = DeterministicGuid.From("platform_settings:homepage"); + if (!await _ctx.HomepageSettings.AnyAsync(x => x.Id == hcId, ct).ConfigureAwait(false)) + { + var hs = HomepageSettings.Create("أهداف المنصة", "Platform objectives"); + typeof(HomepageSettings).GetProperty(nameof(hs.Id))!.SetValue(hs, hcId); + _ctx.HomepageSettings.Add(hs); + } + + var acId = DeterministicGuid.From("platform_settings:about"); + if (!await _ctx.AboutSettings.AnyAsync(x => x.Id == acId, ct).ConfigureAwait(false)) + { + var ac = AboutSettings.Create("وصف المنصة", "Platform description"); + typeof(AboutSettings).GetProperty(nameof(ac.Id))!.SetValue(ac, acId); + _ctx.AboutSettings.Add(ac); + } + + var pcId = DeterministicGuid.From("platform_settings:policies"); + if (!await _ctx.PoliciesSettings.AnyAsync(x => x.Id == pcId, ct).ConfigureAwait(false)) + { + var pc = PoliciesSettings.Create(); + typeof(PoliciesSettings).GetProperty(nameof(pc.Id))!.SetValue(pc, pcId); + _ctx.PoliciesSettings.Add(pc); + } + } } From 216e49b72df9a33d6d0fe0edf70acfe8777593de Mon Sep 17 00:00:00 2001 From: MOHAMED RASHED Date: Thu, 21 May 2026 18:41:02 +0300 Subject: [PATCH 16/22] feat(media): implement Media Upload Service with CRUD APIs - Add MediaFile entity, EF config, repository, upload options - Add UploadMedia, UpdateMediaMetadata, DeleteMedia, GetMediaById commands/queries - Add MediaFileBriefDto for consistent POST/PUT/DELETE response shape - Add Internal (port 5002) and External (port 5001) REST endpoints - Add MEDIA_UPLOADED/UPDATED/DELETED localized success messages (AR/EN) - Add ERR110-ERR113 error codes for file validation - Create and apply EF migration AddMediaService - Build 0 errors, 773 tests pass (4 pre-existing failures) --- .../Localization/Resources.yaml | 30 + .../Endpoints/MediaPublicEndpoints.cs | 97 + backend/src/CCE.Api.External/Program.cs | 2 + .../appsettings.Development.json | 3 + backend/src/CCE.Api.External/appsettings.json | 21 + .../05/50eb086f886f4e34bf0b79406e7cbf48.jpg | Bin 0 -> 1626012 bytes .../05/c6b80dc4d4e24f08be7686ee11043b5e.png | Bin 0 -> 6115 bytes .../05/c792a2fe9fb54640a38d5841c7f0b12b.jpg | Bin 0 -> 1626012 bytes .../05/f1d4295616ab4cb98cef641508ff96c6.jpg | Bin 0 -> 1626012 bytes .../05/f9339b9b8e5c45c49014259f22da6ca0.jpg | Bin 0 -> 1626012 bytes .../Endpoints/MediaEndpoints.cs | 98 + backend/src/CCE.Api.Internal/Program.cs | 2 + .../appsettings.Development.json | 3 + backend/src/CCE.Api.Internal/appsettings.json | 21 + .../Common/Interfaces/ICceDbContext.cs | 4 + .../DeleteMedia/DeleteMediaCommand.cs | 7 + .../DeleteMedia/DeleteMediaCommandHandler.cs | 46 + .../DeleteMediaCommandValidator.cs | 12 + .../UpdateMediaMetadataCommand.cs | 14 + .../UpdateMediaMetadataCommandHandler.cs | 46 + .../UpdateMediaMetadataCommandValidator.cs | 18 + .../UploadMedia/UploadMediaCommand.cs | 17 + .../UploadMedia/UploadMediaCommandHandler.cs | 82 + .../UploadMediaCommandValidator.cs | 20 + .../Media/Dtos/MediaFileBriefDto.cs | 6 + .../Media/Dtos/MediaFileDto.cs | 26 + .../Media/IMediaFileRepository.cs | 8 + .../Media/MediaUploadOptions.cs | 20 + .../Queries/GetMediaById/GetMediaByIdQuery.cs | 7 + .../GetMediaById/GetMediaByIdQueryHandler.cs | 31 + .../Messages/MessageFactory.cs | 7 + .../CCE.Application/Messages/SystemCode.cs | 6 + .../CCE.Application/Messages/SystemCodeMap.cs | 11 + backend/src/CCE.Domain/Media/MediaFile.cs | 113 + .../CceInfrastructureOptions.cs | 3 + .../CCE.Infrastructure/DependencyInjection.cs | 11 + .../Files/LocalFileStorage.cs | 6 + .../Media/MediaFileRepository.cs | 16 + .../Persistence/CceDbContext.cs | 5 + .../Media/MediaFileConfiguration.cs | 24 + ...20260521111720_AddMediaService.Designer.cs | 3233 +++++++++++++++++ .../20260521111720_AddMediaService.cs | 46 + .../Migrations/CceDbContextModelSnapshot.cs | 78 + 43 files changed, 4200 insertions(+) create mode 100644 backend/src/CCE.Api.External/Endpoints/MediaPublicEndpoints.cs create mode 100644 backend/src/CCE.Api.External/wwwroot/media/uploads/2026/05/50eb086f886f4e34bf0b79406e7cbf48.jpg create mode 100644 backend/src/CCE.Api.External/wwwroot/media/uploads/2026/05/c6b80dc4d4e24f08be7686ee11043b5e.png create mode 100644 backend/src/CCE.Api.External/wwwroot/media/uploads/2026/05/c792a2fe9fb54640a38d5841c7f0b12b.jpg create mode 100644 backend/src/CCE.Api.External/wwwroot/media/uploads/2026/05/f1d4295616ab4cb98cef641508ff96c6.jpg create mode 100644 backend/src/CCE.Api.External/wwwroot/media/uploads/2026/05/f9339b9b8e5c45c49014259f22da6ca0.jpg create mode 100644 backend/src/CCE.Api.Internal/Endpoints/MediaEndpoints.cs create mode 100644 backend/src/CCE.Application/Media/Commands/DeleteMedia/DeleteMediaCommand.cs create mode 100644 backend/src/CCE.Application/Media/Commands/DeleteMedia/DeleteMediaCommandHandler.cs create mode 100644 backend/src/CCE.Application/Media/Commands/DeleteMedia/DeleteMediaCommandValidator.cs create mode 100644 backend/src/CCE.Application/Media/Commands/UpdateMediaMetadata/UpdateMediaMetadataCommand.cs create mode 100644 backend/src/CCE.Application/Media/Commands/UpdateMediaMetadata/UpdateMediaMetadataCommandHandler.cs create mode 100644 backend/src/CCE.Application/Media/Commands/UpdateMediaMetadata/UpdateMediaMetadataCommandValidator.cs create mode 100644 backend/src/CCE.Application/Media/Commands/UploadMedia/UploadMediaCommand.cs create mode 100644 backend/src/CCE.Application/Media/Commands/UploadMedia/UploadMediaCommandHandler.cs create mode 100644 backend/src/CCE.Application/Media/Commands/UploadMedia/UploadMediaCommandValidator.cs create mode 100644 backend/src/CCE.Application/Media/Dtos/MediaFileBriefDto.cs create mode 100644 backend/src/CCE.Application/Media/Dtos/MediaFileDto.cs create mode 100644 backend/src/CCE.Application/Media/IMediaFileRepository.cs create mode 100644 backend/src/CCE.Application/Media/MediaUploadOptions.cs create mode 100644 backend/src/CCE.Application/Media/Queries/GetMediaById/GetMediaByIdQuery.cs create mode 100644 backend/src/CCE.Application/Media/Queries/GetMediaById/GetMediaByIdQueryHandler.cs create mode 100644 backend/src/CCE.Domain/Media/MediaFile.cs create mode 100644 backend/src/CCE.Infrastructure/Media/MediaFileRepository.cs create mode 100644 backend/src/CCE.Infrastructure/Persistence/Configurations/Media/MediaFileConfiguration.cs create mode 100644 backend/src/CCE.Infrastructure/Persistence/Migrations/20260521111720_AddMediaService.Designer.cs create mode 100644 backend/src/CCE.Infrastructure/Persistence/Migrations/20260521111720_AddMediaService.cs diff --git a/backend/src/CCE.Api.Common/Localization/Resources.yaml b/backend/src/CCE.Api.Common/Localization/Resources.yaml index eead7f4c..70e8107e 100644 --- a/backend/src/CCE.Api.Common/Localization/Resources.yaml +++ b/backend/src/CCE.Api.Common/Localization/Resources.yaml @@ -374,6 +374,36 @@ POLICY_SECTION_NOT_FOUND: ar: "لم يتم العثور على القسم" en: "Policy section not found" +# ─── Media ─── + +MEDIA_FILE_NOT_FOUND: + ar: "لم يتم العثور على الملف" + en: "Media file not found" + +INVALID_FILE_TYPE: + ar: "نوع الملف غير مسموح به" + en: "File type is not allowed" + +FILE_TOO_LARGE: + ar: "حجم الملف يتجاوز الحد المسموح به" + en: "File size exceeds the maximum allowed" + +EMPTY_FILE: + ar: "الملف فارغ" + en: "File is empty" + +MEDIA_UPLOADED: + ar: "تم رفع الملف بنجاح" + en: "File uploaded successfully" + +MEDIA_UPDATED: + ar: "تم تحديث الملف بنجاح" + en: "File updated successfully" + +MEDIA_DELETED: + ar: "تم حذف الملف بنجاح" + en: "File deleted successfully" + SETTINGS_UPDATED: ar: "تمت عملية التحديث بنجاح" en: "Content update success" diff --git a/backend/src/CCE.Api.External/Endpoints/MediaPublicEndpoints.cs b/backend/src/CCE.Api.External/Endpoints/MediaPublicEndpoints.cs new file mode 100644 index 00000000..3d108efe --- /dev/null +++ b/backend/src/CCE.Api.External/Endpoints/MediaPublicEndpoints.cs @@ -0,0 +1,97 @@ +using CCE.Api.Common.Extensions; +using CCE.Application.Content; +using CCE.Application.Media.Commands.DeleteMedia; +using CCE.Application.Media.Commands.UploadMedia; +using CCE.Application.Media.Commands.UpdateMediaMetadata; +using CCE.Application.Media.Queries.GetMediaById; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; + +namespace CCE.Api.External.Endpoints; + +public static class MediaPublicEndpoints +{ + public static IEndpointRouteBuilder MapMediaPublicEndpoints(this IEndpointRouteBuilder app) + { + var media = app.MapGroup("/api/media").WithTags("Media"); + + media.MapPost("", async ( + IFormFile file, + [FromForm] string? titleAr, + [FromForm] string? titleEn, + [FromForm] string? descriptionAr, + [FromForm] string? descriptionEn, + [FromForm] string? altTextAr, + [FromForm] string? altTextEn, + IMediator mediator, + CancellationToken ct) => + { + await using var stream = file.OpenReadStream(); + var cmd = new UploadMediaCommand( + stream, file.FileName, file.ContentType, file.Length, + titleAr, titleEn, descriptionAr, descriptionEn, altTextAr, altTextEn); + var result = await mediator.Send(cmd, ct).ConfigureAwait(false); + return result.ToCreatedHttpResult(); + }) + .RequireAuthorization() + .DisableAntiforgery() + .WithName("UploadMediaExternal"); + + media.MapGet("{id:guid}", async ( + System.Guid id, + IMediator mediator, + CancellationToken ct) => + { + var result = await mediator.Send(new GetMediaByIdQuery(id), ct).ConfigureAwait(false); + return result.ToHttpResult(); + }) + .RequireAuthorization() + .WithName("GetMediaExternal"); + + media.MapPut("{id:guid}", async ( + System.Guid id, + UpdateMediaMetadataCommand body, + IMediator mediator, + CancellationToken ct) => + { + var cmd = body with { Id = id }; + var result = await mediator.Send(cmd, ct).ConfigureAwait(false); + return result.ToHttpResult(); + }) + .RequireAuthorization() + .WithName("UpdateMediaMetadataExternal"); + + media.MapGet("{id:guid}/download", async ( + System.Guid id, + IMediator mediator, + HttpContext httpContext, + CancellationToken ct) => + { + var meta = await mediator.Send(new GetMediaByIdQuery(id), ct).ConfigureAwait(false); + if (!meta.Success || meta.Data is null) + return Results.NotFound(); + + var fileStorage = httpContext.RequestServices.GetRequiredKeyedService("media"); + var stream = await fileStorage.OpenReadAsync(meta.Data.StorageKey, ct).ConfigureAwait(false); + return Results.File(stream, meta.Data.MimeType, meta.Data.OriginalFileName); + }) + .RequireAuthorization() + .WithName("DownloadMediaExternal"); + + media.MapDelete("{id:guid}", async ( + System.Guid id, + IMediator mediator, + CancellationToken ct) => + { + var result = await mediator.Send(new DeleteMediaCommand(id), ct).ConfigureAwait(false); + return result.ToHttpResult(); + }) + .RequireAuthorization() + .WithName("DeleteMediaExternal"); + + return app; + } +} diff --git a/backend/src/CCE.Api.External/Program.cs b/backend/src/CCE.Api.External/Program.cs index 7e461324..c2e3625e 100644 --- a/backend/src/CCE.Api.External/Program.cs +++ b/backend/src/CCE.Api.External/Program.cs @@ -64,6 +64,7 @@ app.UseCceUserSync(); app.UseCcePrometheus(); app.UseMiddleware(); +app.UseStaticFiles(); app.UseCceOpenApi(apiTag: "external"); @@ -106,6 +107,7 @@ app.MapHomepageSettingsPublicEndpoints(); app.MapAboutSettingsPublicEndpoints(); app.MapPoliciesSettingsPublicEndpoints(); +app.MapMediaPublicEndpoints(); app.MapGet("/health", async (IMediator mediator) => { diff --git a/backend/src/CCE.Api.External/appsettings.Development.json b/backend/src/CCE.Api.External/appsettings.Development.json index 833095db..72162c27 100644 --- a/backend/src/CCE.Api.External/appsettings.Development.json +++ b/backend/src/CCE.Api.External/appsettings.Development.json @@ -82,5 +82,8 @@ "BaseUrl": "http://localhost:3001", "TimeoutSeconds": 30 } + }, + "Media": { + "BaseUrl": "https://cce-external-api.runasp.net/media/" } } diff --git a/backend/src/CCE.Api.External/appsettings.json b/backend/src/CCE.Api.External/appsettings.json index 1d08c9be..1506eaea 100644 --- a/backend/src/CCE.Api.External/appsettings.json +++ b/backend/src/CCE.Api.External/appsettings.json @@ -46,5 +46,26 @@ "RefreshTokenDays": 30, "PasswordResetTokenHours": 2, "RequireConfirmedEmail": false + }, + "Media": { + "BaseUrl": "https://cce-external-api.runasp.net/media/", + "MaxSizeBytes": 52428800, + "AllowedMimeTypes": [ + "image/png", + "image/jpeg", + "image/gif", + "image/svg+xml", + "image/webp", + "video/mp4", + "video/webm", + "application/pdf", + "text/csv", + "text/plain", + "application/zip", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.ms-excel", + "application/msword" + ] } } diff --git a/backend/src/CCE.Api.External/wwwroot/media/uploads/2026/05/50eb086f886f4e34bf0b79406e7cbf48.jpg b/backend/src/CCE.Api.External/wwwroot/media/uploads/2026/05/50eb086f886f4e34bf0b79406e7cbf48.jpg new file mode 100644 index 0000000000000000000000000000000000000000..0c57e10481672fcdc9281e43dc9a7796b11d70db GIT binary patch literal 1626012 zcmbTc2UJtr_VBw?2_ZyEzytz>9zqK(1_Y&sA|>=9Vkn^*kgi}kp$eh*AcFKRO+{4n z(0eb62lXJ*?SP(xr#<}NH}1Xfjq!c2tcz4qK|?SGd4Sp@{FXqGep z1O@>XM?c`7HI$pBiOG4ogB{J%+6(}J003oS9Ta*IEC2u@p-~YI=Eej!cMk&3Gyn#8 z0XU!m0N%clVK$C5C*Y`9nwbzHkCcA!f6BLKz|oliU_sS_P9Xe8{{M;ueZwN6002Zk z($?~0M*1Fc(h;*RMTPy4e?DT2&xIchhW_A)BLj~Z`-21i#VY@!^Dj31!G0m3en&b# zY!34a@%zDpN1PfH%{*d=^%1AU1Tili@!}E7v7$qQj`;T@#)L4vBLM)4`XP^E`UV~` z`G`>wP7WqVtPcPPRKS0+&wp_g^YW3M0AO-4EH)x2ATWwR^i?8g=;~4l7R*a5W>l0a z-P`wqcZ46oHLnZs2XOY$UvOkl0O5z&#KDQ+8yykzgOBcs9|stK1dah100&3{vH%fK z1vG&Zz)8RmFaxXrJHQEW2fP4ZAP@)vB7jT4RUjTn0n&jSpb)qT+y-iZMxYhw1RelA zKp!v+OaQaM60icS0vo_P;4||-kTu8wKo3EEpfS)K=mqEvXa}?h`VNMGQD6*M0;~X52UEdD z;4@$+@Hub*I2;@YP6B6vx!@{r3%Cp12c7^gfmgxrz+WK%gbyMLk%p*3s1Or~9mE6T z4~c+Whj1W8kSa(kp4$}l~cCCm*L0J{WBffd2(VE17|uqD_A>>C^o7lkXrPrxnU?(ksv z6?g``4BiIsh0np?!oTwH@Zflqc?@{$c)WR{cv5&ucv^UV=9%Z&;Q1ZFhmb;OB4`K? zL?|KwQG{qhJVneSwh({t3h>JF>hap~`tio{X7kqYKH{C>-QfKLDTq`=8X%pJ!N_^!ss%<3`7s$J36t9-lw{8&(`^h-G3q*f#7E_B)P%Gs9iL<>DUTUWq}) zNMiP4m&D4&hQ!{BW5iF2`-*eKJH>y&gYhK11O5uW8b68uDj_LhE)gbCA~7KGUQ$%j zP%>DuK=P^Nj+C&}DXAc-0;!*+b_pT`BLa)SB@7ZiOXH<2q@$%PrKhF;kWrLzmPwFl zlX)eJl%>l0%NEG?%YK%VlCzP!D%ULci#)G9RX$L@SbjwQHw8rnSA`o24-~c)v5J<8 zaf;1~FNyrb)5LIM6>(7su0&P3pj4(bO9GR0NP(nM(u^`#nW7w|T&6sy0#ngd300|5 zd9I35HByaHZB~7wCaPwwmZ0`P?W4M!x`%qM`iT19WF0b#Tti;b5Yn*HNYHqsv8PGW z^wliYT-4&%qG|o4)vfhKTUnc_U9P>XBdlYib3>D8teX~+oOA^N70MY>(u*nQuXA8lT9bL^%eD*`ZfA*4P*?w4Jr**PZ3Tr zPF0**JuQ9O`*hXmHA7iLKf^l1Eh8nPV51hJkH%!<2;=+4znSQnTr=r4g_xR~a!jYq zgv^}HZkoNO$zJ>9<4e$zq2;i|)kqp;(7 z$0o;bPR35zPAkqt=V<4Cmt!uTE{!g~xthA>xvsjYyIpgea2InAa_{!w@o@2|^Z0t! z>}=uL4No1<6whbp6wh5gH|8bg#qxS`{@8i%^LH6AhAX3q@u#<)ca`^7A4{KGKA(L} ze2abG`5F2Z`0X$an0d@Ce*^zK|Lp*Sfc$`+z|(<6f$xJ%f^G(V3APBn9sJt`y9@Of z{$aVY+CmT^jF5++LZKHz2QNxqynJykOgStiY&BdjyfFN8#F>b?NKoXt$nGf2Q8G0d zO^i;CeiL&lrZndFOD>o0UKYF@dU-OI6q^>i6-SG!y#l@Bd!_%X^ws#QZ>||#tGo`n z?sL8WC)uBpe%fH0vm4@(@fYH!637X82?vR;iI0*blGsUalg*QxQ_v|9DbH`5ym31f zni`Zkou-vmlJ+;pk29W5PA^LTGs8DyJX0f+oB2 zT;AJ!oBXZSCUim=gq*IOQpu8ZMX2Z(r*1$ z=2tdfZdl%WTjF;5?e7&q70Z?8m0eW|RYleC>X_=y8kd@pT54@$omgFZ-Cy;g^{WjI z4TFs*8XKGNP1()h=IG{~7SER1JEnKKTUA>t+eF*a+ky7z_T3If$Ft5eo&9&IcU$kt z-@A2R_&%o#)D_$H`9a`=)o%ChxrbH{`yZWrbpNs1(C9F2cwoe6iLObV z$@^0}Q}?GS(_J$sX1Zr}XCKY!&pn+#J>R!rx-h(Gxj3<8yEOOA`PuSw&*!Vle#_f0 zSTDZ(67$O+E9{rBmz-AuuZmvdUswIA^lRHHW%bD$(>IfAj%zD#ec!%Yk6izABWaU= zvuI0dt8rU%yJv^CGrN0scjH~yyFcEid=UI_>m%{wy-$XpCO^A<-uM#n<)6KbecXQi zf%ZZFSNhl2-$K6q@f+uN-0uyC)WebQuHU!*xb!E_pWMHc{(AKHnZIBD6Y|eL|EvHe z052SlfWvqZJP0H&FOpvr&5uIyiwR={M5XZ31S!0vq^zPQNmgE6UQ$x|q>8$h4wXuk zCK(v%Q;alEP<4Ks1jLI(^7HZI&}f{FoTQx2|L5{gKOl$@ib4-SKr#SW5Cjnf{WA!N zA0@Cy*~5<)p8vCezz_fmgYz6!;{K=Rzv_+>ATR_9`{x$`1pytM1R{7O`Tg*jQmOZ& zhjF8s(&3IFnH}H-VecEtKC)Hdw+UVL=iJnira?Jtae0_Lltp~IMlX~@648K&`sGlu zt1Oc)UWX7aw~8~7*oDT*Tb6m&yf)6+bO8pSdeBO7Wvl=N#T!*gvd$WFU>Oy}Tgaq< zeJ~&LB~s{MGz82tDsvlrtGp(YVb+-i0R)0&#ZL;#!|hW@eK>QXaYo7t3WRMIhMA-| zY>)n)!-DAa^(Ofr_h9s9ZTEv=)c9wEXBtFip@JNt=Jpr(1|bb|Vl~={O_$kl-WHTb z@^=Vvz}@k~D(URK%6SEWBxZGyv@0*->t2(FV^z zS_|RLsyO>@%>;Z#c0R1`46fnOqS4*MwhzQ(NQ1)Wdb6rK15KH{4g%rv_8h@>Z8Y3P zpf*{7p(Ahft;$Rd?eoHx#6n@JIEU|e>^0=R%wowgm{z<`g&r&Jx3Z2P_XGc;7`Hox zGS3uM;x@dld^L#pm~865_^>Yp;TY`M3t}5NTOXc^wAq0G(R;ejN1jjeSi_invVe#N zR@c(;gkl*w5w883iSa2+Q>xf3qw_HEiToMin2h5^#HP2-_>Awd<^VfGN5*QrN!ca^ zmKI_Jv(CJlzPTAQfm@*Va|n09mHcR4M`6fs&~qC~g|fS3z!f}dLpeHzNVeLr zishTkR1!ukbwEEn$U}inlOID3tJN&!$cNKPMf&}hxr|eu^syVu2prFVxTxGR^pwJM zotot?ICi$(P&oCIkh{Q5mAdHmHqU${t*p#%uwFB2zp+KFz(l$}q3Wr^{y=4aPTxHv zpA6~V-Uh5sQ>#sxnQba(e26T|fZWYdXG@s->`)Ix5)(-7h^>UWcvvTk*%B&5Jb~s?KWsB9#b4P1wAgo_K%l;5F^Y8X zVOFa=a%JqVX;#?wxz@&o@?2ArTA!yHucy6)HHFs$fum8OGZmQ|i*ZRA9`{_%n;V;- zySf(VTUuf9b)QQ~+dOATu1-PbaBvqwidjYpqGQ?leiyK-YFOX+uAz~WC|oLMRGiuS zR!?Xr7kFViq;&^g7%vzthvlwn2M#t2-s~A>f*jVw2p<%*Zz02vYnJ*$2)qsyYe$<5 zTY>jJEo5wf1GEm>P@!Uqu?G#y#TTaAS@U%kd1maswaP_9v5A1mUEw3gbk13r4)Cz( z)A1zxMtyslpnK8TJ4kc-+h^QLZaD*GnV8EjXJ4~{@d-8@vhcWEB?3bE16PX#l?60& zznI7ih%??B$AIT}Tnr@)HMnnuI(tp;4P70M8sJ^aM7hb9)ZZwDki7$9LAeP=NTw8u zF|K8qRq~$q5y~lJf2a@>k!7tv;HEnlYIS_Gv!edw9d_k}R!!M3Q;k$yk7U+ zh_a`EtKrjL#^C1aP*{+nMBds!t*w8rPm{A^N{wYqGI)XbLDTurtz}puTtS7Cp_8fL z?g;3bYXG{Ig>1gYGB*_WZhyl;A|}sTk5$o(K9?61L;adiO~6@|`UJS3mrF&pDrsip zONp2w2qCspE_&* zNq2u?FhxFt@4XdH3P~DNB2=uX zF-Mmf?+wGm)kqJ=v@Dv>hQl@=p^s*i6>jDa>?%vAkPPoM+ydH?p@|es zHOeJH(R2D5`*-gGVWCQFN$zA2ncZ9At>`4OX+DZ1$}(k@jKaOe*rm#t36qji@H{Nf zjVbqn%MDPx>6<+4Pf%tfD8SWBXok*4T*hvV7t49mDy`9`GkD^7;$-<_bqx~AGoLK( zMQjXko3-e3^QTFXZ8SM8WIn;OjIy-!wx z9GiYZjLa|ATW4riJ%#wdqWNS?NL6%w8+T<*Cu(EIFd;#nHVLI3OQB#{VGo~C#JDL! z3k`CgF)3uibke0lxC6KplsRw571+*cEkcO28pN6fQ5vW7Gg?JJkb;W70`oE}?QNQM zVo&Pd_4)5NUSw+3UfqezY(SXT?2we%aC^gN{K)9h_7zJY7K_9x}-iEXH_R z^TJVf0-Bp30x7(se+}%4wvR78N^HV3+s~;eSo;=4`^y<;)Z^lj6wDkjk$5+M33{9G zfkO1-Q(0tmGc0P^tytd^O8F8RVTMm^&)kx`K<1IzCF1G0XG7)%wFnKUTwu#y62B9` z!SZpBtnRJV?VV@%w1{F=*w-jJBAng^WeUe{Q9y910LN}giXa;qm8&>coN1aI)W9e1Df~2g9&uwo2o=Y^Zs&-vzWInC!&Mt zU=svhivj#hOxy3~D!8dJz3UxJHU>e&lhtR-Y+3#4wfao?2Y$%%w!({8%ma7!Ic`s;?h#phDwd|yv0O*TwheEJR zU`-B0UV-J*Zt8-p7MAd_rbmJC@R~Y^&s(@0D7CLs6W=+NpqBKklTS%p*?5!wh-|eh z6r=EUixi)E z`=C%fQO?CvWDqnvA~IdeOJecf(#^?fm%FbK8YeB^>u-Io;!Mr*)no;ti+t+MPBcF< zoTA-W3kcmMLze{#6&z5zz0$q+>`lB81NUAoZ=XvYUu^YtKI#$$JI1yYv0|@vvr;-0 zP4*UJzxENron3uc$cKTYZ@FI6J znp|O_oa}<~yUK&Ie=dFbE$yyDHLP%08fg@-AI3{)@~zZTREY9K@WFB5Y&YM!;8hRl z6AtH!YUav>&PC`Bkh&&Konb4mTn{FHE(7$jy}V_Q z-4;BS{47T@mQ*l8Wpp=-Tz*?&Jen5UH0w6o19NfITw^mH&1$C$TXletgI4v1$F%%X~ksEkpIn4B#lF2_a70|{1#-x+@%tFtquj$sK ze3#hnPk1i)z7@%$*{b*EgZt}ohiySo{>e2hyYwhN%dF-bOmbh5VyT026T^(?=PX^r zYg+3<_c6LHMZ~6eRAwC|wobJVJwwi#13MmDOGZW$U4EY7U42kc0b8J+6+f(`XT1!1 zZQ8_AU{D;G1gOZMA9E1J+vOc5%`l2bQb8&fi+RsV^hbpdpoq8yk#^)QrRh`13(A;O z#E76PiKEG>0oFu~&yeARo_%4u8iPZroLwIaqDC$|X23R2g9cE?jkt`Sm)AH#z;RN~ z4p~T+8s|d5L7hz&(~jN^Vg#BKMHPay{QKWzI3*Wj0FI$po+2$B-Ay+n*&GFphXXXjkOZ0({J037rGd71lwF@*;`$n;XU1Cp})i?ge2x4^SUB@*4jlbtcHtpo9g4Dl=E;) zMp1}mhex<)OG`Upzxl6J=hy4xCTBqcMS8O~L~`Z3OQAkCO49d*Fx zyJ4lR;9s+hD`M*mx>lEq*B5e1eQ#d2hoZ1ZBZ=q2Xy^zQuS6YS2%%bla1d6OjOhPR zjHl5{?=kfcgZODw= zK9R6cp?H~10eqyj{+l*^RM4QyPfTWeDyHl!KcQ&HF zJ~}+9)$C%2a&)Ip1kXE3)Xfz%Y$@e~=y%B20isoW=bJjwaZU1L-%SdrWo&MSDYui( zG^}Gta8MTZWFt`QEGbHLbrF_xjEowjjqA~oIBS;#QMpJ6xl+fE0*e-zTS7QhfF`;W zs0z#2j27p7q3ZR9&U@?_5Ou}ZH)EA8f}lI0K%aeNwwr1i4<6laGp2vbT;BFtDrBAs$@1&^Q`4$uWZZ9n!x)rTq~U0P`GC@>Cb0G^)m`>TE?8aYFpJyQwy9=GWNAr z-N>r^3a5(Q?#6sE>x4~$hqdP~1$u_!1XTZ~)w18=M%cr;UeyVtJZHaFkHaVBDoXWhy}Bp{7a7=9~>hLB`=G!)Z~qD5M`Jr!suLwO`6DO ziK?~gcs3)YH!3^^BD;3M86db!%ZgH#D@}aHtK7tu^vB`Y>PGo2(6SG=Syj;0%+dq< zh^|K~sl=mcLvJYMZ7N@sLG3o@O>m_dgC$9H`dE^CgHl0rfoflJ^{j%s1m~`R@r`Ef6*;& zLZVVXXxhUyouNzImL!%&yhDiv8i)#?eEA{)3JQZl1#;dh6Bi5VHB)|FX}(DqGo8#4 zuf#}PbYvT4igd#Sl3}YgBT?{nyHh5QGk=ak`_l5{Tk_pNjY^Bz&RcM6dz!;z)Iy)L ztO&!pHjA%+I<@JcLkkx`%0WLzY+OxZ4!$sSWi*3sDLFxf{Sb0Xi)osbMR%(UvRnay z05zkF8l0CK({)vEtrN*064+I_bDiY0%w*M)rA?H1z-)YXMrPr#IdsKW;LRXsMW(FK zcs0Q+44j?J^rZ|(jB-92?cK>m>UBvsr58(c)*gDg(DbBAWo(wBg^#;;WPILrQ`bvx z;%!VT=!gd=fKnYMaz3z)a7)JB{Fb`TYWb?{YBkSF)cRc_qA#|+7t{_CM~x0CtY?wV zO2e^AOpJ?nX#=897VW}j_6>mW2ur|6Raip5HBq^t>33J43Xei)#XHaXTWh#zjMB%( zZqT2~fPwMhL0n#C*~1Z{)&4I&g?-TqF2hU+V3UfQO>y1QsjwnyaCJ^vu&cAiv>m|D zWszBl684@%ijU8@tnF|#M46DfEHY_BOvnRMuqhHTf_n7SI>F9tOD7r~-`$iTLXw8I zS9I78FpQIJip@+%KUU@9d{KsH#Zu46u^l*-`Dl99-k3w1jaQ6>TrRCdG8DvVKqZp9 z^LDogcnHg9Qms->0-*3s7G1ViBn^dQS>(wY?exN*4f3$#_x;TczYzK&gEK>|++#_I zTKP_8K2E_z%AkVYpbqHHG@29st!yc|w$g#ITnR2!xVIb3g$*bJXT^`#yW|2v+zg5K zX3JLPlyOJj#9|cOQ=BiA7wQ=nk=r-go#RhX8MKkZ_eL~`=8GUIhsOAbT%T+iWSuxz zC|Lq#IdM0ao?!5>K{G+79ZsDDUKlMH%!dnBvbD98I1g!bd%jX@WDRn^v8Yqvy$DM! z8WE##&pzB+i-Cs+b-1Qu2HcenX!fQs`!EWKWK6@kr*$Z(Ra}Mgr`RMdXRKy`WZIG@ z4Z|r+DH!SHq{l^Ql}q}}Ik_F-{wdCcW=Y|sI3kSF8@&v$-*6nXQIB}XgVHwpj-@#Q za1;LsC(?OtMUW_|wdNT|IKIS!7D3B66FqpV%%}s-&{9RUrkup-sfA@V;dPhuHRQTd z7juIq*r))3CEkoSzP$SAo&XTz z?TJ)kyNA1M$;*tZNrz8DIw`NrWiEX|#@MGB#*nF?_Lf?=I&+ud%Xy!K0K@8|TMI}3> zvz?}0dK=6!BG@{j|C_MExmleua7zLb6b_=s=b(6PN-P7M9gMml3|ik%Rr_0U==OZe zdxTFNvtoQyf@@ZR!J`H}$&rL^q4ah=Sx1xlf)I)h(_tYtk10#+U4aM~#BP&v@RPF2 zn3}-jY$>~ANh8*4AsM^oK{?|cKUU-g(I9tQ3Xx)5{Bi#;C3n4yr>3XUr`ia=mX9C@ z9(9Xm(O$h0L>ClYWI-?7PD`noAPU#uzqf+-u@kt!iYbmr12@SgHxOr8C{4{sNces| zz%#~Zac{(2M+gV*a-OVHPEiZOULz<7=I{Eovl97J6O{IHO-tgCTU&yq<`7QtS1vvEgIC=3xQlv?unS&Az5%_38!$gs9vmf77`iP+I0LWMo$S_LPl7;0Z0EP%&2m2f@i@#v1{b9QyL$lFz z*)z;N?Dy4#wOQ!PSDNm63Hi$_zIbax1Yw!o9>xZl7*-~@7%FRXuVVSw6^XtXCb$$d zc9Di1E#uXp^v<-~w+k*hsDM{-1>71AGeVj@$mscEDFUBV>TMDLdY5zE!+P6GW<8fh za&cDKmKt4^xm5z>0f^@8QN$c=Ox0k;)ifr9WyP*(2(ylt+1WL!W}YwSlQg}CR^9MK zHV@ak{&c#zbjbFjQt&{VSn&xviE^<&PS)vPTbMBF?MWj;O+H9VQ=15%1vO8`+L$Slc$qk0tt>_PLi5xW|p}<`<|J$?Ev?65GL9_6~gdi*^!T4JUbt$Db%&(mECf2R*7f3 z3As87reHIw5<(K)eOqPS{KnLJNOY#9hBK?Fpyh+p`nxT&`T3{iXJJ)5bIKH7c?GzY z=uzB@#5fonK9b&{V4<3wE>Jc@35Hn{3cKMg!WI_*P%XsO-W4gm6{}^?!I+0}qD^n* zb_7_Yb3Ka6Lfn9baHRXrC=lnO!Yo=cWKndzq^Yz!t6+phFO|#CT(h&su&j5-oO(Si zYCXrt8Oxr|?i;3_hEo&@5W+QM@{9H(twD<(b>K&(C<^Kf-lu!W!f2SlZRRh5PJ-e! zup{JzUTzaI28+@ym#QzBSR0!zx;aJU-2w)o&8Zah(b{b|xKNf79<3RPoN`_o;HK$~ zj)$!Z`}DufXsPw039B^`Aq7L$xA@E;m3V@dg4*Yb*zZ0_YmB`SOwOlspWeCJG;v++;fqc4 zDi@_G+7$?sg7r5irFd5r-0W1w_-ZacFRtdF+KxQKH~lQq*+}a2gRH_{wOH=<){dfV zql6Xz1N-TxY5Gr|kAlfV9Qj&~teEj0>E$oVJpE)@?eQ{pg}{51p~fVfk-WiCW7wuX z+LO=KRM3r~I(4Q-BJXp_1NWBwc^Ss?&GOUCXe}{hJ+jZ5%!`7wkH1%l!&lvfz!Brc zPNV^Qxw2-*oz2J!vxU6qj0M2>oiB;_okj61nJfA`TZ*w*2nn3m6H|lzl90pbrZ$B6 zIfT)4;@zw@jFX(0=>Dw172mDrLE%(b`@{HxHz9ZtT==ql*gFm+o(oZCGo0p-js&HG z63yc4o)ibC>H1Ry-;TlpSwT0@Vu#9ZjZbDgaMhCS3e>p*lSlijzKX`(;sQsL(4?rR zmSsP`khx8e0^MKc>EKs?VRphOq(N2$L)PNpEGz?VcCLxi&)cArp~onwra_%Ll+m1* zN9NdyaCt_xF-J+xI!r@`!xkHzFt&KkYiI^by;Bol>{KBsTA~@wrQTs9<7eYjdnaM& zx~Q-E6e7-7jgaC)DF3eXxV`}jTP#-DASY#vyW5UwuwyY>%2=^5PFte|PW zKVhdj4%^WUco!*oC7P=DEa}hbzd*IKz(n zRQsE~62xo?>y;EGTks9CI>UPr|8fJZmy$#+4w?e5&KA?g@#w{~BTp|)@JRgY!Gm-}x zZ*y`+m_?%}wD;cC!;YQ3+xNBQLH4VSvonfjI{D>W$JK*F)AUd7C%pcNWg9rw`J2J7 zncqL???(Mzs8*0G9iw}A4R_1H;Azy8rL7w6N37Uawgq|q9eNs5E`>V6%^4F$3sdSR z>~4?VHn+R|akx0Yx;l34&2N`4WPBBFUOAL|p1u#!k2$f1+E*BOw@bNB<-DeWe?AQ|9f_lAjS`P^Fcp47y~@LMWO6D%M>B z&hVgaH)yj_inA<;4Q~ASX2;w!ILIvRDQsuTAw0HOvxD=7OBBrE5$I|17dD5cb~)3m zqkc_&Vd+7SPq<-yN{0~KC}HY+%s859yE5DyJlwt_$mvK#avA&y0=+|SJYpk)@^eHk zRZ$QG=WmcmOgZ%vgh(h^#1MzkHFm~nPTsXN-AH1Lw-&^I#N7P!{dD?yu`iB`Z{FP| zE(sDd?Ua3QJr)wR=f|f;X+9Pb;iCI*AZr*OGHBRFEfiP4A6k`x4@06{#Nr9YT?iV4=S)sHp_=|8c;~4aC ziF@xo!w;r;UZiS94umbPB*k#ZZReXFLryzM_bR7$lEWi>QgI$WkAyhmdQ6dmzTR1; ze86)}6~woVCfCwdIg_ec9tAWU>ewz$hJZ{6?rt#NJJaq= zerdghU7P!0T%FO5+veV1l0mTxbvywVrTn9p5Z+(!TkzIIUlv}LJjdK=BUDb!tEisc zyt6T9efG?h?ZuP!Rps~7S2i#CJ=?zmYtxC^JG>a~+Uf5L^81t{0c`ZtM3B7u9whp*oRu&>Be{xM0b>671t>%$Y6+EjRG|Wm9Snz-j`^m%mdzPDK z<-*68g}i%P@xMHjd2Z;PHz=bp%K?L3fm>VlxM_UNJK=&rpNtxZEIC(~`Q|6VI( zj`#}SbWOhpo!Hhq_;KpsFt$7Ln*81$m*iBTxd$%YGyRo(Xcdie^3Gi+dgXksa(){Ef>57eqAB?+` zhsfS3&xEkcYr;)dy#4n^wHTd@O}Qb%`1tldLZ;=>_THp3$=MaV24mn6yt2yPN(y?l z1$6AOf(wz~1JK`60&maH+`gD1Ia2mKgK2t~Iv{P7TQI^L#A#r6@^&Y&v>GcY%#|@` zxNT*s*L2Hp0M!!jLJukCPk&ytgEBw-A{TLZ1( zJZ(nx3rk*%_iuRL-t#&M-V>QCN;ojCM_s9vFt08kZpRhuT_Ih)_EJ|XdHyd2i^E@f z4!$zg?Rf5tZT^}Y8+!Qlk^XJ3p1;*A*6hUNBriX`A@5s#yCWoXj?nVftJ!XDfA{LC znG@ps=T2J`>XO@s+5g)g^cCe4W;*qOE=6DHj#oZkf_H zKu4BV#*Njkk~jHYP+uMG-eh?leSm<1r{s?q7Usv8)EztS)VZEr%iq0EN(h$ytt#wJ zt^=1pO_7a;s?_+D=()tDh-tr1@ZnhHyh%)v_+q)?9KvDob~z7##=Bdbu_+4Y(&S|- znxT#OJAzt`og1wCmKPFO?7A7pV8bXIP-0VcV+Ua>8C5rxxiD$JiMWWqTR9}6s-S0u z4asYQ1AX8;l-VgPiZ_)$em9vhESH*kP_2%_wPQLi^HRm|p zoDK(~h7!OHErgc7E>9zk7krHpWL~8H3!8RaK}j)2Q<`DulwyiI&u12%PcZ7Od3oP^ z&X1lKmtP>J7a4GKjT_OzAD=3-mK;Im1H2PcH9UmTwIZ{VNwIln92iVl?a|(cnod*g zZ#>N?m@8gR=B<>Wo&7(gkL~_`aqi>vm;DdYqtU10d$=DD^Hdkg2^F;Q8ee}$@5C-% z7c{kMYH$$SEQ++RTo5rg2cpoPB7~Ol_;W_7j$Ou)AZ2ojOm}y_pQgRpa{S$RJfC@` z(yy2gYo@zL-#Bf(mk;%fjyik-i9J=UV)FR*qFPyJo9Y#5zxi}wH_Iz=e`XELeJL%u za_yLKs{wSs#2{4iww~S<{m}295-#mtF8`Dr%ewqW=hpW2R<=f?NAaDt?}uG`Poz%Q z`Ru!2k#^b$l%9G(OMag<8fEwFaR0@@b>F))ZFPGeLgNlU(M^`JEy4~CW^#vi4&QCZ z92%IL-+m$NI(;B45d2r&ehlewNGmD*KPYAUH%yTkrf@;ud_srC`j8Lzs5J0 zM>=nzck}h7wjtW7C(2vDc6jR%z62C%D#h-M4ecI&kNmpV^Rv;w_WhY;^hezJ=&$9p z59{u5$B={_!)k~fXg8}kG^V36Rz)j~RxLQmgX|yggfwFDM8aS@u|zo^<^0Y%)&0QC zKAytTvZ(daQE27hBqZV+l>|O1Hk)xbE?M|0XgXZTl4TC~7@8=Q5+x+g)|Ymunnq(2 zGO&Ji8ol&BflWXrYk8`@0-}sKmkDF|dul0XWI#ux^jFWAE%)b%yhd6VrL+Dv&5D|cxBO-`ymPxQW-n@Qq!9gaVL)=EL?BA++OgYPGWSXZ@DS7Rh&L7k zOnIKlVN*TehK>Y;QqryS!k$V!o_S-yzZE5s zzjU`qV)b+J^PtPhyB7OAH6z8R&nJZj#P3E%9t^5a?4%!GZ!N##ps*Y9wdneY<=(ma zEU)t0r^4Syn%6tv|9Y7>g7XdyxA1Cs;)0(#_os`4s)ocX$!ih6{n?@wYk)xCeO%P_ za`pD*Y@J8)_Av!d;yni5UDluf>p|(8Z*G5jbxusFpC2v0eP3^V-*@o_AN{$LRC#`t zXy$cqiOi|sz!&c~HosjheE)ubTj%f|U8BQBB9Bb!V6U? zKxpwzS6i+J+stPawMMtB`cz|Jon8-LH~BHeNxevy8AZXs1QZX+{&Dw}q7UsOg0E2i z=9QhwEA}Mg7(q9hBO6^i0vZs%{Heh>Z@ZRfIyuR8>7x1A zy9~cv(_qt9GmoCDOFN$w4xAqa6sLlL+zL<#YnWZTqwSqpP zU>${q6_E_BnFhT`#(>L;1-8WwCT@t#=B7*-1DvKRz3!xj zdx|zZQI{e2f(FaRBcg&gY*WAU&W!wu+}k!#z9?p=$uoPfe?98j@tHYTG*9E!chQCX z2Yu9b`_xMPJ@nv2RW9of!#Jf|TBfo4khNeB|u~rq^ zTRrJoI-hlA|I72cBhRP2E}5uAw}~ST+MK~_5mzL;}&fjR`Zk!!Fc$&Cwuv?$G9igcA%KKb& zRBgeQ$F!_krn-E#%GshB$L+Q5{0}AKRU^%Aac z3v9V}UC+Go=vhrqkcj+w4L^9CH-7}DeCfH z=I43LCE}FS7V?P`RkyAcyuznm!N=d%|NZQ3%3q$Yv?UKGEkfqxoe#fD=I+c)9K6pi z{umqa886@HQj5);8{b(nI4S9`xh7$vCFhh}Sp;?x3dul-rSDG9BdOgm;?%61 zTd^+tXsw~yc>rK>4C`f~$;f;p&ama4tu&#;ij7yJ9vohNa_oeic;fcuxUKQ(Q>&fN zWYpx|-i*F%TjWb0_y z758V;zH9jdDmNAOt)F)%w)m^gDMs8a7sHL_XA-7QPKDNdseU29wJX_2`Z?dbY)j?M z>w4THVUfzgmFLt;^<%~5Pdt~*MX2068W>^cOs}krylc1K4eT1}sGnHB`?%nluHs^n z*rZ+C&Aq0?5=6{*y{r4DEAf997cX~ge(Moe3O#SVzGHFQ_4KjD{e!l&o!E07klpV* zlLvq7dHp1r{cvn6&R$pjakiep%Y?Vd%Sbz;=~tQfbCR4}u(O(R?CY&pTu*x3P`V&e zGj&mGX_NZ=sqAlu648O%x3aIO#!>IcY@~R7%3hi3jJR}Aj{3WKYh!&kS-I2T!!{PkJLg(hro@t{vArywYu6hHkxV@KR0gkKZA-p^24vkB+a@y!<#JG>l(P z4E415e2LF3uLJqj=gCE@ZTQfqthmSHcP_<5yi(odwp^1sDeCpwFj6e`1DE1 z2l=yKx5|5u=c-#c3@c|}xWQG`y`ZbB_b_hb`l-K#jNV<;iI8yJ?2$UX6ffmC@p0n8 z@YkNV6U0AGyTWqU1m4vfOdqY)x*vZ$zo)nUY3;mK4=&C?@Xd*z@NouD)vG0XuE~Ar z%PL;4*N$3AxKTZ@G~n^_gOu3@eP>ub9^I81JvSY|XNk{IbQNx_D*N;K?t_t%jOc;t zs-6i08T|EPrI}N!5vNTic;^n*})O<>BjdfAx(ac<>|Ame<3sV9rJOPUg zL|}&gXOVa`R;`6DkPzC0^oe(eY=TMZ#^VhlizlT6w&G&F?DcG1IAOS1R#v={24~jb zPl{aT_IAco0*hW|Bk=HsUB z+vu0MKRb0Se9YuL1JcL5VxG%;4}DjyYxzxJpzBewdEV)f6j4&z+yly*A#&$=3vwQO97-8eq?Hmi1{ zV+?!mlHbSHkmK2BihKMIkA}HC@5#$=stSTz`+o=*&%_xa(aD<)?`uDp>s>yq#4NO) z3(sk?lQ3(FcQ`RoV;`j4d?9Ur|NP(%^M>8n}4>Q&6Y zIBPCSo_oHq85OiNqg!@wLu1DxdS^VsElBh1jr+xu+g@%TT25L9ZQuX%@7=dv<;<_K z-FmV&A1Gdm{}@|Bxb?1icE63j|Hq-9SGU{G){nKTraiL9EXO-yY1?lC52%64IqMTm zFU#(KJiK17P+tEGD zuvL4p%RiR?EjFrVb7#GkUX|uycCy>xq^{5?wdQ@LS>a#V=n+Ts|3T5YI5PeHf4omO zQ7QKnN^-wNxokGd{gTObu9G_nvAJw6T_uFvZ`)jkxi6R8M}^!sn_Gk~%r0)RQAFRL z-{0^)=e*8&z8RPtM;_Ws4SV_;ZzP<3^Fcbag?%XPNEZ)O8Xq4|_x(_MfB5F`_xuOf!f(kd%IbRy0Qu_3cCC7b z)kf2zc5=PvhLW4t%!h8PE7blNM!;MAEdMy`aFok&0W=v=9ruC)3B>o@7SQMOOYpk% zmhfS*|0-CgXQb+`@lA$4l+JTif_owV6`fY@*hj9kF;!$@#lQ+I=2ExD&wR}CSVuXF ztxKKX*~{NOSo{)S?s81Yw&{g=%)ftf0ot;jI|Eh&zoqLl#p78$>X)|AGJK_fm4HNL;`?UVjgWq6PnfWa)FQ+|0R@R(3vMg<;9Fa_d5q$?#nKLKRwOodEc)pK6VwSBdOne^S^RTGws5X3Vcg(ZmW;nNj z`J4Yz)%rZYNSbP%n6@*#C!FYgXInw&!y7RkwXE;j9-`@D<^DeZVSMC9mo4Tqcc(y< z`?~9nWh1_!_(voIYp&8e3|nBsN<%8K3*FsjB^9lUuxiEb&QX|OEGR>CE6Pf4?gK&` zA`P0EJ=EK1HkMh*7%q4tn`ScBDx-DQa2664uXFHGVurwWcN%%3FcR0sj;)U64WF92 zs*?bDk~_)SUi{y&eCE$y@@o1=mF~EFyWq?b@vb|9Z#nd;D)K8ENKqhN=W$OVXHl@B zIIg{GSW(*7?)rdgq)^~&t`M&2I@Fl`_pXA1zMFB^*BbFSzXvHZv`CAQk+na&!l|&r zFmF!Gh){w76BzT&ARdYfyr`22%hIj}%(SMMI)Y5>UogT*1N1o{>Y%>%8~pPoyHz%7 z%Dr>!!B!-Dh_{|#7Ka4q&GsD_MSn-$D>gBV(Er#keE(Zo<2SDn)|SWRN4Lt)qFi;$ z(P->bP_I<3TU%^h*Ay#}gt>lg@)Lsn9&;I#Pu$8$W$p1sOPJKH%wb~IA7C7h?oQra z4)oQghOY4l{jOI>PE%imc)Qs*9n5%8m&b9EYGqVeXqiG>Li(OyfG zr`QhQO-^CvyMlz)*r;JPsHzjR55!gYWi9Q&<=p3(kIfoLv0_v;+@ zj4~~&OQSQOje2F(Xg%c|3KJqatuRAx7Poy=>86cmz?Ge?O{Y#0n8=(48qnx!d0?J- z*l&at#Y+BA!s~@J*{EF{iPCWbtzgdn-lhWn{B1|jAT9hupN&Pb2#)VD=x65F&PKz3 zbVz3WDmU#^QX&jTY)5E3PThN(;4DUE@()z_l7t<<3kIc=9OdL~WkIW7hC~w64 z_2pbw-{S3Et(l7fm^?4Le$|yU!}_x z^6j1BS95B_^0VEhpB9&*K)n;7rxG1``arK+&fe*X{_1Uj%sOGmcVoBZD=Zr^HzP({ z?OtUrml}!29+yZcq!ON7DJ7-pRodmB|W2kNyb1c%t=% zM4IGroIFuaTb4+gss~mX2|mwK3LVODWqd4i28RIrOgdBPcjIjlVfoPsrxXsprUQ8y zjg~$DksGz}lQ>5XRC?r#m1fJE)}T17s>NG@O`a)rpJ0JV(rDP(TTg7v$C5wwZ`a^blk%l}!=GCeB-u!c|o*3&4Dd!|dV-h4YQ@X>jNjczAj)VZgTQ4dS}7 zyxC%VkR$ffulH81X~o&KIVOc2eqbcbITR6g*F`AB{?WUl5}5OT44-M0^S2}>tVdV2 z67;W)`IB=)e|U!x+TE1GFG_^J;`86#G)SG+7w<|}B3~gC7DqYzx#MIe&2I#zs}2=1 z#(ezlr3&TVWzeWSQvNGmRM0t2aGFl>dRZ8|cVZc0#Hjl8Z$PpacuIdlUPn{tGqBk>aj?XE0)0sbv{c_C+DO z1h0?G0a;Ju&LZ-LJWNx5tnN6`xT_Zul@SgD5wJr>a?S9iS_DBMJB;$uSKK1wjFrVw&%V=1kXn;cdYQi z-1B{jz@gPJXwKwBsd^b{Z+Jka*p8!Ji|xr>Shkm0xOf0dxHLuU=Lfin38+5p<#n^+ zJ$ycm?u#%e-uqoKV!9&Um6Y1%T`Zz@UE$g03g_jAbmyP8Wj_}rydUkhX3oi}D^HAB zs9@r3DEy(*|DIU4%^BHR`up6iPR;UhkqEEtvVK%rK$TSux%TXNPsk}n>W}1W0k(?! zFuw?l7!I4`z(*Pb#5#xRdcNM@kJ?l;S{FGwlDcws%M|WTKL>vhHwCPcc=L9}#~E+1 z?p34NObL}_ZDDlS-^AtptSZV4dzH|yEvrMOS*@z>YSNw-g)Qn^cta5#HJ322vJZV; zwr*gte_4<5hHEq{t2aHE)xG03cCYF>Dt8ZdR&hB$`5io4OvVVZ4g`f3S=D=c6JKd; zWmpMKI9McRqIY%gallzSsiP+M274_UO%&TF-;Qo?y8n&O;!e1&Fl)OLvf}RuTFe`f z@yJw*J<&e7l=eidG}A{YJ_+gq0j?9& z^3QxdW)tiT;dk+AT$C3IYdUu#h>3kfl#nxyFfFVVsjDfl@2jnoH3kc_t;(R{z=}YX zG>{AS3L&$LmK!3CxrIKOBuGIr4C*~qC~ouNxs86!Db3_(c6saKg0%Ifg5NY{g?s&B+jH6+ z_ZQ{h3wjgTQJt*?uoUtnx_<@KxfkGzKp=kYynfd(G4@r8e6+A~UErx%7MDF{Dh_mC zBDdF80HV*Qdhxeag)Tj-JDYz~2X!##q@dXEH9Mpqa*q0WT2|G(_I!m@61Sl(Wj@QK zM*i~xz=g8)H}eIa`fG^h&m^3eQt*I_J0Z+czi1k(>HlKplLAJgL6|JrrAi04k`Tr8 zovwW+mdT68cLA8(J%Z!fQla>*cbP>_HQ4Q+upY^m#F#^|@@dLZsv-R)x$_T@@06f= z^ALpbZnor*d}7@E*Um3y92QvaJ5RfCUacM@l-b%kMntL8qNBpk!#V7RCl)WU38aX} zzQV>A!1PBXuc`?dgPXQV6WpU2NdF|mA$^rLY_=SJ;;`3Y+u^h6WY0k z;mbi|KyMF0-~p}3vl0w?km>EuK`~^5H?)NJecf>}MX74}fPQPJblr@e))++2{Sgt z;MrNO&q_0d58z+FM^@k7mwO!CW%R>{V3kLIc=stLCi`~Ke|TrElMOubWhp}Za|fS@ zm3y_vgk>4X_u%)nh-KpyD-+WCN;c5iGbXlcdA1-v+sXu;wNirggACR}R z7M;Z0j`x1&Al)h3`gXy|xA{!1P{8$4R=~x^6w26yndPnW8}YKzu*1Hgz_9(tZ#keH zpZlR|s9tZbQ`h7%VzW<$p6=3^;541;-6*c69ETlH{mUHK9NVSXP~*<*s_UOF2$>Rwx*f&MF`q3~w&wzM)!BvTmsH+>+D0IGL@GRa53< z$1v(+@(7YzjNGIH)Q9T}s#g(bSXQ{A%2HeOOUD6|$n*_p5(-%?D1A%mxO7lWKh*}B zbk~l6zu1v7{K`FYf_->a*IrrPUfctPL_rc=utVDRJAWLeLnBIFW_yW)L2go6byuZk ze*l#0yMDz)mI5K-G35bdABEwpR`DdZJ6hwFU3P^YRJ?2#q*}3eC^d2UZtxu))w0Jd zMv9kJtrkCh#s3#3Pk)PYjOUqszsV{LcvVqu|~HrgElIj%dJfvJ7LO(M_IisGkvthv95S>L2NV^yOoFs+jWMZ+ESy)`CL$rDLNtSPnCD1TI4GOeg?t*VS`_8_x9hJK6Xo40U`*j=#sM$=ciEc z%BPgz7VV^~Cb=y@5VkfH25k@+j}N=9MC3lo&&9?(f0T`5 zFCHbkcDgTCh!U6Sk()P&4WOw>M_s;&^SA$7oLZ%w@zmd!NF&cG&@v01d1`?o$_;!I zniw>`aA^IitUdWU zl#jmtW2}%#7>}ya(OFW+FyYbhutQ`RfgySk)oIFRF*D4*){vj4)~o8DaJ#rx zGB7qXWwhlI-%s1U{q`lc^m53OzSj}MGBPcvQ zOH+*k3&v5OuFn6+#g8eaDWC4cMy%6w{#()T2}Sy6&Ab>W?wY=!<*JAd=SZL;I9QDj zs>wI_tKW^4*iVYi)!5O2&2j;D==?q|bBEI}6CkG3VJ~EiaFDDLs^-a)o@E*$6QezV z76Ny`fPGu+<(G#l{2B$>uAfuhI^Q!507YD%G5!25r!`aRsL8Z1z-Mk-kdeH%+titO zUWK_5?>QjXm8KW%iJQ1A*mO`;&rY53|twfr+S>rks0p-c`PU7eW7hnet_)&)K=IQhcea)u9yJ zXcI!oc9LDT;tc;fITU8`tq(pleWk)pQ}bElyI7?ujLlY~%YS~ib&wSqP14bz#Yr_Y zgXr#4t8>fi^v1{QTiN4ztW#@?2@;Y6t9#qOKp3-2n>(PQV6t9r&xk&tC1{C4ve{a% z=MghYbchQv0{t)>h@`}{c2+HnjrCfaY6k4as6KcD=AK>BGc8zJ7=D~)OOp9moN4aA zYjjOt)#wj5OKFiNabCQuyEUmr{JfYvbKBvW|C_%6A)7=e=e~@&8((CdXsaRjhtz3Xh3AG(x9t>=E+{f%xIKSlrP5MWDht79_w>oeF^+3LSV@CUKGECF z7!8`WqAZZ8D%+i=YQpE1IGCgGFD-XQiao8Lll=yej*8Y+ibRf-*d`->y;olzII;sujY*2s3jkvEgc4qfk=c_<_+sSL<(z7L~z)w{&8 zvKbMS=k7JQv)U~#@0YE2pODu1Lss;ofA;GNUxnIfGlgUmpT@9~>}r&VE zU6LzE(NvR=LZ-27zth=;E6>5{fSh&@CcG?D)N_#>&t&q3z&`+2KT=@XQPo;$OgK7w zPPw14mFv{DfOYo?H^w57ihN}4FCCR_rphulg~c@E5?vDXh-kXHy%5&BnzxLuVGG5^e^R!pc`=#lgi?T; z9y4#sA-cv+#+!%o_K(p@!7|5IjhT}#)|7yIQpFthRDtUYt^P1Uu8;$p93f7Q!wsFetDd=Zl4?q^^Rx zJNDAQ$_rZh;6`A`*x+D^cOwiCiqWHtBPG-fBiC-XA_4)M!z1P+C4jHE*DTXX{rqVA z2UrR_TA-w2`j^4{8zg-5*@qpDmGI_>YC!5cW9ogQ;@`;b@+@{Vl3AEpFGJ{yw%gXJ zHEU*d-fRjO&@(cu8e#Q)uWfN)EJf`QR(95h#m{W-z=v_SU)W`=oiE!gmVZ4$;eaHD z->of864IE+u$mTk8*8L2d3FNkg=%Ud2j{;TA8^^!-juKzVG_`gFL!xgsJ!LLc9E7! z)uQ2h6o!}(IhWS4avQK%d0qcFce4nt^KHcvEB^|7vN;6xd>@M(+c@~&F@ks;v|$M5 zS2E_>*`;TZRJE|QD~{EAq6VJZ8knW=aQ>*0q=wVKyipLLN3~wX4Y(qFb15q;jP?}> zDZFglC^}|Uyu-J?Df~$2l&Wp4UE}Qgp=`Vwo9b-hu=MVYs>L1KbRaYA7^YC#oZHpyuhI;faPRD!D4D;naq~Sj}S#q$JPc9UuRNF2nuB z8*}KRM9-h!cGYy9pXJUkKFe-OZUC8Trp4;HM^pq(|7#Y-xBBXG&7~)EW#Pszr;waIl@d{m#hgDDBTBP z(3%ci@EVA(N)+Q}=23<*ePTpD+B(FWDe;Wvlt`W>tNLj^U_&t;&rZoE$QhM{22Jy$ z56AB3y4DQm;y!YXvkg&+AI*6X4P5$*+MA#SP58Hh^k8?}!McTyW+BYII1DgFj+K*yfpi$P0DBw6>R^7kO%lkY~rBG2p|z9r;;BWi3Gu5gg-Bm!{L$obR-&~NpvA)zqKlraCWdvn5XVzEm+Zs}EAuv@4QqI6& zGqw;@T|LFWq=&S+0y_J~~n`kA{Vm-1joP46(`i_-#;CRo8S=9FE4!+VN!$I3+`i(^qA?t;T zGbb!OeFyZ-Nc&SCORa6Axys?6H{8l+_ALwID>A*sKv7lw@_^d3ugALw5>~}y7Um;P z=)OpZxI-@r0dWi0qY_70p;FV-s7sR8&HTXey?;fyK>;@rig#7xxULXhT(w$}*x#FP z%B6^98?Uj~?10ph`m2ps{pjF+_MccipahyIAN_eK%bu^R3Wh5v+CXBE46xO%5#im_X5FM!5fhLmIj6u7r zaMEJ#@lo|p9ZU0@Ti`?b>+s26*?)XE2W0oG?{$g2dirXzg(^NLAl1rys{+l9sJLLE zSK%Sh;SGac)^OMQw;_IS`!#D1Y3Z}#=SLox0PTq(0@Y;O-nDenB+?Tz@6n@tp^DBv ztn;Hqw+m=yrMz;H#^&ZpDHByyILTlCJMPU|A^?ZNrEjxVAVpvXoUr=|nW!U^Yyq%%J&9BY}?HcX?1e~~s$cE$1Sw5q}f6dY7q zb<7Qv+R=!p#PR#|-KW$t!($C#-l=;fj&K2q+;f-t(L3^)rR|r-SFZa-UOrA-47m$4 zkZy(Q)xQ1Frgh;-k=$crZxriyT;P=1W0dYACz}3Q%9(3r_H-NG?RsVtOAqv%0zmP0 z?A1r|>W)aS?2Ga5i@klk6S{*<$Yk!>Uq=una#N7XjHrL{q40>XFK2Hz`jx0Xh9|cV z2VLyi7fAyxdjbih;GaE_5{J_1hmD{%U{ON}B8&MMFdXoAvEwp4go4^U7@^(3t zI99LRbJsiq)qVa?lMUitZfjxbOYCi+_PY3xl>U%UhI7CXOd-4*7dhLMtEJ9)KG+y6 zU8^pFm!i$+bfXRM5rN5baw3EKVImizdJaG&$kwNR+L%n{y9}bdS`?FG39kKP-mAQ;lK%Wzj7gD^i z7DcdBKmB3Inu$y>YQv`K`N~Wu_A|oFLI6~eDfZt-tb`B@^hcamr|~CvHLr-E zu#9uZUxn6v5_*2q)Z1E1`vrb^G4>n}!lPqW!OZ^Xr3Bo&E&cL~ z!gHR#*z*%Czn}ZgNRV51Tu2X3G61e&ReEV2hL(E$D&?;8@(lgOv!|Ej!pmhtM8RfO z6)QK_6Z=(Ok45b3KDqan#sO4H!@5>V&ASfyRVT>NSCYH$?8JolXE~(_5#&t>OMiEf zf}EAmqYQ#cO7Kp3S#oAoscL|E;;FoYw8h}Xo$Y=3 z6ci_`Q`Kv@w!{Y>`O}?)^;TBW@h28wQ3&Pk*pu6<{>~Z3p~eQ8pavfUK4)2TxtGIt z%>7q;q?L}ENlvGBy1c3m9{O@>T`Q(Ri`C(1#EvCaieud7?O-!eUAI>$|3J_0Bq_~e zv@H8O=SEjB!LsrNVoTF6QYO%AoDHcrW;Ky=YgR^pW=lAHTFc3f_L^t28V*l|XUfrM zr!IM~Edlrb>O9)pU<|v5yetIZ7Dg0+=%SjODHv=}Y}M%I1;@&ZZ`$!53;80M-F|1| zVJRRz6K=@h^a;@7P&M=3Yg-nwK0EvP>qTFjU?F&wEM6t{kARs*nAENPk{@r}7t@_r z+V(RYaiU31j2R*1tV_3f%!WnB!2(mPsDI=lKP>`{I~s)Dkh-ZLGbFCt^NKkK*x_A7 z)*Z}!=+oz_~?2iZEaB z%cw8$!H>h?;bQ#FVOsH$(i3N)o_f&p3X~79MNOUJZAkH{GnaB}-qeyg zx%A@wKJTw*D5nV&)vLky!YM7uAqw9aCm)R0MNZxlf%@z4ib&4E1L^V!+!;(v@ud&V zSi)EGw9ZxuD=PNjWF^YV@A>Pf-crL6rAKEpWFAT@n4Z(vDG5~^0@CaWr{He9s%)4KH#CEA4njeR;TfmN8M(yB|{6N?HFE z`_c$GWiJF&2hi)<%NYuTnT!BreEX+7Me1##7l}#YhY}&q@LpVg;MX&F!l0j@pUHU^ zXmv!fj~NgbJSoWjTT?o(3Ai>S6c8@vrZ|d%Zp({gI=6*SMMho?lTK%9=c@;VZ_mih zK?BC8o>fQgz1-=-Zm1jZ&1p!#y8L1s^kL+fm`3r|q%;!1l&mHZ2SXsrm*fGS6T^ow zZrNiOoVl(D_5{mD`k~C4F%_8&|tN@l##pq@edSVg;U+y!*2p0`p=)g za3OnclD)ph(<%`_h`UMZK<*F=2`HgYyO22t6B<9I>!hUBB;tEP3p(Bh_TG>elQVYz%+T1-gs4a5GArw% zm*BNYqrFcCiFw$v=d)vwD#MOFMc0h*!FQp1e|+LR1_=xA?9L^r}G5$N(fsDWdP z?1=wx-cCUH<$$4g{%z{jr5QO(j4OC@ zsY0Nt#{Hw<;uR2`r7VwE=Fe=a7oXy`tMh8x=FAQU{#RH9(fFhel&_{-hT6X63vqvv zKZ)3uu*>svqMJq=PJBhUv`FmkO%;DsH7> zb;)Ok-Zu>jl4ERaM+X*VC8FHX_?VgMcI0UJv04p3?@3?k@zQJL)S_D*r+iFq?;peb zg9a&~Jm1p0e84eovbz7ps+tV$>wrX2Sd;~yT(0E>@S<^>v$oX4DciiY;i0j!5c@|vcd1p8FxJ+0d8Mty z9oH`O?nye*LhG)@=N9h@vldx25ZUA{E@=JnHm6M?kAGNH%5>IEHRA}G0$?Wr0lIlV zP-oIxwZAHdqhMEqGkUP`K z6tzATPP;$Ri5EbDR+V0628bu~a96Nw`oV?Bg$}GAR5Z;Bm)6|{gZ%HS^+qem64qIqudmgDh=mq%bQQMeRF!!fWX|cX^fQMhW%aD&jwJfmwyl@5 z5xx{jM2_F1S3_?9@MKtLJ8G{siY}|N5n$IE`pL;gh}68yqW_nDJks>gRB4qRxYBD| zP!@XAzTnHo>vVDc3x=H}X-2-5wA5GW3Pu`o=;Jzao1K$&)VccKF+f$-DCBkLV2V}} zWVKAFv7|I*=7P^dbS8kbJ0Z zwvOTl!5TGz*IQ)?CTK25QZh{6-ptV*eS<$%opG)CoB_qLmZAHcFCBo?OW#E;<@~i)er9!GyPNv9knWzfedV8r1SW@K|`bcnx zw&&W=W#-DU7{!`5a@;G&ab=IfZ#E}`F2Oc4q%#Cs0Ue6Nch$e+1aLe6wVX(%OW_q-f=p?u5OlI zj-Am0%O8{T2zK&{5?Wo-K6hj%iDx~nCy}MPNd=NT`J4$D$1wU<;~aghl@#3!amkt2fpyadep!je-`URcZL9xro$RQkylyOT6%Vm zzL#*M*;)-N1l5v{X(4%6^1ZlY{kDjnYH?nrXbW+k6zMq;Dp>4bgz?MPaWeC{(2v~l zC)!xtuSi+pweXb{mO-9+T}Kpga_Q`ZC!)M&Y#~rg8RBG%%bB&i&U*i8mSb)D1@zTI zs~p+hpI<=hA`#qs5u1#yU&pN-VW#|TuG|Y$&?>Nf!h+dNuZB;{9U%p+r%FLHEANws z(Y|^X7?pMy)TY- zR4}6;Rt99)M$ct@)z<-*6C2%~fKfc>fx2j2D~C8kxxFrROP)fWP!RmV07@DGEO8P= z($~!)jiYPFSQ4-2>eU^uQF5!=YfUt772W_!k-B@ZFp_X>)8j~KtkL#+oB5nZBU$Cr z+6N2`F9w>c=1sXznn597i>}pdeA}hXbm1 zhKSmGX~46V082roYeC1>{A?lCPkjvn1gH>Mi(Z`AL3iy;LbW~M`>>w-1t%PX?KId* zC09C-fSjsPg8xcbGHzxx!aa@BlmfI{!jvd?AoEX}rM@^baf&*1f~%8U9T64@pR^fl z#_RMgx!Klm`r1(K#~rCv{juZ|vklX(Ikx0*Qs>?#6aVrMT?VbmflYjz^63E7!JL%FS?%(KY+@5PzRpWitn zw$&&bF`J=`J#p7v{c`Em?G7()r_4nwMJF=_ReT$Y$DlOPXlbMJ{m+zZ4q|adC&M9v zmu<(7GK6tdoL>zcdd(*}TK6ht=BjEF-D!w7K5-Ik+I(WnX}Er^iGdXhF_Wx|2IcR< zEr?HK8{+}BSse(d+RDv?YUx}P)e^Blr1JYyIl($c9suL!5LiS|kk{&2P_9E3&>um% z-{_>@7ODky2Y9>cNZ>JFY5YCP=f>=Vqko-;=@e+5bib`WIXk*==C1#Uxz3Fm%5BE- z^2l5xt0}jYl<^lmPvl+>&dRMx>y$urR5We=Y&R~_hfB15na`r<+Qd>m-%ZH%b3(pF>?nSTx)P@p!a>8^H0@lLrU9rD@T?fn*EN5y{e_jO!IN?6ZG40XuBbFk#1M*kn&wCOxl{WK!ntrR zLLU|v*M0(*+oJT!!A8x81^X+D8JHS0ak3NTYs{;Wnz^@MjE2{idsLn(7WG#|1JfKQ z_1o4rRyLc`hO3@Uh}2_)Nw|;ivk19Nwf+Sq#eFrWy|X=)mv`ejjF8E?^HK>eErYMnZKr*;2N3lX-+kUHd1{nv0;*H6^fRD_ zsUc6eWR#wEDD%IGe4}LQcYJL*UyXTtEYa+T;uYf*+Fg6N!=q;4-a`}`d99t2;`%x@JELqW-c|1l6geyBB~*y zyI4EOy+hj(Su?j=28I@tV;9O;Td3Oa?9PL#SQY0=$>-C-l5#T9i&rDhXLAk;`#dNb z;(S#N??bhq7e@{60lDNW&RPps9s3#KPL9?p`AC(_4Orng7)2dQgP5FtUk3FGJbtZ5 zwZyLwXnx8jAq4jWl`S`|sVsSrItwoGJFmJ%r5g`St`n@-vQCqhg_|ZkXMZNmR`2g$7=Y;7+ zHgCNn%%8Ke=M(KR?399T08e({5~3w6jk2NRd<>&oNs)Md!ae`Kb$cp*nft%j3!esSWdq4V>))7azD zSm4h=j4b+#Ku06{eK>l2=O8;-)v!T`p2U~VX-sJZq$~P|6Hc>MRA+mcvowUN(+8d6 z$FGdasc*O1AwH|N7&mDw0%s;r*FFoB^j8x4kdv+0eQUF6Kr4$_WV!WkKMeIvXKjW& z((~Ne^6dg&v#VK6_HY6&iSDjvSZnIQ|EyU@Qry*=^|RO`prCrZJ`KXa1E~gxu$>H> z1kbG{=NlDAWa<;eFO6an1hl*v7N5BG>+D+0W?5v+x0yd&Tuvvp7Vi_~T@!F00_XNZ ztVUb9d&rX2)%EY5UJ%HMy|AD zRAXm~rap89m=1$^`FX5rY32J4WrXY=U!iF1Yy6ueUGk%D?2#Z-$unyfH`qe;!MqY zZ=m(W=u%aF+@iz_Tunw!D5NpXiWKM7XmvbDb4cMiF;Spu$luv{1{U?6AIuN{aLG_}Px|NEmaKk4hs*})hS?H&HMBM7b``{kqCQZg-RU~zc%!`OF$ zm*-onji0h}D7mFgwFQ}K-WBzZ;bBQc&`1N^!7kIH*oAb7hv9EqLq0 zZf_o2!dXi+$)Bvf?pt?n{{1`lQA75ep>Jv7QSoS>9#rh+D@n3fLczEYfp3H4SC_p7 zvrnhR@J4>v!A;d}v&O-0y>nsZ52D2myv4HLysriFR<%R!K2TWVHoB?ZnbGDF7+v@A ztDlZ-frJK2J2=DDe89x&LJ&5@@p;!Fx2&YKs@0Qh7vSEP3lqG|C~=&3N6e;8*(Io- zZY^@2c#tl8=6x&J%)Q_RY^y1m@OI?ehi%;#nPck^(7EIOw#6yh?ZhW4_8v)8yLbzp zx7F#TCvdpj_u5+ba>DcE$`fF1_}G|@vUS=Lt6s%3BVocBw6FAX-|LkB%kbl4FXHK8 ziWA2k^!tMampVhmV`6Veggm*#$~TZ4^(-YO&@WrTL5OMP2kb+uTerV^I4Rqv?37f^ z_;HnxsTf@@{9soh4huVCE`aUpCwed?&hwmXnW@ylNDVBMsNmGU@^{f@kB+bapysPS ziwfoaM(gyD!Iz}UCzG`Y*K>2OTGJ&IQ4**Q7T;s3nx0{AD&x2lR1preh30lbbYM4V z6QZ_L?ETu+t+<2_Lh&<2!MLIdX;smmc6X1`*TLUAMJfrSIbGjPo>=8F9WdUn)RS04 zKf?E*_`GLjT4=3_@QZwHf zU|q;Jw1F17L?%0HrGpe?ySK=Okda5g2av8)vM+jSa#-&`<|R8%xj$x znm}?9&eDX!{>$}q!<(H`5{)WnxbBIyrar98c1VA?8}koqNb7QPpV;omBw%a2eZsSJ zfLoj)?;cT9fg?9H9b9kvTMV(xAw_}`pWGaatXe^tc+gw$CsF0x=WanY`J#OuN`JQX z;c^4C;PtcWt4u#_?`|sxYNmg%E!lf_kexC6=;f-cI7;E+woSI;5zO_i4~4ehvC3b6 zxl|ES3#oE$Z|}%(YWP@N?cMS8RbyZPKhzAx7kYE`bckd}+h3-fFzKIz(Mm_S0lT?+ zOi}hEnAglZLhTWkiEuFQe(06kpDaQQNM5bk<=##%8Y!@jKg0_vkQbx8eJLMH&(_{^ zD(?F+H5B3ts}ad8H7vSLvFCl>Y^qfvRfPQoBaV2RO+UPOvHuKEaK^j@pmVS)PGK&+ z>$%>4*@|0nc&e0tSPrEd8Hs&uDWY777G$-5!1e$>#)e@+Ie`O9oUSkQKIPHP^N>I% zZ!B zhoJ2GczP%*DyvJ!Ze@l`b0TLaEzD>hTC|G1omPg=TxINQ9|0OOUh9}hR%h>I4zWrxi9=&<^NQLU zDe5~h8$`Uss%?U;`2t!3gKn@rZ^p4pFViburs^F0{nXws>|UB%#9YGY`^pQdULa0bq3qKAu#dk%H( zIvrPE9gKf)Oo&vUX-5CU6dj30Dr%XADSPQC2gD_3v(?%_+n=Qa@?|sSfJca7FL`ghm z-R%!JwSM<#=fF3AW)oGf7PHpqLT#I4Cysu!0U>Q(Ev4N^Q_80DUY3XHrZ^AvU3+{T zVcJEtJI%KsdLJd7p|w;_1W641cfMI&EhUN=9$=Hx=+$yo9ZjZl&HqAqbtacTdb6n@ z{=5tT{8R30o}WHe(QoD=iZBlgk{`J1U@JLEJUF&WKPN)>nk;4!6g(*;6u_$~gIXGp zc#8t?`6iVyCIr{INI#|8=Z96ONedimm0#%hxPe}Z2y-n%H4GBv47i0eO277u+}=nP z66Yp&yiK2QugF#PDbnN0>iqwar`;W_!r_JFi=zW3(87&}QHLu7T$qhN6t5BcWFEny z^3UnGNwy0f;};;%7&)YDefOim6g_^oJ)0L=BEK3#Z?;{DYobGxo12fU&C=O#YD=#0 zlS?4|Z^o>qma4-;fl#2eG{*(ItMXikelwTwc2wI(+B{J9PG|9ra>{X~6jj4ngY^6;-`LVFUfQV!6&EYtP{v%M7YH z2;{=F+mNwV}hGlawhV2pYT3Na~a9LV(--<1F3FELY$5JcUGFW-oBO?Ae+Z5XRNXRzLzD# zqDEgq1Ju1a?GRY5#a$Iyy+^MbZOj}X%^N>4*iD&fg9Lnmy^v3`N@|@^9{ z6k9adJ3t9@3taM-G^vFphV=69aP0Bj5lxNcK4u)se>Nx=s?GD`+#r`_^}p7HNo;Nc z(@>pBlhS?KfV$6ZdX1y~5Ee6|pi>rVK}c2d2~}yvPo$4`?VjMyVzM_-CLjY z`nnhVvImC>S`u6F1>?rr{n8X3hioWn(VgJ6)s^mNYTkj7&1b?T8GF+yX8w&c_;eep zt$6xmCGX&wG@(Z*0SxfFjHO&DI9&DW-QZxXX|&0-)pgyX|D)*41DWvuKVIpRN>NA& zQz17g=WNOya$mWo97*JwYjboEA>^1jw#n6UE_c{;AjjA+caB^;oY_WM^!@Gk$M*M* z*L!=vUa#l#@dV?yCk@Wn%M3JYY^~rN_*|B&-#=9(mlg`uk+|NuwIEI4Oi~V*Ir;b$R(?3wH4$P*GtoR=O|I0@aPdNmfBlkI{`L`fYA)0)OR+ms zuMIzekE0L4E`H20dyO?!7OM4g(^>X?GFjR9-P1rYjm5rj#ne;`Y6DxEPv&(*!}hDO z&sn|Nu@-KoI3k%oDd13UKS)n(?7FzciVM)`@hYtU0Be5~HeulDB!O}Yi&?n6p^E#- z(KGuZmz@U9E->?mGNv(T4ukLcjn9}g>253|?4%V^JCg<8q40!*#AaKYr3Zt?#55aP z`0F-M#G8+Pj(&)=>3M3vDB`@M5Qdqju^LE_OIx#iI{^CE8NtAb7`~L(G2dnGUc2pC z?1z)Qo(QR__kNT7b_lFos`g`~i{V1E?v#pxX`DJs^xcGw}R~Z z`F3qjC3Pn5Ch|j0l9RF1k@e(Cs>_VeMamAL^EfL&7w6zT;&XLGE~gGTJQAR@=MN{S z^5-aiC-Q=(HO?QF+_fy&&(u;y}*IcH>fRt}= z{E7C11&ilt+*wkT5SX9uUBFQf95wqzxvImdt$h?zYKDi7b=pWltG0ZZO=^}TVUr|_xFD_}tX1}OJmW-c*mbz7fgYw9dxzH>v% zTGj5zrr(uo{?qm`I2GQpG87zB-68iUmgKgx`!3vaJ z04)ifCRRoEE1uri)q6Hm2jHK-n}A3M4#v-04LE*RR;dfnacb~13l!?5qY({v{81nh zZ{sqDx4!RS&$Ot;_S+v-BbrYuc-VJXG{mK>T=;e$VjCI9cyQmv(QhHP1-JN~@e*j_MC8 zoL0aM0-d5QZCvez8P%VjZ_V;<4>{Ein=L%j?9v36_$OIkxfH?ob*1Ksg%LvIobOjah)+i7$YD?iK z#lR9$%=qtx_aSl1)8PEiB3K_-OsV)GUiKF65T?q_rRhabP_`Q`U2&v$_+I3lVh8uN z6%aew$>FoI9YfpE>o*dqE!zSFO9J!Yv3j`tx5bt+%&t==S39ok{ zRY-%U#qd7a_9Bs^!qe|OTJAuCPb(oE0#sX9qhOKH zaI2Ak5ca4t9zS}vtdQ1fZJI!#j_nV#NXe!;8S^eWbOVP5N1V9ha@Z)oI?VOv7U_ws zDR0zy3fYCbdEwSTcm{tVUwk+!Skwa%#$e;0dEaII-hT+w8R?zE7TcvNi)C3H;b5K_ z59YMyE|0xwHAR8;L~_$BAMig%wk5)46n#RgfvDfBdAFTS_h|kv3GnY<1aJP<@@Px)(w0LCnSOu zugrvTHU-Q{(UDLTl#vTnzD97IGwag@tyP5&t|EOZ@}BZ4KY6@%UGcw>goQ8TW)JMk zibkvqA3!@-Jsm+mu-3c|qyS^&G;QlL3L(9kT%x^Hj2#j;cwwQCr&gYBh7Vt<4G9kg zN5RmuyCgN_*|OI1-L+6FiVkdB)Z(gG+gY@WjD-TT*SdNoQkJWo@?E!hrz1LXrq4Cp z!Hqc`18b-GosKYYta;&YUrN$qYj-sX-8}8@h#?$c6IS#t#VNYIVAFSFWLHfhI zhZ74_C#k=kL4UR1<t)T5q;Sc{sKoc78(hCjJ90k89HH$S88FkYUX4=k;`DodDwUnVr8J+e| z5q=|IpKO>zrxm&^HcHuR(GngIA41@m8SH;Y>{+6fk5TP^qc!J;VpCkKRjV;i0?!;f zHCB4}PM4Dk{L-)St0r1nu7Y$CgwmW-2r8kgL_ix~v1#Rc|IO)7$4oy<31BA!k?sB? z@hUSvk?y5OEaMbd@V)4rEta)g9*Pd@FlQ>4@tNW6f_iV1y3T(_kN5?rG4@O4M@Q^> zZ;C70r2tQqpk{`J+gFkfbl?Jw% zY_r^DE(=Bw#WL1j!Jf)`?5D#)_+JeNJC2)reh!YqQ~TQEm{U-xy_kqsVa6L3_dS=ud`RxR5|kmt4uC>4&@S`Hpqr)XoIaK$1_NfK8JrX z^!Rv49a*(Rr_Z;cX+`woPO008Z@L8j6JzW@vV9It^|b2MGV=WcvOHWY^Miyq;Klbj zH8aWx>%s{h6XsFiE!@NJV{hZ0NzOE4A;9PZ@W05QD|zw;JUkPd`~Nz?%3a>oq;*!t z*z$+xHU7s+Uy$l2)r$;KMSQCF;QwJ<=!umD{N%EIfiPmm|BfhpYKv)W$gxX{`PGaD z_)NI6O)CH~`5RR0J3ONZj6XeH+z{04mv8)&d(DemQbB1{G3qxy{uJFdHf5_Q~5nQ?uNy&_KQIPL@P*MJl9aQ zqMy?5MaprOvyON0@Y&BKDjAQuG%UUM2{{(^70$0}nvZ$T0@l1C=(+fsyeKnH6>cLD zgsLLKW>M4=YO+}JL>|l{VkrHa+a~O);Fxvzj?Tha7cc#9SWk)P6uR+Ofs*s@u}Hm6 zr3F(mPW}~Go7Mibzq-GD6iB#TywNzkLnzkK1_LAwTr57fcSgji>WKtpbFNyVHr53+ z)?cVb^-v0}s#A}pD;khTS&W6^TIH`nwjXdp7Ts*P%-R!J!`jdL*b;4wf)Sf1Il|b! zPCLaZgA+19_8ZI*_Ib?ZF9i8H%La?!{##Ypy9QTUUj7}8M2_rmtnGVS8MvQ~cOW>mgmpWki_*OB)SNB?_FCOJ=_pP=SG63}{{l{A5& zcr%#(Id)q5M_|Brn&BO!l>_dP_&gyRPsquD%v&gHh7Y#_lkUir++VkJNZm8EB+gp_ zHt8`pqKLp+ezpz)Yr7R?vXSgzZV`j(w#i8Y3fEZ#rc9 zuFkFNZa;D=F zG^i35BQUKwbE}h5)@4Aem>u*c7g2!7^`;9AG+TiKL%1@e2w9O;JObX<43Wnqa?Vj z+4R8p%$4z>c$I<}nCVlDJ^K;{2ZBSQs=tScU&T_y{2@S@WS`yZ=wqN>m<8P|rh2H> z8^C3w{CiY*u&!DjJ6~Rv3Wxv%{16&Oxz+iB-=yGOf%x?VOup;73XR@% zV@+Do48b{ZuHv%O8dpghX%jr0zg^*h&!pupnhxc#&3uahz3G$3;!uHr{8_ekp1RL{ zF4YX@1h$GXAuHz1GXI|HrB6$*tx9-`&tg8ShaU4)e2wI@V@F?FUwkC3a?1YFp6n3A zWyPY?V$kZ!T@fY>jIr`}ouD$TJU|4^m0hJ5Ef*6SiC_ecF3aC6tIJA>8-vA-)6UeK zj3@)(h7M)l!j?MJn`9kRPCPIb?Wr2qnXEec^|f<^DmOFY?ZbBq#XoSWjTK6(65?eQ zf5~9r%G(X9@fHhyZjqk8+Nx@OuJd!|ygpRA@>=cNu2&bvH@J4ECq7OW+Dl#WfZEvq z9dUkke(-y}e*`Hft)oz9F6aZ=MzB$u1Cxsy6>fWv(b=y(RTjTKO%00y+!KiE+61eu zvL@f*CuL}V{PwE<9iMQawLUvF7Sup%*fr*n<)`fg(_m_r1F!KIE*F6oLqHdaw5* z{-F8FLEEV%(CoU^J=6~KiSBNEa^saC!W z+T&5jw?SOfz~hGc@{zIk_iC%a=bP=3bs}GBk29a~R{P;r{^~hl)3;zZk#tZquSA%| zW^Ya>Ei;^lq+ULGi&Den{xGB6kiH{eg-k4UZ?cuT#|P4hU=daRiO2Z&QyvyF4O{`K zUm_}bjO!n6!5WA~l%G=Lr!NcNX}E-|Uy3CN`?!ApFU zp)sYu*HD`dE)7{pJmEWI46IhOAJS?{0;3tmPpH4d@5(UQ%+O`q;p5vN(ueNjU-8nzY?yqtOK2J}6 zNpl%dy_fem+&3wx@p(j84PE{+vnq<4R)sy9Ml>7x?*28h=jk^AQ;Y(4*-@|c-yg?< z8mLv14kuB->edU!!uL%J`{CDk!p+9aw2lm$(rjX7oT5$)Oapz0igWCx#i?eBAI7WR z>r6gda*`dJ|B3-xm(OcgxkJq?&*qjCajqwAn${vYzOx(0rAKLEWs4Vv3KJS=s7|s( zSnwUp!|nS^0e82p^s0rpCiVT7cxJ~yLGIe}nr_>NfI*SvW-d3w&f&E~_(}G!Y)UTI}VA6v>)o{AsBwjl_hn zYtMnoKNQ~~G{B!w`hcrL8S~`ZO%SV>A(LfqO8LvkzK_AE=v~lfYjQ1PAAB-Lp}fra zrg}zF&b}s~4qvl>X074!dn-=Pw@TJa5%;3iD38#v+W)%7 zks!9&gdO49D(0dQI)#7}8>|DHp<2>NwcqKAjsnS?YAH9s*aC_`ST{ zpy;S0)L?3gT#No0*f6{CWK_QWinT@N?2gharv?_3W2*xfQn5?6XnhjR24O?34T`d~ z%iimhKKbR^^UC@rvK9PR)P1w&p)+kQ)w*M_fzv7AcLP2D#9~VovlkKa+Sub%d-P*C z`u6P~kYv?h1?jUDi#2h_6sM&Sy23${hHo~P5&xm@z+tFe{CJjBwoRQvqXe&_+O^i# ziVA3DzVy>R4+hKvPTmN2+uffmo918Cj^TD9R*z}$lWgF>SO5()PrlmGR)`s}JD9YP zzQjJ%J3h9nm9-|RL)`vzA9tT3+X}uZAuhb;KYlW=^jn3{_?FCa7&2O?LB0v=<9NcU&VJAZ4T>~fUeB@b$5DSaq4tS)xhP3 zSV6K#;i8akT1^E#^-hOzgKq>bz~VEsm&(Q$z#kzqOk;YEp)D!BYbh%EsCj4XU?ruC zqI~b(TiV*I&VuD#mFS9*fxDjOj}KBv8JDe6+B%{ozyi86tPR>Giz9QFfN~l z5Vqzi57hCR&n)ucm@ZymG5pQtHB;e$XIQ_&+}xTr6FEKwp?17$xkA=6L-KD$GU88v z#arW2zN#}Up4$7gRtN4|ORU+62+uKLL+s7cbL^MXH4TNi^onKpOx_ATg$nbcxmeS z>5`t;h`p;%VTzaU?Anud*U5-i>C5x>j(uKES=E(3dB>a#jv8zH*yP@}>ir7HQ&P{z z5qn#W37>P3toA?F_R)2LSOqe7M}MrnM z0M_1t;A8=8e9#($1?B}!aY{kh)6I*t9pg9SeKpOMqZofaq!Hwje6jN7J)44 zCBA8Io3tIdo=u#ie4nsKES}yOSoW$v9W$HW4^?MBRFW*1IH3AfUu@KiajSpax>VoR zwCea+HCTe4aw2;)XYKX5G2f9fjCrJyepUUPHv1i*Pch-8$FDriPm`F?mzN>ufMu)8!v`bcr(`RF|og(2zo(UdBZ!Rg1BXBuEUIPhGDJR(U8$ zl5&bT7ZGYJW}JK3hf%1ndSQB&y187>zymj!C5#Y2vL6Z?oHjC~;!|&)acAa)6^XB3 z-_B)aRMc5X1KzY`HE$bNEIC%+CMXplC3-A_Qc3a|nLxdBE>fnH*csI{7k)gPhYfCQ zEV2Lp)d4rfx7160F8_iZuNCu@@HnASuXD_mw<9RWi__=ZrbR8<#YJJKr+2P2Hl7CB>bz zI8}4tKKpR&`oHdufKYi2SqrKFO zm~5-6$(;Da_!n@{$(M}^6IRaC%X>;II|R=w1rH^J%9EUOZ(LruXFjQSm#uxBpt8+Z zN7`1LWs&qUE+PrOIXTJ~o8@CLYI9Nrzv^+;t;$y%G+dOL;#R~5u1_`k{U-_Vx zF}XYRoVbx=;II#u6xeJS(u$-v0tum;1&|dk7czVe6hiEZkubjoOF zGQwwj`SUsaA4^-!%k{$pShWsqvmamQh^1aPo7)0iJ;chX2=$I)%4(gy88PK6{_yx> zW+g4fkRpyh1hwoD3liqIx{uhy8M0<($=4F)wf$}vdBOCy+Bir~2HnE+<5fnsi>gNA zI6OM;IOLh(8-zkY&Le~r<=n$eNAF4KD$Y}>!tB*Hi=&9expqI4!gjAGHXt6={`fkP zZ*0~+qv;C-0BhB0WwBS2dL^x|LdFpp8Nt>h-0hK{TPdL&cgDbzG#8YdBk%{ZoT_ay z^)h>_&gqsIsFdYcBYt5Q|3m_>xdhxVI`t78pJCKkmi_cJCCuXSosH$)0A$e&i{yT$ zrZqi^Qw(P3h5hk#TtqcfM+Zh^hj&adEhl>@UWZ0ISJ@uA{>CVh5JZ4P`bn-@y!V>2R3KP`tIH zw%v|k8nSxgSH~y{Qj7_cxd7iUH2R_GRgq=#@ZAv!2}qo^ebTKvZ)6_Xx;!q`e0-~F zv|$GDcB3(6iMin}Ty)I&l`L*G91V&`N@Ul(31`^#2O(?)EV6Xy_3kg2MPNouEZORl z#R^7EwcP7&-^7<5(UYPsBBgmOtbt(ZawL;yVYrzro3jnuvt#1gkTEFN)fy{P35BSbD|d{&%c@z;4xgbgyvRX+aF z^x^!0Yvzl_9e2ZGI{!xm=p!lSEhlF4M$jN}waavcEO=l)z*05-r`AU0b2=LN+LJ`pc^B?zf z4QOcGNnJ}aUX$%4-FEKXk|f{As0yp^OV^lMc7Rk;dVONURKBbS#W(gwm|@RXF2w7# z2}9~oCA{YX0g*0|$opLhdF9`p^0$JmeUc6w4|;^8jw z)}C)!_8o5#vg4{p7%ytigm=4VkMczQ3Qui%F->-r1^sQCP`B}L?324OmOB3St<(`< zQ}}5<0gb9KPOLoO_wCzQ>?^=O9q3j$i$K2ENw|bV@dG}Y=UTjO%HnP_^|y(kkVqi; z2=E$3=w=t2^?RD^V7?m!58O|Sif;Jayck)m|C2L|&6@f2327W%`-A$3@_BI0+U+}| zfQ+<=|InP-WaOq<2=Sn995ekGS;G@Yq^az zYj?dx8^?%a=hs(_hAIl4n!!HCXmcp(9wKONzn@}h#=q}!VrvEH-7|5e0juC7ILpi# z$D$d|H}L(eh#g;Z;{kT)q&cfe(BI$kY|{;CLl=R&BXY^2qK-3B9|4H+<#l`r9O79M z^#@Qk`5fUsDp9GBftI5u4>LK)NoMU3#B4pWa)FjT?RGPri@)ehyqI=#b7B2pq0D!m zaI$B=g6@nIp(gL*`5Pq6QgRDCkSAy9&1V0q^Fxj7=8` z5UbI)-ozb4!MWosze0`cMa{tT5RI$U-@kI%u^C*4Htpy&r>ox;M*nMfXuqZmuReR* zEO2oRsU8{9Y;{?El1@qZF%hfsEGpYm{%M8P2T@hMukL7}f~}C=LK048xNu`HKA6%t z@{4dAsO5YExMLKkPwZj9hIDyzV&?grD@9_o%L?xU>6;jMEWYBW`t|yxGdD33e?v^i z7VA2TuZ=TI4kkinjoO@seqa&pFl!G#^R-;<>JKBQx{BWGdeuQEZh*nmPdo~HJLL-hSf-M15DMPwPq-EsBUb3@+>b}Wf44r#qgtg5P$Lp8AfIEp?2bn-wlPS75F zWQ9xF&%hekL7i7ObMbSrM!NtPEziQ;NovF$gek0hPW(OkIP|SA*P8{uj7io;q>Q0u zO*7YbFf#k{MZSzhb*o%GjdmDa%b6E`?I-*IeVr^)4|B|~fdt5Q;$J2@H;^n?*M9qs z_bW0VO$3aBVlbrBCo(O0jiJ!UK4!QNj@-TUIO5V?02dVz`@bXByzlTj5HWguKs}J+Y(c~g>8Pu!a-sJS zkE|ykJgxMK(PN(wKJ(p4;=+&}ZBJ)8@x;qIHaJLk+WPhwlWaJ-zu*qwDpSr~vY^8~ zZ^wWw`{BNHFFmaenyG{rlU*~Q^c2~eUwS9g47P6CR;*ev*!lFa%-Yhnh<=PB;M({F z30L4ZyV_ozO9^Kq*c+^s!1|7>B#D%$U+%wkbbq+)>>qmSL5znK>I+5)kjk&@+(?vd%?L74GW=H>>^X|NZIDKXQe} zFYj%mSc&-A;tQ1B{6B`j@Bhg^+`0|+9NvwO`Fw}>tLg&%;IMPYIytz7H-XGv_v_27 za@>ExFIRo+KVxKFy1zzmJt}5cX{+dfm_7^W(f)n+b;R8w z&4CMVG~zHSkRRH#HD*nmfL7aa!UDhY1Waib@L7Sy!T6amnBn`~X$jfrG*x+fsqf=; zCvYBNh8?eLmLiopl+s;n3@gnzLRI?*9>Xb@Z!S$*9pB6%Z|p-}{1_xx5*_a=!p892 zYD|K-`z!Tax5o{7Pp()M>BL00Tdh2=`^X30vm^u!v?A8bER2=~t>6-!-Ol)z7qX0Pm?YvTC%kV=4_Z#MPf1l}!gQyagm zXBvHA+1R*e8jWREexW%d=)Xp8_6GB|Q8CZz`)BcbHIDosKW@%mf^~&8=9zb|HqFeO z=f%_OU==`T_{xK!@RCBAN4=JxE)}>Y;eT>e%-|DNV0#?O?iT!dH|>YvAulJQ;GRqc({vC6$L9y*9LK7Yg;-BnI z76C}nw<3oc759;-*}zpY!<2zQy5nz1X;Oo<|SyFjYn&tGGiP(Q-aR z?MUFe*!pqkqHe`a2=*6WY8-ei}FkAdd$M)|{_zxki;Heo*_~ zYch24Azlj*94=-NvE+n`NW<9XM9 ze@g!-{JQ$Yz4kv+RronxDV%uwL0otJ3zJp*(kseferxld0%6o4C{)X>SIX;soQ}2z z!no&nQw@4#sW#ZY|F?ieLx1ZZsXx?U`hhHW? z#y_CquErS9xvH>Od#?$fK5|tTcKPeI>@;?XdaQu`P(`t!Ua$dP^43~>(0vWmY~5F#?6KJ7mVPK3YU-)gxPs!;G;A!l*~%VYdzs`F{uoK6 zXQ|XhidUUvdC%3vL|`J(evEXlD*;_^YWWa%SP zsoti(I zKjByACSeU$k)~3&sR(Y5aMJozK>QPz1*&I0e5>#NbA^`?sV(mB8zP^4>x!^3Wq*XH3-2oC$5=d0O)Es>h3r zQQoqv8Q1+Cx0-+gKM=}Tur-zz`3M2IukGx=%`W5*tGS2Bab5-*9#JV$OE?Y^`K zeQ5Xs`oKFq<;0b%A77T|)yaH#_v82*akbp#!0%mWj_@DxkxuM54Ri1Or{^W=0@A(5em{Cwh)petM!#E`nLjUAkA^^#>IGx{uYD?gU8-)GE}$s%*}1MC?mt?tEbqp$ii$oL z-XGR^jMsm>D-j<&ayASE(c%palZaR;1kujKy{=0I-D^^%}uO69(cw@uGj;f`g)t61_P&$U;i&r^|`42i&K|mLL3rqp8v>m_Kny{ zx6_x7pw4_5n6kWgHA48O;HSWoYTp)pe+>9%u>LR#4ugn>czwX?!K}s zWz9IzAe59RJlr0;uz{;loDW~8hm}v>!$oDy68I*`USS_z5@G||bW6m`8~#3}eWx+t zodNXfLFV6s0ypz)iiYj2Hj*Q$+^@l7;Wk~KMX0+5=}j^GoJko0yaOhYX@zh{JdkgW(dZqbpC>vfOrKLhTVH~<;ERp@ayIp?h0>81C}}f+ER+F7(dOxJiGcZD5v_QwCk)9B;{RJK5xHdO=O*&FLSe7lfn= z^50|R&ZXzRvB?mALy=J*=)C7_EllZJ>00gb?dv&e33eRqNepwd5K-&tu77&gX+$PG z)&5>rZjbuTz2}t$;_t++W91&UT=D(y@}mrMG?<(76(MkcSgMLEE=FRRv8DbHbi{YOb>;Nhh-WLZ7t_% zrWv#KCqBsUlt=(kX=cis{a(BCDe>F4t2Ma3Un2BVysOiYO0U_khIM2|CiuN~Q+U`D zwK+eYvJ)R@sz!$fk#try!CGGu!6`1xduGT&pQm>XGnK-9t~KXkJJRs$R0aVP+ffd! z;E<2L$X%7m2-BcdGPu#l(~G^;g)Y?p2>DK=x+|~V|I)&6q+7DrSW#~!8XE-5uT4-l zwVPq9+Z(^yHT06R4#Exy!g-cfYL&p=xH3v5i@A$u_~Fi#y$I%id1*1`522i;{Ma$? zL+BY_=lsk4P=x41P8)$wmKMkR(yh9%2|u6cUC`HOGSXW1gX@g{BKAf+;$}~hF0CyB zeBVjl0=Cse+ca?B9ntNU&pK~(jexP@ldjWsPSBtrbfxtv!`#ci)bP^QYF+dm)EwBx zp%hBQrqb`bhI43SPW-OY;$}bA;HyPYcmrdwhpY({5eWVp-s z%DJECQ=dGzF|BV2%I#0?^MARRdY<`kaOX}w6l8CGVN}BsML6Lk-~3QpdY^5tPvdY@1%OMUf~n0(Z9)*fdfe(_!& zd?o(Er|ZYkWl>wPOVR0B}M5(O<740YBuU+J->@>zfHYfN~H9VNS}-} zVR8qUfl(m|jjRaO*ASA3LS z?A)bjI!7xgM~>>pfXIFc>2Z+wa$|o7b7m-)o=p48d%NVTeHv+_WO*wC250n)75cnr zwA(ZL`eP*mgW7j*!0IoD?S>K^yBUya7n+Qa*5~Pc(#$hWrm;P;C-i!s!=@Z)dmH4+| z?_jG1-QqFk+wh_>Df*q2-Gg;BYWBB06|U#5Wv$f$i^09F$WFe-6%_i9^-^A;a??ql zCi<6ab2J>Ojf~_H$igdj=YOb#58~^L@7jxV3a_{YwEmRwHPbJ%F^^;@GSuVlbjJxr zCGb$hqzP(QeM)qZW}!`!V=FkdIU3tZJAfYP%ZI&|SNI_!|DD+&}y@%p0BVI3D`9-aO7a!=^eUdoYLcB)34m`~QG>W@5i5I>1 zX?8G~y0jRn(Q@T@!i^JCQ$iQ|Tr_kra(-1w@B#m|5{xND)Bc`|y^>?89p_SKk_v1H zc|(SsUy>~${tsG)b38RNWw59(f9B{-w8cxQM1yuk?j=$wr>p8=gIA%^#{%)|k}7;x zL^6_Tn$X%u9iI#PU_hPJ-JOQ#U-q;oU2cgE?9n!?NY)0IF?LOj*pd_cjLTl? zbu>x$8vBGQ-wxoV+H_WVoRzLqZ(bu$;h57%egFOpTpTd3V3WewXY0ZMzF^N2vs4G?)qk9Xn3ds=Bflf=%a)=95Okm;VQJj2W2}}_mBIj|80{(7FAqt3d`eo7Hl+D z6L&%1a3o*+Ws{nDi&OV1ve`jt`k&Oa?o)48M=VIyej?KOYf%N4_x=_zh(#VdX;+K zl4hMyNh7@|$vYq(z}lY&zXg&6LZYi;410MSxr+c=kKo^qF6h*}9Jp~sAx5`v;;&dg zD#op+WPSS*fD)Y%Biq;eG4?@>F$z+aaOcaxEZogojiGs0A%fKWCaraLvVBH&>y z4gcmT$1x9SHMh8KQ!e*X)8#4vSIPLh^EIw z1(PdNGj?O~!rN_{_gZeq4_~psgn8b%D0u4Wc_){v#(CxniQ1{xABkG=pLLVqE4n0p z;=ho<*KT~pr(Gs=-=Vr{#50Fa1|*=XN(IY?QdVKbwq;Ad$My?4Z$^cgpMTwC^2TcL z>kIlm`K-pzr)yXt*(klOI!{kB-P-y&)K2ypQe|a(=@t8cs+ZZ<=t4E2+I(1{8QVT) z_c+&GE|fAgH45;%sA^}fKsc^+e*TZJ6X55eApNSs?7| zm760|O*S{2z*_3WI5hCPt9jXVNoY;N*|GYuX8EVwaQWqY}@IsvWguZo(< zvKw-@+kFK4x1BZv@8lhhZV8K%9Q;k(JRpyR$Yy&82YMmXn-$Hne$|u3nB7&5kID=m z5uj19`IJGp_N%I;*K>YLmOD~=mt~>p^?kjWMTa(Zr`5L0WuU=0!H3tQ^mfYR>mCZZ2Rw?)f>oF zT>?fZeBa$~OfqRv?{Cr1k@(4?6&JmnoL*U<>pw4quS#THn+pmJnuXCmqLN#k`69d# zoKvJ$)bVO@vfufq*M!SVbshjO3fm>hI3SCPhDGsBY;Q;$fxmHRm5m|kk9#L%jwS`F z&egE4GQL3q*kh~l18Xqm?xPT`SW@Vm!g8^vu<^dK>bsY&Ls*%#+=+3%^Xe#vp{hc) zjGpC@wP1y?zf!$?o7>{tgLG&%KM$X*z^7PjNlMG&@DZN=2d3bv{fjtb{Ag#{J`7wK z{T@8|DMy$#uwh~Rt3W1iTiG|jl;^3q5!KXrRKFIipxopd$&WRiSZ+2`tcRl|g+0sOWY zLr_mFE;zY}%D?5}=hEqYL)7W*ZX~NMu2`jF60n@v03^+mxS4yP?Na8d5mqp2htEPK zLpKldK;ycJ>4ggnRO7@d*p#X0i%|x=y3)eaOGnLJ{_juRvzrjo0-BC<`_2*20KHV7 ztU<+(W6o`}O8y>fF@=5~q|(N6o3P-V659BqWYg0NxPfhC`vKZLit_kJ#@Yy8KIF;J zDt+`vaRFODE*1`t9k_fDXB-!NEQLa5{1P2NY%_y8!At*t=`l*it)h`DF+i_5zclg& zEV~(5dqtk;Tqr1-*whq5A&$QxNMtj{n3IipZY;JD-9g?e9^?DWV(@oOGBPNH1BkfG z4r?OV>y<{^vZEcUK8nlz!y@dfb8Upv-WMr+kc*Io)VKR~U*j+E9r@AaXO#JnHvJfC z#gd18p*wzh$Z{+CGFEsk)Nd%!IIz!I$?h{Ss-Mi#B6u9ZAC^TVBa@KJ`AQP)k!uJsOU(&qie@-3Sf! zOQQ#%_{h4v_FOqCp`Z7}%;nL7fuPF3nPpVQ&O6O(;gJOy?3Nc|0+nNWV}r}ET@YoK zpKq1#KlT-^ml6YnRdQjKHSH^dec%22P#{P?hwnS0a+4pz1|HX={h&m&s{NT!$ z7s6IA-5>8asoWRvyVK?jJayImZPCpmj(ttXZ@~qb6#?f5o|;+q-xluI z?Mk?5>hmDz@Pl0Qt63XDeOr2zY(2f$+W!=De?XhbV;e~Dt&#Rvu=GeFJiw}?Fc0j% zy^VEuP9Kgnlj@=~0ov~^MKctAhBD~X$DH@iB6v@6TwF8lC$~##06*XB4^Eg#aVTIMpQmMQgQ`H0saYg~3pQ%Y(jfB-6jx`*Z^9yN52_ISsW zc0jA+P*oOX4LUGLZcY%de?#aleY>`ngT_S^yyG!rpS3M##dgF6{IFE{0`u`F(VWFO z#-(!Npo7d>zzFl;GP1bF{?}G7YK6YFk;21(#cb(ngX5FB!;v+0eH)L$?y@D|GoVqn zPO6uyFJ)>ASk2xKE!wUce54k7CQ$Edr<`sSy2DCaLkeJ~fJmMiqfTP(7=6wkmT&9M z>HNx0-oVM5Kb0sj5HTuHL&Z08?d|mV-fY}SsuAHXV7Pj zt(`||N-S4-vWP>LCJas#>b(;h*?Ig2xf>Dr=+DZnUpyck#nQ;%7$BBw92>nVq=izJa(WoC@AW}Fb$asDR^toVpWv&#|3y`Wad+Wbc_I_d&xGie%-?&b_bv1` z3i+b-e(vLun-aGq zz6@k-8fA9oS*@UA&CmnL=zyq??6ygGP@}<*&5LR9Uz|8UQFi#tCdjV%oO$LIFqMIm(u-a9LmPk{gxZTM6?>-M{xx zAc^!9PP@OSPQdk#A8r~bm{m^EV6^6>e~#?}g63R_W6?r0h(l{3o~{D=AmXA881M)Voxd*54vlu~W8xX^UQWDD?b@3@m^5mUei$Kx%!SD87yVl2tPVmkKQ zYD37P=%&QTus;%t2Wg5XZT=&b;z}kpa!!KjcOcx4Ne#efz^gWm3>L2aCOMn5%9&07 z+DAt+7v#hnCPwFHV&42hpTorW7|V}!b5?$?o<6P)B$UDacSwIsyQN(Mn1)~G+Nd!n zXa5P506g`Ji0=P8WYM*Q5=f>qt3#385E}x4idm`-OTH#^942y^QsIL9{r)xeLElBQ zlZw{qUvz8P01lQ?rKa}DmQp5 zS!rEvT;8BIWEa?V|HIAl)mHC%_PG2J=}p?Bb-(U3=#cg9c*1ODu9{zOcZg5DmEB)@ zA@_(Q++OL&nbGU3-m>%#dtmwX{gq_RnY=wLq~^B&QA$zD72*a zLv(wma^Skc)~DL~<*kFW7F!2OsI}stWkeLl*rU*`-WJ)BpD_ zirN7!p>tGf_;YJQE#W6!EoT2x`BThh0z^kNrHp|EHDQ2$5Jl-Ip-GaemuoPckiA=9i0yiYcu+ z{J0f>{&yGHdP7k{vSI1{vy9G?<=yLJKjdwdglTo_$PCSE)a>?YaZS(8AMBvhH_arC zfn^L@^)TRMZIF>zviDu1$$5y~HxDi0VFvO)(jd=Q-e=z5_l%-&_LDUO+3wP52+{k8 zvNYs`78m>(^c(PM>@4)fcMh(+4PY*zcxyMSCuyE4m+|9p`{pNir}Bta^(JQ)U~MjJ zVZlNY`)^5Rb3%tLeIr`109Ulko|VPwZJb24dy3G#S)7Bk|FA_P?X%FJjO+z=)l=^W zO@-$Jci_$+7WX89r~(g!wn(|3O5=TNF^r^CKIXTnV6)%xm3lf&nnL#KjBmQ>h$87$ z%7xepEwBl}yq;eqJ{12OmGBlVc@?jT^ROnPc*s%bifamqa&m$6N0E5>qNi6_m2DP7 zIw=47wZD}V<=7&hnJ<;`|K%IdY3~u=OX4AM(Ei3|NR5utBn@%SSJWx-&8yP(Q>kV( zF~ci`YV<}9iEQ5ojH$sLc2V!hamMV3;+g?fTik+9!>{Z0n^g~#uEDG3eMhP#!usAC z(AvKIIErB7&jQD(Ud8^s*r!ORt-R5kfbW=*vjpx5HZvTX!PW~C-Pdet16=mwrAXO? zn02mPDbBZBFqd+9blPK^)b_di%L=8wZD&Q>&k^(dO*z+HYrz}EF*9NViQnaKAn12<8eCJ+-Y@Ejh0S?@J1$N&+jR#m>HY2AnOUUm+ck!QRp-8hrg%oQlutqDD? z^a}G70_WZ21o@yL<@ZaWSF%p=FQr}Y_z)9SJ2XcV(VAiF1(rj@VhZr(|3D43=zSQ5 zjVIpjH?CeST73S==?8Kz3`wM~FokBz3f1*eHKX`Otw4+8`LA4!S5lPzw5Ppt+k+PD zfIXnkLQ6QwH<=G-KziAT)ym6tm|{#+CqKS{%t92x`xai}J$Oj7N*q0HDTg-wIGS1@!NO7eWZ!T|=5thmVAYld`leNrr))k0#Jkj| zx30ME&CEx)4xYLd!=nJ4jB}2^si88EQ_wJTvyw8vQ&fCf?SVoEF!t|{s18+i@QGB? zt~rYPei1NOF5NQmFXmp!$VMz?sJ1P;P60H+uwEDno%b6FhPc7^TsyQPRkZYNbUt$m zXwHVqV~2blKbT~^+~1j+`HhNcrrYQ3qhKGK#hu(isny3i&AyL~ z`H^akeF~xahErvOu(@=G11rDDqKg)}Wf>?K{^dsr>zu91rfm91;5WZ^2_?Uh-;vCW z_t!hpWL1)}z(&WEHJ&t~puSUQY~a8QxN|4%?2pQ|zfNGNj&Jr`m2n@di%uDBVR;%E zSOotUQ{WkrZYkPIn~Z~$Nh^uqYW4jtgC*uf{l>N)gEJQB*&*2QWIAW&7fLU#j7pE0 zyPGOC)@NZ$9ja%%`7Y|Lbe1@$t6i~d5GJp3{N!c%il}HL38hehSkYfYW23!3UUP3Kk(VHaB04JA?^wtx_i zhX=xCO&j9VC@fa#Mw(*u$?t(cYMT4bP-P*Q*#vH$hog@Lz*^dYT~gDN|5l?H z3(pS9(k=f8VbR`mt*M|6Oq)=+jSH&%(0Yv4T1|{J zDU(pEpP$4)eqR^Jg2Ia;K)tE>QXxSk|#95Q&-!V@$%Vn2h0A-~)EE?tQdU{`NQMCbL?ZYjMR9 z{J%p58(QLB1>~P^3UCRy4{K5FJ)Mv^Q?gJOR}vf(|6G023;Eo`Wl}EE+Bucp@F#DA|L4DBXVJE>4M9l-#CIptH=pQK7oYQ zH*5pCzr$}5(Ef-xqY*EdACRCT7{;ZAG=XLv_Bwn%P3pVXp(bUjtz?|y<1XP zl;ebjKy@!(NXZv=tsCn7Ss+&?bHnA~>wP=$)FVsUC{)X=h+jeu4T)UkHS`{fPwN$` zo7Lv7xQorG!9K2S7p>}BcCmpinf<$_Ku#wc=Q!i;z2@QYn?W;gp|;mPJuly*By)48 zYN-_w5?P&nS|nT?Vg5l~M*6me@hO5%^^4f#=A~6E`tJS&o(QY3WQ{8mwG>4^93Gsq z(u?iDCr(J{PsTLYjiV$*{`~KdP#dS0A!$URa%I!MPtjNtW*Q}@dLb!wuKwboy`x+7 zM71X;j?Wvv6u4RstS$LE=|@IG$=vbiS)Qd7kXFCYT2fwJ;5}2N935umGKKp(%+R^mOzdVGGA7o3Mq!|rU2i^LMzfaEV{9=gJtztfktIFi^04YCrTYRi^ zc4ef8N}Vz>?UI@dn7&yyq4qR!Mya~c>=33^rv^2xb}_7#w-ajEn3qE!E$d$`qUgIyWEUp*vy7d{4#PIfy>qdsd zEc}h`=2k3ymudaJ9`on(y@eg*8WR9v#MEld@;z7WzjO&DZ7zOtgi>B?R11)!yR8+Q z`!dlgpgHGfH)?V}-_!CFk~O|CH91Be_LJlr-sh5oB@)cAGnL-~W+Y7Z6OBavNU0g> z6V-W!s>^Bdj@p56*WoS#bM2hL8OP5psGvngwl1J&#_tq6jOYe;X_l;|3OS#yC96S5 zVH!7p!dK{e@S4v)P$gxjx^`vJ2>^ZpCHrCL{B|?w`6BmH>}<#n4d*&9^Ply|N%i8S z&qq*UZEvl5BI^eAMvw;(YZ~g(0v>V|dZC#7Frkb{@qUBxSuTD&te_#_ncUjL(j_d< zBu76#OIA-C)CGsk6>JjBwbUn^#p!L8W6M_h(87vk*4|&4qBOVNj9)~AbaBF=+b@@R zywrh>llk;xIA%_~60-HwqaMmq)=rfTQ&$QAHTaOpTM~;B@8|o5b6~Cqfb<>LPNkvx zQDnmC+vW>x9tC=hEh&|5v>8d@kk z&xY>-^11p~kepXLa9n2*m1{BoXqPknXH4Go@J~8V5}#$Co3VOL<&At2RoB#}+sD3W z`9)6ALI`@Ia84)I6`<(gGajH$O78YuV1srV1EsJxA1BFR-9KIbM_f2SaqWt!A2gdf z_y}@(kb+<(?L9%E&x1P_kM{Jm+d=Jh5PfS6knO}}NV~$*P0|eyC2kbhN_%mt=L%26 zfnN@X+^6XH1)u9?I@2@DWR>G{L&xFa?6PD%VN5_-v~RACq3VvJuDrt_R&AINZgwA= zZSctkZl#dsnhca9TU(`9Z&^sBsWk47v0K|h*oC?*fX2gXL2udIwlG|#!Vt4PPS_yF zIFb_^@0(8YKMSG;>^XjEO+|X=P-iW(qY% zP%2}!y5t?uz^I}WNl?>Xq!F^Lr6gaS8$8Gh_-r?}|3eT=j~55NDY6m@ez#**V*fcS z(wW3JlA365s{geVuPbP+i!s0?9aVO8c21O$QGzR-{fM!>*nEhzpDRSOtTJ?eG3baJY(qkGDWQFElkP4HKqtSL-X!Jpy{}~JtGHmp0qh=55Dy^a+SJY==o!lywRe2ee$Zb{Y?{)grrd# zZqm~j{-DCz5@Go8wS({rKAYn6-=Ceuoe7o7RgQ=ukH(DS$NwM?99jFbX@$vJ_tf2o zN~fCzyh(P|<>}X#{jY8;tZLKqI!68peD+eG?Jj-TA&{~=C!593 zQ$D|r`3QfhdX)Ah$M`TT&U}$K3pj0jiZlG*aSuTFXzXclD_>2V$mDjIFAPJyO69W` zw`~oFk2$nT$!ZBE{o9q!tLdIqsT{WSJQV5qh@0)uN!jyzz=12 zWk;l7wSsbEUsudoQ}}YZ2`cv&&T1DfX)#QXu|^*J3mu~Vxz(OxfWV|kJ?LA!cbhN8 z`pWeyG#y`_+YUts6I<+Y4Vg*%*cN7RCG@!#va9kQD9uHFL(m0f7Y@;rF6)3P4v zZ|0>qh3{%Df?yC5gaf_Z4*hpt|yX)h8lqfQuJ#7NmDBlYlt zJ)_KM={ggQf;1Hg^{jqwORHs3U(VR_Jb(w!EfAx1YNqC7Pnl$F?@(K?=T zCN1IW*bj|1lo?m%D(O?nwt;5fYq()!$+P8hficR$EA$H zCd@LY?mbiGZ0$%TlVMNEwT^^VY6e;``;0%9zv7DSdH#Cj!?r(V-q|6>t#xJ3Z8iQA z)r%WuR<1G+Bt>UH8tdojNR+xQ%yH5vRmQ+;oCW5efhxpLFiXaEVndqOT?0ig3-vl2)3-Ce{3#5tw$dCA=C7n;n#T;_PMJ-RV3$G};p`GBY4I_mb zk>9KhZLY9_dx(?NmmgILg?7gEXNZPshOo9V&5ecQej}(%2vIv&FaDdG6U0{l+Wjjm4o{zJadtQb9K#ESDFEb(aY;#kUN zCU6|<<^+L)jWex})`+UV;uria?z>`)#UKB2Bj(H1+8aRCRrf& zbtW2m16FG(9(jY0w~MdZVNK>5%7&z~;GbFS8#ebx+hJ(V8vaf}+MT_JQ$Rz{Y$s#J zGxf9U@R%*@`p0Un))< zE1Bq$HT-Y5>4=R`*9#U}|14iJdrpmVzOjn%h$$?m_89SYxf9@Cjs z6r%U4fJLX}<&Pvt>$x=rdj~)1GYWoYsPgl=&7N z>F|JXt%)NW?B3X!>#THFhZ`2 z0S^SRbKKh$#>n{w2Df_1M16ce>MvK3P07EQ14XiI8p{s7Vo;Bvpl$OzF*W&MPdx_( zSY)lj(Y&Ik(LLq8Ch7L~&pvx?Q2qRp?iB?5RrT?31&4dtDTLJ7e+sVDQ`d_#A6Bo* zGp1{${2LM2iDm$1tiL2!>2n)tN={-Fg2Kh8<&U!ZbnSpWP$kdQS^t%Yzx>fG_o%m* zVb-eDOFR1=IIm$=dy!xI)Yr1z7O=hq*=`@)M^v8m!clCH?bG&9$5m~f*)W@WxYZw z%GX*CYf-jyy{6=TszawlXwYO}{^#U+;A;hM$1LxAnl_J}nzMo5h@6g`e6i2N#hg&O z151dKxU6MNt)7p7BYf*7A49;2h zw6Wdb=~v>V50XZYP0AZrI(pbPO6a2X0q@CtjD4tSyS{F{>fhv#n)mA8mh`<>Y3w?F zVa;r5jrs<$-F|~X1Bbr*ORgIY=#B?X%F+G^Ey&WteEWiB!0xYgz!qO{IhP9l<(#5n z&pc{4Du+7oJc7T2;!vK~SItHquROMc#8#sx0ys;&efa#STFpBKE(?;p=za&HPFZ`# zoD9SsYkMC=6lc=PSsBR+;ij$RJHHN87L%Z9CU*6|+9t-`_mrjzsqLRTV`3UNsXCh^ zFCKvcdYcdhPSyVXZVekV)P-ml-NHqYj+xVV^J5}ees7*PDYO5Bgk7W$0u zs_P=IG6A7E6Y5Z}Z@UWRnokld($hRuacOePJ7`IP`tM&iEkHG&x8E$TQ?o)ag0KV} z+$}u&W4*0h8mOU86 zKX`WB$#L?|jeGC2j6eg>zFirBg8K#ai($4j`MO6Rmlay3cVvE?hF`(Z+LApJhILA> zecEjcwOn}Mas5oRFafwMihG%F`v-0oiY7xv@#&5L;(3}>t+=*j@ zf$<(r%b)UdhV#rG)cz9Vb>2-xKSA#iH=hto28oe z_f+yXFT8g`Rsf%!C=NVhHXyeSi1*Hn}7A z?vFoh3c7nSo2F2Go`C*ew9#t`w}l{IzHwnW@@1Ft(!QZ{Pt=@MZSS?V!>dlL^efb+ zO>nC8`kjc(BKA3o9HwaKS-`jQEJyn~S6eSkQ$urdEhe(_H^Ox%D8cT9xEv-`61yjm zKRBOSYUX%hBv6LqtF%*cq@bn@G9ZETG+^PF3Lu6~c%Od=4+ltOFT=nWtmu3CDnHv} z#yhlRBWRy-mOQI6P$d5poFboR3bcoX!E_EkA+?IC&)#hw+?WMQ=C*p}f!i)k7`qGZ zM719*w1l+r4C6a(o_6MQ{SJ&p#xf?yt6oXy!N+BUjGxim8fSwCj7ySWFZ0GuJm%Gz zcCoSN=Gk|%J{8Q=SHb}H&*#r%)Hf;cz{avGlOY1p1E8$NvT<5t_!~~fU1&&L+*cni zL=GjQ7sDNk`inyPtS$Mf?+iuo)E5jF{EPW`ADYhZ2&25#4DX9+ z<{s%fm8XdDD^}KteCJB({8$Y-=U=IYcKci|o4+H}=owFkn?I$jo4LA37)l<>pV|tD z7y%NI*pJyhKhxBwVUt|puE7=Lc8_UY*JK}$I|~j8SPCw4!d%0pwzszWJ%;9!fCF?%j@ zOlOY+v0}3&oT98Yx;x|%HrAoTgTvk^&RXKsLWD4un@gLqp}d;c(36<7qD5{KfonXw z!e{M+!?l{@J{Q~Cu(#+JwP;tWNbCpH5P+n27E{--B>xD=f-~+~g}Bg=iO;Oii!(C2 zvTHf1yMvc?J64Any<E@$L>|LgiAoHJM#PUtDCdW*QK?j^Kh^%8yi+tHDo?Kk9e zK$jX+!!id~n~UAG#wwn&SARMtk~`Qnlsi$HwLFg4l_7iN{~WF<@p-HnkbL;$6Bz6j z;@kGFL7}mq_`{iq$A~hEj~Z{Jj%4NCF0DLUVR-23^Jl?zZ-vf{r4DZ#@Kzyl5;(u) z^}iVuhF%~edXW|zNB>_kB>#l`g@bp~L>s&?@eqgZn0%_8dU-MtxfA#QNg}HX6bVaHELw;%H4qz+}_G8gU zV@JGDpM6o(Vy$HGD{*{#E0q3-+E`Ki>|^fn_J_Ak8r~;-}1`r?uq=}2m*>Js2dGvTh+2tGFPsvdL3h)+{K6_FF z05H3}!GxA~<%m|OH3f}q*K~%`&@}6vDE7zT$&;e~Ted!9WYJd;$j?9G zdIBE?#q4+5mSLT;HJ99EwMwXa8Z*P+(Qw8KX+N<5cuU=a>-HW2dJe5zbyNnNL{zNFJyr0y=$A|T0_@jkD9Ub;0BFSlGWA>s!nh5Y=PG<@@y$BAk1 z^zdx@YC}PZEk=hQmn!jgHN%k458&F572Ey2zrfjz32bDA2Wo_unL8Qa9{L2E4pmYh zPJU_m<;V$H&*Q%BC#UjV-M*5Zr|_RPqYS>GdzkzbYsv{%u72o|pJ z-qvavQ*e$9dvnAjZD&Ic9Za5s)2#3*H7MpED?Uq#??vH9VunpuwcjpyC8l~!bLTMJ z8Zd|~GQI61Y0Z+fo|BLqJkT0+oto^Kk(HD*=CBu3I^>D(HR0nZ(h>|MRP!oH33^WO zc{HT#+!LO|Mm2*G`ecg=+Qr1_?U6Ahm4*DY&WS#ylc!3EmO7;t51>yWPtHys9@-2=bRokc%8Pp%+EBlf1-729c15grxSD0L6E8|02u-SfGpK#~J>l|UW@u=8B+ zzJcXX)vT(KzivM9$yELR(TyksfQm<@a?C!$It1?hcA#eCbB1c7+KR#0GszEcl&udA zDfy{JJbC51CM)XxKLE z=^K-Nnm7beV6roA$l$Iu=9WQV18b+4y+1^BPL3q-TaN>}Mr%ZiM)(dI5 zt1?Hrvu2OaAJUdxffoR=vO2)=HTW-FHFah#10UN|?*heCi<@F?Z1gpT@bO{gRG4H9- zvKiRNi96pFtR`5;Ya^gyY`7BMC1I2W6rx9=F9(EegHQCo4@q@S#Yyx7o=q!gXKHJHZ8~oWi-`~%+G=x?_i^6my=?LG@S&UE zDs5?v;toQ24S6NxQ+4mQKVJUMr(}-_&3-6_utJ1fg>f8Uue|IIQTY)LqdatbHDZ{5j>^E!QK=ub*zG zs3Y*I+v9IYpReZ8iZfQ~@cw6SpHzBaOWk?&LWVyTkswaPB4)!0ZNZ1M%nBs_K^ht| z_7$sBZV+0JVkJBS#D((5q($V+7lx)w!bv}}DdL0`xShDl%%QR6@a_>DWHu1_WpWJV z6YO1Sb$NJ@2SmAk?oGeBl#ka6cvIxpJcYCk?mXvN!3nM0Ow*#q7E=3nSnO-tyH+Co z%bN?NswPO()Y}fL3s^5(%co)=RRXVLo;P*M+P*(y?Ph2gzsdneAmhxah>6NY)GUhg zc1{aE^|s3-H7DJn+!*6sjZpJ@1&i9+_dc?Ut(VLc|6? z6C4lM^qDuy$u3%hKUTFyy=xe|k%s>Uh!lu$PoOXx+1jmkz50(poVjeLe7Pay-dW{6 z*TuBZ@7WopUx)cehGW%vHr#THlo=k6<-=IlgkL6gPa>wRW>7v7xdVA#XzW=7#UNLg zC$!YctAbNzdb~>V%QY=nx5pj88=^==QHG~;a_Qt(lhO`6{x?^2kaG|x!wY9$vT>OK zxo7ka`XGE>cIr+vOtSP%{3LT3kBqj%znaAfSl-QP=Z`S+1XtdwZwyI|eR`tU$c+yg zmp3-Yh?!`&>e)cbEyr7UDRen^CQn0vgF*BVH;I^ov>P6~o;rWHNKf$zil4KV$yKi5 zJfn%3;mw<0)rlNBau|D9E8_`&ASJ8hqN7+=df>|S%dX~EUB4IWdJomz5DpSG`9L~L z6jH1x_q2)Fo+P0DcGFfCQaeIAq+4I8V}y#Xol-&)dms7yxE*dbQqjk^7)P5>c8bhq8AIT3<|{(#3pz#?0F`HI-KLV8)9Ohr z9zw6fSqitGC+Pd>I@QWLrf6X>;qdd%3`H|rD{maWe*7)B@a_QnZ#WAS=hZ8-qpz^z zvHg`5XU#G*DH}2#e#DBIgOc;G$3_a!``e$8{ex!@U95)R2zrOf68F!!aUR?HYv$%ctwb zHFdm=uMl??D_0^8mn>bWO1hj~KR9>uVh2DHYRy^EXkh((r}Iwp`SoO7Qb~dJ)klZ! zI$bz=J2lBl(BofZNgEfPzRG@N8uX;#Xd;hv^yB`75SiI7aN2e06}#lurlGr@KqRJ` z_;OKn6JIf*fARhPZqX}3q6r<7w9p-1<{gL&*<`^{Zs?0vN-I1rgkItdOW*pp7)`wa zv8H=_e7VgN`qWM$LF~gA*-(0}8vxZZaDF%0HTHcZ5``0~@Bls(afkhTl3rqN)FuH}$EvkoN`EV?pXO778bQtm7|Q1SI#ux%MkZE@z)9kMEY5Jgso_dNJ@k zjlDk>kzhMGDTbWIXKq@Jbv^uv-0VMWc;4#E&W(}L3kJq#=_-eb;X{XDCe#PS~Q zZudG`%|s*uOuU1f#Az1}j8pA@1@V&E-Z`X@E#wT70Tn3rP@R3TmZ0US`t4ICkvt=l z*l_OM?JwZm2CNymI3k1xS8+!Wb0L4`!`C1_K!4gdPpcAQJC?HGRbgImAV!5X|BjYd z#>@Z7?2B_QfP@;Rfe;Xo@(2sU@-S`dosA2{N~GPsRcb>;9Gg)(@!DF}aagsc3fYt- zoH)nRuskR{xh2qROfmVAsqgt* zGM*jvIf~5!rFr#8?|3TgfLT4PLJQW5%_}e5+Ge#RKP*+5c&N*#A1jh^cyNR=^3s^@ zk$Jk;iB_L%k*@*6xW&aaI1Dr(yVb?1aL#=7Yk}O}Ziv><==;$0M-@OiW zSZ){yu^L!b_gEO#(s2pk;?k~6*7b`9i9NWqzypYX*kZCaEv;od@mjcvSRlrug<+)` zK+|a$J;r)6xLdGL88kL(|MHo~xiRnK4n9|YvcRd+dlF~oFt5*Kt8}p+2cHYSoA7Q$ zAT6IW7yWho3L|Fou_tMLD>FH@AVI&RYsGgrO1~n%MYb(P`x%C4RgTA#cys`oD z=YC?xyqA4k9Uxsrs=W{L6~9v!LX!BgE~+VE$+Y~Jle>nyJN0&Des$p-{|l%QaVJ0w z*ZC%!y`PZwev1=m1+2IBm>{n$jqJTycpYssZ+TxHRd4w9|=SIFEDk)i$0L;(Jo)PYa#arK?L zJ=FBxsN@(JhFaN;zY9uIwVGnY98|a6HL8B%|KX&ij1#)5a+%lDAs@)_Higg8BO`_G zpfV5A%a4TDd1x7@(DY354KoAH!zoh7%bha)FJokA-zzjsia!a;HwwF?E6AiK-aHFG zq&9&&5PLt(r3nnh{25yAH>e4R6mfL5ukG(X_!c>0U#mh_cDOBUq?A~GF3YvxQ(;Wo zj$=(KKgqu0!x|VgV{|S9tTPBuS8sQp%G!NFupij`&3?c{jxVmW%2sP{jFhV@+;}>g zDRoFd=)`Jiip}+Y^?(PDET3jomN)W@E2@ZxUUfRaLpB#PZ$2F=yPs^I{Bb2#&CgR6 zS+{w&FB+^qPe4*3yc1DovJnVk&3sk(@~1XikCp@cOXcc0Y|V{Q8=gBl<31hJiOxy^ zo&|`M(0V-Dmc%is6;<{ai&HB?)V7OkOPe04wv&^S%ezGj>kH0pC{d%=zQ@!p3mqqj zV?Fr%CDcDdfy)1XP2oHXxSsh+NH-Zu*BGfbW%Dm_nZMkeg~B04mF&WCcUg4}Z$})y zpP#*IC2OA7o0Vgr-5gYAdCJN==Ys`3%G0<5%2&JO=X&$8j}7x#mYa{ipMp{n;8m@) zfCl(bf!Mzu%?8?Rk39)S|5@nB{NEwNGP1?^eMr7F{8v^^Ve*)3tSeU}8$a@i<{A%I z8O%F5#DDAPLmQ4?MQgg1-0^o~ZxKV5`4FG#yq_wQbAzGISKex}&|-sZRd;yNsE!F1 zJZlyn9Vkbna+I|Sp|u$!%)hvV<2Y}$i9Y!!_odb1;6tsJv|HU6n_TcCsn7}82&=zz-H zs(S7e^r_w|IGE0B4<(18V>)IEwL<6xlv0@;={|~gbkspbPT>ZU&n7O={wK$?=x;#t zaM*PGm=^CtExhE;w;!$RCdVJ$IYD@g{H1Dg*=^ut(NMY$IH$18Tu3gcCi6^{#*xe0 zl{)8gmg%R1ZI-GkX%*Vw*Z?DD-nY3tb`&`7##u^v%O2<-(=Oru(6wDHT?^E$^l~{q zm|x-CcZ$L`ThxLL$Ih4e0ClQC|4D>i)45ablodTKzTYh2Y36yoy%=CcLy&BM(uVW^ zh48G__IU3b$LF0|ODdli-K-9vG9S)c+h=XC@!L{Ct7?@k=f z`z?d83--!g%ud%q-H#4h-kg5CX+@=W8ZL#6MM0rHIOOi_s0;LG@ma}@Wr4}pXW{bs z1)Hur4rX`0Ui>o2n@UD}sLJ9x`w{W)@G=j}1d%RBK6@$a#9I({CTR3U-Y3ODn)v-4 zeWe}nHu?XrgRbhVY;lRuIIw;`57*}3cau%f@$&O$y~J0v0ZC(v=V#keoX-#aCTK^7 z`L-fP=XHm2$WWLt)uu^AqJF)!0g7G&`Y8?` zGq;gSxI=n8G{zrip~U)}jGMi06yh0*>fd;}5%;Q!o6`#GBvP+$^e^IoZ)tIm3&>={(X;%u zqP_o8<`K^1Fc$g(0uEk-g%pi=YFcOp82b!3_AB4y%NJKw%?L0uu5dC|nGhKu9)HMZ zuX?!dp1IA}@(cNS{rrif>#bMhS%_W`8V3t~$Bj@TX7ea9sl85a=`>yq@DFRpVR={- zk_E?^6s-^qx)W5D8hj?nmY^K_ciHuRx3V4o>zBJut>syMe{g=2i!Rv_{0HyMJ{OJe z>Msw-*GxsF+`hf_W5wP4#go$CK?XHvYMvf@a!d2))J+~Zww_%Y@?^Nu>WJnG)Bns0 z>70Yyy5iZ~QJ3Bovyxm^v|Y5#((`ct^QE~cHI{58M4@CGEqPkc9o~HSA#vJaI#57Q`lsr(Z!QRR9a;Es>x7il=tsVZaMNiZvcs%)samgO z`Cm!Leb)aD8L`Gt){6s-MNs6bWhuT-@Am?2wu_#az)2$rlD4Q_spzUF!odc_2pp7$zg*}#@Rut9y0Mc zPB;FKq_>P~`hEZRQBV{W6eJ|2B?lq`V*`mSYTFoW=UH(sU8MDxy7RG?C~kRy2nUEN z9+By}yK8HWZg@R;I_@PxePM@8)I{pw>`PlCJV_Z&Y8?s8Sl?OuzQ#pG z$IuB|Ldy{esA$tOhF3Se9(KR>NQg2HJKXt=V)v(J)XD}_zA(Z`P|N5q6wBXv%+E8Y zZtAtCukZ2OT;q`g(Fp^HraE)*<1!9T9CE#y@}ub`uB3D#oK{eCzl7o2r>te7E(7j^BuFuk9F6h-?wpIIMWs%uVB;`Hb8Ae9$P3 zVBlilyOo(btTC!dIh1MJ$nk4F+1s0UlN=`?mR)sv25c ztOJ=I0{=<_lSF_?{M0EfA)r|dxR{Kr_l2r-)t54&Jkz%+8vp+>cOb25k{ScFk{$;p zp)udO5K4!zmFDX}-|M+TOKy+E)2_d$djV%~+6b^pE~xjbtsFD;kds3B_2t=0Ol<8P z1%m(aX{VYE$t^73wif?IS)8$BmvK*jH>(R5@oj=xaX2Mtrk5o>VT-xB+zUUe2L}sL46BctH&H=Wfg`ghE z1s6=zjYFq~_U`hC7sFa(p2{lQ!w6*GStSoH36nd?A(O~}TJ@hd#+cp8<)EoOeDi<|&6kz4G_=7p$vxGJOn01$wfVO13n^Z~AGq2>z}csnqAMPhoJ( zHlYuh8MB;+r0bdng}%?yua*$e%KB^~C4S39&sA-=j8WsaNDsX`o0*gg=Y2JGxbf?^g=n5|u-56qeKTrLf!D*>oN{(%oc&54)yo{Gt>-i`nc0$Fc%T2q zGhaQ1ES-^@EGWc+N@?>fg0&(-p7xt$S!Yeu^Ci`Kk<@q*9*yxgYSr##=`3>5NIcTg zVF_|~79uJL!{9hG?(_ei`P170TB;~q&0*V^JI^XjX{WaYqI&<0rFQDWb&hr)uZL-w zU3>fR^?K59`&j1LeT=rKd2R?|sg!d8fXu5uksC@v8||$XTQNy9D8O4+kIr zYOf$DHFoysPyDk_aIxM!NFv-`z$O*xUWt@RvycWrgcCEkr(-5KAUSc_&eY1oCNXg2 zj15#)RzbQ|h>8|g_Bc%KjFUE;QoJ87MFA&*m9EGF%tq;_Pmwpr2L8bCa)nd=Bm@G6 z)}0y+7Q`3lOp!wB>gy*CTa8xGa=VTE2hg!EX0tre>f$<$RK{O(#qqjlX%HpuJS1+w z>OgEdM)rnyHL5&usw`FP+6-ciA)$Yf;07!0;kPNm{Nl=*_ED2sfBm@a>` zEs3-PVgxi3F=?x>lG5x-aP@fN4(%|y@E1(9Q4n2T4ixD1)T>adi>C; zb=GCa=fnh&BmH`&x|@<+NvTfuM(=!wDl{@i+iHHjcGa|u(eO^pQ!ZR62U&b62M2NN zI>h4D*)ZUYcANIPOKzK2Kgl%YcAxf3Fjd ztwVT+QveSXQUL)!wKn} z?{j6ypnb6NO(UC}+X+z*N}g90R2DsF!!PB=pF1j@f&R^dcIC5n#g}tMMn|6rJdSMS zC0ug5GQH`bm)5KqWszGxns<25_h2wZ=9Mxp3scMOSL&&Jmv9qjs4AC1%s3^v-;8fG8lAhNWa3GGsR5;Pxf{CGRE?C`UV@B z7nT@%-TgoIEij+-0bglG0iYItOZ;nQ6_;*h`^Aq^`*4S;oN>V!(Hc*Qxg*(kW#bXy z80pE}=Kd$!anuDYLX5x%T&(8y7vg*9D$+2I>YKeC8hjjzG!nmtSxw~xjFZMn25`+^ zXYieF+)_)#uHb>L;NNYRM1yGi7jE7}gwbkl{M;O1;CN`?*Y_9#LO5e+qlqC~hmh@y z98h9tOt)T&B*`a84EEc6WQzAQNmLjA>h--awd?&H?%c9^B!_9@K`ohARmHku{nJJg za~==W4L}I($7*w3nnjbs%g?S{T-5(PUpqX`L(>^gR8o$O+dRV?I^T3}p2pIj4_BEV&VMy>fw#d zv0R-6I)RA!m#L#AdXFAtmxzmte06=^Q(tzok3W8Ua&`LfvTcWWxD<$`@OCEItXdtZ zzFNF<)MId<(n~te|W3N4`Gw4WQ6dVzIu9`ln{sOLiyVcRxLA3t#+i2M{is z&ldCO7_`z#Tz4tdD5&F>W8nNkGyviqDL;bf_c!-I`>F&Aa%cy)mjn)`Sj1VE=7mD2 zcW1Wxrrf;r)iwVEVfnR31vdwl#hjp4X7oZ$hUV!XnZ3)!6FAF0Z!;Lisq&~<&ExJQ z7!py>j+Jhhib!pr=4HUaymp#)-6w5j_%}1$|3x%h_iGP=n!?0M?YJdy6er7@DP^S``g;>xGa7@H#Vu1R!?FAcdvCOWRVG0y0tY792 z!_U|U(ck+cIyHt!%EaF)ZU~5Er)t1PlKKyP38EqdGwEuAxIv`W&ZwqKzPqcg!l&&g z-%s=Z-)p0T7IDURy)(pNO@TmjdhL>)50x}NeL+;!R44Ub-YX>KpFP5iHWNOFe(1Q+ z{+ZOtdLX*58NztpG_*FhG!gpX!}E6+d!ajgH_+akr;X<~LS2=Mk46UXr*V^nH zM-Xng$Bl17`23l+!D;q>LDx6#K+&+IlYX|Z^_06fwkOn6(1Vw#}#xJiC=aER(UE+Y}RhVw69!2FcqEtkERNACT(Kkjo?Nk4!Kj) zc|Y_dx{Hh`NlL~{kWhxoLc;v=WT`b0+N?u0?R?;ZE~#^s#*8dQ`GX|i40))s^5H=e z20chiY`M4#zp(klnJjAT9A~xwwGz!|{WCSvm7J9udva})XWJW9=biY|r&&v>Sx4#j zw&0pdrud{;k*`!B?;RfU(c!j2>NwnZE7)8iRPO)C9w3Bps7X>yX{abAR1PFo{f*I0~!=mo?LonRh@F%Y2vlPk$0tTj@B5)0`7<|-qD@>MOt_>RMv1c zV)kJU&It{k;60{xZyQkBx|U{jb|Coap>cyd<#LaNNmAK+%QGJ?k-4YM z?eY1_L&dNZcb(#cPP-$tTY|s6(*CbQFowyL&*2d5JnNZ$mV^Xqjpvd#Ndei0l4ll? zXTGG1F}tBr%17ox$9i|h&Tj`QP56nm{WYU$yz9xwYb(rT9r>%2hSw-}m}&bEtw}BOLQBVH3ZGv5cV#zdR!uBpm^2bn`eahnC~;q+ zvr|q?)t?3o`a~Z;j|3xhLp2mpSoi;E6?W995v*vcA1Z zc5%UjqXN?4D}e?QXenV{%ok(xwpc4d-~mDk75fU{!f6{N(=`?L{5#E4dQoS2D@yCb zy`;gL?#Vwc?;oh7{Vqa*yd<^137+EC|&q%X9B^9gGSv-!w&t#gs$>);96iim}{uh&q zC+;~6gpvTa0`f=UINfFH7DjKl$x1WgU_%J;l;e|1UL!O6H$N0d1-r{2v~Y08_gBB( ze@1y?c_I%xa6XHfd0cr$?R3Du5EintT6OBG?F@$3PFH=a z@Z_#>GIBvG#8IWdgyerB6jVC>5l1y9y1x5drX&3A=^IC@*xlZ{WmR9(u?~;ruN$(J z3L$6zP--$y__{0Lm78Q_5=0jVXlM%Rp2k7dHS?Qp(n(gyN#v zbTI4=Pdmt#b;d|&syJk#oSMn+Wa9l&f|Qy+tX-z^tX_oo2azvA3FNWr#jRlLkh00q zRt2yyk#Mw^2lpeL$<{>UIt<6#=>-C|*Fp<#P;T*RWbYWQbZV=D;TQe{%q`#5@zsH2 zgoO44 zhO>X@LHJWXg&NuA_wqHJ5bPOM6tJ(Aya84!n0(EaXNu}fUbL+{Zc8v$&Ea09ZfDa; zqa|Wc@q|Ait5$?lJC`j;^kUcKUtd4HUMf;fS6jDll)c#cyvi1ILZqIKw&I=9?5Q5h$#l8{jKJc z{vXW?N6I<;n^Jj%Y1<$FW*TJ&>DbtFd64vx4RWgT>#H;>Qz4cONzaD{bNP#Ib#GM(;Z*9 zh5FMWD`)FLU)xujmWii_R5EjxwOi6O8*JYJHSZ0e1W7EI=L$BzFPeuO?}&$ zw9Ez{7_q*)-;TPkQpCqUcB1Rtn^cRZj$Ded7=d7}a+j&OE^pQJBZ9gui&&UZeN<&=9bSYcwQh#=PL$zvx^_#}NgsR=EjA zmN?>0C^pAke_AGfTCHN?D~#`60!M&B2wOaQ?hs)M>HuISFEGxHBRM0XmUBfbg8F-W z%q{IlQ6rUlM>Pad*`1lLe@LTJW_ab4%H6+O@>tc%`VdSJ33#@dv$($BJ#=u%$6!}@ z9O^)DKY*#k!qEWZS14?or*jK0Cc9;EjvpQ%?Lv~fD!(fWfWhYGc~cR<`l0p)|<*3g7p0Pz{Je(mz+*xLC&P2;5_gk1lzT@O%c zf9UGPpT@`{IPdBp;!ZBgx$~#0f}L2iq(gIJ&-9C{HwpKvb8Rj`+cADI=4{FlD;2f< zm5R`!4WSY-*4?871ijrzKrzU;p5l)k`#3Uj!{B*JQTz152kpLmvguTP50;SVcAneO z0JHg^KW8UAMPAMbNkv3Ug^V$c8>s|imdv87@9uV{8C!5?i1IcoD!fY+oWB97#^Q@% z$)(W88Kid|eTm$@F>t@vaIV;x$@**B*WAkT^&T(WeF7;WE%N|4GU| zSb_R=GyDu^h(jBl&Bce#J5@o83-n(Ql|v-*-``N{V`9+vjwp$BCTE+|>g~25hXwUt zyuUyJC!B4*J1j%djX%8rmLKDzhhm?^Oc=qd83nd8vCG!bd&CaUR8>FyE#tY5gMZ^I zSARyAZiftR2WrRp^}q6!%Dd~zQgMtcKK()2DfROAAshjPM+ovkDQALJ7gZ{)!DwMB z<(H^<_GYN+JkL&4YOHk?4IOJjkDNfthm58Vl~lf49fDT<<<^=Pk01jI<39bd!7T#U z4n+mHu0JSoIrycQ?N0R=xsSWu^_O(xbMsq*=Y}0zBxw&3BhHgPpCi6^I1p8wM(gN& zV}=r2x#FB{^Hl6?a}l|=VBhyi;!FEApdl=wSBjUL>jC*^d=>H z)(_xY$&x2WXF)q@IKTHM(3s738;jx)J6G9Fs{RJ8YUqoTAkU~c7ZPgM{orKK9GMI| z*{1usF|voaSKDee_^bY#>3@>N1#g>2=9xYTrt2_P z=w!iD{zb8cw1uKG#!!Fusd`rGX7^Xn(86fhBYq>{YJkPiS#D@w9@MU&RNhUnV8sh; zi!%xSD}2i(^R;Z#&qbj~ev59t91DjVKy0PfdK=snm|C=hFMs>4C|0PW_h0b1BA7`! z`Oj%bS_7!IWpPTZR1~16FGQ_7Aq#t0=8tyC)Wy$~_J$N^xvF#b9uR~nI%2ft$yx4m zazb0p?F6BsO6ePdtFV;)BFcc{a`y!9Sn7f+rEe@MuegF%mPtR^Y-ZzchYeg~TD7+17Hc6e?x(;koVV2)?uhpxkU?mS+@MEs@8sNap%ztV1aP z-#oNGdQn6pXSn%&LO2+9S>8Vq3O7o0O&=(fxRN`)eDUv3?&~vU^kcLgNwH!HZmeE7 zN&s+?rq3?^)MHTT^+TnF!g`>L-w(JY_^qG#SVVmp*VvqF^69n>+V5LujBS`#voSmn z3@Z1xc70~e8Y#y8*TunrUEA!?1;O7R4AfTUfsj0(TaXkYe5<)eo>hV=FOm9#A=%6Cb0y%>=@lJGNHCz0Pr~V3 zZj0Qq&147q^MUGxA4kgRUHlCO)p!<^kd92x#r}ju8&7zXbC*{>@RE+fA8u!(zlwGp zTOFqy{?OVyK9i(CJ)82-w19D?o%N(yZ|&%I-j`7v9+}q~m><|qmeuXvJAQe9Kq17^ z`bS~59Ulz*5$JwI1}2BZr%B3yv-o}XIzt&roi1RLAWUyDlas*w7ztP+fy5KJ| zI&Nlt8vB`$80RAlzi`1uJgQJol6I`-?&` z^^F^&)qO0erC!U%BlEEty6JanP|&g*(W^7Rih|rPHY^h`nonT9MNmi+oHAlfM5aOn zP6icQTQE}Dw*|=0@WzG?k?)FNXO?TrY-}8dc1_tv6R&{(R z-#jC)`?Xl!d&Tg{U)(3L&G-Em-5xx-9Aq>GkHe}jn{5f6@X7cv zc4~nj&9cs$qR$QDW$(W%GGxdt5DR?)&s}<#{%d=_1w1nj%b9~8g(Abpr!vaN%J+^A z7ZtKMwF3dg>vMLh@{_+MvOES%e+}x^rdx4P)84E;ZUOaQ0vys??;A-c0^ck`3l7fJ z0>eXU84vFGmVLY$hTuuLoA!fr-eGpNj*VV_ElZ+my zk>6I$ptRRQ6e-}0B}$@aBQ)zY@A#r!Zeyhlj-sMdOGtmgcBoCV&?##B%m_*~vn&3K zsiN?DHs*!sBmAat)q5oUo_6j5)vuHs+@GY6b_F2bPWaJ9@4u*k5Pa6jKYMC|R@Cz?>;jM>pJ%W8vcQep zqmpK7qa(w~<<9f@ihS*hPdc&h65?h$i8G?`amKW%>dcA+i2%Flj@$8nV-K>m;CL&ZhD&g-ug|dKx%mHou3u96vBz+ zd|}2qMNqDul+W~rAA;^oac_sN#!dTqNPw@A!2E2fy7!{q(<4ixYv{pl(|+EG>QsY> ziRe-smEa#cZZXhZcaPrBTIZO1HiZ{0@qCoR818*$^C=K!k~qCZM&>|rB>a8+Tz$5O zQ*1wH8@ed#7CQYa`DY+=rK#itw1Y?-vJs%=FZ>jT&@ zV)ILlk}S7L_IV-E@4V--m0Q~?sd=sLq5%5GkvCdidS_-7a22Nir}37cyWXrI1rJ#g zw&F0wcCY_#^CQENBY!-V5O#c-I$6xTwsg2qq|uuh!CU2=_F{^9bbMlkG0$$MTgBEe zJhcJ0I0cNQ>W?dt{oe<~(YxqM+LnQ%EK4jA0kIp)uBed9 zJ5VnIy(00}=*-<3QS(rURLkM4(lTxJZ7$1zz63Ta?z;KW4B4y0+v-g;iXvLppajdG z+WI~YEvN6=9kOBvvS7u823X^sjjOa^o_}+;H$v}pFK64=7g$PPbUqf|d)y5>Jv1Dq5QA%rf2EG;#RA`KRyLX@FoI3lkj5A z(`g}S^Ea6te}3Ll&<=eLs6$=Vo;1UC6y;Pws@v9iD=51S6zNqitIamJJI1(v2X7}q zJoC7dmF_E_TRrZW<*2hQS#>z$JIYf2Ju(0wl2|<&T?FdEn1gUYQIQdBWs$awvI6x^ zq1;gQRq@K_ES!1CjGksV{ncr|Yh@c^M}m{cQDX%H|K^lJqY$N9;T63$h#DWl6Y4ZM z@{@V}dfzs$z}V{QA!rEv@JYa+o6Sk)&MexP)Bf)*13Ct=8*|QClM*y zysfr05lF-C=Vk7Q8G|ARy+?fjp{}yqgB4HZix`Q^e}ei;SExTH+EXWh0l!Ogr1OI70qkFq=?1II?0{T;fl1xBg=@kjILvrb0kYm^J> zN3wkrXXO}rQMb%fnzg#U0BaS-VT$p-17dVR_{Mm#H)Sm;T(_m}MVWUK4B>x85|c+u zweY>OR_`jm?)LOndGkHvrk`*a0T24B>WcI*Y}5Z@Fuh;tOQ$XF-mWCO6yz6x5d+eV z6Q`2LgO2@yjG-`zK@nA7681uer3_X_WnG8*eD%0i`oKmh6c zU3S^{bCo>&8q*31(x))%red*bGG`~hduDCKvK%grvoZ>`0Km2+jPwM2%T3IuiF?MO zb|R=040!pNp0y+37YzN1ls>eyc18t9*2%bwa~qq`$o14FYi-65lZ2VxTqx8p*h%oF zwVGFP%d zp`(euZK5XqvkM_~XUYpCo_20AqS+t;+LoT~O}+ZNqv->;V&m^tiY?&(vL0j`iuu2R zIFu|QesPqYz4bTp{|$EOMY-zSGfGs6#VC~j^xbtR5bkr2{@^(}+YS&8JC2g!kc{w^ z<4M6b8vG_s5h-9`UaqY?=*HPU?m~~8iDjIdN-FY4rP=21)Lw$P{G%Uv&@a0plEhzN z>8b9{sQ~?>j_B#_!}DMsd$=XGP}om_!>GNd6z(04@#SAS$bS|i9UGL@&I<_4_r(TK zWAuMps8oCbTX9pUI`pG@aS)2B8Y?$3kEq8l5^j>x|E3+bD*t|^EuQR>f_#^#wH-71 zSg%nUz-PZ5w6{arkpn3yhZTR;sgARhyI~t^mRHes3Bo~pr$58~{imWEZG?b-s zn|Z-K^jL$V&N2;jJTr3l@uxz^PgO3H%a!=QXVh8o03J|^Et-h0yjv*QLiw`IUFMIX z)~+5_)O>nS0Y6q!aLvtW))w@4NF#spx49RlB_gi1NcYM92ISFDZF(bJjarsF&Bt$j ziZAHtuio|%2`S?f$moi?&J?K_{+e4xO90;!>HT>7wxz+AW{H4wU5_6v+SZ&=r%;D| z!tK#pZHE6O$_k&*HgbOr#LN2YT@g3G_C)xeUfH7nYv(MW#r1}FMmlFvb|TAD$UIJ~ zs<82I1+)`WHUUM9u`sXtWj>YY>A(vG`0>Q~W`;^4@|V894W*KNACpTwahBYt@k@kZ zY))rbB3H%~rm-8@!OfL#iwQoWl9%>g^(}&-;bCxr@B8i|+q$oTcy5O#AClgYoKN{u zPzI1M-RK`$2$k(UIT}$Er^*ja2C33l;%`AYwcC{1d45UZ%c_b3v>)`Nr77Ji){i!< zD(^U|>C3)Q{#E@v6Cuh}zQsbFFczOBCS{$lV^Vx~#tJ}2wqPZQRMs`$gj#dSGAU+1 zlD`jcvQZ4&Su2S>eA~I4l(78M`k!Hvy03o)uD|ci1Cc_0jV)wBZXneCMX^Ayl`_j{ z$MH5h?u3ci#t>j5)b-bUL9BlV7QyHOY0hr^;~jXkM4=9aiB46NvYHae8+v=wg~A%yww2$Ws^CSt$M{+1+Rsl-)M_olUY6Zu<@)jI=;T3%odWSB;QeX)3Zz=# zo32;^TVc8bO+!s~?rrX8W|g(n`D_vM}RHmkbl zCM$p|Hh2zUWG%e*=IOKiD>M^fEsfe^*>o>jcLVQd zHEh;jV=C{-i^{!7VeFaT9xj3`$hk_6>dpOvyS@vP(Wy8b@CJT~Ol-1oBIzkE=$ z<~Le0yPYIA(Z=3D8E`Id+dxHa{VA!xwwrW&a~%7LC#mQgy>Fr)_VwcWOyn;h=p=tTM^C|y@j zcJ+oW$L-95D7J5+2C+zJ0JYf_Xtn?RU;!fal-iqSDshcVqo%#KsQwUC%bbp#g2&a| z3L}p9QiDUK#ftjk7vJz5Z_~e^RW(HFt9>*JOXXeeSf8nQKKHrQ4qvq(*h%8BSE0V+OCz~0~<;20Eq}M zm>AVQzHiGUd8P(hCbA6kR`9+fzgz>m`hr>S1HJ(F?FT*U%UOE}*Glv-0>jSiHmDgA{i8T;}g&VIKvn=Nd4>RGk<>=t>$!-(T4 z3GkD(?Yh)v9&MZ$FT7kZW2b*&#PVZR7PdtqD&OYLN3BKL*Rs!F|D1`F%AXTJfCB1w z6oVC;UJN;P-=RLh^*dHXv2-Hql;Qja3uAHPC6tvIE0N>*gG(x`e6wtba<5G&$WIoJ z(FRthb{N?Vlat#AqsoA>34J^~&UvJCLT`*OA0{}l#Fm)K zX(_B$PFfj56RSVoU`~m8{}vh}!V`=z8z7%~da4@JUkf-eH&G03vH?qH6~B~POn3io z>^jQ#3S%mJrJMeawsd${$sql$sG;yQ#>%LnPcInX-g>w3o31AF(fzRaq8jz4`?+Ew z_o+&i!K}L5IxXL1yQ=U1TsQxjHNaY{6b((hMwZ24N{j`dIFRgK{wC8Khk=z8Zqe!+ z8F#;z>0OO_){!h$qCIHvAjXX)$Dd0{;k8osMtT$1BW}sK_m6nfK$kqGsV_z%!J|HY zV_*K@4VZoIKOU|nhb-vN4X4(gDGEY!Ppj|e{ee|)TawPQ zC8k>Vc~WuA?Jy#bA0BCNf(Cle%xymDh)(tYEIe6UCg50A-PEhdVNNXep8B49{Q5~% zIuLv<+6ejw3UnmtJC+e1!@6I@YMV%ZM-_3Mmsk*SjY&#uO< z%rl29Oop-lXB={|QojuYeFWrp4^2qEll7KOc$HpUl_pVz?dqy(V7#Kp|8sm~(udEY za+4{)mXyb*aday6hZf)bW6bc0q0=XQ$8>1sCx@_ZBr917;R5r34J|NYKv=(RQ0cL= zgBNd05Usb>@so|V&aB5o1+akTyGmo@s1sWz_n>-eRl!Q@ZSPKBzs9+u>r$W^52xO+ z-tU-9>aMQ$k_k{?3{!Gz!M!S+w#!@p3o?gh-SF?pqYTT=)%X+WE*aU3O?do4|2k?Y zK?o+#84O?sRO$?nh<^QA02*>GV$V7!FOg_jGc7Ckpfuj#WA=%0+3%EN>?_|w>~-`* zZNqhm?8yoPO;Zo&8u)AkG?U)J{qA~NLX}3zt<9()Ej`=`Q?|>h$Y*;=APpGA0Sl|sGgVIAhGmyJm zWVh08vP3z~pj%wjc5axr6Sf7BXnOBbziA_bM%b3bWeG6@cEBx0gUgbqApFv6}`0jX6Vp5 zMDDlSu`P0;-J>5YYP%Oz9LfA5RO+_whS`$(NPYb8Me46OO=F_YV?q9~$3K|uUsl6e zQ0?rCad3grn`dE1^xsNTNA~kKRhw!zJ#CyQ9)$Gu(Ej?wJP(_k5_?b~&j?oMq&b}9 zOf2LGwR@p~T;yfuy=aqxla9LJESs#bucfs7w+p_I3nC1O{*b7D%Yyjk#*LxacMspx ztyf-8EPuIKV2I0h&+enM{Fw2%FD*OygJ(zE`+vkc#&V-c!ASi0U&@6)mE?wf6Zm$7|7ILO%R2&CG4 zuzOt954`AQsK2iUc)3v;x?Ys$hd1>Y@px4*qclmV4}2qPl?Pxl8oM6|qqJDkY%8fiH7{HR< z)U(i-8GrW)w>-Cu7d0&EZoCzW!TP=#n;GE!WGaK@=ycA6EU1X;t}3^PkIWR|tfFYp z=I)#Ahk`cxR*CdPAD^8V&1@dp6wQqdvHU4bw=t&Y#{gx2@>r^|3(J4JWz$BZ%0rVZ ztY410MQ$dbl7{g!>_%uKvxdLy{(1S6jn& z*$fo3krTa7?ru-`UcZ`}5Jlr-h^7+d^5SnX1ki+tgh;#7c-K98Ru);C_42FPZFaFm z`rF)J-DwtR)@k}&*trjZX6YNlHTDArQ*1B$SzppVqTTwnAT#tiGEG>9Sla*_UB?fC zc|!xPyI<90_0j!N=6yVr`DaU$TmFWX(r5$zHA4%_;ycpnxGTP$$C>wc?)@D#pioQu z&57x%P>3=0m{2_Ml zwE1D!lX`h<#-Ep2W|cpCrAJHaSL(WUzxB~{v#|m=uglXJY>G#YEpB0Im#Nd`O0u0i z5fvSXB;Yy~4471MI|5MAIU>%9>B;R(ayyVt@0%Zh7B{g>*pUHiXT^z^eYemJ(AF~NmpgvJ-X5qT{zn15d zM7x_{j7nR~xkS}^z{uL>2}~YjXA~OIez1d6`q2?mI_|Y^4SI*r$v*wpng5n_{@9#Z z$L<;1%l~@Sn;1$3jyq44u(Tvy#`C>+WTudffpy4bgJbc6w! z<4h)wPioC1llI9tMq>XAjbEy}nAbeJ{+UtJ+LM zIO1UYyW$%2RVRjPB3;5`w;jf#S<(_@vIayXuUdR&yLaYw9>Hq(+&U$eG4N}iLLxAL z%q_p`AHIOayXsU6xJ*Cr8PDA;w!b-r%$+YX)~&JMW@8t2o|Wlq`MqYMr8IJQJ;bq# z765859Ka>{K<_)Az(QKI^Lb6AGSq-i#Eqpb2A?-Pdy^1V?`}{f$wjl5%KG1nM+x!Q zpU%(QOUzZG0@jp+K^ArecIe4-;`;39{NGn*V?J5;8e~oHetmt#x^JOWdz)Pl{tkydZbP>!)518u4pCeO8tN(<|kcE8ydU`;nc$o1e$Ro9%4+vNy{>AUGhIdge@|DtJ z+Z1XwHFA9UX5rMYa`7!vBV6SB?=Kv{Ol+J+>I}KVt<+5G!l%>#7z6PWn05myiW{-+ zeyXnC$?aH$Mr`MpJIIxP%Qe4uE5fwJrdQ{ul1M{$7^jly1IJP~Eo(cO#jyyiFeb1kWKgOZC@Ctke9 zd{<>)YUh*sLw97(#8gEO$)0I@V)N*Ua@3Hx&R?#m#z~kZacP9-sv;~` zjar+lrV+1x!B68(8}H7_FW&UHS{zp_Q_x0A zT$S2jyO+nh*J}iTH%H&=aIVXx*k@+>7%K@@g*loGt9f&qJAX3uvGg85Tq7+Ja$d$k z1Y~DoKX!fm_E7HQe6BBWO(yHV%p2Wr>b7nAu7DeRC*Eaz`kH_4kTC1&JT*hAoUh+-MHm&<KTdsMPW$nk3tap?ia-OVp5UHN2>S4DGpJEZ5y?@C%* z%6E8`gFX0wXub5c0RJ90k~MS%tx~(tGH|;2hG)saf`H!^aJ$78kXH1Ksd@e7iCh1F z&c>{Q5{09Dn3$c)@a8P$&qLvTJVl%DYy1pji;GIVwt|(7ITBAu)wYn*Dn8?gA@`F0 z>>Un~TM~3^^0y{y1#r|sDZRAd3W`0InuuTanA{~b-mHogyCBRD)U!ry}Do;-{x@oL9iv;U8!?|`TJ|Gy_iNhCyO zCCSK0*5zIyduGpTCp+7{MppLT>t5Tn_ufLbYmbC%*R}5@M4$h=@9+P3T#uK_Z3Sf*Cn2; z_(Kh)kCHAyR;hd_=~P^^)e9;22*xn3@QGhk(Ev_?tgXE17JO zTxbt&mZA)_C-{@WzuewDf*lxU_mPwQ{x>Kq)%W8o&VaXkbkB+&@1!q6q;ww7KG1#Y z5N;c+(??`#{w>2T3jtDc^o&n5V26c&-pA999vp6!j%%|H&9yX*f!|i-gD6o+WX>`D z^bWwk>I|IvoLsqFvEou1+jZur8y?zw$s=% z5){JM_XPQ3j*A$^TAR0)F;)af8A;| zOMAySF*Bkv$*KLWpnXm}2WJl`PLYb3ZgIM-)vi?!e(vceBi11zBPOS_Z@EWc&-aQu-;16sQAOgcN{!Q8Zb8Ts##;hB3_M%~y`Hcm6X0GcEAf8&;z--i z-{pJ?|J`}PYuBBwMt|Q0cNNVn0B0=F;(SdijVOtrJbuO%RQ*I%s82&GzFJm2N+pE6 zPv-;K%dcp@nDEg#>LIWZhZ$YDZjQ4Q`29&CZv~446W?5zDS*^u#Bkvf|g zUuy<+X)@(4Yntj_GNKrjG@S>*25B+7x7D=EQfdPx+4Tz4BQA9*q-VveWRDvs(!yQO zb~#mC-5lsPq;*LRHP{G+yXhmq&o=;Fo)flR7eN%qln<`}Z{caXbLJ$6jiZ?|CaBWU zCZSMynp&^zbSrnORWq?EYn!Y?v_`q%(H%DhXYg&lf*wcvwkJh}H127p58ZkC$Ow9G zi^?nR+4B(+FWe?$EH|s>C%FB*nzYyQ0YYbY>s+1T&3O4@?oMG9$@}+*u~pM#=+kpc z!_!GkzVWF7wCxT!uSHEkbiRt610(ntR;*R}xL?Xmaq!oN1I}qsgU)2FCzNzX{EH*z z#KaC~qNmC9YV7L(fe0<+VHTOoU#WgeF-l{-p4wOTjaL$xzwNh9{8G1d#vafn?E*5| z9AfgmT#thkKnn~{S(M)HM%nU+8HUydYm|1D`&wf6zZXS?m}ldk5{%LvyE1HCucF-apg)C#i-@@MX|%7F^$pGxsk9J_)YJA9+V~h+%pm~tiBdP z^dKpejIm(*#5eZ75bYK|KWOnp+;z{V>Q=+_09FIdTY5d<0t0pMh};!bAxg6+ef7C4 zhEU@{+QORi_XRQ`An+>VA5YRVl)J4-T}mHMCz41!xcK>(=~|R5zXpvJOI<9B4E9=} zYB&EE;0LL2dYG@=uZ!^V&o~R+1~2)(TRx>>;1)^f z{|$M~DxX$aQWq&iPp5i+w*6wDjD=k;7d@`a!57dkkBJzk)4Lyh1XRA!n;0QjOI(peI*h3m_5P( zAV=O@Hu83k3EO+>DGxZsXc|zdB8Kr6@9SuUgtS;*R>wz&kP|-9(^()PyHmuTMnF?E z{Gs+&^30+_V|O0|nORMB&us>K0vDdLa+XRgdH2HHCRQ35A)G;wPtBU>wT6;Im52$W zZP~L+##d@8KgnhBRV2f0^jk9SSrv1qRkMM{2bFT19$5==h3P|el4+AQDyx)-`|UXA zzfd?)cgp2XZnIbH!Tv^c_RER@bs5PRViCHH(?jwY*pq}nv!F14tl}5%9pC%lFA0B~ z5=49S-Pt8ScQ5uX4WEl(e}PWV7^%FJtl;bdF}N^7+T9&|1?CLsS^4#FD!eR@Z+yPp zJwO+dhjU(hpfwgkaGNulSiYAERGpu%%lM_{95B( z&@HC0$7#Bpd}-X19MdmelF+rwE^%fl3kkUs+Y{R>w}{pS*g>Yp^V$uKIa%``!q@Jb z$?((clJK<_NEh=D-?b#8u8ilV4n@VX$n9T4OuBY2ct3Wg++To&V@KEa^pz%G)oAm4 z9;hGcgPRfN_4*SF$hSTda@7?4CUBqd7NZ=(+cWjLNZbjv)^E|0^&i?>Hy*dAggAQrMef|u80JZSIZ4s();C*UJ_M4&_4zrQ$ z6BHCVc-@q*QOi%_D&F=Oi>oVB3$S7WTg}Ugm}#K+!>2^ z9%g!nNmF0Y;UQSOCqQ#bm(`pV8OfQ@ZT*eb?0$kl+Qx1~$RJ9Nb$#1|_9FpcsMs8@ zO03g2HT+)t&VzXJ;XAh;q$&5wJ{2JX!b--gRZZL;@2>ZzZ@RAtkfNfJ9oms$c+rGb z*Q%2D$4d*i&48DL`blMcwqevU?c2Jj!XF{M$mN@4h>bFcDPV?IU^ETxlPZ?#x#&GspK%M8#t3*%Hr# zPdhL4o0OD>S}%jg9ORj?NR2-TZ5%tc#Cq7xmy`K{_s5pY9C+j9MYj5X1PoM%KIqiP zzHfXcQg^M_EYDfDX|PFewA3hqB08v{l9;!!SWJDVw}zaA|5^tmuhgPIFpfLR1N zkGnWoN4Z|F^9zX8a}h0_(2sXg(i{MSQlrgBqMn}FKVU1NB&AK*Eo`jPzs>AvOYn{(H=Nf}B>Mdimu78gm6R1DFgO@{Cl=m%KJW_wkRTz=B{+*yzjR;wh zc|y`QP&u=<3(}z!3uTHxx`CPmhnd~PgxQ7<%~(kZ8PJ8$ty`p#h!m{d zCyMuW{=O{Y-$~ghwD8nFgkXR`i1s1Dp0dbG&OGbexSYb;2mXr9cb)GO@OR%)jWz7H z*jF3$w!_b2T;AG0XlCv30YtRpPF)+Qrka(7)J2Sd;>bcI9MDAR@HOwAGUN`UsphZY z6gX$6>9CuR9$MT{N)Ay1#2AUHj6LC0pr`_MGBRz&@W8xxwLFy7cZW?sPb*UHS_%>{ zM553E3$fAcPS5qHDLL70ks0)x%M`!ej}l`glQeSXUl60RS5`yt=5JkGUo-|*D(7=> z_Gskv*!wiQz7drjv&sv*S3^GYj@Xh-pF!B1_2Y}hKEB~x!)J5^LIvZ+LbE{+#m?@j zBEPrazKU$nw^J6s4Q6PRVL>GW&@Ga88HPbFwFw3c>Mc4`}|l5=r#8ag1!8gmHCHPuf$8&=93PI zZ%-L=29hG)E1bLQ#?F#{{GlBcR+%z2X~=W^zO#Gzy2I)U=ESu8*QAiwH>JKcI05SZ z#7#ytcYa)Qrx2@rPLeMB8 zG$w!iAoCky&_(+!{TKNd+E==|T$V0JQ6_B@I??IvIWBPg{){4i%Ja!uGFjy$>j!S1 zs`vvK&Cu@UN=eVy^eCz>wna2wpW|>1PZi-9;Y%Sgr6ww`1L*%Ij^5_-JifZO?U3}_ z8P2ioklg0=XL`Js>)iS3-cHh;){8$eI-3Jda+%9Y)0Fb|_*U^RipBZE;EW4s zg!`eIpzrpvFZH1~v&+q`3*U1OUSST5lUQ|87FCIb^ZoDH)-TX;kIms}aXD0c#P%G0 ze|U~!uS69!gxJ)RBl*{l&rV#Yt==5e)K!Np16k@Wq=1J_Co33KtV{4UI-EtW7R}pM z{(-Bwav*#Ah9WW&oy@QAF+)P|`x3~++Bt3E8^gJ~!bsf-Mtw07Dkj6^$V08lQl*4=IZ!`t%$%TKk>7~lm?Bm8`>qM~ zy1mrGbZPmGh*pe zZn~)6Uimqptb9zqbZ4w&XYT!Miaok`lnwkO`#+hxLYfXwJCw-8#6KNkPQhXiL=rj^ zmyX5skDye>{IXQY_S4gPkmxRxxr%CHW?gOTuy_l$i0{$7LT*e>6?Axg@5ood?HTG` z=uqe&sm+ELbSIW^AxfRXfj2)}Q>9-ai-?icUsIX-&7Jp>&TL$--z)Oal@EttjYz^t zO6a+Z$^)rT2|RcwtsnDH|IGxE6;QKjW1TcTreHxU?RnKbp^VB+8kW_2K~Ooz z2P~2WmsF&Y4$Brqb=(cnxWz{Ez+SSpdf_dDYHbZuEsjOEvhz#XvhU9DKI%0hcGFo2 zZ`1`z0G2@CSgrEtBOWE{Ev&0(sI2Be)iguv=-<7auxri0=+Lafr7*h*`2l8s9^Eo8(40$CA%l6u}(bdd$b6&vQHZWk4WBegQ@5BT}C+;n6<4iK3 zhlk8so&5(=3i%anH+i|C(c#1e>dp}ld-Me#_)LyWhm|xQ(I@pxh zrbe0ErkC!q5RYq9$#TiBGw)@Z&2u~sA$00<=@**1GYvYw{!5uT5l<-@p~7vSvMZ&B zC3xN&$qAVNHZsYM(1+`1h3n8|UjDs4`XBlk07Xi5FW4@88h%Ga#6w7sY5$~EqvFmm z?T;`X4M8rgESNnCvMf*@2DeCQsaGe~7#imVi7&Umsr)t&$#+5@{^aiS#)TzI)E8Pc z@Ic0Lqca@R?=F(qHduh`kjFw8;i7eASAU6V$OT^x0BVve5tn@Jt`&eZbIC4OmGb^) z0Rpadv#*)OChu;ydjN`{yugfpf$}tG_`4Afx&`hSk1J-~ zd}|YM`>?9l>Eis88|ExzVsi@WOrOjWyaThYNHPd0jH5bbxn6?s{z02%UTL$n!4)Wg#0Z!5veZY1zL#%Du!?0Iy7`Tl{rHJHBH$b9$pP6JgB-*ag z-&-ebpi8SgGcghxW~l4|Kl$JwUYJF}b6dLlc%`ZZWA%nI!q&&%C(WInf0XQ2vX#mZ z5W?I%6BJvY6Dqd7!@b^LF)Yp@oZG?G3+Lan6SCQF69EiR+f54mXWsdTr$J zQ&nJfQgGYN6Z3KV%_Xua(J}4pt*Wru6NFta+I7!~RUdow3YmjD;8l;wD2@q1>zmI` zp*8QmvX#1g3VK`^>={X}fCVRP8gHMb&peM&jS5LLwMIlI(V+N2s|i!{L3NR=LdjaA z&#c}$6EEp7ynL3L>#53{d#C_cO+UU)$6tSWT60!+d8C5|8E3ha(5ZL#iSBuxcrY7b z<`K^LD=<9EdsUTz`=@WD4=Q@qMg30gbdxK!`@^x9g z+iS)@!*(^1zJt!p!TJfT)trkc`YN>vpBuvrUFhFDtO}?VNg)g{@pc@Z!(HinM{IfrM#^w0-VL z@%faqeeMBFR^g9G4%XoK)@LKo*-q;sNKBWYH0IgH`Qoy?`_yW3`M#Hd{>jMb$kZmj z`2@z50ln~qHTs}4cVR#3&f2q~;=yhVleinAG2IXUctd>FR3f)$7pb%GMLMV*C)D32 za)lPVWmuJ1j5}rnp2QH`^Q(j=qB)h^UGPZQ9VeSfQE)8lLPi(bmjpfHq%@8%uL?T2 zi2uh%_D&3oCn-RXv?t~Dw=3QPjF=J_9C;QgF-J3Z-v;0klZm`Qq-KuM>(aNT4BZ=KS2 zA9*e3G7R07T+7m0j>o@?ie$C8i}NPke(}l1H36Q3%q^iMSTxJfxPP}bSQjRv9cP^(7!k-fOuMKmNWtNq3&SW8iCDYmrv zqgABgsq*1T1OqX@vGPz*2&1X}TY8ne$iQHi2nQutiTyJxx+m$tx{&71jf8E==d(k4 zeADR3j)~GXtSs_Vj&bH){5Ng86cVMFZnj*bC-NV|4>MIFlN;n5n(a<%xex8I(On%ybFt3byJqXjzO1{sW`o2|-b?HQ`uCmK+$4NEIH>GAtpPMUjN+nuX zM)j*dcg>uh9Q!xC%I_G*g&toCf&8Q*&fB+iz3SD=^6{q{rLV`VF1?_E$X$m;y+oDaAm%qHJ*+qJPOmY)=1{T6KAiOg|xkdt8#j8wu zXC6*zr(W4{p?KZzkSPu3>|P zYX{Ek7mmGz)NGUx0Thu7HGR&t+Oq=l|5}y1b9HaaA*JIQJhZj{zYE^4A4sRBkRBpk zYQhRTOffMrj)~(>Q!v{Y@l(>XH?^+b%yJb7iO@qYtAD{$!72F5GZ2`Z{LF-X_B0VS zh3mLy^^crlF_)bQOTFjUk~h4<(@Gc6)v)?AakPBX*F&$*0AMilCTQ=!w}nyLSK74E`g1c1;Xk#Girs}6-wez$9zi6#z4V}=ljksz};ag*_6%7azp>&M%6Pk zdy12qTp@2_C~^U~)@Ysadd8{9|^=?(8UU0`-v9 z?S>DUA9`Q*-UbSh=6s(yLS|-CLYIhzqM8|>Er^+#Kk&~F;IzgJ6qT(x!u&*^G~3&% ze4}GBMtbIxYKL%cx7)&f+l|lG+}~dAyPceEeBS-)Q4%*N_PSr$uFb_BnmZJeyiM|c zJ;lTY3$c0PePVAE__O?7&7en;S?(ge>`0_d2T79^wamlfEBiw zJm$^8_(pA`4I&8vGwuA3pyRm1{SfDKRH)yr8GK+Dl{Z}oZSP65(JSGyPEbkxZgUhQ!J!idygG)M5$`8~gV zIBYO5ApJ0rIP%i?A2vYOES^1MkZd3$=Ri*rVc2HHM2kTh5-RKP({|AH;OWc1QSDj~ z2M<)f_j}7@oM=Y3W|5|3ZeK#{DtZ8Z%&|K^lDq3j95daw^^M;AuIija z(yO9j8+BV1-IC@s>`n!1fLQS7L(G6s{i$YDeX0JZsvgTrW%J`SUOy$de9VQf(q1&@ zZV)aMfrn-N-j*NQ;_0!%Yo-ij1<^4+DzLPwBQ{{C%oZmZZ;4k8B6^%HSBOf+T(QEa zPsNL`D8xomoD^LP8q%L@L{o`y(Ksgux1>sPg_7yW5e~;!@nqQ`JcO_h$`OVbuC(o0 zub-VX<8gY7czHXp&2UCa1Y*m(87|;NFJ!NTCDeRh%&1~toK}NBk$#vmIqN`4Lp8Hk zG#YiAymhcor&p(!j`HP5@mfgtgAiE;1_%9lNm*^qw?s9K-|t9~PCom7-hT9f^@JHc zUJT6hTI=fc=lb$64>3F@vfyZkYgPfaXb6ZWu(xH{6_n2qq5U#>uEcQo8cS5DKI|`4 zpcn|sL&vM|>XMd6v}8U`{X!E)rxYYgB26YOU!OUP2JgA-uRq*2I%PNLMtxziX7jl8 z_1S#Q9PXrtmyEu6%#8wzEd@p#=LBClWh@QkQ;#nEw=XzjUKgzoxzVt+$) zzwi53`)!~wY#6<$y`scj(bQJ!?6!4S`_0cE2~1SnKhI;8a7R_{7ad{>!XNd>zTd|W z`WEa%?X(NGQ(YMEEuqand)kR#L6kmeo5Xf1;r@EI^ksqlJnb9tKC4iX8(yj=p{!V{ zJ#pyKbMY#%30yq{mAc2w_rEmaEAk_~!S7>F_RJ7~I+mf1^skrt7opBN5oa!Ex=5}5 z))u&35D_hAd$;n|TN6}f0dA1(O7x0CJkXsrrxW->JJP^a9)Shwwd86NT>$_Vyyp|q zM{BC%f0-}6fYqL}*BIDJRQ}+ZPeBzx3W}X-C0m96C??5kr7K|8Q(Icv#`1X~)Y+Yfv+T<_%$2LF1T|fJlrY9`6j7!K; zpFkF+-c6z=RNz34s3=-G>8TAU71z4%!fPgyI7O1#!3`UjAVIDKh#%|{g?bRB@7>Om z$9GU<3@pSZw47;O>OdO&tQ*mKr*71x8Fi;K7@PVc4=z0 zzzn+bs)RpY%R74!SBrZ__k;4Djz*bsF^nobF84MGF~Rcz9vegY+wIDeWH-OQ*#13J^r`N{!vs8QRG5D0{S2F z(dgN6RhRSS@x_^QF$D_wkCdA}cvQgV_9j`k!VYcp0WWg)g1pYYe)yff19Ny+sP)$o z7^g#&v(es(l(J)`Us`_XS9YaVi}xL(8Lif<;HO8sRmr4ssnYs$yn0K>EGAM00=}$n3J6_BckZ^ut2uC{Z^cC_1nPY@be7G2W|)2V^1+#( z#LVKHa7;(!)^|Xu2B_*97?QKcq(^J6H`ZnN`h1fevDSiJ6(0Mb%PzKR;^WwiebsUi zn)yS3lItP;Bc{osYWZ53aJ7k@N>+tMY4jrZU)R}OoOc;IRxWYsK%AYijK>w0s#h2?f+d^C0m+N)wVjKS*O_8nb-^g^!QcK%6q73u1 zi`v)eUr(Z^q0ZlVy^L^!R!wpeQUF(UxYR%VS7#EG_!l-c0bc~l%(L=Zb!7!IxzIBOe? z;wD|=_JCd^Es+B8=?&z^W#LuVT^wzGhLmWJ??X4_$JZ@26R>axA*RgRxYFO;l2+Tv z?$6y7NU(8W-k0o)zx}*doqlx1?g4&nYU48dNuy_mF)crw;f!(+UrUQcG~?Su1npM2 zdzT<>o^7sC-1Es~GllYVVmW!_p5D$Z&6{zV^ag(7QLV4pvvugRt7(nnllC)w`(K;& z*#}QWpRvEwp(fpZrZ3N3k}l=^YP!fx@?m9?4&AWgf?Pgf;hVwW#RhI;g7pCV{8!ZP zUJej3TBOP8M8^m%bo)D$_gWB@c)UW<8%Z_}0SamiWHitF$++K*nBGnr2a0eQ!)Qdd|;NH+%YC^@=ln*i*;96t)bTfcxQjQl!PL8|JgZfCjYQt1ZJibyDy~wkCLlbg97k=`JmfSqpo^;zH&9+!m<(? zT$ziRe342671Nt=5{pY{0&X}?DrM(opb#^fd1`gXFEZ+wgS;?x;`;ZG!e-uY5>DBr zBSPS?<1>Wt#!jrH%-mZAqSX9^Z_|cg3W2imvC~W6Y)%uCosJ^{HN(L(0dF%uuXCVJ zF;qz*Hi;`mTr=&&*KjQN0`%~MV;P_aj~`r1{Yy~C?;QUQ8{*fqBhyeA@#|@z!sg8- z(+X=<6Av`ZNABFa-I1^PH92O*u~MoNh;LNnfUwv3FOc(*BvUwF2C7?doh@nLqMc;|)YTSwqo=p$23KZhPO^^Y)tn^eA3%8e*YWn2 z@7u#rUzn6jl;+3#L0eWkmtEeioiD*Zr(F4^=c{7f{hzYK@6ijlc7YF9*z{V)c-CFO z0nCsZlhIJIZ(op`U#7$@(cgd0_yUl2D#CTR2EqV~$OwYYCCOOq#p|5ebS|D5eeDp&5hceM$MpASHcEG& z-WmQCQ=#y%(Y`fiVTsE0U3c)t-M!N&nM`|A$$-J;JqA;A?ge^+jR#S8mG25I^pbK8 zN6_g7q^dgm?)VI|)dHnL}n3zg$`XV&n(e-AUc?f&v@>Q)ys;%zi+ zHz-%~UI;<2Zm&4Y>Q0@^epAKT;1$Gm7D@)reoVTj_50ycO^&*FLs*JCg>`95+Hcq+u@^cHBdHA{Y4|szy9c3<;NZWs`m-DsRaHB?9Y*Q* z`gd3)H@sJcygC@?Kz`^gzoK#ZIUSBWS|xY&VCF6=rt2b-Lh3sxl$xK!M{%|L zT_dSKPQbBGQ-Oxslg?~j-51^vwTew$lI+zU?Zfp0aYO+0ka-X1F$o>#`GISkgs$)m z{a9g)i<*0r@o zHu$WlBJITfc>S@$!9XqccrZ}!8av7Lvi0zE_81xHUGBBE_M~-lKKA@PZ=Kt_lsVM1 z+*2~U+Plgvj#e5|jg=0X(7%MDQj&mZOE$az+{eoa_zw1oWl1{edm68qzvfMDzN9&D zJ-akOYUi&)b8vpmwo66Ok9qW3=^|%|V^x;i8`*B$TaAxBF2^t*dpA1$mvEGro%q9K zsHHa(*k>hRnK{OvT_H87aL;w12|0vSL_=`z_vPJ{zC>JAmifJx9ZcOQ=zz?JPG({U z;>|g?oV<&*Ws*{jYZvV3Y zqL1NSzHwLKy6r@e0=Buu`G5V_Eo>B;iSJfKK(UMMg+EEt__n1@oCh4jHjWlE{lGuJ z>veOk=K$RG=aYF^iN}mCa`2+uJ0yi>%adFr*}J$bWN7v(Z}tJ~z2gdVvma2V6v+0A zIg*&f!Lh4Di|hE)^z2s4$aH9NRJtvj(y99?BV;x5paJ){3bEYo z%}mjOt1o+>qGFf>7DC}V!kMjj$78AQ!(H+08ls|!l?mt9^HbM7n+9Mzj4%7RFBAHa zamLniEH62}BpQ}-CL)-AiICRY**y1|dYqgzWlZe2Qh(wb0|>`%{(-!o2LgVGep_?v z#{A~q=E&kHSdFLsFZm(qE_QQIJn!EBFw;1xx&mNE6+jShc<=)VY6TEv_wE04O8~FU zRI@z)q{76SscOQ#@4Gv>J8+22;%e`*Ab^5i>wJK>coWAa(XPAaCmt#=qZ`KGkXV3# zMd!os%jq8+t{KN?2$k8|v1-nAeEnC6IjZ#| z?8av{zc>%7x3_7>$T2%+ne`eOn!ePJ@M6rC!QG(_w%tW!D-7++?pE8r2rN^UIfzkJ zQ`LdppXPeX`?hqXM035f(K=`p3N1<;knI%&o3PIhGk^JV%b(!1VA7CDYDyF3|nVpkZPe+N@y#q-N<#&mnb_aLw(S;GF^y|_mjy68{`cN{2<6rP*`siL5AsvbNgX(*|ZoQ~yxWBQ) ziQ9OTkz2Hq`Grf3y&c4U>hhGy=kIp@x!VQ!*;+4xFh#vWK32|CSNX6Br7#H6DBv9@ zBoFug#TxBuxuRN;s7X-o5^<3uR~O*sCPk<^Zeot+@a1uh*Hh#j-*IwE#(P3MS#pU+ zMqkE^Fz-l!&yYOd1F+frSl8ZCsLZ*~U=xLp0v-p2cCjv;;3luUsz$nW)gFla#>Du{ z)nC-XY&gBI!JmdYA*J#01$&06WobBIq@qeL6}4vTYZ89oGwkMyOWw70*YDpQ?{9mJ zMGCV_y0Gx0_ZCh;zG^}sSV!i_>9vV87YL$>ftarEZyjZH&0ae#{KQo&pa$n03-SJ9 zP!l3=?Yi!j0;=)NvjUjhq}1aB3v~U-DZ~k|v(D{b9O1x(x_O+=TH?i5HAhW^L)$`H z;jmT;Uh{_ijs3duB^hs#KV0M8{eazR8Tx5I{Qr(B2F4eU&XQH1mqY7v`y@fHqMYMY zvm^%r5N%)inJq8%FAMhfF7*Q->RIKDM|t1czlRFSILPj`G@TmS4DcOL^Uv1N~P zVGk7$>Nv5;&v1wa^-sa2gX^w|kEcFg=Dk9*WcIUlDP{*e%X+PBj{DBpIjc0^)7qHF zm){+7AJsT5i9A7E*A@8GT(P94AdHN+wrIy7P4RnuKwp0(I)vFhnmv7%_zHAggjSPI zd9UB1nt_maq+6;&^|t<_D8afMIMqO5#bvQdG+xo?>aj- zb9P`}Gmth$_)UfBsEkPQ0G+^H8wCr)PWuv-46+{mA#Q!+16ndQ=K?AK#G(Yx8-KisH7t=%65?hr0RM zR@)L8t?#VAdR7)qpEw)<}Vpg+r6ymQg&CFHtKliAzN3X>KDdrt3z`p!G(Q^+rJ#aAZZJSrgbr@H>!|c z8;9xdmmOj0Hi7TOd8!Ns-OuITy5M2SxGo8dSEJkcNd2kb>Uw9?8O*zR@`hYZxngtg zfarm|mAu=;uh`tdESf4aV?`xgJ9GB99YmL_y*{X~s$}brXuGcwG<+UN$jt!D0q1$` z4b9Po3U86Cecbu~#c++2j!3_VUjUL?TDkm#n5g9{pOZnu71vBH^1=ofI6(3iQGxD- zFI`&Ik8r*ce0b2M7~3yE(y2-s7-mWBi!OBC^>p@BT31Z0n*zpM6itvihCqbZR(N^| zhm}oYH?gt8sVHOz`8Z?&_)v>oS5<1Mh}R#HeIR?nW^Ak-tkSJ#Y_m?7=YdyZwm|G)8RUKLWecY!i0fq!q!A=Z>j|r^UX{kKI&0TyP?j9^GCV z??~br*NiQTGcoKvd;Wz5|Myl5TC9DiNf2i;f6`H$4cPulC3AVLri)*$As6a)3VzFu zZ3!dmK+mzvzkyrA)?6E*{D;Z0PuM0l*iq+Cg!peBJEbDfl&1)4eoU9~M(z<@96lNX z8XUE`N4!LGOVHG!sJ(u?^5KrgcEMQ$T)a!JWSSb9kdz(ac|AZP%#)g7Q~IR7z6la* z@$=#khY_{q@p{#vsk!_*a(I~->N*()PK#_zwMsilYT&E|#b`%2ezme7iV`CJF)CDk zmv2$1d&!P?p?ik$-cBS%S{c2vS0 zED|NAuSIn{=3Jq^^!@R5kTfyY_qhrh)+Fp-*eFUbc`Yn3w(pquK-$pvIn6SCe z=~M|Y(O|dss>GS7_qVWB!WNJJ)s`8_pw~Av-O@4`;mSAkyzgx{tUh6$&NFbysd5>W zS1`HMlfzjNe}M>$Io6upvJ>!Q6omW!1n&gKgzv&3U-G&jF(nu{#0kYaev2C~_76y+ z2Ml1Iah^+`PteD3<1FK;OUB>Po@b|L1Bjv75$oL9u~}XUS`#)8fYTlVWd`vkH3=!X z3Ea&inq)h50 zqo=W=k?-k}#e>9KTueD!_kj`QoJisyAy4lZbq6n*xtci>i_tx_cdj7@X2*T`9(y{7 z$JEnx*4fFWyP?41RpQBYA2%}YhZN{ax)Qj3Yjkt}JguZ;n}e*O>379>^a?3rGoBdn z{3zbVy=N@TWO)g)z^qYL-_xseT^Z3$(w$HP#x^z;J(%C$sCsSEXs1w*Ny;OScyBG_ ze|lEXOr+TTppnDt2jEZ)cI}=~;XLF9bc!r8%6Hh`gIINp-<%WG+Num>=Ay@@xkPaN zeqDWQR~_RLx?8?n++YZ6u$yb9L_Tlk{DDVSXIyzj6L;gWHEy13ec5IohL(1ALm>QU z=ULd!R^JSr-77VFqKzM>O(f%*eSLG+&DxMXPU5Rtc1w1`P2-QZ8yUV6OvMzUd`wD6UWI4Z;Hlh~W3=ZL7O z+jJuoa;0_Jx?1A%6)!r)2x$`SAHqWgKeJDNd(k<--N`%j2QalQZcJ5mYFXO)^VkO% z*e~b|9#<_@m-P$oO2w9JX?BI}MB6JB&4_a2qd_yD8YV!{xzk4MW!)UcZ$-PH>AEDOh*Y9$X|E)Nn=}I9g(q`3T&>n{ zzj8s)lMtQm;r-am)|RX*VVv)M(VoYQq=0q{E0caj8j!HlM#g+$v&1{<(% zLHk>qXX5)N(x}Kb;Wz`42AwCRu2wPq{4){XXdjV|*N?{*|8XFG?hoom5Z*n*ZTB>e zI3&%nImOj~o@JJhsG8ncOB{zrL&}>6)Luh>`qCqAI=`T-saDvnDLj#i4-2X|$LU{- z>KE;{5hPm$>>||k62+*UL(j#Xq^M$loc;zex@qmqzuI6jLb2#d*t^038)Ke@O=Ct& zOE%6iR`lY1*wKrss^bettY+r?X?q)_6DpC<83}_M}{DWxrwlI|s>P_;q^P2DSdw)CY z-aVO1)8!xrHPQ@A#xz(}YS4pr^mN6DV-8ivpKBw?j|8T16lz^T6DE%Y6X1TpBv;kg zEvhN}^{S#t*04RkLroaKhs`GbhDCVVyK3)|2&!uDS~U_Sh`mRtS*vF3U86=~m&8h|zyJI5{h#A~ zm&=_yp08IP&*$?ope>LIMCRO+E#0E~%EhqT`;A`yey?+{(BdiGL^>3+Nfk455D%

Cg&8uT^j7{9-McpBKUC#?I0k1e&&g-&$kN!lFhOY@`pd8N=0SzM*Y@o1}^Z7bbg zHRF>Y`Nhd&riFze=j>Pvd6@3b0g}n&6=JURc(TgyX`ktv()4Kg!cv3hnZ^(%uZ(}# z@ZF-BOhZ=_xA3q8Ku5M@y~QteZkg_7IR$OLTE;u;|KAyCYmi#N$Xj9&q!drfYB|tI`A}=!#+< zMJ$tKqU5vDLUqAxrv8dO8#(-i%$MF*l&@$Eyp1`meZR6WMksV=EIhH21IUq{pL@A2 zMxOSByUJ<`ft@cpex~Dg2(FuEJKC+ErWTrvgu)#>7xT)P9|+8s_kN!)(mD^V+u1hxUyAh5@LwdeyFJ@eTC=aM3J+!*=1GzcR76Q`J|AM5P=A?q<*dRl(6 zAIypnLRGWVzKT1W+QxN0VJof#qaNh>t5O6Ht`65Td)myZ}Km6Zg6d4h5*g zI)suv$D-A4ym+|qAXu+MZxHd$Oa{(v@lynMa_Wt75h{VBOe$~K=j&3!_p7-qcrrQa z@3y=1stzsxdASjE-1XDK*w4>%`bd$XeWzPxBi0deGXUdQG(3Y8^N>gyA%#9E?Ue^d zH>0;tQI2yLGF2-Omti%cJip_FX85$R(+*tx``-NWB8YvIF!?X_wE8|thkpGu@OEH_ zrhR#RYW*kft*M-u5cjWK?H0XWJ(nwapXa1)|6XxpNB)IE+;PiIYsO~wMLL>S$cQ{HlGA4)aYRyXs!!H&H}#6PFzpaGWoU7M87{4CVeOB5`wjGtK^iIlL$B{StGUaLd9#dDBJkk4}@ zQ-nnWLd*zvX4OHQ{jX&BWtBggQ!W_tWW2vs?pxqmU6XL~1k(BSLBqPkcgH(^6mLw; zHTV|7>Bx%QgN0t-;IzI`lb`Wn-R1{jq%_=E{@lDOrdhc2%JXD;9h9-$uRs--Cp-=V|l@i(Z2z=1Cn&y@G zeE;`d-TYKqY^JX2*7x1*@iBAy7$R99#b9W;|yCU-C;=mIr*ig6dYtW z9acFp_Gm;f;Gt3~W^>O!X98{)Cw88AS(Tiom-_W8WiCw`&L@0431f`uLqGI&8Zecp? zL+sn*i+E!|9QeS3O# zOf}b6g=RV?cg|()|li%)T`)iEa7MWAz80QQ*J>hAGfzFX(GQIN-OO*W9 z>j^alLpC)xGd2G+SaN;lFLA9WQs$1#VlBtKom)5%PBKp~acx9uUc;PrvDUyrII)l; zp8LILHz%blgC|orjrIzp2pcDI#m<9?(nGmITYCz=wzgEqEe+3%i0~82y1LsyPEB_o zy4@I)5_Hj_(H&}3k7d4Dlb`n%O8{Zht&kNw40}J-tjl%w_aGUI%m=fXQ}-1NMm7id^EAGV<%p&HB_vl-Z~^ zca2t$_48;}Ha1H&rZ0>iP3*FRW7||qX~%72WA99qF;3ZKT*=;>Fjqq^G6PAPZQBR#m}qZ1ygS@DSTD2>{NKaj9Z?R<3`$Sm=)@8g*Ff^C z|DpbJVI(ky!2R#G7R2sDGy!Hj)&I&jipKlzF*VktI;X&j!p#v4a1Yafim#veA0^TX zxg-d@wDRfi{JsAb_8d*cNAFM4N!3S>WB|jiWs|H|(-k zPmz!5$6bOOU$!^PjqW50G(VJW7@$J?hqEn`ad73gU0UEzYDJWO~ORpMU zhMRyI&n}yqhx3#`3@NA%pU}3%J_Pqm8s*~KHMLO~iJ_25c$iV__H-X3 z{d)@XX3;P*aaD8h)GybB@jVBl<~4@rwOvbO`%S6K>FI-Zs8tQSYiF+wZLXZ;ky3h zY^O9+^a|^Hyoce?>Erdsb>Gm5_}?Q{@!ci4FB8do2dufMjLFPjZQ$)h_87~3&4|4oUf8v8eOl+y>DU+Nn?87lUq3eqOKn_@PU(?9K7M{+ zljV6N)oz}4{VoI|i24ntwaVRcaA>zb^JcLtsQVul(bjHR-~pg`?qNJ!DWGUO7LN<1 z$;Z+CZ&?@OAX3EtZH(A9Zs4oXwh9@MM%!V+JKqz7nUy=*xZ~lLf5CBtO;#f55|l)h zzBk(dwYufzScHdNP5wRW$7%vj44JsIL16k50RQ{@9#|Qmh_K_*fih0Y!w(bizp$G1 zsg(#*1E=NT@|^kO_Rl#&@kSfd`0bsCMQXU+l%7$%+8|FhuO^|baTj*AQj9_VWPW@m zJ3K<_y|fKd^dUkx(fD4-=(4te;f>oRK`pgAgx?=`hzMxx+21Ajsop9Qr<8&4i<`6w zvI^(UT`ocXgQU~tg@)d+QuI-8aJiO7mtm$_*LHo4&gQPuU2ngBD<&}4rND-*L9-0BZJEnM5Le7Mpw8B?WWAi&gyd zd>uPZAQXYhnzK}phINkzMJN3~3HdfkZ^A=c@Ugu3)duXNDK|CPR$qajS3udO*I#HML!<=E<}I?{(ipPvH|>dVCAv6^wx&Z%qP&QAO^U z|CVe#?V)a53Ie7J06DossP%y*oC^ycrI_c)*C_hYn=@jHpV#e_7bxLn9Q?iwi; zmV^F`(Y#TQ|D&YWc;It#HpfStCY%?};3iS_L3NjYnNvvu>(KY5dhf%uMzcA7+1JX; zzTV5Mj;bLD?i@Scm3BRun%Razvk6?&(6DUFz#0|6xh}ErzQz+o14%Gi!3;eAa>+y*f?({8Y((A|rdV*7I|aq78V; zYNjC@#a534@CEo+N|H@gXs7o`v3W;w<#RHMkUir791qsxWh5=+UL~^XziRy`RP(-M zo{r!C0U#CPi(&xU%6_1T8PgbROm)i)q8j^Fw+HIlf^S|f|IAHJ)1VQz;(}jD;uLqr z8U!?#+`_^0~HCThUME4_QRGjjD=b0NG0nPFYz^ zE8Wb%gZafI_sj&+NxlBji28$QJr)B;S!Rnyv9n6#ibo}Z?NL72>&&TeAD)Q6&b*ML ze?hyL$=^PKnqNB}Fc&C4-Xr>eAE5$DyG2QDWBjYi94>p7h3e!E#H zT}Fbimtg5Qf^;P^Fnm-mee6flqa{R%>$%uV$r)SF70iOo0)g1UCK4hf36Pd5`c>9{Kn|9O*qA?9+l5f2&&|~tW04N9;7_|KMhBd^G-0j6O++xkl%Ahi$#vdM#~${g%dF`NKLj2HwCpyGZ_;>5ZTth_~Ut%MOMQb zxP5Ix5xy5!vEBY+WKA#CLS%)BV)mZM7{*Lxt>dfkh$AyuhO|$5f`g-?@9fj>;3WD< zKo`~a!O}eWfN*dPtX(%#Js4Z2vSmZzrpe;qAo3nCZM@z@&KG6-c}>PZhEl+_>H1Qf z2mRFVfGq9W9R*%B+R2PCmah?voVuTk4Gw@BT%s%$))>5EmTfpa*)7gWANg@6Ys=jRKi?2}z4rl8{U$T7AKa`kmL|Ey6Z); z^G|1osfwu&BG;MS@wO_%>|;iiVBYoIXg8uvAb8clPs#Vs5MnZD`3`J3PrvBW!z4T+ z1Tz5ri0AqVE02sCBA*(ZD&6$j89nn+6lPCprbz=d9C)EevvVCiy&9(FS-D4gF5uO1|_l)CkIxILzHE*JR z!_z6)3X-}|zkVh~LUm>xEF1^j2^O~+A#o~k-pJX3D=I%PJ$By9Spi9-0%|w^=nO=5 z`T8vHcPhU~Pg}Bcdw=Ie`86NB!Nac^RS(C$6JI1|Y)MZDJd%;PKJ<0%TJaK}aWDTo zhO%9tc?9x;SpiN)zy`{#Rz{mu#pzDsOrBi3DWO3{nWh@{)SkJMaAcmh6B6oey#HHo z>=Tuf37f4N)jf@QPV4<=g{+($@mvzx$?5zK_Hs6L$;fcv2qL{n%iNMUSTG{ECV{8y z$*@q4mau11^wKSqe>`K|e-+J_zVv)|3lDBwAio=9A@i!rkU6<$bFxE)Q8x14!JK>U z>y~P8etzF`3w5#d>%LgiEuDeF>^c`Ys@f0hb+K}ke7kho6ty+|y`0t(T}&SVoYCJk z3(&i%9Kyry@OU2u44m&1CUCJ$=>nbX6EGtjH`*(}a{x0jIs;V@u_gMd5a$6qael(v zu*64WlUUONn$qGHX1n3h8J)KyKkH!w2DuxBI*f>C7CK|?*W}LiW-i3APnJk4*I}VL z1jiyJgwNBFik_12sqEfDGYi6wFw)}o^XN39j|5zvq`Bqv859@I7D2t6=Z8WxY+)ec zHiF3eY3a6&=_$LJjU__#d9+-#4f;SYCad1!_I70;0Z)G%YDlR|7Zq&at?aMV|O{Fl78vDLq$p zDu3NCpgnK_EO$`_U1et3Yo)CQ0IhrK1aD@X1oSb8?4K-c{xD$JZT7om;XjQ*>%G7|0$5q9SxwCE z5|xz8`5mW;#9xm8(M>ddf6s< zoQJ>mkN4MUgY10uQaJQk4?CY7I}i~A2{qi9aGvN-O8`1q;)pk=p#EKZuz!o;(Hiw| zWDm_Fj$*DVL9IpOoum*yy@+aVoak71Jer%1>xN#$R3i7p4oc`0QUyybfR9Mu?wi-= z$R}f?OnXm$VyLV@f_R!sYBcun5Xu!L!_;+Mze9bBj(_I`B zbSCqCWb=qpIFjXSnAgDePm7Jcz>vJ|YJu4PD6z7kvxNPs6nW>(@TchdJkk3T0xxT4 zLHxEl`BeWIb5lqE5-9I6h|@A3;C`BZzgiX~j)jQ_`kHIgtI+Yq4mk*m=i$9gB)HAf zrg3`i&@m(TcTP|Tz0`=D>YQ3;arBTUV1T!?MZEQX8|2^jNAa*Hv+vXt@n+WGAe+oi zCx?Dv*k|TX-YA$6GPUzNq{CyfI_KSS=|=6=Wk-^z7ggZq;pSeczt{Pe3d4<>YLH1L zb^;4+2B;p@qN!Kwy>VaIN>VOQV#tRTU{=xtG~rX;r``kTz}dFgp#dijf+#0?Qg+MNC;2J=0=}~=8uel!HOmVGXJVme02H`<%Mf!;OaHVH>+sDMe!d} z!Ty&joo4?>_P}sYb6B3Z;>hR>P9dgN7b$L(Sx73}8o()q{R^l$)y(p#YpVn730HPT zd5l5gTavq;oyCY+B5dp6SCYM#lP%~aDhjnZ+!5|e0yJ1te3S`J(_wbOMe0iMFYjJ+Dn#kPnBQfvi1DQh@?U?g$#~nw9=sSi|U+L9&Qhy`^5K)OeMdL*^(j+4EaojJ}LOt%uRR6a~`Xg zf)|}dvi_S(&|S3)N5>8BoTQ0YkK&$w-py%XDnis!%Gro;&ZT1~=uX>0UHaK3u)vT5 zv1p>11Q|X>l$ni9tZtH4Ub-iSL?506TL~v=D!(=d-mw7$lVlRwdF8;0KaD&9HP06! zftc~X7l3nN1<=CtNiaCjnLi^6$tsrjjcvmmVjvwVFs9HIZ zBom`r5f3}8@J|NhJ^tk{ws0$lR?!r#wn$7!& zGOA*-FKVpx(q)APb*4ou>el7J&O~mVSu_j!Xm3)XC$=r)y^Rb`jsyOB$DvYxTw z9;3DRZYt^nIO{~wglL(2;z2eqx9s$hy0xP(7GYCzZf`kI=7Mg0hAk6`X?E}ZUp7fD zHqF!6-AJjK)?m@-Hz1?yHfRcK3<+%aB6Wf(?h{Q=vrF zBpOoL)O62Z4}`XTHIv@ESi#1N-i$(T!$U0T^ip;%m8#QK(Wqmf_B)J#d;K$j*&z~O zi*|?qv|`LA;-*$W$%oGkF1!Xx9>iImSzHOI13Ur2f9&N-5|Vjlcut32PXWo(){z(z zUMPhMfGEG1q(g;T3EBiM(r`PfR;HocitI{C6-h%F9U-|p@B9uDuW{@lALbUa?cXs> zP5_Eu2-E_PJZ(#ZOb3OEpY9zt51;Go{~pdlu&bX?I^w{-G{ z#XjHA)>ZR+1*p9gtli?D%g_afjw7OqJLL{+V$iN0>usU<%|-;e(h;k73}?PCk!2Tu&KE0hfniGF|+H_)guU$%J?5lO;@#uLGe6KCgFh2gux~CTa3QPkjw$Z|h8M_?G#cRHNGtlLp05|G*swui+E2cm`ZElmMBwx?e`(v z>zxPN_v0ojO8dPs>-^H=W`<`mIps0p6W`v|NbmSxSR&5U@&_;x3rtoJW?#e~p3KbC zf3UUNg|%$@6gH}1J~z}~>qRevx?V~2BbJ5Ap*^)=>;jC$=WA%aGH;r3G!+eCkB{CHTmlJZfGwRb%TW5!Sb-HhndNz8)^R6 z5kvmfx`F(QzmAf?ApSPhp|ih@c{7O2@qvJmVC7gCR#sLPqIiui-cv@vYaDcM68=7p zdY){Xd~>A?00jf6&*J*GG@Ouliv7q|n6Z)%>dkdoHuj zdtMwgPI~i80JWHjdY)s=E-X>zVWxgXPW>|Wm5QyT=|q)N+O93FmQdJ@#HTSYHXyZ= zH141!Ygd6@J68c<9#GE4vhZv{s^*nE%FG@}S{P0fzMW;{L~P2tJGE-KJ(JRtNfDL5 zk@w~1{Z9r)Unc#EcQZ?1%SC+%O3W&*yXr#V6Y`Z4_wVpbk<;7DDHBjCa(Jw$lwF16w1TSB|uUPJ9{>#z5@ zq^iwUl|iIzMA6=EDdCP%UEZDE;F1eB@t=7gX;!@dMuPM1nM)%%8tn|xokItS=`%5$ zrCWjjFqvorzj`#VXlDdH9J(fs3|#(+$nrh$Biz{AY(!>7K9l-vEe?@=2{Wi5690NQ z_Dipi2V5#Oe{vog2+iDuJ#TyL4?|%xcmNkK3@~Z`0nSCTg#v~aYiGy*mAEQ4^5EA2 z8h8F%HNf+13c&g~%yWZ3b$XV1Sob#$s}Nj!+FEOX9!6B;*#=-qAjqKJK3CT7(czK zS2}=J7?M{C-BEl|CFRm~9NTZX+GD@>X1AVTS4>w3&f{N53LOYC?LHtW^?Rfftn&Yc ziSeS3T~GRNHX2N>7WjRnpakNzZqiQYJ#(AJ$fK`|9h%F*6fW6gjpJW(vokOPWGe%S z=ak!f(3GRK6&oo05=Y#bh_tjGF(~}={~Q`(yR0Dc6iubnx4^@gGKF{IO~uP zxJ63v&0DVt)zS0n8h$adxijj^rJqEl zP_|vI+TL)l0kuScka)YR7d!fw^AT>^5h4g3dt6Irv4e%<9vBBp$ib zX+rDJ?Te(Utlv3%E#8-lBv1K;__xwA+2gpMYTG~Mwz=&$pyQs^7ECar-VcGzT5}H5AOA)Jr6BRuIdVqE4_(`zGgBfJv zk;vQ$h3T7vlPix00HCKa0mL8SKpT4-FxOCYPYlZZ?-_z}Nn1Z$=Pl3tQjNrW0@wXC zkJy-8$RW>Agn(g{OibV0;Jg*!J-i8=77?aIyHr3%QZ49s2QXQlw&Zc-H4)c0opj{2 z5`Tjy=;>$=`#E8x1=Rt&WR2gYu*nx=Mn(Hb!M6Zw|J7?uFxrjGkJpvTX?qoS^?zjd z`)*hGU(t-^c4Q%#B8X_=+!LY;&*Og)<=ZB^F(>qADoUau^SmM+p+BZ~^OW-~Rzh30 z2gas{^Gg(~ERGtY| z^Jip$<%Hhf5apoyu{M-PehLA-JR6vC2CqU?Rcn0CWnT1=E1d+)ZW{v>fzj^O6WnkU zwf+rF8*S9eyqgE>!jaDIMMN$(7zQ0d1%>KccoZd={c{GmZX9y`a(6Lq$RF z`Ms87iRu9dKq&a0&Ar8)SXYTL;@WMO{5+N;HY!IxGGfU0J%OLysp(iHp+r0Fo?6v$ zQF@*UyOv-ocB;@}FKcrdWazM>ouDQ>zi>-iSPV^N&LKSYJkqytf6t-k^M^m5;K`y55N8cxk-@y62)<3_+$G#`IrR0T|Z>yqWQ67b8ld!E3=npnpxs}lMsa%(>En6 zxo$Shql~uOLKFWEV0jHu(6{(ftZU5F#P(L6!nV=iU^6!2C zF0w~Wp9}!m#AEarPngs2tAk&s>#)iZAAkn-WqWtKO6d@K$r(4k3FYa4PL3aZ0w(Cl zVJ-hLtax+Wc&5&HI_l;6lnBP$baU1}BNgwWICneaQ`=NZ`dq1SbQ3b2sS#T-=hOW7Mu z)?Br7GuxGLTn|kvLMq6ThYIU@v5hqd0R)`2X&N9y-LiY%cU=o@YU1N>)RgS1&rbrb zA+MT?hF6Dun>raQ8-4N(btoij-(+*70Ljr53_A|5#|qOlwKR+6@Mj66jaWm5GxzYt%#>OWntGG|fd~^>u`pQeG@v|D`cXzl7Dy8pCQQjQqi=t1jOR1wR zj@@2*UR+^2X?eM_eKfY%*ZVU4mQgc=9X*Fs{D)O|RUn?z?7Il|jO0wxOOZYpi(^}H zleT%YVeC}CzrKp%N&Fgni<#IN;eqND>pQr9;Pzs2{; ztBJh9Sx~Ke|Mxk!9^wUzf(j4OJKcsuM$ zw48Yf*U>ZeTt+RsPE0>dr^jcSIg<^^bLor6AyJ`YAZ)uwctS{g-gVUj_D$hnrQcQX zZxM-Kn(lizZ(6-T59Zam9?LdC82-Eq#LcLV{rs}Q{mwflPCm8FyU%&nM{{c8cdQ8r z$lne%z&7s_6bR)*xq^_c;MMOcaeIpItmc*pyepQD*u2sO@hA^xSchG}#ux^AZ!>7S z!~Ng?kx3ykW#=n81W2ikXz<~)kGhJV)%bYq6_JJxt zkB4_Cw@?h@V-ygp`2;2fpJhDZUnZ^>pye~Oj|WNwT`0KZl#TO?yL<-L4ayU8g8SZF zN(1RTT>7W5L}s6XQ#~;&!?calzhm97D<+?)X~MVu2tIGpiWS1oM_Za%HL40MbV(>V z^MQheyuOE}qbD{oi~@pA!EhhKa*?P&lnFogl~QP--Oq6t#e`rs3X_98@%AI2C!F|+ z>Yw5!Ckg(;+=^JAXwqZYw@Jb5U=^6=6iqgmOmps}&wn@(jtr0Ej_Z%|+`?Bh%)Wrb z`A4nHP13;fn%57dyT3i}4ML>nC2NS;E;x=kI;VnJGt*A(Qhh~}r@y1a%*l|ZZ{bcz zPfRiYkIZ_ylPDAE@P`FNx~lrx zW%_L-v^IM4wE6JFYreTYL57ObOv+rY#@g+WLX>$u|Bvi?QYQxY_3GaLkv$#QY53cz zxy#jw{IMoFacf_D&arxZu_H0>u>)z9A}R0g4hb85ex8@{=Lmas<+$7Yeb%bMBWo8t zx^apO&(L4)uru-gzImGV;Z({;+%0p2XH8N4ocvdklj||=umW)s%u=13H)*#mc&cOd zxD$fjqM5Bk#9qPk1jTC%f0dqny@(Fp-&p^nRAYL~@+Z;Za^O5g$uy);2j}^xMeazw zib>gN>}`!=Vrafyc0v+r=WJbA0E*4&?CcZ0+;dU#sGj|mS&eGHP6)nQ*1`MuzPYT* z(}@JDPuJr-R(r&&r^aRojeF}!?%z*OM&|~224ufYBGwNDW9CNpG%=^Psp@B$2|*ag zm^HYPp6p=XZlJ7E+-3c2Fw>)!-7}aT+;L2>0I?6(94!nuRJBwQWqnuE$5K};)1~I_ z=k#FmTbA)3)S0i;@a*AYo)|f-EO&8V>0NaXo-KRc*V3JN|QP`f?@o%hpaFAIZ;3puHRi zWosYW08U8Z{3;ts&(#bg<3JR8@Km$qEzO&eN*`m*6?3=JriTI*8CanR^b27~(hCtW z&~;-UpB)??An^#aq}LV~7!%$a&+*qzbEJ(iZOhHN>KnvJOZ0HQQu}82Xn;=v5L!KN z8jkGm(daSr>>g6NUdw1z3^O}fQJ4D)a$JZ?0y|D+QoFQW{sP|v){Ilw;h$f<1DL45 z`&i%58_I@^Be0y@H+a?b``CV(+(TuPeEasT*_P2c2cUYE{{D0pkW?xAZG!@u{lo z6a2<++O&8l2%z!~J!y6_se}InWfinbEYv{SWfc&38E0DvzTa_VNNRR)8`;6qz&HSY6qJcJWHQ^IVWhjl@+#_g_P|67ZoxAz(#pG`* zXtH~Lo1EFsdxr8ShT%MPc-(@9yk}#JYgF6+HiBajB;J=+DFX}2z2S$IzwaumDI@KO z+5hYzQ%T!w$~snK7FYBL0h5EMzO+Bomm<6`+IZ4W*@sFOA9RVT#uGuFN&4meJR6!H zI-f>Atlw4)In-UT;@k~9 z&$}*qXu5G8^yZj-#SWK)zfPz;+StZUl6mEn@_2=na(N{?vK%h!iOzwH`~6nRX!KMf&ymV< ztLIW(+iCAru}l2voxtS9*L-$mB3bzuG*3Vw}prFGw!0@lb&|sA zYi9Dq#<%RGWH?)Lott@`dAr<8Zy`DlW#crA+Y$#%m+n4h%IBNc;?sDcBDG=XCJl^F zH2~v0w-0NcPaa(d2~Reu`Xy|$axMB-r*lJ0QpX}Op~|cQi9fuDF-qxMb24=O{QR4f zEx1^JLDtMBi^4Lo@b`d9#qwnumA{G!r|VlPbqB9%rH?^FL*^Vsf-hY516M{?{Ic`F!c{@8(-V_IQmgQgM#t{*BAd3p<=qN9%~IU zhHuO-OgPSq}-pOzu= zN%4qo(klc3K-n%k;W4e_scalWj>c=MlS*n>pFrVY+RFp0XNnX&yBCG0bHjG+us1C+ zWmz}-bzhA}3J=y=E7_*SF-?j%;s#e%VBp(?uw7tz*zk{5DW<~#QW2U&vnbmubS@1@ z&Uo`>ac`A1JjUK!v4+4QK{?r~b1eg6yFxfT1U0*%s!Gt+VSHD}COLHahRHrv| zs)l9`@4{;Hz^A7$bm00QZ2Hw*EMxFmpz6v%c>8{!5K->(m`L`UW}FUiU4B7`!q*bM z;U(9YztCU>Cn+zxi61%wgISWu{_H_n&coL7e0W&SpRY3q@Z31}T1yHQe;<6nLw#O% z(XJFBuo+U1wHPRTEKPjo`wl1bfE^?La92=lZ=&xTd9Q-EXCgry^>cG0BwlURUA;s$ zStII)YrZkljl_iSS*z%_3GNV0wLpK=Qk02M%FrF*NziD+X}VNj?7{F*@-iYv#=8P` zY9x}dp6=-G;%KReL?Aum6MD+$P}w;D9H^1K+uEAbZ_vkZO{(}7p9+~Y*N2IBY3Jv& zE#^7h*bQ2fogbcz_KiYw+e)191lV5^wrIFp>GNUI(^G7jL=Y_^R)=hj>%ki@wY`rG zEGh>z&$>A%t#7>de*fV{)59e*nf`)F_JUxEAlTK$KvjiEDpgN8DVGO@5zO4ZG;LFU zxfU#m3k+FJt_REx-gGCP&{_Alj(HEhMo~YAHjRlLp(Wmb^hM`Ug8{>Z1>T{szgydW z0Be$UQm-cXY@4o#h0aS=%KzjMGep_XlTMq_F-d3ad(Cvr?JmEbm{i}B$#%Ac{g*Y~ zYx~z;Gp2pC=JJZ&xRKCwU4@IK>=!ke=SKk!k=k%FTgF=I2fDjto`vgA!o0fX8BCbd z(tJJFG;&IIyLzlW{ZEoPbe8{K-FAlD#&7J!Qgmay2xG^1Ca(5BVTtS2396R1DzRb? zk{qrQ{eks98W1us(D9$W+7rz^@{@s`KEwnG%&YY8*IWujcOdtwYYQH0e3w_p z$Q)^xAK2zI1rdwFSV;}|c;^U)h-EoxWf!i`@i!z?-C`gMlP3(%oLbTB?*@zf?XJdH z<5DGF^#880Xvt1#+YetM;X=_}D2GWt7I(4> z_dMB{;)znGBjTpys%r3b3?q=t$u<}_W62uLO&rK(>|Q1n5N-c~8rW1*Sfk0~0aCw& zoWd>ZcM^pz^lvdLi^hQE&lEZnuSm~<%zlR~`tt_Z`Q@zQPNx3+)2ziL;rzX~t82GN zvqB94Sw*w?SuUb)f$R)hS_&KT`w~>QQ-w=Eu(P7R#1FmIkODEsC}|DWl^Vw#Px^sI zKKdHx!jvO@RzwelTRR)qY>6-!j^K zqp#ul_AW)a!^RLNuJImb7JN@!@I?I4bzyD4I_RmNc%2jFIW^0 z4!!e^%n{S*Va}u+NqfR?{TLu}ZUWjo#%mt(&+fEGa$chbFyK5;4aKlR2VDO9u|zug z0)D2*dK4=OKz)IIC>mO8HN%*WqDO#k$z zvChM)^!q#75(RAfb>j?cR1b~rd`Qg?AHfRVk(4CsO?g7G$*reHfC>8BB}7V+$$M&b3#-rqWo=V>H=IG< zK-6|kfO;Dr28d|K>IXuQH`^_c&c_XKhcGM09Jf{0B_zr90j z%x(>E@@Z9?TI)Dp+v(U_>mY{a0wDfCLXi)Z_|~Q114D8A(~Xj%ZHoE%OGN3W3YMZ9rk(J zN-%6_U`N+L&JcLwm*hUP5LQ#OBDUt5KYjzcrTEXvo9*PQkTM{8^$PKtXxQsAeHy3< zH7Q4n-d0gEP%>arT6vN_;}gt~V-V>AJ@!C456`0~Q+LR2I)#A|6?%HN3Oj(ABh;m9RqmeAl+Ap77}XL~)2oG;5%gD*j?uHAUP^4WZA+zUU0 zFS{Fpm7_n-&aA?#ur7ndzd`)N<89~r1L&k;#jXG_5#VQe$j>?3`_|enerbenfa~j% zeGOW6uQ?9M>FRq&JVF=S+_5k*lC+ju6LUp`i)_A;i*ceGjq|_sEq-&$R{LX#7dfrA zUi8gJu*=Cin7F5Jz9OAJ5c%M?$$771KwCxpY8k%zN{L=xH^cuECvdvj)kb#NBs56^ zR&llzk>O$JNYzM=d`^JfDujvs99x?xijM0P9TE{uN{{gDN7^d7JjrqqGP2|^_-j=4 z|LA%Tu%!1ld>92m5e*d-6$@wXK#d$_?yV_q(?Sqa!%|brj&|IsIdjk4J98h~sHBP8 z%rL9t$dTE!PT8q`@5jz>{NL;Uz8BXOK?%a=`99-*p69;bwi!;ZTECO0zxkNH$KgW8 zWWyi*&mXLOik^J@{n}&tr7aDGuX|q@-n_K1`18w>>hDqSe~)xuzn8!|R{ZTo{e+&C*NPAC>HPX0? zf#H|Mdmpbc&z5{UR&ID|-=&k#m{VmJ0P*RIn1=O#K`MV#Ui>}q{hwjc1+=hTlJzUw zX|L;EjZT$bYsu@`xfrx*9-QJCvSSr>%P-Wlq4ZE&PEV{W%HIYiU(`2mw1m^c`zq{8DcPrd83`}4 zjVx%(MRX2nDB5KdkY-%$-L4SY#B_E{?s*&9wK39Ct`|HDoBbrlS(}TAc%OC=e@XT1 zwd?x~&K_}2o(rNq96!|jo1x{D_m7i9KklwHO(!Ld=WBn7rTv(k^CSRF{Hv!xZR+|(?#WV zwP|H72_*M(pat|v-_E!-7=z1qJInCi4MxQdrT+C7Z_|b9@=YUOyM_;BN7zE zctLdDa7aB7|E#CHRNwu8a%DtOa~e16_0CcAN2Kjr7gkDZu1$@<27|f#qhft0eF|(o zan`&H1Tp#TCL>J>5tVJ-+_4bsKB|NnUYTSf5}5d^Ah$zKRTHD9TRd9|@FJ zgB)}?7qQ=5@mj?)(~d7iDn9fN2_} z^B!g6j21M3z3-x?yJatpDHPlt+)iTYf|X0~oV*)&4rL4=Ro*VYP49zzN^1o13;{)U z`bAmw{;6)^h%Y-olMc5WS@Szvv!ocQ!wOzsSlwKE|C`~-`xA$6#UL*bo)N?@Qui|V`+2U| z&e66V+_z&@U;hOOx_dO?+wXZ-V&3(~tU5FI9GD&XxX+R+^zoYI`=EQ*O8*ksjK0jh z0$!A)_jAwlot|bn2SBO=XY4lld}@8VbS}a zf0teO^1Hy7QzJQbM%uiv7UZbe;8d()z|l+42z%>KM?S}!U1@v{oS z!w0xAc^I4r*y9nUIwcQEB~#$Z^l0?kw>L5#uSN%R$fplK4SX6t4ww#!t+xd`J(g)L z4rGU?p>F!6Ib9dC$OX&D6JhqMU30Eq9U6WM={nMoT5}^T;5GijCcflF*Tc&pi&7V% zW91c%AL?5F7W)YQuwj{f`jAyy*9pB(s}85%uhqb3adB2qk@)S(e?g7|irrH;LG$nV zcg+aAO40$Tw=t(u-+s4f$e)%dyshTB-r0TMbN%9ppy=0XHv2sRwHe@4ZE_g#t$hfH z4wl-{pz}KWlp2yK8?ppQIasntI5LgJ)0x7!)VySP!*?a}q7=J&^HhPV!ZG>r&v(k6 z_ZFh<_=V@m^A7Voumv>JKGn(fRsJ{2V@J}o!=`BGOlfs-t-a-Gw&jLf&i4-(T&pR} zvi^JT$*!h-@{hLpeC=NJO4suYlphy$w>R$SJja-Iq@-=Zl|@@GLl`t#qW;yrOyjbv zktVlvcie7Us`M)L6wSKrw^h-esa<+J#^}N`XLzSfq;y-%o6lQSR^RjryJ_WK^pYI$ z>Vn^<2fg83)l=Q(aJN17qmRe;Qp%NK|Fd$o8yi?U6X-|v<4S*7J{S-YhPM;g0SiG3 zw0D9*G+zC7X=}Jzg0xi};t&PMQtUC9YIewZ;dtFJE6H+twD2TC|MTkB_>=yvoqbi{ zn{QmdPdT!Ajb~c_z_ahi7NR}Zf9y&4lCvCjpRw{}B*t>|hfa*ZUPkzbkeOS*Mb{c; zfBkvq*sk{%wmJ0Nvj4!wQN!pjPrhG%GX3>k*H*J`b`Y3C0JbX~tNUY@tOzv`II zaQ(eY2E)kmzpuE{wxQJl1x}70%i5WBE5(0my5}|6)50Gmu4y+jbXbkV!Qc$***_M( zANcd}&*QJ6@5w}~UMxKnpL^rPrFVlr_Kk_W;Q-W{NWtF8m;P zUQjMOplL`RRcGgdg7D)8=~TEkoC9~{#h@@SU!gNuUU%+I^mZ!FJ#dT2VUWZXaLC*E zO9!SdNY(_aEGR8#a%1Cz)70QjMQM|E^KUcAccp8j&ZI6>6s?WJHn@w~L0NmR`|rK6 z&uzVK71QZGmwCXwO7+v^wZTV|lO~-_PwJm*g~D;3Ip7T{s%tc>lQB=3d}D|Q`{QIB zv_w%~5rnZynn3UH824~0ZFwz8zmDecH7)$Q#(D3MO|j9vSz6m29I3Zgrh2uGIH&n= zmydj1pDTKo-L!`ASgZVS$Le**!{@4!hxFDKDmrgH=0owM`0TPu(E+~>3AYfy1xh#; zB(M?|oRKUH`}GJ6y#_tbvB}fVJayUIgkMC4pZDesbK1|s(v7@~nma=?H>N&(bK1PN z_t_oLsPFq*@8p9`;C&l*vg+6GS6VtR;c$Q7$oTs})(wMqhI`)GL>c~(cTMIKU{wD! z^LK?rOMlbAn=Sn){GZ0`u1|ddtDDh>o_#;u@%Lo>--}-_y-SY1v1{YvU+?#RE&6t} zdS|kxJ^JXgXp_Nr_Ym*Wf-~X}V?Pl60-HO(2g|&aTA-ce`?OCRv32kFt)|Q&?q2ud z^E)4=znnO5|2R7OZ2s%0&`;u@Z=GKKzUSUo+?&5A6aVgdnR0M)W9LQYfk`zaXqqS1 zzHyWGuxStJSGydQ8**`PbYnMK+3IFWQhQQ+)%%<8xAx#1kWC5+zq5qdo;ZRQLOS|f6C)qTjsr3EDfPqRy&qNkH3@c!J{a%9YX zc1-cY)fdOi8WtuqF1+}C>fB$$mnUCE-Tk^fTJ3(sLF&$n%u7v|a=|u3`1|K7sv1fX zwg;Se2S$>Sa0o(Ez?K z8&W~uO|#KTnlioNP~qb3b?eZnt(R|S!`1qXspCmG4v9@=C1YJmnG%F~l@ukFTr4yv z*^M{5$^df(ie3R&-X|gXw298+QaXIZCcSlRwxvaR>J1yfr5(EW&Yd^rdbb2D!)L2X z6!bCa?})v!9J`VscVFljzWmO5R%Su>+OScIMy(}K?E}-y_+UUVA`fHINGVDS7OYYx z?I05+vYVbDpvRebQn87%Emj-rmaKLVoz7n12fA4oBTk%2Ib)ywW8a>}duJw63@&`W zE_`Fpjpci}>@RxZ-!Rqt2evwYyn7gP_TiKAFZcPrBz?c(K5{Jc+u7o8);CU0{&m>L z|HKW68mFaq&c&;<7x#($^)Cpvbo9%8=$E?aqi5NM&aW@ozLxJ=`nkT8RsHQ)={K8` zX@8$h)4TNdqT$yzN^pGilgab%N8iMpEPv&+Am#EgE4SpE){jGgE(d+~z@r^`pp@{f zIHnou>xLNKxpb{?U(}P)$KHQl`HLo;gE9Rd6OdUz!Ik#?CvJgGZ$5*5^)Ny* zJFyXuGO*@^M1B~^k2)y>*$Pf+=8g&cDqMn7$g1Qa0_G33VISQE{jD>HpM)%VY+crW zebDUbCG~r)Tqh_3g(kK4v!o$t2+Jk=O=ncr<|lW=1;7{MW+sH zWb$wqq)G7Mh^Oi1>Mt^%hxQpn?=Ylo%Yk@TxoP%WW-YZ@=lv`a-ifYHtgb-?7K+&1 zBD!JRXrQ4>_^Qk%))E)QhOPxz_290gV#-42I)^_@du~(F9m&cXdCa-65NOPX#A0PtO_QZH_i4I~CXZeoP3D*;?@~N1B_s>r z*wm$T3&1x50pp$ENin8SZ!U1ofB0VcR&l7UFzJW=WVzwar}yvo+&Xj(^;^H}oA=-L zp84p1;-YWI*xe}0eIduRrlT0yh)_nN|xFJkKk2GOte<)zCie%98k-mBdw`4`{d z^50gMVm`gND|{gJ$DXqHKd)V@l32d9FmW>b?@8^seY_09*USUIl>l8Q+k{)CsI&4< zK2D~8R-E7G3F=zJ@Q=$Q6^G`&4?o&F$5l1{@bhHT>hOyQz%y_NRF1#D-(Lj`U*wC( zM{|4I+m~-ktQUii_a;MH!~e>o?h(5r97J1Ix}x35^MvqIuwH~+z~B<-u{hioo=8H5 z3?X*csX=$y9vIl*zeAJX0$n5_nFJ7m1!}rua3O7XKzBh5J*d^r)4u>?`ql#R>vLSI zSuVOpnK;Ox-q|<~BE_KN!iWD9yq_@r;?iaIU*9nw zJ!MP^(+n{-i|Nop6mL3&|4i-5iyKZW5#L1X{BCI8DgzG-|EK6l}YjuEuWt_KhZl&1- z74=!nL|%h0%WiSvYvIweDvLRFTo)=i6nf4tEU`O(EDrCyt!| z(DCMe;-0Fr<-;@kN{1gE9Tj^uJWl#_dP#39OC`%WYV7=5{P_xT1B9B4oVv5TG+Dwx z^q_)yzYx>bQJ@HdMuo}5mk}vN#g)dXj?#OhW2lNQS3iK>+Ru*k=z*kqpab+^XQmZj z3oei(n=}(1Q*twG&~MQGDKz$avapo2Caw*y308zmV`t{wQaezbh^{m0kad#@F4g|EK>O~t9`<*14DTbw19 zG*%cQt8cZB@>kT|2ZJXVZGFj7dZw4Wk~%S-?Gv%Bg8UZy_%tS3_nS2eJeA$BVx5Py zRuts6%7*mVn_G0QeTDvU?J^@$ccu$J8_lU}ug&)!An6$~c|%UC=J`e862L9PcR`6e z9aV1o&dDOc4@JldwO-t1}twV3f?KIj;G-`UUidmIcm$|_GVOnq^l9MjCw=t+-3g2PTkfv zlyQEZaAo~^qhrE)q+~wx7xH%Z|I_Z@4@Qsbo-)dn^Nog4Pkl9H$Qo~SZvMEaJvE4(I^`gVTAYOfopteuZd$@^oUQ+{0aB8UM zz(+vh56biuCHTvGT3m5;)+o9zR2(7f>^qX4UXq4AbTvc9E~GAkETfM^?s4(UK<4wq zLhM}9IYKS60eJ69kNMo>KLV13(YGWY4^1m3Mv%r-#^g@W^xMNaitXf+l%X?vMtM-1 zv>=O)Ot@;0-y%|WyT4Fp@ia6sJYc~WN8}mEW-TCD320tPBR#lmX~9in=!q|wpWE%tz%tKXvgh1I_5{DUQ@+$MT8N^T@eg}~#ym!bYFuElJ~!}*A;yyQfVny$lSXmtFuPd%Rd*TRkMgNy$nI1c$I?)MBJ&;Jx!Uu`z^u=l;vgXex_8OmMPPWSp(-K z5{8k+WG)N{B7tVBG~?Id|JNe^uSe;|LQ# zMV(u0>TIuBIX(NV0be3+wVn!oY5z> z>Xi?@ZQ`2mG`_0}_?VybRBkd!b!M4IhGysT#IRRR9V;zram%yEtkT6EE~J*uzy*Zx zKkzh`5`d{|t)wGhqV?mM2GZOV?rMKr?{wTOX{zdhk}plPMhjC_>ilxX(3@mRXFPmx zFeCfXW9bX>LZ&4-SCEtA69f*$RzMq44K54FNXi$+Xn zUn{7w%lAaQj+^xR#0#XmT!z&P8uKW8#-K*{1CmkZT)sM+2gc?PqrUsx zp{#VY`wBYXgKZX#hbhFVP+t99rWw^f4zBBM94Gisk^0LK|I>8%r}r=n{pS%bUW;hl z`)?R4ArqW+S?&aEj2BtDo9=Z6ca|ZUT4e(54?{R1yYw?4w5xz^cm$aD{uL*F^)dc= z$zNTK4whc*d>v{+?XI@}^2TWynuvgW_E+fHjSwILiS|Vo|2>$oc_GTjbhSI3g~De~ELcZMyCGjhoa0pebfTtU@WSADqNw&HMDlvKcVwYH_Du8hB5-Q+LV?=JM+YP%}>HqTjztTy1^WXO-uf4d2H+4#R zQ}|DcNeB868Ne8DZ5Du(%SQ~t^!-J(1=8lWK_vreCNwUSHLN^V4-y6@pf%wtaT|vT zY;gRYZ~jRqz~#y_h+xu?G~8IA>dkuN`BmZLga>sLI}{h7enx~!1yxm*h&IMXMXl&7 zKR$PB4L&f#Z zOCDlO^9VXKP(}p`wc2=Z1w=BvlyCFld{kO|it1*Z%+oRB@ zAG9weaJTQutgJ^@{1m7*){fUc!f-broV0m*HQCn+rj@1=e~xoKEm=rec4RXtyHcrF zP>I?sZNb(=AQw3yvV&?=IuqI}C~wT@SIcKj_m1YUHRbgYP-qR%vnAtLX#_vyzyrPr zVMz3Kf6cjL;f7rkpM72?>rXf`1L;jS8xM+TJuBN(b)&xww}`@?2@_3ZC@6-sL9Q3M zbpfPKxn6hjgx((}s&2uUkyyFEbTg3lu~H6X3lf9mP0wSnIqaO~t2HZ%Xh9bejQUmV z6tA-=v)-Xx;17-Pm*;fKjv+SgU36tz5a!&_MoOrJH%$&`E>$F%5-2UAq0emyg@s@&thV)HvgDk0J;FShjcYHxJ|Q+IY(KAk4G(D7(u6uXI3RB% z3`4f4A;PQO19+v{V-S#|edA>$dd``X?^tc-7hIZ!|ASpo2T&cHaUD?hya+SK!wu z-}Pon|9NlR7SlMBWq_c`^ZLUqar=WTR8XO{lgiCtCWz17DRR04nti(K)lbv6tzX}J zK*h8=PS(6P>|Xv5lZ7tK*$5f^9HV;Xl5It9iR|obxmWRYusK(yrD-s&Amh2lqxc zgj%h(kPwaHGFoJJ)u^&Y7yXmvY5M9e)dwuqX4iw3SRE5*GYJP1MA2aO%pE2T@jJ(? zNp=(?b)mh42c&jws*UE#I3KjzsobhLU%NiC@>|!8jmYQ;ss0j z@h)>sew0@Y`aTC`4JNHL8k&g9*}7)CuIE3YZMU%g-=qJTH*PsNMSwTQ*h7laox}`; zUtmiK0*Q_Srf&x(gkV`5HysB>2VHajcqys;#qW=!J2fMG%r*=RdY?+(9y z%d4Glx?*ck^+#&z2gQq9ceY!{Jtos{(tVHaIAKX!A9OpI*a@|oE1B37Y#e$GqJU7I4|z#vCLG3B;{0?2nc~I!G?fZq zAWci(qdohwki#|gZEOjc_v!@1oO6~|(GjY*Ou=TzYhiLVJRK*P^8Z;!By2`i+yxX8 zwAe;FV#XeW!Y3~avkVjG`pUQKf{ZCoy_%bnmtLpeInGa53C`%T{cPW4%L$5zog;1(eywdmh| zW7jnPZ6O`-|I<)X`T>n)2$+=xl*(;Ln2t3DJlgT z>eZ8#*9t#HM*j%1`T-<$FCQ&`&T``hLpy+ z2-i9TF`0xoZKz~&Ubah4LT0+Lb4Q^}I?f~+nf5v;AwLv+#%1PH%vBWy_6mNWJR=iM zh3h|@qHK^GN$euJ)?xL_my6Fi08S?h8zTeC;aAeq z?%MK02ukN+NQ8DME(cf z-rR?yc9p}2_O~3|J|C%j@s`J04@Ntf)}mwGnb;!jYZS-lAdM2r4>ecGL*2A=rBqWJ zD{zc-ej-4?-T>Yc5M0pq01snh7yJMdN?!Kh(};n5n-z!tPI`J>TzX_ehj%DYf!N@# z&@+H}0M{|oM6p$>-S0OW?|D!z-(I2nxROkusYWb_FTH^M^M6hwj`b^-StbGRp#(U6 zXaXHd25xR1=Q6-0QQoMbh%s=q8B(^ouI8{$Q6&^^X&frj!NUkT@*Vc+&B|Y%FAhJx zaWd*&&g*ZN!uB3G>W&Kgads>w^&TJ~vyrp4KPE9$k!ly%m>(ZXVj}`TmF$kDZn-n< zLSs3j9a<~hXd|d{^Xs5x4W4!=l{VENg+Sxo$y#tBtNA1aDBahKAY)~!m}Dia8k|9W z<-H_v?(@iY@t1d~~>E{>qOaJaBoCKYIs@${w}wMrvv`(chKSa1~* z8&(HV#_H-h1)wvV;T(j^pyWn$5d%k{E<%Bx>;eR&thqqk>{mew5QEWV>0Qq;k(cHb z@u2P$(DjK<9il{nFB(<3Oz-FgeN65U$PpkRsji-i5@FiiTY0aKj7+ppZ0c+0;ztl( zMgw_7z)6qU&6Ydh|Da~OQi9+^crOqLrBtnDX#3(OLL}sZK@ieQnr@R+*^90IwhwCJ zVpXp{oq`dOTNdioR!dBCS2}B=8>99FO!~b@s<@x@>Er>ym)|PPls}?qcYi8zicspd6$vPi>#Iendz-ugv*Ej$<}XjN0L51>C(x*D z?^y2>g} ze^__5-KoEQ;Vs_)z164TW~wxusUT=I{a6(e_*;uyd#QnS>UpLGM{N@I*FH0 zJaZsZ&-PXL1s_CZNr80=vNNZzpV7~;fbnE!dgy7RH1d3_UYXik+M>{wq05`WQA;9dyZbk^%LWRb$0MPhPen>-wrcmc1 zIR*Klfo9R}jKJgc9IA=2fTRPP9iSj4Qv+NvC|V+U+eJLXt*2FkUN`A^U9?s%;lMGe zE}f@cJB{b^h?Rgn3%DNOcu3^=NKhZavL$LegStC{iV8YBPOp@O!`A08B}KXI9gIg} z;yPD1a#H;~pPCYVPx(2{Kd_;>Ww2DLPy4wD%X)N9q^(tVJkPy-Vfp^q`)=E}4WkoZ zjpBcl-Ftp_`;UriM>SK{ZBvGl8ocMK@154aMNg+i+{Oo|!(r?c12MddsBs60%}#aT z`Xr%vV@K-&t(T~kfD_9l46=38140~QRtfmn)yT*8m~){AQ#M38uN=UZ=R3CsW+%os zzfJAZ!&DdP&yLkirJllI%0KQQ;=3Cx2n@>V~U zzyW;Ooq{%MBa$HHQy&oLg#&a^L=Z59D<4j|#&veY@o-oOX*C2BVnlMr3+-OI|3j^{ zKJ9n4=)zol;6)a+R9302tKPfc6{@Ow(@3uZudFPF!l0Z{5NQa$GNf9@NEjkdP3I+p zqxr{u@8Xxgzz?5ZkTLxC*?<3|?gBMmQAKt2qD^0S?VsZ5*VWKrG=47DfVxGON$5xq zbqob#T`+DeFQ1#2S@<+|-6pdn*G9uW;8w1eMoPDhc?3{}O~X0v20D;ia!Z-PsYNs5 z={wDPju&aXLK`16|nqi~Q!c%Epo zy8@3lmPV0bZ1kH#nab=TLUExD*SEJ{9)|I0Z^a>rl`hjl@vSJ%kWjibLPc1?IW=!$ z*v+Xkk0Z`OtDEG`HYau_U@Ry;{)@8#SE%~psg&4~cIchgaK~Opbl_4{5juC)E8RfJ zri#g+^3=N=t2DMGj3uljN4@a8NvIAtF%UQ>Z{lVr6U*UN3^@uy4G%}SyiQ|K+i1aM zrD%G}Bc}D^0e7S{)DZ0~sa4Vy;+yPdY(rd*5b4Z&I3yX%PpVMok+o4duy0{|by?mR zH`6|4!D7h5B=eH2NHCr}1u-T{7(xIT7DgI~GXQw%AEe6g@*&quUXO1c=R^*e45N56 z>Q|_6ej{Tti-2CNY0eFfCz1!i18PPFXoKuROTeyhtGJiA6)<{3)CY6&$TNL@WYPGbITih$=ou|#FDOYF-pv;3d?(*&F;&Sy`k*9{b#FL_@1Q-Ab zn|CKkkU`(Tu-5CeT%k>lkck_A-$4N%^ZpFg1V&?%iP}W0E(?W)Qf2Z);e?twuiJ0d z!%rLL>GkAaK6av^%1b@ug5hO>5eeMW8qHuer0qqE4htJbvBv_~J2oc4*jRrzk`R=a z8GgMNxhPUl99dmwoHB)#$I4Pn;4;Rlr9$}`z1Y?4JEwfE3i=0(dcLwZz$>>mp3}Hw)={^iFdW7s|_h9mI#r#s? zyk%9YNW0mqeI%|}o4oyHek7KVpI0s*5}0Z6YB;6*nbw8^@tOj5bSq*ee$ zhqPznz)0cp^;0*WyPAouGv5q1JL!va<7Zj~X@bcpjfBb52E zUc+;*J0rl~Ee~93{e&UiHUmU3ZmR(lhrw|<1^Ig;`Q%-B#N|3?YObrcceu{v`!LO` zgXE@FOkv&wg+%l5q+zHiQcx=CgX^e0%}$MuTXQN!4%-L=5CqQ~5o@T>TT!-UpCTD_ z(2|FHrKP&}v9!&UG>=ilZsqERgsPJX{?2yB4hy3*uQJ1LI5bF1X&YtSNtjrF_c2vb zOu7UtQD%0xw&d@1Nhb6UBWQ31Z!0LZ)0fj3&+mY?$=4;x_bB_C^S9T~u?ORZ2w109 z0^!|$XmVrPHRp}q(^FZ)2bmi&Lgk*kQX=-rX|50PG$$UH`$La?)hi08j zV`ac7n76+-zE7SiQW^J8D;&_7aJ;07Hp%u5<=>BqOW;b>>~&W7;Y8p zPw8$Psl++LD8N4;r`#~jqqKMx*O>=y;!1MKx;*hHb5w{lMX2YTX~8&Z$W`53qYkzx zQ^!pN!?UrGt)|lC4$;ckh?zjwCgN!fQ8Z=Io#K`cP2L~qsLH}+^D0VH6XIRtIiFtq z3o`ln^ih|e?rt9zZbpsn9Pgair#8J@@)C9DQ>~=y2NkOm?v<@VrUFe#dy=TEVO%zo z#p~Q<=%G3&1|i8=9`1f zv1^v9?>&Px!)uiuqw|xh`TX6)yDk`|(_F%tO1j$&pC?EU-rP`UQn1oKdz(}}b4N+C za9~_7k%{8(tBTj1SBCWUPhFrnWyiKdmG)`kHR04iKyToDZS1e51G!DQ2s+?W5db^# zZ(@x}I4&A^dN}ug4R5rQjiHZOFfLuI+z8}=DMJVgRstT#EwF^*GBJCx zkM+SPT*h0*w|#P|INhxl$%MoN6{>wN@Et69sEQX<(NE38*GQzgrkamY354`K;tD$T z!5mr%JUb#6bBvN(r4ZgaD}769nIa4O1(#4_NC12{fPmD0UsR!vb;BnU|%qH^iz`T=) ztLMw8w$6gJrhpo(gw&*t(@x;;V8VE6rU7RXMm+jydAy;b(kf%@Onm!IEr50izU}CkQR9V~#J(;o$ zN0jF4FLym#eGd;My&o`8zFB56cCO?h%_GSi=3aElV!xf$6+O9!Xcxj*qqm5wgkz3) z@gY@AWh`=vZOlDJz$NT@aW*c;+RuAS ziKZEBO5&Y1pH=wm=*{-=wlqW=Tva!l8_;m^W>>T+X;kmA(>y1pqjrR1=0h==2rFqG zXC@Z3SSgG8zE52A=iF@Hy%o0?#|rZ=Pyzh`{DH+k*(9*QMZ9q-q#Uw!TNDnL%&!CF zFQ=^yH3Fr~y@E?kGK5_?t0Aj)Bnlo(PFe8q1T=anyLIMQ*PTWwt2pxZ`t~RjTp6>- zQCbs*XAZ@ZeCk=EWB+{hxnT5blJ4zYM@2w5vH>cEM;U>}3zR1A)}D}P!>P}+w*uR1 zJfVE>*|d$UUW`^AD>l0(7z%HvX(YjqVJIilwHEzFk~5rm z6Hs!~^b(({3+F3^#h$L0-i1UhOq(WtE?Rz`xcodVEVDiAZEM)uUcEz=J%e}gQNw-s zQ@6|_KFHfCRVkJU6enUSXSj}BcSlhUq zUFLaUp+K=}Km(V*qSb9MUe_|RecI}t>je-~f-!-I+Z+e;rcDx~6LooFo$@r$eAt2Z zQ4e1aDo-r70Kjbg7@5W^M)s$hFJ2cts5N#oUl8ksG*|A<%k2c12d}VkW>pF#*tS$| zWmm9SvLi(~L`P&cZRTRib^V2)-LuJy^W57Db1wzmbtWntWfTg<%E~)&ofqwljTS1O z3q?f;Za43|=j#oi4HU|qQYc`h&8rH!%%;trIX8G+-%5k8)Y8+gNXeGrWUiIev5)8d zfJ6!U?J+D*1bGa0Q0`1T>g9ue}1`eAgvCTe`^$mI&&pYrQMEM3f! z@^Y#Zs8}3ig`Opkj9`r?stXhl+#y~~DFF7MpseAL&>}Z)id{n6fZh-vPnqB~NV*b@ z*#~f{M&yzy1VPbo67?d9vW;DvF4OFm_cywECFXkh^{mOSi!d9r;*NORKeyJ_?d(+u zuqt6#PvxA8?RG@cTxXS#Y3lK}`_lW-PidD~77*Pe$vOWt>RXFi3oike+ial%k985_ zqV!E!?dHnpv;q^_t-6*}WV}gry|Tc%!e9>cwG#LX@2S}_*SRfm1VWyGLr}6LbXoahSnf+->N0z~|xm$-3SlP(d)ZwurWi zgix=REB2+lwy~&g;K@rq!zYibowvViBkELSjdZ@_zn;m2ZaZH{ll7?+ikqO=JQYGIk%cAz7Ae_+nDHO)r zO0>wlI>qDaV}%Guiq#b#Q7ra|y_hEKKy%dlLv|056uBOS2&LP)=40&7B*KJhZ)-Zj zd*CuU?OSY^^#t}HM3qr-J1y%f!-z|2@Jt)3Jx6SrO3aL~ry?hcscSB(+QEmc0?WYu zld!_J2sBPR#qnFNvlxcm!)L48NsFxrwUI_j3zYF67bx>QVq_oGqu88_d8`3`2cAsF zP#*Szq=hb+hS-f@3xO7TJlq3XJ=#$LGtrGB)?O()+A8BTelV3F&8);jjQTbmxpv=j z9nBtulDxZS6}DIHxaQR}$^1}0$ym^`hl)xmT1GH?bwO8tSE++cK=`p1Y05OAOiT`y z4WMyW`WqSlpwa4hH~>^clK6BL&{czPDl^h8_}M5NyInwuI-NNb(fMa}WODPPQnFs( znsyM+2w4{;(981TOMly-{*B@NXyZijYS!7K%uvqEtBgaw;?23o$Gp9LiYObfnya*g z;=Y;c^vrr2@0I6axS+BX^t!LhO;5Rk`-(z>&9~JCy)?v~%w?q{U6VP8KMVwleSHED zZW5^L!kHJm4$J71cf34%nu1J?0xZv-<;Tawp9aa&R`r z18{WJ152;)D}Ej4f(oK5oc!NRc*H%Pb!oZkJ9OScM|mNA^Y9P9k|HCc1z+(>L318k z0h4MxB;QVy2M&_7AFa!D9_^?JnzJhU3mAL+~D+%pmZmyg@T_M%0 zGj9@^;UK0HZ??z1FMgktp~O@QIi}lm8B1Jre99K)+mkrKwWWcc z2>x_%4S-aL3Pwu|wbnZ$vn8Pow1|pR`#Odfu8H41efa9eZ@SD!ragRI@d^QhdKrjh z)s^ZTFK%|2>>b`nZJI#1to{-Aip<28Ksk+2X&LA%AcKjM(+Ib(x^ zp%6D$a8=M-B~yw!R`g!nFyu;|F(*hW*FfdA1M+KQAD%I)L%D%%n2n-GAu8b_N!GWO zlVayX5`E*8Y#>2a1m6K-Gdm(MfuP!GgQ4P$ZX|7Fqn=$2L*Y#HW~)XtT$SF|_7F?r z#YhUO0-Ku&?q)NaYNybSxy;v!w`wy&wb;2%DQQirH1)T``r-XXHhC(#HRc_m20Ga; zTXbj*(>|q;4yI>xaYd5tRXxffDayUo(}B?IX*j`B)l&mV9zI^BXkaR7$f6LrDVM=G zQ{QvGp>n4N);P5(+(mymanN`Hlij>ltzOts3*9zCKVxbuF|6K=#j%nl3{n|{Roo&k z#KPMM#{?rgizrlUCEE=FW_s|u!~@)tl+SLZFTuu8MkbiY0v-ss2#6kpZnT90a8T__ zv+ic6)*gj!d&oRyODHZG6WPz&ozx@4)bW~o9l1ly=_2fWky{P7@VKIS{*zF%g^Pv_g8t6RiS*VjK`J}%tyDidZ| zU{$Pi9O16?D)mGL?)l+c2k-+gjG~rNT3Z~392xWqI@~Rwdk)u|{zl!gymCdfsLc&i z$9ed~RR0l1FFHV1uYsOD%;SN?sj8*iqOWTV;r?Vs6Hu= zJuvx5^|GXY#P~0`VF^L2d;_NHLp!w6tw>5>tC?>+QCPz~#&0gR!q>KlvIY@K8sq7O zFbS24joon%?3~On01xp8Pmd?0^sJA+;eKZ^*!xjKt3qZ+Xi!|d@ z^Zih=1h}EI)tio3+sw>fomL@<^D*Eo$aT?LrVdyKpprxGRZ6~4nrl6G)Lz)eJEuM< zf_J#K;>6|rP|xZ1Q*LgIkn$ROi%dqQ@DN!)Pm6OquvHkVoS{r@Zo_KR{IA$Jbco-o zJR^M0$$Ny$-2StUfA~5xK0@eJ$%*)Phh1KiqwXFFRybzNkz1Av8Ud`9;mG)Ko2tD{ zXYt7$vu+1?9`HFIyl7`Gk{vE5{>XQVSd^_uYa=6dHJk1_)o$AO@$$2!;e{Je8d ziN=TDrrsHrgOBagEYhE;}VpSDn!gT#U){ky&JHf4A(=I@1=A0!V6TS8u zu_n1cnkav1*b&?tyy-lQzwA#3Vg!lbmyMp69);_wM^%{Ov!Y zzx)1u$LEV#s5|%hUb0v+xGj66?YXUC+}~?Gm&u6uI1(k!xeph|{I3k_GXdit7Va z|FqFqPAN}>z42L4|I5h&co8|;Pqcy2CAGOCOyA22au=#_9j`VQKeMb6Xh7@Ih9P0v z1So<{?<`ck@Ra=jw*(Vt-pI@X?xggnL$4mVu>8O4^DDH6du{RdVnozD36p4@QQ-yW zqecsAVumvsX2h`-CMaM`BTt1Z08R?}Q*GrudI#{^0fxd$NcxD`dXRr2M-{b%0sb_) zd?PQqDmoK{`_i97ozJ2fmzYhjI=EkWy(>AhG8)J<4m`=|F!3pDxwZ^Hg zNS!oyGGiN}XxjwZcwE&RJtZ>V*fFS~kVc@`FfKd4o4+Y*M_`nl;1__I>BW~+yPZKAB zOzr{5cq zZAOP>KMQ>Gwvn(e>O|-H>h{tJGQ> zQR`=S8U58nIYvM0a&3zvS=wQ64f4&5fjXUfL!S`h z$$pbv6E>WTJX3phhJ0HIC*vUxpT*41_c4qif)AJ`R@dr=i!ze~uJzO2yjIm(;C-NPvh)4#B#b0#I|`7d!!xhIrJX>xhdZQT+7W=cgv80akUg%vOx73Pg9`W?-pagv5>IF- zA0$bR?rRpUi8mZX9s`P}YSq*yz}rQkX*YN<^vr;we2GY_`x9-t5|$K4%ky62_+{bS2hXWw7lVpx7r zZxA+T@7$BEo!H!K9jeP=!mhCr8p6M68jc8TY#N^07Li|(X$$iM5FD=fqA*@9qBb$v zn!2u?k_tS_Q%ik}p*SEhm1E-uD=;<@q)+S=fO$ny948;IhH3rF3k9pz6~)eob2A}^ z+_}u@&&$Ds0RiEiJ>bw=0#WiDOYuo|B2K0b(go!>AVp~u8#e-M#?4p{DtI7(HhIu5 z1_f^_BJe65KWd&#-MOt{brh|`iTT|au|>m}5YkKIVC~=Hej$$}S!a5ZPD<){2}bW z1D#8>cL{M}%|RWui7Jj#R%O2cF#C%9%_eZLs+ev2KDBLo%pF}zf+@o&x5Vv*KX|to zi@kAcLVQvPSJ>tLJhTw(;q#r@>tC1|_xzl0GrC4e9R~Ws z6O0?Q?JFFUa(j$Kc&f9KQdtNs9w;M$yApgZR4KS|Nq-}(787%}a{#j|6)0zpl+TM; zo<454m!d5G8q+Yz6QzeGKPh@L0*Vncp9A1ic^YU1yT+OfnGKpF`#_N%j!Os+x^~pS zpNm|bW)tU!1Yi_C#2Vd1ur+svO)L87>);Y7gj$DwPeS#as8#n&OU>hrzLD=6U*oBy zbG$qwxn20|FFy?>Vf89B2I99NJIp@z>sSf9e;&j5*@*D9yt zFGK3R2FiPP4a+S1j=UCpIf`C}C@@KNQnbVqDEN?ZHZlOHuN$>(q&B+dgls;m5)J(E zO-2KuqO6{THJ?|v*N*B&b|ZD+CY0i(*?Op=MsZP4rd?^LI?|h)^_p2(Qz3wWO(BwlJ0755 z8;j2D5hD?Q#W}=g6FK1Z6mWo)R~F&xP-)S1x~uqI7bI`}NyL>X{9DE`LVlLh>@mfQ z+tBHXSezByHMwB_Ofx=bgX1o(TmW*iK=2hCp2Qp09o2JH$p$$>x{_r`*M@cgvEy(f zj*=O2z53y^kT@3?LqDKt2{4g%@|KptK%fZr{2l)}D+f^l6K+Gs!5A>;X^5%-ElL9~ zErZBd<~EYx4nm@l0b?A&>-nAsxTt^g6b5d)6dMJ3s#qD;!WS)Kg1lX74U0P~wL6K^ z1r(lnnLO5%z9Am$@!I7Th>e!|$*)=|t|L~bdZ9Lv>yU;Hp1X9i8&L2OX8#e{uiM)h z-ZzhQRBv?G&VT;$w1J=2b{e?1TE4Wbu_!i%I`~_17+J~3SJkF`Bi<~z>29VbxO`wY zo1RqO6?AmO<-|Zi_B14SHcy78IfT`1tbggcVCYY%edaY(DV0026`^ZIP<3QyFJQ)? z0N2-SHcy^Ht>$|UYXAuVY;5X)HDi$(Oqdd(?f`=gc{6~`{6K^nZk6zeZHxwE#DZQt z5Y}uI-GSOXWu)1_fa{{6$UB{>-By0U$;PJo$p-ERc>OJWV;WvUl9u;byhW5@gAJ@c z=uxHK!hCLHYWzIx%961OP|9YQ(qCH?Dcxs?P#MBcEzVOh=Rls+spel%rHUJ*REc4g zID&wf(2jBiVxWdK_I)PN+!6hJ?+k!P=VW9S7&@1pl<{!l`0rl7plfciU&9k*;9U+^ z>QPS7UVs;Z(sacE_bn!S2e}PFcww9|!W?&$mF&HLhxatmR%5dalRJ?zN@FQ~%w%PR zr?{682H?b1945N%cv6G=7(o|CP7}BKiJ~6D9Q6XZi%6zl*oS`&ogJx5lmTl~K0#{=u z*U0Lhpw0ckyn1vzx_q5fzu}u4zdG>ehZga$0c;UI5{Wo9h$tF3+x3~_&hul@?s6n( z3_Ul&E^GUNmW8{rojMk16I8Bolrz^zFj=1;|8swR{@60Llx_?n8Ps@iInt%oJv;#e|7n(@}wA zqXUmLoK^#&k4^Q=jmyUj7JamQz#zft$;}SfAvLOREmmKd_Iw z>Bt$D$}A#G>(v(DEzPzyO(rNLGDd#F^utW3r6qnU)-0EXW46vCx$XI{IV$b6CB8X* zK4umi8iadA(n)o-Gl*3Otynw0@cc0y`B=~Ju}@xk8N{&j-I`ntw!dzpQqEXW^0qbp zD&O}vY}XhskNXp+0xrkJ7xNcelTi8+X`N+X|_>{lLAt- zs(4vwg%hd}*?5b^r%8EpWnyDovnRU1M1YH~+bwL3^r=Rx!RY6x`!OC>E(@qUdY4|Z z%B5T6f1#jxrN|9cm#+r)ciCj%;&oJr^H>w)=R;mpWV5Um>QQ?BNasypY9eP?x2nv2 zzG{65oX~bK0BOmN7`AAW0A(XUXy#v-4|O;-$CPRjX}FfAdSO$ToB- z<_Q=Br35P<7)-YQH%InkBPNipU`#$&(``)~^AGGa%nrHPn3pp$5eKCdqt9{dbP1?) zxEwiI-4Gs_T}~?B0lrsburD}iUYlF9)|Dg2qr;<3g1va9e$iLPwb~`1V+QlZaFFuF z@b+TbB2#zLI&EEo-;2Agn4|p#+TmMU;<=jbt%T^owLCylH8)!=2Rgew_ypVqabpKo806PzkMFO#d)Y7|CK{h(Zwc>TaW+i%%0~8E za;ya6^DD$h4=RPD^n{R`38qFFBhmKw46BM#zpf6!@#BjSgGLwxLS}3~#e2pk$K(X< z9Pkn~?er`Eri)=Voz>Z!&AdV)bAT7C+I2^_jThyKj-j|Y$cRXI=@gO5CfEbR7zq^7^!BQkZ_7bZwC_BsD4*9liZd$^a4F=@(R|~} zCc*{79GnUyCWwv5IBWXJH*Mnm(M+2^c3rA--`${Qmq$m>SH|vsMcn;o%hgMnY4UIX zh3a~<=kY(yiXnmEz0IA~w%>P0d}(vWAh&?3GK(6mBBh!Vn_t4vB7rbsHH4i@HVv++ z#>$bq#aYH#g5CiEvRhV^H8=N}Eja;m)T_y1c&zwhymdJx)ZmAK7GLkf zu#2}YUJXzB$0_00spQCjvXi}ia|&%o3qR#w2h(lJ}e%v&2S@yU&mxmyA}&1~VK~fDz1#o;SvVoXEI{3ee!+jkBssRX{O!*M|8lA zA@5gacNzX^hXEBqdS@2t8@&YCT<)z9dpk1h9P}hMZ!_m6FR)v>_nJF$7x{aem$aLG zl03dp)a}|FpNh;DBA!}Dl;oB<`jmG-#Ze2MeIuciCE6|EK2ATjW? z0dnhPl1oISbni*Im`6_Xe*se~7^&Ai>YGR9)$6oLk6qH-Wk+xzZ!g3g!ChBHeT=W{ z+rM3!&Bob`%G1`TAa^KCDYy?vO76iwqR($eU&{PF^73}#yOhp9o1Q&Veh9r8Uw49P z%$C(n`$UZUePa1EJ6bEL+UVi zSDf(@HTg1rBhk8sMgx2tpr-jg-q?NS5C#PbS{*t-g?98gU3+I@xnT6WOL}dduIRR& z2C0;e>-ZkA{n{z$OZuBSsOaT!S~4pQJ-Ssz ze||cR7;*WfG;CBfnw8jZc_MM1#~IcH+^^{%agtlnxg;ZB*3OGWPUuW&s1M&`V7o|4dY)P%YA`_s z)a6UPaEO>;Z;M#(uH4~H@dUd-JPvvJ$&~{N&L2=jyv5SGl^y2zvSN+2)@CXcT3I4siP4gAS0GsbKP=1@Ch!2-=6Y0^O1_3&V#A8ZAGp5$X$kM-Ln;|;52*ml} z{L;3STJOrkNZU_D^cRYTIz4^tQ?^_qFTOgDJyXy)%FQ>jJIeoyjTfKh6_%wF|QW<~&_&RC!ra zmgi0iSu4eW;wLSx7_ykqacV75M^Bbcs8sNIP%X7SG`M_xbhHX}C)F3_3y_&&^8X_L z0AL4@7b^e#cy z>=#6IfBGLSrTjbbrxm*hTf*|CZcMMf4?;rBIO6#>q%6q}co&kw!!TR8jjpHQ;q73s zdnCGTQixmc6m_KoOx&k{Bsn4`<4Scku!{jneIC>8b_3;aQe67})6JtB;V%Hcj6&>z zohLleJ9mofTRZyV2+70IoeAqbM@T)tV8u2$S%{#%inKx5%!Fj5#G#wW!2!)4|9c*6 zk1v#(Kx4zd0!M48@$JGbI*so3*4C)=o4j2-bc>Io*sa+S$S#Czd#1OqjI>6GzpKvz zdD>^S?#7usH(Jq_`y{bjHOkw3zS#~LDEy-KpKxn8t73VuPPAuD`JaNDM;2^Jx4^9v zl3G-NruC9>p;mYMng1*k>@9_GhK-Y$V;)x{-R_3H^5Y%tp^x@BEP2iByk9-2)RIrg zmlA1h#uoiHFA&pZ7LIk_CBJ08hPPaA2ps59e(pduET*NQLlK6;f!)}sJWtL6=bO8Z zs_^1u&^c5Kf48*kzRgU~YE^48s>xGI)YWQ7*3!$wbJC1~N2#lOb@*W zvl6njNSw32<%oQAvB`fs*Zt5%Hgep>oj2u`QNG$)dn_Rk8S4Y36tXjTIB&t?z`~!q zQIWj}h1xS*GR8XkYlQ;EV`5_Myb$hJx+e&<`e z`J+`JFJ}|6;ls*GGB*12yrGf2*<2rAF`JIESMI*-*Pik$^1RHgdm6r8DAEKpYoNa_ z5@=}8oz7KkJKdN%+PLlzPW3jA2iiI%xr)%ByIGGlM^TlPueg{I?3iABLlrD28#U@3 zPEP{;$c-7XOq?97Az}@8iW2#EATdSMTlU3NY4Ki0HOGNvP>wh+ckMCEM~MRq8;u(=h$+YrnB!-sm%@sXX`~rL@^G<=1er;jVVSVoi7feDVb#7Vb`XpP zMWdE>c6*uGje1#&hsZ3CJp%}dVIE=@7t~8dhLMI>I*IlKWk}%s+ru0BA0sbs-OKbp z{gHj;U%%CQCohz-OTOOl^eiKdEbUKh*8Efh;gg9uy8_2Me3Up8v36eu_GTQW&E=22 zB@RsA4V&l-N;{{X%o)~IjTk#k96d;FV0O7~Mb%>VgMzRIJ>K?-8ehT0O~~03-@Qyh6hL2-EOwrXt{OYn$@%N3hg<;(he#Y*D(Bc9OA#f z!s7tyDkDS#het-Fb4Lk&GFTKP1+?2q@9y=%q_}0dd)V%N)d(fb$a94k+%jodr ziw64(vWO(5=*_cKJ2l;@$~!RTQ%8#(&|n3rt8(LhWjVBzI$D^Iq=$zsvpqKadtk}z zq8{qCF|vN(&GD&E-c6lHc_T}fVRTD#5YvVHy0Tp>iHt+*S|>01Z^)u>*=BeK32Ul= zNc>sOcl1EBEoSLriZKCNk4@Vkou1&kc5cgbt*>b`29SqAxslhZ`;T}joimn{C-yL8Lz z;mKUMmTjLgeZkHkN$n_1BtkY)#UR(`lE6-6gCu=S)Nm&zp6KM*Z|5D{bI1*C0V5p( zxEuPxm2>ItHpA;mrSMy$sSh4I<_`EoJ1F~PA?%Xlv~Ws!Kj7r6qSojE)nxVCNaw?n zo;9gm+^4YTqOATV@iZYy7_tDa;ghwK2{KgGm*)9o#jM(1E5d@SP- z8v3&3Xg-$v=J@Stl6R;6dTenH4all!dQd%9Pq+MOEN)0ReZ~!II|u+4MANb%g_1Y= zLTYq-y3xj%pdHQP&YHE&0CLI#F7>Yl`ed&*?sL(}uh&oib@{*8g(DP}-#v+z?(bYZ zxYf?^XADP7%!*(pSG9X_ucGqq#|LQ{U4-?!-(3x`QJcs3%uQw-1!nz^$r&w)IaT{5 zwKU7Xcf;z6_sv@C&ijSI_bW#zfXaP`?UNDE+-uCO?6)wARHn44J-XaP^m()Gm0XF!3ty0PZyXS{H_~bPUYUhM_6w#k!y)~ z3ePMbe@Z~$!G1D(OusRcU*IQw<1`VQjNmQtC=NqE9 zuY+>lY(z9<9L9@MuI0LC^n|xTU%(y5Fk0Br0ETm2%#8ei4p78WONxNfu00vy5)=#H z=Qd(e2cECRt|(fG(`)u+ywCmKCy3T0e@Wew;PUE~)tlc9EUfcCU(C%~`5kKPZBt@1x>YfRdnSBGc)(*FlotflVdM5Ek1a%7}yR zrn#Mqa9?(k>#&Zt!=P@8H^xPGEN(YJS5As8eq?kbIT-O?A<>^4@408!f0fc37#WAD;!yjW9`e4@rcW-@vCM(-2Sy8y@oK?EoX}vQ{!am^uutqYUe-wTRW4yb z0f@6JiyDg*R$;7zFGS?nDKeQ9H2mb*c=Y-||Mg!*A1OUXWiIZA4})D7D#n+H#trb? z9@l^w1FT+5b#iM9I4aCy@kh%#&tA>#p!{)st0i)>Ds5KSesuEh!)!%TMrNxYPkUH% zCe6p}WMV1I^T)<-iqZ${ANLw3*R50fE#j9pQ(88}UurcKGw&pLfgM-8G1=a?@isQv zy(RQ~$Ng|bR>?L=x=BCb$BX~nvlnU*1fhT*s#Q$c<*HrEAC%l)c8e|^ zhFR0Bbdns@ zVTT{hqN-gWVW4U8G?FXzL5v!(XT>%?(8nzjmv`;KU{lb!iRS?j-PxjEX-V*^^#PT7 z>W^mo?<#-(u_w~V?uW}{YICU|jiLlP-b`JoAF-M_AkE7!$n!%tiaOxh+SQKDuxw}n zbI1u@J6v=~!j8nh?dhsJ(R}P${z&^)q`&eYBlBEGC%(?IEAYFoOQHx@18i@>g*lF= z+^H&?$r44af2>|r@W3HJ_*m|}705e_j`u#e*c4V0x9`;+R_s&SiEATBUS_pz__Ql0 zUwij))SZZKTqkZxTaR{cXIFMlqh8Zai4$|N*Dtqk3As{ZkQ?OMVc+-I)hQxl$9&ZO zUUkUz%+Tt$j`(b2ydgKvql78h9RE`wu>cf&SO`%auf7*ev~nCO?>lQu>FAqw9qj0D z{Xp6MdCSzu#;!{$mnT)!%){D%*V2hN4y1~jI`ZKo_dgd9BnF&&=D+ar`>!b*+}Vdl z$Z&`-h*FDA!^^T^mS*R3I`}>Y+$%~U#&sZU77JQC!E)08Fn}>QeK>m@ztK!^r3rfQ zI(~dgZd^)XJ^S9Z?>_u`Eiz0s^{gXQA2g#*waJQ_H&%m!vb?j!6pMS~?qTga*;fKI zMpmY!5LLriNw5Q+FEw*$Yto_5Z`6nw8G6KR)}{qp`y3uWca5{V@HFN)_j@8=-KxZ@ zj3TX!rtr31EyQOH!?X;w50FnQURLXjR+r0cN~6>msixL0!)k_5hz1q-z8Cnp_}+`- zCGd1ynBGGP5h1h*rmweVh)11E&+YM?YBP+C+ZODqu@_T0Oqrw1`eZmcOI~F-uIo%& zpPiqaEtTm${1_Q*vLHJY0Ro2??o5(776|>$DKyRs3&F8SxIJ2rCa8 zTm8M?l0hfafcz?{ht#99*kjB|I3Q$oZBaj>0%L^Fau?wh%?S@BPv)|*1NRzb69|*4 z&6>&k*0jG;E4>rUq&-b9rT#9uK0Ig_Q0|Hnqq4e#x#=#`)t%!5Zex4wB1HITaHT^ezsW*O zeRAhB&o(o}OkB(abiK1f-TDWpQ6;9bD9AKQP~OR1csrapzWNI=j`^i)PUvP)EZ(ZX zO;3N}OmnC#?n9UDlj$weQFc>8BxC|x5W-&3SDG)-XE^CnBmB$4Xmor8GK}|-Z8C>w zjXNW!R7MISdRW63ge%4b#ExHzKz~BdQtzPnpQqLwkx{@MLhKI|gAh0x{1`Ip>UYT( zyl=7ODPYc=<+0UaPkKn!B}NOk2ZSI|ltwaaT%5oW?hjR>(INoIi~r~XXbdHTQO6lR z1(YjX+o&RajA7x%qP-W)Xk^J&;pCCZ_Y{c6vHKvSYBMAhk*4KWn&x1cU$w~93vzF8 z`U03aZV3CYYv1$k*%?_oM2b3rYozwlf*APr3bm~p4nq9Xolv6DgoO_>k7LZpus#i1 z-n!6UG*{5&oi-L>H5R(&l-;>(kM!OW`>#yR5n?oD%)T>F+T?pR*BWO)LJU;Kxg|bL z3d8}c)|k8A;V1iTwQ5GgbNSAPardJG6F>Rpo?rt`BfggMGj2NyCZ8s6lp?vRTKZc* zX>j3Mk}gItYDJ>*p9}41MWBA{Qv8q;aV$2x=H--U(?m3?#u|CQj_g{etIrci4vAs@ z%+F2~xe3fvC)+UNty?T#1&i?#Y6k=N*7;QO5vF!0T_5ToG;+W7Yt7ohRKtyydsmO& znQS;co@%_@IkT2})sA|WA^&@X&9(nV8T@c5NMifsIxNU1Bn@{JHs1Q4qBx89K~y2r z_C&pTnk@86Jl&Z{3_{Km7CXQ8t$EcZ6WlM$?A!U9E0SP;`_GpVyh{@6LMOVeh!^YE zltQd+E;dOqm$N3rn7!VLP6y6;;bKE;jy}zc?eKTO zZYx53jj44(R-R6)ypRoPnps?V`vDtm*`E$oG|}A5=`1lny%~piZW-3_-0~;bP-k=) zUKMMAbEppX2&ieN=~lLW_}VO7%|S3oRu^5VDBYVMbta0l)+6fa7d#p;bEEbnE+5jGn_lHEZR=je~Z|K$cxwIthU%-dn+L3N`o`|p-C8YGhQpavrlTq7o)^Jrq&GkNfheI76Bh>rAIAaCcQzW<#Rw`_b6tqA z3rL<|1UX@u$$`9pRlChGlD@)~-k;Wwf zfwSzv-mLX}$&^wg0ml;IS2Ct}YbH9s{Utwd5$ z60{RQ;)YC^&9&cWF}_9=*0tzBBn z#Qfvf!~NdHFSR;9u$)dU-JB~)o?ttjMEBpd;o8J;9`yM_i5lD+hCH7e6zJRXkwbG8 zrV*M}zvzgb75OKK-X>+fSJ$1)k4F(u5@!{VEvj3v7nNimdc7gx%Hoyy)tLds7+e!k zy0Zw|;CM;GInq)yg^i-X+8yy4LMHz2Z*Z#&T$zSmkWXYFXP{WG^WN23ky%fE=aqf7 zbP9r7s7#kPi`(`$Xh!^os+gOYIKCsp)Ei`oDu5rbJj=(@!_fMkF7y2a+K|l(lT{Jo z%-4LpAp?nP&6NmN%oBW+=NIF%HS#6XPBosOA*>bTqPQA&zVeQ#j7bP}kvy6F7gI;S zLC`5oupo-JfaXZoQLO)`Q+Ah+tJCp~Z{zJY&+X!@hBa4=ozz^{1IJ>kQ%f~K7KtQ# z=D=8Nsxjn&PQ0ahYK+;P)EHQJ9s;Z@p2J3Cb)|iVTmdV+I(77E;zA+-wQYmiwqnT^ zzvS(u_N3N6$+c6ge9js&@D%P21A~sKJpl0%Lk1#pL_h9+%|EUl8wPglT)>^IUcB8j z_qO-SBF)SghsqaoMl0J%gR!-dx=dzYDzmUIw>tolTdDI3}We5)KX4%^@11p^Q z%F9J9Mw=_5@2L9G$b=%2V=8TAg}jLDhQdq;D%M)ON@^T5Z%ospp|_8K28_v94*GRc zl&EFp``R;8i}#Qy&r2RNrW@-=j{}cMFPPO2=j|Y&=09Fy4#j!L@y>649bn)s!fntX zr3O4xcN&L}i}q4WGiwE}+a^|q_&Dg1COZA@6?Q~bhc8S=aLzN^ai{KM;u8S4Q49tc z9uN|8!f|Dvbbq}^yEFMsTsOC?J*t>kNQJHJ9nYI*?F>khD4v^(kO~@X4R3dm65`-* z&|=~G(Vvt&r={!J79)&}`YI8kUxxA?OJRum>~!am`Hys4>NO1x)d1-Vf}1612Og>2bJ1X%Fb3S61~!Uq_UR=q z#W86aS)gnUN|fs$d|dg64jw2NWxh9pia<)(t#B}xpdJ(<4XkN@5xRMrT$&nSY&7?M zvvoElQ%@F#$v_q6OKJy}O#>zXjy9NKq&64TB?OhU4cCj*h$3hHv7;&lH{`1nnZf5Z zbChuwybu8Q3nUT(W9HrGg~i}`PjCmM4_gPC=RTjNu$+UgSKB#$Vz1YbDY1HuQH279 zF+nlYhQp=0n!=9o+N0SlT+n>B)VB3Xz#F*4eh}*AzThWWWO%w z+t|#UpZ`hOeboSsHKqwY!90msOy~UdT(9x3YkcZHwD=EXTgkOc@OoIT#%Cby_u0r+ zDinZJ8lq0;DI2+&JSX%QwWSj>O*TGe6%uc8$`(Pdi5Xe{T>fF_=hrvh zjS9A_&oVF_8h%b3dFQL<_ob6VlwcC7wG>Gs&me%j;#>TX6U}D~;>Ao3|HT$rA_(k{ zI|J?CwF#xuf*sETOWbiUa1fv)F)g05Y1VVP?$x_3tW2ss$h*Ubi|DcvH=9o34$-4w zZlz%S4AdE0(Lo1p-Sno{geJ&ydFPrR{<qqkLu8%`)nz;8?0tS?oc8GNB(9l<>}YG>?+HDnIgrp5< zY^O?)XuSdUXT57)wh->@jC6>Y+4-Xm<5@kQSiaJQHGvfjnr6;Ds*P9MQ~_D>^xjuVg2;TZIpOqkWB zVUXE0zCALdUC?UXf~~t>JC?Hf67$UBrO+Wez_%i3SXXNG)z;M3Ac}=3L* zG5F1D?lgiaS=7$hGrV!74>ZO)ndNSVH*|&y$ z?I*pMD64t=hF zxMKeC)3qR$4e?1t|J40v%chHwY0U{E-@u#yXA}FZ$!Gdbi}qQjQ?r)USa&-6-Og=d z!bl%Xb+6u2rvrdxjYX2;B_w8PLm%Qdw7v|#&xcb-M)``-BX8ZhF_WrKmudAwKYe=| zy|u{ts&^`-ESP{e*5-6W0E2QR&HU)K;V%}hm{Wbfpzx#w?$6RJRN!~%y>1B&Asfx{ z8)e8Ptc>%jHWH)#rGrVZNQ8Lqovjm{96bJ?_^AJ5^4*kS>)VZR57+R`7#{kyiGgHE zTF3Nx8egOqveuF6ks|PQW$w#-qH9JS(S}U4X(6AEt*aOQJfEW0MRa&`GA6uLJ>)S( zeCd@Y6Q#fGPoh>Jl>#N7VG=W8wtyTIn`q^k=4kaJo-AKGkFEU;)*Ya6+0$f8@Njvc z+ihQ?e#UGu1AmHkbyVEc%PcSsg6idi)7#i z=FofAx&cSaJD1#}w?e45lXe1Gv^&IIiEzWV+exfjp*yZhyz3r}rTK?dKBoCbhEm={ z2c~aNH$55MM}$Rj`P2u?`ZseLe!B-xA8NB~=7LGjcdN@2ZU1oKRNM2N;}#lg{D~j7 zfP(q`YCi--xJYn~deOBYcVD6jY;G4v?bffCE&^{i&N_)ie{OH!+QUCTZ2pJWb&ygPbW`@pLV<+UGfeTId-<>vp)&9*7a?^W}Z%i^n1qx10j-HWah zo{k^oQev&DnBVm`h|aLaKKAg~Jr&?{;dW*uem-~aXKr(a_s&NE3`CZ1TjH-Qt#c>T-QnZYd;BTJQc9jShXRO0-~u?W<{03+0&5Uaw4m6+h}MceqJ5Ws@UvSN zx2`61`=&TCy6Th%Qo^=@1-q3#yOwV%LU;!ddr2E?MRJIe(g!BB1@^pg-wNP4A1=f< zA$14sRVVKpyPVzt!IPV}y9(=hGhqzolLB`j?rj$+7A8m!LTz zH?*?lL!O73`~#Q$qw&e(qBv1YZ(IUXhT0$6jFDFGnIqeC{;6y%IF(47LHU5-JNLfwz#`gj~#xFf7~Pq7ip7e%Tv^xMNKoi%|qkp4F@bPn067PZjCh_GXZBtdj-<_o zd~4OJjV=7%*S;M!6o|{(lzK|IBtkF+@&JF!x}#TL{=j9UrE^__NU8Os4^#UE*mQg3 zu=MS=b;xMMU4Q$61Y%aI&^pe~jmm%B7p!t1LUFi4~Lmr3LNg z?&#P1?^>-Pj&HYO?Y+|P+uybfCwSN?G<8_LOjJV~vW&Zn$$^n6sBu%UxR6IBVauxIK`FK(B zc;rR5!KOF+^;Z6PyeIwL=M=^MesqsDr00gPoD4j&}mGEqAR5Cnn zuzbv1ExLziw9tIfy>ecz7t?dR{Cz`4Fa(0zYHAc&K2O?F&+00Bys^zZU$XTxMn`=* z^^i}UIp03h{k>C$4Cj?dFbj+O4Ll-Y&)AIEP}N*CwpstA^2Sf*(u(Imm9PlGGppGJ zmPg|onI~RdF@8}d&hD(eNW4|jan>%}wf zQqSyIXU7#nD)Dqqf3C|9CG5H0cG>yjW7JEHn1Mqd&0Ux6=>YB3z@^R}S6d}<@|TK# zY~1Sqa3uP?ZS~qg&MW7w3k3b_NO)bU$ok-FAh4S%kLU|eb}O2B&#IG*MjeHsL35aw zYAArFkO zLRW-l>;0Rmy>pE{NUPcs7w1E7*FAVS!s&X27Zv2r}zhbw{wBvgLTfSa*_4ltdfBFw1s z@1P=6!OV^pC+P}qUfre&aFcI;POFjfe zbjfYu*;iKZa$91yI-4U5DsrSOOYh}c+zd?kI92Rmd{I{H_UG@}lNU;y4}`-;I;M<| zk$_qF%4e_ps!j{rPrP~5cU&9wnx_a)F0MLC*4%Nkm*LB|JW;;*k$QZl7C6dDFp)%6wJMij(llG?oKu{` zkj@D+$r%y#%k9(4Y*CbZP#=hrAEwmS8csZFvAU2Wu?$j64%vjHM7?LJ;cjgf=qqf0 zX|%HU7W^(R?Mo_=zO?DPPS(K#jEjUH4GenM9{+%d{vlRD0dCiP9L9=C?J z%%*O+AAInGgd|n|siV}XsdNxZLpVbgs?85anS+R~*Y-*Ido=oU)JOrIW&?FMD-=Bt z5FJm7c@dgxffDICcRoD3r7wx7TX~~`1|*PDF^9J-*Dv2Oc|Q;o^lrT;`$7nwueH7~ zA|om8cxG1pav|4t4BlYXH)PUF#cC&Yy`o+f1_`$Vg`?G5l0HH6Pv2hpiV7(XllwFb zx~#MDb@%K@@=r6PdqysIh*Q6ZnH=JGgLb8iZTA^l12n4}uZ|Dl(UphlApi%-XJ!W; zq;PL$Y+bI}m)MhP+Jf^YHemcA)~+le5;-Y)!0$#8jMb48wK=6ogxUmZGf4U(K>95@ z^1-P9WIMoKr4Dz@6Jp&o>ywRu;O9&i~mRVR)S{FITrz&>wGz43ME1?JOc%V~CImxIddoyISrjcsS< z0CGnHQsck;H9P*kR_2lU$iQ2MQv@^8Fj5dZtZjxWjL<0k?fxdo&JUpSk@scPXK_^3 zbC@9T-pA8i>f!WW2NF&znRR!4u<@jM+md0F5?kmfUOo*v>_~cH2SKh(3 zhi~03+lijSFB+RxnALRl6I?n(si5LzKE!~}B@q>*?1`BLaFk|KH3qGf9JPcUGta*4 znB;H?NYr`~OQn(qetXiK=7_ArCkd>(sIQS~-I=Aw0`3K|wx`8&hcr~$&-%*%u9P|79o%xyPwX?a^ z-QCq=w0GaH2@6gm?ndptC{it%4FRsU~o*XlETs|Qi|9h`a zF6bRe1oj4Jj3&ppqB0uCPS)8>d5=F?$23tL>vE8YU`}xO%;w=4LQhTEZgXxw=jV@+ zCLB*bg_^(4*!Gt>>T2XH0U#146dvU@sgwMwD{3YRSo5T9Q_P%In!CvK;X@ND&Et-J zK4gh4eRAvZ>R{U4R7XjuUpCQtVC=)8{huN`1v5#O=|Fl-1FE0VUON86?Yv&;s0k7^ z%xzDq#X5d6WrryZ0`dlj>#3DTMoH2MR)~D^edEaP`|$^{P?|*B*1NU0E0_~JnnLlv zp4+gUbTuFtgP9sOWgKOUqERK~OYCDfLt^789y;+`1*ej1Y46Vop7OKBF{YAq!>-FoKwLKSo z#_F#AUy9x{tjTPD!wrOl5CRh*Kxj%Q1(*;ZQq)ba0YgbB0w#eVU~uRojyg zLYo8v(xpZ~#~rE?iu4Jf{z0XxGh>;($Jw*bIj=ToAs{sThDXf-F+X zMes>ulZ`SuY6sPfoGx^N%AUl^gYG&8`HgY3#4eO)o_IxnNW3qUWU6rJ90%$^!HEa- znky9cB#w$>{1K2eDP3vaU0nGgB0K12FuSvd@DUdhhmZMQe<44loLlRjk09SKnBzzk z;COT9ZO*qiC|iryGUN{~j3ZW$A*5VYq*L*e#E!LR)WT?&^N|hv%nn{P)o*T6t9i`3 zCVm3I{e&I+$MfE;5761Z#ZM&=HX*Gf9CS|(3$~VF(dcef`5^^osIP8?yq2MWgy zpXyl;J~OJ-YN2MOG&Y;xqoU^Q%dT@X(m%&<_A;A+WUGw91>Wb}%Gcf0f~I6|kWDML z)?2Bp+*#{8l{W=izz9qLbkMV;eNT5rF|6<1xpS&F4vAt#yIMjP&@NJd4`?L@b)x|0 zg|dn-yq|n|_y!~DuaQl(9^lDMa`Z`gf*JpuP53Hy?kbErg3RIIzMb~h-Lb}bF8}#G zGyb#P>9dT!qcGjA7eYDK6nMly$2*Er9QvOnQu&jh$oLo|XV`EXg;?nsxc<4&>CY9A6NY=(<^J z?=fBDEPW3BS34MIiaWK`_3&XN3 zV|RiF;F@r?E@2^ez%FXCwyw5|Pu%i-sa@Z>ao>OxTdux$fxtd3zB3{ZqJ{AAhcj_% zE`+p0#t>(55T16HDPK(ODXA7JTh$}9RqB1_5NbJtsI>f)-pJdx7d)fq;TiMEfk=e5 zNl$+$*&_N&e^XZJr!BZnAVf7-A6^YxDp!@{Tn$!?lp8k86c8f{FJ_{8sj(KGqWQNHuP9c{#Ez71)@Y7>a{N?XQIlEd|5`k+ZxYS!ZQ zru41*&u(6wP-$w`@rT-2;??DM3r!XEK*rQ3enQT8pMbc`qJyR>s#FWNqs@+dYk*Ri zTwsA#w>H%o!Q7|;Tb0fv@&1&|s>!LMi0 zP7Y22Mr<~cB`C{j0T>7^i0y%`L}>%aLE9nUPc(GZa#QPUQ~gv_;E7F6t4NvEG3Z=y zHT4%=4cZExrvmh6!*+Z>1^|jB)O8Ny?jPT6o7Mk>#8nl%mgB6-(8EEPNYr~Rh6n!06^l#Cm; zrO6HT-`c%_H_uom)k^huM3Bnb*|l#;ziR8J7}&wwPF|JE&b&9}Nhfo4Hl(;r^x|(s#)EG|#iMVZ0T95t zWqX^BGzoj2PI=u0;F?B~a|Y|>4U=o#Ed^?JB#BA~I*+X)Chf`rQrcu*nQMiMudu|m zVg#Hc2FOxV)F{~KY+Y%RBB~F4Fg^~(SGsb%X!K3mO%i`t;lYQb;$(?=^-i|KybLW5nYls6yg0Ci0BJuDc_K+}cc0T_B0w)ic)L12!p0$W)x#z##qvVO4$D74g&UmZVD zXmI(<+|t;s)Q8tvqZ!$d9yb@}N!i=0P9 z1hx9)Q}cfsOU#E7zZ z%Ooz(2n_;w4udc|_TI)^V@Hhl(-%i=uTjT(}u=bAUf^Zt-gCvH!UrB70vw?5?VqGHj<) zs=D6ilH5p9gmQ6|2*hY%4402SWFuU2OlsHG2& zEyTs8^#fO_3&1l0>~_kE*alCl&1XeqZ?6cK*eCb2Mn*qf3`Z5M zuVqA$c(vW=UHHC!^MzqBxT_L=n9xLboI)J8%)_&NIbK!`tu>v=lITAtyi3)$Hq6ddAa#zwgFtb@R3iu zN#!f)y{9?@g?ZPh#GS3tR$!9V9`Grm>QmM(%`m@Q@v9^rV+%(gxaZi$9z}n%`n$ew zty0Y6bHa&l{E zDwE%@`kn%lz9xHn)!^+#wI9E|e4@Ea45Ces|>9E#_4T4rfYunG0l; zg7I1CEVbz3AFUlntsizU<$*c#)<2qksyiNX^o))jYeWwdo;tg76%;)kyA7@^efilq z(W;Kw?5qA)dS4Iw_S`IG%G+4^sRV#!hWX3|;NePq^4lWOZL@y5$GL?z6Qc+5+rxHM)yRGZ81Ad&hPfFAYSN@ARb?_rUBTHhw@K zi*k^QA~pwCDY_o5|Hr$fqGWVwp=iT6!pD4nmXT*P1ZH=5Q6O^~3@)ckvZuYF?3dnf z`p@*1owhHdeOfof7vBYm0(;yZ{U^A*(Psgu73Y8;#3ZFRrv(KQlj+dd2!@-xI@V#6 zhGNrQ(kl185FA;P3M0-$Q~I7L&JNN0-fN}nX?o9S#?R0z<&=Sy=63hw4paKRdtsiq zUwk<#>)U_Ln0LgB{y16r@_#)I&$03INAFUp6}44mDveM>^!`3MOo8CeD|_mV3Hms! zJ~=<5Qm;~vfWi-~B?EwmCK;%R>4C~bz(+lK3|NTZ`bt2uVd`s%EeGge>x8@Int^)u zJr!7e#J$=&&Z1{kx0p^?ozaO%Avr~b=5qjJ+0+aIO2X+ABrs-~=1w4*KJF~XJ^1)W z$geJBSA3nz@w+c{wDR1ARXLz`K97TU89I@ec9^RUtMCda>+) zia`y)qG3!h)Kf~4Nb->Linck^g%R?D1qy(GS1AY9^@-{gt>*Wc@0S#3k}f~G)iU39 z8Be%VvRsvsz=n(Gsj9GPEm+CQ2et_UW@e}R?=%X`incThQyk$bcF$T>~}hYy;RX3-)>yF z0vl`6D_!RgZR!YTa5ic=z|nmV3v3?&w5h(lixSkt6~|_F=vkk~DuLMJjy_)VfO+j6 zqKK|G{61%Jy_q0WU=}tZ$_sP7+;iLu+Y6PV3^*xrGp=Y5k;A~CF~O7LKxI=0&cZ++seo0s^yEi^$UM#b|oR}+Qucv%yZsnP^C2S5PU z3Lu8{jE;{|u)#2!m^>(~Lm?Z=)a^8Jy%$;(;+}^x){RI1Ls0IDOlI}#NjIB5IpgE& zX?kD?PkI;`rK%Pk4*>T_k$Z3jmSv{|W+TfAyRIg3Ks%sh`C!odGFWJ2iD;BOA1(d}x1qsvH6PV>H9Ezh zj<+|PmSDuAakq>agvj2My9srz`aLWEVLFz=4E;fkE@4-{*WlM!|p&O?+C= zOR_Iv1K}3I<&vaVk)@)Ufwt~wTd%A?HgfsDq^DiJuulIXCK7)!uWP(@ieS5COytzC zJq;I!jP+f`U;Utp8u41zyTQOK3ZMbz6%N+UpiJJFbO9t}$@YvQ@T>5eUlb0ayw=}= zmWia0fWoTcg<>$B0(?^4fH#5zAd)X64toWc)+@Umwh0p4R0xY*n=RxI>zgKcUV@3- zF7rITAO$9upc(YBAO-YZovMw<(qVCR(06+7^HjCJ0cy!QM;(JdsNEAzuUs7s2$g7G z{FS${7)>gib&WGQ9h}R!9Ay(lf~ZF6!>fPC3>9OG?ZxWI6gl-n zV~{Zgo=+byBLx>B^4TCah%rgxm{!Hpz`Oc=#s;lho*EoNEc%X`BDQX=>lXk0cDT8v z>d+DklL2gL1wT{|$5R-Q42k5GL8EMaDzqcKyu2h&eTY!BgTfxnVV|ijZ#(uRGH3g_ z(kLFpDcoTjne_dO_~gEY`cE;mLaq2PZ}$R2G#6f9(&E}cz&lsB9FZR*ow#3IB%F6n z1?Uis-CRX?dC;^#hmix#9u`Md_YqErz^l=oC7Ms%9~4bo1Iu5W^H?UZk1`DB0&?uN ztxoFZ=hU13{f304%a}gG52oWIjnQe(-~2?L{B)?*`cabyk%YzKfw@>iTWPUwl?nY7 z6xzP6@<;aj@CQ!*q@;SFa8&~AzE{Uq9{4-n+qld>D!TTMs854hk&mVZ<_~OR${o(@ zy}zkF|Ip6vacAt48^HG9>xw)C-TnF(wi&QWhp-dA(lBIuV6`$ArD%^bo2VktWjFOc zwlJx?aG?mUDD99}D}?*yd_>(D7X}b_$fRg8<>`)3%C_^m*-Ww~POwbFX_xfo8B z(>Ept_@%{UQ0BEf3zu@Z<(tfe{tPr$)^!r^xKdl5^Uv!}W=i)%en)@STVworquRk4pz^;eL+*#D6-suBr*K~vIMOajB3%S#H?)Wp zBNyZ8Fcn@qON*ETNh?DkP?2t&f!ztcgwmkym7mpLS~z!p(#5^KL@n5f_dbq3+8NE> zjrucwa5va17fOLq;8*M6ONG6}u|YZX)QpzWKq7Aj`O#Who|98yqZ?hgBA$Lu96(1S zCZB+UJe)WI7r1Hn{t**!>A}(pU+rT)U|`Il3?CFy_PyigpP6Vvg5XORRw(? zHuMFVCIOXt4Dr04qM~^;IcT{BGM9f_KuyFcOXs|yDIZtO2@cNy&Kig_2*r8nfpTM3 zA3Ft?1V(-z8S9zHsvwrhr~)(RQpfE7GBz&OoYu#rprSpjy4@AXf6DQsv#qKAg-QyJ z36Y;p0GsRI;;4|8l^D%M&=67_>TlJ2x@MpM+oj`HgS5AIFMofREM(BVTwg}qK405$ z`h4P_50Vf5s2UQl@iWy%lZ#u6w ztH{y6=2EA=9_GQ@nCTI$hptswY}k23ON~{?+DXn+H`-kn6&AgYLmuDZJ1J=1_I7H~ zYqFpnD*EJ6@7i`8w^2m}hZlpJSEB!1NJmC9l_BYP%!_j=my`^2I(hG(tr|Aq%xc!9)U87?DApP;<>}!EhD&GoYv{oMKy5FF%&snomEk>wm1538P&yYHEtn_QqTU{Um; zol)(O?dHyPcQnH@TrN73@85r+)$XkB%Y6@g-;1^{b%`6gNdZIAt4AYO@TVo$%=Iv1 z_p14RAv$MS+?@=5k5VPyDfCnLgx8W4sVGW_Bc?UdBmrN+q2qGBxgD348KJGi(;+V- zvDS|!9akod{P08`?3J90RT~oBM&y(sK|6#)pdFZ8{7Ghr%qs(f24R|VPZgM;*`|vR00|9Cl{rBe5S8m(}KiYoFcKH4hWHqUO6{u^v@sK`yJo6Ttf{K zs}cPSKqF%xO82ZecZhPmFri{tIZ zXSEuAA2mcDty(_RsKhwea;7AiH) zAwKrh(aK`EuY-o4pMR;K%-(0D44DVMA zCOy@Zw7<3VnlhsC3Ru71^sK*^CeGZ7(Q15u4-BM{=ViYz-mqW(y!7!inx?-rM?~M5 zL=PwdVy=cVq#XmWECq2!IZ)*;4xTPX+RN2d$d_VOA*t3iOFdMMs`1L(zNwp&&m2w+ zZ1hZ`Uc8Zw+U|rt@lRH#kSpR!<8qU(rnLliPwTean4NqQJQI$emo8DteuwU%Rg@1qc%nOCd!_UZ2q{?G*xc0QT;Bc9)Lx#mbCcF3yN@e zjxt2Y#}8o0(1h14yeI`#rqnR zHvi6~=9x|_$B*9`G?m}9bi5W+40}Zv)}e_e1_cbR(vWu{uB0faOgcRXnaqxf*)C5_ zKph=1np%_5!rG7gt11YPj*Ec*eJ!nZpRb!kyIolPv?sVlxq1Z@h;q_nvP9Fa=gq+^ zTa3QB{uNZzt3yV*jrx8Yh(?B_9ksm{e6ZQEre{2>U0epqqAE_0QO$U5`3$AF2b!ci zN-vq}zL$nf9|0x>s<~t6t7)wnL*o0^f=)AW`H26gi$ANZqz`b@N#6zBYP>NtaqU9K z#Q$k6uK+a3IfRUDr*Egm@j}W`;hzMgeqQ)ubuq;6fWF-AevnQl85(vO0Rk2}Kq!0* zbq<2ijy(S;rdqS()Hjc=S~+%m`kwrzJ*3pK^eQAG%ir`v<=Ljit(TG1Yfj9o_8_XT zwQlTa0N@1nX=>%9>{0Y2*Z^kllQI!Ye zMtQ+*Z3{`t7TrCOjh*3*8{*{X#KnkhzHP58@T;95KrDdOQh8LC$XnO_?t>b!18eQs zUiB-r9Uo5DVd@_bzx(MofYz+52XjCI3siq$aHtUNidF%_BMo$YF2$JVv71AlWbMBW z;al%L(SO+S=tIT$Te-ukdr_51QEvjD!VSRXD@?#6=t>18_m|=m(;ublPCvPI0}Bsadiub&Rag2rBYovaYxu)RZA<#mYHw_y9L$um zfVX>z0o4ZG>zVT`vQ}Q^njWys?Ede9%P)ozo}v?{I{5)@7TEgS-@nE=NMfA;hB3}{ z5VIa@1k#$l>yf{w$kN%KRh_}QU;MB^=v0IjY0##}kQ$=0RF&&Y`4LSH%z={N8|^MO zcyqAq%X}Zw;v~SPAVGU{q?;#w3)>RBao@^WabD~cEe52V@4*bbTrP-skO)>tzKU$u z2kp*1(TPrUKWj~O8@csx`S#%nmTYIr2fmDCOGs!g}CC10U2;cz!iJtk^JwM4CdIWg() zWdn07{OCEfCRAdWv=Oo6K^4tL@Vfp~`wn72IM34kd%VZciD*N5VSynk+1b7D_%Rqu zvyTo8J$0qkXfw5BETGe)n>m{{_=Ntt>|<~nRk_2vHZ*2D!=GPqlfHf=2Ox_*LvwdC zSL(HV6MG_`nm~tOav1{C6KyY&Xqs50TkT-g_zgmG?6oTDqjp%1{0aw?M%;a0Aoz!X6Q`6;1$*qoKrkSgOz%2 zHf{m?r_RVF$hBK#lV36$e1E!?lX2boQ1u`+z$%Sp<;3##TB#>+ykM#(E$DnoP9|m~ zz&|m!sX1!&_ifi-Jp5;O2y-ciDGjgX>!a6p!7o2NPTL;;CKOWy_jqS?R*1luiy zqO*5=OC___**Vp|99eJ^j<6vVE(W!49#oBuFpGR7%WZ}{uQgJ=I?5>iM=dv;2P z4=IvG9nK9G)D6uZre{_TtwEOI6fkS0zcD3f%hPTzL5VVK(`8iz=Dr<5B`N~xv}Wp$z<*tmds zmz>ie&gVQ-0n%+lzg@$R5iz8PfEX^_^w^LqA%32POq7aXw2IXGUWWg(OuyKyAjkKa zLnTgV&dc{|ECslh7r=Kz>v)Pz&M=wf0F{fvo#<*4`x5R^(lXp;A>HAFJ#RgFOmYzm zTuirZhDTJ-hFNl8mUp)0&p6&Z|JZX`eB;ex2&tY43=G&o{w8H!MG`6b_Z^p~Zm)dM zC$kBb{pdVvXII09SBO-xv6aFIN=xvgj}O!Vbfy=}^a$In@lU?acd~etJ~MpDW_fac zNGD5!-(wc_O3h#0dh{;IYH*p2ke5Ibz&A7QTW+LQL4^x_qJNW9$f|M*i#hxhS3_op zngnVpov7UeRSpK{-qaU8mBayhhR8UxlF^3gVUk4HRDPj~JDzZc+qdwx!FH0%+BLi6 zOg}BjDhp;5r{B#-rt_%gH2KDTwL*s`2A&hZEvPIz*dHV|8ZG}k@%|Yt`(KwHD_)2d z$bO!p2yTa7;M zN{8MwRn6Z-ZumQ~mM!eos|4L}YPEP*e1|v;Fn@;4F6MN$1esp-N}0Z%psKrMr#3yN zU}n*<Zubf!pO|LCUQ!>=wV?;-}h(ND9lR%mU zC&y_wqI3qoxuQAiSLMTe+h&5#4P{zBrC`-&U}qjb67Mfk+X$|g`Ct;p7P%y^^K>$+ zC#JWli?>XU4w^4`f))L~IC?Kx$$8N?@U$DdrV$+9Wsc>($?yVe+L{FE&})+tALnmI z(vnmHW1H4|9$#4NhzN=>V~?ker<)f=wLeacup1CTcoKm3DwupUk;6%`QM4Z!Ta5B;1|wn;5WD%&HsQ_0N-F~JZ!Y~D7wD~AiCTvX zz7k-DPNC$fcIy1-ES;@+(J2=FCN(j@bm!-QXMsOF^!#wmdf7xNFet*0!(i$dN?ffbLOk*;YEarJb9v%=G_gmm8uyY=hwf%6l;bpMD+&tGF|+`Ao54Zcy9 zTIpvf>7R_oT)l{IL6&28yd*)M0$&MT+W|J-rE0fzd_y-B=|ix^wzz;%M;GKN$`&PE zXZdB5tVhefn~jgwcIh42eTo%M7aVm$zsWI|DuPtPD}zv?5}kR|m9}HhRgcUir5(lK zfQFJr7p%L3Q2{Dgzp0t5TlKa{1t-$kvf_pruSoHuD<$Rzi|VkW6%RIe>sdt7G=Dq2 zqshVCr_3F@V`^mM_qgC;u}3J^wi~3 zl%Dn+PzcMEvo8I}oB|9ZVULEO*u*I`@lZipxlj8;eBhlUI-jpZR0r9&6!vRx`zGS` zjCVpQi;Oh+btv6-sSVn&SdJv47!e7ezD`RJj3isCfG;Y<6Em>dY~KodbGT2QHbrxu zJ{4463Loe+K?Hfjrv~JK(vM9zje(omm`6Yy0p3(E7?Ipu#|-%0ARL{mXj<2+<_O13 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%0A1gyY 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 df$Kn!3pJ_e@a@UbG^v&U0|lWs27c$%U8o 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%0D}pjhmpn4a?K4yWobc$;&cul^^PtCeba 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

noXlhR#0g8wqNUhk4zlR)L~qO6uc$E!D&ou; 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