Files
baya-monorepo/server/CLAUDE.md
T
2026-07-10 03:22:29 +03:30

57 KiB
Raw Blame History

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. Read it before writing any server code.
  • Repo-wide context and the frontend → root CLAUDE.md.
  • Product/domain rules (business logic, schema, payments, escrow, verification) → 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 usings, locals, parameters, private fields, or members count as failures — delete them, don't suppress them (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), Payments/ (b10 ledger/txn/webhook/gateway + LedgerPosting; b11 adds Refunds/ + Invoices/), Bnpl/ (b12 BnplTransaction + BnplStatus/BnplTransitions/BnplEligibilityStatus/BnplProviderCodes — the net-of-fee card-payment model), Payouts/ (b13 NursePayoutBatch/NursePayout/NursePayoutBookingLink + PayoutBatchStatus/PayoutStatus/*Transitions — the weekly payout run), Reviews/ (b14 Review (IAuditable) + ReviewModerationStatus/ReviewModerationAction codes + ReviewTagMaster/ReviewTagLink + PatientCareRecord — moderated reviews, tag vocab & patient-scoped encrypted clinical notes), + 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); Payments area = the b10 money core (initiate/webhook/confirm-post-ledger/nurse-payable-balance); Refunds + Invoices areas = the b11 reversal leg (create refund/write-off clawback/list/refund-status; issue invoice/get invoice); Bnpl area = the b12 provider-financed-installment checkout (eligibility/initiate/verify/settle/revert/callback/status + BookingConversion shared with b10); Payouts area = the b13 weekly payout engine (compute-eligible/generate-batch/process/retry/mark-failed + admin batch detail/list + nurse history; PayoutSettlement shared ledger+clawback-netting step); Reviews area = the b14 reviews & ratings (submit/moderate/attach-tags + public list/tag-aggregates + admin moderation-queue; RecomputeNurseRating from-source helper + ReviewCache); PatientCareRecords area = the b14 encrypted patient-scoped clinical notes (write/history under strict clinical access); + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts areas), Contracts/ (incl. Contracts/Common cross-cutting seams incl. IBankAccountOwnershipVerifier + IGeocoder + IVariantSnapshotSerializer + IShahkarVerifier + IIdentityKycProvider + ICredentialVerifier + Contracts/Reviews IReviewModerationService (AI review pre-screen seam) + the platform-signal facade contracts + Contracts/Search (INurseSearch read seam + ISearchIndexMaintainer write seam) + Contracts/Persistence per-domain repositories on IUnitOfWork incl. IVerificationRepository + IReviewRepository + IPatientCareRecordRepository), 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 + 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 + 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 + MockBankTransferProvider + MockReviewModerationService) + 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 + 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)
│   └── 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 IsConflictBaseController 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 skeletonServiceCategoriesServiceOptionGroupsServiceOptionValues — 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 layerNurseServiceVariants (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 + adminGetCareInstructionsQuery 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 uniquesUNIQUE(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).

Refunds, clawbacks & invoices (backend-phase-11). The payments schema gains three tables — Refunds, NurseClawbacks, Invoices (+ the single-row InvoiceNumberSequences counter) — entities in Domain/Entities/Refunds/ + …/Invoices/, configs in Persistence/Configuration/{RefundsConfig|InvoicesConfig}/, one migration (RefundsClawbacksInvoices). Features under Baya.Application/Features/{Refunds|Invoices}/; per-domain repos IRefundRepository + IInvoiceRepository on IUnitOfWork; controllers AdminRefundsController / AdminClawbacksController / AdminInvoicesController (admin policy, rate-limited) + customer-facing RefundsController (refunds/{id}/status) / InvoicesController (invoices/{booking_id}). Load-bearing rules:

  • A refund decomposes across both fee legs and reverses the ledger. CreateRefundCommand (the whole money-path under lock(booking:{id}:refund)) reads the booking's frozen split + b9 cancellation snapshot + captured transaction (IRefundRepository.GetRefundContextAsync), splits amount = platform_fee_refunded_irr + nurse_payout_refunded_irr pro-rata at the resolved %, enforces Σ refunded ≤ captured (handler backstop), executes the channel behind its seam, and posts the balanced reversal via b10's LedgerPosting helper (extended with RefundReversalPrePayout / ClawbackReversalPostPayout / RefundPayableClearing / ClawbackWriteOff). The channel-execution/ledger "internal step" commands from the phase are cohesive private steps in the handler (mirroring b10's ConfirmPaymentAndPostLedger) so they stay atomic.
  • Pre-payout reversal vs post-payout clawback fork. INursePayoutStatus (Application Contracts/Payments; DB-backed NursePayoutStatusService in Persistence/Services/Payments) answers "was the nurse already paid?" — pre-payout debits nurse_payable (clean reversal); post-payout debits nurse_clawback_receivable and opens a pending nurse_clawbacks row + raises a nurse_clawback support alert, because an Iranian IBAN transfer is irreversible. Until b13 ships nurse_payouts, "paid?" is derived from the booking's dispute_window_ends_at close (+ a refund_assume_nurse_paid config override); b13 swaps the registration. Clawback recovery/netting is b13 — this phase only opens the receivable + supports admin write_off.
  • Channel parity. psp_card and bnpl_revert post the same reversal legs — only the channel, the external reference (gateway_refund_reference vs external_revert_reference), and the ETA differ (card = immediate succeeded + clearing posts now; BNPL = processing + expected_customer_refund_eta ≈ now + config business days, clearing deferred to reconciliation). The refund_payable ↔ escrow_held clearing posts only once the customer cash-back confirms.
  • Invoices: VAT on the commission line only, sequential number. IssueInvoiceCommand computes vat_irr = round(platform_commission_irr × vat_rate) (config vat_rate, default 0.10; vat_rate = 0 ⇒ 0), never on the nurse payout, and draws a gap-free invoice_number from the InvoiceNumberSequences counter row (locked + committed with the invoice, portable across SQL Server/SQLite — no DB sequence). Idempotent per booking (UNIQUE(booking_id)). IMoadianClient (introduced here; MockMoadianClient in CrossCutting) submits to سامانه مودیان — mock leaves moadian_status = pending / no ref (config can force registered).
  • Forward-deps as nullable columns, no FK. refunds.ticket_id (tickets → b15; "ticket required" is the config-gated refund_ticket_required rule, off by default), nurse_clawbacks.original_payout_id / recovered_in_payout_id (nurse_payouts → b13), invoices.partner_center_id (partner_centers → b15). The data-model's manual_bank channel is stored/served as the canonical wire code manual. IBnplProvider is introduced here as a thin local stub so the bnpl_revert path runs before b12 merges — b12 owns the real seam definition.

BNPL — provider-financed installments (backend-phase-12). The payments schema gains one table — BnplTransactions (entity in Domain/Entities/Bnpl/, config in Persistence/Configuration/BnplConfig/, one migration BnplTransactions) — 1:1 with its payment_transaction (UNIQUE(payment_transaction_id)). A BNPL order is, in our books, a card payment that lands net-of-fee: there is no customer-installment tracking (the provider owns the schedule + 100% default risk). Features under Baya.Application/Features/Bnpl/{Commands|Queries}/ (eligibility/initiate/verify/settle/revert/callback/status); per-domain repo IBnplRepository on IUnitOfWork; controllers CheckoutBnplController (customer, rate-limited) / WebhooksBnplController (anonymous, signature-verified, rate-limited) / AdminBnplController (admin, rate-limited). The b10 booking-conversion path was extracted to the shared Features/Bookings/BookingConversion helper (used by both the card ConfirmPaymentAndPostLedger and the BNPL settle). Load-bearing rules:

  • Forward-only BnplStatus state machine (eligible → token_issued → verified → settled → reverted/cancelled/failed, BnplTransitions), mutated only through the entity's mark-* methods — the idempotency spine. A replayed settle/revert that would re-drive a completed transition is an idempotent no-op.
  • Settle posts the net-of-fee group via LedgerPosting.BnplSettle — the card-capture legs plus DEBIT bnpl_fee_expense / CREDIT escrow_held for the provider commission, one balanced transaction_group_id, so escrow reflects the net cash (settled_amount_irr = order commission). Settle confirms the parent payment_transaction (which triggers the booking conversion) exactly like the card capture.
  • The nurse's payout is invariant to payment methodnurse_payable comes from the booking split (gross commission), never from settled_amount_irr; the BNPL commission is a platform expense.
  • settled_at is per-transaction and nullable — never assumed instant; the commission is read from the actual settlement, never hardcoded. Currency is normalized to IRR at the provider boundary only.
  • Revert reuses the b11 refund path (CreateRefundCommand with refund_channel='bnpl_revert') — money flows customer ↔ provider ↔ Balinyaar only; the async ~710-business-day customer ETA is surfaced.
  • Two new seams in Application/Contracts/Payments/: IBnplProvider (the full SnappPay-superset verb set, superseding b11's revert-only stub; the b11 refund path still injects it) selected per provider_code by IBnplProviderResolver, and ICurrencyNormalizer (Toman↔IRR at the boundary). Mocks (MockBnplProvider/MockBnplProviderResolver/MockCurrencyNormalizer) in CrossCutting/Seams/, registered by AddCrossCuttingSeams. bnpl_settlement_entries (tranched settlement) is DEFERRED — modeled-but-not-built.

Weekly nurse payouts (backend-phase-13). A new payouts schema holds the money-out engine: three tables — NursePayoutBatches (weekly aggregation, holiday-shifted period_end/processing_date), NursePayouts (one row per nurse per batch; the net = gross clawback split as a DB CHECK; encrypted iban_snapshot frozen from the verified primary account) and NursePayoutBookingLinks (UNIQUE(booking_id) unconditional — the structural one-payout-per-booking-ever guard). Entities in Domain/Entities/Payouts/; configs in Persistence/Configuration/PayoutsConfig/; one migration (NursePayoutEngine). Features under Baya.Application/Features/Payouts/{Commands|Queries}/ (compute-eligible / generate-batch / process / retry / mark-failed + admin batch-detail/list + nurse history), with the shared PayoutSettlement step (payout ledger post + clawback netting); per-domain repo IPayoutRepository on IUnitOfWork; controllers AdminPayoutsController (admin, rate-limited) / NursePayoutsController (nurse, tenancy-scoped). Load-bearing rules:

  • Payout eligibility ≠ completed. A booking enters a batch only when status='completed' AND dispute_window_ends_at < now AND it has no active refund AND it isn't already in a link row. There is no payout_released boolean — paid-ness is derived from a nurse_payout_booking_links row + the ledger.
  • One payout per booking, forever. nurse_payout_booking_links.booking_id is an unconditional UNIQUE (not filtered on soft-delete); the "not already linked" filter is the fast first line, the UNIQUE the backstop.
  • The payout drains nurse_payable. ExecutePayoutBatch posts DEBIT nurse_payable / CREDIT escrow_held for the paid net (b10's LedgerPosting.NursePayout); a netted clawback posts DEBIT nurse_payable / CREDIT nurse_clawback_receivable (LedgerPosting.ClawbackRecovery) and marks the nurse_clawbacks row recovered (recovered_in_payout_id + resolved_at). Netting recovers whole pending clawbacks up to earnings (never a negative net, never a partial single-clawback recovery). Forward-only PayoutStatus machine + the ledger-exists guard + a batch idempotency key make a retried process never double-send an irreversible transfer.
  • Holiday-aware. period_end/processing_date shift off is_bank_closed days via IHolidayCalendar; retry refuses on a bank-closed day. First-payout gate: only a is_primary=1 AND is_verified=1 AND matched_national_id=1 account is paid; a nurse without one is skipped with a recorded reason.
  • IBankTransferProvider (new seam, Contracts/Payments; mock MockBankTransferProvider in CrossCutting/Seams/, config Seams:BankTransfer) is the mocked PAYA/SATNA rail — PAYA vs SATNA chosen by the payout_satna_threshold_irr config; a config switch forces whole-batch/single-row failures. b13 also swaps the INursePayoutStatus registration to the authoritative NursePayoutLinkStatusService (a booking is paid iff linked to a paid payout), superseding the b11 dispute-window derivation. The weekly cron trigger is DEFERRED (batches are admin-triggered; cadence in nurse_payout_interval_days); the BNPL settled_at guard is the default-off require_bnpl_settlement_for_payout config flag.

Reviews, ratings & patient care records (backend-phase-14). A new reviews schema holds four tables: Reviews (one per completed booking — UNIQUE(booking_id), CHECK(rating 15), moderation_status code + guarded moderation fields; IAuditable so the interceptor audits every transition), ReviewTagsMaster (seeded tag vocabulary, UNIQUE(code)), ReviewTagLinks (N:N, UNIQUE(review_id, review_tag_master_id)), and PatientCareRecords (nurse-authored, encrypted, patient-scoped clinical notes; (patient_id, recorded_at) index). Entities in Domain/Entities/Reviews/; configs in Persistence/Configuration/ReviewsConfig/; per-domain repos IReviewRepository + IPatientCareRecordRepository on IUnitOfWork; features under Baya.Application/Features/{Reviews|PatientCareRecords}/; controllers BookingReviewsController (submit) / ReviewsController (tags + moderate) / AdminReviewsController (queue) / NursesController (public reviews + review_tags) / PatientCareRecordsController. Load-bearing rules:

  • Reviews are for completed/closed bookings only, owned by the caller, 1:1. The UNIQUE(booking_id) is the backstop; the handler pre-checks and returns a clean OperationResult (409 on a duplicate, not a raw DB error). A cross-tenant booking is a 404, never a leak.
  • Recompute the nurse aggregate from source on EVERY transition — not a delta. RecomputeNurseRating (Features/Reviews/) reads COUNT/SUM(rating) over the nurse's currently-published reviews excluding the transitioning review, folds in that review's new status in memory, sets nurse_profiles.average_rating/ total_reviews (guarded NurseProfile.SetReviewAggregates), and stages the b7 ReindexNurseAsync refresh — all in the same transaction as the status change (the exclude-and-fold avoids a stale pre-commit re-query). This is the fix for inflated-rating-after-hide drift.
  • Publish gate — pending_moderation is never public. ListReviewsForNurse and the aggregate count published only, filtered at the query layer. The public aggregate read is cached (ReviewCache) and evicted on every transition.
  • Low rating raises a support_alert reliably. rating <= min_rating_for_support_alert (config, default 2) → RaiseSupportAlert(low_rating) in the same flow (after the main commit, never silently swallowed).
  • patient_care_records are patient-scoped (not booking-scoped) + encrypted + strict access. body_encrypted holds IFieldEncryptor ciphertext with no EF value converter — the handler encrypts on write and decrypts only after the access check passes (owning customer / nurse with a confirmed booking / admin; anyone else 403).
  • IReviewModerationService (new seam, Contracts/Reviews; mock MockReviewModerationService in CrossCutting, config Seams:ReviewModeration) is the AI pre-screen; clean text stays pending by default (publish gate), banned-word → auto-hidden. Decision authority stays with ModerateReviewCommand (human override).

Messaging, partner centers & admin backoffice (backend-phase-15). The final backend phase adds two schemas and consolidates the admin surface. A new messaging schema holds Tickets / TicketParticipants / TicketMessages (entities in Domain/Entities/Messaging/ + TicketStatus/TicketCategory/TicketParticipantRole codes) — the only sanctioned post-booking channel. A new partner schema holds PartnerCenters (entity in Domain/Entities/PartnerCenters/, IAuditable; the licensed sponsor / merchant-of-record). Configs in Persistence/Configuration/{MessagingConfig|PartnerCentersConfig}/; per-domain repos ITicketRepository + IPartnerCenterRepository on IUnitOfWork; features under Baya.Application/Features/{Messaging|PartnerCenters}/; controllers TicketsController / AdminTicketsController / AdminPartnerCentersController / CentersController / InternalCentersController; one migration (MessagingAndPartnerCenters, which also adds the nurse_profiles.partner_center_id FK in place). Load-bearing rules:

  • is_internal is a HARD visibility boundary enforced at the QUERY layer. GetTicketThreadQuery takes an AsAdmin flag; the user view (false) strips every is_internal message in the repository projection (GetMessagesAsync(includeInternal:false)), the admin view (true, staff only) returns them. A non-staff caller can never set is_internal on PostMessage nor read one. Never enforced only in the UI.
  • No direct nurse↔customer channel. All post-booking comms are ticket-mediated + admin-readable; participation (via TicketParticipant, UNIQUE(ticket_id, user_id), soft-remove via removed_at) plus staff is the auth boundary. reference_code is minted once (collision-checked, UNIQUE) and stable. Both booking_id/refund_id links are nullable — handle a ticket with neither. A coordination ticket is auto-created (idempotent, one per booking) on confirmation via AutoCreateCoordinationTicketCommand, dispatched from the card confirm + BNPL settle handlers. LogEmergencyTicket records the aftermath of an out-of-platform emergency call (+ optional support_alert) — it exposes no phone number.
  • Merchant-of-record resolution follows partner_centers, not a hardcoded platform. PartnerCenterRepository.ResolveCenterForBookingAsync (surfaced by GetCenterForBookingQuery, endpoint GET /internal/bookings/{id}/center) resolves booking → nurse → partner_center_id; the issuer/settlement target is partner_center only when that center is_merchant_of_record, else platform. This is the single resolver b11's IssueInvoice now calls to set invoices.issuing_entity_type + partner_center_id.
  • partner_centersorganizations. The launch licensing sponsor (partner_centers) is distinct from the future employer (organizations, DEFERRED). settlement_iban is encrypted at rest (converter in ApplicationDbContext, [AuditRedacted]) and masked (last 4) in every read; commission_rate (the center's cut) is separate from platform_fee_rate. The four DEFERRED tables (organizations, organization_nurses, fraud_flags, recurring_booking_schedules) are not created.
  • Refund↔ticket link wired. CreateRefundCommand (b11) now auto-opens a category=refund ticket via OpenTicketCommand when the caller supplies none, so refunds.ticket_id is always non-null.
  • Backoffice consolidation surfaces, doesn't rebuild. The support-alert worklist (ISupportAlertService List/Assign/Resolve — SupportAlertsController) and the audit viewer (GetAuditTrailAuditController) already existed since b1 and are reused as-is; verification/refund/payout/moderation surfaces are their own phases'. New seam ILicenseVerificationService (Contracts/Common; mock MockLicenseVerificationService in CrossCutting, config Seams:LicenseVerification, AutoApprove toggle) is the eNamad / MoH permit check — manual-approve at MVP; VerifyPartnerCenter records the human decision. There is no telephony/VoIP seam (the emergency call is an out-of-platform tel: link by design). This is the last backend phase.

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 §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 §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.jsonIdentitySettings.
  • 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. 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