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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+18
@@ -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);
|
||||
}
|
||||
}
|
||||
+8
@@ -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>>>;
|
||||
+18
@@ -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.");
|
||||
}
|
||||
}
|
||||
+12
@@ -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();
|
||||
}
|
||||
}
|
||||
+7
@@ -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>>;
|
||||
+18
@@ -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);
|
||||
}
|
||||
}
|
||||
+8
@@ -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>>>;
|
||||
+18
@@ -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);
|
||||
}
|
||||
}
|
||||
+8
@@ -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>>>;
|
||||
+18
@@ -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}.");
|
||||
}
|
||||
}
|
||||
+6
@@ -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>>;
|
||||
+15
@@ -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);
|
||||
}
|
||||
}
|
||||
+16
@@ -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}.");
|
||||
}
|
||||
}
|
||||
+7
@@ -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>>;
|
||||
+18
@@ -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);
|
||||
}
|
||||
}
|
||||
+9
@@ -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>>>;
|
||||
+19
@@ -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);
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Notifications.Commands.MarkAllRead;
|
||||
|
||||
public record MarkAllReadCommand : IRequest<OperationResult<bool>>;
|
||||
+22
@@ -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.");
|
||||
}
|
||||
}
|
||||
+11
@@ -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);
|
||||
}
|
||||
}
|
||||
+6
@@ -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>>;
|
||||
+19
@@ -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));
|
||||
}
|
||||
}
|
||||
+4
@@ -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);
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Notifications.Queries.GetUnreadCount;
|
||||
|
||||
public record GetUnreadCountQuery : IRequest<OperationResult<UnreadCountResult>>;
|
||||
+22
@@ -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);
|
||||
}
|
||||
}
|
||||
+8
@@ -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>>>;
|
||||
+18
@@ -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.");
|
||||
}
|
||||
}
|
||||
+12
@@ -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);
|
||||
}
|
||||
}
|
||||
+6
@@ -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>>;
|
||||
+18
@@ -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.");
|
||||
}
|
||||
}
|
||||
+12
@@ -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);
|
||||
}
|
||||
}
|
||||
+6
@@ -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>>;
|
||||
+18
@@ -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);
|
||||
}
|
||||
}
|
||||
+13
@@ -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];
|
||||
}
|
||||
-22
@@ -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;
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -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;
|
||||
}
|
||||
|
||||
+4
@@ -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" />
|
||||
|
||||
+24
@@ -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);
|
||||
}
|
||||
}
|
||||
+26
@@ -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);
|
||||
}
|
||||
}
|
||||
+57
@@ -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();
|
||||
}
|
||||
}
|
||||
+50
@@ -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();
|
||||
}
|
||||
}
|
||||
+26
@@ -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();
|
||||
}
|
||||
}
|
||||
+10
@@ -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);
|
||||
}
|
||||
+31
@@ -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.
|
||||
}
|
||||
}
|
||||
+113
-11
@@ -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;
|
||||
}
|
||||
|
||||
+871
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+308
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+487
@@ -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")
|
||||
|
||||
+29
-1
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+40
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+64
@@ -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);
|
||||
}
|
||||
}
|
||||
+113
@@ -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;
|
||||
}
|
||||
}
|
||||
+119
@@ -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);
|
||||
}
|
||||
}
|
||||
+33
@@ -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);
|
||||
}
|
||||
}
|
||||
+50
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+88
@@ -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);
|
||||
}
|
||||
}
|
||||
+99
@@ -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 21–24 March is bank-closed; the answer is the first later open, non-Friday day.
|
||||
var next = await calendar.NextBusinessDay(new DateOnly(2026, 3, 21));
|
||||
|
||||
Assert.True(next > new DateOnly(2026, 3, 24));
|
||||
Assert.NotEqual(DayOfWeek.Friday, next.DayOfWeek);
|
||||
Assert.False(await calendar.IsBankClosed(next));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upsert_ThenDelete_RoundTrips()
|
||||
{
|
||||
using var host = new OpsTestHost();
|
||||
host.CurrentUser.UserId = await host.AddUserAsync("admin");
|
||||
var calendar = new HolidayCalendarService(host.Db, host.Cache);
|
||||
var date = new DateOnly(2026, 9, 1);
|
||||
|
||||
await calendar.UpsertAsync(date, "روز آزمایشی", HolidayType.Official, true);
|
||||
Assert.True(await calendar.IsHoliday(date));
|
||||
Assert.True(await calendar.IsBankClosed(date));
|
||||
|
||||
Assert.True(await calendar.DeleteAsync(date));
|
||||
Assert.False(await calendar.IsHoliday(date));
|
||||
Assert.False(await calendar.DeleteAsync(date));
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user