create mvp path
This commit is contained in:
@@ -0,0 +1,519 @@
|
||||
# Testing setup — boot the stack, get a code, log in
|
||||
|
||||
Everything you need to run Balinyaar locally and walk any flow in [index.md](index.md). **Executed, not
|
||||
transcribed:** every command, URL, status code and console line below was run against this repo on the date
|
||||
in the stamp. Where a predecessor doc says something different, this file says so and says which is right.
|
||||
|
||||
> Last verified: 2026-08-02 against commit `c841bde`, on Windows 11 / .NET SDK 10.0.300-preview / Node 24.11.1.
|
||||
|
||||
**Predecessors, and their standing:** [RUNBOOK.md](../../archive/post-phase/refinement/RUNBOOK.md) and
|
||||
[manual-testing-plan.md](../../archive/post-phase/manual-testing-plan.md) are **superseded by this file**. Both
|
||||
contain instructions that no longer work — see [What the old docs get wrong](#what-the-old-docs-get-wrong).
|
||||
|
||||
---
|
||||
|
||||
## The five-minute path
|
||||
|
||||
Three terminals. The third one is not optional — see [Getting an OTP](#getting-an-otp).
|
||||
|
||||
```bash
|
||||
# 1 — API. Plain HTTP on :5002. Needs the SMS provider overridden, or login 500s.
|
||||
cd server
|
||||
Seams__Sms__Provider=mock dotnet run --project src/API/Baya.Web.Api/Baya.Web.Api.csproj
|
||||
# PowerShell: $env:Seams__Sms__Provider="mock"; dotnet run --project src/API/Baya.Web.Api/Baya.Web.Api.csproj
|
||||
|
||||
# 2 — client
|
||||
cd client && npm install && npm run dev # http://localhost:3000/fa
|
||||
|
||||
# 3 — read the OTP (the console does NOT print the code)
|
||||
curl http://localhost:5002/api/v1/dev/last_otp/09120000010
|
||||
```
|
||||
|
||||
Then open **`http://localhost:3000/fa/login`**, enter `09120000010`, and paste the code from terminal 3.
|
||||
|
||||
No database setup step. The committed dev config already points at a seeded remote SQL Server.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| | Verified working | Notes |
|
||||
| --- | --- | --- |
|
||||
| .NET SDK | `10.0.300-preview.0.26177.108` | `NETSDK1057` (preview SDK) is an expected warning |
|
||||
| Node | `24.11.1` / npm `11.6.2` | README says 18+; 24 is fine |
|
||||
| SQL Server | none locally | the dev config uses a **remote** instance — nothing to install |
|
||||
| Docker | **not required** | only for the optional local-DB path, which is *unverified* here |
|
||||
|
||||
`dotnet build Baya.sln` completes with **0 errors, 95 warnings** on a clean clone. The warnings are
|
||||
pre-existing (`NU1903` vulnerability advisories on `Microsoft.OpenApi` / `SQLitePCLRaw`, `NU1510`,
|
||||
`NETSDK1057`). They are not yours; don't "fix" them.
|
||||
|
||||
---
|
||||
|
||||
## Configuration — where it lives
|
||||
|
||||
**`dotnet user-secrets` is not used and is not read.** The `<UserSecretsId>` was removed from
|
||||
`Baya.Web.Api.csproj` in `5885280`. Any instruction to `dotnet user-secrets set …` is dead — the command
|
||||
will error with "could not find UserSecretsId", and even a leftover `secrets.json` on your machine is inert.
|
||||
|
||||
| What | Where | Read when |
|
||||
| --- | --- | --- |
|
||||
| Server config + keys | [`server/src/API/Baya.Web.Api/appsettings.Development.json`](../../server/src/API/Baya.Web.Api/appsettings.Development.json) | process start |
|
||||
| Server base file | `appsettings.json` — placeholders only, guarded by `StartupSecretsGuard` | process start |
|
||||
| Client dev config | [`client/.env.development`](../../client/.env.development) | `next dev` |
|
||||
| Client prod config | `client/.env.production` | **`next build`** — inlined into the bundle |
|
||||
| Deployment overrides | [`docker-compose.yml`](../../docker-compose.yml) (`Seams__…` env vars) | container start |
|
||||
|
||||
Any server key can be overridden by an environment variable using `__` for `:` —
|
||||
`Seams__Sms__Provider`, `ConnectionStrings__SqlServer`. That is how the five-minute path avoids editing a
|
||||
committed file. Full matrix: [docs/integration/config-matrix.md](../integration/config-matrix.md).
|
||||
|
||||
### The four crypto values, and why they are load-bearing
|
||||
|
||||
```jsonc
|
||||
"IdentitySettings": { "SecretKey": …, "Encryptkey": … } // signs + encrypts the JWE access token
|
||||
"Seams": { "FieldEncryption": { "Key": …, "HashKey": … } } // decrypts PII; derives users.PhoneHash
|
||||
```
|
||||
|
||||
`Seams:FieldEncryption:Key` and `:HashKey` **must match whatever the target database was encrypted under.**
|
||||
Every phone, address, IBAN and clinical note in `Baya` was written with the committed values. Boot against
|
||||
that database with different ones and you get *silent* failure first — every phone lookup misses, so every
|
||||
login says "no such account" — then `Padding is invalid and cannot be removed` on the first PII read. The
|
||||
appsettings file carries a `"//"` comment saying exactly this. Do not rotate them.
|
||||
|
||||
`IdentitySettings` may be changed freely; it only invalidates tokens already issued.
|
||||
|
||||
> **The repo contains live credentials on purpose** — a pre-launch trade for a demo deployment. Rotating them
|
||||
> is a "Going to Production" step in [DEPLOY.md](../../DEPLOY.md), not a local setup step.
|
||||
|
||||
---
|
||||
|
||||
## Which database
|
||||
|
||||
**The committed dev config points at a remote SQL Server: `87.107.152.16,1433` → `Baya` (+ `Baya_Logs`).**
|
||||
It is the same instance the deployed demo uses, it is **already migrated and already seeded**, and it is
|
||||
shared — your writes are visible to everyone else pointed at it.
|
||||
|
||||
Verified reachable (`Test-NetConnection … -Port 1433` → `TcpTestSucceeded: True`) and the API's
|
||||
`sql-app` health check reports `Healthy`.
|
||||
|
||||
### The local alternative — **UNVERIFIED**
|
||||
|
||||
[`server/docker-compose.yml`](../../server/docker-compose.yml) provisions SQL Server 2022 on
|
||||
`localhost:1433` with the dev-only SA password `Balinyaar_Dev1433`. Point the API at it with:
|
||||
|
||||
```bash
|
||||
export ConnectionStrings__SqlServer="Server=localhost,1433;Database=Baya;User Id=sa;Password=Balinyaar_Dev1433;TrustServerCertificate=True;Encrypt=False;"
|
||||
```
|
||||
|
||||
The API self-migrates and self-seeds on boot, so an empty instance is enough. **This path was not executed
|
||||
for this stamp** — Docker is not installed on the verification machine. Treat the remote path as the tested
|
||||
one and this as documented-but-unproven.
|
||||
|
||||
Two things that are true either way: the local DB starts empty, so you get a *freshly dated* demo world
|
||||
(see [staleness](#the-seeded-world-and-how-stale-it-is)); and it is encrypted under whatever
|
||||
`Seams:FieldEncryption` values you boot with, so it is not interchangeable with the remote one.
|
||||
|
||||
---
|
||||
|
||||
## Boot
|
||||
|
||||
```bash
|
||||
cd server
|
||||
Seams__Sms__Provider=mock dotnet run --project src/API/Baya.Web.Api/Baya.Web.Api.csproj
|
||||
```
|
||||
|
||||
**`http://localhost:5002` — plain HTTP.** `launchSettings.json` binds no TLS. There is no
|
||||
`https://localhost:5002`, and `dotnet dev-certs https --trust` has nothing to trust; skip it. Swagger is at
|
||||
`http://localhost:5002/swagger`, the OpenAPI doc at `/swagger/v1/swagger.json` (178 paths).
|
||||
|
||||
A healthy boot logs, in order:
|
||||
|
||||
```
|
||||
[INF] Demo world already seeded — no-op. Search index re-derived: 3 nurses, 27 rows.
|
||||
[INF] Demo lifecycle already seeded — no-op. Search index re-derived: 3 nurses, 27 rows.
|
||||
[INF] Recurring job scheduler starting with 7 job(s): booking_request_expiry, notification_retention,
|
||||
verification_expiry_scan, no_show_sweep, weekly_payout_generation, moadian_reconciliation,
|
||||
audit_log_retention
|
||||
[INF] مودیان reconciliation scanned 8 invoice(s); 0 reached registered
|
||||
```
|
||||
|
||||
Noise you can ignore: ~11 `WRN Entity 'X' has a global query filter …` lines, and two
|
||||
`WRN HTTP/2 is not enabled for 127.0.0.1:5002` lines (expected — HTTP/2 needs TLS; HTTP/1.1 is used).
|
||||
|
||||
### Health
|
||||
|
||||
| Endpoint | Expected | Actual |
|
||||
| --- | --- | --- |
|
||||
| `GET /healthz/live` | `200 Healthy` | ✅ `200` |
|
||||
| `GET /healthz/ready` | `200 Healthy` | ❌ **`503 Unhealthy` on Windows** |
|
||||
|
||||
`/healthz/ready` fails on the `object-storage` probe, not the database:
|
||||
|
||||
```
|
||||
IOException: The process cannot access the file
|
||||
'C:\Users\<you>\AppData\Local\Temp\balinyaar-object-storage\healthz\object-storage-probe'
|
||||
because it is being used by another process.
|
||||
at LocalDiskObjectStorage.DeleteAsync … at ObjectStorageWriteHealthCheck…
|
||||
```
|
||||
|
||||
`sql-app` reports `Healthy` in the same response. It is a real code defect, not configuration:
|
||||
`ObjectStorageWriteHealthCheck.cs:29` opens the probe blob with `await using var` and line 33 deletes it
|
||||
**while the `FileStream` handle is still open**. POSIX `unlink` permits that, so the Linux container passes;
|
||||
Windows `File.Delete` throws. **Do not read a red `/healthz/ready` as "the app is broken"** — check the
|
||||
`entries` object. Phase 4 backlog material.
|
||||
|
||||
`Seams:ObjectStorage:RootPath` is `""` in dev, which resolves to `%TEMP%\balinyaar-object-storage`
|
||||
(`LocalDiskObjectStorage.cs:18-20`). Deployed, `docker-compose.yml` sets `/app/data/object-storage`.
|
||||
|
||||
### Client
|
||||
|
||||
```bash
|
||||
cd client && npm install && npm run dev
|
||||
```
|
||||
|
||||
`✓ Ready in 6.8s` on `http://localhost:3000`. **Routes are locale-prefixed**: `/fa` and `/en` return `200`,
|
||||
bare `/` returns **`404` under `next dev`**. That is a Turbopack dev-server quirk on the root path, not a
|
||||
routing bug — **confirmed by building for production:**
|
||||
|
||||
```bash
|
||||
cd client && npm run build && PORT=3001 npm run start
|
||||
```
|
||||
|
||||
`next build` exits 0. Against `:3001`, `/` → `307 → /fa`; guest `/fa` returns `200` and renders the welcome
|
||||
landing itself (byte-for-byte the same page as `/fa/welcome`, differing only in the URL — which is the
|
||||
middleware **rewrite**, not a redirect); `/fa/search` → `307 → /fa/login?next=%2Fsearch`. **Never judge
|
||||
root-path or guest routing from `next dev`.**
|
||||
|
||||
`client/.env.development` already carries `NEXT_PUBLIC_API_URL = http://localhost:5002`, correctly on HTTP.
|
||||
**Do not copy `.env.sample`** — it still says `https://localhost:5002` and will break every call with an
|
||||
opaque network error.
|
||||
|
||||
---
|
||||
|
||||
## Getting an OTP
|
||||
|
||||
This is where a fresh clone fails, so read the whole section.
|
||||
|
||||
### The problem
|
||||
|
||||
`appsettings.Development.json` ships with:
|
||||
|
||||
```jsonc
|
||||
"Seams": { "Sms": { "Provider": "telegram", "Telegram": { "BaseUrl": "http://127.0.0.1:5010", … } } }
|
||||
```
|
||||
|
||||
With nothing listening on `:5010`, `POST /api/v1/auth/request_otp` returns **`500 Server Error`**:
|
||||
|
||||
```
|
||||
[WRN] Telegram OTP relay delivery failed for phone ending 0010 — http 502
|
||||
[ERR] Telegram OTP relay delivery failed (http 502).
|
||||
at TelegramSmsSender.PostAsync(…) TelegramSmsSender.cs:line 73
|
||||
```
|
||||
|
||||
This is deliberate — the relay fails **loudly** rather than pretending an undelivered code was sent. In the
|
||||
browser it surfaces as an error toast on the login screen and you never reach the code step.
|
||||
|
||||
The code is still generated and persisted before the send, so `dev/last_otp` works even while `request_otp`
|
||||
500s. That is a usable-but-ugly fallback, not a fix.
|
||||
|
||||
### The fix — one of three
|
||||
|
||||
| Option | Command | Result |
|
||||
| --- | --- | --- |
|
||||
| **A (recommended)** | boot with `Seams__Sms__Provider=mock` | `request_otp` → `200`, no relay needed |
|
||||
| B | set `"Provider": "mock"` in `appsettings.Development.json` | same, but shows up in `git status` |
|
||||
| C | run the Telegram relay | code arrives in Telegram — see below |
|
||||
|
||||
Option A verified:
|
||||
|
||||
```json
|
||||
POST /api/v1/auth/request_otp {"phone":"09120000002"} → HTTP 200
|
||||
{"data":{"otpSent":true,"resendAvailableInSeconds":120,"codeLength":6,"expiresInSeconds":60},"isSuccess":true}
|
||||
```
|
||||
|
||||
### Reading the code
|
||||
|
||||
**`GET /api/v1/dev/last_otp/{phone}` is the only way to read the code.** It is anonymous and
|
||||
Development-only (`404` in any other environment).
|
||||
|
||||
```bash
|
||||
curl http://localhost:5002/api/v1/dev/last_otp/09120000010
|
||||
# {"data":{"phone":"09120000010","code":"724740"},"isSuccess":true,"statusCode":200,…}
|
||||
```
|
||||
|
||||
The capture bridge is registered only for a capture-safe sender — `mock`, `telegram`, or unset
|
||||
(`Program.cs:94-100`). Select the real `kavenegar` gateway and the endpoint stops returning codes, by design.
|
||||
|
||||
> **The server console does NOT print the OTP.** With `Provider=mock` the line is
|
||||
> `[INF] MOCK SMS — OTP issued to phone ending in 0002` — the phone suffix only, **no code**. RUNBOOK.md and
|
||||
> manual-testing-plan.md both promise `MOCK SMS — OTP code 123456 for phone ending in 0001`. That string does
|
||||
> not exist in the codebase. Verified: zero 6-digit sequences appear anywhere in a full boot+login log.
|
||||
|
||||
### Option C — the Telegram relay
|
||||
|
||||
```bash
|
||||
cd telegram-otp-bot && npm start # zero dependencies, no npm install
|
||||
```
|
||||
|
||||
Two things must line up, and on the verification machine **neither did**:
|
||||
|
||||
1. `telegram-otp-bot/.env` `API_KEY` **must equal** `Seams:Sms:Telegram:ApiKey` in
|
||||
`appsettings.Development.json`. On the verification machine it did not: the local `.env` still holds
|
||||
`ab8984974bc1fe5ce514d0fd74f71c8738b3aed92a7e4d86` — byte-for-byte the key published in `.env.example`.
|
||||
`TelegramSmsSender.cs:35` hardcodes that exact string as a rejected placeholder, so the relay would
|
||||
reject the API's calls even with everything running. Copy the appsettings value into `.env`.
|
||||
2. `api.telegram.org` is filtered in Iran, so `TELEGRAM_PROXY_URL` must point at a working proxy.
|
||||
|
||||
It **broadcasts** every code to every configured chat id, so it is a shared inbox for a trusted group, not
|
||||
an SMS gateway.
|
||||
|
||||
### Limits that will bite you
|
||||
|
||||
| Limit | Value | Source |
|
||||
| --- | --- | --- |
|
||||
| OTP length | 6 digits | `IdentityDefaults.OtpCodeLength` |
|
||||
| OTP validity | **60 s** | `IdentityDefaults.OtpExpirySeconds` |
|
||||
| Resend window | **120 s** per phone | `platform_configs.auth_otp_resend_seconds` |
|
||||
| Wrong attempts | **5**, then `code: "otp_locked"` | `Lockout.MaxFailedAccessAttempts` |
|
||||
| **Endpoint rate limit** | **5 requests / 60 s per IP** | `otp` policy, `RateLimitingServiceExtension.cs` |
|
||||
|
||||
**The `otp` rate-limit policy covers `request_otp` *and* `verify_otp`.** Five combined calls inside one
|
||||
minute and both endpoints return `429` — `verify_otp` with an **empty body**, which looks like a crash. A
|
||||
scripted login is 2 calls, so **you get two logins per minute, total.** See
|
||||
[Scripting logins](#scripting-logins).
|
||||
|
||||
---
|
||||
|
||||
## Demo accounts
|
||||
|
||||
Read out of [`DemoWorldDefinitions.cs`](../../server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Seeding/DemoWorldDefinitions.cs)
|
||||
and [`DemoLifecycleDefinitions.cs`](../../server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Seeding/DemoLifecycleDefinitions.cs)
|
||||
— the seeders are the authority, not any doc — and each one confirmed by a live `GET /api/v1/me`.
|
||||
|
||||
| Phone | `/me` roles | Who | Demonstrates |
|
||||
| --- | --- | --- | --- |
|
||||
| `09120000001` | `nurse` | زهرا عزیزی (f) | **verified** · 3 priced variants · whole-city Tehran + districts 1, 3 · 2 credentials · bank account |
|
||||
| `09120000002` | `nurse` | علی کریمی (m) | **verified** · 2 variants · districts 3, 6, 12 · sponsored by the partner center |
|
||||
| `09120000003` | `nurse` | مریم احمدی (f) | **unverified** (`status: in_review`, `isBookable: false`, blocking `moh_competency_license` + `criminal_record`) — must never appear in search |
|
||||
| `09120000010` | `customer` | سارا محمدی (f) | 2 patients · 1 Tehran address · owns 6 of the 8 seeded bookings |
|
||||
| `09120000011` | `customer` | رضا حسینی (m) | 1 infant patient · 1 address · owns the BNPL + payout-eligible bookings |
|
||||
| `09120000020` | `super_admin` | نگار مدیری (f) | the full backoffice — **but see the RBAC gap below** |
|
||||
| `09120000021` | `finance` | کامران مالی (m) | scoped backoffice; the client's `useAdminCapabilities()` shows only money consoles |
|
||||
| `09120000030` | `customer` | بهنام رستگار (m) | owns مرکز پرستاری آرامش (merchant-of-record). **No partner role** — `/me` returns `["customer"]`; navigate to `/fa/partner` manually |
|
||||
|
||||
Admin sub-roles are **server-granted** — `POST /me/select_role` only accepts `customer` and `nurse`
|
||||
(`RoleNames.SelfAssignable`).
|
||||
|
||||
### ⚠ The seeded admins cannot reach any admin endpoint
|
||||
|
||||
**Every `[Authorize(DynamicPermission)]` route returns `403` for `09120000020` and `09120000021`.** Verified
|
||||
against all 16 admin GET operations in the live swagger — `platform_config`, `audit`, `holidays`,
|
||||
`admin_verifications`, `admin_payouts`, `admin_refunds`, `admin_evv`, `admin_bnpl`, `admin/tickets`,
|
||||
`admin/reviews`, `admin_cancellation_policies`, `admin/partner-centers`. All `403`. The same token gets
|
||||
`200` from `/me`, and nurse/customer endpoints work normally, so this is not a token problem.
|
||||
|
||||
Root cause, in `DynamicPermissionService.CanAccess`:
|
||||
|
||||
```csharp
|
||||
if (user.IsInRole("admin")) return true;
|
||||
var key = $"{area}:{controller}:";
|
||||
return user.FindAll(ConstantPolicies.DynamicPermission).Any(c => c.Value.Equals(key, …));
|
||||
```
|
||||
|
||||
It grants on the **literal** role `"admin"` or on a per-controller `DynamicPermission` claim. The demo
|
||||
admins hold `super_admin` / `finance` (`RoleNames.cs:13-17`), and refinement-phase-5 stopped auto-seeding the
|
||||
`admin`/`qw123321` account — so **no seeded account satisfies either branch.**
|
||||
|
||||
Consequence for testing: **the entire admin backoffice is untestable end-to-end on the real path.** The
|
||||
client hides this because `USE_ADMIN_MOCK = true`, so the console renders fully on in-browser fake data.
|
||||
See [admin-backoffice.md](admin-backoffice.md). Workaround: add both `"Seed": { "AdminUsername",
|
||||
"AdminPassword" }` keys to `appsettings.Development.json` before boot to mint a literal-`admin` account, and
|
||||
call the API directly (the web login is phone-OTP only).
|
||||
|
||||
---
|
||||
|
||||
## The seeded world, and how stale it is
|
||||
|
||||
`DemoWorldSeeder` builds the personas; `DemoLifecycleSeeder` layers 8 bookings plus the money, reviews,
|
||||
tickets, notifications and records behind them. Both are **idempotent and Development-only**, and log
|
||||
`already seeded — no-op` on every subsequent boot.
|
||||
|
||||
Live counts on the shared remote DB at this stamp:
|
||||
|
||||
| Thing | Count | Read with |
|
||||
| --- | --- | --- |
|
||||
| Bookings | 8 (6 for `…010`, 2 for `…011`) | `GET /bookings/list?role=customer` |
|
||||
| Booking requests | 15 (11 nurse 1, 4 nurse 2) | `GET /booking_requests/list` |
|
||||
| Search rows | 27 across 3 nurses; `category=1&city=101` → **9**, `category=3` → **0** | `GET /search/nurses` (anonymous) |
|
||||
| Patients / addresses | 2 / 1 for `…010` | `GET /patients/list`, `GET /customer_addresses/list` |
|
||||
| Notifications | 8, 6 unread | `GET /notifications/get_notifications` |
|
||||
| Tickets | 9 | `GET /tickets` |
|
||||
| Reviews | nurse 1 → `averageRating: 5`, `publishedCount: 1` | `GET /nurses/1/reviews` (anonymous) |
|
||||
| Refunds | booking 7 → `succeeded`, `psp_card`, `2000000` | `GET /refunds/by_booking/7` |
|
||||
| Payouts | nurse 1: paid `3187500`, clawback outstanding `212500`; nurse 2: eligible `2720000` | `GET /nurse_payouts/earnings_balance` |
|
||||
| Care records | 2 for patient 1 | `GET /patients/1/care_records` |
|
||||
|
||||
### ⚠ It has aged out — and this is not cosmetic
|
||||
|
||||
`DemoLifecycleDefinitions` expresses every timestamp as an **offset from seed time**, and the seeder anchors
|
||||
to the *first* run's epoch, never to wall-clock now. The seeder's own comment is explicit:
|
||||
|
||||
> *"A wipe + reseed is what moves the demo world forward in time."*
|
||||
|
||||
**This world was seeded on 2026-07-26. It is 7 days old.** What that has already broken:
|
||||
|
||||
| Scenario as designed | State today | Effect |
|
||||
| --- | --- | --- |
|
||||
| B1 `upcoming` — scheduled +3 d | scheduled 2026-07-29, **in the past** | nothing is actually "upcoming" |
|
||||
| B3 `completed_in_window` — dispute window open | window closed 2026-07-28 | the dispute/review-moderation path can't be walked |
|
||||
| B2 `in_progress` — session 3 checked in *today* | scheduled 2026-07-26 | mid-engagement is 7 days stale |
|
||||
| One `pending` request awaiting the nurse | `expired_no_response` (the `booking_request_expiry` job ran) | — |
|
||||
| Two `accepted` requests awaiting payment | `payment_deadline_expired` | — |
|
||||
|
||||
**There is no longer a single `pending` or `accepted` booking request in the world.** Confirmed across both
|
||||
nurses: statuses are only `converted`, `rejected_by_nurse`, `cancelled_by_customer`, `expired_no_response`,
|
||||
`payment_deadline_expired`.
|
||||
|
||||
So **[booking-request](booking-request.md) and [checkout-and-payment](checkout-and-payment.md) have nothing
|
||||
seeded to act on.** Create a fresh request yourself (customer → search → C4/C5) — that path works and is the
|
||||
intended way to exercise both — or reseed.
|
||||
|
||||
---
|
||||
|
||||
## The scheduler is running while you test
|
||||
|
||||
`RecurringJobSchedulerHostedService` starts with the API and gives each of the 7 jobs its own loop. **Every
|
||||
job fires once immediately at boot**, then on its own cadence, re-reading its interval from
|
||||
`platform_configs` each tick. It does **not** run under the `Testing` environment.
|
||||
|
||||
Three of them will change state under you:
|
||||
|
||||
| Job | Cadence | What you will notice |
|
||||
| --- | --- | --- |
|
||||
| `booking_request_expiry` | **every 60 s** (hardcoded) | A request you leave un-actioned flips to `expired_no_response`, then `payment_deadline_expired`. **This is what aged the seeded world out**, and it will do the same to yours — accept and pay promptly |
|
||||
| `no_show_sweep` | hourly | A session whose start time passed with no EVV check-in gets flagged missed |
|
||||
| `weekly_payout_generation` | at boot, then 7 d | Creates a **draft** payout batch you did not ask for. Generation only — it never moves money; `process` stays an explicit admin action |
|
||||
|
||||
The other four (`notification_retention`, `verification_expiry_scan`, `moadian_reconciliation`,
|
||||
`audit_log_retention`) are no-ops on a freshly seeded database. `moadian_reconciliation` logs
|
||||
`scanned 8 invoice(s); 0 reached registered` because `IMoadianClient` is on its mock.
|
||||
|
||||
---
|
||||
|
||||
## Reset
|
||||
|
||||
There is **no in-app reseed**. Both seeders guard on natural keys (a persona's phone, a ticket's reference
|
||||
code, the partner permit number, a request's customer/nurse/variant/date tuple), so re-running never
|
||||
duplicates and never refreshes. Moving the world forward in time means dropping the data first.
|
||||
|
||||
| Target | Procedure | Verified |
|
||||
| --- | --- | --- |
|
||||
| Local Docker DB | `cd server && docker compose down -v && docker compose up -d`, then `dotnet run` | ✗ no Docker here |
|
||||
| Remote shared DB | `DROP DATABASE Baya` (and `Baya_Logs`), then boot — `MigrateAsync` + both seeders run | ✗ **not attempted** |
|
||||
|
||||
**Do not drop the shared remote database casually.** It backs the `balinyaar.ir` demo deployment and is used
|
||||
by other people. If you need a clean, freshly-dated world, use a local instance.
|
||||
|
||||
Schema-only, without booting the app:
|
||||
|
||||
```bash
|
||||
cd server
|
||||
dotnet ef database update --project src/Infrastructure/Baya.Infrastructure.Persistence \
|
||||
--startup-project src/API/Baya.Web.Api
|
||||
```
|
||||
|
||||
Reference and demo seeds still run on the next app boot.
|
||||
|
||||
---
|
||||
|
||||
## Scripting logins
|
||||
|
||||
Two API calls per login, both on the `otp` policy, 5 per 60 s per IP. **Space logins ≥ 40 s apart** or you
|
||||
will 429. This script minted tokens for all 8 demo accounts:
|
||||
|
||||
```bash
|
||||
login() {
|
||||
P="$1"
|
||||
curl -s -X POST http://localhost:5002/api/v1/auth/request_otp \
|
||||
-H "Content-Type: application/json" -d "{\"phone\":\"$P\"}" > /dev/null
|
||||
C=$(curl -s "http://localhost:5002/api/v1/dev/last_otp/$P" \
|
||||
| node -pe "JSON.parse(require('fs').readFileSync(0,'utf8')).data.code")
|
||||
curl -s -X POST http://localhost:5002/api/v1/auth/verify_otp \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"phone\":\"$P\",\"code\":\"$C\",\"deviceInfo\":\"cli\"}" \
|
||||
| node -pe "JSON.parse(require('fs').readFileSync(0,'utf8')).data.accessToken"
|
||||
}
|
||||
for P in 09120000001 09120000002 09120000003 09120000010 \
|
||||
09120000011 09120000020 09120000021 09120000030; do
|
||||
echo "T_$P=$(login $P)"; sleep 40
|
||||
done
|
||||
```
|
||||
|
||||
Then `curl -H "Authorization: Bearer $T_09120000010" http://localhost:5002/api/v1/me`.
|
||||
|
||||
Then `curl -H "Authorization: Bearer $T_09120000010" http://localhost:5002/api/v1/me`.
|
||||
|
||||
Four things to get right:
|
||||
|
||||
- **The field is `phone`, not `phoneNumber`.** `phoneNumber` returns `400` with
|
||||
`"The Phone field is required."`
|
||||
- **`Authorization: Bearer <token>`, never a cookie.** The client stores the JWE in a cookie it reads
|
||||
itself and sends as a header; the server's CORS policy does not allow credentials.
|
||||
- Access tokens last **60 minutes** (`IdentitySettings.ExpirationMinutes`).
|
||||
- **One live token per account. Logging in again kills the previous one.** Verified: after re-running the
|
||||
script, the earlier tokens for the re-minted phones all returned `401` while untouched accounts kept
|
||||
working. `AppUserManagerImplementation.VerifyUserCode` calls `UpdateSecurityStampAsync` on every
|
||||
successful verification, and the bearer handler's `OnTokenValidated` runs
|
||||
`ValidateSecurityStampAsync` — so a new login invalidates every token previously issued to that user.
|
||||
**Two people cannot share a demo account**, and a second browser profile will silently log the first out.
|
||||
|
||||
---
|
||||
|
||||
## What the old docs get wrong
|
||||
|
||||
Each row was checked against running code. **This file is right; the predecessor is stale.**
|
||||
|
||||
| # | Claim | Where | Reality |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | Set crypto keys with `dotnet user-secrets` | manual-testing-plan | Removed in `5885280`; the store is not read. Edit `appsettings.Development.json` or use `__` env vars |
|
||||
| 2 | API on `https://localhost:5002` | RUNBOOK ×6, manual-testing-plan | **`http://`**. No TLS binding exists |
|
||||
| 3 | Run `dotnet dev-certs https --trust` | RUNBOOK setup step 1 | Nothing to trust. Skip |
|
||||
| 4 | `client/.env.development` has `https://localhost:5002` | RUNBOOK | The file says `http://`. But **`.env.sample` still says `https://`** — a live trap |
|
||||
| 5 | `Seams:Sms:Provider` "committed default is `mock`" | RUNBOOK, Telegram step 3 | It is **`telegram`**, so `request_otp` **500s** on a fresh clone |
|
||||
| 6 | Console prints `MOCK SMS — OTP code 123456 …` | RUNBOOK, manual-testing-plan | It prints `MOCK SMS — OTP issued to phone ending in 0002` — **no code**. Use `dev/last_otp` |
|
||||
| 7 | "Only `auth` is real; 21 of 22 domains are mocked" | RUNBOOK "Good to know" | Stale by refinement-phase-4. **15 of 22 are real**, 7 mocked — see [index.md](index.md) |
|
||||
| 8 | `docker compose down -v` resets the world | RUNBOOK | Only for the local-DB path. The committed config uses a **remote** DB where it does nothing |
|
||||
| 9 | Demo world shows "upcoming" / open dispute windows | manual-testing-plan | Seeded 2026-07-26 and **aged out**; no `pending`/`accepted` requests remain |
|
||||
| 10 | `09120000020` gives "full backoffice" | RUNBOOK | `403` on **every** admin endpoint — see [the RBAC gap](#-the-seeded-admins-cannot-reach-any-admin-endpoint) |
|
||||
|
||||
Contradictions C-1, C-3, C-4 and C-5 from
|
||||
[clarify-chain/open-contradictions.md](../../archive/clarify-chain/open-contradictions.md) are settled by rows 1–4 and 8.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
| --- | --- | --- |
|
||||
| `request_otp` → `500`, log shows `Telegram OTP relay delivery failed (http 502)` | `Provider=telegram`, relay not running | Boot with `Seams__Sms__Provider=mock` |
|
||||
| `request_otp` → `429`; `verify_otp` → `429` **with an empty body** | `otp` policy, 5/60 s per IP | Wait 60 s. Space scripted logins ≥ 40 s |
|
||||
| `400` `"The Phone field is required."` | Sent `phoneNumber` | The field is `phone` |
|
||||
| `403` on every `/api/v1/admin*` route | `DynamicPermission` doesn't recognise `super_admin` | No clean workaround — see the RBAC gap |
|
||||
| `/healthz/ready` → `503` | object-storage probe file lock (Windows) | Cosmetic. Check `entries.sql-app` instead |
|
||||
| `dev/last_otp` → `404 "No OTP has been issued for this phone yet."` | No `request_otp` for that phone yet, or not Development | Call `request_otp` first (a `500` from it still stores the code) |
|
||||
| Login "succeeds" but the account is unknown / `Padding is invalid` | `Seams:FieldEncryption` doesn't match the DB | Restore the committed values |
|
||||
| `Refusing to start: required secret configuration is missing…` | `ConnectionStrings` blank or still `SET_VIA_USER_SECRETS_OR_ENV` | Use `appsettings.Development.json` or `ConnectionStrings__SqlServer` |
|
||||
| Every `curl` returns `502` | A machine-wide `HTTP_PROXY`/`HTTPS_PROXY` intercepting localhost | `curl --noproxy '*'`, or clear `NO_PROXY` |
|
||||
| Client `404` on `http://localhost:3000/` | Locale prefixes; `next dev` root-path quirk | Use `/fa`. Verify root behaviour with a prod build |
|
||||
| Browser: blocked by CORS | Origin not in `Cors:AllowedOrigins` | `http://localhost:3000` is listed by default |
|
||||
| `dotnet user-secrets`: "could not find UserSecretsId" | Expected | Edit appsettings instead |
|
||||
| A `500` returns a raw stack trace instead of the `ApiResult` envelope | `ExceptionHandler` returns `false` in Development on purpose | Expected locally. Don't document the error shape from a dev response |
|
||||
| A booking request flipped status while you were reading it | `booking_request_expiry` runs **every 60 s** | Expected. Act on requests promptly |
|
||||
| A token that worked a minute ago now `401`s | Someone (or another tab) logged into the same demo account — the security stamp rotated | Use one account per tester, or re-login |
|
||||
|
||||
---
|
||||
|
||||
## Where to go next
|
||||
|
||||
[index.md](index.md) — the flow atlas: what is built, what is mocked, and how to walk each journey.
|
||||
Reference in New Issue
Block a user