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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
7b598e9e8e
commit
8133a2bdf4
@@ -86,7 +86,7 @@
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="درصد">
|
||||
<a-form-item label="درصد" :validate-status="err.percent ? 'error' : ''" :help="err.percent">
|
||||
<a-input-number v-model:value="form.percent" class="w-full" />
|
||||
</a-form-item>
|
||||
<a-form-item label="سقف اعتبار (برحسب گرم)">
|
||||
@@ -199,7 +199,7 @@
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="درصد">
|
||||
<a-form-item label="درصد" :validate-status="err.percent ? 'error' : ''" :help="err.percent">
|
||||
<a-input-number v-model:value="form.percent" class="w-full" />
|
||||
</a-form-item>
|
||||
<a-form-item label="سقف اعتبار (برحسب گرم)">
|
||||
@@ -318,7 +318,9 @@ const form = reactive<Record<string, any>>({
|
||||
// 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<string, unknown> = {
|
||||
...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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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");
|
||||
@@ -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).';
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, string> = {
|
||||
};
|
||||
|
||||
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<NestExpressApplication>(AppModule);
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<string, unknown>) {
|
||||
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<string, unknown>) {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <CODE>" 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}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<SmsSendResult> {
|
||||
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<SmsSendResult> {
|
||||
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)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 ✅.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`. |
|
||||
|
||||
+35
-11
@@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user