diff --git a/dev/contracts/openapi/swagger.v1.json b/dev/contracts/openapi/swagger.v1.json new file mode 100644 index 0000000..7a16c96 --- /dev/null +++ b/dev/contracts/openapi/swagger.v1.json @@ -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" + } + } + } +} \ No newline at end of file diff --git a/dev/shared-working-context/backend/STATUS.md b/dev/shared-working-context/backend/STATUS.md index 04210a6..e4e993f 100644 --- a/dev/shared-working-context/backend/STATUS.md +++ b/dev/shared-working-context/backend/STATUS.md @@ -12,4 +12,16 @@ One block per completed backend phase. Newest at the top. Backend lane writes he - **Notes for frontend:** --> -_(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. diff --git a/dev/shared-working-context/backend/handoff/after-backend-phase-0.md b/dev/shared-working-context/backend/handoff/after-backend-phase-0.md new file mode 100644 index 0000000..3240dc7 --- /dev/null +++ b/dev/shared-working-context/backend/handoff/after-backend-phase-0.md @@ -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": "", "data": } + ``` + 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. diff --git a/dev/shared-working-context/reports/backend-phase-0-report.md b/dev/shared-working-context/reports/backend-phase-0-report.md new file mode 100644 index 0000000..8618780 --- /dev/null +++ b/dev/shared-working-context/reports/backend-phase-0-report.md @@ -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": "" }, + "isSuccess": true, "statusCode": 200, "message": "Success", "requestId": "" }` + (the standard `ApiResult` 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` 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`. diff --git a/dev/shared-working-context/reports/mocks-registry.md b/dev/shared-working-context/reports/mocks-registry.md index c9d49cf..da7fa30 100644 --- a/dev/shared-working-context/reports/mocks-registry.md +++ b/dev/shared-working-context/reports/mocks-registry.md @@ -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 diff --git a/server/Baya.sln b/server/Baya.sln index 8b7019e..dbb7dab 100644 --- a/server/Baya.sln +++ b/server/Baya.sln @@ -52,60 +52,174 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution docker-compose.yml = docker-compose.yml EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Baya.Test.Foundation", "src\Tests\Baya.Test.Foundation\Baya.Test.Foundation.csproj", "{052BF207-440C-4FAB-AF6F-4992B29A3BF4}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {56C4DDD2-4F8C-4D35-85D4-CC9064C52398}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {56C4DDD2-4F8C-4D35-85D4-CC9064C52398}.Debug|Any CPU.Build.0 = Debug|Any CPU + {56C4DDD2-4F8C-4D35-85D4-CC9064C52398}.Debug|x64.ActiveCfg = Debug|Any CPU + {56C4DDD2-4F8C-4D35-85D4-CC9064C52398}.Debug|x64.Build.0 = Debug|Any CPU + {56C4DDD2-4F8C-4D35-85D4-CC9064C52398}.Debug|x86.ActiveCfg = Debug|Any CPU + {56C4DDD2-4F8C-4D35-85D4-CC9064C52398}.Debug|x86.Build.0 = Debug|Any CPU {56C4DDD2-4F8C-4D35-85D4-CC9064C52398}.Release|Any CPU.ActiveCfg = Release|Any CPU {56C4DDD2-4F8C-4D35-85D4-CC9064C52398}.Release|Any CPU.Build.0 = Release|Any CPU + {56C4DDD2-4F8C-4D35-85D4-CC9064C52398}.Release|x64.ActiveCfg = Release|Any CPU + {56C4DDD2-4F8C-4D35-85D4-CC9064C52398}.Release|x64.Build.0 = Release|Any CPU + {56C4DDD2-4F8C-4D35-85D4-CC9064C52398}.Release|x86.ActiveCfg = Release|Any CPU + {56C4DDD2-4F8C-4D35-85D4-CC9064C52398}.Release|x86.Build.0 = Release|Any CPU {09E81356-0531-42A0-9F7F-00C495F1226E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {09E81356-0531-42A0-9F7F-00C495F1226E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {09E81356-0531-42A0-9F7F-00C495F1226E}.Debug|x64.ActiveCfg = Debug|Any CPU + {09E81356-0531-42A0-9F7F-00C495F1226E}.Debug|x64.Build.0 = Debug|Any CPU + {09E81356-0531-42A0-9F7F-00C495F1226E}.Debug|x86.ActiveCfg = Debug|Any CPU + {09E81356-0531-42A0-9F7F-00C495F1226E}.Debug|x86.Build.0 = Debug|Any CPU {09E81356-0531-42A0-9F7F-00C495F1226E}.Release|Any CPU.ActiveCfg = Release|Any CPU {09E81356-0531-42A0-9F7F-00C495F1226E}.Release|Any CPU.Build.0 = Release|Any CPU + {09E81356-0531-42A0-9F7F-00C495F1226E}.Release|x64.ActiveCfg = Release|Any CPU + {09E81356-0531-42A0-9F7F-00C495F1226E}.Release|x64.Build.0 = Release|Any CPU + {09E81356-0531-42A0-9F7F-00C495F1226E}.Release|x86.ActiveCfg = Release|Any CPU + {09E81356-0531-42A0-9F7F-00C495F1226E}.Release|x86.Build.0 = Release|Any CPU {3AFD5AAD-8DCD-44D6-86B9-078FBE8F2A1F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {3AFD5AAD-8DCD-44D6-86B9-078FBE8F2A1F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3AFD5AAD-8DCD-44D6-86B9-078FBE8F2A1F}.Debug|x64.ActiveCfg = Debug|Any CPU + {3AFD5AAD-8DCD-44D6-86B9-078FBE8F2A1F}.Debug|x64.Build.0 = Debug|Any CPU + {3AFD5AAD-8DCD-44D6-86B9-078FBE8F2A1F}.Debug|x86.ActiveCfg = Debug|Any CPU + {3AFD5AAD-8DCD-44D6-86B9-078FBE8F2A1F}.Debug|x86.Build.0 = Debug|Any CPU {3AFD5AAD-8DCD-44D6-86B9-078FBE8F2A1F}.Release|Any CPU.ActiveCfg = Release|Any CPU {3AFD5AAD-8DCD-44D6-86B9-078FBE8F2A1F}.Release|Any CPU.Build.0 = Release|Any CPU + {3AFD5AAD-8DCD-44D6-86B9-078FBE8F2A1F}.Release|x64.ActiveCfg = Release|Any CPU + {3AFD5AAD-8DCD-44D6-86B9-078FBE8F2A1F}.Release|x64.Build.0 = Release|Any CPU + {3AFD5AAD-8DCD-44D6-86B9-078FBE8F2A1F}.Release|x86.ActiveCfg = Release|Any CPU + {3AFD5AAD-8DCD-44D6-86B9-078FBE8F2A1F}.Release|x86.Build.0 = Release|Any CPU {9F3B3E49-3E3C-4244-AE88-D209B18B28B8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {9F3B3E49-3E3C-4244-AE88-D209B18B28B8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9F3B3E49-3E3C-4244-AE88-D209B18B28B8}.Debug|x64.ActiveCfg = Debug|Any CPU + {9F3B3E49-3E3C-4244-AE88-D209B18B28B8}.Debug|x64.Build.0 = Debug|Any CPU + {9F3B3E49-3E3C-4244-AE88-D209B18B28B8}.Debug|x86.ActiveCfg = Debug|Any CPU + {9F3B3E49-3E3C-4244-AE88-D209B18B28B8}.Debug|x86.Build.0 = Debug|Any CPU {9F3B3E49-3E3C-4244-AE88-D209B18B28B8}.Release|Any CPU.ActiveCfg = Release|Any CPU {9F3B3E49-3E3C-4244-AE88-D209B18B28B8}.Release|Any CPU.Build.0 = Release|Any CPU + {9F3B3E49-3E3C-4244-AE88-D209B18B28B8}.Release|x64.ActiveCfg = Release|Any CPU + {9F3B3E49-3E3C-4244-AE88-D209B18B28B8}.Release|x64.Build.0 = Release|Any CPU + {9F3B3E49-3E3C-4244-AE88-D209B18B28B8}.Release|x86.ActiveCfg = Release|Any CPU + {9F3B3E49-3E3C-4244-AE88-D209B18B28B8}.Release|x86.Build.0 = Release|Any CPU {9C0BCB6F-614C-4FA9-83A2-E95834E3C153}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {9C0BCB6F-614C-4FA9-83A2-E95834E3C153}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9C0BCB6F-614C-4FA9-83A2-E95834E3C153}.Debug|x64.ActiveCfg = Debug|Any CPU + {9C0BCB6F-614C-4FA9-83A2-E95834E3C153}.Debug|x64.Build.0 = Debug|Any CPU + {9C0BCB6F-614C-4FA9-83A2-E95834E3C153}.Debug|x86.ActiveCfg = Debug|Any CPU + {9C0BCB6F-614C-4FA9-83A2-E95834E3C153}.Debug|x86.Build.0 = Debug|Any CPU {9C0BCB6F-614C-4FA9-83A2-E95834E3C153}.Release|Any CPU.ActiveCfg = Release|Any CPU {9C0BCB6F-614C-4FA9-83A2-E95834E3C153}.Release|Any CPU.Build.0 = Release|Any CPU + {9C0BCB6F-614C-4FA9-83A2-E95834E3C153}.Release|x64.ActiveCfg = Release|Any CPU + {9C0BCB6F-614C-4FA9-83A2-E95834E3C153}.Release|x64.Build.0 = Release|Any CPU + {9C0BCB6F-614C-4FA9-83A2-E95834E3C153}.Release|x86.ActiveCfg = Release|Any CPU + {9C0BCB6F-614C-4FA9-83A2-E95834E3C153}.Release|x86.Build.0 = Release|Any CPU {DC49CD3F-840E-4634-B9DA-595F160E9499}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {DC49CD3F-840E-4634-B9DA-595F160E9499}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DC49CD3F-840E-4634-B9DA-595F160E9499}.Debug|x64.ActiveCfg = Debug|Any CPU + {DC49CD3F-840E-4634-B9DA-595F160E9499}.Debug|x64.Build.0 = Debug|Any CPU + {DC49CD3F-840E-4634-B9DA-595F160E9499}.Debug|x86.ActiveCfg = Debug|Any CPU + {DC49CD3F-840E-4634-B9DA-595F160E9499}.Debug|x86.Build.0 = Debug|Any CPU {DC49CD3F-840E-4634-B9DA-595F160E9499}.Release|Any CPU.ActiveCfg = Release|Any CPU {DC49CD3F-840E-4634-B9DA-595F160E9499}.Release|Any CPU.Build.0 = Release|Any CPU + {DC49CD3F-840E-4634-B9DA-595F160E9499}.Release|x64.ActiveCfg = Release|Any CPU + {DC49CD3F-840E-4634-B9DA-595F160E9499}.Release|x64.Build.0 = Release|Any CPU + {DC49CD3F-840E-4634-B9DA-595F160E9499}.Release|x86.ActiveCfg = Release|Any CPU + {DC49CD3F-840E-4634-B9DA-595F160E9499}.Release|x86.Build.0 = Release|Any CPU {8F7135E8-68C9-4DA8-AA06-04518EBB403B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {8F7135E8-68C9-4DA8-AA06-04518EBB403B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8F7135E8-68C9-4DA8-AA06-04518EBB403B}.Debug|x64.ActiveCfg = Debug|Any CPU + {8F7135E8-68C9-4DA8-AA06-04518EBB403B}.Debug|x64.Build.0 = Debug|Any CPU + {8F7135E8-68C9-4DA8-AA06-04518EBB403B}.Debug|x86.ActiveCfg = Debug|Any CPU + {8F7135E8-68C9-4DA8-AA06-04518EBB403B}.Debug|x86.Build.0 = Debug|Any CPU {8F7135E8-68C9-4DA8-AA06-04518EBB403B}.Release|Any CPU.ActiveCfg = Release|Any CPU {8F7135E8-68C9-4DA8-AA06-04518EBB403B}.Release|Any CPU.Build.0 = Release|Any CPU + {8F7135E8-68C9-4DA8-AA06-04518EBB403B}.Release|x64.ActiveCfg = Release|Any CPU + {8F7135E8-68C9-4DA8-AA06-04518EBB403B}.Release|x64.Build.0 = Release|Any CPU + {8F7135E8-68C9-4DA8-AA06-04518EBB403B}.Release|x86.ActiveCfg = Release|Any CPU + {8F7135E8-68C9-4DA8-AA06-04518EBB403B}.Release|x86.Build.0 = Release|Any CPU {BE13FF32-B8D5-4AE7-B173-6CA96040B788}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {BE13FF32-B8D5-4AE7-B173-6CA96040B788}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BE13FF32-B8D5-4AE7-B173-6CA96040B788}.Debug|x64.ActiveCfg = Debug|Any CPU + {BE13FF32-B8D5-4AE7-B173-6CA96040B788}.Debug|x64.Build.0 = Debug|Any CPU + {BE13FF32-B8D5-4AE7-B173-6CA96040B788}.Debug|x86.ActiveCfg = Debug|Any CPU + {BE13FF32-B8D5-4AE7-B173-6CA96040B788}.Debug|x86.Build.0 = Debug|Any CPU {BE13FF32-B8D5-4AE7-B173-6CA96040B788}.Release|Any CPU.ActiveCfg = Release|Any CPU {BE13FF32-B8D5-4AE7-B173-6CA96040B788}.Release|Any CPU.Build.0 = Release|Any CPU + {BE13FF32-B8D5-4AE7-B173-6CA96040B788}.Release|x64.ActiveCfg = Release|Any CPU + {BE13FF32-B8D5-4AE7-B173-6CA96040B788}.Release|x64.Build.0 = Release|Any CPU + {BE13FF32-B8D5-4AE7-B173-6CA96040B788}.Release|x86.ActiveCfg = Release|Any CPU + {BE13FF32-B8D5-4AE7-B173-6CA96040B788}.Release|x86.Build.0 = Release|Any CPU {44DD0A96-BA65-476E-BC59-C8D2CFA703B9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {44DD0A96-BA65-476E-BC59-C8D2CFA703B9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {44DD0A96-BA65-476E-BC59-C8D2CFA703B9}.Debug|x64.ActiveCfg = Debug|Any CPU + {44DD0A96-BA65-476E-BC59-C8D2CFA703B9}.Debug|x64.Build.0 = Debug|Any CPU + {44DD0A96-BA65-476E-BC59-C8D2CFA703B9}.Debug|x86.ActiveCfg = Debug|Any CPU + {44DD0A96-BA65-476E-BC59-C8D2CFA703B9}.Debug|x86.Build.0 = Debug|Any CPU {44DD0A96-BA65-476E-BC59-C8D2CFA703B9}.Release|Any CPU.ActiveCfg = Release|Any CPU {44DD0A96-BA65-476E-BC59-C8D2CFA703B9}.Release|Any CPU.Build.0 = Release|Any CPU + {44DD0A96-BA65-476E-BC59-C8D2CFA703B9}.Release|x64.ActiveCfg = Release|Any CPU + {44DD0A96-BA65-476E-BC59-C8D2CFA703B9}.Release|x64.Build.0 = Release|Any CPU + {44DD0A96-BA65-476E-BC59-C8D2CFA703B9}.Release|x86.ActiveCfg = Release|Any CPU + {44DD0A96-BA65-476E-BC59-C8D2CFA703B9}.Release|x86.Build.0 = Release|Any CPU {33AF382A-9E22-42F0-82E5-4F78BCFD40C1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {33AF382A-9E22-42F0-82E5-4F78BCFD40C1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {33AF382A-9E22-42F0-82E5-4F78BCFD40C1}.Debug|x64.ActiveCfg = Debug|Any CPU + {33AF382A-9E22-42F0-82E5-4F78BCFD40C1}.Debug|x64.Build.0 = Debug|Any CPU + {33AF382A-9E22-42F0-82E5-4F78BCFD40C1}.Debug|x86.ActiveCfg = Debug|Any CPU + {33AF382A-9E22-42F0-82E5-4F78BCFD40C1}.Debug|x86.Build.0 = Debug|Any CPU {33AF382A-9E22-42F0-82E5-4F78BCFD40C1}.Release|Any CPU.ActiveCfg = Release|Any CPU {33AF382A-9E22-42F0-82E5-4F78BCFD40C1}.Release|Any CPU.Build.0 = Release|Any CPU + {33AF382A-9E22-42F0-82E5-4F78BCFD40C1}.Release|x64.ActiveCfg = Release|Any CPU + {33AF382A-9E22-42F0-82E5-4F78BCFD40C1}.Release|x64.Build.0 = Release|Any CPU + {33AF382A-9E22-42F0-82E5-4F78BCFD40C1}.Release|x86.ActiveCfg = Release|Any CPU + {33AF382A-9E22-42F0-82E5-4F78BCFD40C1}.Release|x86.Build.0 = Release|Any CPU {54203B4F-3CE8-4EBA-B5E2-F7C985FACE60}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {54203B4F-3CE8-4EBA-B5E2-F7C985FACE60}.Debug|Any CPU.Build.0 = Debug|Any CPU + {54203B4F-3CE8-4EBA-B5E2-F7C985FACE60}.Debug|x64.ActiveCfg = Debug|Any CPU + {54203B4F-3CE8-4EBA-B5E2-F7C985FACE60}.Debug|x64.Build.0 = Debug|Any CPU + {54203B4F-3CE8-4EBA-B5E2-F7C985FACE60}.Debug|x86.ActiveCfg = Debug|Any CPU + {54203B4F-3CE8-4EBA-B5E2-F7C985FACE60}.Debug|x86.Build.0 = Debug|Any CPU {54203B4F-3CE8-4EBA-B5E2-F7C985FACE60}.Release|Any CPU.ActiveCfg = Release|Any CPU {54203B4F-3CE8-4EBA-B5E2-F7C985FACE60}.Release|Any CPU.Build.0 = Release|Any CPU + {54203B4F-3CE8-4EBA-B5E2-F7C985FACE60}.Release|x64.ActiveCfg = Release|Any CPU + {54203B4F-3CE8-4EBA-B5E2-F7C985FACE60}.Release|x64.Build.0 = Release|Any CPU + {54203B4F-3CE8-4EBA-B5E2-F7C985FACE60}.Release|x86.ActiveCfg = Release|Any CPU + {54203B4F-3CE8-4EBA-B5E2-F7C985FACE60}.Release|x86.Build.0 = Release|Any CPU {7699705C-2C15-467F-957D-4C5EBE4FD92E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {7699705C-2C15-467F-957D-4C5EBE4FD92E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7699705C-2C15-467F-957D-4C5EBE4FD92E}.Debug|x64.ActiveCfg = Debug|Any CPU + {7699705C-2C15-467F-957D-4C5EBE4FD92E}.Debug|x64.Build.0 = Debug|Any CPU + {7699705C-2C15-467F-957D-4C5EBE4FD92E}.Debug|x86.ActiveCfg = Debug|Any CPU + {7699705C-2C15-467F-957D-4C5EBE4FD92E}.Debug|x86.Build.0 = Debug|Any CPU {7699705C-2C15-467F-957D-4C5EBE4FD92E}.Release|Any CPU.ActiveCfg = Release|Any CPU {7699705C-2C15-467F-957D-4C5EBE4FD92E}.Release|Any CPU.Build.0 = Release|Any CPU + {7699705C-2C15-467F-957D-4C5EBE4FD92E}.Release|x64.ActiveCfg = Release|Any CPU + {7699705C-2C15-467F-957D-4C5EBE4FD92E}.Release|x64.Build.0 = Release|Any CPU + {7699705C-2C15-467F-957D-4C5EBE4FD92E}.Release|x86.ActiveCfg = Release|Any CPU + {7699705C-2C15-467F-957D-4C5EBE4FD92E}.Release|x86.Build.0 = Release|Any CPU + {052BF207-440C-4FAB-AF6F-4992B29A3BF4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {052BF207-440C-4FAB-AF6F-4992B29A3BF4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {052BF207-440C-4FAB-AF6F-4992B29A3BF4}.Debug|x64.ActiveCfg = Debug|Any CPU + {052BF207-440C-4FAB-AF6F-4992B29A3BF4}.Debug|x64.Build.0 = Debug|Any CPU + {052BF207-440C-4FAB-AF6F-4992B29A3BF4}.Debug|x86.ActiveCfg = Debug|Any CPU + {052BF207-440C-4FAB-AF6F-4992B29A3BF4}.Debug|x86.Build.0 = Debug|Any CPU + {052BF207-440C-4FAB-AF6F-4992B29A3BF4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {052BF207-440C-4FAB-AF6F-4992B29A3BF4}.Release|Any CPU.Build.0 = Release|Any CPU + {052BF207-440C-4FAB-AF6F-4992B29A3BF4}.Release|x64.ActiveCfg = Release|Any CPU + {052BF207-440C-4FAB-AF6F-4992B29A3BF4}.Release|x64.Build.0 = Release|Any CPU + {052BF207-440C-4FAB-AF6F-4992B29A3BF4}.Release|x86.ActiveCfg = Release|Any CPU + {052BF207-440C-4FAB-AF6F-4992B29A3BF4}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -132,6 +246,7 @@ Global {54203B4F-3CE8-4EBA-B5E2-F7C985FACE60} = {45FA88C0-9986-40E5-A2E2-7742302518D2} {7699705C-2C15-467F-957D-4C5EBE4FD92E} = {2373AFFC-1389-4D78-8465-074AB22084AF} {704FAE1E-F0D2-468E-8B3D-E9E6F323ABE8} = {42CAB060-5D50-4E18-8F85-EBA5EB85B268} + {052BF207-440C-4FAB-AF6F-4992B29A3BF4} = {77986571-8153-4120-AD08-36729310A56B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {05C223B9-EA89-44B2-B9F5-D01181F85DFE} diff --git a/server/CLAUDE.md b/server/CLAUDE.md index 7ff1570..7976b38 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -81,27 +81,36 @@ projects/assemblies, Clean-Architecture layers, and cross-layer dependencies. ``` src/ β”œβ”€β”€ Core/ -β”‚ β”œβ”€β”€ Baya.Domain Entities (User, Order, Role…), BaseEntity, IEntity, ITimeModification -β”‚ └── Baya.Application Features/ (Commands & Queries), Contracts/, Models/, pipeline behaviors (Common/) +β”‚ β”œβ”€β”€ Baya.Domain Entities (User, Role…), BaseEntity, IEntity, ITimeModification, IAuditableEntity +β”‚ └── Baya.Application Features/ (Commands & Queries), Contracts/ (incl. Contracts/Common cross-cutting seams), Models/, pipeline behaviors (Common/) β”œβ”€β”€ Infrastructure/ -β”‚ β”œβ”€β”€ Baya.Infrastructure.Persistence ApplicationDbContext, Repositories/, Configuration/, Migrations/ -β”‚ β”œβ”€β”€ Baya.Infrastructure.Identity Jwt/, Identity/ (Managers, Stores, PermissionManager, Seed) -β”‚ β”œβ”€β”€ Baya.Infrastructure.CrossCutting Serilog wiring +β”‚ β”œβ”€β”€ Baya.Infrastructure.Persistence ApplicationDbContext, Repositories/, Configuration/, Migrations/, Interceptors/ (AuditFieldInterceptor) +β”‚ β”œβ”€β”€ Baya.Infrastructure.Identity Jwt/, Identity/ (Managers, Stores, PermissionManager, Seed, CurrentUser/) +β”‚ β”œβ”€β”€ Baya.Infrastructure.CrossCutting Serilog wiring + Seams/ (mock impls of the cross-cutting seams) + AddCrossCuttingSeams β”‚ └── Baya.Infrastructure.Monitoring HealthChecks, OpenTelemetry, prometheus-net β”œβ”€β”€ API/ -β”‚ β”œβ”€β”€ Baya.Web.Api Program.cs, Controllers/V1/, appsettings*.json -β”‚ β”œβ”€β”€ Baya.WebFramework BaseController, Filters/, Middlewares/, Swagger/, Routing/ -β”‚ └── Plugins/Baya.Web.Plugins.Grpc gRPC services + .proto models +β”‚ β”œβ”€β”€ Baya.Web.Api Program.cs, Controllers/V1/ (PingController), appsettings*.json +β”‚ β”œβ”€β”€ Baya.WebFramework BaseController, Filters/, Middlewares/, Swagger/, Routing/, ServiceConfiguration/ (rate limiting) +β”‚ └── Plugins/Baya.Web.Plugins.Grpc gRPC services + .proto models (User only) β”œβ”€β”€ Shared/Baya.SharedKernel Extensions + validation base └── Tests/ β”œβ”€β”€ Baya.Tests.Setup Shared test infrastructure (SQLite, NSubstitute setup) - └── Baya.Test.Infrastructure.Identity xUnit identity tests + β”œβ”€β”€ Baya.Test.Infrastructure.Identity xUnit identity tests + └── Baya.Test.Foundation xUnit tests for cross-cutting plumbing (encryptor, audit interceptor, ping) ``` **Dependency direction points inward.** Domain has no dependencies. Application depends only on Domain. Infrastructure and API implement/consume Application contracts. Never make Domain or Application reference Infrastructure or the API β€” this is a hard rule. +**Cross-cutting seams.** Application defines mock-able external dependencies as interfaces in +`Contracts/Common/` (`IDateTimeProvider`, `IFieldEncryptor`, `ICacheService`, `IObjectStorage`, +`INotificationDispatcher`, 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. + **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 @@ -116,16 +125,20 @@ Service registration is composed from per-layer extension methods (each project' ``` ConfigureHealthChecks() Β· SetupOpenTelemetry() -AddApplicationServices() // Mediator + validators + pipeline behaviors -RegisterIdentityServices(...) // Identity, JWT/JWE, authorization policies -AddPersistenceServices(...) // DbContext, UnitOfWork, repositories +AddApplicationServices() // Mediator + pipeline behaviors (Logging β†’ Metrics β†’ Validate) +RegisterIdentityServices(...) // Identity, JWT/JWE, authorization policies, ICurrentUser + IHttpContextAccessor +AddPersistenceServices(...) // DbContext (+ AuditFieldInterceptor), UnitOfWork, repositories +AddCrossCuttingSeams(config) // IDateTimeProvider, IFieldEncryptor, ICacheService, IObjectStorage, INotificationDispatcher (mocks) AddWebFrameworkServices() // API versioning + snake_case routing +AddRateLimitingPolicies() // built-in rate limiter: per-IP global + named (otp/auth/sensitive) AddSwagger("v1", "v1.1") Β· RegisterValidatorsAsServices() Β· AddMapster() ConfigureGrpcPluginServices() ``` -Pipeline order: exception handler β†’ Swagger β†’ routing β†’ **authentication β†’ authorization** β†’ -controllers β†’ metrics β†’ health checks β†’ gRPC. +Pipeline order: exception handler β†’ Swagger β†’ routing β†’ **rate limiter β†’ authentication β†’ +authorization** β†’ controllers β†’ metrics β†’ health checks β†’ gRPC. `UseRateLimiter()` is placed +**before** `UseAuthentication()` so over-limit auth/OTP attempts are rejected (`429`) before hitting +the auth stack. When adding new infrastructure, expose it as an extension method and call it from `Program.cs` β€” never inline registrations there directly. @@ -137,17 +150,20 @@ never inline registrations there directly. Features live under `Baya.Application/Features//{Commands|Queries}//`: ``` -Features/Order/ -β”œβ”€β”€ Commands/CreateOrderCommand/ -β”‚ β”œβ”€β”€ CreateOrderCommand.cs record : ICommand> -β”‚ β”œβ”€β”€ CreateOrderCommand.Handler.cs internal sealed class : ICommandHandler<...> -β”‚ └── CreateOrderCommand.Validator.cs -└── Queries/GetUserOrdersQuery/ - β”œβ”€β”€ GetUserOrdersQuery.cs - β”œβ”€β”€ GetUserOrdersQuery.Handler.cs - └── GetUserOrdersQuery.Result.cs +Features// +β”œβ”€β”€ Commands/Command/ +β”‚ β”œβ”€β”€ Command.cs record : IRequest> +β”‚ β”œβ”€β”€ Command.Handler.cs internal sealed class : IRequestHandler<...> +β”‚ └── Command.Validator.cs +└── Queries/Query/ + β”œβ”€β”€ Query.cs + β”œβ”€β”€ Query.Handler.cs + └── 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. diff --git a/server/CONVENTIONS.md b/server/CONVENTIONS.md index 5234387..4e78919 100644 --- a/server/CONVENTIONS.md +++ b/server/CONVENTIONS.md @@ -270,6 +270,16 @@ When designing or extending an entity, include audit fields alongside timestamps 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. + +### 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`. + --- ## 7. Validation diff --git a/server/src/API/Baya.Web.Api/Controllers/V1/PingController.cs b/server/src/API/Baya.Web.Api/Controllers/V1/PingController.cs new file mode 100644 index 0000000..b151e33 --- /dev/null +++ b/server/src/API/Baya.Web.Api/Controllers/V1/PingController.cs @@ -0,0 +1,29 @@ +using System.ComponentModel.DataAnnotations; +using Asp.Versioning; +using Baya.Application.Features.System.Queries.Ping; +using Baya.WebFramework.Attributes; +using Baya.WebFramework.BaseController; +using Mediator; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using Baya.WebFramework.ServiceConfiguration; + +namespace Baya.Web.Api.Controllers.V1; + +[ApiVersion("1")] +[ApiController] +[Route("api/v{version:apiVersion}/[controller]")] +[Display(Description = "Service liveness and pipeline-smoke endpoints")] +public sealed class PingController(ISender sender) : BaseController +{ + [HttpGet("[action]")] + [ProducesOkApiResponseType] + public async Task GetStatus(CancellationToken cancellationToken) + => OperationResult(await sender.Send(new PingQuery(), cancellationToken)); + + [HttpGet("[action]")] + [EnableRateLimiting(RateLimitingServiceExtension.GlobalPolicy)] + [ProducesOkApiResponseType] + public async Task GetStatusRateLimited(CancellationToken cancellationToken) + => OperationResult(await sender.Send(new PingQuery(), cancellationToken)); +} diff --git a/server/src/API/Baya.Web.Api/Program.cs b/server/src/API/Baya.Web.Api/Program.cs index 0c68baf..569113b 100644 --- a/server/src/API/Baya.Web.Api/Program.cs +++ b/server/src/API/Baya.Web.Api/Program.cs @@ -5,6 +5,7 @@ using Baya.Application.Models.Identity; using Baya.Application.ServiceConfiguration; using Baya.Domain.Entities.User; using Baya.Infrastructure.CrossCutting.Logging; +using Baya.Infrastructure.CrossCutting.ServiceConfiguration; using Baya.Infrastructure.Identity.Identity.Dtos; using Baya.Infrastructure.Identity.Jwt; using Baya.Infrastructure.Identity.ServiceConfiguration; @@ -69,7 +70,9 @@ builder.Services.AddSwagger("v1","v1.1"); builder.Services.AddApplicationServices() .RegisterIdentityServices(identitySettings) .AddPersistenceServices(configuration) - .AddWebFrameworkServices(); + .AddCrossCuttingSeams(configuration) + .AddWebFrameworkServices() + .AddRateLimitingPolicies(); builder.Services.RegisterValidatorsAsServices(); builder.Services.AddExceptionHandler(); @@ -105,6 +108,8 @@ app.UseSwaggerAndUi(); app.UseRouting(); +app.UseRateLimiter(); + app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); diff --git a/server/src/API/Baya.Web.Api/appsettings.json b/server/src/API/Baya.Web.Api/appsettings.json index 9880419..3df877f 100644 --- a/server/src/API/Baya.Web.Api/appsettings.json +++ b/server/src/API/Baya.Web.Api/appsettings.json @@ -1,6 +1,6 @@ { "ConnectionStrings": { - "SqlServer": "Server=sql_server2022;Database=Baya_DB_Docker;User Id=SA;Password=A&VeryComplex123Password;MultipleActiveResultSets=true;encrypt=false", + "SqlServer": "Server=192.168.100.14,1433;Database=Baya;User Id=sa;Password=Pa##w0rd;TrustServerCertificate=True;Encrypt=False;", "logDb": "Server=sql_server2022;Database=Baya_Log_DB_Docker;User Id=SA;Password=A&VeryComplex123Password;MultipleActiveResultSets=true;encrypt=false" }, "IdentitySettings": { @@ -11,6 +11,15 @@ "NotBeforeMinutes": "0", "ExpirationMinutes": "10000" }, + "Seams": { + "FieldEncryption": { + "Key": "local-dev-field-encryption-key-change-me", + "HashKey": "local-dev-field-hash-key-change-me" + }, + "ObjectStorage": { + "RootPath": "" + } + }, "AllowedHosts": "*", "Kestrel": { "EndpointDefaults": { diff --git a/server/src/API/Baya.WebFramework/Baya.WebFramework.csproj b/server/src/API/Baya.WebFramework/Baya.WebFramework.csproj index 6f008bb..4adf523 100644 --- a/server/src/API/Baya.WebFramework/Baya.WebFramework.csproj +++ b/server/src/API/Baya.WebFramework/Baya.WebFramework.csproj @@ -20,4 +20,8 @@ + + + + diff --git a/server/src/API/Baya.WebFramework/ServiceConfiguration/RateLimitingServiceExtension.cs b/server/src/API/Baya.WebFramework/ServiceConfiguration/RateLimitingServiceExtension.cs new file mode 100644 index 0000000..f64a52d --- /dev/null +++ b/server/src/API/Baya.WebFramework/ServiceConfiguration/RateLimitingServiceExtension.cs @@ -0,0 +1,71 @@ +using System.Threading.RateLimiting; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.Extensions.DependencyInjection; + +namespace Baya.WebFramework.ServiceConfiguration; + +public static class RateLimitingServiceExtension +{ + /// Per-IP baseline applied to every endpoint that doesn't opt into a named policy. + public const string GlobalPolicy = "global"; + + /// Tighter limit for OTP request/verify endpoints (applied in backend-phase-2). + public const string OtpPolicy = "otp"; + + /// Limit for login/refresh and other auth endpoints. + public const string AuthPolicy = "auth"; + + /// Limit for money-sensitive actions (refund/payout) applied in later phases. + public const string SensitivePolicy = "sensitive"; + + /// + /// Registers the built-in rate limiter with a per-IP global limit plus named policies that + /// auth/OTP/sensitive endpoints opt into via [EnableRateLimiting(name)]. Over-limit + /// requests get 429 Too Many Requests. Pair with app.UseRateLimiter() placed + /// before app.UseAuthentication(). + /// + public static IServiceCollection AddRateLimitingPolicies(this IServiceCollection services) + { + services.AddRateLimiter(options => + { + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + + options.GlobalLimiter = PartitionedRateLimiter.Create(context => + RateLimitPartition.GetFixedWindowLimiter( + PartitionKey(context), + _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 100, + Window = TimeSpan.FromMinutes(1), + QueueLimit = 0 + })); + + AddFixedWindowPolicy(options, OtpPolicy, permitLimit: 5, windowSeconds: 60); + AddFixedWindowPolicy(options, AuthPolicy, permitLimit: 10, windowSeconds: 60); + AddFixedWindowPolicy(options, SensitivePolicy, permitLimit: 20, windowSeconds: 60); + + // A deliberately tiny policy used by the phase-0 ping endpoint to demonstrate 429s. + AddFixedWindowPolicy(options, GlobalPolicy, permitLimit: 5, windowSeconds: 10); + }); + + return services; + } + + private static void AddFixedWindowPolicy(RateLimiterOptions options, string name, int permitLimit, int windowSeconds) + { + options.AddPolicy(name, context => + RateLimitPartition.GetFixedWindowLimiter( + PartitionKey(context), + _ => new FixedWindowRateLimiterOptions + { + PermitLimit = permitLimit, + Window = TimeSpan.FromSeconds(windowSeconds), + QueueLimit = 0 + })); + } + + private static string PartitionKey(HttpContext context) => + context.Connection.RemoteIpAddress?.ToString() ?? "unknown"; +} diff --git a/server/src/API/Plugins/Baya.Web.Plugins.Grpc/Baya.Web.Plugins.Grpc.csproj b/server/src/API/Plugins/Baya.Web.Plugins.Grpc/Baya.Web.Plugins.Grpc.csproj index 52a00dd..37a7301 100644 --- a/server/src/API/Plugins/Baya.Web.Plugins.Grpc/Baya.Web.Plugins.Grpc.csproj +++ b/server/src/API/Plugins/Baya.Web.Plugins.Grpc/Baya.Web.Plugins.Grpc.csproj @@ -16,7 +16,6 @@ - diff --git a/server/src/API/Plugins/Baya.Web.Plugins.Grpc/GrpcPluginStartup.cs b/server/src/API/Plugins/Baya.Web.Plugins.Grpc/GrpcPluginStartup.cs index cda271e..901ec40 100644 --- a/server/src/API/Plugins/Baya.Web.Plugins.Grpc/GrpcPluginStartup.cs +++ b/server/src/API/Plugins/Baya.Web.Plugins.Grpc/GrpcPluginStartup.cs @@ -21,7 +21,6 @@ public static class GrpcPluginStartup { app.MapGrpcService(); - app.MapGrpcService(); app.MapGrpcReflectionService(); app.MapGet("/GrpcUser", async context => @@ -29,11 +28,5 @@ public static class GrpcPluginStartup await context.Response.WriteAsync( "Communication with this gRPC endpoint must be made through a gRPC client."); }); - - app.MapGet("/GrpcUserOrder", async context => - { - await context.Response.WriteAsync( - "Communication with this gRPC endpoint must be made through a gRPC client."); - }); } } \ No newline at end of file diff --git a/server/src/API/Plugins/Baya.Web.Plugins.Grpc/ProtoModels/OrderGrpcServiceModels.proto b/server/src/API/Plugins/Baya.Web.Plugins.Grpc/ProtoModels/OrderGrpcServiceModels.proto deleted file mode 100644 index d2ea289..0000000 --- a/server/src/API/Plugins/Baya.Web.Plugins.Grpc/ProtoModels/OrderGrpcServiceModels.proto +++ /dev/null @@ -1,17 +0,0 @@ -ο»Ώsyntax = "proto3"; - -option csharp_namespace = "Baya.Web.Plugins.Grpc.ProtoModels"; -import "google/protobuf/empty.proto"; - -package GrpcOrderController; - - -service OrderServices { - rpc GetUserOrders(google.protobuf.Empty) returns (stream GetUserOrdersModel); -} - -message GetUserOrdersModel{ - int32 OrderId=1; - string OrderName=2; -} - diff --git a/server/src/API/Plugins/Baya.Web.Plugins.Grpc/Services/OrderGrpcServices.cs b/server/src/API/Plugins/Baya.Web.Plugins.Grpc/Services/OrderGrpcServices.cs deleted file mode 100644 index 104f219..0000000 --- a/server/src/API/Plugins/Baya.Web.Plugins.Grpc/Services/OrderGrpcServices.cs +++ /dev/null @@ -1,46 +0,0 @@ -ο»Ώusing Baya.Application.Features.Order.Queries.GetUserOrders; -using Baya.SharedKernel.Extensions; -using Baya.Web.Plugins.Grpc.ProtoModels; -using Google.Protobuf.WellKnownTypes; -using Grpc.Core; -using Mediator; -using Microsoft.AspNetCore.Authorization; - -namespace Baya.Web.Plugins.Grpc.Services -{ - [Authorize] - public class OrderGrpcServices:OrderServices.OrderServicesBase - { - - - private readonly ISender _sender; - - public OrderGrpcServices(ISender sender) - { - _sender = sender; - } - - public override async Task GetUserOrders(Empty request, IServerStreamWriter responseStream, ServerCallContext context) - { - var userId = int.Parse(context.GetHttpContext().User.Identity.GetUserId()); - - var query = await _sender.Send(new GetUserOrdersQueryModel(userId)); - - if (!query.IsSuccess) - { - context.Status = new Status(StatusCode.InvalidArgument, query.GetErrorMessage()); - return; - } - - foreach (var getUsersQueryResultModel in query.Result) - { - await responseStream.WriteAsync(new GetUserOrdersModel() - { OrderId = getUsersQueryResultModel.OrderId, OrderName = getUsersQueryResultModel.OrderName }); - - await Task.Delay(400); - - } - - } - } -} diff --git a/server/src/Core/Baya.Application/Baya.Application.csproj b/server/src/Core/Baya.Application/Baya.Application.csproj index 14cdd00..7481fea 100644 --- a/server/src/Core/Baya.Application/Baya.Application.csproj +++ b/server/src/Core/Baya.Application/Baya.Application.csproj @@ -10,6 +10,9 @@ + + + diff --git a/server/src/Core/Baya.Application/Contracts/Common/ICacheService.cs b/server/src/Core/Baya.Application/Contracts/Common/ICacheService.cs new file mode 100644 index 0000000..96c0cd5 --- /dev/null +++ b/server/src/Core/Baya.Application/Contracts/Common/ICacheService.cs @@ -0,0 +1,21 @@ +#nullable enable +namespace Baya.Application.Contracts.Common; + +/// +/// Typed caching seam for read-heavy and reference data. The mock is backed by in-process memory; the +/// real implementation swaps to Redis while keeping the same key/TTL scheme. +/// +public interface ICacheService +{ + ValueTask GetAsync(string key, CancellationToken cancellationToken = default); + + ValueTask SetAsync(string key, T value, TimeSpan? ttl = null, CancellationToken cancellationToken = default); + + ValueTask RemoveAsync(string key, CancellationToken cancellationToken = default); + + ValueTask GetOrCreateAsync( + string key, + Func> factory, + TimeSpan? ttl = null, + CancellationToken cancellationToken = default); +} diff --git a/server/src/Core/Baya.Application/Contracts/Common/ICurrentUser.cs b/server/src/Core/Baya.Application/Contracts/Common/ICurrentUser.cs new file mode 100644 index 0000000..814aac8 --- /dev/null +++ b/server/src/Core/Baya.Application/Contracts/Common/ICurrentUser.cs @@ -0,0 +1,15 @@ +namespace Baya.Application.Contracts.Common; + +/// +/// Ambient accessor for the authenticated caller, wrapping the HTTP context behind an Application +/// contract. Registered Scoped. A null-object implementation serves non-HTTP contexts (jobs, tests) +/// so the audit interceptor and handlers never depend on IHttpContextAccessor directly. +/// +public interface ICurrentUser +{ + int? UserId { get; } + + bool IsAuthenticated { get; } + + IReadOnlyList Roles { get; } +} diff --git a/server/src/Core/Baya.Application/Contracts/Common/IDateTimeProvider.cs b/server/src/Core/Baya.Application/Contracts/Common/IDateTimeProvider.cs new file mode 100644 index 0000000..73f983e --- /dev/null +++ b/server/src/Core/Baya.Application/Contracts/Common/IDateTimeProvider.cs @@ -0,0 +1,10 @@ +namespace Baya.Application.Contracts.Common; + +/// +/// Abstracts the system clock so handlers and the audit interceptor never call DateTime.Now +/// directly and time can be frozen in tests. +/// +public interface IDateTimeProvider +{ + DateTimeOffset UtcNow { get; } +} diff --git a/server/src/Core/Baya.Application/Contracts/Common/IFieldEncryptor.cs b/server/src/Core/Baya.Application/Contracts/Common/IFieldEncryptor.cs new file mode 100644 index 0000000..86dfc8a --- /dev/null +++ b/server/src/Core/Baya.Application/Contracts/Common/IFieldEncryptor.cs @@ -0,0 +1,20 @@ +namespace Baya.Application.Contracts.Common; + +/// +/// Seam for field-level PII encryption (phone, national id, IBAN, addresses, clinical notes). +/// Every encrypted-at-rest column flows through this. The mock uses a local symmetric key; the real +/// implementation swaps to a KMS / column-encryption / Key Vault provider without touching callers. +/// Implementations must never log or surface plaintext. +/// +public interface IFieldEncryptor +{ + string Encrypt(string plaintext); + + string Decrypt(string ciphertext); + + /// + /// Deterministic keyed hash for equality lookups on encrypted columns (e.g. iban_hash) β€” + /// the same input always yields the same hash so it can be indexed and queried. + /// + string Hash(string value); +} diff --git a/server/src/Core/Baya.Application/Contracts/Common/INotificationDispatcher.cs b/server/src/Core/Baya.Application/Contracts/Common/INotificationDispatcher.cs new file mode 100644 index 0000000..89bfe18 --- /dev/null +++ b/server/src/Core/Baya.Application/Contracts/Common/INotificationDispatcher.cs @@ -0,0 +1,32 @@ +namespace Baya.Application.Contracts.Common; + +/// +/// The channel a notification is delivered through. Only is wired now; SMS/push +/// arrive in later phases behind the same seam. +/// +public enum NotificationChannel +{ + InApp, + Sms, + Push +} + +/// A notification to dispatch to a recipient over one channel. +/// The target user. +/// Delivery channel. +/// Short title/subject. +/// Message body (no secrets/PII in logs). +public sealed record Notification( + int RecipientUserId, + NotificationChannel Channel, + string Title, + string Body); + +/// +/// Seam for emitting notifications from domains like booking and payments. The mock logs/no-ops; the +/// real in-app write lands in backend-phase-15, with SMS/push added behind the same interface. +/// +public interface INotificationDispatcher +{ + ValueTask DispatchAsync(Notification notification, CancellationToken cancellationToken = default); +} diff --git a/server/src/Core/Baya.Application/Contracts/Common/IObjectStorage.cs b/server/src/Core/Baya.Application/Contracts/Common/IObjectStorage.cs new file mode 100644 index 0000000..a7d58ee --- /dev/null +++ b/server/src/Core/Baya.Application/Contracts/Common/IObjectStorage.cs @@ -0,0 +1,22 @@ +#nullable enable +namespace Baya.Application.Contracts.Common; + +/// +/// Blob/object storage seam keyed by an opaque storage key. The mock stores blobs on local disk; the +/// real implementation swaps to MinIO/S3/ArvanCloud (presigned URLs) without changing callers. +/// +public interface IObjectStorage +{ + ValueTask PutAsync( + string key, + Stream content, + string contentType, + CancellationToken cancellationToken = default); + + ValueTask GetAsync(string key, CancellationToken cancellationToken = default); + + ValueTask DeleteAsync(string key, CancellationToken cancellationToken = default); + + /// A retrievable URL for the stored object (presigned by the real provider). + string GetUrl(string key); +} diff --git a/server/src/Core/Baya.Application/Contracts/Persistence/IOrderRepository.cs b/server/src/Core/Baya.Application/Contracts/Persistence/IOrderRepository.cs deleted file mode 100644 index 1b23147..0000000 --- a/server/src/Core/Baya.Application/Contracts/Persistence/IOrderRepository.cs +++ /dev/null @@ -1,12 +0,0 @@ -ο»Ώusing Baya.Domain.Entities.Order; - -namespace Baya.Application.Contracts.Persistence; - -public interface IOrderRepository -{ - Task AddOrderAsync(Order order); - Task> GetAllUserOrdersAsync(int userId); - Task> GetAllOrdersWithRelatedUserAsync(); - Task GetUserOrderByIdAndUserIdAsync(int userId,int orderId,bool trackEntity); - Task DeleteUserOrdersAsync(int userId); -} \ No newline at end of file diff --git a/server/src/Core/Baya.Application/Contracts/Persistence/IUnitOfWork.cs b/server/src/Core/Baya.Application/Contracts/Persistence/IUnitOfWork.cs index 65473c8..e69cf21 100644 --- a/server/src/Core/Baya.Application/Contracts/Persistence/IUnitOfWork.cs +++ b/server/src/Core/Baya.Application/Contracts/Persistence/IUnitOfWork.cs @@ -3,7 +3,6 @@ public interface IUnitOfWork { public IUserRefreshTokenRepository UserRefreshTokenRepository { get; } - public IOrderRepository OrderRepository { get; } Task CommitAsync(); ValueTask RollBackAsync(); } \ No newline at end of file diff --git a/server/src/Core/Baya.Application/Features/Order/Commands/AddOrderCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Order/Commands/AddOrderCommand.Handler.cs deleted file mode 100644 index e939ce6..0000000 --- a/server/src/Core/Baya.Application/Features/Order/Commands/AddOrderCommand.Handler.cs +++ /dev/null @@ -1,25 +0,0 @@ -ο»Ώusing Baya.Application.Contracts.Identity; -using Baya.Application.Contracts.Persistence; -using Baya.Application.Models.Common; -using Mediator; - -namespace Baya.Application.Features.Order.Commands; - -internal class AddOrderCommandHandler(IUnitOfWork unitOfWork, IAppUserManager userManager) - : IRequestHandler> -{ - public async ValueTask> Handle(AddOrderCommand request, CancellationToken cancellationToken) - { - var user = await userManager.GetUserByIdAsync(request.UserId); - - if(user==null) - return OperationResult.FailureResult("User Not Found"); - - await unitOfWork.OrderRepository.AddOrderAsync(new Domain.Entities.Order.Order() - { UserId = user.Id, OrderName = request.OrderName }); - - await unitOfWork.CommitAsync(); - - return OperationResult.SuccessResult(true); - } -} \ No newline at end of file diff --git a/server/src/Core/Baya.Application/Features/Order/Commands/AddOrderCommand.cs b/server/src/Core/Baya.Application/Features/Order/Commands/AddOrderCommand.cs deleted file mode 100644 index 9418c31..0000000 --- a/server/src/Core/Baya.Application/Features/Order/Commands/AddOrderCommand.cs +++ /dev/null @@ -1,25 +0,0 @@ -ο»Ώusing System.Text.Json.Serialization; -using Baya.Application.Models.Common; -using Baya.SharedKernel.ValidationBase; -using Baya.SharedKernel.ValidationBase.Contracts; -using FluentValidation; -using Mediator; - -namespace Baya.Application.Features.Order.Commands; - -public record AddOrderCommand( string OrderName) : IRequest>, - IValidatableModel -{ - [JsonIgnore] - public int UserId { get; set; } - - public IValidator ValidateApplicationModel(ApplicationBaseValidationModelProvider validator) - { - validator.RuleFor(c => c.OrderName) - .NotEmpty() - .NotNull() - .WithMessage("Please enter your role name"); - - return validator; - } -} \ No newline at end of file diff --git a/server/src/Core/Baya.Application/Features/Order/Commands/DeleteUserOrdersCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Order/Commands/DeleteUserOrdersCommand.Handler.cs deleted file mode 100644 index f98305d..0000000 --- a/server/src/Core/Baya.Application/Features/Order/Commands/DeleteUserOrdersCommand.Handler.cs +++ /dev/null @@ -1,15 +0,0 @@ -ο»Ώusing Baya.Application.Contracts.Persistence; -using Baya.Application.Models.Common; -using Mediator; - -namespace Baya.Application.Features.Order.Commands; - -public class DeleteUserOrdersCommandHandler(IUnitOfWork unitOfWork) : IRequestHandler> -{ - public async ValueTask> Handle(DeleteUserOrdersCommand request, CancellationToken cancellationToken) - { - await unitOfWork.OrderRepository.DeleteUserOrdersAsync(request.UserId); - - return OperationResult.SuccessResult(true); - } -} \ No newline at end of file diff --git a/server/src/Core/Baya.Application/Features/Order/Commands/DeleteUserOrdersCommand.cs b/server/src/Core/Baya.Application/Features/Order/Commands/DeleteUserOrdersCommand.cs deleted file mode 100644 index 33a70be..0000000 --- a/server/src/Core/Baya.Application/Features/Order/Commands/DeleteUserOrdersCommand.cs +++ /dev/null @@ -1,6 +0,0 @@ -ο»Ώusing Baya.Application.Models.Common; -using Mediator; - -namespace Baya.Application.Features.Order.Commands; - -public record DeleteUserOrdersCommand(int UserId):IRequest>; \ No newline at end of file diff --git a/server/src/Core/Baya.Application/Features/Order/Commands/UpdateUserOrderCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Order/Commands/UpdateUserOrderCommand.Handler.cs deleted file mode 100644 index 7b29722..0000000 --- a/server/src/Core/Baya.Application/Features/Order/Commands/UpdateUserOrderCommand.Handler.cs +++ /dev/null @@ -1,25 +0,0 @@ -ο»Ώusing Baya.Application.Contracts.Persistence; -using Baya.Application.Models.Common; -using Mediator; - -namespace Baya.Application.Features.Order.Commands; - -public class UpdateUserOrderCommandHandler(IUnitOfWork unitOfWork) : IRequestHandler> -{ - - - public async ValueTask> Handle(UpdateUserOrderCommand request, CancellationToken cancellationToken) - { - var order = await unitOfWork.OrderRepository.GetUserOrderByIdAndUserIdAsync(request.UserId, request.OrderId, - true); - - if(order is null) - return OperationResult.NotFoundResult("Specified Order not found"); - - order.OrderName=request.OrderName; - - await unitOfWork.CommitAsync(); - - return OperationResult.SuccessResult(true); - } -} \ No newline at end of file diff --git a/server/src/Core/Baya.Application/Features/Order/Commands/UpdateUserOrderCommand.cs b/server/src/Core/Baya.Application/Features/Order/Commands/UpdateUserOrderCommand.cs deleted file mode 100644 index 2091066..0000000 --- a/server/src/Core/Baya.Application/Features/Order/Commands/UpdateUserOrderCommand.cs +++ /dev/null @@ -1,23 +0,0 @@ -ο»Ώusing System.Text.Json.Serialization; -using Baya.Application.Models.Common; -using Baya.SharedKernel.ValidationBase; -using Baya.SharedKernel.ValidationBase.Contracts; -using FluentValidation; -using Mediator; - -namespace Baya.Application.Features.Order.Commands; - -public record UpdateUserOrderCommand - (int OrderId, string OrderName) : IRequest>,IValidatableModel -{ - [JsonIgnore] - public int UserId { get; set; } - - public IValidator ValidateApplicationModel(ApplicationBaseValidationModelProvider validator) - { - validator.RuleFor(c => c.OrderId).NotEmpty().GreaterThan(0); - validator.RuleFor(c => c.OrderName).NotEmpty().NotNull(); - - return validator; - } -}; \ No newline at end of file diff --git a/server/src/Core/Baya.Application/Features/Order/Queries/GetAllOrders/GetAllOrdersQuery.cs b/server/src/Core/Baya.Application/Features/Order/Queries/GetAllOrders/GetAllOrdersQuery.cs deleted file mode 100644 index d3ee2f4..0000000 --- a/server/src/Core/Baya.Application/Features/Order/Queries/GetAllOrders/GetAllOrdersQuery.cs +++ /dev/null @@ -1,6 +0,0 @@ -ο»Ώusing Baya.Application.Models.Common; -using Mediator; - -namespace Baya.Application.Features.Order.Queries.GetAllOrders; - -public record GetAllOrdersQuery():IRequest>>; \ No newline at end of file diff --git a/server/src/Core/Baya.Application/Features/Order/Queries/GetAllOrders/GetAllOrdersQueryHandler.cs b/server/src/Core/Baya.Application/Features/Order/Queries/GetAllOrders/GetAllOrdersQueryHandler.cs deleted file mode 100644 index 6c79daa..0000000 --- a/server/src/Core/Baya.Application/Features/Order/Queries/GetAllOrders/GetAllOrdersQueryHandler.cs +++ /dev/null @@ -1,20 +0,0 @@ -ο»Ώusing Baya.Application.Contracts.Persistence; -using Baya.Application.Models.Common; -using MapsterMapper; -using Mediator; - -namespace Baya.Application.Features.Order.Queries.GetAllOrders -{ - internal class GetAllOrdersQueryHandler(IUnitOfWork unitOfWork, IMapper mapper) - : IRequestHandler>> - { - public async ValueTask>> Handle(GetAllOrdersQuery request, CancellationToken cancellationToken) - { - var orders = await unitOfWork.OrderRepository.GetAllOrdersWithRelatedUserAsync(); - - var result = orders.Select(mapper.Map).ToList(); - - return OperationResult>.SuccessResult(result); - } - } -} diff --git a/server/src/Core/Baya.Application/Features/Order/Queries/GetAllOrders/GetAllOrdersQueryResult.cs b/server/src/Core/Baya.Application/Features/Order/Queries/GetAllOrders/GetAllOrdersQueryResult.cs deleted file mode 100644 index b55467c..0000000 --- a/server/src/Core/Baya.Application/Features/Order/Queries/GetAllOrders/GetAllOrdersQueryResult.cs +++ /dev/null @@ -1,19 +0,0 @@ -ο»Ώ - -using Mapster; - -namespace Baya.Application.Features.Order.Queries.GetAllOrders; - -public record GetAllOrdersQueryResult(int OrderId, string OrderName, int OrderOwnerId, string OrderOwnerUserName); - -class GetAllOrdersQueryResultMapping : IRegister -{ - public void Register(TypeAdapterConfig config) - { - config.NewConfig() - .Map(dest => dest.OrderId, src => src.Id) - .Map(dest => dest.OrderName, src => src.OrderName) - .Map(dest => dest.OrderOwnerId, src => src.User.Id) - .Map(dest => dest.OrderOwnerUserName, src => src.User.UserName); - } -} \ No newline at end of file diff --git a/server/src/Core/Baya.Application/Features/Order/Queries/GetUserOrders/GetUserOrdersQueryHandler.cs b/server/src/Core/Baya.Application/Features/Order/Queries/GetUserOrders/GetUserOrdersQueryHandler.cs deleted file mode 100644 index c7218df..0000000 --- a/server/src/Core/Baya.Application/Features/Order/Queries/GetUserOrders/GetUserOrdersQueryHandler.cs +++ /dev/null @@ -1,21 +0,0 @@ -ο»Ώusing Baya.Application.Contracts.Persistence; -using Baya.Application.Models.Common; -using Mediator; - -namespace Baya.Application.Features.Order.Queries.GetUserOrders; - -internal class GetUserOrdersQueryHandler(IUnitOfWork unitOfWork) - : IRequestHandler>> -{ - public async ValueTask>> Handle(GetUserOrdersQueryModel request, CancellationToken cancellationToken) - { - var orders = await unitOfWork.OrderRepository.GetAllUserOrdersAsync(request.UserId); - - if(!orders.Any()) - return OperationResult>.NotFoundResult("You Don't Have Any Orders"); - - var result = orders.Select(c => new GetUsersQueryResultModel(c.Id, c.OrderName)); - - return OperationResult>.SuccessResult(result.ToList()); - } -} \ No newline at end of file diff --git a/server/src/Core/Baya.Application/Features/Order/Queries/GetUserOrders/GetUserOrdersQueryModel.cs b/server/src/Core/Baya.Application/Features/Order/Queries/GetUserOrders/GetUserOrdersQueryModel.cs deleted file mode 100644 index e92f553..0000000 --- a/server/src/Core/Baya.Application/Features/Order/Queries/GetUserOrders/GetUserOrdersQueryModel.cs +++ /dev/null @@ -1,6 +0,0 @@ -ο»Ώusing Baya.Application.Models.Common; -using Mediator; - -namespace Baya.Application.Features.Order.Queries.GetUserOrders; - -public record GetUserOrdersQueryModel(int UserId) : IRequest>>; \ No newline at end of file diff --git a/server/src/Core/Baya.Application/Features/Order/Queries/GetUserOrders/GetUsersQueryResultModel.cs b/server/src/Core/Baya.Application/Features/Order/Queries/GetUserOrders/GetUsersQueryResultModel.cs deleted file mode 100644 index 61f7189..0000000 --- a/server/src/Core/Baya.Application/Features/Order/Queries/GetUserOrders/GetUsersQueryResultModel.cs +++ /dev/null @@ -1,3 +0,0 @@ -ο»Ώnamespace Baya.Application.Features.Order.Queries.GetUserOrders; - -public record GetUsersQueryResultModel(int OrderId, string OrderName); \ No newline at end of file diff --git a/server/src/Core/Baya.Application/Features/System/Queries/Ping/PingQuery.Handler.cs b/server/src/Core/Baya.Application/Features/System/Queries/Ping/PingQuery.Handler.cs new file mode 100644 index 0000000..1a4ef18 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/System/Queries/Ping/PingQuery.Handler.cs @@ -0,0 +1,16 @@ +using Baya.Application.Contracts.Common; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.System.Queries.Ping; + +internal sealed class PingQueryHandler(IDateTimeProvider dateTimeProvider) + : IRequestHandler> +{ + public ValueTask> Handle(PingQuery request, CancellationToken cancellationToken) + { + var result = new PingQueryResult("Baya.Web.Api", "ok", dateTimeProvider.UtcNow); + + return ValueTask.FromResult(OperationResult.SuccessResult(result)); + } +} diff --git a/server/src/Core/Baya.Application/Features/System/Queries/Ping/PingQuery.Result.cs b/server/src/Core/Baya.Application/Features/System/Queries/Ping/PingQuery.Result.cs new file mode 100644 index 0000000..4f6936b --- /dev/null +++ b/server/src/Core/Baya.Application/Features/System/Queries/Ping/PingQuery.Result.cs @@ -0,0 +1,4 @@ +namespace Baya.Application.Features.System.Queries.Ping; + +/// Liveness payload proving the REST β†’ Mediator β†’ envelope pipeline end-to-end. +public record PingQueryResult(string Service, string Status, DateTimeOffset ServerTimeUtc); diff --git a/server/src/Core/Baya.Application/Features/System/Queries/Ping/PingQuery.cs b/server/src/Core/Baya.Application/Features/System/Queries/Ping/PingQuery.cs new file mode 100644 index 0000000..0bc8534 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/System/Queries/Ping/PingQuery.cs @@ -0,0 +1,6 @@ +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.System.Queries.Ping; + +public record PingQuery : IRequest>; diff --git a/server/src/Core/Baya.Application/ServiceConfiguration/ServiceCollectionExtension.cs b/server/src/Core/Baya.Application/ServiceConfiguration/ServiceCollectionExtension.cs index c570ed2..3edd090 100644 --- a/server/src/Core/Baya.Application/ServiceConfiguration/ServiceCollectionExtension.cs +++ b/server/src/Core/Baya.Application/ServiceConfiguration/ServiceCollectionExtension.cs @@ -16,8 +16,10 @@ public static class ServiceCollectionExtension options.Namespace = "Baya.Application.Mediator"; }); + services.AddScoped(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>)); + services.AddScoped(typeof(IPipelineBehavior<,>), typeof(MetricsBehaviour<,>)); - + services.AddScoped(typeof(IPipelineBehavior<,>), typeof(ValidateCommandBehavior<,>)); diff --git a/server/src/Core/Baya.Domain/Common/BaseEntity.cs b/server/src/Core/Baya.Domain/Common/BaseEntity.cs index a4ea467..781752b 100644 --- a/server/src/Core/Baya.Domain/Common/BaseEntity.cs +++ b/server/src/Core/Baya.Domain/Common/BaseEntity.cs @@ -1,4 +1,4 @@ -ο»Ώnamespace Baya.Domain.Common; +namespace Baya.Domain.Common; public interface IEntity { @@ -6,11 +6,21 @@ public interface IEntity public interface ITimeModification { - DateTime CreatedTime { get; set; } - DateTime? ModifiedDate { get; set; } + DateTimeOffset CreatedAt { get; set; } + DateTimeOffset? ModifiedAt { get; set; } } -public abstract class BaseEntity : IEntity, ITimeModification +/// +/// An entity whose create/modify timestamps and the acting user are stamped automatically by the +/// SaveChanges audit interceptor β€” handlers must never set these fields themselves. +/// +public interface IAuditableEntity : ITimeModification +{ + int? CreatedById { get; set; } + int? ModifiedById { get; set; } +} + +public abstract class BaseEntity : IEntity, IAuditableEntity { public TKey Id { get; protected set; } @@ -49,11 +59,13 @@ public abstract class BaseEntity : IEntity, ITimeModification return (GetType().ToString() + Id).GetHashCode(); } - public DateTime CreatedTime { get; set; } - public DateTime? ModifiedDate { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset? ModifiedAt { get; set; } + public int? CreatedById { get; set; } + public int? ModifiedById { get; set; } } public abstract class BaseEntity : BaseEntity { -} \ No newline at end of file +} diff --git a/server/src/Core/Baya.Domain/Entities/Order/Order.cs b/server/src/Core/Baya.Domain/Entities/Order/Order.cs deleted file mode 100644 index 01b3adf..0000000 --- a/server/src/Core/Baya.Domain/Entities/Order/Order.cs +++ /dev/null @@ -1,16 +0,0 @@ -ο»Ώusing Baya.Domain.Common; - -namespace Baya.Domain.Entities.Order; - -public class Order:BaseEntity -{ - public string OrderName { get; set; } - public bool IsDeleted { get; set; } - - #region Navigation Properties - - public User.User User { get; set; } - public int UserId { get; set; } - - #endregion -} \ No newline at end of file diff --git a/server/src/Core/Baya.Domain/Entities/User/User.cs b/server/src/Core/Baya.Domain/Entities/User/User.cs index 820da2e..f98f414 100644 --- a/server/src/Core/Baya.Domain/Entities/User/User.cs +++ b/server/src/Core/Baya.Domain/Entities/User/User.cs @@ -19,11 +19,4 @@ public class User:IdentityUser,IEntity public ICollection Claims { get; set; } public ICollection Tokens { get; set; } public ICollection UserRefreshTokens { get; set; } - - #region Navigation Properties - - public IList Orders { get; set; } - - #endregion - } \ No newline at end of file diff --git a/server/src/Core/Baya.Domain/Entities/User/UserRefreshToken.cs b/server/src/Core/Baya.Domain/Entities/User/UserRefreshToken.cs index 379a1c1..d8e09b3 100644 --- a/server/src/Core/Baya.Domain/Entities/User/UserRefreshToken.cs +++ b/server/src/Core/Baya.Domain/Entities/User/UserRefreshToken.cs @@ -1,16 +1,10 @@ -ο»Ώusing Baya.Domain.Common; +using Baya.Domain.Common; namespace Baya.Domain.Entities.User; public class UserRefreshToken:BaseEntity { - public UserRefreshToken() - { - CreatedAt=DateTime.Now; - } - public int UserId { get; set; } public User User { get; set; } - public DateTime CreatedAt { get; set; } public bool IsValid { get; set; } -} \ No newline at end of file +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Baya.Infrastructure.CrossCutting.csproj b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Baya.Infrastructure.CrossCutting.csproj index dd48cd6..34cb342 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Baya.Infrastructure.CrossCutting.csproj +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Baya.Infrastructure.CrossCutting.csproj @@ -17,4 +17,7 @@ + + + diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/LocalDiskObjectStorage.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/LocalDiskObjectStorage.cs new file mode 100644 index 0000000..7915739 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/LocalDiskObjectStorage.cs @@ -0,0 +1,63 @@ +#nullable enable +using Baya.Application.Contracts.Common; +using Microsoft.Extensions.Options; + +namespace Baya.Infrastructure.CrossCutting.Seams; + +/// +/// Local-disk implementation of β€” the mock seam. Blobs are stored as +/// files under a configured scratch root, keyed by an opaque storage key. The real implementation +/// swaps to MinIO/S3/ArvanCloud (presigned URLs) behind the same interface. +/// +public sealed class LocalDiskObjectStorage : IObjectStorage +{ + private readonly string _root; + + public LocalDiskObjectStorage(IOptions options) + { + var configured = options.Value.ObjectStorage.RootPath; + _root = string.IsNullOrWhiteSpace(configured) + ? Path.Combine(Path.GetTempPath(), "balinyaar-object-storage") + : configured; + + Directory.CreateDirectory(_root); + } + + public async ValueTask PutAsync(string key, Stream content, string contentType, CancellationToken cancellationToken = default) + { + var path = ResolvePath(key); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + + await using var file = File.Create(path); + await content.CopyToAsync(file, cancellationToken); + } + + public ValueTask GetAsync(string key, CancellationToken cancellationToken = default) + { + var path = ResolvePath(key); + Stream? stream = File.Exists(path) ? File.OpenRead(path) : null; + return ValueTask.FromResult(stream); + } + + public ValueTask DeleteAsync(string key, CancellationToken cancellationToken = default) + { + var path = ResolvePath(key); + if (File.Exists(path)) + File.Delete(path); + + return ValueTask.CompletedTask; + } + + public string GetUrl(string key) => new Uri(Path.Combine(_root, SanitizeKey(key))).AbsoluteUri; + + private string ResolvePath(string key) => Path.Combine(_root, SanitizeKey(key)); + + // Keep the key from escaping the storage root; treat '/' as a folder separator only. + private static string SanitizeKey(string key) + { + var normalized = key.Replace('\\', '/').TrimStart('/'); + var segments = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries) + .Where(s => s != "." && s != ".."); + return Path.Combine([.. segments]); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/LogNotificationDispatcher.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/LogNotificationDispatcher.cs new file mode 100644 index 0000000..fd2078f --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/LogNotificationDispatcher.cs @@ -0,0 +1,22 @@ +using Baya.Application.Contracts.Common; +using Microsoft.Extensions.Logging; + +namespace Baya.Infrastructure.CrossCutting.Seams; + +/// +/// No-op implementation of β€” the mock seam. It logs that a +/// notification would be sent (no PII in the log). The real in-app write lands in backend-phase-15, +/// with SMS/push channels added behind the same interface. +/// +public sealed class LogNotificationDispatcher(ILogger logger) : INotificationDispatcher +{ + public ValueTask DispatchAsync(Notification notification, CancellationToken cancellationToken = default) + { + logger.LogInformation( + "Notification suppressed (mock dispatcher): channel {Channel} to user {UserId}", + notification.Channel, + notification.RecipientUserId); + + return ValueTask.CompletedTask; + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MemoryCacheService.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MemoryCacheService.cs new file mode 100644 index 0000000..9b20bae --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MemoryCacheService.cs @@ -0,0 +1,47 @@ +#nullable enable +using Baya.Application.Contracts.Common; +using Microsoft.Extensions.Caching.Memory; + +namespace Baya.Infrastructure.CrossCutting.Seams; + +/// +/// In-process implementation of β€” the mock seam. +/// The real implementation swaps to Redis (StackExchange.Redis) while keeping the same key/TTL scheme. +/// +public sealed class MemoryCacheService(IMemoryCache cache) : ICacheService +{ + public ValueTask GetAsync(string key, CancellationToken cancellationToken = default) + { + return ValueTask.FromResult(cache.TryGetValue(key, out T? value) ? value : default); + } + + public ValueTask SetAsync(string key, T value, TimeSpan? ttl = null, CancellationToken cancellationToken = default) + { + var options = new MemoryCacheEntryOptions(); + if (ttl is { } expiry) + options.AbsoluteExpirationRelativeToNow = expiry; + + cache.Set(key, value, options); + return ValueTask.CompletedTask; + } + + public ValueTask RemoveAsync(string key, CancellationToken cancellationToken = default) + { + cache.Remove(key); + return ValueTask.CompletedTask; + } + + public async ValueTask GetOrCreateAsync( + string key, + Func> factory, + TimeSpan? ttl = null, + CancellationToken cancellationToken = default) + { + if (cache.TryGetValue(key, out T? cached) && cached is not null) + return cached; + + var value = await factory(cancellationToken); + await SetAsync(key, value, ttl, cancellationToken); + return value; + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SeamOptions.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SeamOptions.cs new file mode 100644 index 0000000..6a6d7cf --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SeamOptions.cs @@ -0,0 +1,28 @@ +namespace Baya.Infrastructure.CrossCutting.Seams; + +/// +/// Options bound from the Seams configuration section. The mock seams read non-secret defaults +/// from here; production keys/paths come from environment variables or user-secrets, never committed. +/// +public sealed class SeamOptions +{ + public const string SectionName = "Seams"; + + public FieldEncryptionOptions FieldEncryption { get; set; } = new(); + public ObjectStorageOptions ObjectStorage { get; set; } = new(); +} + +public sealed class FieldEncryptionOptions +{ + /// Base64 32-byte AES key. Local-dev default only; override per environment. + public string Key { get; set; } = string.Empty; + + /// Base64 HMAC key used for deterministic lookup hashes. + public string HashKey { get; set; } = string.Empty; +} + +public sealed class ObjectStorageOptions +{ + /// Filesystem root the local-disk mock writes blobs under. + public string RootPath { get; set; } = string.Empty; +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SymmetricFieldEncryptor.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SymmetricFieldEncryptor.cs new file mode 100644 index 0000000..fdc3dc4 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SymmetricFieldEncryptor.cs @@ -0,0 +1,86 @@ +using System.Security.Cryptography; +using System.Text; +using Baya.Application.Contracts.Common; +using Microsoft.Extensions.Options; + +namespace Baya.Infrastructure.CrossCutting.Seams; + +/// +/// Local symmetric-key implementation of β€” AES-256-CBC with a random +/// per-value IV (prepended to the ciphertext) for at-rest reversibility, and a keyed HMAC-SHA256 for +/// deterministic lookup hashes. This is the mock seam: the real implementation swaps to a KMS / Key +/// Vault provider behind the same interface. Plaintext is never logged. +/// +public sealed class SymmetricFieldEncryptor : IFieldEncryptor +{ + private readonly byte[] _key; + private readonly byte[] _hashKey; + + public SymmetricFieldEncryptor(IOptions options) + { + var settings = options.Value.FieldEncryption; + + // Derive a stable 32-byte AES key from whatever the operator configured (any length/format), + // so a human-friendly secret still yields a valid key. SHA-256 of the configured material. + _key = SHA256.HashData(Encoding.UTF8.GetBytes(Require(settings.Key, nameof(settings.Key)))); + + var hashMaterial = string.IsNullOrEmpty(settings.HashKey) ? settings.Key : settings.HashKey; + _hashKey = SHA256.HashData(Encoding.UTF8.GetBytes(Require(hashMaterial, nameof(settings.HashKey)))); + } + + public string Encrypt(string plaintext) + { + if (string.IsNullOrEmpty(plaintext)) + return plaintext; + + using var aes = Aes.Create(); + aes.Key = _key; + aes.GenerateIV(); + + using var encryptor = aes.CreateEncryptor(); + var plainBytes = Encoding.UTF8.GetBytes(plaintext); + var cipherBytes = encryptor.TransformFinalBlock(plainBytes, 0, plainBytes.Length); + + var result = new byte[aes.IV.Length + cipherBytes.Length]; + Buffer.BlockCopy(aes.IV, 0, result, 0, aes.IV.Length); + Buffer.BlockCopy(cipherBytes, 0, result, aes.IV.Length, cipherBytes.Length); + + return Convert.ToBase64String(result); + } + + public string Decrypt(string ciphertext) + { + if (string.IsNullOrEmpty(ciphertext)) + return ciphertext; + + var cipherWithIv = Convert.FromBase64String(ciphertext); + + using var aes = Aes.Create(); + aes.Key = _key; + + var ivLength = aes.BlockSize / 8; + var iv = new byte[ivLength]; + Buffer.BlockCopy(cipherWithIv, 0, iv, 0, ivLength); + aes.IV = iv; + + using var decryptor = aes.CreateDecryptor(); + var cipherBytes = decryptor.TransformFinalBlock(cipherWithIv, ivLength, cipherWithIv.Length - ivLength); + + return Encoding.UTF8.GetString(cipherBytes); + } + + public string Hash(string value) + { + if (string.IsNullOrEmpty(value)) + return value; + + using var hmac = new HMACSHA256(_hashKey); + var hashBytes = hmac.ComputeHash(Encoding.UTF8.GetBytes(value)); + return Convert.ToHexString(hashBytes); + } + + private static string Require(string value, string name) => + string.IsNullOrWhiteSpace(value) + ? throw new InvalidOperationException($"Seams:FieldEncryption:{name} must be configured.") + : value; +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SystemDateTimeProvider.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SystemDateTimeProvider.cs new file mode 100644 index 0000000..3bc217f --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SystemDateTimeProvider.cs @@ -0,0 +1,9 @@ +using Baya.Application.Contracts.Common; + +namespace Baya.Infrastructure.CrossCutting.Seams; + +/// Real system clock. Tests substitute to freeze time. +public sealed class SystemDateTimeProvider : IDateTimeProvider +{ + public DateTimeOffset UtcNow => DateTimeOffset.UtcNow; +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs new file mode 100644 index 0000000..61d2c30 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs @@ -0,0 +1,29 @@ +using Baya.Application.Contracts.Common; +using Baya.Infrastructure.CrossCutting.Seams; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Baya.Infrastructure.CrossCutting.ServiceConfiguration; + +public static class ServiceCollectionExtension +{ + /// + /// Registers the cross-cutting seams (time, PII encryption, cache, object storage, notifications) + /// with their in-memory/local mock implementations. Swapping in a real provider later is a + /// registration change here β€” callers depend only on the Application contracts. + /// + public static IServiceCollection AddCrossCuttingSeams(this IServiceCollection services, IConfiguration configuration) + { + services.Configure(configuration.GetSection(SeamOptions.SectionName)); + + services.AddMemoryCache(); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddScoped(); + + return services; + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Identity/Identity/CurrentUser/HttpContextCurrentUser.cs b/server/src/Infrastructure/Baya.Infrastructure.Identity/Identity/CurrentUser/HttpContextCurrentUser.cs new file mode 100644 index 0000000..651ac76 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Identity/Identity/CurrentUser/HttpContextCurrentUser.cs @@ -0,0 +1,30 @@ +#nullable enable +using System.Security.Claims; +using Baya.Application.Contracts.Common; +using Baya.SharedKernel.Extensions; +using Microsoft.AspNetCore.Http; + +namespace Baya.Infrastructure.Identity.Identity.CurrentUser; + +/// +/// Resolves the current user from the active HTTP request's claims. When there is no request or no +/// authenticated principal (background work, startup), all members report "no user". +/// +public sealed class HttpContextCurrentUser(IHttpContextAccessor httpContextAccessor) : ICurrentUser +{ + private ClaimsPrincipal? Principal => httpContextAccessor.HttpContext?.User; + + public int? UserId + { + get + { + var rawId = Principal?.Identity?.GetUserId(); + return rawId.HasValue() && int.TryParse(rawId, out var id) ? id : null; + } + } + + public bool IsAuthenticated => Principal?.Identity?.IsAuthenticated ?? false; + + public IReadOnlyList Roles => + Principal?.FindAll(ClaimTypes.Role).Select(c => c.Value).ToArray() ?? []; +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Identity/Identity/CurrentUser/NullCurrentUser.cs b/server/src/Infrastructure/Baya.Infrastructure.Identity/Identity/CurrentUser/NullCurrentUser.cs new file mode 100644 index 0000000..74e8eda --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Identity/Identity/CurrentUser/NullCurrentUser.cs @@ -0,0 +1,16 @@ +using Baya.Application.Contracts.Common; + +namespace Baya.Infrastructure.Identity.Identity.CurrentUser; + +/// +/// Null-object for non-HTTP contexts (background jobs, tests) β€” reports an +/// unauthenticated caller so audit stamping leaves the user fields null rather than failing. +/// +public sealed class NullCurrentUser : ICurrentUser +{ + public int? UserId => null; + + public bool IsAuthenticated => false; + + public IReadOnlyList Roles => []; +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Identity/ServiceConfiguration/ServiceCollectionExtension.cs b/server/src/Infrastructure/Baya.Infrastructure.Identity/ServiceConfiguration/ServiceCollectionExtension.cs index 540e6d4..eaadce3 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Identity/ServiceConfiguration/ServiceCollectionExtension.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Identity/ServiceConfiguration/ServiceCollectionExtension.cs @@ -1,10 +1,12 @@ ο»Ώusing System.Security.Claims; using System.Text; using Baya.Application.Contracts; +using Baya.Application.Contracts.Common; using Baya.Application.Contracts.Identity; using Baya.Application.Models.ApiResult; using Baya.Domain.Entities.User; using Baya.Infrastructure.Identity.Identity; +using Baya.Infrastructure.Identity.Identity.CurrentUser; using Baya.Infrastructure.Identity.Identity.Dtos; using Baya.Infrastructure.Identity.Identity.Extensions; using Baya.Infrastructure.Identity.Identity.Manager; @@ -29,6 +31,9 @@ public static class ServiceCollectionExtension { public static IServiceCollection RegisterIdentityServices(this IServiceCollection services,IdentitySettings identitySettings) { + services.AddHttpContextAccessor(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/ApplicationDbContext.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/ApplicationDbContext.cs index 7cf7dfc..f116d6d 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/ApplicationDbContext.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/ApplicationDbContext.cs @@ -18,7 +18,6 @@ public class ApplicationDbContext: IdentityDbContext - x.Entity is ITimeModification && x.State == EntityState.Modified).Select(x => x.Entity as ITimeModification); - - var addedEntities = ChangeTracker.Entries().Where(x => - x.Entity is ITimeModification && x.State == EntityState.Added).Select(x => x.Entity as ITimeModification); - - foreach (var entity in updatedEntities) - { - if (entity != null) - { - entity.ModifiedDate = DateTime.Now; - } - } - - foreach (var entity in addedEntities) - { - if (entity != null) - { - entity.CreatedTime = DateTime.Now; - entity.ModifiedDate = DateTime.Now; - } - } - } } \ No newline at end of file diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/OrderConfig/OrderConfig.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/OrderConfig/OrderConfig.cs deleted file mode 100644 index a2555aa..0000000 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/OrderConfig/OrderConfig.cs +++ /dev/null @@ -1,15 +0,0 @@ -ο»Ώusing Baya.Domain.Entities.Order; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; - -namespace Baya.Infrastructure.Persistence.Configuration.OrderConfig; - -internal class OrderConfig:IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.HasOne(c => c.User).WithMany(c => c.Orders).HasForeignKey(c => c.UserId); - - builder.HasQueryFilter(c => !c.IsDeleted); - } -} \ No newline at end of file diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Interceptors/AuditFieldInterceptor.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Interceptors/AuditFieldInterceptor.cs new file mode 100644 index 0000000..3c3ff54 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Interceptors/AuditFieldInterceptor.cs @@ -0,0 +1,67 @@ +#nullable enable +using Baya.Application.Contracts.Common; +using Baya.Domain.Common; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace Baya.Infrastructure.Persistence.Interceptors; + +/// +/// Stamps audit fields on every save: CreatedAt/CreatedById on insert and +/// ModifiedAt/ModifiedById on update, sourcing time from +/// and the acting user from . Handlers never set these fields. +/// This is the extension point backend-phase-1 builds on to also write append-only audit-log rows. +/// +public sealed class AuditFieldInterceptor(ICurrentUser currentUser, IDateTimeProvider dateTimeProvider) + : SaveChangesInterceptor +{ + public override InterceptionResult SavingChanges( + DbContextEventData eventData, + InterceptionResult result) + { + Stamp(eventData.Context); + return base.SavingChanges(eventData, result); + } + + public override ValueTask> SavingChangesAsync( + DbContextEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + Stamp(eventData.Context); + return base.SavingChangesAsync(eventData, result, cancellationToken); + } + + private void Stamp(DbContext? context) + { + if (context is null) + return; + + var now = dateTimeProvider.UtcNow; + var userId = currentUser.UserId; + + foreach (var entry in context.ChangeTracker.Entries()) + { + switch (entry.State) + { + case EntityState.Added: + entry.Entity.CreatedAt = now; + entry.Entity.ModifiedAt = now; + if (entry.Entity is IAuditableEntity addedAuditable) + { + addedAuditable.CreatedById = userId; + addedAuditable.ModifiedById = userId; + } + + break; + + case EntityState.Modified: + entry.Entity.ModifiedAt = now; + if (entry.Entity is IAuditableEntity modifiedAuditable) + modifiedAuditable.ModifiedById = userId; + + break; + } + } + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20210327210004_Init.Designer.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20210327210004_Init.Designer.cs deleted file mode 100644 index 89c5fb3..0000000 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20210327210004_Init.Designer.cs +++ /dev/null @@ -1,374 +0,0 @@ -ο»Ώ// -using System; -using Baya.Infrastructure.Persistence; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Persistence; - -namespace Persistence.Migrations -{ - [DbContext(typeof(ApplicationDbContext))] - [Migration("20210327210004_Init")] - partial class Init - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .UseIdentityColumns() - .HasAnnotation("Relational:MaxIdentifierLength", 128) - .HasAnnotation("ProductVersion", "5.0.0"); - - modelBuilder.Entity("Domain.Entities.User.Role", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int") - .UseIdentityColumn(); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("nvarchar(max)"); - - b.Property("CreatedDate") - .HasColumnType("datetime2"); - - b.Property("DisplayName") - .HasColumnType("nvarchar(max)"); - - b.Property("Name") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("NormalizedName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.HasKey("Id"); - - b.HasIndex("NormalizedName") - .IsUnique() - .HasDatabaseName("RoleNameIndex") - .HasFilter("[NormalizedName] IS NOT NULL"); - - b.ToTable("Roles", "usr"); - }); - - modelBuilder.Entity("Domain.Entities.User.RoleClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int") - .UseIdentityColumn(); - - b.Property("ClaimType") - .HasColumnType("nvarchar(max)"); - - b.Property("ClaimValue") - .HasColumnType("nvarchar(max)"); - - b.Property("CreatedClaim") - .HasColumnType("datetime2"); - - b.Property("RoleId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("RoleId"); - - b.ToTable("RoleClaims", "usr"); - }); - - modelBuilder.Entity("Domain.Entities.User.User", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int") - .HasColumnName("UserId") - .UseIdentityColumn(); - - b.Property("AccessFailedCount") - .HasColumnType("int"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("nvarchar(max)"); - - b.Property("Email") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("EmailConfirmed") - .HasColumnType("bit"); - - b.Property("FamilyName") - .HasColumnType("nvarchar(max)"); - - b.Property("GeneratedCode") - .HasColumnType("nvarchar(max)"); - - b.Property("LockoutEnabled") - .HasColumnType("bit"); - - b.Property("LockoutEnd") - .HasColumnType("datetimeoffset"); - - b.Property("Name") - .HasColumnType("nvarchar(max)"); - - b.Property("NormalizedEmail") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("NormalizedUserName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("PasswordHash") - .HasColumnType("nvarchar(max)"); - - b.Property("PhoneNumber") - .HasColumnType("nvarchar(max)"); - - b.Property("PhoneNumberConfirmed") - .HasColumnType("bit"); - - b.Property("SecurityStamp") - .HasColumnType("nvarchar(max)"); - - b.Property("TwoFactorEnabled") - .HasColumnType("bit"); - - b.Property("UserName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.HasKey("Id"); - - b.HasIndex("NormalizedEmail") - .HasDatabaseName("EmailIndex"); - - b.HasIndex("NormalizedUserName") - .IsUnique() - .HasDatabaseName("UserNameIndex") - .HasFilter("[NormalizedUserName] IS NOT NULL"); - - b.ToTable("Users", "usr"); - }); - - modelBuilder.Entity("Domain.Entities.User.UserClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int") - .UseIdentityColumn(); - - b.Property("ClaimType") - .HasColumnType("nvarchar(max)"); - - b.Property("ClaimValue") - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.ToTable("UserClaims", "usr"); - }); - - modelBuilder.Entity("Domain.Entities.User.UserLogin", b => - { - b.Property("LoginProvider") - .HasColumnType("nvarchar(450)"); - - b.Property("ProviderKey") - .HasColumnType("nvarchar(450)"); - - b.Property("LoggedOn") - .HasColumnType("datetime2"); - - b.Property("ProviderDisplayName") - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .HasColumnType("int"); - - b.HasKey("LoginProvider", "ProviderKey"); - - b.HasIndex("UserId"); - - b.ToTable("UserLogins", "usr"); - }); - - modelBuilder.Entity("Domain.Entities.User.UserRefreshToken", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uniqueidentifier"); - - b.Property("CreatedAt") - .HasColumnType("datetime2"); - - b.Property("CreatedTime") - .HasColumnType("datetime2"); - - b.Property("IsValid") - .HasColumnType("bit"); - - b.Property("ModifiedDate") - .HasColumnType("datetime2"); - - b.Property("UserId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.ToTable("UserRefreshTokens", "usr"); - }); - - modelBuilder.Entity("Domain.Entities.User.UserRole", b => - { - b.Property("UserId") - .HasColumnType("int"); - - b.Property("RoleId") - .HasColumnType("int"); - - b.Property("CreatedUserRoleDate") - .HasColumnType("datetime2"); - - b.HasKey("UserId", "RoleId"); - - b.HasIndex("RoleId"); - - b.ToTable("UserRoles", "usr"); - }); - - modelBuilder.Entity("Domain.Entities.User.UserToken", b => - { - b.Property("UserId") - .HasColumnType("int"); - - b.Property("LoginProvider") - .HasColumnType("nvarchar(450)"); - - b.Property("Name") - .HasColumnType("nvarchar(450)"); - - b.Property("GeneratedTime") - .HasColumnType("datetime2"); - - b.Property("Value") - .HasColumnType("nvarchar(max)"); - - b.HasKey("UserId", "LoginProvider", "Name"); - - b.ToTable("UserTokens", "usr"); - }); - - modelBuilder.Entity("Domain.Entities.User.RoleClaim", b => - { - b.HasOne("Domain.Entities.User.Role", "Role") - .WithMany("Claims") - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Role"); - }); - - modelBuilder.Entity("Domain.Entities.User.UserClaim", b => - { - b.HasOne("Domain.Entities.User.User", "User") - .WithMany("Claims") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("User"); - }); - - modelBuilder.Entity("Domain.Entities.User.UserLogin", b => - { - b.HasOne("Domain.Entities.User.User", "User") - .WithMany("Logins") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("User"); - }); - - modelBuilder.Entity("Domain.Entities.User.UserRefreshToken", b => - { - b.HasOne("Domain.Entities.User.User", "User") - .WithMany("UserRefreshTokens") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("User"); - }); - - modelBuilder.Entity("Domain.Entities.User.UserRole", b => - { - b.HasOne("Domain.Entities.User.Role", "Role") - .WithMany("Users") - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("Domain.Entities.User.User", "User") - .WithMany("UserRoles") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Role"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("Domain.Entities.User.UserToken", b => - { - b.HasOne("Domain.Entities.User.User", "User") - .WithMany("Tokens") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("User"); - }); - - modelBuilder.Entity("Domain.Entities.User.Role", b => - { - b.Navigation("Claims"); - - b.Navigation("Users"); - }); - - modelBuilder.Entity("Domain.Entities.User.User", b => - { - b.Navigation("Claims"); - - b.Navigation("Logins"); - - b.Navigation("Tokens"); - - b.Navigation("UserRefreshTokens"); - - b.Navigation("UserRoles"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20221205084354_AddedOrderAndUserRelation.Designer.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20221205084354_AddedOrderAndUserRelation.Designer.cs deleted file mode 100644 index 45f0f68..0000000 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20221205084354_AddedOrderAndUserRelation.Designer.cs +++ /dev/null @@ -1,422 +0,0 @@ -ο»Ώ// -using System; -using Baya.Infrastructure.Persistence; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Persistence; - -#nullable disable - -namespace Persistence.Migrations -{ - [DbContext(typeof(ApplicationDbContext))] - [Migration("20221205084354_AddedOrderAndUserRelation")] - partial class AddedOrderAndUserRelation - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "7.0.0") - .HasAnnotation("Relational:MaxIdentifierLength", 128); - - SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); - - modelBuilder.Entity("Domain.Entities.Order.Order", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("CreatedTime") - .HasColumnType("datetime2"); - - b.Property("ModifiedDate") - .HasColumnType("datetime2"); - - b.Property("OrderName") - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.ToTable("Orders"); - }); - - modelBuilder.Entity("Domain.Entities.User.Role", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("nvarchar(max)"); - - b.Property("CreatedDate") - .HasColumnType("datetime2"); - - b.Property("DisplayName") - .HasColumnType("nvarchar(max)"); - - b.Property("Name") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("NormalizedName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.HasKey("Id"); - - b.HasIndex("NormalizedName") - .IsUnique() - .HasDatabaseName("RoleNameIndex") - .HasFilter("[NormalizedName] IS NOT NULL"); - - b.ToTable("Roles", "usr"); - }); - - modelBuilder.Entity("Domain.Entities.User.RoleClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("ClaimType") - .HasColumnType("nvarchar(max)"); - - b.Property("ClaimValue") - .HasColumnType("nvarchar(max)"); - - b.Property("CreatedClaim") - .HasColumnType("datetime2"); - - b.Property("RoleId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("RoleId"); - - b.ToTable("RoleClaims", "usr"); - }); - - modelBuilder.Entity("Domain.Entities.User.User", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int") - .HasColumnName("UserId"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("AccessFailedCount") - .HasColumnType("int"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("nvarchar(max)"); - - b.Property("Email") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("EmailConfirmed") - .HasColumnType("bit"); - - b.Property("FamilyName") - .HasColumnType("nvarchar(max)"); - - b.Property("GeneratedCode") - .HasColumnType("nvarchar(max)"); - - b.Property("LockoutEnabled") - .HasColumnType("bit"); - - b.Property("LockoutEnd") - .HasColumnType("datetimeoffset"); - - b.Property("Name") - .HasColumnType("nvarchar(max)"); - - b.Property("NormalizedEmail") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("NormalizedUserName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("PasswordHash") - .HasColumnType("nvarchar(max)"); - - b.Property("PhoneNumber") - .HasColumnType("nvarchar(max)"); - - b.Property("PhoneNumberConfirmed") - .HasColumnType("bit"); - - b.Property("SecurityStamp") - .HasColumnType("nvarchar(max)"); - - b.Property("TwoFactorEnabled") - .HasColumnType("bit"); - - b.Property("UserName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.HasKey("Id"); - - b.HasIndex("NormalizedEmail") - .HasDatabaseName("EmailIndex"); - - b.HasIndex("NormalizedUserName") - .IsUnique() - .HasDatabaseName("UserNameIndex") - .HasFilter("[NormalizedUserName] IS NOT NULL"); - - b.ToTable("Users", "usr"); - }); - - modelBuilder.Entity("Domain.Entities.User.UserClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("ClaimType") - .HasColumnType("nvarchar(max)"); - - b.Property("ClaimValue") - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.ToTable("UserClaims", "usr"); - }); - - modelBuilder.Entity("Domain.Entities.User.UserLogin", b => - { - b.Property("LoginProvider") - .HasColumnType("nvarchar(450)"); - - b.Property("ProviderKey") - .HasColumnType("nvarchar(450)"); - - b.Property("LoggedOn") - .HasColumnType("datetime2"); - - b.Property("ProviderDisplayName") - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .HasColumnType("int"); - - b.HasKey("LoginProvider", "ProviderKey"); - - b.HasIndex("UserId"); - - b.ToTable("UserLogins", "usr"); - }); - - modelBuilder.Entity("Domain.Entities.User.UserRefreshToken", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uniqueidentifier"); - - b.Property("CreatedAt") - .HasColumnType("datetime2"); - - b.Property("CreatedTime") - .HasColumnType("datetime2"); - - b.Property("IsValid") - .HasColumnType("bit"); - - b.Property("ModifiedDate") - .HasColumnType("datetime2"); - - b.Property("UserId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.ToTable("UserRefreshTokens", "usr"); - }); - - modelBuilder.Entity("Domain.Entities.User.UserRole", b => - { - b.Property("UserId") - .HasColumnType("int"); - - b.Property("RoleId") - .HasColumnType("int"); - - b.Property("CreatedUserRoleDate") - .HasColumnType("datetime2"); - - b.HasKey("UserId", "RoleId"); - - b.HasIndex("RoleId"); - - b.ToTable("UserRoles", "usr"); - }); - - modelBuilder.Entity("Domain.Entities.User.UserToken", b => - { - b.Property("UserId") - .HasColumnType("int"); - - b.Property("LoginProvider") - .HasColumnType("nvarchar(450)"); - - b.Property("Name") - .HasColumnType("nvarchar(450)"); - - b.Property("GeneratedTime") - .HasColumnType("datetime2"); - - b.Property("Value") - .HasColumnType("nvarchar(max)"); - - b.HasKey("UserId", "LoginProvider", "Name"); - - b.ToTable("UserTokens", "usr"); - }); - - modelBuilder.Entity("Domain.Entities.Order.Order", b => - { - b.HasOne("Domain.Entities.User.User", "User") - .WithMany("Orders") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("User"); - }); - - modelBuilder.Entity("Domain.Entities.User.RoleClaim", b => - { - b.HasOne("Domain.Entities.User.Role", "Role") - .WithMany("Claims") - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Role"); - }); - - modelBuilder.Entity("Domain.Entities.User.UserClaim", b => - { - b.HasOne("Domain.Entities.User.User", "User") - .WithMany("Claims") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("User"); - }); - - modelBuilder.Entity("Domain.Entities.User.UserLogin", b => - { - b.HasOne("Domain.Entities.User.User", "User") - .WithMany("Logins") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("User"); - }); - - modelBuilder.Entity("Domain.Entities.User.UserRefreshToken", b => - { - b.HasOne("Domain.Entities.User.User", "User") - .WithMany("UserRefreshTokens") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("User"); - }); - - modelBuilder.Entity("Domain.Entities.User.UserRole", b => - { - b.HasOne("Domain.Entities.User.Role", "Role") - .WithMany("Users") - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("Domain.Entities.User.User", "User") - .WithMany("UserRoles") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Role"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("Domain.Entities.User.UserToken", b => - { - b.HasOne("Domain.Entities.User.User", "User") - .WithMany("Tokens") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("User"); - }); - - modelBuilder.Entity("Domain.Entities.User.Role", b => - { - b.Navigation("Claims"); - - b.Navigation("Users"); - }); - - modelBuilder.Entity("Domain.Entities.User.User", b => - { - b.Navigation("Claims"); - - b.Navigation("Logins"); - - b.Navigation("Orders"); - - b.Navigation("Tokens"); - - b.Navigation("UserRefreshTokens"); - - b.Navigation("UserRoles"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20221205084354_AddedOrderAndUserRelation.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20221205084354_AddedOrderAndUserRelation.cs deleted file mode 100644 index 87a1590..0000000 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20221205084354_AddedOrderAndUserRelation.cs +++ /dev/null @@ -1,50 +0,0 @@ -ο»Ώusing System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Persistence.Migrations -{ - /// - public partial class AddedOrderAndUserRelation : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "Orders", - columns: table => new - { - Id = table.Column(type: "int", nullable: false) - .Annotation("SqlServer:Identity", "1, 1"), - OrderName = table.Column(type: "nvarchar(max)", nullable: true), - UserId = table.Column(type: "int", nullable: false), - CreatedTime = table.Column(type: "datetime2", nullable: false), - ModifiedDate = table.Column(type: "datetime2", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_Orders", x => x.Id); - table.ForeignKey( - name: "FK_Orders_Users_UserId", - column: x => x.UserId, - principalSchema: "usr", - principalTable: "Users", - principalColumn: "UserId", - onDelete: ReferentialAction.Restrict); - }); - - migrationBuilder.CreateIndex( - name: "IX_Orders_UserId", - table: "Orders", - column: "UserId"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "Orders"); - } - } -} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20231126140035_AddedOrderDeleteFlag.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20231126140035_AddedOrderDeleteFlag.cs deleted file mode 100644 index 776b4a5..0000000 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20231126140035_AddedOrderDeleteFlag.cs +++ /dev/null @@ -1,29 +0,0 @@ -ο»Ώusing Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Persistence.Migrations -{ - /// - public partial class AddedOrderDeleteFlag : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "IsDeleted", - table: "Orders", - type: "bit", - nullable: false, - defaultValue: false); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "IsDeleted", - table: "Orders"); - } - } -} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20231126140035_AddedOrderDeleteFlag.Designer.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260628191947_InitialBaseline.Designer.cs similarity index 87% rename from server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20231126140035_AddedOrderDeleteFlag.Designer.cs rename to server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260628191947_InitialBaseline.Designer.cs index 02b593d..cf0ad76 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20231126140035_AddedOrderDeleteFlag.Designer.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260628191947_InitialBaseline.Designer.cs @@ -9,52 +9,22 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; #nullable disable -namespace Persistence.Migrations +namespace Baya.Infrastructure.Persistence.Migrations { [DbContext(typeof(ApplicationDbContext))] - [Migration("20231126140035_AddedOrderDeleteFlag")] - partial class AddedOrderDeleteFlag + [Migration("20260628191947_InitialBaseline")] + partial class InitialBaseline { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "8.0.0") + .HasAnnotation("ProductVersion", "10.0.9") .HasAnnotation("Relational:MaxIdentifierLength", 128); SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); - modelBuilder.Entity("Baya.Domain.Entities.Order.Order", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("CreatedTime") - .HasColumnType("datetime2"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("ModifiedDate") - .HasColumnType("datetime2"); - - b.Property("OrderName") - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.ToTable("Orders"); - }); - modelBuilder.Entity("Baya.Domain.Entities.User.Role", b => { b.Property("Id") @@ -250,17 +220,20 @@ namespace Persistence.Migrations .ValueGeneratedOnAdd() .HasColumnType("uniqueidentifier"); - b.Property("CreatedAt") - .HasColumnType("datetime2"); + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); - b.Property("CreatedTime") - .HasColumnType("datetime2"); + b.Property("CreatedById") + .HasColumnType("int"); b.Property("IsValid") .HasColumnType("bit"); - b.Property("ModifiedDate") - .HasColumnType("datetime2"); + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); b.Property("UserId") .HasColumnType("int"); @@ -312,17 +285,6 @@ namespace Persistence.Migrations b.ToTable("UserTokens", "usr"); }); - modelBuilder.Entity("Baya.Domain.Entities.Order.Order", b => - { - b.HasOne("Baya.Domain.Entities.User.User", "User") - .WithMany("Orders") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("User"); - }); - modelBuilder.Entity("Baya.Domain.Entities.User.RoleClaim", b => { b.HasOne("Baya.Domain.Entities.User.Role", "Role") @@ -410,8 +372,6 @@ namespace Persistence.Migrations b.Navigation("Logins"); - b.Navigation("Orders"); - b.Navigation("Tokens"); b.Navigation("UserRefreshTokens"); diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20210327210004_Init.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260628191947_InitialBaseline.cs similarity index 95% rename from server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20210327210004_Init.cs rename to server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260628191947_InitialBaseline.cs index 719b39a..5837433 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20210327210004_Init.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260628191947_InitialBaseline.cs @@ -1,10 +1,14 @@ ο»Ώusing System; using Microsoft.EntityFrameworkCore.Migrations; -namespace Persistence.Migrations +#nullable disable + +namespace Baya.Infrastructure.Persistence.Migrations { - public partial class Init : Migration + /// + public partial class InitialBaseline : Migration { + /// protected override void Up(MigrationBuilder migrationBuilder) { migrationBuilder.EnsureSchema( @@ -135,10 +139,11 @@ namespace Persistence.Migrations { Id = table.Column(type: "uniqueidentifier", nullable: false), UserId = table.Column(type: "int", nullable: false), - CreatedAt = table.Column(type: "datetime2", nullable: false), IsValid = table.Column(type: "bit", nullable: false), - CreatedTime = table.Column(type: "datetime2", nullable: false), - ModifiedDate = table.Column(type: "datetime2", nullable: true) + CreatedAt = table.Column(type: "datetimeoffset", nullable: false), + ModifiedAt = table.Column(type: "datetimeoffset", nullable: true), + CreatedById = table.Column(type: "int", nullable: true), + ModifiedById = table.Column(type: "int", nullable: true) }, constraints: table => { @@ -256,6 +261,7 @@ namespace Persistence.Migrations filter: "[NormalizedUserName] IS NOT NULL"); } + /// protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.DropTable( diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs index 17bcb99..51213cf 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs @@ -8,7 +8,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; #nullable disable -namespace Persistence.Migrations +namespace Baya.Infrastructure.Persistence.Migrations { [DbContext(typeof(ApplicationDbContext))] partial class ApplicationDbContextModelSnapshot : ModelSnapshot @@ -17,41 +17,11 @@ namespace Persistence.Migrations { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("ProductVersion", "10.0.9") .HasAnnotation("Relational:MaxIdentifierLength", 128); SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); - modelBuilder.Entity("Baya.Domain.Entities.Order.Order", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("CreatedTime") - .HasColumnType("datetime2"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("ModifiedDate") - .HasColumnType("datetime2"); - - b.Property("OrderName") - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.ToTable("Orders", (string)null); - }); - modelBuilder.Entity("Baya.Domain.Entities.User.Role", b => { b.Property("Id") @@ -247,17 +217,20 @@ namespace Persistence.Migrations .ValueGeneratedOnAdd() .HasColumnType("uniqueidentifier"); - b.Property("CreatedAt") - .HasColumnType("datetime2"); + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); - b.Property("CreatedTime") - .HasColumnType("datetime2"); + b.Property("CreatedById") + .HasColumnType("int"); b.Property("IsValid") .HasColumnType("bit"); - b.Property("ModifiedDate") - .HasColumnType("datetime2"); + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); b.Property("UserId") .HasColumnType("int"); @@ -309,17 +282,6 @@ namespace Persistence.Migrations b.ToTable("UserTokens", "usr"); }); - modelBuilder.Entity("Baya.Domain.Entities.Order.Order", b => - { - b.HasOne("Baya.Domain.Entities.User.User", "User") - .WithMany("Orders") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("User"); - }); - modelBuilder.Entity("Baya.Domain.Entities.User.RoleClaim", b => { b.HasOne("Baya.Domain.Entities.User.Role", "Role") @@ -407,8 +369,6 @@ namespace Persistence.Migrations b.Navigation("Logins"); - b.Navigation("Orders"); - b.Navigation("Tokens"); b.Navigation("UserRefreshTokens"); diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/Common/UnitOfWork.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/Common/UnitOfWork.cs index 6cfcadb..c37067f 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/Common/UnitOfWork.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/Common/UnitOfWork.cs @@ -7,13 +7,11 @@ public class UnitOfWork : IUnitOfWork private readonly ApplicationDbContext _db; public IUserRefreshTokenRepository UserRefreshTokenRepository { get; } - public IOrderRepository OrderRepository { get; } public UnitOfWork(ApplicationDbContext db) { _db = db; UserRefreshTokenRepository = new UserRefreshTokenRepository(_db); - OrderRepository= new OrderRepository(_db); } public Task CommitAsync() diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/OrderRepository.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/OrderRepository.cs deleted file mode 100644 index 62dab82..0000000 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/OrderRepository.cs +++ /dev/null @@ -1,41 +0,0 @@ -ο»Ώusing Baya.Application.Contracts.Persistence; -using Baya.Domain.Entities.Order; -using Baya.Infrastructure.Persistence.Repositories.Common; -using Microsoft.EntityFrameworkCore; - -namespace Baya.Infrastructure.Persistence.Repositories; - -internal class OrderRepository(ApplicationDbContext dbContext) : BaseAsyncRepository(dbContext), IOrderRepository -{ - public async Task AddOrderAsync(Order order) - { - await base.AddAsync(order); - } - - public async Task> GetAllUserOrdersAsync(int userId) - { - return await base.TableNoTracking.Where(c => c.UserId == userId).ToListAsync(); - } - - public async Task> GetAllOrdersWithRelatedUserAsync() - { - var orders = await base.TableNoTracking.Include(c => c.User).ToListAsync(); - - return orders; - } - - public async Task GetUserOrderByIdAndUserIdAsync(int userId, int orderId,bool trackEntity) - { - var order = await base.TableNoTracking.FirstOrDefaultAsync(c => c.UserId == userId && c.Id == orderId); - - if (order is not null && trackEntity) - base.DbContext.Attach(order); - - return order; - } - - public async Task DeleteUserOrdersAsync(int userId) - { - await UpdateAsync(c => c.UserId == userId, p => p.SetProperty(order => order.IsDeleted, true)); - } -} \ No newline at end of file diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs index 37efad8..affc1c6 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ ο»Ώusing Baya.Application.Contracts.Persistence; +using Baya.Infrastructure.Persistence.Interceptors; using Baya.Infrastructure.Persistence.Repositories.Common; using Microsoft.AspNetCore.Builder; using Microsoft.EntityFrameworkCore; @@ -13,10 +14,13 @@ public static class ServiceCollectionExtensions { services.AddScoped(); - services.AddDbContext(options => + services.AddScoped(); + + services.AddDbContext((serviceProvider, options) => { options - .UseSqlServer(configuration.GetConnectionString("SqlServer")); + .UseSqlServer(configuration.GetConnectionString("SqlServer")) + .AddInterceptors(serviceProvider.GetRequiredService()); }); return services; diff --git a/server/src/Tests/Baya.Test.Foundation/AuditFieldInterceptorTests.cs b/server/src/Tests/Baya.Test.Foundation/AuditFieldInterceptorTests.cs new file mode 100644 index 0000000..bf76776 --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/AuditFieldInterceptorTests.cs @@ -0,0 +1,95 @@ +using Baya.Application.Contracts.Common; +using Baya.Domain.Common; +using Baya.Infrastructure.Persistence.Interceptors; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using NSubstitute; + +namespace Baya.Test.Foundation; + +public class AuditFieldInterceptorTests +{ + // A throwaway auditable entity + context so the interceptor is tested in isolation, without the + // full Identity schema. + private sealed class Widget : BaseEntity + { + public string Name { get; set; } = string.Empty; + } + + private sealed class AuditTestDbContext(DbContextOptions options) : DbContext(options) + { + public DbSet Widgets => Set(); + } + + private static AuditTestDbContext CreateContext(AuditFieldInterceptor interceptor) + { + var connection = new SqliteConnection("DataSource=:memory:"); + connection.Open(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .AddInterceptors(interceptor) + .Options; + + var context = new AuditTestDbContext(options); + context.Database.EnsureCreated(); + return context; + } + + [Fact] + public async Task SavingChanges_OnAdd_StampsCreatedAtAndCreatedById() + { + // Arrange + var fixedNow = new DateTimeOffset(2026, 6, 28, 10, 0, 0, TimeSpan.Zero); + + var clock = Substitute.For(); + clock.UtcNow.Returns(fixedNow); + + var currentUser = Substitute.For(); + currentUser.UserId.Returns(42); + + await using var context = CreateContext(new AuditFieldInterceptor(currentUser, clock)); + var widget = new Widget { Name = "test" }; + context.Widgets.Add(widget); + + // Act + await context.SaveChangesAsync(); + + // Assert + Assert.Equal(fixedNow, widget.CreatedAt); + Assert.Equal(fixedNow, widget.ModifiedAt); + Assert.Equal(42, widget.CreatedById); + Assert.Equal(42, widget.ModifiedById); + } + + [Fact] + public async Task SavingChanges_OnUpdate_StampsModifiedButNotCreatedBy() + { + // Arrange + var createNow = new DateTimeOffset(2026, 6, 28, 10, 0, 0, TimeSpan.Zero); + var updateNow = new DateTimeOffset(2026, 6, 28, 12, 0, 0, TimeSpan.Zero); + + var clock = Substitute.For(); + var currentUser = Substitute.For(); + + clock.UtcNow.Returns(createNow); + currentUser.UserId.Returns(7); + + await using var context = CreateContext(new AuditFieldInterceptor(currentUser, clock)); + var widget = new Widget { Name = "before" }; + context.Widgets.Add(widget); + await context.SaveChangesAsync(); + + // Act β€” a different user edits the row later. + clock.UtcNow.Returns(updateNow); + currentUser.UserId.Returns(9); + widget.Name = "after"; + await context.SaveChangesAsync(); + + // Assert + Assert.Equal(createNow, widget.CreatedAt); + Assert.Equal(7, widget.CreatedById); + Assert.Equal(updateNow, widget.ModifiedAt); + Assert.Equal(9, widget.ModifiedById); + } +} diff --git a/server/src/Tests/Baya.Test.Foundation/Baya.Test.Foundation.csproj b/server/src/Tests/Baya.Test.Foundation/Baya.Test.Foundation.csproj new file mode 100644 index 0000000..8c4b26c --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/Baya.Test.Foundation.csproj @@ -0,0 +1,31 @@ + + + + net10.0 + enable + enable + + false + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + diff --git a/server/src/Tests/Baya.Test.Foundation/PingQueryHandlerTests.cs b/server/src/Tests/Baya.Test.Foundation/PingQueryHandlerTests.cs new file mode 100644 index 0000000..8a5d4ce --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/PingQueryHandlerTests.cs @@ -0,0 +1,28 @@ +using Baya.Application.Contracts.Common; +using Baya.Application.Features.System.Queries.Ping; +using NSubstitute; + +namespace Baya.Test.Foundation; + +public class PingQueryHandlerTests +{ + [Fact] + public async Task Handle_ReturnsSuccess_WithServerTimeFromProvider() + { + // Arrange + var now = new DateTimeOffset(2026, 6, 28, 9, 30, 0, TimeSpan.Zero); + var clock = Substitute.For(); + clock.UtcNow.Returns(now); + + var handler = new PingQueryHandler(clock); + + // Act + var result = await handler.Handle(new PingQuery(), CancellationToken.None); + + // Assert + Assert.True(result.IsSuccess); + Assert.NotNull(result.Result); + Assert.Equal("ok", result.Result!.Status); + Assert.Equal(now, result.Result.ServerTimeUtc); + } +} diff --git a/server/src/Tests/Baya.Test.Foundation/SymmetricFieldEncryptorTests.cs b/server/src/Tests/Baya.Test.Foundation/SymmetricFieldEncryptorTests.cs new file mode 100644 index 0000000..91612d0 --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/SymmetricFieldEncryptorTests.cs @@ -0,0 +1,70 @@ +using Baya.Infrastructure.CrossCutting.Seams; +using Microsoft.Extensions.Options; + +namespace Baya.Test.Foundation; + +public class SymmetricFieldEncryptorTests +{ + private static SymmetricFieldEncryptor CreateEncryptor() + { + var options = Options.Create(new SeamOptions + { + FieldEncryption = new FieldEncryptionOptions + { + Key = "unit-test-field-encryption-key", + HashKey = "unit-test-hash-key" + } + }); + + return new SymmetricFieldEncryptor(options); + } + + [Fact] + public void Decrypt_OfEncrypt_ReturnsOriginalPlaintext() + { + // Arrange + var encryptor = CreateEncryptor(); + const string plaintext = "09123456789"; + + // Act + var cipher = encryptor.Encrypt(plaintext); + var roundTripped = encryptor.Decrypt(cipher); + + // Assert + Assert.NotEqual(plaintext, cipher); + Assert.Equal(plaintext, roundTripped); + } + + [Fact] + public void Encrypt_SameInputTwice_ProducesDifferentCiphertext() + { + // Arrange β€” a random IV per call means ciphertext is not deterministic (semantic security). + var encryptor = CreateEncryptor(); + const string plaintext = "IR820540102680020817909002"; + + // Act + var first = encryptor.Encrypt(plaintext); + var second = encryptor.Encrypt(plaintext); + + // Assert + Assert.NotEqual(first, second); + Assert.Equal(plaintext, encryptor.Decrypt(first)); + Assert.Equal(plaintext, encryptor.Decrypt(second)); + } + + [Fact] + public void Hash_IsDeterministic_ForLookups() + { + // Arrange + var encryptor = CreateEncryptor(); + const string value = "IR820540102680020817909002"; + + // Act + var first = encryptor.Hash(value); + var second = encryptor.Hash(value); + + // Assert + Assert.Equal(first, second); + Assert.NotEqual(value, first); + } +} diff --git a/server/src/Tests/Baya.Test.Foundation/Usings.cs b/server/src/Tests/Baya.Test.Foundation/Usings.cs new file mode 100644 index 0000000..c802f44 --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/Usings.cs @@ -0,0 +1 @@ +global using Xunit;