diff --git a/backend/Directory.Build.props b/backend/Directory.Build.props index d20bc5aa..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 @@ -45,12 +45,18 @@ + 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;NU1903 $(MSBuildThisFileDirectory)artifacts/bin/$(MSBuildProjectName)/ diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index 11f592bf..5f9b2126 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -6,16 +6,16 @@ - - - - - - - - - - + + + + + + + + + + @@ -36,6 +36,12 @@ + + + + + + @@ -46,90 +52,115 @@ - - - - - - - + + + + + + + - - + + - - - - - - - - - - + + + + + + + + + - + - - + + - - + + - - + + - - - - + + + + + + + + + + + + - - + + - - - + + + + + + + + + + - - - + resolution across the whole solution. --> + + - + + + + + + + + + + diff --git a/backend/PlatformSettingsDbStructure.md b/backend/PlatformSettingsDbStructure.md new file mode 100644 index 00000000..438e5493 --- /dev/null +++ b/backend/PlatformSettingsDbStructure.md @@ -0,0 +1,240 @@ +# PlatformSettings Database Structure + +## Overview + +There are **3 singleton parent tables** and **4 child/collection tables**. +All singleton parents support soft delete. Child collection tables do **not** +have soft-delete columns (hard delete only). + +--- + +## Singleton Parent Tables (1 row each) + +### `homepage_settings` + +| Column | Type | Single / List | How Populated | +|---|---|---|---| +| `id` | `uniqueidentifier` PK | Single | `ReferenceDataSeeder` creates, `PlatformSettingsSeeder` enriches | +| `objective_ar` | `nvarchar(1000)` | Single | Seeder + Admin API `PUT /api/admin/settings/homepage` | +| `objective_en` | `nvarchar(1000)` | Single | Seeder + Admin API | +| `video_url` | `nvarchar(max)` | Single | Seeder + Admin API | +| `cce_concepts_ar` | `nvarchar(max)` | Single | Seeder + Admin API | +| `cce_concepts_en` | `nvarchar(max)` | Single | Seeder + Admin API | +| `created_by_id`, `created_on`, `last_modified_by_id`, `last_modified_on` | audit | Single | Auto | +| `deleted_by_id`, `deleted_on`, `is_deleted` | soft delete | Single | Auto | +| `row_version` | `rowversion` | Single | Auto (concurrency) | + +**LocalizedText mapping:** `Objective` → `objective_ar` / `objective_en` + +--- + +### `about_settings` + +| Column | Type | Single / List | How Populated | +|---|---|---|---| +| `id` | `uniqueidentifier` PK | Single | `ReferenceDataSeeder` creates, `PlatformSettingsSeeder` enriches | +| `description_ar` | `nvarchar(1000)` | Single | Seeder + Admin API `PUT /api/admin/settings/about` | +| `description_en` | `nvarchar(1000)` | Single | Seeder + Admin API | +| `how_to_use_video_url` | `nvarchar(max)` | Single | Seeder + Admin API | +| `created_by_id`, `created_on`, `last_modified_by_id`, `last_modified_on` | audit | Single | Auto | +| `deleted_by_id`, `deleted_on`, `is_deleted` | soft delete | Single | Auto | +| `row_version` | `rowversion` | Single | Auto (concurrency) | + +**LocalizedText mapping:** `Description` → `description_ar` / `description_en` + +--- + +### `policies_settings` + +| Column | Type | Single / List | How Populated | +|---|---|---|---| +| `id` | `uniqueidentifier` PK | Single | `ReferenceDataSeeder` creates bare row | +| `created_by_id`, `created_on`, `last_modified_by_id`, `last_modified_on` | audit | Single | Auto | +| `deleted_by_id`, `deleted_on`, `is_deleted` | soft delete | Single | Auto | +| `row_version` | `rowversion` | Single | Auto (concurrency) | + +**Note:** No admin endpoint updates this table directly. It is managed +indirectly through its child `policy_sections`. + +--- + +## Child / Collection Tables (0..N rows per parent) + +### `homepage_countries` — **List** of country links + +| Column | Type | Single / List | How Populated | +|---|---|---|---| +| `id` | `uniqueidentifier` PK | Single per row | Seeder + Admin API | +| `homepage_settings_id` | `uniqueidentifier` FK | Single per row | Set by `SyncCountries()` domain method | +| `country_id` | `uniqueidentifier` | Single per row | Seeder + Admin API | +| `order_index` | `int` | Single per row | Auto (0, 1, 2...) | +| `created_by_id`, `created_on`, `last_modified_by_id`, `last_modified_on` | audit | Single per row | Auto | + +**Populated by:** +- **Seeder:** `PlatformSettingsSeeder` adds 5 GCC countries (SAU, ARE, KWT, QAT, BHR) +- **Admin API:** `PUT /api/admin/settings/homepage` sends `ParticipatingCountryIds: ["guid", "guid"]` → `SyncCountries()` adds/removes/reorders + +--- + +### `glossary_entries` — **List** of entries + +| Column | Type | Single / List | How Populated | +|---|---|---|---| +| `id` | `uniqueidentifier` PK | Single per row | Seeder + Admin API | +| `about_settings_id` | `uniqueidentifier` FK | Single per row | Set by `AddGlossaryEntry()` | +| `term_ar` | `nvarchar(100)` | Single per row | Seeder + Admin API | +| `term_en` | `nvarchar(100)` | Single per row | Seeder + Admin API | +| `definition_ar` | `nvarchar(1000)` | Single per row | Seeder + Admin API | +| `definition_en` | `nvarchar(1000)` | Single per row | Seeder + Admin API | +| `order_index` | `int` | Single per row | Auto | +| `created_by_id`, `created_on`, `last_modified_by_id`, `last_modified_on` | audit | Single per row | Auto | + +**LocalizedText mappings:** +- `Term` → `term_ar` / `term_en` +- `Definition` → `definition_ar` / `definition_en` + +**Populated by:** +- **Seeder:** `PlatformSettingsSeeder` adds 4 entries (CCE, DAC, CCUS, LCOE) +- **Admin API:** + - `POST /api/admin/settings/about/glossary` + - `PUT /api/admin/settings/about/glossary/{id}` + - `DELETE /api/admin/settings/about/glossary/{id}` + +--- + +### `knowledge_partners` — **List** of partners + +| Column | Type | Single / List | How Populated | +|---|---|---|---| +| `id` | `uniqueidentifier` PK | Single per row | Seeder + Admin API | +| `about_settings_id` | `uniqueidentifier` FK | Single per row | Set by `AddKnowledgePartner()` | +| `name_ar` | `nvarchar(200)` | Single per row | Seeder + Admin API | +| `name_en` | `nvarchar(200)` | Single per row | Seeder + Admin API | +| `description_ar` | `nvarchar(1000)` | Single per row | Seeder + Admin API | +| `description_en` | `nvarchar(1000)` | Single per row | Seeder + Admin API | +| `logo_url` | `nvarchar(max)` | Single per row | Seeder + Admin API | +| `website_url` | `nvarchar(max)` | Single per row | Seeder + Admin API | +| `order_index` | `int` | Single per row | Auto | +| `created_by_id`, `created_on`, `last_modified_by_id`, `last_modified_on` | audit | Single per row | Auto | + +**LocalizedText mappings:** +- `Name` → `name_ar` / `name_en` +- `Description` → `description_ar` / `description_en` + +**Populated by:** +- **Seeder:** `PlatformSettingsSeeder` adds 3 partners (KAPSARC, IRENA, GCEP) +- **Admin API:** + - `POST /api/admin/settings/about/knowledge-partners` + - `PUT /api/admin/settings/about/knowledge-partners/{id}` + - `DELETE /api/admin/settings/about/knowledge-partners/{id}` + +--- + +### `policy_sections` — **List** of sections + +| Column | Type | Single / List | How Populated | +|---|---|---|---| +| `id` | `uniqueidentifier` PK | Single per row | Seeder + Admin API | +| `policies_settings_id` | `uniqueidentifier` FK | Single per row | Set by `AddSection()` | +| `type` | `int` (enum) | Single per row | Seeder + Admin API | +| `title_ar` | `nvarchar(500)` | Single per row | Seeder + Admin API | +| `title_en` | `nvarchar(500)` | Single per row | Seeder + Admin API | +| `content_ar` | `nvarchar(max)` | Single per row | Seeder + Admin API | +| `content_en` | `nvarchar(max)` | Single per row | Seeder + Admin API | +| `order_index` | `int` | Single per row | Auto | +| `created_by_id`, `created_on`, `last_modified_by_id`, `last_modified_on` | audit | Single per row | Auto | + +**LocalizedText mappings:** +- `Title` → `title_ar` / `title_en` +- `Content` → `content_ar` / `content_en` + +**Populated by:** +- **Seeder:** `PlatformSettingsSeeder` adds 3 sections (Terms, Privacy, FAQ) +- **Admin API:** + - `POST /api/admin/settings/policies/sections` + - `PUT /api/admin/settings/policies/sections/{id}` + - `PUT /api/admin/settings/policies/sections/{id}/order` + - `DELETE /api/admin/settings/policies/sections/{id}` + +--- + +## Key Relationships + +| Child Table | FK Column | Parent Table | Delete Behavior | +|---|---|---|---| +| `homepage_countries` | `homepage_settings_id` | `homepage_settings` | Cascade | +| `glossary_entries` | `about_settings_id` | `about_settings` | Cascade | +| `knowledge_partners` | `about_settings_id` | `about_settings` | Cascade | +| `policy_sections` | `policies_settings_id` | `policies_settings` | Cascade | + +`homepage_countries.country_id` is a **logical reference** to the `countries` +table; there is no database-enforced foreign key constraint. + +--- + +## LocalizedText Column Mappings + +Every bilingual field is stored as two columns (`_ar` / `_en`) via EF Core +owned entities (`OwnsOne`): + +| Table | Property | AR Column | EN Column | Max Length | +|---|---|---|---|---| +| `homepage_settings` | `Objective` | `objective_ar` | `objective_en` | 1000 | +| `about_settings` | `Description` | `description_ar` | `description_en` | 1000 | +| `glossary_entries` | `Term` | `term_ar` | `term_en` | 100 | +| `glossary_entries` | `Definition` | `definition_ar` | `definition_en` | 1000 | +| `knowledge_partners` | `Name` | `name_ar` | `name_en` | 200 | +| `knowledge_partners` | `Description` | `description_ar` | `description_en` | 1000 | +| `policy_sections` | `Title` | `title_ar` | `title_en` | 500 | +| `policy_sections` | `Content` | `content_ar` | `content_en` | max | + +--- + +## Public API Read Models + +- **Homepage:** Returns `VideoUrl`, `Objective` (ar/en), `CceConceptsAr`, + `CceConceptsEn`, linked `Countries` (joined with `countries` table for + name/flag/ISO), and active `HomepageSections` (from the separate + `homepage_sections` content table). + +- **About:** Returns `Description` (ar/en), `HowToUseVideoUrl`, ordered + `GlossaryEntries`, and ordered `KnowledgePartners`. + +- **Policies:** Returns ordered `PolicySections` with `Type`, `Title` (ar/en), + and `Content` (ar/en) — currently as **single HTML strings**. + +--- + +## The Problem + +`policy_sections.content_ar` and `policy_sections.content_en` are currently +**Single values** (one big HTML string per section). You want them to become +a **List** so the API returns: + +```json +{ + "contentItems": [ + { "ar": "1. القبول بالشروط", "en": "1. Acceptance of Terms" }, + { "ar": "باستخدامك لهذه المنصة...", "en": "By using this platform..." } + ] +} +``` + +This would require a **new child table** following the exact same pattern as +`glossary_entries` and `knowledge_partners`. + +--- + +## Related Files + +| Layer | Path | +|---|---| +| Domain | `src/CCE.Domain/PlatformSettings/` | +| EF Config | `src/CCE.Infrastructure/Persistence/Configurations/PlatformSettings/` | +| Migrations | `src/CCE.Infrastructure/Persistence/Migrations/` | +| Commands | `src/CCE.Application/PlatformSettings/Commands/` | +| Queries | `src/CCE.Application/PlatformSettings/Queries/` | +| Public Queries | `src/CCE.Application/PlatformSettings/Public/Queries/` | +| Internal API | `src/CCE.Api.Internal/Endpoints/` | +| External API | `src/CCE.Api.External/Endpoints/` | +| Seeders | `src/CCE.Seeder/Seeders/` | 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/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/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/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/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/permissions.yaml b/backend/permissions.yaml index 210f33a5..6acdc391 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,155 @@ 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] + PolicyEdit: + description: Edit policies & terms settings (restricted) + roles: [cce-super-admin] 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] + LogView: + description: View notification logs and retry failed deliveries + roles: [cce-super-admin, cce-admin] + Send: + description: Send manual/admin notifications + roles: [cce-super-admin, cce-admin] Audit: Read: description: Query the audit-event log - 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/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..34fcfa44 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) @@ -29,46 +33,50 @@ public static IServiceCollection AddCceJwtAuth(this IServiceCollection services, }) .AddScheme( DevAuthHandler.SchemeName, _ => { }); + services.Configure(configuration.GetSection(LocalAuthOptions.SectionName)); services.AddHostedService(); services.Configure(configuration.GetSection(EntraIdOptions.SectionName)); services.AddAuthorization(); 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/Auth/DevAuthHandler.cs b/backend/src/CCE.Api.Common/Auth/DevAuthHandler.cs index d4b1ba47..23299a7a 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; @@ -44,65 +48,130 @@ 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-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"), + ["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; + 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() { - 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)) { var raw = auth.ToString(); + 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) + const string bearerPrefix = "Bearer "; + if (raw.StartsWith(bearerPrefix, StringComparison.OrdinalIgnoreCase)) + { + var token = raw.Substring(bearerPrefix.Length).Trim(); + return TryReadRolesFromJwt(token); + } + } + + return null; + } + + private List? TryReadRolesFromJwt(string token) + { + 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), + }; + + ClaimsPrincipal? principal; + try + { + principal = handler.ValidateToken(token, parameters, out _); } + 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/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.Common/CCE.Api.Common.csproj b/backend/src/CCE.Api.Common/CCE.Api.Common.csproj index 16373dc2..0aeb823d 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 @@ - @@ -39,6 +38,12 @@ + + + + + + @@ -46,4 +51,10 @@ + + + PreserveNewest + + + 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/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/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/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/Localization/Resources.yaml b/backend/src/CCE.Api.Common/Localization/Resources.yaml new file mode 100644 index 00000000..89c98f96 --- /dev/null +++ b/backend/src/CCE.Api.Common/Localization/Resources.yaml @@ -0,0 +1,458 @@ +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" + +# ─── 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: "User 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 recovered successfully!" + +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" + +EXPERT_REQUEST_REJECTED: + ar: "تم رفض طلب الخبير" + en: "Expert request rejected" + +EXPERT_REQUEST_SUBMITTED: + 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: "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 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" + +# ─── 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" + +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" + +# ─── 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" + +# ─── 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" + +CONTENT_UPDATE_FAILED: + ar: "عذراً، حدثت مشكلة أثناء تحديث المحتوى" + en: "Sorry, a problem occurred while updating the content" + +OTP_NOT_FOUND: + ar: "طلب التحقق غير موجود." + en: "Verification request not found." +OTP_EXPIRED: + ar: "انتهت صلاحية رمز التحقق. يرجى طلب رمز جديد." + en: "The verification code has expired. Please request a new one." +OTP_INVALID_CODE: + ar: "رمز التحقق غير صحيح." + en: "The verification code is incorrect." +OTP_MAX_ATTEMPTS: + ar: "تجاوزت الحد الأقصى لمحاولات التحقق. يرجى طلب رمز جديد." + en: "Maximum verification attempts reached. Please request a new code." +OTP_COOLDOWN_ACTIVE: + ar: "يرجى الانتظار 60 ثانية قبل طلب رمز جديد." + en: "Please wait 60 seconds before requesting a new code." +OTP_INVALIDATED: + ar: "تم إلغاء صلاحية رمز التحقق هذا." + en: "This verification code has been invalidated." +OTP_SENT: + ar: "تم إرسال رمز التحقق بنجاح." + en: "Verification code sent successfully." +OTP_VERIFIED: + ar: "تم التحقق بنجاح." + en: "Verification successful." + +NOTIFICATION_SETTINGS_UPDATED: + ar: "تم تحديث إعدادات الإشعارات بنجاح" + en: "Notification settings updated successfully" + +NOTIFICATION_RETRIED: + ar: "تمت إعادة إرسال الإشعار بنجاح" + en: "Notification retried successfully" + +NOTIFICATIONS_MARKED_READ: + ar: "تم تحديد الإشعارات كمقروءة" + en: "Notifications marked as read" + +NOTIFICATION_TEMPLATE_CREATED: + ar: "تم إنشاء قالب الإشعار بنجاح" + en: "Notification template created successfully" + +NOTIFICATION_TEMPLATE_UPDATED: + ar: "تم تحديث قالب الإشعار بنجاح" + en: "Notification template updated successfully" diff --git a/backend/src/CCE.Api.Common/Middleware/ExceptionHandlingMiddleware.cs b/backend/src/CCE.Api.Common/Middleware/ExceptionHandlingMiddleware.cs index 53e54155..77a2eb63 100644 --- a/backend/src/CCE.Api.Common/Middleware/ExceptionHandlingMiddleware.cs +++ b/backend/src/CCE.Api.Common/Middleware/ExceptionHandlingMiddleware.cs @@ -1,9 +1,14 @@ +using CCE.Application.Common; +using CCE.Application.Localization; +using CCE.Application.Messages; using CCE.Domain.Common; using FluentValidation; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using System.Diagnostics; using System.Text.Json; +using System.Text.Json.Serialization; namespace CCE.Api.Common.Middleware; @@ -26,98 +31,106 @@ 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 WriteErrorAsync(context, StatusCodes.Status409Conflict, + "CONCURRENCY_CONFLICT", MessageType.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 WriteErrorAsync(context, StatusCodes.Status409Conflict, + "DUPLICATE_VALUE", MessageType.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 WriteErrorAsync(context, StatusCodes.Status400BadRequest, + "BAD_REQUEST", MessageType.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); + await WriteErrorAsync(context, StatusCodes.Status404NotFound, + "RESOURCE_NOT_FOUND_GENERIC", MessageType.NotFound, ex.Message).ConfigureAwait(false); } catch (Exception ex) { _logger.LogError(ex, "Unhandled exception"); - await WriteServerErrorAsync(context, ex).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(); - - private static async Task WriteValidationProblemAsync(HttpContext ctx, ValidationException ex) + private static async Task WriteErrorAsync( + HttpContext ctx, int statusCode, string domainKey, MessageType type, string? fallbackMessage) { - 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?.GetString(domainKey) ?? fallbackMessage ?? "خطأ"; + var code = SystemCodeMap.ToSystemCode(domainKey); - var problem = new ValidationProblemDetails(errors) + var envelope = new { - Status = StatusCodes.Status400BadRequest, - Type = "https://tools.ietf.org/html/rfc9110#section-15.5.1", - Title = "One or more validation errors occurred." + success = false, + code, + message = msg, + data = (object?)null, + errors = Array.Empty(), + traceId = Activity.Current?.Id ?? ctx.TraceIdentifier, + timestamp = DateTimeOffset.UtcNow, }; - problem.Extensions["correlationId"] = GetCorrelationId(ctx); - ctx.Response.StatusCode = StatusCodes.Status400BadRequest; - ctx.Response.ContentType = "application/problem+json"; - await JsonSerializer.SerializeAsync(ctx.Response.Body, problem).ConfigureAwait(false); + ctx.Response.StatusCode = statusCode; + ctx.Response.ContentType = "application/json"; + await JsonSerializer.SerializeAsync(ctx.Response.Body, envelope, JsonOptions) + .ConfigureAwait(false); } - private static async Task WriteProblemAsync( - HttpContext ctx, int statusCode, string title, string detail, string type) + private static async Task WriteValidationResultAsync(HttpContext ctx, ValidationException ex) { - var problem = new ProblemDetails + var l = ctx.RequestServices.GetService(); + var headerMsg = l?.GetString("VALIDATION_ERROR") ?? "عذرًا، البيانات المدخلة غير صحيحة"; + var headerCode = SystemCodeMap.ToSystemCode("VALIDATION_ERROR"); + + var fieldErrors = ex.Errors.Select(e => { - Status = statusCode, - Type = type, - Title = title, - Detail = detail, - Instance = ctx.Request.Path, + var domainKey = e.ErrorMessage; + var valCode = SystemCodeMap.ToSystemCode(domainKey); + var valMsg = l?.GetString(domainKey) ?? domainKey; + return new + { + field = ToCamelCase(e.PropertyName), + code = valCode, + message = valMsg + }; + }).ToList(); + + var envelope = new + { + success = false, + code = headerCode, + message = headerMsg, + data = (object?)null, + errors = fieldErrors, + traceId = Activity.Current?.Id ?? ctx.TraceIdentifier, + timestamp = DateTimeOffset.UtcNow, }; - problem.Extensions["correlationId"] = GetCorrelationId(ctx); - 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 string ToCamelCase(string name) { - _ = 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); + if (string.IsNullOrEmpty(name)) return name; + return char.ToLowerInvariant(name[0]) + name[1..]; } + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) } + }; } diff --git a/backend/src/CCE.Api.Common/Observability/LoggingExtensions.cs b/backend/src/CCE.Api.Common/Observability/LoggingExtensions.cs index 0b2ad0c8..2e03846d 100644 --- a/backend/src/CCE.Api.Common/Observability/LoggingExtensions.cs +++ b/backend/src/CCE.Api.Common/Observability/LoggingExtensions.cs @@ -2,8 +2,11 @@ using Microsoft.Extensions.Hosting; using Sentry.Serilog; using Serilog; +using Serilog.Core; using Serilog.Events; using Serilog.Formatting.Compact; +using Serilog.Sinks.Seq; +using System.Diagnostics; namespace CCE.Api.Common.Observability; @@ -32,6 +35,7 @@ public static IHostBuilder UseCceSerilog(this IHostBuilder builder) .MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning) .MinimumLevel.Override("Microsoft.EntityFrameworkCore", LogEventLevel.Warning) .Enrich.FromLogContext() + .Enrich.With(new TraceIdEnricher()) .Enrich.WithProperty("app", ctx.HostingEnvironment.ApplicationName) .Enrich.WithProperty("env", ctx.HostingEnvironment.EnvironmentName) .WriteTo.Console(new CompactJsonFormatter()); @@ -49,6 +53,13 @@ public static IHostBuilder UseCceSerilog(this IHostBuilder builder) { cfg.WriteTo.Sentry(o => ConfigureSentry(o, sentryDsn, ctx.Configuration, ctx.HostingEnvironment.EnvironmentName)); } + + var seqUrl = ctx.Configuration["Seq:ServerUrl"]; + var seqApiKey = ctx.Configuration["Seq:ApiKey"]; + if (!string.IsNullOrWhiteSpace(seqUrl)) + { + cfg.WriteTo.Seq(seqUrl, apiKey: seqApiKey); + } }); } @@ -77,6 +88,21 @@ public static void ConfigureSentry( options.MinimumBreadcrumbLevel = LogEventLevel.Information; } + private sealed class TraceIdEnricher : ILogEventEnricher + { + public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory) + { + var activity = Activity.Current; + if (activity is null) + { + return; + } + + logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("TraceId", activity.TraceId.ToString())); + logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("SpanId", activity.SpanId.ToString())); + } + } + private static LogEventLevel? ParseLevel(string? value) => Enum.TryParse(value, ignoreCase: true, out var lvl) ? lvl : null; } diff --git a/backend/src/CCE.Api.Common/Observability/OpenTelemetryExtensions.cs b/backend/src/CCE.Api.Common/Observability/OpenTelemetryExtensions.cs new file mode 100644 index 00000000..3410ba8d --- /dev/null +++ b/backend/src/CCE.Api.Common/Observability/OpenTelemetryExtensions.cs @@ -0,0 +1,45 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; +using System; + +namespace CCE.Api.Common.Observability; + +/// +/// Registers OpenTelemetry tracing for ASP.NET Core and HttpClient, +/// exporting spans to Seq via OTLP. Disabled when Seq:EnableTracing is false +/// or Seq:OtlpEndpoint is missing. +/// +public static class OpenTelemetryExtensions +{ + public static IServiceCollection AddCceOpenTelemetry( + this IServiceCollection services, + IConfiguration configuration, + string serviceName) + { + var otlpEndpoint = configuration["Seq:OtlpEndpoint"] ?? "http://localhost:5341/ingest/otlp"; + var enableTracing = configuration.GetValue("Seq:EnableTracing") ?? true; + + if (!enableTracing || string.IsNullOrWhiteSpace(otlpEndpoint)) + { + return services; + } + + services.AddOpenTelemetry() + .WithTracing(tracing => + { + tracing + .SetResourceBuilder(ResourceBuilder.CreateDefault().AddService(serviceName)) + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddSource("CCE") + .AddOtlpExporter(opts => + { + opts.Endpoint = new Uri(otlpEndpoint); + }); + }); + + return services; + } +} 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/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/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/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/Endpoints/NotificationsEndpoints.cs b/backend/src/CCE.Api.External/Endpoints/NotificationsEndpoints.cs index 91099b75..c941a216 100644 --- a/backend/src/CCE.Api.External/Endpoints/NotificationsEndpoints.cs +++ b/backend/src/CCE.Api.External/Endpoints/NotificationsEndpoints.cs @@ -1,6 +1,9 @@ +using CCE.Api.Common.Extensions; using CCE.Application.Common.Interfaces; using CCE.Application.Notifications.Public.Commands.MarkAllNotificationsRead; using CCE.Application.Notifications.Public.Commands.MarkNotificationRead; +using CCE.Application.Notifications.Public.Commands.UpdateMyNotificationSettings; +using CCE.Application.Notifications.Public.Queries.GetMyNotificationSettings; using CCE.Application.Notifications.Public.Queries.GetMyUnreadCount; using CCE.Application.Notifications.Public.Queries.ListMyNotifications; using CCE.Domain.Notifications; @@ -28,7 +31,7 @@ public static IEndpointRouteBuilder MapNotificationsEndpoints(this IEndpointRout if (userId == System.Guid.Empty) return Results.Unauthorized(); var query = new ListMyNotificationsQuery(userId, page ?? 1, pageSize ?? 20, status); var result = await mediator.Send(query, ct).ConfigureAwait(false); - return Results.Ok(result); + return result.ToHttpResult(); }).WithName("ListMyNotifications"); notif.MapGet("/unread-count", async ( @@ -37,8 +40,8 @@ public static IEndpointRouteBuilder MapNotificationsEndpoints(this IEndpointRout { var userId = currentUser.GetUserId() ?? System.Guid.Empty; if (userId == System.Guid.Empty) return Results.Unauthorized(); - var count = await mediator.Send(new GetMyUnreadCountQuery(userId), ct).ConfigureAwait(false); - return Results.Ok(new { count }); + var result = await mediator.Send(new GetMyUnreadCountQuery(userId), ct).ConfigureAwait(false); + return result.ToHttpResult(); }).WithName("GetMyUnreadNotificationCount"); notif.MapPost("/{id:guid}/mark-read", async ( @@ -48,8 +51,8 @@ public static IEndpointRouteBuilder MapNotificationsEndpoints(this IEndpointRout { var userId = currentUser.GetUserId() ?? System.Guid.Empty; if (userId == System.Guid.Empty) return Results.Unauthorized(); - await mediator.Send(new MarkNotificationReadCommand(id, userId), ct).ConfigureAwait(false); - return Results.NoContent(); + var result = await mediator.Send(new MarkNotificationReadCommand(id, userId), ct).ConfigureAwait(false); + return result.ToHttpResult(); }).WithName("MarkNotificationRead"); notif.MapPost("/mark-all-read", async ( @@ -58,10 +61,36 @@ public static IEndpointRouteBuilder MapNotificationsEndpoints(this IEndpointRout { var userId = currentUser.GetUserId() ?? System.Guid.Empty; if (userId == System.Guid.Empty) return Results.Unauthorized(); - var marked = await mediator.Send(new MarkAllNotificationsReadCommand(userId), ct).ConfigureAwait(false); - return Results.Ok(new { marked }); + var result = await mediator.Send(new MarkAllNotificationsReadCommand(userId), ct).ConfigureAwait(false); + return result.ToHttpResult(); }).WithName("MarkAllNotificationsRead"); + notif.MapGet("/settings", async ( + ICurrentUserAccessor currentUser, + IMediator mediator, CancellationToken ct) => + { + var userId = currentUser.GetUserId() ?? System.Guid.Empty; + if (userId == System.Guid.Empty) return Results.Unauthorized(); + var result = await mediator.Send(new GetMyNotificationSettingsQuery(userId), ct).ConfigureAwait(false); + return result.ToHttpResult(); + }).WithName("GetMyNotificationSettings"); + + notif.MapPut("/settings", async ( + UpdateMyNotificationSettingsRequest body, + ICurrentUserAccessor currentUser, + IMediator mediator, CancellationToken ct) => + { + var userId = currentUser.GetUserId() ?? System.Guid.Empty; + if (userId == System.Guid.Empty) return Results.Unauthorized(); + var command = new UpdateMyNotificationSettingsCommand( + userId, + body.Channel, + body.IsEnabled, + body.EventCode); + var result = await mediator.Send(command, ct).ConfigureAwait(false); + return result.ToHttpResult(); + }).WithName("UpdateMyNotificationSettings"); + return app; } } diff --git a/backend/src/CCE.Api.External/Endpoints/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/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/Endpoints/UpdateMyNotificationSettingsRequest.cs b/backend/src/CCE.Api.External/Endpoints/UpdateMyNotificationSettingsRequest.cs new file mode 100644 index 00000000..4c77f078 --- /dev/null +++ b/backend/src/CCE.Api.External/Endpoints/UpdateMyNotificationSettingsRequest.cs @@ -0,0 +1,8 @@ +using CCE.Domain.Notifications; + +namespace CCE.Api.External.Endpoints; + +public sealed record UpdateMyNotificationSettingsRequest( + NotificationChannel Channel, + bool IsEnabled, + string? EventCode = null); diff --git a/backend/src/CCE.Api.External/Endpoints/Verification/RequestVerificationRequest.cs b/backend/src/CCE.Api.External/Endpoints/Verification/RequestVerificationRequest.cs new file mode 100644 index 00000000..8a59551f --- /dev/null +++ b/backend/src/CCE.Api.External/Endpoints/Verification/RequestVerificationRequest.cs @@ -0,0 +1,9 @@ +using CCE.Domain.Verification; + +namespace CCE.Api.External.Endpoints.Verification; + +public sealed record RequestVerificationRequest( + string? Token, + string? ProviderName, + string Contact, + OtpVerificationType TypeId); diff --git a/backend/src/CCE.Api.External/Endpoints/Verification/VerificationEndpoints.cs b/backend/src/CCE.Api.External/Endpoints/Verification/VerificationEndpoints.cs new file mode 100644 index 00000000..ac2e135d --- /dev/null +++ b/backend/src/CCE.Api.External/Endpoints/Verification/VerificationEndpoints.cs @@ -0,0 +1,41 @@ +using CCE.Api.Common.Extensions; +using CCE.Application.Verification.Commands.RequestVerification; +using CCE.Application.Verification.Commands.VerifyOtp; +using MediatR; + +namespace CCE.Api.External.Endpoints.Verification; + +public static class VerificationEndpoints +{ + public static IEndpointRouteBuilder MapVerificationEndpoints(this IEndpointRouteBuilder app) + { + var verification = app.MapGroup("/verification").WithTags("Verification"); + + verification.MapPost("/request", async ( + RequestVerificationRequest req, + ISender sender, + CancellationToken ct) => + { + var cmd = new RequestVerificationCommand( + req.Token, req.ProviderName, req.Contact, req.TypeId); + var result = await sender.Send(cmd, ct).ConfigureAwait(false); + return result.ToHttpResult(); + }) + .AllowAnonymous() + .WithName("RequestVerification"); + + verification.MapPost("/verify", async ( + VerifyOtpRequest req, + ISender sender, + CancellationToken ct) => + { + var cmd = new VerifyOtpCommand(req.VerificationId, req.Code); + var result = await sender.Send(cmd, ct).ConfigureAwait(false); + return result.ToHttpResult(); + }) + .AllowAnonymous() + .WithName("VerifyOtp"); + + return app; + } +} diff --git a/backend/src/CCE.Api.External/Endpoints/Verification/VerifyOtpRequest.cs b/backend/src/CCE.Api.External/Endpoints/Verification/VerifyOtpRequest.cs new file mode 100644 index 00000000..a251f04a --- /dev/null +++ b/backend/src/CCE.Api.External/Endpoints/Verification/VerifyOtpRequest.cs @@ -0,0 +1,3 @@ +namespace CCE.Api.External.Endpoints.Verification; + +public sealed record VerifyOtpRequest(Guid VerificationId, string Code); diff --git a/backend/src/CCE.Api.External/Hubs/SubClaimUserIdProvider.cs b/backend/src/CCE.Api.External/Hubs/SubClaimUserIdProvider.cs new file mode 100644 index 00000000..143f2be6 --- /dev/null +++ b/backend/src/CCE.Api.External/Hubs/SubClaimUserIdProvider.cs @@ -0,0 +1,17 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.SignalR; + +namespace CCE.Api.External.Hubs; + +/// +/// Routes SignalR messages by the JWT sub claim so that +/// Clients.User(userId) matches the CCE user identifier. +/// +public sealed class SubClaimUserIdProvider : IUserIdProvider +{ + public string? GetUserId(HubConnectionContext connection) + { + return connection.User?.FindFirstValue("sub") + ?? connection.User?.FindFirstValue(ClaimTypes.NameIdentifier); + } +} diff --git a/backend/src/CCE.Api.External/Program.cs b/backend/src/CCE.Api.External/Program.cs index ed173e4b..175570ef 100644 --- a/backend/src/CCE.Api.External/Program.cs +++ b/backend/src/CCE.Api.External/Program.cs @@ -8,12 +8,16 @@ using CCE.Api.Common.OpenApi; using CCE.Api.Common.RateLimiting; using CCE.Api.External.Endpoints; +using CCE.Api.External.Endpoints.Verification; +using CCE.Api.External.Hubs; using CCE.Application; +using CCE.Infrastructure.Notifications; using CCE.Application.Common.CountryScope; using CCE.Application.Common.Interfaces; using CCE.Application.Health; using CCE.Infrastructure; using MediatR; +using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.DependencyInjection.Extensions; using Serilog; using System.Globalization; @@ -40,15 +44,18 @@ .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) + .AddCceOpenTelemetry(builder.Configuration, "CCE.Api.External") .AddCceOpenApi("CCE External API"); builder.Services.AddHttpContextAccessor(); builder.Services.Replace(ServiceDescriptor.Scoped()); builder.Services.Replace(ServiceDescriptor.Scoped()); +builder.Services.Replace(ServiceDescriptor.Singleton()); +builder.Services.AddSignalR().AddJsonProtocol(); var app = builder.Build(); @@ -64,6 +71,7 @@ app.UseCceUserSync(); app.UseCcePrometheus(); app.UseMiddleware(); +app.UseStaticFiles(); app.UseCceOpenApi(apiTag: "external"); @@ -81,10 +89,13 @@ // deployments leave the flag false → endpoints are never mounted. if (builder.Configuration.GetValue("Auth:DevMode")) { - app.MapDevAuthEndpoints(); + app.MapDevAuthEndpoints(); } +app.MapHub("/hubs/notifications"); + app.MapProfileEndpoints(); +app.MapAuthEndpoints(CCE.Application.Identity.Auth.Common.LocalAuthApi.External); app.MapNotificationsEndpoints(); app.MapNewsPublicEndpoints(); app.MapEventsPublicEndpoints(); @@ -102,6 +113,11 @@ app.MapAssistantEndpoints(); app.MapKapsarcEndpoints(); app.MapSurveysEndpoints(); +app.MapHomepageSettingsPublicEndpoints(); +app.MapAboutSettingsPublicEndpoints(); +app.MapPoliciesSettingsPublicEndpoints(); +app.MapMediaPublicEndpoints(); +app.MapVerificationEndpoints(); app.MapGet("/health", async (IMediator mediator) => { 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 5fda9641..e574b41e 100644 --- a/backend/src/CCE.Api.External/appsettings.Development.json +++ b/backend/src/CCE.Api.External/appsettings.Development.json @@ -6,8 +6,8 @@ } }, "Infrastructure": { - "SqlConnectionString": "Server=localhost,1433;Database=CCE;User Id=sa;Password=Strong!Passw0rd;TrustServerCertificate=true;", - "RedisConnectionString": "localhost:6379", + "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 @@ -47,8 +47,24 @@ "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", + "Provider": "gateway", "Host": "localhost", "Port": 1025, "FromAddress": "no-reply@cce.local", @@ -56,5 +72,24 @@ "Username": "", "Password": "", "EnableSsl": false + }, + "ExternalApis": { + "CommunicationGateway": { + "BaseUrl": "http://localhost:3001", + "TimeoutSeconds": 30 + }, + "AdminAuthGateway": { + "BaseUrl": "http://localhost:3001", + "TimeoutSeconds": 30 + } + }, + "Media": { + "BaseUrl": "https://cce-external-api.runasp.net/media/" + }, + "Seq": { + "ServerUrl": "http://localhost:5341" + }, + "Otp": { + "HmacSecret": "3ahs3DvW/rdx+InzjOCpqSUDSFuvyF59sPjziVdeIhE=" } } 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..23b9acc6 --- /dev/null +++ b/backend/src/CCE.Api.External/appsettings.Production.json @@ -0,0 +1,87 @@ +{ + "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", + "MediaUploadsRoot": "./wwwroot/media/", + "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": "gateway", + "Host": "localhost", + "Port": 1025, + "FromAddress": "no-reply@cce.local", + "FromName": "CCE Knowledge Center", + "Username": "", + "Password": "", + "EnableSsl": false + }, + "ExternalApis": { + "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.External/appsettings.json b/backend/src/CCE.Api.External/appsettings.json index a130f656..87727e46 100644 --- a/backend/src/CCE.Api.External/appsettings.json +++ b/backend/src/CCE.Api.External/appsettings.json @@ -30,5 +30,51 @@ "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 + }, + "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" + ] + }, + "Seq": { + "ServerUrl": "", + "ApiKey": "", + "OtlpEndpoint": "http://localhost:5341/ingest/otlp", + "EnableTracing": true + }, + "Otp": { + "HmacSecret": "replace-with-32-byte-base64-hmac-secret" } } 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.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 00000000..0c57e104 Binary files /dev/null and b/backend/src/CCE.Api.External/wwwroot/media/uploads/2026/05/50eb086f886f4e34bf0b79406e7cbf48.jpg differ diff --git a/backend/src/CCE.Api.External/wwwroot/media/uploads/2026/05/c6b80dc4d4e24f08be7686ee11043b5e.png b/backend/src/CCE.Api.External/wwwroot/media/uploads/2026/05/c6b80dc4d4e24f08be7686ee11043b5e.png new file mode 100644 index 00000000..65af187c Binary files /dev/null and b/backend/src/CCE.Api.External/wwwroot/media/uploads/2026/05/c6b80dc4d4e24f08be7686ee11043b5e.png differ diff --git a/backend/src/CCE.Api.External/wwwroot/media/uploads/2026/05/c792a2fe9fb54640a38d5841c7f0b12b.jpg b/backend/src/CCE.Api.External/wwwroot/media/uploads/2026/05/c792a2fe9fb54640a38d5841c7f0b12b.jpg new file mode 100644 index 00000000..0c57e104 Binary files /dev/null and b/backend/src/CCE.Api.External/wwwroot/media/uploads/2026/05/c792a2fe9fb54640a38d5841c7f0b12b.jpg differ diff --git a/backend/src/CCE.Api.External/wwwroot/media/uploads/2026/05/f1d4295616ab4cb98cef641508ff96c6.jpg b/backend/src/CCE.Api.External/wwwroot/media/uploads/2026/05/f1d4295616ab4cb98cef641508ff96c6.jpg new file mode 100644 index 00000000..0c57e104 Binary files /dev/null and b/backend/src/CCE.Api.External/wwwroot/media/uploads/2026/05/f1d4295616ab4cb98cef641508ff96c6.jpg differ diff --git a/backend/src/CCE.Api.External/wwwroot/media/uploads/2026/05/f9339b9b8e5c45c49014259f22da6ca0.jpg b/backend/src/CCE.Api.External/wwwroot/media/uploads/2026/05/f9339b9b8e5c45c49014259f22da6ca0.jpg new file mode 100644 index 00000000..0c57e104 Binary files /dev/null and b/backend/src/CCE.Api.External/wwwroot/media/uploads/2026/05/f9339b9b8e5c45c49014259f22da6ca0.jpg differ 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/Endpoints/AboutSettingsEndpoints.cs b/backend/src/CCE.Api.Internal/Endpoints/AboutSettingsEndpoints.cs new file mode 100644 index 00000000..a96ebc73 --- /dev/null +++ b/backend/src/CCE.Api.Internal/Endpoints/AboutSettingsEndpoints.cs @@ -0,0 +1,149 @@ +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 cmd = new UpdateAboutSettingsCommand( + body.DescriptionAr, body.DescriptionEn, + body.HowToUseVideoUrl); + 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); + +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/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/Endpoints/CreateNotificationTemplateRequest.cs b/backend/src/CCE.Api.Internal/Endpoints/CreateNotificationTemplateRequest.cs new file mode 100644 index 00000000..949959b4 --- /dev/null +++ b/backend/src/CCE.Api.Internal/Endpoints/CreateNotificationTemplateRequest.cs @@ -0,0 +1,12 @@ +using CCE.Domain.Notifications; + +namespace CCE.Api.Internal.Endpoints; + +public sealed record CreateNotificationTemplateRequest( + string Code, + string SubjectAr, + string SubjectEn, + string BodyAr, + string BodyEn, + NotificationChannel Channel, + string VariableSchemaJson); diff --git a/backend/src/CCE.Api.Internal/Endpoints/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/HomepageSettingsEndpoints.cs b/backend/src/CCE.Api.Internal/Endpoints/HomepageSettingsEndpoints.cs new file mode 100644 index 00000000..439b1047 --- /dev/null +++ b/backend/src/CCE.Api.Internal/Endpoints/HomepageSettingsEndpoints.cs @@ -0,0 +1,52 @@ +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 cmd = new UpdateHomepageSettingsCommand( + body.VideoUrl, + body.ObjectiveAr, + body.ObjectiveEn, + body.CceConceptsAr, + body.CceConceptsEn, + body.ParticipatingCountryIds); + 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); diff --git a/backend/src/CCE.Api.Internal/Endpoints/IdentityEndpoints.cs b/backend/src/CCE.Api.Internal/Endpoints/IdentityEndpoints.cs index a85e5c3f..dc805168 100644 --- a/backend/src/CCE.Api.Internal/Endpoints/IdentityEndpoints.cs +++ b/backend/src/CCE.Api.Internal/Endpoints/IdentityEndpoints.cs @@ -1,5 +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; @@ -33,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"); @@ -42,24 +46,59 @@ 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"); + 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, 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"); + 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 @@ -98,8 +137,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 +147,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 +157,13 @@ public static IEndpointRouteBuilder MapIdentityEndpoints(this IEndpointRouteBuil } } -/// Body shape for PUT /api/admin/users/{id}/roles. -public sealed record AssignUserRolesRequest(IReadOnlyList? Roles); +public sealed record ChangeUserStatusRequest(bool IsActive); -/// Body shape for POST /api/admin/state-rep-assignments. -public sealed record CreateStateRepAssignmentRequest(System.Guid UserId, System.Guid CountryId); +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.Api.Internal/Endpoints/MediaEndpoints.cs b/backend/src/CCE.Api.Internal/Endpoints/MediaEndpoints.cs new file mode 100644 index 00000000..69394f70 --- /dev/null +++ b/backend/src/CCE.Api.Internal/Endpoints/MediaEndpoints.cs @@ -0,0 +1,98 @@ +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 CCE.Domain; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; + +namespace CCE.Api.Internal.Endpoints; + +public static class MediaEndpoints +{ + public static IEndpointRouteBuilder MapMediaEndpoints(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(Permissions.Resource_Center_Upload) + .DisableAntiforgery() + .WithName("UploadMediaInternal"); + + 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(Permissions.Resource_Center_Upload) + .WithName("GetMediaInternal"); + + 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(Permissions.Resource_Center_Upload) + .WithName("UpdateMediaMetadataInternal"); + + 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(Permissions.Resource_Center_Upload) + .WithName("DownloadMediaInternal"); + + 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(Permissions.Resource_Center_Upload) + .WithName("DeleteMediaInternal"); + + return app; + } +} diff --git a/backend/src/CCE.Api.Internal/Endpoints/NotificationLogEndpoints.cs b/backend/src/CCE.Api.Internal/Endpoints/NotificationLogEndpoints.cs new file mode 100644 index 00000000..b28695fd --- /dev/null +++ b/backend/src/CCE.Api.Internal/Endpoints/NotificationLogEndpoints.cs @@ -0,0 +1,55 @@ +using CCE.Api.Common.Extensions; +using CCE.Application.Notifications.Admin.Commands.RetryNotificationLog; +using CCE.Application.Notifications.Admin.Queries.GetNotificationLogById; +using CCE.Application.Notifications.Admin.Queries.ListNotificationLogs; +using CCE.Domain; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace CCE.Api.Internal.Endpoints; + +public static class NotificationLogEndpoints +{ + public static IEndpointRouteBuilder MapNotificationLogEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/admin/notification-logs") + .WithTags("Notification Logs") + .RequireAuthorization(Permissions.Notification_LogView); + + group.MapGet("", async ( + int? page, int? pageSize, + Guid? recipientUserId, string? templateCode, int? channel, int? status, + IMediator mediator, CancellationToken ct) => + { + var query = new ListNotificationLogsQuery( + page ?? 1, + pageSize ?? 20, + recipientUserId, + templateCode, + channel is { } c ? (CCE.Domain.Notifications.NotificationChannel)c : null, + status is { } s ? (CCE.Domain.Notifications.NotificationDeliveryStatus)s : null); + var result = await mediator.Send(query, ct).ConfigureAwait(false); + return result.ToHttpResult(); + }).WithName("ListNotificationLogs"); + + group.MapGet("/{id:guid}", async ( + Guid id, + IMediator mediator, CancellationToken ct) => + { + var result = await mediator.Send(new GetNotificationLogByIdQuery(id), ct).ConfigureAwait(false); + return result.ToHttpResult(); + }).WithName("GetNotificationLogById"); + + group.MapPost("/{id:guid}/retry", async ( + Guid id, + IMediator mediator, CancellationToken ct) => + { + var result = await mediator.Send(new RetryNotificationLogCommand(id), ct).ConfigureAwait(false); + return result.ToHttpResult(); + }).WithName("RetryNotificationLog"); + + return app; + } +} diff --git a/backend/src/CCE.Api.Internal/Endpoints/NotificationTemplateEndpoints.cs b/backend/src/CCE.Api.Internal/Endpoints/NotificationTemplateEndpoints.cs index fc10a085..c4663dd0 100644 --- a/backend/src/CCE.Api.Internal/Endpoints/NotificationTemplateEndpoints.cs +++ b/backend/src/CCE.Api.Internal/Endpoints/NotificationTemplateEndpoints.cs @@ -1,3 +1,4 @@ +using CCE.Api.Common.Extensions; using CCE.Application.Notifications.Commands.CreateNotificationTemplate; using CCE.Application.Notifications.Commands.UpdateNotificationTemplate; using CCE.Application.Notifications.Queries.GetNotificationTemplateById; @@ -28,7 +29,7 @@ public static IEndpointRouteBuilder MapNotificationTemplateEndpoints(this IEndpo Channel: channel, IsActive: isActive); var result = await mediator.Send(query, cancellationToken).ConfigureAwait(false); - return Results.Ok(result); + return result.ToHttpResult(); }) .RequireAuthorization(Permissions.Notification_TemplateManage) .WithName("ListNotificationTemplates"); @@ -37,8 +38,8 @@ public static IEndpointRouteBuilder MapNotificationTemplateEndpoints(this IEndpo System.Guid id, IMediator mediator, CancellationToken cancellationToken) => { - var dto = await mediator.Send(new GetNotificationTemplateByIdQuery(id), cancellationToken).ConfigureAwait(false); - return dto is null ? Results.NotFound() : Results.Ok(dto); + var result = await mediator.Send(new GetNotificationTemplateByIdQuery(id), cancellationToken).ConfigureAwait(false); + return result.ToHttpResult(); }) .RequireAuthorization(Permissions.Notification_TemplateManage) .WithName("GetNotificationTemplateById"); @@ -53,8 +54,8 @@ public static IEndpointRouteBuilder MapNotificationTemplateEndpoints(this IEndpo body.BodyAr, body.BodyEn, body.Channel, body.VariableSchemaJson); - var dto = await mediator.Send(cmd, cancellationToken).ConfigureAwait(false); - return Results.Created($"/api/admin/notification-templates/{dto.Id}", dto); + var result = await mediator.Send(cmd, cancellationToken).ConfigureAwait(false); + return result.ToCreatedHttpResult(); }) .RequireAuthorization(Permissions.Notification_TemplateManage) .WithName("CreateNotificationTemplate"); @@ -69,8 +70,8 @@ public static IEndpointRouteBuilder MapNotificationTemplateEndpoints(this IEndpo body.SubjectAr, body.SubjectEn, body.BodyAr, body.BodyEn, body.IsActive); - var dto = await mediator.Send(cmd, cancellationToken).ConfigureAwait(false); - return dto is null ? Results.NotFound() : Results.Ok(dto); + var result = await mediator.Send(cmd, cancellationToken).ConfigureAwait(false); + return result.ToHttpResult(); }) .RequireAuthorization(Permissions.Notification_TemplateManage) .WithName("UpdateNotificationTemplate"); @@ -78,19 +79,3 @@ public static IEndpointRouteBuilder MapNotificationTemplateEndpoints(this IEndpo return app; } } - -public sealed record CreateNotificationTemplateRequest( - string Code, - string SubjectAr, - string SubjectEn, - string BodyAr, - string BodyEn, - CCE.Domain.Notifications.NotificationChannel Channel, - string VariableSchemaJson); - -public sealed record UpdateNotificationTemplateRequest( - string SubjectAr, - string SubjectEn, - string BodyAr, - string BodyEn, - bool IsActive); diff --git a/backend/src/CCE.Api.Internal/Endpoints/PoliciesSettingsEndpoints.cs b/backend/src/CCE.Api.Internal/Endpoints/PoliciesSettingsEndpoints.cs new file mode 100644 index 00000000..ee352dd5 --- /dev/null +++ b/backend/src/CCE.Api.Internal/Endpoints/PoliciesSettingsEndpoints.cs @@ -0,0 +1,94 @@ +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.ReorderPolicySection; +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.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.MapPut("/sections/{id:guid}/order", async ( + System.Guid id, + ReorderPolicySectionRequest body, + IMediator mediator, CancellationToken ct) => + { + var cmd = new ReorderPolicySectionCommand(id, body.OrderIndex); + var result = await mediator.Send(cmd, ct).ConfigureAwait(false); + return result.ToHttpResult(); + }) + .RequireAuthorization(Permissions.Page_PolicyEdit) + .WithName("ReorderPolicySection"); + + 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 CreatePolicySectionRequest( + int Type, + string TitleAr, + string TitleEn, + string ContentAr, + string ContentEn); + +public sealed record UpdatePolicySectionRequest( + string TitleAr, + string TitleEn, + string ContentAr, + string ContentEn); + +public sealed record ReorderPolicySectionRequest(int OrderIndex); diff --git a/backend/src/CCE.Api.Internal/Endpoints/UpdateNotificationTemplateRequest.cs b/backend/src/CCE.Api.Internal/Endpoints/UpdateNotificationTemplateRequest.cs new file mode 100644 index 00000000..6e4120b5 --- /dev/null +++ b/backend/src/CCE.Api.Internal/Endpoints/UpdateNotificationTemplateRequest.cs @@ -0,0 +1,8 @@ +namespace CCE.Api.Internal.Endpoints; + +public sealed record UpdateNotificationTemplateRequest( + string SubjectAr, + string SubjectEn, + string BodyAr, + string BodyEn, + bool IsActive); diff --git a/backend/src/CCE.Api.Internal/Program.cs b/backend/src/CCE.Api.Internal/Program.cs index 05259a12..0f4a848e 100644 --- a/backend/src/CCE.Api.Internal/Program.cs +++ b/backend/src/CCE.Api.Internal/Program.cs @@ -17,25 +17,33 @@ 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) + .AddCceOpenTelemetry(builder.Configuration, "CCE.Api.Internal") .AddCceRateLimiter(builder.Configuration) .AddCceOpenApi("CCE Internal API"); builder.Services.AddHttpContextAccessor(); builder.Services.Replace(ServiceDescriptor.Scoped()); builder.Services.Replace(ServiceDescriptor.Scoped()); +builder.Services.AddSignalR().AddJsonProtocol(); var app = builder.Build(); @@ -50,10 +58,13 @@ app.UseRateLimiter(); app.UseCcePrometheus(); app.UseMiddleware(); +app.UseStaticFiles(); app.UseCceOpenApi(apiTag: "internal"); -app.MapIdentityEndpoints(); + app.MapAuthEndpoints(CCE.Application.Identity.Auth.Common.LocalAuthApi.Internal); + app.MapAdminAuthEndpoints(); + app.MapIdentityEndpoints(); app.MapExpertEndpoints(); app.MapAssetEndpoints(); app.MapResourceEndpoints(); @@ -68,8 +79,13 @@ app.MapTopicEndpoints(); app.MapCommunityModerationEndpoints(); app.MapNotificationTemplateEndpoints(); +app.MapNotificationLogEndpoints(); app.MapReportEndpoints(); app.MapAuditEndpoints(); +app.MapHomepageSettingsEndpoints(); +app.MapAboutSettingsEndpoints(); +app.MapPoliciesSettingsEndpoints(); +app.MapMediaEndpoints(); // 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.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 d0dd31be..9bb007af 100644 --- a/backend/src/CCE.Api.Internal/appsettings.Development.json +++ b/backend/src/CCE.Api.Internal/appsettings.Development.json @@ -6,8 +6,8 @@ } }, "Infrastructure": { - "SqlConnectionString": "Server=localhost,1433;Database=CCE;User Id=sa;Password=Strong!Passw0rd;TrustServerCertificate=true;", - "RedisConnectionString": "localhost:6379", + "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 @@ -34,8 +34,28 @@ "GraphTenantDomain": "cce.local", "CallbackPath": "/signin-oidc" }, + "Messaging": { + "Transport": "InMemory", + "UseAsyncDispatcher": true + }, + "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", + "Provider": "gateway", "Host": "localhost", "Port": 1025, "FromAddress": "no-reply@cce.local", @@ -43,5 +63,24 @@ "Username": "", "Password": "", "EnableSsl": false + }, + "ExternalApis": { + "CommunicationGateway": { + "BaseUrl": "http://localhost:3001", + "TimeoutSeconds": 30 + }, + "AdminAuthGateway": { + "BaseUrl": "http://localhost:3001", + "TimeoutSeconds": 30 + } + }, + "Media": { + "BaseUrl": "https://cce-internal-api.runasp.net/media/" + }, + "Seq": { + "ServerUrl": "http://localhost:5341" + }, + "Otp": { + "HmacSecret": "3ahs3DvW/rdx+InzjOCpqSUDSFuvyF59sPjziVdeIhE=" } } 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..7aceb167 --- /dev/null +++ b/backend/src/CCE.Api.Internal/appsettings.Production.json @@ -0,0 +1,74 @@ +{ + "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/", + "MediaUploadsRoot": "./wwwroot/media/", + "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": "gateway", + "Host": "localhost", + "Port": 1025, + "FromAddress": "no-reply@cce.local", + "FromName": "CCE Knowledge Center", + "Username": "", + "Password": "", + "EnableSsl": false + }, + "ExternalApis": { + "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/appsettings.json b/backend/src/CCE.Api.Internal/appsettings.json index a130f656..7483b509 100644 --- a/backend/src/CCE.Api.Internal/appsettings.json +++ b/backend/src/CCE.Api.Internal/appsettings.json @@ -30,5 +30,51 @@ "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 + }, + "Media": { + "BaseUrl": "https://cce-internal-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" + ] + }, + "Seq": { + "ServerUrl": "", + "ApiKey": "", + "OtlpEndpoint": "http://localhost:5341/ingest/otlp", + "EnableTracing": true + }, + "Otp": { + "HmacSecret": "replace-with-32-byte-base64-hmac-secret" } } 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.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/ResponseValidationBehavior.cs b/backend/src/CCE.Application/Common/Behaviors/ResponseValidationBehavior.cs new file mode 100644 index 00000000..920459b4 --- /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.GetString(domainKey); + return new FieldError( + ToCamelCase(f.PropertyName), + valCode, + msg); + }).ToList(); + + var headerDomainKey = "VALIDATION_ERROR"; + var headerCode = SystemCodeMap.ToSystemCode(headerDomainKey); + var headerMsg = _l.GetString(headerDomainKey); + + var failMethod = responseType.GetMethod("Fail", + new[] { typeof(string), typeof(string), typeof(MessageType), typeof(IReadOnlyList) }); + + return (TResponse)failMethod!.Invoke(null, new object[] + { + headerCode, + headerMsg, + 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/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/FieldError.cs b/backend/src/CCE.Application/Common/FieldError.cs new file mode 100644 index 00000000..b5448d19 --- /dev/null +++ b/backend/src/CCE.Application/Common/FieldError.cs @@ -0,0 +1,6 @@ +namespace CCE.Application.Common; + +public sealed record FieldError( + string Field, + string Code, + string Message); diff --git a/backend/src/CCE.Application/Common/Interfaces/ICceDbContext.cs b/backend/src/CCE.Application/Common/Interfaces/ICceDbContext.cs index 25cae575..9e288bd6 100644 --- a/backend/src/CCE.Application/Common/Interfaces/ICceDbContext.cs +++ b/backend/src/CCE.Application/Common/Interfaces/ICceDbContext.cs @@ -5,8 +5,11 @@ using CCE.Domain.Identity; using CCE.Domain.InteractiveCity; using CCE.Domain.KnowledgeMaps; +using CCE.Domain.Media; using CCE.Domain.Notifications; +using CCE.Domain.PlatformSettings; using CCE.Domain.Surveys; +using CCE.Domain.Verification; using Microsoft.AspNetCore.Identity; using DomainCountry = CCE.Domain.Country; @@ -29,6 +32,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; } @@ -48,6 +52,8 @@ public interface ICceDbContext IQueryable PostFollows { get; } IQueryable NotificationTemplates { get; } IQueryable UserNotifications { get; } + IQueryable NotificationLogs { get; } + IQueryable UserNotificationSettings { get; } IQueryable ServiceRatings { get; } IQueryable AuditEvents { get; } IQueryable KnowledgeMaps { get; } @@ -57,6 +63,28 @@ 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; } + + // ─── Verification ─── + IQueryable OtpVerifications { get; } + IQueryable UserVerifications { get; } + + // ─── Media ─── + IQueryable MediaFiles { get; } + + // Write operations + void Add(T entity) where T : class; + void Attach(T entity) where T : class; + void Delete(T entity) where T : class; + void DeleteRange(System.Collections.Generic.IEnumerable entities) where T : class; + + void SetExpectedRowVersion(T entity, byte[] expectedRowVersion) where T : class; Task SaveChangesAsync(CancellationToken cancellationToken = default); } 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.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/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/Response.cs b/backend/src/CCE.Application/Common/Response.cs new file mode 100644 index 00000000..05802b6f --- /dev/null +++ b/backend/src/CCE.Application/Common/Response.cs @@ -0,0 +1,84 @@ +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. +/// 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 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; + [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, string 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, string message) => new() + { + Success = true, + Code = code, + Message = message, + Data = VoidData.Instance, + Type = MessageType.Success, + }; + + // ─── Failure Factories ─── + + public static Response Fail(string code, string message, MessageType type) => new() + { + Success = false, + Code = code, + Message = message, + Type = type, + }; + + public static Response Fail( + string code, string 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, string message) + => Response.Ok(code, message); + + public static Response Fail(string code, string message, MessageType type) + => Response.Fail(code, message, type); +} 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/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/Community/ICommunityReadService.cs b/backend/src/CCE.Application/Community/ICommunityReadService.cs new file mode 100644 index 00000000..81b8eac0 --- /dev/null +++ b/backend/src/CCE.Application/Community/ICommunityReadService.cs @@ -0,0 +1,13 @@ +namespace CCE.Application.Community; + +public interface ICommunityReadService +{ + /// + /// Returns distinct user IDs who follow the given topic, + /// optionally excluding a specific user (e.g., the author). + /// + Task> GetTopicFollowerIdsAsync( + System.Guid topicId, + System.Guid? excludeUserId, + CancellationToken ct); +} diff --git a/backend/src/CCE.Application/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/DependencyInjection.cs b/backend/src/CCE.Application/DependencyInjection.cs index d5f9b323..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; @@ -15,13 +16,17 @@ 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(ResponseValidationBehavior<,>)); + cfg.AddOpenBehavior(typeof(ResultValidationBehavior<,>)); cfg.AddOpenBehavior(typeof(ValidationBehavior<,>)); }); services.AddValidatorsFromAssembly(assembly); + services.AddScoped(); + 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/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.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/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/IAuthService.cs b/backend/src/CCE.Application/Identity/Auth/Common/IAuthService.cs new file mode 100644 index 00000000..125d429b --- /dev/null +++ b/backend/src/CCE.Application/Identity/Auth/Common/IAuthService.cs @@ -0,0 +1,26 @@ +using CCE.Domain.Identity; + +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); + + 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 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); + + Task AdLoginAsync(string username, string password, string? ip, string? userAgent, CancellationToken ct); +} 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/IRefreshTokenRepository.cs b/backend/src/CCE.Application/Identity/Auth/Common/IRefreshTokenRepository.cs new file mode 100644 index 00000000..4d730c17 --- /dev/null +++ b/backend/src/CCE.Application/Identity/Auth/Common/IRefreshTokenRepository.cs @@ -0,0 +1,14 @@ +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); +} 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..f53fc55a --- /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..aac5f29f --- /dev/null +++ b/backend/src/CCE.Application/Identity/Auth/ForgotPassword/ForgotPasswordCommandHandler.cs @@ -0,0 +1,25 @@ +using CCE.Application.Common; +using CCE.Application.Identity.Auth.Common; +using CCE.Application.Messages; +using MediatR; + +namespace CCE.Application.Identity.Auth.ForgotPassword; + +internal sealed class ForgotPasswordCommandHandler + : IRequestHandler> +{ + private readonly IAuthService _auth; + private readonly MessageFactory _msg; + + public ForgotPasswordCommandHandler(IAuthService auth, MessageFactory msg) + { + _auth = auth; + _msg = msg; + } + + public async Task> Handle(ForgotPasswordCommand request, CancellationToken ct) + { + 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/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..1286d4d1 --- /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..045687a6 --- /dev/null +++ b/backend/src/CCE.Application/Identity/Auth/Login/LoginCommandHandler.cs @@ -0,0 +1,27 @@ +using CCE.Application.Common; +using CCE.Application.Identity.Auth.Common; +using CCE.Application.Messages; +using MediatR; + +namespace CCE.Application.Identity.Auth.Login; + +internal sealed class LoginCommandHandler + : IRequestHandler> +{ + private readonly IAuthService _auth; + private readonly MessageFactory _msg; + + public LoginCommandHandler(IAuthService auth, MessageFactory msg) + { + _auth = auth; + _msg = msg; + } + + public async Task> Handle(LoginCommand request, CancellationToken ct) + { + 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/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..d1d1004b --- /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..daa72103 --- /dev/null +++ b/backend/src/CCE.Application/Identity/Auth/Logout/LogoutCommandHandler.cs @@ -0,0 +1,25 @@ +using CCE.Application.Common; +using CCE.Application.Identity.Auth.Common; +using CCE.Application.Messages; +using MediatR; + +namespace CCE.Application.Identity.Auth.Logout; + +internal sealed class LogoutCommandHandler + : IRequestHandler> +{ + private readonly IAuthService _auth; + private readonly MessageFactory _msg; + + public LogoutCommandHandler(IAuthService auth, MessageFactory msg) + { + _auth = auth; + _msg = msg; + } + + public async Task> Handle(LogoutCommand request, CancellationToken ct) + { + 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/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..493e7a96 --- /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..fbcde08e --- /dev/null +++ b/backend/src/CCE.Application/Identity/Auth/RefreshToken/RefreshTokenCommandHandler.cs @@ -0,0 +1,27 @@ +using CCE.Application.Common; +using CCE.Application.Identity.Auth.Common; +using CCE.Application.Messages; +using MediatR; + +namespace CCE.Application.Identity.Auth.RefreshToken; + +internal sealed class RefreshTokenCommandHandler + : IRequestHandler> +{ + private readonly IAuthService _auth; + private readonly MessageFactory _msg; + + public RefreshTokenCommandHandler(IAuthService auth, MessageFactory msg) + { + _auth = auth; + _msg = msg; + } + + public async Task> Handle(RefreshTokenCommand request, CancellationToken ct) + { + 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/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..8f2eba21 --- /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..acc32cb5 --- /dev/null +++ b/backend/src/CCE.Application/Identity/Auth/Register/RegisterUserCommandHandler.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.Register; + +internal sealed class RegisterUserCommandHandler + : IRequestHandler> +{ + private readonly IAuthService _auth; + private readonly MessageFactory _msg; + + public RegisterUserCommandHandler(IAuthService auth, MessageFactory msg) + { + _auth = auth; + _msg = msg; + } + + public async Task> Handle(RegisterUserCommand request, CancellationToken ct) + { + 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"); + } +} 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..05c40a72 --- /dev/null +++ b/backend/src/CCE.Application/Identity/Auth/Register/RegisterUserCommandValidator.cs @@ -0,0 +1,30 @@ +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).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) + && 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..b0e36572 --- /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..8219f4f0 --- /dev/null +++ b/backend/src/CCE.Application/Identity/Auth/ResetPassword/ResetPasswordCommandHandler.cs @@ -0,0 +1,37 @@ +using CCE.Application.Common; +using CCE.Application.Identity.Auth.Common; +using CCE.Application.Messages; +using MediatR; + +namespace CCE.Application.Identity.Auth.ResetPassword; + +internal sealed class ResetPasswordCommandHandler + : IRequestHandler> +{ + private readonly IAuthService _auth; + private readonly MessageFactory _msg; + + public ResetPasswordCommandHandler(IAuthService auth, MessageFactory msg) + { + _auth = auth; + _msg = msg; + } + + public async Task> Handle(ResetPasswordCommand request, CancellationToken ct) + { + var errorKey = await _auth.ResetPasswordAsync(request.EmailAddress, request.Token, + request.NewPassword, request.IpAddress, ct).ConfigureAwait(false); + + if (errorKey is not null) + { + return errorKey switch + { + "USER_NOT_FOUND" => _msg.UserNotFound(), + "INVALID_RESET_TOKEN" => _msg.Unauthorized("INVALID_RESET_TOKEN"), + _ => _msg.BusinessRule(errorKey), + }; + } + + return _msg.Ok(new AuthMessageDto("PASSWORD_RESET"), "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..ba9d49e1 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..76e2b555 100644 --- a/backend/src/CCE.Application/Identity/Commands/ApproveExpertRequest/ApproveExpertRequestCommandHandler.cs +++ b/backend/src/CCE.Application/Identity/Commands/ApproveExpertRequest/ApproveExpertRequestCommandHandler.cs @@ -1,7 +1,8 @@ +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.Application.Messages; using CCE.Domain.Common; using CCE.Domain.Identity; using MediatR; @@ -9,46 +10,53 @@ namespace CCE.Application.Identity.Commands.ApproveExpertRequest; public sealed class ApproveExpertRequestCommandHandler - : IRequestHandler + : IRequestHandler> { - private readonly IExpertWorkflowService _service; private readonly ICceDbContext _db; + private readonly IExpertWorkflowRepository _service; private readonly ICurrentUserAccessor _currentUser; private readonly ISystemClock _clock; + private readonly MessageFactory _msg; public ApproveExpertRequestCommandHandler( - IExpertWorkflowService service, ICceDbContext db, + IExpertWorkflowRepository service, ICurrentUserAccessor currentUser, - ISystemClock clock) + ISystemClock clock, + MessageFactory msg) { - _service = service; _db = db; + _service = service; _currentUser = currentUser; _clock = clock; + _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) { - throw new System.Collections.Generic.KeyNotFoundException($"Expert registration request {request.Id} not found."); + return _msg.NotFound("EXPERT_REQUEST_NOT_FOUND"); } - 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 _msg.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); + _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, @@ -58,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/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..c398206e 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..09e8a16d 100644 --- a/backend/src/CCE.Application/Identity/Commands/AssignUserRoles/AssignUserRolesCommandHandler.cs +++ b/backend/src/CCE.Application/Identity/Commands/AssignUserRoles/AssignUserRolesCommandHandler.cs @@ -1,27 +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 IUserRoleAssignmentService _service; + private readonly IUserRoleAssignmentRepository _service; private readonly IMediator _mediator; + private readonly MessageFactory _msg; - public AssignUserRolesCommandHandler(IUserRoleAssignmentService service, IMediator mediator) + public AssignUserRolesCommandHandler( + IUserRoleAssignmentRepository service, + IMediator mediator, + MessageFactory msg) { _service = service; _mediator = mediator; + _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 null; + return _msg.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.Success) + { + return result; + } + + return _msg.Ok(result.Data!, "ROLES_ASSIGNED"); } } 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/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/CreateStateRepAssignment/CreateStateRepAssignmentCommand.cs b/backend/src/CCE.Application/Identity/Commands/CreateStateRepAssignment/CreateStateRepAssignmentCommand.cs index bbb2caf5..d8e575eb 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..3dab4af0 100644 --- a/backend/src/CCE.Application/Identity/Commands/CreateStateRepAssignment/CreateStateRepAssignmentCommandHandler.cs +++ b/backend/src/CCE.Application/Identity/Commands/CreateStateRepAssignment/CreateStateRepAssignmentCommandHandler.cs @@ -1,6 +1,8 @@ +using CCE.Application.Common; 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; @@ -8,50 +10,54 @@ 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 MessageFactory _msg; public CreateStateRepAssignmentCommandHandler( ICceDbContext db, - IStateRepAssignmentService service, + IStateRepAssignmentRepository service, ICurrentUserAccessor currentUser, - ISystemClock clock) + ISystemClock clock, + MessageFactory msg) { _db = db; _service = service; _currentUser = currentUser; _clock = clock; + _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) { - throw new System.Collections.Generic.KeyNotFoundException($"User {request.UserId} not found."); + return _msg.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 _msg.NotFound("COUNTRY_NOT_FOUND"); } - 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 _msg.NotAuthenticated(); + } - var assignment = StateRepresentativeAssignment.Assign(request.UserId, request.CountryId, assignedById, _clock); - await _service.SaveAsync(assignment, cancellationToken).ConfigureAwait(false); + var assignment = StateRepresentativeAssignment.Assign(request.UserId, request.CountryId, assignedById.Value, _clock); + 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) @@ -59,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, @@ -68,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/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/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/Commands/RejectExpertRequest/RejectExpertRequestCommand.cs b/backend/src/CCE.Application/Identity/Commands/RejectExpertRequest/RejectExpertRequestCommand.cs index 9d0df6db..8147af0c 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..62448b45 100644 --- a/backend/src/CCE.Application/Identity/Commands/RejectExpertRequest/RejectExpertRequestCommandHandler.cs +++ b/backend/src/CCE.Application/Identity/Commands/RejectExpertRequest/RejectExpertRequestCommandHandler.cs @@ -1,52 +1,59 @@ +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.Application.Messages; using CCE.Domain.Common; using MediatR; namespace CCE.Application.Identity.Commands.RejectExpertRequest; public sealed class RejectExpertRequestCommandHandler - : IRequestHandler + : IRequestHandler> { - private readonly IExpertWorkflowService _service; private readonly ICceDbContext _db; + private readonly IExpertWorkflowRepository _service; private readonly ICurrentUserAccessor _currentUser; private readonly ISystemClock _clock; + private readonly MessageFactory _msg; public RejectExpertRequestCommandHandler( - IExpertWorkflowService service, ICceDbContext db, + IExpertWorkflowRepository service, ICurrentUserAccessor currentUser, - ISystemClock clock) + ISystemClock clock, + MessageFactory msg) { - _service = service; _db = db; + _service = service; _currentUser = currentUser; _clock = clock; + _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) { - throw new System.Collections.Generic.KeyNotFoundException($"Expert registration request {request.Id} not found."); + return _msg.NotFound("EXPERT_REQUEST_NOT_FOUND"); } - 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 _msg.NotAuthenticated(); + } - registration.Reject(rejectedById, request.RejectionReasonAr, request.RejectionReasonEn, _clock); - await _service.SaveAsync(registration, newProfile: null, cancellationToken).ConfigureAwait(false); + registration.Reject(rejectedById.Value, request.RejectionReasonAr, request.RejectionReasonEn, _clock); + 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, @@ -58,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/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..7d80970d 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..105afcf0 100644 --- a/backend/src/CCE.Application/Identity/Commands/RevokeStateRepAssignment/RevokeStateRepAssignmentCommandHandler.cs +++ b/backend/src/CCE.Application/Identity/Commands/RevokeStateRepAssignment/RevokeStateRepAssignmentCommandHandler.cs @@ -1,40 +1,51 @@ +using CCE.Application.Common; using CCE.Application.Common.Interfaces; -using CCE.Application.Identity; +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 IStateRepAssignmentService _service; + private readonly ICceDbContext _db; + private readonly IStateRepAssignmentRepository _service; private readonly ICurrentUserAccessor _currentUser; private readonly ISystemClock _clock; + private readonly MessageFactory _msg; public RevokeStateRepAssignmentCommandHandler( - IStateRepAssignmentService service, + ICceDbContext db, + IStateRepAssignmentRepository service, ICurrentUserAccessor currentUser, - ISystemClock clock) + ISystemClock clock, + MessageFactory msg) { + _db = db; _service = service; _currentUser = currentUser; _clock = clock; + _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) { - throw new System.Collections.Generic.KeyNotFoundException($"State-rep assignment {request.Id} not found."); + return _msg.NotFound("STATE_REP_ASSIGNMENT_NOT_FOUND"); } - 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 _msg.NotAuthenticated(); + } - assignment.Revoke(revokedById, _clock); - await _service.UpdateAsync(assignment, cancellationToken).ConfigureAwait(false); + assignment.Revoke(revokedById.Value, _clock); + _service.Update(assignment); + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - return Unit.Value; + 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 new file mode 100644 index 00000000..4e9c304d --- /dev/null +++ b/backend/src/CCE.Application/Identity/IExpertWorkflowRepository.cs @@ -0,0 +1,22 @@ +using CCE.Application.Common.Interfaces; +using CCE.Domain.Identity; + +namespace CCE.Application.Identity; + +/// +/// Persistence helper for the expert-registration workflow. +/// Tracking-only — handlers call ICceDbContext.SaveChangesAsync to commit. +/// +public interface IExpertWorkflowRepository : IRepository +{ + /// + /// Loads the request by Id, including soft-deleted rows. Returns null when missing. + /// + Task FindIncludingDeletedAsync(System.Guid id, CancellationToken ct); + + /// + /// Registers a new in the change tracker + /// (created as a side-effect of approving an expert request). + /// + void AddProfile(ExpertProfile profile); +} diff --git a/backend/src/CCE.Application/Identity/IExpertWorkflowService.cs b/backend/src/CCE.Application/Identity/IExpertWorkflowService.cs deleted file mode 100644 index 662dcc4d..00000000 --- a/backend/src/CCE.Application/Identity/IExpertWorkflowService.cs +++ /dev/null @@ -1,21 +0,0 @@ -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. -/// -public interface IExpertWorkflowService -{ - /// - /// Loads the request by Id, including soft-deleted rows. Returns null when missing. - /// - 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. - /// - Task SaveAsync(ExpertRegistrationRequest request, ExpertProfile? newProfile, CancellationToken ct); -} diff --git a/backend/src/CCE.Application/Identity/IStateRepAssignmentRepository.cs b/backend/src/CCE.Application/Identity/IStateRepAssignmentRepository.cs new file mode 100644 index 00000000..02c792a3 --- /dev/null +++ b/backend/src/CCE.Application/Identity/IStateRepAssignmentRepository.cs @@ -0,0 +1,15 @@ +using CCE.Application.Common.Interfaces; +using CCE.Domain.Identity; + +namespace CCE.Application.Identity; + +/// +/// Persists new aggregates and revokes existing ones. +/// +public interface IStateRepAssignmentRepository : IRepository +{ + /// + /// Loads the assignment by Id, including soft-deleted (revoked) rows. Returns null when missing. + /// + Task FindIncludingRevokedAsync(System.Guid id, CancellationToken ct); +} diff --git a/backend/src/CCE.Application/Identity/IStateRepAssignmentService.cs b/backend/src/CCE.Application/Identity/IStateRepAssignmentService.cs deleted file mode 100644 index ef6744b8..00000000 --- a/backend/src/CCE.Application/Identity/IStateRepAssignmentService.cs +++ /dev/null @@ -1,30 +0,0 @@ -using CCE.Domain.Identity; - -namespace CCE.Application.Identity; - -/// -/// Persists new aggregates and revokes existing ones. -/// Implemented in Infrastructure (writes via CceDbContext). -/// -public interface IStateRepAssignmentService -{ - /// - /// 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/IUserRepository.cs b/backend/src/CCE.Application/Identity/IUserRepository.cs new file mode 100644 index 00000000..2a9cbab3 --- /dev/null +++ b/backend/src/CCE.Application/Identity/IUserRepository.cs @@ -0,0 +1,9 @@ +using CCE.Domain.Verification; + +namespace CCE.Application.Identity; + +public interface IUserRepository +{ + Task FindUserIdByContactAsync(string contact, OtpVerificationType type, CancellationToken ct = default); + Task StampConfirmedAsync(Guid userId, OtpVerificationType type, CancellationToken ct = default); +} 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..46fabe98 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..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,4 +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; @@ -6,18 +9,26 @@ namespace CCE.Application.Identity.Public.Commands.SubmitExpertRequest; public sealed class SubmitExpertRequestCommandHandler - : IRequestHandler + : IRequestHandler> { - private readonly IExpertRequestSubmissionService _service; + private readonly ICceDbContext _db; + private readonly IExpertRequestSubmissionRepository _service; private readonly ISystemClock _clock; + private readonly MessageFactory _msg; - public SubmitExpertRequestCommandHandler(IExpertRequestSubmissionService 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, @@ -25,9 +36,10 @@ public async Task Handle(SubmitExpertRequestCommand requ 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, @@ -37,6 +49,6 @@ public async Task Handle(SubmitExpertRequestCommand requ entity.Status, entity.ProcessedOn, entity.RejectionReasonAr, - entity.RejectionReasonEn); + entity.RejectionReasonEn), "EXPERT_REQUEST_SUBMITTED"); } } 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..542635c0 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..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,23 +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 IUserProfileService _service; + private readonly ICceDbContext _db; + private readonly IUserProfileRepository _service; + private readonly MessageFactory _msg; - public UpdateMyProfileCommandHandler(IUserProfileService service) + public UpdateMyProfileCommandHandler(ICceDbContext db, IUserProfileRepository service, MessageFactory msg) { + _db = db; _service = service; + _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 null; + return _msg.UserNotFound(); } user.SetLocalePreference(request.LocalePreference); @@ -34,9 +41,10 @@ public UpdateMyProfileCommandHandler(IUserProfileService service) 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, @@ -44,6 +52,6 @@ public UpdateMyProfileCommandHandler(IUserProfileService service) user.KnowledgeLevel, user.Interests, user.CountryId, - user.AvatarUrl); + user.AvatarUrl), "PROFILE_UPDATED"); } } 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/IExpertRequestSubmissionRepository.cs b/backend/src/CCE.Application/Identity/Public/IExpertRequestSubmissionRepository.cs new file mode 100644 index 00000000..1968540a --- /dev/null +++ b/backend/src/CCE.Application/Identity/Public/IExpertRequestSubmissionRepository.cs @@ -0,0 +1,8 @@ +using CCE.Application.Common.Interfaces; +using CCE.Domain.Identity; + +namespace CCE.Application.Identity.Public; + +public interface IExpertRequestSubmissionRepository : IRepository +{ +} diff --git a/backend/src/CCE.Application/Identity/Public/IExpertRequestSubmissionService.cs b/backend/src/CCE.Application/Identity/Public/IExpertRequestSubmissionService.cs deleted file mode 100644 index 84eeb8a9..00000000 --- a/backend/src/CCE.Application/Identity/Public/IExpertRequestSubmissionService.cs +++ /dev/null @@ -1,8 +0,0 @@ -using CCE.Domain.Identity; - -namespace CCE.Application.Identity.Public; - -public interface IExpertRequestSubmissionService -{ - 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 61% rename from backend/src/CCE.Application/Identity/Public/IUserProfileService.cs rename to backend/src/CCE.Application/Identity/Public/IUserProfileRepository.cs index 7146370f..5d3f89e8 100644 --- a/backend/src/CCE.Application/Identity/Public/IUserProfileService.cs +++ b/backend/src/CCE.Application/Identity/Public/IUserProfileRepository.cs @@ -2,8 +2,8 @@ namespace CCE.Application.Identity.Public; -public interface IUserProfileService +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 f8f2e4f4..8fd2c7b8 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..1cf5ffe6 100644 --- a/backend/src/CCE.Application/Identity/Public/Queries/GetMyExpertStatus/GetMyExpertStatusQueryHandler.cs +++ b/backend/src/CCE.Application/Identity/Public/Queries/GetMyExpertStatus/GetMyExpertStatusQueryHandler.cs @@ -1,24 +1,27 @@ +using CCE.Application.Common; 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 MessageFactory _msg; - public GetMyExpertStatusQueryHandler(ICceDbContext db) + public GetMyExpertStatusQueryHandler(ICceDbContext db, MessageFactory msg) { _db = db; + _msg = msg; } - 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,10 +30,10 @@ public GetMyExpertStatusQueryHandler(ICceDbContext db) var entity = rows.FirstOrDefault(); if (entity is null) { - return null; + return _msg.NotFound("EXPERT_REQUEST_NOT_FOUND"); } - return new ExpertRequestStatusDto( + return _msg.Ok(new ExpertRequestStatusDto( entity.Id, entity.RequestedById, entity.RequestedBioAr, @@ -40,6 +43,6 @@ public GetMyExpertStatusQueryHandler(ICceDbContext db) 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 4c289dd6..50fa108c 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..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,26 +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 IUserProfileService _service; + private readonly IUserProfileRepository _service; + private readonly MessageFactory _msg; - public GetMyProfileQueryHandler(IUserProfileService service) + public GetMyProfileQueryHandler(IUserProfileRepository service, MessageFactory msg) { _service = service; + _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 null; + return _msg.UserNotFound(); } - return new UserProfileDto( + return _msg.Ok(new UserProfileDto( user.Id, user.Email, user.UserName, @@ -28,6 +32,6 @@ public GetMyProfileQueryHandler(IUserProfileService service) user.KnowledgeLevel, user.Interests, user.CountryId, - user.AvatarUrl); + user.AvatarUrl), "SUCCESS_OPERATION"); } } 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..0a8482e0 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..0f56dd39 100644 --- a/backend/src/CCE.Application/Identity/Queries/GetUserById/GetUserByIdQueryHandler.cs +++ b/backend/src/CCE.Application/Identity/Queries/GetUserById/GetUserByIdQueryHandler.cs @@ -1,48 +1,49 @@ +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.EntityFrameworkCore; namespace CCE.Application.Identity.Queries.GetUserById; -public sealed class GetUserByIdQueryHandler : IRequestHandler +public sealed class GetUserByIdQueryHandler : IRequestHandler> { private readonly ICceDbContext _db; + private readonly MessageFactory _msg; - public GetUserByIdQueryHandler(ICceDbContext db) + public GetUserByIdQueryHandler(ICceDbContext db, MessageFactory msg) { _db = db; + _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 null; - } + 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 = System.DateTimeOffset.UtcNow; - var isActive = !user.LockoutEnabled || user.LockoutEnd is null || user.LockoutEnd < now; - - return new UserDetailDto( - user.Id, - user.Email, - user.UserName, - user.LocalePreference, - user.KnowledgeLevel, - user.Interests, - user.CountryId, - user.AvatarUrl, - roles, - isActive); + return dto is null + ? _msg.UserNotFound() + : _msg.Ok(dto, "SUCCESS_OPERATION"); } } 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/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 2c2fb880..ef5bf635 100644 --- a/backend/src/CCE.Application/Identity/Queries/ListUsers/ListUsersQueryHandler.cs +++ b/backend/src/CCE.Application/Identity/Queries/ListUsers/ListUsersQueryHandler.cs @@ -1,22 +1,28 @@ +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) + public ListUsersQueryHandler(ICceDbContext db, MessageFactory msg) { _db = db; + _msg = msg; } - 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)) { @@ -28,45 +34,32 @@ public async Task> Handle(ListUsersQuery request, C if (!string.IsNullOrWhiteSpace(request.Role)) { - var roleName = 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 - select u; + var role = request.Role.Trim(); + // 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 page = await query.ToPagedResultAsync(request.Page, request.PageSize, cancellationToken).ConfigureAwait(false); - - if (page.Items.Count == 0) - { - return new PagedResult(System.Array.Empty(), page.Page, page.PageSize, page.Total); - } - - var userIds = page.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 = System.DateTimeOffset.UtcNow; - var items = page.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 : System.Array.Empty(), - !u.LockoutEnabled || u.LockoutEnd is null || u.LockoutEnd < now)).ToList(); + _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 new PagedResult(items, page.Page, page.PageSize, page.Total); - } + var paged = await projected + .ToPagedResultAsync(request.Page, request.PageSize, cancellationToken) + .ConfigureAwait(false); - private sealed record RoleAssignmentRow(System.Guid UserId, string RoleName); + return _msg.Ok(paged, "ITEMS_LISTED"); + } } 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.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.Application/Media/Commands/DeleteMedia/DeleteMediaCommand.cs b/backend/src/CCE.Application/Media/Commands/DeleteMedia/DeleteMediaCommand.cs new file mode 100644 index 00000000..7d0a49d8 --- /dev/null +++ b/backend/src/CCE.Application/Media/Commands/DeleteMedia/DeleteMediaCommand.cs @@ -0,0 +1,7 @@ +using CCE.Application.Common; +using CCE.Application.Media.Dtos; +using MediatR; + +namespace CCE.Application.Media.Commands.DeleteMedia; + +public sealed record DeleteMediaCommand(System.Guid Id) : IRequest>; diff --git a/backend/src/CCE.Application/Media/Commands/DeleteMedia/DeleteMediaCommandHandler.cs b/backend/src/CCE.Application/Media/Commands/DeleteMedia/DeleteMediaCommandHandler.cs new file mode 100644 index 00000000..5c911f5c --- /dev/null +++ b/backend/src/CCE.Application/Media/Commands/DeleteMedia/DeleteMediaCommandHandler.cs @@ -0,0 +1,46 @@ +using CCE.Application.Common; +using CCE.Application.Common.Interfaces; +using CCE.Application.Content; +using CCE.Application.Media.Dtos; +using CCE.Application.Messages; +using MediatR; +using Microsoft.Extensions.DependencyInjection; + +namespace CCE.Application.Media.Commands.DeleteMedia; + +internal sealed class DeleteMediaCommandHandler + : IRequestHandler> +{ + private readonly IMediaFileRepository _repo; + private readonly IFileStorage _fileStorage; + private readonly ICceDbContext _db; + private readonly MessageFactory _msg; + + public DeleteMediaCommandHandler( + IMediaFileRepository repo, + [FromKeyedServices("media")] IFileStorage fileStorage, + ICceDbContext db, + MessageFactory msg) + { + _repo = repo; + _fileStorage = fileStorage; + _db = db; + _msg = msg; + } + + public async Task> Handle( + DeleteMediaCommand request, CancellationToken ct) + { + var mediaFile = await _repo.FindAsync(request.Id, ct).ConfigureAwait(false); + if (mediaFile is null) + return _msg.MediaFileNotFound(); + + await _fileStorage.DeleteAsync(mediaFile.StorageKey, ct).ConfigureAwait(false); + + _db.Delete(mediaFile); + await _db.SaveChangesAsync(ct).ConfigureAwait(false); + + var dto = new MediaFileBriefDto(mediaFile.Id, mediaFile.StorageKey, mediaFile.Url); + return _msg.Ok(dto, "MEDIA_DELETED"); + } +} diff --git a/backend/src/CCE.Application/Media/Commands/DeleteMedia/DeleteMediaCommandValidator.cs b/backend/src/CCE.Application/Media/Commands/DeleteMedia/DeleteMediaCommandValidator.cs new file mode 100644 index 00000000..0746b739 --- /dev/null +++ b/backend/src/CCE.Application/Media/Commands/DeleteMedia/DeleteMediaCommandValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace CCE.Application.Media.Commands.DeleteMedia; + +public sealed class DeleteMediaCommandValidator + : AbstractValidator +{ + public DeleteMediaCommandValidator() + { + RuleFor(x => x.Id).NotEmpty(); + } +} diff --git a/backend/src/CCE.Application/Media/Commands/UpdateMediaMetadata/UpdateMediaMetadataCommand.cs b/backend/src/CCE.Application/Media/Commands/UpdateMediaMetadata/UpdateMediaMetadataCommand.cs new file mode 100644 index 00000000..c16af677 --- /dev/null +++ b/backend/src/CCE.Application/Media/Commands/UpdateMediaMetadata/UpdateMediaMetadataCommand.cs @@ -0,0 +1,14 @@ +using CCE.Application.Common; +using CCE.Application.Media.Dtos; +using MediatR; + +namespace CCE.Application.Media.Commands.UpdateMediaMetadata; + +public sealed record UpdateMediaMetadataCommand( + System.Guid Id, + string? TitleAr, + string? TitleEn, + string? DescriptionAr, + string? DescriptionEn, + string? AltTextAr, + string? AltTextEn) : IRequest>; diff --git a/backend/src/CCE.Application/Media/Commands/UpdateMediaMetadata/UpdateMediaMetadataCommandHandler.cs b/backend/src/CCE.Application/Media/Commands/UpdateMediaMetadata/UpdateMediaMetadataCommandHandler.cs new file mode 100644 index 00000000..a3d57e87 --- /dev/null +++ b/backend/src/CCE.Application/Media/Commands/UpdateMediaMetadata/UpdateMediaMetadataCommandHandler.cs @@ -0,0 +1,46 @@ +using CCE.Application.Common; +using CCE.Application.Common.Interfaces; +using CCE.Application.Media.Dtos; +using CCE.Application.Messages; +using MediatR; + +namespace CCE.Application.Media.Commands.UpdateMediaMetadata; + +internal sealed class UpdateMediaMetadataCommandHandler + : IRequestHandler> +{ + private readonly IMediaFileRepository _repo; + private readonly ICceDbContext _db; + private readonly MessageFactory _msg; + + public UpdateMediaMetadataCommandHandler( + IMediaFileRepository repo, + ICceDbContext db, + MessageFactory msg) + { + _repo = repo; + _db = db; + _msg = msg; + } + + public async Task> Handle( + UpdateMediaMetadataCommand request, CancellationToken ct) + { + var mediaFile = await _repo.FindAsync(request.Id, ct).ConfigureAwait(false); + if (mediaFile is null) + return _msg.MediaFileNotFound(); + + mediaFile.UpdateMetadata( + request.TitleAr, + request.TitleEn, + request.DescriptionAr, + request.DescriptionEn, + request.AltTextAr, + request.AltTextEn); + + await _db.SaveChangesAsync(ct).ConfigureAwait(false); + + var dto = new MediaFileBriefDto(mediaFile.Id, mediaFile.StorageKey, mediaFile.Url); + return _msg.Ok(dto, "MEDIA_UPDATED"); + } +} diff --git a/backend/src/CCE.Application/Media/Commands/UpdateMediaMetadata/UpdateMediaMetadataCommandValidator.cs b/backend/src/CCE.Application/Media/Commands/UpdateMediaMetadata/UpdateMediaMetadataCommandValidator.cs new file mode 100644 index 00000000..59ecae6d --- /dev/null +++ b/backend/src/CCE.Application/Media/Commands/UpdateMediaMetadata/UpdateMediaMetadataCommandValidator.cs @@ -0,0 +1,18 @@ +using FluentValidation; + +namespace CCE.Application.Media.Commands.UpdateMediaMetadata; + +public sealed class UpdateMediaMetadataCommandValidator + : AbstractValidator +{ + public UpdateMediaMetadataCommandValidator() + { + RuleFor(x => x.Id).NotEmpty(); + RuleFor(x => x.TitleAr).MaximumLength(200); + RuleFor(x => x.TitleEn).MaximumLength(200); + RuleFor(x => x.DescriptionAr).MaximumLength(1000); + RuleFor(x => x.DescriptionEn).MaximumLength(1000); + RuleFor(x => x.AltTextAr).MaximumLength(500); + RuleFor(x => x.AltTextEn).MaximumLength(500); + } +} diff --git a/backend/src/CCE.Application/Media/Commands/UploadMedia/UploadMediaCommand.cs b/backend/src/CCE.Application/Media/Commands/UploadMedia/UploadMediaCommand.cs new file mode 100644 index 00000000..37871a00 --- /dev/null +++ b/backend/src/CCE.Application/Media/Commands/UploadMedia/UploadMediaCommand.cs @@ -0,0 +1,17 @@ +using CCE.Application.Common; +using CCE.Application.Media.Dtos; +using MediatR; + +namespace CCE.Application.Media.Commands.UploadMedia; + +public sealed record UploadMediaCommand( + Stream FileStream, + string FileName, + string ContentType, + long FileSize, + string? TitleAr, + string? TitleEn, + string? DescriptionAr, + string? DescriptionEn, + string? AltTextAr, + string? AltTextEn) : IRequest>; diff --git a/backend/src/CCE.Application/Media/Commands/UploadMedia/UploadMediaCommandHandler.cs b/backend/src/CCE.Application/Media/Commands/UploadMedia/UploadMediaCommandHandler.cs new file mode 100644 index 00000000..88b752db --- /dev/null +++ b/backend/src/CCE.Application/Media/Commands/UploadMedia/UploadMediaCommandHandler.cs @@ -0,0 +1,82 @@ +using CCE.Application.Common; +using CCE.Application.Common.Interfaces; +using CCE.Application.Content; +using CCE.Application.Media.Dtos; +using CCE.Application.Messages; +using CCE.Domain.Common; +using CCE.Domain.Media; +using MediatR; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace CCE.Application.Media.Commands.UploadMedia; + +internal sealed class UploadMediaCommandHandler + : IRequestHandler> +{ + private readonly IFileStorage _fileStorage; + private readonly ICceDbContext _db; + private readonly ICurrentUserAccessor _currentUser; + private readonly MediaUploadOptions _opts; + private readonly MessageFactory _msg; + private readonly ISystemClock _clock; + + public UploadMediaCommandHandler( + [FromKeyedServices("media")] IFileStorage fileStorage, + ICceDbContext db, + ICurrentUserAccessor currentUser, + IOptions opts, + MessageFactory msg, + ISystemClock clock) + { + _fileStorage = fileStorage; + _db = db; + _currentUser = currentUser; + _opts = opts.Value; + _msg = msg; + _clock = clock; + } + + public async Task> Handle( + UploadMediaCommand request, CancellationToken ct) + { + if (request.FileSize == 0) + return _msg.EmptyFile(); + + if (request.FileSize > _opts.MaxSizeBytes) + return _msg.FileTooLarge(); + + if (!_opts.AllowedMimeTypes.Contains(request.ContentType)) + return _msg.InvalidFileType(); + + var userId = _currentUser.GetUserId() + ?? throw new DomainException("Authenticated user required."); + + var storageKey = await _fileStorage.SaveAsync(request.FileStream, request.FileName, ct) + .ConfigureAwait(false); + + var baseUrl = _opts.BaseUrl.TrimEnd('/'); + var url = $"{baseUrl}/{storageKey}"; + + var mediaFile = MediaFile.Create( + storageKey, + url, + request.FileName, + request.ContentType, + request.FileSize, + userId, + _clock, + request.TitleAr, + request.TitleEn, + request.DescriptionAr, + request.DescriptionEn, + request.AltTextAr, + request.AltTextEn); + + _db.Add(mediaFile); + await _db.SaveChangesAsync(ct).ConfigureAwait(false); + + var dto = new MediaFileBriefDto(mediaFile.Id, mediaFile.StorageKey, mediaFile.Url); + return _msg.Ok(dto, "MEDIA_UPLOADED"); + } +} diff --git a/backend/src/CCE.Application/Media/Commands/UploadMedia/UploadMediaCommandValidator.cs b/backend/src/CCE.Application/Media/Commands/UploadMedia/UploadMediaCommandValidator.cs new file mode 100644 index 00000000..8b1eea95 --- /dev/null +++ b/backend/src/CCE.Application/Media/Commands/UploadMedia/UploadMediaCommandValidator.cs @@ -0,0 +1,20 @@ +using FluentValidation; + +namespace CCE.Application.Media.Commands.UploadMedia; + +public sealed class UploadMediaCommandValidator + : AbstractValidator +{ + public UploadMediaCommandValidator() + { + RuleFor(x => x.FileStream).NotNull(); + RuleFor(x => x.FileName).NotEmpty().MaximumLength(255); + RuleFor(x => x.ContentType).NotEmpty().MaximumLength(100); + RuleFor(x => x.TitleAr).MaximumLength(200); + RuleFor(x => x.TitleEn).MaximumLength(200); + RuleFor(x => x.DescriptionAr).MaximumLength(1000); + RuleFor(x => x.DescriptionEn).MaximumLength(1000); + RuleFor(x => x.AltTextAr).MaximumLength(500); + RuleFor(x => x.AltTextEn).MaximumLength(500); + } +} diff --git a/backend/src/CCE.Application/Media/Dtos/MediaFileBriefDto.cs b/backend/src/CCE.Application/Media/Dtos/MediaFileBriefDto.cs new file mode 100644 index 00000000..816b18b1 --- /dev/null +++ b/backend/src/CCE.Application/Media/Dtos/MediaFileBriefDto.cs @@ -0,0 +1,6 @@ +namespace CCE.Application.Media.Dtos; + +public sealed record MediaFileBriefDto( + System.Guid Id, + string StorageKey, + string Url); diff --git a/backend/src/CCE.Application/Media/Dtos/MediaFileDto.cs b/backend/src/CCE.Application/Media/Dtos/MediaFileDto.cs new file mode 100644 index 00000000..73cc1464 --- /dev/null +++ b/backend/src/CCE.Application/Media/Dtos/MediaFileDto.cs @@ -0,0 +1,26 @@ +namespace CCE.Application.Media.Dtos; + +public sealed record MediaFileDto( + System.Guid Id, + string StorageKey, + string Url, + string OriginalFileName, + string MimeType, + long SizeBytes, + string? TitleAr, + string? TitleEn, + string? DescriptionAr, + string? DescriptionEn, + string? AltTextAr, + string? AltTextEn, + System.Guid UploadedById, + System.DateTimeOffset UploadedOn) +{ + internal static MediaFileDto FromEntity(CCE.Domain.Media.MediaFile entity) => new( + entity.Id, entity.StorageKey, entity.Url, + entity.OriginalFileName, entity.MimeType, entity.SizeBytes, + entity.TitleAr, entity.TitleEn, + entity.DescriptionAr, entity.DescriptionEn, + entity.AltTextAr, entity.AltTextEn, + entity.UploadedById, entity.UploadedOn); +} diff --git a/backend/src/CCE.Application/Media/IMediaFileRepository.cs b/backend/src/CCE.Application/Media/IMediaFileRepository.cs new file mode 100644 index 00000000..20453bc1 --- /dev/null +++ b/backend/src/CCE.Application/Media/IMediaFileRepository.cs @@ -0,0 +1,8 @@ +using CCE.Domain.Media; + +namespace CCE.Application.Media; + +public interface IMediaFileRepository +{ + Task FindAsync(System.Guid id, CancellationToken ct); +} diff --git a/backend/src/CCE.Application/Media/MediaUploadOptions.cs b/backend/src/CCE.Application/Media/MediaUploadOptions.cs new file mode 100644 index 00000000..8993c361 --- /dev/null +++ b/backend/src/CCE.Application/Media/MediaUploadOptions.cs @@ -0,0 +1,20 @@ +namespace CCE.Application.Media; + +public sealed class MediaUploadOptions +{ + public const string SectionName = "Media"; + + public string BaseUrl { get; init; } = "http://localhost:5001/media/"; + + public long MaxSizeBytes { get; init; } = 52_428_800; + + public IReadOnlyList AllowedMimeTypes { get; init; } = new[] + { + "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.Application/Media/Queries/GetMediaById/GetMediaByIdQuery.cs b/backend/src/CCE.Application/Media/Queries/GetMediaById/GetMediaByIdQuery.cs new file mode 100644 index 00000000..957a0573 --- /dev/null +++ b/backend/src/CCE.Application/Media/Queries/GetMediaById/GetMediaByIdQuery.cs @@ -0,0 +1,7 @@ +using CCE.Application.Common; +using CCE.Application.Media.Dtos; +using MediatR; + +namespace CCE.Application.Media.Queries.GetMediaById; + +public sealed record GetMediaByIdQuery(System.Guid Id) : IRequest>; diff --git a/backend/src/CCE.Application/Media/Queries/GetMediaById/GetMediaByIdQueryHandler.cs b/backend/src/CCE.Application/Media/Queries/GetMediaById/GetMediaByIdQueryHandler.cs new file mode 100644 index 00000000..c2b86971 --- /dev/null +++ b/backend/src/CCE.Application/Media/Queries/GetMediaById/GetMediaByIdQueryHandler.cs @@ -0,0 +1,31 @@ +using CCE.Application.Common; +using CCE.Application.Media.Dtos; +using CCE.Application.Messages; +using MediatR; + +namespace CCE.Application.Media.Queries.GetMediaById; + +internal sealed class GetMediaByIdQueryHandler + : IRequestHandler> +{ + private readonly IMediaFileRepository _repo; + private readonly MessageFactory _msg; + + public GetMediaByIdQueryHandler( + IMediaFileRepository repo, + MessageFactory msg) + { + _repo = repo; + _msg = msg; + } + + public async Task> Handle( + GetMediaByIdQuery request, CancellationToken ct) + { + var mediaFile = await _repo.FindAsync(request.Id, ct).ConfigureAwait(false); + if (mediaFile is null) + return _msg.MediaFileNotFound(); + + return _msg.Ok(MediaFileDto.FromEntity(mediaFile), "ITEMS_LISTED"); + } +} diff --git a/backend/src/CCE.Application/Messages/MessageFactory.cs b/backend/src/CCE.Application/Messages/MessageFactory.cs new file mode 100644 index 00000000..5d07dea3 --- /dev/null +++ b/backend/src/CCE.Application/Messages/MessageFactory.cs @@ -0,0 +1,129 @@ +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 message in the request language +/// 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"); + + // ─── 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"); + + // ─── Convenience shortcuts (Media domain) ─── + + public Response MediaFileNotFound() => NotFound("MEDIA_FILE_NOT_FOUND"); + public Response InvalidFileType() => BusinessRule("INVALID_FILE_TYPE"); + public Response FileTooLarge() => BusinessRule("FILE_TOO_LARGE"); + public Response EmptyFile() => BusinessRule("EMPTY_FILE"); + + // ─── Convenience shortcuts (Verification domain) ─── + + public Response OtpNotFound() => NotFound("OTP_NOT_FOUND"); + public Response OtpExpired() => BusinessRule("OTP_EXPIRED"); + public Response OtpInvalidCode() => BusinessRule("OTP_INVALID_CODE"); + public Response OtpMaxAttempts() => BusinessRule("OTP_MAX_ATTEMPTS"); + public Response OtpCooldownActive() => BusinessRule("OTP_COOLDOWN_ACTIVE"); + public Response OtpInvalidated() => BusinessRule("OTP_INVALIDATED"); + + // ─── Convenience shortcuts (Notification domain) ─── + + public Response NotificationTemplateNotFound() => NotFound("TEMPLATE_NOT_FOUND"); + public Response NotificationLogNotFound() => NotFound("NOTIFICATION_NOT_FOUND"); + public Response NotificationSettingsUpdated() => Ok("NOTIFICATION_SETTINGS_UPDATED"); + public Response NotificationMarkedRead() => Ok("NOTIFICATION_MARKED_READ"); + public Response NotificationsMarkedRead(int count) => Ok(count, "NOTIFICATIONS_MARKED_READ"); + public Response NotificationRetried(T data) => Ok(data, "NOTIFICATION_RETRIED"); + public Response NotificationTemplateCreated(T data) => Ok(data, "NOTIFICATION_TEMPLATE_CREATED"); + public Response NotificationTemplateUpdated(T data) => Ok(data, "NOTIFICATION_TEMPLATE_UPDATED"); + + // ─── Private ─── + + private Response Fail(string domainKey, MessageType type) + { + var code = SystemCodeMap.ToSystemCode(domainKey); + var msg = Localize(domainKey); + return Response.Fail(code, msg, type); + } + + 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 new file mode 100644 index 00000000..86f26693 --- /dev/null +++ b/backend/src/CCE.Application/Messages/SystemCode.cs @@ -0,0 +1,219 @@ +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 (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 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) + 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 + 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 + + // ─── Media Errors ─── + public const string ERR110 = "ERR110"; // Media file not found + public const string ERR111 = "ERR111"; // Invalid file type + public const string ERR112 = "ERR112"; // File too large + public const string ERR113 = "ERR113"; // Empty file + + // ─── InteractiveCity Errors ─── + 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 + + // ─── Verification Errors ─── + public const string ERR120 = "ERR120"; // OTP not found + public const string ERR121 = "ERR121"; // OTP expired + public const string ERR122 = "ERR122"; // OTP invalid code + public const string ERR123 = "ERR123"; // OTP max attempts exceeded + public const string ERR124 = "ERR124"; // OTP cooldown active + public const string ERR125 = "ERR125"; // OTP invalidated + + // ─── 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 (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) + 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 + 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 + public const string CON055 = "CON055"; // User status changed + + // ─── 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 + + // ─── Media Success ─── + public const string CON029 = "CON029"; // Media uploaded + public const string CON036 = "CON036"; // Media updated + public const string CON037 = "CON037"; // Media deleted + + // ─── 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 + + // ─── Verification Success ─── + public const string CON060 = "CON060"; // OTP sent + public const string CON061 = "CON061"; // OTP verified + + // ─── Notification Success ─── + public const string CON040 = "CON040"; // Notification created + public const string CON041 = "CON041"; // Notification marked read + public const string CON042 = "CON042"; // Notification deleted + public const string CON043 = "CON043"; // Notification settings updated + public const string CON044 = "CON044"; // Notification retried + public const string CON045 = "CON045"; // Notifications marked read + public const string CON046 = "CON046"; // Notification template created + public const string CON047 = "CON047"; // Notification template updated + + // ─── General Success ─── + public const string CON100 = "CON100"; // Items listed successfully + 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..d136a013 --- /dev/null +++ b/backend/src/CCE.Application/Messages/SystemCodeMap.cs @@ -0,0 +1,193 @@ +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 (appendix-aligned) ─── + ["USER_NOT_FOUND"] = SystemCode.ERR001, + ["EMAIL_EXISTS"] = SystemCode.ERR019, + ["INVALID_CREDENTIALS"] = SystemCode.ERR020, + ["PASSWORD_RECOVERY_FAILED"] = SystemCode.ERR023, + ["LOGOUT_FAILED"] = SystemCode.ERR024, + + // ─── 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, + ["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, + + // ─── Media Errors ─── + ["MEDIA_FILE_NOT_FOUND"] = SystemCode.ERR110, + ["INVALID_FILE_TYPE"] = SystemCode.ERR111, + ["FILE_TOO_LARGE"] = SystemCode.ERR112, + ["EMPTY_FILE"] = SystemCode.ERR113, + + // ─── InteractiveCity Errors ─── + ["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, + + // ─── Verification Errors ─── + ["OTP_NOT_FOUND"] = SystemCode.ERR120, + ["OTP_EXPIRED"] = SystemCode.ERR121, + ["OTP_INVALID_CODE"] = SystemCode.ERR122, + ["OTP_MAX_ATTEMPTS"] = SystemCode.ERR123, + ["OTP_COOLDOWN_ACTIVE"] = SystemCode.ERR124, + ["OTP_INVALIDATED"] = SystemCode.ERR125, + + // ─── 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 (appendix-aligned) ─── + ["LOGIN_SUCCESS"] = SystemCode.CON001, + ["TOKEN_REFRESHED"] = SystemCode.CON004, + ["PROFILE_UPDATED"] = SystemCode.CON005, + ["EXPERT_REQUEST_SUBMITTED"] = SystemCode.CON006, + ["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, + ["EXPERT_REQUEST_REJECTED"] = SystemCode.CON051, + ["STATE_REP_ASSIGNMENT_CREATED"] = SystemCode.CON052, + ["STATE_REP_ASSIGNMENT_REVOKED"] = SystemCode.CON053, + ["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, + ["CONTENT_DELETED"] = SystemCode.CON022, + + // ─── Media Success ─── + ["MEDIA_UPLOADED"] = SystemCode.CON029, + ["MEDIA_UPDATED"] = SystemCode.CON036, + ["MEDIA_DELETED"] = SystemCode.CON037, + ["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, + ["NOTIFICATION_SETTINGS_UPDATED"] = SystemCode.CON043, + ["NOTIFICATION_RETRIED"] = SystemCode.CON044, + ["NOTIFICATIONS_MARKED_READ"] = SystemCode.CON045, + ["NOTIFICATION_TEMPLATE_CREATED"] = SystemCode.CON046, + ["NOTIFICATION_TEMPLATE_UPDATED"] = SystemCode.CON047, + + // ─── Verification Success ─── + ["OTP_SENT"] = SystemCode.CON060, + ["OTP_VERIFIED"] = SystemCode.CON061, + + // ─── General Success ─── + ["ITEMS_LISTED"] = SystemCode.CON100, + ["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.Application/Notifications/Admin/Commands/RetryNotificationLog/RetryNotificationLogCommand.cs b/backend/src/CCE.Application/Notifications/Admin/Commands/RetryNotificationLog/RetryNotificationLogCommand.cs new file mode 100644 index 00000000..ec596568 --- /dev/null +++ b/backend/src/CCE.Application/Notifications/Admin/Commands/RetryNotificationLog/RetryNotificationLogCommand.cs @@ -0,0 +1,6 @@ +using CCE.Application.Common; +using MediatR; + +namespace CCE.Application.Notifications.Admin.Commands.RetryNotificationLog; + +public sealed record RetryNotificationLogCommand(System.Guid Id) : IRequest>; diff --git a/backend/src/CCE.Application/Notifications/Admin/Commands/RetryNotificationLog/RetryNotificationLogCommandHandler.cs b/backend/src/CCE.Application/Notifications/Admin/Commands/RetryNotificationLog/RetryNotificationLogCommandHandler.cs new file mode 100644 index 00000000..55de6eaf --- /dev/null +++ b/backend/src/CCE.Application/Notifications/Admin/Commands/RetryNotificationLog/RetryNotificationLogCommandHandler.cs @@ -0,0 +1,131 @@ +using CCE.Application.Common; +using CCE.Application.Common.Interfaces; +using CCE.Application.Common.Pagination; +using CCE.Application.Messages; +using CCE.Domain.Common; +using CCE.Domain.Notifications; +using MediatR; + +namespace CCE.Application.Notifications.Admin.Commands.RetryNotificationLog; + +public sealed class RetryNotificationLogCommandHandler + : IRequestHandler> +{ + private readonly INotificationLogRepository _logRepository; + private readonly INotificationTemplateRepository _templateRepository; + private readonly IEnumerable _handlers; + private readonly INotificationTemplateRenderer _renderer; + private readonly ICceDbContext _db; + private readonly MessageFactory _msg; + + public RetryNotificationLogCommandHandler( + INotificationLogRepository logRepository, + INotificationTemplateRepository templateRepository, + IEnumerable handlers, + INotificationTemplateRenderer renderer, + ICceDbContext db, + MessageFactory msg) + { + _logRepository = logRepository; + _templateRepository = templateRepository; + _handlers = handlers; + _renderer = renderer; + _db = db; + _msg = msg; + } + + public async Task> Handle( + RetryNotificationLogCommand request, + CancellationToken cancellationToken) + { + var log = await _logRepository.GetAsync(request.Id, cancellationToken).ConfigureAwait(false); + + if (log is null) + return _msg.NotificationLogNotFound(); + + if (log.Status != NotificationDeliveryStatus.Failed && log.Status != NotificationDeliveryStatus.Skipped) + throw new DomainException($"Cannot retry a log with status {log.Status}."); + + log.IncrementAttempt(); + + // Resolve template + var template = await _templateRepository.GetActiveByCodeAndChannelAsync( + log.TemplateCode, + log.Channel, + cancellationToken) + .ConfigureAwait(false); + + if (template is null) + { + log.MarkSkipped("Template no longer available."); + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + return _msg.NotificationRetried(log.Id); + } + + // Resolve recipient data + string? email = null; + string? phone = null; + string locale = "en"; + + if (log.RecipientUserId is { } userId) + { + var user = (await _db.Users + .Where(u => u.Id == userId) + .Select(u => new { u.Email, u.PhoneNumber }) + .ToListAsyncEither(cancellationToken) + .ConfigureAwait(false)) + .FirstOrDefault(); + + if (user is not null) + { + email = user.Email; + phone = user.PhoneNumber; + } + } + + // Render + var variables = log.PayloadJson is not null + ? System.Text.Json.JsonSerializer.Deserialize>(log.PayloadJson) ?? new Dictionary() + : new Dictionary(); + + var (subjectAr, subjectEn, body) = _renderer.Render(template, variables, locale); + var subject = subjectEn; + + var rendered = new RenderedNotification( + log.TemplateCode, + log.RecipientUserId, + template.Id, + subject, + subjectAr, + subjectEn, + body, + log.Channel, + locale, + email, + phone); + + // Dispatch + var sender = _handlers.FirstOrDefault(s => s.Channel == log.Channel); + if (sender is null) + { + log.MarkSkipped($"No sender registered for channel {log.Channel}."); + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + return _msg.NotificationRetried(log.Id); + } + + var sendResult = await sender.SendAsync(rendered, cancellationToken).ConfigureAwait(false); + + if (sendResult.Success) + { + log.MarkSent(sendResult.ProviderMessageId); + } + else + { + log.MarkFailed(sendResult.Error ?? "Unknown error"); + } + + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + return _msg.NotificationRetried(log.Id); + } +} diff --git a/backend/src/CCE.Application/Notifications/Admin/Queries/GetNotificationLogById/GetNotificationLogByIdQuery.cs b/backend/src/CCE.Application/Notifications/Admin/Queries/GetNotificationLogById/GetNotificationLogByIdQuery.cs new file mode 100644 index 00000000..61a198eb --- /dev/null +++ b/backend/src/CCE.Application/Notifications/Admin/Queries/GetNotificationLogById/GetNotificationLogByIdQuery.cs @@ -0,0 +1,6 @@ +using CCE.Application.Common; +using MediatR; + +namespace CCE.Application.Notifications.Admin.Queries.GetNotificationLogById; + +public sealed record GetNotificationLogByIdQuery(System.Guid Id) : IRequest>; diff --git a/backend/src/CCE.Application/Notifications/Admin/Queries/GetNotificationLogById/GetNotificationLogByIdQueryHandler.cs b/backend/src/CCE.Application/Notifications/Admin/Queries/GetNotificationLogById/GetNotificationLogByIdQueryHandler.cs new file mode 100644 index 00000000..32095736 --- /dev/null +++ b/backend/src/CCE.Application/Notifications/Admin/Queries/GetNotificationLogById/GetNotificationLogByIdQueryHandler.cs @@ -0,0 +1,52 @@ +using CCE.Application.Common; +using CCE.Application.Common.Interfaces; +using CCE.Application.Common.Pagination; +using CCE.Application.Messages; +using CCE.Domain.Notifications; +using MediatR; + +namespace CCE.Application.Notifications.Admin.Queries.GetNotificationLogById; + +public sealed class GetNotificationLogByIdQueryHandler + : IRequestHandler> +{ + private readonly ICceDbContext _db; + private readonly MessageFactory _msg; + + public GetNotificationLogByIdQueryHandler(ICceDbContext db, MessageFactory msg) + { + _db = db; + _msg = msg; + } + + public async Task> Handle( + GetNotificationLogByIdQuery request, + CancellationToken cancellationToken) + { + var log = (await _db.NotificationLogs + .Where(l => l.Id == request.Id) + .ToListAsyncEither(cancellationToken) + .ConfigureAwait(false)) + .FirstOrDefault(); + + return log is null + ? _msg.NotificationLogNotFound() + : _msg.Ok(MapToDto(log), "ITEMS_LISTED"); + } + + internal static NotificationLogDto MapToDto(NotificationLog l) => new( + l.Id, + l.RecipientUserId, + l.TemplateCode, + l.TemplateId, + l.Channel, + l.Status, + l.ProviderMessageId, + l.Error, + l.AttemptCount, + l.CreatedOn, + l.SentOn, + l.FailedOn, + l.CorrelationId, + l.PayloadJson); +} diff --git a/backend/src/CCE.Application/Notifications/Admin/Queries/GetNotificationLogById/NotificationLogDto.cs b/backend/src/CCE.Application/Notifications/Admin/Queries/GetNotificationLogById/NotificationLogDto.cs new file mode 100644 index 00000000..697f265a --- /dev/null +++ b/backend/src/CCE.Application/Notifications/Admin/Queries/GetNotificationLogById/NotificationLogDto.cs @@ -0,0 +1,19 @@ +using CCE.Domain.Notifications; + +namespace CCE.Application.Notifications.Admin.Queries.GetNotificationLogById; + +public sealed record NotificationLogDto( + System.Guid Id, + System.Guid? RecipientUserId, + string TemplateCode, + System.Guid? TemplateId, + NotificationChannel Channel, + NotificationDeliveryStatus Status, + string? ProviderMessageId, + string? Error, + int AttemptCount, + System.DateTimeOffset CreatedOn, + System.DateTimeOffset? SentOn, + System.DateTimeOffset? FailedOn, + string? CorrelationId, + string? PayloadJson); diff --git a/backend/src/CCE.Application/Notifications/Admin/Queries/ListNotificationLogs/ListNotificationLogsQuery.cs b/backend/src/CCE.Application/Notifications/Admin/Queries/ListNotificationLogs/ListNotificationLogsQuery.cs new file mode 100644 index 00000000..9c8912f2 --- /dev/null +++ b/backend/src/CCE.Application/Notifications/Admin/Queries/ListNotificationLogs/ListNotificationLogsQuery.cs @@ -0,0 +1,14 @@ +using CCE.Application.Common; +using CCE.Application.Common.Pagination; +using CCE.Domain.Notifications; +using MediatR; + +namespace CCE.Application.Notifications.Admin.Queries.ListNotificationLogs; + +public sealed record ListNotificationLogsQuery( + int Page, + int PageSize, + System.Guid? RecipientUserId = null, + string? TemplateCode = null, + NotificationChannel? Channel = null, + NotificationDeliveryStatus? Status = null) : IRequest>>; diff --git a/backend/src/CCE.Application/Notifications/Admin/Queries/ListNotificationLogs/ListNotificationLogsQueryHandler.cs b/backend/src/CCE.Application/Notifications/Admin/Queries/ListNotificationLogs/ListNotificationLogsQueryHandler.cs new file mode 100644 index 00000000..3c93a457 --- /dev/null +++ b/backend/src/CCE.Application/Notifications/Admin/Queries/ListNotificationLogs/ListNotificationLogsQueryHandler.cs @@ -0,0 +1,67 @@ +using CCE.Application.Common; +using CCE.Application.Common.Interfaces; +using CCE.Application.Common.Pagination; +using CCE.Application.Messages; +using CCE.Domain.Notifications; +using MediatR; + +namespace CCE.Application.Notifications.Admin.Queries.ListNotificationLogs; + +public sealed class ListNotificationLogsQueryHandler + : IRequestHandler>> +{ + private readonly ICceDbContext _db; + private readonly MessageFactory _msg; + + public ListNotificationLogsQueryHandler(ICceDbContext db, MessageFactory msg) + { + _db = db; + _msg = msg; + } + + public async Task>> Handle( + ListNotificationLogsQuery request, + CancellationToken cancellationToken) + { + IQueryable query = _db.NotificationLogs; + + if (request.RecipientUserId is { } userId) + query = query.Where(l => l.RecipientUserId == userId); + + if (!string.IsNullOrWhiteSpace(request.TemplateCode)) + query = query.Where(l => l.TemplateCode == request.TemplateCode); + + if (request.Channel is { } channel) + query = query.Where(l => l.Channel == channel); + + if (request.Status is { } status) + query = query.Where(l => l.Status == status); + + query = query.OrderByDescending(l => l.CreatedOn).ThenByDescending(l => l.Id); + + var page = await query.ToPagedResultAsync( + request.Page, + request.PageSize, + cancellationToken) + .ConfigureAwait(false); + + var items = page.Items.Select(MapToDto).ToList(); + var result = new PagedResult(items, page.Page, page.PageSize, page.Total); + return _msg.Ok(result, "ITEMS_LISTED"); + } + + internal static NotificationLogListItemDto MapToDto(NotificationLog l) => new( + l.Id, + l.RecipientUserId, + l.TemplateCode, + l.TemplateId, + l.Channel, + l.Status, + l.ProviderMessageId, + l.Error, + l.AttemptCount, + l.CreatedOn, + l.SentOn, + l.FailedOn, + l.CorrelationId); +} diff --git a/backend/src/CCE.Application/Notifications/Admin/Queries/ListNotificationLogs/NotificationLogListItemDto.cs b/backend/src/CCE.Application/Notifications/Admin/Queries/ListNotificationLogs/NotificationLogListItemDto.cs new file mode 100644 index 00000000..7d2d65d6 --- /dev/null +++ b/backend/src/CCE.Application/Notifications/Admin/Queries/ListNotificationLogs/NotificationLogListItemDto.cs @@ -0,0 +1,18 @@ +using CCE.Domain.Notifications; + +namespace CCE.Application.Notifications.Admin.Queries.ListNotificationLogs; + +public sealed record NotificationLogListItemDto( + System.Guid Id, + System.Guid? RecipientUserId, + string TemplateCode, + System.Guid? TemplateId, + NotificationChannel Channel, + NotificationDeliveryStatus Status, + string? ProviderMessageId, + string? Error, + int AttemptCount, + System.DateTimeOffset CreatedOn, + System.DateTimeOffset? SentOn, + System.DateTimeOffset? FailedOn, + string? CorrelationId); diff --git a/backend/src/CCE.Application/Notifications/ChannelSendResult.cs b/backend/src/CCE.Application/Notifications/ChannelSendResult.cs new file mode 100644 index 00000000..f472b254 --- /dev/null +++ b/backend/src/CCE.Application/Notifications/ChannelSendResult.cs @@ -0,0 +1,10 @@ +using CCE.Domain.Notifications; + +namespace CCE.Application.Notifications; + +public sealed record ChannelSendResult( + bool Success, + string? ProviderMessageId = null, + string? Error = null, + System.Guid? UserNotificationId = null, + UserNotification? UserNotification = null); diff --git a/backend/src/CCE.Application/Notifications/Commands/CreateNotificationTemplate/CreateNotificationTemplateCommand.cs b/backend/src/CCE.Application/Notifications/Commands/CreateNotificationTemplate/CreateNotificationTemplateCommand.cs index bed6253a..a15ce1ae 100644 --- a/backend/src/CCE.Application/Notifications/Commands/CreateNotificationTemplate/CreateNotificationTemplateCommand.cs +++ b/backend/src/CCE.Application/Notifications/Commands/CreateNotificationTemplate/CreateNotificationTemplateCommand.cs @@ -1,4 +1,4 @@ -using CCE.Application.Notifications.Dtos; +using CCE.Application.Common; using CCE.Domain.Notifications; using MediatR; @@ -11,4 +11,4 @@ public sealed record CreateNotificationTemplateCommand( string BodyAr, string BodyEn, NotificationChannel Channel, - string VariableSchemaJson) : IRequest; + string VariableSchemaJson) : IRequest>; diff --git a/backend/src/CCE.Application/Notifications/Commands/CreateNotificationTemplate/CreateNotificationTemplateCommandHandler.cs b/backend/src/CCE.Application/Notifications/Commands/CreateNotificationTemplate/CreateNotificationTemplateCommandHandler.cs index 67c4fe0f..3d3f2627 100644 --- a/backend/src/CCE.Application/Notifications/Commands/CreateNotificationTemplate/CreateNotificationTemplateCommandHandler.cs +++ b/backend/src/CCE.Application/Notifications/Commands/CreateNotificationTemplate/CreateNotificationTemplateCommandHandler.cs @@ -1,21 +1,29 @@ -using CCE.Application.Notifications.Dtos; -using CCE.Application.Notifications.Queries.ListNotificationTemplates; +using CCE.Application.Common; +using CCE.Application.Common.Interfaces; +using CCE.Application.Messages; using CCE.Domain.Notifications; using MediatR; namespace CCE.Application.Notifications.Commands.CreateNotificationTemplate; public sealed class CreateNotificationTemplateCommandHandler - : IRequestHandler + : IRequestHandler> { - private readonly INotificationTemplateService _service; + private readonly INotificationTemplateRepository _repo; + private readonly ICceDbContext _db; + private readonly MessageFactory _msg; - public CreateNotificationTemplateCommandHandler(INotificationTemplateService service) + public CreateNotificationTemplateCommandHandler( + INotificationTemplateRepository repo, + ICceDbContext db, + MessageFactory msg) { - _service = service; + _repo = repo; + _db = db; + _msg = msg; } - public async Task Handle( + public async Task> Handle( CreateNotificationTemplateCommand request, CancellationToken cancellationToken) { @@ -28,8 +36,9 @@ public async Task Handle( request.Channel, request.VariableSchemaJson); - await _service.SaveAsync(template, cancellationToken).ConfigureAwait(false); + await _repo.AddAsync(template, cancellationToken).ConfigureAwait(false); + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - return ListNotificationTemplatesQueryHandler.MapToDto(template); + return _msg.NotificationTemplateCreated(template.Id); } } diff --git a/backend/src/CCE.Application/Notifications/Commands/UpdateNotificationTemplate/UpdateNotificationTemplateCommand.cs b/backend/src/CCE.Application/Notifications/Commands/UpdateNotificationTemplate/UpdateNotificationTemplateCommand.cs index 22341132..fd8e8d84 100644 --- a/backend/src/CCE.Application/Notifications/Commands/UpdateNotificationTemplate/UpdateNotificationTemplateCommand.cs +++ b/backend/src/CCE.Application/Notifications/Commands/UpdateNotificationTemplate/UpdateNotificationTemplateCommand.cs @@ -1,4 +1,4 @@ -using CCE.Application.Notifications.Dtos; +using CCE.Application.Common; using MediatR; namespace CCE.Application.Notifications.Commands.UpdateNotificationTemplate; @@ -9,4 +9,4 @@ public sealed record UpdateNotificationTemplateCommand( string SubjectEn, string BodyAr, string BodyEn, - bool IsActive) : IRequest; + bool IsActive) : IRequest>; diff --git a/backend/src/CCE.Application/Notifications/Commands/UpdateNotificationTemplate/UpdateNotificationTemplateCommandHandler.cs b/backend/src/CCE.Application/Notifications/Commands/UpdateNotificationTemplate/UpdateNotificationTemplateCommandHandler.cs index 969f11c3..221e66b6 100644 --- a/backend/src/CCE.Application/Notifications/Commands/UpdateNotificationTemplate/UpdateNotificationTemplateCommandHandler.cs +++ b/backend/src/CCE.Application/Notifications/Commands/UpdateNotificationTemplate/UpdateNotificationTemplateCommandHandler.cs @@ -1,27 +1,35 @@ -using CCE.Application.Notifications.Dtos; -using CCE.Application.Notifications.Queries.ListNotificationTemplates; +using CCE.Application.Common; +using CCE.Application.Common.Interfaces; +using CCE.Application.Messages; using MediatR; namespace CCE.Application.Notifications.Commands.UpdateNotificationTemplate; public sealed class UpdateNotificationTemplateCommandHandler - : IRequestHandler + : IRequestHandler> { - private readonly INotificationTemplateService _service; - - public UpdateNotificationTemplateCommandHandler(INotificationTemplateService service) + private readonly INotificationTemplateRepository _repo; + private readonly ICceDbContext _db; + private readonly MessageFactory _msg; + + public UpdateNotificationTemplateCommandHandler( + INotificationTemplateRepository repo, + ICceDbContext db, + MessageFactory msg) { - _service = service; + _repo = repo; + _db = db; + _msg = msg; } - public async Task Handle( + public async Task> Handle( UpdateNotificationTemplateCommand request, CancellationToken cancellationToken) { - var template = await _service.FindAsync(request.Id, cancellationToken).ConfigureAwait(false); + var template = await _repo.GetAsync(request.Id, cancellationToken).ConfigureAwait(false); if (template is null) { - return null; + return _msg.NotificationTemplateNotFound(); } template.UpdateContent(request.SubjectAr, request.SubjectEn, request.BodyAr, request.BodyEn); @@ -31,8 +39,8 @@ public UpdateNotificationTemplateCommandHandler(INotificationTemplateService ser else template.Deactivate(); - await _service.UpdateAsync(template, cancellationToken).ConfigureAwait(false); + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - return ListNotificationTemplatesQueryHandler.MapToDto(template); + return _msg.NotificationTemplateUpdated(template.Id); } } diff --git a/backend/src/CCE.Application/Notifications/Handlers/EventScheduledNotificationHandler.cs b/backend/src/CCE.Application/Notifications/Handlers/EventScheduledNotificationHandler.cs new file mode 100644 index 00000000..1ddfe843 --- /dev/null +++ b/backend/src/CCE.Application/Notifications/Handlers/EventScheduledNotificationHandler.cs @@ -0,0 +1,25 @@ +using CCE.Domain.Content.Events; +using MediatR; +using Microsoft.Extensions.Logging; + +namespace CCE.Application.Notifications.Handlers; + +public sealed class EventScheduledNotificationHandler + : INotificationHandler +{ + private readonly ILogger _logger; + + public EventScheduledNotificationHandler( + ILogger logger) + { + _logger = logger; + } + + public Task Handle(EventScheduledEvent notification, CancellationToken cancellationToken) + { + _logger.LogInformation( + "Event {EventId} scheduled. Audience notifications require explicit audience definition.", + notification.EventId); + return Task.CompletedTask; + } +} diff --git a/backend/src/CCE.Application/Notifications/Handlers/ExpertRegistrationApprovedNotificationHandler.cs b/backend/src/CCE.Application/Notifications/Handlers/ExpertRegistrationApprovedNotificationHandler.cs new file mode 100644 index 00000000..e14ac6ca --- /dev/null +++ b/backend/src/CCE.Application/Notifications/Handlers/ExpertRegistrationApprovedNotificationHandler.cs @@ -0,0 +1,30 @@ +using CCE.Application.Notifications.Messages; +using CCE.Domain.Identity.Events; +using CCE.Domain.Notifications; +using MediatR; + +namespace CCE.Application.Notifications.Handlers; + +public sealed class ExpertRegistrationApprovedNotificationHandler + : INotificationHandler +{ + private readonly INotificationMessageDispatcher _dispatcher; + + public ExpertRegistrationApprovedNotificationHandler(INotificationMessageDispatcher dispatcher) + { + _dispatcher = dispatcher; + } + + public async Task Handle( + ExpertRegistrationApprovedEvent notification, + CancellationToken cancellationToken) + { + await _dispatcher.DispatchAsync(new NotificationMessage( + TemplateCode: "EXPERT_REQUEST_APPROVED", + RecipientUserId: notification.RequestedById, + EventType: NotificationEventType.ExpertRequestApproved, + Channels: [NotificationChannel.InApp, NotificationChannel.Email], + MetaData: new Dictionary(), + Locale: "en"), cancellationToken).ConfigureAwait(false); + } +} diff --git a/backend/src/CCE.Application/Notifications/Handlers/ExpertRegistrationRejectedNotificationHandler.cs b/backend/src/CCE.Application/Notifications/Handlers/ExpertRegistrationRejectedNotificationHandler.cs new file mode 100644 index 00000000..38ca34df --- /dev/null +++ b/backend/src/CCE.Application/Notifications/Handlers/ExpertRegistrationRejectedNotificationHandler.cs @@ -0,0 +1,33 @@ +using CCE.Application.Notifications.Messages; +using CCE.Domain.Identity.Events; +using CCE.Domain.Notifications; +using MediatR; + +namespace CCE.Application.Notifications.Handlers; + +public sealed class ExpertRegistrationRejectedNotificationHandler + : INotificationHandler +{ + private readonly INotificationMessageDispatcher _dispatcher; + + public ExpertRegistrationRejectedNotificationHandler(INotificationMessageDispatcher dispatcher) + { + _dispatcher = dispatcher; + } + + public async Task Handle( + ExpertRegistrationRejectedEvent notification, + CancellationToken cancellationToken) + { + await _dispatcher.DispatchAsync(new NotificationMessage( + TemplateCode: "EXPERT_REQUEST_REJECTED", + RecipientUserId: notification.RequestedById, + EventType: NotificationEventType.ExpertRequestRejected, + Channels: [NotificationChannel.InApp, NotificationChannel.Email], + MetaData: new Dictionary + { + ["Reason"] = notification.RejectionReasonEn ?? "" + }, + Locale: "en"), cancellationToken).ConfigureAwait(false); + } +} diff --git a/backend/src/CCE.Application/Notifications/Handlers/NewsPublishedNotificationHandler.cs b/backend/src/CCE.Application/Notifications/Handlers/NewsPublishedNotificationHandler.cs new file mode 100644 index 00000000..6e634267 --- /dev/null +++ b/backend/src/CCE.Application/Notifications/Handlers/NewsPublishedNotificationHandler.cs @@ -0,0 +1,48 @@ +using CCE.Application.Common.Interfaces; +using CCE.Application.Content; +using CCE.Application.Notifications.Messages; +using CCE.Domain.Content.Events; +using CCE.Domain.Notifications; +using MediatR; +using Microsoft.Extensions.Logging; + +namespace CCE.Application.Notifications.Handlers; + +public sealed class NewsPublishedNotificationHandler + : INotificationHandler +{ + private readonly INewsRepository _newsRepo; + private readonly INotificationMessageDispatcher _dispatcher; + private readonly ILogger _logger; + + public NewsPublishedNotificationHandler( + INewsRepository newsRepo, + INotificationMessageDispatcher dispatcher, + ILogger logger) + { + _newsRepo = newsRepo; + _dispatcher = dispatcher; + _logger = logger; + } + + public async Task Handle(NewsPublishedEvent notification, CancellationToken cancellationToken) + { + var news = await _newsRepo.FindAsync(notification.NewsId, cancellationToken) + .ConfigureAwait(false); + + if (news is null) + { + _logger.LogWarning( + "News {NewsId} not found for notification.", notification.NewsId); + return; + } + + await _dispatcher.DispatchAsync(new NotificationMessage( + TemplateCode: "NEWS_PUBLISHED", + RecipientUserId: news.AuthorId, + EventType: NotificationEventType.NewsPublished, + Channels: [NotificationChannel.InApp], + MetaData: new Dictionary(), + Locale: "en"), cancellationToken).ConfigureAwait(false); + } +} diff --git a/backend/src/CCE.Application/Notifications/Handlers/PostCreatedNotificationHandler.cs b/backend/src/CCE.Application/Notifications/Handlers/PostCreatedNotificationHandler.cs new file mode 100644 index 00000000..687c5184 --- /dev/null +++ b/backend/src/CCE.Application/Notifications/Handlers/PostCreatedNotificationHandler.cs @@ -0,0 +1,55 @@ +using CCE.Application.Community; +using CCE.Application.Notifications.Messages; +using CCE.Domain.Community.Events; +using CCE.Domain.Notifications; +using MediatR; +using Microsoft.Extensions.Logging; + +namespace CCE.Application.Notifications.Handlers; + +public sealed class PostCreatedNotificationHandler + : INotificationHandler +{ + private readonly ICommunityReadService _communityRead; + private readonly INotificationMessageDispatcher _dispatcher; + private readonly ILogger _logger; + + public PostCreatedNotificationHandler( + ICommunityReadService communityRead, + INotificationMessageDispatcher dispatcher, + ILogger logger) + { + _communityRead = communityRead; + _dispatcher = dispatcher; + _logger = logger; + } + + public async Task Handle(PostCreatedEvent notification, CancellationToken cancellationToken) + { + var followerIds = await _communityRead.GetTopicFollowerIdsAsync( + notification.TopicId, + notification.AuthorId, + cancellationToken) + .ConfigureAwait(false); + + if (followerIds.Count == 0) + { + _logger.LogInformation( + "No followers to notify for post {PostId} in topic {TopicId}", + notification.PostId, + notification.TopicId); + return; + } + + foreach (var userId in followerIds) + { + await _dispatcher.DispatchAsync(new NotificationMessage( + TemplateCode: "COMMUNITY_POST_CREATED", + RecipientUserId: userId, + EventType: NotificationEventType.CommunityPostCreated, + Channels: [NotificationChannel.InApp], + MetaData: new Dictionary(), + Locale: notification.Locale), cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/backend/src/CCE.Application/Notifications/Handlers/ResourcePublishedNotificationHandler.cs b/backend/src/CCE.Application/Notifications/Handlers/ResourcePublishedNotificationHandler.cs new file mode 100644 index 00000000..51e71790 --- /dev/null +++ b/backend/src/CCE.Application/Notifications/Handlers/ResourcePublishedNotificationHandler.cs @@ -0,0 +1,47 @@ +using CCE.Application.Content; +using CCE.Application.Notifications.Messages; +using CCE.Domain.Content.Events; +using CCE.Domain.Notifications; +using MediatR; +using Microsoft.Extensions.Logging; + +namespace CCE.Application.Notifications.Handlers; + +public sealed class ResourcePublishedNotificationHandler + : INotificationHandler +{ + private readonly IResourceRepository _resourceRepo; + private readonly INotificationMessageDispatcher _dispatcher; + private readonly ILogger _logger; + + public ResourcePublishedNotificationHandler( + IResourceRepository resourceRepo, + INotificationMessageDispatcher dispatcher, + ILogger logger) + { + _resourceRepo = resourceRepo; + _dispatcher = dispatcher; + _logger = logger; + } + + public async Task Handle(ResourcePublishedEvent notification, CancellationToken cancellationToken) + { + var resource = await _resourceRepo.FindAsync(notification.ResourceId, cancellationToken) + .ConfigureAwait(false); + + if (resource is null) + { + _logger.LogWarning( + "Resource {ResourceId} not found for notification.", notification.ResourceId); + return; + } + + await _dispatcher.DispatchAsync(new NotificationMessage( + TemplateCode: "RESOURCE_PUBLISHED", + RecipientUserId: resource.UploadedById, + EventType: NotificationEventType.ResourcePublished, + Channels: [NotificationChannel.InApp], + MetaData: new Dictionary(), + Locale: "en"), cancellationToken).ConfigureAwait(false); + } +} diff --git a/backend/src/CCE.Application/Notifications/INotificationChannelHandler.cs b/backend/src/CCE.Application/Notifications/INotificationChannelHandler.cs new file mode 100644 index 00000000..516a1572 --- /dev/null +++ b/backend/src/CCE.Application/Notifications/INotificationChannelHandler.cs @@ -0,0 +1,14 @@ +using CCE.Domain.Notifications; + +namespace CCE.Application.Notifications; + +public interface INotificationChannelHandler +{ + NotificationChannel Channel { get; } + + bool ShouldSend(UserNotificationSettings? settings); + + Task SendAsync( + RenderedNotification notification, + CancellationToken cancellationToken); +} diff --git a/backend/src/CCE.Application/Notifications/INotificationGateway.cs b/backend/src/CCE.Application/Notifications/INotificationGateway.cs new file mode 100644 index 00000000..689b1d2e --- /dev/null +++ b/backend/src/CCE.Application/Notifications/INotificationGateway.cs @@ -0,0 +1,8 @@ +namespace CCE.Application.Notifications; + +public interface INotificationGateway +{ + Task SendAsync( + NotificationDispatchRequest request, + CancellationToken cancellationToken); +} diff --git a/backend/src/CCE.Application/Notifications/INotificationLogRepository.cs b/backend/src/CCE.Application/Notifications/INotificationLogRepository.cs new file mode 100644 index 00000000..c18b13ff --- /dev/null +++ b/backend/src/CCE.Application/Notifications/INotificationLogRepository.cs @@ -0,0 +1,10 @@ +using CCE.Domain.Notifications; + +namespace CCE.Application.Notifications; + +public interface INotificationLogRepository +{ + Task GetAsync(System.Guid id, CancellationToken ct); + + Task AddAsync(NotificationLog log, CancellationToken ct); +} diff --git a/backend/src/CCE.Application/Notifications/INotificationTemplateRenderer.cs b/backend/src/CCE.Application/Notifications/INotificationTemplateRenderer.cs new file mode 100644 index 00000000..af0a0c9c --- /dev/null +++ b/backend/src/CCE.Application/Notifications/INotificationTemplateRenderer.cs @@ -0,0 +1,18 @@ +using CCE.Domain.Notifications; + +namespace CCE.Application.Notifications; + +public interface INotificationTemplateRenderer +{ + /// + /// Renders subject and body by replacing {{Variable}} placeholders with values from . + /// + /// The template to render. + /// Variable values keyed by name. + /// "ar" or "en". + /// A tuple of (subjectAr, subjectEn, body). + (string SubjectAr, string SubjectEn, string Body) Render( + NotificationTemplate template, + IReadOnlyDictionary variables, + string locale); +} diff --git a/backend/src/CCE.Application/Notifications/INotificationTemplateRepository.cs b/backend/src/CCE.Application/Notifications/INotificationTemplateRepository.cs new file mode 100644 index 00000000..8afc3fe4 --- /dev/null +++ b/backend/src/CCE.Application/Notifications/INotificationTemplateRepository.cs @@ -0,0 +1,19 @@ +using CCE.Domain.Notifications; + +namespace CCE.Application.Notifications; + +public interface INotificationTemplateRepository +{ + Task GetAsync(System.Guid id, CancellationToken ct); + + Task GetActiveByCodeAndChannelAsync( + string code, + NotificationChannel channel, + CancellationToken ct); + + Task> ListActiveByCodeAsync( + string code, + CancellationToken ct); + + Task AddAsync(NotificationTemplate template, CancellationToken ct); +} diff --git a/backend/src/CCE.Application/Notifications/INotificationTemplateService.cs b/backend/src/CCE.Application/Notifications/INotificationTemplateService.cs deleted file mode 100644 index be34f83c..00000000 --- a/backend/src/CCE.Application/Notifications/INotificationTemplateService.cs +++ /dev/null @@ -1,10 +0,0 @@ -using CCE.Domain.Notifications; - -namespace CCE.Application.Notifications; - -public interface INotificationTemplateService -{ - Task SaveAsync(NotificationTemplate template, CancellationToken ct); - Task FindAsync(System.Guid id, CancellationToken ct); - Task UpdateAsync(NotificationTemplate template, CancellationToken ct); -} diff --git a/backend/src/CCE.Application/Notifications/ISignalRNotificationPublisher.cs b/backend/src/CCE.Application/Notifications/ISignalRNotificationPublisher.cs new file mode 100644 index 00000000..837d5a57 --- /dev/null +++ b/backend/src/CCE.Application/Notifications/ISignalRNotificationPublisher.cs @@ -0,0 +1,11 @@ +using CCE.Domain.Notifications; + +namespace CCE.Application.Notifications; + +/// +/// Publishes a persisted in-app notification to real-time subscribers via SignalR. +/// +public interface ISignalRNotificationPublisher +{ + Task PublishAsync(UserNotification notification, CancellationToken cancellationToken); +} diff --git a/backend/src/CCE.Application/Notifications/IUserNotificationSettingsRepository.cs b/backend/src/CCE.Application/Notifications/IUserNotificationSettingsRepository.cs new file mode 100644 index 00000000..155ac509 --- /dev/null +++ b/backend/src/CCE.Application/Notifications/IUserNotificationSettingsRepository.cs @@ -0,0 +1,23 @@ +using CCE.Domain.Notifications; + +namespace CCE.Application.Notifications; + +public interface IUserNotificationSettingsRepository +{ + Task GetAsync( + System.Guid userId, + NotificationChannel channel, + string? eventCode, + CancellationToken ct); + + Task> ListForUserAsync( + System.Guid userId, + CancellationToken ct); + + Task> ListForUserAndChannelsAsync( + System.Guid userId, + IReadOnlyCollection channels, + CancellationToken ct); + + Task AddAsync(UserNotificationSettings settings, CancellationToken ct); +} diff --git a/backend/src/CCE.Application/Notifications/Messages/INotificationMessageDispatcher.cs b/backend/src/CCE.Application/Notifications/Messages/INotificationMessageDispatcher.cs new file mode 100644 index 00000000..80b26530 --- /dev/null +++ b/backend/src/CCE.Application/Notifications/Messages/INotificationMessageDispatcher.cs @@ -0,0 +1,6 @@ +namespace CCE.Application.Notifications.Messages; + +public interface INotificationMessageDispatcher +{ + Task DispatchAsync(NotificationMessage message, CancellationToken ct); +} diff --git a/backend/src/CCE.Application/Notifications/Messages/NotificationMessage.cs b/backend/src/CCE.Application/Notifications/Messages/NotificationMessage.cs new file mode 100644 index 00000000..7c106ced --- /dev/null +++ b/backend/src/CCE.Application/Notifications/Messages/NotificationMessage.cs @@ -0,0 +1,14 @@ +using CCE.Domain.Notifications; + +namespace CCE.Application.Notifications.Messages; + +public sealed record NotificationMessage( + string TemplateCode, + System.Guid? RecipientUserId, + NotificationEventType EventType, + IReadOnlyDictionary? MetaData = null, + IReadOnlyCollection? Channels = null, + string Locale = "en", + string? Email = null, + string? PhoneNumber = null, + string? CorrelationId = null); diff --git a/backend/src/CCE.Application/Notifications/NotificationChannelDispatchResult.cs b/backend/src/CCE.Application/Notifications/NotificationChannelDispatchResult.cs new file mode 100644 index 00000000..10eafb1b --- /dev/null +++ b/backend/src/CCE.Application/Notifications/NotificationChannelDispatchResult.cs @@ -0,0 +1,11 @@ +using CCE.Domain.Notifications; + +namespace CCE.Application.Notifications; + +public sealed record NotificationChannelDispatchResult( + NotificationChannel Channel, + NotificationDeliveryStatus Status, + Guid? NotificationLogId = null, + Guid? UserNotificationId = null, + string? ProviderMessageId = null, + string? Error = null); diff --git a/backend/src/CCE.Application/Notifications/NotificationDispatchRequest.cs b/backend/src/CCE.Application/Notifications/NotificationDispatchRequest.cs new file mode 100644 index 00000000..0ec3c683 --- /dev/null +++ b/backend/src/CCE.Application/Notifications/NotificationDispatchRequest.cs @@ -0,0 +1,16 @@ +using CCE.Domain.Notifications; + +namespace CCE.Application.Notifications; + +public sealed record NotificationDispatchRequest( + string TemplateCode, + Guid? RecipientUserId, + IReadOnlyCollection Channels, + IReadOnlyDictionary? Variables = null, + string Locale = "en", + string? Email = null, + string? PhoneNumber = null, + string? Source = null, + string? CorrelationId = null, + string? DeduplicationKey = null, + bool BypassSettings = false); diff --git a/backend/src/CCE.Application/Notifications/NotificationDispatchResult.cs b/backend/src/CCE.Application/Notifications/NotificationDispatchResult.cs new file mode 100644 index 00000000..5c1c7712 --- /dev/null +++ b/backend/src/CCE.Application/Notifications/NotificationDispatchResult.cs @@ -0,0 +1,11 @@ +using CCE.Domain.Notifications; + +namespace CCE.Application.Notifications; + +public sealed record NotificationDispatchResult( + string TemplateCode, + Guid? RecipientUserId, + IReadOnlyCollection Results) +{ + public bool IsSuccess => Results.All(r => r.Status != NotificationDeliveryStatus.Failed); +} diff --git a/backend/src/CCE.Application/Notifications/Public/Commands/MarkAllNotificationsRead/MarkAllNotificationsReadCommand.cs b/backend/src/CCE.Application/Notifications/Public/Commands/MarkAllNotificationsRead/MarkAllNotificationsReadCommand.cs index 1e75893e..81b8549f 100644 --- a/backend/src/CCE.Application/Notifications/Public/Commands/MarkAllNotificationsRead/MarkAllNotificationsReadCommand.cs +++ b/backend/src/CCE.Application/Notifications/Public/Commands/MarkAllNotificationsRead/MarkAllNotificationsReadCommand.cs @@ -1,5 +1,6 @@ +using CCE.Application.Common; using MediatR; namespace CCE.Application.Notifications.Public.Commands.MarkAllNotificationsRead; -public sealed record MarkAllNotificationsReadCommand(System.Guid UserId) : IRequest; +public sealed record MarkAllNotificationsReadCommand(System.Guid UserId) : IRequest>; diff --git a/backend/src/CCE.Application/Notifications/Public/Commands/MarkAllNotificationsRead/MarkAllNotificationsReadCommandHandler.cs b/backend/src/CCE.Application/Notifications/Public/Commands/MarkAllNotificationsRead/MarkAllNotificationsReadCommandHandler.cs index 303bbbcf..74449ea2 100644 --- a/backend/src/CCE.Application/Notifications/Public/Commands/MarkAllNotificationsRead/MarkAllNotificationsReadCommandHandler.cs +++ b/backend/src/CCE.Application/Notifications/Public/Commands/MarkAllNotificationsRead/MarkAllNotificationsReadCommandHandler.cs @@ -1,18 +1,33 @@ +using CCE.Application.Common; +using CCE.Application.Messages; +using CCE.Application.Notifications.Public; +using CCE.Domain.Common; using MediatR; namespace CCE.Application.Notifications.Public.Commands.MarkAllNotificationsRead; -public sealed class MarkAllNotificationsReadCommandHandler : IRequestHandler +public sealed class MarkAllNotificationsReadCommandHandler : IRequestHandler> { - private readonly IUserNotificationService _service; + private readonly IUserNotificationRepository _repo; + private readonly MessageFactory _msg; + private readonly ISystemClock _clock; - public MarkAllNotificationsReadCommandHandler(IUserNotificationService service) + public MarkAllNotificationsReadCommandHandler( + IUserNotificationRepository repo, + MessageFactory msg, + ISystemClock clock) { - _service = service; + _repo = repo; + _msg = msg; + _clock = clock; } - public async Task Handle(MarkAllNotificationsReadCommand request, CancellationToken cancellationToken) + public async Task> Handle(MarkAllNotificationsReadCommand request, CancellationToken cancellationToken) { - return await _service.MarkAllSentAsReadAsync(request.UserId, cancellationToken).ConfigureAwait(false); + var count = await _repo.MarkAllSentAsReadAsync( + request.UserId, + _clock, + cancellationToken).ConfigureAwait(false); + return _msg.NotificationsMarkedRead(count); } } diff --git a/backend/src/CCE.Application/Notifications/Public/Commands/MarkNotificationRead/MarkNotificationReadCommand.cs b/backend/src/CCE.Application/Notifications/Public/Commands/MarkNotificationRead/MarkNotificationReadCommand.cs index b44514c5..d6b305b9 100644 --- a/backend/src/CCE.Application/Notifications/Public/Commands/MarkNotificationRead/MarkNotificationReadCommand.cs +++ b/backend/src/CCE.Application/Notifications/Public/Commands/MarkNotificationRead/MarkNotificationReadCommand.cs @@ -1,5 +1,6 @@ +using CCE.Application.Common; using MediatR; namespace CCE.Application.Notifications.Public.Commands.MarkNotificationRead; -public sealed record MarkNotificationReadCommand(System.Guid Id, System.Guid UserId) : IRequest; +public sealed record MarkNotificationReadCommand(System.Guid Id, System.Guid UserId) : IRequest>; diff --git a/backend/src/CCE.Application/Notifications/Public/Commands/MarkNotificationRead/MarkNotificationReadCommandHandler.cs b/backend/src/CCE.Application/Notifications/Public/Commands/MarkNotificationRead/MarkNotificationReadCommandHandler.cs index 73107444..92a1445b 100644 --- a/backend/src/CCE.Application/Notifications/Public/Commands/MarkNotificationRead/MarkNotificationReadCommandHandler.cs +++ b/backend/src/CCE.Application/Notifications/Public/Commands/MarkNotificationRead/MarkNotificationReadCommandHandler.cs @@ -1,29 +1,41 @@ +using CCE.Application.Common; +using CCE.Application.Common.Interfaces; +using CCE.Application.Messages; +using CCE.Application.Notifications.Public; using CCE.Domain.Common; using MediatR; namespace CCE.Application.Notifications.Public.Commands.MarkNotificationRead; -public sealed class MarkNotificationReadCommandHandler : IRequestHandler +public sealed class MarkNotificationReadCommandHandler : IRequestHandler> { - private readonly IUserNotificationService _service; + private readonly IUserNotificationRepository _repo; + private readonly ICceDbContext _db; + private readonly MessageFactory _msg; private readonly ISystemClock _clock; - public MarkNotificationReadCommandHandler(IUserNotificationService service, ISystemClock clock) + public MarkNotificationReadCommandHandler( + IUserNotificationRepository repo, + ICceDbContext db, + MessageFactory msg, + ISystemClock clock) { - _service = service; + _repo = repo; + _db = db; + _msg = msg; _clock = clock; } - public async Task Handle(MarkNotificationReadCommand request, CancellationToken cancellationToken) + public async Task> Handle(MarkNotificationReadCommand request, CancellationToken cancellationToken) { - var notif = await _service.FindAsync(request.Id, cancellationToken).ConfigureAwait(false); + var notif = await _repo.GetAsync(request.Id, cancellationToken).ConfigureAwait(false); if (notif is null || notif.UserId != request.UserId) - throw new KeyNotFoundException($"Notification {request.Id} not found."); + return _msg.NotificationLogNotFound(); notif.MarkRead(_clock); - await _service.UpdateAsync(notif, cancellationToken).ConfigureAwait(false); + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - return Unit.Value; + return _msg.NotificationMarkedRead(); } } diff --git a/backend/src/CCE.Application/Notifications/Public/Commands/UpdateMyNotificationSettings/UpdateMyNotificationSettingsCommand.cs b/backend/src/CCE.Application/Notifications/Public/Commands/UpdateMyNotificationSettings/UpdateMyNotificationSettingsCommand.cs new file mode 100644 index 00000000..93aca2da --- /dev/null +++ b/backend/src/CCE.Application/Notifications/Public/Commands/UpdateMyNotificationSettings/UpdateMyNotificationSettingsCommand.cs @@ -0,0 +1,11 @@ +using CCE.Application.Common; +using CCE.Domain.Notifications; +using MediatR; + +namespace CCE.Application.Notifications.Public.Commands.UpdateMyNotificationSettings; + +public sealed record UpdateMyNotificationSettingsCommand( + System.Guid UserId, + NotificationChannel Channel, + bool IsEnabled, + string? EventCode = null) : IRequest>; diff --git a/backend/src/CCE.Application/Notifications/Public/Commands/UpdateMyNotificationSettings/UpdateMyNotificationSettingsCommandHandler.cs b/backend/src/CCE.Application/Notifications/Public/Commands/UpdateMyNotificationSettings/UpdateMyNotificationSettingsCommandHandler.cs new file mode 100644 index 00000000..659d6d97 --- /dev/null +++ b/backend/src/CCE.Application/Notifications/Public/Commands/UpdateMyNotificationSettings/UpdateMyNotificationSettingsCommandHandler.cs @@ -0,0 +1,52 @@ +using CCE.Application.Common; +using CCE.Application.Common.Interfaces; +using CCE.Application.Messages; +using CCE.Domain.Notifications; +using MediatR; + +namespace CCE.Application.Notifications.Public.Commands.UpdateMyNotificationSettings; + +public sealed class UpdateMyNotificationSettingsCommandHandler + : IRequestHandler> +{ + private readonly IUserNotificationSettingsRepository _repo; + private readonly ICceDbContext _db; + private readonly MessageFactory _msg; + + public UpdateMyNotificationSettingsCommandHandler( + IUserNotificationSettingsRepository repo, + ICceDbContext db, + MessageFactory msg) + { + _repo = repo; + _db = db; + _msg = msg; + } + + public async Task> Handle( + UpdateMyNotificationSettingsCommand request, + CancellationToken cancellationToken) + { + var existing = await _repo.GetAsync( + request.UserId, + request.Channel, + request.EventCode, + cancellationToken) + .ConfigureAwait(false); + + if (existing is not null) + { + existing.Update(request.IsEnabled); + } + else + { + var settings = UserNotificationSettings.Create( + request.UserId, request.Channel, request.IsEnabled, request.EventCode); + await _repo.AddAsync(settings, cancellationToken).ConfigureAwait(false); + } + + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + return _msg.NotificationSettingsUpdated(); + } +} diff --git a/backend/src/CCE.Application/Notifications/Public/Dtos/NotificationSettingsDto.cs b/backend/src/CCE.Application/Notifications/Public/Dtos/NotificationSettingsDto.cs new file mode 100644 index 00000000..fc24e15c --- /dev/null +++ b/backend/src/CCE.Application/Notifications/Public/Dtos/NotificationSettingsDto.cs @@ -0,0 +1,8 @@ +using CCE.Domain.Notifications; + +namespace CCE.Application.Notifications.Public.Dtos; + +public sealed record NotificationSettingsDto( + NotificationChannel Channel, + string? EventCode, + bool IsEnabled); diff --git a/backend/src/CCE.Application/Notifications/Public/IUserNotificationRepository.cs b/backend/src/CCE.Application/Notifications/Public/IUserNotificationRepository.cs new file mode 100644 index 00000000..7c84d815 --- /dev/null +++ b/backend/src/CCE.Application/Notifications/Public/IUserNotificationRepository.cs @@ -0,0 +1,16 @@ +using CCE.Domain.Common; +using CCE.Domain.Notifications; + +namespace CCE.Application.Notifications.Public; + +public interface IUserNotificationRepository +{ + Task GetAsync(System.Guid id, CancellationToken ct); + + Task AddAsync(UserNotification notification, CancellationToken ct); + + Task MarkAllSentAsReadAsync( + System.Guid userId, + ISystemClock clock, + CancellationToken ct); +} diff --git a/backend/src/CCE.Application/Notifications/Public/IUserNotificationService.cs b/backend/src/CCE.Application/Notifications/Public/IUserNotificationService.cs deleted file mode 100644 index 1e6d6a9c..00000000 --- a/backend/src/CCE.Application/Notifications/Public/IUserNotificationService.cs +++ /dev/null @@ -1,10 +0,0 @@ -using CCE.Domain.Notifications; - -namespace CCE.Application.Notifications.Public; - -public interface IUserNotificationService -{ - Task FindAsync(System.Guid id, CancellationToken ct); - Task UpdateAsync(UserNotification notification, CancellationToken ct); - Task MarkAllSentAsReadAsync(System.Guid userId, CancellationToken ct); -} diff --git a/backend/src/CCE.Application/Notifications/Public/Queries/GetMyNotificationSettings/GetMyNotificationSettingsQuery.cs b/backend/src/CCE.Application/Notifications/Public/Queries/GetMyNotificationSettings/GetMyNotificationSettingsQuery.cs new file mode 100644 index 00000000..c6b5538a --- /dev/null +++ b/backend/src/CCE.Application/Notifications/Public/Queries/GetMyNotificationSettings/GetMyNotificationSettingsQuery.cs @@ -0,0 +1,8 @@ +using CCE.Application.Common; +using CCE.Application.Notifications.Public.Dtos; +using MediatR; + +namespace CCE.Application.Notifications.Public.Queries.GetMyNotificationSettings; + +public sealed record GetMyNotificationSettingsQuery(System.Guid UserId) + : IRequest>>; diff --git a/backend/src/CCE.Application/Notifications/Public/Queries/GetMyNotificationSettings/GetMyNotificationSettingsQueryHandler.cs b/backend/src/CCE.Application/Notifications/Public/Queries/GetMyNotificationSettings/GetMyNotificationSettingsQueryHandler.cs new file mode 100644 index 00000000..cfdd6b15 --- /dev/null +++ b/backend/src/CCE.Application/Notifications/Public/Queries/GetMyNotificationSettings/GetMyNotificationSettingsQueryHandler.cs @@ -0,0 +1,49 @@ +using CCE.Application.Common; +using CCE.Application.Common.Interfaces; +using CCE.Application.Common.Pagination; +using CCE.Application.Messages; +using CCE.Application.Notifications.Public.Dtos; +using CCE.Domain.Notifications; +using MediatR; + +namespace CCE.Application.Notifications.Public.Queries.GetMyNotificationSettings; + +public sealed class GetMyNotificationSettingsQueryHandler + : IRequestHandler>> +{ + private readonly ICceDbContext _db; + private readonly MessageFactory _msg; + + public GetMyNotificationSettingsQueryHandler(ICceDbContext db, MessageFactory msg) + { + _db = db; + _msg = msg; + } + + public async Task>> Handle( + GetMyNotificationSettingsQuery request, + CancellationToken cancellationToken) + { + var explicitSettings = await _db.UserNotificationSettings + .Where(s => s.UserId == request.UserId) + .OrderBy(s => s.Channel) + .ThenBy(s => s.EventCode) + .ToListAsyncEither(cancellationToken) + .ConfigureAwait(false); + + var dtos = explicitSettings + .Select(s => new NotificationSettingsDto(s.Channel, s.EventCode, s.IsEnabled)) + .ToList(); + + // Ensure every channel has at least a default entry + foreach (NotificationChannel channel in Enum.GetValues()) + { + if (!dtos.Any(d => d.Channel == channel && d.EventCode is null)) + { + dtos.Insert(0, new NotificationSettingsDto(channel, null, true)); + } + } + + return _msg.Ok>(dtos, "ITEMS_LISTED"); + } +} diff --git a/backend/src/CCE.Application/Notifications/Public/Queries/GetMyUnreadCount/GetMyUnreadCountQuery.cs b/backend/src/CCE.Application/Notifications/Public/Queries/GetMyUnreadCount/GetMyUnreadCountQuery.cs index d7089046..8b1246a6 100644 --- a/backend/src/CCE.Application/Notifications/Public/Queries/GetMyUnreadCount/GetMyUnreadCountQuery.cs +++ b/backend/src/CCE.Application/Notifications/Public/Queries/GetMyUnreadCount/GetMyUnreadCountQuery.cs @@ -1,5 +1,6 @@ +using CCE.Application.Common; using MediatR; namespace CCE.Application.Notifications.Public.Queries.GetMyUnreadCount; -public sealed record GetMyUnreadCountQuery(System.Guid UserId) : IRequest; +public sealed record GetMyUnreadCountQuery(System.Guid UserId) : IRequest>; diff --git a/backend/src/CCE.Application/Notifications/Public/Queries/GetMyUnreadCount/GetMyUnreadCountQueryHandler.cs b/backend/src/CCE.Application/Notifications/Public/Queries/GetMyUnreadCount/GetMyUnreadCountQueryHandler.cs index ea2a4746..87d584cc 100644 --- a/backend/src/CCE.Application/Notifications/Public/Queries/GetMyUnreadCount/GetMyUnreadCountQueryHandler.cs +++ b/backend/src/CCE.Application/Notifications/Public/Queries/GetMyUnreadCount/GetMyUnreadCountQueryHandler.cs @@ -1,25 +1,30 @@ +using CCE.Application.Common; using CCE.Application.Common.Interfaces; using CCE.Application.Common.Pagination; +using CCE.Application.Messages; using CCE.Domain.Notifications; using MediatR; namespace CCE.Application.Notifications.Public.Queries.GetMyUnreadCount; -public sealed class GetMyUnreadCountQueryHandler : IRequestHandler +public sealed class GetMyUnreadCountQueryHandler : IRequestHandler> { private readonly ICceDbContext _db; + private readonly MessageFactory _msg; - public GetMyUnreadCountQueryHandler(ICceDbContext db) + public GetMyUnreadCountQueryHandler(ICceDbContext db, MessageFactory msg) { _db = db; + _msg = msg; } - public async Task Handle(GetMyUnreadCountQuery request, CancellationToken cancellationToken) + public async Task> Handle(GetMyUnreadCountQuery request, CancellationToken cancellationToken) { var userId = request.UserId; - return await _db.UserNotifications + var count = await _db.UserNotifications .Where(n => n.UserId == userId && n.Status == NotificationStatus.Sent) .CountAsyncEither(cancellationToken) .ConfigureAwait(false); + return _msg.Ok(count, "ITEMS_LISTED"); } } diff --git a/backend/src/CCE.Application/Notifications/Public/Queries/ListMyNotifications/ListMyNotificationsQuery.cs b/backend/src/CCE.Application/Notifications/Public/Queries/ListMyNotifications/ListMyNotificationsQuery.cs index e43f2372..6c476818 100644 --- a/backend/src/CCE.Application/Notifications/Public/Queries/ListMyNotifications/ListMyNotificationsQuery.cs +++ b/backend/src/CCE.Application/Notifications/Public/Queries/ListMyNotifications/ListMyNotificationsQuery.cs @@ -1,3 +1,4 @@ +using CCE.Application.Common; using CCE.Application.Common.Pagination; using CCE.Application.Notifications.Public.Dtos; using CCE.Domain.Notifications; @@ -9,4 +10,4 @@ public sealed record ListMyNotificationsQuery( System.Guid UserId, int Page = 1, int PageSize = 20, - NotificationStatus? Status = null) : IRequest>; + NotificationStatus? Status = null) : IRequest>>; diff --git a/backend/src/CCE.Application/Notifications/Public/Queries/ListMyNotifications/ListMyNotificationsQueryHandler.cs b/backend/src/CCE.Application/Notifications/Public/Queries/ListMyNotifications/ListMyNotificationsQueryHandler.cs index 6c0f1d04..10dca722 100644 --- a/backend/src/CCE.Application/Notifications/Public/Queries/ListMyNotifications/ListMyNotificationsQueryHandler.cs +++ b/backend/src/CCE.Application/Notifications/Public/Queries/ListMyNotifications/ListMyNotificationsQueryHandler.cs @@ -1,5 +1,7 @@ +using CCE.Application.Common; using CCE.Application.Common.Interfaces; using CCE.Application.Common.Pagination; +using CCE.Application.Messages; using CCE.Application.Notifications.Public.Dtos; using CCE.Domain.Notifications; using MediatR; @@ -7,16 +9,18 @@ namespace CCE.Application.Notifications.Public.Queries.ListMyNotifications; public sealed class ListMyNotificationsQueryHandler - : IRequestHandler> + : IRequestHandler>> { private readonly ICceDbContext _db; + private readonly MessageFactory _msg; - public ListMyNotificationsQueryHandler(ICceDbContext db) + public ListMyNotificationsQueryHandler(ICceDbContext db, MessageFactory msg) { _db = db; + _msg = msg; } - public async Task> Handle( + public async Task>> Handle( ListMyNotificationsQuery request, CancellationToken cancellationToken) { @@ -34,7 +38,8 @@ public async Task> Handle( .ConfigureAwait(false); var items = page.Items.Select(MapToDto).ToList(); - return new PagedResult(items, page.Page, page.PageSize, page.Total); + var result = new PagedResult(items, page.Page, page.PageSize, page.Total); + return _msg.Ok(result, "ITEMS_LISTED"); } internal static UserNotificationDto MapToDto(UserNotification n) => new( diff --git a/backend/src/CCE.Application/Notifications/Queries/GetNotificationTemplateById/GetNotificationTemplateByIdQuery.cs b/backend/src/CCE.Application/Notifications/Queries/GetNotificationTemplateById/GetNotificationTemplateByIdQuery.cs index 61fcb276..a9c03ef3 100644 --- a/backend/src/CCE.Application/Notifications/Queries/GetNotificationTemplateById/GetNotificationTemplateByIdQuery.cs +++ b/backend/src/CCE.Application/Notifications/Queries/GetNotificationTemplateById/GetNotificationTemplateByIdQuery.cs @@ -1,6 +1,7 @@ +using CCE.Application.Common; using CCE.Application.Notifications.Dtos; using MediatR; namespace CCE.Application.Notifications.Queries.GetNotificationTemplateById; -public sealed record GetNotificationTemplateByIdQuery(System.Guid Id) : IRequest; +public sealed record GetNotificationTemplateByIdQuery(System.Guid Id) : IRequest>; diff --git a/backend/src/CCE.Application/Notifications/Queries/GetNotificationTemplateById/GetNotificationTemplateByIdQueryHandler.cs b/backend/src/CCE.Application/Notifications/Queries/GetNotificationTemplateById/GetNotificationTemplateByIdQueryHandler.cs index 1bbcc3cc..983b220f 100644 --- a/backend/src/CCE.Application/Notifications/Queries/GetNotificationTemplateById/GetNotificationTemplateByIdQueryHandler.cs +++ b/backend/src/CCE.Application/Notifications/Queries/GetNotificationTemplateById/GetNotificationTemplateByIdQueryHandler.cs @@ -1,5 +1,7 @@ +using CCE.Application.Common; using CCE.Application.Common.Interfaces; using CCE.Application.Common.Pagination; +using CCE.Application.Messages; using CCE.Application.Notifications.Dtos; using CCE.Application.Notifications.Queries.ListNotificationTemplates; using MediatR; @@ -7,16 +9,18 @@ namespace CCE.Application.Notifications.Queries.GetNotificationTemplateById; public sealed class GetNotificationTemplateByIdQueryHandler - : IRequestHandler + : IRequestHandler> { private readonly ICceDbContext _db; + private readonly MessageFactory _msg; - public GetNotificationTemplateByIdQueryHandler(ICceDbContext db) + public GetNotificationTemplateByIdQueryHandler(ICceDbContext db, MessageFactory msg) { _db = db; + _msg = msg; } - public async Task Handle( + public async Task> Handle( GetNotificationTemplateByIdQuery request, CancellationToken cancellationToken) { @@ -25,6 +29,8 @@ public GetNotificationTemplateByIdQueryHandler(ICceDbContext db) .ToListAsyncEither(cancellationToken) .ConfigureAwait(false); var template = list.SingleOrDefault(); - return template is null ? null : ListNotificationTemplatesQueryHandler.MapToDto(template); + return template is null + ? _msg.NotificationTemplateNotFound() + : _msg.Ok(ListNotificationTemplatesQueryHandler.MapToDto(template), "ITEMS_LISTED"); } } diff --git a/backend/src/CCE.Application/Notifications/Queries/ListNotificationTemplates/ListNotificationTemplatesQuery.cs b/backend/src/CCE.Application/Notifications/Queries/ListNotificationTemplates/ListNotificationTemplatesQuery.cs index f9392987..a0f0826b 100644 --- a/backend/src/CCE.Application/Notifications/Queries/ListNotificationTemplates/ListNotificationTemplatesQuery.cs +++ b/backend/src/CCE.Application/Notifications/Queries/ListNotificationTemplates/ListNotificationTemplatesQuery.cs @@ -1,3 +1,4 @@ +using CCE.Application.Common; using CCE.Application.Common.Pagination; using CCE.Application.Notifications.Dtos; using CCE.Domain.Notifications; @@ -9,4 +10,4 @@ public sealed record ListNotificationTemplatesQuery( int Page = 1, int PageSize = 20, NotificationChannel? Channel = null, - bool? IsActive = null) : IRequest>; + bool? IsActive = null) : IRequest>>; diff --git a/backend/src/CCE.Application/Notifications/Queries/ListNotificationTemplates/ListNotificationTemplatesQueryHandler.cs b/backend/src/CCE.Application/Notifications/Queries/ListNotificationTemplates/ListNotificationTemplatesQueryHandler.cs index e9380649..86ae779b 100644 --- a/backend/src/CCE.Application/Notifications/Queries/ListNotificationTemplates/ListNotificationTemplatesQueryHandler.cs +++ b/backend/src/CCE.Application/Notifications/Queries/ListNotificationTemplates/ListNotificationTemplatesQueryHandler.cs @@ -1,5 +1,7 @@ +using CCE.Application.Common; using CCE.Application.Common.Interfaces; using CCE.Application.Common.Pagination; +using CCE.Application.Messages; using CCE.Application.Notifications.Dtos; using CCE.Domain.Notifications; using MediatR; @@ -7,16 +9,18 @@ namespace CCE.Application.Notifications.Queries.ListNotificationTemplates; public sealed class ListNotificationTemplatesQueryHandler - : IRequestHandler> + : IRequestHandler>> { private readonly ICceDbContext _db; + private readonly MessageFactory _msg; - public ListNotificationTemplatesQueryHandler(ICceDbContext db) + public ListNotificationTemplatesQueryHandler(ICceDbContext db, MessageFactory msg) { _db = db; + _msg = msg; } - public async Task> Handle( + public async Task>> Handle( ListNotificationTemplatesQuery request, CancellationToken cancellationToken) { @@ -38,7 +42,8 @@ public async Task> Handle( .ConfigureAwait(false); var items = page.Items.Select(MapToDto).ToList(); - return new PagedResult(items, page.Page, page.PageSize, page.Total); + var result = new PagedResult(items, page.Page, page.PageSize, page.Total); + return _msg.Ok(result, "ITEMS_LISTED"); } internal static NotificationTemplateDto MapToDto(NotificationTemplate t) => new( diff --git a/backend/src/CCE.Application/Notifications/RenderedNotification.cs b/backend/src/CCE.Application/Notifications/RenderedNotification.cs new file mode 100644 index 00000000..0a6dbbd8 --- /dev/null +++ b/backend/src/CCE.Application/Notifications/RenderedNotification.cs @@ -0,0 +1,16 @@ +using CCE.Domain.Notifications; + +namespace CCE.Application.Notifications; + +public sealed record RenderedNotification( + string TemplateCode, + System.Guid? RecipientUserId, + System.Guid TemplateId, + string Subject, + string SubjectAr, + string SubjectEn, + string Body, + NotificationChannel Channel, + string Locale, + string? Email = null, + string? PhoneNumber = null); diff --git a/backend/src/CCE.Application/PlatformSettings/Commands/CreateGlossaryEntry/CreateGlossaryEntryCommand.cs b/backend/src/CCE.Application/PlatformSettings/Commands/CreateGlossaryEntry/CreateGlossaryEntryCommand.cs new file mode 100644 index 00000000..b5bd0ff3 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Commands/CreateGlossaryEntry/CreateGlossaryEntryCommand.cs @@ -0,0 +1,10 @@ +using CCE.Application.Common; +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..450e0310 --- /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.Messages; +using CCE.Domain.Common; +using CCE.Domain.PlatformSettings; +using CCE.Domain.PlatformSettings.ValueObjects; +using MediatR; + +namespace CCE.Application.PlatformSettings.Commands.CreateGlossaryEntry; + +public sealed class CreateGlossaryEntryCommandHandler + : IRequestHandler> +{ + private readonly IAboutSettingsRepository _repo; + private readonly ICceDbContext _db; + private readonly MessageFactory _msg; + private readonly ICurrentUserAccessor _currentUser; + private readonly ISystemClock _clock; + + public CreateGlossaryEntryCommandHandler( + IAboutSettingsRepository repo, + ICceDbContext db, + MessageFactory msg, + ICurrentUserAccessor currentUser, + ISystemClock clock) + { + _repo = repo; + _db = db; + _msg = msg; + _currentUser = currentUser; + _clock = clock; + } + + public async Task> Handle( + CreateGlossaryEntryCommand request, CancellationToken cancellationToken) + { + var about = await _repo.GetAsync(cancellationToken).ConfigureAwait(false); + if (about is null) + return _msg.AboutSettingsNotFound(); + + var userId = _currentUser.GetUserId() + ?? throw new DomainException("User identity required."); + var term = LocalizedText.Create(request.TermAr, request.TermEn); + var definition = LocalizedText.Create(request.DefinitionAr, request.DefinitionEn); + + var entry = about.AddGlossaryEntry(term, definition, userId, _clock); + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + return _msg.Ok(entry.Id, "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..2d37685f --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Commands/CreateKnowledgePartner/CreateKnowledgePartnerCommand.cs @@ -0,0 +1,12 @@ +using CCE.Application.Common; +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..56cbfbb7 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Commands/CreateKnowledgePartner/CreateKnowledgePartnerCommandHandler.cs @@ -0,0 +1,55 @@ +using CCE.Application.Common; +using CCE.Application.Common.Interfaces; +using CCE.Application.Messages; +using CCE.Domain.Common; +using CCE.Domain.PlatformSettings; +using CCE.Domain.PlatformSettings.ValueObjects; +using MediatR; + +namespace CCE.Application.PlatformSettings.Commands.CreateKnowledgePartner; + +public sealed class CreateKnowledgePartnerCommandHandler + : IRequestHandler> +{ + private readonly IAboutSettingsRepository _repo; + private readonly ICceDbContext _db; + private readonly MessageFactory _msg; + private readonly ICurrentUserAccessor _currentUser; + private readonly ISystemClock _clock; + + public CreateKnowledgePartnerCommandHandler( + IAboutSettingsRepository repo, + ICceDbContext db, + MessageFactory msg, + ICurrentUserAccessor currentUser, + ISystemClock clock) + { + _repo = repo; + _db = db; + _msg = msg; + _currentUser = currentUser; + _clock = clock; + } + + public async Task> Handle( + CreateKnowledgePartnerCommand request, CancellationToken cancellationToken) + { + var about = await _repo.GetAsync(cancellationToken).ConfigureAwait(false); + if (about is null) + return _msg.AboutSettingsNotFound(); + + var userId = _currentUser.GetUserId() + ?? throw new DomainException("User identity required."); + var name = LocalizedText.Create(request.NameAr, request.NameEn); + LocalizedText? description = null; + if (!string.IsNullOrWhiteSpace(request.DescriptionAr) && !string.IsNullOrWhiteSpace(request.DescriptionEn)) + { + description = LocalizedText.Create(request.DescriptionAr, request.DescriptionEn); + } + + var partner = about.AddKnowledgePartner(name, description, request.LogoUrl, request.WebsiteUrl, userId, _clock); + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + return _msg.Ok(partner.Id, "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..12936b63 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Commands/CreatePolicySection/CreatePolicySectionCommand.cs @@ -0,0 +1,11 @@ +using CCE.Application.Common; +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..8474b785 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Commands/CreatePolicySection/CreatePolicySectionCommandHandler.cs @@ -0,0 +1,52 @@ +using CCE.Application.Common; +using CCE.Application.Common.Interfaces; +using CCE.Application.Messages; +using CCE.Domain.Common; +using CCE.Domain.PlatformSettings; +using CCE.Domain.PlatformSettings.ValueObjects; +using MediatR; + +namespace CCE.Application.PlatformSettings.Commands.CreatePolicySection; + +public sealed class CreatePolicySectionCommandHandler + : IRequestHandler> +{ + private readonly IPoliciesSettingsRepository _repo; + private readonly ICceDbContext _db; + private readonly MessageFactory _msg; + private readonly ICurrentUserAccessor _currentUser; + private readonly ISystemClock _clock; + + public CreatePolicySectionCommandHandler( + IPoliciesSettingsRepository repo, + ICceDbContext db, + MessageFactory msg, + ICurrentUserAccessor currentUser, + ISystemClock clock) + { + _repo = repo; + _db = db; + _msg = msg; + _currentUser = currentUser; + _clock = clock; + } + + public async Task> Handle( + CreatePolicySectionCommand request, CancellationToken cancellationToken) + { + var settings = await _repo.GetAsync(cancellationToken).ConfigureAwait(false); + if (settings is null) + return _msg.PoliciesSettingsNotFound(); + + var userId = _currentUser.GetUserId() + ?? throw new DomainException("User identity required."); + var title = LocalizedText.Create(request.TitleAr, request.TitleEn); + var content = LocalizedText.Create(request.ContentAr, request.ContentEn); + var type = (PolicySectionType)request.Type; + + var section = settings.AddSection(type, title, content, userId, _clock); + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + return _msg.Ok(section.Id, "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..37fccf71 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Commands/DeleteGlossaryEntry/DeleteGlossaryEntryCommandHandler.cs @@ -0,0 +1,42 @@ +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 IAboutSettingsRepository _repo; + private readonly ICceDbContext _db; + private readonly MessageFactory _msg; + + public DeleteGlossaryEntryCommandHandler( + IAboutSettingsRepository repo, + ICceDbContext db, + MessageFactory msg) + { + _repo = repo; + _db = db; + _msg = msg; + } + + public async Task> Handle( + DeleteGlossaryEntryCommand request, CancellationToken cancellationToken) + { + var about = await _repo.GetAsync(cancellationToken).ConfigureAwait(false); + if (about is null) + return _msg.AboutSettingsNotFound(); + + var entry = about.GlossaryEntries.FirstOrDefault(e => e.Id == request.Id); + if (entry is null) + return _msg.GlossaryEntryNotFound(); + + about.RemoveGlossaryEntry(entry); + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + return _msg.Ok("CONTENT_DELETED"); + } +} diff --git a/backend/src/CCE.Application/PlatformSettings/Commands/DeleteGlossaryEntry/DeleteGlossaryEntryCommandValidator.cs b/backend/src/CCE.Application/PlatformSettings/Commands/DeleteGlossaryEntry/DeleteGlossaryEntryCommandValidator.cs new file mode 100644 index 00000000..9ee16dfb --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Commands/DeleteGlossaryEntry/DeleteGlossaryEntryCommandValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace CCE.Application.PlatformSettings.Commands.DeleteGlossaryEntry; + +public sealed class DeleteGlossaryEntryCommandValidator + : AbstractValidator +{ + public DeleteGlossaryEntryCommandValidator() + { + RuleFor(x => x.Id).NotEmpty(); + } +} 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..98bb1e54 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Commands/DeleteKnowledgePartner/DeleteKnowledgePartnerCommandHandler.cs @@ -0,0 +1,42 @@ +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 IAboutSettingsRepository _repo; + private readonly ICceDbContext _db; + private readonly MessageFactory _msg; + + public DeleteKnowledgePartnerCommandHandler( + IAboutSettingsRepository repo, + ICceDbContext db, + MessageFactory msg) + { + _repo = repo; + _db = db; + _msg = msg; + } + + public async Task> Handle( + DeleteKnowledgePartnerCommand request, CancellationToken cancellationToken) + { + var about = await _repo.GetAsync(cancellationToken).ConfigureAwait(false); + if (about is null) + return _msg.AboutSettingsNotFound(); + + var partner = about.KnowledgePartners.FirstOrDefault(p => p.Id == request.Id); + if (partner is null) + return _msg.KnowledgePartnerNotFound(); + + about.RemoveKnowledgePartner(partner); + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + return _msg.Ok("CONTENT_DELETED"); + } +} diff --git a/backend/src/CCE.Application/PlatformSettings/Commands/DeleteKnowledgePartner/DeleteKnowledgePartnerCommandValidator.cs b/backend/src/CCE.Application/PlatformSettings/Commands/DeleteKnowledgePartner/DeleteKnowledgePartnerCommandValidator.cs new file mode 100644 index 00000000..5ba4c83d --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Commands/DeleteKnowledgePartner/DeleteKnowledgePartnerCommandValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace CCE.Application.PlatformSettings.Commands.DeleteKnowledgePartner; + +public sealed class DeleteKnowledgePartnerCommandValidator + : AbstractValidator +{ + public DeleteKnowledgePartnerCommandValidator() + { + RuleFor(x => x.Id).NotEmpty(); + } +} 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..3bbceec8 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Commands/DeletePolicySection/DeletePolicySectionCommandHandler.cs @@ -0,0 +1,42 @@ +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 IPoliciesSettingsRepository _repo; + private readonly ICceDbContext _db; + private readonly MessageFactory _msg; + + public DeletePolicySectionCommandHandler( + IPoliciesSettingsRepository repo, + ICceDbContext db, + MessageFactory msg) + { + _repo = repo; + _db = db; + _msg = msg; + } + + public async Task> Handle( + DeletePolicySectionCommand request, CancellationToken cancellationToken) + { + var settings = await _repo.GetAsync(cancellationToken).ConfigureAwait(false); + if (settings is null) + return _msg.PoliciesSettingsNotFound(); + + var section = settings.Sections.FirstOrDefault(s => s.Id == request.Id); + if (section is null) + return _msg.PolicySectionNotFound(); + + settings.RemoveSection(section); + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + return _msg.Ok("CONTENT_DELETED"); + } +} diff --git a/backend/src/CCE.Application/PlatformSettings/Commands/DeletePolicySection/DeletePolicySectionCommandValidator.cs b/backend/src/CCE.Application/PlatformSettings/Commands/DeletePolicySection/DeletePolicySectionCommandValidator.cs new file mode 100644 index 00000000..5ca4b102 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Commands/DeletePolicySection/DeletePolicySectionCommandValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace CCE.Application.PlatformSettings.Commands.DeletePolicySection; + +public sealed class DeletePolicySectionCommandValidator + : AbstractValidator +{ + public DeletePolicySectionCommandValidator() + { + RuleFor(x => x.Id).NotEmpty(); + } +} diff --git a/backend/src/CCE.Application/PlatformSettings/Commands/ReorderPolicySection/ReorderPolicySectionCommand.cs b/backend/src/CCE.Application/PlatformSettings/Commands/ReorderPolicySection/ReorderPolicySectionCommand.cs new file mode 100644 index 00000000..2d6b92ab --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Commands/ReorderPolicySection/ReorderPolicySectionCommand.cs @@ -0,0 +1,8 @@ +using CCE.Application.Common; +using MediatR; + +namespace CCE.Application.PlatformSettings.Commands.ReorderPolicySection; + +public sealed record ReorderPolicySectionCommand( + System.Guid Id, + int OrderIndex) : IRequest>; diff --git a/backend/src/CCE.Application/PlatformSettings/Commands/ReorderPolicySection/ReorderPolicySectionCommandHandler.cs b/backend/src/CCE.Application/PlatformSettings/Commands/ReorderPolicySection/ReorderPolicySectionCommandHandler.cs new file mode 100644 index 00000000..25c2a68c --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Commands/ReorderPolicySection/ReorderPolicySectionCommandHandler.cs @@ -0,0 +1,42 @@ +using CCE.Application.Common; +using CCE.Application.Common.Interfaces; +using CCE.Application.Messages; +using CCE.Domain.PlatformSettings; +using MediatR; + +namespace CCE.Application.PlatformSettings.Commands.ReorderPolicySection; + +public sealed class ReorderPolicySectionCommandHandler + : IRequestHandler> +{ + private readonly IPoliciesSettingsRepository _repo; + private readonly ICceDbContext _db; + private readonly MessageFactory _msg; + + public ReorderPolicySectionCommandHandler( + IPoliciesSettingsRepository repo, + ICceDbContext db, + MessageFactory msg) + { + _repo = repo; + _db = db; + _msg = msg; + } + + public async Task> Handle( + ReorderPolicySectionCommand request, CancellationToken cancellationToken) + { + var settings = await _repo.GetAsync(cancellationToken).ConfigureAwait(false); + if (settings is null) + return _msg.PoliciesSettingsNotFound(); + + var section = settings.Sections.FirstOrDefault(s => s.Id == request.Id); + if (section is null) + return _msg.PolicySectionNotFound(); + + settings.ReorderSection(section, request.OrderIndex); + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + return _msg.Ok(section.Id, "SECTION_REORDERED"); + } +} diff --git a/backend/src/CCE.Application/PlatformSettings/Commands/ReorderPolicySection/ReorderPolicySectionCommandValidator.cs b/backend/src/CCE.Application/PlatformSettings/Commands/ReorderPolicySection/ReorderPolicySectionCommandValidator.cs new file mode 100644 index 00000000..d005a7fc --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Commands/ReorderPolicySection/ReorderPolicySectionCommandValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; + +namespace CCE.Application.PlatformSettings.Commands.ReorderPolicySection; + +public sealed class ReorderPolicySectionCommandValidator + : AbstractValidator +{ + public ReorderPolicySectionCommandValidator() + { + RuleFor(x => x.Id).NotEmpty(); + RuleFor(x => x.OrderIndex).GreaterThanOrEqualTo(0); + } +} 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..0fccc951 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Commands/UpdateAboutSettings/UpdateAboutSettingsCommand.cs @@ -0,0 +1,9 @@ +using CCE.Application.Common; +using MediatR; + +namespace CCE.Application.PlatformSettings.Commands.UpdateAboutSettings; + +public sealed record UpdateAboutSettingsCommand( + string DescriptionAr, + string DescriptionEn, + string? HowToUseVideoUrl) : 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..60aa7744 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Commands/UpdateAboutSettings/UpdateAboutSettingsCommandHandler.cs @@ -0,0 +1,51 @@ +using CCE.Application.Common; +using CCE.Application.Common.Interfaces; +using CCE.Application.Messages; +using CCE.Domain.Common; +using CCE.Domain.PlatformSettings; +using CCE.Domain.PlatformSettings.ValueObjects; +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; + private readonly ICurrentUserAccessor _currentUser; + private readonly ISystemClock _clock; + + public UpdateAboutSettingsCommandHandler( + IAboutSettingsRepository repo, + ICceDbContext db, + MessageFactory msg, + ICurrentUserAccessor currentUser, + ISystemClock clock) + { + _repo = repo; + _db = db; + _msg = msg; + _currentUser = currentUser; + _clock = clock; + } + + public async Task> Handle( + UpdateAboutSettingsCommand request, CancellationToken cancellationToken) + { + var settings = await _repo.GetAsync(cancellationToken).ConfigureAwait(false); + if (settings is null) + return _msg.AboutSettingsNotFound(); + + var userId = _currentUser.GetUserId() + ?? throw new DomainException("User identity required."); + var description = LocalizedText.Create(request.DescriptionAr, request.DescriptionEn); + + settings.UpdateContent(description, request.HowToUseVideoUrl, userId, _clock); + + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + return _msg.Ok(settings.Id, "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..7aefed29 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Commands/UpdateAboutSettings/UpdateAboutSettingsCommandValidator.cs @@ -0,0 +1,13 @@ +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); + } +} 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..ac464dbc --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Commands/UpdateGlossaryEntry/UpdateGlossaryEntryCommand.cs @@ -0,0 +1,11 @@ +using CCE.Application.Common; +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..f5b85aed --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Commands/UpdateGlossaryEntry/UpdateGlossaryEntryCommandHandler.cs @@ -0,0 +1,55 @@ +using CCE.Application.Common; +using CCE.Application.Common.Interfaces; +using CCE.Application.Messages; +using CCE.Domain.Common; +using CCE.Domain.PlatformSettings; +using CCE.Domain.PlatformSettings.ValueObjects; +using MediatR; + +namespace CCE.Application.PlatformSettings.Commands.UpdateGlossaryEntry; + +public sealed class UpdateGlossaryEntryCommandHandler + : IRequestHandler> +{ + private readonly IAboutSettingsRepository _repo; + private readonly ICceDbContext _db; + private readonly MessageFactory _msg; + private readonly ICurrentUserAccessor _currentUser; + private readonly ISystemClock _clock; + + public UpdateGlossaryEntryCommandHandler( + IAboutSettingsRepository repo, + ICceDbContext db, + MessageFactory msg, + ICurrentUserAccessor currentUser, + ISystemClock clock) + { + _repo = repo; + _db = db; + _msg = msg; + _currentUser = currentUser; + _clock = clock; + } + + public async Task> Handle( + UpdateGlossaryEntryCommand request, CancellationToken cancellationToken) + { + var about = await _repo.GetAsync(cancellationToken).ConfigureAwait(false); + if (about is null) + return _msg.AboutSettingsNotFound(); + + var entry = about.GlossaryEntries.FirstOrDefault(e => e.Id == request.Id); + if (entry is null) + return _msg.GlossaryEntryNotFound(); + + var userId = _currentUser.GetUserId() + ?? throw new DomainException("User identity required."); + var term = LocalizedText.Create(request.TermAr, request.TermEn); + var definition = LocalizedText.Create(request.DefinitionAr, request.DefinitionEn); + + about.UpdateGlossaryEntry(entry, term, definition, userId, _clock); + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + return _msg.Ok(entry.Id, "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..7f0c87eb --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Commands/UpdateHomepageSettings/UpdateHomepageSettingsCommand.cs @@ -0,0 +1,12 @@ +using CCE.Application.Common; +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) : 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..6bf2086f --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Commands/UpdateHomepageSettings/UpdateHomepageSettingsCommandHandler.cs @@ -0,0 +1,59 @@ +using CCE.Application.Common; +using CCE.Application.Common.Interfaces; +using CCE.Application.Messages; +using CCE.Domain.Common; +using CCE.Domain.PlatformSettings; +using CCE.Domain.PlatformSettings.ValueObjects; +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; + private readonly ICurrentUserAccessor _currentUser; + private readonly ISystemClock _clock; + + public UpdateHomepageSettingsCommandHandler( + IHomepageSettingsRepository repo, + ICceDbContext db, + MessageFactory msg, + ICurrentUserAccessor currentUser, + ISystemClock clock) + { + _repo = repo; + _db = db; + _msg = msg; + _currentUser = currentUser; + _clock = clock; + } + + public async Task> Handle( + UpdateHomepageSettingsCommand request, CancellationToken cancellationToken) + { + var settings = await _repo.GetAsync(cancellationToken).ConfigureAwait(false); + if (settings is null) + return _msg.HomepageSettingsNotFound(); + + var userId = _currentUser.GetUserId() + ?? throw new DomainException("User identity required."); + var objective = LocalizedText.Create(request.ObjectiveAr, request.ObjectiveEn); + + settings.UpdateContent( + request.VideoUrl, + objective, + request.CceConceptsAr, + request.CceConceptsEn, + userId, + _clock); + + settings.SyncCountries(request.ParticipatingCountryIds, userId, _clock); + + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + return _msg.Ok(settings.Id, "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..d1a0f237 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Commands/UpdateHomepageSettings/UpdateHomepageSettingsCommandValidator.cs @@ -0,0 +1,13 @@ +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); + } +} 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..da48bed8 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Commands/UpdateKnowledgePartner/UpdateKnowledgePartnerCommand.cs @@ -0,0 +1,13 @@ +using CCE.Application.Common; +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..df103191 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Commands/UpdateKnowledgePartner/UpdateKnowledgePartnerCommandHandler.cs @@ -0,0 +1,59 @@ +using CCE.Application.Common; +using CCE.Application.Common.Interfaces; +using CCE.Application.Messages; +using CCE.Domain.Common; +using CCE.Domain.PlatformSettings; +using CCE.Domain.PlatformSettings.ValueObjects; +using MediatR; + +namespace CCE.Application.PlatformSettings.Commands.UpdateKnowledgePartner; + +public sealed class UpdateKnowledgePartnerCommandHandler + : IRequestHandler> +{ + private readonly IAboutSettingsRepository _repo; + private readonly ICceDbContext _db; + private readonly MessageFactory _msg; + private readonly ICurrentUserAccessor _currentUser; + private readonly ISystemClock _clock; + + public UpdateKnowledgePartnerCommandHandler( + IAboutSettingsRepository repo, + ICceDbContext db, + MessageFactory msg, + ICurrentUserAccessor currentUser, + ISystemClock clock) + { + _repo = repo; + _db = db; + _msg = msg; + _currentUser = currentUser; + _clock = clock; + } + + public async Task> Handle( + UpdateKnowledgePartnerCommand request, CancellationToken cancellationToken) + { + var about = await _repo.GetAsync(cancellationToken).ConfigureAwait(false); + if (about is null) + return _msg.AboutSettingsNotFound(); + + var partner = about.KnowledgePartners.FirstOrDefault(p => p.Id == request.Id); + if (partner is null) + return _msg.KnowledgePartnerNotFound(); + + var userId = _currentUser.GetUserId() + ?? throw new DomainException("User identity required."); + var name = LocalizedText.Create(request.NameAr, request.NameEn); + LocalizedText? description = null; + if (!string.IsNullOrWhiteSpace(request.DescriptionAr) && !string.IsNullOrWhiteSpace(request.DescriptionEn)) + { + description = LocalizedText.Create(request.DescriptionAr, request.DescriptionEn); + } + + about.UpdateKnowledgePartner(partner, name, description, request.LogoUrl, request.WebsiteUrl, userId, _clock); + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + return _msg.Ok(partner.Id, "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/UpdatePolicySection/UpdatePolicySectionCommand.cs b/backend/src/CCE.Application/PlatformSettings/Commands/UpdatePolicySection/UpdatePolicySectionCommand.cs new file mode 100644 index 00000000..152aa01e --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Commands/UpdatePolicySection/UpdatePolicySectionCommand.cs @@ -0,0 +1,11 @@ +using CCE.Application.Common; +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..5c57309d --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Commands/UpdatePolicySection/UpdatePolicySectionCommandHandler.cs @@ -0,0 +1,55 @@ +using CCE.Application.Common; +using CCE.Application.Common.Interfaces; +using CCE.Application.Messages; +using CCE.Domain.Common; +using CCE.Domain.PlatformSettings; +using CCE.Domain.PlatformSettings.ValueObjects; +using MediatR; + +namespace CCE.Application.PlatformSettings.Commands.UpdatePolicySection; + +public sealed class UpdatePolicySectionCommandHandler + : IRequestHandler> +{ + private readonly IPoliciesSettingsRepository _repo; + private readonly ICceDbContext _db; + private readonly MessageFactory _msg; + private readonly ICurrentUserAccessor _currentUser; + private readonly ISystemClock _clock; + + public UpdatePolicySectionCommandHandler( + IPoliciesSettingsRepository repo, + ICceDbContext db, + MessageFactory msg, + ICurrentUserAccessor currentUser, + ISystemClock clock) + { + _repo = repo; + _db = db; + _msg = msg; + _currentUser = currentUser; + _clock = clock; + } + + public async Task> Handle( + UpdatePolicySectionCommand request, CancellationToken cancellationToken) + { + var settings = await _repo.GetAsync(cancellationToken).ConfigureAwait(false); + if (settings is null) + return _msg.PoliciesSettingsNotFound(); + + var section = settings.Sections.FirstOrDefault(s => s.Id == request.Id); + if (section is null) + return _msg.PolicySectionNotFound(); + + var userId = _currentUser.GetUserId() + ?? throw new DomainException("User identity required."); + var title = LocalizedText.Create(request.TitleAr, request.TitleEn); + var content = LocalizedText.Create(request.ContentAr, request.ContentEn); + + settings.UpdateSection(section, title, content, userId, _clock); + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + return _msg.Ok(section.Id, "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..d362ebaa --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Dtos/AboutSettingsDto.cs @@ -0,0 +1,8 @@ +namespace CCE.Application.PlatformSettings.Dtos; + +public sealed record AboutSettingsDto( + System.Guid Id, + LocalizedTextDto Description, + string? HowToUseVideoUrl, + System.Collections.Generic.IReadOnlyList GlossaryEntries, + System.Collections.Generic.IReadOnlyList KnowledgePartners); 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..f28010e8 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Dtos/GlossaryEntryDto.cs @@ -0,0 +1,7 @@ +namespace CCE.Application.PlatformSettings.Dtos; + +public sealed record GlossaryEntryDto( + System.Guid Id, + LocalizedTextDto Term, + LocalizedTextDto Definition, + 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..f83381a0 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Dtos/HomepageSettingsDto.cs @@ -0,0 +1,14 @@ +namespace CCE.Application.PlatformSettings.Dtos; + +public sealed record HomepageSettingsDto( + System.Guid Id, + string? VideoUrl, + LocalizedTextDto Objective, + string CceConceptsAr, + string CceConceptsEn, + System.Collections.Generic.IReadOnlyList ParticipatingCountries); + +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..274825e0 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Dtos/KnowledgePartnerDto.cs @@ -0,0 +1,9 @@ +namespace CCE.Application.PlatformSettings.Dtos; + +public sealed record KnowledgePartnerDto( + System.Guid Id, + LocalizedTextDto Name, + string? LogoUrl, + string? WebsiteUrl, + LocalizedTextDto? Description, + int OrderIndex); diff --git a/backend/src/CCE.Application/PlatformSettings/Dtos/LocalizedTextDto.cs b/backend/src/CCE.Application/PlatformSettings/Dtos/LocalizedTextDto.cs new file mode 100644 index 00000000..34cdff82 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Dtos/LocalizedTextDto.cs @@ -0,0 +1,3 @@ +namespace CCE.Application.PlatformSettings.Dtos; + +public sealed record LocalizedTextDto(string Ar, string En); 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..d22a771b --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Dtos/PoliciesSettingsDto.cs @@ -0,0 +1,5 @@ +namespace CCE.Application.PlatformSettings.Dtos; + +public sealed record PoliciesSettingsDto( + System.Guid Id, + System.Collections.Generic.IReadOnlyList Sections); 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..8969ba21 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Dtos/PolicySectionDto.cs @@ -0,0 +1,8 @@ +namespace CCE.Application.PlatformSettings.Dtos; + +public sealed record PolicySectionDto( + System.Guid Id, + int Type, + LocalizedTextDto Title, + LocalizedTextDto Content, + 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..f3c68dbf --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/IAboutSettingsRepository.cs @@ -0,0 +1,9 @@ +using CCE.Domain.PlatformSettings; + +namespace CCE.Application.PlatformSettings; + +/// Repository for the single-row AboutSettings aggregate (with children). +public interface IAboutSettingsRepository +{ + Task GetAsync(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..6c98d46a --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/IHomepageSettingsRepository.cs @@ -0,0 +1,9 @@ +using CCE.Domain.PlatformSettings; + +namespace CCE.Application.PlatformSettings; + +/// Repository for the single-row HomepageSettings aggregate (with children). +public interface IHomepageSettingsRepository +{ + Task GetAsync(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..a1c461c2 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/IPoliciesSettingsRepository.cs @@ -0,0 +1,9 @@ +using CCE.Domain.PlatformSettings; + +namespace CCE.Application.PlatformSettings; + +/// Repository for the single-row PoliciesSettings aggregate (with children). +public interface IPoliciesSettingsRepository +{ + Task GetAsync(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..e90cb6f4 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Public/Dtos/PublicAboutSettingsDto.cs @@ -0,0 +1,9 @@ +using CCE.Application.PlatformSettings.Dtos; + +namespace CCE.Application.PlatformSettings.Public.Dtos; + +public sealed record PublicAboutSettingsDto( + LocalizedTextDto Description, + 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..5ed09640 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Public/Dtos/PublicGlossaryEntryDto.cs @@ -0,0 +1,7 @@ +using CCE.Application.PlatformSettings.Dtos; + +namespace CCE.Application.PlatformSettings.Public.Dtos; + +public sealed record PublicGlossaryEntryDto( + LocalizedTextDto Term, + LocalizedTextDto Definition); 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..0e319556 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Public/Dtos/PublicHomepageDto.cs @@ -0,0 +1,12 @@ +using CCE.Application.Content.Public.Dtos; +using CCE.Application.PlatformSettings.Dtos; + +namespace CCE.Application.PlatformSettings.Public.Dtos; + +public sealed record PublicHomepageDto( + string? VideoUrl, + LocalizedTextDto Objective, + 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..0a3bc2a1 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Public/Dtos/PublicKnowledgePartnerDto.cs @@ -0,0 +1,9 @@ +using CCE.Application.PlatformSettings.Dtos; + +namespace CCE.Application.PlatformSettings.Public.Dtos; + +public sealed record PublicKnowledgePartnerDto( + LocalizedTextDto Name, + string? LogoUrl, + string? WebsiteUrl, + LocalizedTextDto? Description); 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..c0f93282 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Public/Dtos/PublicPolicySectionDto.cs @@ -0,0 +1,8 @@ +using CCE.Application.PlatformSettings.Dtos; + +namespace CCE.Application.PlatformSettings.Public.Dtos; + +public sealed record PublicPolicySectionDto( + int Type, + LocalizedTextDto Title, + LocalizedTextDto Content); 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..157d6dc5 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Public/Queries/GetPublicAboutSettings/GetPublicAboutSettingsQueryHandler.cs @@ -0,0 +1,56 @@ +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.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( + new LocalizedTextDto(settings.Description.Ar, settings.Description.En), + settings.HowToUseVideoUrl, + glossary.Select(e => new PublicGlossaryEntryDto( + new LocalizedTextDto(e.Term.Ar, e.Term.En), + new LocalizedTextDto(e.Definition.Ar, e.Definition.En))).ToList(), + partners.Select(p => new PublicKnowledgePartnerDto( + new LocalizedTextDto(p.Name.Ar, p.Name.En), + p.LogoUrl, + p.WebsiteUrl, + p.Description is null ? null : new LocalizedTextDto(p.Description.Ar, p.Description.En))).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..800968b1 --- /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.Dtos; +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, + new LocalizedTextDto(settings.Objective.Ar, settings.Objective.En), + 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..79eeffea --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Public/Queries/GetPublicPoliciesSettings/GetPublicPoliciesSettingsQueryHandler.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.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, + new LocalizedTextDto(s.Title.Ar, s.Title.En), + new LocalizedTextDto(s.Content.Ar, s.Content.En))).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..dd5b10d7 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Queries/GetAboutSettings/GetAboutSettingsQueryHandler.cs @@ -0,0 +1,60 @@ +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, + new LocalizedTextDto(settings.Description.Ar, settings.Description.En), + settings.HowToUseVideoUrl, + glossary.Select(e => new GlossaryEntryDto( + e.Id, + new LocalizedTextDto(e.Term.Ar, e.Term.En), + new LocalizedTextDto(e.Definition.Ar, e.Definition.En), + e.OrderIndex)).ToList(), + partners.Select(p => new KnowledgePartnerDto( + p.Id, + new LocalizedTextDto(p.Name.Ar, p.Name.En), + p.LogoUrl, + p.WebsiteUrl, + p.Description is null ? null : new LocalizedTextDto(p.Description.Ar, p.Description.En), + p.OrderIndex)).ToList()), "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..72f992e4 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Queries/GetHomepageSettings/GetHomepageSettingsQueryHandler.cs @@ -0,0 +1,46 @@ +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, + new LocalizedTextDto(settings.Objective.Ar, settings.Objective.En), + settings.CceConceptsAr, + settings.CceConceptsEn, + countries), "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..f1f60891 --- /dev/null +++ b/backend/src/CCE.Application/PlatformSettings/Queries/GetPoliciesSettings/GetPoliciesSettingsQueryHandler.cs @@ -0,0 +1,45 @@ +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, + new LocalizedTextDto(s.Title.Ar, s.Title.En), + new LocalizedTextDto(s.Content.Ar, s.Content.En), + s.OrderIndex)).ToList()), "ITEMS_LISTED"); + } +} diff --git a/backend/src/CCE.Application/Verification/Commands/RequestVerification/RequestVerificationCommand.cs b/backend/src/CCE.Application/Verification/Commands/RequestVerification/RequestVerificationCommand.cs new file mode 100644 index 00000000..0d5fd973 --- /dev/null +++ b/backend/src/CCE.Application/Verification/Commands/RequestVerification/RequestVerificationCommand.cs @@ -0,0 +1,13 @@ +using CCE.Application.Common; +using CCE.Application.Verification.Dtos; +using CCE.Domain.Verification; +using MediatR; + +namespace CCE.Application.Verification.Commands.RequestVerification; + +public sealed record RequestVerificationCommand( + string? Token, + string? ProviderName, + string Contact, + OtpVerificationType TypeId) + : IRequest>; diff --git a/backend/src/CCE.Application/Verification/Commands/RequestVerification/RequestVerificationCommandHandler.cs b/backend/src/CCE.Application/Verification/Commands/RequestVerification/RequestVerificationCommandHandler.cs new file mode 100644 index 00000000..9549ef20 --- /dev/null +++ b/backend/src/CCE.Application/Verification/Commands/RequestVerification/RequestVerificationCommandHandler.cs @@ -0,0 +1,80 @@ +using CCE.Application.Common; +using CCE.Application.Common.Interfaces; +using CCE.Application.Messages; +using CCE.Application.Notifications; +using CCE.Application.Verification.Dtos; +using CCE.Domain.Notifications; +using CCE.Domain.Verification; +using MediatR; + +namespace CCE.Application.Verification.Commands.RequestVerification; + +internal sealed class RequestVerificationCommandHandler + : IRequestHandler> +{ + private readonly IOtpVerificationRepository _otpRepo; + private readonly ICceDbContext _db; + private readonly INotificationGateway _gateway; + private readonly MessageFactory _msg; + private readonly IOtpCodeGenerator _codeGenerator; + + public RequestVerificationCommandHandler( + IOtpVerificationRepository otpRepo, + ICceDbContext db, + INotificationGateway gateway, + MessageFactory msg, + IOtpCodeGenerator codeGenerator) + { + _otpRepo = otpRepo; + _db = db; + _gateway = gateway; + _msg = msg; + _codeGenerator = codeGenerator; + } + + public async Task> Handle( + RequestVerificationCommand request, CancellationToken ct) + { + var now = DateTimeOffset.UtcNow; + + var existing = await _otpRepo.FindActiveAsync(request.Contact, request.TypeId, now, ct) + .ConfigureAwait(false); + + if (existing is not null && !existing.CanResend(now)) + return _msg.OtpCooldownActive(); + + var (plainCode, codeHash) = _codeGenerator.Generate(); + + OtpVerification entity; + if (existing is not null) + { + existing.Refresh(codeHash, now); + _otpRepo.Update(existing); + entity = existing; + } + else + { + entity = OtpVerification.Create(request.Contact, request.TypeId, codeHash, now); + await _otpRepo.AddAsync(entity, ct).ConfigureAwait(false); + } + + var channel = request.TypeId == OtpVerificationType.Sms + ? NotificationChannel.Sms + : NotificationChannel.Email; + + await _gateway.SendAsync(new NotificationDispatchRequest( + TemplateCode: "OTP_VERIFICATION", + RecipientUserId: null, + Channels: [channel], + Variables: new Dictionary { ["Code"] = plainCode }, + PhoneNumber: request.TypeId == OtpVerificationType.Sms ? request.Contact : null, + Email: request.TypeId == OtpVerificationType.Email ? request.Contact : null, + BypassSettings: true), ct).ConfigureAwait(false); + + await _db.SaveChangesAsync(ct).ConfigureAwait(false); + + return _msg.Ok( + new RequestVerificationResponseDto(entity.Id, entity.ExpiresAt), + "OTP_SENT"); + } +} diff --git a/backend/src/CCE.Application/Verification/Commands/RequestVerification/RequestVerificationCommandValidator.cs b/backend/src/CCE.Application/Verification/Commands/RequestVerification/RequestVerificationCommandValidator.cs new file mode 100644 index 00000000..768b5826 --- /dev/null +++ b/backend/src/CCE.Application/Verification/Commands/RequestVerification/RequestVerificationCommandValidator.cs @@ -0,0 +1,24 @@ +using CCE.Domain.Verification; +using FluentValidation; + +namespace CCE.Application.Verification.Commands.RequestVerification; + +public sealed class RequestVerificationCommandValidator : AbstractValidator +{ + public RequestVerificationCommandValidator() + { + RuleFor(x => x.Contact).NotEmpty(); + + RuleFor(x => x.Contact) + .EmailAddress().When(x => x.TypeId == OtpVerificationType.Email); + + RuleFor(x => x.Contact) + .Matches(@"^\+?[0-9]{7,15}$").When(x => x.TypeId == OtpVerificationType.Sms); + + RuleFor(x => x.TypeId).IsInEnum(); + + RuleFor(x => x.ProviderName) + .NotEmpty().When(x => x.Token is not null) + .WithMessage("ProviderName is required when Token is provided."); + } +} diff --git a/backend/src/CCE.Application/Verification/Commands/VerifyOtp/VerifyOtpCommand.cs b/backend/src/CCE.Application/Verification/Commands/VerifyOtp/VerifyOtpCommand.cs new file mode 100644 index 00000000..5e895567 --- /dev/null +++ b/backend/src/CCE.Application/Verification/Commands/VerifyOtp/VerifyOtpCommand.cs @@ -0,0 +1,10 @@ +using CCE.Application.Common; +using CCE.Application.Verification.Dtos; +using MediatR; + +namespace CCE.Application.Verification.Commands.VerifyOtp; + +public sealed record VerifyOtpCommand( + Guid VerificationId, + string Code) + : IRequest>; diff --git a/backend/src/CCE.Application/Verification/Commands/VerifyOtp/VerifyOtpCommandHandler.cs b/backend/src/CCE.Application/Verification/Commands/VerifyOtp/VerifyOtpCommandHandler.cs new file mode 100644 index 00000000..95e169a6 --- /dev/null +++ b/backend/src/CCE.Application/Verification/Commands/VerifyOtp/VerifyOtpCommandHandler.cs @@ -0,0 +1,101 @@ +using CCE.Application.Common; +using CCE.Application.Common.Interfaces; +using CCE.Application.Identity; +using CCE.Application.Messages; +using CCE.Application.Verification.Dtos; +using CCE.Domain.Identity; +using CCE.Domain.Verification; +using MediatR; + +namespace CCE.Application.Verification.Commands.VerifyOtp; + +internal sealed class VerifyOtpCommandHandler + : IRequestHandler> +{ + private readonly IOtpVerificationRepository _otpRepo; + private readonly IUserVerificationRepository _verificationRepo; + private readonly IUserRepository _userRepo; + private readonly ICceDbContext _db; + private readonly MessageFactory _msg; + private readonly IOtpCodeGenerator _codeGenerator; + + public VerifyOtpCommandHandler( + IOtpVerificationRepository otpRepo, + IUserVerificationRepository verificationRepo, + IUserRepository userRepo, + ICceDbContext db, + MessageFactory msg, + IOtpCodeGenerator codeGenerator) + { + _otpRepo = otpRepo; + _verificationRepo = verificationRepo; + _userRepo = userRepo; + _db = db; + _msg = msg; + _codeGenerator = codeGenerator; + } + + public async Task> Handle( + VerifyOtpCommand request, CancellationToken ct) + { + var now = DateTimeOffset.UtcNow; + + var entity = await _otpRepo + .GetByIdAsync(request.VerificationId, ct) + .ConfigureAwait(false); + + if (entity is null) + return _msg.OtpNotFound(); + + if (entity.IsExpired(now)) + return _msg.OtpExpired(); + + if (entity.IsInvalidated) + return _msg.OtpInvalidated(); + + if (entity.HasExceededMaxAttempts()) + return _msg.OtpMaxAttempts(); + + entity.IncrementAttempt(); + + if (!_codeGenerator.Verify(request.Code, entity.CodeHash)) + { + _otpRepo.Update(entity); + await _db.SaveChangesAsync(ct).ConfigureAwait(false); + return _msg.OtpInvalidCode(); + } + + entity.MarkVerified(); + _otpRepo.Update(entity); + + var userVerification = await _verificationRepo + .FindAsync(entity.Contact, entity.TypeId, ct) + .ConfigureAwait(false); + + if (userVerification is null) + { + userVerification = UserVerification.Create(null, entity.Contact, entity.TypeId); + await _verificationRepo.AddAsync(userVerification, ct).ConfigureAwait(false); + } + userVerification.MarkVerified(now); + _verificationRepo.Update(userVerification); + + Guid? resolvedUserId = await StampUserConfirmedAsync(entity, ct).ConfigureAwait(false); + + await _db.SaveChangesAsync(ct).ConfigureAwait(false); + + return _msg.Ok(new VerifyOtpResponseDto(true, resolvedUserId), "OTP_VERIFIED"); + } + + private async Task StampUserConfirmedAsync(OtpVerification entity, CancellationToken ct) + { + var userId = await _userRepo + .FindUserIdByContactAsync(entity.Contact, entity.TypeId, ct) + .ConfigureAwait(false); + + if (userId is null) return null; + + await _userRepo.StampConfirmedAsync(userId.Value, entity.TypeId, ct).ConfigureAwait(false); + return userId; + } +} diff --git a/backend/src/CCE.Application/Verification/Commands/VerifyOtp/VerifyOtpCommandValidator.cs b/backend/src/CCE.Application/Verification/Commands/VerifyOtp/VerifyOtpCommandValidator.cs new file mode 100644 index 00000000..dccf3e2a --- /dev/null +++ b/backend/src/CCE.Application/Verification/Commands/VerifyOtp/VerifyOtpCommandValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace CCE.Application.Verification.Commands.VerifyOtp; + +public sealed class VerifyOtpCommandValidator : AbstractValidator +{ + public VerifyOtpCommandValidator() + { + RuleFor(x => x.VerificationId).NotEmpty(); + RuleFor(x => x.Code).NotEmpty().Length(6).Matches(@"^\d{6}$"); + } +} diff --git a/backend/src/CCE.Application/Verification/Dtos/RequestVerificationResponseDto.cs b/backend/src/CCE.Application/Verification/Dtos/RequestVerificationResponseDto.cs new file mode 100644 index 00000000..1db73e9a --- /dev/null +++ b/backend/src/CCE.Application/Verification/Dtos/RequestVerificationResponseDto.cs @@ -0,0 +1,6 @@ +namespace CCE.Application.Verification.Dtos; + +public sealed record RequestVerificationResponseDto( + Guid VerificationId, + DateTimeOffset ExpiresAt, + int CooldownSeconds = 60); diff --git a/backend/src/CCE.Application/Verification/Dtos/VerifyOtpResponseDto.cs b/backend/src/CCE.Application/Verification/Dtos/VerifyOtpResponseDto.cs new file mode 100644 index 00000000..1af92589 --- /dev/null +++ b/backend/src/CCE.Application/Verification/Dtos/VerifyOtpResponseDto.cs @@ -0,0 +1,5 @@ +namespace CCE.Application.Verification.Dtos; + +public sealed record VerifyOtpResponseDto( + bool Verified, + Guid? UserId); diff --git a/backend/src/CCE.Application/Verification/IOtpCodeGenerator.cs b/backend/src/CCE.Application/Verification/IOtpCodeGenerator.cs new file mode 100644 index 00000000..804be61a --- /dev/null +++ b/backend/src/CCE.Application/Verification/IOtpCodeGenerator.cs @@ -0,0 +1,8 @@ +namespace CCE.Application.Verification; + +public interface IOtpCodeGenerator +{ + (string PlainCode, string Hash) Generate(); + + bool Verify(string plainCode, string storedHash); +} diff --git a/backend/src/CCE.Application/Verification/IOtpVerificationRepository.cs b/backend/src/CCE.Application/Verification/IOtpVerificationRepository.cs new file mode 100644 index 00000000..22fe8d37 --- /dev/null +++ b/backend/src/CCE.Application/Verification/IOtpVerificationRepository.cs @@ -0,0 +1,11 @@ +using CCE.Application.Common.Interfaces; +using CCE.Domain.Verification; + +namespace CCE.Application.Verification; + +public interface IOtpVerificationRepository : IRepository +{ + Task FindActiveAsync( + string contact, OtpVerificationType typeId, + DateTimeOffset now, CancellationToken ct = default); +} diff --git a/backend/src/CCE.Application/Verification/IUserVerificationRepository.cs b/backend/src/CCE.Application/Verification/IUserVerificationRepository.cs new file mode 100644 index 00000000..c3b7b303 --- /dev/null +++ b/backend/src/CCE.Application/Verification/IUserVerificationRepository.cs @@ -0,0 +1,10 @@ +using CCE.Application.Common.Interfaces; +using CCE.Domain.Verification; + +namespace CCE.Application.Verification; + +public interface IUserVerificationRepository : IRepository +{ + Task FindAsync( + string contact, OtpVerificationType typeId, CancellationToken ct = default); +} 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.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/AuditableEntity.cs b/backend/src/CCE.Domain/Common/AuditableEntity.cs new file mode 100644 index 00000000..a1ab1f0c --- /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 : IEquatable +{ + 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/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/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/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/SoftDeletableEntity.cs b/backend/src/CCE.Domain/Common/SoftDeletableEntity.cs new file mode 100644 index 00000000..e2dda5ca --- /dev/null +++ b/backend/src/CCE.Domain/Common/SoftDeletableEntity.cs @@ -0,0 +1,45 @@ +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 : IEquatable +{ + 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; + 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/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.Domain/Community/Post.cs b/backend/src/CCE.Domain/Community/Post.cs index af1c4d60..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 : AggregateRoot, ISoftDeletable +public sealed class Post : AggregateRoot { 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..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 : Entity, ISoftDeletable +public sealed class Topic : AggregateRoot { 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..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 : AggregateRoot, ISoftDeletable +public sealed class Event : AggregateRoot { 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..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 : Entity, ISoftDeletable +public sealed class HomepageSection : AggregateRoot { 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..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 : AggregateRoot, ISoftDeletable +public sealed class News : AggregateRoot { 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/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 58d43f0b..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 : AggregateRoot, ISoftDeletable +public sealed class Page : AggregateRoot { 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..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 : AggregateRoot, ISoftDeletable +public sealed class Resource : AggregateRoot { 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..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 : AggregateRoot, ISoftDeletable +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); @@ -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..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 : AggregateRoot, ISoftDeletable +public sealed class CountryResourceRequest : AggregateRoot { 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..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 : Entity, ISoftDeletable +public sealed class ExpertProfile : AggregateRoot { 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..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 : AggregateRoot, ISoftDeletable +public sealed class ExpertRegistrationRequest : AggregateRoot { 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..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 : Entity, ISoftDeletable +public sealed class StateRepresentativeAssignment : AggregateRoot { 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..8f8a8f45 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"; @@ -26,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 @@ -67,6 +78,84 @@ 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, + 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 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."); + 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. /// @@ -101,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; @@ -121,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.Domain/InteractiveCity/CityScenario.cs b/backend/src/CCE.Domain/InteractiveCity/CityScenario.cs index b1ec83e4..4bed1c5c 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 : AggregateRoot { 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..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 : AggregateRoot, ISoftDeletable +public sealed class KnowledgeMap : AggregateRoot { 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; - } } diff --git a/backend/src/CCE.Domain/Media/MediaFile.cs b/backend/src/CCE.Domain/Media/MediaFile.cs new file mode 100644 index 00000000..028e4344 --- /dev/null +++ b/backend/src/CCE.Domain/Media/MediaFile.cs @@ -0,0 +1,113 @@ +using CCE.Domain.Common; + +namespace CCE.Domain.Media; + +[Audited] +public sealed class MediaFile : Entity +{ + private MediaFile( + System.Guid id, + string storageKey, + string url, + string originalFileName, + string mimeType, + long sizeBytes, + string? titleAr, + string? titleEn, + string? descriptionAr, + string? descriptionEn, + string? altTextAr, + string? altTextEn, + System.Guid uploadedById, + System.DateTimeOffset uploadedOn) : base(id) + { + StorageKey = storageKey; + Url = url; + OriginalFileName = originalFileName; + MimeType = mimeType; + SizeBytes = sizeBytes; + TitleAr = titleAr; + TitleEn = titleEn; + DescriptionAr = descriptionAr; + DescriptionEn = descriptionEn; + AltTextAr = altTextAr; + AltTextEn = altTextEn; + UploadedById = uploadedById; + UploadedOn = uploadedOn; + } + + public string StorageKey { get; private set; } + public string Url { get; private set; } + public string OriginalFileName { get; private set; } + public string MimeType { get; private set; } + public long SizeBytes { get; private set; } + public string? TitleAr { get; private set; } + public string? TitleEn { get; private set; } + public string? DescriptionAr { get; private set; } + public string? DescriptionEn { get; private set; } + public string? AltTextAr { get; private set; } + public string? AltTextEn { get; private set; } + public System.Guid UploadedById { get; private set; } + public System.DateTimeOffset UploadedOn { get; private set; } + + public static MediaFile Create( + string storageKey, + string url, + string originalFileName, + string mimeType, + long sizeBytes, + System.Guid uploadedById, + ISystemClock clock, + string? titleAr = null, + string? titleEn = null, + string? descriptionAr = null, + string? descriptionEn = null, + string? altTextAr = null, + string? altTextEn = null) + { + if (string.IsNullOrWhiteSpace(storageKey)) + throw new DomainException("StorageKey is required."); + if (string.IsNullOrWhiteSpace(url)) + throw new DomainException("Url is required."); + if (string.IsNullOrWhiteSpace(originalFileName)) + throw new DomainException("OriginalFileName is required."); + if (string.IsNullOrWhiteSpace(mimeType)) + throw new DomainException("MimeType is required."); + if (sizeBytes <= 0) + throw new DomainException("SizeBytes must be positive."); + if (uploadedById == System.Guid.Empty) + throw new DomainException("UploadedById is required."); + + return new MediaFile( + System.Guid.NewGuid(), + storageKey, + url, + originalFileName, + mimeType, + sizeBytes, + titleAr, + titleEn, + descriptionAr, + descriptionEn, + altTextAr, + altTextEn, + uploadedById, + clock.UtcNow); + } + + public void UpdateMetadata( + string? titleAr = null, + string? titleEn = null, + string? descriptionAr = null, + string? descriptionEn = null, + string? altTextAr = null, + string? altTextEn = null) + { + if (titleAr is not null) TitleAr = titleAr; + if (titleEn is not null) TitleEn = titleEn; + if (descriptionAr is not null) DescriptionAr = descriptionAr; + if (descriptionEn is not null) DescriptionEn = descriptionEn; + if (altTextAr is not null) AltTextAr = altTextAr; + if (altTextEn is not null) AltTextEn = altTextEn; + } +} diff --git a/backend/src/CCE.Domain/Notifications/NotificationDeliveryStatus.cs b/backend/src/CCE.Domain/Notifications/NotificationDeliveryStatus.cs new file mode 100644 index 00000000..657b0b3d --- /dev/null +++ b/backend/src/CCE.Domain/Notifications/NotificationDeliveryStatus.cs @@ -0,0 +1,9 @@ +namespace CCE.Domain.Notifications; + +public enum NotificationDeliveryStatus +{ + Pending = 0, + Sent = 1, + Failed = 2, + Skipped = 3 +} diff --git a/backend/src/CCE.Domain/Notifications/NotificationEventType.cs b/backend/src/CCE.Domain/Notifications/NotificationEventType.cs new file mode 100644 index 00000000..58af5c86 --- /dev/null +++ b/backend/src/CCE.Domain/Notifications/NotificationEventType.cs @@ -0,0 +1,14 @@ +namespace CCE.Domain.Notifications; + +public enum NotificationEventType +{ + ExpertRequestApproved = 0, + ExpertRequestRejected = 1, + CountryResourceApproved = 2, + CountryResourceRejected = 3, + NewsPublished = 4, + ResourcePublished = 5, + EventScheduled = 6, + CommunityPostCreated = 7, + AdminAccountCreated = 8 +} diff --git a/backend/src/CCE.Domain/Notifications/NotificationLog.cs b/backend/src/CCE.Domain/Notifications/NotificationLog.cs new file mode 100644 index 00000000..4b4ddbb2 --- /dev/null +++ b/backend/src/CCE.Domain/Notifications/NotificationLog.cs @@ -0,0 +1,98 @@ +using CCE.Domain.Common; + +namespace CCE.Domain.Notifications; + +/// +/// Tracks every attempted delivery per channel. Supports admin troubleshooting and retry. +/// +public sealed class NotificationLog : Entity +{ + private NotificationLog( + System.Guid id, + System.Guid? recipientUserId, + string templateCode, + System.Guid? templateId, + NotificationChannel channel, + string? payloadJson, + string? correlationId) : base(id) + { + RecipientUserId = recipientUserId; + TemplateCode = templateCode; + TemplateId = templateId; + Channel = channel; + Status = NotificationDeliveryStatus.Pending; + AttemptCount = 1; + CreatedOn = System.DateTimeOffset.UtcNow; + PayloadJson = payloadJson; + CorrelationId = correlationId; + } + + public System.Guid? RecipientUserId { get; private set; } + public string TemplateCode { get; private set; } + public System.Guid? TemplateId { get; private set; } + public NotificationChannel Channel { get; private set; } + public NotificationDeliveryStatus Status { get; private set; } + public string? ProviderMessageId { get; private set; } + public string? Error { get; private set; } + public int AttemptCount { get; private set; } + public System.DateTimeOffset CreatedOn { get; private set; } + public System.DateTimeOffset? SentOn { get; private set; } + public System.DateTimeOffset? FailedOn { get; private set; } + public string? CorrelationId { get; private set; } + public string? PayloadJson { get; private set; } + + public static NotificationLog Create( + System.Guid? recipientUserId, + string templateCode, + System.Guid? templateId, + NotificationChannel channel, + string? payloadJson = null, + string? correlationId = null) + { + if (string.IsNullOrWhiteSpace(templateCode)) + throw new DomainException("TemplateCode is required."); + + return new NotificationLog( + System.Guid.NewGuid(), + recipientUserId, + templateCode, + templateId, + channel, + payloadJson, + correlationId); + } + + public void MarkSent(string? providerMessageId = null) + { + if (Status == NotificationDeliveryStatus.Sent) + throw new DomainException("Log is already marked as sent."); + + Status = NotificationDeliveryStatus.Sent; + ProviderMessageId = providerMessageId; + SentOn = System.DateTimeOffset.UtcNow; + } + + public void MarkFailed(string error) + { + if (Status == NotificationDeliveryStatus.Sent) + throw new DomainException("Cannot mark a sent log as failed."); + + Status = NotificationDeliveryStatus.Failed; + Error = error; + FailedOn = System.DateTimeOffset.UtcNow; + } + + public void MarkSkipped(string reason) + { + Status = NotificationDeliveryStatus.Skipped; + Error = reason; + } + + public void IncrementAttempt() + { + AttemptCount++; + Status = NotificationDeliveryStatus.Pending; + Error = null; + FailedOn = null; + } +} diff --git a/backend/src/CCE.Domain/Notifications/UserNotificationSettings.cs b/backend/src/CCE.Domain/Notifications/UserNotificationSettings.cs new file mode 100644 index 00000000..bd7fc8df --- /dev/null +++ b/backend/src/CCE.Domain/Notifications/UserNotificationSettings.cs @@ -0,0 +1,49 @@ +using CCE.Domain.Common; + +namespace CCE.Domain.Notifications; + +/// +/// User-level opt-in/opt-out for notification channels. A row with null EventCode +/// acts as the default for that channel; explicit EventCode rows override the default. +/// +public sealed class UserNotificationSettings : Entity +{ + private UserNotificationSettings( + System.Guid id, + System.Guid userId, + NotificationChannel channel, + string? eventCode, + bool isEnabled) : base(id) + { + UserId = userId; + Channel = channel; + EventCode = eventCode; + IsEnabled = isEnabled; + UpdatedOn = System.DateTimeOffset.UtcNow; + } + + public System.Guid UserId { get; private set; } + public NotificationChannel Channel { get; private set; } + public string? EventCode { get; private set; } + public bool IsEnabled { get; private set; } + public System.DateTimeOffset UpdatedOn { get; private set; } + + public static UserNotificationSettings Create( + System.Guid userId, + NotificationChannel channel, + bool isEnabled, + string? eventCode = null) + { + if (userId == System.Guid.Empty) + throw new DomainException("UserId is required."); + + return new UserNotificationSettings( + System.Guid.NewGuid(), userId, channel, eventCode, isEnabled); + } + + public void Update(bool isEnabled) + { + IsEnabled = isEnabled; + UpdatedOn = System.DateTimeOffset.UtcNow; + } +} diff --git a/backend/src/CCE.Domain/PlatformSettings/AboutSettings.cs b/backend/src/CCE.Domain/PlatformSettings/AboutSettings.cs new file mode 100644 index 00000000..33c669ba --- /dev/null +++ b/backend/src/CCE.Domain/PlatformSettings/AboutSettings.cs @@ -0,0 +1,117 @@ +using CCE.Domain.Common; +using CCE.Domain.PlatformSettings.ValueObjects; + +namespace CCE.Domain.PlatformSettings; + +[Audited] +public sealed class AboutSettings : AggregateRoot +{ + private AboutSettings() : base(System.Guid.Empty) { } // EF Core materialization + + private AboutSettings(System.Guid id, LocalizedText description) : base(id) + { + Description = description; + } + + public LocalizedText Description { get; private set; } = null!; + public string? HowToUseVideoUrl { get; private set; } + public byte[] RowVersion { get; private set; } = System.Array.Empty(); + + public System.Collections.Generic.ICollection GlossaryEntries { get; private set; } = []; + public System.Collections.Generic.ICollection KnowledgePartners { get; private set; } = []; + + public static AboutSettings Create(LocalizedText description, System.Guid by, ISystemClock clock) + { + var settings = new AboutSettings(System.Guid.NewGuid(), description); + settings.MarkAsCreated(by, clock); + return settings; + } + + public void UpdateContent(LocalizedText description, string? howToUseVideoUrl, System.Guid by, ISystemClock clock) + { + Description = description; + HowToUseVideoUrl = howToUseVideoUrl; + MarkAsModified(by, clock); + } + + public GlossaryEntry AddGlossaryEntry(LocalizedText term, LocalizedText definition, System.Guid by, ISystemClock clock) + { + var nextOrder = GlossaryEntries.Count > 0 ? GlossaryEntries.Max(e => e.OrderIndex) + 1 : 0; + var entry = GlossaryEntry.Create(Id, term, definition, nextOrder, by, clock); + GlossaryEntries.Add(entry); + return entry; + } + + public void RemoveGlossaryEntry(GlossaryEntry entry) + { + if (!GlossaryEntries.Any(e => e.Id == entry.Id)) + throw new DomainException("Glossary entry not found in this AboutSettings."); + + GlossaryEntries.Remove(entry); + ReindexGlossary(); + } + + public void UpdateGlossaryEntry(GlossaryEntry entry, LocalizedText term, LocalizedText definition, System.Guid by, ISystemClock clock) + { + if (!GlossaryEntries.Any(e => e.Id == entry.Id)) + throw new DomainException("Glossary entry does not belong to this AboutSettings."); + + entry.UpdateContent(term, definition, by, clock); + } + + public KnowledgePartner AddKnowledgePartner( + LocalizedText name, + LocalizedText? description, + string? logoUrl, + string? websiteUrl, + System.Guid by, + ISystemClock clock) + { + var nextOrder = KnowledgePartners.Count > 0 ? KnowledgePartners.Max(p => p.OrderIndex) + 1 : 0; + var partner = KnowledgePartner.Create(Id, name, description, logoUrl, websiteUrl, nextOrder, by, clock); + KnowledgePartners.Add(partner); + return partner; + } + + public void RemoveKnowledgePartner(KnowledgePartner partner) + { + if (!KnowledgePartners.Any(p => p.Id == partner.Id)) + throw new DomainException("Knowledge partner not found in this AboutSettings."); + + KnowledgePartners.Remove(partner); + ReindexPartners(); + } + + public void UpdateKnowledgePartner( + KnowledgePartner partner, + LocalizedText name, + LocalizedText? description, + string? logoUrl, + string? websiteUrl, + System.Guid by, + ISystemClock clock) + { + if (!KnowledgePartners.Any(p => p.Id == partner.Id)) + throw new DomainException("Knowledge partner does not belong to this AboutSettings."); + + partner.UpdateContent(name, description, logoUrl, websiteUrl, by, clock); + } + + private void ReindexGlossary() + { + var ordered = GlossaryEntries.OrderBy(e => e.OrderIndex).ToList(); + for (int i = 0; i < ordered.Count; i++) + { + ordered[i].Reorder(i); + } + } + + private void ReindexPartners() + { + var ordered = KnowledgePartners.OrderBy(p => p.OrderIndex).ToList(); + for (int i = 0; i < ordered.Count; i++) + { + ordered[i].Reorder(i); + } + } +} diff --git a/backend/src/CCE.Domain/PlatformSettings/GlossaryEntry.cs b/backend/src/CCE.Domain/PlatformSettings/GlossaryEntry.cs new file mode 100644 index 00000000..6b224349 --- /dev/null +++ b/backend/src/CCE.Domain/PlatformSettings/GlossaryEntry.cs @@ -0,0 +1,58 @@ +using CCE.Domain.Common; +using CCE.Domain.PlatformSettings.ValueObjects; + +namespace CCE.Domain.PlatformSettings; + +public sealed class GlossaryEntry : AuditableEntity +{ + private GlossaryEntry() : base(System.Guid.Empty) { } // EF Core materialization + + private GlossaryEntry( + System.Guid id, + System.Guid aboutSettingsId, + LocalizedText term, + LocalizedText definition, + int orderIndex) : base(id) + { + AboutSettingsId = aboutSettingsId; + Term = term; + Definition = definition; + OrderIndex = orderIndex; + } + + public System.Guid AboutSettingsId { get; private set; } + public LocalizedText Term { get; private set; } = null!; + public LocalizedText Definition { get; private set; } = null!; + public int OrderIndex { get; private set; } + + public static GlossaryEntry Create( + System.Guid aboutSettingsId, + LocalizedText term, + LocalizedText definition, + int orderIndex, + System.Guid by, + ISystemClock clock) + { + if (aboutSettingsId == System.Guid.Empty) + throw new DomainException("AboutSettingsId is required."); + + var entry = new GlossaryEntry( + System.Guid.NewGuid(), aboutSettingsId, + term, definition, orderIndex); + entry.MarkAsCreated(by, clock); + return entry; + } + + public void UpdateContent( + LocalizedText term, + LocalizedText definition, + System.Guid by, + ISystemClock clock) + { + Term = term; + Definition = definition; + MarkAsModified(by, clock); + } + + 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..3b05491b --- /dev/null +++ b/backend/src/CCE.Domain/PlatformSettings/HomepageCountry.cs @@ -0,0 +1,34 @@ +using CCE.Domain.Common; + +namespace CCE.Domain.PlatformSettings; + +public sealed class HomepageCountry : AuditableEntity +{ + private HomepageCountry() : base(System.Guid.Empty) { } // EF Core materialization + + 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, System.Guid by, ISystemClock clock) + { + if (homepageSettingsId == System.Guid.Empty) + throw new DomainException("HomepageSettingsId is required."); + if (countryId == System.Guid.Empty) + throw new DomainException("CountryId is required."); + + var hc = new HomepageCountry(System.Guid.NewGuid(), homepageSettingsId, countryId, orderIndex); + hc.MarkAsCreated(by, clock); + return hc; + } + + public void Reorder(int orderIndex) => OrderIndex = 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..6200b8af --- /dev/null +++ b/backend/src/CCE.Domain/PlatformSettings/HomepageSettings.cs @@ -0,0 +1,71 @@ +using CCE.Domain.Common; +using CCE.Domain.PlatformSettings.ValueObjects; + +namespace CCE.Domain.PlatformSettings; + +[Audited] +public sealed class HomepageSettings : AggregateRoot +{ + private HomepageSettings() : base(System.Guid.Empty) { } // EF Core materialization + + private HomepageSettings(System.Guid id, LocalizedText objective) : base(id) + { + Objective = objective; + } + + public string? VideoUrl { get; private set; } + public LocalizedText Objective { get; private set; } = null!; + 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 System.Collections.Generic.ICollection Countries { get; private set; } = []; + + public static HomepageSettings Create(LocalizedText objective, System.Guid by, ISystemClock clock) + { + var settings = new HomepageSettings(System.Guid.NewGuid(), objective); + settings.MarkAsCreated(by, clock); + return settings; + } + + public void UpdateContent( + string? videoUrl, + LocalizedText objective, + string cceConceptsAr, + string cceConceptsEn, + System.Guid by, + ISystemClock clock) + { + VideoUrl = videoUrl; + Objective = objective; + CceConceptsAr = cceConceptsAr ?? string.Empty; + CceConceptsEn = cceConceptsEn ?? string.Empty; + MarkAsModified(by, clock); + } + + public void SyncCountries(System.Collections.Generic.IEnumerable countryIds, System.Guid by, ISystemClock clock) + { + var incoming = countryIds.ToList(); + var existing = Countries.ToList(); + + // Remove countries not in the incoming list + foreach (var ec in existing.Where(e => !incoming.Contains(e.CountryId)).ToList()) + { + Countries.Remove(ec); + } + + // Re-order / add new + var existingById = existing.ToDictionary(e => e.CountryId); + for (int i = 0; i < incoming.Count; i++) + { + if (existingById.TryGetValue(incoming[i], out var country)) + { + country.Reorder(i); + } + else + { + Countries.Add(HomepageCountry.Create(Id, incoming[i], i, by, clock)); + } + } + } +} diff --git a/backend/src/CCE.Domain/PlatformSettings/KnowledgePartner.cs b/backend/src/CCE.Domain/PlatformSettings/KnowledgePartner.cs new file mode 100644 index 00000000..1f133057 --- /dev/null +++ b/backend/src/CCE.Domain/PlatformSettings/KnowledgePartner.cs @@ -0,0 +1,70 @@ +using CCE.Domain.Common; +using CCE.Domain.PlatformSettings.ValueObjects; + +namespace CCE.Domain.PlatformSettings; + +public sealed class KnowledgePartner : AuditableEntity +{ + private KnowledgePartner() : base(System.Guid.Empty) { } // EF Core materialization + + private KnowledgePartner( + System.Guid id, + System.Guid aboutSettingsId, + LocalizedText name, + LocalizedText? description, + string? logoUrl, + string? websiteUrl, + int orderIndex) : base(id) + { + AboutSettingsId = aboutSettingsId; + Name = name; + Description = description; + LogoUrl = logoUrl; + WebsiteUrl = websiteUrl; + OrderIndex = orderIndex; + } + + public System.Guid AboutSettingsId { get; private set; } + public LocalizedText Name { get; private set; } = null!; + public LocalizedText? Description { get; private set; } + public string? LogoUrl { get; private set; } + public string? WebsiteUrl { get; private set; } + public int OrderIndex { get; private set; } + + public static KnowledgePartner Create( + System.Guid aboutSettingsId, + LocalizedText name, + LocalizedText? description, + string? logoUrl, + string? websiteUrl, + int orderIndex, + System.Guid by, + ISystemClock clock) + { + if (aboutSettingsId == System.Guid.Empty) + throw new DomainException("AboutSettingsId is required."); + + var partner = new KnowledgePartner( + System.Guid.NewGuid(), aboutSettingsId, + name, description, logoUrl, websiteUrl, orderIndex); + partner.MarkAsCreated(by, clock); + return partner; + } + + public void UpdateContent( + LocalizedText name, + LocalizedText? description, + string? logoUrl, + string? websiteUrl, + System.Guid by, + ISystemClock clock) + { + Name = name; + Description = description; + LogoUrl = logoUrl; + WebsiteUrl = websiteUrl; + MarkAsModified(by, clock); + } + + 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..8b34f29e --- /dev/null +++ b/backend/src/CCE.Domain/PlatformSettings/PoliciesSettings.cs @@ -0,0 +1,76 @@ +using CCE.Domain.Common; +using CCE.Domain.PlatformSettings.ValueObjects; + +namespace CCE.Domain.PlatformSettings; + +[Audited] +public sealed class PoliciesSettings : AggregateRoot +{ + private PoliciesSettings() : base(System.Guid.Empty) { } // EF Core materialization + + private PoliciesSettings(System.Guid id) : base(id) { } + + public byte[] RowVersion { get; private set; } = System.Array.Empty(); + + public System.Collections.Generic.ICollection Sections { get; private set; } = []; + + public static PoliciesSettings Create(System.Guid by, ISystemClock clock) + { + var settings = new PoliciesSettings(System.Guid.NewGuid()); + settings.MarkAsCreated(by, clock); + return settings; + } + + public PolicySection AddSection( + PolicySectionType type, + LocalizedText title, + LocalizedText content, + System.Guid by, + ISystemClock clock) + { + var nextOrder = Sections.Count > 0 ? Sections.Max(s => s.OrderIndex) + 1 : 0; + var section = PolicySection.Create(Id, type, title, content, nextOrder, by, clock); + Sections.Add(section); + return section; + } + + public void RemoveSection(PolicySection section) + { + if (!Sections.Any(s => s.Id == section.Id)) + throw new DomainException("Section not found in this PoliciesSettings."); + + Sections.Remove(section); + ReindexSections(); + } + + public void UpdateSection( + PolicySection section, + LocalizedText title, + LocalizedText content, + System.Guid by, + ISystemClock clock) + { + if (!Sections.Any(s => s.Id == section.Id)) + throw new DomainException("Section does not belong to this PoliciesSettings."); + + section.UpdateContent(title, content, by, clock); + } + + public void ReorderSection(PolicySection section, int newOrderIndex) + { + if (!Sections.Any(s => s.Id == section.Id)) + throw new DomainException("Section does not belong to this PoliciesSettings."); + + section.Reorder(newOrderIndex); + ReindexSections(); + } + + private void ReindexSections() + { + var ordered = Sections.OrderBy(s => s.OrderIndex).ToList(); + for (int i = 0; i < ordered.Count; i++) + { + ordered[i].Reorder(i); + } + } +} diff --git a/backend/src/CCE.Domain/PlatformSettings/PolicySection.cs b/backend/src/CCE.Domain/PlatformSettings/PolicySection.cs new file mode 100644 index 00000000..ff818cec --- /dev/null +++ b/backend/src/CCE.Domain/PlatformSettings/PolicySection.cs @@ -0,0 +1,62 @@ +using CCE.Domain.Common; +using CCE.Domain.PlatformSettings.ValueObjects; + +namespace CCE.Domain.PlatformSettings; + +public sealed class PolicySection : AuditableEntity +{ + private PolicySection() : base(System.Guid.Empty) { } // EF Core materialization + + private PolicySection( + System.Guid id, + System.Guid policiesSettingsId, + PolicySectionType type, + LocalizedText title, + LocalizedText content, + int orderIndex) : base(id) + { + PoliciesSettingsId = policiesSettingsId; + Type = type; + Title = title; + Content = content; + OrderIndex = orderIndex; + } + + public System.Guid PoliciesSettingsId { get; private set; } + public PolicySectionType Type { get; private set; } + public LocalizedText Title { get; private set; } = null!; + public LocalizedText Content { get; private set; } = null!; + public int OrderIndex { get; private set; } + + public static PolicySection Create( + System.Guid policiesSettingsId, + PolicySectionType type, + LocalizedText title, + LocalizedText content, + int orderIndex, + System.Guid by, + ISystemClock clock) + { + if (policiesSettingsId == System.Guid.Empty) + throw new DomainException("PoliciesSettingsId is required."); + + var section = new PolicySection( + System.Guid.NewGuid(), policiesSettingsId, + type, title, content, orderIndex); + section.MarkAsCreated(by, clock); + return section; + } + + public void UpdateContent( + LocalizedText title, + LocalizedText content, + System.Guid by, + ISystemClock clock) + { + Title = title; + Content = content; + MarkAsModified(by, clock); + } + + 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.Domain/PlatformSettings/ValueObjects/LocalizedText.cs b/backend/src/CCE.Domain/PlatformSettings/ValueObjects/LocalizedText.cs new file mode 100644 index 00000000..23bbc8e9 --- /dev/null +++ b/backend/src/CCE.Domain/PlatformSettings/ValueObjects/LocalizedText.cs @@ -0,0 +1,35 @@ +using CCE.Domain.Common; + +namespace CCE.Domain.PlatformSettings.ValueObjects; + +/// +/// Immutable bilingual text value object. Used throughout PlatformSettings +/// to replace paired Ar/En properties with a single cohesive concept. +/// +public sealed class LocalizedText +{ + public string Ar { get; private init; } = string.Empty; + public string En { get; private init; } = string.Empty; + + private LocalizedText() { } // EF Core materialization + + private LocalizedText(string ar, string en) + { + Ar = ar; + En = en; + } + + /// Creates a with validation (both required). + public static LocalizedText Create(string ar, string en) + { + if (string.IsNullOrWhiteSpace(ar)) throw new DomainException("Arabic text is required."); + if (string.IsNullOrWhiteSpace(en)) throw new DomainException("English text is required."); + return new LocalizedText(ar, en); + } + + /// Creates a without validation (allows empty strings). + public static LocalizedText From(string ar, string en) + { + return new LocalizedText(ar ?? string.Empty, en ?? string.Empty); + } +} diff --git a/backend/src/CCE.Domain/Verification/OtpVerification.cs b/backend/src/CCE.Domain/Verification/OtpVerification.cs new file mode 100644 index 00000000..90861bb5 --- /dev/null +++ b/backend/src/CCE.Domain/Verification/OtpVerification.cs @@ -0,0 +1,61 @@ +using CCE.Domain.Common; + +namespace CCE.Domain.Verification; + +public sealed class OtpVerification : AggregateRoot +{ + private OtpVerification() : base(Guid.NewGuid()) { } + private OtpVerification(Guid id) : base(id) { } + + public string Contact { get; private set; } = string.Empty; + public OtpVerificationType TypeId { get; private set; } + public string CodeHash { get; private set; } = string.Empty; + public DateTimeOffset ExpiresAt { get; private set; } + public DateTimeOffset CreatedAt { get; private set; } + public DateTimeOffset? LastSentAt { get; private set; } + public int AttemptCount { get; private set; } + public bool IsVerified { get; private set; } + public bool IsInvalidated { get; private set; } + + public static OtpVerification Create( + string contact, + OtpVerificationType typeId, + string codeHash, + DateTimeOffset now) + { + return new OtpVerification(Guid.NewGuid()) + { + Contact = contact, + TypeId = typeId, + CodeHash = codeHash, + ExpiresAt = now.AddMinutes(5), + CreatedAt = now, + LastSentAt = now, + AttemptCount = 0, + IsVerified = false, + IsInvalidated = false, + }; + } + + public bool CanResend(DateTimeOffset now) + => LastSentAt is null || (now - LastSentAt.Value).TotalSeconds >= 60; + + public bool IsExpired(DateTimeOffset now) => now >= ExpiresAt; + + public bool HasExceededMaxAttempts() => AttemptCount >= 5; + + public void Refresh(string newCodeHash, DateTimeOffset now) + { + CodeHash = newCodeHash; + ExpiresAt = now.AddMinutes(5); + LastSentAt = now; + AttemptCount = 0; + IsInvalidated = false; + } + + public void IncrementAttempt() => AttemptCount++; + + public void MarkVerified() => IsVerified = true; + + public void Invalidate() => IsInvalidated = true; +} diff --git a/backend/src/CCE.Domain/Verification/OtpVerificationType.cs b/backend/src/CCE.Domain/Verification/OtpVerificationType.cs new file mode 100644 index 00000000..11be2e61 --- /dev/null +++ b/backend/src/CCE.Domain/Verification/OtpVerificationType.cs @@ -0,0 +1,7 @@ +namespace CCE.Domain.Verification; + +public enum OtpVerificationType +{ + Sms = 0, + Email = 1, +} diff --git a/backend/src/CCE.Domain/Verification/UserVerification.cs b/backend/src/CCE.Domain/Verification/UserVerification.cs new file mode 100644 index 00000000..961a4af1 --- /dev/null +++ b/backend/src/CCE.Domain/Verification/UserVerification.cs @@ -0,0 +1,30 @@ +using CCE.Domain.Common; + +namespace CCE.Domain.Verification; + +public sealed class UserVerification : AggregateRoot +{ + private UserVerification() : base(Guid.NewGuid()) { } + private UserVerification(Guid id) : base(id) { } + + public Guid? UserId { get; private set; } + public string Contact { get; private set; } = string.Empty; + public OtpVerificationType TypeId { get; private set; } + public bool IsVerified { get; private set; } + public DateTimeOffset? VerifiedAt { get; private set; } + + public static UserVerification Create(Guid? userId, string contact, OtpVerificationType typeId) + => new(Guid.NewGuid()) + { + UserId = userId, + Contact = contact, + TypeId = typeId, + IsVerified = false, + }; + + public void MarkVerified(DateTimeOffset now) + { + IsVerified = true; + VerifiedAt = now; + } +} diff --git a/backend/src/CCE.Infrastructure/CCE.Infrastructure.csproj b/backend/src/CCE.Infrastructure/CCE.Infrastructure.csproj index 0251abbf..457884b6 100644 --- a/backend/src/CCE.Infrastructure/CCE.Infrastructure.csproj +++ b/backend/src/CCE.Infrastructure/CCE.Infrastructure.csproj @@ -13,13 +13,7 @@ - - - - - - - + @@ -39,15 +33,22 @@ + + + + + + + diff --git a/backend/src/CCE.Infrastructure/CceInfrastructureOptions.cs b/backend/src/CCE.Infrastructure/CceInfrastructureOptions.cs index 61f60aa1..0d374b96 100644 --- a/backend/src/CCE.Infrastructure/CceInfrastructureOptions.cs +++ b/backend/src/CCE.Infrastructure/CceInfrastructureOptions.cs @@ -29,6 +29,9 @@ public sealed class CceInfrastructureOptions public IReadOnlyList AllowedAssetMimeTypes { get; init; } = new[] { "application/pdf", "image/png", "image/jpeg", "image/svg+xml", "video/mp4", "application/zip" }; + /// Root directory for media file storage. When under wwwroot/, files are also served as static content. + public string MediaUploadsRoot { get; init; } = "./wwwroot/media/"; + /// Meilisearch HTTP base URL. Default http://localhost:7700. public string MeilisearchUrl { get; init; } = "http://localhost:7700"; diff --git a/backend/src/CCE.Infrastructure/Communication/GatewayEmailSender.cs b/backend/src/CCE.Infrastructure/Communication/GatewayEmailSender.cs new file mode 100644 index 00000000..2cd833c7 --- /dev/null +++ b/backend/src/CCE.Infrastructure/Communication/GatewayEmailSender.cs @@ -0,0 +1,52 @@ +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; + +/// +/// implementation that delegates to the +/// integration gateway via . +/// +public sealed class GatewayEmailSender : IEmailSender +{ + private readonly ICommunicationGatewayClient _client; + private readonly IOptions _options; + private readonly 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, string? templateId = null, CancellationToken ct = default) + { + 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 (!"success".Equals(response.Status, StringComparison.OrdinalIgnoreCase)) + { + _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} (id {Id})", + to, subject, response.Id); + } +} diff --git a/backend/src/CCE.Infrastructure/Community/CommunityReadService.cs b/backend/src/CCE.Infrastructure/Community/CommunityReadService.cs new file mode 100644 index 00000000..8996b751 --- /dev/null +++ b/backend/src/CCE.Infrastructure/Community/CommunityReadService.cs @@ -0,0 +1,36 @@ +using CCE.Application.Community; +using CCE.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace CCE.Infrastructure.Community; + +public sealed class CommunityReadService : ICommunityReadService +{ + private readonly CceDbContext _db; + + public CommunityReadService(CceDbContext db) + { + _db = db; + } + + public async Task> GetTopicFollowerIdsAsync( + System.Guid topicId, + System.Guid? excludeUserId, + CancellationToken ct) + { + var query = _db.TopicFollows.Where(f => f.TopicId == topicId); + + if (excludeUserId is { } excl) + { + query = query.Where(f => f.UserId != excl); + } + + var ids = await query + .Select(f => f.UserId) + .Distinct() + .ToListAsync(ct) + .ConfigureAwait(false); + + return ids; + } +} diff --git a/backend/src/CCE.Infrastructure/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..fb63c3bc 100644 --- a/backend/src/CCE.Infrastructure/DependencyInjection.cs +++ b/backend/src/CCE.Infrastructure/DependencyInjection.cs @@ -5,11 +5,15 @@ using CCE.Application.Community; using CCE.Application.Content; using CCE.Application.Content.Public; +using CCE.Application.Media; +using CCE.Application.PlatformSettings; 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; +using CCE.Application.Notifications.Messages; using CCE.Application.Notifications.Public; using CCE.Application.Reports; using CCE.Application.Search; @@ -18,19 +22,30 @@ using CCE.Infrastructure.Community; using CCE.Infrastructure.Content; using CCE.Infrastructure.InteractiveCity; +using CCE.Infrastructure.Media; using CCE.Infrastructure.Sanitization; using CCE.Infrastructure.Country; using CCE.Infrastructure.Notifications; +using CCE.Infrastructure.Notifications.Messaging; using CCE.Infrastructure.Reports; using CCE.Infrastructure.Surveys; +using CCE.Application.Verification; +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; using CCE.Infrastructure.Persistence; +using CCE.Infrastructure.Persistence.Repositories; +using CCE.Infrastructure.Security; using CCE.Infrastructure.Persistence.Interceptors; +using CCE.Infrastructure.PlatformSettings; 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 +72,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 +104,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); @@ -91,43 +137,78 @@ 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.AddExternalApiClient("AdminAuthGateway"); 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(); - services.AddScoped(); - services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); // File storage + virus scanning services.AddSingleton(); + services.AddKeyedSingleton("media", (sp, _) => + { + var opts = sp.GetRequiredService>().Value; + return new LocalFileStorage(opts.MediaUploadsRoot); + }); + + // Media upload options (bound from "Media" section in appsettings) + services.Configure(configuration.GetSection(MediaUploadOptions.SectionName)); 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(); 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(); + + // Verification (OTP) + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddSingleton(); + + // Notification gateway + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -148,6 +229,10 @@ public static IServiceCollection AddInfrastructure( // Interactive City services.AddScoped(); + // Messaging (MassTransit) — transport selected by Messaging:Transport in appsettings. + // InMemory by default (no broker); set to RabbitMQ in production. + services.AddCceMessaging(configuration); + // Search services.AddScoped(); services.AddScoped(); diff --git a/backend/src/CCE.Infrastructure/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/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..8dbf0962 --- /dev/null +++ b/backend/src/CCE.Infrastructure/ExternalApis/ExternalApiServiceCollectionExtensions.cs @@ -0,0 +1,62 @@ +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, + PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase + }) + }; + + 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.Infrastructure/Files/LocalFileStorage.cs b/backend/src/CCE.Infrastructure/Files/LocalFileStorage.cs index 5d1b7043..ebd660b4 100644 --- a/backend/src/CCE.Infrastructure/Files/LocalFileStorage.cs +++ b/backend/src/CCE.Infrastructure/Files/LocalFileStorage.cs @@ -16,6 +16,12 @@ public LocalFileStorage(IOptions options) _root = options.Value.LocalUploadsRoot; } + /// Creates storage rooted at an arbitrary path (used for media files). + public LocalFileStorage(string root) + { + _root = root; + } + public async Task SaveAsync(Stream content, string suggestedFileName, CancellationToken ct) { var now = System.DateTimeOffset.UtcNow; 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 new file mode 100644 index 00000000..a5a1c618 --- /dev/null +++ b/backend/src/CCE.Infrastructure/Identity/AuthService.cs @@ -0,0 +1,293 @@ +using CCE.Application.Common.Interfaces; +using CCE.Application.Identity.Auth.Common; +using CCE.Application.Notifications; +using CCE.Domain.Common; +using CCE.Domain.Identity; +using CCE.Domain.Notifications; +using CCE.Integration.AdminAuth; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; + +namespace CCE.Infrastructure.Identity; + +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 INotificationGateway _gateway; + private readonly IConfiguration _config; + private readonly IAdminAuthGatewayClient _adGateway; + + public AuthService( + UserManager userManager, + RoleManager roleManager, + ILocalTokenService tokenService, + IRefreshTokenRepository refreshTokens, + ICceDbContext db, + ISystemClock clock, + IOptions options, + INotificationGateway gateway, + IConfiguration config, + IAdminAuthGatewayClient adGateway) + { + _userManager = userManager; + _roleManager = roleManager; + _tokenService = tokenService; + _refreshTokens = refreshTokens; + _db = db; + _clock = clock; + _options = options; + _gateway = gateway; + _config = config; + _adGateway = adGateway; + } + + 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 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); + if (user is not null) + { + var token = await _userManager.GeneratePasswordResetTokenAsync(user).ConfigureAwait(false); + var encodedToken = PasswordResetTokenCodec.Encode(token); + var baseUrl = _config.GetValue("Frontend:PasswordResetUrl") + ?? "http://localhost:4200/reset-password"; + var separator = baseUrl.Contains('?', StringComparison.Ordinal) ? '&' : '?'; + var resetUrl = $"{baseUrl}{separator}email={Uri.EscapeDataString(user.Email ?? string.Empty)}&token={Uri.EscapeDataString(encodedToken)}"; + + await _gateway.SendAsync(new NotificationDispatchRequest( + TemplateCode: "PASSWORD_RESET", + RecipientUserId: user.Id, + Channels: [NotificationChannel.Email, NotificationChannel.Sms], + Variables: new Dictionary + { + ["Name"] = user.FirstName, + ["ResetUrl"] = resetUrl + }, + Locale: user.LocalePreference, + BypassSettings: true), ct).ConfigureAwait(false); + } + } + + 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; + } + + 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); + 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/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/ExpertRequestSubmissionRepository.cs b/backend/src/CCE.Infrastructure/Identity/ExpertRequestSubmissionRepository.cs new file mode 100644 index 00000000..2b08c95a --- /dev/null +++ b/backend/src/CCE.Infrastructure/Identity/ExpertRequestSubmissionRepository.cs @@ -0,0 +1,11 @@ +using CCE.Application.Identity.Public; +using CCE.Domain.Identity; +using CCE.Infrastructure.Persistence; + +namespace CCE.Infrastructure.Identity; + +public sealed class ExpertRequestSubmissionRepository + : Repository, IExpertRequestSubmissionRepository +{ + public ExpertRequestSubmissionRepository(CceDbContext db) : base(db) { } +} diff --git a/backend/src/CCE.Infrastructure/Identity/ExpertRequestSubmissionService.cs b/backend/src/CCE.Infrastructure/Identity/ExpertRequestSubmissionService.cs deleted file mode 100644 index 6f903fae..00000000 --- a/backend/src/CCE.Infrastructure/Identity/ExpertRequestSubmissionService.cs +++ /dev/null @@ -1,21 +0,0 @@ -using CCE.Application.Identity.Public; -using CCE.Domain.Identity; -using CCE.Infrastructure.Persistence; - -namespace CCE.Infrastructure.Identity; - -public sealed class ExpertRequestSubmissionService : IExpertRequestSubmissionService -{ - private readonly CceDbContext _db; - - public ExpertRequestSubmissionService(CceDbContext db) - { - _db = db; - } - - public async Task SaveAsync(ExpertRegistrationRequest request, CancellationToken ct) - { - _db.ExpertRegistrationRequests.Add(request); - await _db.SaveChangesAsync(ct).ConfigureAwait(false); - } -} diff --git a/backend/src/CCE.Infrastructure/Identity/ExpertWorkflowRepository.cs b/backend/src/CCE.Infrastructure/Identity/ExpertWorkflowRepository.cs new file mode 100644 index 00000000..8c29b5f9 --- /dev/null +++ b/backend/src/CCE.Infrastructure/Identity/ExpertWorkflowRepository.cs @@ -0,0 +1,25 @@ +using CCE.Application.Identity; +using CCE.Domain.Identity; +using CCE.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace CCE.Infrastructure.Identity; + +public sealed class ExpertWorkflowRepository + : Repository, IExpertWorkflowRepository +{ + public ExpertWorkflowRepository(CceDbContext db) : base(db) { } + + public async Task FindIncludingDeletedAsync(System.Guid id, CancellationToken ct) + { + return await Db.ExpertRegistrationRequests + .IgnoreQueryFilters() + .FirstOrDefaultAsync(r => r.Id == id, ct) + .ConfigureAwait(false); + } + + public void AddProfile(ExpertProfile profile) + { + Db.ExpertProfiles.Add(profile); + } +} diff --git a/backend/src/CCE.Infrastructure/Identity/ExpertWorkflowService.cs b/backend/src/CCE.Infrastructure/Identity/ExpertWorkflowService.cs deleted file mode 100644 index ed5b6229..00000000 --- a/backend/src/CCE.Infrastructure/Identity/ExpertWorkflowService.cs +++ /dev/null @@ -1,33 +0,0 @@ -using CCE.Application.Identity; -using CCE.Domain.Identity; -using CCE.Infrastructure.Persistence; -using Microsoft.EntityFrameworkCore; - -namespace CCE.Infrastructure.Identity; - -public sealed class ExpertWorkflowService : IExpertWorkflowService -{ - private readonly CceDbContext _db; - - public ExpertWorkflowService(CceDbContext db) - { - _db = db; - } - - public async Task FindIncludingDeletedAsync(System.Guid id, CancellationToken ct) - { - return await _db.ExpertRegistrationRequests - .IgnoreQueryFilters() - .FirstOrDefaultAsync(r => r.Id == id, ct) - .ConfigureAwait(false); - } - - public async Task SaveAsync(ExpertRegistrationRequest request, ExpertProfile? newProfile, CancellationToken ct) - { - if (newProfile is not null) - { - _db.ExpertProfiles.Add(newProfile); - } - await _db.SaveChangesAsync(ct).ConfigureAwait(false); - } -} 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/RefreshTokenRepository.cs b/backend/src/CCE.Infrastructure/Identity/RefreshTokenRepository.cs new file mode 100644 index 00000000..5a14bb4a --- /dev/null +++ b/backend/src/CCE.Infrastructure/Identity/RefreshTokenRepository.cs @@ -0,0 +1,48 @@ +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); + } + } + +} diff --git a/backend/src/CCE.Infrastructure/Identity/StateRepAssignmentRepository.cs b/backend/src/CCE.Infrastructure/Identity/StateRepAssignmentRepository.cs new file mode 100644 index 00000000..c8253485 --- /dev/null +++ b/backend/src/CCE.Infrastructure/Identity/StateRepAssignmentRepository.cs @@ -0,0 +1,19 @@ +using CCE.Application.Identity; +using CCE.Domain.Identity; +using CCE.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace CCE.Infrastructure.Identity; + +public sealed class StateRepAssignmentRepository : Repository, IStateRepAssignmentRepository +{ + public StateRepAssignmentRepository(CceDbContext db) : base(db) { } + + public async Task FindIncludingRevokedAsync(System.Guid id, CancellationToken ct) + { + return await Db.StateRepresentativeAssignments + .IgnoreQueryFilters() + .FirstOrDefaultAsync(a => a.Id == id, ct) + .ConfigureAwait(false); + } +} diff --git a/backend/src/CCE.Infrastructure/Identity/StateRepAssignmentService.cs b/backend/src/CCE.Infrastructure/Identity/StateRepAssignmentService.cs deleted file mode 100644 index 4aba2a02..00000000 --- a/backend/src/CCE.Infrastructure/Identity/StateRepAssignmentService.cs +++ /dev/null @@ -1,36 +0,0 @@ -using CCE.Application.Identity; -using CCE.Domain.Identity; -using CCE.Infrastructure.Persistence; -using Microsoft.EntityFrameworkCore; - -namespace CCE.Infrastructure.Identity; - -public sealed class StateRepAssignmentService : IStateRepAssignmentService -{ - private readonly CceDbContext _db; - - public StateRepAssignmentService(CceDbContext db) - { - _db = db; - } - - public async Task SaveAsync(StateRepresentativeAssignment assignment, CancellationToken ct) - { - _db.StateRepresentativeAssignments.Add(assignment); - await _db.SaveChangesAsync(ct).ConfigureAwait(false); - } - - public async Task FindIncludingRevokedAsync(System.Guid id, CancellationToken ct) - { - 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/UserProfileService.cs b/backend/src/CCE.Infrastructure/Identity/UserProfileRepository.cs similarity index 64% rename from backend/src/CCE.Infrastructure/Identity/UserProfileService.cs rename to backend/src/CCE.Infrastructure/Identity/UserProfileRepository.cs index b180b5a8..8ceeb478 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; } @@ -17,6 +17,6 @@ public UserProfileService(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/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/Localization/LocalizationService.cs b/backend/src/CCE.Infrastructure/Localization/LocalizationService.cs new file mode 100644 index 00000000..ebfbdfde --- /dev/null +++ b/backend/src/CCE.Infrastructure/Localization/LocalizationService.cs @@ -0,0 +1,69 @@ +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)) + { + try + { + return CultureInfo.CurrentUICulture.TwoLetterISOLanguageName; + } + catch (CultureNotFoundException) + { + 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!); + } +} diff --git a/backend/src/CCE.Infrastructure/Media/MediaFileRepository.cs b/backend/src/CCE.Infrastructure/Media/MediaFileRepository.cs new file mode 100644 index 00000000..d0c9c814 --- /dev/null +++ b/backend/src/CCE.Infrastructure/Media/MediaFileRepository.cs @@ -0,0 +1,16 @@ +using CCE.Application.Media; +using CCE.Domain.Media; +using CCE.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace CCE.Infrastructure.Media; + +public sealed class MediaFileRepository : IMediaFileRepository +{ + private readonly CceDbContext _db; + + public MediaFileRepository(CceDbContext db) => _db = db; + + public async Task FindAsync(System.Guid id, CancellationToken ct) + => await _db.MediaFiles.FirstOrDefaultAsync(m => m.Id == id, ct).ConfigureAwait(false); +} diff --git a/backend/src/CCE.Infrastructure/Notifications/EmailNotificationChannelSender.cs b/backend/src/CCE.Infrastructure/Notifications/EmailNotificationChannelSender.cs new file mode 100644 index 00000000..8bc05e8c --- /dev/null +++ b/backend/src/CCE.Infrastructure/Notifications/EmailNotificationChannelSender.cs @@ -0,0 +1,95 @@ +using CCE.Application.Notifications; +using CCE.Domain.Notifications; +using CCE.Infrastructure.Email; +using CCE.Integration.Communication; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace CCE.Infrastructure.Notifications; + +public sealed class EmailNotificationChannelSender : INotificationChannelHandler +{ + private readonly ICommunicationGatewayClient _client; + private readonly IOptions _options; + private readonly ILogger _logger; + + public EmailNotificationChannelSender( + ICommunicationGatewayClient client, + IOptions options, + ILogger logger) + { + _client = client; + _options = options; + _logger = logger; + } + + public NotificationChannel Channel => NotificationChannel.Email; + + public bool ShouldSend(UserNotificationSettings? settings) => settings?.IsEnabled ?? true; + + public async Task SendAsync( + RenderedNotification notification, + CancellationToken cancellationToken) + { + var to = notification.Email; + if (string.IsNullOrWhiteSpace(to)) + { + _logger.LogWarning( + "Skipping email for template {TemplateCode}: no recipient email.", + notification.TemplateCode); + return new ChannelSendResult( + false, Error: "No recipient email address available."); + } + + try + { + var request = new SendEmailRequest( + To: to, + From: _options.Value.FromAddress, + Subject: notification.Subject, + Html: notification.Body); + + var response = await _client.SendEmailAsync(request, cancellationToken) + .ConfigureAwait(false); + + if (!"success".Equals(response.Status, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogError( + "Gateway email send failed for {To} template {TemplateCode}: {Error}", + to, notification.TemplateCode, response.Error); + return new ChannelSendResult( + false, Error: $"Gateway email send failed: {response.Error}"); + } + + _logger.LogInformation( + "Sent email via gateway to {To} template {TemplateCode} (id {Id})", + to, notification.TemplateCode, response.Id); + + return new ChannelSendResult(true, ProviderMessageId: response.Id); + } + catch (System.Net.Http.HttpRequestException ex) + { + _logger.LogError( + ex, + "Email channel HTTP failure for template {TemplateCode}", + notification.TemplateCode); + return new ChannelSendResult(false, Error: ex.Message); + } + catch (InvalidOperationException ex) + { + _logger.LogError( + ex, + "Email channel invalid operation for template {TemplateCode}", + notification.TemplateCode); + return new ChannelSendResult(false, Error: ex.Message); + } + catch (OperationCanceledException ex) when (ex.CancellationToken != cancellationToken) + { + _logger.LogError( + ex, + "Email channel timeout for template {TemplateCode}", + notification.TemplateCode); + return new ChannelSendResult(false, Error: ex.Message); + } + } +} diff --git a/backend/src/CCE.Infrastructure/Notifications/InAppNotificationChannelSender.cs b/backend/src/CCE.Infrastructure/Notifications/InAppNotificationChannelSender.cs new file mode 100644 index 00000000..c48f5a20 --- /dev/null +++ b/backend/src/CCE.Infrastructure/Notifications/InAppNotificationChannelSender.cs @@ -0,0 +1,51 @@ +using CCE.Application.Common.Interfaces; +using CCE.Application.Notifications; +using CCE.Application.Notifications.Public; +using CCE.Domain.Common; +using CCE.Domain.Notifications; + +namespace CCE.Infrastructure.Notifications; + +public sealed class InAppNotificationChannelSender : INotificationChannelHandler +{ + private readonly IUserNotificationRepository _repo; + private readonly ISystemClock _clock; + + public InAppNotificationChannelSender(IUserNotificationRepository repo, ISystemClock clock) + { + _repo = repo; + _clock = clock; + } + + public NotificationChannel Channel => NotificationChannel.InApp; + + public bool ShouldSend(UserNotificationSettings? settings) => settings?.IsEnabled ?? true; + + public async Task SendAsync( + RenderedNotification notification, + CancellationToken cancellationToken) + { + if (notification.RecipientUserId is null) + { + return new ChannelSendResult( + false, Error: "In-app notifications require a recipient user ID."); + } + + var userNotification = UserNotification.Render( + notification.RecipientUserId.Value, + notification.TemplateId, + notification.SubjectAr, + notification.SubjectEn, + notification.Body, + notification.Locale, + NotificationChannel.InApp); + + userNotification.MarkSent(_clock); + await _repo.AddAsync(userNotification, cancellationToken).ConfigureAwait(false); + + return new ChannelSendResult( + true, + UserNotificationId: userNotification.Id, + UserNotification: userNotification); + } +} diff --git a/backend/src/CCE.Infrastructure/Notifications/InProcessNotificationMessageDispatcher.cs b/backend/src/CCE.Infrastructure/Notifications/InProcessNotificationMessageDispatcher.cs new file mode 100644 index 00000000..2924e9fb --- /dev/null +++ b/backend/src/CCE.Infrastructure/Notifications/InProcessNotificationMessageDispatcher.cs @@ -0,0 +1,27 @@ +using CCE.Application.Notifications; +using CCE.Application.Notifications.Messages; + +namespace CCE.Infrastructure.Notifications; + +public sealed class InProcessNotificationMessageDispatcher : INotificationMessageDispatcher +{ + private readonly INotificationGateway _gateway; + + public InProcessNotificationMessageDispatcher(INotificationGateway gateway) + { + _gateway = gateway; + } + + public async Task DispatchAsync(NotificationMessage message, CancellationToken ct) + { + await _gateway.SendAsync(new NotificationDispatchRequest( + TemplateCode: message.TemplateCode, + RecipientUserId: message.RecipientUserId, + Channels: message.Channels ?? [], + Variables: message.MetaData, + Locale: message.Locale, + Email: message.Email, + PhoneNumber: message.PhoneNumber, + CorrelationId: message.CorrelationId), ct).ConfigureAwait(false); + } +} diff --git a/backend/src/CCE.Infrastructure/Notifications/Messaging/MassTransitNotificationMessageDispatcher.cs b/backend/src/CCE.Infrastructure/Notifications/Messaging/MassTransitNotificationMessageDispatcher.cs new file mode 100644 index 00000000..59c252ba --- /dev/null +++ b/backend/src/CCE.Infrastructure/Notifications/Messaging/MassTransitNotificationMessageDispatcher.cs @@ -0,0 +1,27 @@ +using CCE.Application.Notifications.Messages; +using MassTransit; + +namespace CCE.Infrastructure.Notifications.Messaging; + +/// +/// Drop-in replacement for . +/// Instead of calling INotificationGateway inline it publishes a +/// onto the MassTransit bus so the work +/// is handled asynchronously by +/// (which may run in this process, or in a separate worker process). +/// +/// +/// Wire-up: replace the InProcessNotificationMessageDispatcher DI +/// registration with this class. See MessagingServiceExtensions. +/// +/// +public sealed class MassTransitNotificationMessageDispatcher : INotificationMessageDispatcher +{ + private readonly IPublishEndpoint _publishEndpoint; + + public MassTransitNotificationMessageDispatcher(IPublishEndpoint publishEndpoint) + => _publishEndpoint = publishEndpoint; + + public Task DispatchAsync(NotificationMessage message, CancellationToken ct) + => _publishEndpoint.Publish(message, ct); +} diff --git a/backend/src/CCE.Infrastructure/Notifications/Messaging/MessagingOptions.cs b/backend/src/CCE.Infrastructure/Notifications/Messaging/MessagingOptions.cs new file mode 100644 index 00000000..213ac9ed --- /dev/null +++ b/backend/src/CCE.Infrastructure/Notifications/Messaging/MessagingOptions.cs @@ -0,0 +1,39 @@ +using System.ComponentModel.DataAnnotations; +using CCE.Application.Notifications.Messages; + +namespace CCE.Infrastructure.Notifications.Messaging; + +/// +/// Bound from appsettings.json section "Messaging". +/// +public sealed class MessagingOptions +{ + public const string SectionName = "Messaging"; + + /// + /// Transport to use. + /// + /// InMemory — default; same process, no broker required (dev / test). + /// RabbitMQ — production; requires config. + /// + /// + [Required] + public string Transport { get; init; } = "InMemory"; + + /// RabbitMQ host URI, e.g. amqp://guest:guest@localhost. + public string? RabbitMqHost { get; init; } + + /// + /// Virtual host inside RabbitMQ. Defaults to "/". + /// Use a dedicated vhost per environment (dev/staging/prod) to keep queues isolated. + /// + public string RabbitMqVirtualHost { get; init; } = "/"; + + /// + /// When true (default), is replaced + /// with . Set false to keep + /// the synchronous in-process dispatcher even when MassTransit is registered + /// (useful for integration tests that mock the gateway). + /// + public bool UseAsyncDispatcher { get; init; } = true; +} diff --git a/backend/src/CCE.Infrastructure/Notifications/Messaging/MessagingServiceExtensions.cs b/backend/src/CCE.Infrastructure/Notifications/Messaging/MessagingServiceExtensions.cs new file mode 100644 index 00000000..ca756eba --- /dev/null +++ b/backend/src/CCE.Infrastructure/Notifications/Messaging/MessagingServiceExtensions.cs @@ -0,0 +1,82 @@ +using CCE.Application.Notifications.Messages; +using MassTransit; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace CCE.Infrastructure.Notifications.Messaging; + +/// +/// Registers MassTransit with the correct transport based on +/// appsettings.json → Messaging:Transport: +/// +/// +/// InMemoryNo broker. Messages flow in-process via a channel. Use for local dev and all tests. +/// RabbitMQProduction. Requires Messaging:RabbitMqHost and a running broker. +/// +/// +/// Call services.AddCceMessaging(configuration) from +/// . +/// +public static class MessagingServiceExtensions +{ + public static IServiceCollection AddCceMessaging( + this IServiceCollection services, + IConfiguration configuration) + { + services.AddOptions() + .Bind(configuration.GetSection(MessagingOptions.SectionName)) + .ValidateDataAnnotations() + .ValidateOnStart(); + + var options = configuration + .GetSection(MessagingOptions.SectionName) + .Get() ?? new MessagingOptions(); + + services.AddMassTransit(x => + { + // Register consumer + its definition (retry policy, concurrency). + x.AddConsumer(); + + switch (options.Transport.ToUpperInvariant()) + { + case "RABBITMQ": + x.UsingRabbitMq((ctx, cfg) => + { + cfg.Host(options.RabbitMqHost ?? "amqp://guest:guest@localhost", options.RabbitMqVirtualHost, h => + { + // Credentials are embedded in RabbitMqHost URI or set here. + // Production: use environment variables / Azure Key Vault secrets. + }); + + // Auto-configure endpoints from consumer definitions. + cfg.ConfigureEndpoints(ctx); + }); + break; + + default: // "InMemory" or missing + x.UsingInMemory((ctx, cfg) => + { + cfg.ConfigureEndpoints(ctx); + }); + break; + } + }); + + // Replace the synchronous in-process dispatcher with the async bus publisher + // only when UseAsyncDispatcher=true (default). + if (options.UseAsyncDispatcher) + { + // Remove the InProcessNotificationMessageDispatcher registered in DependencyInjection.cs + var descriptor = services.FirstOrDefault( + d => d.ServiceType == typeof(INotificationMessageDispatcher)); + if (descriptor is not null) + services.Remove(descriptor); + + services.AddScoped(); + } + + return services; + } +} diff --git a/backend/src/CCE.Infrastructure/Notifications/Messaging/NotificationMessageConsumer.cs b/backend/src/CCE.Infrastructure/Notifications/Messaging/NotificationMessageConsumer.cs new file mode 100644 index 00000000..b49862a2 --- /dev/null +++ b/backend/src/CCE.Infrastructure/Notifications/Messaging/NotificationMessageConsumer.cs @@ -0,0 +1,65 @@ +using CCE.Application.Notifications; +using CCE.Application.Notifications.Messages; +using MassTransit; +using Microsoft.Extensions.Logging; + +namespace CCE.Infrastructure.Notifications.Messaging; + +/// +/// MassTransit consumer that receives a from +/// the bus and hands it to for template +/// resolution, rendering, delivery and logging. +/// +/// +/// This is the async counterpart to . +/// The gateway call (and its DB + SMS/Email provider I/O) happens here, off the +/// original HTTP request thread. +/// +/// +/// +/// Retry policy is configured on the consumer definition +/// (): 3 immediate retries, +/// then messages move to the error queue for manual inspection. +/// +/// +public sealed class NotificationMessageConsumer : IConsumer +{ + private readonly INotificationGateway _gateway; + private readonly ILogger _logger; + + public NotificationMessageConsumer( + INotificationGateway gateway, + ILogger logger) + { + _gateway = gateway; + _logger = logger; + } + + public async Task Consume(ConsumeContext context) + { + var message = context.Message; + + _logger.LogInformation( + "Consuming NotificationMessage TemplateCode={TemplateCode} RecipientUserId={RecipientUserId}", + message.TemplateCode, + message.RecipientUserId); + + var result = await _gateway.SendAsync(new NotificationDispatchRequest( + TemplateCode: message.TemplateCode, + RecipientUserId: message.RecipientUserId, + Channels: message.Channels ?? [], + Variables: message.MetaData, + Locale: message.Locale, + Email: message.Email, + PhoneNumber: message.PhoneNumber, + CorrelationId: message.CorrelationId), + context.CancellationToken).ConfigureAwait(false); + + if (!result.IsSuccess) + { + _logger.LogWarning( + "NotificationMessage TemplateCode={TemplateCode} had one or more failed channel dispatches.", + message.TemplateCode); + } + } +} diff --git a/backend/src/CCE.Infrastructure/Notifications/Messaging/NotificationMessageConsumerDefinition.cs b/backend/src/CCE.Infrastructure/Notifications/Messaging/NotificationMessageConsumerDefinition.cs new file mode 100644 index 00000000..767edf1b --- /dev/null +++ b/backend/src/CCE.Infrastructure/Notifications/Messaging/NotificationMessageConsumerDefinition.cs @@ -0,0 +1,34 @@ +using MassTransit; + +namespace CCE.Infrastructure.Notifications.Messaging; + +/// +/// Defines retry, concurrency, and queue naming for +/// . +/// +/// MassTransit picks this up automatically via AddConsumer<,>. +/// +public sealed class NotificationMessageConsumerDefinition + : ConsumerDefinition +{ + public NotificationMessageConsumerDefinition() + { + // One concurrent message per consumer instance (safe for DB write heavy work). + ConcurrentMessageLimit = 10; + } + + protected override void ConfigureConsumer( + IReceiveEndpointConfigurator endpointConfigurator, + IConsumerConfigurator consumerConfigurator, + IRegistrationContext context) + { + // 3 immediate retries, 5-second interval. + // After exhausting retries MassTransit moves the message to the + // _error queue automatically — no message is silently dropped. + endpointConfigurator.UseMessageRetry(r => + r.Intervals( + TimeSpan.FromSeconds(5), + TimeSpan.FromSeconds(15), + TimeSpan.FromSeconds(30))); + } +} diff --git a/backend/src/CCE.Infrastructure/Notifications/NotificationGateway.cs b/backend/src/CCE.Infrastructure/Notifications/NotificationGateway.cs new file mode 100644 index 00000000..12e82d4f --- /dev/null +++ b/backend/src/CCE.Infrastructure/Notifications/NotificationGateway.cs @@ -0,0 +1,288 @@ +using System.Text.Json; +using CCE.Application.Common.Interfaces; +using CCE.Application.Common.Pagination; +using CCE.Application.Notifications; +using CCE.Domain.Common; +using CCE.Domain.Notifications; +using Microsoft.Extensions.Logging; + +namespace CCE.Infrastructure.Notifications; + +public sealed class NotificationGateway : INotificationGateway +{ + private readonly ICceDbContext _db; + private readonly ICurrentUserAccessor _currentUser; + private readonly INotificationTemplateRepository _templates; + private readonly IUserNotificationSettingsRepository _settings; + private readonly INotificationLogRepository _logs; + private readonly INotificationTemplateRenderer _renderer; + private readonly IEnumerable _channelHandlers; + private readonly ISignalRNotificationPublisher? _signalR; + private readonly ILogger _logger; + + public NotificationGateway( + ICceDbContext db, + ICurrentUserAccessor currentUser, + INotificationTemplateRepository templates, + IUserNotificationSettingsRepository settings, + INotificationLogRepository logs, + INotificationTemplateRenderer renderer, + IEnumerable channelHandlers, + ILogger logger, + ISignalRNotificationPublisher? signalR = null) + { + _db = db; + _currentUser = currentUser; + _templates = templates; + _settings = settings; + _logs = logs; + _renderer = renderer; + _channelHandlers = channelHandlers; + _logger = logger; + _signalR = signalR; + } + + public async Task SendAsync( + NotificationDispatchRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + if (string.IsNullOrWhiteSpace(request.TemplateCode)) + throw new DomainException("TemplateCode is required."); + + var requestedChannels = request.Channels?.ToList() ?? []; + + // Resolve recipient data + string? email = request.Email; + string? phone = request.PhoneNumber; + string locale = request.Locale; + + if (request.RecipientUserId is { } userId) + { + var user = (await _db.Users + .Where(u => u.Id == userId) + .Select(u => new { u.Email, u.PhoneNumber }) + .ToListAsyncEither(cancellationToken) + .ConfigureAwait(false)) + .FirstOrDefault(); + + if (user is not null) + { + email ??= user.Email; + phone ??= user.PhoneNumber; + } + } + + var correlationId = request.CorrelationId ?? _currentUser.GetCorrelationId().ToString("N"); + var results = new List(); + var inAppUserNotifications = new List(); + + var templates = await _templates + .ListActiveByCodeAsync(request.TemplateCode, cancellationToken) + .ConfigureAwait(false); + + var templateByChannel = templates.ToDictionary(t => t.Channel); + var channels = requestedChannels.Count == 0 + ? templateByChannel.Keys.ToList() + : requestedChannels; + + if (channels.Count == 0) + { + _logger.LogWarning( + "No active notification templates found for code {TemplateCode}.", + request.TemplateCode); + return new NotificationDispatchResult( + request.TemplateCode, + request.RecipientUserId, + []); + } + + // Load user settings if applicable + Dictionary<(NotificationChannel, string?), UserNotificationSettings>? settingsMap = null; + if (request.RecipientUserId is { } settingsUserId) + { + var settings = await _settings + .ListForUserAndChannelsAsync(settingsUserId, channels, cancellationToken) + .ConfigureAwait(false); + + settingsMap = settings.ToDictionary( + s => (s.Channel, (string?)s.EventCode), + s => s); + } + + foreach (var channel in channels) + { + var result = await DispatchChannelAsync( + request, + channel, + email, + phone, + locale, + templateByChannel, + settingsMap, + correlationId, + inAppUserNotifications, + cancellationToken).ConfigureAwait(false); + + results.Add(result); + } + + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + // SignalR push after persistence + if (_signalR is not null && inAppUserNotifications.Count > 0) + { + foreach (var notif in inAppUserNotifications) + { + await _signalR.PublishAsync(notif, cancellationToken).ConfigureAwait(false); + } + } + + return new NotificationDispatchResult( + request.TemplateCode, + request.RecipientUserId, + results); + } + + private async Task DispatchChannelAsync( + NotificationDispatchRequest request, + NotificationChannel channel, + string? email, + string? phone, + string locale, + Dictionary templateByChannel, + Dictionary<(NotificationChannel, string?), UserNotificationSettings>? settingsMap, + string correlationId, + List inAppUserNotifications, + CancellationToken cancellationToken) + { + // Skip in-app/SMS for anonymous users + if (request.RecipientUserId is null && channel is NotificationChannel.InApp) + { + return new NotificationChannelDispatchResult( + channel, + NotificationDeliveryStatus.Skipped, + Error: "In-app notifications require a recipient user ID."); + } + + UserNotificationSettings? channelSettings = null; + if (!request.BypassSettings && settingsMap is not null) + { + var eventKey = (channel, (string?)request.TemplateCode); + var defaultKey = (channel, (string?)null); + + if (!settingsMap.TryGetValue(eventKey, out channelSettings)) + { + settingsMap.TryGetValue(defaultKey, out channelSettings); + } + } + + // Resolve template + if (!templateByChannel.TryGetValue(channel, out var template)) + { + var log = NotificationLog.Create( + request.RecipientUserId, + request.TemplateCode, + null, + channel, + correlationId: correlationId); + log.MarkSkipped($"No active template found for channel {channel}."); + await _logs.AddAsync(log, cancellationToken).ConfigureAwait(false); + + return new NotificationChannelDispatchResult( + channel, + NotificationDeliveryStatus.Skipped, + NotificationLogId: log.Id, + Error: $"No active template found for channel {channel}."); + } + + // Render + var variables = request.Variables ?? new Dictionary(); + var (subjectAr, subjectEn, body) = _renderer.Render(template, variables, locale); + var subject = locale == "ar" ? subjectAr : subjectEn; + + var rendered = new RenderedNotification( + request.TemplateCode, + request.RecipientUserId, + template.Id, + subject, + subjectAr, + subjectEn, + body, + channel, + locale, + email, + phone); + + // Create pending log + var payloadJson = SerializePayload(variables); + var notificationLog = NotificationLog.Create( + request.RecipientUserId, + request.TemplateCode, + template.Id, + channel, + payloadJson, + correlationId); + await _logs.AddAsync(notificationLog, cancellationToken).ConfigureAwait(false); + + // Dispatch + var sender = _channelHandlers.FirstOrDefault(s => s.Channel == channel); + if (sender is null) + { + notificationLog.MarkSkipped($"No sender registered for channel {channel}."); + return new NotificationChannelDispatchResult( + channel, + NotificationDeliveryStatus.Skipped, + NotificationLogId: notificationLog.Id, + Error: $"No sender registered for channel {channel}."); + } + + if (!sender.ShouldSend(channelSettings)) + { + notificationLog.MarkSkipped("Channel disabled by user settings."); + return new NotificationChannelDispatchResult( + channel, + NotificationDeliveryStatus.Skipped, + NotificationLogId: notificationLog.Id, + Error: "Channel disabled by user settings."); + } + + var sendResult = await sender.SendAsync(rendered, cancellationToken).ConfigureAwait(false); + + if (sendResult.Success) + { + notificationLog.MarkSent(sendResult.ProviderMessageId); + } + else + { + notificationLog.MarkFailed(sendResult.Error ?? "Unknown error"); + } + + // Collect in-app notifications for batch persistence + if (channel == NotificationChannel.InApp && sendResult.UserNotification is { } userNotification) + { + inAppUserNotifications.Add(userNotification); + } + + return new NotificationChannelDispatchResult( + channel, + sendResult.Success ? NotificationDeliveryStatus.Sent : NotificationDeliveryStatus.Failed, + NotificationLogId: notificationLog.Id, + UserNotificationId: sendResult.UserNotificationId, + ProviderMessageId: sendResult.ProviderMessageId, + Error: sendResult.Error); + } + + private static string? SerializePayload(IReadOnlyDictionary variables) + { + try + { + return JsonSerializer.Serialize(variables); + } + catch (JsonException) + { + return null; + } + } +} diff --git a/backend/src/CCE.Infrastructure/Notifications/NotificationLogRepository.cs b/backend/src/CCE.Infrastructure/Notifications/NotificationLogRepository.cs new file mode 100644 index 00000000..f91d02e9 --- /dev/null +++ b/backend/src/CCE.Infrastructure/Notifications/NotificationLogRepository.cs @@ -0,0 +1,14 @@ +using CCE.Application.Notifications; +using CCE.Domain.Notifications; +using CCE.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace CCE.Infrastructure.Notifications; + +public sealed class NotificationLogRepository : EntityRepository, INotificationLogRepository +{ + public NotificationLogRepository(CceDbContext db) : base(db) { } + + public async Task GetAsync(System.Guid id, CancellationToken ct) + => await Db.NotificationLogs.FirstOrDefaultAsync(l => l.Id == id, ct).ConfigureAwait(false); +} diff --git a/backend/src/CCE.Infrastructure/Notifications/NotificationTemplateRenderer.cs b/backend/src/CCE.Infrastructure/Notifications/NotificationTemplateRenderer.cs new file mode 100644 index 00000000..3b67c33e --- /dev/null +++ b/backend/src/CCE.Infrastructure/Notifications/NotificationTemplateRenderer.cs @@ -0,0 +1,84 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using CCE.Application.Notifications; +using CCE.Domain.Common; +using CCE.Domain.Notifications; + +namespace CCE.Infrastructure.Notifications; + +/// +/// Replaces {{Variable}} placeholders in template subject/body with values from the provided dictionary. +/// +public sealed class NotificationTemplateRenderer : INotificationTemplateRenderer +{ + private static readonly Regex PlaceholderPattern = new(@"\{\{(\w+)\}\}", RegexOptions.Compiled); + + public (string SubjectAr, string SubjectEn, string Body) Render( + NotificationTemplate template, + IReadOnlyDictionary variables, + string locale) + { + ArgumentNullException.ThrowIfNull(template); + ArgumentNullException.ThrowIfNull(variables); + if (locale != "ar" && locale != "en") + throw new DomainException("Locale must be 'ar' or 'en'."); + + ValidateVariables(template, variables); + + var subjectAr = ReplacePlaceholders(template.SubjectAr, variables); + var subjectEn = ReplacePlaceholders(template.SubjectEn, variables); + var body = locale == "ar" + ? ReplacePlaceholders(template.BodyAr, variables) + : ReplacePlaceholders(template.BodyEn, variables); + + return (subjectAr, subjectEn, body); + } + + private static void ValidateVariables(NotificationTemplate template, IReadOnlyDictionary variables) + { + var requiredKeys = ExtractRequiredKeys(template.VariableSchemaJson); + foreach (var key in requiredKeys) + { + if (!variables.ContainsKey(key) || string.IsNullOrWhiteSpace(variables[key])) + throw new DomainException($"Missing required notification variable: '{key}'."); + } + } + + private static HashSet ExtractRequiredKeys(string variableSchemaJson) + { + try + { + using var doc = JsonDocument.Parse(variableSchemaJson); + if (doc.RootElement.ValueKind != JsonValueKind.Object) + return []; + + var required = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var property in doc.RootElement.EnumerateObject()) + { + if (property.Value.ValueKind == JsonValueKind.Object) + { + if (property.Value.TryGetProperty("required", out var reqProp) && + reqProp.ValueKind == JsonValueKind.True) + { + required.Add(property.Name); + } + } + } + return required; + } + catch (JsonException) + { + // If schema is not valid JSON, fall back to extracting placeholders from the template body + return []; + } + } + + private static string ReplacePlaceholders(string templateText, IReadOnlyDictionary variables) + { + return PlaceholderPattern.Replace(templateText, match => + { + var key = match.Groups[1].Value; + return variables.TryGetValue(key, out var value) ? value : match.Value; + }); + } +} diff --git a/backend/src/CCE.Infrastructure/Notifications/NotificationTemplateRepository.cs b/backend/src/CCE.Infrastructure/Notifications/NotificationTemplateRepository.cs new file mode 100644 index 00000000..fd8f4bb8 --- /dev/null +++ b/backend/src/CCE.Infrastructure/Notifications/NotificationTemplateRepository.cs @@ -0,0 +1,30 @@ +using CCE.Application.Notifications; +using CCE.Domain.Notifications; +using CCE.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace CCE.Infrastructure.Notifications; + +public sealed class NotificationTemplateRepository : EntityRepository, INotificationTemplateRepository +{ + public NotificationTemplateRepository(CceDbContext db) : base(db) { } + + public async Task GetAsync(System.Guid id, CancellationToken ct) + => await Db.NotificationTemplates.FirstOrDefaultAsync(t => t.Id == id, ct).ConfigureAwait(false); + + public async Task GetActiveByCodeAndChannelAsync( + string code, + NotificationChannel channel, + CancellationToken ct) + => await Db.NotificationTemplates + .FirstOrDefaultAsync(t => t.Code == code && t.Channel == channel && t.IsActive, ct) + .ConfigureAwait(false); + + public async Task> ListActiveByCodeAsync( + string code, + CancellationToken ct) + => await Db.NotificationTemplates + .Where(t => t.Code == code && t.IsActive) + .ToListAsync(ct) + .ConfigureAwait(false); +} diff --git a/backend/src/CCE.Infrastructure/Notifications/NotificationTemplateService.cs b/backend/src/CCE.Infrastructure/Notifications/NotificationTemplateService.cs deleted file mode 100644 index 2f8b402c..00000000 --- a/backend/src/CCE.Infrastructure/Notifications/NotificationTemplateService.cs +++ /dev/null @@ -1,32 +0,0 @@ -using CCE.Application.Notifications; -using CCE.Domain.Notifications; -using CCE.Infrastructure.Persistence; -using Microsoft.EntityFrameworkCore; - -namespace CCE.Infrastructure.Notifications; - -public sealed class NotificationTemplateService : INotificationTemplateService -{ - private readonly CceDbContext _db; - - public NotificationTemplateService(CceDbContext db) - { - _db = db; - } - - public async Task SaveAsync(NotificationTemplate template, CancellationToken ct) - { - _db.NotificationTemplates.Add(template); - await _db.SaveChangesAsync(ct).ConfigureAwait(false); - } - - public async Task FindAsync(System.Guid id, CancellationToken ct) - { - return await _db.NotificationTemplates.FirstOrDefaultAsync(t => t.Id == id, ct).ConfigureAwait(false); - } - - public async Task UpdateAsync(NotificationTemplate template, CancellationToken ct) - { - await _db.SaveChangesAsync(ct).ConfigureAwait(false); - } -} diff --git a/backend/src/CCE.Infrastructure/Notifications/NotificationsHub.cs b/backend/src/CCE.Infrastructure/Notifications/NotificationsHub.cs new file mode 100644 index 00000000..8e237417 --- /dev/null +++ b/backend/src/CCE.Infrastructure/Notifications/NotificationsHub.cs @@ -0,0 +1,28 @@ +using Microsoft.AspNetCore.SignalR; + +namespace CCE.Infrastructure.Notifications; + +public sealed class NotificationsHub : Hub +{ + public override async Task OnConnectedAsync() + { + var userId = Context.UserIdentifier; + if (!string.IsNullOrWhiteSpace(userId)) + { + await Groups.AddToGroupAsync(Context.ConnectionId, $"user:{userId}").ConfigureAwait(false); + } + + await base.OnConnectedAsync().ConfigureAwait(false); + } + + public override async Task OnDisconnectedAsync(Exception? exception) + { + var userId = Context.UserIdentifier; + if (!string.IsNullOrWhiteSpace(userId)) + { + await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"user:{userId}").ConfigureAwait(false); + } + + await base.OnDisconnectedAsync(exception).ConfigureAwait(false); + } +} diff --git a/backend/src/CCE.Infrastructure/Notifications/SignalRNotificationPublisher.cs b/backend/src/CCE.Infrastructure/Notifications/SignalRNotificationPublisher.cs new file mode 100644 index 00000000..af8ebc6d --- /dev/null +++ b/backend/src/CCE.Infrastructure/Notifications/SignalRNotificationPublisher.cs @@ -0,0 +1,47 @@ +using CCE.Application.Notifications; +using CCE.Domain.Notifications; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; + +namespace CCE.Infrastructure.Notifications; + +public sealed class SignalRNotificationPublisher : ISignalRNotificationPublisher +{ + private readonly IHubContext _hubContext; + private readonly ILogger _logger; + + public SignalRNotificationPublisher( + IHubContext hubContext, + ILogger logger) + { + _hubContext = hubContext; + _logger = logger; + } + + public async Task PublishAsync(UserNotification notification, CancellationToken cancellationToken) + { + _logger.LogInformation( + "Publishing notification {NotificationId} to user {UserId}", + notification.Id, + notification.UserId); + + await _hubContext + .Clients + .User(notification.UserId.ToString()) + .SendAsync( + "ReceiveNotification", + new + { + notification.Id, + notification.TemplateId, + notification.RenderedSubjectAr, + notification.RenderedSubjectEn, + notification.RenderedBody, + notification.RenderedLocale, + notification.Status, + notification.SentOn + }, + cancellationToken) + .ConfigureAwait(false); + } +} diff --git a/backend/src/CCE.Infrastructure/Notifications/SmsNotificationChannelSender.cs b/backend/src/CCE.Infrastructure/Notifications/SmsNotificationChannelSender.cs new file mode 100644 index 00000000..2cba3d13 --- /dev/null +++ b/backend/src/CCE.Infrastructure/Notifications/SmsNotificationChannelSender.cs @@ -0,0 +1,88 @@ +using CCE.Application.Notifications; +using CCE.Domain.Notifications; +using CCE.Integration.Communication; +using Microsoft.Extensions.Logging; + +namespace CCE.Infrastructure.Notifications; + +public sealed class SmsNotificationChannelSender : INotificationChannelHandler +{ + private readonly ICommunicationGatewayClient _client; + private readonly ILogger _logger; + + public SmsNotificationChannelSender( + ICommunicationGatewayClient client, + ILogger logger) + { + _client = client; + _logger = logger; + } + + public NotificationChannel Channel => NotificationChannel.Sms; + + public bool ShouldSend(UserNotificationSettings? settings) => settings?.IsEnabled ?? true; + + public async Task SendAsync( + RenderedNotification notification, + CancellationToken cancellationToken) + { + var to = notification.PhoneNumber; + if (string.IsNullOrWhiteSpace(to)) + { + _logger.LogWarning( + "Skipping SMS for template {TemplateCode}: no phone number.", + notification.TemplateCode); + return new ChannelSendResult( + false, Error: "No recipient phone number available."); + } + + try + { + var request = new SendSmsRequest( + To: to, + Message: notification.Body); + + var response = await _client.SendSmsAsync(request, cancellationToken) + .ConfigureAwait(false); + + if (!"success".Equals(response.Status, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogError( + "Gateway SMS send failed for {To} template {TemplateCode}: {Error}", + to, notification.TemplateCode, response.Error); + return new ChannelSendResult( + false, Error: $"Gateway SMS send failed: {response.Error}"); + } + + _logger.LogInformation( + "Sent SMS via gateway to {To} template {TemplateCode} (id {Id})", + to, notification.TemplateCode, response.Id); + + return new ChannelSendResult(true, ProviderMessageId: response.Id); + } + catch (System.Net.Http.HttpRequestException ex) + { + _logger.LogError( + ex, + "SMS channel HTTP failure for template {TemplateCode}", + notification.TemplateCode); + return new ChannelSendResult(false, Error: ex.Message); + } + catch (InvalidOperationException ex) + { + _logger.LogError( + ex, + "SMS channel invalid operation for template {TemplateCode}", + notification.TemplateCode); + return new ChannelSendResult(false, Error: ex.Message); + } + catch (OperationCanceledException ex) when (ex.CancellationToken != cancellationToken) + { + _logger.LogError( + ex, + "SMS channel timeout for template {TemplateCode}", + notification.TemplateCode); + return new ChannelSendResult(false, Error: ex.Message); + } + } +} diff --git a/backend/src/CCE.Infrastructure/Notifications/UserNotificationRepository.cs b/backend/src/CCE.Infrastructure/Notifications/UserNotificationRepository.cs new file mode 100644 index 00000000..5fee3b98 --- /dev/null +++ b/backend/src/CCE.Infrastructure/Notifications/UserNotificationRepository.cs @@ -0,0 +1,34 @@ +using CCE.Application.Notifications.Public; +using CCE.Domain.Common; +using CCE.Domain.Notifications; +using CCE.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace CCE.Infrastructure.Notifications; + +public sealed class UserNotificationRepository : EntityRepository, IUserNotificationRepository +{ + public UserNotificationRepository(CceDbContext db) : base(db) { } + + public async Task GetAsync(System.Guid id, CancellationToken ct) + => await Db.UserNotifications.FirstOrDefaultAsync(n => n.Id == id, ct).ConfigureAwait(false); + + public async Task MarkAllSentAsReadAsync( + System.Guid userId, + ISystemClock clock, + CancellationToken ct) + { + var notifications = await Db.UserNotifications + .Where(n => n.UserId == userId && n.Status == NotificationStatus.Sent) + .ToListAsync(ct) + .ConfigureAwait(false); + + foreach (var n in notifications) + { + n.MarkRead(clock); + } + + await Db.SaveChangesAsync(ct).ConfigureAwait(false); + return notifications.Count; + } +} diff --git a/backend/src/CCE.Infrastructure/Notifications/UserNotificationService.cs b/backend/src/CCE.Infrastructure/Notifications/UserNotificationService.cs deleted file mode 100644 index 3f12870c..00000000 --- a/backend/src/CCE.Infrastructure/Notifications/UserNotificationService.cs +++ /dev/null @@ -1,37 +0,0 @@ -using CCE.Application.Notifications.Public; -using CCE.Domain.Common; -using CCE.Domain.Notifications; -using CCE.Infrastructure.Persistence; -using Microsoft.EntityFrameworkCore; - -namespace CCE.Infrastructure.Notifications; - -public sealed class UserNotificationService : IUserNotificationService -{ - private readonly CceDbContext _db; - private readonly ISystemClock _clock; - - public UserNotificationService(CceDbContext db, ISystemClock clock) - { - _db = db; - _clock = clock; - } - - public async Task FindAsync(System.Guid id, CancellationToken ct) - => await _db.UserNotifications.FirstOrDefaultAsync(n => n.Id == id, ct).ConfigureAwait(false); - - public async Task UpdateAsync(UserNotification notification, CancellationToken ct) - => await _db.SaveChangesAsync(ct).ConfigureAwait(false); - - public async Task MarkAllSentAsReadAsync(System.Guid userId, CancellationToken ct) - { - var now = _clock.UtcNow; - // EF Core 7+ bulk update. Atomic. - return await _db.UserNotifications - .Where(n => n.UserId == userId && n.Status == NotificationStatus.Sent) - .ExecuteUpdateAsync(setters => setters - .SetProperty(n => n.Status, NotificationStatus.Read) - .SetProperty(n => n.ReadOn, (System.DateTimeOffset?)now), ct) - .ConfigureAwait(false); - } -} diff --git a/backend/src/CCE.Infrastructure/Notifications/UserNotificationSettingsRepository.cs b/backend/src/CCE.Infrastructure/Notifications/UserNotificationSettingsRepository.cs new file mode 100644 index 00000000..b270d41e --- /dev/null +++ b/backend/src/CCE.Infrastructure/Notifications/UserNotificationSettingsRepository.cs @@ -0,0 +1,39 @@ +using CCE.Application.Notifications; +using CCE.Domain.Notifications; +using CCE.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace CCE.Infrastructure.Notifications; + +public sealed class UserNotificationSettingsRepository : EntityRepository, IUserNotificationSettingsRepository +{ + public UserNotificationSettingsRepository(CceDbContext db) : base(db) { } + + public async Task GetAsync( + System.Guid userId, + NotificationChannel channel, + string? eventCode, + CancellationToken ct) + => await Db.UserNotificationSettings + .FirstOrDefaultAsync( + s => s.UserId == userId && s.Channel == channel && s.EventCode == eventCode, + ct) + .ConfigureAwait(false); + + public async Task> ListForUserAsync( + System.Guid userId, + CancellationToken ct) + => await Db.UserNotificationSettings + .Where(s => s.UserId == userId) + .ToListAsync(ct) + .ConfigureAwait(false); + + public async Task> ListForUserAndChannelsAsync( + System.Guid userId, + IReadOnlyCollection channels, + CancellationToken ct) + => await Db.UserNotificationSettings + .Where(s => s.UserId == userId && channels.Contains(s.Channel)) + .ToListAsync(ct) + .ConfigureAwait(false); +} diff --git a/backend/src/CCE.Infrastructure/Persistence/CceDbContext.cs b/backend/src/CCE.Infrastructure/Persistence/CceDbContext.cs index bbc4e495..cff5f50f 100644 --- a/backend/src/CCE.Infrastructure/Persistence/CceDbContext.cs +++ b/backend/src/CCE.Infrastructure/Persistence/CceDbContext.cs @@ -9,8 +9,11 @@ using CCE.Domain.Identity; using CCE.Domain.InteractiveCity; using CCE.Domain.KnowledgeMaps; +using CCE.Domain.Media; using CCE.Domain.Notifications; +using CCE.Domain.PlatformSettings; using CCE.Domain.Surveys; +using CCE.Domain.Verification; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; @@ -35,6 +38,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(); @@ -75,49 +79,99 @@ public CceDbContext(DbContextOptions options) : base(options) { } // ─── Notifications ─── public DbSet NotificationTemplates => Set(); public DbSet UserNotifications => Set(); + public DbSet NotificationLogs => Set(); + public DbSet UserNotificationSettings => Set(); + + // ─── Verification ─── + public DbSet OtpVerifications => Set(); + public DbSet UserVerifications => Set(); // ─── Surveys ─── 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; + // ─── Media ─── + public DbSet MediaFiles => 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(); + 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.NotificationLogs => NotificationLogs.AsNoTracking(); + IQueryable ICceDbContext.UserNotificationSettings => UserNotificationSettings.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(); + 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(); + IQueryable ICceDbContext.OtpVerifications => OtpVerifications.AsNoTracking(); + IQueryable ICceDbContext.UserVerifications => UserVerifications.AsNoTracking(); + IQueryable ICceDbContext.MediaFiles => MediaFiles.AsNoTracking(); + + void ICceDbContext.Add(T entity) where T : class => Set().Add(entity); + void ICceDbContext.Attach(T entity) where T : class => Set().Attach(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); + + void ICceDbContext.SetExpectedRowVersion(T entity, byte[] expectedRowVersion) where T : class + => this.SetExpectedRowVersion(entity, expectedRowVersion); + + public override async Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + try + { + return await base.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + catch (DbUpdateConcurrencyException ex) + { + throw new ConcurrencyException("Concurrent update conflict.", ex); + } + } 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..763ff8c1 100644 --- a/backend/src/CCE.Infrastructure/Persistence/Configurations/Identity/UserConfiguration.cs +++ b/backend/src/CCE.Infrastructure/Persistence/Configurations/Identity/UserConfiguration.cs @@ -8,10 +8,15 @@ 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)"); 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/Configurations/Media/MediaFileConfiguration.cs b/backend/src/CCE.Infrastructure/Persistence/Configurations/Media/MediaFileConfiguration.cs new file mode 100644 index 00000000..f21bf00b --- /dev/null +++ b/backend/src/CCE.Infrastructure/Persistence/Configurations/Media/MediaFileConfiguration.cs @@ -0,0 +1,24 @@ +using CCE.Domain.Media; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CCE.Infrastructure.Persistence.Configurations.Media; + +internal sealed class MediaFileConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(m => m.Id); + builder.Property(m => m.Id).ValueGeneratedNever(); + builder.Property(m => m.StorageKey).HasMaxLength(500).IsRequired(); + builder.Property(m => m.Url).HasMaxLength(2048).IsRequired(); + builder.Property(m => m.OriginalFileName).HasMaxLength(255).IsRequired(); + builder.Property(m => m.MimeType).HasMaxLength(100).IsRequired(); + builder.Property(m => m.TitleAr).HasMaxLength(200); + builder.Property(m => m.TitleEn).HasMaxLength(200); + builder.Property(m => m.DescriptionAr).HasMaxLength(1000); + builder.Property(m => m.DescriptionEn).HasMaxLength(1000); + builder.Property(m => m.AltTextAr).HasMaxLength(500); + builder.Property(m => m.AltTextEn).HasMaxLength(500); + } +} diff --git a/backend/src/CCE.Infrastructure/Persistence/Configurations/Notifications/NotificationLogConfiguration.cs b/backend/src/CCE.Infrastructure/Persistence/Configurations/Notifications/NotificationLogConfiguration.cs new file mode 100644 index 00000000..2df1c0b5 --- /dev/null +++ b/backend/src/CCE.Infrastructure/Persistence/Configurations/Notifications/NotificationLogConfiguration.cs @@ -0,0 +1,34 @@ +using CCE.Domain.Notifications; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CCE.Infrastructure.Persistence.Configurations.Notifications; + +internal sealed class NotificationLogConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(x => x.Id); + builder.Property(x => x.Id).ValueGeneratedNever(); + builder.Property(x => x.RecipientUserId); + builder.Property(x => x.TemplateCode).HasMaxLength(64).IsRequired(); + builder.Property(x => x.TemplateId); + builder.Property(x => x.Channel).HasConversion(); + builder.Property(x => x.Status).HasConversion(); + builder.Property(x => x.ProviderMessageId).HasMaxLength(256); + builder.Property(x => x.Error).HasColumnType("nvarchar(max)"); + builder.Property(x => x.AttemptCount).IsRequired(); + builder.Property(x => x.CreatedOn).IsRequired(); + builder.Property(x => x.SentOn); + builder.Property(x => x.FailedOn); + builder.Property(x => x.CorrelationId).HasMaxLength(64); + builder.Property(x => x.PayloadJson).HasColumnType("nvarchar(max)"); + + builder.HasIndex(x => new { x.RecipientUserId, x.Status, x.CreatedOn }) + .HasDatabaseName("ix_notification_log_recipient_status_created"); + builder.HasIndex(x => new { x.TemplateCode, x.Channel }) + .HasDatabaseName("ix_notification_log_template_channel"); + builder.HasIndex(x => x.CorrelationId) + .HasDatabaseName("ix_notification_log_correlation_id"); + } +} diff --git a/backend/src/CCE.Infrastructure/Persistence/Configurations/Notifications/NotificationTemplateConfiguration.cs b/backend/src/CCE.Infrastructure/Persistence/Configurations/Notifications/NotificationTemplateConfiguration.cs index 23b6b3b7..97ca0a80 100644 --- a/backend/src/CCE.Infrastructure/Persistence/Configurations/Notifications/NotificationTemplateConfiguration.cs +++ b/backend/src/CCE.Infrastructure/Persistence/Configurations/Notifications/NotificationTemplateConfiguration.cs @@ -17,6 +17,6 @@ public void Configure(EntityTypeBuilder builder) builder.Property(t => t.BodyEn).HasColumnType("nvarchar(max)"); builder.Property(t => t.Channel).HasConversion(); builder.Property(t => t.VariableSchemaJson).HasColumnType("nvarchar(max)"); - builder.HasIndex(t => t.Code).IsUnique().HasDatabaseName("ux_notification_template_code"); + builder.HasIndex(t => new { t.Code, t.Channel }).IsUnique().HasDatabaseName("ux_notification_template_code_channel"); } } diff --git a/backend/src/CCE.Infrastructure/Persistence/Configurations/Notifications/UserNotificationSettingsConfiguration.cs b/backend/src/CCE.Infrastructure/Persistence/Configurations/Notifications/UserNotificationSettingsConfiguration.cs new file mode 100644 index 00000000..2e7fab91 --- /dev/null +++ b/backend/src/CCE.Infrastructure/Persistence/Configurations/Notifications/UserNotificationSettingsConfiguration.cs @@ -0,0 +1,23 @@ +using CCE.Domain.Notifications; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CCE.Infrastructure.Persistence.Configurations.Notifications; + +internal sealed class UserNotificationSettingsConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(x => x.Id); + builder.Property(x => x.Id).ValueGeneratedNever(); + builder.Property(x => x.UserId).IsRequired(); + builder.Property(x => x.Channel).HasConversion().IsRequired(); + builder.Property(x => x.EventCode).HasMaxLength(64); + builder.Property(x => x.IsEnabled).IsRequired(); + builder.Property(x => x.UpdatedOn).IsRequired(); + + builder.HasIndex(x => new { x.UserId, x.Channel, x.EventCode }) + .IsUnique() + .HasDatabaseName("ux_user_notification_settings_user_channel_event"); + } +} diff --git a/backend/src/CCE.Infrastructure/Persistence/Configurations/PlatformSettings/AboutSettingsConfiguration.cs b/backend/src/CCE.Infrastructure/Persistence/Configurations/PlatformSettings/AboutSettingsConfiguration.cs new file mode 100644 index 00000000..7e377acf --- /dev/null +++ b/backend/src/CCE.Infrastructure/Persistence/Configurations/PlatformSettings/AboutSettingsConfiguration.cs @@ -0,0 +1,24 @@ +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.OwnsOne(s => s.Description, desc => + { + desc.Property(d => d.Ar).HasMaxLength(1000).IsRequired(); + desc.Property(d => d.En).HasMaxLength(1000).IsRequired(); + }); + builder.Property(s => s.HowToUseVideoUrl).HasColumnType("nvarchar(max)"); + builder.Property(s => s.RowVersion).IsRowVersion(); + builder.HasMany(s => s.GlossaryEntries).WithOne().HasForeignKey(e => e.AboutSettingsId); + builder.HasMany(s => s.KnowledgePartners).WithOne().HasForeignKey(p => p.AboutSettingsId); + 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..10a436cb --- /dev/null +++ b/backend/src/CCE.Infrastructure/Persistence/Configurations/PlatformSettings/GlossaryEntryConfiguration.cs @@ -0,0 +1,24 @@ +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.OwnsOne(e => e.Term, term => + { + term.Property(t => t.Ar).HasMaxLength(100).IsRequired(); + term.Property(t => t.En).HasMaxLength(100).IsRequired(); + }); + builder.OwnsOne(e => e.Definition, def => + { + def.Property(d => d.Ar).HasMaxLength(1000).IsRequired(); + def.Property(d => d.En).HasMaxLength(1000).IsRequired(); + }); + } +} 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..d0ba42e5 --- /dev/null +++ b/backend/src/CCE.Infrastructure/Persistence/Configurations/PlatformSettings/HomepageSettingsConfiguration.cs @@ -0,0 +1,25 @@ +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.OwnsOne(s => s.Objective, obj => + { + obj.Property(o => o.Ar).HasMaxLength(1000).IsRequired(); + obj.Property(o => o.En).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.HasMany(s => s.Countries).WithOne().HasForeignKey(c => c.HomepageSettingsId); + 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..e4dd4bff --- /dev/null +++ b/backend/src/CCE.Infrastructure/Persistence/Configurations/PlatformSettings/KnowledgePartnerConfiguration.cs @@ -0,0 +1,26 @@ +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.OwnsOne(p => p.Name, name => + { + name.Property(n => n.Ar).HasMaxLength(200).IsRequired(); + name.Property(n => n.En).HasMaxLength(200).IsRequired(); + }); + builder.OwnsOne(p => p.Description, desc => + { + desc.Property(d => d.Ar).HasMaxLength(1000); + desc.Property(d => d.En).HasMaxLength(1000); + }); + builder.Property(p => p.LogoUrl).HasColumnType("nvarchar(max)"); + builder.Property(p => p.WebsiteUrl).HasColumnType("nvarchar(max)"); + } +} 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..05b00da1 --- /dev/null +++ b/backend/src/CCE.Infrastructure/Persistence/Configurations/PlatformSettings/PoliciesSettingsConfiguration.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 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.HasMany(s => s.Sections).WithOne().HasForeignKey(s => s.PoliciesSettingsId); + 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..72813708 --- /dev/null +++ b/backend/src/CCE.Infrastructure/Persistence/Configurations/PlatformSettings/PolicySectionConfiguration.cs @@ -0,0 +1,25 @@ +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.OwnsOne(s => s.Title, title => + { + title.Property(t => t.Ar).HasMaxLength(500).IsRequired(); + title.Property(t => t.En).HasMaxLength(500).IsRequired(); + }); + builder.OwnsOne(s => s.Content, content => + { + content.Property(c => c.Ar).HasColumnType("nvarchar(max)").IsRequired(); + content.Property(c => c.En).HasColumnType("nvarchar(max)").IsRequired(); + }); + } +} diff --git a/backend/src/CCE.Infrastructure/Persistence/Configurations/Verification/OtpVerificationConfiguration.cs b/backend/src/CCE.Infrastructure/Persistence/Configurations/Verification/OtpVerificationConfiguration.cs new file mode 100644 index 00000000..4163716e --- /dev/null +++ b/backend/src/CCE.Infrastructure/Persistence/Configurations/Verification/OtpVerificationConfiguration.cs @@ -0,0 +1,19 @@ +using CCE.Domain.Verification; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CCE.Infrastructure.Persistence.Configurations.Verification; + +internal sealed class OtpVerificationConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("otp_verifications"); + builder.HasKey(e => e.Id); + builder.Property(e => e.Id).ValueGeneratedNever(); + builder.Property(e => e.Contact).HasMaxLength(256).IsRequired(); + builder.Property(e => e.TypeId).IsRequired(); + builder.Property(e => e.CodeHash).HasMaxLength(512).IsRequired(); + builder.HasIndex(e => new { e.Contact, e.TypeId }); + } +} diff --git a/backend/src/CCE.Infrastructure/Persistence/Configurations/Verification/UserVerificationConfiguration.cs b/backend/src/CCE.Infrastructure/Persistence/Configurations/Verification/UserVerificationConfiguration.cs new file mode 100644 index 00000000..f0802b6b --- /dev/null +++ b/backend/src/CCE.Infrastructure/Persistence/Configurations/Verification/UserVerificationConfiguration.cs @@ -0,0 +1,20 @@ +using CCE.Domain.Identity; +using CCE.Domain.Verification; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CCE.Infrastructure.Persistence.Configurations.Verification; + +internal sealed class UserVerificationConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("user_verifications"); + builder.HasKey(e => e.Id); + builder.Property(e => e.Id).ValueGeneratedNever(); + builder.Property(e => e.Contact).HasMaxLength(256).IsRequired(); + builder.Property(e => e.TypeId).IsRequired(); + builder.HasIndex(e => new { e.Contact, e.TypeId }).IsUnique(); + builder.HasOne().WithMany().HasForeignKey(e => e.UserId).IsRequired(false); + } +} 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/EntityRepository.cs b/backend/src/CCE.Infrastructure/Persistence/EntityRepository.cs new file mode 100644 index 00000000..8d5e6777 --- /dev/null +++ b/backend/src/CCE.Infrastructure/Persistence/EntityRepository.cs @@ -0,0 +1,31 @@ +using CCE.Domain.Common; +using Microsoft.EntityFrameworkCore; + +namespace CCE.Infrastructure.Persistence; + +public abstract class EntityRepository + where T : Entity + where TId : IEquatable +{ + protected CceDbContext Db { get; } + + protected EntityRepository(CceDbContext db) => Db = db; + + public virtual async Task GetByIdAsync(TId id, CancellationToken ct) + => await Db.Set().FindAsync(new object[] { id }, ct).ConfigureAwait(false); + + public virtual async Task AddAsync(T entity, CancellationToken ct) + => await Db.Set().AddAsync(entity, ct).ConfigureAwait(false); + + public virtual void Update(T entity) + { + if (Db.Entry(entity).State == EntityState.Detached) + { + Db.Set().Attach(entity); + Db.Entry(entity).State = EntityState.Modified; + } + } + + public virtual void Delete(T entity) + => Db.Set().Remove(entity); +} diff --git a/backend/src/CCE.Infrastructure/Persistence/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/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/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..459467e3 --- /dev/null +++ b/backend/src/CCE.Infrastructure/Persistence/Migrations/20260515121258_StandardizeCountryProfileAudit.cs @@ -0,0 +1,684 @@ +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.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", + 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/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/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/20260521111720_AddMediaService.Designer.cs b/backend/src/CCE.Infrastructure/Persistence/Migrations/20260521111720_AddMediaService.Designer.cs new file mode 100644 index 00000000..02180f74 --- /dev/null +++ b/backend/src/CCE.Infrastructure/Persistence/Migrations/20260521111720_AddMediaService.Designer.cs @@ -0,0 +1,3233 @@ +// +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("20260521111720_AddMediaService")] + partial class AddMediaService + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("CCE.Domain.Audit.AuditEvent", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("action"); + + b.Property("Actor") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("actor"); + + b.Property("CorrelationId") + .HasColumnType("uniqueidentifier") + .HasColumnName("correlation_id"); + + b.Property("Diff") + .HasColumnType("nvarchar(max)") + .HasColumnName("diff"); + + b.Property("OccurredOn") + .HasColumnType("datetimeoffset") + .HasColumnName("occurred_on"); + + b.Property("Resource") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("resource"); + + b.HasKey("Id") + .HasName("pk_audit_events"); + + b.HasIndex("CorrelationId") + .HasDatabaseName("ix_audit_events_correlation_id"); + + b.HasIndex("Actor", "OccurredOn") + .HasDatabaseName("ix_audit_events_actor_occurred_on"); + + b.ToTable("audit_events", null, t => + { + t.HasTrigger("trg_audit_events_no_update_delete"); + }); + + b.HasAnnotation("SqlServer:UseSqlOutputClause", false); + }); + + modelBuilder.Entity("CCE.Domain.Community.Post", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AnsweredReplyId") + .HasColumnType("uniqueidentifier") + .HasColumnName("answered_reply_id"); + + b.Property("AuthorId") + .HasColumnType("uniqueidentifier") + .HasColumnName("author_id"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)") + .HasColumnName("content"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsAnswerable") + .HasColumnType("bit") + .HasColumnName("is_answerable"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("locale"); + + b.Property("TopicId") + .HasColumnType("uniqueidentifier") + .HasColumnName("topic_id"); + + b.HasKey("Id") + .HasName("pk_posts"); + + b.HasIndex("TopicId") + .HasDatabaseName("ix_post_topic_id"); + + b.HasIndex("AuthorId", "CreatedOn") + .HasDatabaseName("ix_post_author_created"); + + b.ToTable("posts", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.PostFollow", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("FollowedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("followed_on"); + + b.Property("PostId") + .HasColumnType("uniqueidentifier") + .HasColumnName("post_id"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_post_follows"); + + b.HasIndex("PostId", "UserId") + .IsUnique() + .HasDatabaseName("ux_post_follow_post_user"); + + b.ToTable("post_follows", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.PostRating", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("PostId") + .HasColumnType("uniqueidentifier") + .HasColumnName("post_id"); + + b.Property("RatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("rated_on"); + + b.Property("Stars") + .HasColumnType("int") + .HasColumnName("stars"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_post_ratings"); + + b.HasIndex("PostId", "UserId") + .IsUnique() + .HasDatabaseName("ux_post_rating_post_user"); + + b.ToTable("post_ratings", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.PostReply", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AuthorId") + .HasColumnType("uniqueidentifier") + .HasColumnName("author_id"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)") + .HasColumnName("content"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsByExpert") + .HasColumnType("bit") + .HasColumnName("is_by_expert"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("locale"); + + b.Property("ParentReplyId") + .HasColumnType("uniqueidentifier") + .HasColumnName("parent_reply_id"); + + b.Property("PostId") + .HasColumnType("uniqueidentifier") + .HasColumnName("post_id"); + + b.HasKey("Id") + .HasName("pk_post_replies"); + + b.HasIndex("ParentReplyId") + .HasDatabaseName("ix_post_reply_parent_id"); + + b.HasIndex("PostId") + .HasDatabaseName("ix_post_reply_post_id"); + + b.ToTable("post_replies", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.Topic", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("DescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("IconUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("icon_url"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.Property("ParentId") + .HasColumnType("uniqueidentifier") + .HasColumnName("parent_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_topics"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ux_topic_slug_active") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("topics", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.TopicFollow", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("FollowedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("followed_on"); + + b.Property("TopicId") + .HasColumnType("uniqueidentifier") + .HasColumnName("topic_id"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_topic_follows"); + + b.HasIndex("TopicId", "UserId") + .IsUnique() + .HasDatabaseName("ux_topic_follow_topic_user"); + + b.ToTable("topic_follows", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.UserFollow", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("FollowedId") + .HasColumnType("uniqueidentifier") + .HasColumnName("followed_id"); + + b.Property("FollowedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("followed_on"); + + b.Property("FollowerId") + .HasColumnType("uniqueidentifier") + .HasColumnName("follower_id"); + + b.HasKey("Id") + .HasName("pk_user_follows"); + + b.HasIndex("FollowerId", "FollowedId") + .IsUnique() + .HasDatabaseName("ux_user_follow_follower_followed"); + + b.ToTable("user_follows", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.AssetFile", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("MimeType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("mime_type"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("original_file_name"); + + b.Property("ScannedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("scanned_on"); + + b.Property("SizeBytes") + .HasColumnType("bigint") + .HasColumnName("size_bytes"); + + b.Property("UploadedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("uploaded_by_id"); + + b.Property("UploadedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("uploaded_on"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("url"); + + b.Property("VirusScanStatus") + .HasColumnType("int") + .HasColumnName("virus_scan_status"); + + b.HasKey("Id") + .HasName("pk_asset_files"); + + b.HasIndex("VirusScanStatus") + .HasDatabaseName("ix_asset_file_scan_status"); + + b.ToTable("asset_files", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.Event", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("DescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("EndsOn") + .HasColumnType("datetimeoffset") + .HasColumnName("ends_on"); + + b.Property("FeaturedImageUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("featured_image_url"); + + b.Property("ICalUid") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("i_cal_uid"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("LocationAr") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("location_ar"); + + b.Property("LocationEn") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("location_en"); + + b.Property("OnlineMeetingUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("online_meeting_url"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.Property("StartsOn") + .HasColumnType("datetimeoffset") + .HasColumnName("starts_on"); + + b.Property("TitleAr") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_ar"); + + b.Property("TitleEn") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_en"); + + b.HasKey("Id") + .HasName("pk_events"); + + b.HasIndex("ICalUid") + .IsUnique() + .HasDatabaseName("ux_event_ical_uid"); + + b.HasIndex("StartsOn") + .HasDatabaseName("ix_event_starts_on"); + + b.ToTable("events", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.HomepageSection", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("ContentAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_ar"); + + b.Property("ContentEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_en"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.Property("SectionType") + .HasColumnType("int") + .HasColumnName("section_type"); + + b.HasKey("Id") + .HasName("pk_homepage_sections"); + + b.HasIndex("IsActive", "OrderIndex") + .HasDatabaseName("ix_homepage_section_active_order"); + + b.ToTable("homepage_sections", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.News", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AuthorId") + .HasColumnType("uniqueidentifier") + .HasColumnName("author_id"); + + b.Property("ContentAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_ar"); + + b.Property("ContentEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_en"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("FeaturedImageUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("featured_image_url"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("IsFeatured") + .HasColumnType("bit") + .HasColumnName("is_featured"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("PublishedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("published_on"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("slug"); + + b.Property("TitleAr") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_ar"); + + b.Property("TitleEn") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_en"); + + b.HasKey("Id") + .HasName("pk_news"); + + b.HasIndex("PublishedOn") + .HasDatabaseName("ix_news_published_on"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ux_news_slug_active") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("news", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.NewsletterSubscription", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("ConfirmationToken") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("confirmation_token"); + + b.Property("ConfirmedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("confirmed_on"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("nvarchar(320)") + .HasColumnName("email"); + + b.Property("IsConfirmed") + .HasColumnType("bit") + .HasColumnName("is_confirmed"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("LocalePreference") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("locale_preference"); + + b.Property("UnsubscribedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("unsubscribed_on"); + + b.HasKey("Id") + .HasName("pk_newsletter_subscriptions"); + + b.HasIndex("ConfirmationToken") + .HasDatabaseName("ix_newsletter_token"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("ux_newsletter_email"); + + b.ToTable("newsletter_subscriptions", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.Page", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("ContentAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_ar"); + + b.Property("ContentEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_en"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("PageType") + .HasColumnType("int") + .HasColumnName("page_type"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("slug"); + + b.Property("TitleAr") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_ar"); + + b.Property("TitleEn") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_en"); + + b.HasKey("Id") + .HasName("pk_pages"); + + b.HasIndex("PageType", "Slug") + .IsUnique() + .HasDatabaseName("ux_page_type_slug_active") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("pages", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.Resource", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AssetFileId") + .HasColumnType("uniqueidentifier") + .HasColumnName("asset_file_id"); + + b.Property("CategoryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("category_id"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("DescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("PublishedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("published_on"); + + b.Property("ResourceType") + .HasColumnType("int") + .HasColumnName("resource_type"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.Property("TitleAr") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_ar"); + + b.Property("TitleEn") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_en"); + + b.Property("UploadedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("uploaded_by_id"); + + b.Property("ViewCount") + .HasColumnType("bigint") + .HasColumnName("view_count"); + + b.HasKey("Id") + .HasName("pk_resources"); + + b.HasIndex("AssetFileId") + .HasDatabaseName("ix_resource_asset_file_id"); + + b.HasIndex("CountryId") + .HasDatabaseName("ix_resource_country_id"); + + b.HasIndex("CategoryId", "PublishedOn") + .HasDatabaseName("ix_resource_category_published"); + + b.ToTable("resources", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.ResourceCategory", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.Property("ParentId") + .HasColumnType("uniqueidentifier") + .HasColumnName("parent_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_resource_categories"); + + b.HasIndex("ParentId") + .HasDatabaseName("ix_resource_category_parent_id"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ux_resource_category_slug"); + + b.ToTable("resource_categories", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Country.Country", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("FlagUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("flag_url"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("IsoAlpha2") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("iso_alpha2"); + + b.Property("IsoAlpha3") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("nvarchar(3)") + .HasColumnName("iso_alpha3"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("LatestKapsarcSnapshotId") + .HasColumnType("uniqueidentifier") + .HasColumnName("latest_kapsarc_snapshot_id"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.Property("RegionAr") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("region_ar"); + + b.Property("RegionEn") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("region_en"); + + b.HasKey("Id") + .HasName("pk_countries"); + + b.HasIndex("IsoAlpha2") + .HasDatabaseName("ix_country_iso_alpha2"); + + b.HasIndex("IsoAlpha3") + .IsUnique() + .HasDatabaseName("ux_country_iso_alpha3_active") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("countries", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Country.CountryKapsarcSnapshot", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("Classification") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("classification"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("PerformanceScore") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)") + .HasColumnName("performance_score"); + + b.Property("SnapshotTakenOn") + .HasColumnType("datetimeoffset") + .HasColumnName("snapshot_taken_on"); + + b.Property("SourceVersion") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)") + .HasColumnName("source_version"); + + b.Property("TotalIndex") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)") + .HasColumnName("total_index"); + + b.HasKey("Id") + .HasName("pk_country_kapsarc_snapshots"); + + b.HasIndex("CountryId", "SnapshotTakenOn") + .HasDatabaseName("ix_kapsarc_snapshot_country_taken"); + + b.ToTable("country_kapsarc_snapshots", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Country.CountryProfile", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("ContactInfoAr") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("contact_info_ar"); + + b.Property("ContactInfoEn") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("contact_info_en"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("KeyInitiativesAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("key_initiatives_ar"); + + b.Property("KeyInitiativesEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("key_initiatives_en"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.HasKey("Id") + .HasName("pk_country_profiles"); + + b.HasIndex("CountryId") + .IsUnique() + .HasDatabaseName("ux_country_profile_country_id"); + + b.ToTable("country_profiles", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Country.CountryResourceRequest", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AdminNotesAr") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("admin_notes_ar"); + + b.Property("AdminNotesEn") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("admin_notes_en"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("ProcessedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("processed_by_id"); + + b.Property("ProcessedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("processed_on"); + + b.Property("ProposedAssetFileId") + .HasColumnType("uniqueidentifier") + .HasColumnName("proposed_asset_file_id"); + + b.Property("ProposedDescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("proposed_description_ar"); + + b.Property("ProposedDescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("proposed_description_en"); + + b.Property("ProposedResourceType") + .HasColumnType("int") + .HasColumnName("proposed_resource_type"); + + b.Property("ProposedTitleAr") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("proposed_title_ar"); + + b.Property("ProposedTitleEn") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("proposed_title_en"); + + b.Property("RequestedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("requested_by_id"); + + b.Property("Status") + .HasColumnType("int") + .HasColumnName("status"); + + b.Property("SubmittedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("submitted_on"); + + b.HasKey("Id") + .HasName("pk_country_resource_requests"); + + b.HasIndex("CountryId", "Status") + .HasDatabaseName("ix_country_request_country_status"); + + b.ToTable("country_resource_requests", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.ExpertProfile", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AcademicTitleAr") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("academic_title_ar"); + + b.Property("AcademicTitleEn") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("academic_title_en"); + + b.Property("ApprovedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("approved_by_id"); + + b.Property("ApprovedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("approved_on"); + + b.Property("BioAr") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("bio_ar"); + + b.Property("BioEn") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("bio_en"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.PrimitiveCollection("ExpertiseTags") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("expertise_tags"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_expert_profiles"); + + b.HasIndex("UserId") + .IsUnique() + .HasDatabaseName("ux_expert_profile_active_user") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("expert_profiles", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.ExpertRegistrationRequest", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("ProcessedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("processed_by_id"); + + b.Property("ProcessedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("processed_on"); + + b.Property("RejectionReasonAr") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("rejection_reason_ar"); + + b.Property("RejectionReasonEn") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("rejection_reason_en"); + + b.Property("RequestedBioAr") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("requested_bio_ar"); + + b.Property("RequestedBioEn") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("requested_bio_en"); + + b.Property("RequestedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("requested_by_id"); + + b.PrimitiveCollection("RequestedTags") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("requested_tags"); + + b.Property("Status") + .HasColumnType("int") + .HasColumnName("status"); + + b.Property("SubmittedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("submitted_on"); + + b.HasKey("Id") + .HasName("pk_expert_registration_requests"); + + b.HasIndex("RequestedById") + .HasDatabaseName("ix_expert_request_requested_by"); + + b.HasIndex("Status") + .HasDatabaseName("ix_expert_request_status"); + + b.ToTable("expert_registration_requests", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.RefreshToken", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset") + .HasColumnName("created_at_utc"); + + b.Property("CreatedByIp") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("created_by_ip"); + + b.Property("ExpiresAtUtc") + .HasColumnType("datetimeoffset") + .HasColumnName("expires_at_utc"); + + b.Property("ReplacedByTokenHash") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("replaced_by_token_hash"); + + b.Property("RevokedAtUtc") + .HasColumnType("datetimeoffset") + .HasColumnName("revoked_at_utc"); + + b.Property("RevokedByIp") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("revoked_by_ip"); + + b.Property("TokenFamilyId") + .HasColumnType("uniqueidentifier") + .HasColumnName("token_family_id"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("token_hash"); + + b.Property("UserAgent") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_refresh_tokens"); + + b.HasIndex("TokenFamilyId") + .HasDatabaseName("ix_refresh_tokens_token_family_id"); + + b.HasIndex("TokenHash") + .IsUnique() + .HasDatabaseName("ux_refresh_tokens_token_hash"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_refresh_tokens_user_id"); + + b.ToTable("refresh_tokens", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)") + .HasColumnName("concurrency_stamp"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("normalized_name"); + + b.HasKey("Id") + .HasName("pk_asp_net_roles"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[normalized_name] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.StateRepresentativeAssignment", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AssignedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("assigned_by_id"); + + b.Property("AssignedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("assigned_on"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("RevokedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("revoked_by_id"); + + b.Property("RevokedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("revoked_on"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_state_representative_assignments"); + + b.HasIndex("CountryId") + .HasDatabaseName("ix_state_rep_country_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_state_rep_user_id"); + + b.HasIndex("UserId", "CountryId") + .IsUnique() + .HasDatabaseName("ux_state_rep_active_user_country") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("state_representative_assignments", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AccessFailedCount") + .HasColumnType("int") + .HasColumnName("access_failed_count"); + + b.Property("AvatarUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("avatar_url"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)") + .HasColumnName("concurrency_stamp"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("email"); + + b.Property("EmailConfirmed") + .HasColumnType("bit") + .HasColumnName("email_confirmed"); + + b.Property("EntraIdObjectId") + .HasColumnType("uniqueidentifier") + .HasColumnName("entra_id_object_id"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)") + .HasColumnName("first_name"); + + b.PrimitiveCollection("Interests") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("interests"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("JobTitle") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)") + .HasColumnName("job_title"); + + b.Property("KnowledgeLevel") + .HasColumnType("int") + .HasColumnName("knowledge_level"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)") + .HasColumnName("last_name"); + + b.Property("LocalePreference") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("locale_preference"); + + b.Property("LockoutEnabled") + .HasColumnType("bit") + .HasColumnName("lockout_enabled"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset") + .HasColumnName("lockout_end"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("normalized_email"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("normalized_user_name"); + + b.Property("OrganizationName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("organization_name"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)") + .HasColumnName("password_hash"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)") + .HasColumnName("phone_number"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit") + .HasColumnName("phone_number_confirmed"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)") + .HasColumnName("security_stamp"); + + b.Property("Status") + .HasColumnType("int") + .HasColumnName("status"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit") + .HasColumnName("two_factor_enabled"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("user_name"); + + b.HasKey("Id") + .HasName("pk_asp_net_users"); + + b.HasIndex("CountryId") + .HasDatabaseName("ix_users_country_id"); + + b.HasIndex("EntraIdObjectId") + .IsUnique() + .HasDatabaseName("ix_asp_net_users_entra_id_object_id") + .HasFilter("[entra_id_object_id] IS NOT NULL"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[normalized_user_name] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.InteractiveCity.CityScenario", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CityType") + .HasColumnType("int") + .HasColumnName("city_type"); + + b.Property("ConfigurationJson") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("configuration_json"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.Property("TargetYear") + .HasColumnType("int") + .HasColumnName("target_year"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_city_scenarios"); + + b.HasIndex("UserId", "LastModifiedOn") + .HasDatabaseName("ix_city_scenario_user_modified"); + + b.ToTable("city_scenarios", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.InteractiveCity.CityScenarioResult", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("ComputedAt") + .HasColumnType("datetimeoffset") + .HasColumnName("computed_at"); + + b.Property("ComputedCarbonNeutralityYear") + .HasColumnType("int") + .HasColumnName("computed_carbon_neutrality_year"); + + b.Property("ComputedTotalCostUsd") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)") + .HasColumnName("computed_total_cost_usd"); + + b.Property("EngineVersion") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("engine_version"); + + b.Property("ScenarioId") + .HasColumnType("uniqueidentifier") + .HasColumnName("scenario_id"); + + b.HasKey("Id") + .HasName("pk_city_scenario_results"); + + b.HasIndex("ScenarioId", "ComputedAt") + .HasDatabaseName("ix_city_result_scenario_at"); + + b.ToTable("city_scenario_results", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.InteractiveCity.CityTechnology", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CarbonImpactKgPerYear") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)") + .HasColumnName("carbon_impact_kg_per_year"); + + b.Property("CategoryAr") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("category_ar"); + + b.Property("CategoryEn") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("category_en"); + + b.Property("CostUsd") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)") + .HasColumnName("cost_usd"); + + b.Property("DescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("IconUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("icon_url"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.HasKey("Id") + .HasName("pk_city_technologies"); + + b.HasIndex("IsActive") + .HasDatabaseName("ix_city_tech_is_active"); + + b.ToTable("city_technologies", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.KnowledgeMaps.KnowledgeMap", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("DescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_knowledge_maps"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ux_knowledge_map_slug_active") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("knowledge_maps", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.KnowledgeMaps.KnowledgeMapAssociation", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AssociatedId") + .HasColumnType("uniqueidentifier") + .HasColumnName("associated_id"); + + b.Property("AssociatedType") + .HasColumnType("int") + .HasColumnName("associated_type"); + + b.Property("NodeId") + .HasColumnType("uniqueidentifier") + .HasColumnName("node_id"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.HasKey("Id") + .HasName("pk_knowledge_map_associations"); + + b.HasIndex("NodeId", "AssociatedType", "AssociatedId") + .IsUnique() + .HasDatabaseName("ux_km_assoc_node_type_id"); + + b.ToTable("knowledge_map_associations", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.KnowledgeMaps.KnowledgeMapEdge", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("FromNodeId") + .HasColumnType("uniqueidentifier") + .HasColumnName("from_node_id"); + + b.Property("MapId") + .HasColumnType("uniqueidentifier") + .HasColumnName("map_id"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.Property("RelationshipType") + .HasColumnType("int") + .HasColumnName("relationship_type"); + + b.Property("ToNodeId") + .HasColumnType("uniqueidentifier") + .HasColumnName("to_node_id"); + + b.HasKey("Id") + .HasName("pk_knowledge_map_edges"); + + b.HasIndex("FromNodeId") + .HasDatabaseName("ix_km_edge_from_node"); + + b.HasIndex("ToNodeId") + .HasDatabaseName("ix_km_edge_to_node"); + + b.HasIndex("MapId", "FromNodeId", "ToNodeId", "RelationshipType") + .IsUnique() + .HasDatabaseName("ux_km_edge_map_from_to_relation"); + + b.ToTable("knowledge_map_edges", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.KnowledgeMaps.KnowledgeMapNode", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("DescriptionAr") + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("IconUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("icon_url"); + + b.Property("LayoutX") + .HasColumnType("float") + .HasColumnName("layout_x"); + + b.Property("LayoutY") + .HasColumnType("float") + .HasColumnName("layout_y"); + + b.Property("MapId") + .HasColumnType("uniqueidentifier") + .HasColumnName("map_id"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.Property("NodeType") + .HasColumnType("int") + .HasColumnName("node_type"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.HasKey("Id") + .HasName("pk_knowledge_map_nodes"); + + b.HasIndex("MapId", "OrderIndex") + .HasDatabaseName("ix_km_node_map_order"); + + b.ToTable("knowledge_map_nodes", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Media.MediaFile", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AltTextAr") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasColumnName("alt_text_ar"); + + b.Property("AltTextEn") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasColumnName("alt_text_en"); + + b.Property("DescriptionAr") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("description_en"); + + b.Property("MimeType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("mime_type"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)") + .HasColumnName("original_file_name"); + + b.Property("SizeBytes") + .HasColumnType("bigint") + .HasColumnName("size_bytes"); + + b.Property("StorageKey") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasColumnName("storage_key"); + + b.Property("TitleAr") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)") + .HasColumnName("title_ar"); + + b.Property("TitleEn") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)") + .HasColumnName("title_en"); + + b.Property("UploadedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("uploaded_by_id"); + + b.Property("UploadedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("uploaded_on"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("url"); + + b.HasKey("Id") + .HasName("pk_media_files"); + + b.ToTable("media_files", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Notifications.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/20260521111720_AddMediaService.cs b/backend/src/CCE.Infrastructure/Persistence/Migrations/20260521111720_AddMediaService.cs new file mode 100644 index 00000000..495ee17b --- /dev/null +++ b/backend/src/CCE.Infrastructure/Persistence/Migrations/20260521111720_AddMediaService.cs @@ -0,0 +1,46 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CCE.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddMediaService : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "media_files", + columns: table => new + { + id = table.Column(type: "uniqueidentifier", nullable: false), + storage_key = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: false), + url = table.Column(type: "nvarchar(2048)", maxLength: 2048, nullable: false), + original_file_name = table.Column(type: "nvarchar(255)", maxLength: 255, nullable: false), + mime_type = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + size_bytes = table.Column(type: "bigint", nullable: false), + title_ar = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: true), + title_en = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: true), + description_ar = table.Column(type: "nvarchar(1000)", maxLength: 1000, nullable: true), + description_en = table.Column(type: "nvarchar(1000)", maxLength: 1000, nullable: true), + alt_text_ar = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: true), + alt_text_en = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: true), + uploaded_by_id = table.Column(type: "uniqueidentifier", nullable: false), + uploaded_on = table.Column(type: "datetimeoffset", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_media_files", x => x.id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "media_files"); + } + } +} diff --git a/backend/src/CCE.Infrastructure/Persistence/Migrations/20260522211302_RefactorPlatformSettings.Designer.cs b/backend/src/CCE.Infrastructure/Persistence/Migrations/20260522211302_RefactorPlatformSettings.Designer.cs new file mode 100644 index 00000000..082fd464 --- /dev/null +++ b/backend/src/CCE.Infrastructure/Persistence/Migrations/20260522211302_RefactorPlatformSettings.Designer.cs @@ -0,0 +1,3430 @@ +// +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("20260522211302_RefactorPlatformSettings")] + partial class RefactorPlatformSettings + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("CCE.Domain.Audit.AuditEvent", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("action"); + + b.Property("Actor") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("actor"); + + b.Property("CorrelationId") + .HasColumnType("uniqueidentifier") + .HasColumnName("correlation_id"); + + b.Property("Diff") + .HasColumnType("nvarchar(max)") + .HasColumnName("diff"); + + b.Property("OccurredOn") + .HasColumnType("datetimeoffset") + .HasColumnName("occurred_on"); + + b.Property("Resource") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("resource"); + + b.HasKey("Id") + .HasName("pk_audit_events"); + + b.HasIndex("CorrelationId") + .HasDatabaseName("ix_audit_events_correlation_id"); + + b.HasIndex("Actor", "OccurredOn") + .HasDatabaseName("ix_audit_events_actor_occurred_on"); + + b.ToTable("audit_events", null, t => + { + t.HasTrigger("trg_audit_events_no_update_delete"); + }); + + b.HasAnnotation("SqlServer:UseSqlOutputClause", false); + }); + + modelBuilder.Entity("CCE.Domain.Community.Post", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AnsweredReplyId") + .HasColumnType("uniqueidentifier") + .HasColumnName("answered_reply_id"); + + b.Property("AuthorId") + .HasColumnType("uniqueidentifier") + .HasColumnName("author_id"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)") + .HasColumnName("content"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsAnswerable") + .HasColumnType("bit") + .HasColumnName("is_answerable"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("locale"); + + b.Property("TopicId") + .HasColumnType("uniqueidentifier") + .HasColumnName("topic_id"); + + b.HasKey("Id") + .HasName("pk_posts"); + + b.HasIndex("TopicId") + .HasDatabaseName("ix_post_topic_id"); + + b.HasIndex("AuthorId", "CreatedOn") + .HasDatabaseName("ix_post_author_created"); + + b.ToTable("posts", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.PostFollow", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("FollowedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("followed_on"); + + b.Property("PostId") + .HasColumnType("uniqueidentifier") + .HasColumnName("post_id"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_post_follows"); + + b.HasIndex("PostId", "UserId") + .IsUnique() + .HasDatabaseName("ux_post_follow_post_user"); + + b.ToTable("post_follows", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.PostRating", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("PostId") + .HasColumnType("uniqueidentifier") + .HasColumnName("post_id"); + + b.Property("RatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("rated_on"); + + b.Property("Stars") + .HasColumnType("int") + .HasColumnName("stars"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_post_ratings"); + + b.HasIndex("PostId", "UserId") + .IsUnique() + .HasDatabaseName("ux_post_rating_post_user"); + + b.ToTable("post_ratings", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.PostReply", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AuthorId") + .HasColumnType("uniqueidentifier") + .HasColumnName("author_id"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)") + .HasColumnName("content"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsByExpert") + .HasColumnType("bit") + .HasColumnName("is_by_expert"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("locale"); + + b.Property("ParentReplyId") + .HasColumnType("uniqueidentifier") + .HasColumnName("parent_reply_id"); + + b.Property("PostId") + .HasColumnType("uniqueidentifier") + .HasColumnName("post_id"); + + b.HasKey("Id") + .HasName("pk_post_replies"); + + b.HasIndex("ParentReplyId") + .HasDatabaseName("ix_post_reply_parent_id"); + + b.HasIndex("PostId") + .HasDatabaseName("ix_post_reply_post_id"); + + b.ToTable("post_replies", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.Topic", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("DescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("IconUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("icon_url"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.Property("ParentId") + .HasColumnType("uniqueidentifier") + .HasColumnName("parent_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_topics"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ux_topic_slug_active") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("topics", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.TopicFollow", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("FollowedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("followed_on"); + + b.Property("TopicId") + .HasColumnType("uniqueidentifier") + .HasColumnName("topic_id"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_topic_follows"); + + b.HasIndex("TopicId", "UserId") + .IsUnique() + .HasDatabaseName("ux_topic_follow_topic_user"); + + b.ToTable("topic_follows", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.UserFollow", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("FollowedId") + .HasColumnType("uniqueidentifier") + .HasColumnName("followed_id"); + + b.Property("FollowedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("followed_on"); + + b.Property("FollowerId") + .HasColumnType("uniqueidentifier") + .HasColumnName("follower_id"); + + b.HasKey("Id") + .HasName("pk_user_follows"); + + b.HasIndex("FollowerId", "FollowedId") + .IsUnique() + .HasDatabaseName("ux_user_follow_follower_followed"); + + b.ToTable("user_follows", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.AssetFile", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("MimeType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("mime_type"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("original_file_name"); + + b.Property("ScannedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("scanned_on"); + + b.Property("SizeBytes") + .HasColumnType("bigint") + .HasColumnName("size_bytes"); + + b.Property("UploadedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("uploaded_by_id"); + + b.Property("UploadedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("uploaded_on"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("url"); + + b.Property("VirusScanStatus") + .HasColumnType("int") + .HasColumnName("virus_scan_status"); + + b.HasKey("Id") + .HasName("pk_asset_files"); + + b.HasIndex("VirusScanStatus") + .HasDatabaseName("ix_asset_file_scan_status"); + + b.ToTable("asset_files", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.Event", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("DescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("EndsOn") + .HasColumnType("datetimeoffset") + .HasColumnName("ends_on"); + + b.Property("FeaturedImageUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("featured_image_url"); + + b.Property("ICalUid") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("i_cal_uid"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("LocationAr") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("location_ar"); + + b.Property("LocationEn") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("location_en"); + + b.Property("OnlineMeetingUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("online_meeting_url"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.Property("StartsOn") + .HasColumnType("datetimeoffset") + .HasColumnName("starts_on"); + + b.Property("TitleAr") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_ar"); + + b.Property("TitleEn") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_en"); + + b.HasKey("Id") + .HasName("pk_events"); + + b.HasIndex("ICalUid") + .IsUnique() + .HasDatabaseName("ux_event_ical_uid"); + + b.HasIndex("StartsOn") + .HasDatabaseName("ix_event_starts_on"); + + b.ToTable("events", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.HomepageSection", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("ContentAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_ar"); + + b.Property("ContentEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_en"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.Property("SectionType") + .HasColumnType("int") + .HasColumnName("section_type"); + + b.HasKey("Id") + .HasName("pk_homepage_sections"); + + b.HasIndex("IsActive", "OrderIndex") + .HasDatabaseName("ix_homepage_section_active_order"); + + b.ToTable("homepage_sections", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.News", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AuthorId") + .HasColumnType("uniqueidentifier") + .HasColumnName("author_id"); + + b.Property("ContentAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_ar"); + + b.Property("ContentEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_en"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("FeaturedImageUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("featured_image_url"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("IsFeatured") + .HasColumnType("bit") + .HasColumnName("is_featured"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("PublishedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("published_on"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("slug"); + + b.Property("TitleAr") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_ar"); + + b.Property("TitleEn") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_en"); + + b.HasKey("Id") + .HasName("pk_news"); + + b.HasIndex("PublishedOn") + .HasDatabaseName("ix_news_published_on"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ux_news_slug_active") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("news", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.NewsletterSubscription", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("ConfirmationToken") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("confirmation_token"); + + b.Property("ConfirmedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("confirmed_on"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("nvarchar(320)") + .HasColumnName("email"); + + b.Property("IsConfirmed") + .HasColumnType("bit") + .HasColumnName("is_confirmed"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("LocalePreference") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("locale_preference"); + + b.Property("UnsubscribedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("unsubscribed_on"); + + b.HasKey("Id") + .HasName("pk_newsletter_subscriptions"); + + b.HasIndex("ConfirmationToken") + .HasDatabaseName("ix_newsletter_token"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("ux_newsletter_email"); + + b.ToTable("newsletter_subscriptions", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.Page", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("ContentAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_ar"); + + b.Property("ContentEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_en"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("PageType") + .HasColumnType("int") + .HasColumnName("page_type"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("slug"); + + b.Property("TitleAr") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_ar"); + + b.Property("TitleEn") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_en"); + + b.HasKey("Id") + .HasName("pk_pages"); + + b.HasIndex("PageType", "Slug") + .IsUnique() + .HasDatabaseName("ux_page_type_slug_active") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("pages", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.Resource", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AssetFileId") + .HasColumnType("uniqueidentifier") + .HasColumnName("asset_file_id"); + + b.Property("CategoryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("category_id"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("DescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("PublishedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("published_on"); + + b.Property("ResourceType") + .HasColumnType("int") + .HasColumnName("resource_type"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.Property("TitleAr") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_ar"); + + b.Property("TitleEn") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_en"); + + b.Property("UploadedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("uploaded_by_id"); + + b.Property("ViewCount") + .HasColumnType("bigint") + .HasColumnName("view_count"); + + b.HasKey("Id") + .HasName("pk_resources"); + + b.HasIndex("AssetFileId") + .HasDatabaseName("ix_resource_asset_file_id"); + + b.HasIndex("CountryId") + .HasDatabaseName("ix_resource_country_id"); + + b.HasIndex("CategoryId", "PublishedOn") + .HasDatabaseName("ix_resource_category_published"); + + b.ToTable("resources", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.ResourceCategory", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.Property("ParentId") + .HasColumnType("uniqueidentifier") + .HasColumnName("parent_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_resource_categories"); + + b.HasIndex("ParentId") + .HasDatabaseName("ix_resource_category_parent_id"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ux_resource_category_slug"); + + b.ToTable("resource_categories", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Country.Country", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("FlagUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("flag_url"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("IsoAlpha2") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("iso_alpha2"); + + b.Property("IsoAlpha3") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("nvarchar(3)") + .HasColumnName("iso_alpha3"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("LatestKapsarcSnapshotId") + .HasColumnType("uniqueidentifier") + .HasColumnName("latest_kapsarc_snapshot_id"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.Property("RegionAr") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("region_ar"); + + b.Property("RegionEn") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("region_en"); + + b.HasKey("Id") + .HasName("pk_countries"); + + b.HasIndex("IsoAlpha2") + .HasDatabaseName("ix_country_iso_alpha2"); + + b.HasIndex("IsoAlpha3") + .IsUnique() + .HasDatabaseName("ux_country_iso_alpha3_active") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("countries", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Country.CountryKapsarcSnapshot", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("Classification") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("classification"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("PerformanceScore") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)") + .HasColumnName("performance_score"); + + b.Property("SnapshotTakenOn") + .HasColumnType("datetimeoffset") + .HasColumnName("snapshot_taken_on"); + + b.Property("SourceVersion") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)") + .HasColumnName("source_version"); + + b.Property("TotalIndex") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)") + .HasColumnName("total_index"); + + b.HasKey("Id") + .HasName("pk_country_kapsarc_snapshots"); + + b.HasIndex("CountryId", "SnapshotTakenOn") + .HasDatabaseName("ix_kapsarc_snapshot_country_taken"); + + b.ToTable("country_kapsarc_snapshots", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Country.CountryProfile", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("ContactInfoAr") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("contact_info_ar"); + + b.Property("ContactInfoEn") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("contact_info_en"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("KeyInitiativesAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("key_initiatives_ar"); + + b.Property("KeyInitiativesEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("key_initiatives_en"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.HasKey("Id") + .HasName("pk_country_profiles"); + + b.HasIndex("CountryId") + .IsUnique() + .HasDatabaseName("ux_country_profile_country_id"); + + b.ToTable("country_profiles", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Country.CountryResourceRequest", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AdminNotesAr") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("admin_notes_ar"); + + b.Property("AdminNotesEn") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("admin_notes_en"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("ProcessedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("processed_by_id"); + + b.Property("ProcessedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("processed_on"); + + b.Property("ProposedAssetFileId") + .HasColumnType("uniqueidentifier") + .HasColumnName("proposed_asset_file_id"); + + b.Property("ProposedDescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("proposed_description_ar"); + + b.Property("ProposedDescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("proposed_description_en"); + + b.Property("ProposedResourceType") + .HasColumnType("int") + .HasColumnName("proposed_resource_type"); + + b.Property("ProposedTitleAr") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("proposed_title_ar"); + + b.Property("ProposedTitleEn") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("proposed_title_en"); + + b.Property("RequestedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("requested_by_id"); + + b.Property("Status") + .HasColumnType("int") + .HasColumnName("status"); + + b.Property("SubmittedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("submitted_on"); + + b.HasKey("Id") + .HasName("pk_country_resource_requests"); + + b.HasIndex("CountryId", "Status") + .HasDatabaseName("ix_country_request_country_status"); + + b.ToTable("country_resource_requests", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.ExpertProfile", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AcademicTitleAr") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("academic_title_ar"); + + b.Property("AcademicTitleEn") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("academic_title_en"); + + b.Property("ApprovedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("approved_by_id"); + + b.Property("ApprovedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("approved_on"); + + b.Property("BioAr") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("bio_ar"); + + b.Property("BioEn") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("bio_en"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.PrimitiveCollection("ExpertiseTags") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("expertise_tags"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_expert_profiles"); + + b.HasIndex("UserId") + .IsUnique() + .HasDatabaseName("ux_expert_profile_active_user") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("expert_profiles", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.ExpertRegistrationRequest", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("ProcessedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("processed_by_id"); + + b.Property("ProcessedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("processed_on"); + + b.Property("RejectionReasonAr") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("rejection_reason_ar"); + + b.Property("RejectionReasonEn") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("rejection_reason_en"); + + b.Property("RequestedBioAr") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("requested_bio_ar"); + + b.Property("RequestedBioEn") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("requested_bio_en"); + + b.Property("RequestedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("requested_by_id"); + + b.PrimitiveCollection("RequestedTags") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("requested_tags"); + + b.Property("Status") + .HasColumnType("int") + .HasColumnName("status"); + + b.Property("SubmittedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("submitted_on"); + + b.HasKey("Id") + .HasName("pk_expert_registration_requests"); + + b.HasIndex("RequestedById") + .HasDatabaseName("ix_expert_request_requested_by"); + + b.HasIndex("Status") + .HasDatabaseName("ix_expert_request_status"); + + b.ToTable("expert_registration_requests", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.RefreshToken", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset") + .HasColumnName("created_at_utc"); + + b.Property("CreatedByIp") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("created_by_ip"); + + b.Property("ExpiresAtUtc") + .HasColumnType("datetimeoffset") + .HasColumnName("expires_at_utc"); + + b.Property("ReplacedByTokenHash") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("replaced_by_token_hash"); + + b.Property("RevokedAtUtc") + .HasColumnType("datetimeoffset") + .HasColumnName("revoked_at_utc"); + + b.Property("RevokedByIp") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("revoked_by_ip"); + + b.Property("TokenFamilyId") + .HasColumnType("uniqueidentifier") + .HasColumnName("token_family_id"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("token_hash"); + + b.Property("UserAgent") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_refresh_tokens"); + + b.HasIndex("TokenFamilyId") + .HasDatabaseName("ix_refresh_tokens_token_family_id"); + + b.HasIndex("TokenHash") + .IsUnique() + .HasDatabaseName("ux_refresh_tokens_token_hash"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_refresh_tokens_user_id"); + + b.ToTable("refresh_tokens", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)") + .HasColumnName("concurrency_stamp"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("normalized_name"); + + b.HasKey("Id") + .HasName("pk_asp_net_roles"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[normalized_name] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.StateRepresentativeAssignment", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AssignedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("assigned_by_id"); + + b.Property("AssignedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("assigned_on"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("RevokedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("revoked_by_id"); + + b.Property("RevokedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("revoked_on"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_state_representative_assignments"); + + b.HasIndex("CountryId") + .HasDatabaseName("ix_state_rep_country_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_state_rep_user_id"); + + b.HasIndex("UserId", "CountryId") + .IsUnique() + .HasDatabaseName("ux_state_rep_active_user_country") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("state_representative_assignments", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AccessFailedCount") + .HasColumnType("int") + .HasColumnName("access_failed_count"); + + b.Property("AvatarUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("avatar_url"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)") + .HasColumnName("concurrency_stamp"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("email"); + + b.Property("EmailConfirmed") + .HasColumnType("bit") + .HasColumnName("email_confirmed"); + + b.Property("EntraIdObjectId") + .HasColumnType("uniqueidentifier") + .HasColumnName("entra_id_object_id"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)") + .HasColumnName("first_name"); + + b.PrimitiveCollection("Interests") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("interests"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("JobTitle") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)") + .HasColumnName("job_title"); + + b.Property("KnowledgeLevel") + .HasColumnType("int") + .HasColumnName("knowledge_level"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)") + .HasColumnName("last_name"); + + b.Property("LocalePreference") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("locale_preference"); + + b.Property("LockoutEnabled") + .HasColumnType("bit") + .HasColumnName("lockout_enabled"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset") + .HasColumnName("lockout_end"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("normalized_email"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("normalized_user_name"); + + b.Property("OrganizationName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("organization_name"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)") + .HasColumnName("password_hash"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)") + .HasColumnName("phone_number"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit") + .HasColumnName("phone_number_confirmed"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)") + .HasColumnName("security_stamp"); + + b.Property("Status") + .HasColumnType("int") + .HasColumnName("status"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit") + .HasColumnName("two_factor_enabled"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("user_name"); + + b.HasKey("Id") + .HasName("pk_asp_net_users"); + + b.HasIndex("CountryId") + .HasDatabaseName("ix_users_country_id"); + + b.HasIndex("EntraIdObjectId") + .IsUnique() + .HasDatabaseName("ix_asp_net_users_entra_id_object_id") + .HasFilter("[entra_id_object_id] IS NOT NULL"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[normalized_user_name] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.InteractiveCity.CityScenario", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CityType") + .HasColumnType("int") + .HasColumnName("city_type"); + + b.Property("ConfigurationJson") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("configuration_json"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.Property("TargetYear") + .HasColumnType("int") + .HasColumnName("target_year"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_city_scenarios"); + + b.HasIndex("UserId", "LastModifiedOn") + .HasDatabaseName("ix_city_scenario_user_modified"); + + b.ToTable("city_scenarios", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.InteractiveCity.CityScenarioResult", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("ComputedAt") + .HasColumnType("datetimeoffset") + .HasColumnName("computed_at"); + + b.Property("ComputedCarbonNeutralityYear") + .HasColumnType("int") + .HasColumnName("computed_carbon_neutrality_year"); + + b.Property("ComputedTotalCostUsd") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)") + .HasColumnName("computed_total_cost_usd"); + + b.Property("EngineVersion") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("engine_version"); + + b.Property("ScenarioId") + .HasColumnType("uniqueidentifier") + .HasColumnName("scenario_id"); + + b.HasKey("Id") + .HasName("pk_city_scenario_results"); + + b.HasIndex("ScenarioId", "ComputedAt") + .HasDatabaseName("ix_city_result_scenario_at"); + + b.ToTable("city_scenario_results", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.InteractiveCity.CityTechnology", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CarbonImpactKgPerYear") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)") + .HasColumnName("carbon_impact_kg_per_year"); + + b.Property("CategoryAr") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("category_ar"); + + b.Property("CategoryEn") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("category_en"); + + b.Property("CostUsd") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)") + .HasColumnName("cost_usd"); + + b.Property("DescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("IconUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("icon_url"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.HasKey("Id") + .HasName("pk_city_technologies"); + + b.HasIndex("IsActive") + .HasDatabaseName("ix_city_tech_is_active"); + + b.ToTable("city_technologies", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.KnowledgeMaps.KnowledgeMap", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("DescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_knowledge_maps"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ux_knowledge_map_slug_active") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("knowledge_maps", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.KnowledgeMaps.KnowledgeMapAssociation", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AssociatedId") + .HasColumnType("uniqueidentifier") + .HasColumnName("associated_id"); + + b.Property("AssociatedType") + .HasColumnType("int") + .HasColumnName("associated_type"); + + b.Property("NodeId") + .HasColumnType("uniqueidentifier") + .HasColumnName("node_id"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.HasKey("Id") + .HasName("pk_knowledge_map_associations"); + + b.HasIndex("NodeId", "AssociatedType", "AssociatedId") + .IsUnique() + .HasDatabaseName("ux_km_assoc_node_type_id"); + + b.ToTable("knowledge_map_associations", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.KnowledgeMaps.KnowledgeMapEdge", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("FromNodeId") + .HasColumnType("uniqueidentifier") + .HasColumnName("from_node_id"); + + b.Property("MapId") + .HasColumnType("uniqueidentifier") + .HasColumnName("map_id"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.Property("RelationshipType") + .HasColumnType("int") + .HasColumnName("relationship_type"); + + b.Property("ToNodeId") + .HasColumnType("uniqueidentifier") + .HasColumnName("to_node_id"); + + b.HasKey("Id") + .HasName("pk_knowledge_map_edges"); + + b.HasIndex("FromNodeId") + .HasDatabaseName("ix_km_edge_from_node"); + + b.HasIndex("ToNodeId") + .HasDatabaseName("ix_km_edge_to_node"); + + b.HasIndex("MapId", "FromNodeId", "ToNodeId", "RelationshipType") + .IsUnique() + .HasDatabaseName("ux_km_edge_map_from_to_relation"); + + b.ToTable("knowledge_map_edges", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.KnowledgeMaps.KnowledgeMapNode", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("DescriptionAr") + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("IconUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("icon_url"); + + b.Property("LayoutX") + .HasColumnType("float") + .HasColumnName("layout_x"); + + b.Property("LayoutY") + .HasColumnType("float") + .HasColumnName("layout_y"); + + b.Property("MapId") + .HasColumnType("uniqueidentifier") + .HasColumnName("map_id"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.Property("NodeType") + .HasColumnType("int") + .HasColumnName("node_type"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.HasKey("Id") + .HasName("pk_knowledge_map_nodes"); + + b.HasIndex("MapId", "OrderIndex") + .HasDatabaseName("ix_km_node_map_order"); + + b.ToTable("knowledge_map_nodes", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Media.MediaFile", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AltTextAr") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasColumnName("alt_text_ar"); + + b.Property("AltTextEn") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasColumnName("alt_text_en"); + + b.Property("DescriptionAr") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("description_en"); + + b.Property("MimeType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("mime_type"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)") + .HasColumnName("original_file_name"); + + b.Property("SizeBytes") + .HasColumnType("bigint") + .HasColumnName("size_bytes"); + + b.Property("StorageKey") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasColumnName("storage_key"); + + b.Property("TitleAr") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)") + .HasColumnName("title_ar"); + + b.Property("TitleEn") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)") + .HasColumnName("title_en"); + + b.Property("UploadedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("uploaded_by_id"); + + b.Property("UploadedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("uploaded_on"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("url"); + + b.HasKey("Id") + .HasName("pk_media_files"); + + b.ToTable("media_files", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Notifications.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("HowToUseVideoUrl") + .HasColumnType("nvarchar(max)") + .HasColumnName("how_to_use_video_url"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.HasKey("Id") + .HasName("pk_about_settings"); + + b.ToTable("about_settings", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.GlossaryEntry", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AboutSettingsId") + .HasColumnType("uniqueidentifier") + .HasColumnName("about_settings_id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.HasKey("Id") + .HasName("pk_glossary_entries"); + + b.HasIndex("AboutSettingsId") + .HasDatabaseName("ix_glossary_entries_about_settings_id"); + + b.ToTable("glossary_entries", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.HomepageCountry", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("HomepageSettingsId") + .HasColumnType("uniqueidentifier") + .HasColumnName("homepage_settings_id"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.HasKey("Id") + .HasName("pk_homepage_countries"); + + b.HasIndex("HomepageSettingsId", "CountryId") + .IsUnique() + .HasDatabaseName("ix_homepage_country_settings_country"); + + b.ToTable("homepage_countries", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.HomepageSettings", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CceConceptsAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("cce_concepts_ar"); + + b.Property("CceConceptsEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("cce_concepts_en"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.Property("VideoUrl") + .HasColumnType("nvarchar(max)") + .HasColumnName("video_url"); + + b.HasKey("Id") + .HasName("pk_homepage_settings"); + + b.ToTable("homepage_settings", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.KnowledgePartner", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AboutSettingsId") + .HasColumnType("uniqueidentifier") + .HasColumnName("about_settings_id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("LogoUrl") + .HasColumnType("nvarchar(max)") + .HasColumnName("logo_url"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.Property("WebsiteUrl") + .HasColumnType("nvarchar(max)") + .HasColumnName("website_url"); + + b.HasKey("Id") + .HasName("pk_knowledge_partners"); + + b.HasIndex("AboutSettingsId") + .HasDatabaseName("ix_knowledge_partners_about_settings_id"); + + b.ToTable("knowledge_partners", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.PoliciesSettings", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.HasKey("Id") + .HasName("pk_policies_settings"); + + b.ToTable("policies_settings", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.PolicySection", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.Property("PoliciesSettingsId") + .HasColumnType("uniqueidentifier") + .HasColumnName("policies_settings_id"); + + b.Property("Type") + .HasColumnType("int") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_policy_sections"); + + b.HasIndex("PoliciesSettingsId") + .HasDatabaseName("ix_policy_sections_policies_settings_id"); + + b.ToTable("policy_sections", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Surveys.SearchQueryLog", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("locale"); + + b.Property("QueryText") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("query_text"); + + b.Property("ResponseTimeMs") + .HasColumnType("int") + .HasColumnName("response_time_ms"); + + b.Property("ResultsCount") + .HasColumnType("int") + .HasColumnName("results_count"); + + b.Property("SubmittedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("submitted_on"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_search_query_logs"); + + b.HasIndex("SubmittedOn") + .HasDatabaseName("ix_search_query_log_submitted_on"); + + b.ToTable("search_query_logs", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Surveys.ServiceRating", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CommentAr") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("comment_ar"); + + b.Property("CommentEn") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("comment_en"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("locale"); + + b.Property("Page") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("page"); + + b.Property("Rating") + .HasColumnType("int") + .HasColumnName("rating"); + + b.Property("SubmittedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("submitted_on"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_service_ratings"); + + b.HasIndex("SubmittedOn") + .HasDatabaseName("ix_service_rating_submitted_on"); + + b.ToTable("service_ratings", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)") + .HasColumnName("claim_type"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)") + .HasColumnName("claim_value"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier") + .HasColumnName("role_id"); + + b.HasKey("Id") + .HasName("pk_asp_net_role_claims"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_asp_net_role_claims_role_id"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)") + .HasColumnName("claim_type"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)") + .HasColumnName("claim_value"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_asp_net_user_claims"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_asp_net_user_claims_user_id"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)") + .HasColumnName("login_provider"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)") + .HasColumnName("provider_key"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)") + .HasColumnName("provider_display_name"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("LoginProvider", "ProviderKey") + .HasName("pk_asp_net_user_logins"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_asp_net_user_logins_user_id"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier") + .HasColumnName("role_id"); + + b.HasKey("UserId", "RoleId") + .HasName("pk_asp_net_user_roles"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_asp_net_user_roles_role_id"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)") + .HasColumnName("login_provider"); + + b.Property("Name") + .HasColumnType("nvarchar(450)") + .HasColumnName("name"); + + b.Property("Value") + .HasColumnType("nvarchar(max)") + .HasColumnName("value"); + + b.HasKey("UserId", "LoginProvider", "Name") + .HasName("pk_asp_net_user_tokens"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.RefreshToken", b => + { + b.HasOne("CCE.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_refresh_tokens_asp_net_users_user_id"); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.AboutSettings", b => + { + b.OwnsOne("CCE.Domain.PlatformSettings.ValueObjects.LocalizedText", "Description", b1 => + { + b1.Property("AboutSettingsId") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b1.Property("Ar") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("description_ar"); + + b1.Property("En") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("description_en"); + + b1.HasKey("AboutSettingsId"); + + b1.ToTable("about_settings"); + + b1.WithOwner() + .HasForeignKey("AboutSettingsId") + .HasConstraintName("fk_about_settings_about_settings_id"); + }); + + b.Navigation("Description") + .IsRequired(); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.GlossaryEntry", b => + { + b.HasOne("CCE.Domain.PlatformSettings.AboutSettings", null) + .WithMany("GlossaryEntries") + .HasForeignKey("AboutSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_glossary_entries_about_settings_about_settings_id"); + + b.OwnsOne("CCE.Domain.PlatformSettings.ValueObjects.LocalizedText", "Definition", b1 => + { + b1.Property("GlossaryEntryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b1.Property("Ar") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("definition_ar"); + + b1.Property("En") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("definition_en"); + + b1.HasKey("GlossaryEntryId"); + + b1.ToTable("glossary_entries"); + + b1.WithOwner() + .HasForeignKey("GlossaryEntryId") + .HasConstraintName("fk_glossary_entries_glossary_entries_id"); + }); + + b.OwnsOne("CCE.Domain.PlatformSettings.ValueObjects.LocalizedText", "Term", b1 => + { + b1.Property("GlossaryEntryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b1.Property("Ar") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("term_ar"); + + b1.Property("En") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("term_en"); + + b1.HasKey("GlossaryEntryId"); + + b1.ToTable("glossary_entries"); + + b1.WithOwner() + .HasForeignKey("GlossaryEntryId") + .HasConstraintName("fk_glossary_entries_glossary_entries_id"); + }); + + b.Navigation("Definition") + .IsRequired(); + + b.Navigation("Term") + .IsRequired(); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.HomepageCountry", b => + { + b.HasOne("CCE.Domain.PlatformSettings.HomepageSettings", null) + .WithMany("Countries") + .HasForeignKey("HomepageSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_homepage_countries_homepage_settings_homepage_settings_id"); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.HomepageSettings", b => + { + b.OwnsOne("CCE.Domain.PlatformSettings.ValueObjects.LocalizedText", "Objective", b1 => + { + b1.Property("HomepageSettingsId") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b1.Property("Ar") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("objective_ar"); + + b1.Property("En") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("objective_en"); + + b1.HasKey("HomepageSettingsId"); + + b1.ToTable("homepage_settings"); + + b1.WithOwner() + .HasForeignKey("HomepageSettingsId") + .HasConstraintName("fk_homepage_settings_homepage_settings_id"); + }); + + b.Navigation("Objective") + .IsRequired(); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.KnowledgePartner", b => + { + b.HasOne("CCE.Domain.PlatformSettings.AboutSettings", null) + .WithMany("KnowledgePartners") + .HasForeignKey("AboutSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_knowledge_partners_about_settings_about_settings_id"); + + b.OwnsOne("CCE.Domain.PlatformSettings.ValueObjects.LocalizedText", "Description", b1 => + { + b1.Property("KnowledgePartnerId") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b1.Property("Ar") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("description_ar"); + + b1.Property("En") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("description_en"); + + b1.HasKey("KnowledgePartnerId"); + + b1.ToTable("knowledge_partners"); + + b1.WithOwner() + .HasForeignKey("KnowledgePartnerId") + .HasConstraintName("fk_knowledge_partners_knowledge_partners_id"); + }); + + b.OwnsOne("CCE.Domain.PlatformSettings.ValueObjects.LocalizedText", "Name", b1 => + { + b1.Property("KnowledgePartnerId") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b1.Property("Ar") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)") + .HasColumnName("name_ar"); + + b1.Property("En") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)") + .HasColumnName("name_en"); + + b1.HasKey("KnowledgePartnerId"); + + b1.ToTable("knowledge_partners"); + + b1.WithOwner() + .HasForeignKey("KnowledgePartnerId") + .HasConstraintName("fk_knowledge_partners_knowledge_partners_id"); + }); + + b.Navigation("Description"); + + b.Navigation("Name") + .IsRequired(); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.PolicySection", b => + { + b.HasOne("CCE.Domain.PlatformSettings.PoliciesSettings", null) + .WithMany("Sections") + .HasForeignKey("PoliciesSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_policy_sections_policies_settings_policies_settings_id"); + + b.OwnsOne("CCE.Domain.PlatformSettings.ValueObjects.LocalizedText", "Content", b1 => + { + b1.Property("PolicySectionId") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b1.Property("Ar") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_ar"); + + b1.Property("En") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_en"); + + b1.HasKey("PolicySectionId"); + + b1.ToTable("policy_sections"); + + b1.WithOwner() + .HasForeignKey("PolicySectionId") + .HasConstraintName("fk_policy_sections_policy_sections_id"); + }); + + b.OwnsOne("CCE.Domain.PlatformSettings.ValueObjects.LocalizedText", "Title", b1 => + { + b1.Property("PolicySectionId") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b1.Property("Ar") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasColumnName("title_ar"); + + b1.Property("En") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasColumnName("title_en"); + + b1.HasKey("PolicySectionId"); + + b1.ToTable("policy_sections"); + + b1.WithOwner() + .HasForeignKey("PolicySectionId") + .HasConstraintName("fk_policy_sections_policy_sections_id"); + }); + + b.Navigation("Content") + .IsRequired(); + + b.Navigation("Title") + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("CCE.Domain.Identity.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_role_claims_asp_net_roles_role_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("CCE.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_user_claims_asp_net_users_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("CCE.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_user_logins_asp_net_users_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("CCE.Domain.Identity.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_user_roles_asp_net_roles_role_id"); + + b.HasOne("CCE.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_user_roles_asp_net_users_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("CCE.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_user_tokens_asp_net_users_user_id"); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.AboutSettings", b => + { + b.Navigation("GlossaryEntries"); + + b.Navigation("KnowledgePartners"); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.HomepageSettings", b => + { + b.Navigation("Countries"); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.PoliciesSettings", b => + { + b.Navigation("Sections"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/CCE.Infrastructure/Persistence/Migrations/20260522211302_RefactorPlatformSettings.cs b/backend/src/CCE.Infrastructure/Persistence/Migrations/20260522211302_RefactorPlatformSettings.cs new file mode 100644 index 00000000..fe7fbd19 --- /dev/null +++ b/backend/src/CCE.Infrastructure/Persistence/Migrations/20260522211302_RefactorPlatformSettings.cs @@ -0,0 +1,229 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CCE.Infrastructure.Persistence.Migrations +{ + /// + public partial class RefactorPlatformSettings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "deleted_by_id", + table: "policy_sections"); + + migrationBuilder.DropColumn( + name: "deleted_on", + table: "policy_sections"); + + migrationBuilder.DropColumn( + name: "is_deleted", + table: "policy_sections"); + + migrationBuilder.DropColumn( + name: "deleted_by_id", + table: "knowledge_partners"); + + migrationBuilder.DropColumn( + name: "deleted_on", + table: "knowledge_partners"); + + migrationBuilder.DropColumn( + name: "is_deleted", + table: "knowledge_partners"); + + migrationBuilder.DropColumn( + name: "deleted_by_id", + table: "glossary_entries"); + + migrationBuilder.DropColumn( + name: "deleted_on", + table: "glossary_entries"); + + migrationBuilder.DropColumn( + name: "is_deleted", + table: "glossary_entries"); + + migrationBuilder.AddColumn( + name: "created_by_id", + table: "homepage_countries", + type: "uniqueidentifier", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.AddColumn( + name: "created_on", + table: "homepage_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: "homepage_countries", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.AddColumn( + name: "last_modified_on", + table: "homepage_countries", + type: "datetimeoffset", + nullable: true); + + migrationBuilder.CreateIndex( + name: "ix_policy_sections_policies_settings_id", + table: "policy_sections", + column: "policies_settings_id"); + + migrationBuilder.CreateIndex( + name: "ix_knowledge_partners_about_settings_id", + table: "knowledge_partners", + column: "about_settings_id"); + + migrationBuilder.CreateIndex( + name: "ix_glossary_entries_about_settings_id", + table: "glossary_entries", + column: "about_settings_id"); + + migrationBuilder.AddForeignKey( + name: "fk_glossary_entries_about_settings_about_settings_id", + table: "glossary_entries", + column: "about_settings_id", + principalTable: "about_settings", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "fk_homepage_countries_homepage_settings_homepage_settings_id", + table: "homepage_countries", + column: "homepage_settings_id", + principalTable: "homepage_settings", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "fk_knowledge_partners_about_settings_about_settings_id", + table: "knowledge_partners", + column: "about_settings_id", + principalTable: "about_settings", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "fk_policy_sections_policies_settings_policies_settings_id", + table: "policy_sections", + column: "policies_settings_id", + principalTable: "policies_settings", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "fk_glossary_entries_about_settings_about_settings_id", + table: "glossary_entries"); + + migrationBuilder.DropForeignKey( + name: "fk_homepage_countries_homepage_settings_homepage_settings_id", + table: "homepage_countries"); + + migrationBuilder.DropForeignKey( + name: "fk_knowledge_partners_about_settings_about_settings_id", + table: "knowledge_partners"); + + migrationBuilder.DropForeignKey( + name: "fk_policy_sections_policies_settings_policies_settings_id", + table: "policy_sections"); + + migrationBuilder.DropIndex( + name: "ix_policy_sections_policies_settings_id", + table: "policy_sections"); + + migrationBuilder.DropIndex( + name: "ix_knowledge_partners_about_settings_id", + table: "knowledge_partners"); + + migrationBuilder.DropIndex( + name: "ix_glossary_entries_about_settings_id", + table: "glossary_entries"); + + migrationBuilder.DropColumn( + name: "created_by_id", + table: "homepage_countries"); + + migrationBuilder.DropColumn( + name: "created_on", + table: "homepage_countries"); + + migrationBuilder.DropColumn( + name: "last_modified_by_id", + table: "homepage_countries"); + + migrationBuilder.DropColumn( + name: "last_modified_on", + table: "homepage_countries"); + + migrationBuilder.AddColumn( + name: "deleted_by_id", + table: "policy_sections", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.AddColumn( + name: "deleted_on", + table: "policy_sections", + type: "datetimeoffset", + nullable: true); + + migrationBuilder.AddColumn( + name: "is_deleted", + table: "policy_sections", + type: "bit", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "deleted_by_id", + table: "knowledge_partners", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.AddColumn( + name: "deleted_on", + table: "knowledge_partners", + type: "datetimeoffset", + nullable: true); + + migrationBuilder.AddColumn( + name: "is_deleted", + table: "knowledge_partners", + type: "bit", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "deleted_by_id", + table: "glossary_entries", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.AddColumn( + name: "deleted_on", + table: "glossary_entries", + type: "datetimeoffset", + nullable: true); + + migrationBuilder.AddColumn( + name: "is_deleted", + table: "glossary_entries", + type: "bit", + nullable: false, + defaultValue: false); + } + } +} diff --git a/backend/src/CCE.Infrastructure/Persistence/Migrations/20260523111750_AddNotificationGateway.Designer.cs b/backend/src/CCE.Infrastructure/Persistence/Migrations/20260523111750_AddNotificationGateway.Designer.cs new file mode 100644 index 00000000..b46ff60a --- /dev/null +++ b/backend/src/CCE.Infrastructure/Persistence/Migrations/20260523111750_AddNotificationGateway.Designer.cs @@ -0,0 +1,3545 @@ +// +using System; +using CCE.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CCE.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(CceDbContext))] + [Migration("20260523111750_AddNotificationGateway")] + partial class AddNotificationGateway + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("CCE.Domain.Audit.AuditEvent", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("action"); + + b.Property("Actor") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("actor"); + + b.Property("CorrelationId") + .HasColumnType("uniqueidentifier") + .HasColumnName("correlation_id"); + + b.Property("Diff") + .HasColumnType("nvarchar(max)") + .HasColumnName("diff"); + + b.Property("OccurredOn") + .HasColumnType("datetimeoffset") + .HasColumnName("occurred_on"); + + b.Property("Resource") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("resource"); + + b.HasKey("Id") + .HasName("pk_audit_events"); + + b.HasIndex("CorrelationId") + .HasDatabaseName("ix_audit_events_correlation_id"); + + b.HasIndex("Actor", "OccurredOn") + .HasDatabaseName("ix_audit_events_actor_occurred_on"); + + b.ToTable("audit_events", null, t => + { + t.HasTrigger("trg_audit_events_no_update_delete"); + }); + + b.HasAnnotation("SqlServer:UseSqlOutputClause", false); + }); + + modelBuilder.Entity("CCE.Domain.Community.Post", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AnsweredReplyId") + .HasColumnType("uniqueidentifier") + .HasColumnName("answered_reply_id"); + + b.Property("AuthorId") + .HasColumnType("uniqueidentifier") + .HasColumnName("author_id"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)") + .HasColumnName("content"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsAnswerable") + .HasColumnType("bit") + .HasColumnName("is_answerable"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("locale"); + + b.Property("TopicId") + .HasColumnType("uniqueidentifier") + .HasColumnName("topic_id"); + + b.HasKey("Id") + .HasName("pk_posts"); + + b.HasIndex("TopicId") + .HasDatabaseName("ix_post_topic_id"); + + b.HasIndex("AuthorId", "CreatedOn") + .HasDatabaseName("ix_post_author_created"); + + b.ToTable("posts", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.PostFollow", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("FollowedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("followed_on"); + + b.Property("PostId") + .HasColumnType("uniqueidentifier") + .HasColumnName("post_id"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_post_follows"); + + b.HasIndex("PostId", "UserId") + .IsUnique() + .HasDatabaseName("ux_post_follow_post_user"); + + b.ToTable("post_follows", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.PostRating", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("PostId") + .HasColumnType("uniqueidentifier") + .HasColumnName("post_id"); + + b.Property("RatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("rated_on"); + + b.Property("Stars") + .HasColumnType("int") + .HasColumnName("stars"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_post_ratings"); + + b.HasIndex("PostId", "UserId") + .IsUnique() + .HasDatabaseName("ux_post_rating_post_user"); + + b.ToTable("post_ratings", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.PostReply", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AuthorId") + .HasColumnType("uniqueidentifier") + .HasColumnName("author_id"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)") + .HasColumnName("content"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsByExpert") + .HasColumnType("bit") + .HasColumnName("is_by_expert"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("locale"); + + b.Property("ParentReplyId") + .HasColumnType("uniqueidentifier") + .HasColumnName("parent_reply_id"); + + b.Property("PostId") + .HasColumnType("uniqueidentifier") + .HasColumnName("post_id"); + + b.HasKey("Id") + .HasName("pk_post_replies"); + + b.HasIndex("ParentReplyId") + .HasDatabaseName("ix_post_reply_parent_id"); + + b.HasIndex("PostId") + .HasDatabaseName("ix_post_reply_post_id"); + + b.ToTable("post_replies", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.Topic", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("DescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("IconUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("icon_url"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.Property("ParentId") + .HasColumnType("uniqueidentifier") + .HasColumnName("parent_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_topics"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ux_topic_slug_active") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("topics", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.TopicFollow", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("FollowedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("followed_on"); + + b.Property("TopicId") + .HasColumnType("uniqueidentifier") + .HasColumnName("topic_id"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_topic_follows"); + + b.HasIndex("TopicId", "UserId") + .IsUnique() + .HasDatabaseName("ux_topic_follow_topic_user"); + + b.ToTable("topic_follows", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.UserFollow", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("FollowedId") + .HasColumnType("uniqueidentifier") + .HasColumnName("followed_id"); + + b.Property("FollowedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("followed_on"); + + b.Property("FollowerId") + .HasColumnType("uniqueidentifier") + .HasColumnName("follower_id"); + + b.HasKey("Id") + .HasName("pk_user_follows"); + + b.HasIndex("FollowerId", "FollowedId") + .IsUnique() + .HasDatabaseName("ux_user_follow_follower_followed"); + + b.ToTable("user_follows", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.AssetFile", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("MimeType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("mime_type"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("original_file_name"); + + b.Property("ScannedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("scanned_on"); + + b.Property("SizeBytes") + .HasColumnType("bigint") + .HasColumnName("size_bytes"); + + b.Property("UploadedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("uploaded_by_id"); + + b.Property("UploadedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("uploaded_on"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("url"); + + b.Property("VirusScanStatus") + .HasColumnType("int") + .HasColumnName("virus_scan_status"); + + b.HasKey("Id") + .HasName("pk_asset_files"); + + b.HasIndex("VirusScanStatus") + .HasDatabaseName("ix_asset_file_scan_status"); + + b.ToTable("asset_files", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.Event", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("DescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("EndsOn") + .HasColumnType("datetimeoffset") + .HasColumnName("ends_on"); + + b.Property("FeaturedImageUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("featured_image_url"); + + b.Property("ICalUid") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("i_cal_uid"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("LocationAr") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("location_ar"); + + b.Property("LocationEn") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("location_en"); + + b.Property("OnlineMeetingUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("online_meeting_url"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.Property("StartsOn") + .HasColumnType("datetimeoffset") + .HasColumnName("starts_on"); + + b.Property("TitleAr") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_ar"); + + b.Property("TitleEn") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_en"); + + b.HasKey("Id") + .HasName("pk_events"); + + b.HasIndex("ICalUid") + .IsUnique() + .HasDatabaseName("ux_event_ical_uid"); + + b.HasIndex("StartsOn") + .HasDatabaseName("ix_event_starts_on"); + + b.ToTable("events", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.HomepageSection", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("ContentAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_ar"); + + b.Property("ContentEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_en"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.Property("SectionType") + .HasColumnType("int") + .HasColumnName("section_type"); + + b.HasKey("Id") + .HasName("pk_homepage_sections"); + + b.HasIndex("IsActive", "OrderIndex") + .HasDatabaseName("ix_homepage_section_active_order"); + + b.ToTable("homepage_sections", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.News", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AuthorId") + .HasColumnType("uniqueidentifier") + .HasColumnName("author_id"); + + b.Property("ContentAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_ar"); + + b.Property("ContentEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_en"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("FeaturedImageUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("featured_image_url"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("IsFeatured") + .HasColumnType("bit") + .HasColumnName("is_featured"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("PublishedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("published_on"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("slug"); + + b.Property("TitleAr") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_ar"); + + b.Property("TitleEn") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_en"); + + b.HasKey("Id") + .HasName("pk_news"); + + b.HasIndex("PublishedOn") + .HasDatabaseName("ix_news_published_on"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ux_news_slug_active") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("news", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.NewsletterSubscription", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("ConfirmationToken") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("confirmation_token"); + + b.Property("ConfirmedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("confirmed_on"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("nvarchar(320)") + .HasColumnName("email"); + + b.Property("IsConfirmed") + .HasColumnType("bit") + .HasColumnName("is_confirmed"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("LocalePreference") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("locale_preference"); + + b.Property("UnsubscribedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("unsubscribed_on"); + + b.HasKey("Id") + .HasName("pk_newsletter_subscriptions"); + + b.HasIndex("ConfirmationToken") + .HasDatabaseName("ix_newsletter_token"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("ux_newsletter_email"); + + b.ToTable("newsletter_subscriptions", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.Page", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("ContentAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_ar"); + + b.Property("ContentEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_en"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("PageType") + .HasColumnType("int") + .HasColumnName("page_type"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("slug"); + + b.Property("TitleAr") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_ar"); + + b.Property("TitleEn") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_en"); + + b.HasKey("Id") + .HasName("pk_pages"); + + b.HasIndex("PageType", "Slug") + .IsUnique() + .HasDatabaseName("ux_page_type_slug_active") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("pages", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.Resource", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AssetFileId") + .HasColumnType("uniqueidentifier") + .HasColumnName("asset_file_id"); + + b.Property("CategoryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("category_id"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("DescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("PublishedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("published_on"); + + b.Property("ResourceType") + .HasColumnType("int") + .HasColumnName("resource_type"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.Property("TitleAr") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_ar"); + + b.Property("TitleEn") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_en"); + + b.Property("UploadedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("uploaded_by_id"); + + b.Property("ViewCount") + .HasColumnType("bigint") + .HasColumnName("view_count"); + + b.HasKey("Id") + .HasName("pk_resources"); + + b.HasIndex("AssetFileId") + .HasDatabaseName("ix_resource_asset_file_id"); + + b.HasIndex("CountryId") + .HasDatabaseName("ix_resource_country_id"); + + b.HasIndex("CategoryId", "PublishedOn") + .HasDatabaseName("ix_resource_category_published"); + + b.ToTable("resources", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.ResourceCategory", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.Property("ParentId") + .HasColumnType("uniqueidentifier") + .HasColumnName("parent_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_resource_categories"); + + b.HasIndex("ParentId") + .HasDatabaseName("ix_resource_category_parent_id"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ux_resource_category_slug"); + + b.ToTable("resource_categories", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Country.Country", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("FlagUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("flag_url"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("IsoAlpha2") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("iso_alpha2"); + + b.Property("IsoAlpha3") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("nvarchar(3)") + .HasColumnName("iso_alpha3"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("LatestKapsarcSnapshotId") + .HasColumnType("uniqueidentifier") + .HasColumnName("latest_kapsarc_snapshot_id"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.Property("RegionAr") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("region_ar"); + + b.Property("RegionEn") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("region_en"); + + b.HasKey("Id") + .HasName("pk_countries"); + + b.HasIndex("IsoAlpha2") + .HasDatabaseName("ix_country_iso_alpha2"); + + b.HasIndex("IsoAlpha3") + .IsUnique() + .HasDatabaseName("ux_country_iso_alpha3_active") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("countries", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Country.CountryKapsarcSnapshot", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("Classification") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("classification"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("PerformanceScore") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)") + .HasColumnName("performance_score"); + + b.Property("SnapshotTakenOn") + .HasColumnType("datetimeoffset") + .HasColumnName("snapshot_taken_on"); + + b.Property("SourceVersion") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)") + .HasColumnName("source_version"); + + b.Property("TotalIndex") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)") + .HasColumnName("total_index"); + + b.HasKey("Id") + .HasName("pk_country_kapsarc_snapshots"); + + b.HasIndex("CountryId", "SnapshotTakenOn") + .HasDatabaseName("ix_kapsarc_snapshot_country_taken"); + + b.ToTable("country_kapsarc_snapshots", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Country.CountryProfile", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("ContactInfoAr") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("contact_info_ar"); + + b.Property("ContactInfoEn") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("contact_info_en"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("KeyInitiativesAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("key_initiatives_ar"); + + b.Property("KeyInitiativesEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("key_initiatives_en"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.HasKey("Id") + .HasName("pk_country_profiles"); + + b.HasIndex("CountryId") + .IsUnique() + .HasDatabaseName("ux_country_profile_country_id"); + + b.ToTable("country_profiles", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Country.CountryResourceRequest", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AdminNotesAr") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("admin_notes_ar"); + + b.Property("AdminNotesEn") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("admin_notes_en"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("ProcessedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("processed_by_id"); + + b.Property("ProcessedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("processed_on"); + + b.Property("ProposedAssetFileId") + .HasColumnType("uniqueidentifier") + .HasColumnName("proposed_asset_file_id"); + + b.Property("ProposedDescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("proposed_description_ar"); + + b.Property("ProposedDescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("proposed_description_en"); + + b.Property("ProposedResourceType") + .HasColumnType("int") + .HasColumnName("proposed_resource_type"); + + b.Property("ProposedTitleAr") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("proposed_title_ar"); + + b.Property("ProposedTitleEn") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("proposed_title_en"); + + b.Property("RequestedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("requested_by_id"); + + b.Property("Status") + .HasColumnType("int") + .HasColumnName("status"); + + b.Property("SubmittedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("submitted_on"); + + b.HasKey("Id") + .HasName("pk_country_resource_requests"); + + b.HasIndex("CountryId", "Status") + .HasDatabaseName("ix_country_request_country_status"); + + b.ToTable("country_resource_requests", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.ExpertProfile", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AcademicTitleAr") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("academic_title_ar"); + + b.Property("AcademicTitleEn") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("academic_title_en"); + + b.Property("ApprovedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("approved_by_id"); + + b.Property("ApprovedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("approved_on"); + + b.Property("BioAr") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("bio_ar"); + + b.Property("BioEn") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("bio_en"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.PrimitiveCollection("ExpertiseTags") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("expertise_tags"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_expert_profiles"); + + b.HasIndex("UserId") + .IsUnique() + .HasDatabaseName("ux_expert_profile_active_user") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("expert_profiles", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.ExpertRegistrationRequest", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("ProcessedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("processed_by_id"); + + b.Property("ProcessedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("processed_on"); + + b.Property("RejectionReasonAr") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("rejection_reason_ar"); + + b.Property("RejectionReasonEn") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("rejection_reason_en"); + + b.Property("RequestedBioAr") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("requested_bio_ar"); + + b.Property("RequestedBioEn") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("requested_bio_en"); + + b.Property("RequestedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("requested_by_id"); + + b.PrimitiveCollection("RequestedTags") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("requested_tags"); + + b.Property("Status") + .HasColumnType("int") + .HasColumnName("status"); + + b.Property("SubmittedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("submitted_on"); + + b.HasKey("Id") + .HasName("pk_expert_registration_requests"); + + b.HasIndex("RequestedById") + .HasDatabaseName("ix_expert_request_requested_by"); + + b.HasIndex("Status") + .HasDatabaseName("ix_expert_request_status"); + + b.ToTable("expert_registration_requests", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.RefreshToken", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset") + .HasColumnName("created_at_utc"); + + b.Property("CreatedByIp") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("created_by_ip"); + + b.Property("ExpiresAtUtc") + .HasColumnType("datetimeoffset") + .HasColumnName("expires_at_utc"); + + b.Property("ReplacedByTokenHash") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("replaced_by_token_hash"); + + b.Property("RevokedAtUtc") + .HasColumnType("datetimeoffset") + .HasColumnName("revoked_at_utc"); + + b.Property("RevokedByIp") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("revoked_by_ip"); + + b.Property("TokenFamilyId") + .HasColumnType("uniqueidentifier") + .HasColumnName("token_family_id"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("token_hash"); + + b.Property("UserAgent") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_refresh_tokens"); + + b.HasIndex("TokenFamilyId") + .HasDatabaseName("ix_refresh_tokens_token_family_id"); + + b.HasIndex("TokenHash") + .IsUnique() + .HasDatabaseName("ux_refresh_tokens_token_hash"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_refresh_tokens_user_id"); + + b.ToTable("refresh_tokens", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)") + .HasColumnName("concurrency_stamp"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("normalized_name"); + + b.HasKey("Id") + .HasName("pk_asp_net_roles"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[normalized_name] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.StateRepresentativeAssignment", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AssignedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("assigned_by_id"); + + b.Property("AssignedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("assigned_on"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("RevokedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("revoked_by_id"); + + b.Property("RevokedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("revoked_on"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_state_representative_assignments"); + + b.HasIndex("CountryId") + .HasDatabaseName("ix_state_rep_country_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_state_rep_user_id"); + + b.HasIndex("UserId", "CountryId") + .IsUnique() + .HasDatabaseName("ux_state_rep_active_user_country") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("state_representative_assignments", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AccessFailedCount") + .HasColumnType("int") + .HasColumnName("access_failed_count"); + + b.Property("AvatarUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("avatar_url"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)") + .HasColumnName("concurrency_stamp"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("email"); + + b.Property("EmailConfirmed") + .HasColumnType("bit") + .HasColumnName("email_confirmed"); + + b.Property("EntraIdObjectId") + .HasColumnType("uniqueidentifier") + .HasColumnName("entra_id_object_id"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)") + .HasColumnName("first_name"); + + b.PrimitiveCollection("Interests") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("interests"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("JobTitle") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)") + .HasColumnName("job_title"); + + b.Property("KnowledgeLevel") + .HasColumnType("int") + .HasColumnName("knowledge_level"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)") + .HasColumnName("last_name"); + + b.Property("LocalePreference") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("locale_preference"); + + b.Property("LockoutEnabled") + .HasColumnType("bit") + .HasColumnName("lockout_enabled"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset") + .HasColumnName("lockout_end"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("normalized_email"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("normalized_user_name"); + + b.Property("OrganizationName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("organization_name"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)") + .HasColumnName("password_hash"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)") + .HasColumnName("phone_number"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit") + .HasColumnName("phone_number_confirmed"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)") + .HasColumnName("security_stamp"); + + b.Property("Status") + .HasColumnType("int") + .HasColumnName("status"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit") + .HasColumnName("two_factor_enabled"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("user_name"); + + b.HasKey("Id") + .HasName("pk_asp_net_users"); + + b.HasIndex("CountryId") + .HasDatabaseName("ix_users_country_id"); + + b.HasIndex("EntraIdObjectId") + .IsUnique() + .HasDatabaseName("ix_asp_net_users_entra_id_object_id") + .HasFilter("[entra_id_object_id] IS NOT NULL"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[normalized_user_name] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.InteractiveCity.CityScenario", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CityType") + .HasColumnType("int") + .HasColumnName("city_type"); + + b.Property("ConfigurationJson") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("configuration_json"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.Property("TargetYear") + .HasColumnType("int") + .HasColumnName("target_year"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_city_scenarios"); + + b.HasIndex("UserId", "LastModifiedOn") + .HasDatabaseName("ix_city_scenario_user_modified"); + + b.ToTable("city_scenarios", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.InteractiveCity.CityScenarioResult", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("ComputedAt") + .HasColumnType("datetimeoffset") + .HasColumnName("computed_at"); + + b.Property("ComputedCarbonNeutralityYear") + .HasColumnType("int") + .HasColumnName("computed_carbon_neutrality_year"); + + b.Property("ComputedTotalCostUsd") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)") + .HasColumnName("computed_total_cost_usd"); + + b.Property("EngineVersion") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("engine_version"); + + b.Property("ScenarioId") + .HasColumnType("uniqueidentifier") + .HasColumnName("scenario_id"); + + b.HasKey("Id") + .HasName("pk_city_scenario_results"); + + b.HasIndex("ScenarioId", "ComputedAt") + .HasDatabaseName("ix_city_result_scenario_at"); + + b.ToTable("city_scenario_results", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.InteractiveCity.CityTechnology", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CarbonImpactKgPerYear") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)") + .HasColumnName("carbon_impact_kg_per_year"); + + b.Property("CategoryAr") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("category_ar"); + + b.Property("CategoryEn") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("category_en"); + + b.Property("CostUsd") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)") + .HasColumnName("cost_usd"); + + b.Property("DescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("IconUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("icon_url"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.HasKey("Id") + .HasName("pk_city_technologies"); + + b.HasIndex("IsActive") + .HasDatabaseName("ix_city_tech_is_active"); + + b.ToTable("city_technologies", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.KnowledgeMaps.KnowledgeMap", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("DescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_knowledge_maps"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ux_knowledge_map_slug_active") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("knowledge_maps", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.KnowledgeMaps.KnowledgeMapAssociation", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AssociatedId") + .HasColumnType("uniqueidentifier") + .HasColumnName("associated_id"); + + b.Property("AssociatedType") + .HasColumnType("int") + .HasColumnName("associated_type"); + + b.Property("NodeId") + .HasColumnType("uniqueidentifier") + .HasColumnName("node_id"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.HasKey("Id") + .HasName("pk_knowledge_map_associations"); + + b.HasIndex("NodeId", "AssociatedType", "AssociatedId") + .IsUnique() + .HasDatabaseName("ux_km_assoc_node_type_id"); + + b.ToTable("knowledge_map_associations", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.KnowledgeMaps.KnowledgeMapEdge", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("FromNodeId") + .HasColumnType("uniqueidentifier") + .HasColumnName("from_node_id"); + + b.Property("MapId") + .HasColumnType("uniqueidentifier") + .HasColumnName("map_id"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.Property("RelationshipType") + .HasColumnType("int") + .HasColumnName("relationship_type"); + + b.Property("ToNodeId") + .HasColumnType("uniqueidentifier") + .HasColumnName("to_node_id"); + + b.HasKey("Id") + .HasName("pk_knowledge_map_edges"); + + b.HasIndex("FromNodeId") + .HasDatabaseName("ix_km_edge_from_node"); + + b.HasIndex("ToNodeId") + .HasDatabaseName("ix_km_edge_to_node"); + + b.HasIndex("MapId", "FromNodeId", "ToNodeId", "RelationshipType") + .IsUnique() + .HasDatabaseName("ux_km_edge_map_from_to_relation"); + + b.ToTable("knowledge_map_edges", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.KnowledgeMaps.KnowledgeMapNode", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("DescriptionAr") + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("IconUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("icon_url"); + + b.Property("LayoutX") + .HasColumnType("float") + .HasColumnName("layout_x"); + + b.Property("LayoutY") + .HasColumnType("float") + .HasColumnName("layout_y"); + + b.Property("MapId") + .HasColumnType("uniqueidentifier") + .HasColumnName("map_id"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.Property("NodeType") + .HasColumnType("int") + .HasColumnName("node_type"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.HasKey("Id") + .HasName("pk_knowledge_map_nodes"); + + b.HasIndex("MapId", "OrderIndex") + .HasDatabaseName("ix_km_node_map_order"); + + b.ToTable("knowledge_map_nodes", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Media.MediaFile", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AltTextAr") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasColumnName("alt_text_ar"); + + b.Property("AltTextEn") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasColumnName("alt_text_en"); + + b.Property("DescriptionAr") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("description_en"); + + b.Property("MimeType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("mime_type"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)") + .HasColumnName("original_file_name"); + + b.Property("SizeBytes") + .HasColumnType("bigint") + .HasColumnName("size_bytes"); + + b.Property("StorageKey") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasColumnName("storage_key"); + + b.Property("TitleAr") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)") + .HasColumnName("title_ar"); + + b.Property("TitleEn") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)") + .HasColumnName("title_en"); + + b.Property("UploadedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("uploaded_by_id"); + + b.Property("UploadedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("uploaded_on"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("url"); + + b.HasKey("Id") + .HasName("pk_media_files"); + + b.ToTable("media_files", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Notifications.NotificationLog", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AttemptCount") + .HasColumnType("int") + .HasColumnName("attempt_count"); + + b.Property("Channel") + .HasColumnType("int") + .HasColumnName("channel"); + + b.Property("CorrelationId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("correlation_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("Error") + .HasColumnType("nvarchar(max)") + .HasColumnName("error"); + + b.Property("FailedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("failed_on"); + + b.Property("PayloadJson") + .HasColumnType("nvarchar(max)") + .HasColumnName("payload_json"); + + b.Property("ProviderMessageId") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("provider_message_id"); + + b.Property("RecipientUserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("recipient_user_id"); + + b.Property("SentOn") + .HasColumnType("datetimeoffset") + .HasColumnName("sent_on"); + + b.Property("Status") + .HasColumnType("int") + .HasColumnName("status"); + + b.Property("TemplateCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("template_code"); + + b.Property("TemplateId") + .HasColumnType("uniqueidentifier") + .HasColumnName("template_id"); + + b.HasKey("Id") + .HasName("pk_notification_logs"); + + b.HasIndex("CorrelationId") + .HasDatabaseName("ix_notification_log_correlation_id"); + + b.HasIndex("TemplateCode", "Channel") + .HasDatabaseName("ix_notification_log_template_channel"); + + b.HasIndex("RecipientUserId", "Status", "CreatedOn") + .HasDatabaseName("ix_notification_log_recipient_status_created"); + + b.ToTable("notification_logs", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Notifications.NotificationTemplate", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("BodyAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("body_ar"); + + b.Property("BodyEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("body_en"); + + b.Property("Channel") + .HasColumnType("int") + .HasColumnName("channel"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("code"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("SubjectAr") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("subject_ar"); + + b.Property("SubjectEn") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("subject_en"); + + b.Property("VariableSchemaJson") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("variable_schema_json"); + + b.HasKey("Id") + .HasName("pk_notification_templates"); + + b.HasIndex("Code", "Channel") + .IsUnique() + .HasDatabaseName("ux_notification_template_code_channel"); + + b.ToTable("notification_templates", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Notifications.UserNotification", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("Channel") + .HasColumnType("int") + .HasColumnName("channel"); + + b.Property("ReadOn") + .HasColumnType("datetimeoffset") + .HasColumnName("read_on"); + + b.Property("RenderedBody") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("rendered_body"); + + b.Property("RenderedLocale") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("rendered_locale"); + + b.Property("RenderedSubjectAr") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("rendered_subject_ar"); + + b.Property("RenderedSubjectEn") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("rendered_subject_en"); + + b.Property("SentOn") + .HasColumnType("datetimeoffset") + .HasColumnName("sent_on"); + + b.Property("Status") + .HasColumnType("int") + .HasColumnName("status"); + + b.Property("TemplateId") + .HasColumnType("uniqueidentifier") + .HasColumnName("template_id"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_user_notifications"); + + b.HasIndex("UserId", "Status") + .HasDatabaseName("ix_user_notification_user_status"); + + b.ToTable("user_notifications", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Notifications.UserNotificationSettings", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("Channel") + .HasColumnType("int") + .HasColumnName("channel"); + + b.Property("EventCode") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("event_code"); + + b.Property("IsEnabled") + .HasColumnType("bit") + .HasColumnName("is_enabled"); + + b.Property("UpdatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("updated_on"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_user_notification_settings"); + + b.HasIndex("UserId", "Channel", "EventCode") + .IsUnique() + .HasDatabaseName("ux_user_notification_settings_user_channel_event") + .HasFilter("[event_code] IS NOT NULL"); + + b.ToTable("user_notification_settings", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.AboutSettings", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("HowToUseVideoUrl") + .HasColumnType("nvarchar(max)") + .HasColumnName("how_to_use_video_url"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.HasKey("Id") + .HasName("pk_about_settings"); + + b.ToTable("about_settings", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.GlossaryEntry", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AboutSettingsId") + .HasColumnType("uniqueidentifier") + .HasColumnName("about_settings_id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.HasKey("Id") + .HasName("pk_glossary_entries"); + + b.HasIndex("AboutSettingsId") + .HasDatabaseName("ix_glossary_entries_about_settings_id"); + + b.ToTable("glossary_entries", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.HomepageCountry", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("HomepageSettingsId") + .HasColumnType("uniqueidentifier") + .HasColumnName("homepage_settings_id"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.HasKey("Id") + .HasName("pk_homepage_countries"); + + b.HasIndex("HomepageSettingsId", "CountryId") + .IsUnique() + .HasDatabaseName("ix_homepage_country_settings_country"); + + b.ToTable("homepage_countries", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.HomepageSettings", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CceConceptsAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("cce_concepts_ar"); + + b.Property("CceConceptsEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("cce_concepts_en"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.Property("VideoUrl") + .HasColumnType("nvarchar(max)") + .HasColumnName("video_url"); + + b.HasKey("Id") + .HasName("pk_homepage_settings"); + + b.ToTable("homepage_settings", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.KnowledgePartner", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AboutSettingsId") + .HasColumnType("uniqueidentifier") + .HasColumnName("about_settings_id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("LogoUrl") + .HasColumnType("nvarchar(max)") + .HasColumnName("logo_url"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.Property("WebsiteUrl") + .HasColumnType("nvarchar(max)") + .HasColumnName("website_url"); + + b.HasKey("Id") + .HasName("pk_knowledge_partners"); + + b.HasIndex("AboutSettingsId") + .HasDatabaseName("ix_knowledge_partners_about_settings_id"); + + b.ToTable("knowledge_partners", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.PoliciesSettings", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.HasKey("Id") + .HasName("pk_policies_settings"); + + b.ToTable("policies_settings", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.PolicySection", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.Property("PoliciesSettingsId") + .HasColumnType("uniqueidentifier") + .HasColumnName("policies_settings_id"); + + b.Property("Type") + .HasColumnType("int") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_policy_sections"); + + b.HasIndex("PoliciesSettingsId") + .HasDatabaseName("ix_policy_sections_policies_settings_id"); + + b.ToTable("policy_sections", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Surveys.SearchQueryLog", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("locale"); + + b.Property("QueryText") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("query_text"); + + b.Property("ResponseTimeMs") + .HasColumnType("int") + .HasColumnName("response_time_ms"); + + b.Property("ResultsCount") + .HasColumnType("int") + .HasColumnName("results_count"); + + b.Property("SubmittedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("submitted_on"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_search_query_logs"); + + b.HasIndex("SubmittedOn") + .HasDatabaseName("ix_search_query_log_submitted_on"); + + b.ToTable("search_query_logs", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Surveys.ServiceRating", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CommentAr") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("comment_ar"); + + b.Property("CommentEn") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("comment_en"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("locale"); + + b.Property("Page") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("page"); + + b.Property("Rating") + .HasColumnType("int") + .HasColumnName("rating"); + + b.Property("SubmittedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("submitted_on"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_service_ratings"); + + b.HasIndex("SubmittedOn") + .HasDatabaseName("ix_service_rating_submitted_on"); + + b.ToTable("service_ratings", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)") + .HasColumnName("claim_type"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)") + .HasColumnName("claim_value"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier") + .HasColumnName("role_id"); + + b.HasKey("Id") + .HasName("pk_asp_net_role_claims"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_asp_net_role_claims_role_id"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)") + .HasColumnName("claim_type"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)") + .HasColumnName("claim_value"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_asp_net_user_claims"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_asp_net_user_claims_user_id"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)") + .HasColumnName("login_provider"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)") + .HasColumnName("provider_key"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)") + .HasColumnName("provider_display_name"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("LoginProvider", "ProviderKey") + .HasName("pk_asp_net_user_logins"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_asp_net_user_logins_user_id"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier") + .HasColumnName("role_id"); + + b.HasKey("UserId", "RoleId") + .HasName("pk_asp_net_user_roles"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_asp_net_user_roles_role_id"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)") + .HasColumnName("login_provider"); + + b.Property("Name") + .HasColumnType("nvarchar(450)") + .HasColumnName("name"); + + b.Property("Value") + .HasColumnType("nvarchar(max)") + .HasColumnName("value"); + + b.HasKey("UserId", "LoginProvider", "Name") + .HasName("pk_asp_net_user_tokens"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.RefreshToken", b => + { + b.HasOne("CCE.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_refresh_tokens_asp_net_users_user_id"); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.AboutSettings", b => + { + b.OwnsOne("CCE.Domain.PlatformSettings.ValueObjects.LocalizedText", "Description", b1 => + { + b1.Property("AboutSettingsId") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b1.Property("Ar") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("description_ar"); + + b1.Property("En") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("description_en"); + + b1.HasKey("AboutSettingsId"); + + b1.ToTable("about_settings"); + + b1.WithOwner() + .HasForeignKey("AboutSettingsId") + .HasConstraintName("fk_about_settings_about_settings_id"); + }); + + b.Navigation("Description") + .IsRequired(); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.GlossaryEntry", b => + { + b.HasOne("CCE.Domain.PlatformSettings.AboutSettings", null) + .WithMany("GlossaryEntries") + .HasForeignKey("AboutSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_glossary_entries_about_settings_about_settings_id"); + + b.OwnsOne("CCE.Domain.PlatformSettings.ValueObjects.LocalizedText", "Definition", b1 => + { + b1.Property("GlossaryEntryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b1.Property("Ar") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("definition_ar"); + + b1.Property("En") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("definition_en"); + + b1.HasKey("GlossaryEntryId"); + + b1.ToTable("glossary_entries"); + + b1.WithOwner() + .HasForeignKey("GlossaryEntryId") + .HasConstraintName("fk_glossary_entries_glossary_entries_id"); + }); + + b.OwnsOne("CCE.Domain.PlatformSettings.ValueObjects.LocalizedText", "Term", b1 => + { + b1.Property("GlossaryEntryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b1.Property("Ar") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("term_ar"); + + b1.Property("En") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("term_en"); + + b1.HasKey("GlossaryEntryId"); + + b1.ToTable("glossary_entries"); + + b1.WithOwner() + .HasForeignKey("GlossaryEntryId") + .HasConstraintName("fk_glossary_entries_glossary_entries_id"); + }); + + b.Navigation("Definition") + .IsRequired(); + + b.Navigation("Term") + .IsRequired(); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.HomepageCountry", b => + { + b.HasOne("CCE.Domain.PlatformSettings.HomepageSettings", null) + .WithMany("Countries") + .HasForeignKey("HomepageSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_homepage_countries_homepage_settings_homepage_settings_id"); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.HomepageSettings", b => + { + b.OwnsOne("CCE.Domain.PlatformSettings.ValueObjects.LocalizedText", "Objective", b1 => + { + b1.Property("HomepageSettingsId") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b1.Property("Ar") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("objective_ar"); + + b1.Property("En") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("objective_en"); + + b1.HasKey("HomepageSettingsId"); + + b1.ToTable("homepage_settings"); + + b1.WithOwner() + .HasForeignKey("HomepageSettingsId") + .HasConstraintName("fk_homepage_settings_homepage_settings_id"); + }); + + b.Navigation("Objective") + .IsRequired(); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.KnowledgePartner", b => + { + b.HasOne("CCE.Domain.PlatformSettings.AboutSettings", null) + .WithMany("KnowledgePartners") + .HasForeignKey("AboutSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_knowledge_partners_about_settings_about_settings_id"); + + b.OwnsOne("CCE.Domain.PlatformSettings.ValueObjects.LocalizedText", "Description", b1 => + { + b1.Property("KnowledgePartnerId") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b1.Property("Ar") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("description_ar"); + + b1.Property("En") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("description_en"); + + b1.HasKey("KnowledgePartnerId"); + + b1.ToTable("knowledge_partners"); + + b1.WithOwner() + .HasForeignKey("KnowledgePartnerId") + .HasConstraintName("fk_knowledge_partners_knowledge_partners_id"); + }); + + b.OwnsOne("CCE.Domain.PlatformSettings.ValueObjects.LocalizedText", "Name", b1 => + { + b1.Property("KnowledgePartnerId") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b1.Property("Ar") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)") + .HasColumnName("name_ar"); + + b1.Property("En") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)") + .HasColumnName("name_en"); + + b1.HasKey("KnowledgePartnerId"); + + b1.ToTable("knowledge_partners"); + + b1.WithOwner() + .HasForeignKey("KnowledgePartnerId") + .HasConstraintName("fk_knowledge_partners_knowledge_partners_id"); + }); + + b.Navigation("Description"); + + b.Navigation("Name") + .IsRequired(); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.PolicySection", b => + { + b.HasOne("CCE.Domain.PlatformSettings.PoliciesSettings", null) + .WithMany("Sections") + .HasForeignKey("PoliciesSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_policy_sections_policies_settings_policies_settings_id"); + + b.OwnsOne("CCE.Domain.PlatformSettings.ValueObjects.LocalizedText", "Content", b1 => + { + b1.Property("PolicySectionId") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b1.Property("Ar") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_ar"); + + b1.Property("En") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_en"); + + b1.HasKey("PolicySectionId"); + + b1.ToTable("policy_sections"); + + b1.WithOwner() + .HasForeignKey("PolicySectionId") + .HasConstraintName("fk_policy_sections_policy_sections_id"); + }); + + b.OwnsOne("CCE.Domain.PlatformSettings.ValueObjects.LocalizedText", "Title", b1 => + { + b1.Property("PolicySectionId") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b1.Property("Ar") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasColumnName("title_ar"); + + b1.Property("En") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasColumnName("title_en"); + + b1.HasKey("PolicySectionId"); + + b1.ToTable("policy_sections"); + + b1.WithOwner() + .HasForeignKey("PolicySectionId") + .HasConstraintName("fk_policy_sections_policy_sections_id"); + }); + + b.Navigation("Content") + .IsRequired(); + + b.Navigation("Title") + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("CCE.Domain.Identity.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_role_claims_asp_net_roles_role_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("CCE.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_user_claims_asp_net_users_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("CCE.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_user_logins_asp_net_users_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("CCE.Domain.Identity.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_user_roles_asp_net_roles_role_id"); + + b.HasOne("CCE.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_user_roles_asp_net_users_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("CCE.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_user_tokens_asp_net_users_user_id"); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.AboutSettings", b => + { + b.Navigation("GlossaryEntries"); + + b.Navigation("KnowledgePartners"); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.HomepageSettings", b => + { + b.Navigation("Countries"); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.PoliciesSettings", b => + { + b.Navigation("Sections"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/CCE.Infrastructure/Persistence/Migrations/20260523111750_AddNotificationGateway.cs b/backend/src/CCE.Infrastructure/Persistence/Migrations/20260523111750_AddNotificationGateway.cs new file mode 100644 index 00000000..43b865a5 --- /dev/null +++ b/backend/src/CCE.Infrastructure/Persistence/Migrations/20260523111750_AddNotificationGateway.cs @@ -0,0 +1,107 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CCE.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddNotificationGateway : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "ux_notification_template_code", + table: "notification_templates"); + + migrationBuilder.CreateTable( + name: "notification_logs", + columns: table => new + { + id = table.Column(type: "uniqueidentifier", nullable: false), + recipient_user_id = table.Column(type: "uniqueidentifier", nullable: true), + template_code = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + template_id = table.Column(type: "uniqueidentifier", nullable: true), + channel = table.Column(type: "int", nullable: false), + status = table.Column(type: "int", nullable: false), + provider_message_id = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + error = table.Column(type: "nvarchar(max)", nullable: true), + attempt_count = table.Column(type: "int", nullable: false), + created_on = table.Column(type: "datetimeoffset", nullable: false), + sent_on = table.Column(type: "datetimeoffset", nullable: true), + failed_on = table.Column(type: "datetimeoffset", nullable: true), + correlation_id = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true), + payload_json = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_notification_logs", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "user_notification_settings", + columns: table => new + { + id = table.Column(type: "uniqueidentifier", nullable: false), + user_id = table.Column(type: "uniqueidentifier", nullable: false), + channel = table.Column(type: "int", nullable: false), + event_code = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true), + is_enabled = table.Column(type: "bit", nullable: false), + updated_on = table.Column(type: "datetimeoffset", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_user_notification_settings", x => x.id); + }); + + migrationBuilder.CreateIndex( + name: "ux_notification_template_code_channel", + table: "notification_templates", + columns: new[] { "code", "channel" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_notification_log_correlation_id", + table: "notification_logs", + column: "correlation_id"); + + migrationBuilder.CreateIndex( + name: "ix_notification_log_recipient_status_created", + table: "notification_logs", + columns: new[] { "recipient_user_id", "status", "created_on" }); + + migrationBuilder.CreateIndex( + name: "ix_notification_log_template_channel", + table: "notification_logs", + columns: new[] { "template_code", "channel" }); + + migrationBuilder.CreateIndex( + name: "ux_user_notification_settings_user_channel_event", + table: "user_notification_settings", + columns: new[] { "user_id", "channel", "event_code" }, + unique: true, + filter: "[event_code] IS NOT NULL"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "notification_logs"); + + migrationBuilder.DropTable( + name: "user_notification_settings"); + + migrationBuilder.DropIndex( + name: "ux_notification_template_code_channel", + table: "notification_templates"); + + migrationBuilder.CreateIndex( + name: "ux_notification_template_code", + table: "notification_templates", + column: "code", + unique: true); + } + } +} diff --git a/backend/src/CCE.Infrastructure/Persistence/Migrations/20260523180351_AddOtpVerification.Designer.cs b/backend/src/CCE.Infrastructure/Persistence/Migrations/20260523180351_AddOtpVerification.Designer.cs new file mode 100644 index 00000000..e81ed817 --- /dev/null +++ b/backend/src/CCE.Infrastructure/Persistence/Migrations/20260523180351_AddOtpVerification.Designer.cs @@ -0,0 +1,3705 @@ +// +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("20260523180351_AddOtpVerification")] + partial class AddOtpVerification + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("CCE.Domain.Audit.AuditEvent", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("action"); + + b.Property("Actor") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("actor"); + + b.Property("CorrelationId") + .HasColumnType("uniqueidentifier") + .HasColumnName("correlation_id"); + + b.Property("Diff") + .HasColumnType("nvarchar(max)") + .HasColumnName("diff"); + + b.Property("OccurredOn") + .HasColumnType("datetimeoffset") + .HasColumnName("occurred_on"); + + b.Property("Resource") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("resource"); + + b.HasKey("Id") + .HasName("pk_audit_events"); + + b.HasIndex("CorrelationId") + .HasDatabaseName("ix_audit_events_correlation_id"); + + b.HasIndex("Actor", "OccurredOn") + .HasDatabaseName("ix_audit_events_actor_occurred_on"); + + b.ToTable("audit_events", null, t => + { + t.HasTrigger("trg_audit_events_no_update_delete"); + }); + + b.HasAnnotation("SqlServer:UseSqlOutputClause", false); + }); + + modelBuilder.Entity("CCE.Domain.Community.Post", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AnsweredReplyId") + .HasColumnType("uniqueidentifier") + .HasColumnName("answered_reply_id"); + + b.Property("AuthorId") + .HasColumnType("uniqueidentifier") + .HasColumnName("author_id"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)") + .HasColumnName("content"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsAnswerable") + .HasColumnType("bit") + .HasColumnName("is_answerable"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("locale"); + + b.Property("TopicId") + .HasColumnType("uniqueidentifier") + .HasColumnName("topic_id"); + + b.HasKey("Id") + .HasName("pk_posts"); + + b.HasIndex("TopicId") + .HasDatabaseName("ix_post_topic_id"); + + b.HasIndex("AuthorId", "CreatedOn") + .HasDatabaseName("ix_post_author_created"); + + b.ToTable("posts", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.PostFollow", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("FollowedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("followed_on"); + + b.Property("PostId") + .HasColumnType("uniqueidentifier") + .HasColumnName("post_id"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_post_follows"); + + b.HasIndex("PostId", "UserId") + .IsUnique() + .HasDatabaseName("ux_post_follow_post_user"); + + b.ToTable("post_follows", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.PostRating", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("PostId") + .HasColumnType("uniqueidentifier") + .HasColumnName("post_id"); + + b.Property("RatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("rated_on"); + + b.Property("Stars") + .HasColumnType("int") + .HasColumnName("stars"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_post_ratings"); + + b.HasIndex("PostId", "UserId") + .IsUnique() + .HasDatabaseName("ux_post_rating_post_user"); + + b.ToTable("post_ratings", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.PostReply", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AuthorId") + .HasColumnType("uniqueidentifier") + .HasColumnName("author_id"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)") + .HasColumnName("content"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsByExpert") + .HasColumnType("bit") + .HasColumnName("is_by_expert"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("locale"); + + b.Property("ParentReplyId") + .HasColumnType("uniqueidentifier") + .HasColumnName("parent_reply_id"); + + b.Property("PostId") + .HasColumnType("uniqueidentifier") + .HasColumnName("post_id"); + + b.HasKey("Id") + .HasName("pk_post_replies"); + + b.HasIndex("ParentReplyId") + .HasDatabaseName("ix_post_reply_parent_id"); + + b.HasIndex("PostId") + .HasDatabaseName("ix_post_reply_post_id"); + + b.ToTable("post_replies", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.Topic", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("DescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("IconUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("icon_url"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.Property("ParentId") + .HasColumnType("uniqueidentifier") + .HasColumnName("parent_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_topics"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ux_topic_slug_active") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("topics", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.TopicFollow", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("FollowedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("followed_on"); + + b.Property("TopicId") + .HasColumnType("uniqueidentifier") + .HasColumnName("topic_id"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_topic_follows"); + + b.HasIndex("TopicId", "UserId") + .IsUnique() + .HasDatabaseName("ux_topic_follow_topic_user"); + + b.ToTable("topic_follows", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Community.UserFollow", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("FollowedId") + .HasColumnType("uniqueidentifier") + .HasColumnName("followed_id"); + + b.Property("FollowedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("followed_on"); + + b.Property("FollowerId") + .HasColumnType("uniqueidentifier") + .HasColumnName("follower_id"); + + b.HasKey("Id") + .HasName("pk_user_follows"); + + b.HasIndex("FollowerId", "FollowedId") + .IsUnique() + .HasDatabaseName("ux_user_follow_follower_followed"); + + b.ToTable("user_follows", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.AssetFile", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("MimeType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("mime_type"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("original_file_name"); + + b.Property("ScannedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("scanned_on"); + + b.Property("SizeBytes") + .HasColumnType("bigint") + .HasColumnName("size_bytes"); + + b.Property("UploadedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("uploaded_by_id"); + + b.Property("UploadedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("uploaded_on"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("url"); + + b.Property("VirusScanStatus") + .HasColumnType("int") + .HasColumnName("virus_scan_status"); + + b.HasKey("Id") + .HasName("pk_asset_files"); + + b.HasIndex("VirusScanStatus") + .HasDatabaseName("ix_asset_file_scan_status"); + + b.ToTable("asset_files", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.Event", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("DescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("EndsOn") + .HasColumnType("datetimeoffset") + .HasColumnName("ends_on"); + + b.Property("FeaturedImageUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("featured_image_url"); + + b.Property("ICalUid") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("i_cal_uid"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("LocationAr") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("location_ar"); + + b.Property("LocationEn") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("location_en"); + + b.Property("OnlineMeetingUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("online_meeting_url"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.Property("StartsOn") + .HasColumnType("datetimeoffset") + .HasColumnName("starts_on"); + + b.Property("TitleAr") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_ar"); + + b.Property("TitleEn") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_en"); + + b.HasKey("Id") + .HasName("pk_events"); + + b.HasIndex("ICalUid") + .IsUnique() + .HasDatabaseName("ux_event_ical_uid"); + + b.HasIndex("StartsOn") + .HasDatabaseName("ix_event_starts_on"); + + b.ToTable("events", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.HomepageSection", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("ContentAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_ar"); + + b.Property("ContentEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_en"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.Property("SectionType") + .HasColumnType("int") + .HasColumnName("section_type"); + + b.HasKey("Id") + .HasName("pk_homepage_sections"); + + b.HasIndex("IsActive", "OrderIndex") + .HasDatabaseName("ix_homepage_section_active_order"); + + b.ToTable("homepage_sections", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.News", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AuthorId") + .HasColumnType("uniqueidentifier") + .HasColumnName("author_id"); + + b.Property("ContentAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_ar"); + + b.Property("ContentEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_en"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("FeaturedImageUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("featured_image_url"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("IsFeatured") + .HasColumnType("bit") + .HasColumnName("is_featured"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("PublishedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("published_on"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("slug"); + + b.Property("TitleAr") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_ar"); + + b.Property("TitleEn") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_en"); + + b.HasKey("Id") + .HasName("pk_news"); + + b.HasIndex("PublishedOn") + .HasDatabaseName("ix_news_published_on"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ux_news_slug_active") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("news", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.NewsletterSubscription", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("ConfirmationToken") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("confirmation_token"); + + b.Property("ConfirmedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("confirmed_on"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("nvarchar(320)") + .HasColumnName("email"); + + b.Property("IsConfirmed") + .HasColumnType("bit") + .HasColumnName("is_confirmed"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("LocalePreference") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("locale_preference"); + + b.Property("UnsubscribedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("unsubscribed_on"); + + b.HasKey("Id") + .HasName("pk_newsletter_subscriptions"); + + b.HasIndex("ConfirmationToken") + .HasDatabaseName("ix_newsletter_token"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("ux_newsletter_email"); + + b.ToTable("newsletter_subscriptions", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.Page", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("ContentAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_ar"); + + b.Property("ContentEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_en"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("PageType") + .HasColumnType("int") + .HasColumnName("page_type"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("slug"); + + b.Property("TitleAr") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_ar"); + + b.Property("TitleEn") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_en"); + + b.HasKey("Id") + .HasName("pk_pages"); + + b.HasIndex("PageType", "Slug") + .IsUnique() + .HasDatabaseName("ux_page_type_slug_active") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("pages", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.Resource", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AssetFileId") + .HasColumnType("uniqueidentifier") + .HasColumnName("asset_file_id"); + + b.Property("CategoryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("category_id"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("DescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("PublishedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("published_on"); + + b.Property("ResourceType") + .HasColumnType("int") + .HasColumnName("resource_type"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.Property("TitleAr") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_ar"); + + b.Property("TitleEn") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("title_en"); + + b.Property("UploadedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("uploaded_by_id"); + + b.Property("ViewCount") + .HasColumnType("bigint") + .HasColumnName("view_count"); + + b.HasKey("Id") + .HasName("pk_resources"); + + b.HasIndex("AssetFileId") + .HasDatabaseName("ix_resource_asset_file_id"); + + b.HasIndex("CountryId") + .HasDatabaseName("ix_resource_country_id"); + + b.HasIndex("CategoryId", "PublishedOn") + .HasDatabaseName("ix_resource_category_published"); + + b.ToTable("resources", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Content.ResourceCategory", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.Property("ParentId") + .HasColumnType("uniqueidentifier") + .HasColumnName("parent_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_resource_categories"); + + b.HasIndex("ParentId") + .HasDatabaseName("ix_resource_category_parent_id"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ux_resource_category_slug"); + + b.ToTable("resource_categories", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Country.Country", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("FlagUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("flag_url"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("IsoAlpha2") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("iso_alpha2"); + + b.Property("IsoAlpha3") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("nvarchar(3)") + .HasColumnName("iso_alpha3"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("LatestKapsarcSnapshotId") + .HasColumnType("uniqueidentifier") + .HasColumnName("latest_kapsarc_snapshot_id"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.Property("RegionAr") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("region_ar"); + + b.Property("RegionEn") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("region_en"); + + b.HasKey("Id") + .HasName("pk_countries"); + + b.HasIndex("IsoAlpha2") + .HasDatabaseName("ix_country_iso_alpha2"); + + b.HasIndex("IsoAlpha3") + .IsUnique() + .HasDatabaseName("ux_country_iso_alpha3_active") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("countries", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Country.CountryKapsarcSnapshot", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("Classification") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("classification"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("PerformanceScore") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)") + .HasColumnName("performance_score"); + + b.Property("SnapshotTakenOn") + .HasColumnType("datetimeoffset") + .HasColumnName("snapshot_taken_on"); + + b.Property("SourceVersion") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)") + .HasColumnName("source_version"); + + b.Property("TotalIndex") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)") + .HasColumnName("total_index"); + + b.HasKey("Id") + .HasName("pk_country_kapsarc_snapshots"); + + b.HasIndex("CountryId", "SnapshotTakenOn") + .HasDatabaseName("ix_kapsarc_snapshot_country_taken"); + + b.ToTable("country_kapsarc_snapshots", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Country.CountryProfile", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("ContactInfoAr") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("contact_info_ar"); + + b.Property("ContactInfoEn") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("contact_info_en"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("KeyInitiativesAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("key_initiatives_ar"); + + b.Property("KeyInitiativesEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("key_initiatives_en"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.HasKey("Id") + .HasName("pk_country_profiles"); + + b.HasIndex("CountryId") + .IsUnique() + .HasDatabaseName("ux_country_profile_country_id"); + + b.ToTable("country_profiles", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Country.CountryResourceRequest", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AdminNotesAr") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("admin_notes_ar"); + + b.Property("AdminNotesEn") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("admin_notes_en"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("ProcessedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("processed_by_id"); + + b.Property("ProcessedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("processed_on"); + + b.Property("ProposedAssetFileId") + .HasColumnType("uniqueidentifier") + .HasColumnName("proposed_asset_file_id"); + + b.Property("ProposedDescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("proposed_description_ar"); + + b.Property("ProposedDescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("proposed_description_en"); + + b.Property("ProposedResourceType") + .HasColumnType("int") + .HasColumnName("proposed_resource_type"); + + b.Property("ProposedTitleAr") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("proposed_title_ar"); + + b.Property("ProposedTitleEn") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("proposed_title_en"); + + b.Property("RequestedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("requested_by_id"); + + b.Property("Status") + .HasColumnType("int") + .HasColumnName("status"); + + b.Property("SubmittedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("submitted_on"); + + b.HasKey("Id") + .HasName("pk_country_resource_requests"); + + b.HasIndex("CountryId", "Status") + .HasDatabaseName("ix_country_request_country_status"); + + b.ToTable("country_resource_requests", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.ExpertProfile", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AcademicTitleAr") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("academic_title_ar"); + + b.Property("AcademicTitleEn") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("academic_title_en"); + + b.Property("ApprovedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("approved_by_id"); + + b.Property("ApprovedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("approved_on"); + + b.Property("BioAr") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("bio_ar"); + + b.Property("BioEn") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("bio_en"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.PrimitiveCollection("ExpertiseTags") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("expertise_tags"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_expert_profiles"); + + b.HasIndex("UserId") + .IsUnique() + .HasDatabaseName("ux_expert_profile_active_user") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("expert_profiles", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.ExpertRegistrationRequest", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("ProcessedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("processed_by_id"); + + b.Property("ProcessedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("processed_on"); + + b.Property("RejectionReasonAr") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("rejection_reason_ar"); + + b.Property("RejectionReasonEn") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("rejection_reason_en"); + + b.Property("RequestedBioAr") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("requested_bio_ar"); + + b.Property("RequestedBioEn") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("requested_bio_en"); + + b.Property("RequestedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("requested_by_id"); + + b.PrimitiveCollection("RequestedTags") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("requested_tags"); + + b.Property("Status") + .HasColumnType("int") + .HasColumnName("status"); + + b.Property("SubmittedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("submitted_on"); + + b.HasKey("Id") + .HasName("pk_expert_registration_requests"); + + b.HasIndex("RequestedById") + .HasDatabaseName("ix_expert_request_requested_by"); + + b.HasIndex("Status") + .HasDatabaseName("ix_expert_request_status"); + + b.ToTable("expert_registration_requests", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.RefreshToken", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset") + .HasColumnName("created_at_utc"); + + b.Property("CreatedByIp") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("created_by_ip"); + + b.Property("ExpiresAtUtc") + .HasColumnType("datetimeoffset") + .HasColumnName("expires_at_utc"); + + b.Property("ReplacedByTokenHash") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("replaced_by_token_hash"); + + b.Property("RevokedAtUtc") + .HasColumnType("datetimeoffset") + .HasColumnName("revoked_at_utc"); + + b.Property("RevokedByIp") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("revoked_by_ip"); + + b.Property("TokenFamilyId") + .HasColumnType("uniqueidentifier") + .HasColumnName("token_family_id"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("token_hash"); + + b.Property("UserAgent") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_refresh_tokens"); + + b.HasIndex("TokenFamilyId") + .HasDatabaseName("ix_refresh_tokens_token_family_id"); + + b.HasIndex("TokenHash") + .IsUnique() + .HasDatabaseName("ux_refresh_tokens_token_hash"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_refresh_tokens_user_id"); + + b.ToTable("refresh_tokens", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)") + .HasColumnName("concurrency_stamp"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("normalized_name"); + + b.HasKey("Id") + .HasName("pk_asp_net_roles"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[normalized_name] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.StateRepresentativeAssignment", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AssignedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("assigned_by_id"); + + b.Property("AssignedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("assigned_on"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("RevokedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("revoked_by_id"); + + b.Property("RevokedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("revoked_on"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_state_representative_assignments"); + + b.HasIndex("CountryId") + .HasDatabaseName("ix_state_rep_country_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_state_rep_user_id"); + + b.HasIndex("UserId", "CountryId") + .IsUnique() + .HasDatabaseName("ux_state_rep_active_user_country") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("state_representative_assignments", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AccessFailedCount") + .HasColumnType("int") + .HasColumnName("access_failed_count"); + + b.Property("AvatarUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("avatar_url"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)") + .HasColumnName("concurrency_stamp"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("email"); + + b.Property("EmailConfirmed") + .HasColumnType("bit") + .HasColumnName("email_confirmed"); + + b.Property("EntraIdObjectId") + .HasColumnType("uniqueidentifier") + .HasColumnName("entra_id_object_id"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)") + .HasColumnName("first_name"); + + b.PrimitiveCollection("Interests") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("interests"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("JobTitle") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)") + .HasColumnName("job_title"); + + b.Property("KnowledgeLevel") + .HasColumnType("int") + .HasColumnName("knowledge_level"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)") + .HasColumnName("last_name"); + + b.Property("LocalePreference") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("locale_preference"); + + b.Property("LockoutEnabled") + .HasColumnType("bit") + .HasColumnName("lockout_enabled"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset") + .HasColumnName("lockout_end"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("normalized_email"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("normalized_user_name"); + + b.Property("OrganizationName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("organization_name"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)") + .HasColumnName("password_hash"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)") + .HasColumnName("phone_number"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit") + .HasColumnName("phone_number_confirmed"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)") + .HasColumnName("security_stamp"); + + b.Property("Status") + .HasColumnType("int") + .HasColumnName("status"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit") + .HasColumnName("two_factor_enabled"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("user_name"); + + b.HasKey("Id") + .HasName("pk_asp_net_users"); + + b.HasIndex("CountryId") + .HasDatabaseName("ix_users_country_id"); + + b.HasIndex("EntraIdObjectId") + .IsUnique() + .HasDatabaseName("ix_asp_net_users_entra_id_object_id") + .HasFilter("[entra_id_object_id] IS NOT NULL"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[normalized_user_name] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.InteractiveCity.CityScenario", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CityType") + .HasColumnType("int") + .HasColumnName("city_type"); + + b.Property("ConfigurationJson") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("configuration_json"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.Property("TargetYear") + .HasColumnType("int") + .HasColumnName("target_year"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_city_scenarios"); + + b.HasIndex("UserId", "LastModifiedOn") + .HasDatabaseName("ix_city_scenario_user_modified"); + + b.ToTable("city_scenarios", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.InteractiveCity.CityScenarioResult", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("ComputedAt") + .HasColumnType("datetimeoffset") + .HasColumnName("computed_at"); + + b.Property("ComputedCarbonNeutralityYear") + .HasColumnType("int") + .HasColumnName("computed_carbon_neutrality_year"); + + b.Property("ComputedTotalCostUsd") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)") + .HasColumnName("computed_total_cost_usd"); + + b.Property("EngineVersion") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("engine_version"); + + b.Property("ScenarioId") + .HasColumnType("uniqueidentifier") + .HasColumnName("scenario_id"); + + b.HasKey("Id") + .HasName("pk_city_scenario_results"); + + b.HasIndex("ScenarioId", "ComputedAt") + .HasDatabaseName("ix_city_result_scenario_at"); + + b.ToTable("city_scenario_results", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.InteractiveCity.CityTechnology", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CarbonImpactKgPerYear") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)") + .HasColumnName("carbon_impact_kg_per_year"); + + b.Property("CategoryAr") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("category_ar"); + + b.Property("CategoryEn") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("category_en"); + + b.Property("CostUsd") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)") + .HasColumnName("cost_usd"); + + b.Property("DescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("IconUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("icon_url"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.HasKey("Id") + .HasName("pk_city_technologies"); + + b.HasIndex("IsActive") + .HasDatabaseName("ix_city_tech_is_active"); + + b.ToTable("city_technologies", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.KnowledgeMaps.KnowledgeMap", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("DescriptionAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_knowledge_maps"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ux_knowledge_map_slug_active") + .HasFilter("[is_deleted] = 0"); + + b.ToTable("knowledge_maps", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.KnowledgeMaps.KnowledgeMapAssociation", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AssociatedId") + .HasColumnType("uniqueidentifier") + .HasColumnName("associated_id"); + + b.Property("AssociatedType") + .HasColumnType("int") + .HasColumnName("associated_type"); + + b.Property("NodeId") + .HasColumnType("uniqueidentifier") + .HasColumnName("node_id"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.HasKey("Id") + .HasName("pk_knowledge_map_associations"); + + b.HasIndex("NodeId", "AssociatedType", "AssociatedId") + .IsUnique() + .HasDatabaseName("ux_km_assoc_node_type_id"); + + b.ToTable("knowledge_map_associations", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.KnowledgeMaps.KnowledgeMapEdge", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("FromNodeId") + .HasColumnType("uniqueidentifier") + .HasColumnName("from_node_id"); + + b.Property("MapId") + .HasColumnType("uniqueidentifier") + .HasColumnName("map_id"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.Property("RelationshipType") + .HasColumnType("int") + .HasColumnName("relationship_type"); + + b.Property("ToNodeId") + .HasColumnType("uniqueidentifier") + .HasColumnName("to_node_id"); + + b.HasKey("Id") + .HasName("pk_knowledge_map_edges"); + + b.HasIndex("FromNodeId") + .HasDatabaseName("ix_km_edge_from_node"); + + b.HasIndex("ToNodeId") + .HasDatabaseName("ix_km_edge_to_node"); + + b.HasIndex("MapId", "FromNodeId", "ToNodeId", "RelationshipType") + .IsUnique() + .HasDatabaseName("ux_km_edge_map_from_to_relation"); + + b.ToTable("knowledge_map_edges", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.KnowledgeMaps.KnowledgeMapNode", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("DescriptionAr") + .HasColumnType("nvarchar(max)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .HasColumnType("nvarchar(max)") + .HasColumnName("description_en"); + + b.Property("IconUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("icon_url"); + + b.Property("LayoutX") + .HasColumnType("float") + .HasColumnName("layout_x"); + + b.Property("LayoutY") + .HasColumnType("float") + .HasColumnName("layout_y"); + + b.Property("MapId") + .HasColumnType("uniqueidentifier") + .HasColumnName("map_id"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_ar"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("name_en"); + + b.Property("NodeType") + .HasColumnType("int") + .HasColumnName("node_type"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.HasKey("Id") + .HasName("pk_knowledge_map_nodes"); + + b.HasIndex("MapId", "OrderIndex") + .HasDatabaseName("ix_km_node_map_order"); + + b.ToTable("knowledge_map_nodes", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Media.MediaFile", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AltTextAr") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasColumnName("alt_text_ar"); + + b.Property("AltTextEn") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasColumnName("alt_text_en"); + + b.Property("DescriptionAr") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("description_en"); + + b.Property("MimeType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("mime_type"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)") + .HasColumnName("original_file_name"); + + b.Property("SizeBytes") + .HasColumnType("bigint") + .HasColumnName("size_bytes"); + + b.Property("StorageKey") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasColumnName("storage_key"); + + b.Property("TitleAr") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)") + .HasColumnName("title_ar"); + + b.Property("TitleEn") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)") + .HasColumnName("title_en"); + + b.Property("UploadedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("uploaded_by_id"); + + b.Property("UploadedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("uploaded_on"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("url"); + + b.HasKey("Id") + .HasName("pk_media_files"); + + b.ToTable("media_files", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Notifications.NotificationLog", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AttemptCount") + .HasColumnType("int") + .HasColumnName("attempt_count"); + + b.Property("Channel") + .HasColumnType("int") + .HasColumnName("channel"); + + b.Property("CorrelationId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("correlation_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("Error") + .HasColumnType("nvarchar(max)") + .HasColumnName("error"); + + b.Property("FailedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("failed_on"); + + b.Property("PayloadJson") + .HasColumnType("nvarchar(max)") + .HasColumnName("payload_json"); + + b.Property("ProviderMessageId") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("provider_message_id"); + + b.Property("RecipientUserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("recipient_user_id"); + + b.Property("SentOn") + .HasColumnType("datetimeoffset") + .HasColumnName("sent_on"); + + b.Property("Status") + .HasColumnType("int") + .HasColumnName("status"); + + b.Property("TemplateCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("template_code"); + + b.Property("TemplateId") + .HasColumnType("uniqueidentifier") + .HasColumnName("template_id"); + + b.HasKey("Id") + .HasName("pk_notification_logs"); + + b.HasIndex("CorrelationId") + .HasDatabaseName("ix_notification_log_correlation_id"); + + b.HasIndex("TemplateCode", "Channel") + .HasDatabaseName("ix_notification_log_template_channel"); + + b.HasIndex("RecipientUserId", "Status", "CreatedOn") + .HasDatabaseName("ix_notification_log_recipient_status_created"); + + b.ToTable("notification_logs", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Notifications.NotificationTemplate", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("BodyAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("body_ar"); + + b.Property("BodyEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("body_en"); + + b.Property("Channel") + .HasColumnType("int") + .HasColumnName("channel"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("code"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("is_active"); + + b.Property("SubjectAr") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("subject_ar"); + + b.Property("SubjectEn") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("subject_en"); + + b.Property("VariableSchemaJson") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("variable_schema_json"); + + b.HasKey("Id") + .HasName("pk_notification_templates"); + + b.HasIndex("Code", "Channel") + .IsUnique() + .HasDatabaseName("ux_notification_template_code_channel"); + + b.ToTable("notification_templates", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Notifications.UserNotification", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("Channel") + .HasColumnType("int") + .HasColumnName("channel"); + + b.Property("ReadOn") + .HasColumnType("datetimeoffset") + .HasColumnName("read_on"); + + b.Property("RenderedBody") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("rendered_body"); + + b.Property("RenderedLocale") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("rendered_locale"); + + b.Property("RenderedSubjectAr") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("rendered_subject_ar"); + + b.Property("RenderedSubjectEn") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("rendered_subject_en"); + + b.Property("SentOn") + .HasColumnType("datetimeoffset") + .HasColumnName("sent_on"); + + b.Property("Status") + .HasColumnType("int") + .HasColumnName("status"); + + b.Property("TemplateId") + .HasColumnType("uniqueidentifier") + .HasColumnName("template_id"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_user_notifications"); + + b.HasIndex("UserId", "Status") + .HasDatabaseName("ix_user_notification_user_status"); + + b.ToTable("user_notifications", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Notifications.UserNotificationSettings", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("Channel") + .HasColumnType("int") + .HasColumnName("channel"); + + b.Property("EventCode") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("event_code"); + + b.Property("IsEnabled") + .HasColumnType("bit") + .HasColumnName("is_enabled"); + + b.Property("UpdatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("updated_on"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_user_notification_settings"); + + b.HasIndex("UserId", "Channel", "EventCode") + .IsUnique() + .HasDatabaseName("ux_user_notification_settings_user_channel_event") + .HasFilter("[event_code] IS NOT NULL"); + + b.ToTable("user_notification_settings", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.AboutSettings", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("HowToUseVideoUrl") + .HasColumnType("nvarchar(max)") + .HasColumnName("how_to_use_video_url"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.HasKey("Id") + .HasName("pk_about_settings"); + + b.ToTable("about_settings", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.GlossaryEntry", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AboutSettingsId") + .HasColumnType("uniqueidentifier") + .HasColumnName("about_settings_id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.HasKey("Id") + .HasName("pk_glossary_entries"); + + b.HasIndex("AboutSettingsId") + .HasDatabaseName("ix_glossary_entries_about_settings_id"); + + b.ToTable("glossary_entries", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.HomepageCountry", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("HomepageSettingsId") + .HasColumnType("uniqueidentifier") + .HasColumnName("homepage_settings_id"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.HasKey("Id") + .HasName("pk_homepage_countries"); + + b.HasIndex("HomepageSettingsId", "CountryId") + .IsUnique() + .HasDatabaseName("ix_homepage_country_settings_country"); + + b.ToTable("homepage_countries", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.HomepageSettings", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CceConceptsAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("cce_concepts_ar"); + + b.Property("CceConceptsEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("cce_concepts_en"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.Property("VideoUrl") + .HasColumnType("nvarchar(max)") + .HasColumnName("video_url"); + + b.HasKey("Id") + .HasName("pk_homepage_settings"); + + b.ToTable("homepage_settings", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.KnowledgePartner", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AboutSettingsId") + .HasColumnType("uniqueidentifier") + .HasColumnName("about_settings_id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("LogoUrl") + .HasColumnType("nvarchar(max)") + .HasColumnName("logo_url"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.Property("WebsiteUrl") + .HasColumnType("nvarchar(max)") + .HasColumnName("website_url"); + + b.HasKey("Id") + .HasName("pk_knowledge_partners"); + + b.HasIndex("AboutSettingsId") + .HasDatabaseName("ix_knowledge_partners_about_settings_id"); + + b.ToTable("knowledge_partners", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.PoliciesSettings", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.HasKey("Id") + .HasName("pk_policies_settings"); + + b.ToTable("policies_settings", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.PolicySection", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.Property("PoliciesSettingsId") + .HasColumnType("uniqueidentifier") + .HasColumnName("policies_settings_id"); + + b.Property("Type") + .HasColumnType("int") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_policy_sections"); + + b.HasIndex("PoliciesSettingsId") + .HasDatabaseName("ix_policy_sections_policies_settings_id"); + + b.ToTable("policy_sections", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Surveys.SearchQueryLog", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("locale"); + + b.Property("QueryText") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("query_text"); + + b.Property("ResponseTimeMs") + .HasColumnType("int") + .HasColumnName("response_time_ms"); + + b.Property("ResultsCount") + .HasColumnType("int") + .HasColumnName("results_count"); + + b.Property("SubmittedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("submitted_on"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_search_query_logs"); + + b.HasIndex("SubmittedOn") + .HasDatabaseName("ix_search_query_log_submitted_on"); + + b.ToTable("search_query_logs", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Surveys.ServiceRating", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CommentAr") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("comment_ar"); + + b.Property("CommentEn") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("comment_en"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("locale"); + + b.Property("Page") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("page"); + + b.Property("Rating") + .HasColumnType("int") + .HasColumnName("rating"); + + b.Property("SubmittedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("submitted_on"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_service_ratings"); + + b.HasIndex("SubmittedOn") + .HasDatabaseName("ix_service_rating_submitted_on"); + + b.ToTable("service_ratings", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Verification.OtpVerification", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AttemptCount") + .HasColumnType("int") + .HasColumnName("attempt_count"); + + b.Property("CodeHash") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("code_hash"); + + b.Property("Contact") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("contact"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("ExpiresAt") + .HasColumnType("datetimeoffset") + .HasColumnName("expires_at"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("IsInvalidated") + .HasColumnType("bit") + .HasColumnName("is_invalidated"); + + b.Property("IsVerified") + .HasColumnType("bit") + .HasColumnName("is_verified"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("LastSentAt") + .HasColumnType("datetimeoffset") + .HasColumnName("last_sent_at"); + + b.Property("TypeId") + .HasColumnType("int") + .HasColumnName("type_id"); + + b.HasKey("Id") + .HasName("pk_otp_verifications"); + + b.HasIndex("Contact", "TypeId") + .HasDatabaseName("ix_otp_verifications_contact_type_id"); + + b.ToTable("otp_verifications", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Verification.UserVerification", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("Contact") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("contact"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("IsVerified") + .HasColumnType("bit") + .HasColumnName("is_verified"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("TypeId") + .HasColumnType("int") + .HasColumnName("type_id"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.Property("VerifiedAt") + .HasColumnType("datetimeoffset") + .HasColumnName("verified_at"); + + b.HasKey("Id") + .HasName("pk_user_verifications"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_verifications_user_id"); + + b.HasIndex("Contact", "TypeId") + .IsUnique() + .HasDatabaseName("ix_user_verifications_contact_type_id"); + + b.ToTable("user_verifications", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)") + .HasColumnName("claim_type"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)") + .HasColumnName("claim_value"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier") + .HasColumnName("role_id"); + + b.HasKey("Id") + .HasName("pk_asp_net_role_claims"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_asp_net_role_claims_role_id"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)") + .HasColumnName("claim_type"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)") + .HasColumnName("claim_value"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_asp_net_user_claims"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_asp_net_user_claims_user_id"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)") + .HasColumnName("login_provider"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)") + .HasColumnName("provider_key"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)") + .HasColumnName("provider_display_name"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("LoginProvider", "ProviderKey") + .HasName("pk_asp_net_user_logins"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_asp_net_user_logins_user_id"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier") + .HasColumnName("role_id"); + + b.HasKey("UserId", "RoleId") + .HasName("pk_asp_net_user_roles"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_asp_net_user_roles_role_id"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)") + .HasColumnName("login_provider"); + + b.Property("Name") + .HasColumnType("nvarchar(450)") + .HasColumnName("name"); + + b.Property("Value") + .HasColumnType("nvarchar(max)") + .HasColumnName("value"); + + b.HasKey("UserId", "LoginProvider", "Name") + .HasName("pk_asp_net_user_tokens"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Identity.RefreshToken", b => + { + b.HasOne("CCE.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_refresh_tokens_asp_net_users_user_id"); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.AboutSettings", b => + { + b.OwnsOne("CCE.Domain.PlatformSettings.ValueObjects.LocalizedText", "Description", b1 => + { + b1.Property("AboutSettingsId") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b1.Property("Ar") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("description_ar"); + + b1.Property("En") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("description_en"); + + b1.HasKey("AboutSettingsId"); + + b1.ToTable("about_settings"); + + b1.WithOwner() + .HasForeignKey("AboutSettingsId") + .HasConstraintName("fk_about_settings_about_settings_id"); + }); + + b.Navigation("Description") + .IsRequired(); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.GlossaryEntry", b => + { + b.HasOne("CCE.Domain.PlatformSettings.AboutSettings", null) + .WithMany("GlossaryEntries") + .HasForeignKey("AboutSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_glossary_entries_about_settings_about_settings_id"); + + b.OwnsOne("CCE.Domain.PlatformSettings.ValueObjects.LocalizedText", "Definition", b1 => + { + b1.Property("GlossaryEntryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b1.Property("Ar") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("definition_ar"); + + b1.Property("En") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("definition_en"); + + b1.HasKey("GlossaryEntryId"); + + b1.ToTable("glossary_entries"); + + b1.WithOwner() + .HasForeignKey("GlossaryEntryId") + .HasConstraintName("fk_glossary_entries_glossary_entries_id"); + }); + + b.OwnsOne("CCE.Domain.PlatformSettings.ValueObjects.LocalizedText", "Term", b1 => + { + b1.Property("GlossaryEntryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b1.Property("Ar") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("term_ar"); + + b1.Property("En") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("term_en"); + + b1.HasKey("GlossaryEntryId"); + + b1.ToTable("glossary_entries"); + + b1.WithOwner() + .HasForeignKey("GlossaryEntryId") + .HasConstraintName("fk_glossary_entries_glossary_entries_id"); + }); + + b.Navigation("Definition") + .IsRequired(); + + b.Navigation("Term") + .IsRequired(); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.HomepageCountry", b => + { + b.HasOne("CCE.Domain.PlatformSettings.HomepageSettings", null) + .WithMany("Countries") + .HasForeignKey("HomepageSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_homepage_countries_homepage_settings_homepage_settings_id"); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.HomepageSettings", b => + { + b.OwnsOne("CCE.Domain.PlatformSettings.ValueObjects.LocalizedText", "Objective", b1 => + { + b1.Property("HomepageSettingsId") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b1.Property("Ar") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("objective_ar"); + + b1.Property("En") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("objective_en"); + + b1.HasKey("HomepageSettingsId"); + + b1.ToTable("homepage_settings"); + + b1.WithOwner() + .HasForeignKey("HomepageSettingsId") + .HasConstraintName("fk_homepage_settings_homepage_settings_id"); + }); + + b.Navigation("Objective") + .IsRequired(); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.KnowledgePartner", b => + { + b.HasOne("CCE.Domain.PlatformSettings.AboutSettings", null) + .WithMany("KnowledgePartners") + .HasForeignKey("AboutSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_knowledge_partners_about_settings_about_settings_id"); + + b.OwnsOne("CCE.Domain.PlatformSettings.ValueObjects.LocalizedText", "Description", b1 => + { + b1.Property("KnowledgePartnerId") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b1.Property("Ar") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("description_ar"); + + b1.Property("En") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("description_en"); + + b1.HasKey("KnowledgePartnerId"); + + b1.ToTable("knowledge_partners"); + + b1.WithOwner() + .HasForeignKey("KnowledgePartnerId") + .HasConstraintName("fk_knowledge_partners_knowledge_partners_id"); + }); + + b.OwnsOne("CCE.Domain.PlatformSettings.ValueObjects.LocalizedText", "Name", b1 => + { + b1.Property("KnowledgePartnerId") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b1.Property("Ar") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)") + .HasColumnName("name_ar"); + + b1.Property("En") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)") + .HasColumnName("name_en"); + + b1.HasKey("KnowledgePartnerId"); + + b1.ToTable("knowledge_partners"); + + b1.WithOwner() + .HasForeignKey("KnowledgePartnerId") + .HasConstraintName("fk_knowledge_partners_knowledge_partners_id"); + }); + + b.Navigation("Description"); + + b.Navigation("Name") + .IsRequired(); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.PolicySection", b => + { + b.HasOne("CCE.Domain.PlatformSettings.PoliciesSettings", null) + .WithMany("Sections") + .HasForeignKey("PoliciesSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_policy_sections_policies_settings_policies_settings_id"); + + b.OwnsOne("CCE.Domain.PlatformSettings.ValueObjects.LocalizedText", "Content", b1 => + { + b1.Property("PolicySectionId") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b1.Property("Ar") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_ar"); + + b1.Property("En") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_en"); + + b1.HasKey("PolicySectionId"); + + b1.ToTable("policy_sections"); + + b1.WithOwner() + .HasForeignKey("PolicySectionId") + .HasConstraintName("fk_policy_sections_policy_sections_id"); + }); + + b.OwnsOne("CCE.Domain.PlatformSettings.ValueObjects.LocalizedText", "Title", b1 => + { + b1.Property("PolicySectionId") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b1.Property("Ar") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasColumnName("title_ar"); + + b1.Property("En") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasColumnName("title_en"); + + b1.HasKey("PolicySectionId"); + + b1.ToTable("policy_sections"); + + b1.WithOwner() + .HasForeignKey("PolicySectionId") + .HasConstraintName("fk_policy_sections_policy_sections_id"); + }); + + b.Navigation("Content") + .IsRequired(); + + b.Navigation("Title") + .IsRequired(); + }); + + modelBuilder.Entity("CCE.Domain.Verification.UserVerification", b => + { + b.HasOne("CCE.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .HasConstraintName("fk_user_verifications_asp_net_users_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("CCE.Domain.Identity.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_role_claims_asp_net_roles_role_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("CCE.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_user_claims_asp_net_users_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("CCE.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_user_logins_asp_net_users_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("CCE.Domain.Identity.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_user_roles_asp_net_roles_role_id"); + + b.HasOne("CCE.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_user_roles_asp_net_users_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("CCE.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_user_tokens_asp_net_users_user_id"); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.AboutSettings", b => + { + b.Navigation("GlossaryEntries"); + + b.Navigation("KnowledgePartners"); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.HomepageSettings", b => + { + b.Navigation("Countries"); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.PoliciesSettings", b => + { + b.Navigation("Sections"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/CCE.Infrastructure/Persistence/Migrations/20260523180351_AddOtpVerification.cs b/backend/src/CCE.Infrastructure/Persistence/Migrations/20260523180351_AddOtpVerification.cs new file mode 100644 index 00000000..cd2144db --- /dev/null +++ b/backend/src/CCE.Infrastructure/Persistence/Migrations/20260523180351_AddOtpVerification.cs @@ -0,0 +1,96 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CCE.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddOtpVerification : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "otp_verifications", + columns: table => new + { + id = table.Column(type: "uniqueidentifier", nullable: false), + contact = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: false), + type_id = table.Column(type: "int", nullable: false), + code_hash = table.Column(type: "nvarchar(512)", maxLength: 512, nullable: false), + expires_at = table.Column(type: "datetimeoffset", nullable: false), + created_at = table.Column(type: "datetimeoffset", nullable: false), + last_sent_at = table.Column(type: "datetimeoffset", nullable: true), + attempt_count = table.Column(type: "int", nullable: false), + is_verified = table.Column(type: "bit", nullable: false), + is_invalidated = table.Column(type: "bit", 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_otp_verifications", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "user_verifications", + columns: table => new + { + id = table.Column(type: "uniqueidentifier", nullable: false), + user_id = table.Column(type: "uniqueidentifier", nullable: true), + contact = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: false), + type_id = table.Column(type: "int", nullable: false), + is_verified = table.Column(type: "bit", nullable: false), + verified_at = table.Column(type: "datetimeoffset", nullable: true), + 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_user_verifications", x => x.id); + table.ForeignKey( + name: "fk_user_verifications_asp_net_users_user_id", + column: x => x.user_id, + principalTable: "AspNetUsers", + principalColumn: "id"); + }); + + migrationBuilder.CreateIndex( + name: "ix_otp_verifications_contact_type_id", + table: "otp_verifications", + columns: new[] { "contact", "type_id" }); + + migrationBuilder.CreateIndex( + name: "ix_user_verifications_contact_type_id", + table: "user_verifications", + columns: new[] { "contact", "type_id" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_user_verifications_user_id", + table: "user_verifications", + column: "user_id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "otp_verifications"); + + migrationBuilder.DropTable( + name: "user_verifications"); + } + } +} diff --git a/backend/src/CCE.Infrastructure/Persistence/Migrations/CceDbContextModelSnapshot.cs b/backend/src/CCE.Infrastructure/Persistence/Migrations/CceDbContextModelSnapshot.cs index ff901e75..eebf84ba 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); @@ -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"); @@ -1253,7 +1449,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"); @@ -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"); @@ -1329,7 +1549,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 +1574,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") @@ -1405,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"); @@ -1417,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"); @@ -1471,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)") @@ -1484,15 +1796,37 @@ 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("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) @@ -1517,6 +1851,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"); @@ -1533,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"); @@ -1579,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"); @@ -1595,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"); @@ -1740,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"); @@ -1766,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) @@ -1936,23 +2304,178 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("knowledge_map_nodes", (string)null); }); - modelBuilder.Entity("CCE.Domain.Notifications.NotificationTemplate", b => + modelBuilder.Entity("CCE.Domain.Media.MediaFile", b => { b.Property("Id") .HasColumnType("uniqueidentifier") .HasColumnName("id"); - b.Property("BodyAr") - .IsRequired() - .HasColumnType("nvarchar(max)") - .HasColumnName("body_ar"); + b.Property("AltTextAr") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasColumnName("alt_text_ar"); - b.Property("BodyEn") - .IsRequired() - .HasColumnType("nvarchar(max)") - .HasColumnName("body_en"); + b.Property("AltTextEn") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasColumnName("alt_text_en"); - b.Property("Channel") + b.Property("DescriptionAr") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("description_ar"); + + b.Property("DescriptionEn") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("description_en"); + + b.Property("MimeType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("mime_type"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)") + .HasColumnName("original_file_name"); + + b.Property("SizeBytes") + .HasColumnType("bigint") + .HasColumnName("size_bytes"); + + b.Property("StorageKey") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasColumnName("storage_key"); + + b.Property("TitleAr") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)") + .HasColumnName("title_ar"); + + b.Property("TitleEn") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)") + .HasColumnName("title_en"); + + b.Property("UploadedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("uploaded_by_id"); + + b.Property("UploadedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("uploaded_on"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("url"); + + b.HasKey("Id") + .HasName("pk_media_files"); + + b.ToTable("media_files", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Notifications.NotificationLog", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AttemptCount") + .HasColumnType("int") + .HasColumnName("attempt_count"); + + b.Property("Channel") + .HasColumnType("int") + .HasColumnName("channel"); + + b.Property("CorrelationId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("correlation_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("Error") + .HasColumnType("nvarchar(max)") + .HasColumnName("error"); + + b.Property("FailedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("failed_on"); + + b.Property("PayloadJson") + .HasColumnType("nvarchar(max)") + .HasColumnName("payload_json"); + + b.Property("ProviderMessageId") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("provider_message_id"); + + b.Property("RecipientUserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("recipient_user_id"); + + b.Property("SentOn") + .HasColumnType("datetimeoffset") + .HasColumnName("sent_on"); + + b.Property("Status") + .HasColumnType("int") + .HasColumnName("status"); + + b.Property("TemplateCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("template_code"); + + b.Property("TemplateId") + .HasColumnType("uniqueidentifier") + .HasColumnName("template_id"); + + b.HasKey("Id") + .HasName("pk_notification_logs"); + + b.HasIndex("CorrelationId") + .HasDatabaseName("ix_notification_log_correlation_id"); + + b.HasIndex("TemplateCode", "Channel") + .HasDatabaseName("ix_notification_log_template_channel"); + + b.HasIndex("RecipientUserId", "Status", "CreatedOn") + .HasDatabaseName("ix_notification_log_recipient_status_created"); + + b.ToTable("notification_logs", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Notifications.NotificationTemplate", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("BodyAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("body_ar"); + + b.Property("BodyEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("body_en"); + + b.Property("Channel") .HasColumnType("int") .HasColumnName("channel"); @@ -1986,9 +2509,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id") .HasName("pk_notification_templates"); - b.HasIndex("Code") + b.HasIndex("Code", "Channel") .IsUnique() - .HasDatabaseName("ux_notification_template_code"); + .HasDatabaseName("ux_notification_template_code_channel"); b.ToTable("notification_templates", (string)null); }); @@ -2018,41 +2541,411 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("nvarchar(2)") .HasColumnName("rendered_locale"); - b.Property("RenderedSubjectAr") - .IsRequired() - .HasMaxLength(512) - .HasColumnType("nvarchar(512)") - .HasColumnName("rendered_subject_ar"); + b.Property("RenderedSubjectAr") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("rendered_subject_ar"); + + b.Property("RenderedSubjectEn") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("rendered_subject_en"); + + b.Property("SentOn") + .HasColumnType("datetimeoffset") + .HasColumnName("sent_on"); + + b.Property("Status") + .HasColumnType("int") + .HasColumnName("status"); + + b.Property("TemplateId") + .HasColumnType("uniqueidentifier") + .HasColumnName("template_id"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_user_notifications"); + + b.HasIndex("UserId", "Status") + .HasDatabaseName("ix_user_notification_user_status"); + + b.ToTable("user_notifications", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Notifications.UserNotificationSettings", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("Channel") + .HasColumnType("int") + .HasColumnName("channel"); + + b.Property("EventCode") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("event_code"); + + b.Property("IsEnabled") + .HasColumnType("bit") + .HasColumnName("is_enabled"); + + b.Property("UpdatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("updated_on"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_user_notification_settings"); + + b.HasIndex("UserId", "Channel", "EventCode") + .IsUnique() + .HasDatabaseName("ux_user_notification_settings_user_channel_event") + .HasFilter("[event_code] IS NOT NULL"); + + b.ToTable("user_notification_settings", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.AboutSettings", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("HowToUseVideoUrl") + .HasColumnType("nvarchar(max)") + .HasColumnName("how_to_use_video_url"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.HasKey("Id") + .HasName("pk_about_settings"); + + b.ToTable("about_settings", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.GlossaryEntry", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AboutSettingsId") + .HasColumnType("uniqueidentifier") + .HasColumnName("about_settings_id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.HasKey("Id") + .HasName("pk_glossary_entries"); + + b.HasIndex("AboutSettingsId") + .HasDatabaseName("ix_glossary_entries_about_settings_id"); + + b.ToTable("glossary_entries", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.HomepageCountry", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CountryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("country_id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("HomepageSettingsId") + .HasColumnType("uniqueidentifier") + .HasColumnName("homepage_settings_id"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.HasKey("Id") + .HasName("pk_homepage_countries"); + + b.HasIndex("HomepageSettingsId", "CountryId") + .IsUnique() + .HasDatabaseName("ix_homepage_country_settings_country"); + + b.ToTable("homepage_countries", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.HomepageSettings", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CceConceptsAr") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("cce_concepts_ar"); + + b.Property("CceConceptsEn") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("cce_concepts_en"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.Property("VideoUrl") + .HasColumnType("nvarchar(max)") + .HasColumnName("video_url"); + + b.HasKey("Id") + .HasName("pk_homepage_settings"); + + b.ToTable("homepage_settings", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.KnowledgePartner", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AboutSettingsId") + .HasColumnType("uniqueidentifier") + .HasColumnName("about_settings_id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("LogoUrl") + .HasColumnType("nvarchar(max)") + .HasColumnName("logo_url"); + + b.Property("OrderIndex") + .HasColumnType("int") + .HasColumnName("order_index"); + + b.Property("WebsiteUrl") + .HasColumnType("nvarchar(max)") + .HasColumnName("website_url"); + + b.HasKey("Id") + .HasName("pk_knowledge_partners"); + + b.HasIndex("AboutSettingsId") + .HasDatabaseName("ix_knowledge_partners_about_settings_id"); + + b.ToTable("knowledge_partners", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.PoliciesSettings", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasColumnName("row_version"); + + b.HasKey("Id") + .HasName("pk_policies_settings"); + + b.ToTable("policies_settings", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.PolicySection", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); - b.Property("RenderedSubjectEn") - .IsRequired() - .HasMaxLength(512) - .HasColumnType("nvarchar(512)") - .HasColumnName("rendered_subject_en"); + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); - b.Property("SentOn") + b.Property("LastModifiedOn") .HasColumnType("datetimeoffset") - .HasColumnName("sent_on"); + .HasColumnName("last_modified_on"); - b.Property("Status") + b.Property("OrderIndex") .HasColumnType("int") - .HasColumnName("status"); + .HasColumnName("order_index"); - b.Property("TemplateId") + b.Property("PoliciesSettingsId") .HasColumnType("uniqueidentifier") - .HasColumnName("template_id"); + .HasColumnName("policies_settings_id"); - b.Property("UserId") - .HasColumnType("uniqueidentifier") - .HasColumnName("user_id"); + b.Property("Type") + .HasColumnType("int") + .HasColumnName("type"); b.HasKey("Id") - .HasName("pk_user_notifications"); + .HasName("pk_policy_sections"); - b.HasIndex("UserId", "Status") - .HasDatabaseName("ix_user_notification_user_status"); + b.HasIndex("PoliciesSettingsId") + .HasDatabaseName("ix_policy_sections_policies_settings_id"); - b.ToTable("user_notifications", (string)null); + b.ToTable("policy_sections", (string)null); }); modelBuilder.Entity("CCE.Domain.Surveys.SearchQueryLog", b => @@ -2147,6 +3040,158 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("service_ratings", (string)null); }); + modelBuilder.Entity("CCE.Domain.Verification.OtpVerification", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AttemptCount") + .HasColumnType("int") + .HasColumnName("attempt_count"); + + b.Property("CodeHash") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("code_hash"); + + b.Property("Contact") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("contact"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("ExpiresAt") + .HasColumnType("datetimeoffset") + .HasColumnName("expires_at"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("IsInvalidated") + .HasColumnType("bit") + .HasColumnName("is_invalidated"); + + b.Property("IsVerified") + .HasColumnType("bit") + .HasColumnName("is_verified"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("LastSentAt") + .HasColumnType("datetimeoffset") + .HasColumnName("last_sent_at"); + + b.Property("TypeId") + .HasColumnType("int") + .HasColumnName("type_id"); + + b.HasKey("Id") + .HasName("pk_otp_verifications"); + + b.HasIndex("Contact", "TypeId") + .HasDatabaseName("ix_otp_verifications_contact_type_id"); + + b.ToTable("otp_verifications", (string)null); + }); + + modelBuilder.Entity("CCE.Domain.Verification.UserVerification", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("Contact") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("contact"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by_id"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("created_on"); + + b.Property("DeletedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by_id"); + + b.Property("DeletedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_on"); + + b.Property("IsDeleted") + .HasColumnType("bit") + .HasColumnName("is_deleted"); + + b.Property("IsVerified") + .HasColumnType("bit") + .HasColumnName("is_verified"); + + b.Property("LastModifiedById") + .HasColumnType("uniqueidentifier") + .HasColumnName("last_modified_by_id"); + + b.Property("LastModifiedOn") + .HasColumnType("datetimeoffset") + .HasColumnName("last_modified_on"); + + b.Property("TypeId") + .HasColumnType("int") + .HasColumnName("type_id"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("user_id"); + + b.Property("VerifiedAt") + .HasColumnType("datetimeoffset") + .HasColumnName("verified_at"); + + b.HasKey("Id") + .HasName("pk_user_verifications"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_verifications_user_id"); + + b.HasIndex("Contact", "TypeId") + .IsUnique() + .HasDatabaseName("ix_user_verifications_contact_type_id"); + + b.ToTable("user_verifications", (string)null); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { b.Property("Id") @@ -2277,6 +3322,307 @@ 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("CCE.Domain.PlatformSettings.AboutSettings", b => + { + b.OwnsOne("CCE.Domain.PlatformSettings.ValueObjects.LocalizedText", "Description", b1 => + { + b1.Property("AboutSettingsId") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b1.Property("Ar") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("description_ar"); + + b1.Property("En") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("description_en"); + + b1.HasKey("AboutSettingsId"); + + b1.ToTable("about_settings"); + + b1.WithOwner() + .HasForeignKey("AboutSettingsId") + .HasConstraintName("fk_about_settings_about_settings_id"); + }); + + b.Navigation("Description") + .IsRequired(); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.GlossaryEntry", b => + { + b.HasOne("CCE.Domain.PlatformSettings.AboutSettings", null) + .WithMany("GlossaryEntries") + .HasForeignKey("AboutSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_glossary_entries_about_settings_about_settings_id"); + + b.OwnsOne("CCE.Domain.PlatformSettings.ValueObjects.LocalizedText", "Definition", b1 => + { + b1.Property("GlossaryEntryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b1.Property("Ar") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("definition_ar"); + + b1.Property("En") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("definition_en"); + + b1.HasKey("GlossaryEntryId"); + + b1.ToTable("glossary_entries"); + + b1.WithOwner() + .HasForeignKey("GlossaryEntryId") + .HasConstraintName("fk_glossary_entries_glossary_entries_id"); + }); + + b.OwnsOne("CCE.Domain.PlatformSettings.ValueObjects.LocalizedText", "Term", b1 => + { + b1.Property("GlossaryEntryId") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b1.Property("Ar") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("term_ar"); + + b1.Property("En") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("term_en"); + + b1.HasKey("GlossaryEntryId"); + + b1.ToTable("glossary_entries"); + + b1.WithOwner() + .HasForeignKey("GlossaryEntryId") + .HasConstraintName("fk_glossary_entries_glossary_entries_id"); + }); + + b.Navigation("Definition") + .IsRequired(); + + b.Navigation("Term") + .IsRequired(); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.HomepageCountry", b => + { + b.HasOne("CCE.Domain.PlatformSettings.HomepageSettings", null) + .WithMany("Countries") + .HasForeignKey("HomepageSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_homepage_countries_homepage_settings_homepage_settings_id"); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.HomepageSettings", b => + { + b.OwnsOne("CCE.Domain.PlatformSettings.ValueObjects.LocalizedText", "Objective", b1 => + { + b1.Property("HomepageSettingsId") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b1.Property("Ar") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("objective_ar"); + + b1.Property("En") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("objective_en"); + + b1.HasKey("HomepageSettingsId"); + + b1.ToTable("homepage_settings"); + + b1.WithOwner() + .HasForeignKey("HomepageSettingsId") + .HasConstraintName("fk_homepage_settings_homepage_settings_id"); + }); + + b.Navigation("Objective") + .IsRequired(); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.KnowledgePartner", b => + { + b.HasOne("CCE.Domain.PlatformSettings.AboutSettings", null) + .WithMany("KnowledgePartners") + .HasForeignKey("AboutSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_knowledge_partners_about_settings_about_settings_id"); + + b.OwnsOne("CCE.Domain.PlatformSettings.ValueObjects.LocalizedText", "Description", b1 => + { + b1.Property("KnowledgePartnerId") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b1.Property("Ar") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("description_ar"); + + b1.Property("En") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("description_en"); + + b1.HasKey("KnowledgePartnerId"); + + b1.ToTable("knowledge_partners"); + + b1.WithOwner() + .HasForeignKey("KnowledgePartnerId") + .HasConstraintName("fk_knowledge_partners_knowledge_partners_id"); + }); + + b.OwnsOne("CCE.Domain.PlatformSettings.ValueObjects.LocalizedText", "Name", b1 => + { + b1.Property("KnowledgePartnerId") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b1.Property("Ar") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)") + .HasColumnName("name_ar"); + + b1.Property("En") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)") + .HasColumnName("name_en"); + + b1.HasKey("KnowledgePartnerId"); + + b1.ToTable("knowledge_partners"); + + b1.WithOwner() + .HasForeignKey("KnowledgePartnerId") + .HasConstraintName("fk_knowledge_partners_knowledge_partners_id"); + }); + + b.Navigation("Description"); + + b.Navigation("Name") + .IsRequired(); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.PolicySection", b => + { + b.HasOne("CCE.Domain.PlatformSettings.PoliciesSettings", null) + .WithMany("Sections") + .HasForeignKey("PoliciesSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_policy_sections_policies_settings_policies_settings_id"); + + b.OwnsOne("CCE.Domain.PlatformSettings.ValueObjects.LocalizedText", "Content", b1 => + { + b1.Property("PolicySectionId") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b1.Property("Ar") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_ar"); + + b1.Property("En") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("content_en"); + + b1.HasKey("PolicySectionId"); + + b1.ToTable("policy_sections"); + + b1.WithOwner() + .HasForeignKey("PolicySectionId") + .HasConstraintName("fk_policy_sections_policy_sections_id"); + }); + + b.OwnsOne("CCE.Domain.PlatformSettings.ValueObjects.LocalizedText", "Title", b1 => + { + b1.Property("PolicySectionId") + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b1.Property("Ar") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasColumnName("title_ar"); + + b1.Property("En") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasColumnName("title_en"); + + b1.HasKey("PolicySectionId"); + + b1.ToTable("policy_sections"); + + b1.WithOwner() + .HasForeignKey("PolicySectionId") + .HasConstraintName("fk_policy_sections_policy_sections_id"); + }); + + b.Navigation("Content") + .IsRequired(); + + b.Navigation("Title") + .IsRequired(); + }); + + modelBuilder.Entity("CCE.Domain.Verification.UserVerification", b => + { + b.HasOne("CCE.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .HasConstraintName("fk_user_verifications_asp_net_users_user_id"); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { b.HasOne("CCE.Domain.Identity.Role", null) @@ -2333,6 +3679,23 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasConstraintName("fk_asp_net_user_tokens_asp_net_users_user_id"); }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.AboutSettings", b => + { + b.Navigation("GlossaryEntries"); + + b.Navigation("KnowledgePartners"); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.HomepageSettings", b => + { + b.Navigation("Countries"); + }); + + modelBuilder.Entity("CCE.Domain.PlatformSettings.PoliciesSettings", b => + { + b.Navigation("Sections"); + }); #pragma warning restore 612, 618 } } diff --git a/backend/src/CCE.Infrastructure/Persistence/Repositories/OtpVerificationRepository.cs b/backend/src/CCE.Infrastructure/Persistence/Repositories/OtpVerificationRepository.cs new file mode 100644 index 00000000..22b3bc83 --- /dev/null +++ b/backend/src/CCE.Infrastructure/Persistence/Repositories/OtpVerificationRepository.cs @@ -0,0 +1,23 @@ +using CCE.Application.Verification; +using CCE.Domain.Verification; +using Microsoft.EntityFrameworkCore; + +namespace CCE.Infrastructure.Persistence.Repositories; + +public sealed class OtpVerificationRepository + : Repository, IOtpVerificationRepository +{ + public OtpVerificationRepository(CceDbContext db) : base(db) { } + + public async Task FindActiveAsync( + string contact, OtpVerificationType typeId, DateTimeOffset now, CancellationToken ct) + => await Db.OtpVerifications + .Where(o => o.Contact == contact + && o.TypeId == typeId + && !o.IsVerified + && !o.IsInvalidated + && o.ExpiresAt > now) + .OrderByDescending(o => o.CreatedAt) + .FirstOrDefaultAsync(ct) + .ConfigureAwait(false); +} diff --git a/backend/src/CCE.Infrastructure/Persistence/Repositories/UserRepository.cs b/backend/src/CCE.Infrastructure/Persistence/Repositories/UserRepository.cs new file mode 100644 index 00000000..3fdd7310 --- /dev/null +++ b/backend/src/CCE.Infrastructure/Persistence/Repositories/UserRepository.cs @@ -0,0 +1,47 @@ +using CCE.Application.Identity; +using CCE.Domain.Identity; +using CCE.Domain.Verification; +using Microsoft.EntityFrameworkCore; + +namespace CCE.Infrastructure.Persistence.Repositories; + +public sealed class UserRepository : IUserRepository +{ + private readonly CceDbContext _db; + + public UserRepository(CceDbContext db) => _db = db; + + public async Task FindUserIdByContactAsync(string contact, OtpVerificationType type, CancellationToken ct) + { + return type switch + { + OtpVerificationType.Email => await _db.Users + .Where(u => u.Email == contact) + .Select(u => (Guid?)u.Id) + .FirstOrDefaultAsync(ct) + .ConfigureAwait(false), + OtpVerificationType.Sms => await _db.Users + .Where(u => u.PhoneNumber == contact) + .Select(u => (Guid?)u.Id) + .FirstOrDefaultAsync(ct) + .ConfigureAwait(false), + _ => null, + }; + } + + public async Task StampConfirmedAsync(Guid userId, OtpVerificationType type, CancellationToken ct) + { + var stamp = await _db.Users + .Where(u => u.Id == userId) + .Select(u => u.ConcurrencyStamp) + .FirstOrDefaultAsync(ct) + .ConfigureAwait(false); + + var stub = new User { Id = userId, ConcurrencyStamp = stamp ?? string.Empty }; + _db.Attach(stub); + if (type == OtpVerificationType.Email) + stub.EmailConfirmed = true; + else + stub.PhoneNumberConfirmed = true; + } +} diff --git a/backend/src/CCE.Infrastructure/Persistence/Repositories/UserVerificationRepository.cs b/backend/src/CCE.Infrastructure/Persistence/Repositories/UserVerificationRepository.cs new file mode 100644 index 00000000..67e59057 --- /dev/null +++ b/backend/src/CCE.Infrastructure/Persistence/Repositories/UserVerificationRepository.cs @@ -0,0 +1,18 @@ +using CCE.Application.Verification; +using CCE.Domain.Verification; +using Microsoft.EntityFrameworkCore; + +namespace CCE.Infrastructure.Persistence.Repositories; + +public sealed class UserVerificationRepository + : Repository, IUserVerificationRepository +{ + public UserVerificationRepository(CceDbContext db) : base(db) { } + + public async Task FindAsync( + string contact, OtpVerificationType typeId, CancellationToken ct) + => await Db.UserVerifications + .Where(v => v.Contact == contact && v.TypeId == typeId) + .FirstOrDefaultAsync(ct) + .ConfigureAwait(false); +} 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/src/CCE.Infrastructure/PlatformSettings/AboutSettingsRepository.cs b/backend/src/CCE.Infrastructure/PlatformSettings/AboutSettingsRepository.cs new file mode 100644 index 00000000..b1b8585c --- /dev/null +++ b/backend/src/CCE.Infrastructure/PlatformSettings/AboutSettingsRepository.cs @@ -0,0 +1,20 @@ +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 + .Include(s => s.GlossaryEntries) + .Include(s => s.KnowledgePartners) + .FirstOrDefaultAsync(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..eb9a177f --- /dev/null +++ b/backend/src/CCE.Infrastructure/PlatformSettings/HomepageSettingsRepository.cs @@ -0,0 +1,19 @@ +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 + .Include(s => s.Countries) + .FirstOrDefaultAsync(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..f0def816 --- /dev/null +++ b/backend/src/CCE.Infrastructure/PlatformSettings/PoliciesSettingsRepository.cs @@ -0,0 +1,19 @@ +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 + .Include(s => s.Sections) + .FirstOrDefaultAsync(ct) + .ConfigureAwait(false); +} 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/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/src/CCE.Infrastructure/Security/OtpCodeGenerator.cs b/backend/src/CCE.Infrastructure/Security/OtpCodeGenerator.cs new file mode 100644 index 00000000..f579bf86 --- /dev/null +++ b/backend/src/CCE.Infrastructure/Security/OtpCodeGenerator.cs @@ -0,0 +1,32 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using CCE.Application.Verification; +using Microsoft.Extensions.Configuration; + +namespace CCE.Infrastructure.Security; + +public sealed class OtpCodeGenerator : IOtpCodeGenerator +{ + private readonly byte[] _secret; + + public OtpCodeGenerator(IConfiguration config) + => _secret = Convert.FromBase64String(config["Otp:HmacSecret"]!); + + public (string PlainCode, string Hash) Generate() + { + var code = RandomNumberGenerator.GetInt32(0, 1_000_000).ToString("D6", CultureInfo.InvariantCulture); + return (code, ComputeHash(code)); + } + + public bool Verify(string plainCode, string storedHash) + => CryptographicOperations.FixedTimeEquals( + Encoding.UTF8.GetBytes(ComputeHash(plainCode)), + Encoding.UTF8.GetBytes(storedHash)); + + private string ComputeHash(string code) + { + using var hmac = new HMACSHA256(_secret); + return Convert.ToBase64String(hmac.ComputeHash(Encoding.UTF8.GetBytes(code))); + } +} 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); +} 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..cd6e731e --- /dev/null +++ b/backend/src/CCE.Integration/Communication/GatewayResponse.cs @@ -0,0 +1,6 @@ +namespace CCE.Integration.Communication; + +public sealed record GatewayResponse( + 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 new file mode 100644 index 00000000..62e4e585 --- /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("/integrationgateway/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..e3cfb230 --- /dev/null +++ b/backend/src/CCE.Integration/Communication/SendEmailRequest.cs @@ -0,0 +1,8 @@ +namespace CCE.Integration.Communication; + +public sealed record SendEmailRequest( + string To, + string From, + string Subject, + string Html, + string? TemplateId = null); 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..9b8f10a5 100644 --- a/backend/src/CCE.Seeder/Program.cs +++ b/backend/src/CCE.Seeder/Program.cs @@ -66,10 +66,17 @@ 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(); 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/PlatformSettingsSeeder.cs b/backend/src/CCE.Seeder/Seeders/PlatformSettingsSeeder.cs new file mode 100644 index 00000000..ec1267da --- /dev/null +++ b/backend/src/CCE.Seeder/Seeders/PlatformSettingsSeeder.cs @@ -0,0 +1,269 @@ +using CCE.Domain.Common; +using CCE.Domain.PlatformSettings; +using CCE.Domain.PlatformSettings.ValueObjects; +using CCE.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace CCE.Seeder.Seeders; + +/// +/// Idempotent seeder that enriches the singleton PlatformSettings aggregates with +/// default child entities (glossary entries, knowledge partners, policy sections, +/// homepage country links) and richer content. Safe to run repeatedly. +/// +public sealed class PlatformSettingsSeeder : ISeeder +{ + private readonly CceDbContext _ctx; + private readonly ISystemClock _clock; + private readonly ILogger _logger; + + private static readonly Guid SystemUserId = DeterministicGuid.From("platform_settings:seeder"); + + public PlatformSettingsSeeder(CceDbContext ctx, ISystemClock clock, ILogger logger) + { + _ctx = ctx; + _clock = clock; + _logger = logger; + } + + public int Order => 40; + + public async Task SeedAsync(CancellationToken cancellationToken = default) + { + await SeedHomepageSettingsAsync(cancellationToken).ConfigureAwait(false); + await SeedAboutSettingsAsync(cancellationToken).ConfigureAwait(false); + await SeedPoliciesSettingsAsync(cancellationToken).ConfigureAwait(false); + await _ctx.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + private async Task SeedHomepageSettingsAsync(CancellationToken ct) + { + var hcId = DeterministicGuid.From("platform_settings:homepage"); + var homepage = await _ctx.HomepageSettings + .Include(h => h.Countries) + .FirstOrDefaultAsync(h => h.Id == hcId, ct) + .ConfigureAwait(false); + + if (homepage is null) + { + _logger.LogWarning("HomepageSettings singleton not found — skipping."); + return; + } + + // Enrich content only if still barebones + if (string.IsNullOrEmpty(homepage.CceConceptsAr)) + { + homepage.UpdateContent( + videoUrl: "https://cdn.example.com/cce-hero.mp4", + objective: LocalizedText.Create( + "تعزيز الاقتصاد الكربوني الدائري عبر المعرفة والابتكار", + "Advancing the Circular Carbon Economy through knowledge and innovation"), + cceConceptsAr: + "

الاقتصاد الكربوني الدائري هو نهج شامل لإدارة الانبعاثات عبر تقليلها وإعادة استخدامها وتدويرها وإزالتها.

", + cceConceptsEn: + "

The Circular Carbon Economy is a comprehensive approach to managing emissions through reduction, reuse, recycling, and removal.

", + by: SystemUserId, + clock: _clock); + _logger.LogInformation("Enriched HomepageSettings content."); + } + + // Seed homepage country links (first 5 GCC countries) + var countryIds = new[] + { + DeterministicGuid.From("country:SAU"), + DeterministicGuid.From("country:ARE"), + DeterministicGuid.From("country:KWT"), + DeterministicGuid.From("country:QAT"), + DeterministicGuid.From("country:BHR"), + }; + + var existingCountryIds = homepage.Countries.Select(c => c.CountryId).ToHashSet(); + var missing = countryIds.Where(id => !existingCountryIds.Contains(id)).ToList(); + + if (missing.Count > 0) + { + homepage.SyncCountries(countryIds, SystemUserId, _clock); + _logger.LogInformation("Linked {Count} countries to HomepageSettings.", countryIds.Length); + } + } + + private async Task SeedAboutSettingsAsync(CancellationToken ct) + { + var acId = DeterministicGuid.From("platform_settings:about"); + var about = await _ctx.AboutSettings + .Include(a => a.GlossaryEntries) + .Include(a => a.KnowledgePartners) + .FirstOrDefaultAsync(a => a.Id == acId, ct) + .ConfigureAwait(false); + + if (about is null) + { + _logger.LogWarning("AboutSettings singleton not found — skipping."); + return; + } + + // Enrich description only if still the barebones text seeded by ReferenceDataSeeder + if (about.Description.Ar == "وصف المنصة" && about.Description.En == "Platform description") + { + about.UpdateContent( + description: LocalizedText.Create( + "منصة المعرفة المركزية للاقتصاد الكربوني الدائري تجمع بين الباحثين وصناع السياسات والصناعة لتبادل المعرفة وتسريع الانتقال نحو مستقبل منخفض الكربون.", + "The Central Knowledge Platform for the Circular Carbon Economy brings together researchers, policymakers, and industry to exchange knowledge and accelerate the transition to a low-carbon future."), + howToUseVideoUrl: "https://cdn.example.com/how-to-use.mp4", + by: SystemUserId, + clock: _clock); + _logger.LogInformation("Enriched AboutSettings content."); + } + + // Seed glossary entries + foreach (var g in GlossaryData) + { + if (await _ctx.GlossaryEntries.IgnoreQueryFilters() + .AnyAsync(e => e.Id == g.Id, ct).ConfigureAwait(false)) + { + continue; + } + + var entry = about.AddGlossaryEntry(g.Term, g.Definition, SystemUserId, _clock); + typeof(GlossaryEntry).GetProperty(nameof(entry.Id))!.SetValue(entry, g.Id); + _logger.LogInformation("Added glossary entry: {TermEn}", g.Term.En); + } + + // Seed knowledge partners + foreach (var p in PartnerData) + { + if (await _ctx.KnowledgePartners.IgnoreQueryFilters() + .AnyAsync(e => e.Id == p.Id, ct).ConfigureAwait(false)) + { + continue; + } + + var partner = about.AddKnowledgePartner( + p.Name, p.Description, p.LogoUrl, p.WebsiteUrl, SystemUserId, _clock); + typeof(KnowledgePartner).GetProperty(nameof(partner.Id))!.SetValue(partner, p.Id); + _logger.LogInformation("Added knowledge partner: {NameEn}", p.Name.En); + } + } + + private async Task SeedPoliciesSettingsAsync(CancellationToken ct) + { + var pcId = DeterministicGuid.From("platform_settings:policies"); + var policies = await _ctx.PoliciesSettings + .Include(p => p.Sections) + .FirstOrDefaultAsync(p => p.Id == pcId, ct) + .ConfigureAwait(false); + + if (policies is null) + { + _logger.LogWarning("PoliciesSettings singleton not found — skipping."); + return; + } + + foreach (var s in SectionData) + { + if (await _ctx.PolicySections.IgnoreQueryFilters() + .AnyAsync(e => e.Id == s.Id, ct).ConfigureAwait(false)) + { + continue; + } + + var section = policies.AddSection(s.Type, s.Title, s.Content, SystemUserId, _clock); + typeof(PolicySection).GetProperty(nameof(section.Id))!.SetValue(section, s.Id); + _logger.LogInformation("Added policy section: {TitleEn}", s.Title.En); + } + } + + // ─── Data tables ─── + + private static readonly (Guid Id, LocalizedText Term, LocalizedText Definition)[] GlossaryData = + { + ( + DeterministicGuid.From("glossary:cce"), + LocalizedText.Create("الاقتصاد الكربوني الدائري", "Circular Carbon Economy"), + LocalizedText.Create( + "نهج شامل لإدارة الانبعاثات الكربونية يشمل الأربعة Rs: التقليل، إعادة الاستخدام، التدوير، والإزالة.", + "A comprehensive approach to managing carbon emissions encompassing the 4 Rs: Reduce, Reuse, Recycle, and Remove.") + ), + ( + DeterministicGuid.From("glossary:dac"), + LocalizedText.Create("الالتقاط المباشر من الجو", "Direct Air Capture (DAC)"), + LocalizedText.Create( + "تقنية لالتقاط ثاني أكسيد الكربون مباشرة من الهواء الجوي باستخدام محاليل كيميائية أو أغشية انتقالية.", + "Technology that captures carbon dioxide directly from ambient air using chemical solutions or selective membranes.") + ), + ( + DeterministicGuid.From("glossary:ccus"), + LocalizedText.Create("الاستخدام والتخزين الكربوني", "Carbon Capture, Utilization and Storage (CCUS)"), + LocalizedText.Create( + "عملية التقاط انبعاثات CO2 واستخدامها في منتجات أو تخزينها تحت الأرض بشكل دائم.", + "The process of capturing CO2 emissions and either using them in products or storing them permanently underground.") + ), + ( + DeterministicGuid.From("glossary:lcoe"), + LocalizedText.Create("تكلفة الطاقة المستوية", "Levelized Cost of Energy (LCOE)"), + LocalizedText.Create( + "تكلفة إنتاج وحدة الطاقة (عادةً MWh) على مدى عمر المشروع، تأخذ في الاعتبار الاستثمار الأولي والتشغيل والصيانة.", + "The cost of producing a unit of energy (typically MWh) over a project lifetime, accounting for initial investment and operation & maintenance.") + ), + }; + + private static readonly (Guid Id, LocalizedText Name, LocalizedText? Description, string? LogoUrl, string? WebsiteUrl)[] PartnerData = + { + ( + DeterministicGuid.From("partner:kapsarc"), + LocalizedText.Create("كابسارك", "KAPSARC"), + LocalizedText.Create( + "مركز الملك عبدالله للبحوث والدراسات البترولية - مركز أبحاث عالمي مكرس لدراسة سياسات الطاقة.", + "King Abdullah Petroleum Studies and Research Center - a global research institution dedicated to energy policy studies."), + "https://cdn.example.com/partners/kapsarc.png", + "https://www.kapsarc.org" + ), + ( + DeterministicGuid.From("partner:irena"), + LocalizedText.Create("الوكالة الدولية للطاقة المتجددة", "IRENA"), + LocalizedText.Create( + "منظمة حكومية دولية تدعم انتقال الطاقة المتجددة في جميع أنحاء العالم.", + "An intergovernmental organization that supports countries in their transition to a sustainable energy future."), + "https://cdn.example.com/partners/irena.png", + "https://www.irena.org" + ), + ( + DeterministicGuid.From("partner:gcep"), + LocalizedText.Create("برنامج الاقتصاد الكربوني العالمي", "Global Carbon Economy Program (GCEP)"), + LocalizedText.Create( + "برنامج بحثي دولي يركز على تطوير تقنيات منخفضة الكربون والسياسات المرتبطة بها.", + "An international research program focused on developing low-carbon technologies and associated policies."), + "https://cdn.example.com/partners/gcep.png", + "https://gcep.stanford.edu" + ), + }; + + private static readonly (Guid Id, PolicySectionType Type, LocalizedText Title, LocalizedText Content)[] SectionData = + { + ( + DeterministicGuid.From("policy:terms"), + PolicySectionType.Terms, + LocalizedText.Create("شروط الخدمة", "Terms of Service"), + LocalizedText.Create( + "

1. القبول بالشروط

باستخدامك لهذه المنصة، فإنك توافق على الالتزام بهذه الشروط.

2. الاستخدام المسموح

يجب استخدام المنصة لأغراض قانونية فقط.

", + "

1. Acceptance of Terms

By using this platform, you agree to comply with these terms.

2. Permitted Use

The platform must be used for lawful purposes only.

") + ), + ( + DeterministicGuid.From("policy:privacy"), + PolicySectionType.Privacy, + LocalizedText.Create("سياسة الخصوصية", "Privacy Policy"), + LocalizedText.Create( + "

1. جمع البيانات

نقوم بجمع المعلومات الضرورية لتقديم خدماتنا.

2. حماية البيانات

نستخدم تدابير أمنية متقدمة لحماية بياناتك.

", + "

1. Data Collection

We collect information necessary to provide our services.

2. Data Protection

We use advanced security measures to protect your data.

") + ), + ( + DeterministicGuid.From("policy:faq"), + PolicySectionType.FAQ, + LocalizedText.Create("الأسئلة الشائعة", "Frequently Asked Questions"), + LocalizedText.Create( + "

كيف أبدأ؟

يمكنك التسجيل مجاناً والبدء في استكشاف المحتوى فوراً.

هل المحتوى متاح بلغات متعددة؟

نعم، المنصة تدعم اللغتين العربية والإنجليزية.

", + "

How do I get started?

You can register for free and start exploring content immediately.

Is content available in multiple languages?

Yes, the platform supports both Arabic and English.

") + ), + }; +} diff --git a/backend/src/CCE.Seeder/Seeders/ReferenceDataSeeder.cs b/backend/src/CCE.Seeder/Seeders/ReferenceDataSeeder.cs index 5d81d5a9..c8cebbfe 100644 --- a/backend/src/CCE.Seeder/Seeders/ReferenceDataSeeder.cs +++ b/backend/src/CCE.Seeder/Seeders/ReferenceDataSeeder.cs @@ -1,6 +1,8 @@ using CCE.Domain.Common; using CCE.Domain.Community; using CCE.Domain.Content; +using CCE.Domain.PlatformSettings; +using CCE.Domain.PlatformSettings.ValueObjects; using CCE.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -36,6 +38,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); } @@ -160,29 +163,126 @@ private static readonly (string Code, string SubjectAr, string SubjectEn, string BodyAr, string BodyEn, CCE.Domain.Notifications.NotificationChannel Channel)[] InitialTemplates = { + // ACCOUNT_CREATED ("ACCOUNT_CREATED", "تم إنشاء حسابك", "Your account is created", "مرحباً {{Name}}، تم إنشاء حسابك بنجاح.", "Hi {{Name}}, your account is now active.", CCE.Domain.Notifications.NotificationChannel.Email), + ("ACCOUNT_CREATED", "تم إنشاء حسابك", "Your account is created", + "مرحباً {{Name}}، تم إنشاء حسابك بنجاح.", "Hi {{Name}}, your account is now active.", + CCE.Domain.Notifications.NotificationChannel.Sms), + ("ACCOUNT_CREATED", "تم إنشاء حسابك", "Your account is created", + "مرحباً {{Name}}، تم إنشاء حسابك بنجاح.", "Hi {{Name}}, your account is now active.", + CCE.Domain.Notifications.NotificationChannel.InApp), + + // EXPERT_REQUEST_APPROVED ("EXPERT_REQUEST_APPROVED", "تمت الموافقة على طلبك", "Your expert request was approved", "مرحباً {{Name}}، تمت الموافقة على طلب الخبير الخاص بك.", "Hi {{Name}}, your expert-registration request has been approved.", CCE.Domain.Notifications.NotificationChannel.Email), + ("EXPERT_REQUEST_APPROVED", "تمت الموافقة", "Approved", + "تمت الموافقة على طلب الخبير.", "Your expert request has been approved.", + CCE.Domain.Notifications.NotificationChannel.Sms), + ("EXPERT_REQUEST_APPROVED", "تمت الموافقة على طلبك", "Your expert request was approved", + "مرحباً {{Name}}، تمت الموافقة على طلب الخبير الخاص بك.", + "Hi {{Name}}, your expert-registration request has been approved.", + CCE.Domain.Notifications.NotificationChannel.InApp), + + // EXPERT_REQUEST_REJECTED + ("EXPERT_REQUEST_REJECTED", "تم رفض طلبك", "Your expert request was rejected", + "نأسف، تم رفض طلب الخبير: {{Reason}}", "Sorry, your expert request was rejected: {{Reason}}", + CCE.Domain.Notifications.NotificationChannel.Email), + ("EXPERT_REQUEST_REJECTED", "تم الرفض", "Rejected", + "نأسف، تم رفض طلب الخبير.", "Sorry, your expert request was rejected.", + CCE.Domain.Notifications.NotificationChannel.Sms), ("EXPERT_REQUEST_REJECTED", "تم رفض طلبك", "Your expert request was rejected", "نأسف، تم رفض طلب الخبير: {{Reason}}", "Sorry, your expert request was rejected: {{Reason}}", + CCE.Domain.Notifications.NotificationChannel.InApp), + + // RESOURCE_REQUEST_APPROVED + ("RESOURCE_REQUEST_APPROVED", "تمت الموافقة على المورد", "Country resource approved", + "تمت الموافقة على مساهمة الدولة الخاصة بك.", "Your country resource submission was approved.", CCE.Domain.Notifications.NotificationChannel.Email), + ("RESOURCE_REQUEST_APPROVED", "تمت الموافقة", "Approved", + "تمت الموافقة على المورد.", "Your country resource was approved.", + CCE.Domain.Notifications.NotificationChannel.Sms), ("RESOURCE_REQUEST_APPROVED", "تمت الموافقة على المورد", "Country resource approved", "تمت الموافقة على مساهمة الدولة الخاصة بك.", "Your country resource submission was approved.", CCE.Domain.Notifications.NotificationChannel.InApp), + + // NEWS_PUBLISHED + ("NEWS_PUBLISHED", "تم نشر خبر", "News published", + "تم نشر الخبر.", "Your news article has been published.", + CCE.Domain.Notifications.NotificationChannel.Email), + ("NEWS_PUBLISHED", "تم النشر", "Published", + "تم نشر الخبر.", "News published.", + CCE.Domain.Notifications.NotificationChannel.Sms), + ("NEWS_PUBLISHED", "تم نشر خبر", "News published", + "تم نشر الخبر.", "Your news article has been published.", + CCE.Domain.Notifications.NotificationChannel.InApp), + + // RESOURCE_PUBLISHED + ("RESOURCE_PUBLISHED", "تم نشر مورد", "Resource published", + "تم نشر المورد.", "Your resource has been published.", + CCE.Domain.Notifications.NotificationChannel.Email), + ("RESOURCE_PUBLISHED", "تم النشر", "Published", + "تم نشر المورد.", "Resource published.", + CCE.Domain.Notifications.NotificationChannel.Sms), + ("RESOURCE_PUBLISHED", "تم نشر مورد", "Resource published", + "تم نشر المورد.", "Your resource has been published.", + CCE.Domain.Notifications.NotificationChannel.InApp), + + // EVENT_SCHEDULED + ("EVENT_SCHEDULED", "تم جدولة فعالية", "Event scheduled", + "تم جدولة الفعالية.", "The event has been scheduled.", + CCE.Domain.Notifications.NotificationChannel.Email), + ("EVENT_SCHEDULED", "تم الجدولة", "Scheduled", + "تم جدولة الفعالية.", "Event scheduled.", + CCE.Domain.Notifications.NotificationChannel.Sms), + ("EVENT_SCHEDULED", "تم جدولة فعالية", "Event scheduled", + "تم جدولة الفعالية.", "The event has been scheduled.", + CCE.Domain.Notifications.NotificationChannel.InApp), + + // COMMUNITY_POST_CREATED + ("COMMUNITY_POST_CREATED", "منشور جديد", "New post", + "تم إنشاء منشور جديد في الموضوع الذي تتابعه.", "A new post was created in a topic you follow.", + CCE.Domain.Notifications.NotificationChannel.Email), + ("COMMUNITY_POST_CREATED", "منشور جديد", "New post", + "منشور جديد.", "New post.", + CCE.Domain.Notifications.NotificationChannel.Sms), + ("COMMUNITY_POST_CREATED", "منشور جديد", "New post", + "تم إنشاء منشور جديد في الموضوع الذي تتابعه.", "A new post was created in a topic you follow.", + CCE.Domain.Notifications.NotificationChannel.InApp), + + // OTP_VERIFICATION + ("OTP_VERIFICATION", "رمز التحقق", "Verification Code", + "رمز التحقق الخاص بك هو: {{Code}}. صالح لمدة 5 دقائق.", + "Your verification code is: {{Code}}. Valid for 5 minutes.", + CCE.Domain.Notifications.NotificationChannel.Email), + ("OTP_VERIFICATION", "رمز التحقق", "Verification Code", + "رمز التحقق: {{Code}}", + "Your code: {{Code}}", + CCE.Domain.Notifications.NotificationChannel.Sms), + + // PASSWORD_RESET + ("PASSWORD_RESET", "استعادة كلمة المرور", "Reset your password", + "مرحباً {{Name}}، استخدم الرابط التالي لإعادة تعيين كلمة المرور: {{ResetUrl}}", + "Hi {{Name}}, use the link below to reset your password: {{ResetUrl}}", + CCE.Domain.Notifications.NotificationChannel.Email), + ("PASSWORD_RESET", "استعادة كلمة المرور", "Reset your password", + "مرحباً {{Name}}، رابط إعادة تعيين كلمة المرور: {{ResetUrl}}", + "Hi {{Name}}, reset your password: {{ResetUrl}}", + CCE.Domain.Notifications.NotificationChannel.Sms), }; private async Task SeedNotificationTemplatesAsync(CancellationToken ct) { foreach (var t in InitialTemplates) { - var id = DeterministicGuid.From($"template:{t.Code}"); var exists = await _ctx.NotificationTemplates - .AnyAsync(x => x.Id == id, ct).ConfigureAwait(false); + .AnyAsync(x => x.Code == t.Code && x.Channel == t.Channel, ct) + .ConfigureAwait(false); if (exists) continue; + var id = DeterministicGuid.From($"template:{t.Code}:{(int)t.Channel}"); var template = CCE.Domain.Notifications.NotificationTemplate.Define( t.Code, t.SubjectAr, t.SubjectEn, t.BodyAr, t.BodyEn, t.Channel, "{}"); typeof(CCE.Domain.Notifications.NotificationTemplate) @@ -251,4 +351,38 @@ private async Task SeedHomepageSectionsAsync(CancellationToken ct) _ctx.HomepageSections.Add(section); } } + + // ─── Platform Settings (singleton rows) ─── + private async Task SeedPlatformSettingsAsync(CancellationToken ct) + { + var systemUser = DeterministicGuid.From("platform_settings:seeder"); + + var hcId = DeterministicGuid.From("platform_settings:homepage"); + if (!await _ctx.HomepageSettings.AnyAsync(x => x.Id == hcId, ct).ConfigureAwait(false)) + { + var hs = HomepageSettings.Create( + LocalizedText.Create("أهداف المنصة", "Platform objectives"), + systemUser, _clock); + 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( + LocalizedText.Create("وصف المنصة", "Platform description"), + systemUser, _clock); + 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(systemUser, _clock); + typeof(PoliciesSettings).GetProperty(nameof(pc.Id))!.SetValue(pc, pcId); + _ctx.PoliciesSettings.Add(pc); + } + } } 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(), }; } 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/Endpoints/UsersEndpointTests.cs b/backend/tests/CCE.Api.IntegrationTests/Endpoints/UsersEndpointTests.cs index 8bfbdc2f..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] @@ -110,4 +113,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.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/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.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.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.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.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/DependencyInjectionTests.cs b/backend/tests/CCE.Application.Tests/DependencyInjectionTests.cs index 77ddf0b2..a724e3e9 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.GetString(Arg.Any(), Arg.Any()).Returns("ar"); + 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.GetString(Arg.Any(), Arg.Any()).Returns("ar"); + 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 96498469..86461bd4 100644 --- a/backend/tests/CCE.Application.Tests/Identity/Commands/ApproveExpertRequestCommandHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Identity/Commands/ApproveExpertRequestCommandHandlerTests.cs @@ -1,10 +1,12 @@ 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; using Microsoft.AspNetCore.Identity; +using static CCE.Application.Tests.Identity.IdentityTestHelpers; namespace CCE.Application.Tests.Identity.Commands; @@ -13,17 +15,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(BuildDb(), service, BuildCurrentUser(), new FakeSystemClock(), BuildMsg()); - 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.Success.Should().BeFalse(); + result.Code.Should().Be(SystemCode.ERR400); } [Fact] @@ -32,19 +35,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(BuildDb(), service, currentUser, clock, BuildMsg()); - var act = async () => await sut.Handle( + var result = await sut.Handle( new ApproveExpertRequestCommand(registration.Id, "Dr.", "Dr."), CancellationToken.None); - await act.Should().ThrowAsync(); + result.Success.Should().BeFalse(); + result.Code.Should().Be(SystemCode.ERR407); } [Fact] @@ -56,11 +60,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(BuildDb(), service, BuildCurrentUser(adminId), clock, BuildMsg()); var act = async () => await sut.Handle( new ApproveExpertRequestCommand(registration.Id, "Dr.", "Dr."), @@ -78,24 +82,26 @@ 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 db = BuildDb(users); - var sut = new ApproveExpertRequestCommandHandler(service, BuildDb(users), BuildCurrentUser(adminId), clock); + var sut = new ApproveExpertRequestCommandHandler(db, service, BuildCurrentUser(adminId), clock, BuildMsg()); - 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()); + 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 c38cb5cc..f7a5686c 100644 --- a/backend/tests/CCE.Application.Tests/Identity/Commands/AssignUserRolesCommandHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Identity/Commands/AssignUserRolesCommandHandlerTests.cs @@ -1,34 +1,39 @@ +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.Application.Messages; +using CCE.Domain.Common; 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, BuildMsg()); var result = await sut.Handle(new AssignUserRolesCommand(System.Guid.NewGuid(), new[] { "SuperAdmin" }), CancellationToken.None); - result.Should().BeNull(); - await mediator.DidNotReceiveWithAnyArgs().Send(default!, default); + result.Success.Should().BeFalse(); + result.Code.Should().Be(SystemCode.ERR001); + 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 +42,32 @@ 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(Response.Ok(dto, SystemCode.CON900, "ar")); - var sut = new AssignUserRolesCommandHandler(service, mediator); + var sut = new AssignUserRolesCommandHandler(service, mediator, BuildMsg()); var result = await sut.Handle(new AssignUserRolesCommand(id, new[] { "ContentManager" }), CancellationToken.None); - result.Should().BeEquivalentTo(dto); + result.Success.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); + + 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(dto, SystemCode.CON900, "ar")); + + 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/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/CreateStateRepAssignmentCommandHandlerTests.cs b/backend/tests/CCE.Application.Tests/Identity/Commands/CreateStateRepAssignmentCommandHandlerTests.cs index 2bfba1b8..2652ab74 100644 --- a/backend/tests/CCE.Application.Tests/Identity/Commands/CreateStateRepAssignmentCommandHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Identity/Commands/CreateStateRepAssignmentCommandHandlerTests.cs @@ -1,47 +1,52 @@ +using CCE.Application.Common; 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; 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(), BuildMsg()); - 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.Success.Should().BeFalse(); + result.Code.Should().Be(SystemCode.ERR001); } [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(), BuildMsg()); - var act = async () => await sut.Handle( + var result = await sut.Handle( new CreateStateRepAssignmentCommand(aliceId, System.Guid.NewGuid()), CancellationToken.None); - await act.Should().ThrowAsync(); + result.Success.Should().BeFalse(); + result.Code.Should().Be(SystemCode.ERR070); } [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 +56,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(), BuildMsg()); - var act = async () => await sut.Handle( + var result = await sut.Handle( new CreateStateRepAssignmentCommand(aliceId, country.Id), CancellationToken.None); - await act.Should().ThrowAsync(); + result.Success.Should().BeFalse(); + result.Code.Should().Be(SystemCode.ERR407); } [Fact] @@ -66,22 +72,24 @@ 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, BuildMsg()); - 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(); - await service.Received(1).SaveAsync(Arg.Any(), Arg.Any()); + 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).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/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/Commands/RejectExpertRequestCommandHandlerTests.cs b/backend/tests/CCE.Application.Tests/Identity/Commands/RejectExpertRequestCommandHandlerTests.cs index e9155087..f8e45970 100644 --- a/backend/tests/CCE.Application.Tests/Identity/Commands/RejectExpertRequestCommandHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Identity/Commands/RejectExpertRequestCommandHandlerTests.cs @@ -1,10 +1,12 @@ 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; using Microsoft.AspNetCore.Identity; +using static CCE.Application.Tests.Identity.IdentityTestHelpers; namespace CCE.Application.Tests.Identity.Commands; @@ -13,17 +15,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(BuildDb(), service, BuildCurrentUser(), new FakeSystemClock(), BuildMsg()); - 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.Success.Should().BeFalse(); + result.Code.Should().Be(SystemCode.ERR400); } [Fact] @@ -32,19 +35,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(BuildDb(), service, currentUser, clock, BuildMsg()); - var act = async () => await sut.Handle( + var result = await sut.Handle( new RejectExpertRequestCommand(registration.Id, "غير مؤهل", "Insufficient evidence."), CancellationToken.None); - await act.Should().ThrowAsync(); + result.Success.Should().BeFalse(); + result.Code.Should().Be(SystemCode.ERR407); } [Fact] @@ -56,11 +60,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(BuildDb(), service, BuildCurrentUser(adminId), clock, BuildMsg()); var act = async () => await sut.Handle( new RejectExpertRequestCommand(registration.Id, "غير مؤهل", "Insufficient evidence."), @@ -78,23 +82,24 @@ 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 db = BuildDb(users); - var sut = new RejectExpertRequestCommandHandler(service, BuildDb(users), BuildCurrentUser(adminId), clock); + var sut = new RejectExpertRequestCommandHandler(db, service, BuildCurrentUser(adminId), clock, BuildMsg()); - 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()); + 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 052d26e3..ec3203bb 100644 --- a/backend/tests/CCE.Application.Tests/Identity/Commands/RevokeStateRepAssignmentCommandHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Identity/Commands/RevokeStateRepAssignmentCommandHandlerTests.cs @@ -1,46 +1,53 @@ +using CCE.Application.Common; 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; +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 db = 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(db, service, BuildCurrentUser(), new FakeSystemClock(), BuildMsg()); - 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.Success.Should().BeFalse(); + result.Code.Should().Be(SystemCode.ERR401); } [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 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); + var sut = new RevokeStateRepAssignmentCommandHandler(db, service, currentUser, clock, BuildMsg()); - 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.Success.Should().BeFalse(); + result.Code.Should().Be(SystemCode.ERR407); } [Fact] @@ -52,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 service = Substitute.For(); + var db = 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(db, service, BuildCurrentUser(revokerId), clock, BuildMsg()); var act = async () => await sut.Handle(new RevokeStateRepAssignmentCommand(assignment.Id), CancellationToken.None); @@ -71,18 +79,21 @@ 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 db = 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(db, service, BuildCurrentUser(revokerId), clock, BuildMsg()); - await sut.Handle(new RevokeStateRepAssignmentCommand(assignment.Id), CancellationToken.None); + var result = await sut.Handle(new RevokeStateRepAssignmentCommand(assignment.Id), CancellationToken.None); + 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 new file mode 100644 index 00000000..8a91b783 --- /dev/null +++ b/backend/tests/CCE.Application.Tests/Identity/IdentityTestHelpers.cs @@ -0,0 +1,17 @@ +using CCE.Application.Localization; +using CCE.Application.Messages; +using NSubstitute; + +namespace CCE.Application.Tests.Identity; + +public static class IdentityTestHelpers +{ + public static MessageFactory BuildMsg() + { + var localization = Substitute.For(); + 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/Commands/SubmitExpertRequestCommandHandlerTests.cs b/backend/tests/CCE.Application.Tests/Identity/Public/Commands/SubmitExpertRequestCommandHandlerTests.cs index a0a03ba6..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 service = Substitute.For(); - var sut = new SubmitExpertRequestCommandHandler(service, clock); + var db = Substitute.For(); + var service = Substitute.For(); + var sut = new SubmitExpertRequestCommandHandler(db, service, clock, BuildMsg()); var requesterId = System.Guid.NewGuid(); var cmd = new SubmitExpertRequestCommand( @@ -22,24 +26,26 @@ public async Task Persists_request_and_returns_dto() "English bio", new[] { "Hydrogen", "Solar" }); - var dto = 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(); - await service.Received(1).SaveAsync(Arg.Any(), Arg.Any()); + var result = await sut.Handle(cmd, CancellationToken.None); + + 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).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 service = Substitute.For(); - var sut = new SubmitExpertRequestCommandHandler(service, clock); + var db = Substitute.For(); + var service = Substitute.For(); + 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 58d3b06b..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,6 +1,9 @@ +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; namespace CCE.Application.Tests.Identity.Public.Commands; @@ -9,10 +12,11 @@ public class UpdateMyProfileCommandHandlerTests [Fact] public async Task Returns_null_when_user_not_found() { - var service = Substitute.For(); + var db = Substitute.For(); + var service = Substitute.For(); service.FindAsync(Arg.Any(), Arg.Any()) .Returns((User?)null); - var sut = new UpdateMyProfileCommandHandler(service); + var sut = new UpdateMyProfileCommandHandler(db, service, BuildMsg()); var cmd = new UpdateMyProfileCommand( System.Guid.NewGuid(), "en", KnowledgeLevel.Intermediate, @@ -20,8 +24,10 @@ public async Task Returns_null_when_user_not_found() var result = await sut.Handle(cmd, CancellationToken.None); - result.Should().BeNull(); - 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] @@ -31,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 service = Substitute.For(); + 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); + var sut = new UpdateMyProfileCommandHandler(db, service, BuildMsg()); var cmd = new UpdateMyProfileCommand( userId, "en", KnowledgeLevel.Advanced, @@ -45,12 +51,13 @@ 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); - await service.Received(1).UpdateAsync(user, Arg.Any()); + 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); + service.Received(1).Update(user); + await db.Received(1).SaveChangesAsync(Arg.Any()); } [Fact] @@ -60,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 service = Substitute.For(); + 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); + var sut = new UpdateMyProfileCommandHandler(db, service, BuildMsg()); var cmd = new UpdateMyProfileCommand( userId, "ar", KnowledgeLevel.Beginner, @@ -72,6 +79,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..dd108d5e 100644 --- a/backend/tests/CCE.Application.Tests/Identity/Public/Queries/GetMyExpertStatusQueryHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Identity/Public/Queries/GetMyExpertStatusQueryHandlerTests.cs @@ -1,7 +1,9 @@ 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; namespace CCE.Application.Tests.Identity.Public.Queries; @@ -11,11 +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); + var sut = new GetMyExpertStatusQueryHandler(db, BuildMsg()); var result = await sut.Handle(new GetMyExpertStatusQuery(System.Guid.NewGuid()), CancellationToken.None); - result.Should().BeNull(); + result.Success.Should().BeFalse(); + result.Code.Should().Be(SystemCode.ERR400); } [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, BuildMsg()); 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, BuildMsg()); 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..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,6 +1,8 @@ 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; namespace CCE.Application.Tests.Identity.Public.Queries; @@ -9,14 +11,15 @@ 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, BuildMsg()); var result = await sut.Handle(new GetMyProfileQuery(System.Guid.NewGuid()), CancellationToken.None); - result.Should().BeNull(); + result.Success.Should().BeFalse(); + result.Code.Should().Be(SystemCode.ERR001); } [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, BuildMsg()); 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..c8030bc5 100644 --- a/backend/tests/CCE.Application.Tests/Identity/Queries/GetUserByIdQueryHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Identity/Queries/GetUserByIdQueryHandlerTests.cs @@ -1,7 +1,9 @@ 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; namespace CCE.Application.Tests.Identity.Queries; @@ -11,11 +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); + var sut = new GetUserByIdQueryHandler(db, BuildMsg()); var result = await sut.Handle(new GetUserByIdQuery(System.Guid.NewGuid()), CancellationToken.None); - result.Should().BeNull(); + result.Success.Should().BeFalse(); + result.Code.Should().Be(SystemCode.ERR001); } [Fact] @@ -28,35 +31,33 @@ 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, BuildMsg()); 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] - 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); + var sut = new GetUserByIdQueryHandler(db, BuildMsg()); 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.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( diff --git a/backend/tests/CCE.Application.Tests/Notifications/CreateNotificationTemplateCommandHandlerTests.cs b/backend/tests/CCE.Application.Tests/Notifications/CreateNotificationTemplateCommandHandlerTests.cs index 78c155a6..613529d3 100644 --- a/backend/tests/CCE.Application.Tests/Notifications/CreateNotificationTemplateCommandHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Notifications/CreateNotificationTemplateCommandHandlerTests.cs @@ -1,3 +1,4 @@ +using CCE.Application.Common.Interfaces; using CCE.Application.Notifications; using CCE.Application.Notifications.Commands.CreateNotificationTemplate; using CCE.Domain.Notifications; @@ -7,10 +8,11 @@ namespace CCE.Application.Tests.Notifications; public class CreateNotificationTemplateCommandHandlerTests { [Fact] - public async Task Persists_template_and_returns_dto_when_inputs_valid() + public async Task Persists_template_and_returns_id_when_inputs_valid() { - var service = Substitute.For(); - var sut = new CreateNotificationTemplateCommandHandler(service); + var repo = Substitute.For(); + var db = Substitute.For(); + var sut = new CreateNotificationTemplateCommandHandler(repo, db, NotificationTestMessages.Create()); var cmd = new CreateNotificationTemplateCommand( "WELCOME_EMAIL", @@ -19,12 +21,11 @@ public async Task Persists_template_and_returns_dto_when_inputs_valid() NotificationChannel.Email, "{}"); - var dto = await sut.Handle(cmd, CancellationToken.None); + var result = await sut.Handle(cmd, CancellationToken.None); - dto.Code.Should().Be("WELCOME_EMAIL"); - dto.SubjectEn.Should().Be("Welcome"); - dto.Channel.Should().Be(NotificationChannel.Email); - dto.IsActive.Should().BeTrue(); - await service.Received(1).SaveAsync(Arg.Any(), Arg.Any()); + result.Success.Should().BeTrue(); + result.Data.Should().NotBe(System.Guid.Empty); + await repo.Received(1).AddAsync(Arg.Any(), Arg.Any()); + await db.Received(1).SaveChangesAsync(Arg.Any()); } } diff --git a/backend/tests/CCE.Application.Tests/Notifications/GetNotificationTemplateByIdQueryHandlerTests.cs b/backend/tests/CCE.Application.Tests/Notifications/GetNotificationTemplateByIdQueryHandlerTests.cs index 6b72af96..68c9210b 100644 --- a/backend/tests/CCE.Application.Tests/Notifications/GetNotificationTemplateByIdQueryHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Notifications/GetNotificationTemplateByIdQueryHandlerTests.cs @@ -7,14 +7,15 @@ namespace CCE.Application.Tests.Notifications; public class GetNotificationTemplateByIdQueryHandlerTests { [Fact] - public async Task Returns_null_when_template_not_found() + public async Task Returns_failure_response_when_template_not_found() { var db = BuildDb(System.Array.Empty()); - var sut = new GetNotificationTemplateByIdQueryHandler(db); + var sut = new GetNotificationTemplateByIdQueryHandler(db, NotificationTestMessages.Create()); var result = await sut.Handle(new GetNotificationTemplateByIdQuery(System.Guid.NewGuid()), CancellationToken.None); - result.Should().BeNull(); + result.Success.Should().BeFalse(); + result.Data.Should().BeNull(); } [Fact] @@ -30,20 +31,20 @@ public async Task Returns_dto_with_all_fields_when_found() "{\"name\": \"string\"}"); var db = BuildDb(new[] { template }); - var sut = new GetNotificationTemplateByIdQueryHandler(db); + var sut = new GetNotificationTemplateByIdQueryHandler(db, NotificationTestMessages.Create()); var result = await sut.Handle(new GetNotificationTemplateByIdQuery(template.Id), CancellationToken.None); result.Should().NotBeNull(); - result!.Id.Should().Be(template.Id); - result.Code.Should().Be("WELCOME_EMAIL"); - result.SubjectAr.Should().Be("مرحبا"); - result.SubjectEn.Should().Be("Welcome"); - result.BodyAr.Should().Be("جسم عربي"); - result.BodyEn.Should().Be("English body"); - result.Channel.Should().Be(NotificationChannel.Email); - result.VariableSchemaJson.Should().Be("{\"name\": \"string\"}"); - result.IsActive.Should().BeTrue(); + result.Data!.Id.Should().Be(template.Id); + result.Data.Code.Should().Be("WELCOME_EMAIL"); + result.Data.SubjectAr.Should().Be("مرحبا"); + result.Data.SubjectEn.Should().Be("Welcome"); + result.Data.BodyAr.Should().Be("جسم عربي"); + result.Data.BodyEn.Should().Be("English body"); + result.Data.Channel.Should().Be(NotificationChannel.Email); + result.Data.VariableSchemaJson.Should().Be("{\"name\": \"string\"}"); + result.Data.IsActive.Should().BeTrue(); } private static ICceDbContext BuildDb(IEnumerable templates) diff --git a/backend/tests/CCE.Application.Tests/Notifications/ListNotificationTemplatesQueryHandlerTests.cs b/backend/tests/CCE.Application.Tests/Notifications/ListNotificationTemplatesQueryHandlerTests.cs index 9ec4232b..e231cab8 100644 --- a/backend/tests/CCE.Application.Tests/Notifications/ListNotificationTemplatesQueryHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Notifications/ListNotificationTemplatesQueryHandlerTests.cs @@ -10,14 +10,14 @@ public class ListNotificationTemplatesQueryHandlerTests public async Task Returns_empty_paged_result_when_no_templates_exist() { var db = BuildDb(System.Array.Empty()); - var sut = new ListNotificationTemplatesQueryHandler(db); + var sut = new ListNotificationTemplatesQueryHandler(db, NotificationTestMessages.Create()); var result = await sut.Handle(new ListNotificationTemplatesQuery(Page: 1, PageSize: 20), CancellationToken.None); - result.Items.Should().BeEmpty(); - result.Total.Should().Be(0); - result.Page.Should().Be(1); - result.PageSize.Should().Be(20); + result.Data!.Items.Should().BeEmpty(); + result.Data.Total.Should().Be(0); + result.Data.Page.Should().Be(1); + result.Data.PageSize.Should().Be(20); } [Fact] @@ -27,13 +27,13 @@ public async Task Returns_templates_sorted_by_Code_ascending() var beta = NotificationTemplate.Define("BETA_CODE", "ب", "Beta Subject", "جسم", "Beta Body", NotificationChannel.Email, "{}"); var db = BuildDb(new[] { beta, alpha }); - var sut = new ListNotificationTemplatesQueryHandler(db); + var sut = new ListNotificationTemplatesQueryHandler(db, NotificationTestMessages.Create()); var result = await sut.Handle(new ListNotificationTemplatesQuery(Page: 1, PageSize: 20), CancellationToken.None); - result.Total.Should().Be(2); - result.Items[0].Code.Should().Be("ALPHA_CODE"); - result.Items[1].Code.Should().Be("BETA_CODE"); + result.Data!.Total.Should().Be(2); + result.Data.Items[0].Code.Should().Be("ALPHA_CODE"); + result.Data.Items[1].Code.Should().Be("BETA_CODE"); } [Fact] @@ -44,12 +44,12 @@ public async Task Filters_by_channel_and_isActive() sms.Deactivate(); var db = BuildDb(new[] { email, sms }); - var sut = new ListNotificationTemplatesQueryHandler(db); + var sut = new ListNotificationTemplatesQueryHandler(db, NotificationTestMessages.Create()); var result = await sut.Handle(new ListNotificationTemplatesQuery(Channel: NotificationChannel.Email, IsActive: true), CancellationToken.None); - result.Total.Should().Be(1); - result.Items.Single().Code.Should().Be("EMAIL_TMPL"); + result.Data!.Total.Should().Be(1); + result.Data.Items.Single().Code.Should().Be("EMAIL_TMPL"); } private static ICceDbContext BuildDb(IEnumerable templates) diff --git a/backend/tests/CCE.Application.Tests/Notifications/NotificationTestMessages.cs b/backend/tests/CCE.Application.Tests/Notifications/NotificationTestMessages.cs new file mode 100644 index 00000000..4a695904 --- /dev/null +++ b/backend/tests/CCE.Application.Tests/Notifications/NotificationTestMessages.cs @@ -0,0 +1,14 @@ +using CCE.Application.Localization; +using CCE.Application.Messages; + +namespace CCE.Application.Tests.Notifications; + +internal static class NotificationTestMessages +{ + public static MessageFactory Create() + { + var localization = Substitute.For(); + localization.GetString(Arg.Any(), Arg.Any()).Returns(call => call[0]!.ToString()!); + return new MessageFactory(localization); + } +} diff --git a/backend/tests/CCE.Application.Tests/Notifications/Public/GetMyUnreadCountQueryHandlerTests.cs b/backend/tests/CCE.Application.Tests/Notifications/Public/GetMyUnreadCountQueryHandlerTests.cs index 4198f4ea..69a2e867 100644 --- a/backend/tests/CCE.Application.Tests/Notifications/Public/GetMyUnreadCountQueryHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Notifications/Public/GetMyUnreadCountQueryHandlerTests.cs @@ -1,5 +1,6 @@ using CCE.Application.Common.Interfaces; using CCE.Application.Notifications.Public.Queries.GetMyUnreadCount; +using CCE.Application.Tests.Notifications; using CCE.Domain.Notifications; using CCE.TestInfrastructure.Time; @@ -31,10 +32,10 @@ public async Task Returns_count_of_Sent_notifications_only() var db = Substitute.For(); db.UserNotifications.Returns(new[] { sent1, sent2, read, pending }.AsQueryable()); - var sut = new GetMyUnreadCountQueryHandler(db); + var sut = new GetMyUnreadCountQueryHandler(db, NotificationTestMessages.Create()); - var count = await sut.Handle(new GetMyUnreadCountQuery(userId), CancellationToken.None); + var result = await sut.Handle(new GetMyUnreadCountQuery(userId), CancellationToken.None); - count.Should().Be(2); + result.Data.Should().Be(2); } } diff --git a/backend/tests/CCE.Application.Tests/Notifications/Public/ListMyNotificationsQueryHandlerTests.cs b/backend/tests/CCE.Application.Tests/Notifications/Public/ListMyNotificationsQueryHandlerTests.cs index 744c0db0..736502da 100644 --- a/backend/tests/CCE.Application.Tests/Notifications/Public/ListMyNotificationsQueryHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Notifications/Public/ListMyNotificationsQueryHandlerTests.cs @@ -1,5 +1,6 @@ using CCE.Application.Common.Interfaces; using CCE.Application.Notifications.Public.Queries.ListMyNotifications; +using CCE.Application.Tests.Notifications; using CCE.Domain.Notifications; using CCE.TestInfrastructure.Time; @@ -20,13 +21,13 @@ private static UserNotification MakeSent(System.Guid userId) public async Task Returns_empty_when_user_has_no_notifications() { var db = BuildDb(System.Array.Empty()); - var sut = new ListMyNotificationsQueryHandler(db); + var sut = new ListMyNotificationsQueryHandler(db, NotificationTestMessages.Create()); var userId = System.Guid.NewGuid(); var result = await sut.Handle(new ListMyNotificationsQuery(userId), CancellationToken.None); - result.Items.Should().BeEmpty(); - result.Total.Should().Be(0); + result.Data!.Items.Should().BeEmpty(); + result.Data.Total.Should().Be(0); } [Fact] @@ -38,12 +39,12 @@ public async Task Returns_only_notifications_belonging_to_the_requesting_user() var other = MakeSent(otherId); var db = BuildDb(new[] { mine, other }); - var sut = new ListMyNotificationsQueryHandler(db); + var sut = new ListMyNotificationsQueryHandler(db, NotificationTestMessages.Create()); var result = await sut.Handle(new ListMyNotificationsQuery(myId), CancellationToken.None); - result.Total.Should().Be(1); - result.Items.Single().Id.Should().Be(mine.Id); + result.Data!.Total.Should().Be(1); + result.Data.Items.Single().Id.Should().Be(mine.Id); } [Fact] @@ -61,14 +62,14 @@ public async Task Filters_by_status_when_provided() read.MarkRead(clock); var db = BuildDb(new[] { sent, read }); - var sut = new ListMyNotificationsQueryHandler(db); + var sut = new ListMyNotificationsQueryHandler(db, NotificationTestMessages.Create()); var result = await sut.Handle( new ListMyNotificationsQuery(userId, Status: NotificationStatus.Sent), CancellationToken.None); - result.Total.Should().Be(1); - result.Items.Single().Status.Should().Be(NotificationStatus.Sent); + result.Data!.Total.Should().Be(1); + result.Data.Items.Single().Status.Should().Be(NotificationStatus.Sent); } private static ICceDbContext BuildDb(IEnumerable notifications) diff --git a/backend/tests/CCE.Application.Tests/Notifications/Public/MarkNotificationReadCommandHandlerTests.cs b/backend/tests/CCE.Application.Tests/Notifications/Public/MarkNotificationReadCommandHandlerTests.cs index 155e5aa7..c0215939 100644 --- a/backend/tests/CCE.Application.Tests/Notifications/Public/MarkNotificationReadCommandHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Notifications/Public/MarkNotificationReadCommandHandlerTests.cs @@ -1,3 +1,5 @@ +using CCE.Application.Common.Interfaces; +using CCE.Application.Tests.Notifications; using CCE.Application.Notifications.Public; using CCE.Application.Notifications.Public.Commands.MarkNotificationRead; using CCE.Domain.Common; @@ -18,19 +20,20 @@ private static (UserNotification notification, FakeSystemClock clock) MakeSentNo } [Fact] - public async Task Throws_KeyNotFoundException_when_notification_not_found_or_belongs_to_different_user() + public async Task Returns_not_found_response_when_notification_not_found_or_belongs_to_different_user() { - var service = Substitute.For(); + var repo = Substitute.For(); var clock = new FakeSystemClock(); - service.FindAsync(Arg.Any(), Arg.Any()) + repo.GetAsync(Arg.Any(), Arg.Any()) .Returns((UserNotification?)null); - var sut = new MarkNotificationReadCommandHandler(service, clock); + var db = Substitute.For(); + var sut = new MarkNotificationReadCommandHandler(repo, db, NotificationTestMessages.Create(), clock); var cmd = new MarkNotificationReadCommand(System.Guid.NewGuid(), System.Guid.NewGuid()); - var act = async () => await sut.Handle(cmd, CancellationToken.None); + var result = await sut.Handle(cmd, CancellationToken.None); - await act.Should().ThrowAsync(); + result.Success.Should().BeFalse(); } [Fact] @@ -39,16 +42,18 @@ public async Task Marks_notification_as_read_and_calls_update() var userId = System.Guid.NewGuid(); var (notif, clock) = MakeSentNotification(userId); - var service = Substitute.For(); - service.FindAsync(notif.Id, Arg.Any()) + var repo = Substitute.For(); + repo.GetAsync(notif.Id, Arg.Any()) .Returns(notif); - var sut = new MarkNotificationReadCommandHandler(service, clock); + var db = Substitute.For(); + var sut = new MarkNotificationReadCommandHandler(repo, db, NotificationTestMessages.Create(), clock); var cmd = new MarkNotificationReadCommand(notif.Id, userId); - await sut.Handle(cmd, CancellationToken.None); + var result = await sut.Handle(cmd, CancellationToken.None); + result.Success.Should().BeTrue(); notif.Status.Should().Be(NotificationStatus.Read); - await service.Received(1).UpdateAsync(notif, Arg.Any()); + await db.Received(1).SaveChangesAsync(Arg.Any()); } } diff --git a/backend/tests/CCE.Application.Tests/Notifications/UpdateNotificationTemplateCommandHandlerTests.cs b/backend/tests/CCE.Application.Tests/Notifications/UpdateNotificationTemplateCommandHandlerTests.cs index d3e361bf..5cbd77f5 100644 --- a/backend/tests/CCE.Application.Tests/Notifications/UpdateNotificationTemplateCommandHandlerTests.cs +++ b/backend/tests/CCE.Application.Tests/Notifications/UpdateNotificationTemplateCommandHandlerTests.cs @@ -1,3 +1,4 @@ +using CCE.Application.Common.Interfaces; using CCE.Application.Notifications; using CCE.Application.Notifications.Commands.UpdateNotificationTemplate; using CCE.Domain.Notifications; @@ -7,20 +8,21 @@ namespace CCE.Application.Tests.Notifications; public class UpdateNotificationTemplateCommandHandlerTests { [Fact] - public async Task Returns_null_when_template_not_found() + public async Task Returns_not_found_response_when_template_not_found() { - var service = Substitute.For(); - service.FindAsync(Arg.Any(), Arg.Any()) + var repo = Substitute.For(); + repo.GetAsync(Arg.Any(), Arg.Any()) .Returns((NotificationTemplate?)null); - var sut = new UpdateNotificationTemplateCommandHandler(service); + var db = Substitute.For(); + var sut = new UpdateNotificationTemplateCommandHandler(repo, db, NotificationTestMessages.Create()); var result = await sut.Handle(BuildCommand(System.Guid.NewGuid()), CancellationToken.None); - result.Should().BeNull(); + result.Success.Should().BeFalse(); } [Fact] - public async Task Updates_content_and_active_state_and_returns_dto() + public async Task Updates_content_and_active_state_and_returns_id() { var template = NotificationTemplate.Define( "OLD_CODE", @@ -29,10 +31,11 @@ public async Task Updates_content_and_active_state_and_returns_dto() NotificationChannel.Email, "{}"); - var service = Substitute.For(); - service.FindAsync(template.Id, Arg.Any()).Returns(template); + var repo = Substitute.For(); + repo.GetAsync(template.Id, Arg.Any()).Returns(template); - var sut = new UpdateNotificationTemplateCommandHandler(service); + var db = Substitute.For(); + var sut = new UpdateNotificationTemplateCommandHandler(repo, db, NotificationTestMessages.Create()); var cmd = new UpdateNotificationTemplateCommand( template.Id, @@ -42,11 +45,12 @@ public async Task Updates_content_and_active_state_and_returns_dto() var result = await sut.Handle(cmd, CancellationToken.None); - result.Should().NotBeNull(); - result!.SubjectEn.Should().Be("New Subject"); - result.BodyEn.Should().Be("New Body"); - result.IsActive.Should().BeFalse(); - await service.Received(1).UpdateAsync(template, Arg.Any()); + result.Success.Should().BeTrue(); + result.Data.Should().Be(template.Id); + template.SubjectEn.Should().Be("New Subject"); + template.BodyEn.Should().Be("New Body"); + template.IsActive.Should().BeFalse(); + await db.Received(1).SaveChangesAsync(Arg.Any()); } private static UpdateNotificationTemplateCommand BuildCommand(System.Guid id) => diff --git a/backend/tests/CCE.Domain.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.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/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/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"); } 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/PermissionsYamlSchemaTests.cs b/backend/tests/CCE.Domain.Tests/PermissionsYamlSchemaTests.cs index cfe41d37..96eacf02 100644 --- a/backend/tests/CCE.Domain.Tests/PermissionsYamlSchemaTests.cs +++ b/backend/tests/CCE.Domain.Tests/PermissionsYamlSchemaTests.cs @@ -72,7 +72,7 @@ public void All_BRD_required_permissions_are_present() [Fact] public void Permissions_All_count_matches_BRD_matrix() { - Permissions.All.Count.Should().Be(42); + Permissions.All.Count.Should().Be(45); } [Fact] 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); } 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();