From 8133a2bdf482b6892d17b135dd3eb94093bc26b1 Mon Sep 17 00:00:00 2001 From: "s.esmaeili" Date: Tue, 22 Sep 2026 08:08:50 +0330 Subject: [PATCH] parity(api,admin): split customer name, add making-charge offset, wire SMS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/views/apps/customer/bulkCustomer.vue | 29 ++- .../views/apps/customer/singleCustomer.vue | 12 +- apps/api/.env.example | 25 +++ .../migration.sql | 97 +++++++++ .../migration.sql | 49 +++++ apps/api/prisma/schema.prisma | 43 +++- apps/api/scripts/test-name-trigger.cjs | 109 ++++++++++ apps/api/src/app.module.ts | 2 + apps/api/src/main.ts | 6 + .../admin-customers.service.ts | 98 +++++++-- .../customer-auth/customer-auth.service.ts | 34 +-- apps/api/src/modules/sms/sms.module.ts | 14 ++ apps/api/src/modules/sms/sms.service.spec.ts | 138 ++++++++++++ apps/api/src/modules/sms/sms.service.ts | 200 ++++++++++++++++++ docs/parity/CHANGELOG.md | 92 ++++++++ docs/parity/DECISIONS.md | 64 ++++++ docs/parity/ISSUES.md | 6 + docs/parity/PROGRESS.md | 46 +++- 18 files changed, 1013 insertions(+), 51 deletions(-) create mode 100644 apps/api/prisma/migrations/20260922120000_customer_name_parts/migration.sql create mode 100644 apps/api/prisma/migrations/20260922120100_customer_making_charge_offset/migration.sql create mode 100644 apps/api/scripts/test-name-trigger.cjs create mode 100644 apps/api/src/modules/sms/sms.module.ts create mode 100644 apps/api/src/modules/sms/sms.service.spec.ts create mode 100644 apps/api/src/modules/sms/sms.service.ts diff --git a/apps/admin-panel/src/views/apps/customer/bulkCustomer.vue b/apps/admin-panel/src/views/apps/customer/bulkCustomer.vue index 5c34097..01eb674 100644 --- a/apps/admin-panel/src/views/apps/customer/bulkCustomer.vue +++ b/apps/admin-panel/src/views/apps/customer/bulkCustomer.vue @@ -86,7 +86,7 @@ - + @@ -199,7 +199,7 @@ - + @@ -318,7 +318,9 @@ const form = reactive>({ // backend falls back to when the field is absent. type: undefined, reagent_id: undefined, - percent: undefined, + // D9 — the reference defaults a new customer's making-charge offset to 3 + // (AdminController.php:338) and makes the field required (:354). + percent: 3, gram_level: undefined, // The three document photos legacy collected via MyUpload. Stored as plain // URLs (schema: nationalIdImageUrl / businessLicenseUrl / shopImageUrl), @@ -377,6 +379,12 @@ function validate(): boolean { if (!form.fname) err.fname = 'لطفا مقدار را وارد کنید'; if (!form.lname) err.lname = 'لطفا مقدار را وارد کنید'; if (!form.mobile) err.mobile = 'لطفا مقدار را وارد کنید'; + // D9 — `percent` is required on create in the reference + // (AdminController.php:354). 0 is a legitimate value, so test for + // "not supplied", not for falsiness. + if (form.percent === undefined || form.percent === null || (form.percent as unknown) === '') { + err.percent = 'لطفا مقدار را وارد کنید'; + } return Object.keys(err).length === 0; } @@ -390,12 +398,18 @@ async function handleSubmitOmde() { // whole form means any field the edit path failed to repopulate is sent as // '' and overwrites good data — see the note on Object.assign below. const changed = id ? diffFromInitial() : { ...form }; - const payload = { + const payload: Record = { ...changed, id: id ?? null, - name: `${form.fname} ${form.lname}`.trim(), national_code: form.ncode, }; + // D0 — fname/lname are real columns and travel in `changed` on their own. + // `name` is only sent when a name part actually changed; sending it + // unconditionally would re-submit the whole name on every save and defeat + // the diff-send the edit path relies on. + if (!id || 'fname' in changed || 'lname' in changed) { + payload.name = `${form.fname} ${form.lname}`.trim(); + } if (id) { await api.put(`customer/${id}`, payload); } else { @@ -440,7 +454,10 @@ onMounted(async () => { mobile: data.mobile ?? '', city_id: data.city_id ?? undefined, address: data.address ?? '', - percent: data.percent ?? undefined, + // D9 — the API sends a Decimal, which serialises as a string. Coerce + // it, or a-input-number shows it fine but diffFromInitial() compares + // "3" with 3 and re-sends an unchanged field on every save. + percent: data.percent === null || data.percent === undefined ? undefined : Number(data.percent), gram_level: data.gram_level ?? undefined, reagent_id: data.reagent?.id ?? undefined, // These five were previously OMITTED here. Because handleSubmitOmde diff --git a/apps/admin-panel/src/views/apps/customer/singleCustomer.vue b/apps/admin-panel/src/views/apps/customer/singleCustomer.vue index 913ac76..2c75f4c 100644 --- a/apps/admin-panel/src/views/apps/customer/singleCustomer.vue +++ b/apps/admin-panel/src/views/apps/customer/singleCustomer.vue @@ -89,7 +89,9 @@ async function handleSubmit() { loading.value = true; const payload = { ...form, - // one fullName in the schema; the two inputs are joined on submit + // D0 — fname and lname are real columns now, so they go as-is (they are + // already in `form`). `name` is still sent for the legacy readers that + // expect a whole name; the server derives it from the parts anyway. name: `${form.fname} ${form.lname}`.trim(), mobile: form.phone, }; @@ -118,10 +120,12 @@ onMounted(async () => { if (id) { try { const { data } = await api.get(`customer/${id}`); - const parts = String(data.name ?? '').trim().split(' '); + // D0 — the API returns the real parts now. The old split(' ') fallback + // is gone: it guessed at the boundary and put two-token first names in + // the wrong box. Object.assign(form, { - fname: data.fname || parts[0] || '', - lname: data.lname || parts.slice(1).join(' '), + fname: data.fname ?? '', + lname: data.lname ?? '', phone: data.mobile ?? '', city_id: data.city_id ?? undefined, }); diff --git a/apps/api/.env.example b/apps/api/.env.example index 4d9d86d..f4e4f29 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -11,3 +11,28 @@ PORT=3000 JWT_CUSTOMER_SECRET= # Customer token lifetime. The reference issued a token that never expired. JWT_CUSTOMER_EXPIRES_IN=30d + +# --------------------------------------------------------------------------- +# SMS gateway — Trez panel (port of henza_server/app/Models/Sms.php:57-82) +# --------------------------------------------------------------------------- +# NEVER commit real values. The reference hardcoded its live production +# credentials in the source (SEC-12); those are treated as compromised and are +# not reused. Set the real values in .env, which is gitignored. +# +# In production the server REFUSES TO START when the three required variables +# below are unset, because without them no customer can receive a login code. +# Outside production it only warns, and OTP codes are logged locally instead. + +# "trez" (default) or "none" to disable sending entirely. +SMS_PROVIDER=trez +# Defaults to the reference endpoint when unset; override for staging. +SMS_TREZ_URL= +# Required in production: +SMS_TREZ_USERNAME= +SMS_TREZ_PASSWORD= +# The panel's sender line — the `PhoneNumber` form field. +SMS_TREZ_SENDER_LINE= +# Comma-separated admin numbers for order/document notifications. The reference +# hardcoded six mobile numbers across its controllers (STUB-05); empty means +# no admin notifications are sent. +SMS_ADMIN_RECIPIENTS= diff --git a/apps/api/prisma/migrations/20260922120000_customer_name_parts/migration.sql b/apps/api/prisma/migrations/20260922120000_customer_name_parts/migration.sql new file mode 100644 index 0000000..60ecd93 --- /dev/null +++ b/apps/api/prisma/migrations/20260922120000_customer_name_parts/migration.sql @@ -0,0 +1,97 @@ +-- D0 — split Customer.fullName into firstName / lastName (ADDITIVE). +-- +-- Reference: henza_server has two separate columns from the start +-- database/migrations/2020_10_01_120430_customers_table.php:18-19 +-- $table->string('fname')->nullable(); +-- $table->string('lname')->nullable(); +-- The target collapsed them into one `fullName`, so the admin edit form shows +-- the whole name in the "first name" box and saves an empty last name +-- (admin-customers.service.ts:231-233 returns `fname: c.fullName, lname: ''`). +-- +-- NOTHING IS DROPPED. `fullName` stays and is kept in sync as +-- `firstName || ' ' || lastName`, so every existing reader keeps working +-- (D0 requirement 4). +-- +-- D13 / storefront impact: `Customer` is a SHARED table — both the B2B app and +-- the retail storefront read it. This migration only ADDS two nullable columns +-- and leaves `fullName` populated and authoritative, so no storefront query, +-- view or report changes behaviour. No storefront column is touched. + +-- 1. The two new columns. Nullable: a customer may legitimately have no +-- surname, and the reference's own columns are nullable. +ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "firstName" TEXT; +ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "lastName" TEXT; + +-- 2. Backfill: first whitespace-separated token -> firstName, the REST -> +-- lastName (D0 requirement 2). Multi-space and leading/trailing whitespace +-- are normalised first so " علی محمدی " splits correctly. +-- +-- Rows with a single token get firstName = that token and lastName = NULL. +-- Rows with more than two tokens put every remaining token in lastName; +-- they are ALSO listed in the review report (see the companion script) so a +-- human can correct them — the split cannot be guessed safely +-- (D0 requirement 3). +UPDATE "Customer" +SET + "firstName" = split_part(btrim(regexp_replace("fullName", '\s+', ' ', 'g')), ' ', 1), + "lastName" = NULLIF( + substr( + btrim(regexp_replace("fullName", '\s+', ' ', 'g')), + length(split_part(btrim(regexp_replace("fullName", '\s+', ' ', 'g')), ' ', 1)) + 2 + ), + '' + ) +WHERE "firstName" IS NULL + AND "fullName" IS NOT NULL + AND btrim("fullName") <> ''; + +-- 3. Keep `fullName` in sync from now on. +-- +-- Writers may update either the parts or the whole; this trigger makes the +-- parts authoritative whenever they are the thing that changed, and +-- otherwise leaves an explicit `fullName` write alone. That way old code +-- that still writes only `fullName` is not broken (D0 requirement 4). +-- Precedence when BOTH the parts and fullName change in the SAME statement: +-- THE PARTS WIN. fullName is rebuilt from them and the submitted fullName is +-- discarded. Rationale: after D0 the parts are the field the forms edit, so +-- they carry the operator's intent; fullName is the derived, legacy-facing +-- value. Choosing the opposite would silently undo an edit made in the two +-- boxes the operator actually typed into. +-- The first branch below tests the parts, so it wins by construction. +CREATE OR REPLACE FUNCTION customer_sync_full_name() RETURNS trigger AS $$ +BEGIN + -- The name parts changed (or are being set on insert): rebuild fullName. + -- This is checked FIRST, so when both change in one statement the parts win. + IF (TG_OP = 'INSERT' AND (NEW."firstName" IS NOT NULL OR NEW."lastName" IS NOT NULL)) + OR (TG_OP = 'UPDATE' + AND (NEW."firstName" IS DISTINCT FROM OLD."firstName" + OR NEW."lastName" IS DISTINCT FROM OLD."lastName")) THEN + NEW."fullName" := btrim( + coalesce(NEW."firstName", '') || ' ' || coalesce(NEW."lastName", '') + ); + + -- Only fullName changed: derive the parts from it, so the two never drift. + ELSIF TG_OP = 'UPDATE' AND NEW."fullName" IS DISTINCT FROM OLD."fullName" + AND NEW."fullName" IS NOT NULL AND btrim(NEW."fullName") <> '' THEN + NEW."firstName" := split_part(btrim(regexp_replace(NEW."fullName", '\s+', ' ', 'g')), ' ', 1); + NEW."lastName" := NULLIF( + substr( + btrim(regexp_replace(NEW."fullName", '\s+', ' ', 'g')), + length(split_part(btrim(regexp_replace(NEW."fullName", '\s+', ' ', 'g')), ' ', 1)) + 2 + ), + '' + ); + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS customer_sync_full_name_trg ON "Customer"; +CREATE TRIGGER customer_sync_full_name_trg + BEFORE INSERT OR UPDATE ON "Customer" + FOR EACH ROW EXECUTE FUNCTION customer_sync_full_name(); + +-- 4. Name search hits both parts in the admin grid. +CREATE INDEX IF NOT EXISTS "Customer_firstName_idx" ON "Customer" ("firstName"); +CREATE INDEX IF NOT EXISTS "Customer_lastName_idx" ON "Customer" ("lastName"); diff --git a/apps/api/prisma/migrations/20260922120100_customer_making_charge_offset/migration.sql b/apps/api/prisma/migrations/20260922120100_customer_making_charge_offset/migration.sql new file mode 100644 index 0000000..5d35556 --- /dev/null +++ b/apps/api/prisma/migrations/20260922120100_customer_making_charge_offset/migration.sql @@ -0,0 +1,49 @@ +-- D9 — add Customer.makingChargeOffsetPercent (ADDITIVE, behaviour-neutral). +-- +-- Reference: `customers.percent`, a real per-customer column. +-- database/migrations/2020_10_19_153228_customer_add.php:32 +-- $table->double('percent')->default(0); +-- +-- What it does in the reference: +-- * it is ADDED to the variant's making charge when the app builds an order +-- line: henza_pwa/src/Mixins/product/index.ts:69-75 +-- getWage(v) = myStore.state.percent + v.percentage_pay +-- and `state.percent` is this column, delivered by POST /me-data +-- (henza_pwa/src/store/myDataStore.ts:29,59,122-127). +-- * it shifts the catalogue making-charge filter bounds: +-- ApiMobileController.php:391-396 and :76-77. +-- * it is NOT part of the price formula itself — the extra percent there +-- comes from ConfigStatic['product_price_extra_percent'] +-- (ProductVariant.php:50,183). +-- +-- Why this name and not `productPercent`: the reference has three unrelated +-- things called `percent` (see docs/parity/MIGRATION-STATE.md). This is the +-- per-customer making-charge offset. It is mapped back to the legacy key +-- `percent` in toRow(), so the admin panel's field name does not change. +-- No duplicate field is created. +-- +-- The target currently reads `referredByAgent.wagePercent` instead, which is +-- the wrong field entirely (CUST-02) — that is corrected in the service layer, +-- not here. +-- +-- D13 / storefront impact: NONE. `customers.percent` is a B2B wholesale field; +-- it is consumed only by the B2B app's cart and catalogue filter. The +-- storefront's own percent is ConfigStatic['percent'] (the wallet cashback +-- rate), a different key in a different table, which this migration does not +-- touch. `Customer` is a shared table, but adding a column with a default that +-- reproduces today's behaviour changes no storefront read. + +-- DEFAULT 0 is deliberate and behaviour-neutral: +-- * it matches the reference's own column default (customer_add.php:32); +-- * with 0 the computed wage is `0 + variant.percentage_pay`, i.e. exactly +-- the making charge alone — which is what the target does today, since it +-- has no such column. So no existing customer's price moves. +-- NOTE: the reference's createCustomer() writes 3, not 0, for NEW customers +-- (AdminController.php:338) and makes the field required on create (:354). +-- That is an application-layer default for new rows, NOT a backfill value, +-- and it is handled in the service, not in this migration. +ALTER TABLE "Customer" + ADD COLUMN IF NOT EXISTS "makingChargeOffsetPercent" DECIMAL(6,3) NOT NULL DEFAULT 0; + +COMMENT ON COLUMN "Customer"."makingChargeOffsetPercent" IS + 'Legacy customers.percent — per-customer making-charge offset, ADDED to ProductVariant.defaultWagePercent when pricing an order line, and used to shift the catalogue making-charge filter bounds. Not the wallet cashback rate (that is the storefront ConfigStatic[percent]) and not the wholesale per-line charge (that is OrderItem.wagePercentSnapshot).'; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index f0ec677..dcf979a 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -170,8 +170,29 @@ model AdminUser { } model Customer { - id String @id @default(cuid()) - fullName String + id String @id @default(cuid()) + + /// ⚠️ `fullName`, `firstName` and `lastName` are kept in sync BY A DATABASE + /// TRIGGER — `customer_sync_full_name`, created in migration + /// 20260922120000_customer_name_parts. Prisma does not know about it, so a + /// write here is NOT the whole story: + /// + /// * write the parts -> fullName is rebuilt as "first last" + /// * write fullName only -> the parts are re-derived from it + /// * write BOTH in one update -> THE PARTS WIN; fullName is overwritten + /// with "first last" and whatever you passed for fullName is discarded + /// + /// So a value you wrote can differ from the value that lands in the row. + /// Re-read after writing if you need the stored value. Prefer writing the + /// parts; `fullName` exists for the legacy readers that predate D0. + /// + /// Reference has these as two columns from the start + /// (henza_server 2020_10_01_120430_customers_table.php:18-19 fname/lname); + /// the port collapsed them, which is the D0 defect. + fullName String + firstName String? + lastName String? + mobile String @unique nationalCode String @unique passwordHash String @@ -201,6 +222,22 @@ model Customer { goldDebtGrams Decimal @default(0) @db.Decimal(14, 4) // legacy remain fiatDebtRial Decimal @default(0) @db.Decimal(20, 2) // legacy rial_remain + /// Legacy `customers.percent` — the per-customer making-charge offset. It is + /// ADDED to the variant's making charge when pricing an order line + /// (henza_pwa src/Mixins/product/index.ts:69-75: + /// getWage(v) = state.percent + v.percentage_pay) + /// and it shifts the catalogue making-charge filter bounds + /// (ApiMobileController.php:391-396,76-77). It is NOT part of the price + /// formula itself — that extra percent is ConfigStatic['product_price_extra_percent']. + /// + /// NOT the wallet cashback rate (storefront ConfigStatic['percent'], D13) and + /// NOT the wholesale per-line charge (OrderItem.wagePercentSnapshot). + /// + /// DB default 0 keeps every existing customer's price unchanged. The + /// reference's application-layer default for NEW customers is 3 + /// (AdminController.php:338) and the field is required on create (:354). + makingChargeOffsetPercent Decimal @default(0) @db.Decimal(6, 3) + verificationStep VerificationStep @default(UNVERIFIED) nationalIdImageUrl String? businessLicenseUrl String? @@ -236,6 +273,8 @@ model Customer { otpChallenges CustomerOtp[] @@index([referredByAgentId]) + @@index([firstName]) + @@index([lastName]) } model CustomerAddress { diff --git a/apps/api/scripts/test-name-trigger.cjs b/apps/api/scripts/test-name-trigger.cjs new file mode 100644 index 0000000..a22dcdd --- /dev/null +++ b/apps/api/scripts/test-name-trigger.cjs @@ -0,0 +1,109 @@ +/** + * Integration test for the `customer_sync_full_name` database trigger + * (migration 20260922120000_customer_name_parts). + * + * The trigger is SQL, not Prisma, so jest's unit suite cannot cover it — it + * needs a live database. Run it against a development DB: + * + * node scripts/test-name-trigger.cjs + * + * Exits non-zero on any failure. It creates ONE temporary Customer row and + * deletes it again; the deletion is reported on stdout (CHANGELOG rule 2). + * + * The three directions required by the owner's ruling of 2026-09-22: + * parts only -> fullName is rebuilt + * fullName only -> the parts are re-derived + * both at once -> THE PARTS WIN + */ +const { PrismaClient } = require('@prisma/client'); + +const prisma = new PrismaClient(); +let failures = 0; + +function check(condition, message) { + if (!condition) failures++; + console.log(`${condition ? ' PASS' : ' FAIL'} ${message}`); +} + +const read = async (id) => + ( + await prisma.$queryRawUnsafe( + `SELECT "fullName","firstName","lastName","makingChargeOffsetPercent" AS pct + FROM "Customer" WHERE id=$1`, + id, + ) + )[0]; + +(async () => { + const id = 'trigger-test-' + Date.now(); + const unique = Date.now(); + + await prisma.$executeRawUnsafe( + `INSERT INTO "Customer" + (id,"fullName","firstName","lastName",mobile,"nationalCode","passwordHash","updatedAt") + VALUES ($1,'',$2,$3,$4,$5,'x',NOW())`, + id, + 'محمد علی', + 'احمدی', + '09' + String(unique).slice(-9), + 'NC' + unique, + ); + + // The owner's worked example: a two-token FIRST name must survive intact. + console.log('\nCASE 0 — INSERT with parts only («محمد علی» + «احمدی»)'); + let r = await read(id); + console.log(' ', JSON.stringify(r)); + check(r.fullName === 'محمد علی احمدی', 'fullName rebuilt from the parts'); + check(r.firstName === 'محمد علی', 'a two-token first name is NOT re-split'); + check(r.lastName === 'احمدی', 'last name kept separate'); + + console.log('\nCASE 1 — UPDATE the parts only'); + await prisma.$executeRawUnsafe( + `UPDATE "Customer" SET "firstName"='رضا',"lastName"='کریمی' WHERE id=$1`, + id, + ); + r = await read(id); + console.log(' ', JSON.stringify(r)); + check(r.fullName === 'رضا کریمی', 'fullName rebuilt from the parts'); + + console.log('\nCASE 2 — UPDATE fullName only'); + await prisma.$executeRawUnsafe(`UPDATE "Customer" SET "fullName"='سعید موسوی' WHERE id=$1`, id); + r = await read(id); + console.log(' ', JSON.stringify(r)); + check(r.firstName === 'سعید' && r.lastName === 'موسوی', 'parts re-derived from fullName'); + + console.log('\nCASE 3 — UPDATE BOTH in one statement (the parts must win)'); + await prisma.$executeRawUnsafe( + `UPDATE "Customer" SET "fullName"='IGNORED',"firstName"='نرگس',"lastName"='رحیمی' WHERE id=$1`, + id, + ); + r = await read(id); + console.log(' ', JSON.stringify(r)); + check(r.fullName === 'نرگس رحیمی', 'parts win; the submitted fullName is discarded'); + + console.log('\nCASE 4 — a multi-token fullName splits first-token / rest'); + await prisma.$executeRawUnsafe(`UPDATE "Customer" SET "fullName"='سید محمد حسینی' WHERE id=$1`, id); + r = await read(id); + console.log(' ', JSON.stringify(r)); + check(r.firstName === 'سید' && r.lastName === 'محمد حسینی', 'first token / the rest'); + + console.log('\nCASE 5 — an unrelated update leaves the name untouched'); + const before = await read(id); + await prisma.$executeRawUnsafe(`UPDATE "Customer" SET mobile=mobile WHERE id=$1`, id); + const after = await read(id); + check(JSON.stringify(before) === JSON.stringify(after), 'no-op update changes nothing'); + + console.log('\nCASE 6 — makingChargeOffsetPercent'); + check(Number(after.pct) === 0, 'defaults to 0, so no existing price moves'); + + await prisma.$executeRawUnsafe(`DELETE FROM "Customer" WHERE id=$1`, id); + console.log(`\nCleanup: deleted 1 temporary Customer row (${id}).`); + + console.log(failures === 0 ? '\nALL PASSED' : `\n${failures} FAILED`); + await prisma.$disconnect(); + process.exit(failures ? 1 : 0); +})().catch(async (e) => { + console.error(e); + await prisma.$disconnect(); + process.exit(1); +}); diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 16433c7..b69ad9d 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -3,6 +3,7 @@ import { PrismaModule } from './prisma/prisma.module'; import { CatalogModule } from './modules/catalog/catalog.module'; import { CustomersModule } from './modules/customers/customers.module'; import { CustomerAuthModule } from './modules/customer-auth/customer-auth.module'; +import { SmsModule } from './modules/sms/sms.module'; import { OrdersModule } from './modules/orders/orders.module'; import { AccountingModule } from './modules/accounting/accounting.module'; import { PricingModule } from './modules/pricing/pricing.module'; @@ -49,6 +50,7 @@ import { SalesAgentModule } from './modules/sales-agent/sales-agent.module'; AdminDashboardModule, CatalogModule, CustomersModule, + SmsModule, CustomerAuthModule, SalesAgentModule, OrdersModule, diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 37b943e..3f80c18 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -7,6 +7,7 @@ import type { Response } from 'express'; import { AppModule } from './app.module'; import { prepareDatabase } from './prisma/bootstrap'; import { PrismaExceptionFilter } from './prisma/prisma-exception.filter'; +import { assertSmsConfigured } from './modules/sms/sms.service'; // serve-static's bundled `mime` version predates .avif, so it falls back to // application/octet-stream — browsers still render the image (they sniff @@ -16,6 +17,11 @@ const EXTRA_MIME_TYPES: Record = { }; async function bootstrap() { + // Fail fast rather than start a server on which no customer could log in. + // In production a missing SMS credential throws here; outside production it + // only warns and the codes are logged locally. + assertSmsConfigured(); + await prepareDatabase(); const app = await NestFactory.create(AppModule); diff --git a/apps/api/src/modules/admin-customers/admin-customers.service.ts b/apps/api/src/modules/admin-customers/admin-customers.service.ts index ae0af76..710ff5d 100644 --- a/apps/api/src/modules/admin-customers/admin-customers.service.ts +++ b/apps/api/src/modules/admin-customers/admin-customers.service.ts @@ -97,6 +97,9 @@ export class AdminCustomersService { private static readonly ALLOWED = [ 'id', 'fullName', + 'firstName', + 'lastName', + 'makingChargeOffsetPercent', 'mobile', 'nationalCode', 'cityId', @@ -120,8 +123,11 @@ export class AdminCustomersService { /** Translates legacy filter field names onto real schema paths. */ private static readonly FIELD_ALIASES: Record = { name: 'fullName', - fname: 'fullName', - lname: 'fullName', + // D0 — these now have real columns to point at. Sorting or filtering on + // the last name used to silently act on the whole name. + fname: 'firstName', + lname: 'lastName', + percent: 'makingChargeOffsetPercent', city_id: 'cityId', active: 'isActive', gram_level: 'goldCreditLimitGrams', @@ -196,7 +202,10 @@ export class AdminCustomersService { const aliased = this.applyAliases(query); const { where, orderBy, skip, take, page, limit } = this.table.build(aliased, { - searchable: ['fullName', 'mobile', 'nationalCode'], + // D0 — `fullName` is kept in sync by the trigger so it still matches, but + // the parts are searched too: a surname-only query is the common case and + // it must not depend on how the whole name happens to be spaced. + searchable: ['fullName', 'firstName', 'lastName', 'mobile', 'nationalCode'], allowed: AdminCustomersService.ALLOWED, defaultOrderBy: { createdAt: 'desc' }, }); @@ -227,10 +236,15 @@ export class AdminCustomersService { ) { return { id: c.id, - // Legacy stored fname/lname separately; the new schema has one fullName. + // D0 — the schema now has the real parts, as the reference always did + // (2020_10_01_120430_customers_table.php:18-19). Before this, the edit + // form showed the WHOLE name in the "first name" box and saved an empty + // last name. `firstName`/`lastName` are backfilled and kept in sync with + // `fullName` by the customer_sync_full_name trigger, so the fallbacks + // below only matter for a row written before the migration ran. name: c.fullName, - fname: c.fullName, - lname: '', + fname: c.firstName ?? c.fullName, + lname: c.lastName ?? '', mobile: c.mobile, national_code: c.nationalCode, city_id: c.cityId, @@ -241,8 +255,12 @@ export class AdminCustomersService { gram_level: c.goldCreditLimitGrams, remain: c.goldDebtGrams, rial_remain: c.fiatDebtRial, - // legacy `percent` came from the referring agent's commission rate - percent: c.referredByAgent?.wagePercent ?? null, + // D9 — legacy `percent` is a column ON THE CUSTOMER + // (customer_add.php:32), the per-customer making-charge offset. The port + // read the referring AGENT's commission rate instead, which is a + // different number entirely (CUST-02). The legacy key is kept so the + // ported table models and forms bind unchanged. + percent: c.makingChargeOffsetPercent, // legacy kept these as two INDEPENDENT switches — see toggleSts/toggleActive active: c.isActive ? 1 : -1, sts: c.canPlaceOrders ? 1 : -1, @@ -516,7 +534,13 @@ export class AdminCustomersService { * is generic enough to patch any Customer row, agent or otherwise. */ async createSingle(data: Record) { - const fullName = String(data.name ?? data.fname ?? '').trim(); + // D0 — store the parts when the caller sends them. This is the retail + // (storefront) form, which D13 puts out of scope for parity work; the name + // handling is corrected only so the shared Customer columns stay + // consistent, and no storefront behaviour changes. + const firstName = String(data.fname ?? data.name ?? '').trim(); + const lastName = String(data.lname ?? '').trim(); + const fullName = [firstName, lastName].filter(Boolean).join(' '); const mobile = String(data.mobile ?? data.phone ?? '').trim(); if (!fullName || !mobile) { @@ -526,6 +550,8 @@ export class AdminCustomersService { return this.prisma.customer.create({ data: { fullName, + firstName: firstName || null, + lastName: lastName || null, mobile, nationalCode: mobile, // schema requires it and it is unique; singleCustomer.vue collects no ncode passwordHash: '', @@ -545,7 +571,11 @@ export class AdminCustomersService { * (e.g. reg.vue, or a stale client) still sending type=1. */ async create(data: Record) { - const fullName = String(data.name ?? data.fname ?? '').trim(); + // D0 — the form posts fname and lname separately; store them separately. + // `name` is still accepted for the callers that only send a whole name. + const firstName = String(data.fname ?? data.name ?? '').trim(); + const lastName = String(data.lname ?? '').trim(); + const fullName = [firstName, lastName].filter(Boolean).join(' '); const mobile = String(data.mobile ?? '').trim(); const nationalCode = String(data.national_code ?? data.nationalCode ?? '').trim(); @@ -553,6 +583,14 @@ export class AdminCustomersService { throw new BadRequestException('نام و موبایل الزامی است'); } + // D9 — the reference makes `percent` required on create + // (AdminController.php:354) with the stock Laravel message, and its + // attribute has no Persian translation, so the text is verbatim what the + // reference produces. + if (data.percent === undefined || data.percent === null || data.percent === '') { + throw new BadRequestException('وارد کردن percent الزامی است'); + } + const kind = legacyTypeToKind(data.type) ?? CustomerKind.APP_SHOPKEEPER; if (kind === CustomerKind.SALES_AGENT) { throw new BadRequestException( @@ -563,6 +601,12 @@ export class AdminCustomersService { return this.prisma.customer.create({ data: { fullName, + firstName: firstName || null, + lastName: lastName || null, + // D9 — per-customer making-charge offset. Required above, so this is + // always a real value; the reference's own form default is 3 + // (AdminController.php:338) and is applied client-side. + makingChargeOffsetPercent: new Prisma.Decimal(String(data.percent)), mobile, nationalCode: nationalCode || mobile, // schema requires it and it is unique // The form collects a password (bulkCustomer.vue / ReagentCustomer.vue). @@ -603,7 +647,25 @@ export class AdminCustomersService { if (!existing) throw new NotFoundException('مشتری یافت نشد'); const patch: Prisma.CustomerUpdateInput = {}; - if (data.name !== undefined) patch.fullName = String(data.name); + + // D0 — write the parts, not a collapsed whole name. The + // customer_sync_full_name trigger rebuilds `fullName` from them, and when + // a caller sends both the parts win (see the migration's header). + if (data.fname !== undefined || data.lname !== undefined) { + const firstName = String(data.fname ?? existing.firstName ?? '').trim(); + const lastName = String(data.lname ?? existing.lastName ?? '').trim(); + patch.firstName = firstName || null; + patch.lastName = lastName || null; + } else if (data.name !== undefined) { + // Whole-name-only callers: the trigger derives the parts. + patch.fullName = String(data.name); + } + + // D9 — this was simply not handled, so editing it silently did nothing. + if (data.percent !== undefined && data.percent !== null && data.percent !== '') { + patch.makingChargeOffsetPercent = new Prisma.Decimal(String(data.percent)); + } + if (data.mobile !== undefined) patch.mobile = String(data.mobile); if (data.national_code !== undefined) patch.nationalCode = String(data.national_code); if (data.city_id !== undefined) { @@ -857,9 +919,15 @@ export class AdminCustomersService { throw new BadRequestException('مقدار سطح اعتبار ارسال نشده است'); } - return this.prisma.customer.update({ - where: { id }, - data: { goldCreditLimitGrams: new Prisma.Decimal(data.gram_level) }, - }); + // D9 — `percent` was in this method's signature but was never written, so + // editing it from the row menu silently did nothing. + const patch: Prisma.CustomerUpdateInput = { + goldCreditLimitGrams: new Prisma.Decimal(data.gram_level), + }; + if (data.percent !== undefined && data.percent !== null && String(data.percent) !== '') { + patch.makingChargeOffsetPercent = new Prisma.Decimal(String(data.percent)); + } + + return this.prisma.customer.update({ where: { id }, data: patch }); } } diff --git a/apps/api/src/modules/customer-auth/customer-auth.service.ts b/apps/api/src/modules/customer-auth/customer-auth.service.ts index 7b75c5c..281e863 100644 --- a/apps/api/src/modules/customer-auth/customer-auth.service.ts +++ b/apps/api/src/modules/customer-auth/customer-auth.service.ts @@ -5,6 +5,7 @@ import * as bcrypt from 'bcryptjs'; import { PrismaService } from '../../prisma/prisma.service'; import { CUSTOMER_TOKEN_TYPE, type CustomerJwtPayload } from './customer-jwt.strategy'; import { faToEn } from './digits'; +import { SmsService, smsConfigured } from '../sms/sms.service'; /** Nest 10 has no TooManyRequestsException; 429 with a Persian message. */ class TooManyRequestsException extends HttpException { @@ -32,6 +33,7 @@ export class CustomerAuthService { constructor( private readonly prisma: PrismaService, private readonly jwt: JwtService, + private readonly sms: SmsService, ) {} /** @@ -201,28 +203,34 @@ export class CustomerAuthService { } /** - * Hands the code to the SMS gateway. + * Hands the code to the SMS gateway (SmsService -> Trez, per Sms.php:57-82). * - * No gateway is wired up in this project yet — SmsMessage is persistence only - * (logged as STUB-02 in docs/parity/ISSUES.md), exactly as the reference's - * Sms::Send would have been the integration point. Until one is connected the - * code is logged, and only outside production, so a deployment cannot print - * live codes into its logs. + * When no credentials are configured, SmsService does not send and the code + * is logged instead — development only; production refuses to boot without + * credentials (assertSmsConfigured in main.ts). * - * The reference's message text is kept verbatim for when the gateway lands - * (ApiMobileController.php:47). + * A failed send does NOT throw. The reference ignored Sms::Send's result, so + * a gateway outage never blocked the flow that triggered it; the customer + * simply retries, and the throttle in assertSendAllowed still applies. */ private async deliver(mobile: string, code: string) { - // The exact text the reference sent (ApiMobileController.php:47), kept for - // whenever a gateway is wired up. It is built here and handed to the - // gateway; it is deliberately NOT what gets persisted (see below). + // BYTE-IDENTICAL to the reference (ApiMobileController.php:47). The + // leading "<#> code is " line and the trailing "aMo6iFyUQaY" are the + // Android SMS Retriever app hash — change either and autofill stops + // working on every installed client. Do not "tidy" this string. const smsText = `<#> code is ${code}\n` + `رمز عبور یکبار مصرف شما جهت ورود به اپلیکشن هنزا گلد ${code} می باشد\n` + `aMo6iFyUQaY`; - void smsText; // TODO: hand to the SMS provider once one exists (STUB-02) - if (process.env.NODE_ENV !== 'production') { + // persist:false — the real text carries the live code, and the admin SMS + // grid lists and searches `body` (SEC-11). The redacted audit row below + // takes its place. + await this.sms.send(mobile, smsText, { persist: false }); + + // Only outside production, and only when no gateway is configured, so a + // deployment can never print live codes into its logs. + if (process.env.NODE_ENV !== 'production' && !smsConfigured()) { this.logger.log(`[dev] OTP for ${mobile}: ${code}`); } diff --git a/apps/api/src/modules/sms/sms.module.ts b/apps/api/src/modules/sms/sms.module.ts new file mode 100644 index 0000000..23810ea --- /dev/null +++ b/apps/api/src/modules/sms/sms.module.ts @@ -0,0 +1,14 @@ +import { Global, Module } from '@nestjs/common'; +import { SmsService } from './sms.service'; + +/** + * Global so any module can send an SMS without re-importing it — the twelve + * reference notification points are spread across orders, documents and + * customer management. + */ +@Global() +@Module({ + providers: [SmsService], + exports: [SmsService], +}) +export class SmsModule {} diff --git a/apps/api/src/modules/sms/sms.service.spec.ts b/apps/api/src/modules/sms/sms.service.spec.ts new file mode 100644 index 0000000..a0a446d --- /dev/null +++ b/apps/api/src/modules/sms/sms.service.spec.ts @@ -0,0 +1,138 @@ +import { Logger } from '@nestjs/common'; +import { assertSmsConfigured, smsConfig, smsConfigured } from './sms.service'; + +/** + * Covers the boot-time contract the owner specified: + * * while no key is set, development behaviour is unchanged; + * * in production a missing key is a clear, explicit boot failure. + */ +describe('SMS configuration', () => { + const saved = { ...process.env }; + // Silence the warnings these tests deliberately provoke. + const logger = { warn: jest.fn(), log: jest.fn(), error: jest.fn() } as unknown as Logger; + + beforeEach(() => { + for (const key of [ + 'SMS_PROVIDER', + 'SMS_TREZ_URL', + 'SMS_TREZ_USERNAME', + 'SMS_TREZ_PASSWORD', + 'SMS_TREZ_SENDER_LINE', + 'NODE_ENV', + ]) { + delete process.env[key]; + } + jest.clearAllMocks(); + }); + + afterAll(() => { + process.env = { ...saved }; + }); + + const configureFully = () => { + process.env.SMS_TREZ_USERNAME = 'u'; + process.env.SMS_TREZ_PASSWORD = 'p'; + process.env.SMS_TREZ_SENDER_LINE = '9998882797'; + }; + + describe('smsConfig', () => { + it('defaults the provider to trez and the URL to the reference endpoint', () => { + const c = smsConfig(); + expect(c.provider).toBe('trez'); + expect(c.url).toBe('https://smspanel.trez.ir/SendMessageWithCode.ashx'); + }); + + it('lets the URL be overridden for staging', () => { + process.env.SMS_TREZ_URL = 'https://staging.example/send'; + expect(smsConfig().url).toBe('https://staging.example/send'); + }); + }); + + describe('smsConfigured', () => { + it('is false when nothing is set', () => { + expect(smsConfigured()).toBe(false); + }); + + it('is false when only some credentials are set', () => { + process.env.SMS_TREZ_USERNAME = 'u'; + process.env.SMS_TREZ_PASSWORD = 'p'; + expect(smsConfigured()).toBe(false); + }); + + it('is true once all three are set', () => { + configureFully(); + expect(smsConfigured()).toBe(true); + }); + + it('is false when the provider is explicitly disabled', () => { + configureFully(); + process.env.SMS_PROVIDER = 'none'; + expect(smsConfigured()).toBe(false); + }); + }); + + describe('assertSmsConfigured', () => { + it('only warns outside production when keys are missing', () => { + process.env.NODE_ENV = 'development'; + expect(() => assertSmsConfigured(logger)).not.toThrow(); + expect(logger.warn).toHaveBeenCalled(); + }); + + it('THROWS in production when keys are missing, naming them', () => { + process.env.NODE_ENV = 'production'; + expect(() => assertSmsConfigured(logger)).toThrow(/SMS_TREZ_USERNAME/); + expect(() => assertSmsConfigured(logger)).toThrow(/SMS_TREZ_PASSWORD/); + expect(() => assertSmsConfigured(logger)).toThrow(/SMS_TREZ_SENDER_LINE/); + }); + + it('passes in production once everything is set', () => { + process.env.NODE_ENV = 'production'; + configureFully(); + expect(() => assertSmsConfigured(logger)).not.toThrow(); + }); + + it('THROWS in production when SMS is explicitly disabled', () => { + // Disabling SMS in production means nobody can log in; that must be a + // deliberate, visible failure rather than a silent one. + process.env.NODE_ENV = 'production'; + process.env.SMS_PROVIDER = 'none'; + expect(() => assertSmsConfigured(logger)).toThrow(/Refusing to start in production/); + }); + + it('allows SMS to be disabled outside production', () => { + process.env.NODE_ENV = 'test'; + process.env.SMS_PROVIDER = 'none'; + expect(() => assertSmsConfigured(logger)).not.toThrow(); + }); + + it('rejects an unknown provider name outright', () => { + process.env.SMS_PROVIDER = 'kavenegar'; + expect(() => assertSmsConfigured(logger)).toThrow(/Unknown SMS_PROVIDER/); + }); + }); +}); + +/** + * The OTP text must stay byte-identical to the reference + * (ApiMobileController.php:47) — the first line and the trailing token are the + * Android SMS Retriever hash, and autofill breaks if either changes. + */ +describe('OTP message template', () => { + const build = (code: string) => + `<#> code is ${code}\n` + + `رمز عبور یکبار مصرف شما جهت ورود به اپلیکشن هنزا گلد ${code} می باشد\n` + + `aMo6iFyUQaY`; + + it('matches the reference byte for byte', () => { + expect(build('1234')).toBe( + '<#> code is 1234\nرمز عبور یکبار مصرف شما جهت ورود به اپلیکشن هنزا گلد 1234 می باشد\naMo6iFyUQaY', + ); + }); + + it('keeps the SMS Retriever markers', () => { + const text = build('9876'); + expect(text.startsWith('<#> code is ')).toBe(true); + expect(text.endsWith('\naMo6iFyUQaY')).toBe(true); + expect(text.split('\n')).toHaveLength(3); + }); +}); diff --git a/apps/api/src/modules/sms/sms.service.ts b/apps/api/src/modules/sms/sms.service.ts new file mode 100644 index 0000000..8458f1d --- /dev/null +++ b/apps/api/src/modules/sms/sms.service.ts @@ -0,0 +1,200 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { MessageAudience } from '@prisma/client'; +import { PrismaService } from '../../prisma/prisma.service'; + +/** + * SMS delivery — port of henza_server/app/Models/Sms.php. + * + * Reference behaviour (`Sms::Send`, `Sms.php:57-82`): + * * POST, form-urlencoded, to the Trez panel, 20s timeout + * * fields: PhoneNumber (sender line), Username, Password, Mobile, Message + * * the message is trim()ed before sending + * * EVERY message is persisted, via `Sms::set()` (`:80`) + * * the response body is returned and the callers ignore it + * + * Two things differ, both deliberate: + * 1. Credentials come from the environment. The reference hardcodes live + * production credentials in the source (SEC-12); they are not copied here. + * 2. The OTP message body is NOT persisted in readable form — see + * CustomerAuthService.deliver() and SEC-11. + * + * Failure handling matches the reference: `Sms::Send` is called inline and its + * result is discarded, so a failed send never aborted the order, the document + * approval or the customer creation that triggered it. `send()` therefore + * never throws — it logs and reports failure in its return value. + */ + +/** Reference endpoint (`Sms.php:69`). Overridable for staging. */ +const DEFAULT_TREZ_URL = 'https://smspanel.trez.ir/SendMessageWithCode.ashx'; + +/** `Http::timeout(20)` in the reference (`Sms.php:72`). */ +const REQUEST_TIMEOUT_MS = 20_000; + +export interface SmsSendResult { + ok: boolean; + /** Provider response body, when one was received. */ + response?: string; + error?: string; +} + +export interface SmsSendOptions { + /** + * Persist the message body. Default true, matching `Sms::set()`. + * OTP delivery passes false and writes its own redacted row (SEC-11). + */ + persist?: boolean; + audience?: MessageAudience; +} + +/** Reads the provider config. Exported so main.ts can validate it at boot. */ +export function smsConfig() { + return { + provider: (process.env.SMS_PROVIDER ?? 'trez').toLowerCase(), + url: process.env.SMS_TREZ_URL ?? DEFAULT_TREZ_URL, + username: process.env.SMS_TREZ_USERNAME ?? '', + password: process.env.SMS_TREZ_PASSWORD ?? '', + senderLine: process.env.SMS_TREZ_SENDER_LINE ?? '', + }; +} + +/** + * True when a real gateway is configured. When false the service stays in + * development mode: nothing is sent, and OTP codes are logged locally. + */ +export function smsConfigured(): boolean { + const c = smsConfig(); + if (c.provider === 'none') return false; + return Boolean(c.username && c.password && c.senderLine); +} + +/** + * Called from main.ts before the app listens. In production a missing key is + * fatal: without it no customer can log in, and a silent no-op gives no clue + * why. Outside production it is only a warning, so local development works + * with no credentials at all. + */ +export function assertSmsConfigured(logger: Logger = new Logger('SmsConfig')): void { + const c = smsConfig(); + + if (c.provider === 'none') { + const message = 'SMS_PROVIDER=none — no SMS will be sent.'; + if (process.env.NODE_ENV === 'production') { + throw new Error( + `${message} Refusing to start in production: customers could not log in. ` + + 'Set SMS_PROVIDER=trez and the SMS_TREZ_* variables.', + ); + } + logger.warn(message); + return; + } + + if (c.provider !== 'trez') { + throw new Error( + `Unknown SMS_PROVIDER "${c.provider}". Supported values: "trez", "none".`, + ); + } + + const missing = ( + [ + ['SMS_TREZ_USERNAME', c.username], + ['SMS_TREZ_PASSWORD', c.password], + ['SMS_TREZ_SENDER_LINE', c.senderLine], + ] as const + ) + .filter(([, v]) => !v) + .map(([name]) => name); + + if (missing.length === 0) return; + + const message = `SMS provider "trez" is selected but these are not set: ${missing.join(', ')}.`; + if (process.env.NODE_ENV === 'production') { + throw new Error( + `${message} Refusing to start in production: no customer could receive a login code. ` + + 'Set them, or set SMS_PROVIDER=none to acknowledge that SMS is disabled.', + ); + } + logger.warn(`${message} Running in development mode — messages are logged, not sent.`); +} + +@Injectable() +export class SmsService { + private readonly logger = new Logger(SmsService.name); + + constructor(private readonly prisma: PrismaService) {} + + /** + * Sends one message. Never throws — a failed SMS must not roll back the + * business operation that triggered it, exactly as in the reference. + */ + async send(mobile: string, body: string, options: SmsSendOptions = {}): Promise { + const { persist = true, audience = MessageAudience.SINGLE_NUMBER } = options; + // The reference trims before sending (`Sms.php:77`). + const text = body.trim(); + + let result: SmsSendResult; + + if (!smsConfigured()) { + this.logger.warn(`[dev] SMS not configured; not sending to ${mobile}`); + result = { ok: false, error: 'sms-not-configured' }; + } else { + result = await this.deliverViaTrez(mobile, text); + } + + if (persist) { + // `Sms::set()` records every message, delivered or not. + await this.record(mobile, text, audience); + } + + return result; + } + + /** POST to the Trez panel, shaped exactly as the reference's form post. */ + private async deliverViaTrez(mobile: string, text: string): Promise { + const c = smsConfig(); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + + try { + const response = await fetch(c.url, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + PhoneNumber: c.senderLine, + Username: c.username, + Password: c.password, + Mobile: mobile, + Message: text, + }).toString(), + signal: controller.signal, + }); + + const responseBody = await response.text(); + + if (!response.ok) { + // Logged, not thrown — see the class comment. + this.logger.error(`SMS to ${mobile} failed: HTTP ${response.status} ${responseBody}`); + return { ok: false, response: responseBody, error: `http-${response.status}` }; + } + + return { ok: true, response: responseBody }; + } catch (e) { + const error = e instanceof Error ? e.message : String(e); + this.logger.error(`SMS to ${mobile} failed: ${error}`); + return { ok: false, error }; + } finally { + clearTimeout(timer); + } + } + + /** The `messages` row the reference writes for every send (`Sms.php:120-129`). */ + private async record(mobile: string, body: string, audience: MessageAudience) { + try { + await this.prisma.smsMessage.create({ + data: { audience, recipients: mobile, body, sentCount: 1 }, + }); + } catch (e) { + // Never let the audit write break the caller either. + this.logger.error(`Could not record the SMS to ${mobile}: ${String(e)}`); + } + } +} diff --git a/docs/parity/CHANGELOG.md b/docs/parity/CHANGELOG.md index 1306a6f..63185e0 100644 --- a/docs/parity/CHANGELOG.md +++ b/docs/parity/CHANGELOG.md @@ -306,3 +306,95 @@ this wrote the missing `_prisma_migrations` row **without running any SQL**, so | `src/modules/pricing/variant-price.spec.ts` | 11 tests against **hand calculations** derived from the PHP source, covering with/without the offset, the VAT-on-non-metal-portion-only rule, floor-to-1000, and the equivalence between the app folding the offset into `percent_pay` and the server deriving it. | `npx jest` → **18 passed** (7 existing + 11 new), 2 suites. + +--- + +## 2026-09-22 (later) — D0 + percent applied, SMS provider implemented + +### Migrations applied + +`migrate dev` could not run (interactive-only, and this shell is not a TTY). +Drift was checked read-only first: the only difference is the pre-existing +`_goldbank_migrations` table, which Prisma would DROP — **it was not dropped** +(DEAD-07). Applied with `migrate deploy`, which touches only the pending files: + +``` +Applying migration `20260922120000_customer_name_parts` +Applying migration `20260922120100_customer_making_charge_offset` +All migrations have been successfully applied. +→ 11 migrations found. Database schema is up to date! +``` + +Backfill result (`node scripts/report-name-split.cjs`): 8 customers, 7 split +reliably, 0 single-token, 0 empty, **1 needs review** — `customer-dev-test` +(«کاربر تست توسعه»), a row this session created itself. + +### Data deletions (standing rule 2) + +| When | What | How many | Why | +|---|---|---|---| +| Round-trip test | `Customer` rows created by the test: 2 successful + 1 whose Persian text the Windows shell had mangled into `?` | 3 | Test fixtures. Customer count back to its original 8. | +| Trigger test | 1 temporary `Customer` row per run, deleted by the script itself and reported on stdout | 1/run | Test fixture. | + +### API + +- `toRow()` returns the **real** `fname`/`lname` (D0) and `percent` now reads + `makingChargeOffsetPercent` **from the customer** instead of the referring + agent's commission rate (D9 / CUST-02). +- `create()` stores the name parts, and **requires `percent`** with the + reference's own message, `وارد کردن percent الزامی است` — Laravel's stock + `required` text (`resources/lang/fa/validation.php:99`) with no attribute + translation for `percent`, so it is verbatim. +- `update()` writes the parts (letting the trigger rebuild `fullName`) and + **persists `percent`**, which it previously ignored entirely. +- `updateCredit()` accepted `percent` in its signature and never wrote it — + fixed. +- Sort/filter aliases `fname`/`lname` pointed at `fullName`; they now point at + the real columns, and `percent` is aliased too. Grid search covers both parts. +- `createSingle()` (retail form, D13) stores the parts too. No storefront + behaviour changed — only the shared columns are kept consistent. + +### SMS — implemented (owner-approved) + +- New `SmsModule` / `SmsService` (`@Global`), a port of `Sms.php:57-82`: POST, + form-urlencoded, 20s timeout, `PhoneNumber`/`Username`/`Password`/`Mobile`/ + `Message`, body trimmed, every message persisted as `Sms::set()` did. +- **Failure handling matches the reference**: `send()` never throws. The + reference discarded `Sms::Send`'s result, so a gateway outage never aborted + the order, the document approval or the customer creation that triggered it. + Failures are logged and returned in `SmsSendResult`. +- **OTP text is byte-identical** to `ApiMobileController.php:47`, including the + `<#> code is …` first line and the trailing `aMo6iFyUQaY` — the Android SMS + Retriever hash. A unit test asserts the exact string. +- `assertSmsConfigured()` runs in `main.ts` **before the server listens**: in + production a missing key throws, naming it; outside production it warns and + codes are logged locally, so today's dev flow is unchanged. +- Credentials come from the environment only. `.env.example` lists the six names + with **empty values**; `.env` is gitignored (verified). +- The 12 B2B message templates are implemented per flow in their own phase — + admin in Phase 1, app in Phase 2 — not pre-emptively. + +### Admin panel + +- `singleCustomer.vue` — the `split(' ')` fallback is **removed** (D0 item 6). +- `bulkCustomer.vue` — `percent` defaults to **3** on create and is required + with the legacy message; the edit path coerces the Decimal string to a number + so diff-send does not re-send an unchanged field; `name` is only sent when a + name part actually changed, so the whole name is no longer re-submitted on + every save. +- `ReagentCustomer.vue` — **unchanged**. Its «درصد کمیسیون» is + `AdminUser.wagePercent`, a genuinely different field from `customers.percent`. + +### Tests + +- `npx jest` — **32 passed** (7 existing + 11 price formula + 14 SMS config/template). +- `scripts/test-name-trigger.cjs` — **9 assertions**, all three sync directions. +- `scripts/auth-smoke.sh` — **53 passed, 0 failed**. +- **Round-trip test (D12 rule E0)** on the customer entity, live: + create «محمد علی» + «احمدی» + percent 3 → edit form shows each field + separately and correctly → no-op save leaves the row **byte-identical** → + changing only `lname` changes `lastName` and `fullName` (trigger) and nothing + else → changing only `percent` changes exactly that column. The two-token + first name «محمد علی» survives intact, which is the defect the owner + originally reported. +- Typecheck: api ✅ · admin-panel ✅. diff --git a/docs/parity/DECISIONS.md b/docs/parity/DECISIONS.md index b7fada7..968d664 100644 --- a/docs/parity/DECISIONS.md +++ b/docs/parity/DECISIONS.md @@ -713,3 +713,67 @@ owner's ruling the target's behaviour stays.** Recorded here as a deliberate security deviation from the reference. The visible result is identical whenever the client sends the true variant weight, which the reference app always does (`cart.vue:178`); it differs only for a forged request, which is the point. + +--- + +# D14 — Migration workflow, and the `customer_sync_full_name` trigger (2026-09-22) + +## Workflow + +| Environment | Command | Rule | +|---|---|---| +| Development | `npx prisma migrate dev` | the only way schema changes are applied | +| Production | `npx prisma migrate deploy` | **only** this — never `migrate dev`, never `migrate reset` | +| Anywhere | `prisma db execute` | **no longer used for schema changes** | + +If `migrate dev` reports drift and offers a reset: **stop, do not accept, report +it.** `_prisma_migrations` is never edited by hand. + +### What actually happened on 2026-09-22 + +`migrate dev` **refused to run**: it is interactive by design and this shell is +not a TTY. That is a safety property, not a problem — a reset prompt could never +be auto-accepted here. + +Before falling back, drift was checked read-only with `prisma migrate diff`. It +reported one pre-existing difference that has nothing to do with this work: + +``` +-- DropTable +DROP TABLE "_goldbank_migrations"; +``` + +`_goldbank_migrations` is a **bespoke ledger from the earlier port**, holding 12 +historical rows (`0001_init.sql`, `seed:initial`, …). It is not in +`schema.prisma`, so Prisma considers it foreign and would drop it. **It was not +dropped.** Dropping it is a destructive operation, it is unapproved, and the +rows are the only record of how the pre-Prisma port was applied. Logged as +**DEAD-07**; leave it until the owner decides. + +The two new migrations were therefore applied with **`migrate deploy`**, which +applies exactly the pending files and touches nothing else — so the drift was +neither "fixed" nor acted upon. Result: `11 migrations found. Database schema is +up to date!` + +## The `customer_sync_full_name` trigger + +Created by `20260922120000_customer_name_parts`. **Prisma does not know it +exists**, so a write through Prisma Client can produce a row different from what +was written. Documented in three places so the next developer cannot miss it: +the migration header, the `Customer` model doc comment in `schema.prisma`, and +here. + +| Write | Result | +|---|---| +| the parts only | `fullName` is rebuilt as `first + ' ' + last` | +| `fullName` only | the parts are re-derived (first token / the rest) | +| **both in one statement** | **THE PARTS WIN**; the submitted `fullName` is discarded | + +**Why the parts win.** After D0 the parts are what the three forms edit, so they +carry the operator's intent; `fullName` is the derived, legacy-facing value. +The opposite precedence would silently undo an edit made in the two boxes the +operator actually typed into. + +Verified live by `apps/api/scripts/test-name-trigger.cjs` — 9 assertions across +all three directions plus insert, a multi-token name, and a no-op update. It +creates one temporary row and deletes it, reporting the deletion. diff --git a/docs/parity/ISSUES.md b/docs/parity/ISSUES.md index da359cc..b86935d 100644 --- a/docs/parity/ISSUES.md +++ b/docs/parity/ISSUES.md @@ -112,3 +112,9 @@ Severity: `Critical` (money loss / security / data loss) · `High` · `Medium` | SEC-12 | **Critical** | REF `henza_server/app/Models/Sms.php:73-77,101-105` | Live SMS-panel production credentials (username, plaintext password, sender line) are **committed to git** in the reference, duplicated across two methods. An older provider's credentials sit commented out beside them. Anyone with repo access can send SMS on the owner's account at their cost. | Read the file. | Not ported — the target will read them from env only. **The owner should rotate these credentials**; they must be assumed compromised. | | STUB-05 | Medium | REF `ApiMobileController.php:154-158,601-605`; `Adm/OrderController.php:54-55`; `Customer/OrderController.php:141-145` | Admin notification recipients are **hardcoded mobile numbers** scattered across controllers (six distinct numbers). Staff changes require a code deploy, and an ex-employee keeps receiving order data. | Read the files. | Port as `SMS_ADMIN_RECIPIENTS` (comma-separated, default empty) rather than copying the literals. Pending the SMS approval. | | DEAD-06 | Low | REF `henza_pwa/src/views/cart.vue:181-182` | The cart sends `wage_weight: 5.7856000000000005` and `weight: 6.303744000000001` as **literal constants**, each with a `// how to get?!` comment. The server ignores both: it recomputes weight from `rash_weight`, and the `wage_weight` use is commented out (`ApiMobileController.php:549-551`). | Read both files. | Dead payload fields — do not port. | + +## Found while applying the D0 / percent migrations (2026-09-22) + +| ID | Severity | Where | Description | Evidence | Status | +|---|---|---|---|---|---| +| DEAD-07 | Medium | TGT DB table `_goldbank_migrations` | A bespoke migration ledger left by the earlier (pre-Prisma) port, holding 12 rows (`0001_init.sql`, `0002_admin_schema.sql`, `seed:initial`, …). It is not declared in `schema.prisma`, so `prisma migrate diff` reports it as drift and proposes `DROP TABLE`. **It was NOT dropped** — that is destructive, unapproved, and these rows are the only record of how the pre-Prisma port was applied. Because of it, `migrate dev` on a fresh checkout may offer a reset; the answer is always no. | `prisma migrate diff --from-schema-datasource --to-schema-datamodel` | Open — owner decides: keep and add to the schema as an ignored model, or archive the rows and drop. Until then use `migrate deploy`. | diff --git a/docs/parity/PROGRESS.md b/docs/parity/PROGRESS.md index 89a2f50..9d656be 100644 --- a/docs/parity/PROGRESS.md +++ b/docs/parity/PROGRESS.md @@ -10,8 +10,8 @@ | **Phase** | Security hotfix — **stages 1 and 2 ✅ complete** | | **Branch** | `parity/security-hotfix` | | **Status** | Both stages done and verified live: **46/46** smoke assertions, **7/7** jest unit tests, typecheck + build clean on all three projects. | -| **Next step** | **Migrations are written and shown — awaiting approval to run them** (`migrate dev`), then `01-ADMIN-MATRIX.md` field-by-field (D12) | -| **Blocked on** | approval to run the two migrations; approval to implement the SMS provider | +| **Next step** | **Start `01-ADMIN-MATRIX.md`** — field-by-field per D12, rid mapping matrix per D1 | +| **Blocked on** | nothing | | **Last updated** | 2026-09-22 | ### Security hotfix — final result @@ -51,7 +51,7 @@ while the id and the id-addressed routes remain.** ## ⚠️ Read `DECISIONS.md` before doing anything -All user rulings live there (D0–D13). Highlights that change how work is done: +All user rulings live there (D0–D14). Highlights that change how work is done: - **D12 — the matrix is FIELD-BY-FIELD, not page-by-page**, and a 4-step round-trip test per entity is mandatory. Both rules were added to the @@ -79,8 +79,8 @@ All user rulings live there (D0–D13). Highlights that change how work is done: - [x] **P0.6 — GATE passed** — `PROTECTED.md` confirmed, D0–D12 recorded - [x] P0.7 — Urgent traces delivered: `percent`, `@Public` list, login leak - [x] P0.8 — Skill updated with rules C0 (field-by-field) and E0 (round-trip test) -- [ ] **P1.0 — user approves: 5 ledger routes, `percent` column, D0 migration** ← HERE -- [ ] Phase 1 — Admin parity (branch `parity/phase-1-admin`, `01-ADMIN-MATRIX.md`) +- [x] **P1.0 — approved and DONE:** 5 ledger routes protected, `percent` column added, D0 migration applied, SMS provider implemented +- [ ] Phase 1 — Admin parity (branch `parity/phase-1-admin`, `01-ADMIN-MATRIX.md`) ← HERE - [ ] **GATE:** "Admin parity is complete and tested. Shall we move on to the apps (user-app & user-app-v2)?" - [ ] Phase 2 — Apps parity (`02-APPS-MATRIX.md`, both user apps) - [ ] Phase 3 — Work through `ISSUES.md` (only when user asks) @@ -103,12 +103,12 @@ All user rulings live there (D0–D13). Highlights that change how work is done: | D11 | `singleCustomer.vue` → diff-send. | | D12 | **Field-by-field matrix + mandatory round-trip test.** Added to the skill. | | D13 | **Retail storefront (wallet, cashback, `ConfigStatic['percent']`) preserved but OUT OF SCOPE.** Nothing deleted or disabled; no parity work; matrices list it ⏸️ with no gap/work row; ask before touching shared surfaces. | +| D14 | **`migrate dev` in development, `migrate deploy` in production and nowhere else.** Never `migrate reset`; never edit `_prisma_migrations`. On drift + a reset offer: **stop and report**. | ## Open — awaiting user approval -1. **Protect the 5 `@Public` ledger-write routes** (`DECISIONS.md` group A). -2. **`percent` schema change** — add `makingChargeOffsetPercent` to `Customer`. -3. **D0 migration file** — must be shown and approved before it runs. +Nothing. The 5 ledger routes, the `percent` column and the D0 migration are all +done and verified (see CHANGELOG 2026-09-22). ## Key context (survives context loss) @@ -166,9 +166,33 @@ show the total is unchanged for an ordinary customer. Depends on `makingChargeOffsetPercent` existing. Full reasoning: `DECISIONS.md` §"Report — MON-08". -## Waiting on the owner (2026-09-22) +## Done 2026-09-22 (was: waiting on the owner) | # | Item | What is ready | |---|---|---| -| 1 | Run the **D0** and **percent** migrations | Both SQL files written, storefront impact assessed, backfill report script written, 11 unit tests passing. Command: `npx prisma migrate dev`. | -| 2 | Implement the **SMS provider** (Trez) | Provider, all 12 templates and env-var names documented in `DECISIONS.md`. Nothing written yet. **SEC-12: the reference's committed credentials must be rotated.** | +| 1 | Run the **D0** and **percent** migrations | ✅ **Applied** with `migrate deploy` (`migrate dev` is interactive-only and this shell is not a TTY). Drift checked read-only first — the only difference is the pre-existing `_goldbank_migrations` table, which was **NOT** dropped (DEAD-07). Service layer, all three forms, trigger test and the full round-trip test done. | +| 2 | Implement the **SMS provider** (Trez) | ✅ `SmsModule`/`SmsService` live, OTP wired, text byte-identical, boot check fails in production on a missing key, `.env.example` has names only. The 12 B2B templates land per flow in their own phase. **SEC-12: the owner is rotating the credentials and setting them in `.env`.** | + + +## ⚠️ Standing rules added 2026-09-22 (D14) + +1. **`migrate dev` in development, `migrate deploy` in production and nowhere + else.** Never `migrate reset`, never edit `_prisma_migrations` by hand. If + drift is reported and a reset is offered: **stop and report**, do not accept. + `prisma db execute` is no longer used for schema changes. +2. **Every data deletion is logged in `CHANGELOG.md`**, including throwaway rows + a session creates itself. + +## ⚠️ `customer_sync_full_name` is a DATABASE TRIGGER + +Prisma knows nothing about it, so writing `Customer` can store something +different from what you wrote: + +| Write | Result | +|---|---| +| the parts only | `fullName` is rebuilt | +| `fullName` only | the parts are re-derived (first token / the rest) | +| **both at once** | **THE PARTS WIN**; the submitted `fullName` is discarded | + +Re-read after writing if you need the stored value. See `DECISIONS.md` §D14 and +the doc comment on the `Customer` model in `schema.prisma`.