parity(api,apps): delivery-mode gaps — master OTP, non-blocking SMS, faToEn, v2 cart cap

D20: OTP_MASTER_CODE login bypass read only from the environment, disabled when
unset, audited on every use, with a boot banner while armed. SMS no longer
aborts the boot in production — it warns and declines silently, so no message
can stop an operation.

D19: port Utility::faTOen onto admin-entered mobiles (before the uniqueness
lookup, which could previously be bypassed with Persian digits), and the
create-only welcome SMS, byte-identical to the reference and fire-and-forget.

user-app-v2 was missing the cart stock ceiling entirely: capQty/maxQty, the
un-orderable guard and the qty clamp are ported, leaving v2's own design intact.
The two cart stores are now byte-identical. isVariantOrderable and the
orderable-first default selection ported too.

jest 45/45; typecheck clean on api, admin-panel, user-app, user-app-v2.

[ref: henza_server/app/Http/Controllers/Adm/AdminController.php:333,348,364,378-387]
[ref: henza_server/app/Models/Utility.php:26]

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
s.esmaeili
2026-09-22 18:35:33 +03:30
co-authored by Claude Opus 5
parent 2194b3d4d9
commit 460cf276b6
128 changed files with 756 additions and 181 deletions
+51
View File
@@ -540,3 +540,54 @@ pass.**
|---|---|---|
| `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. |
## 2026-09-22 — Delivery mode (D19/D20)
### API
- **`OTP_MASTER_CODE`** (D20) — `customer-auth.service.ts`. A delivery-only login
bypass, read **only** from the env var and disabled when unset or blank. It is
checked before the stored challenge so it works with no `CustomerOtp` row, and
it still enforces "an active, non-archived customer must exist". Every use is
logged with the customer's mobile. `main.ts` prints a boot banner while armed.
Token issuance was factored into `issueToken()` so the bypass and the real path
produce an identical token. Logged as **REL-01 (Critical)**.
- **SMS never blocks the boot** (D20) — `sms.service.ts`. `assertSmsConfigured()`
no longer throws in production; it warns and returns. `send()` already never
threw. Reverses D14. Logged as **REL-02**.
- **`faToEn` on admin-entered mobiles** (D19 gap 2) — `admin-customers.service.ts`
(`create`, `update`) and `admin-users.service.ts` (`create`, `update`).
Ports `Utility::faTOen` (`AdminController.php:333,364`). In `admin-users` the
normalisation runs **before** the uniqueness lookup, which previously let the
same number be registered twice as `۰۹۱۲…` and `0912…`.
- **Welcome SMS on customer create** (D19 gap 1) — `admin-customers.service.ts`.
Byte-identical to `AdminController.php:378-387` (verified by comparison,
including the trailing space in `لینک pwa `). Create-only, as in the reference,
and fire-and-forget so no gateway problem can delay or fail the save.
### Apps — user-app-v2 brought level with user-app
- **Cart stock ceiling** — `stores/cartStore.ts`. Ported `capQty`, `maxQty`, the
un-orderable guard in `addToCart` (now returning a boolean) and the clamp in
`updateFeild`. The two stores are now **byte-identical**.
- `composables/useProduct.ts` — ported `isVariantOrderable` and the
orderable-first default-variant selection. v2's variant-image `image` computed
is PROTECTED and was left untouched.
- `components/product/CartItem.vue`, `SingleProduct.vue` — the `+` button is
disabled at the ceiling; un-orderable variant chips are disabled and dimmed.
Only logic was ported: v2 keeps its own stepper DOM order, borderless rows and
chip design.
- `user-app/.../SingleProduct.vue` — `addToCart` now checks the store's return
value, so the "added" toast no longer fires when the store refused the item.
Applied to BOTH apps, per the rule that a logic fix lands in each.
### Tests
- New `customer-auth/master-otp.spec.ts` — 13 cases: the env switch, login with
the master code (including Persian digits), the audit log, refusal for
inactive/unknown customers, rejection when the var is absent, and the real code
still working both with and without the bypass armed.
- `sms/sms.service.spec.ts` — the boot-contract block rewritten for D20: never
throws, warns and names the missing variables.
- jest: **45 passed** (was 32). typecheck clean on api, admin-panel, user-app,
user-app-v2.
### Data
- No data was deleted or modified.
+100
View File
@@ -1065,3 +1065,103 @@ parts win** when both change in one statement, and a multi-token review report.
Every `fname: fullName, lname: ''` site is corrected — starting with
`listReagents` (`admin-customers.service.ts:375-376`).
---
# D18 — Standing approval (owner decision, 2026-09-22)
To reduce round-trips, the owner granted a **standing approval**. From now on the
following are done **without stopping and without asking**, with only a short
report at the end of each section before moving straight to the next:
- Implementing gaps whose reference behavior is unambiguous — a field, column,
filter, validation, message, button or page.
- Fixing target/reference mismatches where the reference behavior is clear.
- **Purely additive** migrations (a new column or table) on the development DB,
provided a dry-run inside a transaction with rollback is clean first.
- Writing and running tests.
- Applying the same fix to other sites of the same pattern (as with the six
extra `fname: fullName, lname: ''` sites found during D17).
**STOP and ask** only for:
1. Deleting any data, column, table, file or capability.
2. Any change to a PROTECTED zone or to the storefront surfaces of D13.
3. Any place the reference behavior is ambiguous or open to two readings.
4. Any place a reference bug is NOT being ported and that affects business
logic (the D17 situation).
5. A migration that alters existing data (backfill or update), unless it only
touches test data.
6. The Phase 1 end gate.
Everything else in DECISIONS, PROTECTED, D13 and D15 stays in force. In
particular this approval does **not** loosen: the ban on `migrate reset` or
touching `_prisma_migrations`, the CHANGELOG entry required for every data
deletion, or the UTF-8-from-file rule for tests with Persian input.
---
# D19 — Delivery Mode (owner decision, 2026-09-22)
The owner has a **delivery tonight**. Until further notice:
1. **No more deep field-by-field auditing and no more questions.** Implement the
main gaps and make sure the project works.
2. **The "admin first, then apps" gate is lifted for tonight.** Work on admin and
both apps in the priority order below.
3. **Nothing is deleted or changed** in PROTECTED zones, storefront (D13) areas,
data, features or designs. All earlier security rules remain in force.
4. **When a decision is needed, pick the lowest-risk option that breaks nothing**,
continue, and record it here rather than stopping to ask.
## Answers that closed the Persons audit
| Q | Ruling |
|---|---|
| A — `singleCustomer` diff-send | **Do not touch it** (D13 wins). **D11 is CANCELLED.** |
| B — lock `mobile` on edit | **Not ported.** The target keeps its current editable behaviour. Logged in ISSUES. |
| Gap 2 — `faToEn` on admin mobile | **Implement** (create and update). |
| Gap 1 — welcome SMS | **Implement**, byte-identical text, but non-blocking per D20. |
| Gap 4 — province/city tree | **ISSUES only.** |
| Gap 5 — Jalali birth date | **ISSUES only.** |
**D11 is superseded by this decision and must not be acted on.**
---
# D20 — SMS boot rule reversed, and a master OTP for delivery (2026-09-22)
## 1. SMS must never block anything
The rule from D14 — *"in production, if the SMS key is missing the server must
fail to boot with a clear error"* — is **REMOVED**.
New behaviour, in every environment:
* Missing credentials → log a **warning** and **boot normally**.
* `send()` with no credentials → **silently declines and logs**; it never throws.
* **No SMS, including the welcome SMS, may ever stop an operation.** Customer
creation succeeds whether or not the message goes out.
The live send test to the owner's number is **cancelled**; the SMS panel is not
a priority for this delivery.
## 2. `OTP_MASTER_CODE` — a manual login code for the delivery
A temporary bypass so login works during the demo.
| Rule | Detail |
|---|---|
| Source | **`OTP_MASTER_CODE` env var only.** Never hardcoded in source. |
| Default | Unset or empty → **completely disabled**. |
| Effect | When set, it is accepted for **any active customer's** mobile, **alongside** the real code, which keeps working. |
| Unchanged | Active-customer check, throttling and attempt limits all still apply. |
| Boot | When enabled, print a clear warning: `OTP_MASTER_CODE فعال است — قبل از انتشار اصلی حذف شود`. |
| Audit | Every login through it is logged **with the customer's mobile**. |
Tests required: master code logs in; with the env var absent the same code is
rejected; the real code still works.
**Before the real release: remove `OTP_MASTER_CODE` from the environment and
configure the SMS credentials.** Recorded in `DELIVERY.md` and as a Critical
entry in `ISSUES.md`.
+11
View File
@@ -126,3 +126,14 @@ Severity: `Critical` (money loss / security / data loss) · `High` · `Medium`
| PRICE-01 | ⏸️ out of scope (was Critical) | TGT `apps/api/src/modules/admin-products/admin-products.service.ts:188-208`; `apps/admin-panel/src/views/apps/product/ProductList.vue` | **No product price is computed or shown anywhere in the admin panel.** The reference appends `price` and `price_with_discount` to every variant (`ProductVariant.php:115-119`), sets the live gold rate before listing (`AdminController.php:97-98`), and renders `{{ item.price }}` in three places in its product list (`ProductList.vue:283,287,301`). The target's `variantRow()` returns neither field, nothing fetches a gold price for the admin list, and `computeVariantPrice()` — ported and unit-tested today — is called from nowhere. | `grep computeVariantPrice src/ --include=*.ts` matches only its own file and spec. | ⏸️ **Stopped by D16** — price display is retail-storefront, out of scope. `variant-price.ts` and its 11 tests are KEPT but unused. Nothing needed reverting: the function was never called. Making-charge fields stay in scope. |
| PRICE-02 | Info | REF `app/Models/ProductVariant.php:163` vs TGT `admin-products.service.ts:206-208` | `rial_pay` (a flat rial charge per variant) appears only in the **superseded, commented-out** formula, not the live one at `:183-190`. The target returns null rather than faking it, which matches the live reference. Recorded so nobody "restores" it from the dead code. | Read both. | ✅ No action — matches. |
| PERM-01 | Medium | TGT `prisma/seed.ts`; `apps/admin-panel/src/views/user/permissionConst.ts` | `dashboard.omde` and `dashboard.tak` are seeded into the `permission` table but are **absent from the permission UI tree**, so they can be held by a row yet never seen, granted or revoked from the admin screen — and nothing reads them any more (the protected Dashboard uses `dashboard.view`). The DB holds **178** distinct rids against the UI's **174**. | `SELECT rid FROM permission GROUP BY rid` → 178, including all three dashboard rids. | Open — the fix removes permission rows, which is a data deletion and needs the owner's approval first. |
## Delivery-mode items (D19/D20, 2026-09-22)
| ID | Severity | Where | Description | Evidence | Suggested fix |
|---|---|---|---|---|---|
| **REL-01** | **Critical — before the real release** | TGT deployment env | **`OTP_MASTER_CODE` must be removed from the environment, and the SMS credentials configured.** While the variable is set, ANY active customer can sign in with that one fixed code. It is off by default (unset = disabled) and the API prints a boot banner while it is armed, but nothing stops it reaching production except this step. | `customer-auth.service.ts` `masterOtpCode()`; `main.ts` boot banner; D20 | Unset `OTP_MASTER_CODE`; set `SMS_TREZ_USERNAME` / `SMS_TREZ_PASSWORD` / `SMS_TREZ_SENDER_LINE`; restart and confirm the banner is gone. |
| **REL-02** | High | TGT `apps/api/src/modules/sms/sms.service.ts` | D20 removed the rule that a missing SMS credential aborts the boot in production. The server now starts with SMS disabled, so **customers cannot receive a login code** and the failure is only a log line. Deliberate, to keep the delivery unblocked. | D14 (superseded) vs D20 | After the delivery, decide whether to restore a boot check or add a health endpoint that reports SMS readiness. |
| **PERS-01** | Medium | REF `AdminController.php:348` vs TGT `admin-customers.service.ts` `update()` | The reference **discards the submitted mobile on edit** (`$input['mobile'] = $customer->mobile;`), making it immutable once set; the target lets an operator change it. D19 ruled the lock NOT ported, so the target keeps the editable behaviour. Changing a mobile changes the customer's login identity. | `AdminController.php:348`; D19 answer B | If the lock is wanted later, apply it in `update()` and hide/disable the field on the edit form. |
| **PERS-02** | Medium | REF `cities` table vs TGT `City` model | The reference's cities are a **two-level hierarchy** (`parent` + `level`, provinces at level 0) rendered as an `a-tree-select` with provinces **not selectable** (`citiesModel.js:38`). The target flattened this to a nullable `province` **string** and a flat `a-select` on all three Persons forms. Functionally the city is still captured; the province grouping and its tree UI are absent. | `2021_09_18_171700_city_table.php:18-19`; `henza_admin/src/mixins/citiesModel.js:20-48`; `schema.prisma` `model City` | Structural change; decide with the owner during the cities screen (Phase 1c). |
| **PERS-03** | Low | REF `bulkCustomer.vue:111`, `singleCustomer.vue:29` vs TGT | The reference collects a **Jalali birth date** on both the wholesale (`birth_date`) and retail (`birth_day`) customer forms via `DateInput`. The target's `Customer` model has no column for it and both forms omit the field. | `henza_admin/.../bulkCustomer.vue:111`; `schema.prisma` `model Customer` (no birth field) | Additive: add a nullable `birthDate` column and the picker, if the owner wants it. |
| **PERF-01** | Low | TGT `admin-customers.service.ts` `listReagents()` | The per-agent gold-sold total runs **one `order.aggregate` per row** inside `Promise.all` — an N+1 on every page of the sales-expert grid. Harmless at the current agent count. | `listReagents()` `rows.map(async ...)` | Replace with a single `groupBy` over the referring agent. |