backend phase 0: foundation, cross-cutting seams & starter cleanup

Remove the Order demo (entity/feature/repo/config/gRPC/proto) and the three
pre-marketplace migrations; regenerate a fresh InitialBaseline migration.

Stand up the REST surface (PingController + System/Ping CQRS) proving the
Mediator -> behaviors -> OperationResult -> ApiResult envelope end to end.

Close wiring gaps: register LoggingBehavior (outermost) and add the built-in
rate limiter (per-IP global + otp/auth/sensitive policies), placed before
authentication.

Add current-user + audit plumbing: ICurrentUser (HttpContext + null impls),
rename BaseEntity audit fields to CreatedAt/ModifiedAt (DateTimeOffset) +
CreatedById/ModifiedById, stamped by a new AuditFieldInterceptor.

Introduce five cross-cutting seams (IDateTimeProvider, IFieldEncryptor,
ICacheService, IObjectStorage, INotificationDispatcher) with in-memory/local
mocks registered via AddCrossCuttingSeams.

Add Baya.Test.Foundation (encryptor, audit interceptor, ping handler) and
update docs, contracts (swagger.v1.json), handoff, report, and mocks registry.
This commit is contained in:
hamid
2026-06-30 22:48:41 +03:30
parent 53a40dc51d
commit 765cc632d5
75 changed files with 1539 additions and 1418 deletions
+256
View File
@@ -0,0 +1,256 @@
{
"x-generator": "NSwag v14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))",
"openapi": "3.0.0",
"info": {
"title": "Clean Architecture OpenAPI docs",
"version": "v1"
},
"servers": [
{
"url": "http://127.0.0.1:5082"
}
],
"paths": {
"/api/v1/ping/get_status": {
"get": {
"tags": [
"Ping"
],
"operationId": "Ping_GetStatus",
"responses": {
"400": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString"
}
}
}
},
"401": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResult"
}
}
}
},
"403": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResult"
}
}
}
},
"500": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResult"
}
}
}
},
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResultOfPingQueryResult"
}
}
}
}
}
}
},
"/api/v1/ping/get_status_rate_limited": {
"get": {
"tags": [
"Ping"
],
"operationId": "Ping_GetStatusRateLimited",
"responses": {
"400": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString"
}
}
}
},
"401": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResult"
}
}
}
},
"403": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResult"
}
}
}
},
"500": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResult"
}
}
}
},
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResultOfPingQueryResult"
}
}
}
}
}
}
}
},
"components": {
"schemas": {
"ApiResultOfDictionaryOfStringAndListOfString": {
"allOf": [
{
"$ref": "#/components/schemas/ApiResult"
},
{
"type": "object",
"additionalProperties": false,
"properties": {
"data": {
"type": "object",
"nullable": true,
"additionalProperties": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
}
]
},
"ApiResult": {
"type": "object",
"additionalProperties": false,
"properties": {
"isSuccess": {
"type": "boolean"
},
"statusCode": {
"$ref": "#/components/schemas/ApiResultStatusCode"
},
"message": {
"type": "string",
"nullable": true
},
"requestId": {
"type": "string",
"nullable": true
}
}
},
"ApiResultStatusCode": {
"type": "integer",
"description": "",
"x-enumNames": [
"Success",
"BadRequest",
"UnAuthorized",
"Forbidden",
"NotFound",
"NotAcceptable",
"EntityProcessError",
"FailedDependency",
"ServerError"
],
"enum": [
200,
400,
401,
403,
404,
406,
422,
424,
500
]
},
"ApiResultOfPingQueryResult": {
"allOf": [
{
"$ref": "#/components/schemas/ApiResult"
},
{
"type": "object",
"additionalProperties": false,
"properties": {
"data": {
"nullable": true,
"oneOf": [
{
"$ref": "#/components/schemas/PingQueryResult"
}
]
}
}
}
]
},
"PingQueryResult": {
"type": "object",
"additionalProperties": false,
"properties": {
"service": {
"type": "string",
"nullable": true
},
"status": {
"type": "string",
"nullable": true
},
"serverTimeUtc": {
"type": "string",
"format": "date-time"
}
}
}
},
"securitySchemes": {
"Bearer": {
"type": "http",
"description": "Enter JWT Token ONLY",
"name": "Authorization",
"in": "header",
"scheme": "Bearer"
}
}
}
}
+13 -1
View File
@@ -12,4 +12,16 @@ One block per completed backend phase. Newest at the top. Backend lane writes he
- **Notes for frontend:** <anything load-bearing>
-->
_(no phases completed yet)_
## backend-phase-0 — Foundation, cross-cutting seams & starter cleanup — 2026-06-28
- **Shipped:** removed the `Order` demo (entity/feature/repo/config/gRPC) + 3 old migrations; fresh
`InitialBaseline` migration; REST surface (`PingController` + `System/Ping` CQRS); `ICurrentUser` +
`AuditFieldInterceptor`; five cross-cutting seams (`IDateTimeProvider`, `IFieldEncryptor`,
`ICacheService`, `IObjectStorage`, `INotificationDispatcher`) with mocks; `LoggingBehavior` +
rate limiter (per-IP global + `otp`/`auth`/`sensitive`).
- **Contracts:** `dev/contracts/openapi/swagger.v1.json` published (envelope + ping schemas).
- **Mocked:** the 5 seams above → 🟡 (see reports/mocks-registry.md).
- **Gate:** build clean (0 new warnings) / tests green (10 pass). Live API verified vs `192.168.100.14`
(migration applied + seeded; ping `200`; rate-limit `429`).
- **Handoff:** backend/handoff/after-backend-phase-0.md
- **Notes for frontend:** `ApiResult` envelope is fixed (camelCase body, snake_case URLs);
`GET /api/v1/ping/get_status` is live to wire types against; `429` on over-limit.
@@ -0,0 +1,45 @@
# After backend-phase-0 — what f0/b1 can rely on
**The spine is clean and REST works.** The inherited `Order` demo (entity/feature/repo/config/gRPC) and
the three pre-marketplace migrations are gone; Identity + JWE auth + dynamic permissions + the CQRS
behaviors + observability are untouched and the existing tests stay green. A real REST controller is
live and proves the full pipeline.
## What the frontend (f0) can rely on now
- **The response envelope is fixed.** Every endpoint returns the server's `ApiResult` envelope, not a
bare body. Success shape:
```json
{ "isSuccess": true, "statusCode": 0, "message": "Success", "requestId": "<trace>", "data": <T> }
```
Failure returns `400/401/403/404` with the same envelope (field-level errors for validation). This is
what `clientFetch`/`serverFetch` must unwrap. JSON casing is the server default (camelCase for the
envelope/body properties; **URL segments are snake_case**).
- **A working endpoint to wire the type pipeline against:**
`GET /api/v1/ping/get_status` → `ApiResult<{ service, status, serverTimeUtc }>`.
- **Rate limiting exists.** Over-limit requests get `429`; `get_status_rate_limited` demonstrates it
(first 5/10s OK, then 429).
- **swagger.json is published:** `dev/contracts/openapi/swagger.v1.json` (envelope schemas
`ApiResult`, `ApiResultStatusCode`, `PingQueryResult`). Generate frontend types from it.
## What b1 can rely on now
- **Audit base type:** `BaseEntity`/`IAuditableEntity` (`Domain/Common/BaseEntity.cs`) with
`CreatedAt`/`ModifiedAt` (`DateTimeOffset`) + `CreatedById`/`ModifiedById` (`int?`). New entities that
derive `BaseEntity` get audit stamping for free.
- **Audit interceptor:** `AuditFieldInterceptor` (`Infrastructure.Persistence/Interceptors/`) stamps
those fields on save from `ICurrentUser` + `IDateTimeProvider`. **b1 extends this** to also write the
append-only `audit_logs` rows — the stamping plumbing and the clean extension point are in place.
- **Cross-cutting seams (DI-registered, mocked):** `IDateTimeProvider`, `IFieldEncryptor` (use it for
every encrypted PII column — phone/national_id/IBAN/addresses/clinical notes; deterministic `Hash`
for `*_hash` lookup columns), `ICacheService` (cache `platform_configs` / read-heavy data through it),
`IObjectStorage`, `INotificationDispatcher`. Contracts in `Application/Contracts/Common`; mocks in
`Infrastructure.CrossCutting/Seams/`; registered by `AddCrossCuttingSeams`.
- **Money rule:** IRR is `long`/`BIGINT`, integer-only, no floats (CONVENTIONS §6).
- **Migration baseline:** `20260628191947_InitialBaseline`. Add the marketplace schema on top of it;
generate with the documented `dotnet ef migrations add` command.
- **Rate-limit policies** `otp`/`auth`/`sensitive` are defined and ready to apply via
`[EnableRateLimiting("...")]` on the relevant endpoints (b2 auth/OTP).
## Known caveat
Non-Development environments route Serilog to the `logDb` connection (`Server=sql_server2022`); if that
host isn't reachable, the API won't start there. Development logs to console/file and boots cleanly
against the configured `SqlServer` DB. This is pre-existing infra config, unrelated to phase-0 code.
@@ -0,0 +1,93 @@
# Backend Phase 0 — Foundation, cross-cutting seams & starter cleanup — Report (2026-06-28)
## What was built
- **Starter cleanup.** Removed the `Order` demo end-to-end: entity (`Domain/Entities/Order`), feature
folder (`Application/Features/Order`), `IOrderRepository`/`OrderRepository`, `OrderConfig`,
`User.Orders` nav, `IUnitOfWork.OrderRepository`, the gRPC `OrderGrpcServices` + proto + wiring +
`/GrpcUserOrder` map. Deleted the three pre-marketplace migrations (`2021/2022/2023`) + snapshot.
- **Fresh baseline migration** `Migrations/20260628191947_InitialBaseline` — Identity (`usr` schema) +
`UserRefreshTokens`, with audit columns `CreatedAt`/`ModifiedAt` (`datetimeoffset`),
`CreatedById`/`ModifiedById` (`int`). No `Orders` table.
- **Audit base type.** `BaseEntity`/`IAuditableEntity` in `Domain/Common/BaseEntity.cs`:
`CreatedAt`/`ModifiedAt` as `DateTimeOffset`, `CreatedById`/`ModifiedById` as `int?`
(renamed from the old `CreatedTime`/`ModifiedDate` `DateTime` pair, to match `CONVENTIONS.md` §6).
- **Current-user plumbing.** `ICurrentUser` (`Application/Contracts/Common`) with
`HttpContextCurrentUser` + `NullCurrentUser` (`Infrastructure.Identity/Identity/CurrentUser/`),
registered Scoped; `AddHttpContextAccessor()` wired.
- **Audit interceptor.** `AuditFieldInterceptor` (`Infrastructure.Persistence/Interceptors/`), a
`SaveChangesInterceptor` stamping audit fields from `ICurrentUser` + `IDateTimeProvider`. Replaces
the old `DateTime.Now` date hook in `ApplicationDbContext` (the `_cleanString` Persian-normalisation
hook stays). Registered via `AddInterceptors` in `AddPersistenceServices`.
- **Five cross-cutting seams** (interfaces in `Application/Contracts/Common`, mocks in
`Infrastructure.CrossCutting/Seams/`, registered by `AddCrossCuttingSeams(config)`):
`IDateTimeProvider``SystemDateTimeProvider`, `IFieldEncryptor``SymmetricFieldEncryptor`,
`ICacheService``MemoryCacheService`, `IObjectStorage``LocalDiskObjectStorage`,
`INotificationDispatcher``LogNotificationDispatcher`.
- **Pipeline + rate limiting.** Registered `LoggingBehavior<,>` as the outermost Mediator behavior.
Added `AddRateLimitingPolicies()` (`WebFramework/ServiceConfiguration`) — built-in rate limiter with
a per-IP global limit + named policies `otp`/`auth`/`sensitive`; `app.UseRateLimiter()` placed
**before** `app.UseAuthentication()`.
- **REST surface.** `Controllers/V1/PingController` (sealed, `BaseController`, `ISender`) with
`GetStatus` and a rate-limited `GetStatusRateLimited`, backed by the `Features/System/Queries/Ping`
CQRS feature — proves REST → Mediator → behaviors → `OperationResult``ApiResult` envelope.
- **Tests.** New `Baya.Test.Foundation` project: `SymmetricFieldEncryptor` round-trip + deterministic
hash, `AuditFieldInterceptor` add/update stamping (SQLite), `PingQueryHandler` happy path.
## What is now testable (and exactly how)
- **Build/test gate:** `dotnet build Baya.sln` → 0 errors, 0 new warnings; `dotnet test Baya.sln`
10 pass (4 existing identity + 6 new foundation). ✅ verified.
- **Live API:** ✅ verified end-to-end against SQL Server `192.168.100.14` (Development env). On boot the
`InitialBaseline` migration applied and the default users seeded. `dotnet run --project
src/API/Baya.Web.Api/...` → open `/swagger`:
- `GET /api/v1/ping/get_status``200` with body
`{ "data": { "service": "Baya.Web.Api", "status": "ok", "serverTimeUtc": "<utc>" },
"isSuccess": true, "statusCode": 200, "message": "Success", "requestId": "<trace>" }`
(the standard `ApiResult<T>` envelope). ✅
- `GET /api/v1/ping/get_status_rate_limited` from one IP → first 5 = `200`, then `429`. ✅
- The Order REST/gRPC endpoints are gone; the swagger doc shows only the two ping paths. ✅
> Run note: in non-Development the Serilog `MSSqlServer` sink targets the `logDb` connection
> (`Server=sql_server2022`), which must resolve or the host fails to build (pre-existing config,
> unrelated to this phase). Development logs to console/file, so verify there.
## What is mocked / waiting on a real service
All five seams are 🟡 (mock behind DI seam) — see `reports/mocks-registry.md` rows for
`IFieldEncryptor`, `ICacheService`, `IObjectStorage`, `INotificationDispatcher` (and `IDateTimeProvider`,
not external). Each mock lives in `Baya.Infrastructure.CrossCutting/Seams/`; swapping to a real provider
is a registration change in `AddCrossCuttingSeams`. `INotificationDispatcher` does **not** write yet —
the in-app `notifications` write lands in b15.
## Contracts
- **Produced:** the `ApiResult`/`OperationResult` envelope shape. The first machine `swagger.json`
snapshot is published at `dev/contracts/openapi/swagger.v1.json` (paths `ping/get_status` +
`ping/get_status_rate_limited`; schemas `ApiResult`, `ApiResultStatusCode`,
`ApiResultOfPingQueryResult`, `PingQueryResult`). Casing on the wire: **camelCase** body/envelope
properties, **snake_case** URL segments.
- **Consumed:** none.
## Docs updated
- `server/CLAUDE.md`*Project map* (Order removed; CrossCutting `Seams/`, Persistence `Interceptors/`,
Identity `CurrentUser/`, `Baya.Test.Foundation`, new seams note), *Startup wiring* (new registrations
+ rate-limiter-before-auth pipeline order), CQRS example (Order → generic + Ping).
- `server/CONVENTIONS.md` — §6 "as built" note (audit base type + interceptor) and a new
"Money is IRR `BIGINT` — integer-only, no floats" rule.
- `dev/shared-working-context/reports/mocks-registry.md` — five seam rows → 🟡 with files + config keys.
## Follow-ups for later phases
- **b1:** extend `AuditFieldInterceptor` to also write append-only `audit_logs` rows; evolve the
baseline migration with the marketplace schema; `IHolidayCalendar` seed.
- **b2:** `ISmsSender` seam + auth/OTP REST surface; apply the `otp`/`auth` rate-limit policies to those
endpoints.
- **Integration tests:** a `WebApplicationFactory<Program>` test project (CONVENTIONS §10) was not
scaffolded this phase; add it when the first real feature area lands so the HTTP pipeline (routing,
auth, envelope translation, 429) is covered automatically.
- **Logging config (pre-existing):** the non-Development Serilog `MSSqlServer` sink points at
`logDb` = `Server=sql_server2022`. If that host isn't reachable in an environment, the API won't
start there. Out of scope for this phase — flag for whoever owns environment/infra config.
## Status
All Definition-of-Done items met: build clean (0 new warnings), 10 tests green, REST controller live
through Swagger with the `OperationResult` envelope, `LoggingBehavior` + rate limiter wired,
`ICurrentUser` + audit interceptor + the five seams in place, migration applied + DB seeded, docs/
contracts/handoff/registry updated, swagger snapshot published. Live verification done against
`192.168.100.14`.
@@ -10,8 +10,8 @@ Status legend: 🔴 not built · 🟡 mocked (seam + fake impl in place) · 🟢
| Seam (interface) | Introduced in | What it fakes | Config keys | Make it real → | Status |
| --- | --- | --- | --- | --- | --- |
| `ISmsSender` | backend-phase-2 | OTP/SMS delivery — logs the code instead of sending | _tbd_ | Implement a Kavenegar/Ghasedak/SMS.ir client; keep idempotency + rate-limit | 🔴 |
| `IObjectStorage` | backend-phase-0/6 | File storage — local/in-memory instead of object store | _tbd_ | Point at MinIO/S3/ArvanCloud; presigned upload/download; bucket + creds | 🔴 |
| `ICacheService` | backend-phase-0 | Caching — in-memory dictionary | _tbd_ | Swap to Redis (`StackExchange.Redis`); keep key/TTL scheme | 🔴 |
| `IObjectStorage` | backend-phase-0/6 | File storage — local-disk store under a scratch root (`LocalDiskObjectStorage`, `Baya.Infrastructure.CrossCutting/Seams/`) | `Seams:ObjectStorage:RootPath` (default: temp dir) | Point at MinIO/S3/ArvanCloud; presigned upload/download; bucket + creds | 🟡 |
| `ICacheService` | backend-phase-0 | Caching — in-memory `IMemoryCache` (`MemoryCacheService`, `Baya.Infrastructure.CrossCutting/Seams/`) | _none_ | Swap to Redis (`StackExchange.Redis`); keep key/TTL scheme | 🟡 |
| `IDistributedLock` | backend-phase-10 | Money-path locks — no-op/in-proc | _tbd_ | Redis lock (RedLock); DB constraint remains the backstop | 🔴 |
| `INurseSearch` | backend-phase-7 | Search — SQL over `nurse_search_index` | _tbd_ | Elasticsearch index + feeder; reimplement the interface | 🔴 |
| `IPaymentProvider` | backend-phase-10 | Card PSP/IPG — deterministic success | _tbd_ | ZarinPal/Sadad/Vandar/Jibit + Shaparak; merchant/terminal/تسهیم | 🔴 |
@@ -28,8 +28,8 @@ Status legend: 🔴 not built · 🟡 mocked (seam + fake impl in place) · 🟢
| `IGeocoder` | backend-phase-4 | Address→lat/lng — echo/static | _tbd_ | Neshan/Google geocoding | 🔴 |
| `IMoadianClient` | backend-phase-11 | سامانه مودیان e-invoice — leaves ref pending | _tbd_ | Real مودیان submission → 22-digit ref | 🔴 |
| `IReviewModerationService` | backend-phase-14 | AI moderation — keyword/pass-through | _tbd_ | Real classifier/LLM endpoint | 🔴 |
| `IFieldEncryptor` | backend-phase-0 | PII encryption — local symmetric key | _tbd_ | KMS / column encryption / Key Vault / HSM | 🔴 |
| `INotificationDispatcher` | backend-phase-0/15 | Notification channels — in-app write only | _tbd_ | Add SMS/push (FCM); polling → Redis pub/sub or SignalR later | 🔴 |
| `IFieldEncryptor` | backend-phase-0 | PII encryption — AES-256-CBC + HMAC hash from a local symmetric key (`SymmetricFieldEncryptor`, `Baya.Infrastructure.CrossCutting/Seams/`) | `Seams:FieldEncryption:Key`, `Seams:FieldEncryption:HashKey` | KMS / column encryption / Key Vault / HSM | 🟡 |
| `INotificationDispatcher` | backend-phase-0/15 | Notification channels — logs/no-op (`LogNotificationDispatcher`, `Baya.Infrastructure.CrossCutting/Seams/`); no write yet | _none_ | Add the in-app `notifications` write (b15) + SMS/push (FCM); polling → Redis pub/sub or SignalR later | 🟡 |
| `ILicenseVerificationService` | backend-phase-15 | eNamad / MoH establishment-permit — manual approve | _tbd_ | Real registry/API | 🔴 |
> Exact config keys and file paths get filled in by the phase that builds each seam. Keep the