Commit Graph
117 Commits
Author SHA1 Message Date
s.esmaeiliandClaude Opus 5.5 dbead8a6ba feat(apps): version on every build, a «what changed» modal, and a clean update
- The build number is the commit count, so every build raises it with no
  file to bump. scripts/app-version.mjs bakes it into each front-end
  (__APP_BUILD__) and writes dist/version.json with that app's notes.
- Notes come from commit messages: only Persian lines that start with
  one of the four app tags are shown, each to its own app
  (docs/RELEASE-NOTES.md).
- Both customer apps and the admin panel poll version.json (every 5
  minutes and on return to the tab, bypassing caches). A newer build opens
  a modal that cannot be dismissed. «به‌روزرسانی» unregisters the service
  worker, empties Cache Storage and reloads; localStorage (sign-in, cart,
  settings) is kept.
- Replaces legacy's version dialog, which compared against a
  package.json version that was never bumped and so never opened, and
  useRefresh, which swapped in a new service worker silently.
- nginx (all three apps and nginx-spa.conf): index.html, sw.js and
  version.json are never cached. sw.js used to fall under the 6-month rule,
  which could pin a browser to an old release.

Checked: served with the real nginx config, a newer version.json opens
the modal with its notes; the same build does not.

اپ: اجرت هر کالا حالا همان است که در فاکتور حساب می‌شود؛ درصد خود شما و تخفیف‌ها هم در آن آمده است.
اپ: علاقه‌مندی‌های شما ذخیره می‌شوند و در هر دستگاهی که وارد شوید دیده می‌شوند.
اپ: از این به بعد هر نسخه‌ی تازه با فهرست تغییراتش اعلام می‌شود.
پنل: بخش تازه‌ی «اجرت»: فرمول اجرت را با یک نمونه امتحان و بعد تأیید کنید.
پنل: شهرها مثل قبل به‌صورت درخت‌اند (کشور، استان، شهر، محله)؛ شهری که درصد ندارد درصد شهر بالاتر را می‌گیرد.
پنل: «تاریخ تولد» به فرم مشتری عمده و تکی برگشت.
پنل: در گزارش تجمیعی سفارش‌ها، دکمه‌ی «دریافت فایل اکسل» اضافه شد.
پنل: رد احراز هویت مشتری حالا دلیل هم می‌گیرد.
پنل: با تأیید یا رد سند مالی، به مشتری پیامک می‌رود؛ در پیامک گروهی، %name با نام مشتری عوض می‌شود.
پنل: از این به بعد هر نسخه‌ی تازه‌ی پنل با فهرست تغییراتش اعلام می‌شود.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:45:52 +03:30
s.esmaeiliandClaude Opus 5.5 2bbd9408dd build: rebuild the server bundle, admin panel and both apps from main
Fresh release output after merging dev: apps/api/release (server
bundle, migrations and the database client) and the three front-end
dist folders. All three front-ends type-check clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:24:53 +03:30
s.esmaeiliandClaude Opus 5.5 1bc3739e78 Merge release/delivery (IAM single sign-on) into the triage work
Brings the four release/delivery commits (IAM SSO for the admin panel,
both apps and the API; IAM env in docker-compose) onto fix/issues-triage.

Conflicts:
- customer-auth.service.ts imports: both kept.
- both apps' api/client.ts: both kept (AccountingBalance/WageQuote, and
  iamAppUrl).
- all build output (admin-panel, user-app, user-app-v2 dist, and the api
  release bundle): rebuilt from the merged source instead of picking a
  side.

Fixed while verifying:
- The IAM change calls iam.browserBootstrapUrl after every login, but
  the identity-service stand-ins in three test files lacked it. 15 tests
  failed on release/delivery itself.
- SMS relay: SMS_RELAY_PLATFORM was read but never sent, so the gateway
  never got `platform` (documented body: phone, host, text, platform).
  relayConfig() now returns it ("default" → null) and the request carries
  it. sms.service.spec now passes.

All 249 API tests pass; the three front-ends type-check and build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:03:53 +03:30
s.esmaeiliandClaude Opus 5.5 9d3b5388ba fix(security): a production server refuses to start until its keys, allowed origins and test login are right (REL-01, REL-03, SEC-04)
With NODE_ENV=production (always, in the Docker image) the API lists
every problem at once and does not start while any of these holds:
- OTP_MASTER_CODE is set: the fixed test login signs in any customer. A
  test server built from the production image must say so with
  TEST_SERVER_ALLOW_MASTER_CODE=yes.
- JWT_CUSTOMER_SECRET is missing, equal to JWT_SECRET, or under 32
  characters.
- JWT_SECRET is missing, the published dev key, or under 32 characters.
- CORS_ORIGINS is empty, `*`, or not full origins. It no longer falls
  back to allow-all.

Checked live: a misconfigured start lists all four problems; a correct
one starts, answers the allowed origin, and blocks any other.
docs/SERVER-SETUP-CHECKLIST.md is the operator's list. DELIVERY.md,
docker-compose.yml and .env.example are updated to match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:00:17 +03:30
s.esmaeiliandClaude Opus 5.5 d91dcf197b feat(admin): birth date on the customer forms; permission saves limited to known rids (PERS-03, SEC-07)
- PERS-03: «تاریخ تولد» (Jalali DateInput) on the wholesale and retail
  forms, as legacy. The server accepts birth_date or birth_day, refuses
  impossible or future dates, clears on '', and returns both names.
- SEC-07: setRoles stores only rids in ALL_RIDS or their tree parents.
  Anything else is dropped with a warning log, so a tick on a legacy-only
  tree leaf never fails the save.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:45:41 +03:30
s.esmaeiliandClaude Opus 5.5 7bb5c2e00f chore(apps): remove unused code and packages (DEAD-01, DEAD-02, DEAD-03)
Owner approved the deletions. Found by walking imports from each app's
main.ts; sw.ts stays (the PWA plugin's entry).
- user-app: 8 unreachable components, loadingStore.
- user-app-v2: 11 unreachable components, loadingStore.
- both: the unused validators and price helpers, and the gsap and
  @ant-design/icons-vue packages.
- the stray root user-app-v2/ folder (an old package.json and
  vite.config.ts that nothing referenced).
Both apps type-check clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:40:10 +03:30
s.esmaeiliandClaude Opus 5.5 2d74f53d0a feat(admin): D16 (b)(c) — Excel order-item report, document-confirm SMS, %name in bulk SMS
Finished from a parallel session that expired (owner: «بساز»).

- RPT-01: GET admin/report/order-items — legacy report/order_item's
  32-column Excel (order items, one row per unit, plus financial
  documents), RTL, Jalali dates. The «گزارش تجمیعی» screen gets
  «دریافت فایل اکسل». Review fixes:
  - weight: computedWeightGrams is already one unit's weight; legacy
    divided it by qty again, under-reporting «بدهکار» on multi-unit
    lines. Not repeated.
  - «درصد»: the final percent (wage − discount), as legacy.
  - dates: the end date covers its whole day; a bad date is a 400.
- SMS-01: confirming or rejecting a financial document texts the customer,
  as legacy CashController::toggleConfiremCash.
- SMS-02: bulk SMS substitutes %name per recipient and is actually sent
  through the configured gateway (D26 relay), no longer only logged.
- Docs: 01-ADMIN-MATRIX and D16-BC-GAP-SUMMARY.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:34:45 +03:30
s.esmaeiliandClaude Opus 5.5 fbe4f38e06 feat(cities): cities are a tree again, as legacy's; old dashboard permissions merged (PERS-02, PERM-01)
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>
2026-10-08 16:31:12 +03:30
s.esmaeiliandClaude Opus 5.5 fbe7b98ada feat(admin): «اجرت» screen — edit the making-charge formula, check a sample, approve
The formula shown as a sentence with coloured terms; each term can be
switched off or given a coefficient; promotion as largest or sum; a
constant, floor, cap and rounding. A sample order line is computed by
the server for the formula in force and the draft side by side, changes
highlighted. Saving needs a change, a confirmed sample and a final
confirm; the approved sample is kept and shown as the formula's
reference, with earlier versions below.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 11:26:56 +03:30
s.esmaeiliandClaude Opus 5.5 699657e483 feat(pricing): the server prices app orders with legacy's making-charge formula (MON-08)
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>
2026-10-08 11:26:55 +03:30
s.esmaeiliandClaude Opus 5.5 4b7b2473fc feat(orders): «عدم سفارش» blocks app orders; import starts everyone allowed (MON-05)
Legacy stored customers.sts but no code read it, so the switch never
stopped an order, and 313 active customers carry it, mostly as the
sign-up default. Owner option 2: enforce it from now on, for orders
placed from the app only (an operator can still order for the
customer), and import every customer as allowed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 11:16:29 +03:30
s.esmaeiliandClaude Opus 5.5 1c850216e9 fix(security): no dev key, demo seed or open CORS in production (SEC-04, SEC-05)
- 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>
2026-10-08 11:00:45 +03:30
s.esmaeiliandClaude Opus 5.5 fb25b2251b fix(ledger): an order's debt follows its status, as legacy recomputed it (ACC-03)
Legacy never posted order debt; updateRemain recomputed it as
SUM(orders.input) WHERE sts > 4. The port posted it once on entering
DELIVERED_TO_AGENT, guarded on deliveredAt, which:
- posted it twice when an order reached that status again
- posted nothing for an order sent straight to DELIVERED_TO_CUSTOMER
- kept it after a rollback or cancellation

syncOrderDebt compares what the order should owe with the confirmed
ORDER_STOCK entries on it and posts only the difference (append-only,
idempotent). Status removal runs it too, so removing «تحویل به نماینده»
is allowed again, as in legacy. Existing data: 0 orders out of line.

Owner approval: 2026-10-08.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:57:38 +03:30
s.esmaeiliandClaude Opus 5.5 5d386bf752 feat(favorites): keep customer favourites on the server, as legacy did (FAV-01)
Both apps kept favourites in memory only, and the API had no route, so
the 874 favourites imported from legacy major_favorites never showed.

- GET customer/me/favorites, POST customer/me/favorites/toggle
- apps load them on sign-in, clear on sign-out, toggle optimistically
  and roll back on failure

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:54:17 +03:30
s.esmaeiliandClaude Opus 5.5 38ce3d2488 fix(admin): Jalali dates in grids and legacy order-status tags
The API sends ISO timestamps where legacy sent formatted Jalali strings,
so every grid printed "2026-07-25T12:32:29.000Z". Date columns now render
1405/5/3-16:02. The order-status column is a green/red «فعال/عدم سفارش»
tag again (Customer::$STS), not bare «مجاز».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:40:22 +03:30
s.esmaeiliandClaude Opus 5.5 29084904e9 feat(orders): credit each order to the expert who placed it, as legacy did
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>
2026-10-08 10:40:21 +03:30
mahmood dcb74603ad feat add some for users in iam 2026-10-08 00:10:10 +03:30
mahmood 40a28d5605 Feat build new 2026-10-07 21:26:54 +03:30
s.esmaeili 8c5d971d1f migration 2026-10-07 20:20:27 +03:30
s.esmaeiliandClaude Opus 5.5 be5ca829d2 feat(import): henza → gold-bank data import tool
apps/api/scripts/legacy-import/ reads the legacy MySQL (read only) and
merges into the target Postgres. Every rule is from
docs/DATA-MIGRATION-MAP.md, and each step cites its section. It covers
cities, roles and permissions, branches, admin users, sales experts,
workshops, customers, brands, categories, products, variants, product
images, orders, the ledger, favourites, custom orders and a
reconciliation report.

Key rules:
- Debt is rebuilt from legacy's own formula (updateRemain): delivered
  orders minus confirmed type 1-4 documents. Type 9 «سفارش» documents
  mirror orders and are skipped, or every order's debt would count twice.
  A balance the records cannot explain is carried as one ADJUSTMENT entry.
  In the 2026-10-04 dump that is only customer #125 (owner ruling).
- Merge, not import: rows already in the target are matched by mobile or
  product code, and only their empty fields are filled. Never written:
  accounting* columns, ProductVariant.imageId, Accounting* tables. No
  ProductImage is ever deleted.
- Roles map by title, not number. Legacy 86 is «حسابدار» but target 86
  is «کارشناس فروش». An expert sharing a mobile with an admin never
  demotes the admin.
- Soft-deleted variants and products that live order lines still point
  at (473 of 610 lines) come over inactive, so order history stays whole.
- Owner rulings 2026-10-07: birthdays filtered (541 of 915), typed city
  kept plus exact-match cityId, the 7 legacy rejections get the sentinel
  reason, OTP-only accounts get OTP_ONLY_PASSWORD_HASH.
- Tehran wall-clock timestamps are converted with ICU rules, including
  Iran's pre-2022 DST. Jalali receipt dates are converted with the
  jalaali-js arithmetic (truncating division; verified on 8 known dates).

Safety: --target is required and never read from .env. Nothing is
written without --yes, and a target not named *import*/*staging* also
needs --confirm-db. The id map lives in its own schema, legacy_import,
so a re-run updates instead of duplicating. Writes bypass the API
services, so no SMS is sent and nothing is queued to accounting.

Tested against goldbank_import_test, a clone of the dev DB, in three
runs:
- 1,503/1,503 created customers match legacy gold and rial balances
  (±0.0001 g / ±1 rial)
- accounting data is byte-identical before and after
- 0 imported variants have an imageId
- runs 2 and 3 created zero duplicate rows
- 6,196 image files copied, 3 missing in the legacy storage

New devDependency: mysql2 ^3.24.5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 19:35:18 +03:30
s.esmaeiliandClaude Opus 5.5 7019cad932 refactor(auth): move the password-hash helpers into their own module
compareLocalHash, OTP_ONLY_PASSWORD_HASH and a new isPasswordHash now
live in password-hash.ts, which depends on bcryptjs only. The data-import
script needs them, and importing auth.service would load NestJS and the
ESM-only @nestjs/jwt.

auth.service re-exports the first two, so callers and tests are
unchanged. Its own bcrypt import, now unused, is dropped. No behaviour
change: 21/21 auth tests, 178/178 overall.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 19:35:02 +03:30
s.esmaeiliandClaude Opus 5.5 3ac9e92e9f feat(db): Customer.cityNameRaw for the city typed at legacy sign-up
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>
2026-10-07 19:35:01 +03:30
s.esmaeiliandClaude Opus 5.5 884413513d feat(auth): refuse non-bcrypt password hashes before calling bcrypt
compareLocalHash() returns false for any passwordHash that does not
start with `$2`: '' (accounts created without a password) and the new
OTP_ONLY_PASSWORD_HASH sentinel the data import writes for ~1,590
OTP-only legacy customers. It is used on both local-compare paths of
verifyPassword(), IAM off and IAM fallback.

bcryptjs 3 already returns false for these. The guard keeps the result
from depending on that: a future version or a native binding that
throws would turn a wrong password into a 500 (owner ruling 2026-10-07).

Behaviour-neutral for existing data. Real hashes start with $2a$, $2b$
or PHP's $2y$, and every row that fails the prefix already failed in
bcrypt. Verified live: wrong password 401, correct password 200.

Tests: refusal for both values with IAM off and on, bcrypt never
called for them, and legacy $2y$ hashes still verify. 21/21.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:37:26 +03:30
s.esmaeiliandClaude Opus 5.5 e020e6a633 fix(customers): handle REJECTED verification explicitly
verifyStepOne() sent REJECTED to `default`, which returns 2
("accepted"). The admin list unlocks the order/account switches when
step one is 2, so a rejected customer would have had them unlocked.
REJECTED now maps to 3, matching legacy, where a step-one reject also
set active = -1.

Both switches are now exhaustive with no `default`, so a future enum
value fails the build instead of falling through silently.

Both user apps' ProfileView label it «رد شده». Before this they showed
the raw English value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:37:19 +03:30
s.esmaeiliandClaude Opus 5.5 45633b5aa0 feat(db): additive prep migration for the henza data import
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>
2026-10-07 17:37:12 +03:30
Montech 5223de313a fix some for iam 2026-10-07 16:45:36 +03:30
s.esmaeiliandClaude Opus 4.6 19d490e790 feat: accounting integration (D27), SMS relay driver (D26), rebuild user apps
- Add Tahesab/Zarrin accounting sync module with admin panel UI
- Add SMS relay driver as default provider, refactor SMS into driver pattern
- Update customer services, orders, products, and catalog queries
- Rebuild user-app and user-app-v2 dist assets
- Add Prisma migration for accounting tables
- Update docker-compose and env examples
- Update parity docs (DECISIONS, ISSUES, PROGRESS)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-06 16:48:36 +03:30
s.esmaeiliandClaude Opus 5.5 a72fa607a5 Add CartView build asset for user-app-v2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 20:19:15 +03:30
Montech dfc70b6757 fix some for docker 2 2026-10-04 17:25:00 +03:30
Montech f3928fe228 fix some for docker 2026-10-04 17:23:30 +03:30
Montech eecd91c9be fix docker 2026-10-04 17:20:04 +03:30
s.esmaeiliandClaude Opus 5.5 b5b3cd8bef build(user-apps): rebuild user-app and user-app-v2 dist
Production build of both customer apps for f441ca8 (appearance settings,
dark mode, reduced motion, product morph, filter sheet). The API,
admin-panel and shared-types builds are unchanged: none of their sources
changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 16:31:50 +03:30
s.esmaeili f441ca8289 feat(user-apps): appearance settings (dark mode + reduced motion), product morph, filter sheet
Applies to both user-app and user-app-v2. Logic and structure are shared;
each app keeps its own look.

Appearance settings
- New "تنظیمات ظاهری" page (/settings/appearance), opened from the
  dashboard and available without login. It offers a light/dark/system
  theme and a "fast & light" motion switch.
- appearanceStore saves the choice to localStorage. A small inline boot
  script in index.html applies it before first paint so there is no
  white flash. The "system" option follows OS changes live.
- New ToggleSwitch component.

Dark mode
- Theme LESS tokens now compile to CSS custom properties. Light/dark
  values come from a generated virtual:palette.css (themeConfig/
  appearance.ts, with colors.json + new colors.dark.json).
- New LESS alpha() plugin replaces fade() on theme tokens.
- New semantic tokens: page, onInk, heroSurface, hairlineStrong,
  photoMat, photoStage.
- The dark palette is layered (page < card < panel < border) so
  surfaces separate without shadows.
- Hard-coded colors were replaced by tokens according to their role.
- antd inputs/text get dark overrides; vant uses its own
  .van-theme-dark.
- Light mode looks the same as before.

Reduced motion
- With html.motion-reduced, transitions are capped at 120ms, keyframe
  loops stop, and backdrop blurs and permanent will-change are dropped.
- Spinners and the toast progress bar (which closes the toast) are
  exempt via .keep-motion.
- Carousel autoplay, the v1 Lottie and the product morph honor the
  setting.

Product detail
- Opening a product is now a container-transform morph from the tapped
  card (ProductMorph). It replaces the bottom Drawer and uses composited
  transform/opacity only.
- The detail view is seeded with the card's data, so it no longer
  opens blank and jumps.

Filters and sheets
- Filters are now a full-width bottom sheet. Selections stay a draft
  until applied, so the result list no longer re-renders while
  dragging the slider.
- DropDown collapse animates grid-template-rows instead of a capped
  max-height.
- v2 IslandSheet is restyled to the v2 design language (API unchanged).

Fixes
- filtersStore.reset() shallow-copied shared arrays, so clearing the
  filters never cleared the selected brands.
2026-10-04 16:18:56 +03:30
s.esmaeiliandClaude Sonnet 5 c05352d6fd refactor(ui): replace modal dialogs with toast/sheet components
Remove DialogVant/NotifyVant and add Toast and Sheet components with
useToast, useReveal, and usePageTransition composables. Rework
Navigation, RegisterView, and styles across both apps; rebuild dist assets.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-04 13:57:25 +03:30
mahmood 148b99c4a6 feat build 2026-09-25 19:12:39 +03:30
mahmood 36f239e02c Feat add iam auth 2026-09-25 19:04:48 +03:30
s.esmaeiliandClaude Opus 5 24b7a4c395 fix(api): treat blank env vars as unset; verify the whole flow in Docker Compose
Found by running the delivery flow against the actual built container rather
than a local dev process.

DEPLOY-02 (Critical) — ADMIN LOGIN RETURNED 500 FOR EVERY USER IN DOCKER.
docker-compose.yml passes each optional variable as ${X:-}, so an unconfigured
one arrives as "" rather than undefined. The code read them with
`process.env.X ?? 'default'`, which only falls back on undefined, so the empty
string reached jsonwebtoken and it threw

  "expiresIn" should be a number of seconds or string representing a timespan

on every sign. Invisible to every existing suite, all of which run against a
local process where the variable is genuinely absent.

Added src/common/env.ts (envOr / envOrNull), which treat blank as unset, and
applied it to every auth, port, upload and SMS read. Three were not cosmetic:
a blank UPLOAD_DIR would have served static files from the FILESYSTEM ROOT, a
blank PORT would have bound a random ephemeral port (Number('') === 0), and a
blank JWT_SECRET would have become the verifying key. Recorded as D25, with a
standing rule to use these readers instead of `??`.

Pinned by src/common/env.spec.ts (9 tests), which reproduces the original throw
against the real jsonwebtoken and asserts the reader can no longer produce it.

DEPLOY-03 (process, documented) — the API image is `COPY release/ ./`, so it
ships a bundle pre-built on the developer machine. `docker compose build` will
happily produce an image from a STALE release/app.js and report success; at the
start of this run that bundle was four days old and the rebuilt container was
missing every route added since. DELIVERY.md's deploy section now leads with
`pnpm build:release`.

New: scripts/test-compose-e2e.cjs — the whole delivery flow against the built
container, over HTTP only, as a black box: a sales expert and an ordinary
customer both sign in with the master code, both place an order, both orders
appear in the admin grid and open there, and the three token boundaries still
hold in the deployed image. 32/32.

Verified: compose e2e 32/32 · jest 54/54 · build 5/5 ·
against the CONTAINER: complete-profile 38/38, report 40/40, expert login 36/36 ·
locally: apps smoke 43/43, admin smoke 43/43, B2B scan 34 ok/0 broken.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 02:27:30 +03:30
s.esmaeiliandClaude Opus 5 3f549f1749 parity(apps+api): report + report/detail on the internal ledger [ref: henza_pwa views/report]
Ports "گزارش" end to end (D24), the last missing app page.

In the reference this feature computes nothing: Customer\TahesabController
proxies an external accounting system (hesab.henzagold.com and a bare IP at
31.7.68.147:3030) with TLS verification disabled, a bearer token hardcoded in
the source and a URL-embedded secret, keyed on Customer.mcode. The target has
no such system and no mcode, so per the owner's option الف this is built on our
own ledger with no external connection.

API — customer/me/report{,/detail,/balances,/:id}
- one row per CONFIRMED ledger entry with the running gold and rial balance
  after it, newest first as the reference displayed it
- only confirmed entries count, the same rule the cached
  Customer.goldDebtGrams/fiatDebtRial follow, so the statement's closing
  balance agrees with the figure shown everywhere else in the app
- fields the target has no source for (foreign currency, نام کارساخته,
  جنس فلز, the external system's internal ids) are null/zero and the UI OMITS
  the row rather than rendering a fabricated value (REP-01)

SEC-16, NOT ported: the reference's detail($id) took the factor code from the
URL and performed no ownership check at all — it never consults auth() — so any
signed-in customer could read any other customer's document by guessing a code.
Here the id is only a filter on top of a customerId scope from the token.
Tested in both directions.

Apps (both, template+script byte-identical, per-app styling only)
- ReportView with the reference's two tabs, its card rows and its «جزئیات»
  modal; ReportDocument for one document
- routes /report and /report/detail, the latter opening the second tab
- dashboard entry (v1 tile, v2 row), shown to every signed-in customer since
  the reference's hasMCode() gate has no target equivalent

Verified: build 5/5 · jest 45/45 · report 40/40 (new) ·
complete-profile 38/38 · apps smoke 43/43 · typecheck clean on all four apps.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 01:49:49 +03:30
s.esmaeiliandClaude Opus 5 5ad1083aee parity(apps+api): complete_profile in both apps [ref: henza_server Major/CustomerController::complete]
Ports "تکمیل پروفایل" end to end (D22).

API
- POST customer/me/complete-profile — three required document ids, the
  customer taken from the token, verification step set to the reference's
  LIVE value (approved outright; its `= 1` line is commented out at :83 in
  favour of `= 2` at :85). Both SMS messages byte-identical and non-blocking
  per D20.
- POST customer/me/documents — the reference let the app upload through the
  same file/create the admin used, inside its auth:api-customer group
  (routes/api.php:95); the target's copy is admin-guarded, so the page could
  not have uploaded at all. Reuses AdminUploadsService unchanged, so both
  paths share one size limit, MIME whitelist and naming scheme (UPL-01).
- PROFILE_ADMIN_MOBILES replaces the reference's four hardcoded personal
  numbers; blank means nobody is notified and the upload still succeeds.

Three reference behaviours deliberately NOT ported, all in ISSUES.md:
- SEC-14: `$customer->update($request->all())` mass-assigned the whole request
  body, letting the app write its own credit limit, making charge, gold debt,
  active flag or mobile. Replaced with a three-field whitelist.
- SEC-15: four real personal mobiles hardcoded in the source.
- APP-03: refresh()'s unterminated setInterval page reload.

Apps (both, template+script byte-identical, per-app styling only)
- CompleteProfileView + SingleImageUploader
- route /complete_profile, redirecting home once the documents are in, as the
  reference's guard did (router/index.ts:266)
- the entry point on the profile screen, shown only while they are missing
  (profile.vue:96-102)

Also (D23): JWT_CUSTOMER_SECRET now warns loudly at boot when unset. It falls
back to JWT_SECRET, which signs app and admin tokens with the same key and
leaves the `typ` check as the only barrier — and an expert's app token carries
their AdminUser id in `sub`. Warn rather than refuse to boot (D19, lowest
risk); DELIVERY.md lists it as the first pre-release step (REL-03).

Verified: build 5/5 · jest 45/45 · complete-profile 38/38 (new) ·
expert login 36/36 · apps smoke 43/43 · admin smoke 43/43 ·
B2B scan 34 ok/0 broken · typecheck clean on all four apps.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 01:42:07 +03:30
s.esmaeiliandClaude Opus 5 f71fd5bfe1 feat(apps): sales-expert screens in both apps + v2 admin home slider (D21, APP-02)
Both expert screens are built in user-app and user-app-v2, with byte-identical
template and script and a per-app style block only — verified by diffing
everything above <style>:

  ChildOrdersView  "سفارش مشتریان"  — legacy ChildOrders.vue. Keeps the
    reference's title, search, infinite paging, and per-card item count, total
    weight, customer name and the fixed «تحویل مسئول فروش» chip (the endpoint
    only returns that status, ApiMobileController:766).
  CustomerChoiceView "لیست مشتریان" — legacy customerChoice.vue. Keeps its
    title, per-search re-query and the reference's choice to LIST inactive
    customers (its active() scope is commented out at :187).

Reachable from inside the app, not just by URL: routes carry the reference's own
paths and names, the dashboard grows the two entries only for an expert (v1 as
tiles, v2 as its own rows), and a router guard sends an ordinary customer back
to their dashboard. None of that is relied on for safety — the server scopes
both endpoints on the AdminUser id in the token and answers 403 otherwise.

Not ported, both recorded in D21: the act-as api_token minting (:197-201) and
the child-order edit drawer, which recomputed an order's money from client-sent
values. The card opens a read-only summary instead.

APP-02 fixed: user-app-v2 gained getHomeSlider and now prefers admin-configured
slides, keeping its category-derived hero as the fallback and its own design.

Session stores stay byte-identical across the apps.
build 5/5, jest 45/45, expert login 36/36, apps smoke 43/43, typecheck clean.

[ref: henza_pwa/src/views/user/src/components/ChildOrders.vue, customerChoice.vue]
[ref: henza_pwa/src/router/index.ts:171-188]

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 01:12:53 +03:30
s.esmaeiliandClaude Opus 5 69f4ba2b29 feat(api): sales-expert login to the customer apps (D21)
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>
2026-09-23 01:05:36 +03:30
s.esmaeiliandClaude Opus 5 53436ec855 chore(release): prepare release/delivery + fix docker env passthrough
DEPLOY-01: docker-compose.yml passed only DATABASE_URL and PORT to the api
service, so JWT_SECRET, JWT_CUSTOMER_SECRET, OTP_MASTER_CODE and every SMS_TREZ_*
variable were silently dropped in Docker. A deployed build would have used the
development JWT fallback and would not have had the delivery master code at all,
so the demo login would have failed with no obvious cause. All are now forwarded,
defaulting to empty (which the code treats as unset); verified with
`docker compose config`.

TEST-01: test-sales-expert-roundtrip.cjs had localhost:3077 hardcoded and could
only connect by accident. Now defaults to the repo's API_PORT. 33/33.

DELIVERY.md documents env vars (names only), run/deploy commands, how migrations
run inside the container, the verification results and the known issues.

release/delivery contains both parity branches with no conflicts (fast-forward).
build 5/5, jest 45/45, admin smoke 43/43, apps smoke 43/43, B2B scan 34/34,
B2B writes 25/25, auth-smoke 32/32, typecheck clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 00:30:09 +03:30
s.esmaeiliandClaude Opus 5 6d0f5c5359 test(api): B2B admin scan + write-path smoke, both green
scan-admin-b2b.cjs (34 ok, 0 broken) hits every read endpoint the wholesale desk
uses daily. smoke-admin-b2b-writes.cjs (25/25) covers the money-touching writes:
admin order placement and a cash payment document, asserting the ledger and the
customer balance rather than the HTTP status.

Verified: the making charge computes exactly, a document is created unconfirmed,
confirming moves the gold debt, and confirming again toggles back and restores
the balance to the byte.

Retail/storefront surfaces were deliberately not scanned (D13).

No B2B breakage found. Three first-draft failures were test bugs, corrected
against the real callers: cities/list -> admin/cities/list, by-ids takes
products_id, and document type 4 is cash (2 is a transfer).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 00:25:53 +03:30
s.esmaeiliandClaude Opus 5 8b7da90f51 test(apps): customer-flow smoke test for both apps + 4-digit master-code guard
smoke-apps.cjs walks login (master OTP), catalogue, cart cap, order placement
and order history, with the empty-order and unauthenticated negatives: 43/43.
The cart rules are executed from BOTH apps' own source so the result cannot be
carried by v1 alone.

No behavioural divergence found: all 9 stores are byte-identical, and the router,
API client and composables match except v1's protected variant-image picker.

APP-01: both apps' OTP box is exactly 4 inputs and only submits at 4, so a longer
OTP_MASTER_CODE cannot be typed into them. The login UI was left alone; main.ts
now warns at boot when the code is not 4 digits and .env.example states the rule.

APP-02 logged, not fixed: user-app-v2 has no getHomeSlider, so admin-configured
home slides show in v1 only. Changing v2's hero during a delivery is not worth
the risk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 00:22:50 +03:30
s.esmaeiliandClaude Opus 5 0983c42788 fix(admin-panel): retail order status editing posted a body with no orderId
views/order/List.vue handed the timeline activity entry straight to
single/order-sts/edit. That entry is {status, title, proceed, uaj, note} and has
no order id, so UpdateOrderStatusDto rejected every call with 400 — neither
"ثبت وضعیت" nor "حذف وضعیت" worked on the retail order screen. Both handlers now
take the id from tempRow, matching OmdeList.vue and TaminList.vue, which were
already correct.

Found by the new scripts/smoke-admin.cjs (43/43), which walks login, customer
create, sales-expert create/edit, product+variant create/edit and order
list/detail/status, asserting the database changed rather than trusting a 200.

[ref: henza_server/routes/admin.php:59]

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 21:30:58 +03:30
s.esmaeiliandClaude Opus 5 676ec3ed72 test(api): live D20 verification + document every env var
Two live scripts against a running API, covering both halves of the master-OTP
contract:
  * with OTP_MASTER_CODE set (8/8): signs in, the JWT works on customer/me,
    Persian digits accepted, and the RULE E1 negatives — near-miss code,
    unknown mobile, inactive customer — all still rejected.
  * without it (6/6): the same code returns 401, no boot banner, a real code
    still logs in, and replaying it fails.

Also confirmed from the boot log that with no SMS credentials the server starts
normally and declines to send instead of erroring (D20).

.env.example gained the auth, SMS and upload variables, names only, no values,
with OTP_MASTER_CODE marked delivery-only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 21:28:17 +03:30
s.esmaeiliandClaude Opus 5 460cf276b6 parity(api,apps): delivery-mode gaps — master OTP, non-blocking SMS, faToEn, v2 cart cap
D20: OTP_MASTER_CODE login bypass read only from the environment, disabled when
unset, audited on every use, with a boot banner while armed. SMS no longer
aborts the boot in production — it warns and declines silently, so no message
can stop an operation.

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 18:35:33 +03:30
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 654b98520d parity(api): declare _goldbank_migrations as an ignored model; drift now zero
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>
2026-09-22 08:25:03 +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