Restores the sales-expert fields that were lost when a sales expert became
an AdminUser instead of a type=1 Customer row.
New SalesExpertProfile holds cityId, gramLevel, phone and nationalCode. A
separate table rather than columns on AdminUser, because these mean
something only for role = SALES_EXPERT. gramLevel is DECIMAL(14,4), matched
to Customer.goldCreditLimitGrams so the same credit limit has identical
precision on both tables. phone and nationalCode copy the reference exactly:
nullable, NOT unique, no format rule — createCustomer validates only mobile
and percent, and both fields are required:false on the reference form.
Inventing a uniqueness constraint would reject data the reference accepts.
reagent_id is deliberately NOT ported (D17). Every use of it in the
reference reads a CUSTOMER's reagent_id, which the target already models as
Customer.referredByAgentId. On a sales expert's own form it was a side
effect of the shared customers form: filling it removed that expert from
the selectable-agent list, because queryReagent filters on
whereNull('reagent_id'). The target's equivalent filters on role and active
only, so no agent can be dropped — strictly better than the reference.
AdminUser gains firstName/lastName with the same additive migration,
backfill and sync trigger as Customer, for all admins rather than only
sales experts. This closes D0 requirement 7, and every remaining
`fname: fullName, lname: ''` was fixed — six further sites across
admin-users, admin-comments, admin-documents, admin-orders and
admin-preorders.
listReagents now returns real city_id, city, gram_level, phone and ncode,
and left-joins so an expert without a profile still lists with empty cells
rather than vanishing. The two grid filters map through the relation. The
service upserts a profile on create and on a role change to SALES_EXPERT,
and the migration backfilled one for every existing expert.
ReagentCustomer.vue gains the four fields plus the city -> commission
autofill, ported verbatim including the reference's falsy test so a city
percent of 0 also falls through to the aux lookup (replicated, not fixed).
Tests: new sales-expert round trip 33/33, covering create with every field,
an edit form with each field populated separately, a byte-identical no-op
save, single-field edits, both grid filters with negative cases, the
left-join case and the referrer dropdown. «محمد علی» + «احمدی» survives
intact. Plus jest 32/32, auth-smoke 53/53, customer trigger 9/9, typecheck
clean on api and admin-panel. Migration dry-run twice in a rolled-back
transaction before applying; drift is zero.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
543 lines
27 KiB
Markdown
543 lines
27 KiB
Markdown
# CHANGELOG — parity changes
|
||
|
||
Every change made to `gold-banking-monorepo` during the parity project, with the
|
||
reason and the reference path that justifies it.
|
||
|
||
Format:
|
||
|
||
```
|
||
## <date> — <phase> — <group title>
|
||
- **What:** <the change>
|
||
- **Why:** <the parity gap it closes>
|
||
- **Reference:** <henza_* path:line>
|
||
- **Target:** <gold-banking-monorepo path:line>
|
||
- **Matrix row:** <id in 01-ADMIN-MATRIX.md / 02-APPS-MATRIX.md>
|
||
- **Commit:** <sha>
|
||
```
|
||
|
||
---
|
||
|
||
## 2026-09-21 — Phase 0 — Setup
|
||
|
||
- **What:** Created branch `parity/phase-0` and `docs/parity/` scaffolding
|
||
(`PROGRESS.md`, `ISSUES.md`, `CHANGELOG.md`).
|
||
- **Why:** Persistent state required by `CLAUDE.md` §6.
|
||
- **Reference:** n/a
|
||
- **Target:** `docs/parity/`
|
||
- **Matrix row:** n/a
|
||
|
||
_No source code changed in Phase 0 (read-only phase)._
|
||
|
||
---
|
||
|
||
## 2026-09-22 — Security hotfix, stage 1 — close the unauthenticated write paths
|
||
|
||
Branch `parity/security-hotfix`. Authorised by the user's rulings D7, T1–T3.
|
||
|
||
### 1. Ledger routes are admin-only (SEC-09)
|
||
|
||
- **What:** removed the controller-level `@Public()` from `AccountingController`
|
||
and gave each route a rid: `wallet.list` (balance), `wallet.charge`
|
||
(reconcile), `document.create` (both repayments), `document.list.confirm`
|
||
(confirm entry).
|
||
- **Why:** four of the five routes WRITE to the ledger and were reachable with no
|
||
token at all. Faithful to the reference, whose entire admin route file is
|
||
unauthenticated, but contrary to confirmed decision D1.
|
||
- **Reference:** `henza_server/routes/admin.php:12,272` (the `auth:api` wrapper is
|
||
commented out at both ends)
|
||
- **Target:** `apps/api/src/modules/accounting/accounting.controller.ts`
|
||
- **Breakage check:** `api.getBalance()` is the only app-side consumer and it has
|
||
**zero call sites** in either user app (dead code, already logged as DEAD-01/02).
|
||
|
||
### 2. Order status change is admin-only (SEC-10)
|
||
|
||
- **What:** moved `@Public()` from the class to the two customer-facing handlers,
|
||
leaving `PATCH orders/:id/status` behind `order.list.sts.submit`.
|
||
- **Why:** it let an anonymous caller move any order's status **and** attribute
|
||
the change to any admin through the DTO's `actorAdminUserId`.
|
||
- **Reference:** an admin operation; the admin panel posts to `admin/orders/status`
|
||
(`apps/admin-panel/src/api/endpoints.ts:42,48`), so this route had no caller.
|
||
- **Target:** `apps/api/src/modules/orders/orders.controller.ts`
|
||
- **Still public:** `POST /orders` and `GET /orders/:id`, because the target has
|
||
no customer session yet — closed in stage 2.
|
||
|
||
### 3. `GET /customers` no longer leaks the customer book (SEC-08)
|
||
|
||
- **What:** added `LOGIN_LOOKUP_SELECT` (`id`, `mobile`, `isActive`,
|
||
`isArchived`) and pointed `list()` at it. Removed `fullName`, `nationalCode`,
|
||
`goldDebtGrams`, `fiatDebtRial`, `goldCreditLimitGrams`, `verificationStep`,
|
||
`cityId`, `referredByAgentId`, `nationalIdImageUrl`, `businessLicenseUrl`,
|
||
`shopImageUrl`, `createdAt`, `updatedAt` from that response.
|
||
- **Why:** the route is unauthenticated because both apps match the phone number
|
||
in the browser, so it shipped every customer's national ID number, debts,
|
||
credit limit and identity-document URLs to any visitor.
|
||
- **Reference:** login was `POST userAuth` → SMS → `POST userVerfiy`, returning
|
||
exactly one customer after the code matched
|
||
(`henza_server/app/Http/Controllers/Api/ApiMobileController.php:39-78`).
|
||
- **Target:** `apps/api/src/modules/customers/customers.service.ts`,
|
||
plus `CustomerSummary` narrowed in **both** apps' `src/api/client.ts`
|
||
(parity rule: the same logic change in both).
|
||
- **Route survives** until stage 2 replaces the client-side phone match with OTP.
|
||
|
||
### 4. Test script
|
||
|
||
- **What:** added `apps/api/scripts/auth-smoke.sh` — asserts 401 without a token,
|
||
403 with a low-privilege token, and pass-through for a super admin, on every
|
||
closed route; plus that the public routes still answer 200 and that
|
||
`GET /customers` exposes only the four login fields.
|
||
- **Result:** **23 passed, 0 failed** against a live server and the dev seed.
|
||
|
||
### Not changed
|
||
|
||
`/uploads` — per ruling T2, uploaded files including identity documents stay
|
||
public. The filename check requested alongside it passed with no changes needed
|
||
(md5 of time + name + UUID; no directory listing).
|
||
|
||
---
|
||
|
||
## 2026-09-22 — Security hotfix, stage 2 — real customer sessions
|
||
|
||
Branch `parity/security-hotfix`. Authorised by D6, D7, T1 and T3.
|
||
|
||
Stage 1 narrowed `GET /customers` but, as the user pointed out, did not close
|
||
the leak: the response still carried every customer **id**, and
|
||
`/customers/:id`, `/:id/orders` and `/:id/ledger-entries` were still public — so
|
||
two requests still reconstructed the whole book. Stage 2 removes the id-bearing
|
||
surface entirely.
|
||
|
||
### 1. Customer JWT, separate from the admin one (T1)
|
||
|
||
- **New:** `src/modules/customer-auth/` — `CustomerJwtStrategy` (passport
|
||
strategy `'customer-jwt'`), `CustomerJwtGuard`, `@CustomerAuth()`,
|
||
`@CurrentCustomer()`.
|
||
- Signed with **`JWT_CUSTOMER_SECRET`**, separate from `JWT_SECRET`, and every
|
||
token carries **`typ: 'customer'`** which the strategy verifies. Either check
|
||
alone would be enough; both are present deliberately.
|
||
- Customer state is re-read from the DB on every request, so deactivating or
|
||
archiving a customer takes effect at once rather than at token expiry.
|
||
- **Reference:** the same split existed there — `auth:api` for admins,
|
||
`auth('api-customer')` for the app.
|
||
|
||
### 2. Real OTP (D6)
|
||
|
||
- **New table `CustomerOtp`** (migration `20260922000000_customer_otp`,
|
||
additive: one new table, nothing existing touched).
|
||
- `POST /api/customer/auth/request-otp` ← `ApiMobileController::userAuth` (:39-49)
|
||
- `POST /api/customer/auth/verify-otp` ← `ApiMobileController::userVerfiy` (:51-64)
|
||
- **Parity behaviour:** the 4-digit `rand(1000,9999)` range, the
|
||
active-customer filter, `Utility::faTOen()` digit normalisation on both the
|
||
number and the code, and rejection on mismatch.
|
||
- **Deliberate improvements over the reference** (user-approved, recorded in
|
||
DECISIONS.md so the matrix does not flag them later):
|
||
- the code is stored as a **bcrypt hash**, never plain text;
|
||
- it **expires** after 2 minutes (the reference's never did);
|
||
- **5 attempts** per challenge, then lockout (the reference had no limit);
|
||
- **single use** — a consumed code cannot be replayed;
|
||
- **send throttle**: 60s cooldown per number, 5 per number and 20 per IP per
|
||
10 minutes, counted off the table so it survives restarts;
|
||
- **no enumeration** — an unregistered number returns the same `{sent:true}`.
|
||
The reference's `firstOrFail()` leaked a 404 for unknown numbers.
|
||
- No SMS gateway exists in this project (STUB-02), so the code is logged in
|
||
non-production only and the message is persisted as an `SmsMessage` row. The
|
||
reference's message text is kept verbatim for when a gateway lands.
|
||
|
||
### 3. The id-bearing customer routes are gone
|
||
|
||
- **Deleted** `customers.controller.ts` — with it `GET /customers`,
|
||
`/customers/:id`, `/:id/orders`, `/:id/ledger-entries`.
|
||
- **Replaced by** `/api/customer/me`, `/me/orders`, `/me/ledger-entries`, which
|
||
take the id **from the token**. There is no longer any route that accepts a
|
||
customer id, so the IDOR is closed structurally rather than by a check.
|
||
- `CustomersService` stays and is reached only through those routes.
|
||
- The admin panel was unaffected — it uses `admin/customers-omde/*`.
|
||
|
||
### 4. Both apps migrated together (parity rule)
|
||
|
||
- `stores/session.ts`: `token` is now the credential and `isLoggedIn` is
|
||
token-based. `customerId` is kept for display only and is no longer an
|
||
identity claim.
|
||
- `api/client.ts`: sends `Authorization: Bearer`, throws `UnauthorizedError` on
|
||
401 and calls the handler registered in `main.ts`, which clears the session
|
||
and routes to login so stale personal data cannot stay on screen.
|
||
- `LoginView.vue`: real two-step OTP. Each app keeps its own markup and styling;
|
||
only the logic changed, and it is identical in both.
|
||
- The four account screens now call `getMe()` / `getMyOrders()` /
|
||
`getMyLedgerEntries()`; router guards test `isLoggedIn`.
|
||
|
||
### Tests
|
||
|
||
- `apps/api/scripts/auth-smoke.sh` extended with the stage-2 assertions:
|
||
**46 passed, 0 failed** against a live server — old routes 404, customer
|
||
routes 401 without a token, wrong/reused code 401, correct code 200, throttle
|
||
429, no enumeration, customer token rejected on admin routes and admin token
|
||
rejected on customer routes, no id-bearing surface, and `/customer/me`
|
||
returning the token's own customer.
|
||
- **jest installed** (dev-only, user-approved) with `jest.config.cjs`, so
|
||
`pnpm test` works for the first time: **7 passed** covering `faToEn`.
|
||
`faToEn` moved to its own `digits.ts` so it can be tested without booting Nest.
|
||
- Typecheck clean on api, user-app and user-app-v2; both apps build.
|
||
|
||
---
|
||
|
||
## 2026-09-22 — Security hotfix, stage 3 — SEC-02 (orders) + OTP audit
|
||
|
||
Branch `parity/security-hotfix`. The user declined to defer SEC-02 to Phase 2:
|
||
a public `POST /orders` taking `customerId` from the body let anyone place an
|
||
order on any customer's account, and an order lands on that customer's **gold
|
||
debt and ledger**. That is a money hole, so it is closed here.
|
||
|
||
### 1. `POST /orders` is customer-authenticated
|
||
|
||
- **What:** the route is behind `@CustomerAuth()`. `OrdersService.createOrder`
|
||
now takes `customerId` as a **separate argument**, supplied by the controller
|
||
from the token. The body's `customerId` is ignored entirely; a mismatch is
|
||
logged (`OrdersService` warn) and the token always wins.
|
||
- `CreateOrderDto.customerId` and the shared `CreateOrderDto` are **optional**,
|
||
so both apps keep validating while they still send it. Removed in Phase 2.
|
||
- **Reference:** `ApiMobileController::setOrder` read `auth()->id()`
|
||
(henza_server:533) behind the `api-customer` guard — this restores that.
|
||
- **Admin path unaffected:** `AdminOrdersService` places orders *for* a selected
|
||
customer and passes that id explicitly; its route is admin-guarded.
|
||
|
||
### 2. `GET /orders/:id` is scoped to the owner
|
||
|
||
- `getOwnedById(orderId, customerId)` matches on both, and a miss is **404, not
|
||
403** — a 403 would confirm the id exists, the same enumeration leak the old
|
||
`/customers/:id` routes had. A nonexistent id and someone else's id are
|
||
indistinguishable.
|
||
- Operators use the new `GET /orders/admin/:id` behind rid `order.list`.
|
||
Declared **before** `:id` because Nest matches in declaration order.
|
||
|
||
### 3. Both apps
|
||
|
||
- `CartView.vue` in **both** apps now gates on `session.isLoggedIn` (the token)
|
||
rather than on a remembered `customerId`, which is no longer a credential.
|
||
|
||
### 4. OTP audit (user request 3)
|
||
|
||
- **No master code, no bypass, no hardcoded code** anywhere in the API or either
|
||
app — verified by grep for `master.?code|bypass|backdoor|1111|0000|1234|skipOtp|DEV_OTP|TEST_OTP`.
|
||
Exactly one code path issues a token, and only after `bcrypt.compare`.
|
||
- **SMS provider: none.** No gateway is wired up (STUB-02). In development the
|
||
code is logged once via `Logger`, guarded by `NODE_ENV !== 'production'`.
|
||
- **Fixed while auditing:** `deliver()` persisted the real message text — with
|
||
the code in it — into `SmsMessage.body`, which the admin SMS grid both lists
|
||
and full-text searches (`admin-misc.service.ts:135-136`). Any operator with
|
||
the `sms` rid could read live OTP codes, defeating the point of hashing
|
||
`codeHash`. The persisted row is now a redacted audit line; the real text is
|
||
built for the future gateway and not stored. The 9 test rows created during
|
||
this session were deleted.
|
||
|
||
### Tests
|
||
|
||
`apps/api/scripts/auth-smoke.sh` extended again — **53 passed, 0 failed**:
|
||
order routes 401 without a token, a spoofed body `customerId` ignored in favour
|
||
of the token, own order readable, unknown/foreign order id 404, and the admin
|
||
order route rejecting a customer token while admitting an admin one.
|
||
Typecheck clean on api, user-app, user-app-v2 and shared-types.
|
||
|
||
---
|
||
|
||
## 2026-09-22 — migrate resolve, D13 scope, and the D0/percent migration set
|
||
|
||
### Standing rule adopted (owner, 2026-09-22)
|
||
|
||
1. **Migrations run only through the standard Prisma workflow** — `migrate dev`
|
||
in development. **`prisma db execute` is no longer used to apply schema
|
||
changes.** (It was used once, in the security hotfix, to create the
|
||
`CustomerOtp` table; that is what the resolve below repairs.)
|
||
2. **Every data deletion is recorded here** — including throwaway rows this
|
||
session creates itself.
|
||
|
||
### Data deletions (rule 2, applied retroactively to this session)
|
||
|
||
| When | What | How many | Why |
|
||
|---|---|---|---|
|
||
| Security hotfix stage 3 | `SmsMessage` rows written by the OTP flow during testing, whose `body` still held the live code before the SEC-11 fix | 9 | They contained real OTP codes in a grid any `sms`-rid operator can read. Test data only; no customer-facing message was ever sent (no gateway is wired). |
|
||
|
||
No other data has been deleted in this project.
|
||
|
||
### Ledger repair — approved and executed
|
||
|
||
```
|
||
npx prisma migrate resolve --applied 20260922000000_customer_otp
|
||
→ Migration 20260922000000_customer_otp marked as applied.
|
||
npx prisma migrate status
|
||
→ 9 migrations found. Database schema is up to date!
|
||
```
|
||
|
||
The `CustomerOtp` table already existed (created with `db execute` in stage 2);
|
||
this wrote the missing `_prisma_migrations` row **without running any SQL**, so
|
||
`migrate deploy` will no longer try to re-create the table. No schema change.
|
||
|
||
### Documentation
|
||
|
||
- **D13 recorded** — the retail storefront (wallet, cashback,
|
||
`ConfigStatic['percent']`) is preserved but out of scope. `DECISIONS.md` §D13,
|
||
`PROTECTED.md` §H, and status `⏸️` plus RULE C1 added to the audit skill.
|
||
- **Endpoint count corrected: 412, not 369.** `routes/admin.php` has 151 live
|
||
explicit routes, not the 117 the Phase 0 inventory recorded (DOC-01). Of the
|
||
412, **109 are retail storefront**, 15 are `web.php` dev/ops, and **288 stay
|
||
in scope** (218 admin + 70 B2B app).
|
||
- **SMS provider report** — the reference uses the Trez panel
|
||
(`smspanel.trez.ir`, `Sms.php:69,97`) with **credentials committed to git**
|
||
(SEC-12, must be rotated). All 12 message templates catalogued, env var names
|
||
proposed. **Nothing implemented; awaiting approval.**
|
||
- **MON-08 answered** — `henza_pwa` **computes** `percent_pay`, `discount` and
|
||
`rial_pay`; the user types none of them (`cart.vue:170-179`,
|
||
`Mixins/product/index.ts:69-75`). So Phase 2 item #1 is to derive them
|
||
server-side and ignore the client's values, with a comparison test proving the
|
||
total is unchanged for an ordinary customer. `rash_weight` stays read from the
|
||
DB, recorded as a deliberate deviation.
|
||
|
||
### New, not yet applied — awaiting approval
|
||
|
||
| File | Purpose |
|
||
|---|---|
|
||
| `prisma/migrations/20260922120000_customer_name_parts/migration.sql` | D0 — adds `firstName`/`lastName`, backfills first-token/rest, adds a `customer_sync_full_name` trigger keeping `fullName` in sync in both directions, indexes both parts. Additive; nothing dropped. |
|
||
| `prisma/migrations/20260922120100_customer_making_charge_offset/migration.sql` | D9 — adds `makingChargeOffsetPercent DECIMAL(6,3) NOT NULL DEFAULT 0`. |
|
||
| `scripts/report-name-split.cjs` | Read-only review report for names with more than two tokens (D0 requirement 3), with `--csv` output. |
|
||
|
||
### Added and already passing
|
||
|
||
| File | Purpose |
|
||
|---|---|
|
||
| `src/modules/pricing/variant-price.ts` | The reference price formula ported verbatim from `ProductVariant.php:183-190`, with the customer offset as an explicit input defaulting to 0. |
|
||
| `src/modules/pricing/variant-price.spec.ts` | 11 tests against **hand calculations** derived from the PHP source, covering with/without the offset, the VAT-on-non-metal-portion-only rule, floor-to-1000, and the equivalence between the app folding the offset into `percent_pay` and the server deriving it. |
|
||
|
||
`npx jest` → **18 passed** (7 existing + 11 new), 2 suites.
|
||
|
||
---
|
||
|
||
## 2026-09-22 (later) — D0 + percent applied, SMS provider implemented
|
||
|
||
### Migrations applied
|
||
|
||
`migrate dev` could not run (interactive-only, and this shell is not a TTY).
|
||
Drift was checked read-only first: the only difference is the pre-existing
|
||
`_goldbank_migrations` table, which Prisma would DROP — **it was not dropped**
|
||
(DEAD-07). Applied with `migrate deploy`, which touches only the pending files:
|
||
|
||
```
|
||
Applying migration `20260922120000_customer_name_parts`
|
||
Applying migration `20260922120100_customer_making_charge_offset`
|
||
All migrations have been successfully applied.
|
||
→ 11 migrations found. Database schema is up to date!
|
||
```
|
||
|
||
Backfill result (`node scripts/report-name-split.cjs`): 8 customers, 7 split
|
||
reliably, 0 single-token, 0 empty, **1 needs review** — `customer-dev-test`
|
||
(«کاربر تست توسعه»), a row this session created itself.
|
||
|
||
### Data deletions (standing rule 2)
|
||
|
||
| When | What | How many | Why |
|
||
|---|---|---|---|
|
||
| Round-trip test | `Customer` rows created by the test: 2 successful + 1 whose Persian text the Windows shell had mangled into `?` | 3 | Test fixtures. Customer count back to its original 8. |
|
||
| Trigger test | 1 temporary `Customer` row per run, deleted by the script itself and reported on stdout | 1/run | Test fixture. |
|
||
|
||
### API
|
||
|
||
- `toRow()` returns the **real** `fname`/`lname` (D0) and `percent` now reads
|
||
`makingChargeOffsetPercent` **from the customer** instead of the referring
|
||
agent's commission rate (D9 / CUST-02).
|
||
- `create()` stores the name parts, and **requires `percent`** with the
|
||
reference's own message, `وارد کردن percent الزامی است` — Laravel's stock
|
||
`required` text (`resources/lang/fa/validation.php:99`) with no attribute
|
||
translation for `percent`, so it is verbatim.
|
||
- `update()` writes the parts (letting the trigger rebuild `fullName`) and
|
||
**persists `percent`**, which it previously ignored entirely.
|
||
- `updateCredit()` accepted `percent` in its signature and never wrote it —
|
||
fixed.
|
||
- Sort/filter aliases `fname`/`lname` pointed at `fullName`; they now point at
|
||
the real columns, and `percent` is aliased too. Grid search covers both parts.
|
||
- `createSingle()` (retail form, D13) stores the parts too. No storefront
|
||
behaviour changed — only the shared columns are kept consistent.
|
||
|
||
### SMS — implemented (owner-approved)
|
||
|
||
- New `SmsModule` / `SmsService` (`@Global`), a port of `Sms.php:57-82`: POST,
|
||
form-urlencoded, 20s timeout, `PhoneNumber`/`Username`/`Password`/`Mobile`/
|
||
`Message`, body trimmed, every message persisted as `Sms::set()` did.
|
||
- **Failure handling matches the reference**: `send()` never throws. The
|
||
reference discarded `Sms::Send`'s result, so a gateway outage never aborted
|
||
the order, the document approval or the customer creation that triggered it.
|
||
Failures are logged and returned in `SmsSendResult`.
|
||
- **OTP text is byte-identical** to `ApiMobileController.php:47`, including the
|
||
`<#> code is …` first line and the trailing `aMo6iFyUQaY` — the Android SMS
|
||
Retriever hash. A unit test asserts the exact string.
|
||
- `assertSmsConfigured()` runs in `main.ts` **before the server listens**: in
|
||
production a missing key throws, naming it; outside production it warns and
|
||
codes are logged locally, so today's dev flow is unchanged.
|
||
- Credentials come from the environment only. `.env.example` lists the six names
|
||
with **empty values**; `.env` is gitignored (verified).
|
||
- The 12 B2B message templates are implemented per flow in their own phase —
|
||
admin in Phase 1, app in Phase 2 — not pre-emptively.
|
||
|
||
### Admin panel
|
||
|
||
- `singleCustomer.vue` — the `split(' ')` fallback is **removed** (D0 item 6).
|
||
- `bulkCustomer.vue` — `percent` defaults to **3** on create and is required
|
||
with the legacy message; the edit path coerces the Decimal string to a number
|
||
so diff-send does not re-send an unchanged field; `name` is only sent when a
|
||
name part actually changed, so the whole name is no longer re-submitted on
|
||
every save.
|
||
- `ReagentCustomer.vue` — **unchanged**. Its «درصد کمیسیون» is
|
||
`AdminUser.wagePercent`, a genuinely different field from `customers.percent`.
|
||
|
||
### Tests
|
||
|
||
- `npx jest` — **32 passed** (7 existing + 11 price formula + 14 SMS config/template).
|
||
- `scripts/test-name-trigger.cjs` — **9 assertions**, all three sync directions.
|
||
- `scripts/auth-smoke.sh` — **53 passed, 0 failed**.
|
||
- **Round-trip test (D12 rule E0)** on the customer entity, live:
|
||
create «محمد علی» + «احمدی» + percent 3 → edit form shows each field
|
||
separately and correctly → no-op save leaves the row **byte-identical** →
|
||
changing only `lname` changes `lastName` and `fullName` (trigger) and nothing
|
||
else → changing only `percent` changes exactly that column. The two-token
|
||
first name «محمد علی» survives intact, which is the defect the owner
|
||
originally reported.
|
||
- Typecheck: api ✅ · admin-panel ✅.
|
||
|
||
---
|
||
|
||
## 2026-09-22 (later still) — D16 priority change, SEC-02 re-verified
|
||
|
||
### PRICE-01 stopped (D16)
|
||
|
||
Price display is retail-storefront work, so it falls under D13 and is now ⏸️.
|
||
|
||
- `apps/api/src/modules/pricing/variant-price.ts` and its 11 tests are **kept**
|
||
and still run; they are simply not used.
|
||
- **Nothing needed reverting.** `computeVariantPrice` was never called from
|
||
anywhere outside its own file and spec — verified by grep over `apps/api/src`
|
||
and `apps/admin-panel/src`. No code had been wired to display a price.
|
||
- Making-charge work (`percentage_pay`, `makingChargeOffsetPercent`, wage display
|
||
on variants and order lines) **stays in scope**.
|
||
- `ISSUES.md` PRICE-01 re-marked ⏸️ rather than deleted, so the evidence survives.
|
||
|
||
### SEC-02 re-verified live
|
||
|
||
Re-tested end to end rather than quoting the earlier run:
|
||
|
||
| Check | Result |
|
||
|---|---|
|
||
| `POST /orders` with no token | 401 |
|
||
| Body `customerId: "customer-2"` + `customer-dev-test` token | landed on **customer-dev-test**; warn-logged |
|
||
| `GET /orders/:id` own | 200 |
|
||
| `GET /orders/:id` another customer's | **404** (not 403) |
|
||
| `GET /orders/:id` unknown | 404, identical |
|
||
| `GET /orders/admin/:id` with a customer token | 401 |
|
||
|
||
**9/9 passed**, plus **32/32** in `auth-smoke.sh`.
|
||
|
||
### Data deletions (standing rule 2)
|
||
|
||
| What | How many | Why |
|
||
|---|---|---|
|
||
| 1 `Order` created by the SEC-02 test, with its `OrderItem`, `OrderStatusEvent` and `LedgerEntry` rows | 1 order + its children | Test fixture, removed by the test itself. |
|
||
|
||
### A testing note worth keeping
|
||
|
||
The dev OTP line briefly appeared to be missing after the SMS change. It was not
|
||
a regression: a **stale API process from an earlier test still held port 3077**,
|
||
so the new instance died with `EADDRINUSE` and the log being tailed belonged to
|
||
the old process. Kill the listener (`netstat -ano | grep :3077`) before
|
||
concluding anything from a log. `smsConfigured()` returns false with no
|
||
credentials, so the dev log fires exactly as intended — confirmed once the port
|
||
was free.
|
||
|
||
---
|
||
|
||
## 2026-09-22 (evening) — D17: SalesExpertProfile + AdminUser name parts
|
||
|
||
### Migration `20260922180000_sales_expert_profile_and_admin_name_parts`
|
||
|
||
Shown, dry-run and approved before running. Applied with `migrate deploy`
|
||
(D14); drift afterwards is **zero**.
|
||
|
||
- **`SalesExpertProfile`** — `adminUserId` (unique FK, cascade), `cityId`
|
||
(FK → City, SET NULL), `gramLevel`, `phone`, `nationalCode`.
|
||
- **`AdminUser.firstName` / `lastName`** — additive, backfilled first-token/rest,
|
||
with the `admin_user_sync_full_name` trigger (**parts win** when both change).
|
||
Applies to **all** admins, closing D0 requirement 7.
|
||
- **Profile backfill** — `INSERT … WHERE NOT EXISTS` gave all 3 existing sales
|
||
experts a profile; the id is derived from the admin id, so a replay cannot
|
||
duplicate.
|
||
|
||
Owner's two corrections, both applied:
|
||
|
||
1. **Profiles for existing experts** + an `upsert` in the service on create and
|
||
on a role change to `SALES_EXPERT`, and `listReagents` **left-joins** so an
|
||
expert without a profile still lists (verified by test).
|
||
2. **`gramLevel` type matched** to `Customer.goldCreditLimitGrams` —
|
||
`DECIMAL(14,4)`, asserted identical in the dry-run.
|
||
|
||
**Validation research (as instructed).** The reference is deliberately lax:
|
||
`ncode` is `string(20)` nullable and **NOT unique**
|
||
(`2020_10_01_120430_customers_table.php:22`), `phone` is `string(15)` nullable
|
||
(`2020_10_25_155247_customers_cols.php:17`). Neither has a server rule —
|
||
`createCustomer` validates only `mobile` (required|unique) and `percent`
|
||
(required) (`AdminController.php:354`) — and both are `required: false` on the
|
||
client (`ReagentCustomer.vue:31,95`). Copied exactly; **no uniqueness invented**,
|
||
since that would reject data the reference accepts.
|
||
|
||
### Dry-run before applying
|
||
|
||
Ran the whole file inside a transaction, **twice**, then rolled back: 14
|
||
statements, table created, 3 profiles inserted, backfill correct, replay
|
||
produced no duplicates, and the rollback left the database untouched (verified
|
||
by re-querying `information_schema`).
|
||
|
||
### API
|
||
|
||
- `listReagents` returns **real** `city_id`, `city`, `gram_level`, `phone`,
|
||
`ncode`; the grid's «شهر» and «سطح اعتبار» filters map through the relation.
|
||
`remain` / `rial_remain` stay null — a sales expert is an AdminUser and has no
|
||
ledger of its own (D10).
|
||
- `findOne` includes the profile so the edit form populates.
|
||
- `create` stores name parts and creates the profile for a sales expert;
|
||
`update` writes the parts and upserts the profile.
|
||
- **Every remaining `fname: fullName, lname: ''` was fixed** — 6 more sites in
|
||
`admin-users`, `admin-comments`, `admin-documents` (×2), `admin-orders`,
|
||
`admin-preorders`.
|
||
|
||
### Admin panel
|
||
|
||
`ReagentCustomer.vue` gains «شهر», «سقف اعتبار», «کد ملی», «تلفن», and the
|
||
**city → commission autofill** ported verbatim from
|
||
`henza_admin/src/views/apps/customer/ReagentCustomer.vue:215-228` — including
|
||
the reference's falsy test, so a city percent of 0 also falls through to
|
||
`cities/get-percent-aux/{id}` (replicated, not "fixed" — D4). `name` is now sent
|
||
only when a name part changed, so diff-send is not defeated.
|
||
|
||
### Tests
|
||
|
||
| Suite | Result |
|
||
|---|---|
|
||
| `scripts/test-sales-expert-roundtrip.cjs` (new) | **33/33** |
|
||
| `npx jest` | 32/32 |
|
||
| `scripts/auth-smoke.sh` | 53/53 |
|
||
| `scripts/test-name-trigger.cjs` | 9/9 |
|
||
| Typecheck | api ✅ · admin-panel ✅ |
|
||
|
||
The round trip covers create-with-every-field → edit form populated separately →
|
||
byte-identical no-op save → single-field edits, with **«محمد علی» + «احمدی»**
|
||
surviving intact, plus both grid filters (positive **and** negative), the
|
||
left-join case, and the referrer dropdown.
|
||
|
||
**A test bug worth recording:** the filter assertions first failed because the
|
||
test sent `op: 'eq'`. The grid actually sends `op: '='`
|
||
(`XTable.vue:197-200`), and `toCondition` returns null for an unknown operator,
|
||
so the filter was silently dropped and the "returns ONLY matches" assertions
|
||
passed vacuously against an empty list. The product was correct; the test was
|
||
not. **A filter test needs a negative case, or a dropped filter looks like a
|
||
pass.**
|
||
|
||
### Data deletions (standing rule 2)
|
||
|
||
| What | How many | Why |
|
||
|---|---|---|
|
||
| `AdminUser` rows created by the round-trip test (one full expert, one profile-less) + their `SalesExpertProfile` rows | 2 per run | Test fixtures, removed by the test itself. |
|
||
| 1 temporary `Customer` row per `test-name-trigger.cjs` run | 1 per run | Test fixture. |
|