- Rial wage and gram-debt arithmetic matched to the reference; order corrections keep debt in sync - Document confirm/un-confirm is a compare-and-swap (no double-applied cache under concurrency) - Settlement document and expert inputs validated (400 instead of 500) - Expert: edit / activate-deactivate own customers, adjust raw weights only - Made-to-order flow in the app, PreOrder.quantity migration - Order grids: customer column, sort keys; panel select search; pre-order quantity save - Rebuilt release artifacts Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
85 KiB
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-0anddocs/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()fromAccountingControllerand 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(theauth:apiwrapper 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, leavingPATCH orders/:id/statusbehindorder.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 /ordersandGET /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 pointedlist()at it. RemovedfullName,nationalCode,goldDebtGrams,fiatDebtRial,goldCreditLimitGrams,verificationStep,cityId,referredByAgentId,nationalIdImageUrl,businessLicenseUrl,shopImageUrl,createdAt,updatedAtfrom 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, plusCustomerSummarynarrowed 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 thatGET /customersexposes 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 fromJWT_SECRET, and every token carriestyp: '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:apifor admins,auth('api-customer')for the app.
2. Real OTP (D6)
- New table
CustomerOtp(migration20260922000000_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'sfirstOrFail()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
SmsMessagerow. 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 itGET /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. CustomersServicestays 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:tokenis now the credential andisLoggedInis token-based.customerIdis kept for display only and is no longer an identity claim.api/client.ts: sendsAuthorization: Bearer, throwsUnauthorizedErroron 401 and calls the handler registered inmain.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 testisLoggedIn.
Tests
apps/api/scripts/auth-smoke.shextended 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/mereturning the token's own customer.- jest installed (dev-only, user-approved) with
jest.config.cjs, sopnpm testworks for the first time: 7 passed coveringfaToEn.faToEnmoved to its owndigits.tsso 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.createOrdernow takescustomerIdas a separate argument, supplied by the controller from the token. The body'scustomerIdis ignored entirely; a mismatch is logged (OrdersServicewarn) and the token always wins. CreateOrderDto.customerIdand the sharedCreateOrderDtoare optional, so both apps keep validating while they still send it. Removed in Phase 2.- Reference:
ApiMobileController::setOrderreadauth()->id()(henza_server:533) behind theapi-customerguard — this restores that. - Admin path unaffected:
AdminOrdersServiceplaces 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/:idroutes had. A nonexistent id and someone else's id are indistinguishable.- Operators use the new
GET /orders/admin/:idbehind ridorder.list. Declared before:idbecause Nest matches in declaration order.
3. Both apps
CartView.vuein both apps now gates onsession.isLoggedIn(the token) rather than on a rememberedcustomerId, 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 afterbcrypt.compare. - SMS provider: none. No gateway is wired up (STUB-02). In development the
code is logged once via
Logger, guarded byNODE_ENV !== 'production'. - Fixed while auditing:
deliver()persisted the real message text — with the code in it — intoSmsMessage.body, which the admin SMS grid both lists and full-text searches (admin-misc.service.ts:135-136). Any operator with thesmsrid could read live OTP codes, defeating the point of hashingcodeHash. 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)
- Migrations run only through the standard Prisma workflow —
migrate devin development.prisma db executeis no longer used to apply schema changes. (It was used once, in the security hotfix, to create theCustomerOtptable; that is what the resolve below repairs.) - 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.phphas 151 live explicit routes, not the 117 the Phase 0 inventory recorded (DOC-01). Of the 412, 109 are retail storefront, 15 areweb.phpdev/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_pwacomputespercent_pay,discountandrial_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_weightstays 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 realfname/lname(D0) andpercentnow readsmakingChargeOffsetPercentfrom the customer instead of the referring agent's commission rate (D9 / CUST-02).create()stores the name parts, and requirespercentwith the reference's own message,وارد کردن percent الزامی است— Laravel's stockrequiredtext (resources/lang/fa/validation.php:99) with no attribute translation forpercent, so it is verbatim.update()writes the parts (letting the trigger rebuildfullName) and persistspercent, which it previously ignored entirely.updateCredit()acceptedpercentin its signature and never wrote it — fixed.- Sort/filter aliases
fname/lnamepointed atfullName; they now point at the real columns, andpercentis 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 ofSms.php:57-82: POST, form-urlencoded, 20s timeout,PhoneNumber/Username/Password/Mobile/Message, body trimmed, every message persisted asSms::set()did. - Failure handling matches the reference:
send()never throws. The reference discardedSms::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 inSmsSendResult. - OTP text is byte-identical to
ApiMobileController.php:47, including the<#> code is …first line and the trailingaMo6iFyUQaY— the Android SMS Retriever hash. A unit test asserts the exact string. assertSmsConfigured()runs inmain.tsbefore 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.examplelists the six names with empty values;.envis 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— thesplit(' ')fallback is removed (D0 item 6).bulkCustomer.vue—percentdefaults 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;nameis only sent when a name part actually changed, so the whole name is no longer re-submitted on every save.ReagentCustomer.vue— unchanged. Its «درصد کمیسیون» isAdminUser.wagePercent, a genuinely different field fromcustomers.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
lnamechangeslastNameandfullName(trigger) and nothing else → changing onlypercentchanges 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.tsand its 11 tests are kept and still run; they are simply not used.- Nothing needed reverting.
computeVariantPricewas never called from anywhere outside its own file and spec — verified by grep overapps/api/srcandapps/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.mdPRICE-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 theadmin_user_sync_full_nametrigger (parts win when both change). Applies to all admins, closing D0 requirement 7.- Profile backfill —
INSERT … WHERE NOT EXISTSgave 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:
- Profiles for existing experts + an
upsertin the service on create and on a role change toSALES_EXPERT, andlistReagentsleft-joins so an expert without a profile still lists (verified by test). gramLeveltype matched toCustomer.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
listReagentsreturns realcity_id,city,gram_level,phone,ncode; the grid's «شهر» and «سطح اعتبار» filters map through the relation.remain/rial_remainstay null — a sales expert is an AdminUser and has no ledger of its own (D10).findOneincludes the profile so the edit form populates.createstores name parts and creates the profile for a sales expert;updatewrites the parts and upserts the profile.- Every remaining
fname: fullName, lname: ''was fixed — 6 more sites inadmin-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 noCustomerOtprow, and it still enforces "an active, non-archived customer must exist". Every use is logged with the customer's mobile.main.tsprints a boot banner while armed. Token issuance was factored intoissueToken()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. faToEnon admin-entered mobiles (D19 gap 2) —admin-customers.service.ts(create,update) andadmin-users.service.ts(create,update). PortsUtility::faTOen(AdminController.php:333,364). Inadmin-usersthe normalisation runs before the uniqueness lookup, which previously let the same number be registered twice as۰۹۱۲…and0912….- Welcome SMS on customer create (D19 gap 1) —
admin-customers.service.ts. Byte-identical toAdminController.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. PortedcapQty,maxQty, the un-orderable guard inaddToCart(now returning a boolean) and the clamp inupdateFeild. The two stores are now byte-identical. composables/useProduct.ts— portedisVariantOrderableand the orderable-first default-variant selection. v2's variant-imageimagecomputed 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—addToCartnow 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 withOTP_MASTER_CODE: 8/8. Signs the customer in, the JWT works onGET 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.2957per unit, ×2 =16.5913total), 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:
OrdersServicenow injectsSmsServiceand, after the order transaction commits, texts three audiences: the admins listed inORDER_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
OrdersServicedid not injectSmsServiceat 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_MOBILESpattern; 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
@RequirePermissiondecorators andpermissionConst.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 —
newslettersandpersons.list.sellers.view— are logged as PERM-02; both are inherited from the reference, whose ownpermissionConst.tsheader 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 recordsreconcile → 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 fordiscountPercent, plus any non-zeroflatRialCharge). 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 besideadminMobiles). - 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
isLoadedrouter 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_twoguard 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(theisLoadedguard),router/customAnalytics.ts+henza_server/app/Http/Controllers/Api/LogController.php(analytics). - Target: both apps'
src/router/index.tsandsrc/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:
- A failed SMS send also returned
{ sent: true }, becauseSmsServicenever 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 offSmsService's ownsms-not-configuredresult rather than re-testing the config — with D26's defaultrelaydriver,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. - 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; serverApiMobileController.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 inDECISIONS.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:
- Ownership is re-checked on every request, with
referredByAgentIdin the samewhereas 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. - Nothing durable is handed out. The reference's token sat in
localStoragewith no expiry and kept working after the expert left. Here the target is insessionStorageand is only a hint; the authority is the expert's own token plus the per-request check. - The real actor survives.
adminUserIdis kept, so an order placed this way is logged assales expert <id> acting as them. In the reference the request was literally the customer's and nothing could be attributed afterwards. - 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),favoritesStoreandwageQuoteStoreeach 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.uploadDocumentbypassesrequest()and builds its own headers, so an expert's upload would have been filed against their own account, silently.grep Authorizationon 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
pushes that stranded the user becamereplace; 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:5etc. 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 pushes 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:
Productand its two child tables get the indexes the storefront query actually needs, andGET /catalog/productslearns 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 = 20was 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 indocs/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; apimodules/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-onselection). 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-sectionpanels 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; apitscand admin-panelvue-tscclean. ⚠️ Not click-tested. - Matrix row: n/a
No schema change, no data deleted.
2026-10-29 — Money audit against henza_server/henza_pwa — wage, rial wage, gram debt
Unit confirmed by the owner: rial_pay, rial_remain and every rial figure are
ریال (the reference PWA's «تومان» suffix was a labelling slip). Nothing to convert.
- Verified equal to the reference (parity test, hand-typed PHP): order grams /
making-charge grams / rial charge (
setOrder), debt only while delivered (updateRemain), melted-gold and cash conversions (CashController::store).accounting/reference-parity.spec.ts. - Fixed — formula: the promotion exclusion list («استثنا») is honoured when pricing
(
pricing/wage.service.ts; referencecheckIgnoreList). A product, category, brand or customer on it no longer gets a discount taken off its making charge. - Fixed — gram debt: editing an unconfirmed melted-gold / cash document recomputes
the credited grams from the corrected raw figures and stores the karat / price per gram
(
admin-documents.service.ts, sharedrepaymentToGrams). A document an admin files is confirmed at once, asCashController::storedoes (confirmed = 1); only a customer's own submission would wait (set-findoc, not built). - Fixed — rial wage display: the app cart, cart total and order detail show «اجرت
ریالی»; the wholesale and sourcing grids bind the columns the API now returns (
input,wage,rial_input,discount) and the order modal shows each line's weight and rial charge × quantity. - Added — reference features that were missing: admin line edit / delete
(
order-item/edit,DELETE major/order_item/{id}) and the sales expert's real-weight entry + «ثبت نهایی» (PUT order/child/{id}). Both recompute the order's totals and the debt server-side (a correction entry on the ledger) and re-queue an already-sent accounting document. Reference defects NOT copied:orderItemEditset the order'swageto its whole weight and leftrial_inputstale;updateChildOrderdropped the making charge (read apercent_payits lines do not have) and let any customer edit any order. - Fixed — consistency: a cancelled order no longer counts in «میزان فروش» (legacy
deleted it); the panel's order builder prices a line with no typed percent like the app
does (customer % + variant % − promotion); the accounting document's weight check allows
for the four-decimal rounding of stored unit weights (
accounting-sync/document-check.ts). - Left alone, owner's call: dashboard «طلای فروشرفته» counts delivered orders only (reference summed all; area is in PROTECTED.md).
2026-10-29 — Reference app menus that were missing in user-app-v2
Checked every entry of henza_pwa (router, dashboard, «مشتریان من») against the app.
«سفارش مشتریان» (ChildOrder) already existed. What was missing, now added:
- «مشتریان من» — فعال/غیرفعال کردن and ویرایش (reference
customer/toggle-active,customer/update):PUT expert/customers/:id,POST expert/customers/:id/toggle-active(sales-expert-app). Scoped to the expert's OWN customers (the reference toggle checked nothing). The edit takes contact details only — name, mobile (one-mobile-one-person rule), phones, store, pasazh, address, national code, birth date, city — and never the percent, credit limit, kind, expert or account switches, whatever the request carries. Switching a customer back on texts them, as the reference did. - «سفارش ساخت» (
makeOrder/makeOrder_status, referencecreate-custom-order,get-custom-orders):POST|GET customer/me/pre-orders, two app screens (MakeOrderView,MakeOrderStatusView), a dashboard row with the reference's two-button choice. New columnPreOrder.quantity(legacysum; migration20261029120000_preorder_quantity), shown and editable in the admin grid instead of a hard-coded 1. The reference's «تایید سفارش / ارسال سفارش» buttons for workshop users are not carried over (both posted the same status, with no ownership check). - Not added, owner's call: adding a NEW customer from the app (reference
addUser,customer/createwithfrom_reagent) — it created the account with a fixed 100 g credit limit and 3 % percent; that is a money decision.
2026-10-29 — Browser run against the real stack (Chrome, scratch database)
API (migrated from empty by its own runner), admin panel and user app were started and driven in Chrome. The earlier fixes held; three defects that unit tests could not see were found and fixed:
- Order grids showed no customer. The «مشتری» column of «لیست سفارشهای عمده» / «لیست
تامین» binds
customer.name, which the row never carried (blank cell for every order, since before this audit).admin-orders.service.tsnow sendscustomer.name; covered by a spec. - Typed search in the panel selects found nothing. ant-design-vue 4 hands a custom
filter-optionthe option slot as a function, so the legacyoption.children[0].childrenwas always undefined. Newadmin-panel/src/utils/select.ts(optionText) replaces it in all 11 views that had the pattern (order builders, documents, invoices, branches, categories, promotions, …). - «سفارش ساختنی»: quantity edited in the panel was not saved — the edit field existed but
the save only sent colours.
MakeOrder.vuenow sendssumtoo.
Not touched: /add/findoc (direct link) renders blank — open is a Boolean prop, so Vue
makes it false rather than undefined and the page thinks it is a modal. Nothing links to
the route (the list page opens the form as a modal), so it was left.
2026-10-29 — QA round: money paths, sorting, malformed input
- Money — fixed: confirming / un-confirming a settlement document was check-then-update, so
simultaneous requests (a double-click, two admins) each moved the cached balance. 10 at once
left the book at 8.04 g and
Customer.goldDebtGramsat 71.44 g.LedgerService.setConfirmednow flips the flag as a compare-and-swap and only the winner moves the balance, in one transaction;AdminDocumentsService.toggleConfirmandconfirmEntryuse it, and the customer is texted only when the state really changed. Re-run live: cache = book = recomputed after 10 simultaneous toggles. - 500s turned into 400s: settlement-document figures that are not numbers or do not fit their
columns; the expert weight entry when
itemsis not a list; expert edit fields that are objects. An order line is capped at 100 000 pieces (the panel line edit already was). - Order grids: sorting by «جمع کل سفارش», «اجرت ریالی», «مشتری» answered 400 on the wholesale and sourcing grids. The grid now sorts by a column's filter key when it has one, and the API keeps the default order for a sort field it cannot map instead of failing the request.
- Not changed on purpose: the wallet charge (
customers-omde/wallet/charge) still answers 500 to a non-numeric value; the wallet belongs to the retail site (owner). An admin can still move a credit-limit-SUSPENDED order straight to delivered (the limit is checked at placement only).