cleanup phase 1
This commit is contained in:
@@ -12,7 +12,6 @@ Dockerfile
|
||||
.dockerignore
|
||||
docker-compose.yml
|
||||
CLAUDE.md
|
||||
CONVENTIONS.md
|
||||
AGENTS.md
|
||||
README.md
|
||||
LICENSE.md
|
||||
|
||||
+6
-5
@@ -1,12 +1,13 @@
|
||||
# AGENTS.md — Balinyaar Server
|
||||
|
||||
The canonical agent guide for the backend is **[CLAUDE.md](CLAUDE.md)** (same folder): role, stack,
|
||||
commands, architecture, project map, and a conventions quick-reference.
|
||||
|
||||
The **full coding rule set** is in **[CONVENTIONS.md](CONVENTIONS.md)** — read it before writing any
|
||||
server code.
|
||||
The canonical agent guide for the backend is **[CLAUDE.md](CLAUDE.md)** (same folder): stack,
|
||||
commands, the quality gates, the project map, and the hard rules every change must follow.
|
||||
|
||||
- Reference rules, read on demand per area → [../docs/rules/server/](../docs/rules/server/)
|
||||
(structure · cqrs · persistence · **money** · identity · conventions). `conventions.md` is the
|
||||
successor to the old `CONVENTIONS.md`, which was distilled into it.
|
||||
- Repo-wide context → [../CLAUDE.md](../CLAUDE.md)
|
||||
- Business rules (schema, payments, escrow, verification) → [../product/](../product/index.md)
|
||||
- Human setup/run instructions → [README.md](README.md)
|
||||
|
||||
`CLAUDE.md` is the single source of truth; this file is just a pointer so the convention is
|
||||
|
||||
+137
-725
@@ -1,29 +1,14 @@
|
||||
# Balinyaar Server — Claude Code Guidelines
|
||||
# Balinyaar Server
|
||||
|
||||
The backend API of **Balinyaar**, a trust-first home-nursing marketplace in Iran.
|
||||
The backend API of **Balinyaar**, a trust-first home-nursing marketplace in Iran. It owns the booking
|
||||
lifecycle, an escrow-style double-entry ledger, weekly nurse payouts, the nurse verification pipeline, and
|
||||
every piece of encrypted PII and clinical data on the platform.
|
||||
|
||||
- **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.
|
||||
> Last verified: 2026-07-30 against commit `d3ec723`.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
- Repo-wide context and the frontend → root [CLAUDE.md](../CLAUDE.md)
|
||||
- Business rules (schema, payments, escrow, verification) → [`product/`](../product/index.md). **Read the
|
||||
relevant doc before designing an entity, feature, or endpoint** — don't infer a business rule from code.
|
||||
|
||||
---
|
||||
|
||||
@@ -31,742 +16,169 @@ You are a **senior .NET software engineer** working on this codebase. That means
|
||||
|
||||
- **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
|
||||
- **CQRS** with **Mediator** (`martinothamar/Mediator` — source-generator based, **not** MediatR). Use
|
||||
`ISender`/`ICommand`/`IQuery`; any prose that says "MediatR" is wrong.
|
||||
- **EF Core 10** + **SQL Server** (Repository + Unit of Work)
|
||||
- **ASP.NET Core Identity** with **JWE** (signed + AES-128-encrypted JWT), phone-OTP, and dynamic permission
|
||||
authorization
|
||||
- **Mapster** for mapping, **FluentValidation** for validation, **Serilog** for structured logging
|
||||
- **OpenTelemetry** (metrics + tracing; Prometheus-scrape at `/metrics`, opt-in OTLP export) for observability, **NSwag** for OpenAPI, **Asp.Versioning** for versioning
|
||||
- **OpenTelemetry** (metrics at `/metrics`, tracing, opt-in OTLP), **NSwag** for OpenAPI, **Asp.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` |
|
||||
| --- | --- |
|
||||
| Restore / build | `dotnet restore Baya.sln` · `dotnet build Baya.sln` |
|
||||
| Run API | `dotnet run --project src/API/Baya.Web.Api/Baya.Web.Api.csproj` |
|
||||
| Test | `dotnet test Baya.sln` |
|
||||
| Apply migrations (deploy-time one-shot) | `dotnet run --project src/API/Baya.Web.Api/Baya.Web.Api.csproj -- migrate` |
|
||||
| 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`.
|
||||
**Migrations are split from boot (refinement-phase-7).** `dotnet run -- migrate` (the deploy-time one-shot / a CI
|
||||
`dotnet ef database update`) applies migrations + the idempotent seeders, then exits — so multi-instance boots never
|
||||
race on DDL and the runtime login needs no permanent DDL rights. **In Development**, boot still migrates + seeds for
|
||||
convenience: `Program.cs` calls `ApplyMigrationsAsync()` + `SeedDefaultUsersAsync()` (roles always; a bootstrap admin
|
||||
**only if `Seed:AdminUsername`/`Seed:AdminPassword` are configured** — never a committed credential) + the
|
||||
Development-only `SeedPaymentGatewaysAsync()` (sandbox gateway) + `SeedDemoWorldAsync()` (demo marketplace, see
|
||||
Persistence below). **In deployed environments**, boot instead only *checks* the schema is current
|
||||
(`EnsureSchemaUpToDateAsync` — fail fast on a pending migration) and seeds roles/break-glass admin (idempotent). A
|
||||
reachable SQL Server is required to start. Startup **fails fast**
|
||||
(`StartupSecretsGuard`) if a load-bearing secret — the DB connection strings, and in deployed environments the
|
||||
JWE + field-encryption keys — is missing or left at its committed `SET_VIA_USER_SECRETS_OR_ENV` placeholder
|
||||
(refinement-phase-5). **`dotnet user-secrets` is no longer used** — the `<UserSecretsId>` was removed from
|
||||
`Baya.Web.Api.csproj`, so that store is not read at all. Every value, connection strings and dev-only crypto keys
|
||||
alike, lives in `appsettings.Development.json`; the deployment's two container-specific overrides live in the root
|
||||
`docker-compose.yml` (see [DEPLOY.md](../DEPLOY.md)).
|
||||
**Default URL: `http://localhost:5002`** (per `launchSettings.json`), Swagger at `/swagger`. A reachable SQL
|
||||
Server is required to start.
|
||||
|
||||
## Quality gates
|
||||
|
||||
1. `dotnet build Baya.sln` — **zero new warnings.** Unused usings, locals, parameters, private fields or
|
||||
members count as failures. Delete them; don't suppress them.
|
||||
2. `dotnet test Baya.sln` — all tests pass, including the ones your change adds.
|
||||
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.
|
||||
|
||||
Two pre-existing warnings are expected and must **not** be "fixed" unless a task says so: `NU1510` on
|
||||
`Microsoft.Extensions.Logging.Debug` (`Baya.Web.Api`), and `NETSDK1057` (the .NET 10 SDK is preview here).
|
||||
|
||||
---
|
||||
|
||||
## Quality gates — run before declaring work done
|
||||
## Hard rules
|
||||
|
||||
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").
|
||||
1. **Dependencies point inward.** Domain references nothing; Application references only Domain.
|
||||
**Never reference Infrastructure or the API from Domain or Application.**
|
||||
2. **Never throw for an expected failure.** Return `OperationResult.SuccessResult` / `FailureResult` /
|
||||
`NotFoundResult` / `ConflictResult`. Let genuinely unexpected exceptions reach the global
|
||||
`ExceptionHandler`; never swallow one.
|
||||
3. **Controllers are `sealed`, inherit `BaseController`, inject `ISender`, and return
|
||||
`base.OperationResult(result)`.** Never call `Ok()`/`BadRequest()`/`NotFound()` directly. One `Send`, one
|
||||
result, no business logic.
|
||||
4. **Route segments come from `[controller]`/`[action]` tokens** (the snake_case transformer). Never hardcode a
|
||||
route string — it also breaks the dynamic-permission key. If a method name doesn't read as a URL, rename it.
|
||||
5. **Handlers are `internal sealed`; requests are `record`s; one handler per request.** Entities are `class`
|
||||
with **no public setters**.
|
||||
6. **Reads use `AsNoTracking()` and project with `.Select()` to a DTO.** Never hydrate entities to map them,
|
||||
never return an entity from a handler. **Every unbounded list is paginated.**
|
||||
7. **Access the DB through `IUnitOfWork`; commit once per command.** `ApplicationDbContext` is referenced
|
||||
directly only inside Infrastructure.
|
||||
8. **Every soft-deletable entity declares a global query filter** in its `IEntityTypeConfiguration<T>`. A
|
||||
missing filter is a silent data leak. Never `Where(x => !x.IsDeleted)` per query.
|
||||
9. **Money is IRR `BIGINT`, integer-only — no float path anywhere.** `gross = commission + payout` always.
|
||||
Toman converts only inside a provider adapter at its boundary. `ledger_entries` is append-only and every
|
||||
posting group balances.
|
||||
10. **Config is rows, read at compute time** via `IPlatformConfig` — never hardcoded. And **a rate change is
|
||||
never retroactive**: snapshot the rate onto the row at compute time.
|
||||
11. **Money-path writes are idempotent**: upsert the webhook event first and no-op on a duplicate, claim before
|
||||
executing, and treat a unique-violation on confirm as an idempotent success. The DB constraint is the
|
||||
authoritative backstop, not the handler's `if`.
|
||||
12. **Money movement stays human-approved.** A scheduled job may *generate* a draft payout batch; the
|
||||
irreversible `process` step is always an explicit admin action.
|
||||
13. **Route every status write through the forward-only transition table.** `status` has a private setter and
|
||||
only cohesive domain methods mutate it; the handler pre-checks and returns a clean **409**.
|
||||
14. **A guarded cross-aggregate flip is one transaction**: load both tracked, mutate through one pure domain
|
||||
helper, `CommitAsync` once. Never flip a derived flag from a controller or out of band.
|
||||
15. **Self-committing facades run *after* `CommitAsync()`** — `RaiseAsync`, `DispatchAsync`, `WriteAsync` and
|
||||
`SetConfig` each call `SaveChanges` on the shared scoped context and will flush your partial changes.
|
||||
16. **`Seams:FieldEncryption:Key` and `:HashKey` are load-bearing — never change them.** They decrypt all
|
||||
existing PII and derive the phone-lookup hash.
|
||||
17. **PII goes through `IFieldEncryptor`; equality lookups go through the deterministic hash column.**
|
||||
**Never query `PhoneNumber == x`.** The encryptor must stay a process-wide singleton.
|
||||
18. **Two-stage clinical disclosure.** A booking request exposes only limited unencrypted `customer_notes` and
|
||||
masks the address to a coarse city/district; encrypted care instructions are readable only
|
||||
post-confirmation, only by the assigned nurse and admin, and never projected into a list or logged.
|
||||
19. **`is_internal` is a hard visibility boundary enforced at the QUERY layer**, never in the UI. A non-staff
|
||||
caller can never set or read one.
|
||||
20. **Tenancy is resolved from `ICurrentUser`, never from the request body, and a mismatch is a clean 404** —
|
||||
never a 403, which confirms the row exists.
|
||||
21. **Auth, OTP and money endpoints are rate-limited** (`otp` / `auth` / `sensitive` / `webhook` policies).
|
||||
22. **Never hardcode a secret in C#**, and never put a real value in the base `appsettings.json` — it stays at
|
||||
its `StartupSecretsGuard`-rejected placeholder. **`dotnet user-secrets` is not used and is not read** (the
|
||||
`<UserSecretsId>` was removed), so any instruction to use it is stale.
|
||||
23. **Never concatenate raw SQL.** EF parameterizes; if you must, `FromSqlInterpolated`, never `FromSqlRaw`
|
||||
with user data.
|
||||
24. **`async`/`await` all the way, `CancellationToken` threaded through every call.** Never `.Result`,
|
||||
`.Wait()`, or `async void`. Don't add `.ConfigureAwait(false)` in this app.
|
||||
25. **Never log PII or secrets.** Structured templates only; use `userId`, not an email.
|
||||
26. **Package versions live only in `Directory.Packages.props`** — never `Version=` in a `.csproj`.
|
||||
27. **Register infrastructure through a `ServiceConfiguration/` extension method** called from `Program.cs`.
|
||||
No inline registration; `Program.cs` stays an orchestrator.
|
||||
28. **A mock lives behind a DI-registered seam, selected by config, defaulting to the mock.** Never an
|
||||
`if (mock)` in a handler. Record every mock in `docs/status/`.
|
||||
29. **No dead code** (the gate is zero new warnings) and **comment the *why*, never the *what*.**
|
||||
30. **When you change the architecture, update the Project map below in the same change.**
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
The canonical list of projects, layers, and cross-layer dependencies — **14 `.csproj` projects, 55 V1
|
||||
controllers.** Expanded, with the seam catalogue and startup wiring, in
|
||||
[`docs/rules/server/structure.md`](../docs/rules/server/structure.md).
|
||||
|
||||
```
|
||||
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/confirm-settlement/mark-failed [refinement-phase-6: the BNPL/manual `processing → succeeded` clearing]/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)
|
||||
│ ├── Baya.Domain Entities per area (User, Identity, Geography, Catalog, Verification,
|
||||
│ │ Search, Booking, Payments, Refunds, Invoices, Bnpl, Payouts, Reviews,
|
||||
│ │ Messaging, PartnerCenters, + Configuration/Audit/Analytics/Holidays/
|
||||
│ │ Notifications/SupportAlerts) · Common/ (BaseEntity, IAuditable,
|
||||
│ │ [AuditRedacted]) · status-code sets + transition tables
|
||||
│ └── Baya.Application Features/<Area>/{Commands|Queries}/ · Contracts/ (the seams:
|
||||
│ Common, Payments, Search, Reviews, Persistence) · Models/ ·
|
||||
│ pipeline behaviors (Logging → Metrics → Validate)
|
||||
├── 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 + Scheduling/ = RecurringJobSchedulerHostedService + Jobs/ (the IRecurringJob crons — refinement-phase-7) + Search/ = SearchIndexMaintainer + SqlNurseSearch)
|
||||
│ ├── Baya.Infrastructure.Identity Jwt/, Identity/ (Managers, Stores, PermissionManager, Seed, CurrentUser/)
|
||||
│ ├── Baya.Infrastructure.CrossCutting Serilog wiring + Seams/ (mock impls of the cross-cutting seams incl. LoggingSmsSender + MockBankAccountOwnershipVerifier + MockShahkarVerifier + MockIdentityKycProvider + MockCredentialVerifier + MockPaymentCaptureSimulator + MockBankTransferProvider + MockReviewModerationService) + AddCrossCuttingSeams
|
||||
│ └── Baya.Infrastructure.Monitoring HealthChecks (live/ready split + IObjectStorage write-probe → refs Baya.Application), OpenTelemetry (one stack: metrics + tracing, opt-in OTLP)
|
||||
│ ├── Baya.Infrastructure.Persistence ApplicationDbContext (+ encrypted-PII converters & phone-hash sync) ·
|
||||
│ │ ValueConversion/ · Configuration/<Area>Config/ · Repositories/ ·
|
||||
│ │ Migrations/ · Interceptors/ (AuditFieldInterceptor) ·
|
||||
│ │ Services/ (DB-backed platform facades, Scheduling/, Search/, Seeding/)
|
||||
│ ├── Baya.Infrastructure.Identity Jwt/ · Identity/ (Managers, Stores, PermissionManager, Seed, CurrentUser/)
|
||||
│ ├── Baya.Infrastructure.CrossCutting Serilog wiring · Seams/ (mocks) · Seams/Real/ (vendor adapters) ·
|
||||
│ │ AddCrossCuttingSeams
|
||||
│ └── Baya.Infrastructure.Monitoring HealthChecks (live/ready split) · OpenTelemetry
|
||||
├── API/
|
||||
│ ├── Baya.Web.Api Program.cs, Controllers/V1/ (Ping + Development-only Dev (dev/last_otp OTP helper, 404 outside Development) + Auth/Me phone-OTP surface + admin PlatformConfig/Holidays/Audit/SupportAlerts + current-user Notifications + public Geo + admin AdminGeo + nurse NurseServiceAreas + customer CustomerAddresses + public Catalog + admin AdminCatalog + nurse NurseVariants + nurse NurseVerification + admin AdminVerificationStepTypes/AdminVerifications + public Nurses (trust badge) + public Search + admin AdminSearch + customer/nurse BookingRequests + admin AdminBookingRequests + customer/nurse/admin Bookings + nurse/admin BookingSessions + admin AdminEvv + admin AdminCancellationPolicies + customer PaymentsController + public WebhooksController + admin AdminRefunds/AdminClawbacks/AdminInvoices + customer Refunds/Invoices + customer CheckoutBnpl + public WebhooksBnpl + admin AdminBnpl + admin AdminPayouts + nurse NursePayouts + customer BookingReviews (submit) + owner/admin Reviews (tags + moderate status) + admin AdminReviews (moderation queue) + public Nurses (reviews + review_tags) + nurse/owner/admin PatientCareRecords), appsettings*.json
|
||||
│ ├── Baya.WebFramework BaseController (incl. 401/403 OperationResult mapping), Filters/, Middlewares/, Swagger/, Routing/, ServiceConfiguration/ (rate limiting)
|
||||
│ └── Plugins/Baya.Web.Plugins.Grpc gRPC services + .proto models (User only)
|
||||
├── Shared/Baya.SharedKernel Extensions + validation base
|
||||
│ ├── Baya.Web.Api Program.cs · Controllers/V1/ (55) · appsettings*.json
|
||||
│ ├── Baya.WebFramework BaseController · Filters/ · Middlewares/ · Swagger/ · Routing/ ·
|
||||
│ │ ServiceConfiguration/ (rate limiting)
|
||||
│ └── Plugins/Baya.Web.Plugins.Grpc gRPC services + .proto (User only)
|
||||
├── Shared/Baya.SharedKernel Extensions + validation base
|
||||
└── Tests/
|
||||
├── Baya.Tests.Setup Shared test infrastructure (SQLite, NSubstitute setup, TestFieldEncryptor)
|
||||
├── Baya.Tests.Setup Shared test infrastructure (SQLite, NSubstitute, 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")
|
||||
├── Baya.Test.Foundation Cross-cutting plumbing + identity handler unit tests
|
||||
└── Baya.Test.Api WebApplicationFactory integration tests (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.
|
||||
**DB schemas**, one per area: `usr`, `ops`, `geo`, `catalog`, `verif`, `search`, `booking`, `payments`,
|
||||
`payouts`, `reviews`, `messaging`, `partner`.
|
||||
|
||||
**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.
|
||||
|
||||
**External rails go real — config-selected vendor adapters (refinement-phase-8).** Every vendor rail now has a
|
||||
**real HTTP adapter** in `Baya.Infrastructure.CrossCutting/Seams/Real/`, **config-selected** by a per-rail
|
||||
`Seams:*:Provider` selector in `AddCrossCuttingSeams` (default = the mock, so an unconfigured env is unchanged;
|
||||
a typo falls closed to the mock). Real adapters use `HttpClient` (typed via `IHttpClientFactory`) +
|
||||
`System.Text.Json` + BCL crypto — **no new NuGet packages**; credentials come from `Seams:*` (appsettings/env). Swapping is a registration change; **no handler is touched**. The adapters:
|
||||
`KavenegarSmsSender` (`Sms:Provider=kavenegar` — **launch-critical**; when a real gateway is selected the
|
||||
Development OTP-in-logs bridge is **disabled**, so the OTP is never logged), `TelegramSmsSender`
|
||||
(`Sms:Provider=telegram` — **broadcast, not a gateway**; the pre-launch demo OTP rail: it posts to the standalone
|
||||
`telegram-otp-bot/` relay, which pushes *every* code to a fixed list of Telegram chat ids, so manual testing
|
||||
beats reading OTPs out of the log. It is the **one non-mock SMS provider that keeps the OTP-capture bridge
|
||||
enabled** — see Startup wiring — and its `Seams:Sms:Telegram:ApiKey` is a user-secret, never committed),
|
||||
`Finnotech{Shahkar,IdentityKyc,
|
||||
BankAccountOwnership}` (`{Shahkar,IdentityKyc,BankOwnership}:Provider=finnotech`, shared `Seams:Finnotech`
|
||||
creds), `NeshanGeocoder` (`Geocoding:Provider=neshan`), `S3ObjectStorage` (`ObjectStorage:Provider=s3` — MinIO/
|
||||
S3/ArvanCloud via **manual AWS SigV4**, presigned GET = the real b6 signed-URL contract), `ZarinPalPaymentProvider`
|
||||
+ `HmacWebhookVerifier` (per-provider HMAC over the raw body) + `ProviderSettlementSplitProvider`
|
||||
(`Payments:Provider=zarinpal`), `SnappPayBnplProvider`/`DigipayBnplProvider` + `ConfiguredBnplProviderResolver`
|
||||
(`Bnpl:Provider=real`; **`balinyaar` = in-house, resolves to the net-of-fee model, no external API**),
|
||||
`JibitBankTransferProvider` (`BankTransfer:Provider=jibit` — **async rail**: accepts as `submitted`, the
|
||||
reconciliation callback `POST webhooks/payouts/{provider}` → `ReconcilePayoutBatchCommand` [HMAC-verified] flips
|
||||
`submitted → paid/failed`), and `MoadianClient` (`Moadian:Provider=moadian`) with the `MoadianReconciliationJob`
|
||||
`IRecurringJob` (6 h, walks `pending/submitted → registered`). **6.4:** `IPaymentCaptureSimulator` is out of the
|
||||
production registration — prod gets the fail-closed `DisabledPaymentCaptureSimulator`; Dev/Testing re-register the
|
||||
succeeding `MockPaymentCaptureSimulator` (the `bookings/convert` path is a Dev/Testing affordance — prod converts
|
||||
via the b10 webhook confirm). **5.6:** `ICredentialVerifier`/`ILicenseVerificationService` stay mock —
|
||||
**manual MoH/INO/eNamad review is the intended MVP** (no public B2B API). `ICurrencyNormalizer` is already
|
||||
config-driven (the real impl). See the mocks-registry for the per-rail config keys.
|
||||
|
||||
**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. 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
|
||||
(`PlatformConfig`, `PartnerCenter`, `Review`, and — refinement-phase-6 — the admin-decided money & trust
|
||||
entities `Refund`, `NurseClawback`, `NursePayout`, `NursePayoutBatch`, `NurseVerification`; encrypted columns
|
||||
like `NursePayout.IbanSnapshot` carry `[AuditRedacted]` so the diff records a marker, never plaintext) 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 expiry sweep is the `booking_request_expiry` `IRecurringJob` run by the scheduler (see
|
||||
"Unattended operation" below — refinement-phase-7 re-homed it from a standalone hosted service). 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`).
|
||||
|
||||
**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 — **reached (refinement-phase-6) by `ConfirmRefundSettlementCommand`**
|
||||
(admin `POST admin_refunds/{id}/confirm_settlement` + the BNPL cash-back callback branch), which transitions
|
||||
`processing → succeeded`, stamps the settled instant, and posts `LedgerPosting.RefundPayableClearing` in the
|
||||
same commit (idempotent under `booking:{id}:refund`); `MarkRefundSettlementFailedCommand` (`.../mark_failed`)
|
||||
is the counterpart. **The refund row is now persisted (approved) *before* the external channel call** — the
|
||||
crash-window fix (claim-first / execute-second), matching the webhook handler.
|
||||
- **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-dep columns — FKs added in refinement-phase-6.** `refunds.ticket_id` (→ `messaging.Tickets`),
|
||||
`nurse_clawbacks.original_payout_id` / `recovered_in_payout_id` (→ `payouts.NursePayouts`),
|
||||
`invoices.partner_center_id` (→ `partner.PartnerCenters`, + index) now carry real FKs (`ON DELETE NO ACTION`;
|
||||
all nullable) — b15 unconditionally auto-opens the refund ticket so `refunds.ticket_id` is always non-null, and
|
||||
the orphaned `refund_ticket_required` config key was retired (its rule had no consumer left). 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 method** — `nurse_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 ~7–10-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 1–5)`, `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_centers` ≠ `organizations`.** 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 (`GetAuditTrail` — `AuditController`)
|
||||
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.
|
||||
|
||||
**Unattended operation — the recurring-job scheduler (refinement-phase-7).** A single in-process scheduler,
|
||||
`Persistence/Services/Scheduling/RecurringJobSchedulerHostedService`, drives every registered `IRecurringJob`
|
||||
(`Services/Scheduling/Jobs/`) on its own cadence — replacing the two stand-alone `PeriodicTimer` hosted services
|
||||
and giving the previously admin-manual sweeps a schedule, **using no new infrastructure** (SQL Server stays the
|
||||
only external dependency). Jobs, each reading its seeded `platform_configs` cadence key via `IPlatformConfig`:
|
||||
`booking_request_expiry` (1 min const) · `notification_retention` (24 h const) · `verification_expiry_scan`
|
||||
(`verification_expiry_scan_cadence_hours`) · `no_show_sweep` (`no_show_scan_cadence_hours`) ·
|
||||
`weekly_payout_generation` (`nurse_payout_interval_days`) · `MoadianReconciliationJob` (6 h, refinement-phase-8) ·
|
||||
`audit_log_retention` (`audit_retention_scan_cadence_hours`, refinement-phase-9). Load-bearing rules:
|
||||
- **Add a cron = implement `IRecurringJob` + one `AddSingleton<IRecurringJob, …>()`** in `AddPersistenceServices`.
|
||||
Phase 8 registers the Moadian reconciliation + refund-settlement poll exactly this way. The scheduler owns the
|
||||
per-tick DI scope, error isolation (a throwing tick never kills the loop), and the lock; a job says only *how
|
||||
often* and *what one idempotent run does*.
|
||||
- **Jobs must be idempotent** — a retry (or a second instance once the lock is Redis-backed) must never double-pay
|
||||
or double-post; the DB uniques/state-machines are the backstop. Each tick runs under
|
||||
`IDistributedLock("scheduler:{name}")` — in-proc today, the **>1-instance scale-out gate** (swap the seam to
|
||||
Redis to serialize ticks across nodes; single-instance MVP needs neither Redis nor Hangfire/Quartz).
|
||||
- **Money movement stays human-approved.** The payout job schedules *generation* only (a `draft` batch, recorded
|
||||
system-initiated — `NursePayoutBatch.InitiatedByAdminId` is nullable = "no human initiator"); the irreversible
|
||||
`process` step remains an explicit admin action. The command's `SystemInitiated` flag is scheduler-only —
|
||||
`AdminPayoutsController` neutralizes any request-supplied value.
|
||||
- **Admin manual triggers remain overrides** (the same idempotent commands). The scheduler is **dormant under the
|
||||
`Testing` environment** so integration tests stay deterministic; each job/command is unit-tested directly.
|
||||
- **Audit-log retention (refinement-phase-9 §9.4)** is an `IRecurringJob` (`AuditLogRetentionJob`) over the
|
||||
append-only `ops.AuditLogs`: a **two-tier** sweep via `IAuditLogger.PurgeExpiredAsync` — financial/verification
|
||||
entity types (`Refund`/`NurseClawback`/`NursePayout`/`NursePayoutBatch`/`NurseVerification`/`PlatformConfig`/
|
||||
`PartnerCenter`) keep `audit_retention_financial_days` (default 2555 ≈ 7 yr); everyday rows
|
||||
`audit_retention_general_days` (default 730 ≈ 2 yr). Oldest-first, capped, id-keyed delete; idempotent.
|
||||
|
||||
**Observability (refinement-phase-9).** One **OpenTelemetry** stack (`Baya.Infrastructure.Monitoring`,
|
||||
`SetupOpenTelemetry`): metrics (runtime + ASP.NET Core + the `mediator_meter` histogram) scraped at `/metrics` via
|
||||
the OTel Prometheus exporter, and **tracing** (ASP.NET Core + EF Core) sharing `service.name = Baya.Web.Api`. The
|
||||
duplicate prometheus-net stack was removed. **OTLP export (traces + metrics) is opt-in** — wired only when
|
||||
`OpenTelemetry:Otlp:Endpoint` is set, so an MVP with Prometheus alone runs unchanged. `ApiResult.RequestId` is the
|
||||
W3C trace id (`Activity.Current.TraceId`, `Activity.DefaultIdFormat = W3C`), so a support ticket maps 1:1 to a
|
||||
trace. **Health checks split** (`ConfigureHealthChecks`/`UseHealthChecks`): `/healthz/live` (process, dependency-
|
||||
free), `/healthz/ready` (app DB + `logDb` [deployed only] + an `IObjectStorage` write-probe), `/HealthCheck`
|
||||
(aggregate, kept for compat). **Logs:** deployed envs write **Information+** to `Baya_Logs` (framework categories
|
||||
held at Warning); **no PII/secrets** — the mock SMS sender never logs the OTP code; clinical text/IBANs are
|
||||
encrypted/masked. The dead Elasticsearch sink + package were removed (SQL sink is the deployed default; set the
|
||||
OTLP collector to ship logs off-box). **gRPC reflection is Development-only** (`GrpcPluginStartup` gates
|
||||
`AddGrpcReflection`/`MapGrpcReflectionService` on `IsDevelopment`); the plugin shares the mixed-protocol Kestrel
|
||||
listener. **`TicketMessage.Body` is encrypted at rest** through `IFieldEncryptor` (converter in
|
||||
`ApplicationDbContext`; column widened to `nvarchar(max)`; the 4000-char cap stays a boundary-validation rule) —
|
||||
ticket bodies are the refund/dispute paper trail (phone numbers, addresses, clinical detail).
|
||||
|
||||
**Keeping the Project map current.** When a change touches the architecture — adds, removes, or
|
||||
renames a project/assembly, a Clean-Architecture layer, or a major folder, or changes a cross-layer
|
||||
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.
|
||||
> `Features/Booking` (singular — the money-free pre-payment request) and `Features/Bookings` (plural — the
|
||||
> post-payment engine) are **different areas, not a rename.** The entity type `Booking` is aliased where the
|
||||
> namespaces collide.
|
||||
|
||||
---
|
||||
|
||||
## Startup wiring
|
||||
## Where to read more
|
||||
|
||||
Service registration is composed from per-layer extension methods (each project's `ServiceConfiguration/`):
|
||||
Open **one** of these for the area you are touching.
|
||||
|
||||
```
|
||||
builder.ValidateRequiredSecrets() // refinement-phase-5: fail fast on missing/placeholder DB + crypto secrets
|
||||
ConfigureHealthChecks() · SetupOpenTelemetry() // refinement-phase-9: live/ready health split + object-storage probe; one OTel stack (metrics + tracing, opt-in OTLP)
|
||||
AddApplicationServices() // Mediator + pipeline behaviors (Logging → Metrics → Validate)
|
||||
RegisterIdentityServices(…, requireHttpsMetadata) // Identity, JWT/JWE (RequireHttpsMetadata on outside Dev/Testing), ICurrentUser
|
||||
AddPersistenceServices(...) // DbContext (+ AuditFieldInterceptor), UnitOfWork, repositories, the IRecurringJob crons + RecurringJobSchedulerHostedService (refinement-phase-7)
|
||||
AddCrossCuttingSeams(config) // IDateTimeProvider, IFieldEncryptor, ICacheService, IObjectStorage, INotificationDispatcher (mocks)
|
||||
AddWebFrameworkServices() // API versioning + snake_case routing
|
||||
AddCorsPolicies(config) // browser CORS policy from Cors:AllowedOrigins (refinement-phase-0; default http://localhost:3000 in Dev)
|
||||
AddForwardedHeadersConfiguration(config) // refinement-phase-5: trust ForwardedHeaders:KnownProxies/KnownNetworks so the rate limiter sees the real client IP behind a proxy
|
||||
AddRateLimitingPolicies() // built-in rate limiter: per-resolved-IP global + named (otp/auth/sensitive/webhook)
|
||||
AddSwagger("v1", "v1.1") · RegisterValidatorsAsServices() · AddMapster()
|
||||
ConfigureGrpcPluginServices(builder.Environment) // refinement-phase-9: gRPC reflection registered only in Development
|
||||
// Development-only: AddDevelopmentOtpCapture() (refinement-phase-0) decorates the registered ISmsSender to
|
||||
// capture each OTP in-memory for the GET /api/v1/dev/last_otp/{phone} helper — never wired outside Development,
|
||||
// and only for a capture-safe Seams:Sms:Provider (`mock` / unset, or the Development-only `telegram` relay).
|
||||
// A real gateway (kavenegar) disables it, so the code only ever leaves the process over the SMS wire.
|
||||
```
|
||||
|
||||
Pipeline order: **forwarded headers** → exception handler → Swagger → routing → **CORS → rate limiter →
|
||||
authentication → authorization** → controllers → metrics → health checks → gRPC. `UseForwardedHeaders()`
|
||||
(refinement-phase-5) is **first** so the resolved client IP (`X-Forwarded-For` from a trusted proxy) is in
|
||||
place before the rate limiter partitions on it. `UseCors(...)` (refinement-phase-0) sits **after
|
||||
`UseRouting()` and before `UseRateLimiter()`** so a pre-flight `OPTIONS` is answered before the limiter/auth
|
||||
run; `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).
|
||||
- **Development demo seeder (refinement-phase-1).** `Persistence/Services/Seeding/DemoWorldSeeder.cs`
|
||||
(+ `DemoWorldDefinitions.cs`) idempotently populates a coherent demo marketplace on top of the reference
|
||||
`HasData` seeds — 3 nurses (2 verified w/ variants + Tehran coverage + `approved` verification + credentials
|
||||
+ a `matched_national_id` bank account, 1 unverified), 2 customers (patients + addresses), **2 phone-OTP
|
||||
admins** (refinement-phase-2: a `super_admin` + a scoped `finance` operator, so the `/admin` console is
|
||||
reachable through the normal phone-OTP login and `useAdminCapabilities` gating is demonstrable — admin
|
||||
sub-roles are server-granted, never self-selectable), and one cross-category required demo option group
|
||||
(شیفت / *Shift Type*). It writes through the real entities and
|
||||
drives the search projection through `ISearchIndexMaintainer.RebuildAsync` (never hand-inserts index rows),
|
||||
guarding each persona on its phone number so re-runs are a no-op. Invoked via `SeedDemoWorldAsync()`
|
||||
**only under `IsDevelopment()`** — never in Production/Staging. The demo world (phones, which nurse is
|
||||
verified) is in `dev/post-phase/refinement/RUNBOOK.md`.
|
||||
- **Development lifecycle seeder (manual-testing bring-up).** `Persistence/Services/Seeding/DemoLifecycleSeeder.cs`
|
||||
(+ `.Money.cs`/`.Social.cs` partials + `DemoLifecycleDefinitions.cs`) layers a full **lifecycle** world on the
|
||||
demo personas so every flow is manually testable: booking requests in every status, 8 bookings across every
|
||||
reachable state (upcoming w/ care instructions, a 5-session package mid-engagement with EVV, completed
|
||||
inside/past the dispute window, BNPL-settled, cancelled-with-refund, clawed-back), the balanced payment
|
||||
ledger behind each (via `LedgerPosting`), refunds on all three forks, a **paid** and a **draft** payout batch
|
||||
(dispatched through the real `GeneratePayoutBatch`/`ExecutePayoutBatch` commands), moderated reviews +
|
||||
recomputed nurse aggregates, tickets (incl. an `is_internal` note + coordination tickets via the real
|
||||
command), notifications, patient care records, a merchant-of-record partner center (portal user
|
||||
`09120000030`, linked to the second nurse), and a mid-pipeline verification case for the unverified nurse.
|
||||
States are reached through the entities' guarded transition methods + `BookingFactory` (Application grants
|
||||
`InternalsVisibleTo` to Persistence for this); business timestamps are backdated explicitly. Idempotent per
|
||||
scenario on natural keys — **never guard on a Persian string**: the `ApplicationDbContext` save hook
|
||||
normalizes Persian digits/ZWNJ in every stored string, so a Persian literal never round-trips equal.
|
||||
Invoked via `SeedDemoLifecycleAsync()` after the demo-world + gateway seeds, Development-only. Scenario
|
||||
table + testing plan: `dev/post-phase/manual-testing-plan.md`.
|
||||
|
||||
---
|
||||
|
||||
## 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`; `SeedDataBase` always seeds the roles,
|
||||
and seeds a **bootstrap admin only when `Seed:AdminUsername`/`Seed:AdminPassword` are configured**
|
||||
(refinement-phase-5 — no more committed `admin`/`qw123321`; break-glass only, day-to-day admins come from
|
||||
the phone-OTP demo seeds or are provisioned out-of-band). `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`. The base `appsettings.json` carries
|
||||
`SET_VIA_USER_SECRETS_OR_ENV` placeholders that `StartupSecretsGuard` rejects; the real values live in the
|
||||
environment-specific file (`appsettings.Development.json` holds the dev-only keys the demo deployment runs on). `RequireHttpsMetadata` is **on outside Dev/Testing**
|
||||
(passed into `RegisterIdentityServices`), the access-token lifetime is `ExpirationMinutes: 60`, and
|
||||
`Issuer`/`Audience` are real (`Balinyaar`/`BalinyaarClient`) — refinement-phase-5.
|
||||
- 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`. The two
|
||||
PSP/BNPL webhooks share the single deliberate **`webhook`** policy (bursty-tolerant, partitioned per-provider);
|
||||
behind a reverse proxy the limiter partitions on the forwarded client IP (see Startup wiring).
|
||||
|
||||
---
|
||||
|
||||
## 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 |
|
||||
| Working on… | Read |
|
||||
| --- | --- |
|
||||
| Projects, layers, startup wiring, the seam catalogue, observability | [docs/rules/server/structure.md](../docs/rules/server/structure.md) |
|
||||
| Adding a feature — command, query, handler, validator, controller | [docs/rules/server/cqrs.md](../docs/rules/server/cqrs.md) |
|
||||
| EF Core, audit, state machines, uniqueness, snapshots, search, jobs, seeders | [docs/rules/server/persistence.md](../docs/rules/server/persistence.md) |
|
||||
| **Anything on the money path** — ledger, refunds, BNPL, payouts, invoices | [docs/rules/server/money.md](../docs/rules/server/money.md) |
|
||||
| Auth, JWE, sessions, field encryption, tenancy, disclosure, logging | [docs/rules/server/identity.md](../docs/rules/server/identity.md) |
|
||||
| C# style, naming, async, error handling, tests, DI | [docs/rules/server/conventions.md](../docs/rules/server/conventions.md) |
|
||||
| The wire contract — envelope, status codes, enums, pagination | [docs/integration/](../docs/integration/index.md) |
|
||||
| What is built, what is mocked, what is next | [docs/status/](../docs/status/index.md) |
|
||||
| Cross-project rules — naming, gates, code quality, config | [docs/rules/shared/](../docs/rules/shared/) |
|
||||
|
||||
@@ -1,508 +0,0 @@
|
||||
# Server Coding Conventions
|
||||
|
||||
Rules enforced for all code in `server/`. These represent the standards expected from a **senior .NET engineer**. Read alongside [CLAUDE.md](CLAUDE.md).
|
||||
|
||||
When in doubt, ask: _would a senior engineer approve this diff without comment?_
|
||||
|
||||
---
|
||||
|
||||
## 1. Routing
|
||||
|
||||
### Rule: all URL segments must be `snake_case`
|
||||
|
||||
`SnakeCaseParameterTransformer` (`Baya.WebFramework/Routing/`) is registered globally via `RouteTokenTransformerConvention`. It converts `[controller]` and `[action]` tokens automatically.
|
||||
|
||||
```csharp
|
||||
// ✅ transformer converts MyFeature → my_feature, GetBySlug → get_by_slug
|
||||
[Route("api/v{version:apiVersion}/[controller]")]
|
||||
public class MyFeatureController : BaseController
|
||||
{
|
||||
[HttpGet("[action]")]
|
||||
public Task<IActionResult> GetBySlug(...) { }
|
||||
}
|
||||
|
||||
// ❌ bypasses transformer — hardcoded segment escapes snake_case enforcement
|
||||
[Route("api/v{version:apiVersion}/MyFeature")]
|
||||
[HttpGet("GetBySlug")]
|
||||
```
|
||||
|
||||
If a method name doesn't read cleanly as a URL, **rename the method** — don't hardcode the route string.
|
||||
|
||||
---
|
||||
|
||||
## 2. C# code quality
|
||||
|
||||
### Use the right type for the job
|
||||
|
||||
| Scenario | Use |
|
||||
|---|---|
|
||||
| Request/response/DTO | `record` (immutable, value semantics) |
|
||||
| Domain entity | `class` (mutable state, encapsulated) |
|
||||
| Shared small value | `readonly record struct` |
|
||||
| Handler, service | `sealed class` |
|
||||
|
||||
### Language features — use them
|
||||
|
||||
```csharp
|
||||
// ✅ primary constructor (C# 12)
|
||||
public sealed class OrderHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<...> { }
|
||||
|
||||
// ✅ switch expression over if/else chains
|
||||
var label = status switch
|
||||
{
|
||||
OrderStatus.Pending => "Pending",
|
||||
OrderStatus.Shipped => "Shipped",
|
||||
OrderStatus.Cancelled => "Cancelled",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(status))
|
||||
};
|
||||
|
||||
// ✅ pattern matching
|
||||
if (result is { IsSuccess: false, IsNotFound: true }) return NotFound();
|
||||
|
||||
// ✅ collection expressions (C# 12)
|
||||
List<string> tags = ["new", "sale"];
|
||||
```
|
||||
|
||||
### Immutability & safety
|
||||
|
||||
- Mark fields `readonly` unless mutation is genuinely needed.
|
||||
- Prefer `IReadOnlyList<T>` / `IReadOnlyCollection<T>` over `List<T>` in signatures unless the caller needs to mutate.
|
||||
- Never expose public setters on entities — use methods or constructors.
|
||||
- Avoid `static` mutable state.
|
||||
|
||||
### Null handling
|
||||
|
||||
- Enable `<Nullable>enable</Nullable>` in any new project you create.
|
||||
- Use guard clauses at the entry point; don't scatter null checks throughout.
|
||||
- Prefer returning `OperationResult.NotFoundResult(...)` over returning `null` from handlers.
|
||||
- Never use `null!` (null-forgiving) unless you can prove the value cannot be null and the compiler cannot.
|
||||
|
||||
### Naming
|
||||
|
||||
| Kind | Convention | Example |
|
||||
|---|---|---|
|
||||
| Class, record, interface | PascalCase | `OrderHandler`, `IOrderRepository` |
|
||||
| Method | PascalCase | `GetUserOrdersAsync` |
|
||||
| Parameter, local variable | camelCase | `orderId`, `userEmail` |
|
||||
| Private field | `_camelCase` | `_unitOfWork` |
|
||||
| Constant | PascalCase | `MaxRetryCount` |
|
||||
| Generic type param | `T` or descriptive `TEntity` | |
|
||||
| Command | `{Verb}{Noun}Command` | `CreateOrderCommand` |
|
||||
| Query | `{Verb}{Noun}Query` | `GetUserOrdersQuery` |
|
||||
| Handler | `{RequestName}Handler` | `CreateOrderCommandHandler` |
|
||||
| Result DTO | `{RequestName}Result` | `CreateOrderCommandResult` |
|
||||
|
||||
No abbreviations unless universally understood (`dto`, `id`, `url`). No Hungarian notation (`strName`, `intCount`).
|
||||
|
||||
### No unused code
|
||||
|
||||
Leave nothing dead behind. Remove unused `using` directives, local variables, parameters, private fields, and private members rather than letting them accumulate.
|
||||
|
||||
- These already surface as compiler/analyzer signals — `CS0168` (variable declared, never used), `CS0219` (variable assigned, value never used), `CS0169` (private field never used), `IDE0005` (unnecessary `using`). The quality gate is **zero new warnings**, so treat unused code as a gate failure.
|
||||
- **Delete it — don't silence it.** Do not add `#pragma warning disable`, throwaway discards, or `_ =` assignments just to quiet the analyzer.
|
||||
- The one exception: a parameter that must exist to satisfy an interface or delegate signature but is genuinely unused. Keep it, name it conventionally, and add a one-line `// why` only if the reason isn't obvious.
|
||||
|
||||
### Comments — explain *why*, never *what*
|
||||
|
||||
Code that needs a comment to be understood usually needs a better name instead. Prefer self-documenting names over prose.
|
||||
|
||||
- **Do not** write comments that restate what the code already says — no `// constructor`, `// loop over users`, or XML-doc that merely echoes the method name.
|
||||
- **Do** add a comment only where a non-obvious decision, constraint, business rule, workaround, or trade-off is *not* evident from the code — explain the reasoning, not the mechanics.
|
||||
- Keep any necessary comment tight, and delete comments that no longer match the code.
|
||||
|
||||
```csharp
|
||||
// ❌ restates the obvious
|
||||
// increment the retry counter
|
||||
retryCount++;
|
||||
|
||||
// ✅ captures a non-obvious constraint the code can't express on its own
|
||||
// Payment gateway rejects amounts above 50M IRR per call; split larger settlements upstream.
|
||||
if (amount > MaxPerCallRial) ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Async / await
|
||||
|
||||
```csharp
|
||||
// ✅ always async all the way — no .Result or .Wait()
|
||||
public async ValueTask<OperationResult<T>> Handle(MyQuery request, CancellationToken ct)
|
||||
{
|
||||
var entity = await _repository.GetAsync(request.Id, ct);
|
||||
return OperationResult<T>.SuccessResult(_mapper.Map(entity));
|
||||
}
|
||||
|
||||
// ❌ blocks the thread, risks deadlock
|
||||
var result = _repository.GetAsync(id).Result;
|
||||
|
||||
// ✅ pass CancellationToken through every async call
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// ❌ fire and forget with no error handling
|
||||
_ = DoSomethingAsync();
|
||||
```
|
||||
|
||||
- Every public async method must accept `CancellationToken` and pass it downstream.
|
||||
- Use `ValueTask<T>` for hot paths (handlers, repositories). Use `Task<T>` for rarely-called or always-async methods.
|
||||
- Never use `async void` — it swallows exceptions. Use `async Task` even for event-like callbacks.
|
||||
- Do not add `.ConfigureAwait(false)` in this ASP.NET Core app — it's unnecessary and adds noise.
|
||||
|
||||
---
|
||||
|
||||
## 4. Controllers
|
||||
|
||||
Every controller must follow this skeleton:
|
||||
|
||||
```csharp
|
||||
[ApiVersion("1")]
|
||||
[ApiController]
|
||||
[Route("api/v{version:apiVersion}/[controller]")]
|
||||
[Display(Description = "One-line description shown in Swagger")]
|
||||
[Authorize(ConstantPolicies.DynamicPermission)] // or [Authorize], or omit for public
|
||||
public sealed class MyFeatureController(ISender sender) : BaseController
|
||||
{
|
||||
[HttpGet("[action]")]
|
||||
[ProducesOkApiResponseType<MyQueryResult>]
|
||||
public async Task<IActionResult> GetSomething(CancellationToken ct)
|
||||
=> OperationResult(await sender.Send(new MyQuery(), ct));
|
||||
|
||||
[HttpPost("[action]")]
|
||||
[ProducesOkApiResponseType<MyCommandResult>]
|
||||
public async Task<IActionResult> CreateSomething(MyCommand command, CancellationToken ct)
|
||||
=> OperationResult(await sender.Send(command, ct));
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
- `sealed` — controllers are not designed for inheritance beyond `BaseController`.
|
||||
- Inject `ISender` via primary constructor — not `IMediator`.
|
||||
- **Never call `Ok()`, `BadRequest()`, `NotFound()` directly** — always `base.OperationResult(result)`.
|
||||
- Keep controller methods thin: one `Send`, one `OperationResult`. No business logic in controllers.
|
||||
- Use `[Display(Description = "...")]` so NSwag generates meaningful Swagger tags.
|
||||
- Pass `CancellationToken` from the action into `sender.Send(...)`.
|
||||
|
||||
### Authorization levels — use the narrowest that fits
|
||||
|
||||
| Attribute | When |
|
||||
|---|---|
|
||||
| _(none)_ | Truly public (health check, metrics) |
|
||||
| `[Authorize]` | Any authenticated user |
|
||||
| `[Authorize(ConstantPolicies.DynamicPermission)]` | Role/claim-gated admin action |
|
||||
| `[RequireTokenWithoutAuthorization]` | Token must be present but may be expired (e.g. refresh) |
|
||||
|
||||
Apply at the **controller level** for uniform policy; override at the action level only for exceptions.
|
||||
|
||||
---
|
||||
|
||||
## 5. CQRS — feature structure
|
||||
|
||||
```
|
||||
Features/<Area>/
|
||||
├── Commands/<VerbNoun>Command/
|
||||
│ ├── <Name>Command.cs record Command(…) : IRequest<OperationResult<T>>
|
||||
│ ├── <Name>Command.Handler.cs internal sealed class Handler : IRequestHandler<…>
|
||||
│ └── <Name>Command.Validator.cs AbstractValidator<Command> (omit if no validation needed)
|
||||
└── Queries/<VerbNoun>Query/
|
||||
├── <Name>Query.cs record Query(…) : IRequest<OperationResult<T>>
|
||||
├── <Name>Query.Handler.cs internal sealed class Handler : IRequestHandler<…>
|
||||
└── <Name>Query.Result.cs record Result(…) ← the DTO returned
|
||||
```
|
||||
|
||||
- Request types are `record` — immutable.
|
||||
- Handlers are `internal sealed` — they are never used outside the Application layer.
|
||||
- **Handlers must not throw for expected failures.** Use `OperationResult` factory methods:
|
||||
- `OperationResult<T>.SuccessResult(value)` — happy path
|
||||
- `OperationResult<T>.FailureResult(errors)` — validation / business rule failure
|
||||
- `OperationResult<T>.NotFoundResult(message)` — entity not found
|
||||
- Only one handler per request type — no conditional dispatch.
|
||||
- Contracts the handler depends on go in `Application/Contracts/` as interfaces; implementations live in Infrastructure.
|
||||
|
||||
---
|
||||
|
||||
## 6. Persistence — EF Core rules
|
||||
|
||||
```csharp
|
||||
// ✅ project to DTO in the query — never load full entity for read operations
|
||||
var dto = await _db.Orders
|
||||
.AsNoTracking()
|
||||
.Where(o => o.UserId == userId)
|
||||
.Select(o => new OrderResult(o.Id, o.Status, o.CreatedAt))
|
||||
.ToListAsync(ct);
|
||||
|
||||
// ❌ loads entire entity graph then maps in memory — N+1 risk
|
||||
var orders = await _db.Orders.Include(o => o.Lines).ToListAsync();
|
||||
var dtos = _mapper.Map<List<OrderResult>>(orders);
|
||||
```
|
||||
|
||||
Rules:
|
||||
- **Always use `AsNoTracking()`** on read-only queries.
|
||||
- **Always project with `Select()`** in queries — never hydrate full entities just to map them.
|
||||
- Never load more than you need. Pagination is mandatory for any unbounded list: `Skip` / `Take`.
|
||||
- Use `Include` only in command handlers where you need to mutate the aggregate and need navigation properties loaded.
|
||||
- Access the DB through `IUnitOfWork` in Application-layer handlers. `ApplicationDbContext` is only referenced directly inside Infrastructure.
|
||||
- Commit once per command at the end: `await _unitOfWork.CommitAsync(ct)`.
|
||||
- One `IEntityTypeConfiguration<T>` per entity, in `Persistence/Configuration/<Area>Config/`.
|
||||
- Migrations command: `dotnet ef migrations add <Name> --project src/Infrastructure/Baya.Infrastructure.Persistence --startup-project src/API/Baya.Web.Api`
|
||||
|
||||
### Soft delete
|
||||
|
||||
Every entity that supports soft delete **must** declare a global EF query filter in its `IEntityTypeConfiguration<T>`:
|
||||
|
||||
```csharp
|
||||
public void Configure(EntityTypeBuilder<Order> builder)
|
||||
{
|
||||
builder.HasQueryFilter(o => !o.IsDeleted);
|
||||
}
|
||||
```
|
||||
|
||||
Without this filter, soft-deleted records appear in every query that doesn't explicitly filter them — a silent data leak. Never add `Where(x => !x.IsDeleted)` in individual queries; the filter makes it automatic and auditable.
|
||||
|
||||
### Entity audit fields
|
||||
|
||||
When designing or extending an entity, include audit fields alongside timestamps:
|
||||
|
||||
| Field | Type | Set by |
|
||||
|---|---|---|
|
||||
| `CreatedAt` | `DateTimeOffset` | `SaveChangesAsync` override (on Add) |
|
||||
| `ModifiedAt` | `DateTimeOffset` | `SaveChangesAsync` override (on Update) |
|
||||
| `CreatedById` | `int?` | `SaveChangesAsync` override via `ICurrentUser` |
|
||||
| `ModifiedById` | `int?` | `SaveChangesAsync` override via `ICurrentUser` |
|
||||
|
||||
Wire `ICurrentUser` (HTTP context accessor wrapped in an interface, registered Scoped) into `ApplicationDbContext` so the context can stamp who made the change without handlers needing to pass it explicitly. Audit fields cannot be backfilled retroactively — design them in from the start.
|
||||
|
||||
> **As built (backend-phase-0):** the audit base type is `BaseEntity`/`IAuditableEntity` in
|
||||
> `Baya.Domain/Common/BaseEntity.cs` (`CreatedAt`/`ModifiedAt` as `DateTimeOffset`, `CreatedById`/
|
||||
> `ModifiedById` as `int?`). Stamping is done by `AuditFieldInterceptor`
|
||||
> (`Baya.Infrastructure.Persistence/Interceptors/`), a `SaveChangesInterceptor` that reads time from
|
||||
> `IDateTimeProvider` and the user from `ICurrentUser` — not in the `DbContext` itself.
|
||||
|
||||
> **As built (backend-phase-1) — reusable patterns you should follow:**
|
||||
> - **Config is rows, read at compute time.** Money-critical constants (commission %, VAT, deadlines,
|
||||
> EVV tolerance, cancellation tiers) live in `platform_configs`, read via `IPlatformConfig.GetConfig<T>`
|
||||
> (cached, parsed by the row's `data_type`) — **never hardcode**. Changing a rate must never
|
||||
> retroactively alter an already-computed amount: later phases snapshot the rate onto the
|
||||
> booking/invoice at compute time; do not live-re-read a rate for an already-priced row.
|
||||
> - **Append-only audit trail.** `audit_logs` is immutable — there is **no** update/delete path in app
|
||||
> code. Mark a compliance-sensitive entity with `IAuditable` (`Baya.Domain/Common`) and the
|
||||
> `AuditFieldInterceptor` writes an old/new diff row per change in the same transaction; annotate any
|
||||
> encrypted/PII property with `[AuditRedacted]` so it is redacted (never plaintext) in the diff.
|
||||
> `platform_configs` is the first `IAuditable` entity.
|
||||
> - **DB-backed platform facades** (`IPlatformConfig`/`IHolidayCalendar`/`IAnalyticsSink`/`IAuditLogger`/
|
||||
> `INotificationService`/`ISupportAlertService`) live in `Persistence/Services/` and are the contracts
|
||||
> other domains reuse — don't re-query these tables directly. `IAnalyticsSink` is fire-and-forget
|
||||
> (never fail the caller); `INotificationService`/notification endpoints are always tenant-scoped to
|
||||
> `ICurrentUser`; `support_alerts` are admin-only and never appear on a user-facing route.
|
||||
> - **Retention/scheduling seam.** Background jobs run behind the hosted-service seam
|
||||
> (`NotificationRetentionHostedService`); real Hangfire/Quartz is deferred. The notification retention
|
||||
> predicate is exactly `is_read = 1 AND age > 90d` — unread is never auto-deleted.
|
||||
|
||||
### Money is IRR `BIGINT` — integer-only, no floats
|
||||
|
||||
Every monetary value is **IRR Rials stored as `long` / `BIGINT`**. There is **no float/decimal path** on money — not in entities, DTOs, the API, or arithmetic. Toman is display-only and converts to/from Rials **only** inside a provider adapter at its boundary, never in domain or shared code. If a money value object is introduced later it must be integer-only. The three booking amounts always satisfy `gross = commission + payout`.
|
||||
|
||||
### Deterministic set-hash for multi-row uniqueness
|
||||
|
||||
When "no two rows may share the same *set* of child rows" must be enforced (e.g. a nurse can't list two
|
||||
identical variants — same category + identical answered option-set), a plain composite unique index can't
|
||||
express it because the set spans multiple rows. Reduce the set to a single comparable column with
|
||||
**`Baya.Application.Common.OptionSetHash.Compute(pairs)`** (backend-phase-5): it sorts the `(long, long)`
|
||||
pairs and SHA-256s them to a stable 64-char hex hash that is **order-independent** (identical sets always
|
||||
collide). Persist it (`NVARCHAR(64)`) and back it with a **filtered unique index** (e.g.
|
||||
`UNIQUE(nurse_id, service_category_id, option_set_hash) WHERE deleted_at IS NULL`) as the race-safe backstop,
|
||||
with a handler pre-check for the friendly `409`. Reuse this helper for any future "same set of ids" guard;
|
||||
do **not** reuse `IFieldEncryptor.Hash` (that is for PII-column equality lookups).
|
||||
|
||||
### Guarded cross-aggregate state flip (backend-phase-6)
|
||||
|
||||
When one write must atomically change a header row's state **and** a derived boolean on a *different*
|
||||
aggregate (e.g. `nurse_verifications.status` → `nurse_profiles.is_verified`), do it in one transaction:
|
||||
load **both** as tracked entities, mutate them through a single pure domain helper
|
||||
(`VerificationAggregator.Finalize`), then `CommitAsync` **once** — never flip the derived flag from a
|
||||
controller, a partial write, or an out-of-band update, and never leave an in-between state. Two follow-on
|
||||
rules this establishes:
|
||||
|
||||
- **Self-committing facades come after the atomic commit.** `ISupportAlertService.RaiseAsync`,
|
||||
`INotificationDispatcher.DispatchAsync`, `IAuditLogger.WriteAsync` and `IPlatformConfig.SetConfig` each call
|
||||
`SaveChanges` on the *shared scoped* `DbContext`. Calling one mid-build flushes your partial tracked changes —
|
||||
invoke them only **after** `unitOfWork.CommitAsync()`. In a batch loop that commits per item, load and guard
|
||||
every dependency **before** mutating tracked state, or an early `continue` leaks a dirty entity that a later
|
||||
iteration's commit will flush.
|
||||
- **Persist a status enum as its stable snake_case code, not the member name.** Define the C# enum, then map
|
||||
it with a `HasConversion(e => e.ToCode(), s => Parse(s))` value converter (see `VerificationCodes`) so the DB
|
||||
and the wire carry `in_review`, not `InReview`. Enum→code mapping in a projected read happens **in memory
|
||||
after materialization** (`.ToCode()` is not LINQ-translatable); DTOs expose the code string.
|
||||
|
||||
### Forward-only status machine (backend-phase-8)
|
||||
|
||||
When an entity has a lifecycle `status` with a fixed set of allowed transitions, model the machine as a
|
||||
**static allowed-edges table** and route **every** write through it — never assign `status` ad-hoc. The b8
|
||||
pattern (reused by b9 for the `bookings` machine):
|
||||
|
||||
- **Statuses are `const string` codes** (`BookingRequestStatus`) persisted as the stable snake_case string —
|
||||
no C# enum, no value converter needed. **Edges live in a static `CanTransition(from, to)`**
|
||||
(`BookingRequestTransitions`) built from a `Dictionary<string, IReadOnlyCollection<string>>`; terminal
|
||||
states map to an empty set.
|
||||
- **The entity owns the transition.** `status` has a **private setter**; the only mutators are cohesive domain
|
||||
methods (`Accept`/`Reject`/`Cancel…`) that call a private `Transition(target)` which asserts the edge is
|
||||
legal (throws on an illegal edge — a programming error, since the handler pre-checks). Side-effect fields
|
||||
(`payment_deadline_at`, `rejection_reason`) are set in the same method.
|
||||
- **The handler pre-checks and returns a clean 409.** `if (!entity.CanTransitionTo(target)) return
|
||||
OperationResult.ConflictResult(...)` — never throw for the expected "already moved / terminal" case.
|
||||
- **Time-sensitive commands self-guard** against a passed deadline via `IDateTimeProvider` rather than trusting
|
||||
a sweep has run; the recurring expiry `BackgroundService` is bounded/paginated/idempotent, and its
|
||||
`WHERE status = …` predicate (re-queried each tick) is the concurrency guard — a row a racing action moved is
|
||||
simply not reloaded.
|
||||
- **Deadline columns that are compared/sorted use `DateTime` (UTC `datetime2`), not `DateTimeOffset`** — the
|
||||
SQLite test provider cannot translate `DateTimeOffset` comparison/`ORDER BY`. Order lists/sweeps by `Id`, not
|
||||
the timestamp, for the same reason.
|
||||
|
||||
---
|
||||
|
||||
## 7. Validation
|
||||
|
||||
- All commands that accept user input need a `FluentValidation` validator. The `ValidateCommandBehavior` pipeline behavior runs it automatically before the handler.
|
||||
- Validators are registered automatically via `RegisterValidatorsAsServices()` in `Program.cs`.
|
||||
- Validate at the boundary (command/query), not deep in the domain or repositories.
|
||||
|
||||
```csharp
|
||||
public sealed class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
|
||||
{
|
||||
public CreateOrderCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.UserId).GreaterThan(0);
|
||||
RuleFor(x => x.Items).NotEmpty().WithMessage("Order must have at least one item.");
|
||||
RuleForEach(x => x.Items).ChildRules(item =>
|
||||
{
|
||||
item.RuleFor(i => i.ProductId).GreaterThan(0);
|
||||
item.RuleFor(i => i.Quantity).InclusiveBetween(1, 100);
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Mapping — Mapster rules
|
||||
|
||||
- Use `IMapper` (injected via DI) for all entity↔DTO mapping in handlers.
|
||||
- Register type adapter configs in `Program.cs` via `TypeAdapterConfig.GlobalSettings.Scan(...)`. Add new assemblies that contain mapping configs there.
|
||||
- Never write manual mapping code when Mapster can infer it — only write custom `TypeAdapterConfig` when shapes diverge.
|
||||
- Mapping happens **in the handler after the DB query**, not in the repository.
|
||||
|
||||
---
|
||||
|
||||
## 9. Error handling & logging
|
||||
|
||||
```csharp
|
||||
// ✅ expected failure — use OperationResult, do not throw
|
||||
if (user is null)
|
||||
return OperationResult<T>.NotFoundResult("User not found.");
|
||||
|
||||
// ✅ unexpected failure — let it propagate; ExceptionHandler middleware catches it
|
||||
// Log at the point you catch unexpected exceptions (ExceptionHandler logs automatically)
|
||||
|
||||
// ❌ swallowing exceptions
|
||||
try { ... } catch { return OperationResult<T>.FailureResult(...); }
|
||||
|
||||
// ✅ structured logging — never interpolate sensitive data
|
||||
_logger.LogInformation("Order {OrderId} created for user {UserId}", order.Id, userId);
|
||||
|
||||
// ❌ logs PII / secrets
|
||||
_logger.LogInformation($"Token for {user.Email}: {token}");
|
||||
```
|
||||
|
||||
- Log at the correct level: `Debug` for trace info, `Information` for meaningful events, `Warning` for recoverable issues, `Error` for unexpected failures.
|
||||
- Never log passwords, tokens, secrets, or full PII (email is borderline — use `userId` in logs instead).
|
||||
- The global `ExceptionHandler` middleware catches unhandled exceptions — do not add try/catch in handlers for unknown exceptions; let them propagate.
|
||||
|
||||
---
|
||||
|
||||
## 10. Testing
|
||||
|
||||
### Arrange — Act — Assert, always
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task CreateOrder_ValidCommand_ReturnsSuccess()
|
||||
{
|
||||
// Arrange
|
||||
var command = new CreateOrderCommand(UserId: 1, Items: [new(ProductId: 5, Quantity: 2)]);
|
||||
var handler = new CreateOrderCommandHandler(_unitOfWork, _mapper);
|
||||
|
||||
// Act
|
||||
var result = await handler.Handle(command, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.Result.Should().NotBeNull();
|
||||
}
|
||||
```
|
||||
|
||||
- Test the **handler directly** — not the controller. Controllers are thin wrappers.
|
||||
- Use `NSubstitute` for mocking: `Substitute.For<IUnitOfWork>()`.
|
||||
- Integration tests use `Baya.Tests.Setup` which provides an in-memory SQLite context — prefer this over mocking the DB for persistence tests.
|
||||
- Name tests: `{MethodUnderTest}_{Scenario}_{ExpectedOutcome}`.
|
||||
- One assertion concept per test. Multiple `.Should()` calls are fine if they all verify the same outcome.
|
||||
- Do not test EF internals (entity tracking, migrations) — test behavior through the handler.
|
||||
|
||||
### Integration tests — HTTP pipeline coverage
|
||||
|
||||
Handler tests verify business logic but leave the entire HTTP stack (routing, auth pipeline, middleware, `OperationResult → IActionResult` translation) untested. Each feature area must have at least one `WebApplicationFactory<Program>`-based test covering:
|
||||
|
||||
1. Happy path — authenticated request returns 200 with correct body shape.
|
||||
2. Unauthenticated request returns 401.
|
||||
3. Validation failure returns 400 with field-level error detail.
|
||||
|
||||
```csharp
|
||||
public class MyFeatureApiTests(WebApplicationFactory<Program> factory)
|
||||
: IClassFixture<WebApplicationFactory<Program>>
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetSomething_Authenticated_Returns200()
|
||||
{
|
||||
var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", TestTokens.ValidAdminToken);
|
||||
|
||||
var response = await client.GetAsync("/api/v1/my_feature/get_something");
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Place these tests in a dedicated `Baya.Test.Api` project so they can run against the full `Program.cs` wiring.
|
||||
|
||||
---
|
||||
|
||||
## 11. Security rules
|
||||
|
||||
- **Never hardcode secrets in C#.** Keys, connection strings, and tokens come from `appsettings.*.json` or environment variables, bound to typed settings classes — never a literal in a handler or service. (`dotnet user-secrets` is not used; see [DEPLOY.md](../DEPLOY.md) for the configuration model.)
|
||||
- `SecretKey` and `Encryptkey` (in `IdentitySettings`) belong in the environment-specific file, never in the base `appsettings.json`, which stays at its `StartupSecretsGuard`-rejected placeholder.
|
||||
- Always validate all external input with FluentValidation before processing.
|
||||
- EF Core parameterizes queries automatically — never concatenate raw SQL.
|
||||
- If you must use raw SQL, use `FromSqlInterpolated` (parameterized), never `FromSqlRaw` with user data.
|
||||
- Respect the principle of least privilege: grant `[Authorize(ConstantPolicies.DynamicPermission)]` to admin actions, not just `[Authorize]`.
|
||||
- **Auth and OTP endpoints must be rate-limited.** Use ASP.NET Core's built-in `AddRateLimiter` (no extra NuGet package needed). Apply at minimum to: login, OTP request, and token refresh. A fixed window or token bucket policy per IP is the baseline. Register the limiter in a `ServiceConfiguration/` extension; add `app.UseRateLimiter()` before `app.UseAuthentication()` in `Program.cs`.
|
||||
|
||||
---
|
||||
|
||||
## 12. Service registration
|
||||
|
||||
- Every new infrastructure service gets an extension method in the project's `ServiceConfiguration/` folder.
|
||||
- That extension is called from `Program.cs` — no inline DI registration in `Program.cs`.
|
||||
- Register with the correct lifetime:
|
||||
- **Singleton** — stateless, thread-safe services (e.g. `IHttpContextAccessor`)
|
||||
- **Scoped** — per-request services (repositories, `DbContext`, handlers)
|
||||
- **Transient** — lightweight, stateless (validators, transformers)
|
||||
- All NuGet versions live in `Directory.Packages.props`. Never add `Version=` to a `<PackageReference>` in a `.csproj`.
|
||||
|
||||
---
|
||||
|
||||
## 13. Code organisation
|
||||
|
||||
- One type per file. File name matches the type name exactly.
|
||||
- Handlers and validators go in the same feature folder — not in separate `Handlers/` or `Validators/` root folders.
|
||||
- If a file exceeds ~150 lines, consider splitting it. Long files usually mean mixed concerns.
|
||||
- Partial classes are only for generated code (source generators, EF scaffolding).
|
||||
- Keep `Program.cs` as an orchestrator — extension method calls only, no logic.
|
||||
+3
-2
@@ -9,8 +9,9 @@ Backend API for the Balinyaar application. It is an **ASP.NET Core (.NET 10)** s
|
||||
- A modular **gRPC plugin** mounted via Application Parts
|
||||
- Observability out of the box: Serilog, OpenTelemetry, Prometheus metrics, health checks
|
||||
|
||||
> Looking for an architecture/file map to navigate the code? See [CLAUDE.md](CLAUDE.md) (agent guide)
|
||||
> and [CONVENTIONS.md](CONVENTIONS.md) (coding rules).
|
||||
> Looking for an architecture/file map to navigate the code? See [CLAUDE.md](CLAUDE.md) (the project map
|
||||
> and the hard rules) and [../docs/rules/server/](../docs/rules/server/) (the coding rules, one file per
|
||||
> area — `conventions.md` is the successor to the former `CONVENTIONS.md`).
|
||||
|
||||
## Requirements
|
||||
|
||||
|
||||
@@ -5,11 +5,15 @@
|
||||
# seeds roles + an admin user + a sandbox gateway against this empty instance.
|
||||
#
|
||||
# The SA password below is a well-known DEV-ONLY value: it is NOT a secret, is used only on localhost,
|
||||
# and never reaches a deployed environment. Point the API at this instance via `dotnet user-secrets`
|
||||
# (see dev/post-phase/refinement/RUNBOOK.md) — never by editing a committed appsettings*.json.
|
||||
# and never reaches a deployed environment.
|
||||
#
|
||||
# Point the API at this instance by setting `ConnectionStrings:SqlServer` in
|
||||
# `src/API/Baya.Web.Api/appsettings.Development.json` (or as an environment variable).
|
||||
# `dotnet user-secrets` is NOT used in this repo — the `<UserSecretsId>` was removed, so that store is
|
||||
# never read. See docs/rules/shared/code-quality.md §6 for the configuration model.
|
||||
#
|
||||
# docker compose up -d
|
||||
# # then set ConnectionStrings:SqlServer via user-secrets (see the runbook), then `dotnet run`
|
||||
# # then set ConnectionStrings:SqlServer as above, then `dotnet run`
|
||||
#
|
||||
# The API itself runs on the host via `dotnet run` (not in a container) for the local-dev loop — this
|
||||
# compose file intentionally provisions only the database.
|
||||
|
||||
Reference in New Issue
Block a user