اپ: نام برند در همهی صفحهها و پیامها یکسان شده و از یک تنظیم مرکزی خوانده میشود.
اپ: کارت حسابداری در صفحهی مشتری بازطراحی شد؛ ماندهها و وضعیت لینک واضحتر نمایش داده میشوند.
پنل: کارت حسابداری در پروندهی مشتری بازطراحی شد؛ ماندهها، آخرین ارسال و فرم لینک دستی واضحتر شدهاند.
پنل: ستون «کد» در جدولها بهطور پیشفرض پنهان است و از انتخاب ستونها قابل نمایش است.
پنل: تغییر نمایش ستونها در جدولها بلافاصله اعمال میشود.
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
The real catalogue reaches ~16k products and GET /catalog/products had no
paging parameters at all: it returned every active product with its
variants, images, category, brand and collections in one response. The
apps' PAGE_SIZE = 20 was only a render window over that full array, so
scrolling made zero requests. The reference paged this server-side; the
port had moved it to the client (STUB-03), so this is a return to
reference behaviour.
Step 1 — indexes, which were not optional. Product carried NO index
whatsoever (not isActive, categoryId, brandId or createdAt) and
ProductVariant had none on productId, so every per-product include
scanned its whole table. Adding take/skip on top of that would have
paged the response without making the query faster, which is the exact
failure this change exists to avoid. Product_isActive_createdAt_idx is
composite so one index serves the filter, the default ordering and the
OFFSET walk.
CREATE INDEX, not CONCURRENTLY: the deploy image's migration runner
wraps each file in a transaction, which CONCURRENTLY cannot join (D15).
Every statement is IF NOT EXISTS because both migration paths run it.
Step 2 — the routes. Paging is opt-in, which is the whole
backward-compatibility story: with no query parameters the route returns
the same plain array from the same cache as before, so the admin panel,
the smoke scripts and installed app builds are untouched. Pass page,
limit or any filter and it becomes { rows, total, page, limit }.
The filters had to move too, 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. So every filter is built into
the Prisma query, and count uses the same where clause, so `total`
describes the set the app is actually scrolling.
Three routes added so the apps can stop fetching everything to find one
thing (wired up in step 3): ?ids=a,b,c for the cart, favourites and
order items; /:id for the product screen, which today downloads the
whole catalogue to find one row; /facets for the filter sheet's options,
which the apps currently derive by scanning the catalogue in the
browser.
Deliberately not cached: CatalogCacheService exists because the unpaged
query is expensive and every screen wanted it. A paged, filtered query
is neither — bounded by limit and index-served — and caching it would
need a key per filter combination, i.e. unbounded memory for cheap
queries plus a second place for stale rows to hide.
D27's two storefront rules (active, has an image) live in one shared
where clause, so the list, its count, the single-product route, by-ids
and the facets can never disagree about what is visible. limit and ids
are both capped at 100; an uncapped limit is just the old "give me
everything" query spelled differently.
17 new cases, 329/329 overall. All 17 passed on the first run, which is
not evidence by itself, so one filter was deliberately broken to confirm
the suite catches it (1 failed/16 passed) and then restored.
Not measured on real data — the database container was not running, so
16k-row behaviour is reasoned from the query plan, not timed. Worth one
EXPLAIN ANALYZE on a full dataset before step 4.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Cities (owner: as legacy)
- City.parentId (self relation) replaces the flat `province` label; the
migration turns each label into a parent row («استان …») first, so nothing
is lost.
- Admin pickers are legacy's tree (CityTreeSelect): the top level, the
country, is a heading. The city form and grid get «والد».
- Restored legacy _getP: a city without its own percent takes its
ancestor's. Restored the omde-customer form's city → percent autofill.
- Import copies legacy parents; delete refuses a city with children; a
parent loop is refused.
Permissions (owner approved the deletion)
- dashboard / dashboard.omde / dashboard.tak rows become dashboard.view,
then are deleted.
- Import maps them the same way: legacy types 102, 104, 105, 108 and 135
had lost the dashboard.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Legacy: final % = variant % + customer % − the largest active promotion
on the product, its category or its brand; rial charge 0. The apps sent
the variant's own % only, so the new app undercharged by the customer's
percent (usually 3) and ignored promotions, and the server trusted
whatever the app sent.
- pricing/wage-formula.ts: one formula, legacy by default, with
coefficients, promotion pick, constant, floor/cap and rounding so the
admin screen can change it; validated
- an app order is priced by it; the app's wage, discount and rial charge
are ignored; an operator's manual numbers are kept
- POST customer/me/wage-quote; both apps show that figure
- admin/pricing/wage-formula (config.wage): read, preview, approve with
the sample it was checked on, last 20 versions kept; the setting is
kept off the generic const routes
Owner approval: 2026-10-08.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- production refuses to boot without JWT_SECRET instead of signing
admin tokens with the published development key
- CORS_ORIGINS restricts browser callers; blank keeps allow-all with a
production warning so a running deployment is not cut off
- the boot-time seed creates only roles and permissions in production;
the demo customers, orders, debt and known-password accounts need
SEED_DEMO_DATA=true
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Legacy fixed `orders.reagent_id` once, when the order was placed, and its
«میزان فروش» summed `customer_id = X OR reagent_id = X`. The app credited
orders to the customer's CURRENT referrer and left out an expert's own
orders, so 9 of 49 imported experts showed a different figure.
- Order.salesAgentId (+ referrerCustomerId for 17 legacy orders whose
referrer was not an expert); additive migration, recent app orders
backfilled from the customer's referrer
- createOrder records the expert at placement time
- one shared filter (agent-orders.ts) for the experts list, expert
detail, dashboard and the expert app; child orders follow salesAgentId
- customer page gets its «میزان فروش» back
- import carries orders.reagent_id as is; ex-experts kept as admins keep
their profile so their own orders stay linked
- reconcile-sales: 1,557 of 1,558 match legacy's own formula; balances
unchanged
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Additive and nullable. Owner ruling 2026-10-07: keep the typed text
verbatim, and set cityId as well only on an exact match.
Legacy customers.new_city is free text: all 172 live values are names,
none are ids. The legacy city table has only 15 rows and 43 of the names
match no city, so dropping the text would lose where those customers are.
Legacy sign-up also forced city_id = 5 (Tehran) on everyone
(Major/CustomerController.php:21). 91 of the 92 customers with both
values carry that forced Tehran, so the typed name is the truth.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Purely additive; nothing dropped, renamed or retyped. Owner rulings of
2026-10-06/07, recorded in docs/DATA-MIGRATION-MAP.md.
- Customer.nationalCode is now optional. All 1,595 live legacy customers
have none, and fabricating one is a data violation. Still @unique:
NULLs do not collide in Postgres, so the import must write NULL, not ''.
- Customer.birthDate added, empty. Must not be backfilled until the
Jalali-vs-Gregorian check (precheck-3) is in.
- VerificationStep.REJECTED plus verificationRejectionReason/RejectedAt.
Legacy has a third "rejected" state that the enum could not represent.
- CustomerFavorite for legacy `fav` (251 rows), which had no home.
accounting-sync customerPayload() now takes `nationalCode: string | null`.
validNationalCode() already handled null, so Tahesab/Zarrin receive
exactly what they did before. Caught by tsc after the column change.
Not touched: accounting* columns, ProductVariant.imageId, the
Accounting* tables, SMS (PROTECTED.md §B, §G).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A sales expert can now sign into the apps with their AdminUser account, without
reversing D17 — the expert remains an AdminUser and is never turned back into a
Customer.
The app token carries a `typ` of 'customer' or 'sales-expert'; the strategy
resolves both to one principal whose `id` is always a Customer id, so every
route already written for a customer works unchanged for an expert. That is what
the reference gave them, where an agent was a customer with extra screens.
Each expert gets ONE linked trading Customer row (additive migration, dry-run
clean and rolled back before applying) created at first sign-in, seeded from the
two numbers the reference read off the agent's own row: SalesExpertProfile
.gramLevel -> goldCreditLimitGrams and AdminUser.wagePercent ->
makingChargeOffsetPercent. Orders the expert places land there, exactly as the
reference's single row did.
New expert-only routes scope on the AdminUser id from the token:
POST expert/child-orders (legacy /order/child, ApiMobileController:759, keeping
its sts=5 filter as DELIVERED_TO_AGENT), POST expert/my-customers (legacy
/my-child, :184) and GET expert/whoami so the apps can hide the entries.
Security: jwt.strategy now rejects any token carrying an app `typ`. An expert's
app token holds their AdminUser id in `sub`, and JWT_CUSTOMER_SECRET falls back
to JWT_SECRET when unset — in that configuration the token would otherwise have
validated as an admin with full panel permissions.
NOT ported: myChild minted an api_token per listed customer (:197-201), letting
an agent act AS that customer. Recorded in D21 rather than silently dropped.
Tests: new test-sales-expert-app-login.cjs 36/36 — expert sign-in, trading
account seeding, own-customers-only (with both negatives), child orders, the
expert ordering for themselves, an ordinary customer refused both screens with
their own app unaffected, and all four token boundaries including immediate
lockout on deactivation. jest 45/45, admin smoke 43/43, apps smoke 43/43,
B2B scan 34/34, B2B writes 25/25, auth-smoke 32/32.
[ref: henza_server/app/Http/Controllers/Api/ApiMobileController.php:184-195,534-535,759-781]
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Prisma migrate diff proposed DROP TABLE "_goldbank_migrations" because the
table exists in the database but was not declared in the schema. Declaring
it with @@ignore removes the proposal without exposing it to Prisma Client,
and the table and all its rows are kept.
Correcting the earlier DEAD-07 note: this is not a leftover from the
pre-Prisma port. It is the tracking table of the application's own
migration runner (src/prisma/migrate.ts:5), which exists because the Docker
deploy image is copy-only and ships no Prisma CLI, so migrate deploy cannot
run in the container (migrate.ts:7-13). The project therefore keeps two
ledgers deliberately: _prisma_migrations for the CLI in development, and
_goldbank_migrations for the runtime runner in the container. That runner
already filtered out any DROP of its own table (migrate.ts:190-196); the
schema declaration removes the cause rather than relying on that guard.
The practical consequence, recorded in DECISIONS.md D15: a new migration
must be safe under both paths, so its SQL stays idempotent.
The ledger grew 12 -> 14 rows while testing, with nothing deleted: starting
the API ran prepareDatabase(), which appended the two migrations applied
earlier today. Its $$-aware statement splitter handled the PL/pgSQL trigger
body correctly — trigger and function are both present and the 9 trigger
assertions pass against the live database.
Also records the UTF-8 testing rule: Persian test input must come from a
file, never a shell argument, because the Windows shell replaces Persian
characters with '?' and a test can then pass while storing '????'.
Verified: migrate diff -> "This is an empty migration" (zero drift);
migrate status -> up to date; tsc clean; 32 jest; 9 trigger assertions.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
- SingleProduct.vue: rebuild gallery/header/action bar as a self-scrolling
layout with a sticky photo, solid-on-scroll header matching PageBar, and
a full-screen image preview (blurred overlay, centered thumbnail strip)
- Add GallerySlider.vue: pointer-based swipe carousel isolated in an
LTR subtree, fixing RTL/transform sign mismatch that caused slide jumps
- Fix Icon.vue: icon was read into a plain ref once at setup and never
updated on prop change; switch to computed so icon swaps (e.g. cart
qty stepper trash/minus toggle) actually re-render
- Fix quantity stepper DOM order in SingleProduct.vue/CartItem.vue so
plus/minus render correctly under RTL flex
- prisma/assign-downloaded-images.js: seed real product photos (FEATURED/
FEATURED_MOBILE/GALLERY/GALLERY_MOBILE) onto the 5 placeholder products
# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.
- Fix silent redirect-to-home for unauthenticated users on protected routes;
now redirects to login with a return path
- Restyle OrderCard, SearchInput, Filters, DropDown, and product card image
sizing/centering to match the app's LESS-token design language
- Fix ant-design Slider handle placement/drag under RTL and seed a valid
default range so handles actually render
- Fix chip-row edge padding and top spacing across Orders/Cart/Search lists
- Add pull-to-refresh to the dashboard and fix its header/padding to match
the fixed PageBar pattern
- Remove the floating WhatsApp button from index.html
Three real bugs found and fixed while wiring up product images end-to-end:
1. src/prisma/migrate.ts only discovered flat prisma/migrations/<name>.sql
files, never Prisma's own folder-per-migration layout
(<timestamp>_<name>/migration.sql). Every migration created the normal
way (via 'prisma migrate dev') was silently skipped; only a hand-added
flat duplicate (prisma/migrations/0001_init.sql, now removed) ever ran.
This meant schema.prisma had drifted well ahead of what any real deploy
database actually had (missing columns on City, Customer, Branch,
Product, several new tables). Fixed to discover both layouts; the
drift is closed with a new 20260914165255_sync_schema_drift migration.
2. Neither nginx.conf (production) nor vite.config.ts (dev) proxied
/uploads/ to the API — only /api/. UploadedFile/ProductImage URLs
(/uploads/<file>, served by main.ts's useStaticAssets OUTSIDE the /api
prefix) 404'd through both. Both now proxy /uploads/ the same way /api/
already was.
3. docker-compose.yml's api service had no volume for storage/uploads, so
every uploaded file — and by extension every ProductImage row pointing
at one — was lost on the next container recreate/redeploy. Added a
named volume.
Also: main.ts's static file serving now sets Content-Type: image/avif
explicitly (serve-static's bundled mime table predates .avif and served it
as application/octet-stream; browsers rendered it anyway by sniffing, but
a stricter client would not have).
Product photos: copied 15 real jewelry photos into the uploads volume,
registered them as UploadedFile rows, and assigned each of the 5 existing
Product rows a randomly picked one (duplicates allowed, per request),
replacing their /img/no-items.png placeholder ProductImage row. Verified
end-to-end in a real browser against the real containerized API+DB: logged
in, opened the admin product list, confirmed all 5 rows render their photo
(naturalWidth/Height populated, network 200s, no console errors).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>