Commit Graph
10 Commits
Author SHA1 Message Date
s.esmaeiliandClaude Opus 5 2194b3d4d9 parity(admin): D17 — SalesExpertProfile, AdminUser name parts, agent grid fields
Restores the sales-expert fields that were lost when a sales expert became
an AdminUser instead of a type=1 Customer row.

New SalesExpertProfile holds cityId, gramLevel, phone and nationalCode. A
separate table rather than columns on AdminUser, because these mean
something only for role = SALES_EXPERT. gramLevel is DECIMAL(14,4), matched
to Customer.goldCreditLimitGrams so the same credit limit has identical
precision on both tables. phone and nationalCode copy the reference exactly:
nullable, NOT unique, no format rule — createCustomer validates only mobile
and percent, and both fields are required:false on the reference form.
Inventing a uniqueness constraint would reject data the reference accepts.

reagent_id is deliberately NOT ported (D17). Every use of it in the
reference reads a CUSTOMER's reagent_id, which the target already models as
Customer.referredByAgentId. On a sales expert's own form it was a side
effect of the shared customers form: filling it removed that expert from
the selectable-agent list, because queryReagent filters on
whereNull('reagent_id'). The target's equivalent filters on role and active
only, so no agent can be dropped — strictly better than the reference.

AdminUser gains firstName/lastName with the same additive migration,
backfill and sync trigger as Customer, for all admins rather than only
sales experts. This closes D0 requirement 7, and every remaining
`fname: fullName, lname: ''` was fixed — six further sites across
admin-users, admin-comments, admin-documents, admin-orders and
admin-preorders.

listReagents now returns real city_id, city, gram_level, phone and ncode,
and left-joins so an expert without a profile still lists with empty cells
rather than vanishing. The two grid filters map through the relation. The
service upserts a profile on create and on a role change to SALES_EXPERT,
and the migration backfilled one for every existing expert.

ReagentCustomer.vue gains the four fields plus the city -> commission
autofill, ported verbatim including the reference's falsy test so a city
percent of 0 also falls through to the aux lookup (replicated, not fixed).

Tests: new sales-expert round trip 33/33, covering create with every field,
an edit form with each field populated separately, a byte-identical no-op
save, single-field edits, both grid filters with negative cases, the
left-join case and the referrer dropdown. «محمد علی» + «احمدی» survives
intact. Plus jest 32/32, auth-smoke 53/53, customer trigger 9/9, typecheck
clean on api and admin-panel. Migration dry-run twice in a rolled-back
transaction before applying; drift is zero.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 17:50:47 +03:30
s.esmaeiliandClaude Opus 5 8133a2bdf4 parity(api,admin): split customer name, add making-charge offset, wire SMS
Applies the two approved additive migrations and fixes every consumer.

D0 — Customer.firstName / lastName
The reference has had two columns since 2020
(2020_10_01_120430_customers_table.php:18-19); the port collapsed them, so
the edit form showed the whole name in the "first name" box and saved an
empty last name. Adds both columns, backfills first-token/rest, and keeps
fullName in sync with a customer_sync_full_name trigger. Nothing dropped.

The trigger is SQL, so Prisma cannot see it: writing the parts rebuilds
fullName, writing fullName re-derives the parts, and writing both in one
statement lets THE PARTS WIN. Documented in the migration header, the
Customer model doc comment and DECISIONS.md §D14, because a write can now
store something other than what was passed.

D9 — Customer.makingChargeOffsetPercent
Legacy customers.percent, the per-customer making-charge offset that the
app adds to the variant's making charge (henza_pwa
Mixins/product/index.ts:69-75). toRow() was returning the referring
AGENT's commission rate instead (CUST-02). DB default 0 reproduces
today's behaviour exactly, so no existing customer's price moves; the
reference's application-layer default of 3 for NEW customers
(AdminController.php:338) lives in the create form, and the field is
required on create with the reference's own message (:354).

update() and updateCredit() both accepted percent and silently discarded
it; both now persist it. Sort/filter aliases fname/lname pointed at
fullName and now point at the real columns.

SMS — Trez provider (Sms.php:57-82)
New global SmsModule/SmsService: form-urlencoded POST, 20s timeout, body
trimmed, every message persisted. send() never throws, matching the
reference, which discarded Sms::Send's result so a gateway outage never
aborted the order or approval that triggered it. The OTP text is
byte-identical, including the Android SMS Retriever markers. Credentials
come from env only; production refuses to boot without them rather than
silently locking every customer out. .env.example carries names, no values.

Admin panel: the split(' ') fallback is removed from singleCustomer.vue,
and bulkCustomer.vue defaults percent to 3, requires it, coerces the
Decimal on load so diff-send works, and only re-sends `name` when a name
part changed. ReagentCustomer.vue is untouched — its «درصد کمیسیون» is
AdminUser.wagePercent, a different field.

Tests: 32 jest (incl. 14 new SMS config/template), 9 trigger assertions,
53 auth smoke, and the full D12 round-trip on the customer entity —
«محمد علی» + «احمدی» survives create, edit, a byte-identical no-op save,
and single-field edits. Typecheck clean on api and admin-panel.

Note: migrate dev is interactive-only and could not run here, so the
migrations were applied with migrate deploy after a read-only drift check.
The only drift is the pre-existing _goldbank_migrations table, which
Prisma would DROP; it was NOT dropped (DEAD-07).

[ref: henza_server/app/Models/Sms.php, app/Http/Controllers/Adm/AdminController.php]

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 08:08:50 +03:30
s.esmaeiliandClaude Opus 5 7b598e9e8e parity(api): port the reference price formula with tests; SMS + MON-08 reports
Adds computeVariantPrice(), a verbatim port of the live formula at
henza_server/app/Models/ProductVariant.php:183-190, with the per-customer
making-charge offset as an explicit input defaulting to 0. The three
superseded formulas commented out above it apply VAT to the whole price
and are deliberately not ported (D4).

11 unit tests assert against hand calculations derived from the PHP source
rather than from the implementation, covering: with and without the
offset, VAT on the non-metal portion only, floor-to-nearest-1000, the
extraPercent default of 7, and the equivalence between the app folding the
offset into percent_pay and the server deriving it. 18 tests pass overall.

Also adds the D0 name-split review report script (read-only) and two
investigations the owner asked for:

- SMS: the reference uses the Trez panel (smspanel.trez.ir, Sms.php:69,97)
  with production credentials committed to git (SEC-12 — rotate them). All
  12 message templates catalogued; env var names proposed; nothing
  implemented pending approval.
- MON-08: henza_pwa computes percent_pay, discount and rial_pay rather
  than asking the user (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. rash_weight stays read from the DB as a recorded deviation.

Records the owner's two new standing rules: migrations only through the
standard Prisma workflow, and every data deletion logged in CHANGELOG.

[ref: henza_server/app/Models/{ProductVariant,Sms}.php, henza_pwa/src/views/cart.vue]

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 07:40:28 +03:30
s.esmaeiliandClaude Opus 5 024e3dd045 parity(api,apps): close SEC-02 - orders take the customer from the token
The user declined to defer this to Phase 2, correctly: a public POST /orders
with customerId in the body let anyone place an order on any customer's
account, and an order lands on that customer's gold debt and ledger.

POST /orders is now behind CustomerAuth. OrdersService.createOrder takes the
customer id as a separate argument that the controller fills from the token;
the body's customerId is ignored and a mismatch is logged. The field stays
optional in the DTO and in shared-types only so both apps keep validating
while they still send it -- they drop it in Phase 2. The admin builder is
unaffected: it legitimately places orders for a chosen customer and passes
that id explicitly, behind the admin guard.

GET /orders/:id is scoped to the owner. A foreign or nonexistent id both
return 404, never 403, so ids stay unenumerable. Operators read through the
new GET /orders/admin/:id behind rid order.list, declared before the :id
route because Nest matches in declaration order.

Both apps' CartView now gates on session.isLoggedIn rather than a remembered
customerId, which is no longer a credential.

OTP audit (requested): no master code, no bypass, no hardcoded code anywhere;
one code path issues a token and only after bcrypt.compare. No SMS provider is
wired up -- in development the code is logged once, guarded by
NODE_ENV !== 'production'.

Fixed while auditing (SEC-11): deliver() persisted the real message text, code
included, into SmsMessage.body, which the admin SMS grid lists and full-text
searches. Any operator with the sms rid could read live codes, defeating the
hashing of codeHash. The stored row is now a redacted audit line.

Also logged MON-08: wagePercent/discountPercent/flatRialCharge are still taken
from the client on order creation. The reference is worse -- it takes
rash_weight from the client too -- so this is already a partial improvement;
logged rather than changed, per D4.

Tests: auth-smoke.sh now 53 passed, 0 failed, including a spoofed body
customerId being ignored in favour of the token. Typecheck clean on api,
user-app, user-app-v2 and shared-types.

[ref: henza_server ApiMobileController.php:533,536-545,615-632]

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 07:09:33 +03:30
s.esmaeiliandClaude Opus 5 4efe543d62 parity(api,apps): security hotfix stage 2 - real customer sessions, close IDOR
Stage 1 narrowed GET /customers but 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 the full customer book was still two
requests away. The user caught this. Stage 2 deletes the id-bearing surface.

Customer JWT, separate from the admin one (T1):
  - new src/modules/customer-auth/ with its own passport strategy,
    CustomerJwtGuard, @CustomerAuth() and @CurrentCustomer()
  - signed with JWT_CUSTOMER_SECRET, and every token carries typ:'customer'
    which the strategy verifies; both checks are present deliberately
  - customer state re-read per request, so deactivating takes effect at once
  - mirrors the reference's own split: auth:api vs auth('api-customer')

Real OTP (D6), porting ApiMobileController::userAuth/userVerfiy (:39-64):
  - new CustomerOtp table, additive migration, nothing existing touched
  - parity behaviour: rand(1000,9999), active-customer filter, faTOen digit
    normalisation on both number and code, rejection on mismatch
  - approved improvements over the reference: bcrypt-hashed code, 2-minute
    expiry, 5 attempts then lockout, single use, send throttle (60s per
    number, 5/number and 20/IP per 10 min), and no number enumeration

Routes removed, replaced by token-derived equivalents:
  GET customers, customers/:id, /:id/orders, /:id/ledger-entries
    -> GET customer/me, customer/me/orders, customer/me/ledger-entries
No route accepts a customer id any more, so the IDOR is closed structurally
rather than by a check. The admin panel was unaffected (admin/customers-omde/*).

Both apps migrated in the same commit per the v1/v2 parity rule: token-based
session store, Bearer header, 401 -> clear session and route to login, real
two-step OTP login. Each app keeps its own markup and styling; only logic moved.

Tests: auth-smoke.sh extended with the stage-2 assertions, 46 passed 0 failed
against a live server. jest installed dev-only (user-approved) so pnpm test
works for the first time: 7 passed covering faToEn, which moved to its own
digits.ts to be testable without booting Nest. Typecheck clean on api,
user-app and user-app-v2; both apps build.

[ref: henza_server ApiMobileController.php:39-78]

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 01:20:00 +03:30
s.esmaeiliandClaude Opus 5 fdf833565f parity(api,apps): security hotfix stage 1 - close unauthenticated write paths
Authorised by rulings D7 and T1-T3 in docs/parity/DECISIONS.md. This is the
CLAUDE.md section 3 carve-out: matching the reference here would keep a money
write path open to anonymous callers.

Closed, each behind the rid the legacy vocabulary already had:
  GET   accounting/customers/:id/balance    -> wallet.list
  POST  accounting/customers/:id/reconcile  -> wallet.charge
  POST  accounting/repayments/gold          -> document.create
  POST  accounting/repayments/rial          -> document.create
  POST  accounting/entries/:id/confirm      -> document.list.confirm
  PATCH orders/:id/status                   -> order.list.sts.submit

Four of the accounting routes WRITE to the ledger. The order-status route was
worse in kind: its DTO takes a client-supplied actorAdminUserId, so an
anonymous caller could move any order and pin it on any admin. It had no
caller - the admin panel posts to admin/orders/status.

GET /customers kept its public status (both apps' login still matches the
phone number in the browser) but now selects only id/mobile/isActive/
isArchived. It previously returned every active customer's full name, national
code, both debts, credit limit and the URLs of their identity-document scans.
CustomerSummary narrowed in BOTH user apps in the same commit, per the
v1/v2 parity rule.

Not changed: /uploads stays public per ruling T2. The filename check requested
alongside it passed unchanged - md5(time + name + uuid), no directory listing.

Tests: apps/api/scripts/auth-smoke.sh asserts 401 without a token, 403 with a
low-privilege token (ut 88, 5 unrelated rids) 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. 23 passed, 0 failed
against a live server. Typecheck clean on api, user-app and user-app-v2.

[ref: henza_server/routes/admin.php:12,272; ApiMobileController.php:39-78]

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 00:43:11 +03:30
mahmood f19f49daeb feat: auto-run SQL migrations and one-time seed on boot 2026-09-10 17:15:17 +03:30
mahmood 7d93507089 fix: run API on bookworm with debian Prisma engine 2026-09-10 14:45:47 +03:30
mahmood f0f82d20ec fix: vendor Prisma into release for Docker 2026-09-10 12:53:23 +03:30
mahmood cea3b808d8 Fix some for production 2026-09-10 12:17:47 +03:30