Files
baya-monorepo/dev/post-phase/refinement/RUNBOOK.md
T
2026-08-02 17:18:36 +03:30

16 KiB
Raw Blame History

Run the whole app locally — Balinyaar bring-up runbook

SUPERSEDED (2026-08-02) by docs/flows/testing-setup.md, which was executed rather than transcribed. This file is right about user-secrets but wrong about six other things — the API scheme (http, not https), the dev cert step, .env.development's value, the committed Seams:Sms:Provider (it is telegram, so request_otp 500s on a fresh clone), the console OTP line (the code is never printed), and "only auth is real" (15 of 22 client domains are real). Kept as a record.

The one copy-pasteable procedure for standing up both projects on one machine and watching a real, authenticated request cross the wire. Established by Refinement Phase 0 (refinement-phase-0-bring-up.md).

After this you have: the API on https://localhost:5002 (against a local SQL Server), the web client on http://localhost:3000, and a working phone-OTP login backed by the real backend. Auth is the only real domain until Refinement Phase 4 — everything else in the UI is still an in-browser mock.


Prerequisites

  • .NET 10 SDK (dotnet --version) — preview is fine.
  • Node.js 20+ and npm (node --version).
  • Docker (Desktop or engine) for the local database — or your own SQL Server on localhost:1433.

One-time setup

1. Trust the ASP.NET Core HTTPS dev certificate

Without this the browser (and fetch) rejects https://localhost:5002 and every call fails with an opaque network error.

dotnet dev-certs https --trust

Accept the OS prompt. (macOS/Windows trust the cert; on Linux see the .NET docs for the per-distro step.)

2. Start a local SQL Server

From server/:

cd server
docker compose up -d

This runs SQL Server 2022 (Developer edition) on localhost:1433 with a dev-only SA password (Balinyaar_Dev1433, defined in server/docker-compose.yml — not a secret, never used in production). Give it ~2030s on first start (docker compose ps shows healthy).

Already have a SQL Server? Skip this and point the connection string in step 3 at it instead.

3. Point the API at a database

dotnet user-secrets is no longer used — the <UserSecretsId> was removed from Baya.Web.Api.csproj, so that store isn't read at all. A leftover secrets.json on your machine is inert and can be deleted. All configuration lives in appsettings.Development.json, which already points at the shared remote database the deployed demo also uses — so a fresh clone boots with no configuration step at all.

To work against the throwaway local container from step 2 instead, edit ConnectionStrings:SqlServer in that file (the Password must match MSSQL_SA_PASSWORD in server/docker-compose.yml):

"SqlServer": "Server=localhost,1433;Database=Baya;User Id=sa;Password=Balinyaar_Dev1433;TrustServerCertificate=True;Encrypt=False;"

Env-var alternative (CI/containers, or to avoid a local edit showing up in git status): set ConnectionStrings__SqlServer (double underscore = the : config separator) — it overrides the file. PowerShell: $env:ConnectionStrings__SqlServer = "Server=localhost,1433;Database=Baya;User Id=sa;Password=Balinyaar_Dev1433;TrustServerCertificate=True;Encrypt=False;" bash: export ConnectionStrings__SqlServer="Server=localhost,1433;Database=Baya;User Id=sa;Password=Balinyaar_Dev1433;TrustServerCertificate=True;Encrypt=False;"

Deploying rather than developing? See DEPLOY.md.


Run it (two terminals)

Terminal 1 — backend (from server/)

dotnet run --project src/API/Baya.Web.Api/Baya.Web.Api.csproj

On boot the API applies all EF migrations and seeds roles against the (empty) local DB, then listens on https://localhost:5002 — Swagger at https://localhost:5002/swagger. In Development it also seeds a sandbox payment gateway and the demo world (below). It does not seed the old admin/qw123321 account anymore (refinement-phase-5); a break-glass admin is created only if you set Seed:AdminUsername / Seed:AdminPassword (see below), and the day-to-day admin path is the phone-OTP demo admins.

In Development it additionally runs the demo-world seeder (Refinement Phase 1): verified/unverified demo nurses with priced variants + Tehran coverage (and therefore real nurse_search_index rows), plus demo customers with patients and addresses. The console logs Demo world seeded: 3 nurse(s), 2 customer(s)… (or already seeded — no-op on a subsequent run). It is idempotent and never runs outside Development.

On top of that, the lifecycle seeder (DemoLifecycleSeeder) populates every flow's mid-state so manual testing never starts from an empty world: booking requests in every status, 8 bookings across every reachable state (incl. a 5-session package mid-engagement and a cancelled-and-refunded one), the balanced ledger, three refunds (card-succeeded / BNPL-processing / post-payout clawback), a paid and a draft payout batch, reviews in all moderation states, tickets, notifications, patient care records, a merchant-of-record partner center, and a mid-pipeline verification case. The console logs Demo lifecycle seeded: … (or already seeded — no-op). The full scenario map + per-flow walkthroughs live in dev/post-phase/manual-testing-plan.md.

Terminal 2 — frontend (from client/)

cd client
npm install      # first time only
npm run dev

Serves http://localhost:3000. It reads the API base URL from client/.env.development (NEXT_PUBLIC_API_URL = https://localhost:5002). If you run the API on a different port/scheme, create client/.env.local with your NEXT_PUBLIC_API_URL=… (it overrides .env.development, is git-ignored).


Demo accounts (Development seed)

The demo seeder creates these loginable accounts (phone-OTP; any 6-digit code you read from the console/dev endpoint works). Use them to see the real path populated:

Phone Role Who State
09120000001 nurse زهرا عزیزی (female) verified, 3 variants, whole-city + 2 districts
09120000002 nurse علی کریمی (male) verified, 2 variants, 3 districts
09120000003 nurse مریم احمدی (female) unverified — not discoverable in search
09120000010 customer سارا محمدی (female) 2 patients, 1 Tehran address
09120000011 customer رضا حسینی (male) 1 patient, 1 Tehran address
09120000020 admin (super_admin) نگار مدیری (female) full backoffice — lands on /admin, sees every console incl. RBAC
09120000021 admin (finance) کامران مالی (male) scoped backoffice — lands on /admin, sidebar shows only the money consoles (useAdminCapabilities gating)
09120000030 partner-center owner بهنام رستگار (male) owns مرکز پرستاری آرامش (merchant-of-record, sponsors علی کریمی). No admin/nurse role — log in, then navigate to /partner manually (REQ-038: no /me partner signal yet)

The old username+password admin/qw123321 account is no longer auto-seeded (refinement-phase-5 — no committed credential). To bootstrap a break-glass username+password admin, add both keys to appsettings.Development.json before boot, then log in via the API (not the web UI, which is phone-OTP only):

"Seed": { "AdminUsername": "admin", "AdminPassword": "<a-strong-password>" }

The phone-OTP admins (09120000020 / 09120000021, refinement-phase-2) are how you reach the /admin console through the same web login flow as everyone else — admin sub-roles are server-granted, never self-selectable via me/select_role. Log in with either phone exactly like a nurse/customer; role hydration routes you to /admin. To reach the nurse app, log in as a verified nurse phone (09120000001); a fresh customer can also become a nurse in-app (SelectRole → me/select_role) and is then routed to /nurse after the next /me.

Prove search works without the frontend: open Swagger → GET /api/v1/search/nurses?service_category_id=1&city_id=101 returns the two verified nurses' variants; service_category_id=3 (only the unverified nurse) returns an empty page.

Log in (the real round-trip)

  1. Open http://localhost:3000/fa/login.
  2. Enter an Iranian mobile number (e.g. the verified nurse 09120000001, or any demo phone above) and request the code.
  3. Get the 6-digit OTP one of two ways:
    • Read the server console (Terminal 1) — SMS is mocked, so the code is logged: MOCK SMS — OTP code 123456 for phone ending in 0001.
    • Or hit the Development-only helper (handy for scripts/e2e): GET https://localhost:5002/api/v1/dev/last_otp/09120000001{ "data": { "phone": "09120000001", "code": "123456" }, ... }. This endpoint returns 404 outside Development and is superseded by real SMS in Refinement Phase 8.
  4. Enter the code and submit → role hydration routes you to the app for your role: a customer to the family home (/), a nurse to /nurse, an admin to /admin (refinement-phase-2).
  5. Verify in DevTools → Network: POST /api/v1/auth/request_otp, POST /api/v1/auth/verify_otp, and GET /api/v1/me all return 200 with the ApiResult envelope, and there is no CORS error in the console. That is the first real authenticated request between the two projects.

OTP over Telegram (optional — instead of reading the log)

For manual testing you can have the code arrive on your phone in Telegram rather than in the server console. A standalone dev-only relay (telegram-otp-bot/) forwards it; the API talks to it through the normal ISmsSender seam. Development only — the relay broadcasts every code to every configured chat id, so it is a test-group convenience, not an SMS gateway.

  1. Start the relay (see its README for creating the bot with @BotFather and discovering chat ids — each recipient must press Start in Telegram first, then GET /chat_ids):
    cd telegram-otp-bot && npm start      # no npm install — zero dependencies
    
    api.telegram.org is filtered in Iran, so set TELEGRAM_PROXY_URL in its .env to your VPN/proxy client (http://127.0.0.1:10809, socks5://…, or the proxy container on a VPS). The boot banner prints the bot's @username — that line appearing means the token and the proxy work.
  2. Share the secret with the API — the same value on both sides: the relay's .env API_KEY and Seams:Sms:Telegram:ApiKey in appsettings.Development.json. The appsettings side is already filled in; copy that value into your local telegram-otp-bot/.env. Do not use the one in .env.example — it is published in git, so TelegramSmsSender rejects it with Seams:Sms:Telegram:ApiKey is not configured (unset, or still the published example key).
  3. Flip the provider in server/src/API/Baya.Web.Api/appsettings.Development.json:
    "Seams": { "Sms": { "Provider": "telegram" } }   // committed default is "mock"
    
  4. Log in as usual — the 6-digit code arrives in Telegram. The dev/last_otp helper keeps working alongside it (telegram is the one non-mock provider that leaves the capture bridge on), so scripts and e2e tests are unaffected. Set the provider back to mock to return to reading the console.

If the relay is down or reaches nobody it answers 502 and login fails loudly (request_otp returns an error) rather than pretending an undelivered code was sent.


Good to know

  • The API speaks HTTP/1.1 and HTTP/2 (Kestrel Protocols: Http1AndHttp2, refinement-phase-5 — the previous HTTP/2-only default broke non-TLS HTTP/1.1 hops). Over TLS the client negotiates h2 via ALPN, so gRPC and fetch both work; plain-HTTP hops fall back to HTTP/1.1.
  • Secrets fail fast. If ConnectionStrings is blank or still the base file's SET_VIA_USER_SECRETS_OR_ENV placeholder, the API refuses to start with Refusing to start: required secret configuration is missing…. Production/Staging must additionally supply real IdentitySettings JWE keys and Seams:FieldEncryption keys; Development uses the dev-only ones in appsettings.Development.json.
  • Never change Seams:FieldEncryption:Key/:HashKey. They decrypt every PII column in the shared database and derive users.PhoneHash, which every login looks up. Changing either locks everyone out.
  • Enable the secret-scan pre-commit hook once per clone so a stray credential can't be committed: git config core.hooksPath .githooks (see .githooks/README.md).
  • Behind a reverse proxy, list its address in ForwardedHeaders:KnownProxies (or a CIDR in :KnownNetworks) so the rate limiter partitions on the real client IP, not the proxy's.
  • Only auth is real by default. 21 of 22 client service domains default to an in-browser mock (USE_*_MOCK = true); the home, search, bookings, etc. are fake in-memory data until Refinement Phase 4.
  • The DB self-migrates + self-seeds, so pointing at an empty local instance is enough — including the Development demo world (nurses, variants, search rows, customers) from Refinement Phase 1.
  • Explicit migration path (deploy / migrations-off-boot). Boot-time MigrateAsync is the dev convenience; to apply migrations without booting the app (the direction Refinement Phase 7 will formalise), run from server/:
    dotnet ef database update --project src/Infrastructure/Baya.Infrastructure.Persistence --startup-project src/API/Baya.Web.Api
    
    (dotnet tool install --global dotnet-ef if the ef command is missing. It reads the same connection string, so set the user-secret / env var first.) This applies schema only — the reference + demo seeds run on the next app boot.
  • Allowed browser origins are configuration-driven (Cors:AllowedOrigins), defaulting to http://localhost:3000 in Development. A deployed environment lists its real web origin(s).

Stopping / resetting

docker compose down          # stop the DB, keep its data
docker compose down -v       # stop the DB and wipe the volume (fresh migrate + reference + demo seed next run)

Re-running dotnet run against an existing seeded DB does not duplicate anything — both the reference and the demo seeders are idempotent (the demo seeder guards each persona on its phone number). To get a clean demo world, wipe the volume (docker compose down -v) and boot again.

Troubleshooting

Symptom Fix
Browser: net::ERR_CERT_AUTHORITY_INVALID on :5002 Run dotnet dev-certs https --trust (setup step 1).
API startup: Refusing to start: required secret configuration is missing… ConnectionStrings:SqlServer is blank or still a placeholder. Do setup step 3.
API startup: Login failed for user 'sa' / connect timeout DB not up or wrong password — check docker compose ps and that the password in appsettings.Development.json matches docker-compose.yml.
Console: ...has been blocked by CORS policy UseCors missing/mis-ordered, or the browser origin isn't in Cors:AllowedOrigins. It must sit after UseRouting and before the rate limiter.
dotnet user-secrets errors with "could not find UserSecretsId" Expected — user-secrets was removed. Edit appsettings.Development.json instead.