1. "0. Quick start — run these in order" at the top of DELIVERY.md: five steps
from an empty machine to a verified running stack, copy-pasteable, with no
decisions to make. Covers which env vars must have a value (and how to
generate the two JWT secrets), `pnpm build:release` BEFORE
`docker compose build`, what the boot log should say, which banner is
expected and which means a step was missed, and how to run the end-to-end
check. Every command in it was executed verbatim to confirm it works.
2. "Before the real release" replaces the old three-item list:
- A. blocking config: real JWT_SECRET and a SEPARATE JWT_CUSTOMER_SECRET,
remove OTP_MASTER_CODE, configure the SMS credentials
- B. credentials the reference leaked and that must be rotated (SEC-12,
SEC-06) — same live accounts, never copied into this repo
- C. parity work not finished: admin (financial documents, users and
permissions incl. the still-open PERM-01, reports) and apps (MON-08 first,
then STUB-05 SMS recipients, STUB-04, STUB-03, analytics), pointing at
ISSUES.md for the rest
Also removed the obsolete `version:` key from docker-compose.yml, which printed
a deprecation warning on every compose command and made the checklist's output
noisy. Config re-validated.
Verified after these changes: container bundle is byte-identical to the local
release build (sha256 match), and that container was re-tested end to end:
compose e2e 32/32, complete-profile 38/38, report 40/40, expert login 36/36.
Locally: jest 54/54, build 5/5, typecheck clean on all four apps.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
409 lines
20 KiB
Markdown
409 lines
20 KiB
Markdown
# DELIVERY — gold-banking-monorepo
|
||
|
||
Branch: **`release/delivery`** · Prepared 2026-09-23
|
||
|
||
Contains everything from `parity/security-hotfix` and `parity/phase-1-admin`.
|
||
Both merged with **no conflicts** (`phase-1-admin` already contained
|
||
`security-hotfix`, so the merge was a fast-forward). `main` was not touched.
|
||
|
||
---
|
||
|
||
## 0. Quick start — run these in order
|
||
|
||
Copy-paste, top to bottom. Nothing here needs a decision.
|
||
|
||
### Step 1 — fill in the environment
|
||
|
||
Create `.env` in the repo root. These **must** have a value:
|
||
|
||
```dotenv
|
||
# Database
|
||
POSTGRES_USER=postgres
|
||
POSTGRES_PASSWORD=<choose a strong password>
|
||
POSTGRES_DB=goldbank
|
||
DATABASE_URL=postgresql://postgres:<the same password>@db:5432/goldbank?schema=public
|
||
|
||
# Auth — two DIFFERENT random values. Generate each with:
|
||
# node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))"
|
||
JWT_SECRET=<random value 1>
|
||
JWT_CUSTOMER_SECRET=<random value 2, NOT the same as above>
|
||
```
|
||
|
||
Inside Docker the database host is `db`, not `localhost`.
|
||
|
||
Everything else is optional and may be left blank. Two you may want now:
|
||
|
||
```dotenv
|
||
# Delivery only — a fixed OTP that signs in ANY active customer.
|
||
# Exactly 4 digits, or it cannot be typed into the apps. REMOVE before the
|
||
# real release (see "Before the real release" below).
|
||
OTP_MASTER_CODE=1379
|
||
|
||
# Leave blank and SMS is logged, not sent. The server still starts.
|
||
SMS_TREZ_USERNAME=
|
||
SMS_TREZ_PASSWORD=
|
||
SMS_TREZ_SENDER_LINE=
|
||
```
|
||
|
||
### Step 2 — build
|
||
|
||
```bash
|
||
pnpm install
|
||
pnpm build:release
|
||
```
|
||
|
||
**Do not skip `pnpm build:release`, and never run `docker compose build`
|
||
without it first.** The API image copies a bundle built here on the machine; it
|
||
does not build inside the image. Skipping this step produces an image from
|
||
**old code** and still reports success. This is real — it happened during this
|
||
delivery's own test run, with a bundle four days stale. (DEPLOY-03)
|
||
|
||
### Step 3 — start
|
||
|
||
```bash
|
||
docker compose up -d --build
|
||
```
|
||
|
||
Database migrations and seeding run **automatically inside the API container**
|
||
at boot. There is no manual migration step. Never run `prisma migrate reset`.
|
||
|
||
### Step 4 — check it came up
|
||
|
||
```bash
|
||
docker compose ps # db healthy, api up
|
||
docker compose logs api | tail -40
|
||
```
|
||
|
||
In the log you should see `Nest application successfully started`, and
|
||
`Applied migrations: …` (first run) or `No pending migrations` (later runs).
|
||
|
||
An `OTP_MASTER_CODE IS ACTIVE` banner is **expected** while that variable is
|
||
set — it is a reminder, not an error, and it disappears once you remove the
|
||
variable before the real release.
|
||
|
||
A `JWT_CUSTOMER_SECRET IS NOT SET` banner means **Step 1 was not done**. That
|
||
one is a real security problem, not a warning to ignore — go back and set it.
|
||
|
||
### Step 5 — prove the whole flow works
|
||
|
||
```bash
|
||
# Publishes the API on host port 3100 so the test can reach it.
|
||
docker compose -f docker-compose.yml -f docker-compose.delivery-test.yml up -d api
|
||
node apps/api/scripts/test-compose-e2e.cjs http://localhost:3100/api <OTP_MASTER_CODE>
|
||
```
|
||
|
||
Expect **32 passed, 0 failed**. It signs a sales expert and an ordinary
|
||
customer into the app, has both place an order, confirms both orders appear in
|
||
the admin panel, and re-checks the token boundaries — all against the real
|
||
container.
|
||
|
||
`docker-compose.delivery-test.yml` exists **only** for that check. The normal
|
||
deployment uses `docker-compose.yml` alone, where the API has no host port and
|
||
Dokploy/Traefik routes to it by domain.
|
||
|
||
---
|
||
|
||
## ⚠ Before the real release
|
||
|
||
Tonight's build is a **delivery build**. These are the things that must change
|
||
before it is a production release. The first three are blocking.
|
||
|
||
### A. Configuration — blocking
|
||
|
||
| # | Do this | Why it matters |
|
||
|---|---|---|
|
||
| **1** | **Set `JWT_CUSTOMER_SECRET` to its own random value**, different from `JWT_SECRET`. | Not a cosmetic default. Unset, the API signs customer and sales-expert **app** tokens with `JWT_SECRET` — *the same key admin tokens use* — and the two token realms stop being cryptographically separate. The only remaining barrier is the `typ` check in `jwt.strategy.ts`, and a sales expert's app token carries their **AdminUser id** in `sub`. Two independent defences collapse into one. A loud boot banner fires while it is missing. **REL-03 (Critical).** |
|
||
| **2** | **Remove `OTP_MASTER_CODE` from the environment.** | While set, **any active customer signs in with that one fixed code**. Unset or empty disables it completely; the API prints a banner at boot for as long as it is armed. **REL-01 (Critical).** |
|
||
| **3** | **Set real `JWT_SECRET`.** | Without it the API falls back to a well-known development string, so anyone can forge an admin token. |
|
||
| **4** | **Configure the SMS credentials** (`SMS_TREZ_USERNAME`, `SMS_TREZ_PASSWORD`, `SMS_TREZ_SENDER_LINE`). | Until then **no customer can receive a real login code** — messages are written to the log instead of sent. The server still boots (D20). **REL-02.** |
|
||
|
||
After changing any of these: `docker compose up -d --build` and confirm the
|
||
`OTP_MASTER_CODE` banner is **gone** from `docker compose logs api`.
|
||
|
||
### B. Credentials to rotate — the reference leaked them
|
||
|
||
These are committed in plaintext in `henza_server` and must be assumed
|
||
compromised by anyone who has ever had repo access. None were copied into this
|
||
repository, but they are **the same live accounts**.
|
||
|
||
- **SMS panel** username + password + sender line (`app/Models/Sms.php:73-77`) — **SEC-12 (Critical)**
|
||
- PayPing bearer token, Tahesab bearer token + IP allowlist, wallet hash pepper — **SEC-06**
|
||
|
||
### C. Parity work not finished
|
||
|
||
Phase 1 stopped after the Persons section when delivery mode began (D19). The
|
||
**Phase 1 gate still stands** as the next milestone.
|
||
|
||
**Admin — not yet audited field-by-field.** Each has had only the breakage scan
|
||
(`scan-admin-b2b.cjs`, 34 endpoints, 0 broken), not a parity audit:
|
||
|
||
- **Financial documents** (اسناد) — the money path; highest priority of the three
|
||
- **Users & permissions** (کاربران و دسترسیها) — includes **PERM-01**, still
|
||
open: the database holds **178** distinct permission ids against the UI's
|
||
**174**. Unresolved because fixing it means **deleting rows**, which needs
|
||
your approval.
|
||
- **Reports** (گزارشها)
|
||
- Also untouched: products/categories/variants, SMS templates, promotions,
|
||
cities, branches, workshops.
|
||
|
||
**Apps — known gaps.**
|
||
|
||
| ID | What |
|
||
|---|---|
|
||
| **MON-08** | `wagePercent`, `discountPercent` and `flatRialCharge` are taken from the **client** on order creation, so a caller can set their own making charge. `rashWeightGrams` is correctly read from the variant. The reference is *worse* (it trusts client `rash_weight` too), so this is already a partial improvement — but it is still a money path trusting client input. **Fix this first in Phase 2.** |
|
||
| **STUB-05** | Admin SMS recipients are hardcoded mobile numbers scattered across six places in the reference. Port as `SMS_ADMIN_RECIPIENTS` (comma-separated), the way `PROFILE_ADMIN_MOBILES` already works — not by copying the literals. Staff changes should not need a deploy. |
|
||
| **STUB-04** | Live gold price is **pull-on-mount only** — no socket, no polling. `quotedAt` renders as a Jalali **date**, so a stale rate looks current. |
|
||
| **STUB-03** | All list filtering and paging is **client-side** over one flat `GET /catalog/products`; no server `page`/`limit`. |
|
||
| **Analytics** | The reference PWA calls `useCustomAnalytics` on every route change (`henza_pwa/src/router/index.ts:249`); neither target app has any analytics. Deliberate — it needs a provider decision from you, not a port. |
|
||
|
||
**`docs/parity/ISSUES.md`** holds the full list with evidence and file
|
||
references — currently **40+ entries**, including everything above plus the
|
||
reference bugs deliberately not ported (SEC-14, SEC-16, APP-03, and the two
|
||
D21 items). Phase 3 is the pass over that file.
|
||
|
||
---
|
||
|
||
## 1. Environment variables
|
||
|
||
Names only — no values here, and none are committed. The full annotated list is
|
||
in `.env.example`; `docker-compose.yml` passes each one through to the API
|
||
container.
|
||
|
||
### Required
|
||
|
||
| Variable | Purpose |
|
||
|---|---|
|
||
| `DATABASE_URL` | PostgreSQL connection string. Inside Docker the host is `db`. |
|
||
| `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB` | Credentials for the bundled `db` service. |
|
||
| `JWT_SECRET` | Signs **admin** tokens. Without it the API falls back to a well-known development string — set it. |
|
||
| `JWT_CUSTOMER_SECRET` | Signs **customer and sales-expert app** tokens. Must be its own value — see the warning at the top of this file. Unset, it falls back to `JWT_SECRET` and the two token realms merge. |
|
||
|
||
### Optional
|
||
|
||
| Variable | Default | Purpose |
|
||
|---|---|---|
|
||
| `PORT` / `API_PORT` | `3000` | API listen port. |
|
||
| `JWT_EXPIRES_IN` | `12h` | Admin token lifetime. |
|
||
| `JWT_CUSTOMER_EXPIRES_IN` | `30d` | Customer token lifetime. |
|
||
| `UPLOAD_DIR` | `<api>/storage/uploads` | Where uploaded files are written. |
|
||
| `ADMIN_PANEL_PORT`, `USER_APP_PORT`, `USER_APP_V2_PORT` | 5180–5182 | Dev servers only. |
|
||
| `RUN_MIGRATIONS` | `true` | Set `false` to skip the boot-time migration run. |
|
||
| `RUN_SEED` | `true` | Set `false` to skip seeding. |
|
||
| `PROFILE_ADMIN_MOBILES` | *(blank)* | Comma-separated admin mobiles told when a customer completes their profile. The reference hardcoded four numbers; blank means nobody is notified and the upload still succeeds. |
|
||
|
||
### SMS — all optional; blank means "log, do not send"
|
||
|
||
`SMS_PROVIDER` (`trez` default, or `none`), `SMS_TREZ_URL`,
|
||
`SMS_TREZ_USERNAME`, `SMS_TREZ_PASSWORD`, `SMS_TREZ_SENDER_LINE`.
|
||
|
||
With these unset the server **still starts normally** and logs a warning; a
|
||
failed or skipped SMS never blocks an operation, including customer creation.
|
||
|
||
### ⚠ `OTP_MASTER_CODE` — delivery only
|
||
|
||
A fixed OTP accepted for **any active customer**, alongside the real code.
|
||
|
||
- Read only from the environment; never hardcoded.
|
||
- Unset or empty → completely disabled.
|
||
- **Must be exactly 4 digits.** Both apps' login box is four single-character
|
||
inputs and only submits at four, so a longer code works over the API but
|
||
cannot be typed into either app.
|
||
- Every login through it is logged with the customer's mobile.
|
||
- The real OTP keeps working unchanged while it is armed.
|
||
|
||
---
|
||
|
||
## 2. Running it
|
||
|
||
### Development
|
||
|
||
```bash
|
||
pnpm install
|
||
pnpm dev # all apps via turbo
|
||
```
|
||
|
||
Ports come from the repo-root `.env` (single source of truth):
|
||
API 3000, admin-panel 5180, user-app 5181, user-app-v2 5182.
|
||
|
||
### Build
|
||
|
||
```bash
|
||
pnpm build # all workspaces
|
||
pnpm build:release # the production/Docker build
|
||
```
|
||
|
||
### Deploy (Docker)
|
||
|
||
```bash
|
||
pnpm build:release # ⚠ REQUIRED FIRST — see below
|
||
docker compose up -d --build
|
||
```
|
||
|
||
> **⚠ `pnpm build:release` must be run before `docker compose build`.**
|
||
> The API image is `COPY release/ ./` — it ships a bundle built on the
|
||
> developer machine, not one built inside the image. So `docker compose build`
|
||
> will happily produce an image from a **stale** `release/app.js` and report
|
||
> success. During this delivery's compose run that bundle was four days old and
|
||
> the container was missing every route added since. Tracked as **DEPLOY-03**.
|
||
|
||
Services: `db` (Postgres 15), `api` (port 3000), `admin-panel`, `user-app`,
|
||
`user-app-v2` (each nginx on port 80). The apps expose no host ports —
|
||
Dokploy/Traefik routes to them by domain. Uploads persist in the `api_uploads`
|
||
volume; without it every previously uploaded image 404s after a redeploy.
|
||
|
||
---
|
||
|
||
## 3. How migrations run
|
||
|
||
**Automatically, inside the API container, at boot.** No manual step.
|
||
|
||
`src/prisma/bootstrap.ts` calls `runMigrations()` before Nest starts. That
|
||
runner (`src/prisma/migrate.ts`) applies the `.sql` files from
|
||
`prisma/migrations` and records each one in its own tracking table,
|
||
**`_goldbank_migrations`**, so every file runs exactly once.
|
||
|
||
It exists because **the deploy image is copy-only and ships no Prisma CLI**, so
|
||
`prisma migrate deploy` is not available there. This is why the database has two
|
||
migration ledgers — `_prisma_migrations` (written by the Prisma CLI in
|
||
development) and `_goldbank_migrations` (written by the runtime runner). Both
|
||
are intentional. `_goldbank_migrations` is declared in `schema.prisma` with
|
||
`@@ignore` so drift checks do not propose dropping it.
|
||
|
||
**Never** run `prisma migrate reset`, and never edit either table by hand.
|
||
In development, migrations are created with `prisma migrate dev`; in production
|
||
the container runner applies them.
|
||
|
||
Seeding runs once after migrations, gated on a marker row, and is skipped when
|
||
no seed script is present.
|
||
|
||
---
|
||
|
||
## 4. What was verified on this branch
|
||
|
||
All green on `release/delivery`:
|
||
|
||
| Suite | Result |
|
||
|---|---|
|
||
| `pnpm build` (5 workspaces) | ✅ all pass |
|
||
| jest (api unit) | **54/54** |
|
||
| `smoke-admin.cjs` — admin flows | **43/43** |
|
||
| `smoke-apps.cjs` — customer flows, both apps | **43/43** |
|
||
| `scan-admin-b2b.cjs` — every B2B read endpoint | **34 ok, 0 broken** |
|
||
| `smoke-admin-b2b-writes.cjs` — order + document writes | **25/25** |
|
||
| `auth-smoke.sh` — permissions/401/403 | **32/32** |
|
||
| `test-sales-expert-roundtrip.cjs` | **33/33** |
|
||
| `test-sales-expert-app-login.cjs` — expert app login (D21) | **36/36** |
|
||
| `test-complete-profile.cjs` — «تکمیل پروفایل» | **38/38** |
|
||
| `test-report.cjs` — «گزارش» | **40/40** |
|
||
| `test-compose-e2e.cjs` — **the whole flow inside Docker Compose** | **32/32** |
|
||
| `test-name-trigger.cjs` | **9/9** |
|
||
| typecheck (api, admin-panel, user-app, user-app-v2) | ✅ clean |
|
||
|
||
### Verified inside Docker Compose, not just locally
|
||
|
||
The whole delivery flow was run against the **built container**, which is what
|
||
actually ships: `test-compose-e2e.cjs` signs a **sales expert** and an
|
||
**ordinary customer** into the app with the master code, has **both place an
|
||
order**, and confirms **both orders appear in the admin panel** and open there.
|
||
It also re-checks the three token boundaries in the deployed image. **32/32.**
|
||
The three page suites were re-run against the container too — complete-profile
|
||
38/38, report 40/40, expert login 36/36.
|
||
|
||
That run found two deployment problems that no local test could have caught:
|
||
|
||
- **DEPLOY-02 (fixed):** admin login returned **500 for every user in Docker**.
|
||
Compose passes optional variables as `${X:-}`, so an unconfigured one arrives
|
||
as `""`, not `undefined`; the code used `?? 'default'`, which only falls back
|
||
on `undefined`, so the empty string reached `jsonwebtoken` and it threw on
|
||
every sign. Fixed with `src/common/env.ts`, applied to every auth, port,
|
||
upload and SMS read, and pinned by a test that reproduces the original throw.
|
||
- **DEPLOY-03 (process):** the API image ships a **pre-built** bundle, so
|
||
`docker compose build` can silently produce an image from stale code. See the
|
||
warning in the deploy section above.
|
||
|
||
Flows walked end to end: admin login, customer creation, sales-expert
|
||
create/edit, product and variant create/edit, order list/detail/status change,
|
||
wholesale order placement from the admin panel, cash document creation +
|
||
confirm + un-confirm; and in **both** customer apps: OTP login with the master
|
||
code, product browsing, cart, order placement, order history.
|
||
|
||
Money paths were checked against the reference's own formulas, not just for a
|
||
200: making charge `rash × (1 + (wage − discount)/100)` reproduced exactly, and
|
||
the cash-document conversion `price / (price_per_gram / 4.331802)` moved the
|
||
customer's gold debt by the expected amount and restored it exactly on
|
||
un-confirm.
|
||
|
||
Every test script deletes only the rows it created; the write test also asserts
|
||
the customer's balances end exactly as they started.
|
||
|
||
---
|
||
|
||
## 5. Known issues — not done tonight
|
||
|
||
Nothing here blocks the delivery. Full detail in `docs/parity/ISSUES.md`.
|
||
|
||
### Missing app pages
|
||
|
||
**None.** Every page the reference PWA has now exists in both apps.
|
||
|
||
|
||
### Delivered tonight
|
||
|
||
| Feature | State |
|
||
|---|---|
|
||
| **Sales-expert app login (D21)** | ✅ Done. An expert signs into both apps with their AdminUser account and the ordinary OTP flow; the master code works for them too. They get the whole app — browsing, cart, ordering, order history — plus their own two screens. |
|
||
| **«سفارش مشتریان» (ChildOrder)** | ✅ Done in both apps. Their referred customers' orders at «تحویل مسئول فروش», searchable, infinite-scrolled. |
|
||
| **«مشتریان من» (customerChoice)** | ✅ Done in both apps. Their referred customers, searchable, with a read-only detail sheet. |
|
||
| **APP-02 — admin home slider in v2** | ✅ Fixed. v2 now prefers admin-configured slides and keeps its category-derived hero as the fallback. |
|
||
| **«تکمیل پروفایل» (complete_profile)** | ✅ Done in both apps. Three document uploads (جواز کسب، سردر مغازه، کارت ملی), then the profile is marked verified. Reached from the profile screen, and only while the documents are missing — exactly the reference's condition. |
|
||
| **«گزارش» (report + report/detail)** | ✅ Done in both apps. Two tabs («کلیات حساب» / «جزئیات حساب»), a running gold and rial balance per document, and a per-document detail sheet. Built on the **internal ledger** (owner's option الف) — no connection to the external accounting system the reference proxied. See D24. |
|
||
|
||
An expert's own order lands on their **own trading account**, with the making
|
||
charge from `AdminUser.wagePercent` and the credit limit from
|
||
`SalesExpertProfile.gramLevel` — the same two numbers the reference read off
|
||
the agent's own customer row. See D21.
|
||
|
||
**Two reference behaviours were deliberately not ported**, both money/session
|
||
risks and both recorded in D21:
|
||
1. `myChild` minted an `api_token` per listed customer so the agent could act
|
||
**as** them — a session-hijack primitive.
|
||
2. The child-order edit drawer (`PUT /order/child/{id}`) recalculated an
|
||
order's weights and prices from client-sent values. The card opens a
|
||
read-only summary instead.
|
||
|
||
### Behavioural gaps
|
||
|
||
| ID | What |
|
||
|---|---|
|
||
| **APP-02** | `user-app-v2` has no `getHomeSlider`, so slides configured in the admin panel appear in **user-app only**; v2 always derives its hero from categories. Not fixed because v2's home is its own design and rewiring it during a delivery risks the page being demonstrated. |
|
||
| **PERS-02** | The reference's cities are a province → city **tree** (provinces not selectable); the target flattened this to a `province` string and a flat select on all three Persons forms. The city itself is still captured correctly. |
|
||
| **PERS-03** | The reference collects a Jalali **birth date** on the customer forms; the target has no column for it. |
|
||
| **PERS-01** | The reference makes a customer's **mobile immutable on edit**; the target allows editing it. Owner ruled this NOT ported — the target keeps its current behaviour. |
|
||
| **PERF-01** | `listReagents()` runs one aggregate per row (N+1) for the per-agent sales total. Harmless at the current agent count. |
|
||
|
||
### Parity work not reached
|
||
|
||
Phase 1 was not completed: the field-by-field audit stopped after the Persons
|
||
section, and products/categories/variants, financial documents, users and
|
||
permissions, SMS templates, promotions, cities, branches, workshops and reports
|
||
have had only the breakage scan above, not a full parity audit. The
|
||
**Phase 1 gate was lifted for tonight only** (D19) and still stands as the next
|
||
milestone.
|
||
|
||
`PERM-01` (the database holds 178 distinct permission ids against the UI's 174)
|
||
is still awaiting a decision, because resolving it means deleting rows.
|
||
|
||
---
|
||
|
||
## 6. Decisions that shaped this build
|
||
|
||
Recorded in `docs/parity/DECISIONS.md`:
|
||
|
||
- **D13** — the retail storefront (`single*`, wallet, comments, `customer.php`,
|
||
`site.php`) is **preserved but out of scope**. Nothing there was changed.
|
||
- **D19** — delivery mode: implement the main gaps, do not stop to ask, choose
|
||
the lowest-risk option, change nothing protected.
|
||
- **D20** — SMS can never block a boot or an operation; `OTP_MASTER_CODE` for
|
||
the delivery.
|
||
- **D11 is cancelled** — superseded by D13.
|