refinement phase 9

This commit is contained in:
hamid
2026-07-13 22:52:57 +03:30
parent ef3024ef2f
commit 70fb0a9202
32 changed files with 6945 additions and 113 deletions
+5 -5
View File
@@ -50,7 +50,7 @@
"close": "بستن", "close": "بستن",
"optional": "اختیاری", "optional": "اختیاری",
"currency_toman": "تومان", "currency_toman": "تومان",
"brand": "بلینیار", "brand": الین یار",
"brand_tagline": "مراقبت مطمئن در خانه" "brand_tagline": "مراقبت مطمئن در خانه"
}, },
"shell": { "shell": {
@@ -622,7 +622,7 @@
"issuer_platform": "بالین‌یار" "issuer_platform": "بالین‌یار"
}, },
"auth": { "auth": {
"customer_title": "ورود به بلینیار", "customer_title": "ورود به بالین یار",
"customer_subtitle": "با شماره موبایل خود وارد شوید", "customer_subtitle": "با شماره موبایل خود وارد شوید",
"nurse_title": "ورود پرستاران", "nurse_title": "ورود پرستاران",
"nurse_subtitle": "ویژه پرستاران دارای پروانه نظام پرستاری", "nurse_subtitle": "ویژه پرستاران دارای پروانه نظام پرستاری",
@@ -642,7 +642,7 @@
"resend": "ارسال مجدد کد", "resend": "ارسال مجدد کد",
"change_number": "تغییر شماره", "change_number": "تغییر شماره",
"routing_title": "در حال ورود…", "routing_title": "در حال ورود…",
"select_role_title": "به بلینیار خوش آمدید", "select_role_title": "به بالین یار خوش آمدید",
"select_role_subtitle": "برای شروع، نقش خود را انتخاب کنید", "select_role_subtitle": "برای شروع، نقش خود را انتخاب کنید",
"role_customer": "خانواده", "role_customer": "خانواده",
"role_customer_desc": "برای رزرو پرستار و مراقبت در منزل", "role_customer_desc": "برای رزرو پرستار و مراقبت در منزل",
@@ -651,7 +651,7 @@
"continue": "ادامه", "continue": "ادامه",
"guard_denied": "شما به این بخش دسترسی ندارید.", "guard_denied": "شما به این بخش دسترسی ندارید.",
"account_error_title": "حساب شما بارگذاری نشد", "account_error_title": "حساب شما بارگذاری نشد",
"account_error_body": "در ارتباط با بلینیار برای بارگذاری حساب شما مشکلی پیش آمد. اتصال خود را بررسی کنید و دوباره تلاش کنید.", "account_error_body": "در ارتباط با بالین یار برای بارگذاری حساب شما مشکلی پیش آمد. اتصال خود را بررسی کنید و دوباره تلاش کنید.",
"account_error_retry": "تلاش مجدد" "account_error_retry": "تلاش مجدد"
}, },
"verification": { "verification": {
@@ -660,7 +660,7 @@
"retry": "تلاش مجدد", "retry": "تلاش مجدد",
"load_error": "بارگذاری وضعیت احراز هویت ممکن نشد.", "load_error": "بارگذاری وضعیت احراز هویت ممکن نشد.",
"start_title": "احراز هویت را آغاز کنید", "start_title": "احراز هویت را آغاز کنید",
"start_body": "اعتماد، بنیان بلینیار است. با تکمیل این مراحل، خانواده‌ها با اطمینان شما را انتخاب می‌کنند.", "start_body": "اعتماد، بنیان بالین یار است. با تکمیل این مراحل، خانواده‌ها با اطمینان شما را انتخاب می‌کنند.",
"start_cta": "شروع احراز هویت", "start_cta": "شروع احراز هویت",
"starting": "در حال آماده‌سازی…", "starting": "در حال آماده‌سازی…",
"progress_title": "پیشرفت احراز هویت", "progress_title": "پیشرفت احراز هویت",
+28 -13
View File
@@ -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** > 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) > (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. > 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 ## 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** | — | | 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) | — | | 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** | — | | 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 | | 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 | | 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 | | 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. Both DBs fit one instance; `Baya_Logs` can move later.
- **Config:** `ConnectionStrings:SqlServer`, `ConnectionStrings:logDb`**rotate + externalize first** - **Config:** `ConnectionStrings:SqlServer`, `ConnectionStrings:logDb`**rotate + externalize first**
(plan §1.1; live `sa` credentials are committed today). (plan §1.1; live `sa` credentials are committed today).
- **Health/readiness:** the app's only health check (`/HealthCheck`, - **Health/readiness (refinement-phase-9 — §7.2 landed):** split `/healthz/live` (process) vs `/healthz/ready`
`Monitoring/Configurations/HealthCheckConfigurations.cs:17`); `logDb` has none (plan §7.2). Boot runs (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 `MigrateAsync` + 3 seeders (`Program.cs:99-104`) → the login needs DDL rights and concurrent multi-node
boot races (plan §4.3). 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 - **Default:** caddy 2 / nginx 1.27; terminate TLS, h2 to clients, HTTP/1.1 (or h2c) upstream once §1.5
lands. lands.
### 4 · Prometheus (+ Grafana) ### 4 · Prometheus (+ Grafana) · optional OTLP collector
- **Evidence:** `/metrics` via prometheus-net `UseMetricServer` + OTel `AddPrometheusExporter` (two stacks — - **Evidence (refinement-phase-9 — §7.1 landed):** **one** OpenTelemetry stack — metrics scraped at `/metrics` via
consolidate, plan §7.1) at `Monitoring/Configurations/PrometheusMetricsConfigurations.cs:11` and `UseOpenTelemetryPrometheusScrapingEndpoint()` (`Monitoring/Configurations/PrometheusMetricsConfigurations.cs`),
`OpenTelemetryConfigurations.cs:21`; health forwarded (`HealthCheckConfigurations.cs:18`). plus `WithTracing` (ASP.NET Core + EF Core) exporting **OTLP only when `OpenTelemetry:Otlp:Endpoint` is set**
- **Default:** `prom/prometheus:v2.53` + `grafana/grafana:11`. No tracing backend exists yet (metrics-only); (`OpenTelemetryConfigurations.cs`). The duplicate prometheus-net stack (`UseMetricServer`/`UseHttpMetrics`/
an OTLP collector becomes relevant with plan §7.1. `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 ### 5 · Redis
@@ -278,14 +291,16 @@ flowchart LR
2. **Environment files:** `appsettings.json``appsettings.Development.json` (byte-identical); **no 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 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. 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 3. **HTTP posture:** mixed `Http1AndHttp2` Kestrel (refinement-phase-5), so gRPC shares the listener via ALPN;
(plan §7.5), TLS required for JWE sanity. 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 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. 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 §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 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. *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 5. **Logs (refinement-phase-9 — §7.3 landed):** deployed envs write **Information+** to `Baya_Logs` (framework
console + `logs/log.json`. 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; 6. **Client:** the Next.js app needs `NEXT_PUBLIC_API_URL` pointing at the proxy; wire casing camelCase;
snake_case routes. snake_case routes.
@@ -12,6 +12,25 @@ One block per completed backend phase. Newest at the top. Backend lane writes he
- **Notes for frontend:** <anything load-bearing> - **Notes for frontend:** <anything load-bearing>
--> -->
## 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.79.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 ## refinement-phase-7 — Unattended ops: scheduler, locking & multi-instance readiness — 2026-07-13
- **Shipped:** one in-process **`RecurringJobSchedulerHostedService`** + the **`IRecurringJob`** seam - **Shipped:** one in-process **`RecurringJobSchedulerHostedService`** + the **`IRecurringJob`** seam
(`Persistence/Services/Scheduling/`) replacing the two `PeriodicTimer` hosted services and scheduling the (`Persistence/Services/Scheduling/`) replacing the two `PeriodicTimer` hosted services and scheduling the
@@ -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.
@@ -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 - **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; `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 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). wordmark/product-spelling divergence worth a product decision).
- **Dark scheme:** EscrowNotice text/border use `--bal-primary` (the info token is an alert *background* - **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 and is illegible as dark-mode text); the print button temporarily flips `data-mui-color-scheme` to
@@ -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 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. 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 > **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 > 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 > 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 | 🟡 | | `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 | 🟡 | | `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) | | `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` | 🟡 | | `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 | 🟡 | | `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 | 🟡 | | `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** | 🟡 | | `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` | 🟡 | | `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 | 🟡 | | `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` | 🟡 | | `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 | 🟡 | | `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 | 🟡 | | `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 → **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) |
| `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 | 🟡 |
| `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 🟡 | | `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 | 🟡 | | `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) | 🟡 | | `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) | 🟡 |
@@ -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 2426).
### 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.
+28 -5
View File
@@ -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) - **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 - **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 - **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 - **xUnit** + **NSubstitute** for tests
- All NuGet versions are centrally pinned in `Directory.Packages.props` - 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.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.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.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/ ├── 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.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) │ ├── 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`: 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` `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`) · (`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<IRecurringJob, …>()`** in `AddPersistenceServices`. - **Add a cron = implement `IRecurringJob` + one `AddSingleton<IRecurringJob, …>()`** in `AddPersistenceServices`.
Phase 8 registers the Moadian reconciliation + refund-settlement poll exactly this way. The scheduler owns the 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 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. `AdminPayoutsController` neutralizes any request-supplied value.
- **Admin manual triggers remain overrides** (the same idempotent commands). The scheduler is **dormant under the - **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. `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 **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 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 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) AddApplicationServices() // Mediator + pipeline behaviors (Logging → Metrics → Validate)
RegisterIdentityServices(…, requireHttpsMetadata) // Identity, JWT/JWE (RequireHttpsMetadata on outside Dev/Testing), ICurrentUser RegisterIdentityServices(…, requireHttpsMetadata) // Identity, JWT/JWE (RequireHttpsMetadata on outside Dev/Testing), ICurrentUser
AddPersistenceServices(...) // DbContext (+ AuditFieldInterceptor), UnitOfWork, repositories, the IRecurringJob crons + RecurringJobSchedulerHostedService (refinement-phase-7) 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 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) AddRateLimitingPolicies() // built-in rate limiter: per-resolved-IP global + named (otp/auth/sensitive/webhook)
AddSwagger("v1", "v1.1") · RegisterValidatorsAsServices() · AddMapster() 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 // 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. // OTP in-memory for the GET /api/v1/dev/last_otp/{phone} helper — never wired outside Development.
``` ```
+3 -4
View File
@@ -36,19 +36,18 @@
<PackageVersion Include="NSubstitute" Version="5.3.0" /> <PackageVersion Include="NSubstitute" Version="5.3.0" />
<PackageVersion Include="NSwag.AspNetCore" Version="14.7.1" /> <PackageVersion Include="NSwag.AspNetCore" Version="14.7.1" />
<PackageVersion Include="NuGet.Packaging" Version="7.6.0" /> <PackageVersion Include="NuGet.Packaging" Version="7.6.0" />
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.3" />
<PackageVersion Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.9.0-beta.1" /> <PackageVersion Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.9.0-beta.1" />
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.16.0" /> <PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.16.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.2" /> <PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.2" />
<PackageVersion Include="OpenTelemetry.Instrumentation.EntityFrameworkCore" Version="1.15.1-beta.1" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" /> <PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" />
<PackageVersion Include="Pluralize.NET" Version="1.0.2" /> <PackageVersion Include="Pluralize.NET" Version="1.0.2" />
<PackageVersion Include="prometheus-net" Version="8.2.1" />
<PackageVersion Include="prometheus-net.AspNetCore" Version="8.2.1" />
<PackageVersion Include="prometheus-net.AspNetCore.HealthChecks" Version="8.2.1" />
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" /> <PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageVersion Include="Serilog.Enrichers.Span" Version="3.1.0" /> <PackageVersion Include="Serilog.Enrichers.Span" Version="3.1.0" />
<PackageVersion Include="Serilog.Exceptions" Version="8.4.0" /> <PackageVersion Include="Serilog.Exceptions" Version="8.4.0" />
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" /> <PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageVersion Include="Serilog.Sinks.Elasticsearch" Version="10.0.0" /> <PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageVersion Include="Serilog.Sinks.MSSqlServer" Version="10.0.0" /> <PackageVersion Include="Serilog.Sinks.MSSqlServer" Version="10.0.0" />
<PackageVersion Include="Serilog.Sinks.PeriodicBatching" Version="5.0.0" /> <PackageVersion Include="Serilog.Sinks.PeriodicBatching" Version="5.0.0" />
<PackageVersion Include="System.Linq.Async" Version="7.0.1" /> <PackageVersion Include="System.Linq.Async" Version="7.0.1" />
+1 -1
View File
@@ -110,7 +110,7 @@ TypeAdapterConfig.GlobalSettings.Scan(typeof(UserCreateCommand).Assembly,
#region Plugin Services Configuration #region Plugin Services Configuration
builder.Services.ConfigureGrpcPluginServices(); builder.Services.ConfigureGrpcPluginServices(builder.Environment);
#endregion #endregion
@@ -1,27 +1,36 @@
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Baya.Web.Plugins.Grpc.Services; using Baya.Web.Plugins.Grpc.Services;
namespace Baya.Web.Plugins.Grpc; namespace Baya.Web.Plugins.Grpc;
/// <summary>
/// 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. <b>gRPC reflection is enabled only in Development</b> (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 <c>Http1AndHttp2</c>), so ALPN negotiates
/// HTTP/2 for gRPC clients while the REST API keeps HTTP/1.1 — no dedicated port is required.
/// </summary>
public static class GrpcPluginStartup public static class GrpcPluginStartup
{ {
public static IServiceCollection ConfigureGrpcPluginServices(this IServiceCollection services) public static IServiceCollection ConfigureGrpcPluginServices(this IServiceCollection services, IHostEnvironment environment)
{ {
services.AddGrpc(); services.AddGrpc();
services.AddGrpcReflection();
if (environment.IsDevelopment())
services.AddGrpcReflection();
return services; return services;
} }
public static void ConfigureGrpcPipeline(this WebApplication app) public static void ConfigureGrpcPipeline(this WebApplication app)
{ {
app.MapGrpcService<UserGrpcServices>(); app.MapGrpcService<UserGrpcServices>();
app.MapGrpcReflectionService();
if (app.Environment.IsDevelopment())
app.MapGrpcReflectionService();
app.MapGet("/GrpcUser", async context => app.MapGet("/GrpcUser", async context =>
{ {
@@ -28,4 +28,17 @@ public interface IAuditLogger
int page, int page,
int pageSize, int pageSize,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
/// <summary>
/// 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 <paramref name="maxRowsPerRun"/> so a large backlog drains across runs without
/// loading the whole table. Returns the number of rows deleted.
/// </summary>
ValueTask<int> PurgeExpiredAsync(
int generalRetentionDays,
int financialRetentionDays,
IReadOnlyCollection<string> financialEntityTypes,
int maxRowsPerRun,
CancellationToken cancellationToken = default);
} }
@@ -7,10 +7,10 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" /> <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
<PackageReference Include="Serilog.Sinks.Console" /> <PackageReference Include="Serilog.Sinks.Console" />
<PackageReference Include="Serilog.Sinks.File" />
<PackageReference Include="Serilog.Sinks.MSSqlServer" /> <PackageReference Include="Serilog.Sinks.MSSqlServer" />
<PackageReference Include="Serilog.Sinks.PeriodicBatching" /> <PackageReference Include="Serilog.Sinks.PeriodicBatching" />
<PackageReference Include="Serilog.Exceptions" /> <PackageReference Include="Serilog.Exceptions" />
<PackageReference Include="Serilog.Sinks.Elasticsearch" />
<PackageReference Include="Serilog.Enrichers.Span" /> <PackageReference Include="Serilog.Enrichers.Span" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -1,8 +1,9 @@
using System.Data; using System.Data;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Serilog; using Serilog;
using Serilog.Enrichers.Span; using Serilog.Enrichers.Span;
using Serilog.Events;
using Serilog.Exceptions; using Serilog.Exceptions;
using Serilog.Formatting.Json; using Serilog.Formatting.Json;
using Serilog.Sinks.MSSqlServer; using Serilog.Sinks.MSSqlServer;
@@ -13,22 +14,29 @@ public static class LoggingConfiguration
{ {
public static Action<HostBuilderContext, LoggerConfiguration> ConfigureLogger => (context, configuration) => public static Action<HostBuilderContext, LoggerConfiguration> ConfigureLogger => (context, configuration) =>
{ {
#region Enriching Logger Context #region Level & enrichment
var env = context.HostingEnvironment; var env = context.HostingEnvironment;
// refinement-phase-9 §9.3: deployed environments log Information+ (previously Warning+, which silently
configuration.Enrich.FromLogContext() // 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("ApplicationName", env.ApplicationName)
.Enrich.WithProperty("Environment", env.EnvironmentName) .Enrich.WithProperty("Environment", env.EnvironmentName)
.Enrich.WithSpan() .Enrich.WithSpan()
.Enrich.WithExceptionDetails(); .Enrich.WithExceptionDetails();
#endregion #endregion
var columnOpts = new ColumnOptions(); var columnOpts = new ColumnOptions();
columnOpts.Store.Remove(StandardColumn.Properties); columnOpts.Store.Remove(StandardColumn.Properties);
columnOpts.Store.Add(StandardColumn.LogEvent); 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. // Development and Testing (WebApplicationFactory) log locally; the SQL sink is for deployed envs.
if (!context.HostingEnvironment.IsDevelopment() && !context.HostingEnvironment.IsEnvironment("Testing")) 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 configuration.WriteTo
.MSSqlServer( .MSSqlServer(
connectionString: context.Configuration.GetConnectionString("logDb"), connectionString: context.Configuration.GetConnectionString("logDb"),
sinkOptions: new MSSqlServerSinkOptions { TableName = "LogEvents", AutoCreateSqlTable = true, SchemaName = "log",AutoCreateSqlDatabase = true}) sinkOptions: new MSSqlServerSinkOptions { TableName = "LogEvents", AutoCreateSqlTable = true, SchemaName = "log", AutoCreateSqlDatabase = true },
.MinimumLevel.Warning(); columnOptions: columnOpts);
} }
else
else{ {
configuration.WriteTo.Console().MinimumLevel.Information(); configuration.WriteTo.Console();
configuration.WriteTo.File(new JsonFormatter(), "logs/log.json").MinimumLevel.Information(); configuration.WriteTo.File(new JsonFormatter(), "logs/log.json");
} }
#region ElasticSearch Configuration. UnComment if Needed
//var elasticUrl = context.Configuration.GetValue<string>("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
}; };
} }
@@ -4,22 +4,24 @@ using Microsoft.Extensions.Logging;
namespace Baya.Infrastructure.CrossCutting.Seams; namespace Baya.Infrastructure.CrossCutting.Seams;
/// <summary> /// <summary>
/// Mock <see cref="ISmsSender"/>: "delivers" by logging. The OTP code is written to the log so a /// Mock <see cref="ISmsSender"/>: "delivers" by logging. The OTP <b>code is never logged</b> (refinement-phase-9
/// developer can complete the login flow; the phone number is never logged in full (PII policy) — /// §9.3 — no secrets/PII in logs, in any environment): a developer retrieves it from the Development-only
/// only its last four digits. The real implementation swaps to an Iranian SMS gateway /// <c>GET /api/v1/dev/last_otp/{phone}</c> helper (the <c>DevCapturingSmsSender</c> decorator), never the log.
/// (Kavenegar/Ghasedak/SMS.ir) behind the same interface via a registration change. /// 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.
/// </summary> /// </summary>
public sealed class LoggingSmsSender(ILogger<LoggingSmsSender> logger) : ISmsSender public sealed class LoggingSmsSender(ILogger<LoggingSmsSender> logger) : ISmsSender
{ {
public Task SendOtpAsync(string phone, string code, CancellationToken cancellationToken = default) 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; return Task.CompletedTask;
} }
public Task SendAsync(string phone, string message, CancellationToken cancellationToken = default) 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; return Task.CompletedTask;
} }
@@ -10,13 +10,17 @@
<PackageReference Include="AspNetCore.HealthChecks.SqlServer" /> <PackageReference Include="AspNetCore.HealthChecks.SqlServer" />
<PackageReference Include="AspNetCore.HealthChecks.UI.Client" /> <PackageReference Include="AspNetCore.HealthChecks.UI.Client" />
<PackageReference Include="AspNetCore.HealthChecks.UI.InMemory.Storage" /> <PackageReference Include="AspNetCore.HealthChecks.UI.InMemory.Storage" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
<PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" /> <PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" /> <PackageReference Include="OpenTelemetry.Extensions.Hosting" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" /> <PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" />
<PackageReference Include="OpenTelemetry.Instrumentation.EntityFrameworkCore" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" /> <PackageReference Include="OpenTelemetry.Instrumentation.Runtime" />
<PackageReference Include="prometheus-net" /> </ItemGroup>
<PackageReference Include="prometheus-net.AspNetCore" />
<PackageReference Include="prometheus-net.AspNetCore.HealthChecks" /> <ItemGroup>
<!-- The object-storage readiness probe resolves the IObjectStorage seam (Application contract). -->
<ProjectReference Include="..\..\Core\Baya.Application\Baya.Application.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -1,37 +1,79 @@
using System.Net.Security; using Baya.Infrastructure.Monitoring.HealthChecks;
using HealthChecks.UI.Client; using HealthChecks.UI.Client;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Prometheus;
namespace Baya.Infrastructure.Monitoring.Configurations; namespace Baya.Infrastructure.Monitoring.Configurations;
/// <summary>
/// Health checks split into <b>liveness</b> (is the process up?) and <b>readiness</b> (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.
/// <list type="bullet">
/// <item><c>/healthz/live</c> — process only (always healthy while the host runs); never touches a dependency.</item>
/// <item><c>/healthz/ready</c> — the app DB, the log DB (deployed only), and the object-storage write probe.</item>
/// <item><c>/HealthCheck</c> — the aggregate (every check), retained for backward compatibility.</item>
/// </list>
/// </summary>
public static class HealthCheckConfigurations public static class HealthCheckConfigurations
{ {
private const string Ready = "ready";
private const string Live = "live";
public static WebApplicationBuilder ConfigureHealthChecks(this WebApplicationBuilder builder) public static WebApplicationBuilder ConfigureHealthChecks(this WebApplicationBuilder builder)
{ {
builder.Services.AddHealthChecks() var checks = builder.Services.AddHealthChecks();
.AddSqlServer(builder.Configuration.GetConnectionString("SqlServer")!, name: "SQL Server")
.ForwardToPrometheus();
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<ObjectStorageWriteHealthCheck>("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; return builder;
} }
public static WebApplication UseHealthChecks(this WebApplication app) 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 app.MapHealthChecks("/HealthCheck", new HealthCheckOptions
{ {
Predicate = _ => true, Predicate = _ => true,
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
}).ShortCircuit().DisableHttpMetrics(); }).ShortCircuit();
return app; return app;
} }
@@ -1,14 +1,35 @@
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using OpenTelemetry.Metrics; using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
namespace Baya.Infrastructure.Monitoring.Configurations; namespace Baya.Infrastructure.Monitoring.Configurations;
/// <summary>
/// The single OpenTelemetry stack (refinement-phase-9, §9.1). Metrics and traces share one resource
/// (<c>service.name = Baya.Web.Api</c>). Metrics are scraped by Prometheus (the <c>/metrics</c> endpoint,
/// wired in <see cref="PrometheusMetricsConfigurations"/>); 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 <c>ApiResult.RequestId</c> the client sees, so a support ticket maps 1:1 to a
/// trace.
/// <para>The prior duplicate prometheus-net stack was removed — OpenTelemetry is now the only metrics source.</para>
/// <para>OTLP export (traces + metrics) is <b>opt-in</b>: it is wired only when <c>OpenTelemetry:Otlp:Endpoint</c>
/// is configured, so an MVP deployment with Prometheus alone runs unchanged and no exporter spams connection
/// errors against an absent collector.</para>
/// </summary>
public static class OpenTelemetryConfigurations public static class OpenTelemetryConfigurations
{ {
public const string ServiceName = "Baya.Web.Api";
public static WebApplicationBuilder SetupOpenTelemetry(this WebApplicationBuilder builder) public static WebApplicationBuilder SetupOpenTelemetry(this WebApplicationBuilder builder)
{ {
var otlpEndpoint = builder.Configuration["OpenTelemetry:Otlp:Endpoint"];
var hasOtlp = !string.IsNullOrWhiteSpace(otlpEndpoint);
builder.Services.AddOpenTelemetry() builder.Services.AddOpenTelemetry()
.ConfigureResource(resource => resource.AddService(ServiceName))
.WithMetrics(metricsBuilder => .WithMetrics(metricsBuilder =>
{ {
metricsBuilder.AddRuntimeInstrumentation() metricsBuilder.AddRuntimeInstrumentation()
@@ -17,8 +38,22 @@ public static class OpenTelemetryConfigurations
, "Microsoft.AspNetCore.Server.Kestrel" , "Microsoft.AspNetCore.Server.Kestrel"
, "System.Net.Http" , "System.Net.Http"
, "Baya.Web.Api" , "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(); .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(); builder.Services.AddMetrics();
@@ -1,15 +1,17 @@
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Prometheus;
namespace Baya.Infrastructure.Monitoring.Configurations; namespace Baya.Infrastructure.Monitoring.Configurations;
public static class PrometheusMetricsConfigurations public static class PrometheusMetricsConfigurations
{ {
/// <summary>
/// Exposes the single OpenTelemetry metrics stack for Prometheus scraping at <c>/metrics</c>
/// (refinement-phase-9, §9.1 — replaces the removed prometheus-net <c>UseMetricServer</c>/
/// <c>UseHttpMetrics</c> stack; HTTP request metrics now come from the OTel ASP.NET Core instrumentation).
/// </summary>
public static WebApplication UseMetrics(this WebApplication app) public static WebApplication UseMetrics(this WebApplication app)
{ {
app.UseOpenTelemetryPrometheusScrapingEndpoint();
app.UseMetricServer();
app.UseHttpMetrics();
return app; return app;
} }
} }
@@ -0,0 +1,41 @@
#nullable enable
using System.Text;
using Baya.Application.Contracts.Common;
using Microsoft.Extensions.Diagnostics.HealthChecks;
namespace Baya.Infrastructure.Monitoring.HealthChecks;
/// <summary>
/// Readiness probe for the <see cref="IObjectStorage"/> 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 <see cref="HealthStatus.Unhealthy"/> rather than a first-user 500.
/// The probe key is namespaced and deleted every run, so it never accumulates.
/// </summary>
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<HealthCheckResult> 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);
}
}
}
@@ -179,5 +179,15 @@ public class ApplicationDbContext: IdentityDbContext<User, Role, int, UserClaim,
{ {
builder.Property(c => c.SettlementIban).HasConversion(encrypted); builder.Property(c => 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<Baya.Domain.Entities.Messaging.TicketMessage>(builder =>
{
builder.Property(m => m.Body).HasConversion(encrypted);
});
} }
} }
@@ -52,6 +52,11 @@ internal sealed class PlatformConfigConfig : IEntityTypeConfiguration<PlatformCo
(21, "refund_assume_nurse_paid", "false", ConfigDataType.Bool, "Ops/testing override that forces the post-payout clawback path for refunds (b11); b13 replaces the derivation."), (21, "refund_assume_nurse_paid", "false", ConfigDataType.Bool, "Ops/testing override that forces the post-payout clawback path for refunds (b11); b13 replaces the derivation."),
(22, "payout_satna_threshold_irr", "1000000000", ConfigDataType.Decimal, "IRR net-amount threshold above which a payout is routed via SATNA (real-time) instead of PAYA (batch) (b13)."), (22, "payout_satna_threshold_irr", "1000000000", ConfigDataType.Decimal, "IRR net-amount threshold above which a payout is routed via SATNA (real-time) instead of PAYA (batch) (b13)."),
(23, "require_bnpl_settlement_for_payout", "false", ConfigDataType.Bool, "When on, a BNPL-paid booking is payout-eligible only after its provider settlement is received (b13; default off — the DEFERRED settled_at guard)."), (23, "require_bnpl_settlement_for_payout", "false", ConfigDataType.Bool, "When on, a BNPL-paid booking is payout-eligible only after its provider settlement is received (b13; default off — the DEFERRED settled_at guard)."),
// refinement-phase-9 §9.4: two-tier audit-log retention. Financial/verification rows keep a long legal
// window; everyday rows a shorter one; the retention sweep runs on the cadence key.
(24, "audit_retention_general_days", "730", ConfigDataType.Int, "Retention (days) for everyday audit rows before the retention sweep deletes them (~2 years)."),
(25, "audit_retention_financial_days", "2555", ConfigDataType.Int, "Retention (days) for financial/verification audit rows (refunds/clawbacks/payouts/verification/config/partner-center) — the long legal window (~7 years)."),
(26, "audit_retention_scan_cadence_hours", "24", ConfigDataType.Int, "Hours between audit-log retention sweeps."),
]; ];
return rows return rows
@@ -15,7 +15,10 @@ internal sealed class TicketMessageConfig : IEntityTypeConfiguration<TicketMessa
{ {
builder.ToTable("TicketMessages", "messaging"); builder.ToTable("TicketMessages", "messaging");
builder.Property(m => 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.IsInternal).HasDefaultValue(false);
builder.Property(m => m.ClientMessageId).HasMaxLength(100); builder.Property(m => m.ClientMessageId).HasMaxLength(100);
@@ -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
{
/// <inheritdoc />
public partial class RefinementPhase9TicketBodyEncryptionAndAuditRetention : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
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" }
});
}
/// <inheritdoc />
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<string>(
name: "Body",
schema: "messaging",
table: "TicketMessages",
type: "nvarchar(4000)",
maxLength: 4000,
nullable: false,
oldClrType: typeof(string),
oldType: "nvarchar(max)");
}
}
}
@@ -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).", 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", Key = "require_bnpl_settlement_for_payout",
Value = "false" 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<string>("Body") b.Property<string>("Body")
.IsRequired() .IsRequired()
.HasMaxLength(4000) .HasColumnType("nvarchar(max)");
.HasColumnType("nvarchar(4000)");
b.Property<string>("ClientMessageId") b.Property<string>("ClientMessageId")
.HasMaxLength(100) .HasMaxLength(100)
@@ -72,6 +72,8 @@ public static class ServiceCollectionExtensions
services.AddSingleton<IRecurringJob, WeeklyPayoutGenerationJob>(); services.AddSingleton<IRecurringJob, WeeklyPayoutGenerationJob>();
// refinement-phase-8 (6.5): walk pending/submitted سامانه مودیان invoices toward their registered reference. // refinement-phase-8 (6.5): walk pending/submitted سامانه مودیان invoices toward their registered reference.
services.AddSingleton<IRecurringJob, MoadianReconciliationJob>(); services.AddSingleton<IRecurringJob, MoadianReconciliationJob>();
// refinement-phase-9 (§9.4): two-tier retention sweep over the append-only audit_logs table.
services.AddSingleton<IRecurringJob, AuditLogRetentionJob>();
services.AddHostedService<RecurringJobSchedulerHostedService>(); services.AddHostedService<RecurringJobSchedulerHostedService>();
// Search (backend-phase-7). The index maintainer keeps nurse_search_index consistent inline inside // Search (backend-phase-7). The index maintainer keeps nurse_search_index consistent inline inside
@@ -77,4 +77,37 @@ internal sealed class AuditLogger(
return new PagedResult<AuditLogDto>(items, total, page, pageSize); return new PagedResult<AuditLogDto>(items, total, page, pageSize);
} }
public async ValueTask<int> PurgeExpiredAsync(
int generalRetentionDays,
int financialRetentionDays,
IReadOnlyCollection<string> 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<AuditLog>().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<AuditLog>()
.Where(a => expiredIds.Contains(a.Id))
.ExecuteDeleteAsync(cancellationToken);
}
} }
@@ -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;
/// <summary>
/// Retention/archival policy for the append-only <c>ops.AuditLogs</c> 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: <b>financial &amp; verification</b> entity types (refunds, clawbacks, payouts,
/// payout batches, verification decisions, config changes, partner centers) keep a long window
/// (<c>audit_retention_financial_days</c>, default ~7 years); everyday operational rows keep a shorter one
/// (<c>audit_retention_general_days</c>, default ~2 years). Idempotent: a re-run simply finds nothing new to
/// delete. Runs on the <c>audit_retention_scan_cadence_hours</c> cadence.
/// </summary>
internal sealed class AuditLogRetentionJob(ILogger<AuditLogRetentionJob> 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;
/// <summary>
/// The <c>IAuditable</c> entity types whose audit rows carry money- or verification-legal weight. Their
/// <c>EntityType</c> is the CLR type name written by <c>AuditFieldInterceptor</c>. Everything else uses the
/// shorter general window.
/// </summary>
private static readonly IReadOnlyCollection<string> FinancialEntityTypes =
[
"Refund",
"NurseClawback",
"NursePayout",
"NursePayoutBatch",
"NurseVerification",
"PlatformConfig",
"PartnerCenter"
];
public string Name => "audit_log_retention";
public async ValueTask<TimeSpan> GetIntervalAsync(IServiceProvider services, CancellationToken cancellationToken)
{
var config = services.GetRequiredService<IPlatformConfig>();
var hours = await config.GetConfig<int>("audit_retention_scan_cadence_hours", cancellationToken);
return TimeSpan.FromHours(hours);
}
public async ValueTask RunAsync(IServiceProvider services, CancellationToken cancellationToken)
{
var config = services.GetRequiredService<IPlatformConfig>();
var audit = services.GetRequiredService<IAuditLogger>();
var generalDays = await config.GetConfig<int>("audit_retention_general_days", cancellationToken);
var financialDays = await config.GetConfig<int>("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);
}
}
@@ -0,0 +1,21 @@
using System.Net;
namespace Baya.Test.Api;
/// <summary>
/// refinement-phase-9 §9.2: liveness is split from readiness. <c>/healthz/live</c> 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.
/// </summary>
public class HealthCheckApiTests(BayaApiFactory factory) : IClassFixture<BayaApiFactory>
{
[Fact]
public async Task Liveness_IsHealthy_WithoutTouchingDependencies()
{
var client = factory.CreateClient();
var response = await client.GetAsync("/healthz/live");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
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<ApplicationDbContext>().UseSqlite(_connection).Options;
_db = new ApplicationDbContext(options, TestFieldEncryptor.Instance);
_db.Database.EnsureCreated();
var clock = Substitute.For<IDateTimeProvider>();
clock.UtcNow.Returns(Now);
_audit = new AuditLogger(_db, Substitute.For<ICurrentUser>(), clock);
}
private void Seed(string entityType, int ageDays)
{
_db.Set<AuditLog>().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<AuditLog>().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<AuditLog>().CountAsync());
}
public void Dispose()
{
_db.Dispose();
_connection.Dispose();
}
}
@@ -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;
/// <summary>
/// refinement-phase-9 §9.5: ticket message bodies (the refund/dispute paper trail — phone numbers, addresses,
/// clinical detail) are encrypted at rest through the <c>IFieldEncryptor</c> seam. The stored column holds
/// ciphertext, never the plaintext; a normal read still returns the plaintext (EF applies the converter).
/// </summary>
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<ApplicationDbContext>().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<Ticket>().Add(ticket);
_db.SaveChanges();
var message = new TicketMessage { TicketId = ticket.Id, SenderId = sender.Id, Body = body, IsInternal = false, SentAt = Now };
_db.Set<TicketMessage>().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<ApplicationDbContext>().UseSqlite(_connection).Options;
await using var freshDb = new ApplicationDbContext(options, TestFieldEncryptor.Instance);
var body = await freshDb.Set<TicketMessage>().Where(m => m.Id == id).Select(m => m.Body).SingleAsync();
Assert.Equal(plaintext, body);
}
public void Dispose()
{
_db.Dispose();
_connection.Dispose();
}
}