diff --git a/client/messages/fa.json b/client/messages/fa.json index 1949ec4..28d1f8d 100644 --- a/client/messages/fa.json +++ b/client/messages/fa.json @@ -50,7 +50,7 @@ "close": "بستن", "optional": "اختیاری", "currency_toman": "تومان", - "brand": "بلینیار", + "brand": "بالین یار", "brand_tagline": "مراقبت مطمئن در خانه" }, "shell": { @@ -622,7 +622,7 @@ "issuer_platform": "بالین‌یار" }, "auth": { - "customer_title": "ورود به بلینیار", + "customer_title": "ورود به بالین یار", "customer_subtitle": "با شماره موبایل خود وارد شوید", "nurse_title": "ورود پرستاران", "nurse_subtitle": "ویژه پرستاران دارای پروانه نظام پرستاری", @@ -642,7 +642,7 @@ "resend": "ارسال مجدد کد", "change_number": "تغییر شماره", "routing_title": "در حال ورود…", - "select_role_title": "به بلینیار خوش آمدید", + "select_role_title": "به بالین یار خوش آمدید", "select_role_subtitle": "برای شروع، نقش خود را انتخاب کنید", "role_customer": "خانواده", "role_customer_desc": "برای رزرو پرستار و مراقبت در منزل", @@ -651,7 +651,7 @@ "continue": "ادامه", "guard_denied": "شما به این بخش دسترسی ندارید.", "account_error_title": "حساب شما بارگذاری نشد", - "account_error_body": "در ارتباط با بلینیار برای بارگذاری حساب شما مشکلی پیش آمد. اتصال خود را بررسی کنید و دوباره تلاش کنید.", + "account_error_body": "در ارتباط با بالین یار برای بارگذاری حساب شما مشکلی پیش آمد. اتصال خود را بررسی کنید و دوباره تلاش کنید.", "account_error_retry": "تلاش مجدد" }, "verification": { @@ -660,7 +660,7 @@ "retry": "تلاش مجدد", "load_error": "بارگذاری وضعیت احراز هویت ممکن نشد.", "start_title": "احراز هویت را آغاز کنید", - "start_body": "اعتماد، بنیان بلینیار است. با تکمیل این مراحل، خانواده‌ها با اطمینان شما را انتخاب می‌کنند.", + "start_body": "اعتماد، بنیان بالین یار است. با تکمیل این مراحل، خانواده‌ها با اطمینان شما را انتخاب می‌کنند.", "start_cta": "شروع احراز هویت", "starting": "در حال آماده‌سازی…", "progress_title": "پیشرفت احراز هویت", diff --git a/dev/post-phase/server/runtime-services.md b/dev/post-phase/server/runtime-services.md index fd04188..761d942 100644 --- a/dev/post-phase/server/runtime-services.md +++ b/dev/post-phase/server/runtime-services.md @@ -15,6 +15,15 @@ is invented; "not needed" claims are backed by the absence of the package/code. > and flipping the selector turns each on, no code change. What is still genuinely absent (no adapter): **Redis** > (5), **Elasticsearch** (17), and the LLM **review-moderation classifier** (15, optional). MoH/INO/eNamad (16) > stay **manual by design**. See the mocks-registry refinement-phase-8 banner for the provider tokens per rail. +> +> **Refinement-phase-9 update (2026-07-13) — observability (service 4):** the two overlapping metric stacks were +> consolidated onto **one OpenTelemetry stack** — prometheus-net was removed; metrics are scraped at `/metrics` via +> the OTel Prometheus exporter, and **distributed tracing** (ASP.NET Core + EF Core) was added, exporting **OTLP +> only when `OpenTelemetry:Otlp:Endpoint` is configured** (an MVP with Prometheus alone is unchanged). A request's +> `ApiResult.requestId` is its W3C trace id (support ↔ trace 1:1). Health checks split into `/healthz/live` +> (process) vs `/healthz/ready` (app DB + log DB [deployed] + an object-storage write probe); `/HealthCheck` stays +> as the aggregate. An **OTLP collector** (Grafana Tempo / Jaeger / OTEL Collector) becomes the optional new +> observability service when trace export is turned on. Elasticsearch (17) is still deferred. ## Service inventory @@ -23,7 +32,7 @@ is invented; "not needed" claims are backed by the absence of the package/code. | 1 | **SQL Server** (app DB `Baya`) | System of record — 12 schemas (`usr ops geo catalog verif search booking payments payouts reviews messaging partner`) | EF Core; `ConnectionStrings:SqlServer` | **Required now** | — | | 2 | **SQL Server** (log DB `Baya_Logs`) | Serilog sink in deployed envs (Warning+, auto-created `log.LogEvents`) | `ConnectionStrings:logDb` | **Required now** (deployed) | — | | 3 | **Reverse proxy / TLS** (nginx·caddy·traefik) | TLS termination, HTTP/1.1+2, forwarded headers | Kestrel config; JWE bearer | **Required now** | — | -| 4 | **Prometheus** (+ Grafana) | Scrapes `/metrics`; health forwarded to gauges | `UseMetricServer` + OTel exporter | **Recommended now** | — | +| 4 | **Prometheus** (+ Grafana) · optional **OTLP collector** | Scrapes `/metrics` (one OTel stack, refinement-phase-9); traces export OTLP when configured | OTel metrics + tracing; `OpenTelemetry:Otlp:Endpoint` | **Recommended now** (collector optional) | — | | 5 | **Redis** | `ICacheService` + `IDistributedLock` (money-path mutex) | `Seams:*` (keys TBD; none today) | Before >1 API instance | rows 14, 42 | | 6 | **MinIO / S3 / ArvanCloud** | `IObjectStorage` — verification docs, avatars (REQ-006), invoice PDFs | `Seams:ObjectStorage:*` | Before real verification | row 13 | | 7 | **Job scheduler** — in-process, SQL only (refinement-phase-7 **done**) | The recurring crons: booking-expiry, notification-retention, credential-expiry scan, no-show sweep, weekly payout-batch generation (Moadian/refund-settlement poll = Phase 8) | `RecurringJobSchedulerHostedService` + `IRecurringJob`s | **Done for single instance** (no new infra) | row 26 | @@ -133,8 +142,9 @@ flowchart LR Both DBs fit one instance; `Baya_Logs` can move later. - **Config:** `ConnectionStrings:SqlServer`, `ConnectionStrings:logDb` — **rotate + externalize first** (plan §1.1; live `sa` credentials are committed today). -- **Health/readiness:** the app's only health check (`/HealthCheck`, - `Monitoring/Configurations/HealthCheckConfigurations.cs:17`); `logDb` has none (plan §7.2). Boot runs +- **Health/readiness (refinement-phase-9 — §7.2 landed):** split `/healthz/live` (process) vs `/healthz/ready` + (app DB + `logDb` [deployed] + object-storage write probe); `/HealthCheck` stays as the aggregate + (`Monitoring/Configurations/HealthCheckConfigurations.cs`). Boot runs `MigrateAsync` + 3 seeders (`Program.cs:99-104`) → the login needs DDL rights and concurrent multi-node boot races (plan §4.3). @@ -149,13 +159,16 @@ flowchart LR - **Default:** caddy 2 / nginx 1.27; terminate TLS, h2 to clients, HTTP/1.1 (or h2c) upstream once §1.5 lands. -### 4 · Prometheus (+ Grafana) +### 4 · Prometheus (+ Grafana) · optional OTLP collector -- **Evidence:** `/metrics` via prometheus-net `UseMetricServer` + OTel `AddPrometheusExporter` (two stacks — - consolidate, plan §7.1) at `Monitoring/Configurations/PrometheusMetricsConfigurations.cs:11` and - `OpenTelemetryConfigurations.cs:21`; health forwarded (`HealthCheckConfigurations.cs:18`). -- **Default:** `prom/prometheus:v2.53` + `grafana/grafana:11`. No tracing backend exists yet (metrics-only); - an OTLP collector becomes relevant with plan §7.1. +- **Evidence (refinement-phase-9 — §7.1 landed):** **one** OpenTelemetry stack — metrics scraped at `/metrics` via + `UseOpenTelemetryPrometheusScrapingEndpoint()` (`Monitoring/Configurations/PrometheusMetricsConfigurations.cs`), + plus `WithTracing` (ASP.NET Core + EF Core) exporting **OTLP only when `OpenTelemetry:Otlp:Endpoint` is set** + (`OpenTelemetryConfigurations.cs`). The duplicate prometheus-net stack (`UseMetricServer`/`UseHttpMetrics`/ + `ForwardToPrometheus` + packages) was removed. A request's `ApiResult.requestId` is its W3C trace id. +- **Default:** `prom/prometheus:v2.53` + `grafana/grafana:11`. A **tracing backend / OTLP collector** (Grafana + Tempo · Jaeger · OpenTelemetry Collector) is the optional new service — set `OpenTelemetry:Otlp:Endpoint` at it to + turn trace + metric export on; Prometheus-scrape-only is an acceptable MVP. ### 5 · Redis @@ -278,14 +291,16 @@ flowchart LR 2. **Environment files:** `appsettings.json` ≡ `appsettings.Development.json` (byte-identical); **no Production/Staging file exists.** All non-secret env differences ride on ~14 `Seams:*` groups whose defaults live in code (`SeamOptions.cs`), not in config files. -3. **HTTP posture:** HTTP/2-only Kestrel default (plan §1.5), gRPC plugin + reflection always on - (plan §7.5), TLS required for JWE sanity. +3. **HTTP posture:** mixed `Http1AndHttp2` Kestrel (refinement-phase-5), so gRPC shares the listener via ALPN; + gRPC **reflection is Development-only** (refinement-phase-9 §7.5); TLS required for JWE sanity. 4. **Single-instance constraints today:** in-memory cache, in-proc money lock, the in-proc recurring-job scheduler (refinement-phase-7 — its per-tick lock is that same in-proc seam), per-instance rate-limit buckets. §4.3 (migrations split from boot) **landed**; scaling past one instance still requires §4.2 (Redis for the shared cache + the cross-instance lock the scheduler/money path use) first — the DB uniques keep money *correct* either way, but locks/cache/limits/scheduler-de-dup silently degrade. -5. **Logs:** deployed envs write Warning+ to `Baya_Logs` only (Information dropped — plan §7.3); dev writes - console + `logs/log.json`. +5. **Logs (refinement-phase-9 — §7.3 landed):** deployed envs write **Information+** to `Baya_Logs` (framework + categories held at Warning); **no PII/secrets** (the OTP code is no longer logged in any env). The dead + Elasticsearch sink + package were removed; log-table retention is an ops/DBA task (or ship logs to the OTLP + collector). Dev writes console + `logs/log.json`. 6. **Client:** the Next.js app needs `NEXT_PUBLIC_API_URL` pointing at the proxy; wire casing camelCase; snake_case routes. diff --git a/dev/shared-working-context/backend/STATUS.md b/dev/shared-working-context/backend/STATUS.md index 965671d..f8246ed 100644 --- a/dev/shared-working-context/backend/STATUS.md +++ b/dev/shared-working-context/backend/STATUS.md @@ -12,6 +12,25 @@ One block per completed backend phase. Newest at the top. Backend lane writes he - **Notes for frontend:** --> +## refinement-phase-9 — Observability, ops hardening, docs honesty & scale-later — 2026-07-13 +- **Shipped:** **one OpenTelemetry stack** (metrics scraped at `/metrics` + **tracing** ASP.NET Core/EF, opt-in + OTLP via `OpenTelemetry:Otlp:Endpoint`; prometheus-net removed) — `ApiResult.requestId` = W3C trace id. + **Health split** `/healthz/live` vs `/healthz/ready` (app DB + logDb[deployed] + object-storage write probe); + `/HealthCheck` aggregate kept. **Prod logs Information+** with **no PII** (OTP code no longer logged) + dead + Elasticsearch sink/package removed. **`AuditLogRetentionJob`** (`IRecurringJob`, two-tier legal retention). + **`TicketMessage.Body` encrypted at rest** (`IFieldEncryptor`; column → nvarchar(max)). **gRPC reflection + Development-only.** Docs reconciled (mocks-registry stale rows pruned; deferrals 9.7–9.11 recorded with pull- + triggers). Migration `RefinementPhase9TicketBodyEncryptionAndAuditRetention` (Body widen + 3 config seed rows). +- **Contracts:** none changed — no wire/shape change (observability + docs + at-rest encryption only). +- **Mocked:** none new. Deferred (recorded, not gaps): Elasticsearch `INurseSearch`, SMS/push + `INotificationDispatcher`, analytics pipeline, holiday feed, 8 product tables — each with a written pull-trigger. +- **Gate:** build clean (0 new warnings) / **407 tests pass** (402 prior + 5 new: audit-retention, ticket-body + encryption, liveness). +- **Handoff:** backend/handoff/after-refinement-phase-9.md +- **Notes for frontend:** no client-facing change. Ticket message bodies are now encrypted at rest server-side + (the thread read still returns plaintext — unchanged wire). A request's `requestId` is a real trace id useful + for support correlation. + ## refinement-phase-7 — Unattended ops: scheduler, locking & multi-instance readiness — 2026-07-13 - **Shipped:** one in-process **`RecurringJobSchedulerHostedService`** + the **`IRecurringJob`** seam (`Persistence/Services/Scheduling/`) replacing the two `PeriodicTimer` hosted services and scheduling the diff --git a/dev/shared-working-context/backend/handoff/after-refinement-phase-9.md b/dev/shared-working-context/backend/handoff/after-refinement-phase-9.md new file mode 100644 index 0000000..d7abc35 --- /dev/null +++ b/dev/shared-working-context/backend/handoff/after-refinement-phase-9.md @@ -0,0 +1,43 @@ +# After refinement-phase-9 — Observability, ops hardening, docs honesty & scale-later + +**For the frontend / next backend phase. Backend-owned; frontend reads.** + +## What changed for a client + +**Nothing user-facing.** No route, envelope, shape, or enum changed. Two things worth knowing: + +- **`ApiResult.requestId` is now a real W3C trace id** (the request's OpenTelemetry trace). It's the id to quote in + a support ticket — it maps 1:1 to the server-side trace once an OTLP collector is wired. +- **Ticket message bodies are encrypted at rest** server-side. The thread read still returns **plaintext** (the wire + is unchanged); the change is purely storage-side (the refund/dispute paper trail is no longer plaintext in the DB). + +## What the platform now does / exposes (ops) + +- **One OpenTelemetry stack.** Metrics scrape at `/metrics`; distributed **tracing** (ASP.NET Core + EF Core) is + wired. **OTLP export is opt-in** — set `OpenTelemetry:Otlp:Endpoint` (Grafana Tempo / Jaeger / OTEL Collector) to + turn trace + metric export on. Prometheus-scrape-only is an acceptable MVP; prometheus-net was removed. +- **Health endpoints:** `/healthz/live` (process only — safe liveness), `/healthz/ready` (app DB + log DB [deployed] + + an object-storage write probe — pull an instance out of rotation when a dependency is down), `/HealthCheck` + (aggregate, kept for compat). Point the orchestrator's liveness probe at `/healthz/live`, readiness at + `/healthz/ready`. +- **Prod logs are Information+ with no PII/secrets.** The OTP code is no longer logged in any environment. Log-table + retention on `Baya_Logs` is an ops/DBA task (or ship logs to the OTLP collector). +- **Audit-log retention** runs as a scheduled `IRecurringJob` — two-tier (financial/verification rows kept ~7 yr, + everyday rows ~2 yr) via the `audit_retention_*` config keys. +- **gRPC reflection is Development-only** (the plugin itself is unchanged; it shares the mixed-protocol listener). + +## For the next backend phase / deploy + +- **Turn tracing on in deployed envs** by provisioning an OTLP collector and setting `OpenTelemetry:Otlp:Endpoint`. +- **When Redis lands (>1 instance)**, add a `redis` readiness check (tagged `ready`) to `ConfigureHealthChecks`. +- **Register any new retention/cron via `IRecurringJob`** (unchanged from phase 7). + +## Deferred — recorded, NOT gaps (each has a written pull-trigger; see the phase report) + +- **Elasticsearch `INurseSearch` backend + outbox feeder** — pull when SQL search shows strain. SQL search is the + real MVP (`Search:Backend=sql`; any other value fails fast). +- **SMS/push channels of `INotificationDispatcher`** — pull when the notification UX demands out-of-app reach. + In-app notifications are real now. +- **Analytics warehouse/stream, holiday-calendar feed, 8 deferred product tables** (`organizations`, + `organization_nurses`, `fraud_flags`, `recurring_booking_schedules`, `bnpl_settlement_entries`, availability + slots, customer national-ID KYC, geo bulk import) — each a pure additive step when product pulls it. diff --git a/dev/shared-working-context/reports/frontend-phase-9-report.md b/dev/shared-working-context/reports/frontend-phase-9-report.md index 00ad4cf..7fddade 100644 --- a/dev/shared-working-context/reports/frontend-phase-9-report.md +++ b/dev/shared-working-context/reports/frontend-phase-9-report.md @@ -146,7 +146,7 @@ A 26-agent review/verify pass over the diff confirmed and fixed, pre-merge: - **i18n/UX:** inline initiate errors always use the localized copy (never raw `ApiError.message`); en `cta_pay` arrow points → (fa keeps ←); fa `error_body` matches the app's «بارگذاری … ممکن نشد» pattern; the C6 service-cost row carries the quantity (`row_service_cost_with_count`); the invoice issuer line - uses the product spelling «بالین‌یار» (note: fa `common.brand` reads «بلینیار» — a pre-existing + uses the product spelling «بالین‌یار» (note: fa `common.brand` reads «بالین یار» — a pre-existing wordmark/product-spelling divergence worth a product decision). - **Dark scheme:** EscrowNotice text/border use `--bal-primary` (the info token is an alert *background* and is illegible as dark-mode text); the print button temporarily flips `data-mui-color-scheme` to diff --git a/dev/shared-working-context/reports/mocks-registry.md b/dev/shared-working-context/reports/mocks-registry.md index dbe116e..85a0eaa 100644 --- a/dev/shared-working-context/reports/mocks-registry.md +++ b/dev/shared-working-context/reports/mocks-registry.md @@ -8,6 +8,16 @@ seam updates its row. This is the checklist the team works through to go from "M Status legend: 🔴 not built · 🟡 mocked (seam + fake impl in place) · 🟢 real integration live · 🟢◐ **real adapter shipped, config-selected (mock remains the default/fallback)** — the refinement-phase-8 state. +> **Refinement-phase-9 — docs honesty + observability (2026-07-13).** §7.6: pruned the **stale duplicate 🔴 rows** +> (`IDistributedLock`/`INurseSearch`/`IPaymentProvider`/`ISettlementSplitProvider`/`IWebhookVerifier`/`IMoadianClient`/ +> `ILicenseVerificationService`) that the detailed rows below already correct; the recurring-jobs row is the real +> in-process scheduler; `IPaymentCaptureSimulator` now reflects its 6.4 prod removal. **No seam was un-mocked** — the +> deferred **Elasticsearch `INurseSearch` backend** (row) and the **SMS/push channels of `INotificationDispatcher`** +> (row) stay explicitly out of MVP with the pull-triggers in their "Make it real →" columns (SQL search / in-app +> notifications are the real MVP). Also removed the unused `Serilog.Sinks.Elasticsearch` package (dead sink block); +> tracing/metrics unified on OpenTelemetry; audit-log retention is now a scheduled `IRecurringJob`; `TicketMessage.Body` +> is encrypted at rest (§9.5). See `backend-phase-9-report.md` (refinement) for the full observability + deferral list. + > **Refinement-phase-8 — external rails go real (2026-07-13).** Every vendor rail below now has a **real HTTP > adapter behind the same seam**, config-selected via a per-rail **`Seams:*:Provider`** selector (default = the > mock, so an unconfigured environment is unchanged; a typo falls closed to the mock). Selecting a real provider @@ -39,11 +49,6 @@ adapter shipped, config-selected (mock remains the default/fallback)** — the r | `ISmsSender` | backend-phase-2 | OTP/SMS delivery — `LoggingSmsSender` (`Baya.Infrastructure.CrossCutting/Seams/`) logs the OTP code (phone shown as last-4 only) and returns success; registered singleton in `AddCrossCuttingSeams`. **refinement-phase-0:** in **Development only**, `DevCapturingSmsSender` decorates it (via `AddDevelopmentOtpCapture`, called from `Program.cs` inside `IsDevelopment()`) to also capture the code in `DevOtpStore` for the `GET /api/v1/dev/last_otp/{phone}` bring-up helper — not wired / 404 outside Development | none today; real client will need `Seams:Sms:ApiKey` + `Seams:Sms:SenderLine` (+ gateway base URL) | 1) pick a gateway (Kavenegar/Ghasedak/SMS.ir), add its client package to `Directory.Packages.props`; 2) implement `ISmsSender.SendOtpAsync`/`SendAsync` against it (template/pattern-based OTP send); 3) bind the new `Seams:Sms` options; 4) swap the registration in `AddCrossCuttingSeams` (config-selected) — handlers unchanged; 5) keep the per-phone resend window + `otp` rate-limit policy exactly as-is; test with a real SIM | 🟡 | | `IObjectStorage` | backend-phase-0/6 | File storage — local-disk store under a scratch root (`LocalDiskObjectStorage`, `Baya.Infrastructure.CrossCutting/Seams/`) | `Seams:ObjectStorage:RootPath` (default: temp dir) | Point at MinIO/S3/ArvanCloud; presigned upload/download; bucket + creds | 🟡 | | `ICacheService` | backend-phase-0 | Caching — in-memory `IMemoryCache` (`MemoryCacheService`, `Baya.Infrastructure.CrossCutting/Seams/`) | _none_ | Swap to Redis (`StackExchange.Redis`); keep key/TTL scheme. **refinement-phase-7: this is the >1-instance scale-out gate** — a single-instance MVP intentionally keeps the in-proc cache (its generation-token invalidation is process-local); add Redis only when a second API instance runs | 🟡 (in-proc is correct single-instance) | -| `IDistributedLock` | backend-phase-10 | Money-path locks — no-op/in-proc | _tbd_ | Redis lock (RedLock); DB constraint remains the backstop | 🔴 | -| `INurseSearch` | backend-phase-7 | Search — SQL over `nurse_search_index` | _tbd_ | Elasticsearch index + feeder; reimplement the interface | 🔴 | -| `IPaymentProvider` | backend-phase-10 | Card PSP/IPG — deterministic success | _tbd_ | ZarinPal/Sadad/Vandar/Jibit + Shaparak; merchant/terminal/تسهیم | 🔴 | -| `ISettlementSplitProvider` | backend-phase-10 | تسهیم split — accepts any balanced legs | _tbd_ | Provider split-by-ratio to registered Shebas | 🔴 | -| `IWebhookVerifier` | backend-phase-10 | Callback auth — always valid | _tbd_ | Per-provider HMAC/signature + server-side re-verify | 🔴 | | `IBnplProvider` | backend-phase-12 | BNPL — `MockBnplProvider` drives the full state machine (eligible→settled→reverted), settle returns `order − commission%` | `Seams:Bnpl:{CommissionRate,SettlementInstant,CreditCeilingIrr,NotEligibleMobile,ForceFailure,ReverseProviderCommission}` | SnappPay/Digipay OAuth + verb set; encrypted creds in `payment_gateways.config_json` | 🟡 | | `IBnplProviderResolver` | backend-phase-12 | Per-`provider_code` selection — maps every known code to the one mock | _none_ | One concrete adapter per code; resolver returns the right one | 🟡 | | `ICurrencyNormalizer` | backend-phase-12 | Toman↔IRR — ×10 at the boundary | `Seams:Currency:TomanToIrrMultiplier` (default `10`) | Config-driven per provider boundary | 🟡 | @@ -56,12 +61,10 @@ adapter shipped, config-selected (mock remains the default/fallback)** — the r | `ICredentialVerifier` | backend-phase-6 | MoH پروانه صلاحیت حرفه‌ای / INO / عدم سوء پیشینه verification — `MockCredentialVerifier` (`.../Seams/`) **is** the manual-admin default: every call returns `RequiresManualReview` with `verification_method=manual` (an admin verifies the uploaded document against the official portal in `AdminReviewStep`). No portal call; registered singleton. There is **no public B2B API** for MoH/INO, so this stays manual until one appears | _none_ | 1) when an MoH/INO portal or API becomes available, implement `VerifyAsync(credentialType, credentialNumber)` to return `Verified`/`Failed` with `verification_method=portal|api` (+ `external_response_json`); 2) swap the registration (config-selected) for those credential types — the manual path stays the fallback; 3) the structured `nurse_credentials` registry already stores number/authority/expiry so cross-check + renewal survive the swap. **MoH/INO have no public B2B API today** | 🟡 | | `IBankAccountOwnershipVerifier` | backend-phase-3 | استعلام شبا IBAN-owner ↔ national-id inquiry — `MockBankAccountOwnershipVerifier` (`Baya.Infrastructure.CrossCutting/Seams/`) returns a deterministic fake: every IBAN matches (`matched_national_id=true`, echoes a holder name + `MOCK-SHEBA-{sha}` vendor ref) except the configured mismatch IBAN which returns `false`; registered singleton in `AddCrossCuttingSeams`. No real bank/KYC call, no money moves | `Seams:BankOwnership:MismatchIban` (default `IR000000000000000000000000`), `Seams:BankOwnership:MatchedHolderName`, `Seams:BankOwnership:MismatchHolderName` | 1) pick a Finnotech / banking-bridge استعلام شبا provider, add its client package to `Directory.Packages.props`; 2) add `Seams:BankOwnership:{ApiKey,BaseUrl}` options; 3) implement `VerifyOwnershipAsync(iban, nurseNationalId)` against the real Sheba-owner inquiry, mapping to `OwnershipInquiryResult`; 4) persist the real `ownership_vendor_ref` (+ raw response if a column is added); 5) swap the registration in `AddCrossCuttingSeams` (config-selected) — handlers unchanged; 6) test match/mismatch + that the b13 first-payout gate honours `matched_national_id=true` | 🟡 | | `IGeocoder` | backend-phase-4 | Address→lat/lng — `MockGeocoder` (`Baya.Infrastructure.CrossCutting/Seams/`) returns deterministic `decimal` coordinates jittered (FNV-1a, ~±5 km) around the known city centroid (unknown city → Iran centroid) plus `formatted_address` + `confidence`; **no network call**. A global switch or a per-address marker forces the null-coordinate ("no map pin") path; registered singleton in `AddCrossCuttingSeams` | `Seams:Geocoding:ReturnNullCoordinates` (default `false`), `Seams:Geocoding:LowConfidenceMarker` (default `NO_GEO`), `Seams:Geocoding:ResolvedConfidence` (default `0.9`) | 1) pick Neshan (or Google) geocoding, add its client package to `Directory.Packages.props`; 2) add `Seams:Geocoding:{ApiKey,BaseUrl}` options; 3) implement `IGeocoder.GeocodeAsync(addressText, cityName, districtName?)` against it, mapping to `(lat, lng, formatted_address, confidence)` with `decimal` coords; 4) add rate-limit/retry; 5) swap the registration in `AddCrossCuttingSeams` (config-selected) — handlers unchanged; 6) test a known Tehran address resolves within expected bounds | 🟡 | -| `IMoadianClient` | backend-phase-11 | سامانه مودیان e-invoice — leaves ref pending | _tbd_ | Real مودیان submission → 22-digit ref | 🔴 | | `IReviewModerationService` | backend-phase-14 | AI review pre-screen — `MockReviewModerationService` (`Baya.Infrastructure.CrossCutting/Seams/`), **no external call**: `ScreenAsync(reviewText)` returns a `ModerationVerdict(Decision, Reason)` — a banned-word substring hit → `Reject` (`banned_word:{w}`); otherwise clean text → a human-review `Flag` by default (so the publish gate holds), or `Approve` when `AutoApproveClean` is set. The `SubmitReview` handler maps the verdict to the initial status (`Approve`→published, `Reject`→hidden, else pending) — **decision authority stays with `ModerateReviewCommand` (human override)**. Registered singleton in `AddCrossCuttingSeams` | `Seams:ReviewModeration:AutoApproveClean` (default `false`), `Seams:ReviewModeration:BannedWords` (default `scam,fraud,کلاهبردار`) | 1) pick a text classifier / LLM moderation endpoint, add its client package to `Directory.Packages.props`; 2) add `Seams:ReviewModeration:{ApiKey,BaseUrl}` options; 3) implement `ScreenAsync(reviewText)` → map the provider's toxicity/spam scores to `Approve`/`Flag`/`Reject` + a reason; 4) swap the registration in `AddCrossCuttingSeams` (config-selected) — `SubmitReviewCommand`/`ModerateReviewCommand` unchanged, and the human moderation path always overrides; 5) test clean/flagged/rejected dispositions + that the publish gate still holds for a `Flag` | 🟡 | | `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/**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 | 🔴 | -| `IPaymentCaptureSimulator` | backend-phase-9 | The **temporary conversion trigger** standing in for b10's real card capture. `MockPaymentCaptureSimulator` (`Baya.Infrastructure.CrossCutting/Seams/`) returns a deterministic *succeeded* capture (a fake `gateway_reference` + a configurable `psp_fee_amount`) so `ConvertRequestToBookingCommand` is exercisable now; a config switch forces a *failed* capture (→ no booking is created). **This is the trigger, not a parallel money path** — registered singleton in `AddCrossCuttingSeams` | `Seams:PaymentCapture:ForceFailure` (default `false`), `Seams:PaymentCapture:PspFeeAmount` (default unset) | In b10: 1) build the real card capture (`payment_transactions`, PSP/IPG client, webhook verify); 2) on a real `payment_transactions.succeeded`, call `ConvertRequestToBooking` **directly** (the same conversion command that computes the three-amount split + generates sessions) instead of this seam; 3) remove the `IPaymentCaptureSimulator` registration + `MockPaymentCaptureSimulator`; the conversion/idempotency logic is unchanged | 🟡 | +| `IPaymentCaptureSimulator` | backend-phase-9 → **removed from prod refinement-phase-8 §6.4** | The **temporary conversion trigger** that stood in for b10's real card capture. **Prod now gets the fail-closed `DisabledPaymentCaptureSimulator`**; only Dev/Testing re-register the succeeding `MockPaymentCaptureSimulator` (the `bookings/convert` path is a Dev/Testing affordance — prod converts via the b10 webhook confirm calling `ConvertRequestToBooking` directly). Registered in `AddCrossCuttingSeams` (prod) + re-registered in Dev/Testing from `Program.cs` | `Seams:PaymentCapture:ForceFailure` (default `false`), `Seams:PaymentCapture:PspFeeAmount` (default unset) | Nothing further for prod — the real conversion trigger is the b10 webhook confirm. The Dev/Testing mock stays as a test affordance; drop it only if/when `bookings/convert` is retired | 🟢 (prod fail-closed; Dev/Testing mock is a test affordance) | | `INurseSearch` | backend-phase-7 | The search-service seam (read side). **The MVP impl `SqlNurseSearch` (`Persistence/Services/Search/`) is REAL, not a mock** — it reads the maintained `nurse_search_index WHERE is_searchable=1`, applies the category/city/district (NULL=whole-city)/gender/price filters + rating sort + pagination, projected & `AsNoTracking`. Registered by `AddPersistenceServices`, config-selected. Only the DEFERRED Elasticsearch backend is unbuilt | `Search:Backend` (default `sql`; any other value throws until Elastic ships) | 1) add an Elasticsearch client package (`Elastic.Clients.Elasticsearch`) to `Directory.Packages.props`; 2) define the index mapping (the `NurseSearchResultDto` fields + `is_searchable`); 3) implement `ElasticNurseSearch : INurseSearch` (same filters/sort/paging) reading the ES index; 4) build the feeder that consumes the `ISearchIndexMaintainer` change events via an **outbox/CDC** stream into ES (see the next row); 5) point `Search:Backend=elastic` in config — **callers unchanged**; 6) keep the SQL index as the projection/fallback + the reconciliation source (`RebuildAsync`); 7) test filter/sort/paging parity vs `SqlNurseSearch` | 🟢 SQL real; Elastic 🟡 | | `IPaymentProvider` | backend-phase-10 | Card PSP acquirer — `MockPaymentProvider` (`Baya.Infrastructure.CrossCutting/Seams/`), **no external call**: `InitPaymentAsync` → a deterministic `gatewayReferenceCode` (`mock-ref-{requestId}-{key}`) + a fake redirect URL; `VerifyAsync` → instant `Succeeded` echoing the expected amount (the server-side re-check); `RefundAsync(ref, amount, idempotencyKey, ct)` → always `Succeeded`, echoes a deterministic refund ref (b11 refunds carry the booking+refund idempotency key so a retry never double-refunds). Registered singleton in `AddCrossCuttingSeams` | none today; real client needs merchant id + terminal/IBAN registration + sandbox flag from `payment_gateways.config_json` (encrypted), not appsettings | 1) pick ZarinPal/Sadad/Vandar/Jibit as an acquirer-with-تسهیم, add its client package to `Directory.Packages.props`; 2) implement `InitPaymentAsync` (open the IPG session, return the Shaparak-routed redirect + reference), `VerifyAsync` (the mandatory server-side `verify` re-check of amount + reference — **never trust the callback alone**), `RefundAsync`; 3) read merchant id/terminal from the encrypted `payment_gateways.config_json`; 4) a config-driven `IProviderRegistry`/factory selects the concrete provider per gateway so a cut-off provider swaps without code change; 5) persist the full gateway response into `gateway_response_json`; 6) swap the registration (config-selected) — handlers unchanged | 🟡 | | `ISettlementSplitProvider` | backend-phase-10 | تسهیم settlement-sharing — `MockSettlementSplitProvider` (`.../Seams/`) records the split intent and returns `Settled` for any legs whose sum is positive; the platform never moves money. Registered singleton in `AddCrossCuttingSeams` | none today; real client needs each beneficiary's registered SHEBA + split-by-ratio config | 1) pick the acquirer's تسهیم API, implement `RegisterSplitAsync(bookingId, legs)` to register the split-by-ratio to each beneficiary's **registered IBAN** (nurse payout + platform commission), honouring the ~100,000 IRR min-amount caveat; 2) resolve each nurse's SHEBA from `nurse_bank_accounts` (the b3 `matched_national_id` gate) and the platform SHEBA from config; 3) `GetSplitStatusAsync` polls the provider; the provider credits IBANs directly — the ledger only mirrors it; 4) swap the registration (config-selected) | 🟡 | diff --git a/dev/shared-working-context/reports/refinement-phase-9-report.md b/dev/shared-working-context/reports/refinement-phase-9-report.md new file mode 100644 index 0000000..72951c1 --- /dev/null +++ b/dev/shared-working-context/reports/refinement-phase-9-report.md @@ -0,0 +1,110 @@ +# Refinement Phase 9 — Observability, ops hardening, docs honesty & scale-later — Report (2026-07-13) + +**Track:** backend (observability/docs) + explicit deferrals · **Depends on:** nothing hard · +**Gate:** `dotnet build` **0 new warnings** · `dotnet test` **407 pass** (402 prior + 5 new: audit-retention ×2, +ticket-body encryption ×2, liveness ×1; the existing messaging suite now also exercises the encrypted body). + +This phase makes the running platform **diagnosable and honest**, and records the explicitly-deferred scale work so +nobody mistakes it for missing MVP scope. No feature behaviour changed; the money/trust rules are untouched. + +## Observability & ops hardening (finish before launch) + +### 9.1 — Tracing added; one metrics stack; `requestId` = trace id +- **One metrics stack.** Removed the duplicate **prometheus-net** stack (`UseMetricServer`/`UseHttpMetrics`/ + `ForwardToPrometheus` + the three `prometheus-net*` packages). **OpenTelemetry is now the only metrics source**, + scraped at `/metrics` via `UseOpenTelemetryPrometheusScrapingEndpoint()`. HTTP request metrics now come from the + OTel ASP.NET Core instrumentation; the `mediator_meter` request-duration histogram (`MetricsBehaviour`) is now + actually exported (added to the meter list — the old prometheus-net stack never captured it). +- **Tracing.** Added `WithTracing` (ASP.NET Core + **EF Core** instrumentation) sharing one resource + (`service.name = Baya.Web.Api`), so a cross-service money flow (webhook → confirm → ledger) is one trace. +- **OTLP export is opt-in.** Traces + metrics export to an OTLP collector **only when `OpenTelemetry:Otlp:Endpoint` + is set** — an MVP with Prometheus alone runs unchanged and no exporter spams an absent collector. +- **`requestId` already carries the trace id** (`ApiResult.RequestId = Activity.Current.TraceId`) with + `Activity.DefaultIdFormat = W3C` — a support ticket maps 1:1 to a trace with no extra wiring. +- **New packages** (all cached, no restore risk): `OpenTelemetry.Exporter.OpenTelemetryProtocol` (1.15.3), + `OpenTelemetry.Instrumentation.EntityFrameworkCore` (1.15.1-beta.1). + +### 9.2 — Health checks broadened; liveness/readiness split +- `/healthz/live` — process only (a dependency-free `self` check), so a dependency outage never restart-loops. +- `/healthz/ready` — the app DB, the log DB (**deployed only** — its conn string is a placeholder in Dev/Testing), + and a real **object-storage write round-trip** (`ObjectStorageWriteHealthCheck`: put → get → delete a probe blob). +- `/HealthCheck` — the aggregate, retained for backward compatibility. The dead `currentUrl` line is gone. +- **Redis** is noted as the next readiness check to add *when* it becomes a real dependency (>1 instance); not now. +- `Baya.Infrastructure.Monitoring` now references `Baya.Application` (for the `IObjectStorage` probe) — a legitimate + Infrastructure→Application edge, noted in the server Project map. + +### 9.3 — Prod log level raised to Information+; no PII; dead ES sink removed +- Deployed envs now log **Information+** (was Warning+, which dropped every Information-level audit trail), with + framework categories held at Warning so the floor raise doesn't flood the sink. +- **No secrets/PII in logs.** `LoggingSmsSender` **no longer logs the OTP code** (a login secret) in any + environment — a developer gets it from the Development-only `GET /api/v1/dev/last_otp`. Clinical text / IBANs / + phone numbers are already encrypted or masked before any handler logs. The `columnOptions` (previously built but + never applied) are now wired to the SQL sink. +- **Dead Elasticsearch sink resolved by deletion:** removed the commented ES sink block *and* the unused + `Serilog.Sinks.Elasticsearch` package (this also revealed `Serilog.Sinks.File` was only a transitive of the ES + package — added it explicitly). Log-table retention is documented as an ops/DBA responsibility (or ship logs to + the OTLP collector). + +### 9.4 — Audit-log retention as a scheduled job +- New `AuditLogRetentionJob` (`IRecurringJob`, registered like the others) runs a **two-tier** retention sweep over + the append-only `ops.AuditLogs`: **financial/verification** entity types (`Refund`, `NurseClawback`, + `NursePayout`, `NursePayoutBatch`, `NurseVerification`, `PlatformConfig`, `PartnerCenter`) keep a long legal window + (`audit_retention_financial_days`, default **2555** ≈ 7 years); everyday rows a shorter one + (`audit_retention_general_days`, default **730** ≈ 2 years). Cadence key `audit_retention_scan_cadence_hours` (24). +- `IAuditLogger.PurgeExpiredAsync(...)` does the delete: oldest-first (Id is monotonic with `OccurredAt`), capped at + 20 000 rows/run so a backlog drains across runs; age compared in memory (SQLite can't translate a `DateTimeOffset` + predicate), delete is a single id-keyed `ExecuteDeleteAsync`. Idempotent. +- Migration `RefinementPhase9TicketBodyEncryptionAndAuditRetention` seeds the three config keys (ids 24–26). + +### 9.5 — `TicketMessage.Body` encrypted; gRPC reflection gated to Development +- **Ticket bodies are the refund/dispute paper trail** (users type phone numbers, addresses, clinical detail) — now + **encrypted at rest** through the existing `IFieldEncryptor` converter (wired in `ApplicationDbContext`, like every + other PII column). The stored column is widened to `nvarchar(max)` (ciphertext is longer than plaintext); the 4000- + char plaintext limit stays a boundary-validation rule (Open/PostMessage validators). Body is never a SQL search/ + filter predicate (the admin thread read decrypts per row), so losing SQL-searchability is an accepted trade-off. +- **gRPC decision — keep the plugin, gate reflection to Development.** The plugin exposes only the User service and + the client is HTTP/JSON, but removing it is more invasive than the risk warrants. gRPC **reflection** (which + advertises the full schema) is now registered/mapped **only in Development**. The HTTP/2-posture concern is already + mitigated (refinement-phase-5 set Kestrel `Http1AndHttp2`), so the plugin shares the mixed-protocol listener (ALPN + negotiates h2 for gRPC clients) — no dedicated port needed. + +### 9.6 — Docs made honest +- **Mocks-registry:** pruned the 7 **stale duplicate 🔴 rows** (`IDistributedLock`/`INurseSearch`/`IPaymentProvider`/ + `ISettlementSplitProvider`/`IWebhookVerifier`/`IMoadianClient`/`ILicenseVerificationService`) the detailed rows + already correct; the recurring-jobs row is the real in-process scheduler; the `IPaymentCaptureSimulator` row now + reflects its 6.4 prod removal (fail-closed in prod; Dev/Testing mock is a test affordance). Added a phase-9 banner. +- **REQ tracker:** already honest — refinement-phase-3 marked every delivered/deferred/resolved REQ; this phase ships + no new contract, so no REQ status changed. (The pre-phase-3 "all 15 open" state the audit flagged is long fixed.) +- **Architecture maps:** `server/CLAUDE.md` updated (observability wiring, the audit-retention cron, ticket-body + encryption, the gRPC decision, the new Monitoring→Application edge); `runtime-services.md` updated (OTel + consolidation, tracing, health split). + +## Scale & later — explicitly NOT MVP (recorded, not built). Each has a written pull-trigger. + +| # | Deferred item | Where it lives today (the real MVP) | **Pull it when…** | +| --- | --- | --- | --- | +| 9.7 | **Elasticsearch read backend + outbox feeder** — `ElasticNurseSearch` + the CDC/outbox stream | `SqlNurseSearch` is real & correct; `Search:Backend` fails fast on any non-`sql` value | **SQL search shows strain** (latency/throughput on `nurse_search_index`). Build `ElasticNurseSearch` (same filters/sort/paging) + the outbox feeder off `ISearchIndexMaintainer`; keep SQL as the reconciliation source (`RebuildAsync`). | +| 9.8 | **Analytics pipeline** — warehouse/stream sink | `IAnalyticsSink` writes `ops.SystemEvents` fire-and-forget (real, queryable) | **Product needs cross-event analytics** beyond SQL queries. Pipe `SystemEvents` to a warehouse/stream (e.g. Kafka→ClickHouse), keeping fire-and-forget semantics. | +| 9.9 | **Holiday-calendar feed** — automated lunar-Hijri drift feed | `IHolidayCalendar` reads the seeded, manually-maintained `ops.IranianHolidays` table (real) | **The manual yearly refresh becomes a burden.** A **yearly ops-checklist item to top up the table is an acceptable MVP alternative** to a feed — the read interface stays. | +| 9.10 | **Push/SMS notification channels** — SMS/FCM fan-out | `InAppNotificationDispatcher` writes real in-app `ops.Notifications`; non-InApp channels are dropped by design | **The notification UX demands** out-of-app reach. Fan out to SMS (via the phase-8 `ISmsSender`) and FCM push behind the same `INotificationDispatcher`. | +| 9.11 | **Deferred product tables** — `organizations`, `organization_nurses`, `fraud_flags`, `recurring_booking_schedules`, `bnpl_settlement_entries`, availability slots, customer national-ID KYC, geo bulk import | All **verified absent**; each is a **pure additive migration** when product pulls it | **Product pulls the feature.** No structural blocker — additive migration + feature slice; nothing in the current schema needs to change first. | + +**These are decisions, not gaps.** SQL search, in-app notifications, and the manual holiday table are the real, +correct MVP; Elasticsearch/analytics/push each has a concrete trigger above and stays out until then. + +## How it was verified +- **Build:** `dotnet build Baya.sln` — 0 new warnings (the pre-existing NU1510 + NU1903 transitive-dependency audit + warnings are unrelated to this phase). +- **Tests:** `dotnet test Baya.sln` — all green. New: `AuditLogRetentionTests` (two-tier purge + idempotency), + `TicketMessageEncryptionTests` (encrypted at rest + round-trips on read), `HealthCheckApiTests` (liveness healthy + without dependencies). +- **Trace/requestId:** a request's `ApiResult.requestId` is `Activity.Current.TraceId` (W3C) — the same id a + configured OTLP collector records. +- **No PII in logs:** the OTP code is no longer logged in any environment; clinical text/IBANs are encrypted/masked. + +## Follow-ups for later phases +- Wire an OTLP collector (Grafana Tempo / Jaeger / OTEL Collector) in the deploy topology and set + `OpenTelemetry:Otlp:Endpoint` to turn tracing export on. +- When the first `redis` dependency lands (>1 instance), add its readiness check to `/healthz/ready`. +- The NU1903 transitive-dependency vulnerability warnings (`Microsoft.OpenApi`, `SQLitePCLRaw`) are a separate + dependency-bump task, out of this phase's scope. diff --git a/server/CLAUDE.md b/server/CLAUDE.md index 6093398..9aadb5f 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -35,7 +35,7 @@ You are a **senior .NET software engineer** working on this codebase. That means - **EF Core 10** + **SQL Server** (Repository + Unit of Work pattern) - **ASP.NET Core Identity** with **JWE** (signed + AES-128-encrypted JWT), OTP, and dynamic permission authorization - **Mapster** for mapping, **FluentValidation** for validation, **Serilog** for structured logging -- **OpenTelemetry** + **prometheus-net** for observability, **NSwag** for OpenAPI, **Asp.Versioning** for versioning +- **OpenTelemetry** (metrics + tracing; Prometheus-scrape at `/metrics`, opt-in OTLP export) for observability, **NSwag** for OpenAPI, **Asp.Versioning** for versioning - **xUnit** + **NSubstitute** for tests - All NuGet versions are centrally pinned in `Directory.Packages.props` @@ -98,7 +98,7 @@ src/ │ ├── Baya.Infrastructure.Persistence ApplicationDbContext (+ encrypted-PII value converters & phone-hash sync), ValueConversion/, Repositories/, Configuration/ (per-area EF config incl. SearchConfig/ + BookingConfig/ — b8 BookingRequest + b9 bookings/sessions/care/EVV/cancellation-policy configs & seed + ReviewsConfig/ — b14 reviews/tags-master (seeded)/tag-links/patient-care-records configs), Repositories/ (incl. b9 BookingRepository + CancellationPolicyRepository + b14 ReviewRepository + PatientCareRecordRepository), Migrations/, Interceptors/ (AuditFieldInterceptor — audit-fields + audit-log rows), Services/ (DB-backed platform-signal facades + Scheduling/ = RecurringJobSchedulerHostedService + Jobs/ (the IRecurringJob crons — refinement-phase-7) + Search/ = SearchIndexMaintainer + SqlNurseSearch) │ ├── Baya.Infrastructure.Identity Jwt/, Identity/ (Managers, Stores, PermissionManager, Seed, CurrentUser/) │ ├── Baya.Infrastructure.CrossCutting Serilog wiring + Seams/ (mock impls of the cross-cutting seams incl. LoggingSmsSender + MockBankAccountOwnershipVerifier + MockShahkarVerifier + MockIdentityKycProvider + MockCredentialVerifier + MockPaymentCaptureSimulator + MockBankTransferProvider + MockReviewModerationService) + AddCrossCuttingSeams -│ └── Baya.Infrastructure.Monitoring HealthChecks, OpenTelemetry, prometheus-net +│ └── Baya.Infrastructure.Monitoring HealthChecks (live/ready split + IObjectStorage write-probe → refs Baya.Application), OpenTelemetry (one stack: metrics + tracing, opt-in OTLP) ├── API/ │ ├── Baya.Web.Api Program.cs, Controllers/V1/ (Ping + Development-only Dev (dev/last_otp OTP helper, 404 outside Development) + Auth/Me phone-OTP surface + admin PlatformConfig/Holidays/Audit/SupportAlerts + current-user Notifications + public Geo + admin AdminGeo + nurse NurseServiceAreas + customer CustomerAddresses + public Catalog + admin AdminCatalog + nurse NurseVariants + nurse NurseVerification + admin AdminVerificationStepTypes/AdminVerifications + public Nurses (trust badge) + public Search + admin AdminSearch + customer/nurse BookingRequests + admin AdminBookingRequests + customer/nurse/admin Bookings + nurse/admin BookingSessions + admin AdminEvv + admin AdminCancellationPolicies + customer PaymentsController + public WebhooksController + admin AdminRefunds/AdminClawbacks/AdminInvoices + customer Refunds/Invoices + customer CheckoutBnpl + public WebhooksBnpl + admin AdminBnpl + admin AdminPayouts + nurse NursePayouts + customer BookingReviews (submit) + owner/admin Reviews (tags + moderate status) + admin AdminReviews (moderation queue) + public Nurses (reviews + review_tags) + nurse/owner/admin PatientCareRecords), appsettings*.json │ ├── Baya.WebFramework BaseController (incl. 401/403 OperationResult mapping), Filters/, Middlewares/, Swagger/, Routing/, ServiceConfiguration/ (rate limiting) @@ -547,7 +547,8 @@ and giving the previously admin-manual sweeps a schedule, **using no new infrast only external dependency). Jobs, each reading its seeded `platform_configs` cadence key via `IPlatformConfig`: `booking_request_expiry` (1 min const) · `notification_retention` (24 h const) · `verification_expiry_scan` (`verification_expiry_scan_cadence_hours`) · `no_show_sweep` (`no_show_scan_cadence_hours`) · -`weekly_payout_generation` (`nurse_payout_interval_days`). Load-bearing rules: +`weekly_payout_generation` (`nurse_payout_interval_days`) · `MoadianReconciliationJob` (6 h, refinement-phase-8) · +`audit_log_retention` (`audit_retention_scan_cadence_hours`, refinement-phase-9). Load-bearing rules: - **Add a cron = implement `IRecurringJob` + one `AddSingleton()`** in `AddPersistenceServices`. Phase 8 registers the Moadian reconciliation + refund-settlement poll exactly this way. The scheduler owns the per-tick DI scope, error isolation (a throwing tick never kills the loop), and the lock; a job says only *how @@ -562,6 +563,28 @@ only external dependency). Jobs, each reading its seeded `platform_configs` cade `AdminPayoutsController` neutralizes any request-supplied value. - **Admin manual triggers remain overrides** (the same idempotent commands). The scheduler is **dormant under the `Testing` environment** so integration tests stay deterministic; each job/command is unit-tested directly. +- **Audit-log retention (refinement-phase-9 §9.4)** is an `IRecurringJob` (`AuditLogRetentionJob`) over the + append-only `ops.AuditLogs`: a **two-tier** sweep via `IAuditLogger.PurgeExpiredAsync` — financial/verification + entity types (`Refund`/`NurseClawback`/`NursePayout`/`NursePayoutBatch`/`NurseVerification`/`PlatformConfig`/ + `PartnerCenter`) keep `audit_retention_financial_days` (default 2555 ≈ 7 yr); everyday rows + `audit_retention_general_days` (default 730 ≈ 2 yr). Oldest-first, capped, id-keyed delete; idempotent. + +**Observability (refinement-phase-9).** One **OpenTelemetry** stack (`Baya.Infrastructure.Monitoring`, +`SetupOpenTelemetry`): metrics (runtime + ASP.NET Core + the `mediator_meter` histogram) scraped at `/metrics` via +the OTel Prometheus exporter, and **tracing** (ASP.NET Core + EF Core) sharing `service.name = Baya.Web.Api`. The +duplicate prometheus-net stack was removed. **OTLP export (traces + metrics) is opt-in** — wired only when +`OpenTelemetry:Otlp:Endpoint` is set, so an MVP with Prometheus alone runs unchanged. `ApiResult.RequestId` is the +W3C trace id (`Activity.Current.TraceId`, `Activity.DefaultIdFormat = W3C`), so a support ticket maps 1:1 to a +trace. **Health checks split** (`ConfigureHealthChecks`/`UseHealthChecks`): `/healthz/live` (process, dependency- +free), `/healthz/ready` (app DB + `logDb` [deployed only] + an `IObjectStorage` write-probe), `/HealthCheck` +(aggregate, kept for compat). **Logs:** deployed envs write **Information+** to `Baya_Logs` (framework categories +held at Warning); **no PII/secrets** — the mock SMS sender never logs the OTP code; clinical text/IBANs are +encrypted/masked. The dead Elasticsearch sink + package were removed (SQL sink is the deployed default; set the +OTLP collector to ship logs off-box). **gRPC reflection is Development-only** (`GrpcPluginStartup` gates +`AddGrpcReflection`/`MapGrpcReflectionService` on `IsDevelopment`); the plugin shares the mixed-protocol Kestrel +listener. **`TicketMessage.Body` is encrypted at rest** through `IFieldEncryptor` (converter in +`ApplicationDbContext`; column widened to `nvarchar(max)`; the 4000-char cap stays a boundary-validation rule) — +ticket bodies are the refund/dispute paper trail (phone numbers, addresses, clinical detail). **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 @@ -577,7 +600,7 @@ Service registration is composed from per-layer extension methods (each project' ``` builder.ValidateRequiredSecrets() // refinement-phase-5: fail fast on missing/placeholder DB + crypto secrets -ConfigureHealthChecks() · SetupOpenTelemetry() +ConfigureHealthChecks() · SetupOpenTelemetry() // refinement-phase-9: live/ready health split + object-storage probe; one OTel stack (metrics + tracing, opt-in OTLP) AddApplicationServices() // Mediator + pipeline behaviors (Logging → Metrics → Validate) RegisterIdentityServices(…, requireHttpsMetadata) // Identity, JWT/JWE (RequireHttpsMetadata on outside Dev/Testing), ICurrentUser AddPersistenceServices(...) // DbContext (+ AuditFieldInterceptor), UnitOfWork, repositories, the IRecurringJob crons + RecurringJobSchedulerHostedService (refinement-phase-7) @@ -587,7 +610,7 @@ AddCorsPolicies(config) // browser CORS policy from Cors:AllowedOrigi AddForwardedHeadersConfiguration(config) // refinement-phase-5: trust ForwardedHeaders:KnownProxies/KnownNetworks so the rate limiter sees the real client IP behind a proxy AddRateLimitingPolicies() // built-in rate limiter: per-resolved-IP global + named (otp/auth/sensitive/webhook) AddSwagger("v1", "v1.1") · RegisterValidatorsAsServices() · AddMapster() -ConfigureGrpcPluginServices() +ConfigureGrpcPluginServices(builder.Environment) // refinement-phase-9: gRPC reflection registered only in Development // Development-only: AddDevelopmentOtpCapture() (refinement-phase-0) decorates ISmsSender to capture each // OTP in-memory for the GET /api/v1/dev/last_otp/{phone} helper — never wired outside Development. ``` diff --git a/server/Directory.Packages.props b/server/Directory.Packages.props index 73ee819..3a34fab 100644 --- a/server/Directory.Packages.props +++ b/server/Directory.Packages.props @@ -36,19 +36,18 @@ + + - - - - + diff --git a/server/src/API/Baya.Web.Api/Program.cs b/server/src/API/Baya.Web.Api/Program.cs index 4f5b739..537051b 100644 --- a/server/src/API/Baya.Web.Api/Program.cs +++ b/server/src/API/Baya.Web.Api/Program.cs @@ -110,7 +110,7 @@ TypeAdapterConfig.GlobalSettings.Scan(typeof(UserCreateCommand).Assembly, #region Plugin Services Configuration -builder.Services.ConfigureGrpcPluginServices(); +builder.Services.ConfigureGrpcPluginServices(builder.Environment); #endregion diff --git a/server/src/API/Plugins/Baya.Web.Plugins.Grpc/GrpcPluginStartup.cs b/server/src/API/Plugins/Baya.Web.Plugins.Grpc/GrpcPluginStartup.cs index 901ec40..85b3390 100644 --- a/server/src/API/Plugins/Baya.Web.Plugins.Grpc/GrpcPluginStartup.cs +++ b/server/src/API/Plugins/Baya.Web.Plugins.Grpc/GrpcPluginStartup.cs @@ -1,27 +1,36 @@ -using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using Baya.Web.Plugins.Grpc.Services; namespace Baya.Web.Plugins.Grpc; +/// +/// gRPC plugin wiring. The plugin exposes the User service over HTTP/2; the client is HTTP/JSON only, so this is +/// an internal/optional surface. gRPC reflection is enabled only in Development (refinement-phase-9 §9.5) — +/// reflection advertises the full service schema and must not be reachable in deployed environments. The endpoint +/// shares the mixed-protocol Kestrel listener (refinement-phase-5 set Http1AndHttp2), so ALPN negotiates +/// HTTP/2 for gRPC clients while the REST API keeps HTTP/1.1 — no dedicated port is required. +/// public static class GrpcPluginStartup { - public static IServiceCollection ConfigureGrpcPluginServices(this IServiceCollection services) + public static IServiceCollection ConfigureGrpcPluginServices(this IServiceCollection services, IHostEnvironment environment) { - - services.AddGrpc(); - services.AddGrpcReflection(); + + if (environment.IsDevelopment()) + services.AddGrpcReflection(); return services; } public static void ConfigureGrpcPipeline(this WebApplication app) { - app.MapGrpcService(); - app.MapGrpcReflectionService(); + + if (app.Environment.IsDevelopment()) + app.MapGrpcReflectionService(); app.MapGet("/GrpcUser", async context => { @@ -29,4 +38,4 @@ public static class GrpcPluginStartup "Communication with this gRPC endpoint must be made through a gRPC client."); }); } -} \ No newline at end of file +} diff --git a/server/src/Core/Baya.Application/Contracts/Audit/IAuditLogger.cs b/server/src/Core/Baya.Application/Contracts/Audit/IAuditLogger.cs index e4b9828..172c952 100644 --- a/server/src/Core/Baya.Application/Contracts/Audit/IAuditLogger.cs +++ b/server/src/Core/Baya.Application/Contracts/Audit/IAuditLogger.cs @@ -28,4 +28,17 @@ public interface IAuditLogger int page, int pageSize, CancellationToken cancellationToken = default); + + /// + /// Retention sweep (refinement-phase-9 §9.4): deletes audit rows past their retention window. Financial / + /// compliance entity types (money + verification) keep a longer legal window than everyday rows. Processes the + /// oldest rows first, capped at so a large backlog drains across runs without + /// loading the whole table. Returns the number of rows deleted. + /// + ValueTask PurgeExpiredAsync( + int generalRetentionDays, + int financialRetentionDays, + IReadOnlyCollection financialEntityTypes, + int maxRowsPerRun, + CancellationToken cancellationToken = default); } diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Baya.Infrastructure.CrossCutting.csproj b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Baya.Infrastructure.CrossCutting.csproj index 34cb342..53358f0 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Baya.Infrastructure.CrossCutting.csproj +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Baya.Infrastructure.CrossCutting.csproj @@ -7,10 +7,10 @@ + - diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Logging/LoggingConfiguration.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Logging/LoggingConfiguration.cs index d96d396..439b306 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Logging/LoggingConfiguration.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Logging/LoggingConfiguration.cs @@ -1,8 +1,9 @@ -using System.Data; +using System.Data; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; using Serilog; using Serilog.Enrichers.Span; +using Serilog.Events; using Serilog.Exceptions; using Serilog.Formatting.Json; using Serilog.Sinks.MSSqlServer; @@ -13,22 +14,29 @@ public static class LoggingConfiguration { public static Action ConfigureLogger => (context, configuration) => { - #region Enriching Logger Context + #region Level & enrichment var env = context.HostingEnvironment; - - configuration.Enrich.FromLogContext() + // refinement-phase-9 §9.3: deployed environments log Information+ (previously Warning+, which silently + // dropped every Information-level audit trail — logins, money-operation context). Framework categories + // are held at Warning so raising the floor doesn't flood the sink with ASP.NET/EF request noise. + // No PII/secrets are ever logged: the mock SMS sender no longer logs the OTP code, and clinical text / + // IBANs / phone numbers are encrypted or masked before they reach any handler that logs. + configuration + .MinimumLevel.Information() + .MinimumLevel.Override("Microsoft", LogEventLevel.Warning) + .MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning) + .MinimumLevel.Override("Microsoft.EntityFrameworkCore", LogEventLevel.Warning) + .MinimumLevel.Override("System", LogEventLevel.Warning) + .Enrich.FromLogContext() .Enrich.WithProperty("ApplicationName", env.ApplicationName) .Enrich.WithProperty("Environment", env.EnvironmentName) .Enrich.WithSpan() .Enrich.WithExceptionDetails(); - - #endregion - var columnOpts = new ColumnOptions(); columnOpts.Store.Remove(StandardColumn.Properties); columnOpts.Store.Add(StandardColumn.LogEvent); @@ -39,36 +47,19 @@ public static class LoggingConfiguration // Development and Testing (WebApplicationFactory) log locally; the SQL sink is for deployed envs. if (!context.HostingEnvironment.IsDevelopment() && !context.HostingEnvironment.IsEnvironment("Testing")) { + // Retention for the `log.LogEvents` table is an ops/DBA responsibility (a scheduled purge or a + // partitioned/rolling table on Baya_Logs) — distinct from application data. Set an OpenTelemetry:Otlp + // collector (see OpenTelemetryConfigurations) if you prefer to ship logs off-box instead of the SQL sink. configuration.WriteTo .MSSqlServer( connectionString: context.Configuration.GetConnectionString("logDb"), - sinkOptions: new MSSqlServerSinkOptions { TableName = "LogEvents", AutoCreateSqlTable = true, SchemaName = "log",AutoCreateSqlDatabase = true}) - .MinimumLevel.Warning(); - + sinkOptions: new MSSqlServerSinkOptions { TableName = "LogEvents", AutoCreateSqlTable = true, SchemaName = "log", AutoCreateSqlDatabase = true }, + columnOptions: columnOpts); } - - else{ - configuration.WriteTo.Console().MinimumLevel.Information(); - configuration.WriteTo.File(new JsonFormatter(), "logs/log.json").MinimumLevel.Information(); + else + { + configuration.WriteTo.Console(); + configuration.WriteTo.File(new JsonFormatter(), "logs/log.json"); } - - #region ElasticSearch Configuration. UnComment if Needed - - - //var elasticUrl = context.Configuration.GetValue("Logging:ElasticUrl"); - - //if (!string.IsNullOrEmpty(elasticUrl)) - //{ - // configuration.WriteTo.Elasticsearch( - // new ElasticsearchSinkOptions(new Uri(elasticUrl)) - // { - // AutoRegisterTemplate = true, - // AutoRegisterTemplateVersion = AutoRegisterTemplateVersion.ESv7, - // IndexFormat = "web-logs-{0:yyyy.MM.dd}", - // MinimumLogEventLevel = LogEventLevel.Debug - // }); - //} - - #endregion }; -} \ No newline at end of file +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/LoggingSmsSender.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/LoggingSmsSender.cs index 874f3d7..7d523bb 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/LoggingSmsSender.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/LoggingSmsSender.cs @@ -4,22 +4,24 @@ using Microsoft.Extensions.Logging; namespace Baya.Infrastructure.CrossCutting.Seams; /// -/// Mock : "delivers" by logging. The OTP code is written to the log so a -/// developer can complete the login flow; the phone number is never logged in full (PII policy) — -/// only its last four digits. The real implementation swaps to an Iranian SMS gateway -/// (Kavenegar/Ghasedak/SMS.ir) behind the same interface via a registration change. +/// Mock : "delivers" by logging. The OTP code is never logged (refinement-phase-9 +/// §9.3 — no secrets/PII in logs, in any environment): a developer retrieves it from the Development-only +/// GET /api/v1/dev/last_otp/{phone} helper (the DevCapturingSmsSender decorator), never the log. +/// The phone number is logged only as its last four digits. The real implementation swaps to an Iranian SMS +/// gateway (Kavenegar/Ghasedak/SMS.ir) behind the same interface via a registration change. /// public sealed class LoggingSmsSender(ILogger logger) : ISmsSender { public Task SendOtpAsync(string phone, string code, CancellationToken cancellationToken = default) { - logger.LogWarning("MOCK SMS — OTP code {OtpCode} for phone ending in {PhoneTail}", code, Tail(phone)); + // Deliberately does NOT log the OTP code — it is a login secret. Retrieve it via /dev/last_otp in Development. + logger.LogInformation("MOCK SMS — OTP issued to phone ending in {PhoneTail}", Tail(phone)); return Task.CompletedTask; } public Task SendAsync(string phone, string message, CancellationToken cancellationToken = default) { - logger.LogWarning("MOCK SMS — message to phone ending in {PhoneTail}: {Message}", Tail(phone), message); + logger.LogInformation("MOCK SMS — message to phone ending in {PhoneTail}: {Message}", Tail(phone), message); return Task.CompletedTask; } diff --git a/server/src/Infrastructure/Baya.Infrastructure.Monitoring/Baya.Infrastructure.Monitoring.csproj b/server/src/Infrastructure/Baya.Infrastructure.Monitoring/Baya.Infrastructure.Monitoring.csproj index b691635..6a3e528 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Monitoring/Baya.Infrastructure.Monitoring.csproj +++ b/server/src/Infrastructure/Baya.Infrastructure.Monitoring/Baya.Infrastructure.Monitoring.csproj @@ -10,13 +10,17 @@ + + - - - + + + + + diff --git a/server/src/Infrastructure/Baya.Infrastructure.Monitoring/Configurations/HealthCheckConfigurations.cs b/server/src/Infrastructure/Baya.Infrastructure.Monitoring/Configurations/HealthCheckConfigurations.cs index 86dce7b..4fc7b5f 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Monitoring/Configurations/HealthCheckConfigurations.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Monitoring/Configurations/HealthCheckConfigurations.cs @@ -1,38 +1,80 @@ -using System.Net.Security; +using Baya.Infrastructure.Monitoring.HealthChecks; using HealthChecks.UI.Client; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Hosting; -using Prometheus; namespace Baya.Infrastructure.Monitoring.Configurations; +/// +/// Health checks split into liveness (is the process up?) and readiness (can it serve traffic — +/// are its dependencies reachable?), refinement-phase-9 §9.2. An orchestrator restarts on a failed liveness +/// probe but only routes traffic on a passing readiness probe, so a broken log DB or object storage takes the +/// instance out of rotation without a restart loop. +/// +/// /healthz/live — process only (always healthy while the host runs); never touches a dependency. +/// /healthz/ready — the app DB, the log DB (deployed only), and the object-storage write probe. +/// /HealthCheck — the aggregate (every check), retained for backward compatibility. +/// +/// public static class HealthCheckConfigurations { + private const string Ready = "ready"; + private const string Live = "live"; + public static WebApplicationBuilder ConfigureHealthChecks(this WebApplicationBuilder builder) { - builder.Services.AddHealthChecks() - .AddSqlServer(builder.Configuration.GetConnectionString("SqlServer")!, name: "SQL Server") - .ForwardToPrometheus(); + var checks = builder.Services.AddHealthChecks(); - var currentUrl = builder.Configuration["ASPNETCORE_URLS"]?.Split(';')[0].Replace("+", "localhost"); - + // Liveness: the process answers. Deliberately dependency-free so a dependency outage never restarts a + // healthy instance — that is readiness's job. + checks.AddCheck("self", () => HealthCheckResult.Healthy(), tags: [Live]); + + // Readiness: the system of record. + checks.AddSqlServer(builder.Configuration.GetConnectionString("SqlServer")!, name: "sql-app", tags: [Ready]); + + // The Serilog SQL sink only runs in deployed environments (Development/Testing log to console/file), and + // its connection string is a placeholder there — so only gate readiness on it where it is actually used. + if (!builder.Environment.IsDevelopment() && !builder.Environment.IsEnvironment("Testing")) + { + var logDb = builder.Configuration.GetConnectionString("logDb"); + if (!string.IsNullOrWhiteSpace(logDb)) + checks.AddSqlServer(logDb, name: "sql-logs", tags: [Ready]); + } + + // Object storage (verification docs, avatars, invoice PDFs) — a real write round-trip. + checks.AddCheck("object-storage", tags: [Ready]); + + // Redis (ICacheService / IDistributedLock) is single-process today (refinement-phase-7); when it becomes a + // real external dependency for a multi-instance deployment, add a "redis" check tagged Ready here. return builder; } public static WebApplication UseHealthChecks(this WebApplication app) { + app.MapHealthChecks("/healthz/live", new HealthCheckOptions + { + Predicate = check => check.Tags.Contains(Live), + ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse + }).ShortCircuit(); + + app.MapHealthChecks("/healthz/ready", new HealthCheckOptions + { + Predicate = check => check.Tags.Contains(Ready), + ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse + }).ShortCircuit(); + + // Aggregate endpoint (all checks) — retained for backward compatibility with existing probes/dashboards. app.MapHealthChecks("/HealthCheck", new HealthCheckOptions { Predicate = _ => true, ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse - }).ShortCircuit().DisableHttpMetrics(); - - + }).ShortCircuit(); return app; } -} \ No newline at end of file +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Monitoring/Configurations/OpenTelemetryConfigurations.cs b/server/src/Infrastructure/Baya.Infrastructure.Monitoring/Configurations/OpenTelemetryConfigurations.cs index 8165cf0..72d5f80 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Monitoring/Configurations/OpenTelemetryConfigurations.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Monitoring/Configurations/OpenTelemetryConfigurations.cs @@ -1,14 +1,35 @@ -using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; namespace Baya.Infrastructure.Monitoring.Configurations; +/// +/// The single OpenTelemetry stack (refinement-phase-9, §9.1). Metrics and traces share one resource +/// (service.name = Baya.Web.Api). Metrics are scraped by Prometheus (the /metrics endpoint, +/// wired in ); traces cover ASP.NET Core requests and EF Core +/// commands so a cross-service money flow (webhook → confirm → ledger) can be followed end to end. The +/// request's trace id is the ApiResult.RequestId the client sees, so a support ticket maps 1:1 to a +/// trace. +/// The prior duplicate prometheus-net stack was removed — OpenTelemetry is now the only metrics source. +/// OTLP export (traces + metrics) is opt-in: it is wired only when OpenTelemetry:Otlp:Endpoint +/// is configured, so an MVP deployment with Prometheus alone runs unchanged and no exporter spams connection +/// errors against an absent collector. +/// public static class OpenTelemetryConfigurations { + public const string ServiceName = "Baya.Web.Api"; + public static WebApplicationBuilder SetupOpenTelemetry(this WebApplicationBuilder builder) { + var otlpEndpoint = builder.Configuration["OpenTelemetry:Otlp:Endpoint"]; + var hasOtlp = !string.IsNullOrWhiteSpace(otlpEndpoint); + builder.Services.AddOpenTelemetry() + .ConfigureResource(resource => resource.AddService(ServiceName)) .WithMetrics(metricsBuilder => { metricsBuilder.AddRuntimeInstrumentation() @@ -17,12 +38,26 @@ public static class OpenTelemetryConfigurations , "Microsoft.AspNetCore.Server.Kestrel" , "System.Net.Http" , "Baya.Web.Api" - , "ControllerMetrics") + , "ControllerMetrics" + // The mediator request-duration histogram (MetricsBehaviour) — captured here so the one + // metrics stack actually exports it (the removed prometheus-net stack never did). + , "mediator_meter") .AddPrometheusExporter(); + + if (hasOtlp) + metricsBuilder.AddOtlpExporter(options => options.Endpoint = new Uri(otlpEndpoint!)); + }) + .WithTracing(tracingBuilder => + { + tracingBuilder.AddAspNetCoreInstrumentation() + .AddEntityFrameworkCoreInstrumentation(); + + if (hasOtlp) + tracingBuilder.AddOtlpExporter(options => options.Endpoint = new Uri(otlpEndpoint!)); }); builder.Services.AddMetrics(); - + return builder; } -} \ No newline at end of file +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Monitoring/Configurations/PrometheusMetricsConfigurations.cs b/server/src/Infrastructure/Baya.Infrastructure.Monitoring/Configurations/PrometheusMetricsConfigurations.cs index 02c39f1..c62323e 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Monitoring/Configurations/PrometheusMetricsConfigurations.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Monitoring/Configurations/PrometheusMetricsConfigurations.cs @@ -1,15 +1,17 @@ -using Microsoft.AspNetCore.Builder; -using Prometheus; +using Microsoft.AspNetCore.Builder; namespace Baya.Infrastructure.Monitoring.Configurations; public static class PrometheusMetricsConfigurations { + /// + /// Exposes the single OpenTelemetry metrics stack for Prometheus scraping at /metrics + /// (refinement-phase-9, §9.1 — replaces the removed prometheus-net UseMetricServer/ + /// UseHttpMetrics stack; HTTP request metrics now come from the OTel ASP.NET Core instrumentation). + /// public static WebApplication UseMetrics(this WebApplication app) { - - app.UseMetricServer(); - app.UseHttpMetrics(); + app.UseOpenTelemetryPrometheusScrapingEndpoint(); return app; } -} \ No newline at end of file +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Monitoring/HealthChecks/ObjectStorageWriteHealthCheck.cs b/server/src/Infrastructure/Baya.Infrastructure.Monitoring/HealthChecks/ObjectStorageWriteHealthCheck.cs new file mode 100644 index 0000000..6af368d --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Monitoring/HealthChecks/ObjectStorageWriteHealthCheck.cs @@ -0,0 +1,41 @@ +#nullable enable +using System.Text; +using Baya.Application.Contracts.Common; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Baya.Infrastructure.Monitoring.HealthChecks; + +/// +/// Readiness probe for the seam (verification documents, avatars, invoice PDFs): +/// a deploy can pass a DB-only health check while uploads are broken. This does a real round-trip — +/// write a tiny probe blob, read it back, delete it — so a misconfigured bucket / unreachable endpoint / +/// missing write permission surfaces as rather than a first-user 500. +/// The probe key is namespaced and deleted every run, so it never accumulates. +/// +internal sealed class ObjectStorageWriteHealthCheck(IObjectStorage objectStorage) : IHealthCheck +{ + private const string ProbeKey = "healthz/object-storage-probe"; + private static readonly byte[] ProbePayload = Encoding.UTF8.GetBytes("ok"); + + public async Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + try + { + await using (var writeStream = new MemoryStream(ProbePayload, writable: false)) + await objectStorage.PutAsync(ProbeKey, writeStream, "text/plain", cancellationToken); + + await using var readStream = await objectStorage.GetAsync(ProbeKey, cancellationToken); + if (readStream is null) + return HealthCheckResult.Unhealthy("Object storage write succeeded but the probe blob was not readable."); + + await objectStorage.DeleteAsync(ProbeKey, cancellationToken); + return HealthCheckResult.Healthy(); + } + catch (Exception ex) + { + return HealthCheckResult.Unhealthy("Object storage round-trip failed.", ex); + } + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/ApplicationDbContext.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/ApplicationDbContext.cs index f6bd216..fa4f4d9 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/ApplicationDbContext.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/ApplicationDbContext.cs @@ -179,5 +179,15 @@ public class ApplicationDbContext: IdentityDbContext c.SettlementIban).HasConversion(encrypted); }); + + // refinement-phase-9 §9.5: ticket message bodies are the refund/dispute paper trail — users type phone + // numbers, addresses and clinical detail into them — so they are encrypted at rest through the same seam. + // The plaintext-length limit (4000) stays a boundary-validation rule; the stored ciphertext column is + // widened to nvarchar(max) in TicketMessageConfig. Body is never a search/filter predicate (the admin + // thread read decrypts per row), so losing SQL-searchability on it is an accepted trade-off. + modelBuilder.Entity(builder => + { + builder.Property(m => m.Body).HasConversion(encrypted); + }); } } \ No newline at end of file diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/ConfigurationConfig/PlatformConfigConfig.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/ConfigurationConfig/PlatformConfigConfig.cs index 149b7bb..fee678f 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/ConfigurationConfig/PlatformConfigConfig.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/ConfigurationConfig/PlatformConfigConfig.cs @@ -52,6 +52,11 @@ internal sealed class PlatformConfigConfig : IEntityTypeConfiguration m.Body).HasMaxLength(4000).IsRequired(); + // Body is encrypted at rest (converter wired in ApplicationDbContext, refinement-phase-9 §9.5), so the + // stored column holds base64 ciphertext (longer than the plaintext) — nvarchar(max), no length cap here. + // The 4000-char plaintext limit is enforced at the boundary (Open/PostMessage validators). + builder.Property(m => m.Body).IsRequired(); builder.Property(m => m.IsInternal).HasDefaultValue(false); builder.Property(m => m.ClientMessageId).HasMaxLength(100); diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260713184239_RefinementPhase9TicketBodyEncryptionAndAuditRetention.Designer.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260713184239_RefinementPhase9TicketBodyEncryptionAndAuditRetention.Designer.cs new file mode 100644 index 0000000..5207e1c --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260713184239_RefinementPhase9TicketBodyEncryptionAndAuditRetention.Designer.cs @@ -0,0 +1,6072 @@ +// +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("20260713184239_RefinementPhase9TicketBodyEncryptionAndAuditRetention")] + partial class RefinementPhase9TicketBodyEncryptionAndAuditRetention + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Baya.Domain.Entities.Analytics.SystemEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("PropsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.HasIndex("OccurredAt"); + + b.HasIndex("UserId"); + + b.ToTable("SystemEvents", "ops"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Audit.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("ActorUserId") + .HasColumnType("int"); + + b.Property("ChangedFieldsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserId"); + + b.HasIndex("OccurredAt"); + + b.HasIndex("EntityType", "EntityId"); + + b.ToTable("AuditLogs", "ops"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Bnpl.BnplTransaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BnplCommissionIrr") + .HasColumnType("bigint"); + + b.Property("CallbackPayloadJson") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("Currency") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("nvarchar(5)"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("EligibilityStatus") + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.Property("ExternalPaymentToken") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ExternalTransactionId") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("InstallmentCount") + .HasColumnType("tinyint"); + + b.Property("MerchantOfRecord") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("OrderAmountIrr") + .HasColumnType("bigint"); + + b.Property("PaymentTransactionId") + .HasColumnType("bigint"); + + b.Property("ProviderCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ProviderCommissionReversedAmount") + .HasColumnType("bigint"); + + b.Property("RefundChannel") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("RevertTransactionId") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RevertedAmountIrr") + .HasColumnType("bigint"); + + b.Property("RevertedAt") + .HasColumnType("datetime2"); + + b.Property("SettledAmountIrr") + .HasColumnType("bigint"); + + b.Property("SettledAt") + .HasColumnType("datetime2"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.HasKey("Id"); + + b.HasIndex("ExternalPaymentToken") + .HasFilter("[ExternalPaymentToken] IS NOT NULL"); + + b.HasIndex("PaymentTransactionId") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("BnplTransactions", "payments", t => + { + t.HasCheckConstraint("CK_BnplTransactions_SettleSplit", "([SettledAmountIrr] IS NULL AND [BnplCommissionIrr] IS NULL) OR ([SettledAmountIrr] = [OrderAmountIrr] - [BnplCommissionIrr] AND [SettledAmountIrr] >= 0 AND [BnplCommissionIrr] >= 0)"); + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.Booking", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AddressSnapshotJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("BalinyaarCommissionIrr") + .HasColumnType("bigint"); + + b.Property("BookingRequestId") + .HasColumnType("bigint"); + + b.Property("CancellationPolicyCode") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CancellationReason") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("CancellationRefundPercentage") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)"); + + b.Property("CancelledAt") + .HasColumnType("datetime2"); + + b.Property("CancelledBy") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("ConfirmedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("CustomerAddressId") + .HasColumnType("bigint"); + + b.Property("CustomerId") + .HasColumnType("bigint"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DisputeWindowEndsAt") + .HasColumnType("datetime2"); + + b.Property("GrossPriceIrr") + .HasColumnType("bigint"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("NursePayoutAmount") + .HasColumnType("bigint"); + + b.Property("PartnerCenterId") + .HasColumnType("bigint"); + + b.Property("PatientId") + .HasColumnType("bigint"); + + b.Property("PlatformFeeRate") + .HasPrecision(5, 4) + .HasColumnType("decimal(5,4)"); + + b.Property("PspFeeAmount") + .HasColumnType("bigint"); + + b.Property("RefundableAmountIrr") + .HasColumnType("bigint"); + + b.Property("ScheduledDate") + .HasColumnType("date"); + + b.Property("ScheduledTimeEnd") + .HasColumnType("time"); + + b.Property("ScheduledTimeStart") + .HasColumnType("time"); + + b.Property("SessionCount") + .HasColumnType("smallint"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.Property("VariantId") + .HasColumnType("bigint"); + + b.Property("VariantSnapshotJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("BookingRequestId") + .IsUnique(); + + b.HasIndex("CustomerAddressId"); + + b.HasIndex("DisputeWindowEndsAt"); + + b.HasIndex("PatientId"); + + b.HasIndex("VariantId"); + + b.HasIndex("CustomerId", "Status"); + + b.HasIndex("NurseId", "Status"); + + b.ToTable("Bookings", "booking", t => + { + t.HasCheckConstraint("CK_Bookings_AmountSplit", "[GrossPriceIrr] = [BalinyaarCommissionIrr] + [NursePayoutAmount] AND [GrossPriceIrr] >= 0 AND [BalinyaarCommissionIrr] >= 0 AND [NursePayoutAmount] >= 0"); + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingCareInstruction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Allergies") + .HasColumnType("nvarchar(max)"); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("CurrentConditions") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("EmergencyContactName") + .HasColumnType("nvarchar(max)"); + + b.Property("EmergencyContactPhone") + .HasColumnType("nvarchar(max)"); + + b.Property("Medications") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("SpecialInstructions") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("BookingId") + .IsUnique(); + + b.ToTable("BookingCareInstructions", "booking"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("CustomerAddressId") + .HasColumnType("bigint"); + + b.Property("CustomerId") + .HasColumnType("bigint"); + + b.Property("CustomerNotes") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("NurseRejectionReason") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("NurseResponseDeadlineAt") + .HasColumnType("datetime2"); + + b.Property("PatientId") + .HasColumnType("bigint"); + + b.Property("PaymentDeadlineAt") + .HasColumnType("datetime2"); + + b.Property("RequestedDate") + .HasColumnType("date"); + + b.Property("RequestedTimeEnd") + .HasColumnType("time"); + + b.Property("RequestedTimeStart") + .HasColumnType("time"); + + b.Property("RequiredCaregiverGender") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("VariantId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("CustomerAddressId"); + + b.HasIndex("PatientId"); + + b.HasIndex("VariantId"); + + b.HasIndex("CustomerId", "Status"); + + b.HasIndex("NurseId", "Status"); + + b.HasIndex("Status", "NurseResponseDeadlineAt"); + + b.HasIndex("Status", "PaymentDeadlineAt"); + + b.ToTable("BookingRequests", "booking"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("CancellationEventId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("PayoutEligibleAt") + .HasColumnType("datetime2"); + + b.Property("ScheduledDate") + .HasColumnType("date"); + + b.Property("ScheduledTimeEnd") + .HasColumnType("time"); + + b.Property("ScheduledTimeStart") + .HasColumnType("time"); + + b.Property("SessionIndex") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("VisitPayoutAmount") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("BookingId", "SessionIndex"); + + b.HasIndex("Status", "ScheduledDate"); + + b.ToTable("BookingSessions", "booking"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.CancellationPolicy", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AppliesTo") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("FeeAmountIrr") + .HasColumnType("bigint"); + + b.Property("FeeRate") + .HasPrecision(5, 4) + .HasColumnType("decimal(5,4)"); + + b.Property("HoursBeforeStartMax") + .HasColumnType("int"); + + b.Property("HoursBeforeStartMin") + .HasColumnType("int"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("RefundPercentage") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("AppliesTo", "IsActive"); + + b.ToTable("CancellationPolicies", "booking"); + + b.HasData( + new + { + Id = 1L, + AppliesTo = "customer", + Code = "standard_24h", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + FeeAmountIrr = 0L, + HoursBeforeStartMin = 24, + IsActive = true, + RefundPercentage = 100m + }, + new + { + Id = 2L, + AppliesTo = "customer", + Code = "standard_inside_24h", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + FeeAmountIrr = 0L, + HoursBeforeStartMax = 24, + IsActive = true, + RefundPercentage = 50m + }, + new + { + Id = 3L, + AppliesTo = "nurse", + Code = "nurse_no_show", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + FeeAmountIrr = 0L, + FeeRate = 0m, + IsActive = true, + RefundPercentage = 100m + }, + new + { + Id = 4L, + AppliesTo = "admin", + Code = "admin_cancellation", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + FeeAmountIrr = 0L, + IsActive = true, + RefundPercentage = 100m + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.VisitVerification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BookingSessionId") + .HasColumnType("bigint"); + + b.Property("CheckInAddressMatch") + .HasColumnType("bit"); + + b.Property("CheckInAt") + .HasColumnType("datetime2"); + + b.Property("CheckInDistanceMeters") + .HasPrecision(10, 2) + .HasColumnType("decimal(10,2)"); + + b.Property("CheckInLat") + .HasPrecision(9, 6) + .HasColumnType("decimal(9,6)"); + + b.Property("CheckInLng") + .HasPrecision(9, 6) + .HasColumnType("decimal(9,6)"); + + b.Property("CheckOutAt") + .HasColumnType("datetime2"); + + b.Property("CheckOutLat") + .HasPrecision(9, 6) + .HasColumnType("decimal(9,6)"); + + b.Property("CheckOutLng") + .HasPrecision(9, 6) + .HasColumnType("decimal(9,6)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("BookingSessionId") + .IsUnique(); + + b.HasIndex("CheckInAddressMatch"); + + b.ToTable("VisitVerifications", "booking"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("OptionSetHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Price") + .HasColumnType("bigint"); + + b.Property("PriceUnit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("ServiceCategoryId") + .HasColumnType("bigint"); + + b.Property("SessionCount") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ServiceCategoryId"); + + b.HasIndex("NurseId", "IsActive"); + + b.HasIndex("NurseId", "ServiceCategoryId", "OptionSetHash") + .IsUnique() + .HasDatabaseName("UX_NurseServiceVariants_Nurse_Category_OptionSet") + .HasFilter("[DeletedAt] IS NULL"); + + b.ToTable("NurseServiceVariants", "catalog"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariantOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("OptionGroupId") + .HasColumnType("bigint"); + + b.Property("OptionValueId") + .HasColumnType("bigint"); + + b.Property("VariantId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("OptionGroupId"); + + b.HasIndex("OptionValueId"); + + b.HasIndex("VariantId", "OptionGroupId") + .IsUnique() + .HasDatabaseName("UX_NurseServiceVariantOptions_Variant_Group"); + + b.ToTable("NurseServiceVariantOptions", "catalog"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DescriptionEn") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("DescriptionFa") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("IconKey") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("IsActive", "SortOrder"); + + b.ToTable("ServiceCategories", "catalog"); + + 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)), + IsActive = true, + NameEn = "Elderly Care", + NameFa = "مراقبت از سالمند", + SortOrder = 1 + }, + new + { + Id = 2L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Post-Surgery Recovery", + NameFa = "مراقبت پس از جراحی", + SortOrder = 2 + }, + new + { + Id = 3L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Infant Care", + NameFa = "مراقبت از نوزاد", + SortOrder = 3 + }, + new + { + Id = 4L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Chronic Illness Management", + NameFa = "مدیریت بیماری مزمن", + SortOrder = 4 + }, + new + { + Id = 5L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Companionship", + NameFa = "همراهی و مراقبت روزمره", + SortOrder = 5 + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("IsRequired") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("ServiceCategoryId") + .HasColumnType("bigint"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("ServiceCategoryId", "SortOrder"); + + b.ToTable("ServiceOptionGroups", "catalog"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionValue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("OptionGroupId") + .HasColumnType("bigint"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("OptionGroupId", "SortOrder"); + + b.ToTable("ServiceOptionValues", "catalog"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Configuration.PlatformConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("PlatformConfigs", "ops"); + + b.HasData( + new + { + Id = 1L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "decimal", + Description = "Balinyaar commission rate on the booking gross (fraction).", + Key = "platform_fee_rate", + Value = "0.15" + }, + new + { + Id = 2L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "decimal", + Description = "VAT rate applied to the commission line only (fraction).", + Key = "vat_rate", + Value = "0.10" + }, + new + { + Id = 3L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Hours after check-out a booking can be disputed.", + Key = "dispute_window_hours", + Value = "72" + }, + new + { + Id = 4L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Minutes a family has to pay before a pending booking expires.", + Key = "booking_payment_deadline_minutes", + Value = "30" + }, + new + { + Id = 5L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Hours a nurse has to accept/decline a booking request.", + Key = "nurse_response_deadline_hours", + Value = "24" + }, + new + { + Id = 6L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Weekly payout cadence in days.", + Key = "nurse_payout_interval_days", + Value = "7" + }, + new + { + Id = 7L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Allowed EVV check-in distance from the care address.", + Key = "evv_location_tolerance_meters", + Value = "200" + }, + new + { + Id = 8L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "decimal", + Description = "A review at or below this rating raises a support alert.", + Key = "min_rating_for_support_alert", + Value = "2" + }, + new + { + Id = 9L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "string", + Description = "Who is merchant of record for BNPL orders (platform|nurse).", + Key = "bnpl_merchant_of_record", + Value = "platform" + }, + new + { + Id = 10L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "decimal", + Description = "BNPL provider commission rate (fraction).", + Key = "bnpl_provider_commission_rate", + Value = "0.07" + }, + new + { + Id = 11L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "string", + Description = "When BNPL settles funds to the platform (immediate|deferred).", + Key = "bnpl_settlement_timing", + Value = "immediate" + }, + new + { + Id = 12L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "json", + Description = "Tiered cancellation refund policy: refund_percent by hours before the visit.", + Key = "cancellation_tiers", + Value = "[{\"min_hours_before\":48,\"refund_percent\":100},{\"min_hours_before\":24,\"refund_percent\":50},{\"min_hours_before\":0,\"refund_percent\":0}]" + }, + new + { + Id = 13L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Seconds a phone must wait before another OTP can be requested.", + Key = "auth_otp_resend_seconds", + Value = "120" + }, + new + { + Id = 14L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Wrong-code attempts allowed before OTP verification is refused until a fresh code.", + Key = "auth_otp_max_attempts", + Value = "5" + }, + new + { + Id = 15L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Refresh-token session lifetime in days.", + Key = "auth_session_ttl_days", + Value = "30" + }, + new + { + Id = 16L, + 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 between credential-expiry scans (the scheduled cron is deferred; the scan is admin-triggered today).", + Key = "verification_expiry_scan_cadence_hours", + Value = "24" + }, + new + { + Id = 17L, + 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 after a session's scheduled start with no EVV check-in before it is flagged a no-show.", + Key = "no_show_threshold_minutes", + Value = "60" + }, + new + { + Id = 18L, + 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 between no-show sweeps (the scheduled cron is deferred; the sweep is admin-triggered today).", + Key = "no_show_scan_cadence_hours", + Value = "1" + }, + new + { + Id = 20L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Business days shown as the customer BNPL refund ETA (b11).", + Key = "bnpl_refund_eta_business_days", + Value = "10" + }, + new + { + Id = 21L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "bool", + Description = "Ops/testing override that forces the post-payout clawback path for refunds (b11); b13 replaces the derivation.", + Key = "refund_assume_nurse_paid", + Value = "false" + }, + new + { + Id = 22L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "decimal", + Description = "IRR net-amount threshold above which a payout is routed via SATNA (real-time) instead of PAYA (batch) (b13).", + Key = "payout_satna_threshold_irr", + Value = "1000000000" + }, + new + { + Id = 23L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "bool", + Description = "When on, a BNPL-paid booking is payout-eligible only after its provider settlement is received (b13; default off — the DEFERRED settled_at guard).", + Key = "require_bnpl_settlement_for_payout", + Value = "false" + }, + new + { + Id = 24L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Retention (days) for everyday audit rows before the retention sweep deletes them (~2 years).", + Key = "audit_retention_general_days", + Value = "730" + }, + new + { + Id = 25L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Retention (days) for financial/verification audit rows (refunds/clawbacks/payouts/verification/config/partner-center) — the long legal window (~7 years).", + Key = "audit_retention_financial_days", + Value = "2555" + }, + new + { + Id = 26L, + 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 between audit-log retention sweeps.", + Key = "audit_retention_scan_cadence_hours", + Value = "24" + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.City", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("ProvinceId") + .HasColumnType("bigint"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("ProvinceId", "SortOrder"); + + b.ToTable("Cities", "geo"); + + b.HasData( + new + { + Id = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Tehran", + NameFa = "تهران", + ProvinceId = 1L, + SortOrder = 1 + }, + new + { + Id = 102L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Karaj", + NameFa = "کرج", + ProvinceId = 2L, + SortOrder = 1 + }, + new + { + Id = 103L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Isfahan", + NameFa = "اصفهان", + ProvinceId = 3L, + SortOrder = 1 + }, + new + { + Id = 104L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Shiraz", + NameFa = "شیراز", + ProvinceId = 4L, + SortOrder = 1 + }, + new + { + Id = 105L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Mashhad", + NameFa = "مشهد", + ProvinceId = 5L, + SortOrder = 1 + }, + new + { + Id = 106L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Tabriz", + NameFa = "تبریز", + ProvinceId = 6L, + SortOrder = 1 + }, + new + { + Id = 107L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Urmia", + NameFa = "ارومیه", + ProvinceId = 7L, + SortOrder = 1 + }, + new + { + Id = 108L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Ahvaz", + NameFa = "اهواز", + ProvinceId = 8L, + SortOrder = 1 + }, + new + { + Id = 109L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Qom", + NameFa = "قم", + ProvinceId = 9L, + SortOrder = 1 + }, + new + { + Id = 110L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kerman", + NameFa = "کرمان", + ProvinceId = 10L, + SortOrder = 1 + }, + new + { + Id = 111L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Rasht", + NameFa = "رشت", + ProvinceId = 11L, + SortOrder = 1 + }, + new + { + Id = 112L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Sari", + NameFa = "ساری", + ProvinceId = 12L, + SortOrder = 1 + }, + new + { + Id = 113L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Arak", + NameFa = "اراک", + ProvinceId = 13L, + SortOrder = 1 + }, + new + { + Id = 114L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Ardabil", + NameFa = "اردبیل", + ProvinceId = 14L, + SortOrder = 1 + }, + new + { + Id = 115L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Qazvin", + NameFa = "قزوین", + ProvinceId = 15L, + SortOrder = 1 + }, + new + { + Id = 116L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kermanshah", + NameFa = "کرمانشاه", + ProvinceId = 16L, + SortOrder = 1 + }, + new + { + Id = 117L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Bojnord", + NameFa = "بجنورد", + ProvinceId = 17L, + SortOrder = 1 + }, + new + { + Id = 118L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Birjand", + NameFa = "بیرجند", + ProvinceId = 18L, + SortOrder = 1 + }, + new + { + Id = 119L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Hamadan", + NameFa = "همدان", + ProvinceId = 19L, + SortOrder = 1 + }, + new + { + Id = 120L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Sanandaj", + NameFa = "سنندج", + ProvinceId = 20L, + SortOrder = 1 + }, + new + { + Id = 121L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Khorramabad", + NameFa = "خرم‌آباد", + ProvinceId = 21L, + SortOrder = 1 + }, + new + { + Id = 122L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Gorgan", + NameFa = "گرگان", + ProvinceId = 22L, + SortOrder = 1 + }, + new + { + Id = 123L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Bandar Abbas", + NameFa = "بندرعباس", + ProvinceId = 23L, + SortOrder = 1 + }, + new + { + Id = 124L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Bushehr", + NameFa = "بوشهر", + ProvinceId = 24L, + SortOrder = 1 + }, + new + { + Id = 125L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Zanjan", + NameFa = "زنجان", + ProvinceId = 25L, + SortOrder = 1 + }, + new + { + Id = 126L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Semnan", + NameFa = "سمنان", + ProvinceId = 26L, + SortOrder = 1 + }, + new + { + Id = 127L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Yazd", + NameFa = "یزد", + ProvinceId = 27L, + SortOrder = 1 + }, + new + { + Id = 128L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Zahedan", + NameFa = "زاهدان", + ProvinceId = 28L, + SortOrder = 1 + }, + new + { + Id = 129L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Shahrekord", + NameFa = "شهرکرد", + ProvinceId = 29L, + SortOrder = 1 + }, + new + { + Id = 130L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Yasuj", + NameFa = "یاسوج", + ProvinceId = 30L, + SortOrder = 1 + }, + new + { + Id = 131L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Ilam", + NameFa = "ایلام", + ProvinceId = 31L, + SortOrder = 1 + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.District", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CityId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("CityId", "SortOrder"); + + b.ToTable("Districts", "geo"); + + b.HasData( + new + { + Id = 1001L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 1", + NameFa = "منطقه ۱", + SortOrder = 1 + }, + new + { + Id = 1002L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 2", + NameFa = "منطقه ۲", + SortOrder = 2 + }, + new + { + Id = 1003L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 3", + NameFa = "منطقه ۳", + SortOrder = 3 + }, + new + { + Id = 1004L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 4", + NameFa = "منطقه ۴", + SortOrder = 4 + }, + new + { + Id = 1005L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 5", + NameFa = "منطقه ۵", + SortOrder = 5 + }, + new + { + Id = 1006L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 6", + NameFa = "منطقه ۶", + SortOrder = 6 + }, + new + { + Id = 1007L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 7", + NameFa = "منطقه ۷", + SortOrder = 7 + }, + new + { + Id = 1008L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 8", + NameFa = "منطقه ۸", + SortOrder = 8 + }, + new + { + Id = 1009L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 9", + NameFa = "منطقه ۹", + SortOrder = 9 + }, + new + { + Id = 1010L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 10", + NameFa = "منطقه ۱۰", + SortOrder = 10 + }, + new + { + Id = 1011L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 11", + NameFa = "منطقه ۱۱", + SortOrder = 11 + }, + new + { + Id = 1012L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 12", + NameFa = "منطقه ۱۲", + SortOrder = 12 + }, + new + { + Id = 1013L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 13", + NameFa = "منطقه ۱۳", + SortOrder = 13 + }, + new + { + Id = 1014L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 14", + NameFa = "منطقه ۱۴", + SortOrder = 14 + }, + new + { + Id = 1015L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 15", + NameFa = "منطقه ۱۵", + SortOrder = 15 + }, + new + { + Id = 1016L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 16", + NameFa = "منطقه ۱۶", + SortOrder = 16 + }, + new + { + Id = 1017L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 17", + NameFa = "منطقه ۱۷", + SortOrder = 17 + }, + new + { + Id = 1018L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 18", + NameFa = "منطقه ۱۸", + SortOrder = 18 + }, + new + { + Id = 1019L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 19", + NameFa = "منطقه ۱۹", + SortOrder = 19 + }, + new + { + Id = 1020L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 20", + NameFa = "منطقه ۲۰", + SortOrder = 20 + }, + new + { + Id = 1021L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 21", + NameFa = "منطقه ۲۱", + SortOrder = 21 + }, + new + { + Id = 1022L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 22", + NameFa = "منطقه ۲۲", + SortOrder = 22 + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.NurseServiceArea", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CityId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DistrictId") + .HasColumnType("bigint"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("CityId"); + + b.HasIndex("DistrictId"); + + b.HasIndex("NurseId", "CityId") + .IsUnique() + .HasDatabaseName("UX_NurseServiceAreas_Nurse_City_WholeCity") + .HasFilter("[DistrictId] IS NULL AND [DeletedAt] IS NULL"); + + b.HasIndex("NurseId", "CityId", "DistrictId") + .IsUnique() + .HasDatabaseName("UX_NurseServiceAreas_Nurse_City_District") + .HasFilter("[DistrictId] IS NOT NULL AND [DeletedAt] IS NULL"); + + b.ToTable("NurseServiceAreas", "geo"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.Province", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("SortOrder"); + + b.ToTable("Provinces", "geo"); + + 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)), + IsActive = true, + NameEn = "Tehran", + NameFa = "تهران", + SortOrder = 1 + }, + new + { + Id = 2L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Alborz", + NameFa = "البرز", + SortOrder = 2 + }, + new + { + Id = 3L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Isfahan", + NameFa = "اصفهان", + SortOrder = 3 + }, + new + { + Id = 4L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Fars", + NameFa = "فارس", + SortOrder = 4 + }, + new + { + Id = 5L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Razavi Khorasan", + NameFa = "خراسان رضوی", + SortOrder = 5 + }, + new + { + Id = 6L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "East Azerbaijan", + NameFa = "آذربایجان شرقی", + SortOrder = 6 + }, + new + { + Id = 7L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "West Azerbaijan", + NameFa = "آذربایجان غربی", + SortOrder = 7 + }, + new + { + Id = 8L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Khuzestan", + NameFa = "خوزستان", + SortOrder = 8 + }, + new + { + Id = 9L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Qom", + NameFa = "قم", + SortOrder = 9 + }, + new + { + Id = 10L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kerman", + NameFa = "کرمان", + SortOrder = 10 + }, + new + { + Id = 11L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Gilan", + NameFa = "گیلان", + SortOrder = 11 + }, + new + { + Id = 12L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Mazandaran", + NameFa = "مازندران", + SortOrder = 12 + }, + new + { + Id = 13L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Markazi", + NameFa = "مرکزی", + SortOrder = 13 + }, + new + { + Id = 14L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Ardabil", + NameFa = "اردبیل", + SortOrder = 14 + }, + new + { + Id = 15L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Qazvin", + NameFa = "قزوین", + SortOrder = 15 + }, + new + { + Id = 16L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kermanshah", + NameFa = "کرمانشاه", + SortOrder = 16 + }, + new + { + Id = 17L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "North Khorasan", + NameFa = "خراسان شمالی", + SortOrder = 17 + }, + new + { + Id = 18L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "South Khorasan", + NameFa = "خراسان جنوبی", + SortOrder = 18 + }, + new + { + Id = 19L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Hamadan", + NameFa = "همدان", + SortOrder = 19 + }, + new + { + Id = 20L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kurdistan", + NameFa = "کردستان", + SortOrder = 20 + }, + new + { + Id = 21L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Lorestan", + NameFa = "لرستان", + SortOrder = 21 + }, + new + { + Id = 22L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Golestan", + NameFa = "گلستان", + SortOrder = 22 + }, + new + { + Id = 23L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Hormozgan", + NameFa = "هرمزگان", + SortOrder = 23 + }, + new + { + Id = 24L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Bushehr", + NameFa = "بوشهر", + SortOrder = 24 + }, + new + { + Id = 25L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Zanjan", + NameFa = "زنجان", + SortOrder = 25 + }, + new + { + Id = 26L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Semnan", + NameFa = "سمنان", + SortOrder = 26 + }, + new + { + Id = 27L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Yazd", + NameFa = "یزد", + SortOrder = 27 + }, + new + { + Id = 28L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Sistan and Baluchestan", + NameFa = "سیستان و بلوچستان", + SortOrder = 28 + }, + new + { + Id = 29L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Chaharmahal and Bakhtiari", + NameFa = "چهارمحال و بختیاری", + SortOrder = 29 + }, + new + { + Id = 30L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kohgiluyeh and Boyer-Ahmad", + NameFa = "کهگیلویه و بویراحمد", + SortOrder = 30 + }, + new + { + Id = 31L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Ilam", + NameFa = "ایلام", + SortOrder = 31 + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Holidays.IranianHoliday", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("HolidayDate") + .HasColumnType("date"); + + b.Property("IsBankClosed") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("HolidayDate") + .IsUnique(); + + b.ToTable("IranianHolidays", "ops"); + + b.HasData( + new + { + Id = 1L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 2, 11), + IsBankClosed = true, + NameFa = "پیروزی انقلاب اسلامی", + Type = "national" + }, + new + { + Id = 2L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 3, 21), + IsBankClosed = true, + NameFa = "نوروز", + Type = "national" + }, + new + { + Id = 3L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 3, 22), + IsBankClosed = true, + NameFa = "نوروز", + Type = "national" + }, + new + { + Id = 4L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 3, 23), + IsBankClosed = true, + NameFa = "نوروز", + Type = "national" + }, + new + { + Id = 5L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 3, 24), + IsBankClosed = true, + NameFa = "نوروز", + Type = "national" + }, + new + { + Id = 6L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 4, 1), + IsBankClosed = true, + NameFa = "روز طبیعت (سیزده‌به‌در)", + Type = "official" + }, + new + { + Id = 7L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 6, 26), + IsBankClosed = true, + NameFa = "عید سعید قربان", + Type = "religious" + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.CustomerAddress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AddressLine") + .HasColumnType("nvarchar(max)"); + + b.Property("CityId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("CustomerId") + .HasColumnType("bigint"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DistrictId") + .HasColumnType("bigint"); + + b.Property("GeocodeSource") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("IsPrimary") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("Latitude") + .HasPrecision(9, 6) + .HasColumnType("decimal(9,6)"); + + b.Property("Longitude") + .HasPrecision(9, 6) + .HasColumnType("decimal(9,6)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("PostalCode") + .HasColumnType("nvarchar(max)"); + + b.Property("RecipientName") + .HasColumnType("nvarchar(max)"); + + b.Property("RecipientPhone") + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("CityId"); + + b.HasIndex("CustomerId") + .IsUnique() + .HasDatabaseName("UX_CustomerAddresses_Customer_Primary") + .HasFilter("[IsPrimary] = 1 AND [DeletedAt] IS NULL"); + + b.HasIndex("DistrictId"); + + b.ToTable("CustomerAddresses", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.CustomerProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AvatarUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DefaultEmergencyContactName") + .HasColumnType("nvarchar(max)"); + + b.Property("DefaultEmergencyContactPhone") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("PreferredLanguage") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("CustomerProfiles", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.NurseBankAccount", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountHolderFromBank") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("AccountHolderName") + .HasColumnType("nvarchar(max)"); + + b.Property("BankName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("Iban") + .HasColumnType("nvarchar(max)"); + + b.Property("IbanHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IsPrimary") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("IsVerified") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("MatchedNationalId") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("OwnershipVendorRef") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("VerifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("VerifiedByAdminId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("IbanHash") + .IsUnique(); + + b.HasIndex("NurseId") + .IsUnique() + .HasDatabaseName("UX_NurseBankAccounts_NurseId_Primary") + .HasFilter("[IsPrimary] = 1"); + + b.HasIndex("VerifiedByAdminId"); + + b.ToTable("NurseBankAccounts", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.NurseProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AvatarUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("AverageRating") + .ValueGeneratedOnAdd() + .HasPrecision(3, 2) + .HasColumnType("decimal(3,2)") + .HasDefaultValue(0m); + + b.Property("Bio") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("EducationField") + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("EducationLevel") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("IsAcceptingBookings") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("IsVerified") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("PartnerCenterId") + .HasColumnType("bigint"); + + b.Property("SpecializationsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("TotalCompletedBookings") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("TotalReviews") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("UserId") + .HasColumnType("int"); + + b.Property("YearsOfExperience") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("PartnerCenterId"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("NurseProfiles", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.Patient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BirthDate") + .HasColumnType("date"); + + b.Property("BloodType") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("ConditionsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("CustomerId") + .HasColumnType("bigint"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("FirstName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Gender") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("InitialMedicalNotes") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("LastName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("Relation") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId"); + + b.ToTable("Patients", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.PatientCarePlan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("MedicationsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("PatientId") + .HasColumnType("bigint"); + + b.Property("RoutineJson") + .HasColumnType("nvarchar(max)"); + + b.Property("TasksJson") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("PatientId") + .IsUnique() + .HasDatabaseName("UX_PatientCarePlans_Patient") + .HasFilter("[DeletedAt] IS NULL"); + + b.ToTable("PatientCarePlans", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Invoices.Invoice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BnplCommissionIrr") + .HasColumnType("bigint"); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("GrossIrr") + .HasColumnType("bigint"); + + b.Property("InvoiceNumber") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("IssuedAt") + .HasColumnType("datetime2"); + + b.Property("IssuingEntityType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("MoadianReferenceNumber") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("MoadianStatus") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("PartnerCenterId") + .HasColumnType("bigint"); + + b.Property("PdfStorageKey") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("PlatformCommissionIrr") + .HasColumnType("bigint"); + + b.Property("VatIrr") + .HasColumnType("bigint"); + + b.Property("VatRate") + .HasPrecision(5, 4) + .HasColumnType("decimal(5,4)"); + + b.HasKey("Id"); + + b.HasIndex("BookingId") + .IsUnique(); + + b.HasIndex("InvoiceNumber") + .IsUnique(); + + b.HasIndex("PartnerCenterId"); + + b.ToTable("Invoices", "payments"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Invoices.InvoiceNumberSequence", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("NextValue") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.ToTable("InvoiceNumberSequences", "payments"); + + b.HasData( + new + { + Id = 1, + NextValue = 1L + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Messaging.Ticket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.Property("ClosedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ClosedById") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("OpenedById") + .HasColumnType("int"); + + b.Property("ReferenceCode") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("RefundId") + .HasColumnType("bigint"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Subject") + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.HasKey("Id"); + + b.HasIndex("BookingId"); + + b.HasIndex("ClosedById"); + + b.HasIndex("OpenedById"); + + b.HasIndex("ReferenceCode") + .IsUnique(); + + b.HasIndex("RefundId"); + + b.HasIndex("Status"); + + b.HasIndex("Status", "CreatedAt"); + + b.ToTable("Tickets", "messaging"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Messaging.TicketMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Body") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ClientMessageId") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsInternal") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("SenderId") + .HasColumnType("int"); + + b.Property("SentAt") + .HasColumnType("datetimeoffset"); + + b.Property("TicketId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("SenderId"); + + b.HasIndex("TicketId", "ClientMessageId") + .IsUnique() + .HasDatabaseName("UX_TicketMessages_Ticket_ClientMessageId") + .HasFilter("[ClientMessageId] IS NOT NULL AND [DeletedAt] IS NULL"); + + b.HasIndex("TicketId", "SentAt"); + + b.ToTable("TicketMessages", "messaging"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Messaging.TicketParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AddedById") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("LastReadAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("RemovedAt") + .HasColumnType("datetimeoffset"); + + b.Property("RoleOnTicket") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("TicketId") + .HasColumnType("bigint"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("AddedById"); + + b.HasIndex("TicketId", "UserId") + .IsUnique(); + + b.HasIndex("UserId", "TicketId"); + + b.ToTable("TicketParticipants", "messaging"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Notifications.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Body") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DataJson") + .HasColumnType("nvarchar(max)"); + + b.Property("IsRead") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ReadAt") + .HasColumnType("datetimeoffset"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "IsRead", "CreatedAt"); + + b.ToTable("Notifications", "ops"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.PartnerCenters.PartnerCenter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AdminUserId") + .HasColumnType("int"); + + b.Property("CommissionRate") + .HasPrecision(5, 4) + .HasColumnType("decimal(5,4)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("EnamadCode") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsMerchantOfRecord") + .HasColumnType("bit"); + + b.Property("LegalEntityType") + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("MohEstablishmentPermitNo") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("SettlementIban") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("TechnicalDirectorLicenseNo") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("TechnicalDirectorNurseUserId") + .HasColumnType("int"); + + b.Property("VerifiedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("AdminUserId"); + + b.HasIndex("IsActive"); + + b.HasIndex("TechnicalDirectorNurseUserId"); + + b.ToTable("PartnerCenters", "partner"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payments.LedgerEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountType") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("AmountIrr") + .HasColumnType("bigint"); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Direction") + .IsRequired() + .HasMaxLength(6) + .HasColumnType("nvarchar(6)"); + + b.Property("Memo") + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("SourceRefId") + .HasColumnType("bigint"); + + b.Property("SourceRefType") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("TransactionGroupId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("BookingId"); + + b.HasIndex("NurseId"); + + b.HasIndex("TransactionGroupId"); + + b.HasIndex("AccountType", "NurseId"); + + b.HasIndex("SourceRefType", "SourceRefId"); + + b.ToTable("LedgerEntries", "payments"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payments.PaymentGateway", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConfigJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("Priority") + .HasColumnType("int"); + + b.Property("ProviderCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("Type", "IsActive", "Priority"); + + b.ToTable("PaymentGateways", "payments"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payments.PaymentTransaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("bigint"); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("BookingRequestId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("Currency") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("nvarchar(3)"); + + b.Property("CustomerId") + .HasColumnType("bigint"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("GatewayId") + .HasColumnType("bigint"); + + b.Property("GatewayReferenceCode") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("GatewayResponseCode") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("GatewayResponseJson") + .HasColumnType("nvarchar(max)"); + + b.Property("GatewayTransactionId") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IsInstallment") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("UserAgent") + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.HasKey("Id"); + + b.HasIndex("BookingId") + .IsUnique() + .HasFilter("[Status] = 'succeeded' AND [BookingId] IS NOT NULL"); + + b.HasIndex("CustomerId"); + + b.HasIndex("GatewayId"); + + b.HasIndex("GatewayReferenceCode") + .IsUnique() + .HasFilter("[GatewayReferenceCode] IS NOT NULL"); + + b.HasIndex("BookingId", "Status"); + + b.HasIndex("BookingRequestId", "Status"); + + b.ToTable("PaymentTransactions", "payments"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payments.PaymentWebhookEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("ExternalEventId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("PayloadJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ProcessedAt") + .HasColumnType("datetime2"); + + b.Property("ProcessingStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("ProviderCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ReceivedAt") + .HasColumnType("datetime2"); + + b.Property("RelatedPaymentTransactionId") + .HasColumnType("bigint"); + + b.Property("SignatureValid") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("ProviderCode", "ExternalEventId") + .IsUnique(); + + b.ToTable("PaymentWebhookEvents", "payments"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payouts.NursePayout", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("bigint"); + + b.Property("BankAccountId") + .HasColumnType("bigint"); + + b.Property("BatchId") + .HasColumnType("bigint"); + + b.Property("BookingCount") + .HasColumnType("int"); + + b.Property("ClawbackAppliedIrr") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("FailureReason") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("GrossEarningsIrr") + .HasColumnType("bigint"); + + b.Property("IbanSnapshot") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NetAmountIrr") + .HasColumnType("bigint"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("PaidAt") + .HasColumnType("datetime2"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("TransferReference") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("BankAccountId"); + + b.HasIndex("BatchId"); + + b.HasIndex("NurseId"); + + b.HasIndex("Status"); + + b.ToTable("NursePayouts", "payouts", t => + { + t.HasCheckConstraint("CK_NursePayouts_NetSplit", "[NetAmountIrr] = [GrossEarningsIrr] - [ClawbackAppliedIrr] AND [GrossEarningsIrr] >= 0 AND [ClawbackAppliedIrr] >= 0 AND [NetAmountIrr] >= 0 AND [Amount] >= 0"); + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payouts.NursePayoutBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("FailureNotes") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("InitiatedByAdminId") + .HasColumnType("int"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("PayoutCount") + .HasColumnType("int"); + + b.Property("PeriodEnd") + .HasColumnType("date"); + + b.Property("PeriodStart") + .HasColumnType("date"); + + b.Property("ProcessedAt") + .HasColumnType("datetime2"); + + b.Property("ProcessingDate") + .HasColumnType("date"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.Property("TotalAmount") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("InitiatedByAdminId"); + + b.HasIndex("ProcessingDate"); + + b.HasIndex("Status"); + + b.ToTable("NursePayoutBatches", "payouts"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payouts.NursePayoutBookingLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("PayoutAmountIrr") + .HasColumnType("bigint"); + + b.Property("PayoutId") + .HasColumnType("bigint"); + + b.Property("SessionId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("BookingId") + .IsUnique(); + + b.HasIndex("PayoutId"); + + b.HasIndex("SessionId"); + + b.ToTable("NursePayoutBookingLinks", "payouts"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Refunds.NurseClawback", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AmountIrr") + .HasColumnType("bigint"); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("OriginalPayoutId") + .HasColumnType("bigint"); + + b.Property("RecoveredInPayoutId") + .HasColumnType("bigint"); + + b.Property("RefundId") + .HasColumnType("bigint"); + + b.Property("ResolutionNotes") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ResolvedAt") + .HasColumnType("datetime2"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.HasKey("Id"); + + b.HasIndex("BookingId"); + + b.HasIndex("NurseId"); + + b.HasIndex("OriginalPayoutId"); + + b.HasIndex("RecoveredInPayoutId"); + + b.HasIndex("RefundId") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("NurseClawbacks", "payments"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Refunds.Refund", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AdminNotes") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Amount") + .HasColumnType("bigint"); + + b.Property("ApprovedByAdminId") + .HasColumnType("int"); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("CancellationPolicyCode") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExpectedCustomerRefundEta") + .HasColumnType("date"); + + b.Property("ExternalRevertReference") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("GatewayRefundReference") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NursePayoutRefundedIrr") + .HasColumnType("bigint"); + + b.Property("PaymentTransactionId") + .HasColumnType("bigint"); + + b.Property("PlatformFeeRefundedIrr") + .HasColumnType("bigint"); + + b.Property("ProcessedAt") + .HasColumnType("datetime2"); + + b.Property("ReasonCategory") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ReasonNotes") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("RefundChannel") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("RefundPercentage") + .HasPrecision(6, 4) + .HasColumnType("decimal(6,4)"); + + b.Property("RefundPercentageApplied") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)"); + + b.Property("RejectedReason") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("RequestedByCustomerId") + .HasColumnType("bigint"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("TicketId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("BookingId"); + + b.HasIndex("PaymentTransactionId"); + + b.HasIndex("RequestedByCustomerId"); + + b.HasIndex("Status"); + + b.HasIndex("TicketId"); + + b.ToTable("Refunds", "payments", t => + { + t.HasCheckConstraint("CK_Refunds_LegSplit", "[Amount] = [PlatformFeeRefundedIrr] + [NursePayoutRefundedIrr] AND [Amount] >= 0 AND [PlatformFeeRefundedIrr] >= 0 AND [NursePayoutRefundedIrr] >= 0"); + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Reviews.PatientCareRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BodyEncrypted") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseProfileId") + .HasColumnType("bigint"); + + b.Property("PatientId") + .HasColumnType("bigint"); + + b.Property("RecordedAt") + .HasColumnType("datetime2"); + + b.Property("TaskResultsJson") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("BookingId"); + + b.HasIndex("NurseProfileId"); + + b.HasIndex("PatientId", "RecordedAt") + .HasDatabaseName("IX_PatientCareRecords_Patient_RecordedAt"); + + b.ToTable("PatientCareRecords", "reviews"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Reviews.Review", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Body") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("CustomerProfileId") + .HasColumnType("bigint"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModeratedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModeratedById") + .HasColumnType("int"); + + b.Property("ModerationReason") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ModerationStatus") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseProfileId") + .HasColumnType("bigint"); + + b.Property("Rating") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("BookingId") + .IsUnique(); + + b.HasIndex("CustomerProfileId"); + + b.HasIndex("ModerationStatus"); + + b.HasIndex("NurseProfileId", "ModerationStatus"); + + b.ToTable("Reviews", "reviews", t => + { + t.HasCheckConstraint("CK_Reviews_Rating", "[Rating] BETWEEN 1 AND 5"); + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Reviews.ReviewTagLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("ReviewId") + .HasColumnType("bigint"); + + b.Property("ReviewTagMasterId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ReviewTagMasterId"); + + b.HasIndex("ReviewId", "ReviewTagMasterId") + .IsUnique() + .HasDatabaseName("UX_ReviewTagLinks_Review_Tag"); + + b.ToTable("ReviewTagLinks", "reviews"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Reviews.ReviewTagMaster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("LabelEn") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("LabelFa") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsActive", "SortOrder"); + + b.ToTable("ReviewTagsMasters", "reviews"); + + b.HasData( + new + { + Id = 1L, + Code = "punctual", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + LabelEn = "Punctual", + LabelFa = "وقت‌شناس", + SortOrder = 1 + }, + new + { + Id = 2L, + Code = "professional", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + LabelEn = "Professional", + LabelFa = "حرفه‌ای", + SortOrder = 2 + }, + new + { + Id = 3L, + Code = "clean", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + LabelEn = "Clean", + LabelFa = "تمیز و بهداشتی", + SortOrder = 3 + }, + new + { + Id = 4L, + Code = "kind", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + LabelEn = "Kind", + LabelFa = "مهربان", + SortOrder = 4 + }, + new + { + Id = 5L, + Code = "communicative", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + LabelEn = "Communicative", + LabelFa = "خوش‌برخورد", + SortOrder = 5 + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Search.NurseSearchIndex", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AvatarUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("AverageRating") + .HasPrecision(3, 2) + .HasColumnType("decimal(3,2)"); + + b.Property("CityId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DistrictId") + .HasColumnType("bigint"); + + b.Property("IsSearchable") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseGender") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("NurseName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Price") + .HasColumnType("bigint"); + + b.Property("PriceUnit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("ServiceCategoryId") + .HasColumnType("bigint"); + + b.Property("TotalCompletedBookings") + .HasColumnType("int"); + + b.Property("TotalReviews") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("VariantId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("NurseId"); + + b.HasIndex("VariantId", "CityId") + .IsUnique() + .HasDatabaseName("UX_NurseSearchIndex_Variant_City_WholeCity") + .HasFilter("[DistrictId] IS NULL AND [DeletedAt] IS NULL"); + + b.HasIndex("VariantId", "CityId", "DistrictId") + .IsUnique() + .HasDatabaseName("UX_NurseSearchIndex_Variant_City_District") + .HasFilter("[DistrictId] IS NOT NULL AND [DeletedAt] IS NULL"); + + b.HasIndex("IsSearchable", "ServiceCategoryId", "CityId", "DistrictId") + .HasDatabaseName("IX_NurseSearchIndex_Search"); + + SqlServerIndexBuilderExtensions.IncludeProperties(b.HasIndex("IsSearchable", "ServiceCategoryId", "CityId", "DistrictId"), new[] { "Price", "NurseGender", "AverageRating", "TotalReviews", "NurseId", "VariantId", "NurseName" }); + + b.ToTable("NurseSearchIndices", "search"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.SupportAlerts.SupportAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("OwnerUserId") + .HasColumnType("int"); + + b.Property("ResolutionNote") + .HasColumnType("nvarchar(max)"); + + b.Property("ResolvedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ReviewId") + .HasColumnType("bigint"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.HasIndex("Status"); + + b.HasIndex("Type"); + + b.ToTable("SupportAlerts", "ops"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("Roles", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.RoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedClaim") + .HasColumnType("datetime2"); + + b.Property("RoleId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("UserId"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("FamilyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Gender") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("GeneratedCode") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("NationalId") + .HasColumnType("nvarchar(max)"); + + b.Property("NationalIdVerifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("PhoneVerifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("ShahkarVerifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.HasIndex("PhoneHash") + .IsUnique() + .HasFilter("[PhoneHash] IS NOT NULL"); + + b.ToTable("Users", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("LoggedOn") + .HasColumnType("datetime2"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserRefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("IsValid") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserRefreshTokens", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserRole", b => + { + b.Property("UserId") + .HasColumnType("int"); + + b.Property("RoleId") + .HasColumnType("int"); + + b.Property("CreatedUserRoleDate") + .HasColumnType("datetime2"); + + b.Property("GrantedAt") + .HasColumnType("datetimeoffset"); + + b.Property("GrantedById") + .HasColumnType("int"); + + b.Property("RevokedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("GrantedById"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeviceInfo") + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("ExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IsRevoked") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("RefreshTokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RevokedAt") + .HasColumnType("datetimeoffset"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("RefreshTokenHash") + .IsUnique(); + + b.HasIndex("UserId", "IsRevoked"); + + b.ToTable("UserSessions", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserToken", b => + { + b.Property("UserId") + .HasColumnType("int"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("GeneratedTime") + .HasColumnType("datetime2"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.NurseCredential", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("CredentialNumber") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("CredentialType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExpiresAt") + .HasColumnType("date"); + + b.Property("HolderNameSnapshot") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("IssuedAt") + .HasColumnType("date"); + + b.Property("IssuingAuthority") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("VerificationMethod") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("VerificationSource") + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("VerifiedByAdminId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("VerifiedByAdminId"); + + b.HasIndex("NurseId", "CredentialType"); + + b.ToTable("NurseCredentials", "verif"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.NurseVerification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ApprovedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("InternalNotes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("RejectedAt") + .HasColumnType("datetimeoffset"); + + b.Property("RejectionReason") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("ReviewedByAdminId") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("SubmittedAt") + .HasColumnType("datetimeoffset"); + + b.Property("SuspendedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("NurseId") + .IsUnique(); + + b.HasIndex("ReviewedByAdminId"); + + b.ToTable("NurseVerifications", "verif"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint"); + + b.Property("IntegrityHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("ObjectStorageKey") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("OriginalFileName") + .HasMaxLength(260) + .HasColumnType("nvarchar(260)"); + + b.Property("StepId") + .HasColumnType("bigint"); + + b.Property("UploadedByUserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("StepId"); + + b.HasIndex("UploadedByUserId"); + + b.ToTable("VerificationDocuments", "verif"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("ExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExternalResponseJson") + .HasColumnType("nvarchar(max)"); + + b.Property("FailureReason") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("IsAutomated") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseVerificationId") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("StepTypeId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("StepTypeId"); + + b.HasIndex("NurseVerificationId", "StepTypeId") + .IsUnique() + .HasDatabaseName("UX_VerificationSteps_Verification_StepType"); + + b.ToTable("VerificationSteps", "verif"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationStepType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AutomationProvider") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("IsAutomated") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("IsRequired") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsActive", "SortOrder"); + + b.ToTable("VerificationStepTypes", "verif"); + + b.HasData( + new + { + Id = 1L, + AutomationProvider = "identity_kyc_vendor", + Code = "identity_kyc", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + Description = "National-ID validity, name match and photo/video liveness via an Iranian e-KYC vendor.", + DisplayName = "Identity Verification (KYC)", + IsActive = true, + IsAutomated = true, + IsRequired = true, + SortOrder = 1 + }, + new + { + Id = 2L, + AutomationProvider = "shahkar", + Code = "shahkar_match", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + Description = "Confirms the login SIM is registered to the nurse's own national ID (شاهکار).", + DisplayName = "Shahkar Phone Binding", + IsActive = true, + IsAutomated = true, + IsRequired = true, + SortOrder = 2 + }, + new + { + Id = 3L, + Code = "moh_competency_license", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + Description = "پروانه صلاحیت حرفه‌ای — the MoH-mandated in-home nursing licence (bundles the criminal-record screen). Manual today.", + DisplayName = "MoH Professional Competency License", + IsActive = true, + IsAutomated = false, + IsRequired = true, + SortOrder = 3 + }, + new + { + Id = 4L, + Code = "ino_membership", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + Description = "نظام پرستاری membership cross-check (ino.ir). Manual today.", + DisplayName = "Nursing Organization (INO) Membership", + IsActive = true, + IsAutomated = false, + IsRequired = true, + SortOrder = 4 + }, + new + { + Id = 5L, + Code = "criminal_record", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + Description = "عدم سوء پیشینه — consent-gated, nurse-uploaded, time-limited (reverts on expiry).", + DisplayName = "Criminal Record Certificate", + IsActive = true, + IsAutomated = false, + IsRequired = true, + SortOrder = 5 + }, + new + { + Id = 6L, + AutomationProvider = "sheba", + Code = "bank_account_verification", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + Description = "استعلام شبا — the payout IBAN owner's national ID must equal the verified nurse national ID.", + DisplayName = "Bank Account (IBAN) Ownership", + IsActive = true, + IsAutomated = true, + IsRequired = true, + SortOrder = 6 + }); + }); + + 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.Bnpl.BnplTransaction", b => + { + b.HasOne("Baya.Domain.Entities.Payments.PaymentTransaction", null) + .WithMany() + .HasForeignKey("PaymentTransactionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.Booking", b => + { + b.HasOne("Baya.Domain.Entities.Booking.BookingRequest", null) + .WithOne() + .HasForeignKey("Baya.Domain.Entities.Booking.Booking", "BookingRequestId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.CustomerAddress", null) + .WithMany() + .HasForeignKey("CustomerAddressId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.CustomerProfile", null) + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null) + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.Patient", null) + .WithMany() + .HasForeignKey("PatientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Catalog.NurseServiceVariant", null) + .WithMany() + .HasForeignKey("VariantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingCareInstruction", b => + { + b.HasOne("Baya.Domain.Entities.Booking.Booking", "Booking") + .WithOne("CareInstructions") + .HasForeignKey("Baya.Domain.Entities.Booking.BookingCareInstruction", "BookingId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Booking"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingRequest", b => + { + b.HasOne("Baya.Domain.Entities.Identity.CustomerAddress", "CustomerAddress") + .WithMany() + .HasForeignKey("CustomerAddressId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.CustomerProfile", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", "Nurse") + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.Patient", "Patient") + .WithMany() + .HasForeignKey("PatientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Catalog.NurseServiceVariant", "Variant") + .WithMany() + .HasForeignKey("VariantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Customer"); + + b.Navigation("CustomerAddress"); + + b.Navigation("Nurse"); + + b.Navigation("Patient"); + + b.Navigation("Variant"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingSession", b => + { + b.HasOne("Baya.Domain.Entities.Booking.Booking", "Booking") + .WithMany("Sessions") + .HasForeignKey("BookingId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Booking"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.VisitVerification", b => + { + b.HasOne("Baya.Domain.Entities.Booking.BookingSession", "Session") + .WithOne("Verification") + .HasForeignKey("Baya.Domain.Entities.Booking.VisitVerification", "BookingSessionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Session"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariant", b => + { + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", "Nurse") + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Catalog.ServiceCategory", "ServiceCategory") + .WithMany("Variants") + .HasForeignKey("ServiceCategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Nurse"); + + b.Navigation("ServiceCategory"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariantOption", b => + { + b.HasOne("Baya.Domain.Entities.Catalog.ServiceOptionGroup", "OptionGroup") + .WithMany() + .HasForeignKey("OptionGroupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Catalog.ServiceOptionValue", "OptionValue") + .WithMany() + .HasForeignKey("OptionValueId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Catalog.NurseServiceVariant", "Variant") + .WithMany("Options") + .HasForeignKey("VariantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("OptionGroup"); + + b.Navigation("OptionValue"); + + b.Navigation("Variant"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionGroup", b => + { + b.HasOne("Baya.Domain.Entities.Catalog.ServiceCategory", "ServiceCategory") + .WithMany("OptionGroups") + .HasForeignKey("ServiceCategoryId"); + + b.Navigation("ServiceCategory"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionValue", b => + { + b.HasOne("Baya.Domain.Entities.Catalog.ServiceOptionGroup", "OptionGroup") + .WithMany("Values") + .HasForeignKey("OptionGroupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("OptionGroup"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.City", b => + { + b.HasOne("Baya.Domain.Entities.Geography.Province", "Province") + .WithMany("Cities") + .HasForeignKey("ProvinceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Province"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.District", b => + { + b.HasOne("Baya.Domain.Entities.Geography.City", "City") + .WithMany("Districts") + .HasForeignKey("CityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("City"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.NurseServiceArea", b => + { + b.HasOne("Baya.Domain.Entities.Geography.City", "City") + .WithMany() + .HasForeignKey("CityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Geography.District", "District") + .WithMany() + .HasForeignKey("DistrictId"); + + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null) + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("City"); + + b.Navigation("District"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.CustomerAddress", b => + { + b.HasOne("Baya.Domain.Entities.Geography.City", "City") + .WithMany() + .HasForeignKey("CityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.CustomerProfile", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Geography.District", "District") + .WithMany() + .HasForeignKey("DistrictId"); + + b.Navigation("City"); + + b.Navigation("Customer"); + + b.Navigation("District"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.CustomerProfile", b => + { + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithOne() + .HasForeignKey("Baya.Domain.Entities.Identity.CustomerProfile", "UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.NurseBankAccount", b => + { + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", "Nurse") + .WithMany("BankAccounts") + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("VerifiedByAdminId"); + + b.Navigation("Nurse"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.NurseProfile", b => + { + b.HasOne("Baya.Domain.Entities.PartnerCenters.PartnerCenter", null) + .WithMany() + .HasForeignKey("PartnerCenterId"); + + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithOne() + .HasForeignKey("Baya.Domain.Entities.Identity.NurseProfile", "UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.Patient", b => + { + b.HasOne("Baya.Domain.Entities.Identity.CustomerProfile", "Customer") + .WithMany("Patients") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.PatientCarePlan", b => + { + b.HasOne("Baya.Domain.Entities.Identity.Patient", null) + .WithMany() + .HasForeignKey("PatientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Invoices.Invoice", b => + { + b.HasOne("Baya.Domain.Entities.Booking.Booking", null) + .WithMany() + .HasForeignKey("BookingId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.PartnerCenters.PartnerCenter", null) + .WithMany() + .HasForeignKey("PartnerCenterId") + .OnDelete(DeleteBehavior.NoAction); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Messaging.Ticket", b => + { + b.HasOne("Baya.Domain.Entities.Booking.Booking", null) + .WithMany() + .HasForeignKey("BookingId"); + + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("ClosedById"); + + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("OpenedById") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Refunds.Refund", null) + .WithMany() + .HasForeignKey("RefundId"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Messaging.TicketMessage", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("SenderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Messaging.Ticket", "Ticket") + .WithMany("Messages") + .HasForeignKey("TicketId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Ticket"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Messaging.TicketParticipant", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("AddedById"); + + b.HasOne("Baya.Domain.Entities.Messaging.Ticket", "Ticket") + .WithMany("Participants") + .HasForeignKey("TicketId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Ticket"); + }); + + 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.PartnerCenters.PartnerCenter", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("AdminUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("TechnicalDirectorNurseUserId"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payments.LedgerEntry", b => + { + b.HasOne("Baya.Domain.Entities.Booking.Booking", null) + .WithMany() + .HasForeignKey("BookingId"); + + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null) + .WithMany() + .HasForeignKey("NurseId"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payments.PaymentTransaction", b => + { + b.HasOne("Baya.Domain.Entities.Booking.Booking", null) + .WithMany() + .HasForeignKey("BookingId"); + + b.HasOne("Baya.Domain.Entities.Booking.BookingRequest", null) + .WithMany() + .HasForeignKey("BookingRequestId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.CustomerProfile", null) + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Payments.PaymentGateway", null) + .WithMany() + .HasForeignKey("GatewayId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payouts.NursePayout", b => + { + b.HasOne("Baya.Domain.Entities.Identity.NurseBankAccount", null) + .WithMany() + .HasForeignKey("BankAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Payouts.NursePayoutBatch", "Batch") + .WithMany("Payouts") + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null) + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Batch"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payouts.NursePayoutBatch", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("InitiatedByAdminId"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payouts.NursePayoutBookingLink", b => + { + b.HasOne("Baya.Domain.Entities.Booking.Booking", null) + .WithMany() + .HasForeignKey("BookingId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Payouts.NursePayout", null) + .WithMany("BookingLinks") + .HasForeignKey("PayoutId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Booking.BookingSession", null) + .WithMany() + .HasForeignKey("SessionId"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Refunds.NurseClawback", b => + { + b.HasOne("Baya.Domain.Entities.Booking.Booking", null) + .WithMany() + .HasForeignKey("BookingId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null) + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Payouts.NursePayout", null) + .WithMany() + .HasForeignKey("OriginalPayoutId") + .OnDelete(DeleteBehavior.NoAction); + + b.HasOne("Baya.Domain.Entities.Payouts.NursePayout", null) + .WithMany() + .HasForeignKey("RecoveredInPayoutId") + .OnDelete(DeleteBehavior.NoAction); + + b.HasOne("Baya.Domain.Entities.Refunds.Refund", null) + .WithMany() + .HasForeignKey("RefundId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Refunds.Refund", b => + { + b.HasOne("Baya.Domain.Entities.Booking.Booking", null) + .WithMany() + .HasForeignKey("BookingId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Payments.PaymentTransaction", null) + .WithMany() + .HasForeignKey("PaymentTransactionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.CustomerProfile", null) + .WithMany() + .HasForeignKey("RequestedByCustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Messaging.Ticket", null) + .WithMany() + .HasForeignKey("TicketId") + .OnDelete(DeleteBehavior.NoAction); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Reviews.PatientCareRecord", b => + { + b.HasOne("Baya.Domain.Entities.Booking.Booking", null) + .WithMany() + .HasForeignKey("BookingId"); + + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null) + .WithMany() + .HasForeignKey("NurseProfileId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.Patient", null) + .WithMany() + .HasForeignKey("PatientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Reviews.Review", b => + { + b.HasOne("Baya.Domain.Entities.Booking.Booking", null) + .WithMany() + .HasForeignKey("BookingId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.CustomerProfile", null) + .WithMany() + .HasForeignKey("CustomerProfileId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null) + .WithMany() + .HasForeignKey("NurseProfileId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Reviews.ReviewTagLink", b => + { + b.HasOne("Baya.Domain.Entities.Reviews.Review", "Review") + .WithMany("TagLinks") + .HasForeignKey("ReviewId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Reviews.ReviewTagMaster", "Tag") + .WithMany("Links") + .HasForeignKey("ReviewTagMasterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Review"); + + b.Navigation("Tag"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Search.NurseSearchIndex", b => + { + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null) + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Catalog.NurseServiceVariant", "Variant") + .WithMany() + .HasForeignKey("VariantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Variant"); + }); + + 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.User", null) + .WithMany() + .HasForeignKey("GrantedById"); + + 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.UserSession", b => + { + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithMany("Sessions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + 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.Verification.NurseCredential", b => + { + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null) + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("VerifiedByAdminId"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.NurseVerification", b => + { + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", "Nurse") + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("ReviewedByAdminId"); + + b.Navigation("Nurse"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationDocument", b => + { + b.HasOne("Baya.Domain.Entities.Verification.VerificationStep", "Step") + .WithMany("Documents") + .HasForeignKey("StepId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("UploadedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Step"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationStep", b => + { + b.HasOne("Baya.Domain.Entities.Verification.NurseVerification", "NurseVerification") + .WithMany("Steps") + .HasForeignKey("NurseVerificationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Verification.VerificationStepType", "StepType") + .WithMany("Steps") + .HasForeignKey("StepTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("NurseVerification"); + + b.Navigation("StepType"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.Booking", b => + { + b.Navigation("CareInstructions"); + + b.Navigation("Sessions"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingSession", b => + { + b.Navigation("Verification"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariant", b => + { + b.Navigation("Options"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceCategory", b => + { + b.Navigation("OptionGroups"); + + b.Navigation("Variants"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionGroup", b => + { + b.Navigation("Values"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.City", b => + { + b.Navigation("Districts"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.Province", b => + { + b.Navigation("Cities"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.CustomerProfile", b => + { + b.Navigation("Patients"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.NurseProfile", b => + { + b.Navigation("BankAccounts"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Messaging.Ticket", b => + { + b.Navigation("Messages"); + + b.Navigation("Participants"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payouts.NursePayout", b => + { + b.Navigation("BookingLinks"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payouts.NursePayoutBatch", b => + { + b.Navigation("Payouts"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Reviews.Review", b => + { + b.Navigation("TagLinks"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Reviews.ReviewTagMaster", b => + { + b.Navigation("Links"); + }); + + 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("Sessions"); + + b.Navigation("Tokens"); + + b.Navigation("UserRefreshTokens"); + + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.NurseVerification", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationStep", b => + { + b.Navigation("Documents"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationStepType", b => + { + b.Navigation("Steps"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260713184239_RefinementPhase9TicketBodyEncryptionAndAuditRetention.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260713184239_RefinementPhase9TicketBodyEncryptionAndAuditRetention.cs new file mode 100644 index 0000000..1adb127 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260713184239_RefinementPhase9TicketBodyEncryptionAndAuditRetention.cs @@ -0,0 +1,70 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional + +namespace Baya.Infrastructure.Persistence.Migrations +{ + /// + public partial class RefinementPhase9TicketBodyEncryptionAndAuditRetention : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "Body", + schema: "messaging", + table: "TicketMessages", + type: "nvarchar(max)", + nullable: false, + oldClrType: typeof(string), + oldType: "nvarchar(4000)", + oldMaxLength: 4000); + + migrationBuilder.InsertData( + schema: "ops", + table: "PlatformConfigs", + columns: new[] { "Id", "CreatedAt", "CreatedById", "DataType", "Description", "Key", "ModifiedAt", "ModifiedById", "Value" }, + values: new object[,] + { + { 24L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "int", "Retention (days) for everyday audit rows before the retention sweep deletes them (~2 years).", "audit_retention_general_days", null, null, "730" }, + { 25L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "int", "Retention (days) for financial/verification audit rows (refunds/clawbacks/payouts/verification/config/partner-center) — the long legal window (~7 years).", "audit_retention_financial_days", null, null, "2555" }, + { 26L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "int", "Hours between audit-log retention sweeps.", "audit_retention_scan_cadence_hours", null, null, "24" } + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DeleteData( + schema: "ops", + table: "PlatformConfigs", + keyColumn: "Id", + keyValue: 24L); + + migrationBuilder.DeleteData( + schema: "ops", + table: "PlatformConfigs", + keyColumn: "Id", + keyValue: 25L); + + migrationBuilder.DeleteData( + schema: "ops", + table: "PlatformConfigs", + keyColumn: "Id", + keyValue: 26L); + + migrationBuilder.AlterColumn( + name: "Body", + schema: "messaging", + table: "TicketMessages", + type: "nvarchar(4000)", + maxLength: 4000, + nullable: false, + oldClrType: typeof(string), + oldType: "nvarchar(max)"); + } + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs index 9f13ad7..7a5cba0 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs @@ -1300,6 +1300,33 @@ namespace Baya.Infrastructure.Persistence.Migrations Description = "When on, a BNPL-paid booking is payout-eligible only after its provider settlement is received (b13; default off — the DEFERRED settled_at guard).", Key = "require_bnpl_settlement_for_payout", Value = "false" + }, + new + { + Id = 24L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Retention (days) for everyday audit rows before the retention sweep deletes them (~2 years).", + Key = "audit_retention_general_days", + Value = "730" + }, + new + { + Id = 25L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Retention (days) for financial/verification audit rows (refunds/clawbacks/payouts/verification/config/partner-center) — the long legal window (~7 years).", + Key = "audit_retention_financial_days", + Value = "2555" + }, + new + { + Id = 26L, + 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 between audit-log retention sweeps.", + Key = "audit_retention_scan_cadence_hours", + Value = "24" }); }); @@ -3035,8 +3062,7 @@ namespace Baya.Infrastructure.Persistence.Migrations b.Property("Body") .IsRequired() - .HasMaxLength(4000) - .HasColumnType("nvarchar(4000)"); + .HasColumnType("nvarchar(max)"); b.Property("ClientMessageId") .HasMaxLength(100) diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs index 3c12315..e186123 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs @@ -72,6 +72,8 @@ public static class ServiceCollectionExtensions services.AddSingleton(); // refinement-phase-8 (6.5): walk pending/submitted سامانه مودیان invoices toward their registered reference. services.AddSingleton(); + // refinement-phase-9 (§9.4): two-tier retention sweep over the append-only audit_logs table. + services.AddSingleton(); services.AddHostedService(); // Search (backend-phase-7). The index maintainer keeps nurse_search_index consistent inline inside diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Audit/AuditLogger.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Audit/AuditLogger.cs index 8e592c8..2cffc33 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Audit/AuditLogger.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Audit/AuditLogger.cs @@ -77,4 +77,37 @@ internal sealed class AuditLogger( return new PagedResult(items, total, page, pageSize); } + + public async ValueTask PurgeExpiredAsync( + int generalRetentionDays, + int financialRetentionDays, + IReadOnlyCollection financialEntityTypes, + int maxRowsPerRun, + CancellationToken cancellationToken = default) + { + var now = dateTimeProvider.UtcNow; + var generalCutoff = now.AddDays(-generalRetentionDays); + var financialCutoff = now.AddDays(-financialRetentionDays); + + // Oldest-first (Id is monotonic with OccurredAt), capped. The age comparison is done in memory — the + // SQLite test provider can't translate a DateTimeOffset predicate — and the delete is a single id-keyed + // statement that translates on every provider. A financial/compliance row survives until the longer window. + var candidates = await db.Set().AsNoTracking() + .OrderBy(a => a.Id) + .Select(a => new { a.Id, a.EntityType, a.OccurredAt }) + .Take(maxRowsPerRun) + .ToListAsync(cancellationToken); + + var expiredIds = candidates + .Where(a => a.OccurredAt < (financialEntityTypes.Contains(a.EntityType) ? financialCutoff : generalCutoff)) + .Select(a => a.Id) + .ToList(); + + if (expiredIds.Count == 0) + return 0; + + return await db.Set() + .Where(a => expiredIds.Contains(a.Id)) + .ExecuteDeleteAsync(cancellationToken); + } } diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Scheduling/Jobs/AuditLogRetentionJob.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Scheduling/Jobs/AuditLogRetentionJob.cs new file mode 100644 index 0000000..a0d4633 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Scheduling/Jobs/AuditLogRetentionJob.cs @@ -0,0 +1,68 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Baya.Application.Contracts.Audit; +using Baya.Application.Contracts.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Baya.Infrastructure.Persistence.Services.Scheduling.Jobs; + +/// +/// Retention/archival policy for the append-only ops.AuditLogs table (refinement-phase-9 §9.4). The audit +/// trail is never pruned by the application except here, on a schedule, and even then a two-tier policy protects +/// the legally-sensitive rows: financial & verification entity types (refunds, clawbacks, payouts, +/// payout batches, verification decisions, config changes, partner centers) keep a long window +/// (audit_retention_financial_days, default ~7 years); everyday operational rows keep a shorter one +/// (audit_retention_general_days, default ~2 years). Idempotent: a re-run simply finds nothing new to +/// delete. Runs on the audit_retention_scan_cadence_hours cadence. +/// +internal sealed class AuditLogRetentionJob(ILogger logger) : IRecurringJob +{ + // Bound the per-run working set so a large backlog drains across successive runs without loading the whole table. + private const int MaxRowsPerRun = 20_000; + + /// + /// The IAuditable entity types whose audit rows carry money- or verification-legal weight. Their + /// EntityType is the CLR type name written by AuditFieldInterceptor. Everything else uses the + /// shorter general window. + /// + private static readonly IReadOnlyCollection FinancialEntityTypes = + [ + "Refund", + "NurseClawback", + "NursePayout", + "NursePayoutBatch", + "NurseVerification", + "PlatformConfig", + "PartnerCenter" + ]; + + public string Name => "audit_log_retention"; + + public async ValueTask GetIntervalAsync(IServiceProvider services, CancellationToken cancellationToken) + { + var config = services.GetRequiredService(); + var hours = await config.GetConfig("audit_retention_scan_cadence_hours", cancellationToken); + return TimeSpan.FromHours(hours); + } + + public async ValueTask RunAsync(IServiceProvider services, CancellationToken cancellationToken) + { + var config = services.GetRequiredService(); + var audit = services.GetRequiredService(); + + var generalDays = await config.GetConfig("audit_retention_general_days", cancellationToken); + var financialDays = await config.GetConfig("audit_retention_financial_days", cancellationToken); + + var deleted = await audit.PurgeExpiredAsync( + generalDays, financialDays, FinancialEntityTypes, MaxRowsPerRun, cancellationToken); + + if (deleted > 0) + logger.LogInformation( + "Audit retention purged {Count} audit row(s) (general>{GeneralDays}d, financial>{FinancialDays}d)", + deleted, generalDays, financialDays); + } +} diff --git a/server/src/Tests/Baya.Test.Api/HealthCheckApiTests.cs b/server/src/Tests/Baya.Test.Api/HealthCheckApiTests.cs new file mode 100644 index 0000000..5d5e9b5 --- /dev/null +++ b/server/src/Tests/Baya.Test.Api/HealthCheckApiTests.cs @@ -0,0 +1,21 @@ +using System.Net; + +namespace Baya.Test.Api; + +/// +/// refinement-phase-9 §9.2: liveness is split from readiness. /healthz/live reports only the process +/// (dependency-free) so a dependency outage never triggers a restart loop; it must answer healthy even in the +/// test host, whose readiness dependencies (a real SQL Server) are absent. +/// +public class HealthCheckApiTests(BayaApiFactory factory) : IClassFixture +{ + [Fact] + public async Task Liveness_IsHealthy_WithoutTouchingDependencies() + { + var client = factory.CreateClient(); + + var response = await client.GetAsync("/healthz/live"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } +} diff --git a/server/src/Tests/Baya.Test.Foundation/Audit/AuditLogRetentionTests.cs b/server/src/Tests/Baya.Test.Foundation/Audit/AuditLogRetentionTests.cs new file mode 100644 index 0000000..c58f1f7 --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/Audit/AuditLogRetentionTests.cs @@ -0,0 +1,88 @@ +using Baya.Application.Contracts.Common; +using Baya.Domain.Entities.Audit; +using Baya.Infrastructure.Persistence; +using Baya.Infrastructure.Persistence.Services.Audit; +using Baya.Tests.Setup.Setups; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using NSubstitute; + +namespace Baya.Test.Foundation.Audit; + +/// +/// refinement-phase-9 §9.4: the two-tier audit-log retention sweep. Everyday rows are deleted past the general +/// window; financial/verification rows survive until the longer legal window. The sweep is idempotent. +/// +public sealed class AuditLogRetentionTests : IDisposable +{ + private static readonly DateTimeOffset Now = new(2026, 8, 10, 10, 0, 0, TimeSpan.Zero); + private const int GeneralDays = 730; + private const int FinancialDays = 2555; + private static readonly string[] FinancialTypes = ["Refund", "NursePayout", "NurseVerification", "PlatformConfig"]; + + private readonly SqliteConnection _connection; + private readonly ApplicationDbContext _db; + private readonly AuditLogger _audit; + + public AuditLogRetentionTests() + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + var options = new DbContextOptionsBuilder().UseSqlite(_connection).Options; + _db = new ApplicationDbContext(options, TestFieldEncryptor.Instance); + _db.Database.EnsureCreated(); + + var clock = Substitute.For(); + clock.UtcNow.Returns(Now); + _audit = new AuditLogger(_db, Substitute.For(), clock); + } + + private void Seed(string entityType, int ageDays) + { + _db.Set().Add(new AuditLog + { + EntityType = entityType, + EntityId = $"{entityType}-{ageDays}", + Action = AuditAction.Updated, + OccurredAt = Now.AddDays(-ageDays) + }); + _db.SaveChanges(); + } + + [Fact] + public async Task Purge_deletes_expired_general_rows_and_keeps_recent_and_financial() + { + Seed("SomeEntity", ageDays: 800); // general, past 730 → deleted + Seed("SomeEntity", ageDays: 700); // general, within 730 → kept + Seed("Refund", ageDays: 800); // financial, within 2555 → kept + Seed("NursePayout", ageDays: 3000); // financial, past 2555 → deleted + + var deleted = await _audit.PurgeExpiredAsync(GeneralDays, FinancialDays, FinancialTypes, maxRowsPerRun: 1000, CancellationToken.None); + + Assert.Equal(2, deleted); + var survivors = await _db.Set().AsNoTracking().Select(a => a.EntityId).ToListAsync(); + Assert.Equal(2, survivors.Count); + Assert.Contains("SomeEntity-700", survivors); + Assert.Contains("Refund-800", survivors); + } + + [Fact] + public async Task Purge_is_idempotent_when_nothing_is_expired() + { + Seed("SomeEntity", ageDays: 10); + Seed("Refund", ageDays: 100); + + var first = await _audit.PurgeExpiredAsync(GeneralDays, FinancialDays, FinancialTypes, maxRowsPerRun: 1000, CancellationToken.None); + var second = await _audit.PurgeExpiredAsync(GeneralDays, FinancialDays, FinancialTypes, maxRowsPerRun: 1000, CancellationToken.None); + + Assert.Equal(0, first); + Assert.Equal(0, second); + Assert.Equal(2, await _db.Set().CountAsync()); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } +} diff --git a/server/src/Tests/Baya.Test.Foundation/Messaging/TicketMessageEncryptionTests.cs b/server/src/Tests/Baya.Test.Foundation/Messaging/TicketMessageEncryptionTests.cs new file mode 100644 index 0000000..087cea2 --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/Messaging/TicketMessageEncryptionTests.cs @@ -0,0 +1,83 @@ +using Baya.Domain.Entities.Messaging; +using Baya.Domain.Entities.User; +using Baya.Infrastructure.Persistence; +using Baya.Tests.Setup.Setups; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace Baya.Test.Foundation.Messaging; + +/// +/// refinement-phase-9 §9.5: ticket message bodies (the refund/dispute paper trail — phone numbers, addresses, +/// clinical detail) are encrypted at rest through the IFieldEncryptor seam. The stored column holds +/// ciphertext, never the plaintext; a normal read still returns the plaintext (EF applies the converter). +/// +public sealed class TicketMessageEncryptionTests : IDisposable +{ + private static readonly DateTimeOffset Now = new(2026, 8, 10, 10, 0, 0, TimeSpan.Zero); + + private readonly SqliteConnection _connection; + private readonly ApplicationDbContext _db; + + public TicketMessageEncryptionTests() + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + var options = new DbContextOptionsBuilder().UseSqlite(_connection).Options; + _db = new ApplicationDbContext(options, TestFieldEncryptor.Instance); + _db.Database.EnsureCreated(); + } + + private long SeedMessage(string body) + { + var sender = new User { UserName = "u1", PhoneNumber = "09120000001", Gender = "male", Name = "علی", FamilyName = "رضایی", IsActive = true }; + _db.Users.Add(sender); + _db.SaveChanges(); + + var ticket = new Ticket { ReferenceCode = "TKT-ENC001", Category = TicketCategory.Support, OpenedById = sender.Id }; + _db.Set().Add(ticket); + _db.SaveChanges(); + + var message = new TicketMessage { TicketId = ticket.Id, SenderId = sender.Id, Body = body, IsInternal = false, SentAt = Now }; + _db.Set().Add(message); + _db.SaveChanges(); + return message.Id; + } + + [Fact] + public async Task Body_is_stored_encrypted_at_rest() + { + const string plaintext = "patient at 09121234567, unit 4B — chest pain"; + var id = SeedMessage(plaintext); + + await using var command = _connection.CreateCommand(); + command.CommandText = "SELECT Body FROM TicketMessages WHERE Id = $id"; + command.Parameters.AddWithValue("$id", id); + var stored = (string?)await command.ExecuteScalarAsync(); + + Assert.NotNull(stored); + Assert.NotEqual(plaintext, stored); + Assert.Equal(TestFieldEncryptor.Instance.Encrypt(plaintext), stored); + Assert.DoesNotContain("09121234567", stored); + } + + [Fact] + public async Task Body_round_trips_to_plaintext_on_read() + { + const string plaintext = "please confirm the visit address"; + var id = SeedMessage(plaintext); + + // Fresh context so the value comes from the store, not the change tracker. + var options = new DbContextOptionsBuilder().UseSqlite(_connection).Options; + await using var freshDb = new ApplicationDbContext(options, TestFieldEncryptor.Instance); + var body = await freshDb.Set().Where(m => m.Id == id).Select(m => m.Body).SingleAsync(); + + Assert.Equal(plaintext, body); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } +}