458 lines
38 KiB
Markdown
458 lines
38 KiB
Markdown
# 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).
|
||
|
||
**Payments core — ledger, transactions, webhooks & card capture (backend-phase-10).** A new **`payments`
|
||
schema** holds the money core: `PaymentGateways` (config per PSP; **encrypted `config_json`**;
|
||
selection by `type`+`priority`), `PaymentTransactions` (every attempt; the **two filtered uniques** —
|
||
`UNIQUE(gateway_reference_code) WHERE NOT NULL` and `UNIQUE(booking_id) WHERE status='succeeded'` — are the
|
||
anti-double-capture backstop), `PaymentWebhookEvents` (the idempotency store; **`UNIQUE(provider_code,
|
||
external_event_id)`**), and the **append-only** `LedgerEntries` (double-entry source of truth). Entities in
|
||
`Domain/Entities/Payments/` (+ `LedgerPosting` balanced-group builder, `LedgerAccountType`/`PaymentTransactionStatus`/
|
||
`WebhookProcessingStatus`/`PaymentGatewayType` code sets); configs in `Persistence/Configuration/PaymentsConfig/`;
|
||
one migration (`PaymentsCoreLedger`). Features under `Baya.Application/Features/Payments/{Commands|Queries}/`
|
||
(`InitiatePayment`, `HandlePaymentWebhook`, `ConfirmPaymentAndPostLedger`, `GetNursePayableBalance`);
|
||
`IPaymentRepository` on `IUnitOfWork`; controllers `PaymentsController` (`POST bookings/{id}/payments`),
|
||
`WebhooksController` (public `POST webhooks/payments/{provider}`), `NursePayableBalanceController`
|
||
(`GET nurses/{id}/payable_balance`). Load-bearing rules:
|
||
- **A `bookings` row exists only on capture (b9).** So a payment is initiated against the
|
||
`accepted_awaiting_payment` **request**; `payment_transactions.booking_id` is **nullable**, bound only when
|
||
the confirm creates/loads the booking. Confirm reuses b9 via the extracted **`BookingFactory`** (shared
|
||
conversion/amount logic) rather than re-implementing it — the mock `IPaymentCaptureSimulator` Convert path
|
||
stays for b9's own tests.
|
||
- **Idempotency ordering:** `HandlePaymentWebhook` **upserts the webhook event first** on `(provider,
|
||
external_event_id)` and **no-ops on a duplicate**; on a new success event it **re-verifies server-side**
|
||
(`IPaymentProvider.VerifyAsync`) then dispatches `ConfirmPaymentAndPostLedger`, all under
|
||
`IDistributedLock(booking-request:{id}:payment)`. A unique-violation on confirm is treated as an
|
||
**idempotent no-op success**, not an error.
|
||
- **The card-capture group is balanced:** `LedgerPosting.CardCapture` posts DEBIT `escrow_held` gross =
|
||
CREDIT `platform_revenue` commission + `nurse_payable` payout under one `transaction_group_id`
|
||
(Σdebit = Σcredit; throws if the three frozen amounts don't reconcile). `ledger_entries` is **append-only**
|
||
(implements `IEntity` only — no `ITimeModification`, so the audit interceptor never stamps it; no soft-delete).
|
||
- **Escrow IS the ledger.** `GetNursePayableBalance` is the **signed sum** over `nurse_payable` legs — never a
|
||
stored column. The lawful split is **تسهیم via `ISettlementSplitProvider`** to registered IBANs (the platform
|
||
never moves money).
|
||
- **Four money-path seams** in `Application/Contracts/Payments/` — `IPaymentProvider`,
|
||
`ISettlementSplitProvider`, `IWebhookVerifier`, `IDistributedLock` — with faithful mocks in
|
||
`CrossCutting/Seams/` (`MockPaymentProvider`, `MockSettlementSplitProvider`, `MockWebhookVerifier`,
|
||
`InProcessDistributedLock`), registered by `AddCrossCuttingSeams`. `payment_gateways.config_json` is
|
||
encrypted through the b0 `IFieldEncryptor` (converter wired in `ApplicationDbContext`).
|
||
|
||
**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 |
|