From 2f2aec61a29bdee9456655a446f8dd510ebef90d Mon Sep 17 00:00:00 2001 From: hamid Date: Thu, 2 Jul 2026 01:18:00 +0330 Subject: [PATCH] backend phase 1: config, reference & platform signals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lay the cross-cutting platform backbone every later phase reads from. Adds the first marketplace EF migration baseline (new `ops` schema) and the mechanisms b2..b15 reuse: typed runtime config, an append-only audit trail, an analytics event log, the holiday/bank-closure calendar, in-app notifications, and the internal support-alert worklist. Schema & migration - New `ops` schema + migration InitialMarketplaceBaseline with 6 tables: PlatformConfigs (IAuditable), AuditLogs (append-only), SystemEvents, IranianHolidays, Notifications, SupportAlerts — with indexes/uniques and FKs to usr.Users. Seeded 12 config keys + 7 sample holidays via HasData. Domain / Application - IAuditable marker + [AuditRedacted] attribute; entities + string-code constant holders (config data_type, holiday type, alert type/severity/status). - Facade contracts: IPlatformConfig, IHolidayCalendar, IAnalyticsSink, IAuditLogger, INotificationService, ISupportAlertService; DTOs + PagedResult; evolved the INotificationDispatcher.Notification record to carry Type + DataJson; Pagination helper. - 14 CQRS commands/queries (+ validators) wiring the endpoints to the facades. Infrastructure - DB-backed facade implementations in Persistence/Services/; real in-app INotificationDispatcher (removes the b0 log stub); notification-retention hosted service (purge is_read=1 AND age>90d). - Extended AuditFieldInterceptor to also append an old/new-diff audit_logs row for every IAuditable change in the same transaction (PII redacted). - Registered all facades + hosted service in AddPersistenceServices; removed the dispatcher registration from AddCrossCuttingSeams. API - 5 controllers: admin PlatformConfig/Holidays/Audit/SupportAlerts ([Authorize(DynamicPermission)]) + current-user Notifications ([Authorize]), all tenant-scoped and paginated. 16 Swagger paths total. Money-correctness & safety rules honoured - Config read at compute time (cached, parsed by data_type), never hardcoded; every config change is audited in the same transaction; audit_logs is append-only (no update/delete path); support alerts are admin-only; notifications are tenant-scoped; analytics is fire-and-forget. Tests & docs - 18 new foundation tests over in-memory SQLite (config typing + audit, holidays, notifications + tenancy + retention, support alerts, analytics); build clean (0 new code warnings), 22 tests green; migration applied to the dev DB and swagger.v1.json refreshed. - Updated server Project map + CONVENTIONS, product data-model doc 12 (seeded config defaults), config-reference contract, mock registry, backend handoff/ STATUS/report. Follow-ups: add FK constraints for SupportAlerts.BookingId (b9) and ReviewId (b14) when those tables land. Co-Authored-By: Claude Opus 4.8 (1M context) --- dev/contracts/domains/config-reference.md | 113 ++ dev/contracts/openapi/swagger.v1.json | 1768 ++++++++++++++++- dev/shared-working-context/backend/STATUS.md | 18 + .../backend/handoff/after-backend-phase-1.md | 58 + .../reports/backend-phase-1-report.md | 78 + .../reports/mocks-registry.md | 6 +- .../12-audit-config-and-reference.html | 16 + .../12-audit-config-and-reference.md | 19 + server/CLAUDE.md | 22 +- server/CONVENTIONS.md | 20 + .../Controllers/V1/AuditController.cs | 26 + .../Controllers/V1/HolidaysController.cs | 38 + .../Controllers/V1/NotificationsController.cs | 43 + .../V1/PlatformConfigController.cs | 38 + .../Controllers/V1/SupportAlertsController.cs | 38 + .../Baya.Application/Common/Pagination.cs | 15 + .../Contracts/Analytics/IAnalyticsSink.cs | 11 + .../Contracts/Audit/IAuditLogger.cs | 27 + .../Common/INotificationDispatcher.cs | 20 +- .../Configuration/IPlatformConfig.cs | 28 + .../Contracts/Holidays/IHolidayCalendar.cs | 29 + .../Notifications/INotificationService.cs | 26 + .../SupportAlerts/ISupportAlertService.cs | 37 + .../GetAuditTrailQuery.Handler.cs | 18 + .../GetAuditTrail/GetAuditTrailQuery.cs | 8 + .../UpdatePlatformConfigCommand.Handler.cs | 18 + .../UpdatePlatformConfigCommand.Validator.cs | 12 + .../UpdatePlatformConfigCommand.cs | 7 + .../GetConfigChangeHistoryQuery.Handler.cs | 18 + .../GetConfigChangeHistoryQuery.cs | 8 + .../ListPlatformConfigsQuery.Handler.cs | 18 + .../ListPlatformConfigsQuery.cs | 8 + .../DeleteHolidayCommand.Handler.cs | 18 + .../DeleteHoliday/DeleteHolidayCommand.cs | 6 + .../UpsertHolidayCommand.Handler.cs | 15 + .../UpsertHolidayCommand.Validator.cs | 16 + .../UpsertHoliday/UpsertHolidayCommand.cs | 7 + .../ListHolidays/ListHolidaysQuery.Handler.cs | 18 + .../Queries/ListHolidays/ListHolidaysQuery.cs | 9 + .../MarkAllRead/MarkAllReadCommand.Handler.cs | 19 + .../MarkAllRead/MarkAllReadCommand.cs | 6 + .../MarkNotificationReadCommand.Handler.cs | 22 + .../MarkNotificationReadCommand.Validator.cs | 11 + .../MarkNotificationReadCommand.cs | 6 + .../GetUnreadCountQuery.Handler.cs | 19 + .../GetUnreadCountQuery.Result.cs | 4 + .../GetUnreadCount/GetUnreadCountQuery.cs | 6 + .../ListMyNotificationsQuery.Handler.cs | 22 + .../ListMyNotificationsQuery.cs | 8 + .../AssignSupportAlertCommand.Handler.cs | 18 + .../AssignSupportAlertCommand.Validator.cs | 12 + .../AssignSupportAlertCommand.cs | 6 + .../ResolveSupportAlertCommand.Handler.cs | 18 + .../ResolveSupportAlertCommand.Validator.cs | 12 + .../ResolveSupportAlertCommand.cs | 6 + .../ListSupportAlertsQuery.Handler.cs | 18 + .../ListSupportAlertsQuery.cs | 13 + .../Models/Audit/AuditLogDto.cs | 12 + .../Models/Common/PagedResult.cs | 4 + .../Models/Configuration/PlatformConfigDto.cs | 13 + .../Models/Holidays/HolidayDto.cs | 4 + .../Models/Notifications/NotificationDto.cs | 13 + .../Models/SupportAlerts/SupportAlertDto.cs | 17 + .../src/Core/Baya.Domain/Common/IAuditable.cs | 22 + .../Entities/Analytics/SystemEvent.cs | 21 + .../Baya.Domain/Entities/Audit/AuditLog.cs | 34 + .../Entities/Configuration/PlatformConfig.cs | 31 + .../Entities/Holidays/IranianHoliday.cs | 28 + .../Entities/Notifications/Notification.cs | 30 + .../Entities/SupportAlerts/SupportAlert.cs | 71 + .../Seams/LogNotificationDispatcher.cs | 22 - .../ServiceCollectionExtension.cs | 8 +- .../Baya.Infrastructure.Persistence.csproj | 4 + .../AnalyticsConfig/SystemEventConfig.cs | 24 + .../AuditConfig/AuditLogConfig.cs | 26 + .../PlatformConfigConfig.cs | 57 + .../HolidaysConfig/IranianHolidayConfig.cs | 50 + .../NotificationsConfig/NotificationConfig.cs | 26 + .../Configuration/SeedConstants.cs | 10 + .../SupportAlertsConfig/SupportAlertConfig.cs | 31 + .../Interceptors/AuditFieldInterceptor.cs | 124 +- ...257_InitialMarketplaceBaseline.Designer.cs | 871 ++++++++ ...260701193257_InitialMarketplaceBaseline.cs | 308 +++ .../ApplicationDbContextModelSnapshot.cs | 487 +++++ .../ServiceCollectionExtensions.cs | 30 +- .../Services/Analytics/AnalyticsSink.cs | 40 + .../Services/Audit/AuditLogger.cs | 64 + .../Configuration/PlatformConfigService.cs | 113 ++ .../Holidays/HolidayCalendarService.cs | 119 ++ .../InAppNotificationDispatcher.cs | 33 + .../NotificationRetentionHostedService.cs | 50 + .../Notifications/NotificationService.cs | 88 + .../SupportAlerts/SupportAlertService.cs | 99 + .../Marketplace/AnalyticsSinkTests.cs | 24 + .../HolidayCalendarServiceTests.cs | 56 + .../Marketplace/NotificationServiceTests.cs | 79 + .../Marketplace/OpsTestHost.cs | 69 + .../Marketplace/PlatformConfigServiceTests.cs | 52 + .../Marketplace/SupportAlertServiceTests.cs | 36 + 99 files changed, 6172 insertions(+), 52 deletions(-) create mode 100644 dev/contracts/domains/config-reference.md create mode 100644 dev/shared-working-context/backend/handoff/after-backend-phase-1.md create mode 100644 dev/shared-working-context/reports/backend-phase-1-report.md create mode 100644 server/src/API/Baya.Web.Api/Controllers/V1/AuditController.cs create mode 100644 server/src/API/Baya.Web.Api/Controllers/V1/HolidaysController.cs create mode 100644 server/src/API/Baya.Web.Api/Controllers/V1/NotificationsController.cs create mode 100644 server/src/API/Baya.Web.Api/Controllers/V1/PlatformConfigController.cs create mode 100644 server/src/API/Baya.Web.Api/Controllers/V1/SupportAlertsController.cs create mode 100644 server/src/Core/Baya.Application/Common/Pagination.cs create mode 100644 server/src/Core/Baya.Application/Contracts/Analytics/IAnalyticsSink.cs create mode 100644 server/src/Core/Baya.Application/Contracts/Audit/IAuditLogger.cs create mode 100644 server/src/Core/Baya.Application/Contracts/Configuration/IPlatformConfig.cs create mode 100644 server/src/Core/Baya.Application/Contracts/Holidays/IHolidayCalendar.cs create mode 100644 server/src/Core/Baya.Application/Contracts/Notifications/INotificationService.cs create mode 100644 server/src/Core/Baya.Application/Contracts/SupportAlerts/ISupportAlertService.cs create mode 100644 server/src/Core/Baya.Application/Features/Audit/Queries/GetAuditTrail/GetAuditTrailQuery.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Audit/Queries/GetAuditTrail/GetAuditTrailQuery.cs create mode 100644 server/src/Core/Baya.Application/Features/Configuration/Commands/UpdatePlatformConfig/UpdatePlatformConfigCommand.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Configuration/Commands/UpdatePlatformConfig/UpdatePlatformConfigCommand.Validator.cs create mode 100644 server/src/Core/Baya.Application/Features/Configuration/Commands/UpdatePlatformConfig/UpdatePlatformConfigCommand.cs create mode 100644 server/src/Core/Baya.Application/Features/Configuration/Queries/GetConfigChangeHistory/GetConfigChangeHistoryQuery.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Configuration/Queries/GetConfigChangeHistory/GetConfigChangeHistoryQuery.cs create mode 100644 server/src/Core/Baya.Application/Features/Configuration/Queries/ListPlatformConfigs/ListPlatformConfigsQuery.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Configuration/Queries/ListPlatformConfigs/ListPlatformConfigsQuery.cs create mode 100644 server/src/Core/Baya.Application/Features/Holidays/Commands/DeleteHoliday/DeleteHolidayCommand.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Holidays/Commands/DeleteHoliday/DeleteHolidayCommand.cs create mode 100644 server/src/Core/Baya.Application/Features/Holidays/Commands/UpsertHoliday/UpsertHolidayCommand.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Holidays/Commands/UpsertHoliday/UpsertHolidayCommand.Validator.cs create mode 100644 server/src/Core/Baya.Application/Features/Holidays/Commands/UpsertHoliday/UpsertHolidayCommand.cs create mode 100644 server/src/Core/Baya.Application/Features/Holidays/Queries/ListHolidays/ListHolidaysQuery.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Holidays/Queries/ListHolidays/ListHolidaysQuery.cs create mode 100644 server/src/Core/Baya.Application/Features/Notifications/Commands/MarkAllRead/MarkAllReadCommand.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Notifications/Commands/MarkAllRead/MarkAllReadCommand.cs create mode 100644 server/src/Core/Baya.Application/Features/Notifications/Commands/MarkNotificationRead/MarkNotificationReadCommand.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Notifications/Commands/MarkNotificationRead/MarkNotificationReadCommand.Validator.cs create mode 100644 server/src/Core/Baya.Application/Features/Notifications/Commands/MarkNotificationRead/MarkNotificationReadCommand.cs create mode 100644 server/src/Core/Baya.Application/Features/Notifications/Queries/GetUnreadCount/GetUnreadCountQuery.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Notifications/Queries/GetUnreadCount/GetUnreadCountQuery.Result.cs create mode 100644 server/src/Core/Baya.Application/Features/Notifications/Queries/GetUnreadCount/GetUnreadCountQuery.cs create mode 100644 server/src/Core/Baya.Application/Features/Notifications/Queries/ListMyNotifications/ListMyNotificationsQuery.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Notifications/Queries/ListMyNotifications/ListMyNotificationsQuery.cs create mode 100644 server/src/Core/Baya.Application/Features/SupportAlerts/Commands/AssignSupportAlert/AssignSupportAlertCommand.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/SupportAlerts/Commands/AssignSupportAlert/AssignSupportAlertCommand.Validator.cs create mode 100644 server/src/Core/Baya.Application/Features/SupportAlerts/Commands/AssignSupportAlert/AssignSupportAlertCommand.cs create mode 100644 server/src/Core/Baya.Application/Features/SupportAlerts/Commands/ResolveSupportAlert/ResolveSupportAlertCommand.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/SupportAlerts/Commands/ResolveSupportAlert/ResolveSupportAlertCommand.Validator.cs create mode 100644 server/src/Core/Baya.Application/Features/SupportAlerts/Commands/ResolveSupportAlert/ResolveSupportAlertCommand.cs create mode 100644 server/src/Core/Baya.Application/Features/SupportAlerts/Queries/ListSupportAlerts/ListSupportAlertsQuery.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/SupportAlerts/Queries/ListSupportAlerts/ListSupportAlertsQuery.cs create mode 100644 server/src/Core/Baya.Application/Models/Audit/AuditLogDto.cs create mode 100644 server/src/Core/Baya.Application/Models/Common/PagedResult.cs create mode 100644 server/src/Core/Baya.Application/Models/Configuration/PlatformConfigDto.cs create mode 100644 server/src/Core/Baya.Application/Models/Holidays/HolidayDto.cs create mode 100644 server/src/Core/Baya.Application/Models/Notifications/NotificationDto.cs create mode 100644 server/src/Core/Baya.Application/Models/SupportAlerts/SupportAlertDto.cs create mode 100644 server/src/Core/Baya.Domain/Common/IAuditable.cs create mode 100644 server/src/Core/Baya.Domain/Entities/Analytics/SystemEvent.cs create mode 100644 server/src/Core/Baya.Domain/Entities/Audit/AuditLog.cs create mode 100644 server/src/Core/Baya.Domain/Entities/Configuration/PlatformConfig.cs create mode 100644 server/src/Core/Baya.Domain/Entities/Holidays/IranianHoliday.cs create mode 100644 server/src/Core/Baya.Domain/Entities/Notifications/Notification.cs create mode 100644 server/src/Core/Baya.Domain/Entities/SupportAlerts/SupportAlert.cs delete mode 100644 server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/LogNotificationDispatcher.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/AnalyticsConfig/SystemEventConfig.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/AuditConfig/AuditLogConfig.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/ConfigurationConfig/PlatformConfigConfig.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/HolidaysConfig/IranianHolidayConfig.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/NotificationsConfig/NotificationConfig.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/SeedConstants.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/SupportAlertsConfig/SupportAlertConfig.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260701193257_InitialMarketplaceBaseline.Designer.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260701193257_InitialMarketplaceBaseline.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Analytics/AnalyticsSink.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Audit/AuditLogger.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Configuration/PlatformConfigService.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Holidays/HolidayCalendarService.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Notifications/InAppNotificationDispatcher.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Notifications/NotificationRetentionHostedService.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Notifications/NotificationService.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/SupportAlerts/SupportAlertService.cs create mode 100644 server/src/Tests/Baya.Test.Foundation/Marketplace/AnalyticsSinkTests.cs create mode 100644 server/src/Tests/Baya.Test.Foundation/Marketplace/HolidayCalendarServiceTests.cs create mode 100644 server/src/Tests/Baya.Test.Foundation/Marketplace/NotificationServiceTests.cs create mode 100644 server/src/Tests/Baya.Test.Foundation/Marketplace/OpsTestHost.cs create mode 100644 server/src/Tests/Baya.Test.Foundation/Marketplace/PlatformConfigServiceTests.cs create mode 100644 server/src/Tests/Baya.Test.Foundation/Marketplace/SupportAlertServiceTests.cs diff --git a/dev/contracts/domains/config-reference.md b/dev/contracts/domains/config-reference.md new file mode 100644 index 0000000..69e2b27 --- /dev/null +++ b/dev/contracts/domains/config-reference.md @@ -0,0 +1,113 @@ +# Contract — Config, Reference & Platform Signals (backend phase b1) + +> Admin config/holiday/audit/support-alert endpoints + the current-user notification endpoints. Assumes +> [`../conventions/api-conventions.md`](../conventions/api-conventions.md) + +> [`../conventions/money-and-types.md`](../conventions/money-and-types.md). Machine schema (authoritative +> for exact field/param casing): [`../openapi/swagger.v1.json`](../openapi/README.md). + +**Status:** live as of backend-phase-1 · **Frontend consumer:** frontend-phase-f14 (notification center) / frontend-phase-f15 (admin config/holidays/audit/alerts) + +All responses are the standard `OperationResult`→`ApiResult` envelope (camelCase body, snake_case URLs). +Lists carry `{ items, total, page, pageSize }`. Pagination inputs are `page` (1-based) + `page_size` +(default 50, max 100) — bound from the query string; derive exact casing from `swagger.v1.json`. + +## Enums used +- **config `data_type`**: `decimal` | `int` | `bool` | `string` | `json` — how to parse a config `value`. +- **holiday `type`**: `official` | `religious` | `national`. +- **support-alert `type`**: `low_rating` | `evv_no_show` | `evv_location_mismatch` | `verification_expired` | `payment_anomaly` | `fraud_signal`. +- **support-alert `severity`**: `low` | `medium` | `high`. +- **support-alert `status`**: `open` | `assigned` | `resolved` (forward-only). +- **notification `type`**: open string code the front-end renders/deep-links on (e.g. `booking_confirmed`); its shape is the `data_json` contract (below), versioned per type. + +--- + +## Admin — Platform config (`platform_config` controller, `[Authorize(DynamicPermission)]`) + +### `GET api/v1/platform_config/get_platform_configs` +- **Purpose:** list config rows. **Auth:** admin (DynamicPermission). **Rate-limited:** no. +- **Query:** `page`, `page_size`. +- **200 `data`:** `PagedResult` — `{ items:[{ key, value, dataType, description }], total, page, pageSize }`. + +### `POST api/v1/platform_config/update_platform_config` +- **Purpose:** update one existing config row; writes an `audit_logs` entry in the same transaction and evicts the cache. **Auth:** admin. +- **Body:** `{ "key": "platform_fee_rate", "value": "0.18" }`. +- **200 `data`:** `true` (empty-body success). +- **Failures:** `400` validation (empty key); `404` key does not exist. **Notes:** value is the raw string parsed per the row's `data_type`; changing a rate never retroactively re-prices already-computed rows. + +### `GET api/v1/platform_config/get_config_change_history` +- **Purpose:** the audited change history for one key (from the append-only trail). **Auth:** admin. +- **Query:** `key` (required), `page`, `page_size`. +- **200 `data`:** `PagedResult` — `{ items:[{ id, action, changedFieldsJson, actorUserId, occurredAt }], … }`, newest first. + +--- + +## Admin — Holidays (`holidays` controller, `[Authorize(DynamicPermission)]`) + +### `GET api/v1/holidays/get_holidays` +- **Query:** `from` (date, optional), `to` (date, optional), `page`, `page_size`. +- **200 `data`:** `PagedResult` — `{ items:[{ id, holidayDate, nameFa, type, isBankClosed }], … }`, by date. + +### `POST api/v1/holidays/upsert_holiday` +- **Body:** `{ "holidayDate": "2026-03-21", "nameFa": "نوروز", "type": "national", "isBankClosed": true }`. +- **200 `data`:** `true`. **Failures:** `400` (bad `type`, empty `nameFa`, default date). **Notes:** upsert keyed on `holidayDate`. + +### `POST api/v1/holidays/delete_holiday` +- **Body:** `{ "holidayDate": "2026-03-21" }`. **200:** `true`; **404** if no holiday on that date. + +--- + +## Admin — Audit (`audit` controller, `[Authorize(DynamicPermission)]`) + +### `GET api/v1/audit/get_audit_trail` +- **Purpose:** the immutable trail for one entity. **Auth:** admin. +- **Query:** `entity_type` (e.g. `PlatformConfig`), `entity_id` (string), `page`, `page_size`. +- **200 `data`:** `PagedResult` — `{ items:[{ id, entityType, entityId, action, changedFieldsJson, actorUserId, occurredAt }], … }`, newest first. **Notes:** read-only; there is no write/update/delete endpoint for audit rows. + +--- + +## Admin — Support alerts (`support_alerts` controller, `[Authorize(DynamicPermission)]`, never user-facing) + +### `GET api/v1/support_alerts/get_support_alerts` +- **Query:** `type?`, `status?`, `owner_user_id?`, `page`, `page_size`. +- **200 `data`:** `PagedResult` — `{ items:[{ id, type, severity, status, entityType, entityId, bookingId, reviewId, ownerUserId, resolutionNote, resolvedAt, createdAt }], … }`. + +### `POST api/v1/support_alerts/assign_support_alert` +- **Body:** `{ "alertId": 42, "ownerUserId": 7 }`. **200:** `true` (open → assigned); **404** if missing or already resolved. + +### `POST api/v1/support_alerts/resolve_support_alert` +- **Body:** `{ "alertId": 42, "note": "handled" }`. **200:** `true` (→ resolved); **404** if missing or already resolved. + +--- + +## Current user — Notifications (`notifications` controller, `[Authorize]`, tenant-scoped) + +Every endpoint is scoped to the signed-in caller (`ICurrentUser`) — never a body-supplied user id. + +### `GET api/v1/notifications/get_notifications` +- **Query:** `page`, `page_size`. +- **200 `data`:** `PagedResult` — `{ items:[{ id, type, title, body, dataJson, isRead, readAt, createdAt }], … }`, **unread-first** then newest-first. + +### `GET api/v1/notifications/get_unread_count` +- **200 `data`:** `{ count }` — cheap index-backed count for the polling bell. + +### `POST api/v1/notifications/mark_notification_read` +- **Body:** `{ "notificationId": 100 }`. **200:** `true`; **404** if it isn't the caller's or doesn't exist. + +### `POST api/v1/notifications/mark_all_read` +- **No body. 200:** `true`. + +> **Not exposed via REST** (internal contracts other backend domains call): `CreateNotification` (via +> `INotificationDispatcher.DispatchAsync`), `RaiseSupportAlert` (`ISupportAlertService.RaiseAsync`), +> `EmitSystemEvent` (`IAnalyticsSink.EmitAsync`), `WriteAuditLog` (`IAuditLogger.WriteAsync`). The +> notification retention purge runs on a background hosted service, not an endpoint. + +## Shared shapes +- **`PlatformConfigDto`**: `key` (string), `value` (string, raw — parse per `dataType`), `dataType` (enum), `description` (string, nullable). +- **`ConfigChangeDto`**: `id` (long), `action` (`created`/`updated`/`deleted`), `changedFieldsJson` (string, nullable — `{ "Field": { "old": …, "new": … } }`; encrypted/PII fields redacted as `""`), `actorUserId` (int, nullable), `occurredAt` (UTC ISO-8601). +- **`HolidayDto`**: `id` (long), `holidayDate` (date), `nameFa` (string), `type` (enum), `isBankClosed` (bool). +- **`AuditLogDto`**: `id` (long), `entityType` (string), `entityId` (string), `action`, `changedFieldsJson` (nullable), `actorUserId` (nullable), `occurredAt`. +- **`NotificationDto`**: `id` (long), `type` (string code), `title` (string), `body` (string, nullable), `dataJson` (string, nullable — a **typed, versioned deep-link payload**; shape depends on `type`, e.g. `{"booking_id": 1}`), `isRead` (bool), `readAt` (UTC, nullable), `createdAt` (UTC). +- **`SupportAlertDto`**: `id`, `type`, `severity`, `status`, `entityType` (string), `entityId` (string), `bookingId` (long, nullable), `reviewId` (long, nullable), `ownerUserId` (int, nullable), `resolutionNote` (string, nullable), `resolvedAt` (UTC, nullable), `createdAt` (UTC). + +## Changelog +- b1 — initial contract (config, holidays, audit, support alerts, notifications). diff --git a/dev/contracts/openapi/swagger.v1.json b/dev/contracts/openapi/swagger.v1.json index 7a16c96..b49cc11 100644 --- a/dev/contracts/openapi/swagger.v1.json +++ b/dev/contracts/openapi/swagger.v1.json @@ -7,10 +7,668 @@ }, "servers": [ { - "url": "http://127.0.0.1:5082" + "url": "https://localhost:5002" } ], "paths": { + "/api/v1/audit/get_audit_trail": { + "get": { + "tags": [ + "Audit" + ], + "operationId": "Audit_GetAuditTrail", + "parameters": [ + { + "name": "EntityType", + "in": "query", + "schema": { + "type": "string", + "nullable": true + }, + "x-position": 1 + }, + { + "name": "EntityId", + "in": "query", + "schema": { + "type": "string", + "nullable": true + }, + "x-position": 2 + }, + { + "name": "Page", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + }, + "x-position": 3 + }, + { + "name": "PageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + }, + "x-position": 4 + } + ], + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfPagedResultOfAuditLogDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/holidays/get_holidays": { + "get": { + "tags": [ + "Holidays" + ], + "summary": "Returns all Holidays", + "operationId": "Holidays_GetHolidays", + "parameters": [ + { + "name": "From", + "in": "query", + "schema": { + "type": "string", + "format": "date", + "nullable": true + }, + "x-position": 1 + }, + { + "name": "To", + "in": "query", + "schema": { + "type": "string", + "format": "date", + "nullable": true + }, + "x-position": 2 + }, + { + "name": "Page", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + }, + "x-position": 3 + }, + { + "name": "PageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + }, + "x-position": 4 + } + ], + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfPagedResultOfHolidayDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/holidays/upsert_holiday": { + "post": { + "tags": [ + "Holidays" + ], + "operationId": "Holidays_UpsertHoliday", + "requestBody": { + "x-name": "command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpsertHolidayCommand" + } + } + }, + "required": true, + "x-position": 1 + }, + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/holidays/delete_holiday": { + "post": { + "tags": [ + "Holidays" + ], + "summary": "Deletes a Holiday by unique id", + "operationId": "Holidays_DeleteHoliday", + "requestBody": { + "x-name": "command", + "description": "A unique id for the Holiday", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteHolidayCommand" + } + } + }, + "required": true, + "x-position": 1 + }, + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/notifications/get_notifications": { + "get": { + "tags": [ + "Notifications" + ], + "summary": "Returns all Notifications", + "operationId": "Notifications_GetNotifications", + "parameters": [ + { + "name": "Page", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + }, + "x-position": 1 + }, + { + "name": "PageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + }, + "x-position": 2 + } + ], + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfPagedResultOfNotificationDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/notifications/get_unread_count": { + "get": { + "tags": [ + "Notifications" + ], + "operationId": "Notifications_GetUnreadCount", + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfUnreadCountResult" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/notifications/mark_notification_read": { + "post": { + "tags": [ + "Notifications" + ], + "operationId": "Notifications_MarkNotificationRead", + "requestBody": { + "x-name": "command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MarkNotificationReadCommand" + } + } + }, + "required": true, + "x-position": 1 + }, + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/notifications/mark_all_read": { + "post": { + "tags": [ + "Notifications" + ], + "operationId": "Notifications_MarkAllRead", + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, "/api/v1/ping/get_status": { "get": { "tags": [ @@ -130,6 +788,533 @@ } } } + }, + "/api/v1/platform_config/get_platform_configs": { + "get": { + "tags": [ + "PlatformConfig" + ], + "summary": "Returns all PlatformConfigs", + "operationId": "PlatformConfig_GetPlatformConfigs", + "parameters": [ + { + "name": "Page", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + }, + "x-position": 1 + }, + { + "name": "PageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + }, + "x-position": 2 + } + ], + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfPagedResultOfPlatformConfigDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/platform_config/update_platform_config": { + "post": { + "tags": [ + "PlatformConfig" + ], + "summary": "Updates a PlatformConfig by unique id", + "operationId": "PlatformConfig_UpdatePlatformConfig", + "requestBody": { + "x-name": "command", + "description": "A PlatformConfig representation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdatePlatformConfigCommand" + } + } + }, + "required": true, + "x-position": 1 + }, + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/platform_config/get_config_change_history": { + "get": { + "tags": [ + "PlatformConfig" + ], + "operationId": "PlatformConfig_GetConfigChangeHistory", + "parameters": [ + { + "name": "Key", + "in": "query", + "schema": { + "type": "string", + "nullable": true + }, + "x-position": 1 + }, + { + "name": "Page", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + }, + "x-position": 2 + }, + { + "name": "PageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + }, + "x-position": 3 + } + ], + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfPagedResultOfConfigChangeDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/support_alerts/get_support_alerts": { + "get": { + "tags": [ + "SupportAlerts" + ], + "summary": "Returns all SupportAlerts", + "operationId": "SupportAlerts_GetSupportAlerts", + "parameters": [ + { + "name": "Type", + "in": "query", + "schema": { + "type": "string", + "nullable": true + }, + "x-position": 1 + }, + { + "name": "Status", + "in": "query", + "schema": { + "type": "string", + "nullable": true + }, + "x-position": 2 + }, + { + "name": "OwnerUserId", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "x-position": 3 + }, + { + "name": "Page", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + }, + "x-position": 4 + }, + { + "name": "PageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + }, + "x-position": 5 + } + ], + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfPagedResultOfSupportAlertDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/support_alerts/assign_support_alert": { + "post": { + "tags": [ + "SupportAlerts" + ], + "operationId": "SupportAlerts_AssignSupportAlert", + "requestBody": { + "x-name": "command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssignSupportAlertCommand" + } + } + }, + "required": true, + "x-position": 1 + }, + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/support_alerts/resolve_support_alert": { + "post": { + "tags": [ + "SupportAlerts" + ], + "operationId": "SupportAlerts_ResolveSupportAlert", + "requestBody": { + "x-name": "command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResolveSupportAlertCommand" + } + } + }, + "required": true, + "x-position": 1 + }, + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } } }, "components": { @@ -203,6 +1388,309 @@ 500 ] }, + "ApiResultOfPagedResultOfAuditLogDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/PagedResultOfAuditLogDto" + } + ] + } + } + } + ] + }, + "PagedResultOfAuditLogDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "items": { + "type": "array", + "nullable": true, + "items": { + "$ref": "#/components/schemas/AuditLogDto" + } + }, + "total": { + "type": "integer", + "format": "int32" + }, + "page": { + "type": "integer", + "format": "int32" + }, + "pageSize": { + "type": "integer", + "format": "int32" + } + } + }, + "AuditLogDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "entityType": { + "type": "string" + }, + "entityId": { + "type": "string" + }, + "action": { + "type": "string" + }, + "changedFieldsJson": { + "type": "string", + "nullable": true + }, + "actorUserId": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "occurredAt": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResultOfPagedResultOfHolidayDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/PagedResultOfHolidayDto" + } + ] + } + } + } + ] + }, + "PagedResultOfHolidayDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "items": { + "type": "array", + "nullable": true, + "items": { + "$ref": "#/components/schemas/HolidayDto" + } + }, + "total": { + "type": "integer", + "format": "int32" + }, + "page": { + "type": "integer", + "format": "int32" + }, + "pageSize": { + "type": "integer", + "format": "int32" + } + } + }, + "HolidayDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "holidayDate": { + "type": "string", + "format": "date" + }, + "nameFa": { + "type": "string", + "nullable": true + }, + "type": { + "type": "string", + "nullable": true + }, + "isBankClosed": { + "type": "boolean" + } + } + }, + "UpsertHolidayCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "holidayDate": { + "type": "string", + "format": "date" + }, + "nameFa": { + "type": "string", + "nullable": true + }, + "type": { + "type": "string", + "nullable": true + }, + "isBankClosed": { + "type": "boolean" + } + } + }, + "DeleteHolidayCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "holidayDate": { + "type": "string", + "format": "date" + } + } + }, + "ApiResultOfPagedResultOfNotificationDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/PagedResultOfNotificationDto" + } + ] + } + } + } + ] + }, + "PagedResultOfNotificationDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "items": { + "type": "array", + "nullable": true, + "items": { + "$ref": "#/components/schemas/NotificationDto" + } + }, + "total": { + "type": "integer", + "format": "int32" + }, + "page": { + "type": "integer", + "format": "int32" + }, + "pageSize": { + "type": "integer", + "format": "int32" + } + } + }, + "NotificationDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "type": { + "type": "string" + }, + "title": { + "type": "string" + }, + "body": { + "type": "string", + "nullable": true + }, + "dataJson": { + "type": "string", + "nullable": true + }, + "isRead": { + "type": "boolean" + }, + "readAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResultOfUnreadCountResult": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/UnreadCountResult" + } + ] + } + } + } + ] + }, + "UnreadCountResult": { + "type": "object", + "additionalProperties": false, + "properties": { + "count": { + "type": "integer", + "format": "int32" + } + } + }, + "MarkNotificationReadCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "notificationId": { + "type": "integer", + "format": "int64" + } + } + }, "ApiResultOfPingQueryResult": { "allOf": [ { @@ -241,6 +1729,284 @@ "format": "date-time" } } + }, + "ApiResultOfPagedResultOfPlatformConfigDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/PagedResultOfPlatformConfigDto" + } + ] + } + } + } + ] + }, + "PagedResultOfPlatformConfigDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "items": { + "type": "array", + "nullable": true, + "items": { + "$ref": "#/components/schemas/PlatformConfigDto" + } + }, + "total": { + "type": "integer", + "format": "int32" + }, + "page": { + "type": "integer", + "format": "int32" + }, + "pageSize": { + "type": "integer", + "format": "int32" + } + } + }, + "PlatformConfigDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + }, + "dataType": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + } + } + }, + "UpdatePlatformConfigCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "key": { + "type": "string", + "nullable": true + }, + "value": { + "type": "string", + "nullable": true + } + } + }, + "ApiResultOfPagedResultOfConfigChangeDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/PagedResultOfConfigChangeDto" + } + ] + } + } + } + ] + }, + "PagedResultOfConfigChangeDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "items": { + "type": "array", + "nullable": true, + "items": { + "$ref": "#/components/schemas/ConfigChangeDto" + } + }, + "total": { + "type": "integer", + "format": "int32" + }, + "page": { + "type": "integer", + "format": "int32" + }, + "pageSize": { + "type": "integer", + "format": "int32" + } + } + }, + "ConfigChangeDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "action": { + "type": "string" + }, + "changedFieldsJson": { + "type": "string", + "nullable": true + }, + "actorUserId": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "occurredAt": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResultOfPagedResultOfSupportAlertDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/PagedResultOfSupportAlertDto" + } + ] + } + } + } + ] + }, + "PagedResultOfSupportAlertDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "items": { + "type": "array", + "nullable": true, + "items": { + "$ref": "#/components/schemas/SupportAlertDto" + } + }, + "total": { + "type": "integer", + "format": "int32" + }, + "page": { + "type": "integer", + "format": "int32" + }, + "pageSize": { + "type": "integer", + "format": "int32" + } + } + }, + "SupportAlertDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "type": { + "type": "string" + }, + "severity": { + "type": "string" + }, + "status": { + "type": "string" + }, + "entityType": { + "type": "string" + }, + "entityId": { + "type": "string" + }, + "bookingId": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "reviewId": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "ownerUserId": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "resolutionNote": { + "type": "string", + "nullable": true + }, + "resolvedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + } + }, + "AssignSupportAlertCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "alertId": { + "type": "integer", + "format": "int64" + }, + "ownerUserId": { + "type": "integer", + "format": "int32" + } + } + }, + "ResolveSupportAlertCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "alertId": { + "type": "integer", + "format": "int64" + }, + "note": { + "type": "string", + "nullable": true + } + } } }, "securitySchemes": { diff --git a/dev/shared-working-context/backend/STATUS.md b/dev/shared-working-context/backend/STATUS.md index e4e993f..cc786cc 100644 --- a/dev/shared-working-context/backend/STATUS.md +++ b/dev/shared-working-context/backend/STATUS.md @@ -12,6 +12,24 @@ One block per completed backend phase. Newest at the top. Backend lane writes he - **Notes for frontend:** --> +## backend-phase-1 — Config, reference & platform signals — 2026-07-02 +- **Shipped:** first marketplace migration baseline (`InitialMarketplaceBaseline`, new **`ops`** schema) + with 6 tables (`PlatformConfigs`, `AuditLogs`, `SystemEvents`, `IranianHolidays`, `Notifications`, + `SupportAlerts`) + seed (12 config keys, 7 holidays); platform-signal facades `IPlatformConfig` / + `IHolidayCalendar` / `IAnalyticsSink` / `IAuditLogger` / `INotificationService` / `ISupportAlertService` + (`Persistence/Services/`); `AuditFieldInterceptor` extended to write append-only `audit_logs` rows for + `IAuditable` entities; real in-app `INotificationDispatcher` (b0 stub removed); notification-retention + hosted service; 5 controllers (admin config/holidays/audit/support-alerts + current-user notifications). +- **Contracts:** `dev/contracts/domains/config-reference.md` + openapi snapshot refreshed (yes — 16 paths). +- **Mocked:** `IHolidayCalendar`, `IAnalyticsSink`, retention `IJobScheduler` → 🟡; `INotificationDispatcher` + flipped to in-app-real 🟡 (SMS/push deferred). See reports/mocks-registry.md. +- **Gate:** build clean (0 new code warnings) / tests green (22 pass: 4 identity + 18 foundation). Migration + applied to the dev DB; API boots with all 16 paths in Swagger; retention job runs on startup. +- **Handoff:** backend/handoff/after-backend-phase-1.md +- **Notes for frontend:** f14 = `notifications/*` (envelope unchanged; unread-first lists; `data_json` is a + typed deep-link payload). f15 = admin `platform_config/*`, `holidays/*`, `audit/get_audit_trail`, + `support_alerts/*` (DynamicPermission). Pagination `page`/`page_size` (default 50, max 100). + ## backend-phase-0 — Foundation, cross-cutting seams & starter cleanup — 2026-06-28 - **Shipped:** removed the `Order` demo (entity/feature/repo/config/gRPC) + 3 old migrations; fresh `InitialBaseline` migration; REST surface (`PingController` + `System/Ping` CQRS); `ICurrentUser` + diff --git a/dev/shared-working-context/backend/handoff/after-backend-phase-1.md b/dev/shared-working-context/backend/handoff/after-backend-phase-1.md new file mode 100644 index 0000000..ad3f690 --- /dev/null +++ b/dev/shared-working-context/backend/handoff/after-backend-phase-1.md @@ -0,0 +1,58 @@ +# After backend-phase-1 — what b2…b15 and the frontend can rely on + +The **platform backbone** is live. Six cross-cutting tables exist in a new **`ops` schema** on top of +b0's `InitialBaseline`, seeded with real config + a sample holiday calendar. The mechanisms every later +phase needs — typed config, holiday math, audit trail, analytics, in-app notifications, support alerts — +are built **once, here**, behind Application contracts. **Reuse them; do not re-create the tables.** + +## Internal contracts b2…b15 must depend on (never reinvent) +All are DI-registered (Scoped) and implemented in `Baya.Infrastructure.Persistence/Services/`: + +- **`IPlatformConfig`** — `GetConfig(key)` (cached, parsed by `data_type`), `SetConfig(key,value)` + (audited, evicts cache), `ListAsync`, `GetConfigChangeHistory(key)`. **Read money-critical constants + here at compute time — never hardcode.** Seeded keys: `platform_fee_rate`, `vat_rate` (0.10), + `dispute_window_hours` (72), `booking_payment_deadline_minutes` (30), `nurse_response_deadline_hours`, + `nurse_payout_interval_days`, `evv_location_tolerance_meters`, `min_rating_for_support_alert`, + `bnpl_merchant_of_record`, `bnpl_provider_commission_rate`, `bnpl_settlement_timing`, + `cancellation_tiers`. **Snapshot the rate you use onto the priced row** — a later config change must not + re-price it. +- **`IHolidayCalendar`** — `IsHoliday`, `IsBankClosed`, `NextBusinessDay` (skips bank-closed days + the + Iranian banking weekend = Friday), plus admin CRUD. **b13 payout scheduling calls `NextBusinessDay`.** +- **`IAuditLogger`** — `WriteAsync(entityType, entityId, action, changedFields?)` for state changes with + no row diff, plus `GetTrailAsync`. Row-level diffs on **`IAuditable`** entities are written + automatically by the extended `AuditFieldInterceptor` (mark an entity `IAuditable`; annotate encrypted + props `[AuditRedacted]`). `platform_configs` is the first `IAuditable` entity. **`audit_logs` is + append-only — never update/delete it.** +- **`IAnalyticsSink`** — `EmitAsync(name, props)`; fire-and-forget (`system_events`). **Never** route + compliance facts here — those go to `IAuditLogger`. +- **`INotificationDispatcher`** — `DispatchAsync(Notification(userId, type, title, body?, dataJson?))` + now writes a **real in-app `notifications` row** (b0 stub gone). This is how booking/payment/review + domains mint a user notification. `data_json` is a **typed, versioned deep-link payload** — version it. +- **`INotificationService`** — per-user reads/commands (list unread-first, unread count, mark read/all, + purge). Always tenant-scoped to `ICurrentUser`. +- **`ISupportAlertService`** — `RaiseAsync(type, entityType, entityId, severity, bookingId?, reviewId?)` + for review/EVV/verification/payment flows to call, plus assign/resolve/list. **Support alerts are + admin-only — never surface them on a user-facing route or in a user `notification`.** + +## Live endpoints (contract: `dev/contracts/domains/config-reference.md`) +Admin (`DynamicPermission`): `platform_config/*`, `holidays/*`, `audit/get_audit_trail`, +`support_alerts/*`. Current-user (`Authorize`): `notifications/*` (f14 notification center; +f15 admin config/holidays/audit/alerts). Envelope unchanged (camelCase body, snake_case URLs); lists +paginated `page`/`page_size`. + +## Migration / schema +New migration **`20260701193257_InitialMarketplaceBaseline`** — the marketplace baseline every later +phase adds onto. Applied cleanly to the dev DB (tables + indexes + seed present). Tables live in **`ops`** +(keep new marketplace tables off `usr`). + +## Follow-ups later phases must close +- **FK constraints for `support_alerts.booking_id` / `review_id`.** The columns exist now (no FK yet). + **b9** (bookings) adds `FK SupportAlerts.BookingId → Bookings`; **b14** (reviews) adds + `FK SupportAlerts.ReviewId → Reviews`. Do it in the migration that creates those tables. +- **Make-it-real seams (🟡):** `IHolidayCalendar` (real feed/sync), `IAnalyticsSink` (warehouse), + `IJobScheduler` retention (Hangfire/Quartz), `INotificationDispatcher` SMS/push channels — see + `reports/mocks-registry.md`. + +## Caveat (unchanged from b0) +Non-Development Serilog targets the `logDb` connection; Development boots against the configured +`SqlServer`. A reachable SQL Server is required to run the API (it applies migrations + seeds on boot). diff --git a/dev/shared-working-context/reports/backend-phase-1-report.md b/dev/shared-working-context/reports/backend-phase-1-report.md new file mode 100644 index 0000000..e4ff50f --- /dev/null +++ b/dev/shared-working-context/reports/backend-phase-1-report.md @@ -0,0 +1,78 @@ +# Backend Phase 1 — Config, reference & platform signals — Report (2026-07-02) + +## What was built +- **First marketplace migration baseline** `20260701193257_InitialMarketplaceBaseline` (on top of b0's + `InitialBaseline`) creating a new **`ops` schema** with six tables: + - `PlatformConfigs` (unique `Key`, audit fields, `IAuditable`), `AuditLogs` (append-only; indexes on + `(EntityType,EntityId)` + `OccurredAt`; nullable FK → `usr.Users`), `SystemEvents` (append-only; + indexes on `Name`/`OccurredAt`/`UserId`), `IranianHolidays` (unique `HolidayDate`), `Notifications` + (index `(UserId,IsRead,CreatedAt)`; FK → Users), `SupportAlerts` (indexes on `Status`/`Type`; + nullable `BookingId`/`ReviewId` columns **without FK yet**; FK → Users on `OwnerUserId`). + - Seeded via `HasData`: 12 `platform_configs` keys and 7 sample holidays (Nowruz block + Revolution + Day + Nature Day + a religious day). +- **Domain:** entities under `Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts`; the + `IAuditable` marker + `[AuditRedacted]` attribute (`Domain/Common`); string-code constant holders + (`ConfigDataType`, `AuditAction`, `HolidayType`, `SupportAlertType/Severity/Status`). +- **Application:** facade contracts `IPlatformConfig`/`IHolidayCalendar`/`IAnalyticsSink`/`IAuditLogger`/ + `INotificationService`/`ISupportAlertService`; DTOs + `PagedResult`; evolved the + `INotificationDispatcher.Notification` record to carry `Type` + `DataJson`; a `Pagination.Normalize` + helper; **14 CQRS commands/queries** (+ validators) wiring the endpoints to the facades. +- **Persistence (`Services/`):** DB-backed implementations of every facade; the real + `InAppNotificationDispatcher` (supersedes and **removes** the b0 `LogNotificationDispatcher`); + `NotificationRetentionHostedService` (interval `BackgroundService`). The `AuditFieldInterceptor` was + **extended** (not duplicated) to append an `audit_logs` row with an old/new diff for every `IAuditable` + change, in the same transaction, redacting `[AuditRedacted]` properties. Registered all facades + + hosted service in `AddPersistenceServices`; removed the `INotificationDispatcher` registration from + `AddCrossCuttingSeams`. +- **API:** 5 sealed `BaseController` controllers — admin `PlatformConfigController`/`HolidaysController`/ + `AuditController`/`SupportAlertsController` (`[Authorize(DynamicPermission)]`) and current-user + `NotificationsController` (`[Authorize]`). + +## What is now testable (and exactly how) +`dotnet test Baya.sln` → **22 pass** (4 identity + 18 new foundation), build clean (0 new code warnings; +only pre-existing NU1903/NU1510 package advisories remain). Foundation tests run against a real +`ApplicationDbContext` over in-memory SQLite (`OpsTestHost`, seed applied via `EnsureCreated`): +1. **Typed config** — `GetConfig("vat_rate")==0.10`, `("dispute_window_hours")==72`, + `("booking_payment_deadline_minutes")==30`; cache hit on re-read. +2. **Config change is audited** — `SetConfig("platform_fee_rate","0.18")` → new value read back (cache + evicted) + one `audit_logs` row (`updated`, actor, old `0.15`/new `0.18`); missing key → `false`. +3. **Holidays** — `IsBankClosed(2026-03-21)==true`, `IsHoliday(non-holiday)==false`, + `NextBusinessDay(2026-03-21)` → a later open, non-Friday day; upsert/delete round-trip. +4. **Notifications** — dispatch → list unread-first → unread count 1 → mark read → 0; tenancy (another + user sees nothing and cannot mark your row); **retention** deletes only read >90d (unread >90d and + read <90d survive). +5. **Support alerts** — raise (`low_rating`,`review`,`42`) → list open → assign → resolve; resolved is + terminal. +6. **Analytics** — `EmitAsync` inserts a `system_events` row. + +**Live:** the migration was applied to the dev DB (`Server=87.107.152.16`); the API boots with all 16 +Swagger paths present, and the notification-retention hosted service runs its purge query on startup. +Swagger snapshot refreshed at `dev/contracts/openapi/swagger.v1.json` (fetched over HTTP/2 — the gRPC +plugin makes the REST port HTTP/2-only, so a browser or HTTP/2 client is needed to hit Swagger by hand). + +## What is mocked / waiting on a real service +See `reports/mocks-registry.md`. New/changed 🟡: `IHolidayCalendar` (seeded table → real feed/sync), +`IAnalyticsSink` (system_events row → warehouse), retention `IJobScheduler` +(`NotificationRetentionHostedService` interval runner → Hangfire/Quartz), `INotificationDispatcher` +(now **real in-app write**; SMS/push channels deferred). Selection is by registration, never `if(mock)`. + +## Contracts produced +`dev/contracts/domains/config-reference.md` (live as of b1; consumers f14/f15) + refreshed +`openapi/swagger.v1.json`. + +## Decisions recorded (were not pinned by product docs) +Seeded config defaults marked _provisional_ in `product/data-model/12-audit-config-and-reference.md`: +`platform_fee_rate` 0.15, `nurse_response_deadline_hours` 24, `evv_location_tolerance_meters` 200, +`min_rating_for_support_alert` 2, `bnpl_provider_commission_rate` 0.07, `bnpl_settlement_timing` +immediate, and a 3-tier `cancellation_tiers` default. Confirm before launch — all are config-driven. + +## Follow-ups for later phases +- **b9 / b14:** add the FK constraints `SupportAlerts.BookingId → Bookings` and + `SupportAlerts.ReviewId → Reviews` when those tables land (columns already exist). +- List orderings use the monotonic `Id` (newest-first, deterministic, cross-provider) rather than the + `DateTimeOffset` column — SQLite can't `ORDER BY`/compare `DateTimeOffset`; equivalent on SQL Server. +- Notification retention filters the age cutoff in memory (after a server-side `IsRead` filter) so the + bulk delete translates on every provider; fine for a bounded, off-peak job. +- **Integration tests** (`WebApplicationFactory`, CONVENTIONS §10) still not scaffolded — the + HTTP pipeline (auth 401/403, envelope) is covered by the live Swagger boot but not automated; add the + project when convenient. diff --git a/dev/shared-working-context/reports/mocks-registry.md b/dev/shared-working-context/reports/mocks-registry.md index da7fa30..67c97a6 100644 --- a/dev/shared-working-context/reports/mocks-registry.md +++ b/dev/shared-working-context/reports/mocks-registry.md @@ -20,7 +20,9 @@ Status legend: 🔴 not built · 🟡 mocked (seam + fake impl in place) · 🟢 | `IBnplProvider` | backend-phase-12 | BNPL — drives state machine, fake settle/revert | _tbd_ | SnappPay/Digipay OAuth + verb set; encrypted creds in `payment_gateways.config_json` | 🔴 | | `ICurrencyNormalizer` | backend-phase-12 | Toman↔IRR — ×10 | _tbd_ | Config-driven per provider boundary | 🔴 | | `IBankTransferProvider` | backend-phase-13 | PAYA/SATNA payout — fake transfer ref | _tbd_ | Jibit/Vandar/Sadad payout; source account; PAYA vs SATNA | 🔴 | -| `IHolidayCalendar` | backend-phase-1 | Bank holidays — seeded static table | _tbd_ | Iranian banking-holiday feed / sync job | 🔴 | +| `IHolidayCalendar` | backend-phase-1 | Bank holidays — reads the seeded `ops.IranianHolidays` table; lookups cached (`HolidayCalendarService`, `Persistence/Services/Holidays/`); Iranian banking weekend = Friday | _none_ | Add a sync job/feed that maintains the (partly lunar-Hijri) calendar table; the read interface stays | 🟡 | +| `IAnalyticsSink` | backend-phase-1 | Behavioural events — inserts an `ops.SystemEvents` row, fire-and-forget (`AnalyticsSink`, `Persistence/Services/Analytics/`) | _none_ | Pipe to a warehouse/stream (e.g. Kafka→ClickHouse); keep fire-and-forget semantics | 🟡 | +| `IJobScheduler` (retention) | backend-phase-1 | Scheduling — in-process interval `BackgroundService` running `PurgeOldReadNotifications` daily (`NotificationRetentionHostedService`, `Persistence/Services/Notifications/`) | _none_ | Swap to Hangfire/Quartz; register the job there; keep the purge predicate (`is_read=1 AND age>90d`) | 🟡 | | `IShahkarVerifier` | backend-phase-6 | Phone↔national-id match — fake pass | _tbd_ | Real Shahkar/KYC vendor; persist `external_response_json` | 🔴 | | `IIdentityKycProvider` | backend-phase-6 | National-ID + liveness — fake pass | _tbd_ | Finnotech/U-ID/Jibbit/Verify liveness+OCR | 🔴 | | `ICredentialVerifier` | backend-phase-6 | MoH/INO/criminal-record — manual/fake | _tbd_ | Manual admin today; API when a portal appears (`verification_method=api`) | 🔴 | @@ -29,7 +31,7 @@ Status legend: 🔴 not built · 🟡 mocked (seam + fake impl in place) · 🟢 | `IMoadianClient` | backend-phase-11 | سامانه مودیان e-invoice — leaves ref pending | _tbd_ | Real مودیان submission → 22-digit ref | 🔴 | | `IReviewModerationService` | backend-phase-14 | AI moderation — keyword/pass-through | _tbd_ | Real classifier/LLM endpoint | 🔴 | | `IFieldEncryptor` | backend-phase-0 | PII encryption — AES-256-CBC + HMAC hash from a local symmetric key (`SymmetricFieldEncryptor`, `Baya.Infrastructure.CrossCutting/Seams/`) | `Seams:FieldEncryption:Key`, `Seams:FieldEncryption:HashKey` | KMS / column encryption / Key Vault / HSM | 🟡 | -| `INotificationDispatcher` | backend-phase-0/15 | Notification channels — logs/no-op (`LogNotificationDispatcher`, `Baya.Infrastructure.CrossCutting/Seams/`); no write yet | _none_ | Add the in-app `notifications` write (b15) + SMS/push (FCM); polling → Redis pub/sub or SignalR later | 🟡 | +| `INotificationDispatcher` | backend-phase-0/**1** | Notification channels — **in-app write is now real** (`InAppNotificationDispatcher`, `Persistence/Services/Notifications/`, writes an `ops.Notifications` row); b0 log stub removed. SMS/push channels still deferred (no-op) behind the same seam | _none_ | Add SMS (`ISmsSender`) / push (FCM) channels; polling → Redis pub/sub or SignalR later | 🟡 | | `ILicenseVerificationService` | backend-phase-15 | eNamad / MoH establishment-permit — manual approve | _tbd_ | Real registry/API | 🔴 | > Exact config keys and file paths get filled in by the phase that builds each seam. Keep the diff --git a/product/data-model/12-audit-config-and-reference.html b/product/data-model/12-audit-config-and-reference.html index 6c888c5..28d4ff5 100644 --- a/product/data-model/12-audit-config-and-reference.html +++ b/product/data-model/12-audit-config-and-reference.html @@ -23,6 +23,22 @@

Role: High-volume behavioral/analytics event log. Why kept but de-emphasized: product analytics, not compliance. It grows unbounded — at scale, pipe it to an analytics sink/warehouse rather than the transactional DB. Fields unchanged.

platform_configs [CORE]

Role: Key-value runtime business parameters — change without a deploy. Why typed values: data_type tells the app how to parse. New keys this revision: dispute_window_hours (default 72), vat_rate (0.10), bnpl_merchant_of_record, bnpl_provider_commission_rate, bnpl_settlement_timing, cancellation-tier defaults — alongside the existing platform_fee_rate, booking_payment_deadline_minutes, nurse_response_deadline_hours, nurse_payout_interval_days, evv_location_tolerance_meters, min_rating_for_support_alert. Relations: referenced everywhere; changes audited.

+

Seeded defaults (as built, backend-phase-1). The baseline migration seeds every key below. Values marked _provisional_ were chosen as safe defaults where the product docs did not pin a number — confirm before launch; each is config-driven so it changes without a deploy.

+
+ + + + + + + + + + + + +
Keydata_typeSeeded valueSource
platform_fee_ratedecimal0.15_provisional_
vat_ratedecimal0.10doc (10%, commission line only)
dispute_window_hoursint72doc
booking_payment_deadline_minutesint30doc
nurse_response_deadline_hoursint24_provisional_
nurse_payout_interval_daysint7doc (weekly)
evv_location_tolerance_metersint200_provisional_
min_rating_for_support_alertdecimal2_provisional_ (review ≤ 2 raises an alert)
bnpl_merchant_of_recordstringplatformdoc (Balinyaar is MoR)
bnpl_provider_commission_ratedecimal0.07_provisional_
bnpl_settlement_timingstringimmediate_provisional_
cancellation_tiersjson[{"min_hours_before":48,"refund_percent":100},{"min_hours_before":24,"refund_percent":50},{"min_hours_before":0,"refund_percent":0}]_provisional_
+

Rates are DECIMAL fractions (not money); the IRR amounts they later multiply are BIGINT. Read them at compute time (cached via IPlatformConfig), never hardcode, and snapshot the rate used onto the priced booking/invoice so a later rate change never re-prices an existing row.

iranian_holidays [MVP] — NEW

Role: Shared official/religious holiday calendar (movable, partly lunar-Hijri), with a is_bank_closed flag. Why a real table: Iran's holidays are numerous and partly movable, and they drive payout bank-closure scheduling (PAYA/SATNA closed → a weekly payout shifts to the next business day), optional holiday pricing, and business-hour deadline math — none of which a purely manual per-nurse availability exception can express.

diff --git a/product/data-model/12-audit-config-and-reference.md b/product/data-model/12-audit-config-and-reference.md index f6480d3..295c5d0 100644 --- a/product/data-model/12-audit-config-and-reference.md +++ b/product/data-model/12-audit-config-and-reference.md @@ -11,6 +11,25 @@ ### `platform_configs` [CORE] **Role:** Key-value runtime business parameters — change without a deploy. **Why typed values:** `data_type` tells the app how to parse. **New keys** this revision: `dispute_window_hours` (default 72), `vat_rate` (0.10), `bnpl_merchant_of_record`, `bnpl_provider_commission_rate`, `bnpl_settlement_timing`, cancellation-tier defaults — alongside the existing `platform_fee_rate`, `booking_payment_deadline_minutes`, `nurse_response_deadline_hours`, `nurse_payout_interval_days`, `evv_location_tolerance_meters`, `min_rating_for_support_alert`. **Relations:** referenced everywhere; changes audited. +**Seeded defaults (as built, backend-phase-1).** The baseline migration seeds every key below. Values marked _provisional_ were chosen as safe defaults where the product docs did not pin a number — confirm before launch; each is config-driven so it changes without a deploy. + +| Key | `data_type` | Seeded value | Source | +|---|---|---|---| +| `platform_fee_rate` | decimal | `0.15` | _provisional_ | +| `vat_rate` | decimal | `0.10` | doc (10%, commission line only) | +| `dispute_window_hours` | int | `72` | doc | +| `booking_payment_deadline_minutes` | int | `30` | doc | +| `nurse_response_deadline_hours` | int | `24` | _provisional_ | +| `nurse_payout_interval_days` | int | `7` | doc (weekly) | +| `evv_location_tolerance_meters` | int | `200` | _provisional_ | +| `min_rating_for_support_alert` | decimal | `2` | _provisional_ (review ≤ 2 raises an alert) | +| `bnpl_merchant_of_record` | string | `platform` | doc (Balinyaar is MoR) | +| `bnpl_provider_commission_rate` | decimal | `0.07` | _provisional_ | +| `bnpl_settlement_timing` | string | `immediate` | _provisional_ | +| `cancellation_tiers` | json | `[{"min_hours_before":48,"refund_percent":100},{"min_hours_before":24,"refund_percent":50},{"min_hours_before":0,"refund_percent":0}]` | _provisional_ | + +Rates are `DECIMAL` fractions (not money); the IRR amounts they later multiply are `BIGINT`. **Read them at compute time (cached via `IPlatformConfig`), never hardcode**, and snapshot the rate used onto the priced booking/invoice so a later rate change never re-prices an existing row. + ### `iranian_holidays` [MVP] — **NEW** **Role:** Shared official/religious holiday calendar (movable, partly lunar-Hijri), with a `is_bank_closed` flag. **Why a real table:** Iran's holidays are numerous and partly movable, and they drive **payout bank-closure scheduling** (PAYA/SATNA closed → a weekly payout shifts to the next business day), optional holiday pricing, and business-hour deadline math — none of which a purely manual per-nurse availability exception can express. diff --git a/server/CLAUDE.md b/server/CLAUDE.md index 7976b38..972d88c 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -81,15 +81,15 @@ projects/assemblies, Clean-Architecture layers, and cross-layer dependencies. ``` src/ ├── Core/ -│ ├── Baya.Domain Entities (User, Role…), BaseEntity, IEntity, ITimeModification, IAuditableEntity -│ └── Baya.Application Features/ (Commands & Queries), Contracts/ (incl. Contracts/Common cross-cutting seams), Models/, pipeline behaviors (Common/) +│ ├── Baya.Domain Entities (User, Role…, + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts), BaseEntity, IEntity, ITimeModification, IAuditableEntity, IAuditable (audit-row marker) +│ └── Baya.Application Features/ (Commands & Queries; + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts areas), Contracts/ (incl. Contracts/Common cross-cutting seams + the platform-signal facade contracts), Models/, pipeline behaviors (Common/) ├── Infrastructure/ -│ ├── Baya.Infrastructure.Persistence ApplicationDbContext, Repositories/, Configuration/, Migrations/, Interceptors/ (AuditFieldInterceptor) +│ ├── Baya.Infrastructure.Persistence ApplicationDbContext, Repositories/, Configuration/ (per-area EF config), Migrations/, Interceptors/ (AuditFieldInterceptor — audit-fields + audit-log rows), Services/ (DB-backed platform-signal facades + notification-retention hosted service) │ ├── Baya.Infrastructure.Identity Jwt/, Identity/ (Managers, Stores, PermissionManager, Seed, CurrentUser/) │ ├── Baya.Infrastructure.CrossCutting Serilog wiring + Seams/ (mock impls of the cross-cutting seams) + AddCrossCuttingSeams │ └── Baya.Infrastructure.Monitoring HealthChecks, OpenTelemetry, prometheus-net ├── API/ -│ ├── Baya.Web.Api Program.cs, Controllers/V1/ (PingController), appsettings*.json +│ ├── Baya.Web.Api Program.cs, Controllers/V1/ (PingController + admin PlatformConfig/Holidays/Audit/SupportAlerts + current-user Notifications), appsettings*.json │ ├── Baya.WebFramework BaseController, Filters/, Middlewares/, Swagger/, Routing/, ServiceConfiguration/ (rate limiting) │ └── Plugins/Baya.Web.Plugins.Grpc gRPC services + .proto models (User only) ├── Shared/Baya.SharedKernel Extensions + validation base @@ -111,6 +111,20 @@ Application reference Infrastructure or the API — this is a hard rule. real provider is a registration change — handlers depend only on the contract. Audit fields are stamped by `AuditFieldInterceptor` (Persistence), not in handlers. +**Platform-signal facades (backend-phase-1).** The cross-cutting marketplace tables live in a dedicated +**`ops` schema** (mirroring how Identity uses `usr`): `PlatformConfigs`, `AuditLogs`, `SystemEvents`, +`IranianHolidays`, `Notifications`, `SupportAlerts`. Because they are DB-backed, their Application +contracts — `IPlatformConfig` (typed cached config), `IHolidayCalendar` (bank-closure calendar), +`IAnalyticsSink` (fire-and-forget `system_events`), `IAuditLogger` (explicit append-only writes + +trail), `INotificationService` (per-user notification reads/commands), `ISupportAlertService` (internal +worklist) — are implemented in **`Baya.Infrastructure.Persistence/Services/`** and registered by +`AddPersistenceServices`, *not* in CrossCutting. The real `INotificationDispatcher` (in-app +`notifications` write) also lives there and **supersedes** the b0 log stub. The +`NotificationRetentionHostedService` (the retention/`IJobScheduler` seam) is registered as a hosted +service there too. Other domains call these contracts; they never re-create the tables. The +`AuditFieldInterceptor` additionally writes an append-only `audit_logs` row for any `IAuditable` entity +(currently `PlatformConfig`) in the same transaction as the change. + **Keeping the Project map current.** When a change touches the architecture — adds, removes, or renames a project/assembly, a Clean-Architecture layer, or a major folder, or changes a cross-layer dependency — you **must** update this Project map (and the dependency rule above, if affected) in the diff --git a/server/CONVENTIONS.md b/server/CONVENTIONS.md index 4e78919..f7084a4 100644 --- a/server/CONVENTIONS.md +++ b/server/CONVENTIONS.md @@ -276,6 +276,26 @@ Wire `ICurrentUser` (HTTP context accessor wrapped in an interface, registered S > (`Baya.Infrastructure.Persistence/Interceptors/`), a `SaveChangesInterceptor` that reads time from > `IDateTimeProvider` and the user from `ICurrentUser` — not in the `DbContext` itself. +> **As built (backend-phase-1) — reusable patterns you should follow:** +> - **Config is rows, read at compute time.** Money-critical constants (commission %, VAT, deadlines, +> EVV tolerance, cancellation tiers) live in `platform_configs`, read via `IPlatformConfig.GetConfig` +> (cached, parsed by the row's `data_type`) — **never hardcode**. Changing a rate must never +> retroactively alter an already-computed amount: later phases snapshot the rate onto the +> booking/invoice at compute time; do not live-re-read a rate for an already-priced row. +> - **Append-only audit trail.** `audit_logs` is immutable — there is **no** update/delete path in app +> code. Mark a compliance-sensitive entity with `IAuditable` (`Baya.Domain/Common`) and the +> `AuditFieldInterceptor` writes an old/new diff row per change in the same transaction; annotate any +> encrypted/PII property with `[AuditRedacted]` so it is redacted (never plaintext) in the diff. +> `platform_configs` is the first `IAuditable` entity. +> - **DB-backed platform facades** (`IPlatformConfig`/`IHolidayCalendar`/`IAnalyticsSink`/`IAuditLogger`/ +> `INotificationService`/`ISupportAlertService`) live in `Persistence/Services/` and are the contracts +> other domains reuse — don't re-query these tables directly. `IAnalyticsSink` is fire-and-forget +> (never fail the caller); `INotificationService`/notification endpoints are always tenant-scoped to +> `ICurrentUser`; `support_alerts` are admin-only and never appear on a user-facing route. +> - **Retention/scheduling seam.** Background jobs run behind the hosted-service seam +> (`NotificationRetentionHostedService`); real Hangfire/Quartz is deferred. The notification retention +> predicate is exactly `is_read = 1 AND age > 90d` — unread is never auto-deleted. + ### Money is IRR `BIGINT` — integer-only, no floats Every monetary value is **IRR Rials stored as `long` / `BIGINT`**. There is **no float/decimal path** on money — not in entities, DTOs, the API, or arithmetic. Toman is display-only and converts to/from Rials **only** inside a provider adapter at its boundary, never in domain or shared code. If a money value object is introduced later it must be integer-only. The three booking amounts always satisfy `gross = commission + payout`. diff --git a/server/src/API/Baya.Web.Api/Controllers/V1/AuditController.cs b/server/src/API/Baya.Web.Api/Controllers/V1/AuditController.cs new file mode 100644 index 0000000..4c30077 --- /dev/null +++ b/server/src/API/Baya.Web.Api/Controllers/V1/AuditController.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; +using Asp.Versioning; +using Baya.Application.Features.Audit.Queries.GetAuditTrail; +using Baya.Application.Models.Audit; +using Baya.Application.Models.Common; +using Baya.Infrastructure.Identity.Identity.PermissionManager; +using Baya.WebFramework.Attributes; +using Baya.WebFramework.BaseController; +using Mediator; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Baya.Web.Api.Controllers.V1; + +[ApiVersion("1")] +[ApiController] +[Route("api/v{version:apiVersion}/[controller]")] +[Authorize(ConstantPolicies.DynamicPermission)] +[Display(Description = "Admin: the immutable, append-only audit trail")] +public sealed class AuditController(ISender sender) : BaseController +{ + [HttpGet("[action]")] + [ProducesOkApiResponseType>] + public async Task GetAuditTrail([FromQuery] GetAuditTrailQuery query, CancellationToken cancellationToken) + => OperationResult(await sender.Send(query, cancellationToken)); +} diff --git a/server/src/API/Baya.Web.Api/Controllers/V1/HolidaysController.cs b/server/src/API/Baya.Web.Api/Controllers/V1/HolidaysController.cs new file mode 100644 index 0000000..f1a5e90 --- /dev/null +++ b/server/src/API/Baya.Web.Api/Controllers/V1/HolidaysController.cs @@ -0,0 +1,38 @@ +using System.ComponentModel.DataAnnotations; +using Asp.Versioning; +using Baya.Application.Features.Holidays.Commands.DeleteHoliday; +using Baya.Application.Features.Holidays.Commands.UpsertHoliday; +using Baya.Application.Features.Holidays.Queries.ListHolidays; +using Baya.Application.Models.Common; +using Baya.Application.Models.Holidays; +using Baya.Infrastructure.Identity.Identity.PermissionManager; +using Baya.WebFramework.Attributes; +using Baya.WebFramework.BaseController; +using Mediator; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Baya.Web.Api.Controllers.V1; + +[ApiVersion("1")] +[ApiController] +[Route("api/v{version:apiVersion}/[controller]")] +[Authorize(ConstantPolicies.DynamicPermission)] +[Display(Description = "Admin: the Iranian holiday calendar that drives payout bank-closure scheduling")] +public sealed class HolidaysController(ISender sender) : BaseController +{ + [HttpGet("[action]")] + [ProducesOkApiResponseType>] + public async Task GetHolidays([FromQuery] ListHolidaysQuery query, CancellationToken cancellationToken) + => OperationResult(await sender.Send(query, cancellationToken)); + + [HttpPost("[action]")] + [ProducesOkApiResponseType] + public async Task UpsertHoliday(UpsertHolidayCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command, cancellationToken)); + + [HttpPost("[action]")] + [ProducesOkApiResponseType] + public async Task DeleteHoliday(DeleteHolidayCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command, cancellationToken)); +} diff --git a/server/src/API/Baya.Web.Api/Controllers/V1/NotificationsController.cs b/server/src/API/Baya.Web.Api/Controllers/V1/NotificationsController.cs new file mode 100644 index 0000000..bb254a5 --- /dev/null +++ b/server/src/API/Baya.Web.Api/Controllers/V1/NotificationsController.cs @@ -0,0 +1,43 @@ +using System.ComponentModel.DataAnnotations; +using Asp.Versioning; +using Baya.Application.Features.Notifications.Commands.MarkAllRead; +using Baya.Application.Features.Notifications.Commands.MarkNotificationRead; +using Baya.Application.Features.Notifications.Queries.GetUnreadCount; +using Baya.Application.Features.Notifications.Queries.ListMyNotifications; +using Baya.Application.Models.Common; +using Baya.Application.Models.Notifications; +using Baya.WebFramework.Attributes; +using Baya.WebFramework.BaseController; +using Mediator; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Baya.Web.Api.Controllers.V1; + +[ApiVersion("1")] +[ApiController] +[Route("api/v{version:apiVersion}/[controller]")] +[Authorize] +[Display(Description = "The signed-in user's in-app notifications")] +public sealed class NotificationsController(ISender sender) : BaseController +{ + [HttpGet("[action]")] + [ProducesOkApiResponseType>] + public async Task GetNotifications([FromQuery] ListMyNotificationsQuery query, CancellationToken cancellationToken) + => OperationResult(await sender.Send(query, cancellationToken)); + + [HttpGet("[action]")] + [ProducesOkApiResponseType] + public async Task GetUnreadCount(CancellationToken cancellationToken) + => OperationResult(await sender.Send(new GetUnreadCountQuery(), cancellationToken)); + + [HttpPost("[action]")] + [ProducesOkApiResponseType] + public async Task MarkNotificationRead(MarkNotificationReadCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command, cancellationToken)); + + [HttpPost("[action]")] + [ProducesOkApiResponseType] + public async Task MarkAllRead(CancellationToken cancellationToken) + => OperationResult(await sender.Send(new MarkAllReadCommand(), cancellationToken)); +} diff --git a/server/src/API/Baya.Web.Api/Controllers/V1/PlatformConfigController.cs b/server/src/API/Baya.Web.Api/Controllers/V1/PlatformConfigController.cs new file mode 100644 index 0000000..9374864 --- /dev/null +++ b/server/src/API/Baya.Web.Api/Controllers/V1/PlatformConfigController.cs @@ -0,0 +1,38 @@ +using System.ComponentModel.DataAnnotations; +using Asp.Versioning; +using Baya.Application.Features.Configuration.Commands.UpdatePlatformConfig; +using Baya.Application.Features.Configuration.Queries.GetConfigChangeHistory; +using Baya.Application.Features.Configuration.Queries.ListPlatformConfigs; +using Baya.Application.Models.Common; +using Baya.Application.Models.Configuration; +using Baya.Infrastructure.Identity.Identity.PermissionManager; +using Baya.WebFramework.Attributes; +using Baya.WebFramework.BaseController; +using Mediator; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Baya.Web.Api.Controllers.V1; + +[ApiVersion("1")] +[ApiController] +[Route("api/v{version:apiVersion}/[controller]")] +[Authorize(ConstantPolicies.DynamicPermission)] +[Display(Description = "Admin: typed runtime platform configuration and its audited change history")] +public sealed class PlatformConfigController(ISender sender) : BaseController +{ + [HttpGet("[action]")] + [ProducesOkApiResponseType>] + public async Task GetPlatformConfigs([FromQuery] ListPlatformConfigsQuery query, CancellationToken cancellationToken) + => OperationResult(await sender.Send(query, cancellationToken)); + + [HttpPost("[action]")] + [ProducesOkApiResponseType] + public async Task UpdatePlatformConfig(UpdatePlatformConfigCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command, cancellationToken)); + + [HttpGet("[action]")] + [ProducesOkApiResponseType>] + public async Task GetConfigChangeHistory([FromQuery] GetConfigChangeHistoryQuery query, CancellationToken cancellationToken) + => OperationResult(await sender.Send(query, cancellationToken)); +} diff --git a/server/src/API/Baya.Web.Api/Controllers/V1/SupportAlertsController.cs b/server/src/API/Baya.Web.Api/Controllers/V1/SupportAlertsController.cs new file mode 100644 index 0000000..5fadc80 --- /dev/null +++ b/server/src/API/Baya.Web.Api/Controllers/V1/SupportAlertsController.cs @@ -0,0 +1,38 @@ +using System.ComponentModel.DataAnnotations; +using Asp.Versioning; +using Baya.Application.Features.SupportAlerts.Commands.AssignSupportAlert; +using Baya.Application.Features.SupportAlerts.Commands.ResolveSupportAlert; +using Baya.Application.Features.SupportAlerts.Queries.ListSupportAlerts; +using Baya.Application.Models.Common; +using Baya.Application.Models.SupportAlerts; +using Baya.Infrastructure.Identity.Identity.PermissionManager; +using Baya.WebFramework.Attributes; +using Baya.WebFramework.BaseController; +using Mediator; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Baya.Web.Api.Controllers.V1; + +[ApiVersion("1")] +[ApiController] +[Route("api/v{version:apiVersion}/[controller]")] +[Authorize(ConstantPolicies.DynamicPermission)] +[Display(Description = "Admin-only: the internal support-alert worklist (never user-facing)")] +public sealed class SupportAlertsController(ISender sender) : BaseController +{ + [HttpGet("[action]")] + [ProducesOkApiResponseType>] + public async Task GetSupportAlerts([FromQuery] ListSupportAlertsQuery query, CancellationToken cancellationToken) + => OperationResult(await sender.Send(query, cancellationToken)); + + [HttpPost("[action]")] + [ProducesOkApiResponseType] + public async Task AssignSupportAlert(AssignSupportAlertCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command, cancellationToken)); + + [HttpPost("[action]")] + [ProducesOkApiResponseType] + public async Task ResolveSupportAlert(ResolveSupportAlertCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command, cancellationToken)); +} diff --git a/server/src/Core/Baya.Application/Common/Pagination.cs b/server/src/Core/Baya.Application/Common/Pagination.cs new file mode 100644 index 0000000..eff5580 --- /dev/null +++ b/server/src/Core/Baya.Application/Common/Pagination.cs @@ -0,0 +1,15 @@ +namespace Baya.Application.Common; + +/// Normalises paging inputs so every list handler clamps page/page_size the same way. +public static class Pagination +{ + public const int MaxPageSize = 100; + public const int DefaultPageSize = 50; + + public static (int Page, int PageSize) Normalize(int page, int pageSize) + { + var normalizedPage = page < 1 ? 1 : page; + var normalizedSize = pageSize < 1 ? DefaultPageSize : Math.Min(pageSize, MaxPageSize); + return (normalizedPage, normalizedSize); + } +} diff --git a/server/src/Core/Baya.Application/Contracts/Analytics/IAnalyticsSink.cs b/server/src/Core/Baya.Application/Contracts/Analytics/IAnalyticsSink.cs new file mode 100644 index 0000000..0230ca8 --- /dev/null +++ b/server/src/Core/Baya.Application/Contracts/Analytics/IAnalyticsSink.cs @@ -0,0 +1,11 @@ +namespace Baya.Application.Contracts.Analytics; + +/// +/// Fire-and-forget behavioural/analytics event sink. Emission must never fail or slow the caller's +/// operation — a sink error is logged and swallowed. NEVER route compliance-relevant facts here; those +/// go to the audit trail. Mock inserts a system_events row; the real path pipes to a warehouse. +/// +public interface IAnalyticsSink +{ + ValueTask EmitAsync(string name, object props, CancellationToken cancellationToken = default); +} diff --git a/server/src/Core/Baya.Application/Contracts/Audit/IAuditLogger.cs b/server/src/Core/Baya.Application/Contracts/Audit/IAuditLogger.cs new file mode 100644 index 0000000..1dd2c93 --- /dev/null +++ b/server/src/Core/Baya.Application/Contracts/Audit/IAuditLogger.cs @@ -0,0 +1,27 @@ +#nullable enable +using Baya.Application.Models.Audit; +using Baya.Application.Models.Common; + +namespace Baya.Application.Contracts.Audit; + +/// +/// Explicit append-only audit writer, for recording a state change that has no tracked-entity row diff +/// (row-level changes on auditable entities are captured automatically by the SaveChanges interceptor). +/// The trail is immutable — there is no update or delete path. +/// +public interface IAuditLogger +{ + ValueTask WriteAsync( + string entityType, + string entityId, + string action, + IReadOnlyDictionary? changedFields = null, + CancellationToken cancellationToken = default); + + ValueTask> GetTrailAsync( + string entityType, + string entityId, + int page, + int pageSize, + CancellationToken cancellationToken = default); +} diff --git a/server/src/Core/Baya.Application/Contracts/Common/INotificationDispatcher.cs b/server/src/Core/Baya.Application/Contracts/Common/INotificationDispatcher.cs index 89bfe18..50f558d 100644 --- a/server/src/Core/Baya.Application/Contracts/Common/INotificationDispatcher.cs +++ b/server/src/Core/Baya.Application/Contracts/Common/INotificationDispatcher.cs @@ -1,3 +1,4 @@ +#nullable enable namespace Baya.Application.Contracts.Common; /// @@ -11,20 +12,25 @@ public enum NotificationChannel Push } -/// A notification to dispatch to a recipient over one channel. +/// A notification to mint for a recipient over one channel. /// The target user. -/// Delivery channel. +/// Stable type code driving front-end rendering/deep-link (e.g. booking_confirmed). /// Short title/subject. -/// Message body (no secrets/PII in logs). +/// Optional message body (no secrets/PII in logs). +/// Optional typed, versioned deep-link payload — a contract, not an arbitrary blob. +/// Delivery channel (in-app now; SMS/push deferred). public sealed record Notification( int RecipientUserId, - NotificationChannel Channel, + string Type, string Title, - string Body); + string? Body = null, + string? DataJson = null, + NotificationChannel Channel = NotificationChannel.InApp); /// -/// Seam for emitting notifications from domains like booking and payments. The mock logs/no-ops; the -/// real in-app write lands in backend-phase-15, with SMS/push added behind the same interface. +/// Seam for minting notifications from domains like booking, payments, and reviews. The real in-app +/// implementation writes a notifications row; SMS/push channels are added later behind this same +/// interface, so callers use unchanged. /// public interface INotificationDispatcher { diff --git a/server/src/Core/Baya.Application/Contracts/Configuration/IPlatformConfig.cs b/server/src/Core/Baya.Application/Contracts/Configuration/IPlatformConfig.cs new file mode 100644 index 0000000..4bb634b --- /dev/null +++ b/server/src/Core/Baya.Application/Contracts/Configuration/IPlatformConfig.cs @@ -0,0 +1,28 @@ +#nullable enable +using Baya.Application.Models.Common; +using Baya.Application.Models.Configuration; + +namespace Baya.Application.Contracts.Configuration; + +/// +/// Typed, cached accessor for runtime business parameters stored as rows in platform_configs. +/// Money-critical constants (commission %, VAT, deadlines, cancellation tiers) are read from here at +/// compute time — never hardcoded. Every write is audited in the same transaction. +/// +public interface IPlatformConfig +{ + /// Reads a config value and parses it to per the row's data_type (cached). + ValueTask GetConfig(string key, CancellationToken cancellationToken = default); + + /// + /// Updates an existing config row (and writes an audit entry, in one transaction) then evicts the cache. + /// Returns false if the key does not exist. Changing a rate must never retroactively alter an + /// already-computed amount — later phases snapshot the rate onto the priced row at compute time. + /// + ValueTask SetConfig(string key, string value, CancellationToken cancellationToken = default); + + ValueTask> ListAsync(int page, int pageSize, CancellationToken cancellationToken = default); + + /// The audited change history for a single config key (from the append-only audit trail). + ValueTask> GetConfigChangeHistory(string key, int page, int pageSize, CancellationToken cancellationToken = default); +} diff --git a/server/src/Core/Baya.Application/Contracts/Holidays/IHolidayCalendar.cs b/server/src/Core/Baya.Application/Contracts/Holidays/IHolidayCalendar.cs new file mode 100644 index 0000000..fd3e66b --- /dev/null +++ b/server/src/Core/Baya.Application/Contracts/Holidays/IHolidayCalendar.cs @@ -0,0 +1,29 @@ +#nullable enable +using Baya.Application.Models.Common; +using Baya.Application.Models.Holidays; + +namespace Baya.Application.Contracts.Holidays; + +/// +/// The Iranian holiday calendar seam. Lookups are cached. Payout scheduling (later phase) calls +/// to shift a payout off bank-closed days. Mock reads the seeded +/// iranian_holidays table; the real path syncs an external banking-holiday feed. +/// +public interface IHolidayCalendar +{ + ValueTask IsHoliday(DateOnly date, CancellationToken cancellationToken = default); + + /// True if PAYA/SATNA banks are closed that day (a seeded bank-closed holiday). + ValueTask IsBankClosed(DateOnly date, CancellationToken cancellationToken = default); + + /// The next day banks are open — skips bank-closed holidays and the Iranian banking weekend (Friday). + ValueTask NextBusinessDay(DateOnly date, CancellationToken cancellationToken = default); + + ValueTask> ListAsync(DateOnly? from, DateOnly? to, int page, int pageSize, CancellationToken cancellationToken = default); + + /// Inserts or updates the holiday for then evicts the cached lookups for it. + ValueTask UpsertAsync(DateOnly date, string nameFa, string type, bool isBankClosed, CancellationToken cancellationToken = default); + + /// Deletes the holiday for ; returns false if none existed. + ValueTask DeleteAsync(DateOnly date, CancellationToken cancellationToken = default); +} diff --git a/server/src/Core/Baya.Application/Contracts/Notifications/INotificationService.cs b/server/src/Core/Baya.Application/Contracts/Notifications/INotificationService.cs new file mode 100644 index 0000000..48e2e31 --- /dev/null +++ b/server/src/Core/Baya.Application/Contracts/Notifications/INotificationService.cs @@ -0,0 +1,26 @@ +using Baya.Application.Models.Common; +using Baya.Application.Models.Notifications; + +namespace Baya.Application.Contracts.Notifications; + +/// +/// Reads and per-user commands over the in-app notification store. Every operation is tenant-scoped to +/// the passed userId (always the authenticated caller — never a body-supplied id). Minting a new +/// notification is done through , not here. +/// +public interface INotificationService +{ + /// The caller's notifications, unread-first then newest-first. + ValueTask> ListMineAsync(int userId, int page, int pageSize, CancellationToken cancellationToken = default); + + ValueTask GetUnreadCountAsync(int userId, CancellationToken cancellationToken = default); + + /// Marks one of the caller's notifications read; returns false if it isn't theirs or doesn't exist. + ValueTask MarkReadAsync(int userId, long notificationId, CancellationToken cancellationToken = default); + + /// Marks all of the caller's unread notifications read; returns the number flipped. + ValueTask MarkAllReadAsync(int userId, CancellationToken cancellationToken = default); + + /// Hard-deletes read notifications older than ; never touches unread. Returns the count removed. + ValueTask PurgeOldReadAsync(int retentionDays, CancellationToken cancellationToken = default); +} diff --git a/server/src/Core/Baya.Application/Contracts/SupportAlerts/ISupportAlertService.cs b/server/src/Core/Baya.Application/Contracts/SupportAlerts/ISupportAlertService.cs new file mode 100644 index 0000000..ac4cf35 --- /dev/null +++ b/server/src/Core/Baya.Application/Contracts/SupportAlerts/ISupportAlertService.cs @@ -0,0 +1,37 @@ +#nullable enable +using Baya.Application.Models.Common; +using Baya.Application.Models.SupportAlerts; + +namespace Baya.Application.Contracts.SupportAlerts; + +/// +/// Internal support-alert worklist. is called by later review/EVV/verification/ +/// payment flows. Alerts are staff-only — never surfaced on a user-facing endpoint. The polymorphic +/// (entityType, entityId) is validated at the application layer; the typed FK is preferred when +/// the subject is a booking or review. +/// +public interface ISupportAlertService +{ + ValueTask RaiseAsync( + string type, + string entityType, + string entityId, + string severity, + long? bookingId = null, + long? reviewId = null, + CancellationToken cancellationToken = default); + + /// Assigns an open alert to an owner (open → assigned). Returns false if the alert is missing or already resolved. + ValueTask AssignAsync(long alertId, int ownerUserId, CancellationToken cancellationToken = default); + + /// Resolves an alert with a note (→ resolved). Returns false if the alert is missing or already resolved. + ValueTask ResolveAsync(long alertId, string note, CancellationToken cancellationToken = default); + + ValueTask> ListAsync( + string? type, + string? status, + int? ownerUserId, + int page, + int pageSize, + CancellationToken cancellationToken = default); +} diff --git a/server/src/Core/Baya.Application/Features/Audit/Queries/GetAuditTrail/GetAuditTrailQuery.Handler.cs b/server/src/Core/Baya.Application/Features/Audit/Queries/GetAuditTrail/GetAuditTrailQuery.Handler.cs new file mode 100644 index 0000000..055a72c --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Audit/Queries/GetAuditTrail/GetAuditTrailQuery.Handler.cs @@ -0,0 +1,18 @@ +using Baya.Application.Common; +using Baya.Application.Contracts.Audit; +using Baya.Application.Models.Audit; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Audit.Queries.GetAuditTrail; + +internal sealed class GetAuditTrailQueryHandler(IAuditLogger auditLogger) + : IRequestHandler>> +{ + public async ValueTask>> Handle(GetAuditTrailQuery request, CancellationToken cancellationToken) + { + var (page, pageSize) = Pagination.Normalize(request.Page, request.PageSize); + var result = await auditLogger.GetTrailAsync(request.EntityType, request.EntityId, page, pageSize, cancellationToken); + return OperationResult>.SuccessResult(result); + } +} diff --git a/server/src/Core/Baya.Application/Features/Audit/Queries/GetAuditTrail/GetAuditTrailQuery.cs b/server/src/Core/Baya.Application/Features/Audit/Queries/GetAuditTrail/GetAuditTrailQuery.cs new file mode 100644 index 0000000..c6cdcc5 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Audit/Queries/GetAuditTrail/GetAuditTrailQuery.cs @@ -0,0 +1,8 @@ +using Baya.Application.Models.Audit; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Audit.Queries.GetAuditTrail; + +public record GetAuditTrailQuery(string EntityType, string EntityId, int Page = 1, int PageSize = 50) + : IRequest>>; diff --git a/server/src/Core/Baya.Application/Features/Configuration/Commands/UpdatePlatformConfig/UpdatePlatformConfigCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Configuration/Commands/UpdatePlatformConfig/UpdatePlatformConfigCommand.Handler.cs new file mode 100644 index 0000000..eaf22d5 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Configuration/Commands/UpdatePlatformConfig/UpdatePlatformConfigCommand.Handler.cs @@ -0,0 +1,18 @@ +using Baya.Application.Contracts.Configuration; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Configuration.Commands.UpdatePlatformConfig; + +internal sealed class UpdatePlatformConfigCommandHandler(IPlatformConfig platformConfig) + : IRequestHandler> +{ + public async ValueTask> Handle(UpdatePlatformConfigCommand request, CancellationToken cancellationToken) + { + var updated = await platformConfig.SetConfig(request.Key, request.Value, cancellationToken); + + return updated + ? OperationResult.SuccessResult(true) + : OperationResult.NotFoundResult($"Config key '{request.Key}' does not exist."); + } +} diff --git a/server/src/Core/Baya.Application/Features/Configuration/Commands/UpdatePlatformConfig/UpdatePlatformConfigCommand.Validator.cs b/server/src/Core/Baya.Application/Features/Configuration/Commands/UpdatePlatformConfig/UpdatePlatformConfigCommand.Validator.cs new file mode 100644 index 0000000..dd0b72f --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Configuration/Commands/UpdatePlatformConfig/UpdatePlatformConfigCommand.Validator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace Baya.Application.Features.Configuration.Commands.UpdatePlatformConfig; + +public sealed class UpdatePlatformConfigCommandValidator : AbstractValidator +{ + public UpdatePlatformConfigCommandValidator() + { + RuleFor(x => x.Key).NotEmpty().MaximumLength(100); + RuleFor(x => x.Value).NotNull(); + } +} diff --git a/server/src/Core/Baya.Application/Features/Configuration/Commands/UpdatePlatformConfig/UpdatePlatformConfigCommand.cs b/server/src/Core/Baya.Application/Features/Configuration/Commands/UpdatePlatformConfig/UpdatePlatformConfigCommand.cs new file mode 100644 index 0000000..deacab4 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Configuration/Commands/UpdatePlatformConfig/UpdatePlatformConfigCommand.cs @@ -0,0 +1,7 @@ +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Configuration.Commands.UpdatePlatformConfig; + +public record UpdatePlatformConfigCommand(string Key, string Value) + : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Configuration/Queries/GetConfigChangeHistory/GetConfigChangeHistoryQuery.Handler.cs b/server/src/Core/Baya.Application/Features/Configuration/Queries/GetConfigChangeHistory/GetConfigChangeHistoryQuery.Handler.cs new file mode 100644 index 0000000..a152df1 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Configuration/Queries/GetConfigChangeHistory/GetConfigChangeHistoryQuery.Handler.cs @@ -0,0 +1,18 @@ +using Baya.Application.Common; +using Baya.Application.Contracts.Configuration; +using Baya.Application.Models.Common; +using Baya.Application.Models.Configuration; +using Mediator; + +namespace Baya.Application.Features.Configuration.Queries.GetConfigChangeHistory; + +internal sealed class GetConfigChangeHistoryQueryHandler(IPlatformConfig platformConfig) + : IRequestHandler>> +{ + public async ValueTask>> Handle(GetConfigChangeHistoryQuery request, CancellationToken cancellationToken) + { + var (page, pageSize) = Pagination.Normalize(request.Page, request.PageSize); + var result = await platformConfig.GetConfigChangeHistory(request.Key, page, pageSize, cancellationToken); + return OperationResult>.SuccessResult(result); + } +} diff --git a/server/src/Core/Baya.Application/Features/Configuration/Queries/GetConfigChangeHistory/GetConfigChangeHistoryQuery.cs b/server/src/Core/Baya.Application/Features/Configuration/Queries/GetConfigChangeHistory/GetConfigChangeHistoryQuery.cs new file mode 100644 index 0000000..fe7b619 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Configuration/Queries/GetConfigChangeHistory/GetConfigChangeHistoryQuery.cs @@ -0,0 +1,8 @@ +using Baya.Application.Models.Common; +using Baya.Application.Models.Configuration; +using Mediator; + +namespace Baya.Application.Features.Configuration.Queries.GetConfigChangeHistory; + +public record GetConfigChangeHistoryQuery(string Key, int Page = 1, int PageSize = 50) + : IRequest>>; diff --git a/server/src/Core/Baya.Application/Features/Configuration/Queries/ListPlatformConfigs/ListPlatformConfigsQuery.Handler.cs b/server/src/Core/Baya.Application/Features/Configuration/Queries/ListPlatformConfigs/ListPlatformConfigsQuery.Handler.cs new file mode 100644 index 0000000..17c75b1 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Configuration/Queries/ListPlatformConfigs/ListPlatformConfigsQuery.Handler.cs @@ -0,0 +1,18 @@ +using Baya.Application.Common; +using Baya.Application.Contracts.Configuration; +using Baya.Application.Models.Common; +using Baya.Application.Models.Configuration; +using Mediator; + +namespace Baya.Application.Features.Configuration.Queries.ListPlatformConfigs; + +internal sealed class ListPlatformConfigsQueryHandler(IPlatformConfig platformConfig) + : IRequestHandler>> +{ + public async ValueTask>> Handle(ListPlatformConfigsQuery request, CancellationToken cancellationToken) + { + var (page, pageSize) = Pagination.Normalize(request.Page, request.PageSize); + var result = await platformConfig.ListAsync(page, pageSize, cancellationToken); + return OperationResult>.SuccessResult(result); + } +} diff --git a/server/src/Core/Baya.Application/Features/Configuration/Queries/ListPlatformConfigs/ListPlatformConfigsQuery.cs b/server/src/Core/Baya.Application/Features/Configuration/Queries/ListPlatformConfigs/ListPlatformConfigsQuery.cs new file mode 100644 index 0000000..1c8294a --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Configuration/Queries/ListPlatformConfigs/ListPlatformConfigsQuery.cs @@ -0,0 +1,8 @@ +using Baya.Application.Models.Common; +using Baya.Application.Models.Configuration; +using Mediator; + +namespace Baya.Application.Features.Configuration.Queries.ListPlatformConfigs; + +public record ListPlatformConfigsQuery(int Page = 1, int PageSize = 50) + : IRequest>>; diff --git a/server/src/Core/Baya.Application/Features/Holidays/Commands/DeleteHoliday/DeleteHolidayCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Holidays/Commands/DeleteHoliday/DeleteHolidayCommand.Handler.cs new file mode 100644 index 0000000..ce4d9c7 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Holidays/Commands/DeleteHoliday/DeleteHolidayCommand.Handler.cs @@ -0,0 +1,18 @@ +using Baya.Application.Contracts.Holidays; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Holidays.Commands.DeleteHoliday; + +internal sealed class DeleteHolidayCommandHandler(IHolidayCalendar holidayCalendar) + : IRequestHandler> +{ + public async ValueTask> Handle(DeleteHolidayCommand request, CancellationToken cancellationToken) + { + var deleted = await holidayCalendar.DeleteAsync(request.HolidayDate, cancellationToken); + + return deleted + ? OperationResult.SuccessResult(true) + : OperationResult.NotFoundResult($"No holiday exists on {request.HolidayDate:yyyy-MM-dd}."); + } +} diff --git a/server/src/Core/Baya.Application/Features/Holidays/Commands/DeleteHoliday/DeleteHolidayCommand.cs b/server/src/Core/Baya.Application/Features/Holidays/Commands/DeleteHoliday/DeleteHolidayCommand.cs new file mode 100644 index 0000000..71c95d4 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Holidays/Commands/DeleteHoliday/DeleteHolidayCommand.cs @@ -0,0 +1,6 @@ +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Holidays.Commands.DeleteHoliday; + +public record DeleteHolidayCommand(DateOnly HolidayDate) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Holidays/Commands/UpsertHoliday/UpsertHolidayCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Holidays/Commands/UpsertHoliday/UpsertHolidayCommand.Handler.cs new file mode 100644 index 0000000..4872413 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Holidays/Commands/UpsertHoliday/UpsertHolidayCommand.Handler.cs @@ -0,0 +1,15 @@ +using Baya.Application.Contracts.Holidays; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Holidays.Commands.UpsertHoliday; + +internal sealed class UpsertHolidayCommandHandler(IHolidayCalendar holidayCalendar) + : IRequestHandler> +{ + public async ValueTask> Handle(UpsertHolidayCommand request, CancellationToken cancellationToken) + { + await holidayCalendar.UpsertAsync(request.HolidayDate, request.NameFa, request.Type, request.IsBankClosed, cancellationToken); + return OperationResult.SuccessResult(true); + } +} diff --git a/server/src/Core/Baya.Application/Features/Holidays/Commands/UpsertHoliday/UpsertHolidayCommand.Validator.cs b/server/src/Core/Baya.Application/Features/Holidays/Commands/UpsertHoliday/UpsertHolidayCommand.Validator.cs new file mode 100644 index 0000000..129b682 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Holidays/Commands/UpsertHoliday/UpsertHolidayCommand.Validator.cs @@ -0,0 +1,16 @@ +using Baya.Domain.Entities.Holidays; +using FluentValidation; + +namespace Baya.Application.Features.Holidays.Commands.UpsertHoliday; + +public sealed class UpsertHolidayCommandValidator : AbstractValidator +{ + public UpsertHolidayCommandValidator() + { + RuleFor(x => x.HolidayDate).NotEqual(default(DateOnly)); + RuleFor(x => x.NameFa).NotEmpty().MaximumLength(200); + RuleFor(x => x.Type) + .Must(t => t is HolidayType.Official or HolidayType.Religious or HolidayType.National) + .WithMessage($"Type must be one of: {HolidayType.Official}, {HolidayType.Religious}, {HolidayType.National}."); + } +} diff --git a/server/src/Core/Baya.Application/Features/Holidays/Commands/UpsertHoliday/UpsertHolidayCommand.cs b/server/src/Core/Baya.Application/Features/Holidays/Commands/UpsertHoliday/UpsertHolidayCommand.cs new file mode 100644 index 0000000..ba88600 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Holidays/Commands/UpsertHoliday/UpsertHolidayCommand.cs @@ -0,0 +1,7 @@ +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Holidays.Commands.UpsertHoliday; + +public record UpsertHolidayCommand(DateOnly HolidayDate, string NameFa, string Type, bool IsBankClosed) + : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Holidays/Queries/ListHolidays/ListHolidaysQuery.Handler.cs b/server/src/Core/Baya.Application/Features/Holidays/Queries/ListHolidays/ListHolidaysQuery.Handler.cs new file mode 100644 index 0000000..dd2787f --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Holidays/Queries/ListHolidays/ListHolidaysQuery.Handler.cs @@ -0,0 +1,18 @@ +using Baya.Application.Common; +using Baya.Application.Contracts.Holidays; +using Baya.Application.Models.Common; +using Baya.Application.Models.Holidays; +using Mediator; + +namespace Baya.Application.Features.Holidays.Queries.ListHolidays; + +internal sealed class ListHolidaysQueryHandler(IHolidayCalendar holidayCalendar) + : IRequestHandler>> +{ + public async ValueTask>> Handle(ListHolidaysQuery request, CancellationToken cancellationToken) + { + var (page, pageSize) = Pagination.Normalize(request.Page, request.PageSize); + var result = await holidayCalendar.ListAsync(request.From, request.To, page, pageSize, cancellationToken); + return OperationResult>.SuccessResult(result); + } +} diff --git a/server/src/Core/Baya.Application/Features/Holidays/Queries/ListHolidays/ListHolidaysQuery.cs b/server/src/Core/Baya.Application/Features/Holidays/Queries/ListHolidays/ListHolidaysQuery.cs new file mode 100644 index 0000000..c52e59f --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Holidays/Queries/ListHolidays/ListHolidaysQuery.cs @@ -0,0 +1,9 @@ +#nullable enable +using Baya.Application.Models.Common; +using Baya.Application.Models.Holidays; +using Mediator; + +namespace Baya.Application.Features.Holidays.Queries.ListHolidays; + +public record ListHolidaysQuery(DateOnly? From = null, DateOnly? To = null, int Page = 1, int PageSize = 50) + : IRequest>>; diff --git a/server/src/Core/Baya.Application/Features/Notifications/Commands/MarkAllRead/MarkAllReadCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Notifications/Commands/MarkAllRead/MarkAllReadCommand.Handler.cs new file mode 100644 index 0000000..821db0d --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Notifications/Commands/MarkAllRead/MarkAllReadCommand.Handler.cs @@ -0,0 +1,19 @@ +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Notifications; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Notifications.Commands.MarkAllRead; + +internal sealed class MarkAllReadCommandHandler(INotificationService notifications, ICurrentUser currentUser) + : IRequestHandler> +{ + public async ValueTask> Handle(MarkAllReadCommand request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult.FailureResult("User", "Not authenticated."); + + await notifications.MarkAllReadAsync(userId, cancellationToken); + return OperationResult.SuccessResult(true); + } +} diff --git a/server/src/Core/Baya.Application/Features/Notifications/Commands/MarkAllRead/MarkAllReadCommand.cs b/server/src/Core/Baya.Application/Features/Notifications/Commands/MarkAllRead/MarkAllReadCommand.cs new file mode 100644 index 0000000..a3f5ad5 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Notifications/Commands/MarkAllRead/MarkAllReadCommand.cs @@ -0,0 +1,6 @@ +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Notifications.Commands.MarkAllRead; + +public record MarkAllReadCommand : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Notifications/Commands/MarkNotificationRead/MarkNotificationReadCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Notifications/Commands/MarkNotificationRead/MarkNotificationReadCommand.Handler.cs new file mode 100644 index 0000000..17a4f9d --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Notifications/Commands/MarkNotificationRead/MarkNotificationReadCommand.Handler.cs @@ -0,0 +1,22 @@ +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Notifications; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Notifications.Commands.MarkNotificationRead; + +internal sealed class MarkNotificationReadCommandHandler(INotificationService notifications, ICurrentUser currentUser) + : IRequestHandler> +{ + public async ValueTask> Handle(MarkNotificationReadCommand request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult.FailureResult("User", "Not authenticated."); + + var marked = await notifications.MarkReadAsync(userId, request.NotificationId, cancellationToken); + + return marked + ? OperationResult.SuccessResult(true) + : OperationResult.NotFoundResult($"Notification {request.NotificationId} was not found."); + } +} diff --git a/server/src/Core/Baya.Application/Features/Notifications/Commands/MarkNotificationRead/MarkNotificationReadCommand.Validator.cs b/server/src/Core/Baya.Application/Features/Notifications/Commands/MarkNotificationRead/MarkNotificationReadCommand.Validator.cs new file mode 100644 index 0000000..d0e10c0 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Notifications/Commands/MarkNotificationRead/MarkNotificationReadCommand.Validator.cs @@ -0,0 +1,11 @@ +using FluentValidation; + +namespace Baya.Application.Features.Notifications.Commands.MarkNotificationRead; + +public sealed class MarkNotificationReadCommandValidator : AbstractValidator +{ + public MarkNotificationReadCommandValidator() + { + RuleFor(x => x.NotificationId).GreaterThan(0); + } +} diff --git a/server/src/Core/Baya.Application/Features/Notifications/Commands/MarkNotificationRead/MarkNotificationReadCommand.cs b/server/src/Core/Baya.Application/Features/Notifications/Commands/MarkNotificationRead/MarkNotificationReadCommand.cs new file mode 100644 index 0000000..4ff0b9d --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Notifications/Commands/MarkNotificationRead/MarkNotificationReadCommand.cs @@ -0,0 +1,6 @@ +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Notifications.Commands.MarkNotificationRead; + +public record MarkNotificationReadCommand(long NotificationId) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Notifications/Queries/GetUnreadCount/GetUnreadCountQuery.Handler.cs b/server/src/Core/Baya.Application/Features/Notifications/Queries/GetUnreadCount/GetUnreadCountQuery.Handler.cs new file mode 100644 index 0000000..03ed8e1 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Notifications/Queries/GetUnreadCount/GetUnreadCountQuery.Handler.cs @@ -0,0 +1,19 @@ +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Notifications; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Notifications.Queries.GetUnreadCount; + +internal sealed class GetUnreadCountQueryHandler(INotificationService notifications, ICurrentUser currentUser) + : IRequestHandler> +{ + public async ValueTask> Handle(GetUnreadCountQuery request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult.FailureResult("User", "Not authenticated."); + + var count = await notifications.GetUnreadCountAsync(userId, cancellationToken); + return OperationResult.SuccessResult(new UnreadCountResult(count)); + } +} diff --git a/server/src/Core/Baya.Application/Features/Notifications/Queries/GetUnreadCount/GetUnreadCountQuery.Result.cs b/server/src/Core/Baya.Application/Features/Notifications/Queries/GetUnreadCount/GetUnreadCountQuery.Result.cs new file mode 100644 index 0000000..1b160c5 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Notifications/Queries/GetUnreadCount/GetUnreadCountQuery.Result.cs @@ -0,0 +1,4 @@ +namespace Baya.Application.Features.Notifications.Queries.GetUnreadCount; + +/// Unread-notification count for the polling bell. +public record UnreadCountResult(int Count); diff --git a/server/src/Core/Baya.Application/Features/Notifications/Queries/GetUnreadCount/GetUnreadCountQuery.cs b/server/src/Core/Baya.Application/Features/Notifications/Queries/GetUnreadCount/GetUnreadCountQuery.cs new file mode 100644 index 0000000..d12f256 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Notifications/Queries/GetUnreadCount/GetUnreadCountQuery.cs @@ -0,0 +1,6 @@ +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Notifications.Queries.GetUnreadCount; + +public record GetUnreadCountQuery : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Notifications/Queries/ListMyNotifications/ListMyNotificationsQuery.Handler.cs b/server/src/Core/Baya.Application/Features/Notifications/Queries/ListMyNotifications/ListMyNotificationsQuery.Handler.cs new file mode 100644 index 0000000..db8299b --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Notifications/Queries/ListMyNotifications/ListMyNotificationsQuery.Handler.cs @@ -0,0 +1,22 @@ +using Baya.Application.Common; +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Notifications; +using Baya.Application.Models.Common; +using Baya.Application.Models.Notifications; +using Mediator; + +namespace Baya.Application.Features.Notifications.Queries.ListMyNotifications; + +internal sealed class ListMyNotificationsQueryHandler(INotificationService notifications, ICurrentUser currentUser) + : IRequestHandler>> +{ + public async ValueTask>> Handle(ListMyNotificationsQuery request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult>.FailureResult("User", "Not authenticated."); + + var (page, pageSize) = Pagination.Normalize(request.Page, request.PageSize); + var result = await notifications.ListMineAsync(userId, page, pageSize, cancellationToken); + return OperationResult>.SuccessResult(result); + } +} diff --git a/server/src/Core/Baya.Application/Features/Notifications/Queries/ListMyNotifications/ListMyNotificationsQuery.cs b/server/src/Core/Baya.Application/Features/Notifications/Queries/ListMyNotifications/ListMyNotificationsQuery.cs new file mode 100644 index 0000000..d2fe9a5 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Notifications/Queries/ListMyNotifications/ListMyNotificationsQuery.cs @@ -0,0 +1,8 @@ +using Baya.Application.Models.Common; +using Baya.Application.Models.Notifications; +using Mediator; + +namespace Baya.Application.Features.Notifications.Queries.ListMyNotifications; + +public record ListMyNotificationsQuery(int Page = 1, int PageSize = 50) + : IRequest>>; diff --git a/server/src/Core/Baya.Application/Features/SupportAlerts/Commands/AssignSupportAlert/AssignSupportAlertCommand.Handler.cs b/server/src/Core/Baya.Application/Features/SupportAlerts/Commands/AssignSupportAlert/AssignSupportAlertCommand.Handler.cs new file mode 100644 index 0000000..f892642 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/SupportAlerts/Commands/AssignSupportAlert/AssignSupportAlertCommand.Handler.cs @@ -0,0 +1,18 @@ +using Baya.Application.Contracts.SupportAlerts; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.SupportAlerts.Commands.AssignSupportAlert; + +internal sealed class AssignSupportAlertCommandHandler(ISupportAlertService supportAlerts) + : IRequestHandler> +{ + public async ValueTask> Handle(AssignSupportAlertCommand request, CancellationToken cancellationToken) + { + var assigned = await supportAlerts.AssignAsync(request.AlertId, request.OwnerUserId, cancellationToken); + + return assigned + ? OperationResult.SuccessResult(true) + : OperationResult.NotFoundResult($"Support alert {request.AlertId} was not found or is already resolved."); + } +} diff --git a/server/src/Core/Baya.Application/Features/SupportAlerts/Commands/AssignSupportAlert/AssignSupportAlertCommand.Validator.cs b/server/src/Core/Baya.Application/Features/SupportAlerts/Commands/AssignSupportAlert/AssignSupportAlertCommand.Validator.cs new file mode 100644 index 0000000..bb775da --- /dev/null +++ b/server/src/Core/Baya.Application/Features/SupportAlerts/Commands/AssignSupportAlert/AssignSupportAlertCommand.Validator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace Baya.Application.Features.SupportAlerts.Commands.AssignSupportAlert; + +public sealed class AssignSupportAlertCommandValidator : AbstractValidator +{ + public AssignSupportAlertCommandValidator() + { + RuleFor(x => x.AlertId).GreaterThan(0); + RuleFor(x => x.OwnerUserId).GreaterThan(0); + } +} diff --git a/server/src/Core/Baya.Application/Features/SupportAlerts/Commands/AssignSupportAlert/AssignSupportAlertCommand.cs b/server/src/Core/Baya.Application/Features/SupportAlerts/Commands/AssignSupportAlert/AssignSupportAlertCommand.cs new file mode 100644 index 0000000..59b2ebb --- /dev/null +++ b/server/src/Core/Baya.Application/Features/SupportAlerts/Commands/AssignSupportAlert/AssignSupportAlertCommand.cs @@ -0,0 +1,6 @@ +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.SupportAlerts.Commands.AssignSupportAlert; + +public record AssignSupportAlertCommand(long AlertId, int OwnerUserId) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/SupportAlerts/Commands/ResolveSupportAlert/ResolveSupportAlertCommand.Handler.cs b/server/src/Core/Baya.Application/Features/SupportAlerts/Commands/ResolveSupportAlert/ResolveSupportAlertCommand.Handler.cs new file mode 100644 index 0000000..2464efc --- /dev/null +++ b/server/src/Core/Baya.Application/Features/SupportAlerts/Commands/ResolveSupportAlert/ResolveSupportAlertCommand.Handler.cs @@ -0,0 +1,18 @@ +using Baya.Application.Contracts.SupportAlerts; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.SupportAlerts.Commands.ResolveSupportAlert; + +internal sealed class ResolveSupportAlertCommandHandler(ISupportAlertService supportAlerts) + : IRequestHandler> +{ + public async ValueTask> Handle(ResolveSupportAlertCommand request, CancellationToken cancellationToken) + { + var resolved = await supportAlerts.ResolveAsync(request.AlertId, request.Note, cancellationToken); + + return resolved + ? OperationResult.SuccessResult(true) + : OperationResult.NotFoundResult($"Support alert {request.AlertId} was not found or is already resolved."); + } +} diff --git a/server/src/Core/Baya.Application/Features/SupportAlerts/Commands/ResolveSupportAlert/ResolveSupportAlertCommand.Validator.cs b/server/src/Core/Baya.Application/Features/SupportAlerts/Commands/ResolveSupportAlert/ResolveSupportAlertCommand.Validator.cs new file mode 100644 index 0000000..31f6555 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/SupportAlerts/Commands/ResolveSupportAlert/ResolveSupportAlertCommand.Validator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace Baya.Application.Features.SupportAlerts.Commands.ResolveSupportAlert; + +public sealed class ResolveSupportAlertCommandValidator : AbstractValidator +{ + public ResolveSupportAlertCommandValidator() + { + RuleFor(x => x.AlertId).GreaterThan(0); + RuleFor(x => x.Note).NotEmpty().MaximumLength(1000); + } +} diff --git a/server/src/Core/Baya.Application/Features/SupportAlerts/Commands/ResolveSupportAlert/ResolveSupportAlertCommand.cs b/server/src/Core/Baya.Application/Features/SupportAlerts/Commands/ResolveSupportAlert/ResolveSupportAlertCommand.cs new file mode 100644 index 0000000..14df4d4 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/SupportAlerts/Commands/ResolveSupportAlert/ResolveSupportAlertCommand.cs @@ -0,0 +1,6 @@ +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.SupportAlerts.Commands.ResolveSupportAlert; + +public record ResolveSupportAlertCommand(long AlertId, string Note) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/SupportAlerts/Queries/ListSupportAlerts/ListSupportAlertsQuery.Handler.cs b/server/src/Core/Baya.Application/Features/SupportAlerts/Queries/ListSupportAlerts/ListSupportAlertsQuery.Handler.cs new file mode 100644 index 0000000..5c2dc92 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/SupportAlerts/Queries/ListSupportAlerts/ListSupportAlertsQuery.Handler.cs @@ -0,0 +1,18 @@ +using Baya.Application.Common; +using Baya.Application.Contracts.SupportAlerts; +using Baya.Application.Models.Common; +using Baya.Application.Models.SupportAlerts; +using Mediator; + +namespace Baya.Application.Features.SupportAlerts.Queries.ListSupportAlerts; + +internal sealed class ListSupportAlertsQueryHandler(ISupportAlertService supportAlerts) + : IRequestHandler>> +{ + public async ValueTask>> Handle(ListSupportAlertsQuery request, CancellationToken cancellationToken) + { + var (page, pageSize) = Pagination.Normalize(request.Page, request.PageSize); + var result = await supportAlerts.ListAsync(request.Type, request.Status, request.OwnerUserId, page, pageSize, cancellationToken); + return OperationResult>.SuccessResult(result); + } +} diff --git a/server/src/Core/Baya.Application/Features/SupportAlerts/Queries/ListSupportAlerts/ListSupportAlertsQuery.cs b/server/src/Core/Baya.Application/Features/SupportAlerts/Queries/ListSupportAlerts/ListSupportAlertsQuery.cs new file mode 100644 index 0000000..c32ba13 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/SupportAlerts/Queries/ListSupportAlerts/ListSupportAlertsQuery.cs @@ -0,0 +1,13 @@ +#nullable enable +using Baya.Application.Models.Common; +using Baya.Application.Models.SupportAlerts; +using Mediator; + +namespace Baya.Application.Features.SupportAlerts.Queries.ListSupportAlerts; + +public record ListSupportAlertsQuery( + string? Type = null, + string? Status = null, + int? OwnerUserId = null, + int Page = 1, + int PageSize = 50) : IRequest>>; diff --git a/server/src/Core/Baya.Application/Models/Audit/AuditLogDto.cs b/server/src/Core/Baya.Application/Models/Audit/AuditLogDto.cs new file mode 100644 index 0000000..d3303e3 --- /dev/null +++ b/server/src/Core/Baya.Application/Models/Audit/AuditLogDto.cs @@ -0,0 +1,12 @@ +#nullable enable +namespace Baya.Application.Models.Audit; + +/// One immutable audit-trail row. +public record AuditLogDto( + long Id, + string EntityType, + string EntityId, + string Action, + string? ChangedFieldsJson, + int? ActorUserId, + DateTimeOffset OccurredAt); diff --git a/server/src/Core/Baya.Application/Models/Common/PagedResult.cs b/server/src/Core/Baya.Application/Models/Common/PagedResult.cs new file mode 100644 index 0000000..d3884a7 --- /dev/null +++ b/server/src/Core/Baya.Application/Models/Common/PagedResult.cs @@ -0,0 +1,4 @@ +namespace Baya.Application.Models.Common; + +/// Standard paginated payload: the page of plus the total row count. +public record PagedResult(IReadOnlyList Items, int Total, int Page, int PageSize); diff --git a/server/src/Core/Baya.Application/Models/Configuration/PlatformConfigDto.cs b/server/src/Core/Baya.Application/Models/Configuration/PlatformConfigDto.cs new file mode 100644 index 0000000..a0019fd --- /dev/null +++ b/server/src/Core/Baya.Application/Models/Configuration/PlatformConfigDto.cs @@ -0,0 +1,13 @@ +#nullable enable +namespace Baya.Application.Models.Configuration; + +/// A runtime config row as returned to admins. Value is the raw string; parse per DataType. +public record PlatformConfigDto(string Key, string Value, string DataType, string? Description); + +/// One audited change to a config key (from the append-only audit trail). +public record ConfigChangeDto( + long Id, + string Action, + string? ChangedFieldsJson, + int? ActorUserId, + DateTimeOffset OccurredAt); diff --git a/server/src/Core/Baya.Application/Models/Holidays/HolidayDto.cs b/server/src/Core/Baya.Application/Models/Holidays/HolidayDto.cs new file mode 100644 index 0000000..b1861e3 --- /dev/null +++ b/server/src/Core/Baya.Application/Models/Holidays/HolidayDto.cs @@ -0,0 +1,4 @@ +namespace Baya.Application.Models.Holidays; + +/// A calendar day in the Iranian holiday table. +public record HolidayDto(long Id, DateOnly HolidayDate, string NameFa, string Type, bool IsBankClosed); diff --git a/server/src/Core/Baya.Application/Models/Notifications/NotificationDto.cs b/server/src/Core/Baya.Application/Models/Notifications/NotificationDto.cs new file mode 100644 index 0000000..5ff7d83 --- /dev/null +++ b/server/src/Core/Baya.Application/Models/Notifications/NotificationDto.cs @@ -0,0 +1,13 @@ +#nullable enable +namespace Baya.Application.Models.Notifications; + +/// An in-app notification as returned to its owner. DataJson is the typed deep-link payload. +public record NotificationDto( + long Id, + string Type, + string Title, + string? Body, + string? DataJson, + bool IsRead, + DateTimeOffset? ReadAt, + DateTimeOffset CreatedAt); diff --git a/server/src/Core/Baya.Application/Models/SupportAlerts/SupportAlertDto.cs b/server/src/Core/Baya.Application/Models/SupportAlerts/SupportAlertDto.cs new file mode 100644 index 0000000..ae2ca3d --- /dev/null +++ b/server/src/Core/Baya.Application/Models/SupportAlerts/SupportAlertDto.cs @@ -0,0 +1,17 @@ +#nullable enable +namespace Baya.Application.Models.SupportAlerts; + +/// An internal support-alert row (admin-only — never returned on a user-facing endpoint). +public record SupportAlertDto( + long Id, + string Type, + string Severity, + string Status, + string EntityType, + string EntityId, + long? BookingId, + long? ReviewId, + int? OwnerUserId, + string? ResolutionNote, + DateTimeOffset? ResolvedAt, + DateTimeOffset CreatedAt); diff --git a/server/src/Core/Baya.Domain/Common/IAuditable.cs b/server/src/Core/Baya.Domain/Common/IAuditable.cs new file mode 100644 index 0000000..cf3f234 --- /dev/null +++ b/server/src/Core/Baya.Domain/Common/IAuditable.cs @@ -0,0 +1,22 @@ +namespace Baya.Domain.Common; + +/// +/// Marks an entity whose row-level changes are written to the append-only audit_logs trail by the +/// SaveChanges audit interceptor. This is distinct from (which only stamps +/// the create/modify audit fields): implementing IAuditable additionally produces an +/// immutable audit-log row per insert/update/delete. Reserve it for compliance-sensitive entities — +/// platform_configs is auditable so finance can prove the exact rate in effect at any moment. +/// +public interface IAuditable : IEntity +{ +} + +/// +/// Applied to a property of an entity whose value must never appear in the +/// audit diff (changed_fields_json). The interceptor writes a redaction marker instead of the +/// plaintext — used for encrypted/PII columns. +/// +[AttributeUsage(AttributeTargets.Property)] +public sealed class AuditRedactedAttribute : Attribute +{ +} diff --git a/server/src/Core/Baya.Domain/Entities/Analytics/SystemEvent.cs b/server/src/Core/Baya.Domain/Entities/Analytics/SystemEvent.cs new file mode 100644 index 0000000..53cc91d --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Analytics/SystemEvent.cs @@ -0,0 +1,21 @@ +#nullable enable +using Baya.Domain.Common; + +namespace Baya.Domain.Entities.Analytics; + +/// +/// High-volume behavioural/analytics event. NOT compliance evidence — it can be sampled, dropped, or +/// exported to a warehouse at scale. Append-only; the only timestamp it needs is . +/// +public class SystemEvent : IEntity +{ + public long Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public string? PropsJson { get; set; } + + public int? UserId { get; set; } + + public DateTimeOffset OccurredAt { get; set; } +} diff --git a/server/src/Core/Baya.Domain/Entities/Audit/AuditLog.cs b/server/src/Core/Baya.Domain/Entities/Audit/AuditLog.cs new file mode 100644 index 0000000..44ca1a0 --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Audit/AuditLog.cs @@ -0,0 +1,34 @@ +#nullable enable +using Baya.Domain.Common; + +namespace Baya.Domain.Entities.Audit; + +/// +/// Immutable, append-only record of a state change on a compliance-sensitive entity. Never updated or +/// deleted in app code — it is the system of record for disputes/finance. is a +/// string so the trail is polymorphic across differently-typed primary keys. +/// +public class AuditLog : IEntity +{ + public long Id { get; set; } + + public string EntityType { get; set; } = string.Empty; + + public string EntityId { get; set; } = string.Empty; + + public string Action { get; set; } = AuditAction.Updated; + + public string? ChangedFieldsJson { get; set; } + + public int? ActorUserId { get; set; } + + public DateTimeOffset OccurredAt { get; set; } +} + +/// Stable codes for . +public static class AuditAction +{ + public const string Created = "created"; + public const string Updated = "updated"; + public const string Deleted = "deleted"; +} diff --git a/server/src/Core/Baya.Domain/Entities/Configuration/PlatformConfig.cs b/server/src/Core/Baya.Domain/Entities/Configuration/PlatformConfig.cs new file mode 100644 index 0000000..bcf0f0d --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Configuration/PlatformConfig.cs @@ -0,0 +1,31 @@ +#nullable enable +using Baya.Domain.Common; + +namespace Baya.Domain.Entities.Configuration; + +/// +/// A typed key-value runtime business parameter (commission rate, VAT, deadlines…). The app parses +/// according to . Every change is audited (the type implements +/// ); there is no soft-delete — configs are updated in place and the audit trail +/// is their history. +/// +public class PlatformConfig : BaseEntity, IAuditable +{ + public string Key { get; set; } = string.Empty; + + public string Value { get; set; } = string.Empty; + + public string DataType { get; set; } = ConfigDataType.String; + + public string? Description { get; set; } +} + +/// Stable codes for — tells the app how to parse the raw value. +public static class ConfigDataType +{ + public const string Decimal = "decimal"; + public const string Int = "int"; + public const string Bool = "bool"; + public const string String = "string"; + public const string Json = "json"; +} diff --git a/server/src/Core/Baya.Domain/Entities/Holidays/IranianHoliday.cs b/server/src/Core/Baya.Domain/Entities/Holidays/IranianHoliday.cs new file mode 100644 index 0000000..43ca4c1 --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Holidays/IranianHoliday.cs @@ -0,0 +1,28 @@ +using Baya.Domain.Common; + +namespace Baya.Domain.Entities.Holidays; + +/// +/// A single day in the shared Iranian official/religious/national calendar. +/// drives payout date shifting — when PAYA/SATNA banks are closed a weekly payout moves to the next +/// business day. The calendar is partly movable/lunar-Hijri, so the table is maintained rather than +/// computed. +/// +public class IranianHoliday : BaseEntity +{ + public DateOnly HolidayDate { get; set; } + + public string NameFa { get; set; } = string.Empty; + + public string Type { get; set; } = HolidayType.Official; + + public bool IsBankClosed { get; set; } +} + +/// Stable codes for . +public static class HolidayType +{ + public const string Official = "official"; + public const string Religious = "religious"; + public const string National = "national"; +} diff --git a/server/src/Core/Baya.Domain/Entities/Notifications/Notification.cs b/server/src/Core/Baya.Domain/Entities/Notifications/Notification.cs new file mode 100644 index 0000000..c94031a --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Notifications/Notification.cs @@ -0,0 +1,30 @@ +#nullable enable +using Baya.Domain.Common; + +namespace Baya.Domain.Entities.Notifications; + +/// +/// An in-app notification for a single user. is a typed, versioned deep-link +/// payload the front-end navigates on — not an arbitrary blob. Read notifications older than 90 days are +/// hard-deleted by the retention job; unread ones are never auto-deleted. +/// +public class Notification : IEntity +{ + public long Id { get; set; } + + public int UserId { get; set; } + + public string Type { get; set; } = string.Empty; + + public string Title { get; set; } = string.Empty; + + public string? Body { get; set; } + + public string? DataJson { get; set; } + + public bool IsRead { get; set; } + + public DateTimeOffset? ReadAt { get; set; } + + public DateTimeOffset CreatedAt { get; set; } +} diff --git a/server/src/Core/Baya.Domain/Entities/SupportAlerts/SupportAlert.cs b/server/src/Core/Baya.Domain/Entities/SupportAlerts/SupportAlert.cs new file mode 100644 index 0000000..da4ef7e --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/SupportAlerts/SupportAlert.cs @@ -0,0 +1,71 @@ +#nullable enable +using Baya.Domain.Common; + +namespace Baya.Domain.Entities.SupportAlerts; + +/// +/// An internal staff worklist item (low rating, EVV no-show, expired verification, payment anomaly…). +/// NEVER user-facing — it must not appear in any user-facing endpoint, query, or join. The subject is a +/// polymorphic (EntityType, EntityId) validated at the application layer (no DB FK); the common +/// booking/review cases also set the typed FK. Status is forward-only: open → assigned → resolved. +/// +public class SupportAlert : BaseEntity +{ + public string Type { get; set; } = string.Empty; + + public string Severity { get; set; } = SupportAlertSeverity.Medium; + + public string Status { get; set; } = SupportAlertStatus.Open; + + public string EntityType { get; set; } = string.Empty; + + public string EntityId { get; set; } = string.Empty; + + // Typed FK columns for the common cases. The bookings/reviews tables arrive in later phases; the FK + // constraints are added there, so no relationship is configured now (the migration stays additive-safe). + public long? BookingId { get; set; } + + public long? ReviewId { get; set; } + + public int? OwnerUserId { get; set; } + + public string? ResolutionNote { get; set; } + + public DateTimeOffset? ResolvedAt { get; set; } +} + +/// Stable codes for . +public static class SupportAlertType +{ + public const string LowRating = "low_rating"; + public const string EvvNoShow = "evv_no_show"; + public const string EvvLocationMismatch = "evv_location_mismatch"; + public const string VerificationExpired = "verification_expired"; + public const string PaymentAnomaly = "payment_anomaly"; + public const string FraudSignal = "fraud_signal"; + + public static readonly IReadOnlyList All = + [ + LowRating, EvvNoShow, EvvLocationMismatch, VerificationExpired, PaymentAnomaly, FraudSignal + ]; +} + +/// Stable codes for . +public static class SupportAlertSeverity +{ + public const string Low = "low"; + public const string Medium = "medium"; + public const string High = "high"; + + public static readonly IReadOnlyList All = [Low, Medium, High]; +} + +/// Stable codes for (forward-only). +public static class SupportAlertStatus +{ + public const string Open = "open"; + public const string Assigned = "assigned"; + public const string Resolved = "resolved"; + + public static readonly IReadOnlyList All = [Open, Assigned, Resolved]; +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/LogNotificationDispatcher.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/LogNotificationDispatcher.cs deleted file mode 100644 index fd2078f..0000000 --- a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/LogNotificationDispatcher.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Baya.Application.Contracts.Common; -using Microsoft.Extensions.Logging; - -namespace Baya.Infrastructure.CrossCutting.Seams; - -/// -/// No-op implementation of — the mock seam. It logs that a -/// notification would be sent (no PII in the log). The real in-app write lands in backend-phase-15, -/// with SMS/push channels added behind the same interface. -/// -public sealed class LogNotificationDispatcher(ILogger logger) : INotificationDispatcher -{ - public ValueTask DispatchAsync(Notification notification, CancellationToken cancellationToken = default) - { - logger.LogInformation( - "Notification suppressed (mock dispatcher): channel {Channel} to user {UserId}", - notification.Channel, - notification.RecipientUserId); - - return ValueTask.CompletedTask; - } -} diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs index 61d2c30..a28db14 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs @@ -8,9 +8,10 @@ namespace Baya.Infrastructure.CrossCutting.ServiceConfiguration; public static class ServiceCollectionExtension { /// - /// Registers the cross-cutting seams (time, PII encryption, cache, object storage, notifications) - /// with their in-memory/local mock implementations. Swapping in a real provider later is a - /// registration change here — callers depend only on the Application contracts. + /// Registers the cross-cutting seams (time, PII encryption, cache, object storage) with their + /// in-memory/local mock implementations. Swapping in a real provider later is a registration change + /// here — callers depend only on the Application contracts. (The real in-app + /// INotificationDispatcher needs the database, so it is registered in the Persistence layer.) /// public static IServiceCollection AddCrossCuttingSeams(this IServiceCollection services, IConfiguration configuration) { @@ -22,7 +23,6 @@ public static class ServiceCollectionExtension services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddScoped(); return services; } diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Baya.Infrastructure.Persistence.csproj b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Baya.Infrastructure.Persistence.csproj index 89c515e..65b602c 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Baya.Infrastructure.Persistence.csproj +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Baya.Infrastructure.Persistence.csproj @@ -13,6 +13,10 @@ + + + + diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/AnalyticsConfig/SystemEventConfig.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/AnalyticsConfig/SystemEventConfig.cs new file mode 100644 index 0000000..3a9933e --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/AnalyticsConfig/SystemEventConfig.cs @@ -0,0 +1,24 @@ +using Baya.Domain.Entities.Analytics; +using Baya.Domain.Entities.User; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Baya.Infrastructure.Persistence.Configuration.AnalyticsConfig; + +internal sealed class SystemEventConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("SystemEvents", "ops"); + + builder.Property(e => e.Name).HasMaxLength(100).IsRequired(); + + builder.HasIndex(e => e.Name); + builder.HasIndex(e => e.OccurredAt); + + builder.HasOne() + .WithMany() + .HasForeignKey(e => e.UserId) + .IsRequired(false); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/AuditConfig/AuditLogConfig.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/AuditConfig/AuditLogConfig.cs new file mode 100644 index 0000000..1f3430a --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/AuditConfig/AuditLogConfig.cs @@ -0,0 +1,26 @@ +using Baya.Domain.Entities.Audit; +using Baya.Domain.Entities.User; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Baya.Infrastructure.Persistence.Configuration.AuditConfig; + +internal sealed class AuditLogConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("AuditLogs", "ops"); + + builder.Property(a => a.EntityType).HasMaxLength(100).IsRequired(); + builder.Property(a => a.EntityId).HasMaxLength(100).IsRequired(); + builder.Property(a => a.Action).HasMaxLength(20).IsRequired(); + + builder.HasIndex(a => new { a.EntityType, a.EntityId }); + builder.HasIndex(a => a.OccurredAt); + + builder.HasOne() + .WithMany() + .HasForeignKey(a => a.ActorUserId) + .IsRequired(false); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/ConfigurationConfig/PlatformConfigConfig.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/ConfigurationConfig/PlatformConfigConfig.cs new file mode 100644 index 0000000..417e8d9 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/ConfigurationConfig/PlatformConfigConfig.cs @@ -0,0 +1,57 @@ +using Baya.Domain.Entities.Configuration; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Baya.Infrastructure.Persistence.Configuration.ConfigurationConfig; + +internal sealed class PlatformConfigConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("PlatformConfigs", "ops"); + + builder.Property(c => c.Key).HasMaxLength(100).IsRequired(); + builder.Property(c => c.Value).IsRequired(); + builder.Property(c => c.DataType).HasMaxLength(20).IsRequired(); + builder.Property(c => c.Description).HasMaxLength(500); + + builder.HasIndex(c => c.Key).IsUnique(); + + builder.HasData(SeedData()); + } + + // Seeded via HasData so the values land with the baseline migration on a fresh DB. Defaults for keys + // the product docs don't pin down (fee/BNPL/cancellation) are decisions recorded in product doc 12. + private static object[] SeedData() + { + var ts = SeedConstants.Timestamp; + + (long Id, string Key, string Value, string DataType, string Description)[] rows = + [ + (1, "platform_fee_rate", "0.15", ConfigDataType.Decimal, "Balinyaar commission rate on the booking gross (fraction)."), + (2, "vat_rate", "0.10", ConfigDataType.Decimal, "VAT rate applied to the commission line only (fraction)."), + (3, "dispute_window_hours", "72", ConfigDataType.Int, "Hours after check-out a booking can be disputed."), + (4, "booking_payment_deadline_minutes", "30", ConfigDataType.Int, "Minutes a family has to pay before a pending booking expires."), + (5, "nurse_response_deadline_hours", "24", ConfigDataType.Int, "Hours a nurse has to accept/decline a booking request."), + (6, "nurse_payout_interval_days", "7", ConfigDataType.Int, "Weekly payout cadence in days."), + (7, "evv_location_tolerance_meters", "200", ConfigDataType.Int, "Allowed EVV check-in distance from the care address."), + (8, "min_rating_for_support_alert", "2", ConfigDataType.Decimal, "A review at or below this rating raises a support alert."), + (9, "bnpl_merchant_of_record", "platform", ConfigDataType.String, "Who is merchant of record for BNPL orders (platform|nurse)."), + (10, "bnpl_provider_commission_rate", "0.07", ConfigDataType.Decimal, "BNPL provider commission rate (fraction)."), + (11, "bnpl_settlement_timing", "immediate", ConfigDataType.String, "When BNPL settles funds to the platform (immediate|deferred)."), + (12, "cancellation_tiers", "[{\"min_hours_before\":48,\"refund_percent\":100},{\"min_hours_before\":24,\"refund_percent\":50},{\"min_hours_before\":0,\"refund_percent\":0}]", ConfigDataType.Json, "Tiered cancellation refund policy: refund_percent by hours before the visit."), + ]; + + return rows + .Select(r => (object)new + { + r.Id, + r.Key, + r.Value, + r.DataType, + r.Description, + CreatedAt = ts + }) + .ToArray(); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/HolidaysConfig/IranianHolidayConfig.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/HolidaysConfig/IranianHolidayConfig.cs new file mode 100644 index 0000000..2acd456 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/HolidaysConfig/IranianHolidayConfig.cs @@ -0,0 +1,50 @@ +using Baya.Domain.Entities.Holidays; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Baya.Infrastructure.Persistence.Configuration.HolidaysConfig; + +internal sealed class IranianHolidayConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("IranianHolidays", "ops"); + + builder.Property(h => h.NameFa).HasMaxLength(200).IsRequired(); + builder.Property(h => h.Type).HasMaxLength(20).IsRequired(); + + builder.HasIndex(h => h.HolidayDate).IsUnique(); + + builder.HasData(SeedData()); + } + + // A representative sample so IsBankClosed/NextBusinessDay are testable. The full, maintained, + // partly-lunar-Hijri feed is deferred behind IHolidayCalendar's "make it real" path. + private static object[] SeedData() + { + var ts = SeedConstants.Timestamp; + + (long Id, DateOnly Date, string NameFa, string Type, bool BankClosed)[] rows = + [ + (1, new DateOnly(2026, 2, 11), "پیروزی انقلاب اسلامی", HolidayType.National, true), + (2, new DateOnly(2026, 3, 21), "نوروز", HolidayType.National, true), + (3, new DateOnly(2026, 3, 22), "نوروز", HolidayType.National, true), + (4, new DateOnly(2026, 3, 23), "نوروز", HolidayType.National, true), + (5, new DateOnly(2026, 3, 24), "نوروز", HolidayType.National, true), + (6, new DateOnly(2026, 4, 1), "روز طبیعت (سیزده‌به‌در)", HolidayType.Official, true), + (7, new DateOnly(2026, 6, 26), "عید سعید قربان", HolidayType.Religious, true), + ]; + + return rows + .Select(r => (object)new + { + r.Id, + HolidayDate = r.Date, + r.NameFa, + r.Type, + IsBankClosed = r.BankClosed, + CreatedAt = ts + }) + .ToArray(); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/NotificationsConfig/NotificationConfig.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/NotificationsConfig/NotificationConfig.cs new file mode 100644 index 0000000..538776d --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/NotificationsConfig/NotificationConfig.cs @@ -0,0 +1,26 @@ +using Baya.Domain.Entities.Notifications; +using Baya.Domain.Entities.User; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Baya.Infrastructure.Persistence.Configuration.NotificationsConfig; + +internal sealed class NotificationConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("Notifications", "ops"); + + builder.Property(n => n.Type).HasMaxLength(100).IsRequired(); + builder.Property(n => n.Title).HasMaxLength(200).IsRequired(); + builder.Property(n => n.IsRead).HasDefaultValue(false); + + // Serves unread-first paging and the cheap unread-count query. + builder.HasIndex(n => new { n.UserId, n.IsRead, n.CreatedAt }); + + builder.HasOne() + .WithMany() + .HasForeignKey(n => n.UserId) + .IsRequired(); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/SeedConstants.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/SeedConstants.cs new file mode 100644 index 0000000..f2f80a6 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/SeedConstants.cs @@ -0,0 +1,10 @@ +namespace Baya.Infrastructure.Persistence.Configuration; + +/// +/// Fixed values used by HasData seeding so the generated migration is deterministic. A literal +/// timestamp (never DateTime.Now) keeps the model snapshot stable across migration regenerations. +/// +internal static class SeedConstants +{ + public static readonly DateTimeOffset Timestamp = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/SupportAlertsConfig/SupportAlertConfig.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/SupportAlertsConfig/SupportAlertConfig.cs new file mode 100644 index 0000000..4aacfef --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/SupportAlertsConfig/SupportAlertConfig.cs @@ -0,0 +1,31 @@ +using Baya.Domain.Entities.SupportAlerts; +using Baya.Domain.Entities.User; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Baya.Infrastructure.Persistence.Configuration.SupportAlertsConfig; + +internal sealed class SupportAlertConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("SupportAlerts", "ops"); + + builder.Property(a => a.Type).HasMaxLength(40).IsRequired(); + builder.Property(a => a.Severity).HasMaxLength(20).IsRequired(); + builder.Property(a => a.Status).HasMaxLength(20).IsRequired(); + builder.Property(a => a.EntityType).HasMaxLength(100).IsRequired(); + builder.Property(a => a.EntityId).HasMaxLength(100).IsRequired(); + + builder.HasIndex(a => a.Status); + builder.HasIndex(a => a.Type); + + builder.HasOne() + .WithMany() + .HasForeignKey(a => a.OwnerUserId) + .IsRequired(false); + + // BookingId/ReviewId are declared columns only — the FK constraints are added by the phases that + // create the bookings/reviews tables (b9/b14), keeping this baseline migration additive-safe. + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Interceptors/AuditFieldInterceptor.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Interceptors/AuditFieldInterceptor.cs index 3c3ff54..40f3d25 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Interceptors/AuditFieldInterceptor.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Interceptors/AuditFieldInterceptor.cs @@ -1,25 +1,36 @@ #nullable enable +using System.Reflection; +using System.Text.Json; using Baya.Application.Contracts.Common; using Baya.Domain.Common; +using Baya.Domain.Entities.Audit; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; using Microsoft.EntityFrameworkCore.Diagnostics; namespace Baya.Infrastructure.Persistence.Interceptors; /// -/// Stamps audit fields on every save: CreatedAt/CreatedById on insert and -/// ModifiedAt/ModifiedById on update, sourcing time from -/// and the acting user from . Handlers never set these fields. -/// This is the extension point backend-phase-1 builds on to also write append-only audit-log rows. +/// On every save this interceptor does two things in the caller's transaction: +/// (1) stamps CreatedAt/CreatedById on insert and ModifiedAt/ModifiedById on +/// update (from + ); and +/// (2) appends an immutable audit_logs row for every change to an entity, +/// with a redacted old/new diff. The audit rows ride the same SaveChanges, so a config change and +/// its audit entry commit atomically. Handlers never set audit fields or write audit rows themselves. /// public sealed class AuditFieldInterceptor(ICurrentUser currentUser, IDateTimeProvider dateTimeProvider) : SaveChangesInterceptor { + private const string RedactedMarker = ""; + + private static readonly HashSet NonBusinessFields = + ["Id", "CreatedAt", "ModifiedAt", "CreatedById", "ModifiedById"]; + public override InterceptionResult SavingChanges( DbContextEventData eventData, InterceptionResult result) { - Stamp(eventData.Context); + Process(eventData.Context); return base.SavingChanges(eventData, result); } @@ -28,11 +39,11 @@ public sealed class AuditFieldInterceptor(ICurrentUser currentUser, IDateTimePro InterceptionResult result, CancellationToken cancellationToken = default) { - Stamp(eventData.Context); + Process(eventData.Context); return base.SavingChangesAsync(eventData, result, cancellationToken); } - private void Stamp(DbContext? context) + private void Process(DbContext? context) { if (context is null) return; @@ -40,13 +51,28 @@ public sealed class AuditFieldInterceptor(ICurrentUser currentUser, IDateTimePro var now = dateTimeProvider.UtcNow; var userId = currentUser.UserId; - foreach (var entry in context.ChangeTracker.Entries()) + // Snapshot the entries before we add any audit rows, so adding to the context can't disturb the loop. + var entries = context.ChangeTracker.Entries().ToList(); + + Stamp(entries, now, userId); + + var auditLogs = CollectAuditLogs(entries, now, userId); + if (auditLogs.Count > 0) + context.Set().AddRange(auditLogs); + } + + private static void Stamp(IReadOnlyList entries, DateTimeOffset now, int? userId) + { + foreach (var entry in entries) { + if (entry.Entity is not ITimeModification timed) + continue; + switch (entry.State) { case EntityState.Added: - entry.Entity.CreatedAt = now; - entry.Entity.ModifiedAt = now; + timed.CreatedAt = now; + timed.ModifiedAt = now; if (entry.Entity is IAuditableEntity addedAuditable) { addedAuditable.CreatedById = userId; @@ -56,7 +82,7 @@ public sealed class AuditFieldInterceptor(ICurrentUser currentUser, IDateTimePro break; case EntityState.Modified: - entry.Entity.ModifiedAt = now; + timed.ModifiedAt = now; if (entry.Entity is IAuditableEntity modifiedAuditable) modifiedAuditable.ModifiedById = userId; @@ -64,4 +90,80 @@ public sealed class AuditFieldInterceptor(ICurrentUser currentUser, IDateTimePro } } } + + private List CollectAuditLogs(IReadOnlyList entries, DateTimeOffset now, int? userId) + { + var logs = new List(); + + foreach (var entry in entries) + { + if (entry.Entity is not IAuditable) + continue; + + var (action, useOriginal) = entry.State switch + { + EntityState.Added => (AuditAction.Created, false), + EntityState.Modified => (AuditAction.Updated, false), + EntityState.Deleted => (AuditAction.Deleted, true), + _ => (string.Empty, false) + }; + + if (action.Length == 0) + continue; + + logs.Add(new AuditLog + { + EntityType = entry.Metadata.ClrType.Name, + EntityId = ResolveEntityId(entry, useOriginal), + Action = action, + ChangedFieldsJson = BuildDiff(entry), + ActorUserId = userId, + OccurredAt = now + }); + } + + return logs; + } + + private static string ResolveEntityId(EntityEntry entry, bool useOriginal) + { + var keyProperty = entry.Metadata.FindPrimaryKey()?.Properties.FirstOrDefault(); + if (keyProperty is null) + return string.Empty; + + var property = entry.Property(keyProperty.Name); + var value = useOriginal ? property.OriginalValue : property.CurrentValue; + return value?.ToString() ?? string.Empty; + } + + // { "Field": { "old": , "new": } } for the changed business fields; PII columns marked + // [AuditRedacted] are written as a redaction marker, never plaintext. + private static string? BuildDiff(EntityEntry entry) + { + var diff = new Dictionary(); + + foreach (var property in entry.Properties) + { + var name = property.Metadata.Name; + if (NonBusinessFields.Contains(name)) + continue; + + var isDeletedOrAdded = entry.State is EntityState.Added or EntityState.Deleted; + if (entry.State == EntityState.Modified && !property.IsModified) + continue; + + var redacted = property.Metadata.PropertyInfo?.GetCustomAttribute() is not null; + + object? oldValue = entry.State == EntityState.Added ? null : Sanitize(property.OriginalValue, redacted); + object? newValue = entry.State == EntityState.Deleted ? null : Sanitize(property.CurrentValue, redacted); + + if (isDeletedOrAdded || !Equals(property.OriginalValue, property.CurrentValue)) + diff[name] = new { old = oldValue, @new = newValue }; + } + + return diff.Count == 0 ? null : JsonSerializer.Serialize(diff); + } + + private static object? Sanitize(object? value, bool redacted) => + value is null ? null : redacted ? RedactedMarker : value; } diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260701193257_InitialMarketplaceBaseline.Designer.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260701193257_InitialMarketplaceBaseline.Designer.cs new file mode 100644 index 0000000..9b919e5 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260701193257_InitialMarketplaceBaseline.Designer.cs @@ -0,0 +1,871 @@ +// +using System; +using Baya.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 Baya.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260701193257_InitialMarketplaceBaseline")] + partial class InitialMarketplaceBaseline + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Baya.Domain.Entities.Analytics.SystemEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("PropsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.HasIndex("OccurredAt"); + + b.HasIndex("UserId"); + + b.ToTable("SystemEvents", "ops"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Audit.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("ActorUserId") + .HasColumnType("int"); + + b.Property("ChangedFieldsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserId"); + + b.HasIndex("OccurredAt"); + + b.HasIndex("EntityType", "EntityId"); + + b.ToTable("AuditLogs", "ops"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Configuration.PlatformConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("PlatformConfigs", "ops"); + + b.HasData( + new + { + Id = 1L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "decimal", + Description = "Balinyaar commission rate on the booking gross (fraction).", + Key = "platform_fee_rate", + Value = "0.15" + }, + new + { + Id = 2L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "decimal", + Description = "VAT rate applied to the commission line only (fraction).", + Key = "vat_rate", + Value = "0.10" + }, + new + { + Id = 3L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Hours after check-out a booking can be disputed.", + Key = "dispute_window_hours", + Value = "72" + }, + new + { + Id = 4L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Minutes a family has to pay before a pending booking expires.", + Key = "booking_payment_deadline_minutes", + Value = "30" + }, + new + { + Id = 5L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Hours a nurse has to accept/decline a booking request.", + Key = "nurse_response_deadline_hours", + Value = "24" + }, + new + { + Id = 6L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Weekly payout cadence in days.", + Key = "nurse_payout_interval_days", + Value = "7" + }, + new + { + Id = 7L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Allowed EVV check-in distance from the care address.", + Key = "evv_location_tolerance_meters", + Value = "200" + }, + new + { + Id = 8L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "decimal", + Description = "A review at or below this rating raises a support alert.", + Key = "min_rating_for_support_alert", + Value = "2" + }, + new + { + Id = 9L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "string", + Description = "Who is merchant of record for BNPL orders (platform|nurse).", + Key = "bnpl_merchant_of_record", + Value = "platform" + }, + new + { + Id = 10L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "decimal", + Description = "BNPL provider commission rate (fraction).", + Key = "bnpl_provider_commission_rate", + Value = "0.07" + }, + new + { + Id = 11L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "string", + Description = "When BNPL settles funds to the platform (immediate|deferred).", + Key = "bnpl_settlement_timing", + Value = "immediate" + }, + new + { + Id = 12L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "json", + Description = "Tiered cancellation refund policy: refund_percent by hours before the visit.", + Key = "cancellation_tiers", + Value = "[{\"min_hours_before\":48,\"refund_percent\":100},{\"min_hours_before\":24,\"refund_percent\":50},{\"min_hours_before\":0,\"refund_percent\":0}]" + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Holidays.IranianHoliday", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("HolidayDate") + .HasColumnType("date"); + + b.Property("IsBankClosed") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("HolidayDate") + .IsUnique(); + + b.ToTable("IranianHolidays", "ops"); + + b.HasData( + new + { + Id = 1L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 2, 11), + IsBankClosed = true, + NameFa = "پیروزی انقلاب اسلامی", + Type = "national" + }, + new + { + Id = 2L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 3, 21), + IsBankClosed = true, + NameFa = "نوروز", + Type = "national" + }, + new + { + Id = 3L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 3, 22), + IsBankClosed = true, + NameFa = "نوروز", + Type = "national" + }, + new + { + Id = 4L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 3, 23), + IsBankClosed = true, + NameFa = "نوروز", + Type = "national" + }, + new + { + Id = 5L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 3, 24), + IsBankClosed = true, + NameFa = "نوروز", + Type = "national" + }, + new + { + Id = 6L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 4, 1), + IsBankClosed = true, + NameFa = "روز طبیعت (سیزده‌به‌در)", + Type = "official" + }, + new + { + Id = 7L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 6, 26), + IsBankClosed = true, + NameFa = "عید سعید قربان", + Type = "religious" + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Notifications.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Body") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DataJson") + .HasColumnType("nvarchar(max)"); + + b.Property("IsRead") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ReadAt") + .HasColumnType("datetimeoffset"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "IsRead", "CreatedAt"); + + b.ToTable("Notifications", "ops"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.SupportAlerts.SupportAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("OwnerUserId") + .HasColumnType("int"); + + b.Property("ResolutionNote") + .HasColumnType("nvarchar(max)"); + + b.Property("ResolvedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ReviewId") + .HasColumnType("bigint"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.HasIndex("Status"); + + b.HasIndex("Type"); + + b.ToTable("SupportAlerts", "ops"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("Roles", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.RoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedClaim") + .HasColumnType("datetime2"); + + b.Property("RoleId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("UserId"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("FamilyName") + .HasColumnType("nvarchar(max)"); + + b.Property("GeneratedCode") + .HasColumnType("nvarchar(max)"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("Users", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("LoggedOn") + .HasColumnType("datetime2"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserRefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("IsValid") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserRefreshTokens", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserRole", b => + { + b.Property("UserId") + .HasColumnType("int"); + + b.Property("RoleId") + .HasColumnType("int"); + + b.Property("CreatedUserRoleDate") + .HasColumnType("datetime2"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserToken", b => + { + b.Property("UserId") + .HasColumnType("int"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("GeneratedTime") + .HasColumnType("datetime2"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Analytics.SystemEvent", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("UserId"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Audit.AuditLog", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("ActorUserId"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Notifications.Notification", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Baya.Domain.Entities.SupportAlerts.SupportAlert", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("OwnerUserId"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.RoleClaim", b => + { + b.HasOne("Baya.Domain.Entities.User.Role", "Role") + .WithMany("Claims") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserClaim", b => + { + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithMany("Claims") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserLogin", b => + { + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithMany("Logins") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserRefreshToken", b => + { + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithMany("UserRefreshTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserRole", b => + { + b.HasOne("Baya.Domain.Entities.User.Role", "Role") + .WithMany("Users") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserToken", b => + { + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithMany("Tokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.Role", b => + { + b.Navigation("Claims"); + + b.Navigation("Users"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.User", b => + { + b.Navigation("Claims"); + + b.Navigation("Logins"); + + b.Navigation("Tokens"); + + b.Navigation("UserRefreshTokens"); + + b.Navigation("UserRoles"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260701193257_InitialMarketplaceBaseline.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260701193257_InitialMarketplaceBaseline.cs new file mode 100644 index 0000000..c1b94ad --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260701193257_InitialMarketplaceBaseline.cs @@ -0,0 +1,308 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional + +namespace Baya.Infrastructure.Persistence.Migrations +{ + /// + public partial class InitialMarketplaceBaseline : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "ops"); + + migrationBuilder.CreateTable( + name: "AuditLogs", + schema: "ops", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + EntityType = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + EntityId = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + Action = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: false), + ChangedFieldsJson = table.Column(type: "nvarchar(max)", nullable: true), + ActorUserId = table.Column(type: "int", nullable: true), + OccurredAt = table.Column(type: "datetimeoffset", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AuditLogs", x => x.Id); + table.ForeignKey( + name: "FK_AuditLogs_Users_ActorUserId", + column: x => x.ActorUserId, + principalSchema: "usr", + principalTable: "Users", + principalColumn: "UserId"); + }); + + migrationBuilder.CreateTable( + name: "IranianHolidays", + schema: "ops", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + HolidayDate = table.Column(type: "date", nullable: false), + NameFa = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + Type = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: false), + IsBankClosed = table.Column(type: "bit", nullable: false), + CreatedAt = table.Column(type: "datetimeoffset", nullable: false), + ModifiedAt = table.Column(type: "datetimeoffset", nullable: true), + CreatedById = table.Column(type: "int", nullable: true), + ModifiedById = table.Column(type: "int", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_IranianHolidays", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Notifications", + schema: "ops", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + UserId = table.Column(type: "int", nullable: false), + Type = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + Title = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + Body = table.Column(type: "nvarchar(max)", nullable: true), + DataJson = table.Column(type: "nvarchar(max)", nullable: true), + IsRead = table.Column(type: "bit", nullable: false, defaultValue: false), + ReadAt = table.Column(type: "datetimeoffset", nullable: true), + CreatedAt = table.Column(type: "datetimeoffset", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Notifications", x => x.Id); + table.ForeignKey( + name: "FK_Notifications_Users_UserId", + column: x => x.UserId, + principalSchema: "usr", + principalTable: "Users", + principalColumn: "UserId", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "PlatformConfigs", + schema: "ops", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Key = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + Value = table.Column(type: "nvarchar(max)", nullable: false), + DataType = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: false), + Description = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: true), + CreatedAt = table.Column(type: "datetimeoffset", nullable: false), + ModifiedAt = table.Column(type: "datetimeoffset", nullable: true), + CreatedById = table.Column(type: "int", nullable: true), + ModifiedById = table.Column(type: "int", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_PlatformConfigs", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "SupportAlerts", + schema: "ops", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Type = table.Column(type: "nvarchar(40)", maxLength: 40, nullable: false), + Severity = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: false), + Status = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: false), + EntityType = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + EntityId = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + BookingId = table.Column(type: "bigint", nullable: true), + ReviewId = table.Column(type: "bigint", nullable: true), + OwnerUserId = table.Column(type: "int", nullable: true), + ResolutionNote = table.Column(type: "nvarchar(max)", nullable: true), + ResolvedAt = table.Column(type: "datetimeoffset", nullable: true), + CreatedAt = table.Column(type: "datetimeoffset", nullable: false), + ModifiedAt = table.Column(type: "datetimeoffset", nullable: true), + CreatedById = table.Column(type: "int", nullable: true), + ModifiedById = table.Column(type: "int", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_SupportAlerts", x => x.Id); + table.ForeignKey( + name: "FK_SupportAlerts_Users_OwnerUserId", + column: x => x.OwnerUserId, + principalSchema: "usr", + principalTable: "Users", + principalColumn: "UserId"); + }); + + migrationBuilder.CreateTable( + name: "SystemEvents", + schema: "ops", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Name = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + PropsJson = table.Column(type: "nvarchar(max)", nullable: true), + UserId = table.Column(type: "int", nullable: true), + OccurredAt = table.Column(type: "datetimeoffset", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SystemEvents", x => x.Id); + table.ForeignKey( + name: "FK_SystemEvents_Users_UserId", + column: x => x.UserId, + principalSchema: "usr", + principalTable: "Users", + principalColumn: "UserId"); + }); + + migrationBuilder.InsertData( + schema: "ops", + table: "IranianHolidays", + columns: new[] { "Id", "CreatedAt", "CreatedById", "HolidayDate", "IsBankClosed", "ModifiedAt", "ModifiedById", "NameFa", "Type" }, + values: new object[,] + { + { 1L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, new DateOnly(2026, 2, 11), true, null, null, "پیروزی انقلاب اسلامی", "national" }, + { 2L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, new DateOnly(2026, 3, 21), true, null, null, "نوروز", "national" }, + { 3L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, new DateOnly(2026, 3, 22), true, null, null, "نوروز", "national" }, + { 4L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, new DateOnly(2026, 3, 23), true, null, null, "نوروز", "national" }, + { 5L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, new DateOnly(2026, 3, 24), true, null, null, "نوروز", "national" }, + { 6L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, new DateOnly(2026, 4, 1), true, null, null, "روز طبیعت (سیزده‌به‌در)", "official" }, + { 7L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, new DateOnly(2026, 6, 26), true, null, null, "عید سعید قربان", "religious" } + }); + + migrationBuilder.InsertData( + schema: "ops", + table: "PlatformConfigs", + columns: new[] { "Id", "CreatedAt", "CreatedById", "DataType", "Description", "Key", "ModifiedAt", "ModifiedById", "Value" }, + values: new object[,] + { + { 1L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "decimal", "Balinyaar commission rate on the booking gross (fraction).", "platform_fee_rate", null, null, "0.15" }, + { 2L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "decimal", "VAT rate applied to the commission line only (fraction).", "vat_rate", null, null, "0.10" }, + { 3L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "int", "Hours after check-out a booking can be disputed.", "dispute_window_hours", null, null, "72" }, + { 4L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "int", "Minutes a family has to pay before a pending booking expires.", "booking_payment_deadline_minutes", null, null, "30" }, + { 5L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "int", "Hours a nurse has to accept/decline a booking request.", "nurse_response_deadline_hours", null, null, "24" }, + { 6L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "int", "Weekly payout cadence in days.", "nurse_payout_interval_days", null, null, "7" }, + { 7L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "int", "Allowed EVV check-in distance from the care address.", "evv_location_tolerance_meters", null, null, "200" }, + { 8L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "decimal", "A review at or below this rating raises a support alert.", "min_rating_for_support_alert", null, null, "2" }, + { 9L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "string", "Who is merchant of record for BNPL orders (platform|nurse).", "bnpl_merchant_of_record", null, null, "platform" }, + { 10L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "decimal", "BNPL provider commission rate (fraction).", "bnpl_provider_commission_rate", null, null, "0.07" }, + { 11L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "string", "When BNPL settles funds to the platform (immediate|deferred).", "bnpl_settlement_timing", null, null, "immediate" }, + { 12L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "json", "Tiered cancellation refund policy: refund_percent by hours before the visit.", "cancellation_tiers", null, null, "[{\"min_hours_before\":48,\"refund_percent\":100},{\"min_hours_before\":24,\"refund_percent\":50},{\"min_hours_before\":0,\"refund_percent\":0}]" } + }); + + migrationBuilder.CreateIndex( + name: "IX_AuditLogs_ActorUserId", + schema: "ops", + table: "AuditLogs", + column: "ActorUserId"); + + migrationBuilder.CreateIndex( + name: "IX_AuditLogs_EntityType_EntityId", + schema: "ops", + table: "AuditLogs", + columns: new[] { "EntityType", "EntityId" }); + + migrationBuilder.CreateIndex( + name: "IX_AuditLogs_OccurredAt", + schema: "ops", + table: "AuditLogs", + column: "OccurredAt"); + + migrationBuilder.CreateIndex( + name: "IX_IranianHolidays_HolidayDate", + schema: "ops", + table: "IranianHolidays", + column: "HolidayDate", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Notifications_UserId_IsRead_CreatedAt", + schema: "ops", + table: "Notifications", + columns: new[] { "UserId", "IsRead", "CreatedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_PlatformConfigs_Key", + schema: "ops", + table: "PlatformConfigs", + column: "Key", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_SupportAlerts_OwnerUserId", + schema: "ops", + table: "SupportAlerts", + column: "OwnerUserId"); + + migrationBuilder.CreateIndex( + name: "IX_SupportAlerts_Status", + schema: "ops", + table: "SupportAlerts", + column: "Status"); + + migrationBuilder.CreateIndex( + name: "IX_SupportAlerts_Type", + schema: "ops", + table: "SupportAlerts", + column: "Type"); + + migrationBuilder.CreateIndex( + name: "IX_SystemEvents_Name", + schema: "ops", + table: "SystemEvents", + column: "Name"); + + migrationBuilder.CreateIndex( + name: "IX_SystemEvents_OccurredAt", + schema: "ops", + table: "SystemEvents", + column: "OccurredAt"); + + migrationBuilder.CreateIndex( + name: "IX_SystemEvents_UserId", + schema: "ops", + table: "SystemEvents", + column: "UserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AuditLogs", + schema: "ops"); + + migrationBuilder.DropTable( + name: "IranianHolidays", + schema: "ops"); + + migrationBuilder.DropTable( + name: "Notifications", + schema: "ops"); + + migrationBuilder.DropTable( + name: "PlatformConfigs", + schema: "ops"); + + migrationBuilder.DropTable( + name: "SupportAlerts", + schema: "ops"); + + migrationBuilder.DropTable( + name: "SystemEvents", + schema: "ops"); + } + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs index 51213cf..ecbe96a 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs @@ -22,6 +22,463 @@ namespace Baya.Infrastructure.Persistence.Migrations SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + modelBuilder.Entity("Baya.Domain.Entities.Analytics.SystemEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("PropsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.HasIndex("OccurredAt"); + + b.HasIndex("UserId"); + + b.ToTable("SystemEvents", "ops"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Audit.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("ActorUserId") + .HasColumnType("int"); + + b.Property("ChangedFieldsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserId"); + + b.HasIndex("OccurredAt"); + + b.HasIndex("EntityType", "EntityId"); + + b.ToTable("AuditLogs", "ops"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Configuration.PlatformConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("PlatformConfigs", "ops"); + + b.HasData( + new + { + Id = 1L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "decimal", + Description = "Balinyaar commission rate on the booking gross (fraction).", + Key = "platform_fee_rate", + Value = "0.15" + }, + new + { + Id = 2L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "decimal", + Description = "VAT rate applied to the commission line only (fraction).", + Key = "vat_rate", + Value = "0.10" + }, + new + { + Id = 3L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Hours after check-out a booking can be disputed.", + Key = "dispute_window_hours", + Value = "72" + }, + new + { + Id = 4L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Minutes a family has to pay before a pending booking expires.", + Key = "booking_payment_deadline_minutes", + Value = "30" + }, + new + { + Id = 5L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Hours a nurse has to accept/decline a booking request.", + Key = "nurse_response_deadline_hours", + Value = "24" + }, + new + { + Id = 6L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Weekly payout cadence in days.", + Key = "nurse_payout_interval_days", + Value = "7" + }, + new + { + Id = 7L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Allowed EVV check-in distance from the care address.", + Key = "evv_location_tolerance_meters", + Value = "200" + }, + new + { + Id = 8L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "decimal", + Description = "A review at or below this rating raises a support alert.", + Key = "min_rating_for_support_alert", + Value = "2" + }, + new + { + Id = 9L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "string", + Description = "Who is merchant of record for BNPL orders (platform|nurse).", + Key = "bnpl_merchant_of_record", + Value = "platform" + }, + new + { + Id = 10L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "decimal", + Description = "BNPL provider commission rate (fraction).", + Key = "bnpl_provider_commission_rate", + Value = "0.07" + }, + new + { + Id = 11L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "string", + Description = "When BNPL settles funds to the platform (immediate|deferred).", + Key = "bnpl_settlement_timing", + Value = "immediate" + }, + new + { + Id = 12L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "json", + Description = "Tiered cancellation refund policy: refund_percent by hours before the visit.", + Key = "cancellation_tiers", + Value = "[{\"min_hours_before\":48,\"refund_percent\":100},{\"min_hours_before\":24,\"refund_percent\":50},{\"min_hours_before\":0,\"refund_percent\":0}]" + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Holidays.IranianHoliday", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("HolidayDate") + .HasColumnType("date"); + + b.Property("IsBankClosed") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("HolidayDate") + .IsUnique(); + + b.ToTable("IranianHolidays", "ops"); + + b.HasData( + new + { + Id = 1L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 2, 11), + IsBankClosed = true, + NameFa = "پیروزی انقلاب اسلامی", + Type = "national" + }, + new + { + Id = 2L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 3, 21), + IsBankClosed = true, + NameFa = "نوروز", + Type = "national" + }, + new + { + Id = 3L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 3, 22), + IsBankClosed = true, + NameFa = "نوروز", + Type = "national" + }, + new + { + Id = 4L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 3, 23), + IsBankClosed = true, + NameFa = "نوروز", + Type = "national" + }, + new + { + Id = 5L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 3, 24), + IsBankClosed = true, + NameFa = "نوروز", + Type = "national" + }, + new + { + Id = 6L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 4, 1), + IsBankClosed = true, + NameFa = "روز طبیعت (سیزده‌به‌در)", + Type = "official" + }, + new + { + Id = 7L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 6, 26), + IsBankClosed = true, + NameFa = "عید سعید قربان", + Type = "religious" + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Notifications.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Body") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DataJson") + .HasColumnType("nvarchar(max)"); + + b.Property("IsRead") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ReadAt") + .HasColumnType("datetimeoffset"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "IsRead", "CreatedAt"); + + b.ToTable("Notifications", "ops"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.SupportAlerts.SupportAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("OwnerUserId") + .HasColumnType("int"); + + b.Property("ResolutionNote") + .HasColumnType("nvarchar(max)"); + + b.Property("ResolvedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ReviewId") + .HasColumnType("bigint"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.HasIndex("Status"); + + b.HasIndex("Type"); + + b.ToTable("SupportAlerts", "ops"); + }); + modelBuilder.Entity("Baya.Domain.Entities.User.Role", b => { b.Property("Id") @@ -282,6 +739,36 @@ namespace Baya.Infrastructure.Persistence.Migrations b.ToTable("UserTokens", "usr"); }); + modelBuilder.Entity("Baya.Domain.Entities.Analytics.SystemEvent", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("UserId"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Audit.AuditLog", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("ActorUserId"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Notifications.Notification", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Baya.Domain.Entities.SupportAlerts.SupportAlert", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("OwnerUserId"); + }); + modelBuilder.Entity("Baya.Domain.Entities.User.RoleClaim", b => { b.HasOne("Baya.Domain.Entities.User.Role", "Role") diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs index affc1c6..67bc622 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs @@ -1,6 +1,19 @@ -using Baya.Application.Contracts.Persistence; +using Baya.Application.Contracts.Analytics; +using Baya.Application.Contracts.Audit; +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Configuration; +using Baya.Application.Contracts.Holidays; +using Baya.Application.Contracts.Notifications; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Contracts.SupportAlerts; using Baya.Infrastructure.Persistence.Interceptors; using Baya.Infrastructure.Persistence.Repositories.Common; +using Baya.Infrastructure.Persistence.Services.Analytics; +using Baya.Infrastructure.Persistence.Services.Audit; +using Baya.Infrastructure.Persistence.Services.Configuration; +using Baya.Infrastructure.Persistence.Services.Holidays; +using Baya.Infrastructure.Persistence.Services.Notifications; +using Baya.Infrastructure.Persistence.Services.SupportAlerts; using Microsoft.AspNetCore.Builder; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; @@ -23,6 +36,21 @@ public static class ServiceCollectionExtensions .AddInterceptors(serviceProvider.GetRequiredService()); }); + // Platform-signal facades — DB-backed implementations of the Application contracts other domains + // (b2…b15) depend on. Config/holiday lookups cache through ICacheService. + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + // Supersedes the b0 log/no-op stub with the real in-app notifications write. + services.AddScoped(); + + // Retention job seam (mock = in-process interval runner; real Hangfire/Quartz deferred). + services.AddHostedService(); + return services; } diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Analytics/AnalyticsSink.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Analytics/AnalyticsSink.cs new file mode 100644 index 0000000..f273463 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Analytics/AnalyticsSink.cs @@ -0,0 +1,40 @@ +#nullable enable +using System.Text.Json; +using Baya.Application.Contracts.Analytics; +using Baya.Application.Contracts.Common; +using Baya.Domain.Entities.Analytics; +using Microsoft.Extensions.Logging; + +namespace Baya.Infrastructure.Persistence.Services.Analytics; + +/// +/// Fire-and-forget analytics sink — the mock inserts a system_events row. A failure is logged and +/// swallowed so it can never surface to, or slow, the caller's operation. Compliance facts never come +/// here (they go to the audit trail). +/// +internal sealed class AnalyticsSink( + ApplicationDbContext db, + ICurrentUser currentUser, + IDateTimeProvider dateTimeProvider, + ILogger logger) : IAnalyticsSink +{ + public async ValueTask EmitAsync(string name, object props, CancellationToken cancellationToken = default) + { + try + { + db.Set().Add(new SystemEvent + { + Name = name, + PropsJson = JsonSerializer.Serialize(props), + UserId = currentUser.UserId, + OccurredAt = dateTimeProvider.UtcNow + }); + + await db.SaveChangesAsync(cancellationToken); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Analytics emit failed for event {EventName}", name); + } + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Audit/AuditLogger.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Audit/AuditLogger.cs new file mode 100644 index 0000000..53a81a1 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Audit/AuditLogger.cs @@ -0,0 +1,64 @@ +#nullable enable +using System.Text.Json; +using Baya.Application.Contracts.Audit; +using Baya.Application.Contracts.Common; +using Baya.Application.Models.Audit; +using Baya.Application.Models.Common; +using Baya.Domain.Entities.Audit; +using Microsoft.EntityFrameworkCore; + +namespace Baya.Infrastructure.Persistence.Services.Audit; + +/// +/// Explicit append-only audit writer + trail reader. The trail is immutable: there is deliberately no +/// update or delete path here. Row-level diffs on auditable entities are captured automatically by the +/// SaveChanges interceptor; this contract covers state changes that have no tracked-entity diff. +/// +internal sealed class AuditLogger( + ApplicationDbContext db, + ICurrentUser currentUser, + IDateTimeProvider dateTimeProvider) : IAuditLogger +{ + public async ValueTask WriteAsync( + string entityType, + string entityId, + string action, + IReadOnlyDictionary? changedFields = null, + CancellationToken cancellationToken = default) + { + db.Set().Add(new AuditLog + { + EntityType = entityType, + EntityId = entityId, + Action = action, + ChangedFieldsJson = changedFields is null ? null : JsonSerializer.Serialize(changedFields), + ActorUserId = currentUser.UserId, + OccurredAt = dateTimeProvider.UtcNow + }); + + await db.SaveChangesAsync(cancellationToken); + } + + public async ValueTask> GetTrailAsync( + string entityType, + string entityId, + int page, + int pageSize, + CancellationToken cancellationToken = default) + { + var query = db.Set() + .AsNoTracking() + .Where(a => a.EntityType == entityType && a.EntityId == entityId) + // Id is a monotonic identity → newest-first and deterministic (no timestamp ties). + .OrderByDescending(a => a.Id); + + var total = await query.CountAsync(cancellationToken); + var items = await query + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Select(a => new AuditLogDto(a.Id, a.EntityType, a.EntityId, a.Action, a.ChangedFieldsJson, a.ActorUserId, a.OccurredAt)) + .ToListAsync(cancellationToken); + + return new PagedResult(items, total, page, pageSize); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Configuration/PlatformConfigService.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Configuration/PlatformConfigService.cs new file mode 100644 index 0000000..6b2f9e3 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Configuration/PlatformConfigService.cs @@ -0,0 +1,113 @@ +#nullable enable +using System.Globalization; +using System.Text.Json; +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Configuration; +using Baya.Application.Models.Common; +using Baya.Application.Models.Configuration; +using Baya.Domain.Entities.Audit; +using Baya.Domain.Entities.Configuration; +using Microsoft.EntityFrameworkCore; + +namespace Baya.Infrastructure.Persistence.Services.Configuration; + +/// +/// Cached, typed accessor over platform_configs. Reads go through ; +/// a write updates the row (audited by the SaveChanges interceptor in the same transaction) and evicts +/// the cache key so the next read sees the new value. +/// +internal sealed class PlatformConfigService(ApplicationDbContext db, ICacheService cache) : IPlatformConfig +{ + private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(30); + + private static string CacheKey(string key) => $"platform_config:{key}"; + + public async ValueTask GetConfig(string key, CancellationToken cancellationToken = default) + { + var dto = await cache.GetOrCreateAsync( + CacheKey(key), + async ct => await db.Set() + .AsNoTracking() + .Where(c => c.Key == key) + .Select(c => new PlatformConfigDto(c.Key, c.Value, c.DataType, c.Description)) + .FirstOrDefaultAsync(ct), + CacheTtl, + cancellationToken); + + if (dto is null) + throw new InvalidOperationException($"Platform config key '{key}' does not exist."); + + return Parse(dto.Value, dto.DataType); + } + + public async ValueTask SetConfig(string key, string value, CancellationToken cancellationToken = default) + { + var entity = await db.Set().FirstOrDefaultAsync(c => c.Key == key, cancellationToken); + if (entity is null) + return false; + + entity.Value = value; + await db.SaveChangesAsync(cancellationToken); + + await cache.RemoveAsync(CacheKey(key), cancellationToken); + return true; + } + + public async ValueTask> ListAsync(int page, int pageSize, CancellationToken cancellationToken = default) + { + var query = db.Set().AsNoTracking().OrderBy(c => c.Key); + + var total = await query.CountAsync(cancellationToken); + var items = await query + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Select(c => new PlatformConfigDto(c.Key, c.Value, c.DataType, c.Description)) + .ToListAsync(cancellationToken); + + return new PagedResult(items, total, page, pageSize); + } + + public async ValueTask> GetConfigChangeHistory(string key, int page, int pageSize, CancellationToken cancellationToken = default) + { + var configId = await db.Set() + .AsNoTracking() + .Where(c => c.Key == key) + .Select(c => (long?)c.Id) + .FirstOrDefaultAsync(cancellationToken); + + if (configId is null) + return new PagedResult([], 0, page, pageSize); + + var entityId = configId.Value.ToString(CultureInfo.InvariantCulture); + + var query = db.Set() + .AsNoTracking() + .Where(a => a.EntityType == nameof(PlatformConfig) && a.EntityId == entityId) + // Id is a monotonic identity → newest-first and deterministic (no timestamp ties). + .OrderByDescending(a => a.Id); + + var total = await query.CountAsync(cancellationToken); + var items = await query + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Select(a => new ConfigChangeDto(a.Id, a.Action, a.ChangedFieldsJson, a.ActorUserId, a.OccurredAt)) + .ToListAsync(cancellationToken); + + return new PagedResult(items, total, page, pageSize); + } + + private static T Parse(string value, string dataType) + { + object parsed = dataType switch + { + ConfigDataType.Decimal => decimal.Parse(value, CultureInfo.InvariantCulture), + ConfigDataType.Int => int.Parse(value, CultureInfo.InvariantCulture), + ConfigDataType.Bool => bool.Parse(value), + ConfigDataType.Json => JsonSerializer.Deserialize(value) + ?? throw new InvalidOperationException($"Config value for type '{typeof(T)}' deserialized to null."), + _ => value + }; + + return (T)parsed; + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Holidays/HolidayCalendarService.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Holidays/HolidayCalendarService.cs new file mode 100644 index 0000000..8c741df --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Holidays/HolidayCalendarService.cs @@ -0,0 +1,119 @@ +#nullable enable +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Holidays; +using Baya.Application.Models.Common; +using Baya.Application.Models.Holidays; +using Baya.Domain.Entities.Holidays; +using Microsoft.EntityFrameworkCore; + +namespace Baya.Infrastructure.Persistence.Services.Holidays; + +/// +/// Reads the seeded iranian_holidays table (lookups cached) to answer holiday/bank-closure +/// questions and shift a date to the next open bank day. The Iranian banking weekend is Friday. +/// +internal sealed class HolidayCalendarService(ApplicationDbContext db, ICacheService cache) : IHolidayCalendar +{ + private static readonly TimeSpan CacheTtl = TimeSpan.FromHours(6); + + // Payouts must not schedule past a bounded horizon even if the calendar is misconfigured. + private const int MaxLookaheadDays = 60; + + private static string HolidayKey(DateOnly date) => $"holiday:is_holiday:{date:yyyy-MM-dd}"; + private static string BankClosedKey(DateOnly date) => $"holiday:bank_closed:{date:yyyy-MM-dd}"; + + public ValueTask IsHoliday(DateOnly date, CancellationToken cancellationToken = default) => + cache.GetOrCreateAsync( + HolidayKey(date), + async ct => await db.Set().AsNoTracking().AnyAsync(h => h.HolidayDate == date, ct), + CacheTtl, + cancellationToken); + + public ValueTask IsBankClosed(DateOnly date, CancellationToken cancellationToken = default) => + cache.GetOrCreateAsync( + BankClosedKey(date), + async ct => await db.Set().AsNoTracking().AnyAsync(h => h.HolidayDate == date && h.IsBankClosed, ct), + CacheTtl, + cancellationToken); + + public async ValueTask NextBusinessDay(DateOnly date, CancellationToken cancellationToken = default) + { + var candidate = date; + for (var i = 0; i <= MaxLookaheadDays; i++) + { + if (!IsBankWeekend(candidate) && !await IsBankClosed(candidate, cancellationToken)) + return candidate; + + candidate = candidate.AddDays(1); + } + + throw new InvalidOperationException( + $"No open bank day found within {MaxLookaheadDays} days of {date:yyyy-MM-dd} — the holiday calendar is likely misconfigured."); + } + + public async ValueTask> ListAsync(DateOnly? from, DateOnly? to, int page, int pageSize, CancellationToken cancellationToken = default) + { + var query = db.Set().AsNoTracking().AsQueryable(); + + if (from is { } f) + query = query.Where(h => h.HolidayDate >= f); + if (to is { } t) + query = query.Where(h => h.HolidayDate <= t); + + query = query.OrderBy(h => h.HolidayDate); + + var total = await query.CountAsync(cancellationToken); + var items = await query + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Select(h => new HolidayDto(h.Id, h.HolidayDate, h.NameFa, h.Type, h.IsBankClosed)) + .ToListAsync(cancellationToken); + + return new PagedResult(items, total, page, pageSize); + } + + public async ValueTask UpsertAsync(DateOnly date, string nameFa, string type, bool isBankClosed, CancellationToken cancellationToken = default) + { + var existing = await db.Set().FirstOrDefaultAsync(h => h.HolidayDate == date, cancellationToken); + if (existing is null) + { + db.Set().Add(new IranianHoliday + { + HolidayDate = date, + NameFa = nameFa, + Type = type, + IsBankClosed = isBankClosed + }); + } + else + { + existing.NameFa = nameFa; + existing.Type = type; + existing.IsBankClosed = isBankClosed; + } + + await db.SaveChangesAsync(cancellationToken); + await Evict(date, cancellationToken); + } + + public async ValueTask DeleteAsync(DateOnly date, CancellationToken cancellationToken = default) + { + var existing = await db.Set().FirstOrDefaultAsync(h => h.HolidayDate == date, cancellationToken); + if (existing is null) + return false; + + db.Set().Remove(existing); + await db.SaveChangesAsync(cancellationToken); + await Evict(date, cancellationToken); + return true; + } + + // Iranian banks are closed on Fridays; Thursday is treated as a business day. + private static bool IsBankWeekend(DateOnly date) => date.DayOfWeek == DayOfWeek.Friday; + + private async ValueTask Evict(DateOnly date, CancellationToken cancellationToken) + { + await cache.RemoveAsync(HolidayKey(date), cancellationToken); + await cache.RemoveAsync(BankClosedKey(date), cancellationToken); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Notifications/InAppNotificationDispatcher.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Notifications/InAppNotificationDispatcher.cs new file mode 100644 index 0000000..e913077 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Notifications/InAppNotificationDispatcher.cs @@ -0,0 +1,33 @@ +#nullable enable +using Baya.Application.Contracts.Common; +using NotificationMessage = Baya.Application.Contracts.Common.Notification; +using NotificationEntity = Baya.Domain.Entities.Notifications.Notification; + +namespace Baya.Infrastructure.Persistence.Services.Notifications; + +/// +/// Real in-app implementation of — supersedes the b0 log/no-op +/// stub. It writes a notifications row for the in-app channel. SMS/push are deferred behind this +/// same seam; those channels are no-ops for now so callers use unchanged. +/// +internal sealed class InAppNotificationDispatcher(ApplicationDbContext db, IDateTimeProvider dateTimeProvider) : INotificationDispatcher +{ + public async ValueTask DispatchAsync(NotificationMessage notification, CancellationToken cancellationToken = default) + { + if (notification.Channel != NotificationChannel.InApp) + return; + + db.Set().Add(new NotificationEntity + { + UserId = notification.RecipientUserId, + Type = notification.Type, + Title = notification.Title, + Body = notification.Body, + DataJson = notification.DataJson, + IsRead = false, + CreatedAt = dateTimeProvider.UtcNow + }); + + await db.SaveChangesAsync(cancellationToken); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Notifications/NotificationRetentionHostedService.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Notifications/NotificationRetentionHostedService.cs new file mode 100644 index 0000000..ea0bb69 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Notifications/NotificationRetentionHostedService.cs @@ -0,0 +1,50 @@ +#nullable enable +using Baya.Application.Contracts.Notifications; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Baya.Infrastructure.Persistence.Services.Notifications; + +/// +/// The scheduling seam for the notification retention job (mock = an in-process interval runner). It +/// periodically hard-deletes read notifications older than the retention window; unread notifications are +/// never deleted. Real Hangfire/Quartz is deferred — swapping it in is a registration change here. +/// +internal sealed class NotificationRetentionHostedService( + IServiceScopeFactory scopeFactory, + ILogger logger) : BackgroundService +{ + private const int RetentionDays = 90; + private static readonly TimeSpan Interval = TimeSpan.FromHours(24); + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + // Run once at startup, then on the interval. + await PurgeSafely(stoppingToken); + + using var timer = new PeriodicTimer(Interval); + while (await timer.WaitForNextTickAsync(stoppingToken)) + await PurgeSafely(stoppingToken); + } + + private async Task PurgeSafely(CancellationToken cancellationToken) + { + try + { + await using var scope = scopeFactory.CreateAsyncScope(); + var notifications = scope.ServiceProvider.GetRequiredService(); + var removed = await notifications.PurgeOldReadAsync(RetentionDays, cancellationToken); + if (removed > 0) + logger.LogInformation("Notification retention purged {Count} read notifications older than {Days}d", removed, RetentionDays); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // Host is shutting down — expected, don't log as an error. + } + catch (Exception ex) + { + logger.LogError(ex, "Notification retention purge failed"); + } + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Notifications/NotificationService.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Notifications/NotificationService.cs new file mode 100644 index 0000000..a501386 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Notifications/NotificationService.cs @@ -0,0 +1,88 @@ +#nullable enable +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Notifications; +using Baya.Application.Models.Common; +using Baya.Application.Models.Notifications; +using Microsoft.EntityFrameworkCore; +using NotificationEntity = Baya.Domain.Entities.Notifications.Notification; + +namespace Baya.Infrastructure.Persistence.Services.Notifications; + +/// +/// Reads and per-user commands over notifications. Every method is scoped to the passed +/// userId (the authenticated caller). Retention hard-deletes read notifications past the window +/// and never touches unread ones. +/// +internal sealed class NotificationService(ApplicationDbContext db, IDateTimeProvider dateTimeProvider) : INotificationService +{ + public async ValueTask> ListMineAsync(int userId, int page, int pageSize, CancellationToken cancellationToken = default) + { + var query = db.Set() + .AsNoTracking() + .Where(n => n.UserId == userId) + // Unread first, then newest-first via the monotonic identity (deterministic, no timestamp ties). + .OrderBy(n => n.IsRead) + .ThenByDescending(n => n.Id); + + var total = await query.CountAsync(cancellationToken); + var items = await query + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Select(n => new NotificationDto(n.Id, n.Type, n.Title, n.Body, n.DataJson, n.IsRead, n.ReadAt, n.CreatedAt)) + .ToListAsync(cancellationToken); + + return new PagedResult(items, total, page, pageSize); + } + + public ValueTask GetUnreadCountAsync(int userId, CancellationToken cancellationToken = default) => + new(db.Set().AsNoTracking().CountAsync(n => n.UserId == userId && !n.IsRead, cancellationToken)); + + public async ValueTask MarkReadAsync(int userId, long notificationId, CancellationToken cancellationToken = default) + { + var notification = await db.Set() + .FirstOrDefaultAsync(n => n.Id == notificationId && n.UserId == userId, cancellationToken); + + if (notification is null) + return false; + + if (!notification.IsRead) + { + notification.IsRead = true; + notification.ReadAt = dateTimeProvider.UtcNow; + await db.SaveChangesAsync(cancellationToken); + } + + return true; + } + + public async ValueTask MarkAllReadAsync(int userId, CancellationToken cancellationToken = default) + { + var now = dateTimeProvider.UtcNow; + return await db.Set() + .Where(n => n.UserId == userId && !n.IsRead) + .ExecuteUpdateAsync( + s => s.SetProperty(n => n.IsRead, true).SetProperty(n => n.ReadAt, now), + cancellationToken); + } + + public async ValueTask PurgeOldReadAsync(int retentionDays, CancellationToken cancellationToken = default) + { + var cutoff = dateTimeProvider.UtcNow.AddDays(-retentionDays); + + // Read-only rows are the only purge candidates (unread is never deleted). The age cutoff is + // applied in memory so the delete is a single id-keyed statement that translates on every + // provider; the candidate set is bounded (only read notifications). + var readRows = await db.Set() + .Where(n => n.IsRead) + .Select(n => new { n.Id, n.CreatedAt }) + .ToListAsync(cancellationToken); + + var expiredIds = readRows.Where(n => n.CreatedAt < cutoff).Select(n => n.Id).ToList(); + if (expiredIds.Count == 0) + return 0; + + return await db.Set() + .Where(n => expiredIds.Contains(n.Id)) + .ExecuteDeleteAsync(cancellationToken); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/SupportAlerts/SupportAlertService.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/SupportAlerts/SupportAlertService.cs new file mode 100644 index 0000000..8980487 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/SupportAlerts/SupportAlertService.cs @@ -0,0 +1,99 @@ +#nullable enable +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.SupportAlerts; +using Baya.Application.Models.Common; +using Baya.Application.Models.SupportAlerts; +using Baya.Domain.Entities.SupportAlerts; +using Microsoft.EntityFrameworkCore; + +namespace Baya.Infrastructure.Persistence.Services.SupportAlerts; + +/// +/// Internal support-alert worklist store. Never exposed on a user-facing route. Status is forward-only: +/// an alert can be assigned or resolved from open, and resolved from assigned, but a +/// resolved alert is terminal. +/// +internal sealed class SupportAlertService(ApplicationDbContext db, IDateTimeProvider dateTimeProvider) : ISupportAlertService +{ + public async ValueTask RaiseAsync( + string type, + string entityType, + string entityId, + string severity, + long? bookingId = null, + long? reviewId = null, + CancellationToken cancellationToken = default) + { + var alert = new SupportAlert + { + Type = type, + EntityType = entityType, + EntityId = entityId, + Severity = severity, + Status = SupportAlertStatus.Open, + BookingId = bookingId, + ReviewId = reviewId + }; + + db.Set().Add(alert); + await db.SaveChangesAsync(cancellationToken); + return alert.Id; + } + + public async ValueTask AssignAsync(long alertId, int ownerUserId, CancellationToken cancellationToken = default) + { + var alert = await db.Set().FirstOrDefaultAsync(a => a.Id == alertId, cancellationToken); + if (alert is null || alert.Status == SupportAlertStatus.Resolved) + return false; + + alert.OwnerUserId = ownerUserId; + alert.Status = SupportAlertStatus.Assigned; + await db.SaveChangesAsync(cancellationToken); + return true; + } + + public async ValueTask ResolveAsync(long alertId, string note, CancellationToken cancellationToken = default) + { + var alert = await db.Set().FirstOrDefaultAsync(a => a.Id == alertId, cancellationToken); + if (alert is null || alert.Status == SupportAlertStatus.Resolved) + return false; + + alert.Status = SupportAlertStatus.Resolved; + alert.ResolutionNote = note; + alert.ResolvedAt = dateTimeProvider.UtcNow; + await db.SaveChangesAsync(cancellationToken); + return true; + } + + public async ValueTask> ListAsync( + string? type, + string? status, + int? ownerUserId, + int page, + int pageSize, + CancellationToken cancellationToken = default) + { + var query = db.Set().AsNoTracking().AsQueryable(); + + if (!string.IsNullOrWhiteSpace(type)) + query = query.Where(a => a.Type == type); + if (!string.IsNullOrWhiteSpace(status)) + query = query.Where(a => a.Status == status); + if (ownerUserId is { } owner) + query = query.Where(a => a.OwnerUserId == owner); + + // Id is a monotonic identity → newest-first and deterministic (no timestamp ties). + query = query.OrderByDescending(a => a.Id); + + var total = await query.CountAsync(cancellationToken); + var items = await query + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Select(a => new SupportAlertDto( + a.Id, a.Type, a.Severity, a.Status, a.EntityType, a.EntityId, + a.BookingId, a.ReviewId, a.OwnerUserId, a.ResolutionNote, a.ResolvedAt, a.CreatedAt)) + .ToListAsync(cancellationToken); + + return new PagedResult(items, total, page, pageSize); + } +} diff --git a/server/src/Tests/Baya.Test.Foundation/Marketplace/AnalyticsSinkTests.cs b/server/src/Tests/Baya.Test.Foundation/Marketplace/AnalyticsSinkTests.cs new file mode 100644 index 0000000..d077339 --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/Marketplace/AnalyticsSinkTests.cs @@ -0,0 +1,24 @@ +using Baya.Domain.Entities.Analytics; +using Baya.Infrastructure.Persistence.Services.Analytics; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Baya.Test.Foundation.Marketplace; + +public sealed class AnalyticsSinkTests +{ + [Fact] + public async Task Emit_InsertsSystemEventRow() + { + using var host = new OpsTestHost(); + host.CurrentUser.UserId = await host.AddUserAsync("actor"); + var sink = new AnalyticsSink(host.Db, host.CurrentUser, host.Clock, NullLogger.Instance); + + await sink.EmitAsync("nurse_search_performed", new { query = "cardiac" }); + + var evt = await host.Db.Set().SingleAsync(); + Assert.Equal("nurse_search_performed", evt.Name); + Assert.Equal(host.CurrentUser.UserId, evt.UserId); + Assert.Contains("cardiac", evt.PropsJson); + } +} diff --git a/server/src/Tests/Baya.Test.Foundation/Marketplace/HolidayCalendarServiceTests.cs b/server/src/Tests/Baya.Test.Foundation/Marketplace/HolidayCalendarServiceTests.cs new file mode 100644 index 0000000..8daf3aa --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/Marketplace/HolidayCalendarServiceTests.cs @@ -0,0 +1,56 @@ +using Baya.Domain.Entities.Holidays; +using Baya.Infrastructure.Persistence.Services.Holidays; + +namespace Baya.Test.Foundation.Marketplace; + +public sealed class HolidayCalendarServiceTests +{ + [Fact] + public async Task IsBankClosed_SeededBankClosedDate_True() + { + using var host = new OpsTestHost(); + var calendar = new HolidayCalendarService(host.Db, host.Cache); + + Assert.True(await calendar.IsBankClosed(new DateOnly(2026, 3, 21))); + } + + [Fact] + public async Task IsHoliday_NonHoliday_False() + { + using var host = new OpsTestHost(); + var calendar = new HolidayCalendarService(host.Db, host.Cache); + + Assert.False(await calendar.IsHoliday(new DateOnly(2026, 5, 4))); + } + + [Fact] + public async Task NextBusinessDay_FromHoliday_ReturnsOpenBankDay() + { + using var host = new OpsTestHost(); + var calendar = new HolidayCalendarService(host.Db, host.Cache); + + // The Nowruz block 21–24 March is bank-closed; the answer is the first later open, non-Friday day. + var next = await calendar.NextBusinessDay(new DateOnly(2026, 3, 21)); + + Assert.True(next > new DateOnly(2026, 3, 24)); + Assert.NotEqual(DayOfWeek.Friday, next.DayOfWeek); + Assert.False(await calendar.IsBankClosed(next)); + } + + [Fact] + public async Task Upsert_ThenDelete_RoundTrips() + { + using var host = new OpsTestHost(); + host.CurrentUser.UserId = await host.AddUserAsync("admin"); + var calendar = new HolidayCalendarService(host.Db, host.Cache); + var date = new DateOnly(2026, 9, 1); + + await calendar.UpsertAsync(date, "روز آزمایشی", HolidayType.Official, true); + Assert.True(await calendar.IsHoliday(date)); + Assert.True(await calendar.IsBankClosed(date)); + + Assert.True(await calendar.DeleteAsync(date)); + Assert.False(await calendar.IsHoliday(date)); + Assert.False(await calendar.DeleteAsync(date)); + } +} diff --git a/server/src/Tests/Baya.Test.Foundation/Marketplace/NotificationServiceTests.cs b/server/src/Tests/Baya.Test.Foundation/Marketplace/NotificationServiceTests.cs new file mode 100644 index 0000000..553fd58 --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/Marketplace/NotificationServiceTests.cs @@ -0,0 +1,79 @@ +using Baya.Application.Contracts.Common; +using Baya.Infrastructure.Persistence.Services.Notifications; + +namespace Baya.Test.Foundation.Marketplace; + +public sealed class NotificationServiceTests +{ + [Fact] + public async Task Dispatch_ThenList_UnreadFirst_Count_MarkRead() + { + using var host = new OpsTestHost(); + var userId = await host.AddUserAsync("family"); + var dispatcher = new InAppNotificationDispatcher(host.Db, host.Clock); + var service = new NotificationService(host.Db, host.Clock); + + await dispatcher.DispatchAsync(new Notification(userId, "booking_confirmed", "Booking confirmed", DataJson: "{\"booking_id\":1}")); + + var page = await service.ListMineAsync(userId, 1, 20); + var notification = Assert.Single(page.Items); + Assert.False(notification.IsRead); + Assert.Equal("booking_confirmed", notification.Type); + Assert.Equal(1, await service.GetUnreadCountAsync(userId)); + + Assert.True(await service.MarkReadAsync(userId, notification.Id)); + Assert.Equal(0, await service.GetUnreadCountAsync(userId)); + } + + [Fact] + public async Task Notifications_AreTenantScoped() + { + using var host = new OpsTestHost(); + var owner = await host.AddUserAsync("owner"); + var other = await host.AddUserAsync("other"); + var dispatcher = new InAppNotificationDispatcher(host.Db, host.Clock); + var service = new NotificationService(host.Db, host.Clock); + + await dispatcher.DispatchAsync(new Notification(owner, "booking_confirmed", "For owner")); + + Assert.Empty((await service.ListMineAsync(other, 1, 20)).Items); + Assert.Equal(0, await service.GetUnreadCountAsync(other)); + + // A different user cannot mark another user's notification read. + var ownerNotificationId = (await service.ListMineAsync(owner, 1, 20)).Items[0].Id; + Assert.False(await service.MarkReadAsync(other, ownerNotificationId)); + } + + [Fact] + public async Task PurgeOldRead_RemovesOnlyReadOlderThanWindow() + { + using var host = new OpsTestHost(); + var userId = await host.AddUserAsync("family"); + var dispatcher = new InAppNotificationDispatcher(host.Db, host.Clock); + var service = new NotificationService(host.Db, host.Clock); + + var reference = host.Clock.UtcNow; + + // Old + read → should be purged. + host.Clock.UtcNow = reference.AddDays(-100); + await dispatcher.DispatchAsync(new Notification(userId, "t", "old read")); + var oldReadId = (await service.ListMineAsync(userId, 1, 20)).Items[0].Id; + await service.MarkReadAsync(userId, oldReadId); + + // Old + unread → must survive. + await dispatcher.DispatchAsync(new Notification(userId, "t", "old unread")); + + // Recent + read → must survive. + host.Clock.UtcNow = reference; + await dispatcher.DispatchAsync(new Notification(userId, "t", "recent read")); + var recentReadId = (await service.ListMineAsync(userId, 1, 20)).Items.First(n => n.Title == "recent read").Id; + await service.MarkReadAsync(userId, recentReadId); + + var removed = await service.PurgeOldReadAsync(90); + + Assert.Equal(1, removed); + var remaining = (await service.ListMineAsync(userId, 1, 20)).Items; + Assert.Equal(2, remaining.Count); + Assert.DoesNotContain(remaining, n => n.Title == "old read"); + } +} diff --git a/server/src/Tests/Baya.Test.Foundation/Marketplace/OpsTestHost.cs b/server/src/Tests/Baya.Test.Foundation/Marketplace/OpsTestHost.cs new file mode 100644 index 0000000..71b08b5 --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/Marketplace/OpsTestHost.cs @@ -0,0 +1,69 @@ +using Baya.Application.Contracts.Common; +using Baya.Domain.Entities.User; +using Baya.Infrastructure.CrossCutting.Seams; +using Baya.Infrastructure.Persistence; +using Baya.Infrastructure.Persistence.Interceptors; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; + +namespace Baya.Test.Foundation.Marketplace; + +/// +/// Spins up a real over an isolated in-memory SQLite database with the +/// audit interceptor wired and the marketplace seed applied (via EnsureCreated). Gives the +/// platform-signal services something faithful to run against, with a controllable clock and caller. +/// +internal sealed class OpsTestHost : IDisposable +{ + private readonly SqliteConnection _connection; + + public ApplicationDbContext Db { get; } + public TestClock Clock { get; } = new(); + public TestCurrentUser CurrentUser { get; } = new(); + public ICacheService Cache { get; } = new MemoryCacheService(new MemoryCache(new MemoryCacheOptions())); + + public OpsTestHost() + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + Clock.UtcNow = new DateTimeOffset(2026, 7, 1, 12, 0, 0, TimeSpan.Zero); + + var interceptor = new AuditFieldInterceptor(CurrentUser, Clock); + var options = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .AddInterceptors(interceptor) + .Options; + + Db = new ApplicationDbContext(options); + Db.Database.EnsureCreated(); + } + + /// Adds a real user row so FK-bound rows (notifications, audit actor) satisfy the constraint. + public async Task AddUserAsync(string userName) + { + var user = new User { UserName = userName }; + Db.Set().Add(user); + await Db.SaveChangesAsync(); + return user.Id; + } + + public void Dispose() + { + Db.Dispose(); + _connection.Dispose(); + } +} + +internal sealed class TestClock : IDateTimeProvider +{ + public DateTimeOffset UtcNow { get; set; } +} + +internal sealed class TestCurrentUser : ICurrentUser +{ + public int? UserId { get; set; } + public bool IsAuthenticated => UserId is not null; + public IReadOnlyList Roles { get; set; } = []; +} diff --git a/server/src/Tests/Baya.Test.Foundation/Marketplace/PlatformConfigServiceTests.cs b/server/src/Tests/Baya.Test.Foundation/Marketplace/PlatformConfigServiceTests.cs new file mode 100644 index 0000000..4bffd99 --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/Marketplace/PlatformConfigServiceTests.cs @@ -0,0 +1,52 @@ +using Baya.Infrastructure.Persistence.Services.Configuration; + +namespace Baya.Test.Foundation.Marketplace; + +public sealed class PlatformConfigServiceTests +{ + [Fact] + public async Task GetConfig_ParsesValueByDataType() + { + using var host = new OpsTestHost(); + var config = new PlatformConfigService(host.Db, host.Cache); + + Assert.Equal(0.10m, await config.GetConfig("vat_rate")); + Assert.Equal(72, await config.GetConfig("dispute_window_hours")); + Assert.Equal(30, await config.GetConfig("booking_payment_deadline_minutes")); + Assert.Equal("platform", await config.GetConfig("bnpl_merchant_of_record")); + } + + [Fact] + public async Task SetConfig_UpdatesValue_WritesAuditRow_AndEvictsCache() + { + using var host = new OpsTestHost(); + host.CurrentUser.UserId = await host.AddUserAsync("admin"); + var config = new PlatformConfigService(host.Db, host.Cache); + + // Prime the cache with the seeded value. + Assert.Equal(0.15m, await config.GetConfig("platform_fee_rate")); + + var updated = await config.SetConfig("platform_fee_rate", "0.18"); + Assert.True(updated); + + // Cache was evicted → the next read returns the new value. + Assert.Equal(0.18m, await config.GetConfig("platform_fee_rate")); + + var history = await config.GetConfigChangeHistory("platform_fee_rate", 1, 20); + Assert.Equal(1, history.Total); + var change = Assert.Single(history.Items); + Assert.Equal("updated", change.Action); + Assert.Equal(host.CurrentUser.UserId, change.ActorUserId); + Assert.Contains("0.15", change.ChangedFieldsJson); + Assert.Contains("0.18", change.ChangedFieldsJson); + } + + [Fact] + public async Task SetConfig_MissingKey_ReturnsFalse() + { + using var host = new OpsTestHost(); + var config = new PlatformConfigService(host.Db, host.Cache); + + Assert.False(await config.SetConfig("does_not_exist", "x")); + } +} diff --git a/server/src/Tests/Baya.Test.Foundation/Marketplace/SupportAlertServiceTests.cs b/server/src/Tests/Baya.Test.Foundation/Marketplace/SupportAlertServiceTests.cs new file mode 100644 index 0000000..0de306c --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/Marketplace/SupportAlertServiceTests.cs @@ -0,0 +1,36 @@ +using Baya.Domain.Entities.SupportAlerts; +using Baya.Infrastructure.Persistence.Services.SupportAlerts; + +namespace Baya.Test.Foundation.Marketplace; + +public sealed class SupportAlertServiceTests +{ + [Fact] + public async Task Raise_List_Assign_Resolve_Lifecycle() + { + using var host = new OpsTestHost(); + var admin = await host.AddUserAsync("admin"); + var service = new SupportAlertService(host.Db, host.Clock); + + var alertId = await service.RaiseAsync( + SupportAlertType.LowRating, "review", "42", SupportAlertSeverity.High, reviewId: 42); + + var open = await service.ListAsync(null, SupportAlertStatus.Open, null, 1, 20); + var raised = Assert.Single(open.Items); + Assert.Equal(alertId, raised.Id); + Assert.Equal(42, raised.ReviewId); + Assert.Equal(SupportAlertStatus.Open, raised.Status); + + Assert.True(await service.AssignAsync(alertId, admin)); + var assigned = (await service.ListAsync(null, SupportAlertStatus.Assigned, null, 1, 20)).Items.Single(); + Assert.Equal(admin, assigned.OwnerUserId); + + Assert.True(await service.ResolveAsync(alertId, "Handled — nurse contacted.")); + var resolved = (await service.ListAsync(null, SupportAlertStatus.Resolved, null, 1, 20)).Items.Single(); + Assert.Equal("Handled — nurse contacted.", resolved.ResolutionNote); + Assert.NotNull(resolved.ResolvedAt); + + // Forward-only: a resolved alert cannot be resolved again. + Assert.False(await service.ResolveAsync(alertId, "again")); + } +}
FieldTypeNotes