backend phase 1: config, reference & platform signals

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<T>; 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) <noreply@anthropic.com>
This commit is contained in:
hamid
2026-07-02 01:18:00 +03:30
parent aae1ce971f
commit 2f2aec61a2
99 changed files with 6172 additions and 52 deletions
+113
View File
@@ -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<PlatformConfigDto>``{ 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<ConfigChangeDto>``{ 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<HolidayDto>``{ 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<AuditLogDto>``{ 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<SupportAlertDto>``{ 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<NotificationDto>``{ 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 `"<redacted>"`), `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).
File diff suppressed because it is too large Load Diff
@@ -12,6 +12,24 @@ One block per completed backend phase. Newest at the top. Backend lane writes he
- **Notes for frontend:** <anything load-bearing>
-->
## 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` +
@@ -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<T>(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).
@@ -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<T>`; 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<decimal>("vat_rate")==0.10`, `<int>("dispute_window_hours")==72`,
`<int>("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<Program>`, 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.
@@ -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
@@ -23,6 +23,22 @@
<p><strong>Role:</strong> High-volume behavioral/analytics event log. <strong>Why kept but de-emphasized:</strong> product analytics, not compliance. It grows unbounded — at scale, pipe it to an analytics sink/warehouse rather than the transactional DB. Fields unchanged.</p>
<h3 id="platform_configs-core"><code>platform_configs</code> [CORE] <a class="anchor" href="#platform_configs-core" aria-hidden="true">#</a></h3>
<p><strong>Role:</strong> Key-value runtime business parameters — change without a deploy. <strong>Why typed values:</strong> <code>data_type</code> tells the app how to parse. <strong>New keys</strong> this revision: <code>dispute_window_hours</code> (default 72), <code>vat_rate</code> (0.10), <code>bnpl_merchant_of_record</code>, <code>bnpl_provider_commission_rate</code>, <code>bnpl_settlement_timing</code>, cancellation-tier defaults — alongside the existing <code>platform_fee_rate</code>, <code>booking_payment_deadline_minutes</code>, <code>nurse_response_deadline_hours</code>, <code>nurse_payout_interval_days</code>, <code>evv_location_tolerance_meters</code>, <code>min_rating_for_support_alert</code>. <strong>Relations:</strong> referenced everywhere; changes audited.</p>
<p><strong>Seeded defaults (as built, backend-phase-1).</strong> 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.</p>
<div class="table-wrap"><table><thead><tr><th>Key</th><th><code>data_type</code></th><th>Seeded value</th><th>Source</th></tr></thead><tbody>
<tr><td><code>platform_fee_rate</code></td><td>decimal</td><td><code>0.15</code></td><td>_provisional_</td></tr>
<tr><td><code>vat_rate</code></td><td>decimal</td><td><code>0.10</code></td><td>doc (10%, commission line only)</td></tr>
<tr><td><code>dispute_window_hours</code></td><td>int</td><td><code>72</code></td><td>doc</td></tr>
<tr><td><code>booking_payment_deadline_minutes</code></td><td>int</td><td><code>30</code></td><td>doc</td></tr>
<tr><td><code>nurse_response_deadline_hours</code></td><td>int</td><td><code>24</code></td><td>_provisional_</td></tr>
<tr><td><code>nurse_payout_interval_days</code></td><td>int</td><td><code>7</code></td><td>doc (weekly)</td></tr>
<tr><td><code>evv_location_tolerance_meters</code></td><td>int</td><td><code>200</code></td><td>_provisional_</td></tr>
<tr><td><code>min_rating_for_support_alert</code></td><td>decimal</td><td><code>2</code></td><td>_provisional_ (review ≤ 2 raises an alert)</td></tr>
<tr><td><code>bnpl_merchant_of_record</code></td><td>string</td><td><code>platform</code></td><td>doc (Balinyaar is MoR)</td></tr>
<tr><td><code>bnpl_provider_commission_rate</code></td><td>decimal</td><td><code>0.07</code></td><td>_provisional_</td></tr>
<tr><td><code>bnpl_settlement_timing</code></td><td>string</td><td><code>immediate</code></td><td>_provisional_</td></tr>
<tr><td><code>cancellation_tiers</code></td><td>json</td><td><code>[{"min_hours_before":48,"refund_percent":100},{"min_hours_before":24,"refund_percent":50},{"min_hours_before":0,"refund_percent":0}]</code></td><td>_provisional_</td></tr>
</tbody></table></div>
<p>Rates are <code>DECIMAL</code> fractions (not money); the IRR amounts they later multiply are <code>BIGINT</code>. <strong>Read them at compute time (cached via <code>IPlatformConfig</code>), never hardcode</strong>, and snapshot the rate used onto the priced booking/invoice so a later rate change never re-prices an existing row.</p>
<h3 id="iranian_holidays-mvp-new"><code>iranian_holidays</code> [MVP] — <strong>NEW</strong> <a class="anchor" href="#iranian_holidays-mvp-new" aria-hidden="true">#</a></h3>
<p><strong>Role:</strong> Shared official/religious holiday calendar (movable, partly lunar-Hijri), with a <code>is_bank_closed</code> flag. <strong>Why a real table:</strong> Iran's holidays are numerous and partly movable, and they drive <strong>payout bank-closure scheduling</strong> (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.</p>
<div class="table-wrap"><table><thead><tr><th>Field</th><th>Type</th><th>Notes</th></tr></thead><tbody>
@@ -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.
+18 -4
View File
@@ -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
+20
View File
@@ -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<T>`
> (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`.
@@ -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<PagedResult<AuditLogDto>>]
public async Task<IActionResult> GetAuditTrail([FromQuery] GetAuditTrailQuery query, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(query, cancellationToken));
}
@@ -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<PagedResult<HolidayDto>>]
public async Task<IActionResult> GetHolidays([FromQuery] ListHolidaysQuery query, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(query, cancellationToken));
[HttpPost("[action]")]
[ProducesOkApiResponseType]
public async Task<IActionResult> UpsertHoliday(UpsertHolidayCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command, cancellationToken));
[HttpPost("[action]")]
[ProducesOkApiResponseType]
public async Task<IActionResult> DeleteHoliday(DeleteHolidayCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command, cancellationToken));
}
@@ -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<PagedResult<NotificationDto>>]
public async Task<IActionResult> GetNotifications([FromQuery] ListMyNotificationsQuery query, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(query, cancellationToken));
[HttpGet("[action]")]
[ProducesOkApiResponseType<UnreadCountResult>]
public async Task<IActionResult> GetUnreadCount(CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new GetUnreadCountQuery(), cancellationToken));
[HttpPost("[action]")]
[ProducesOkApiResponseType]
public async Task<IActionResult> MarkNotificationRead(MarkNotificationReadCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command, cancellationToken));
[HttpPost("[action]")]
[ProducesOkApiResponseType]
public async Task<IActionResult> MarkAllRead(CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new MarkAllReadCommand(), cancellationToken));
}
@@ -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<PagedResult<PlatformConfigDto>>]
public async Task<IActionResult> GetPlatformConfigs([FromQuery] ListPlatformConfigsQuery query, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(query, cancellationToken));
[HttpPost("[action]")]
[ProducesOkApiResponseType]
public async Task<IActionResult> UpdatePlatformConfig(UpdatePlatformConfigCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command, cancellationToken));
[HttpGet("[action]")]
[ProducesOkApiResponseType<PagedResult<ConfigChangeDto>>]
public async Task<IActionResult> GetConfigChangeHistory([FromQuery] GetConfigChangeHistoryQuery query, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(query, cancellationToken));
}
@@ -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<PagedResult<SupportAlertDto>>]
public async Task<IActionResult> GetSupportAlerts([FromQuery] ListSupportAlertsQuery query, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(query, cancellationToken));
[HttpPost("[action]")]
[ProducesOkApiResponseType]
public async Task<IActionResult> AssignSupportAlert(AssignSupportAlertCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command, cancellationToken));
[HttpPost("[action]")]
[ProducesOkApiResponseType]
public async Task<IActionResult> ResolveSupportAlert(ResolveSupportAlertCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command, cancellationToken));
}
@@ -0,0 +1,15 @@
namespace Baya.Application.Common;
/// <summary>Normalises paging inputs so every list handler clamps <c>page</c>/<c>page_size</c> the same way.</summary>
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);
}
}
@@ -0,0 +1,11 @@
namespace Baya.Application.Contracts.Analytics;
/// <summary>
/// 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 <c>system_events</c> row; the real path pipes to a warehouse.
/// </summary>
public interface IAnalyticsSink
{
ValueTask EmitAsync(string name, object props, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,27 @@
#nullable enable
using Baya.Application.Models.Audit;
using Baya.Application.Models.Common;
namespace Baya.Application.Contracts.Audit;
/// <summary>
/// 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.
/// </summary>
public interface IAuditLogger
{
ValueTask WriteAsync(
string entityType,
string entityId,
string action,
IReadOnlyDictionary<string, object?>? changedFields = null,
CancellationToken cancellationToken = default);
ValueTask<PagedResult<AuditLogDto>> GetTrailAsync(
string entityType,
string entityId,
int page,
int pageSize,
CancellationToken cancellationToken = default);
}
@@ -1,3 +1,4 @@
#nullable enable
namespace Baya.Application.Contracts.Common;
/// <summary>
@@ -11,20 +12,25 @@ public enum NotificationChannel
Push
}
/// <summary>A notification to dispatch to a recipient over one channel.</summary>
/// <summary>A notification to mint for a recipient over one channel.</summary>
/// <param name="RecipientUserId">The target user.</param>
/// <param name="Channel">Delivery channel.</param>
/// <param name="Type">Stable type code driving front-end rendering/deep-link (e.g. <c>booking_confirmed</c>).</param>
/// <param name="Title">Short title/subject.</param>
/// <param name="Body">Message body (no secrets/PII in logs).</param>
/// <param name="Body">Optional message body (no secrets/PII in logs).</param>
/// <param name="DataJson">Optional typed, versioned deep-link payload — a contract, not an arbitrary blob.</param>
/// <param name="Channel">Delivery channel (in-app now; SMS/push deferred).</param>
public sealed record Notification(
int RecipientUserId,
NotificationChannel Channel,
string Type,
string Title,
string Body);
string? Body = null,
string? DataJson = null,
NotificationChannel Channel = NotificationChannel.InApp);
/// <summary>
/// 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 <c>notifications</c> row; SMS/push channels are added later behind this same
/// interface, so callers use <see cref="DispatchAsync"/> unchanged.
/// </summary>
public interface INotificationDispatcher
{
@@ -0,0 +1,28 @@
#nullable enable
using Baya.Application.Models.Common;
using Baya.Application.Models.Configuration;
namespace Baya.Application.Contracts.Configuration;
/// <summary>
/// Typed, cached accessor for runtime business parameters stored as rows in <c>platform_configs</c>.
/// 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.
/// </summary>
public interface IPlatformConfig
{
/// <summary>Reads a config value and parses it to <typeparamref name="T"/> per the row's <c>data_type</c> (cached).</summary>
ValueTask<T> GetConfig<T>(string key, CancellationToken cancellationToken = default);
/// <summary>
/// Updates an existing config row (and writes an audit entry, in one transaction) then evicts the cache.
/// Returns <c>false</c> 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.
/// </summary>
ValueTask<bool> SetConfig(string key, string value, CancellationToken cancellationToken = default);
ValueTask<PagedResult<PlatformConfigDto>> ListAsync(int page, int pageSize, CancellationToken cancellationToken = default);
/// <summary>The audited change history for a single config key (from the append-only audit trail).</summary>
ValueTask<PagedResult<ConfigChangeDto>> GetConfigChangeHistory(string key, int page, int pageSize, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,29 @@
#nullable enable
using Baya.Application.Models.Common;
using Baya.Application.Models.Holidays;
namespace Baya.Application.Contracts.Holidays;
/// <summary>
/// The Iranian holiday calendar seam. Lookups are cached. Payout scheduling (later phase) calls
/// <see cref="NextBusinessDay"/> to shift a payout off bank-closed days. Mock reads the seeded
/// <c>iranian_holidays</c> table; the real path syncs an external banking-holiday feed.
/// </summary>
public interface IHolidayCalendar
{
ValueTask<bool> IsHoliday(DateOnly date, CancellationToken cancellationToken = default);
/// <summary>True if PAYA/SATNA banks are closed that day (a seeded bank-closed holiday).</summary>
ValueTask<bool> IsBankClosed(DateOnly date, CancellationToken cancellationToken = default);
/// <summary>The next day banks are open — skips bank-closed holidays and the Iranian banking weekend (Friday).</summary>
ValueTask<DateOnly> NextBusinessDay(DateOnly date, CancellationToken cancellationToken = default);
ValueTask<PagedResult<HolidayDto>> ListAsync(DateOnly? from, DateOnly? to, int page, int pageSize, CancellationToken cancellationToken = default);
/// <summary>Inserts or updates the holiday for <paramref name="date"/> then evicts the cached lookups for it.</summary>
ValueTask UpsertAsync(DateOnly date, string nameFa, string type, bool isBankClosed, CancellationToken cancellationToken = default);
/// <summary>Deletes the holiday for <paramref name="date"/>; returns <c>false</c> if none existed.</summary>
ValueTask<bool> DeleteAsync(DateOnly date, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,26 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Notifications;
namespace Baya.Application.Contracts.Notifications;
/// <summary>
/// Reads and per-user commands over the in-app notification store. Every operation is tenant-scoped to
/// the passed <c>userId</c> (always the authenticated caller — never a body-supplied id). Minting a new
/// notification is done through <see cref="Common.INotificationDispatcher"/>, not here.
/// </summary>
public interface INotificationService
{
/// <summary>The caller's notifications, unread-first then newest-first.</summary>
ValueTask<PagedResult<NotificationDto>> ListMineAsync(int userId, int page, int pageSize, CancellationToken cancellationToken = default);
ValueTask<int> GetUnreadCountAsync(int userId, CancellationToken cancellationToken = default);
/// <summary>Marks one of the caller's notifications read; returns <c>false</c> if it isn't theirs or doesn't exist.</summary>
ValueTask<bool> MarkReadAsync(int userId, long notificationId, CancellationToken cancellationToken = default);
/// <summary>Marks all of the caller's unread notifications read; returns the number flipped.</summary>
ValueTask<int> MarkAllReadAsync(int userId, CancellationToken cancellationToken = default);
/// <summary>Hard-deletes read notifications older than <paramref name="retentionDays"/>; never touches unread. Returns the count removed.</summary>
ValueTask<int> PurgeOldReadAsync(int retentionDays, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,37 @@
#nullable enable
using Baya.Application.Models.Common;
using Baya.Application.Models.SupportAlerts;
namespace Baya.Application.Contracts.SupportAlerts;
/// <summary>
/// Internal support-alert worklist. <see cref="RaiseAsync"/> is called by later review/EVV/verification/
/// payment flows. Alerts are staff-only — never surfaced on a user-facing endpoint. The polymorphic
/// <c>(entityType, entityId)</c> is validated at the application layer; the typed FK is preferred when
/// the subject is a booking or review.
/// </summary>
public interface ISupportAlertService
{
ValueTask<long> RaiseAsync(
string type,
string entityType,
string entityId,
string severity,
long? bookingId = null,
long? reviewId = null,
CancellationToken cancellationToken = default);
/// <summary>Assigns an open alert to an owner (open → assigned). Returns <c>false</c> if the alert is missing or already resolved.</summary>
ValueTask<bool> AssignAsync(long alertId, int ownerUserId, CancellationToken cancellationToken = default);
/// <summary>Resolves an alert with a note (→ resolved). Returns <c>false</c> if the alert is missing or already resolved.</summary>
ValueTask<bool> ResolveAsync(long alertId, string note, CancellationToken cancellationToken = default);
ValueTask<PagedResult<SupportAlertDto>> ListAsync(
string? type,
string? status,
int? ownerUserId,
int page,
int pageSize,
CancellationToken cancellationToken = default);
}
@@ -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<GetAuditTrailQuery, OperationResult<PagedResult<AuditLogDto>>>
{
public async ValueTask<OperationResult<PagedResult<AuditLogDto>>> 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<PagedResult<AuditLogDto>>.SuccessResult(result);
}
}
@@ -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<OperationResult<PagedResult<AuditLogDto>>>;
@@ -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<UpdatePlatformConfigCommand, OperationResult<bool>>
{
public async ValueTask<OperationResult<bool>> Handle(UpdatePlatformConfigCommand request, CancellationToken cancellationToken)
{
var updated = await platformConfig.SetConfig(request.Key, request.Value, cancellationToken);
return updated
? OperationResult<bool>.SuccessResult(true)
: OperationResult<bool>.NotFoundResult($"Config key '{request.Key}' does not exist.");
}
}
@@ -0,0 +1,12 @@
using FluentValidation;
namespace Baya.Application.Features.Configuration.Commands.UpdatePlatformConfig;
public sealed class UpdatePlatformConfigCommandValidator : AbstractValidator<UpdatePlatformConfigCommand>
{
public UpdatePlatformConfigCommandValidator()
{
RuleFor(x => x.Key).NotEmpty().MaximumLength(100);
RuleFor(x => x.Value).NotNull();
}
}
@@ -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<OperationResult<bool>>;
@@ -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<GetConfigChangeHistoryQuery, OperationResult<PagedResult<ConfigChangeDto>>>
{
public async ValueTask<OperationResult<PagedResult<ConfigChangeDto>>> 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<PagedResult<ConfigChangeDto>>.SuccessResult(result);
}
}
@@ -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<OperationResult<PagedResult<ConfigChangeDto>>>;
@@ -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<ListPlatformConfigsQuery, OperationResult<PagedResult<PlatformConfigDto>>>
{
public async ValueTask<OperationResult<PagedResult<PlatformConfigDto>>> Handle(ListPlatformConfigsQuery request, CancellationToken cancellationToken)
{
var (page, pageSize) = Pagination.Normalize(request.Page, request.PageSize);
var result = await platformConfig.ListAsync(page, pageSize, cancellationToken);
return OperationResult<PagedResult<PlatformConfigDto>>.SuccessResult(result);
}
}
@@ -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<OperationResult<PagedResult<PlatformConfigDto>>>;
@@ -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<DeleteHolidayCommand, OperationResult<bool>>
{
public async ValueTask<OperationResult<bool>> Handle(DeleteHolidayCommand request, CancellationToken cancellationToken)
{
var deleted = await holidayCalendar.DeleteAsync(request.HolidayDate, cancellationToken);
return deleted
? OperationResult<bool>.SuccessResult(true)
: OperationResult<bool>.NotFoundResult($"No holiday exists on {request.HolidayDate:yyyy-MM-dd}.");
}
}
@@ -0,0 +1,6 @@
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Holidays.Commands.DeleteHoliday;
public record DeleteHolidayCommand(DateOnly HolidayDate) : IRequest<OperationResult<bool>>;
@@ -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<UpsertHolidayCommand, OperationResult<bool>>
{
public async ValueTask<OperationResult<bool>> Handle(UpsertHolidayCommand request, CancellationToken cancellationToken)
{
await holidayCalendar.UpsertAsync(request.HolidayDate, request.NameFa, request.Type, request.IsBankClosed, cancellationToken);
return OperationResult<bool>.SuccessResult(true);
}
}
@@ -0,0 +1,16 @@
using Baya.Domain.Entities.Holidays;
using FluentValidation;
namespace Baya.Application.Features.Holidays.Commands.UpsertHoliday;
public sealed class UpsertHolidayCommandValidator : AbstractValidator<UpsertHolidayCommand>
{
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}.");
}
}
@@ -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<OperationResult<bool>>;
@@ -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<ListHolidaysQuery, OperationResult<PagedResult<HolidayDto>>>
{
public async ValueTask<OperationResult<PagedResult<HolidayDto>>> 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<PagedResult<HolidayDto>>.SuccessResult(result);
}
}
@@ -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<OperationResult<PagedResult<HolidayDto>>>;
@@ -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<MarkAllReadCommand, OperationResult<bool>>
{
public async ValueTask<OperationResult<bool>> Handle(MarkAllReadCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<bool>.FailureResult("User", "Not authenticated.");
await notifications.MarkAllReadAsync(userId, cancellationToken);
return OperationResult<bool>.SuccessResult(true);
}
}
@@ -0,0 +1,6 @@
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Notifications.Commands.MarkAllRead;
public record MarkAllReadCommand : IRequest<OperationResult<bool>>;
@@ -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<MarkNotificationReadCommand, OperationResult<bool>>
{
public async ValueTask<OperationResult<bool>> Handle(MarkNotificationReadCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<bool>.FailureResult("User", "Not authenticated.");
var marked = await notifications.MarkReadAsync(userId, request.NotificationId, cancellationToken);
return marked
? OperationResult<bool>.SuccessResult(true)
: OperationResult<bool>.NotFoundResult($"Notification {request.NotificationId} was not found.");
}
}
@@ -0,0 +1,11 @@
using FluentValidation;
namespace Baya.Application.Features.Notifications.Commands.MarkNotificationRead;
public sealed class MarkNotificationReadCommandValidator : AbstractValidator<MarkNotificationReadCommand>
{
public MarkNotificationReadCommandValidator()
{
RuleFor(x => x.NotificationId).GreaterThan(0);
}
}
@@ -0,0 +1,6 @@
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Notifications.Commands.MarkNotificationRead;
public record MarkNotificationReadCommand(long NotificationId) : IRequest<OperationResult<bool>>;
@@ -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<GetUnreadCountQuery, OperationResult<UnreadCountResult>>
{
public async ValueTask<OperationResult<UnreadCountResult>> Handle(GetUnreadCountQuery request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<UnreadCountResult>.FailureResult("User", "Not authenticated.");
var count = await notifications.GetUnreadCountAsync(userId, cancellationToken);
return OperationResult<UnreadCountResult>.SuccessResult(new UnreadCountResult(count));
}
}
@@ -0,0 +1,4 @@
namespace Baya.Application.Features.Notifications.Queries.GetUnreadCount;
/// <summary>Unread-notification count for the polling bell.</summary>
public record UnreadCountResult(int Count);
@@ -0,0 +1,6 @@
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Notifications.Queries.GetUnreadCount;
public record GetUnreadCountQuery : IRequest<OperationResult<UnreadCountResult>>;
@@ -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<ListMyNotificationsQuery, OperationResult<PagedResult<NotificationDto>>>
{
public async ValueTask<OperationResult<PagedResult<NotificationDto>>> Handle(ListMyNotificationsQuery request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<PagedResult<NotificationDto>>.FailureResult("User", "Not authenticated.");
var (page, pageSize) = Pagination.Normalize(request.Page, request.PageSize);
var result = await notifications.ListMineAsync(userId, page, pageSize, cancellationToken);
return OperationResult<PagedResult<NotificationDto>>.SuccessResult(result);
}
}
@@ -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<OperationResult<PagedResult<NotificationDto>>>;
@@ -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<AssignSupportAlertCommand, OperationResult<bool>>
{
public async ValueTask<OperationResult<bool>> Handle(AssignSupportAlertCommand request, CancellationToken cancellationToken)
{
var assigned = await supportAlerts.AssignAsync(request.AlertId, request.OwnerUserId, cancellationToken);
return assigned
? OperationResult<bool>.SuccessResult(true)
: OperationResult<bool>.NotFoundResult($"Support alert {request.AlertId} was not found or is already resolved.");
}
}
@@ -0,0 +1,12 @@
using FluentValidation;
namespace Baya.Application.Features.SupportAlerts.Commands.AssignSupportAlert;
public sealed class AssignSupportAlertCommandValidator : AbstractValidator<AssignSupportAlertCommand>
{
public AssignSupportAlertCommandValidator()
{
RuleFor(x => x.AlertId).GreaterThan(0);
RuleFor(x => x.OwnerUserId).GreaterThan(0);
}
}
@@ -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<OperationResult<bool>>;
@@ -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<ResolveSupportAlertCommand, OperationResult<bool>>
{
public async ValueTask<OperationResult<bool>> Handle(ResolveSupportAlertCommand request, CancellationToken cancellationToken)
{
var resolved = await supportAlerts.ResolveAsync(request.AlertId, request.Note, cancellationToken);
return resolved
? OperationResult<bool>.SuccessResult(true)
: OperationResult<bool>.NotFoundResult($"Support alert {request.AlertId} was not found or is already resolved.");
}
}
@@ -0,0 +1,12 @@
using FluentValidation;
namespace Baya.Application.Features.SupportAlerts.Commands.ResolveSupportAlert;
public sealed class ResolveSupportAlertCommandValidator : AbstractValidator<ResolveSupportAlertCommand>
{
public ResolveSupportAlertCommandValidator()
{
RuleFor(x => x.AlertId).GreaterThan(0);
RuleFor(x => x.Note).NotEmpty().MaximumLength(1000);
}
}
@@ -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<OperationResult<bool>>;
@@ -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<ListSupportAlertsQuery, OperationResult<PagedResult<SupportAlertDto>>>
{
public async ValueTask<OperationResult<PagedResult<SupportAlertDto>>> 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<PagedResult<SupportAlertDto>>.SuccessResult(result);
}
}
@@ -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<OperationResult<PagedResult<SupportAlertDto>>>;
@@ -0,0 +1,12 @@
#nullable enable
namespace Baya.Application.Models.Audit;
/// <summary>One immutable audit-trail row.</summary>
public record AuditLogDto(
long Id,
string EntityType,
string EntityId,
string Action,
string? ChangedFieldsJson,
int? ActorUserId,
DateTimeOffset OccurredAt);
@@ -0,0 +1,4 @@
namespace Baya.Application.Models.Common;
/// <summary>Standard paginated payload: the page of <paramref name="Items"/> plus the total row count.</summary>
public record PagedResult<T>(IReadOnlyList<T> Items, int Total, int Page, int PageSize);
@@ -0,0 +1,13 @@
#nullable enable
namespace Baya.Application.Models.Configuration;
/// <summary>A runtime config row as returned to admins. <c>Value</c> is the raw string; parse per <c>DataType</c>.</summary>
public record PlatformConfigDto(string Key, string Value, string DataType, string? Description);
/// <summary>One audited change to a config key (from the append-only audit trail).</summary>
public record ConfigChangeDto(
long Id,
string Action,
string? ChangedFieldsJson,
int? ActorUserId,
DateTimeOffset OccurredAt);
@@ -0,0 +1,4 @@
namespace Baya.Application.Models.Holidays;
/// <summary>A calendar day in the Iranian holiday table.</summary>
public record HolidayDto(long Id, DateOnly HolidayDate, string NameFa, string Type, bool IsBankClosed);
@@ -0,0 +1,13 @@
#nullable enable
namespace Baya.Application.Models.Notifications;
/// <summary>An in-app notification as returned to its owner. <c>DataJson</c> is the typed deep-link payload.</summary>
public record NotificationDto(
long Id,
string Type,
string Title,
string? Body,
string? DataJson,
bool IsRead,
DateTimeOffset? ReadAt,
DateTimeOffset CreatedAt);
@@ -0,0 +1,17 @@
#nullable enable
namespace Baya.Application.Models.SupportAlerts;
/// <summary>An internal support-alert row (admin-only — never returned on a user-facing endpoint).</summary>
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);
@@ -0,0 +1,22 @@
namespace Baya.Domain.Common;
/// <summary>
/// Marks an entity whose row-level changes are written to the append-only <c>audit_logs</c> trail by the
/// SaveChanges audit interceptor. This is distinct from <see cref="IAuditableEntity"/> (which only stamps
/// the create/modify audit <em>fields</em>): implementing <c>IAuditable</c> additionally produces an
/// immutable audit-log row per insert/update/delete. Reserve it for compliance-sensitive entities —
/// <c>platform_configs</c> is auditable so finance can prove the exact rate in effect at any moment.
/// </summary>
public interface IAuditable : IEntity
{
}
/// <summary>
/// Applied to a property of an <see cref="IAuditable"/> entity whose value must never appear in the
/// audit diff (<c>changed_fields_json</c>). The interceptor writes a redaction marker instead of the
/// plaintext — used for encrypted/PII columns.
/// </summary>
[AttributeUsage(AttributeTargets.Property)]
public sealed class AuditRedactedAttribute : Attribute
{
}
@@ -0,0 +1,21 @@
#nullable enable
using Baya.Domain.Common;
namespace Baya.Domain.Entities.Analytics;
/// <summary>
/// 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 <see cref="OccurredAt"/>.
/// </summary>
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; }
}
@@ -0,0 +1,34 @@
#nullable enable
using Baya.Domain.Common;
namespace Baya.Domain.Entities.Audit;
/// <summary>
/// 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. <see cref="EntityId"/> is a
/// string so the trail is polymorphic across differently-typed primary keys.
/// </summary>
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; }
}
/// <summary>Stable codes for <see cref="AuditLog.Action"/>.</summary>
public static class AuditAction
{
public const string Created = "created";
public const string Updated = "updated";
public const string Deleted = "deleted";
}
@@ -0,0 +1,31 @@
#nullable enable
using Baya.Domain.Common;
namespace Baya.Domain.Entities.Configuration;
/// <summary>
/// A typed key-value runtime business parameter (commission rate, VAT, deadlines…). The app parses
/// <see cref="Value"/> according to <see cref="DataType"/>. Every change is audited (the type implements
/// <see cref="IAuditable"/>); there is no soft-delete — configs are updated in place and the audit trail
/// is their history.
/// </summary>
public class PlatformConfig : BaseEntity<long>, 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; }
}
/// <summary>Stable codes for <see cref="PlatformConfig.DataType"/> — tells the app how to parse the raw value.</summary>
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";
}
@@ -0,0 +1,28 @@
using Baya.Domain.Common;
namespace Baya.Domain.Entities.Holidays;
/// <summary>
/// A single day in the shared Iranian official/religious/national calendar. <see cref="IsBankClosed"/>
/// 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.
/// </summary>
public class IranianHoliday : BaseEntity<long>
{
public DateOnly HolidayDate { get; set; }
public string NameFa { get; set; } = string.Empty;
public string Type { get; set; } = HolidayType.Official;
public bool IsBankClosed { get; set; }
}
/// <summary>Stable codes for <see cref="IranianHoliday.Type"/>.</summary>
public static class HolidayType
{
public const string Official = "official";
public const string Religious = "religious";
public const string National = "national";
}
@@ -0,0 +1,30 @@
#nullable enable
using Baya.Domain.Common;
namespace Baya.Domain.Entities.Notifications;
/// <summary>
/// An in-app notification for a single user. <see cref="DataJson"/> 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.
/// </summary>
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; }
}
@@ -0,0 +1,71 @@
#nullable enable
using Baya.Domain.Common;
namespace Baya.Domain.Entities.SupportAlerts;
/// <summary>
/// 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 <c>(EntityType, EntityId)</c> 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.
/// </summary>
public class SupportAlert : BaseEntity<long>
{
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; }
}
/// <summary>Stable codes for <see cref="SupportAlert.Type"/>.</summary>
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<string> All =
[
LowRating, EvvNoShow, EvvLocationMismatch, VerificationExpired, PaymentAnomaly, FraudSignal
];
}
/// <summary>Stable codes for <see cref="SupportAlert.Severity"/>.</summary>
public static class SupportAlertSeverity
{
public const string Low = "low";
public const string Medium = "medium";
public const string High = "high";
public static readonly IReadOnlyList<string> All = [Low, Medium, High];
}
/// <summary>Stable codes for <see cref="SupportAlert.Status"/> (forward-only).</summary>
public static class SupportAlertStatus
{
public const string Open = "open";
public const string Assigned = "assigned";
public const string Resolved = "resolved";
public static readonly IReadOnlyList<string> All = [Open, Assigned, Resolved];
}
@@ -1,22 +0,0 @@
using Baya.Application.Contracts.Common;
using Microsoft.Extensions.Logging;
namespace Baya.Infrastructure.CrossCutting.Seams;
/// <summary>
/// No-op implementation of <see cref="INotificationDispatcher"/> — 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.
/// </summary>
public sealed class LogNotificationDispatcher(ILogger<LogNotificationDispatcher> 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;
}
}
@@ -8,9 +8,10 @@ namespace Baya.Infrastructure.CrossCutting.ServiceConfiguration;
public static class ServiceCollectionExtension
{
/// <summary>
/// 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
/// <c>INotificationDispatcher</c> needs the database, so it is registered in the Persistence layer.)
/// </summary>
public static IServiceCollection AddCrossCuttingSeams(this IServiceCollection services, IConfiguration configuration)
{
@@ -22,7 +23,6 @@ public static class ServiceCollectionExtension
services.AddSingleton<IFieldEncryptor, SymmetricFieldEncryptor>();
services.AddSingleton<ICacheService, MemoryCacheService>();
services.AddSingleton<IObjectStorage, LocalDiskObjectStorage>();
services.AddScoped<INotificationDispatcher, LogNotificationDispatcher>();
return services;
}
@@ -13,6 +13,10 @@
</PackageReference>
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Baya.Test.Foundation" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Core\Baya.Application\Baya.Application.csproj" />
<ProjectReference Include="..\..\Core\Baya.Domain\Baya.Domain.csproj" />
@@ -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<SystemEvent>
{
public void Configure(EntityTypeBuilder<SystemEvent> 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<User>()
.WithMany()
.HasForeignKey(e => e.UserId)
.IsRequired(false);
}
}
@@ -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<AuditLog>
{
public void Configure(EntityTypeBuilder<AuditLog> 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<User>()
.WithMany()
.HasForeignKey(a => a.ActorUserId)
.IsRequired(false);
}
}
@@ -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<PlatformConfig>
{
public void Configure(EntityTypeBuilder<PlatformConfig> 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();
}
}
@@ -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<IranianHoliday>
{
public void Configure(EntityTypeBuilder<IranianHoliday> 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();
}
}
@@ -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<Notification>
{
public void Configure(EntityTypeBuilder<Notification> 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<User>()
.WithMany()
.HasForeignKey(n => n.UserId)
.IsRequired();
}
}
@@ -0,0 +1,10 @@
namespace Baya.Infrastructure.Persistence.Configuration;
/// <summary>
/// Fixed values used by <c>HasData</c> seeding so the generated migration is deterministic. A literal
/// timestamp (never <c>DateTime.Now</c>) keeps the model snapshot stable across migration regenerations.
/// </summary>
internal static class SeedConstants
{
public static readonly DateTimeOffset Timestamp = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
}
@@ -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<SupportAlert>
{
public void Configure(EntityTypeBuilder<SupportAlert> 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<User>()
.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.
}
}
@@ -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;
/// <summary>
/// Stamps audit fields on every save: <c>CreatedAt</c>/<c>CreatedById</c> on insert and
/// <c>ModifiedAt</c>/<c>ModifiedById</c> on update, sourcing time from <see cref="IDateTimeProvider"/>
/// and the acting user from <see cref="ICurrentUser"/>. 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 <c>CreatedAt</c>/<c>CreatedById</c> on insert and <c>ModifiedAt</c>/<c>ModifiedById</c> on
/// update (from <see cref="IDateTimeProvider"/> + <see cref="ICurrentUser"/>); and
/// (2) appends an immutable <c>audit_logs</c> row for every change to an <see cref="IAuditable"/> entity,
/// with a redacted old/new diff. The audit rows ride the same <c>SaveChanges</c>, so a config change and
/// its audit entry commit atomically. Handlers never set audit fields or write audit rows themselves.
/// </summary>
public sealed class AuditFieldInterceptor(ICurrentUser currentUser, IDateTimeProvider dateTimeProvider)
: SaveChangesInterceptor
{
private const string RedactedMarker = "<redacted>";
private static readonly HashSet<string> NonBusinessFields =
["Id", "CreatedAt", "ModifiedAt", "CreatedById", "ModifiedById"];
public override InterceptionResult<int> SavingChanges(
DbContextEventData eventData,
InterceptionResult<int> result)
{
Stamp(eventData.Context);
Process(eventData.Context);
return base.SavingChanges(eventData, result);
}
@@ -28,11 +39,11 @@ public sealed class AuditFieldInterceptor(ICurrentUser currentUser, IDateTimePro
InterceptionResult<int> 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<ITimeModification>())
// 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<AuditLog>().AddRange(auditLogs);
}
private static void Stamp(IReadOnlyList<EntityEntry> 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<AuditLog> CollectAuditLogs(IReadOnlyList<EntityEntry> entries, DateTimeOffset now, int? userId)
{
var logs = new List<AuditLog>();
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": <old>, "new": <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<string, object?>();
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<AuditRedactedAttribute>() 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;
}
@@ -0,0 +1,871 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTimeOffset>("OccurredAt")
.HasColumnType("datetimeoffset");
b.Property<string>("PropsJson")
.HasColumnType("nvarchar(max)");
b.Property<int?>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<int?>("ActorUserId")
.HasColumnType("int");
b.Property<string>("ChangedFieldsJson")
.HasColumnType("nvarchar(max)");
b.Property<string>("EntityId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("EntityType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTimeOffset>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<string>("DataType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<string>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<DateOnly>("HolidayDate")
.HasColumnType("date");
b.Property<bool>("IsBankClosed")
.HasColumnType("bit");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<string>("NameFa")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<string>("Body")
.HasColumnType("nvarchar(max)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("DataJson")
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsRead")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.Property<DateTimeOffset?>("ReadAt")
.HasColumnType("datetimeoffset");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<int>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<long?>("BookingId")
.HasColumnType("bigint");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<string>("EntityId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("EntityType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<int?>("OwnerUserId")
.HasColumnType("int");
b.Property<string>("ResolutionNote")
.HasColumnType("nvarchar(max)");
b.Property<DateTimeOffset?>("ResolvedAt")
.HasColumnType("datetimeoffset");
b.Property<long?>("ReviewId")
.HasColumnType("bigint");
b.Property<string>("Severity")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("CreatedDate")
.HasColumnType("datetime2");
b.Property<string>("DisplayName")
.HasColumnType("nvarchar(max)");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("nvarchar(max)");
b.Property<string>("ClaimValue")
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("CreatedClaim")
.HasColumnType("datetime2");
b.Property<int>("RoleId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("RoleClaims", "usr");
});
modelBuilder.Entity("Baya.Domain.Entities.User.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasColumnName("UserId");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("AccessFailedCount")
.HasColumnType("int");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("nvarchar(max)");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("bit");
b.Property<string>("FamilyName")
.HasColumnType("nvarchar(max)");
b.Property<string>("GeneratedCode")
.HasColumnType("nvarchar(max)");
b.Property<bool>("LockoutEnabled")
.HasColumnType("bit");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("datetimeoffset");
b.Property<string>("Name")
.HasColumnType("nvarchar(max)");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("PasswordHash")
.HasColumnType("nvarchar(max)");
b.Property<string>("PhoneNumber")
.HasColumnType("nvarchar(max)");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("bit");
b.Property<string>("SecurityStamp")
.HasColumnType("nvarchar(max)");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("bit");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("nvarchar(max)");
b.Property<string>("ClaimValue")
.HasColumnType("nvarchar(max)");
b.Property<int>("UserId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("UserClaims", "usr");
});
modelBuilder.Entity("Baya.Domain.Entities.User.UserLogin", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("nvarchar(450)");
b.Property<string>("ProviderKey")
.HasColumnType("nvarchar(450)");
b.Property<DateTime>("LoggedOn")
.HasColumnType("datetime2");
b.Property<string>("ProviderDisplayName")
.HasColumnType("nvarchar(max)");
b.Property<int>("UserId")
.HasColumnType("int");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("UserLogins", "usr");
});
modelBuilder.Entity("Baya.Domain.Entities.User.UserRefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<bool>("IsValid")
.HasColumnType("bit");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<int>("UserId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("UserRefreshTokens", "usr");
});
modelBuilder.Entity("Baya.Domain.Entities.User.UserRole", b =>
{
b.Property<int>("UserId")
.HasColumnType("int");
b.Property<int>("RoleId")
.HasColumnType("int");
b.Property<DateTime>("CreatedUserRoleDate")
.HasColumnType("datetime2");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("UserRoles", "usr");
});
modelBuilder.Entity("Baya.Domain.Entities.User.UserToken", b =>
{
b.Property<int>("UserId")
.HasColumnType("int");
b.Property<string>("LoginProvider")
.HasColumnType("nvarchar(450)");
b.Property<string>("Name")
.HasColumnType("nvarchar(450)");
b.Property<DateTime>("GeneratedTime")
.HasColumnType("datetime2");
b.Property<string>("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
}
}
}
@@ -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
{
/// <inheritdoc />
public partial class InitialMarketplaceBaseline : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "ops");
migrationBuilder.CreateTable(
name: "AuditLogs",
schema: "ops",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
EntityType = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
EntityId = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
Action = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
ChangedFieldsJson = table.Column<string>(type: "nvarchar(max)", nullable: true),
ActorUserId = table.Column<int>(type: "int", nullable: true),
OccurredAt = table.Column<DateTimeOffset>(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<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
HolidayDate = table.Column<DateOnly>(type: "date", nullable: false),
NameFa = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
Type = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
IsBankClosed = table.Column<bool>(type: "bit", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedById = table.Column<int>(type: "int", nullable: true),
ModifiedById = table.Column<int>(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<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
UserId = table.Column<int>(type: "int", nullable: false),
Type = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
Title = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
Body = table.Column<string>(type: "nvarchar(max)", nullable: true),
DataJson = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsRead = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
ReadAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(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<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Key = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
Value = table.Column<string>(type: "nvarchar(max)", nullable: false),
DataType = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
Description = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedById = table.Column<int>(type: "int", nullable: true),
ModifiedById = table.Column<int>(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<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Type = table.Column<string>(type: "nvarchar(40)", maxLength: 40, nullable: false),
Severity = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
Status = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
EntityType = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
EntityId = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
BookingId = table.Column<long>(type: "bigint", nullable: true),
ReviewId = table.Column<long>(type: "bigint", nullable: true),
OwnerUserId = table.Column<int>(type: "int", nullable: true),
ResolutionNote = table.Column<string>(type: "nvarchar(max)", nullable: true),
ResolvedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedById = table.Column<int>(type: "int", nullable: true),
ModifiedById = table.Column<int>(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<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Name = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
PropsJson = table.Column<string>(type: "nvarchar(max)", nullable: true),
UserId = table.Column<int>(type: "int", nullable: true),
OccurredAt = table.Column<DateTimeOffset>(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");
}
/// <inheritdoc />
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");
}
}
}
@@ -22,6 +22,463 @@ namespace Baya.Infrastructure.Persistence.Migrations
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("Baya.Domain.Entities.Analytics.SystemEvent", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTimeOffset>("OccurredAt")
.HasColumnType("datetimeoffset");
b.Property<string>("PropsJson")
.HasColumnType("nvarchar(max)");
b.Property<int?>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<int?>("ActorUserId")
.HasColumnType("int");
b.Property<string>("ChangedFieldsJson")
.HasColumnType("nvarchar(max)");
b.Property<string>("EntityId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("EntityType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTimeOffset>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<string>("DataType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<string>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<DateOnly>("HolidayDate")
.HasColumnType("date");
b.Property<bool>("IsBankClosed")
.HasColumnType("bit");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<string>("NameFa")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<string>("Body")
.HasColumnType("nvarchar(max)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("DataJson")
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsRead")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.Property<DateTimeOffset?>("ReadAt")
.HasColumnType("datetimeoffset");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<int>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<long?>("BookingId")
.HasColumnType("bigint");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<string>("EntityId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("EntityType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<int?>("OwnerUserId")
.HasColumnType("int");
b.Property<string>("ResolutionNote")
.HasColumnType("nvarchar(max)");
b.Property<DateTimeOffset?>("ResolvedAt")
.HasColumnType("datetimeoffset");
b.Property<long?>("ReviewId")
.HasColumnType("bigint");
b.Property<string>("Severity")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<string>("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<int>("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")
@@ -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<AuditFieldInterceptor>());
});
// Platform-signal facades — DB-backed implementations of the Application contracts other domains
// (b2…b15) depend on. Config/holiday lookups cache through ICacheService.
services.AddScoped<IPlatformConfig, PlatformConfigService>();
services.AddScoped<IHolidayCalendar, HolidayCalendarService>();
services.AddScoped<IAnalyticsSink, AnalyticsSink>();
services.AddScoped<IAuditLogger, AuditLogger>();
services.AddScoped<INotificationService, NotificationService>();
services.AddScoped<ISupportAlertService, SupportAlertService>();
// Supersedes the b0 log/no-op stub with the real in-app notifications write.
services.AddScoped<INotificationDispatcher, InAppNotificationDispatcher>();
// Retention job seam (mock = in-process interval runner; real Hangfire/Quartz deferred).
services.AddHostedService<NotificationRetentionHostedService>();
return services;
}
@@ -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;
/// <summary>
/// Fire-and-forget analytics sink — the mock inserts a <c>system_events</c> 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).
/// </summary>
internal sealed class AnalyticsSink(
ApplicationDbContext db,
ICurrentUser currentUser,
IDateTimeProvider dateTimeProvider,
ILogger<AnalyticsSink> logger) : IAnalyticsSink
{
public async ValueTask EmitAsync(string name, object props, CancellationToken cancellationToken = default)
{
try
{
db.Set<SystemEvent>().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);
}
}
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
internal sealed class AuditLogger(
ApplicationDbContext db,
ICurrentUser currentUser,
IDateTimeProvider dateTimeProvider) : IAuditLogger
{
public async ValueTask WriteAsync(
string entityType,
string entityId,
string action,
IReadOnlyDictionary<string, object?>? changedFields = null,
CancellationToken cancellationToken = default)
{
db.Set<AuditLog>().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<PagedResult<AuditLogDto>> GetTrailAsync(
string entityType,
string entityId,
int page,
int pageSize,
CancellationToken cancellationToken = default)
{
var query = db.Set<AuditLog>()
.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<AuditLogDto>(items, total, page, pageSize);
}
}
@@ -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;
/// <summary>
/// Cached, typed accessor over <c>platform_configs</c>. Reads go through <see cref="ICacheService"/>;
/// 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.
/// </summary>
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<T> GetConfig<T>(string key, CancellationToken cancellationToken = default)
{
var dto = await cache.GetOrCreateAsync(
CacheKey(key),
async ct => await db.Set<PlatformConfig>()
.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<T>(dto.Value, dto.DataType);
}
public async ValueTask<bool> SetConfig(string key, string value, CancellationToken cancellationToken = default)
{
var entity = await db.Set<PlatformConfig>().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<PagedResult<PlatformConfigDto>> ListAsync(int page, int pageSize, CancellationToken cancellationToken = default)
{
var query = db.Set<PlatformConfig>().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<PlatformConfigDto>(items, total, page, pageSize);
}
public async ValueTask<PagedResult<ConfigChangeDto>> GetConfigChangeHistory(string key, int page, int pageSize, CancellationToken cancellationToken = default)
{
var configId = await db.Set<PlatformConfig>()
.AsNoTracking()
.Where(c => c.Key == key)
.Select(c => (long?)c.Id)
.FirstOrDefaultAsync(cancellationToken);
if (configId is null)
return new PagedResult<ConfigChangeDto>([], 0, page, pageSize);
var entityId = configId.Value.ToString(CultureInfo.InvariantCulture);
var query = db.Set<AuditLog>()
.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<ConfigChangeDto>(items, total, page, pageSize);
}
private static T Parse<T>(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<T>(value)
?? throw new InvalidOperationException($"Config value for type '{typeof(T)}' deserialized to null."),
_ => value
};
return (T)parsed;
}
}
@@ -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;
/// <summary>
/// Reads the seeded <c>iranian_holidays</c> 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.
/// </summary>
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<bool> IsHoliday(DateOnly date, CancellationToken cancellationToken = default) =>
cache.GetOrCreateAsync(
HolidayKey(date),
async ct => await db.Set<IranianHoliday>().AsNoTracking().AnyAsync(h => h.HolidayDate == date, ct),
CacheTtl,
cancellationToken);
public ValueTask<bool> IsBankClosed(DateOnly date, CancellationToken cancellationToken = default) =>
cache.GetOrCreateAsync(
BankClosedKey(date),
async ct => await db.Set<IranianHoliday>().AsNoTracking().AnyAsync(h => h.HolidayDate == date && h.IsBankClosed, ct),
CacheTtl,
cancellationToken);
public async ValueTask<DateOnly> 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<PagedResult<HolidayDto>> ListAsync(DateOnly? from, DateOnly? to, int page, int pageSize, CancellationToken cancellationToken = default)
{
var query = db.Set<IranianHoliday>().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<HolidayDto>(items, total, page, pageSize);
}
public async ValueTask UpsertAsync(DateOnly date, string nameFa, string type, bool isBankClosed, CancellationToken cancellationToken = default)
{
var existing = await db.Set<IranianHoliday>().FirstOrDefaultAsync(h => h.HolidayDate == date, cancellationToken);
if (existing is null)
{
db.Set<IranianHoliday>().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<bool> DeleteAsync(DateOnly date, CancellationToken cancellationToken = default)
{
var existing = await db.Set<IranianHoliday>().FirstOrDefaultAsync(h => h.HolidayDate == date, cancellationToken);
if (existing is null)
return false;
db.Set<IranianHoliday>().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);
}
}
@@ -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;
/// <summary>
/// Real in-app implementation of <see cref="INotificationDispatcher"/> — supersedes the b0 log/no-op
/// stub. It writes a <c>notifications</c> row for the in-app channel. SMS/push are deferred behind this
/// same seam; those channels are no-ops for now so callers use <see cref="DispatchAsync"/> unchanged.
/// </summary>
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<NotificationEntity>().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);
}
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
internal sealed class NotificationRetentionHostedService(
IServiceScopeFactory scopeFactory,
ILogger<NotificationRetentionHostedService> 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<INotificationService>();
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");
}
}
}
@@ -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;
/// <summary>
/// Reads and per-user commands over <c>notifications</c>. Every method is scoped to the passed
/// <c>userId</c> (the authenticated caller). Retention hard-deletes read notifications past the window
/// and never touches unread ones.
/// </summary>
internal sealed class NotificationService(ApplicationDbContext db, IDateTimeProvider dateTimeProvider) : INotificationService
{
public async ValueTask<PagedResult<NotificationDto>> ListMineAsync(int userId, int page, int pageSize, CancellationToken cancellationToken = default)
{
var query = db.Set<NotificationEntity>()
.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<NotificationDto>(items, total, page, pageSize);
}
public ValueTask<int> GetUnreadCountAsync(int userId, CancellationToken cancellationToken = default) =>
new(db.Set<NotificationEntity>().AsNoTracking().CountAsync(n => n.UserId == userId && !n.IsRead, cancellationToken));
public async ValueTask<bool> MarkReadAsync(int userId, long notificationId, CancellationToken cancellationToken = default)
{
var notification = await db.Set<NotificationEntity>()
.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<int> MarkAllReadAsync(int userId, CancellationToken cancellationToken = default)
{
var now = dateTimeProvider.UtcNow;
return await db.Set<NotificationEntity>()
.Where(n => n.UserId == userId && !n.IsRead)
.ExecuteUpdateAsync(
s => s.SetProperty(n => n.IsRead, true).SetProperty(n => n.ReadAt, now),
cancellationToken);
}
public async ValueTask<int> 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<NotificationEntity>()
.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<NotificationEntity>()
.Where(n => expiredIds.Contains(n.Id))
.ExecuteDeleteAsync(cancellationToken);
}
}
@@ -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;
/// <summary>
/// Internal support-alert worklist store. Never exposed on a user-facing route. Status is forward-only:
/// an alert can be assigned or resolved from <c>open</c>, and resolved from <c>assigned</c>, but a
/// resolved alert is terminal.
/// </summary>
internal sealed class SupportAlertService(ApplicationDbContext db, IDateTimeProvider dateTimeProvider) : ISupportAlertService
{
public async ValueTask<long> 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<SupportAlert>().Add(alert);
await db.SaveChangesAsync(cancellationToken);
return alert.Id;
}
public async ValueTask<bool> AssignAsync(long alertId, int ownerUserId, CancellationToken cancellationToken = default)
{
var alert = await db.Set<SupportAlert>().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<bool> ResolveAsync(long alertId, string note, CancellationToken cancellationToken = default)
{
var alert = await db.Set<SupportAlert>().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<PagedResult<SupportAlertDto>> ListAsync(
string? type,
string? status,
int? ownerUserId,
int page,
int pageSize,
CancellationToken cancellationToken = default)
{
var query = db.Set<SupportAlert>().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<SupportAlertDto>(items, total, page, pageSize);
}
}
@@ -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<AnalyticsSink>.Instance);
await sink.EmitAsync("nurse_search_performed", new { query = "cardiac" });
var evt = await host.Db.Set<SystemEvent>().SingleAsync();
Assert.Equal("nurse_search_performed", evt.Name);
Assert.Equal(host.CurrentUser.UserId, evt.UserId);
Assert.Contains("cardiac", evt.PropsJson);
}
}
@@ -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 2124 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));
}
}
@@ -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");
}
}
@@ -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;
/// <summary>
/// Spins up a real <see cref="ApplicationDbContext"/> over an isolated in-memory SQLite database with the
/// audit interceptor wired and the marketplace seed applied (via <c>EnsureCreated</c>). Gives the
/// platform-signal services something faithful to run against, with a controllable clock and caller.
/// </summary>
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<ApplicationDbContext>()
.UseSqlite(_connection)
.AddInterceptors(interceptor)
.Options;
Db = new ApplicationDbContext(options);
Db.Database.EnsureCreated();
}
/// <summary>Adds a real user row so FK-bound rows (notifications, audit actor) satisfy the constraint.</summary>
public async Task<int> AddUserAsync(string userName)
{
var user = new User { UserName = userName };
Db.Set<User>().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<string> Roles { get; set; } = [];
}
@@ -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<decimal>("vat_rate"));
Assert.Equal(72, await config.GetConfig<int>("dispute_window_hours"));
Assert.Equal(30, await config.GetConfig<int>("booking_payment_deadline_minutes"));
Assert.Equal("platform", await config.GetConfig<string>("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<decimal>("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<decimal>("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"));
}
}
@@ -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"));
}
}