Files
baya-monorepo/server/CLAUDE.md
T
2026-07-06 19:23:44 +03:30

422 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Balinyaar Server — Claude Code Guidelines
The backend API of **Balinyaar**, a trust-first home-nursing marketplace in Iran.
- **Coding rules** (the full rule set you must follow) → [CONVENTIONS.md](CONVENTIONS.md). Read it
before writing any server code.
- Repo-wide context and the frontend → root [CLAUDE.md](../CLAUDE.md).
- Product/domain rules (business logic, schema, payments, escrow, verification) → [`product/`](../product/).
Read the relevant doc before designing an entity, feature, or endpoint — don't infer business rules
from code.
---
## Role
You are a **senior .NET software engineer** working on this codebase. That means:
- You write production-quality code, not demo code. Every file you touch should look like it was
written by someone who has shipped .NET APIs at scale.
- You understand the architecture and work _with_ it, not around it. Clean Architecture boundaries
are non-negotiable.
- You think before you write. If a task is ambiguous, reason through the design first. If it touches a
contract other layers depend on, think about downstream impact.
- You prefer simplicity and clarity over cleverness. The next engineer (or agent) should read your
code without a guide.
- You never leave the codebase in a worse state than you found it.
---
## Stack
- **ASP.NET Core / .NET 10** (`net10.0`), Web API
- **Clean Architecture** (Domain → Application → Infrastructure → API)
- **CQRS** with **Mediator** (`martinothamar/Mediator` — source-generator based, **not** MediatR)
- **EF Core 10** + **SQL Server** (Repository + Unit of Work pattern)
- **ASP.NET Core Identity** with **JWE** (signed + AES-128-encrypted JWT), OTP, and dynamic permission authorization
- **Mapster** for mapping, **FluentValidation** for validation, **Serilog** for structured logging
- **OpenTelemetry** + **prometheus-net** for observability, **NSwag** for OpenAPI, **Asp.Versioning** for versioning
- **xUnit** + **NSubstitute** for tests
- All NuGet versions are centrally pinned in `Directory.Packages.props`
> Note: some prose elsewhere may say "MediatR" — the actual dispatcher is `martinothamar/Mediator`.
> Use `ISender`/`ICommand`/`IQuery` from that package, not MediatR types.
---
## Commands (run from `server/`)
| Task | Command |
| ----------------- | ------- |
| Restore | `dotnet restore Baya.sln` |
| Build | `dotnet build Baya.sln` |
| Run API | `dotnet run --project src/API/Baya.Web.Api/Baya.Web.Api.csproj` |
| Test | `dotnet test Baya.sln` |
| Add migration | `dotnet ef migrations add <Name> --project src/Infrastructure/Baya.Infrastructure.Persistence --startup-project src/API/Baya.Web.Api` |
| Update DB | `dotnet ef database update --project src/Infrastructure/Baya.Infrastructure.Persistence --startup-project src/API/Baya.Web.Api` |
**Default URL:** `https://localhost:5002` — Swagger at `/swagger`.
On boot, `Program.cs` calls `ApplyMigrationsAsync()` and `SeedDefaultUsersAsync()` — a reachable SQL
Server is required to start.
---
## Quality gates — run before declaring work done
1. `dotnet build Baya.sln` — zero new warnings introduced. Unused `using`s, locals, parameters,
private fields, or members count as failures — delete them, don't suppress them
([CONVENTIONS.md](CONVENTIONS.md) §2 "No unused code").
2. `dotnet test Baya.sln` — all tests pass.
3. Read your own diff as if reviewing a PR: would a senior engineer approve it without comment?
4. If the change alters the architecture, update the **Project map** below in the same change
(see "Keeping the Project map current").
---
## Project map
This tree is the **canonical description of the server's architecture** — the authoritative list of
projects/assemblies, Clean-Architecture layers, and cross-layer dependencies.
```
src/
├── Core/
│ ├── Baya.Domain Entities (User, Role, UserSession, RoleNames…, Identity/ (NurseProfile, CustomerProfile, Patient, NurseBankAccount, CustomerAddress), Geography/ (Province, City, District, NurseServiceArea), Catalog/ (ServiceCategory, ServiceOptionGroup, ServiceOptionValue, NurseServiceVariant, NurseServiceVariantOption, PriceUnits), Verification/ (NurseVerification, VerificationStepType, VerificationStep, VerificationDocument, NurseCredential + VerificationStatus/VerificationStepStatus enums), Search/ (NurseSearchIndex — the denormalized search projection), Booking/ (BookingRequest — the money-free pre-payment intent + BookingRequestStatus/BookingRequestTransitions forward-only status guard + CaregiverGender codes; b9 adds Booking/BookingSession/BookingCareInstruction/VisitVerification/CancellationPolicy + their status/transition tables + BookingAmounts money split), + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts), BaseEntity, IEntity, ITimeModification, IAuditableEntity, IAuditable (audit-row marker)
│ └── Baya.Application Features/ (Commands & Queries; Identity area = auth + profiles/patients/nurse-bank-accounts; Geography/ServiceAreas/Addresses areas = geo hierarchy + nurse service areas + customer addresses; Catalog/Variants areas = admin catalog skeleton + nurse pricing variants; Verification area = the b6 nurse-verification pipeline (submit/status/uploads/automated runs + admin review/suspend/scan + public trust badge); Search area = the b7 discovery query + admin index-rebuild; Booking area = the b8 booking-request lifecycle (create/accept/reject/cancel + role-scoped inbox/detail + the expiry sweep command); Bookings area = the b9 booking engine (convert/detail/list/transition, care-instructions submit+gated read, EVV check-in/out + today's sessions + admin EVV queue, cancel booking/session, no-show sweep, cancellation-policy CRUD); + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts areas), Contracts/ (incl. Contracts/Common cross-cutting seams incl. IBankAccountOwnershipVerifier + IGeocoder + IVariantSnapshotSerializer + IShahkarVerifier + IIdentityKycProvider + ICredentialVerifier + the platform-signal facade contracts + Contracts/Search (INurseSearch read seam + ISearchIndexMaintainer write seam) + Contracts/Persistence per-domain repositories on IUnitOfWork incl. IVerificationRepository), Models/, pipeline behaviors (Common/ — validators auto-registered from this assembly; VerificationAggregator + IdentityNameMatch helpers)
├── Infrastructure/
│ ├── 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), Repositories/ (incl. b9 BookingRepository + CancellationPolicyRepository), Migrations/, Interceptors/ (AuditFieldInterceptor — audit-fields + audit-log rows), Services/ (DB-backed platform-signal facades + notification-retention hosted service + Search/ = SearchIndexMaintainer + SqlNurseSearch + Booking/ = BookingRequestExpiryHostedService)
│ ├── 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) + AddCrossCuttingSeams
│ └── Baya.Infrastructure.Monitoring HealthChecks, OpenTelemetry, prometheus-net
├── API/
│ ├── Baya.Web.Api Program.cs, Controllers/V1/ (Ping + 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), appsettings*.json
│ ├── Baya.WebFramework BaseController (incl. 401/403 OperationResult mapping), Filters/, Middlewares/, Swagger/, Routing/, ServiceConfiguration/ (rate limiting)
│ └── Plugins/Baya.Web.Plugins.Grpc gRPC services + .proto models (User only)
├── Shared/Baya.SharedKernel Extensions + validation base
└── Tests/
├── Baya.Tests.Setup Shared test infrastructure (SQLite, NSubstitute setup, TestFieldEncryptor)
├── Baya.Test.Infrastructure.Identity xUnit identity tests
├── Baya.Test.Foundation xUnit tests for cross-cutting plumbing + identity handler unit tests
└── Baya.Test.Api WebApplicationFactory integration tests (full HTTP pipeline over in-memory SQLite, env "Testing")
```
**Dependency direction points inward.** Domain has no dependencies. Application depends only on
Domain. Infrastructure and API implement/consume Application contracts. Never make Domain or
Application reference Infrastructure or the API — this is a hard rule.
**Cross-cutting seams.** Application defines mock-able external dependencies as interfaces in
`Contracts/Common/` (`IDateTimeProvider`, `IFieldEncryptor`, `ICacheService`, `IObjectStorage`,
`INotificationDispatcher`, `IGeocoder`, `IShahkarVerifier`, `IIdentityKycProvider`, `ICredentialVerifier`,
`IPaymentCaptureSimulator`, plus `ICurrentUser`). Their in-memory/local mock implementations live in
`Baya.Infrastructure.CrossCutting/Seams/` and are registered by `AddCrossCuttingSeams(configuration)`
(config section `Seams`); `ICurrentUser` is registered in the Identity layer. Swapping a mock for a
real provider is a registration change — handlers depend only on the contract. Audit fields are
stamped by `AuditFieldInterceptor` (Persistence), not in handlers.
**Platform-signal facades (backend-phase-1).** The cross-cutting marketplace tables live in a dedicated
**`ops` schema** (mirroring how Identity uses `usr`): `PlatformConfigs`, `AuditLogs`, `SystemEvents`,
`IranianHolidays`, `Notifications`, `SupportAlerts`. Because they are DB-backed, their Application
contracts — `IPlatformConfig` (typed cached config), `IHolidayCalendar` (bank-closure calendar),
`IAnalyticsSink` (fire-and-forget `system_events`), `IAuditLogger` (explicit append-only writes +
trail), `INotificationService` (per-user notification reads/commands), `ISupportAlertService` (internal
worklist) — are implemented in **`Baya.Infrastructure.Persistence/Services/`** and registered by
`AddPersistenceServices`, *not* in CrossCutting. The real `INotificationDispatcher` (in-app
`notifications` write) also lives there and **supersedes** the b0 log stub. The
`NotificationRetentionHostedService` (the retention/`IJobScheduler` seam) is registered as a hosted
service there too. Other domains call these contracts; they never re-create the tables. The
`AuditFieldInterceptor` additionally writes an append-only `audit_logs` row for any `IAuditable` entity
(currently `PlatformConfig`) in the same transaction as the change.
**Identity profiles, patients & nurse bank accounts (backend-phase-3).** On top of the b2 auth spine,
the `usr` schema gains four role-attached tables: `NurseProfiles` (1:1 with `Users`; guarded
`is_verified` with **no public setter** — flipped only by b6; read-only aggregates), `CustomerProfiles`
(thin payer extension; encrypted emergency contact), `Patients` (care recipient, tenancy-scoped to its
`customer_id`; `is_active` archive flag; encrypted `initial_medical_notes`) and `NurseBankAccounts`
(encrypted `iban` + `UNIQUE(iban_hash)` deterministic-hash duplicate guard + filtered
`UNIQUE(nurse_id) WHERE is_primary=1`). Features live under `Baya.Application/Features/Identity/{Commands|Queries}/`;
one `IEntityTypeConfiguration<T>` each in `Persistence/Configuration/IdentityConfig/`; per-domain
repositories in `Persistence/Repositories/` exposed on `IUnitOfWork` (reads project to DTOs, incl. the
masked IBAN). The **`IBankAccountOwnershipVerifier`** seam (Application `Contracts/Common`; mock
`MockBankAccountOwnershipVerifier` in CrossCutting, registered in `AddCrossCuttingSeams`) runs the mocked
استعلام شبا IBAN-owner ↔ national-id inquiry that sets `matched_national_id` (the b13 first-payout gate).
Encrypted-PII value converters for the new columns are wired in `ApplicationDbContext.OnModelCreating`
alongside the b2 `User` ones. **FluentValidation activation:** `AddApplicationServices` now registers
every `AbstractValidator<T>` in the Application assembly as `IValidator<T>` so the pre-existing
`ValidateCommandBehavior` (and the `ModelStateValidationAttribute` controller filter) actually run —
route-supplied ids (e.g. `patients/update/{id}`) must therefore **not** be validated in the body command.
**Geography, addresses & nurse service areas (backend-phase-4).** A new **`geo` schema** holds the
`Provinces` 1:N `Cities` 1:N `Districts` reference hierarchy (tables, not code lists — new regions launch
by admin insert; `is_active`/`sort_order` drive ordered, toggleable dropdowns) plus `NurseServiceAreas`
(where a nurse travels). `usr.CustomerAddresses` (identity-domain) holds saved service locations. Seeded
via `HasData` (b1 path): 31 provinces + their capital cities (covers the white-space targets) + Tehran's 22
مناطق. Features under `Baya.Application/Features/{Geography|ServiceAreas|Addresses}/`; configs in
`Persistence/Configuration/{GeographyConfig|IdentityConfig}/`; per-domain repos (`IGeoRepository`,
`INurseServiceAreaRepository`, `ICustomerAddressRepository`) on `IUnitOfWork`. Load-bearing rules:
- **`district_id = NULL` means "entire city"** — a real coverage choice, not missing data. Whole-city
uniqueness is enforced with a **filtered-index pair** (`UNIQUE(nurse_id, city_id) WHERE district_id IS
NULL …` + `UNIQUE(nurse_id, city_id, district_id) WHERE district_id IS NOT NULL …`, both `AND deleted_at
IS NULL`), because SQL Server treats NULLs as distinct. A duplicate area returns **409** (`OperationResult.ConflictResult` → new `IsConflict``BaseController` 409 mapping).
- **Coverage is named districts, not GPS radii.** Address lat/lng exists only for the later EVV distance
check (b9); it is never used for coverage matching.
- **Single primary address** per customer via filtered `UNIQUE(customer_id) WHERE is_primary=1 AND
deleted_at IS NULL` + clear-then-set in one transaction; the first address is primary by default.
- **Address PII** (`address_line`, `postal_code`, recipient name/phone) is encrypted at rest through
`IFieldEncryptor` (converters in `ApplicationDbContext`); decrypted only in the owner's own read.
- **`IGeocoder`** (new seam, `Contracts/Common`; mock `MockGeocoder` in CrossCutting, config
`Seams:Geocoding`) turns a typed address into deterministic `decimal` coordinates with no network call;
a config switch / `NO_GEO` marker forces the null-coordinate path.
- **Reference reads are cached** through `ICacheService` behind a generation-token key scheme (`GeoCache`);
any admin geo write bumps the token, invalidating the whole geo cache namespace at once.
**Service catalog & nurse pricing variants (backend-phase-5).** A new **`catalog` schema** holds the two-tier
service model. The **admin skeleton** — `ServiceCategories` → `ServiceOptionGroups` → `ServiceOptionValues` —
is intentionally **EAV/data, not code**: an admin adds a category or a pricing dimension as rows, never a
migration (the only closed code enum in the area is `PriceUnits`). A `ServiceOptionGroups.ServiceCategoryId =
NULL` marks a **cross-category** dimension that applies to every category. The **nurse layer** —
`NurseServiceVariants` (the atomic **bookable unit**: FK `nurse_profiles` + category + `Price` **BIGINT IRR**
+ `PriceUnit` code + `SessionCount?` + auto-generated-but-editable `DisplayName`) + `NurseServiceVariantOptions`
(one row per answered dimension, `UNIQUE(variant_id, option_group_id)`) — turns the skeleton into priced
offerings. Features under `Baya.Application/Features/{Catalog|Variants}/`; configs +
seed in `Persistence/Configuration/CatalogConfig/`; per-domain repos (`ICatalogRepository`,
`INurseServiceVariantRepository`) on `IUnitOfWork`. Load-bearing rules:
- **The bookable unit is the variant, not the nurse.** b7 (search) and b8 (booking) operate on a variant;
keep it a clean projectable source. `price` is IRR `BIGINT` (no floats) and crosses the wire as a digit
string; the engagement total is `price` + `price_unit` + `session_count`, never `price` alone.
- **Duplicate-listing guard** = a deterministic `OptionSetHash` (see `CONVENTIONS.md`) + a filtered
`UNIQUE(nurse_id, service_category_id, option_set_hash) WHERE deleted_at IS NULL` backstop, plus a friendly
pre-check (409) — a multi-row option-set can't be a plain composite unique.
- **Applicable groups = the category's own groups + every cross-category (NULL) group** everywhere (public
browse, required-group validation, duplicate guard). All required groups must be answered; one value per
dimension; deactivate, never hard-delete (soft-delete query filters).
- **Public catalog reads are cached** through `ICacheService` behind a `CatalogCache` generation-token scheme;
any admin catalog write bumps the token.
- **`IVariantSnapshotSerializer`** (Application contract, single real impl in `Application/Common`) emits the
canonical `variant_snapshot_json` and is **consumed by b8** (which owns the `booking_requests` column);
this phase ships and unit-tests it but persists nothing. `nurse_search_index` is **b7's** (not built here).
**Search & matching (backend-phase-7).** A new **`search` schema** holds the single denormalized read model
`NurseSearchIndex` (table `NurseSearchIndices`) — **one flat row per (bookable variant × covered service
area)** (fan-out), copying the variant's category/price/unit, the covered `city_id`/`district_id`
(`district_id = NULL` = whole city), the nurse's `nurse_gender` + rating aggregates, and the single
`is_searchable` visibility gate. It is a **read-only projection**, written only by the maintainer that
re-derives it from source. Features under `Baya.Application/Features/Search/{Queries|Commands}/`; config in
`Persistence/Configuration/SearchConfig/`; the maintainer + SQL search in `Persistence/Services/Search/`.
Two seams live in `Application/Contracts/Search/`, registered by `AddPersistenceServices` (config key
`Search:Backend`, default `sql`):
- **`INurseSearch`** (read) — impl `SqlNurseSearch` reads **only `is_searchable = 1`** rows, applies the
category/city/district/gender/price filters + rating sort + pagination. The real MVP backend; a later
`ElasticNurseSearch` is a config-selected drop-in and callers depend only on the interface.
- **`ISearchIndexMaintainer`** (write, the "ISearchIndexWriter" shape) — `SearchIndexMaintainer` keeps the
index consistent **inline, inside the source write's own unit of work** (single `CommitAsync`), invoked
from the b3/b4/b5/b6 handlers that own each source row: `ReindexVariantAsync` (variant create/edit/toggle),
`ReindexNurseAsync` (verification flip / suspend / accepting-toggle / rating recompute),
`FanOutServiceAreaAsync` + `RemoveServiceAreaRowsAsync` (area add/remove), and `RebuildAsync` (idempotent
full rebuild — the admin `POST admin_search/rebuild_index` job). It shares the request-scoped
`ApplicationDbContext`, so it only *stages* changes; the handler's commit flushes source + projection
atomically. It reads the facts a trigger does **not** change from the DB and takes the facts it **does**
change as tracked arguments, so it never reads a stale pre-commit value. Load-bearing rules:
- **`is_searchable = 1` only when** nurse `is_verified = 1` AND `nurse_verifications.status != 'suspended'`
AND `is_accepting_bookings = 1` AND variant `is_active = 1` — recomputed on every relevant source write.
An unverified/paused/suspended/deactivated nurse or variant must **never** surface.
- **`district_id = NULL` = whole city**, both directions: a city search matches every row in the city; a
district search matches that district's rows **plus** the NULL-district (whole-city) rows. Uniqueness
(`UNIQUE(variant_id, city_id, district_id) WHERE deleted_at IS NULL`) uses the filtered-index pair (the
`nurse_service_areas` trick) so NULL participates on SQL Server; the maintainer resurrects a soft-deleted
row on re-upsert so each (variant × area) has exactly one live row.
- **Incremental maintenance and full rebuild must converge** — the index is fully re-derivable from source.
**Booking requests — pre-payment intent (backend-phase-8).** A new **`booking` schema** holds the single
table `BookingRequests` — the **money-free** first half of the engagement lifecycle (`bookings` + money are
b9/b10). One customer requests one nurse for a patient/variant/address/date; the nurse accepts (opening a
30-minute payment window) or rejects before a frozen response deadline; unanswered/unpaid requests auto-expire.
Features under `Baya.Application/Features/Booking/{Commands|Queries}/`; config in
`Persistence/Configuration/BookingConfig/`; per-domain repo (`IBookingRequestRepository`) on `IUnitOfWork`;
the recurring sweep is `Persistence/Services/Booking/BookingRequestExpiryHostedService` (reuses the b1
`IJobScheduler`/`BackgroundService` seam). Load-bearing rules:
- **No money, ever, and no `bookings` row.** A request carries no price/total; accept only opens the payment
window. b9 consumes an `accepted_awaiting_payment` request → creates the booking → sets it `converted`.
- **Two-stage clinical disclosure (stage 1).** The nurse sees **only** the unencrypted, limited `customer_notes`
(never routed through `IFieldEncryptor`); the nurse view of a request **masks the full address** (line/postal/
recipient) to a coarse city/district. The encrypted `booking_care_instructions` are b9's stage 2.
- **Tenancy invariant.** patient + address ∈ the caller's `customer_id`; variant ∈ the requested `nurse_id`.
Resolved from `ICurrentUser`, never the body; a mismatch is a clean 404.
- **Same-gender match at request time.** `required_caregiver_gender` (`male`/`female`/`any`) is matched against
the nurse's `User.Gender`; required on create, never silently defaulted.
- **Deadlines frozen from config.** `nurse_response_deadline_at` = `now + nurse_response_deadline_hours` at
create; `payment_deadline_at` = `now + booking_payment_deadline_minutes` (30) at accept — both stored as
absolute UTC `datetime2` so a later config change can't move them. Stored as `DateTime` (not `DateTimeOffset`)
because they are compared/sorted in queries and the SQLite test provider can't translate `DateTimeOffset`.
- **Forward-only status guard** (`BookingRequestTransitions`) — every write is pre-checked; an illegal edge is a
409, terminal states have no outgoing edge; the expiry sweep's `WHERE status = …` predicate is the concurrency
guard (a row a racing accept/cancel moved is simply not reloaded). See CONVENTIONS §6.
**Bookings, sessions, EVV & cancellation (backend-phase-9).** The `booking` schema gains the five post-payment
tables — `Bookings`, `BookingSessions`, `BookingCareInstructions`, `VisitVerifications`, `CancellationPolicies`
(entities in `Domain/Entities/Booking/`, configs in `Persistence/Configuration/BookingConfig/`, one migration).
A `bookings` row exists **only** when the nurse accepted **and** payment was captured: `ConvertRequestToBooking`
reads an `accepted_awaiting_payment` request, confirms a capture, and creates the booking 1:1 (`pending_payment →
confirmed`), fanning out N `booking_sessions`. Features under `Baya.Application/Features/Bookings/{Commands|Queries}/`
(namespace **plural** `Bookings` — distinct from b8's singular `Booking`; the entity type `Booking` is aliased where
the two collide); per-domain repos `IBookingRepository` + `ICancellationPolicyRepository` on `IUnitOfWork`;
controllers `BookingsController` / `BookingSessionsController` / `AdminEvvController` / `AdminCancellationPoliciesController`.
Load-bearing rules:
- **Money is IRR `BIGINT`, three amounts reconcile.** `gross_price_irr = balinyaar_commission_irr + nurse_payout_amount`
(all ≥ 0) is a **DB CHECK** and handler invariant; commission = integer-round(`gross × platform_fee_rate`) with the
rate **snapshotted** onto the booking; `nurse_payout_amount` is derived, never free-entered. `Σ(visit_payout_amount)
= nurse_payout_amount` exactly (integer split, remainder on the last session — `BookingAmounts`). The
`payout_released` boolean was **cut** — paid-ness is derived later (b13). On the wire money is a **digit string**.
- **Snapshots freeze history.** `variant_snapshot_json` (via `IVariantSnapshotSerializer`), the **encrypted**
`address_snapshot_json`, `platform_fee_rate`, and the resolved cancellation `code` + `refund_percentage` are frozen
at their moment; later edits to the source variant/address/policy never mutate an existing booking.
- **Two-stage clinical disclosure (stage 2).** `booking_care_instructions` (all fields **encrypted** through
`IFieldEncryptor`) are readable **only post-confirmation** and **only** by the **assigned nurse + admin** —
`GetCareInstructionsQuery` enforces it; the fields are never projected into a list or logged.
- **EVV is per session; mismatch is advisory.** `visit_verifications` FK is on `booking_session_id`. Check-in computes
the distance to the frozen booking address (reusing `IGeocoder` + `GeoDistance` haversine) against
`evv_location_tolerance_meters`; a mismatch raises a `location_mismatch` `support_alerts` + notifies **without
blocking**. GPS-denied still checks in (flagged null).
- **`SetDisputeWindow` is the only payout-eligibility trigger.** Booking completion (last check-out, or all sessions
settled) sets `dispute_window_ends_at = completed_at + config(dispute_window_hours, 72)` and each completed session's
`payout_eligible_at`; b13 gates payout on those, never on `completed` alone.
- **Cancellation snapshots the policy + refunds only un-started sessions.** The applicable `cancellation_policies` tier
is resolved by `(actor, lead-time bucket)` and its `code` + `refund_percentage` + computed refundable amount are
frozen onto the booking; only still-`scheduled` sessions are refundable; **no refund ledger is posted (b11)**.
- **`IPaymentCaptureSimulator`** (Application `Contracts/Common`; mock `MockPaymentCaptureSimulator` in CrossCutting,
registered in `AddCrossCuttingSeams`, config `Seams:PaymentCapture`) is the **temporary conversion trigger** — b10's
real card capture replaces it by calling `ConvertRequestToBooking` directly on a `succeeded` transaction. The no-show
sweep (`DetectNoShowSessions`) is admin/test-triggered; its recurring cron is DEFERRED (like b8's expiry sweep).
**Keeping the Project map current.** When a change touches the architecture — adds, removes, or
renames a project/assembly, a Clean-Architecture layer, or a major folder, or changes a cross-layer
dependency — you **must** update this Project map (and the dependency rule above, if affected) in the
**same** change. This is the server-specific form of the root "Keep docs honest" rule: the map is
only canonical if it stays accurate.
---
## Startup wiring
Service registration is composed from per-layer extension methods (each project's `ServiceConfiguration/`):
```
ConfigureHealthChecks() · SetupOpenTelemetry()
AddApplicationServices() // Mediator + pipeline behaviors (Logging → Metrics → Validate)
RegisterIdentityServices(...) // Identity, JWT/JWE, authorization policies, ICurrentUser + IHttpContextAccessor
AddPersistenceServices(...) // DbContext (+ AuditFieldInterceptor), UnitOfWork, repositories
AddCrossCuttingSeams(config) // IDateTimeProvider, IFieldEncryptor, ICacheService, IObjectStorage, INotificationDispatcher (mocks)
AddWebFrameworkServices() // API versioning + snake_case routing
AddRateLimitingPolicies() // built-in rate limiter: per-IP global + named (otp/auth/sensitive)
AddSwagger("v1", "v1.1") · RegisterValidatorsAsServices() · AddMapster()
ConfigureGrpcPluginServices()
```
Pipeline order: exception handler → Swagger → routing → **rate limiter → authentication →
authorization** → controllers → metrics → health checks → gRPC. `UseRateLimiter()` is placed
**before** `UseAuthentication()` so over-limit auth/OTP attempts are rejected (`429`) before hitting
the auth stack.
When adding new infrastructure, expose it as an extension method and call it from `Program.cs` —
never inline registrations there directly.
---
## CQRS — how a feature is shaped
Features live under `Baya.Application/Features/<Area>/{Commands|Queries}/<Name>/`:
```
Features/<Area>/
├── Commands/<VerbNoun>Command/
│ ├── <VerbNoun>Command.cs record : IRequest<OperationResult<T>>
│ ├── <VerbNoun>Command.Handler.cs internal sealed class : IRequestHandler<...>
│ └── <VerbNoun>Command.Validator.cs
└── Queries/<VerbNoun>Query/
├── <VerbNoun>Query.cs
├── <VerbNoun>Query.Handler.cs
└── <VerbNoun>Query.Result.cs
```
A minimal live example shipped in backend-phase-0: `Features/System/Queries/Ping/` (query + handler +
result), surfaced by `Controllers/V1/PingController`.
Handlers are `internal sealed`. Requests are `record` types. Validators use FluentValidation and are
picked up automatically by the `ValidateCommandBehavior` pipeline behavior. Never throw for expected
failures — use `OperationResult` factory methods.
**To add a feature:** create the folder, implement request + handler + (optional) validator, add any
new contracts to `Application/Contracts/` and implement them in Infrastructure, then wire a controller
action to `sender.Send(...)`. Full conventions are in [CONVENTIONS.md](CONVENTIONS.md) §5.
---
## Persistence
- Access the DB through `IUnitOfWork` — not `ApplicationDbContext` directly outside Infrastructure.
- Commit once per command via `unitOfWork.CommitAsync()`.
- Use `AsNoTracking()` on all read-only queries.
- Always project to a DTO in queries — never return entity objects from handlers.
- Add entity config in `Persistence/Configuration/<Area>Config/` implementing `IEntityTypeConfiguration<T>`.
- Soft delete is enforced via a global query filter per entity (see [CONVENTIONS.md](CONVENTIONS.md) §6).
---
## Identity & auth
- JWT/JWE issued by `IJwtService` (`Baya.Infrastructure.Identity/Jwt/JwtService.cs`).
`GenerateAccessTokenAsync` mints an access token only (the REST flow); the legacy `GenerateAsync`
additionally writes a `UserRefreshTokens` row and still feeds the gRPC path.
- **Phone-OTP is the public login** (backend-phase-2): `Controllers/V1/AuthController`
(`request_otp`/`verify_otp`/`refresh`/`logout`) + `MeController` (`/me`, `select_role`) drive the
`Features/Identity/` slices. OTP delivery goes through the **`ISmsSender`** seam (mock
`LoggingSmsSender` in CrossCutting logs the code; registered in `AddCrossCuttingSeams`).
- **Sessions & rotation:** every login creates a revocable `usr.UserSessions` row storing only the
refresh token's `IFieldEncryptor.Hash`. Refresh rotates (old session revoked, new pair issued);
a replayed/revoked token revokes **all** the user's sessions and returns 401. Logout revokes the
session **and** rotates the security stamp so outstanding access tokens fail the JWE
`OnTokenValidated` stamp check.
- **Encrypted PII:** `users.PhoneNumber/Email/NationalId` are encrypted at rest via an EF value
converter over `IFieldEncryptor` (wired in `ApplicationDbContext`; the encryptor must stay a
process-wide singleton because EF caches the model). Equality lookups go through the deterministic
`PhoneHash` column (UNIQUE, synced on SaveChanges — which also resets `ShahkarVerifiedAt` when the
phone actually changes). Never query `PhoneNumber == x`.
- **Roles:** full vocabulary in `Domain/Entities/User/RoleNames` (seeded by `SeedDataBase`).
`customer`/`nurse` are self-selectable via `POST me/select_role` (audited
`granted_by`/`granted_at`, idempotent, both can be held); admin sub-roles are internal-only and
return 403 there. `user_roles.revoked_at` has a global query filter, so revoked grants disappear
from every role read automatically. Auth knobs (`auth_otp_resend_seconds`, `auth_otp_max_attempts`,
`auth_session_ttl_days`) are `platform_configs` rows read via `IPlatformConfig`.
- Dynamic permission system: `DynamicPermissionHandler` reads `[controller]` + `[action]` route
values and checks role claims. Always use `[controller]`/`[action]` tokens so the keys stay
consistent (see CONVENTIONS.md §1 Routing).
- Settings bound from `appsettings.json` → `IdentitySettings`.
- Auth and OTP endpoints must be rate-limited (CONVENTIONS.md §11) — `request_otp`/`verify_otp` use
the `otp` policy, `refresh` the `auth` policy; plus a per-phone resend window via `ICacheService`.
---
## Conventions — quick reference
Full rules in [CONVENTIONS.md](CONVENTIONS.md). The essentials:
- All URL segments are `snake_case` via `SnakeCaseParameterTransformer` — use `[controller]`/`[action]` tokens.
- Controllers are `sealed`, inherit `BaseController`, inject `ISender`, return `base.OperationResult(result)`.
Never call `Ok()` / `BadRequest()` / `NotFound()` directly.
- Handlers are `internal sealed`; never throw for expected failures — return `OperationResult`.
- `record` for requests/DTOs, `class` for entities (no public setters), `sealed class` for handlers/services.
- `async`/`await` all the way; pass `CancellationToken` through every async call; never `.Result`/`.Wait()`/`async void`.
- Mapster for mapping; FluentValidation for validation (validate at the boundary).
- Package versions live **only** in `Directory.Packages.props` — never `Version=` in a `.csproj`.
- No unused code (usings, locals, parameters, private fields/members) and no *what*-comments — explain *why*, prefer self-documenting names (§2).
- Architecture changes (a project/layer/major folder or a cross-layer dependency) must update the **Project map** in the same change.
- The `Baya.*` namespace is project naming — do not rename without explicit instruction.
---
## Known build warnings (pre-existing — do not fix unless tasked)
| Warning | Project | Note |
| ------- | ------- | ---- |
| `NU1510` on `Microsoft.Extensions.Logging.Debug` | `Baya.Web.Api` | Redundant transitive reference, harmless |
| `NETSDK1057` (preview SDK) | all | .NET 10 SDK is preview on this machine |