Ports "تکمیل پروفایل" end to end (D22). API - POST customer/me/complete-profile — three required document ids, the customer taken from the token, verification step set to the reference's LIVE value (approved outright; its `= 1` line is commented out at :83 in favour of `= 2` at :85). Both SMS messages byte-identical and non-blocking per D20. - POST customer/me/documents — the reference let the app upload through the same file/create the admin used, inside its auth:api-customer group (routes/api.php:95); the target's copy is admin-guarded, so the page could not have uploaded at all. Reuses AdminUploadsService unchanged, so both paths share one size limit, MIME whitelist and naming scheme (UPL-01). - PROFILE_ADMIN_MOBILES replaces the reference's four hardcoded personal numbers; blank means nobody is notified and the upload still succeeds. Three reference behaviours deliberately NOT ported, all in ISSUES.md: - SEC-14: `$customer->update($request->all())` mass-assigned the whole request body, letting the app write its own credit limit, making charge, gold debt, active flag or mobile. Replaced with a three-field whitelist. - SEC-15: four real personal mobiles hardcoded in the source. - APP-03: refresh()'s unterminated setInterval page reload. Apps (both, template+script byte-identical, per-app styling only) - CompleteProfileView + SingleImageUploader - route /complete_profile, redirecting home once the documents are in, as the reference's guard did (router/index.ts:266) - the entry point on the profile screen, shown only while they are missing (profile.vue:96-102) Also (D23): JWT_CUSTOMER_SECRET now warns loudly at boot when unset. It falls back to JWT_SECRET, which signs app and admin tokens with the same key and leaves the `typ` check as the only barrier — and an expert's app token carries their AdminUser id in `sub`. Warn rather than refuse to boot (D19, lowest risk); DELIVERY.md lists it as the first pre-release step (REL-03). Verified: build 5/5 · jest 45/45 · complete-profile 38/38 (new) · expert login 36/36 · apps smoke 43/43 · admin smoke 43/43 · B2B scan 34 ok/0 broken · typecheck clean on all four apps. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
247 lines
12 KiB
Markdown
247 lines
12 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.
|
||
|
||
---
|
||
|
||
## ⚠ Before the real release — do these three things
|
||
|
||
1. **Set `JWT_CUSTOMER_SECRET` to its own random value.** This one is a real
|
||
security boundary, not a default. If it is unset, the API signs customer and
|
||
sales-expert **app** tokens with `JWT_SECRET` — *the same key admin tokens
|
||
use* — so the two token realms stop being cryptographically separate. The
|
||
only thing still keeping an app token off the admin routes is the `typ`
|
||
check in `jwt.strategy.ts`, and a sales expert's app token carries their
|
||
**AdminUser id** in `sub`. Two independent defences become one. The API
|
||
prints a loud banner at boot while it is missing. Tracked as **REL-03
|
||
(Critical)**.
|
||
2. **Remove `OTP_MASTER_CODE` from the environment.** While it is set, **any
|
||
active customer can sign in with that one fixed code**. Unset or empty
|
||
disables it completely, and the API prints a warning banner at boot for as
|
||
long as it is armed. Tracked as **REL-01 (Critical)** in
|
||
`docs/parity/ISSUES.md`.
|
||
3. **Configure the SMS credentials** (`SMS_TREZ_*`). Until then no customer can
|
||
receive a real login code — messages are written to the log instead of sent.
|
||
Tracked as **REL-02**.
|
||
|
||
---
|
||
|
||
## 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
|
||
docker compose up -d --build
|
||
```
|
||
|
||
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) | **45/45** |
|
||
| `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-name-trigger.cjs` | **9/9** |
|
||
| typecheck (api, admin-panel, user-app, user-app-v2) | ✅ clean |
|
||
|
||
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 (not started)
|
||
|
||
Present in the reference PWA, absent in both apps. Listed in the owner's own
|
||
priority order:
|
||
|
||
| Page | Reference | Notes |
|
||
|---|---|---|
|
||
| `report` + `report/detail` | `henza_pwa/src/views/report/` | The accounting statement — a store plus two components (`Accounting.vue`, `AccountingDetail.vue`). The largest of the four. |
|
||
|
||
### 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. |
|
||
|
||
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.
|