First commit of the Hedena Gold product (separate from gold-bank): branding, package names, and deploy defaults aligned to the new brand; legacy Henza trees stay local-only via .gitignore.
1438 lines
78 KiB
Markdown
1438 lines
78 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. |
|
||
|
||
## 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.
|
||
|
||
### Live verification of D20 (against a running API, port 3000)
|
||
|
||
Two scripts, both kept in `apps/api/scripts/`:
|
||
|
||
- `test-master-otp-live.cjs` — API started **with** `OTP_MASTER_CODE`: **8/8**.
|
||
Signs the customer in, the JWT works on `GET customer/me`, Persian digits are
|
||
accepted (faToEn), and — the RULE E1 negatives — a near-miss code, an unknown
|
||
mobile and an inactive customer are all still rejected.
|
||
- `test-master-otp-disabled.cjs` — API restarted **without** the variable:
|
||
**6/6**. The same master code now returns 401, the boot banner is absent, a
|
||
freshly requested real code still logs in, and replaying it fails (single use).
|
||
|
||
Boot behaviour confirmed from the log: with no SMS credentials the server
|
||
**started normally**, warned once, and `[dev] SMS not configured; not sending`
|
||
appeared instead of an error — no operation was blocked (D20).
|
||
|
||
**Data touched:** `test-master-otp-disabled.cjs` deletes the `CustomerOtp` rows
|
||
**for its own test number only** (to clear the 60s resend cooldown); 1 row was
|
||
deleted on the recorded run. No other data was created, changed or removed.
|
||
|
||
### Admin smoke test + a real bug it caught (D19 step 3)
|
||
|
||
New `apps/api/scripts/smoke-admin.cjs` — **43/43**. Walks the flows the owner
|
||
listed: login → create a customer → create and edit a sales expert → create and
|
||
edit a product with variants → list, view and change an order's status. Each
|
||
step asserts the DATABASE changed, not just that the call returned 200.
|
||
|
||
**ORD-01 (fixed) — retail order status editing was completely broken.**
|
||
`views/order/List.vue:203,215` posted the timeline ACTIVITY entry straight to
|
||
`single/order-sts/edit`. That object is `{status, title, proceed, uaj, note}`
|
||
(`admin-orders.service.ts:249-258`) and carries **no order id**, so
|
||
`UpdateOrderStatusDto` rejected every call with 400: neither «ثبت وضعیت» nor
|
||
«حذف وضعیت» worked on that screen. Both handlers now build the body from
|
||
`tempRow.id`, the order loaded when the dialog opens — the shape `OmdeList.vue:187`
|
||
and `TaminList.vue:169,184` already used. Those two screens were checked and
|
||
were already correct; `List.vue` was the only broken caller.
|
||
|
||
Two assertions in the first draft of the smoke test were wrong, not the product:
|
||
the product form reads `variants_all` (not `variants`), and the status endpoint
|
||
takes `orderId` (not `id`/`sts`). Both corrected.
|
||
|
||
**Data touched:** the script creates and then deletes its own rows only
|
||
(1 product + its variants/images, 1 admin user + profile, 2 customers per run).
|
||
The order status change is made against an existing order and then **restored to
|
||
its original value** in the same run; no pre-existing row is left modified.
|
||
|
||
## 2026-09-23 — Apps smoke test (D19 step 1)
|
||
|
||
New `apps/api/scripts/smoke-apps.cjs` — **43/43**. Walks the customer flow the
|
||
owner listed: login with the master OTP → see products → add to cart → place an
|
||
order → see my orders, plus the negatives (empty order → 400, unauthenticated
|
||
order → 401).
|
||
|
||
Because both apps share one API client, the network flow is exercised once; the
|
||
**cart rules are asserted against BOTH apps' real source**, loaded from disk and
|
||
executed, so "the apps work" cannot be proved by v1 alone. `capQty` was checked
|
||
over five cases (over-stock, under-stock, pre-order, zero, negative) in each app.
|
||
|
||
**Result: no behavioural divergence between user-app and user-app-v2.**
|
||
Confirmed by direct comparison:
|
||
|
||
| Surface | Result |
|
||
|---|---|
|
||
| All 9 Pinia stores | **byte-identical** |
|
||
| Router (route names) | identical |
|
||
| API client call list | identical |
|
||
| Composables | identical except `useProduct.image` — v1's variant-image picker, **PROTECTED** |
|
||
| Components | none missing from v2; its 4 extra home components are its own design |
|
||
|
||
The making-charge formula was verified against a real order:
|
||
`5.65g +15% = 6.4975` exactly, matching the reference's
|
||
`rash × (1 + (wage − discount)/100)`.
|
||
|
||
### APP-01 (constraint, not a code change) — the master code must be 4 digits
|
||
|
||
Both apps render the login OTP as **four single-character boxes** and submit
|
||
only on `@on-complete`, i.e. at exactly four digits
|
||
(`LoginView.vue:43` in each app; `OtpInput.vue:43`). The API accepts any length,
|
||
so a longer `OTP_MASTER_CODE` works over HTTP but **cannot be typed into either
|
||
app**. The real OTP is 4 digits (`Math.floor(1000 + random*9000)`), so the box
|
||
is correct — only the master code has to match it.
|
||
|
||
The apps were NOT changed (that would touch the shipped login UI for a
|
||
delivery-only feature). Instead `main.ts` now checks the code at boot and adds
|
||
an explicit warning when it is not 4 digits, and `.env.example` states the rule.
|
||
Re-ran the full apps smoke with a 4-digit code: **43/43**.
|
||
|
||
### APP-02 (logged, not fixed) — v2 does not show the admin's home slider
|
||
|
||
`user-app` calls `GET /home/slider` and prefers admin-configured slides, falling
|
||
back to category-derived ones (`HomeView.vue:258`). `user-app-v2` has no
|
||
`getHomeSlider` in its API client at all and always derives its hero from
|
||
categories. So slides configured in the admin panel appear in v1 only.
|
||
|
||
Not fixed tonight: v2's home is its own design (HeroCarousel/ProductPanel), and
|
||
wiring a different slide source into it during a delivery risks breaking the
|
||
page that is actually being demonstrated. Listed under known issues.
|
||
|
||
**Data touched:** the script creates one order and deletes it again, with its
|
||
items, status events and ledger entries. No pre-existing data is modified.
|
||
|
||
## 2026-09-23 — B2B admin scan (D19 step 2)
|
||
|
||
Two new scripts, scoped to the B2B surfaces the owner named. Retail/storefront
|
||
(`single*`, wallet, comments) was **not** scanned — D13, preserved untouched.
|
||
|
||
**`scripts/scan-admin-b2b.cjs` — 34 ok, 0 broken.** Every read endpoint the
|
||
wholesale desk opens daily: the five customer grids, the referrer dropdown,
|
||
customer detail/dashboard/orders/documents, the sales-expert list and detail,
|
||
products, variants, brands, collections, product detail and its variants,
|
||
wholesale and tamin order grids, order detail, the documents grid and detail,
|
||
and the supporting lookups (cities, branches, workshops, pre-orders, users).
|
||
|
||
**`scripts/smoke-admin-b2b-writes.cjs` — 25/25.** The money-touching writes a
|
||
read-only scan cannot cover:
|
||
|
||
- Placing a wholesale order from the admin panel: the making charge computes
|
||
exactly (`8.133g +2% = 8.2957` per unit, ×2 = `16.5913` total), the order
|
||
lands on the right customer and appears in the grid with a status timeline.
|
||
- Recording a cash payment document: the reference's formula
|
||
`price / (price_per_gram / 4.331802)` gives 0.8664g, the entry is created
|
||
**unconfirmed** (the intentional deviation from legacy's auto-confirm),
|
||
confirming moves the customer's gold debt 8.25 → 7.3836, and confirming again
|
||
**toggles it back** and restores the balance exactly.
|
||
|
||
No B2B breakage was found. Three failures in the first drafts were test bugs,
|
||
not product bugs, each corrected after checking the real caller:
|
||
|
||
| Wrong assumption | What the admin panel actually sends |
|
||
|---|---|
|
||
| `POST cities/list` | `admin/cities/list` (endpoints.ts:152) |
|
||
| `by-ids` takes `ids` | `products_id` (stores/cart.ts:102) |
|
||
| document `type: 2` is cash | `type: 4` is «پولی»; 2 is «حواله» and takes a weight in `value` (addFinDoc.vue:33) |
|
||
|
||
**Data touched:** both scripts delete only what they create (one order with its
|
||
items/status events/ledger entries; one ledger entry). The write script also
|
||
asserts the customer's gold and rial balances end exactly as they started.
|
||
|
||
## 2026-09-23 — Delivery branch prepared (D19 step 4)
|
||
|
||
**`release/delivery`** created from `parity/phase-1-admin`, which already
|
||
contained every commit of `parity/security-hotfix` — so the merge was a
|
||
fast-forward and there were **no conflicts**. `main` untouched.
|
||
|
||
### DEPLOY-01 (fixed) — the compose file dropped every auth and SMS variable
|
||
|
||
`docker-compose.yml`'s `api` service passed only `DATABASE_URL` and `PORT`.
|
||
`JWT_SECRET`, `JWT_CUSTOMER_SECRET`, `OTP_MASTER_CODE` and all five `SMS_TREZ_*`
|
||
variables were **never forwarded into the container**, so in a deployed
|
||
environment the admin JWT would have fallen back to the well-known development
|
||
secret and the delivery master code would simply not have existed — the demo
|
||
login would have failed with no obvious cause. All of them are now passed
|
||
through, each defaulting to empty, which the code already treats as "unset"
|
||
(verified with `docker compose config`, with and without values set).
|
||
|
||
### TEST-01 (fixed) — a stale hardcoded port
|
||
|
||
`test-sales-expert-roundtrip.cjs` pointed at `localhost:3077`, a port from an
|
||
earlier dev session, so it failed with ECONNREFUSED. Now defaults to
|
||
`localhost:3000` (the repo's `API_PORT`) and takes an override argument.
|
||
Re-ran: **33/33**.
|
||
|
||
### `DELIVERY.md` written
|
||
|
||
Covers the env variables (names only, no values), dev/build/deploy commands, how
|
||
migrations run (the in-container runner and why `_goldbank_migrations` exists
|
||
alongside `_prisma_migrations`), the full verification table, and the known
|
||
issues — the four missing app pages, APP-02, PERS-01/02/03, PERF-01, and the
|
||
parity work Phase 1 has not reached. The two pre-release actions (remove
|
||
`OTP_MASTER_CODE`, configure SMS) are at the top of the file and logged as
|
||
REL-01/REL-02 in ISSUES.
|
||
|
||
### Verification on release/delivery — all green
|
||
|
||
build 5/5 · jest 45/45 · admin smoke 43/43 · apps smoke 43/43 ·
|
||
B2B scan 34 ok / 0 broken · B2B writes 25/25 · auth-smoke 32/32 ·
|
||
sales-expert 33/33 · name trigger 9/9 · typecheck clean on all four apps.
|
||
|
||
---
|
||
|
||
## 2026-10-12 — Phase 1 close-out — order SMS (SMS-03) and the §2 rid matrix
|
||
|
||
### 1. Order-placement SMS — SMS-03
|
||
|
||
- **What:** `OrdersService` now injects `SmsService` and, after the order
|
||
transaction commits, texts three audiences: the admins listed in
|
||
`ORDER_ADMIN_MOBILES`, the customer (different text when the order was
|
||
suspended for exceeding the gram ceiling), and the customer's referring sales
|
||
expert. Brand «منتزر» replaces «هنزا گلد»; every other character of the four
|
||
texts is the reference's.
|
||
- **Why:** the matrix had logged only the suspended-order message as missing. On
|
||
reading the reference, **all four** messages were absent and `OrdersService`
|
||
did not inject `SmsService` at all — a customer placing an order was told
|
||
nothing, and neither was anyone else.
|
||
- **Reference:** `henza_server/app/Http/Controllers/Api/ApiMobileController.php:733-766`
|
||
- **Target:** `apps/api/src/modules/orders/orders.service.ts`
|
||
(`notifyOrderPlaced`, `notifySalesAgent`), `orders.module.ts`, `.env.example`
|
||
- **Matrix row:** `01-ADMIN-MATRIX.md` §4, SMS-03
|
||
- **Deliberate non-replication:** the «در سیستم ویرایش شد» variant (`:734-737`)
|
||
is **not** ported — no target path lets a customer edit a placed order, so the
|
||
branch could never fire. Annotated «عمداً تکرار نشد — SMS-03» per D4.
|
||
- **Decisions carried over:** admin numbers come from an env var rather than the
|
||
reference's four hardcoded personal numbers, matching the existing
|
||
`PROFILE_ADMIN_MOBILES` pattern; SMS is fire-and-forget per D20, so a dead
|
||
gateway can never fail an order; the messages are sent **after** the
|
||
transaction commits, as the reference did, so a rolled-back order texts nobody.
|
||
`Notify::store` (Firebase push) beside each message stays deferred as PUSH-01.
|
||
- **Tests:** 6 new cases in `orders.service.spec.ts` (customer placed/suspended
|
||
text, admin fan-out, no-admins-configured, sales expert, expert without a
|
||
mobile). Full api suite **283/283**, typecheck clean.
|
||
- **Found while testing:** the pre-existing "sales expert credit" suite mocked no
|
||
`adminUser`, so the new agent lookup hit its catch and logged an error while
|
||
the test still passed green. Mock completed — a passing test now means the
|
||
path actually ran.
|
||
|
||
### 2. §2 of the admin matrix — the D1 rid matrix
|
||
|
||
- **What:** filled in the section that had stood as *(pending)* since the phase
|
||
began, mechanically from the target's `@RequirePermission` decorators and
|
||
`permissionConst.ts`: 163 rid-guarded routes, 68 distinct rids, 176 tree keys,
|
||
mapped per module onto the reference controller each one ports.
|
||
- **Why:** D1 required proof that no admin operation is left without an
|
||
equivalent permission. It is the last unfinished item of Phase 1.
|
||
- **Result:** 66 of the 68 enforced rids are grantable from the permission
|
||
screen. The 2 that are not — `newsletters` and `persons.list.sellers.view` —
|
||
are logged as **PERM-02**; both are inherited from the reference, whose own
|
||
`permissionConst.ts` header had already recorded four such rids (the other two
|
||
now have no route on either side).
|
||
- **Target:** `docs/parity/01-ADMIN-MATRIX.md` §2
|
||
- **Note on method:** the first extraction pass read the rid that followed each
|
||
HTTP decorator and produced an off-by-one map (it claimed
|
||
`reconcile → wallet.list`). Caught by spot-checking against this CHANGELOG's
|
||
own stage-1 entry, which records `reconcile → wallet.charge`. The corrected
|
||
pass was re-verified against five independently documented mappings before any
|
||
table was written.
|
||
|
||
_No data was deleted in this entry._
|
||
|
||
---
|
||
|
||
## 2026-10-12 — Phase 2 — item #1: MON-08 step 2 (the discarded client price is now logged)
|
||
|
||
- **What:** `OrdersService.warnOnPriceMismatch` — when an app order is priced by
|
||
the server and the client had sent different figures, the difference is logged
|
||
(`wagePercent sent=… used=…`, same for `discountPercent`, plus any non-zero
|
||
`flatRialCharge`). The order is still charged the server's figures; nothing
|
||
about the pricing changed.
|
||
- **Why:** step 2 of the owner's MON-08 ruling. Steps 1, 3 and 4 had landed with
|
||
the wage-formula work, but the client's values were being discarded **in
|
||
silence** — so a stale app version, a promotion that expired between the
|
||
product page and checkout, and a tampered request were indistinguishable and
|
||
left no trace at all.
|
||
- **Reference:** `henza_server/app/Http/Controllers/Api/ApiMobileController.php:536-541`
|
||
(debt computed from the request, nothing re-read from the DB);
|
||
`henza_pwa/src/views/cart.vue:170-179` (the app computes all three).
|
||
- **Target:** `apps/api/src/modules/orders/orders.service.ts` (the call site in
|
||
the per-item map; the method beside `adminMobiles`).
|
||
- **Matrix row:** MON-08 in `ISSUES.md` §"Phase 2 — item #1".
|
||
- **Commit:** see below.
|
||
|
||
**Also added — the comparison test the ruling asked for.** Step 3 required «تست
|
||
مقایسهای که نشان دهد نتیجه برای کاربر عادی یکسان است», and the suite did not
|
||
have one: it only proved that a *tampered* request gets overridden. The new
|
||
`"gives an honest app exactly the total it sent"` places the same honest order
|
||
once as a customer (server-priced) and once as an operator (client-priced) and
|
||
asserts the two totals are identical — which is the actual claim, that an
|
||
ordinary customer sees no change. Two more cover the logging itself: the
|
||
tampered order logs all three fields, and an honest one logs nothing (so a green
|
||
"quiet" test cannot be green merely because the method never ran).
|
||
|
||
`orders.service.spec.ts`: 13 → 16 tests. Full api suite **286/286, 26 suites**,
|
||
`tsc --noEmit` clean, and the run log inspected for swallowed errors.
|
||
|
||
**STUB-05 closed** in the same pass: the status still read "Pending the SMS
|
||
approval" though `PROFILE_ADMIN_MOBILES` and `ORDER_ADMIN_MOBILES` had replaced
|
||
the reference's six hardcoded staff numbers. No literal number is in the
|
||
target's git history.
|
||
|
||
**Deliberately unchanged:** the target reads `rashWeightGrams` from the variant
|
||
row rather than trusting the client, unlike the reference. Per the owner's
|
||
earlier ruling this deviation stays, and it is re-recorded in `ISSUES.md` so it
|
||
is not "fixed" later by someone restoring parity for its own sake.
|
||
|
||
_No data was deleted in this entry._
|
||
|
||
---
|
||
|
||
## 2026-10-12 — Phase 2 — item #2: D3's three outstanding behaviours
|
||
|
||
- **What:** (1) A guest bounced off a personal page now sees «ابتدا وارد شوید»
|
||
in both apps. (2) Legacy's `isLoaded` router guard is recorded as deliberately
|
||
not ported. (3) Route analytics parked as ANLY-01.
|
||
- **Why:** D3's remaining items. Seven of its ten were already built — all five
|
||
screens in **both** apps, the `sts_verify_step_two` guard and v2's cart stock
|
||
ceiling — confirmed by reading the code rather than trusting the checkmarks.
|
||
- **Reference:** `henza_pwa/src/router/index.ts:253-261` (the notify + home),
|
||
`:263-266` (the `isLoaded` guard), `router/customAnalytics.ts` +
|
||
`henza_server/app/Http/Controllers/Api/LogController.php` (analytics).
|
||
- **Target:** both apps' `src/router/index.ts` and `src/stores/configStore.ts`.
|
||
- **Matrix row:** APP-03, APP-04, ANLY-01 in `ISSUES.md` §"Phase 2 — item #2".
|
||
- **Commit:** see below.
|
||
|
||
**The one judgement call.** Legacy showed the toast and then sent the guest to
|
||
`home`, discarding where they were going. The target redirected to `/login` with
|
||
`query.redirect`, so signing in returns the guest to the page they wanted — that
|
||
part is better than the reference, and the gap was only the missing message.
|
||
Rather than choose silently, the trade-off went to the owner, who kept the
|
||
target's destination. So the message is the parity row and the destination is
|
||
**«عمداً تکرار نشد — D3/الف»**. The guard also keys off `meta.requiresAuth`
|
||
instead of legacy's hand-kept list of five route names, which means the two
|
||
sales-expert pages are covered too.
|
||
|
||
**`isLoaded` — argued against, and the owner agreed.** It guarded legacy's one
|
||
big bootstrap request, which the target does not have (each page fetches its own
|
||
data). Added, it would guard nothing and introduce a bounce: a shared product
|
||
link would open `home` instead of the product. That is porting the breakage, not
|
||
the behaviour (D4). The reasoning is written into both apps' `configStore.ts`,
|
||
beside the line that says the flag was not ported, so the next reader finds it
|
||
where they would actually look.
|
||
|
||
**Analytics deferred by the owner** («ج اصلا اولیوت نداره»). The design is kept
|
||
in `docs/PHASE2-D3-PLAN.md` so it does not have to be re-derived, including the
|
||
two reference defects not to copy — `log/create` was unauthenticated and took any
|
||
title, so anyone could fill the table from outside, and `log/update` was a `GET`
|
||
that wrote — plus the two questions only the owner can answer: how long to keep
|
||
per-customer browsing history (legacy never deleted it) and whether to store the
|
||
IP at all.
|
||
|
||
**Verification.** Neither app has a test runner, so the check is `vue-tsc -b` on
|
||
both (clean) plus reading the three things that make the toast actually appear
|
||
rather than merely compile: `toast.info` exists (`composables/useToast.ts:66`),
|
||
`<ToastHost />` is mounted in each `App.vue:25`, and the call sits before the
|
||
guard's `return`. A compiling toast with no host would have been invisible.
|
||
Noted in the code: with the IAM enabled, silent SSO leaves the page before this
|
||
guard runs, so the toast is a no-op there by design, not a defect.
|
||
|
||
_No data was deleted in this entry._
|
||
|
||
---
|
||
|
||
## 2026-10-12 — App login — say what went wrong (AUTH-01/02/03, DOC-01)
|
||
|
||
- **What:** Step 1 of the app login now reports an unregistered number (404) and
|
||
a failed SMS send (503) instead of answering success in both cases. A sales
|
||
expert whose trading account cannot be used gets its own 403 rather than the
|
||
unknown-number message. Both apps surface the server's Persian message for a
|
||
404 as they already did for a 429.
|
||
- **Why:** owner report — a number that was not in the system produced no error:
|
||
the app moved to the code box and «واقعا نمیشد فهمید خطا از پروژه هست یا نه
|
||
پیامک نمیاد یا نه واقعا نیست». Three causes looked identical on screen, and
|
||
only one of them was something the person could act on.
|
||
- **Reference:** `ApiMobileController::userAuth:43` — `firstOrFail()`, i.e. 404
|
||
for a number with no active customer. **The opaque answer was the deviation;
|
||
this restores the reference.**
|
||
- **Target:** `apps/api/src/modules/customer-auth/customer-auth.service.ts`
|
||
(`requestOtp`, `deliver`, `resolveExpertTradingAccount`), both apps'
|
||
`src/views/user/LoginView.vue`.
|
||
- **Matrix row:** AUTH-01, AUTH-02, AUTH-03, DOC-01 in `ISSUES.md`.
|
||
- **Commit:** see below.
|
||
|
||
**The security trade-off, stated plainly.** The opaque response existed to stop
|
||
an outsider enumerating which mobile numbers are registered. That protection is
|
||
now reduced at step 1, deliberately, on the owner's instruction: the cost was
|
||
being paid by real users and by support on every login problem. What still
|
||
stands — the per-mobile and per-IP send throttles (`assertSendAllowed`), which
|
||
the reference did not have, so enumeration is rate-bounded rather than free; and
|
||
`verifyOtp`, which keeps one message for every failure reason. `ISSUES.md`
|
||
SEC-03's "no enumeration" claim was corrected rather than left standing.
|
||
|
||
**Two further problems found while fixing the reported one.** Both were the same
|
||
shape — an operation reporting success while doing nothing:
|
||
|
||
1. A failed SMS send also returned `{ sent: true }`, because `SmsService` never
|
||
throws (D20). A dead gateway therefore produced the exact same dead end. D20
|
||
is about not letting a failed SMS break an *operation*; in a login the SMS is
|
||
the operation, so it now fails loudly. The local-dev path is preserved by
|
||
keying off `SmsService`'s own `sms-not-configured` result rather than
|
||
re-testing the config — with D26's default `relay` driver, `smsConfigured()`
|
||
is true on any machine with an external IPv4, so a config test would have
|
||
called a dev box configured and then failed its login. **The test caught
|
||
exactly this**, on the first run.
|
||
2. Three states return no trading account for a sales expert who *does* exist.
|
||
Under the new 404 they would each have claimed the number was not registered
|
||
— a false message that sends support looking for the wrong thing.
|
||
|
||
**A stale comment that actively misled.** Both apps' `LoginView.vue` header still
|
||
described the login as stubbed: no SMS provider, any 4-digit code accepted, and
|
||
step 1 matching against the customer list in the client (which was the login
|
||
leak, D8). All three had been untrue for weeks, and it misled a reader during
|
||
this very session. Corrected in place, with that noted.
|
||
|
||
**Verification.** `requestOtp` had **no tests at all** — the 28-test customer-auth
|
||
suite stayed green through a change to its response for every unregistered
|
||
number, which is why the suite's greenness was not treated as evidence.
|
||
`request-otp.spec.ts` adds 9 cases, including that the dev fallback does *not*
|
||
apply in production. Full api suite **295/295, 27 suites**; both apps `vue-tsc`
|
||
clean.
|
||
|
||
_No data was deleted in this entry._
|
||
|
||
---
|
||
|
||
## 2026-10-12 — Phase 2 — «ورود به حساب مشتری» restored (EXP-01/02/03, D29)
|
||
|
||
- **What:** A sales expert can again open one of their referred customers'
|
||
accounts and use the whole app as them — browse, see balance, debt and
|
||
reports, and place orders that land on the customer's account. Entered from
|
||
the customer's card on «لیست مشتریان»; a permanent bar names whose account it
|
||
is and exits.
|
||
- **Why:** owner report — the reference had it («یکجورایی انگار با اکانت اون ها
|
||
لاگین کرده … و این خیلی کاربردی بود ولی الان نیست») and D21 had deliberately
|
||
left it out as a session-hijack primitive. The owner asked for the capability
|
||
back, with the implementation left to judgement: «همان قابلیت هارو میخوام حالا
|
||
هرجور که فکر میکنی از لحاظ امنیت و پرفورمنس بهتره».
|
||
- **Reference:** `customerChoice.vue:155-167` → `myDataStore.ts:159-169` →
|
||
`tokenManage.ts:8` → `plugins/axios.ts:18`; server
|
||
`ApiMobileController.php:197-201`.
|
||
- **Target:** `customer-auth/app-principal.ts`, `customer-jwt.strategy.ts`,
|
||
`orders.controller.ts`, `orders.service.ts`, `sales-expert-app.controller.ts`;
|
||
both apps' `stores/session.ts`, `api/client.ts`, `stores/queryCache.ts`,
|
||
`stores/favoritesStore.ts`, `stores/wageQuoteStore.ts`,
|
||
`views/expert/CustomerChoiceView.vue`, `components/expert/ActingAsBar.vue`,
|
||
`App.vue`.
|
||
- **Matrix row:** EXP-01, EXP-02, EXP-03 in `ISSUES.md`; D29 in `DECISIONS.md`;
|
||
D21's "deliberately not ported" paragraph marked superseded.
|
||
- **Commit:** see below.
|
||
|
||
**Scope is the reference's, in full.** Its axios interceptor swapped the bearer
|
||
token, so *every* request went as the customer — including document upload. No
|
||
route is closed in act-as mode. (The plan had proposed closing documents and
|
||
logout; the owner asked for the reference's behaviour, so that was dropped.)
|
||
|
||
**The mechanism is not the reference's.** It minted a permanent `api_token` per
|
||
customer and handed it over. Instead the expert keeps their own token and sends
|
||
`X-Act-As-Customer`, resolved in one place — `CustomerJwtStrategy.validateExpert`.
|
||
That single point is why all 17 customer routes work unchanged: `validate()`
|
||
already returns a principal whose `id` is a Customer id, and every customer route
|
||
reads it. Four consequences, each fixing a real defect of the original:
|
||
|
||
1. **Ownership is re-checked on every request**, with `referredByAgentId` in the
|
||
same `where` as the id — so another expert's customer and a non-existent id
|
||
give the same answer (403), and a customer moved to another expert loses
|
||
access at once rather than at token expiry.
|
||
2. **Nothing durable is handed out.** The reference's token sat in
|
||
`localStorage` with no expiry and kept working after the expert left. Here
|
||
the target is in `sessionStorage` and is only a hint; the authority is the
|
||
expert's own token plus the per-request check.
|
||
3. **The real actor survives.** `adminUserId` is kept, so an order placed this
|
||
way is logged as `sales expert <id> acting as them`. In the reference the
|
||
request was literally the customer's and nothing could be attributed
|
||
afterwards.
|
||
4. **403, not 401**, for a customer that is not theirs: the expert's own session
|
||
is valid, so the app clears the act-as state instead of logging them out.
|
||
|
||
**Performance.** One `findFirst` on the primary key, and only when the header is
|
||
present — it *replaces* the `tradingCustomer` lookup rather than adding to it, so
|
||
the query count is unchanged in act-as mode and untouched for everyone else.
|
||
|
||
**Three things the reference did not need, because it swapped the whole session.**
|
||
Found by reading the stores rather than assuming:
|
||
|
||
- `queryCache` (`me:` keys), `favoritesStore` and `wageQuoteStore` each dropped
|
||
their data on a **token** change. Act-as changes whose data they hold without
|
||
changing the token, so without this an expert would have seen their own
|
||
profile, orders, balance, ledger and favourites inside the customer's account.
|
||
The wage-quote one is the money case: making charge is per customer, so the
|
||
figure on screen would not have been the one the order was priced at. Each
|
||
watcher's own comment already stated the rule it was about to break.
|
||
- `uploadDocument` bypasses `request()` and builds its own headers, so an
|
||
expert's upload would have been filed against their own account, silently.
|
||
`grep Authorization` on the client returning two call sites is what caught it.
|
||
- The cart is in `localStorage`, so it is cleared in **both** directions —
|
||
otherwise the expert's own basket follows them in and ends up on the
|
||
customer's order.
|
||
|
||
**Added on purpose, with no reference equivalent:** the permanent «در حال مشاهده
|
||
بهعنوان …» bar. There, impersonation was invisible once the token had been
|
||
swapped — the only way to tell whose account you were in was to notice the data
|
||
looked wrong, and placing an order on the wrong customer's gold debt was one tap
|
||
away. It is also the exit; the reference's only way out was the logout button,
|
||
which ended the expert's own session too.
|
||
|
||
**Verification.** `CustomerJwtStrategy` had **no test coverage at all** before
|
||
this, and it is now where the whole authorisation boundary of the feature lives,
|
||
so `act-as-customer.spec.ts` covers 11 cases: the happy path, the scoped query
|
||
itself, another expert's customer, an inactive customer, a deactivated expert, a
|
||
demoted expert, the header being **ignored** on a customer token, the no-header
|
||
fallback (asserting the extra query does not run), blank and duplicated headers,
|
||
and a revoked session short-circuiting before the header is read. Plus 2 cases on
|
||
the order audit trail. Full api suite **308/308, 28 suites**; both apps
|
||
`vue-tsc` clean.
|
||
|
||
_No data was deleted in this entry._
|
||
|
||
---
|
||
|
||
## 2026-10-12 — Apps — back navigation and read freshness (NAV-01…04, READ-01…03)
|
||
|
||
- **What:** The back arrow follows real history instead of a fixed route; three
|
||
`push`es that stranded the user became `replace`; screens showing
|
||
server-owned data re-read it on entry.
|
||
- **Why:** owner report — «از صفحه پروفایل وارد صفحه لاگین میشم بک میزنم میره به
|
||
صفحه اصلی در صورتی که باید بیاد توی صفحه پروفایل»— with the instruction to
|
||
audit the whole structure, not just that case. Seven defects, four of them
|
||
reachable in ordinary use.
|
||
- **Reference:** the fixed-destination back arrow is inherited —
|
||
`henza_pwa/.../profile.vue:6`, `Orders.vue:8`, `transactions.vue:7`,
|
||
`favorits.vue:5` etc. all `$router.push({name:'userPage::hasSubRoute'})`. Not
|
||
replicated (D4: behaviour, not breakage).
|
||
- **Target:** both apps' `components/ui/PageBar.vue`, `stores/navStore.ts`,
|
||
`router/index.ts`, `main.ts`, `views/user/LoginView.vue`,
|
||
`views/user/CompleteProfileView.vue`, `views/user/OrdersView.vue`,
|
||
`views/user/TransactionsView.vue`, `views/user/DashboardView.vue`,
|
||
`views/HomeView.vue`, `stores/queryCache.ts`.
|
||
- **Matrix row:** NAV-01…04, READ-01…03 in `ISSUES.md`.
|
||
- **Commit:** see below.
|
||
|
||
**The reported bug, precisely.** `/user` has no auth guard, so a guest sees the
|
||
dashboard and taps «ورود / ثبتنام». History is then dashboard → login. Back
|
||
should return to the dashboard, but `LoginView` declared `back-to="home"` and
|
||
`PageBar` **pushed that name**, discarding history. The same was true of 14 of
|
||
the 15 `PageBar` uses in each app — only `SearchView` used `router.back()`. So
|
||
this was never one screen's bug.
|
||
|
||
**`backTo` is now a fallback, not a destination.** The 14 declarations are kept
|
||
untouched, because they are the right answer when there is genuinely nowhere to
|
||
go back to — a shared link, a refresh. What changed is that history wins when it
|
||
exists.
|
||
|
||
**Answering "is there anywhere to go back to?" honestly.** `history.length` is
|
||
the obvious choice and it is wrong: it counts entries from before the app was
|
||
opened, so a link opened in a fresh tab looks like it has history and the back
|
||
button walks the person out of the app. `navStore` instead records the history
|
||
`position` of the app's first navigation and compares the current one against it.
|
||
Chosen over a navigation counter because a counter only grows — after one
|
||
back-and-forward it would claim depth that is not there. Confirmed vue-router
|
||
writes `position` on every entry (`vue-router.mjs:97,109,145`) rather than
|
||
assuming it.
|
||
|
||
**Three `push`es that should always have been `replace`.** Each left behind a
|
||
page the guards immediately bounce, so "back" was either a no-op or a trip home:
|
||
after sign-in the login screen stayed in history (the worst of the three — the
|
||
file already honoured `?redirect=`, so sign-in and back disagreed about where the
|
||
person belonged); the 401 handler kept the page whose request had just failed;
|
||
and complete-profile kept a form that is unreachable once the documents are in.
|
||
`CartView`'s post-order push was **left alone** — back lands on the emptied
|
||
cart, which is a real screen rather than a dead end.
|
||
|
||
**Read freshness, by the owner's rule** («هربار با رفتن به اون صفحه اون مقادیر
|
||
مورد نیاز رو درخواست بزنه») — chosen over the TTL that had been proposed,
|
||
and the better call: a TTL guesses how long data stays true, while entering a
|
||
screen is when it actually matters.
|
||
|
||
The worst finding here was **OrdersView**: it is in the keep-alive list but only
|
||
loaded in `onMounted`, so **the second and every later visit made no request at
|
||
all**, and the first one read the cache. Order status changes on the admin side
|
||
and nothing in the app invalidates it, so the list could be stale for an entire
|
||
session. Ledger entries had the same shape and a stronger argument — they are
|
||
written by the accounting side, which this app can never learn about. The gold
|
||
price was forced only by pull-to-refresh on two screens that show it
|
||
prominently, and `HomeView` being kept alive meant once per app launch.
|
||
|
||
**What is deliberately NOT re-read: the catalogue.** By the owner's own
|
||
reasoning («سربار برای سرور نداره»): it is the whole active product list in one
|
||
response, it changes when an admin edits it, and pull-to-refresh and placing an
|
||
order already invalidate it. Re-fetching it on every tab switch is exactly the
|
||
needless load the ruling exists to avoid. So on Home only the price is re-read.
|
||
|
||
**On the cache layer, which the owner also asked about.** Its structure is
|
||
sound, and two things that are easy to get wrong are right: concurrent callers
|
||
share one in-flight promise, and a response arriving after an `invalidate` is
|
||
returned to its caller but not stored, so a write cannot lose a race against a
|
||
read already in flight. Its one real gap was that nothing ever went stale, which
|
||
the above fixes at the only point where staleness is observable. The policy is
|
||
now written at the top of `queryCache.ts` so a TTL is not re-introduced later.
|
||
|
||
**Verification.** Neither app has a test runner, so: `vue-tsc -b` clean on both,
|
||
and each finding was confirmed by reading the code that causes it — the
|
||
keep-alive list against each view's hooks, every `router.push` against the
|
||
guards, and `grep back-to=` across both apps. api suite untouched at 308/308.
|
||
⚠️ Not click-tested in a browser; the back-button behaviour in particular is
|
||
worth one pass by hand.
|
||
|
||
_No data was deleted in this entry._
|
||
|
||
---
|
||
|
||
## 2026-10-12 — Catalogue paging, steps 1–2: indexes and the server routes
|
||
|
||
- **What:** `Product` and its two child tables get the indexes the storefront
|
||
query actually needs, and `GET /catalog/products` learns paging, filtering,
|
||
sorting, a by-ids mode, a single-product route and a facets route.
|
||
- **Why:** owner — «دیتاها در اپ واقعی تا 16 هزار رکورد میرود و گرفتن همه یک جا
|
||
اشتباه است، حتما باید پیج نیشن داشته باشد». The route had **no** paging
|
||
parameters at all and returned every active product with its variants, images,
|
||
category, brand and collections in one response; the apps' `PAGE_SIZE = 20`
|
||
was only a render window over that full array, so scrolling made zero
|
||
requests.
|
||
- **Reference:** the reference DID page this server-side; the port moved it to
|
||
the client. Logged as STUB-03, so this is a return to reference behaviour.
|
||
- **Target:** `apps/api/prisma/schema.prisma`,
|
||
`prisma/migrations/20261012120000_catalog_paging_indexes/`,
|
||
`modules/catalog/catalog.controller.ts`, `catalog.service.ts`,
|
||
`dto/list-products.dto.ts`, `catalog-paging.spec.ts`.
|
||
- **Matrix row:** STUB-03 in `ISSUES.md`; plan in `docs/CATALOG-PAGINATION-PLAN.md`.
|
||
- **Commit:** see below.
|
||
|
||
**The indexes were not optional.** `Product` carried **no index whatsoever** — not
|
||
`isActive`, not `categoryId`, not `brandId`, not `createdAt` — and
|
||
`ProductVariant` had none on `productId`, so each per-product include scanned
|
||
its whole table. Adding `take`/`skip` on top of that would have paged the
|
||
response without making the query any faster, which is the failure mode this
|
||
change exists to avoid. `Product_isActive_createdAt_idx` is composite on purpose:
|
||
it serves the filter, the default ordering and the OFFSET walk from one index.
|
||
|
||
`CREATE INDEX` and not `CONCURRENTLY`: the deploy image's own migration runner
|
||
wraps each file in a transaction, which `CONCURRENTLY` cannot join (D15). At 16k
|
||
rows the write lock is a few hundred milliseconds. Every statement is
|
||
`IF NOT EXISTS`, because these run under both migration paths.
|
||
|
||
**Paging is opt-in, and that is the whole backward-compatibility story.** With no
|
||
query parameters the route returns the same plain array from the same cache it
|
||
always did — the admin panel, the smoke scripts and installed app builds are
|
||
untouched. Pass `page` or `limit` (or any filter) and the answer becomes
|
||
`{ rows, total, page, limit }`.
|
||
|
||
**The filters had to move server-side, not just the paging.** The apps filtered
|
||
title, category, brand and gram range in the browser over the full array. Paging
|
||
the response while leaving that in place would have made search **quietly
|
||
wrong** — searching only the first page and reporting "محصولی پیدا نشد" for
|
||
products that exist. That is worse than the slowness, so `storefrontWhere`
|
||
builds every filter into the Prisma query, and `count` uses the same `where` so
|
||
`total` describes the filtered set the app is scrolling.
|
||
|
||
**Three routes exist so the apps can stop fetching everything to find one thing**
|
||
(step 3 wires them up): `?ids=a,b,c` for the cart, favourites and order items;
|
||
`/:id` for the product screen, which currently downloads the entire catalogue to
|
||
`find()` one row; and `/facets` for the filter sheet's options, which the apps
|
||
derive today by scanning the whole catalogue in the browser.
|
||
|
||
**Deliberately NOT cached.** `CatalogCacheService` holds one built response
|
||
because the unpaged query is expensive and every screen asked for it. A paged,
|
||
filtered query is neither — it is bounded by `limit` and index-served. Caching it
|
||
would need a key per filter combination: unbounded memory for queries that are
|
||
already cheap, and a second place for stale rows to hide. Noted in the code so
|
||
it is not "optimised" later.
|
||
|
||
**Guards kept everywhere.** D27's two storefront rules (active, and has at least
|
||
one image) are in one shared `where`, so a paged list, its count, the single
|
||
product route, the by-ids route and the facets can never disagree about what is
|
||
visible. Asking for a hidden product by id still 404s. `limit` is capped at 100
|
||
and `ids` at 100 — an uncapped `limit` would just be the old "give me
|
||
everything" query spelled differently.
|
||
|
||
**Verification.** 17 new cases (329/329 overall, 29 suites), covering the
|
||
compatibility shape, skip/take translation, that `total` counts the filtered set,
|
||
each filter reaching the DB, the gram range matching ANY variant as the app did,
|
||
by-ids ignoring paging, and a hidden product 404ing. All 17 passed on the first
|
||
run, which is not evidence on its own — so one filter was deliberately broken to
|
||
confirm the suite fails (it did, 1 failed/16 passed) and then restored.
|
||
|
||
⚠️ Measured on **no real data**: the database container was not running, so the
|
||
16k-row behaviour is reasoned from the query plan and the new indexes, not timed.
|
||
Worth one `EXPLAIN ANALYZE` on a full dataset before step 4.
|
||
|
||
_No data was deleted in this entry._
|
||
|
||
---
|
||
|
||
## 2026-10-12 — Catalogue paging, step 3: the apps stop fetching everything
|
||
|
||
- **What:** Five places that loaded the whole catalogue to find a few rows now
|
||
read exactly what they need — the product screen, the cart re-sync,
|
||
favourites, the home categories, and order-row labels.
|
||
- **Why:** step 3 of `docs/CATALOG-PAGINATION-PLAN.md`. Step 2 added the
|
||
targeted routes; these are the callers. At ~16k products each of these was
|
||
downloading the entire catalogue, and three of them would have been **wrong**
|
||
as well as slow once the list is paged.
|
||
- **Target:** both apps' `api/client.ts`, `composables/useProductDetail.ts`,
|
||
`composables/useOrderItemProduct.ts`, `stores/cartStore.ts`,
|
||
`stores/configStore.ts`, `stores/catalogStore.ts`,
|
||
`views/user/FavoritesView.vue`, `views/HomeView.vue`,
|
||
`components/product/OrderItemCard.vue`; api
|
||
`modules/customers/customers.service.ts` + `order-items-include.spec.ts`.
|
||
- **Matrix row:** STUB-03 in `ISSUES.md`.
|
||
- **Commit:** see below.
|
||
|
||
| Caller | Was | Now |
|
||
|---|---|---|
|
||
| product screen | whole catalogue, then `find(id)` | `GET /catalog/products/:id` |
|
||
| cart re-sync | whole catalogue for 2–3 lines | `?ids=` for the cart's products |
|
||
| favourites | whole catalogue, filtered in the browser | `?ids=` for the saved ids |
|
||
| home categories | whole catalogue, categories derived from it | `GET /catalog/products/facets` |
|
||
| order row labels | whole catalogue, joined on variantId | included with the order |
|
||
|
||
**Three of these were not just slow, they were going to be wrong.** Favourites
|
||
filtered the loaded catalogue, so once the list pages, a favourite outside the
|
||
loaded slice silently disappears. The home categories were derived the same way,
|
||
so a category whose products all sit further down would vanish from the home
|
||
screen. Order rows looked their title up in the catalogue, which **already**
|
||
failed for a product that had since left the storefront — exactly the old orders
|
||
that history exists for — and rendered a blank row.
|
||
|
||
**Order labels moved to the server rather than to another id lookup.** The item
|
||
carries `productVariantId`, not a product id, so `?ids=` could not answer it.
|
||
One nested `include` in `getOrders` returns the variant, its product and the
|
||
product's first image: one join instead of 16k rows, and it answers for
|
||
de-listed products too, because order history is deliberately NOT filtered by
|
||
storefront visibility (asserted in the new spec). `OrderItemCard` is now
|
||
synchronous — no fetch, no `onMounted`, the row renders complete on its first
|
||
frame.
|
||
|
||
**`variantLookup` was deleted from `catalogStore`**, not left behind: it existed
|
||
only to label order rows, and keeping it would have kept a reason to load the
|
||
full catalogue.
|
||
|
||
**`getProductsByIds` chunks at 100** because the route caps `ids` there and
|
||
drops the rest. A partial answer would be read by callers as "these products are
|
||
no longer on the storefront" — the cart would silently drop valid lines.
|
||
Favourites already run to 874 rows in the imported data (FAV-01), so this is a
|
||
live case.
|
||
|
||
**A failed cart re-sync now leaves the cart alone.** It is the person's own
|
||
data in localStorage; dropping lines because the network blinked is worse than
|
||
showing a slightly stale price.
|
||
|
||
**The product screen tells 404 from "request failed".** Before, a failed
|
||
catalogue fetch left `notFound` false, which was right — the product probably
|
||
exists. Only a real 404 renders the not-found page. If the page was already
|
||
seeded from the tapped card and the product has since been de-listed, the card's
|
||
copy stays on screen rather than blanking mid-read; the cart refuses it at
|
||
checkout.
|
||
|
||
**One bug introduced and caught here:** moving the home categories to `/facets`
|
||
meant `memory.init()` no longer loaded the catalogue as a side effect — and the
|
||
home rails read `catalog.products`, so the home screen would have rendered empty
|
||
rails. `HomeView` now loads the catalogue explicitly. Found by grepping every
|
||
caller of `configStore.init` rather than by running the app, which is also how I
|
||
know `HomeView` is the only caller.
|
||
|
||
**Still on the unpaged list, by design:** the home rails and the search/newest
|
||
lists. They slice locally today; moving them onto paged reads with server-side
|
||
filtering is step 4, kept separate because it is the risky part (debounce,
|
||
loading state, telling "no results" from "still loading").
|
||
|
||
**Verification.** api 334/334 across 30 suites; both apps `vue-tsc` clean. The
|
||
`getOrders` include has a new spec (3 cases) because it is now a contract both
|
||
apps depend on and the method had no test at all. ⚠️ Nothing click-tested: the
|
||
cart re-sync and the favourites screen are the two worth a manual pass.
|
||
|
||
_No data was deleted in this entry._
|
||
|
||
---
|
||
|
||
## 2026-10-14 — Owner request (not parity) — add/edit product UI/UX redesign
|
||
|
||
- **What:** redesigned the Add/Edit Product page (`AddProduct.vue` / `EditProduct.vue`)
|
||
with a modern e-commerce dashboard architecture. Introduced a 2-column layout
|
||
(content/variants in primary 1fr column; publication status and media uploaders
|
||
in dedicated 340px sidebar); added a top page header with breadcrumb navigation,
|
||
accounting-sync indicators, and dual fast-save actions; redesigned the variants
|
||
table with badge columns, inline actions, empty states, and clean weight/wage
|
||
formatting; streamlined the variant modal; unified the gallery slots with clean
|
||
site/mobile segmented tabs and compact upload grids; strictly preserved the
|
||
PROTECTED §B variant gallery image picker (64x64 grid with `.is-on` selection).
|
||
All schemas, fields, accounting locks (D27), permissions, and payloads preserved.
|
||
- **Why:** owner requested professional, efficient, high-end SaaS UI/UX overhaul
|
||
for Add Product.
|
||
- **Reference:** n/a (visual layer overhaul; all fields, validations, and logic preserved)
|
||
- **Target:** `apps/admin-panel/src/views/apps/product/AddProduct.vue`
|
||
- **Matrix row:** n/a
|
||
|
||
_No schema change, no data deleted._
|
||
|
||
---
|
||
|
||
## 2026-10-14 — Owner request (not parity) — add/edit customer UI/UX redesign
|
||
|
||
- **What:** redesigned the Add Person (`/customer/add`) page and its wholesale
|
||
(`bulkCustomer.vue`), sales expert (`ReagentCustomer.vue`), and retail
|
||
(`singleCustomer.vue`) forms. Replaced nested double/triple card boxes with
|
||
semantic `.form-section` panels featuring clean icon badges, titles, and
|
||
descriptions; improved input grid rhythm (3-column responsive layout with
|
||
clear required asterisks and placeholders); redesigned document upload blocks
|
||
as dedicated dash-bordered upload cards; upgraded the sales-expert avatar
|
||
slot; added a top page header with back navigation; unified the action bar
|
||
with loading indicators and explicit cancel/back buttons. Both full-page and
|
||
modal forms (`asModal`) upgraded.
|
||
- **Why:** owner requested professional SaaS-grade UI/UX overhaul starting with
|
||
the Add Person forms.
|
||
- **Reference:** n/a (visual layer overhaul; all fields, validations, and logic preserved)
|
||
- **Target:** `apps/admin-panel/src/views/apps/customer/AddCustomer.vue`,
|
||
`apps/admin-panel/src/views/apps/customer/bulkCustomer.vue`,
|
||
`apps/admin-panel/src/views/apps/customer/ReagentCustomer.vue`,
|
||
`apps/admin-panel/src/views/apps/customer/singleCustomer.vue`
|
||
- **Matrix row:** n/a
|
||
|
||
_No schema change, no data deleted._
|
||
|
||
---
|
||
|
||
## 2026-10-14 — Owner request (not parity) — admin dashboard visual unify
|
||
|
||
- **What:** unified dashboard chrome with the rest of the admin panel — removed
|
||
radial wash / Mentzer badge / gold period chips / multi-shadow cards / KPI lift
|
||
hover and coloured side bars; muted period text; flat navy KPI icons; equal
|
||
sales-summary cells. Same data, block order, charts, sorts, and API.
|
||
- **Why:** owner asked for a principled restyle; PROTECTED §A chrome updated with
|
||
approval.
|
||
- **Reference:** n/a (target-only dashboard; reference dashboards were demo husks)
|
||
- **Target:** `apps/admin-panel/src/views/Dashboard.vue`,
|
||
`apps/admin-panel/src/components/statistics-cards/StatisticsCardLine.vue`,
|
||
`docs/parity/PROTECTED.md` §A,
|
||
`docs/superpowers/specs/2026-10-14-admin-dashboard-visual-unify-design.md`
|
||
- **Matrix row:** n/a
|
||
|
||
_No schema change, no data deleted._
|
||
|
||
---
|
||
|
||
## 2026-10-14 — Owner request (not parity) — super-admin-only admin features (D30)
|
||
|
||
- **What:** retail customers, bankers, walk-in shopkeepers, branches and
|
||
workshops, the «مشتری تک» tab, trays («سینی ها», product field + Const tab),
|
||
retail orders (create + list), retail promotions (add/edit/delete) and wallet
|
||
management are reserved to the 09128081392 account — hidden in the menu and
|
||
pages, blocked by route, 403 from the API, and removed from the permission tree
|
||
for everyone else.
|
||
- **Why:** owner ruling D30. Deliberately not reference behaviour.
|
||
- **Reference:** n/a
|
||
- **Target:** API — `auth/super-admin.ts` (new), `auth/guards.ts`
|
||
(`@SuperAdminOnly`, multi-rid `@RequirePermission`), `auth/jwt.strategy.ts`,
|
||
`auth/auth.service.ts` (`is_super_admin`), and the branches, workshops,
|
||
customers, orders, documents, categories and products controllers. Panel —
|
||
`utils/superAdmin.ts` (new), `stores/users.ts`, `stores/auth.ts`,
|
||
`router/index.ts`, `router/admin.ts` (16 routes), `layouts/navMenuItems.ts`,
|
||
`NavMenuNode.vue`, `VerticalNavMenu.vue`, `AddCustomer.vue`, `AddProduct.vue`,
|
||
`Const.vue`, `promotion_list.vue`, `Permission.vue`.
|
||
- **Tests:** `auth/guards.spec.ts` (new, 8 cases). api 342/342 across 31
|
||
suites; api `tsc` and admin-panel `vue-tsc` clean. ⚠️ Not click-tested.
|
||
- **Matrix row:** n/a
|
||
|
||
_No schema change, no data deleted._
|