diff --git a/apps/admin-panel/src/views/customer/omde/OmdeCustomerList.vue b/apps/admin-panel/src/views/customer/omde/OmdeCustomerList.vue index 6f28097..14cdf8f 100644 --- a/apps/admin-panel/src/views/customer/omde/OmdeCustomerList.vue +++ b/apps/admin-panel/src/views/customer/omde/OmdeCustomerList.vue @@ -127,7 +127,12 @@ class="cursor-pointer w-5 ml-1" :class="stepIconClass(getStepOneIcon(record))" /> -
{{ getStepOneText(record) }}
+
+
{{ getStepOneText(record) }}
+
+ رد شده{{ record.verification_rejection_reason ? `: ${record.verification_rejection_reason}` : '' }} +
+
@@ -213,6 +218,26 @@ + + +

دلیل رد (اختیاری) — در پرونده‌ی مشتری ثبت می‌شود.

+ +
+ , errorMsg: string) { const acceptStepOne = (id: string) => postVerify({ customer_id: id, sts_verify_step_one: 2 }, 'خطا در تغییر وضعیت اولیه مشتری'); -const rejectStepOne = (id: string) => - postVerify({ customer_id: id, sts_verify_step_one: 3 }, 'خطا در تغییر وضعیت اولیه مشتری'); +const rejectStepOne = (id: string) => openReject(id, 'sts_verify_step_one'); const acceptStepTwo = (id: string) => postVerify({ customer_id: id, sts_verify_step_two: 2 }, 'خطا در تغییر وضعیت سفارش مشتری'); -const rejectStepTwo = (id: string) => - postVerify({ customer_id: id, sts_verify_step_two: 3 }, 'خطا در تغییر وضعیت سفارش مشتری'); +const rejectStepTwo = (id: string) => openReject(id, 'sts_verify_step_two'); + +/** + * A reject asks for an optional reason first; the customer is then stored as + * REJECTED with it (admin-customers.service.ts verify()). + */ +type VerifyStep = 'sts_verify_step_one' | 'sts_verify_step_two'; +const rejectDialog = ref<{ open: boolean; saving: boolean; id: string; step: VerifyStep; reason: string }>({ + open: false, + saving: false, + id: '', + step: 'sts_verify_step_one', + reason: '', +}); + +function openReject(id: string, step: VerifyStep) { + rejectDialog.value = { open: true, saving: false, id, step, reason: '' }; +} + +async function submitReject() { + const d = rejectDialog.value; + d.saving = true; + await postVerify( + { customer_id: d.id, [d.step]: 3, reason: d.reason.trim() || null }, + d.step === 'sts_verify_step_one' ? 'خطا در تغییر وضعیت اولیه مشتری' : 'خطا در تغییر وضعیت سفارش مشتری', + ); + d.saving = false; + d.open = false; +} /** legacy inline ternary on @confirm, preserved including the NonVerified quirk */ function onConfirmStepOne(record: Record) { diff --git a/apps/api/prisma/migrations/20261008090000_customer_identity_cleanup/migration.sql b/apps/api/prisma/migrations/20261008090000_customer_identity_cleanup/migration.sql new file mode 100644 index 0000000..2a86031 --- /dev/null +++ b/apps/api/prisma/migrations/20261008090000_customer_identity_cleanup/migration.sql @@ -0,0 +1,31 @@ +-- ============================================================================ +-- Customer identity cleanup — data only, no schema change. Idempotent. +-- +-- Owner ruling 2026-10-07: «هر دیتایی باید سر جای خودش بشینه». +-- +-- 1. nationalCode held the MOBILE (or "--") on every +-- customer created without a national code, because the column used to be +-- NOT NULL. It is optional since 20261007120000, and the code paths that +-- wrote those stand-ins are fixed in the same change as this file. The +-- stand-ins are cleared to NULL; a real national code is never touched. +-- Accounting is unaffected: it only ever received validNationalCode(), +-- which already mapped a mobile to null. +-- +-- 2. passwordHash '' becomes the explicit "no password login" marker +-- (auth/password-hash.ts). Both are refused before bcrypt by +-- compareLocalHash, so nobody's ability to sign in changes. +-- ============================================================================ + +UPDATE "Customer" + SET "nationalCode" = NULL + WHERE "nationalCode" = '' + OR "nationalCode" = "mobile" + OR "nationalCode" LIKE "mobile" || '-%'; + +UPDATE "Customer" SET "passwordHash" = '!OTP_ONLY_NO_PASSWORD_LOGIN' WHERE "passwordHash" = ''; +UPDATE "AdminUser" SET "passwordHash" = '!NO_PASSWORD_SET_YET' WHERE "passwordHash" = ''; + +-- The earlier comment named the wrong source table: `fav` belongs to the retail +-- shop; wholesale favourites come from `major_favorites` (DATA-MIGRATION-MAP §10.7). +COMMENT ON TABLE "CustomerFavorite" IS + 'Legacy `major_favorites` (wholesale; deduplicated on import). Composite PK replaces the source table''s missing primary key. Derive a favourite count from here rather than storing a counter.'; diff --git a/apps/api/scripts/test-accounting-tahesab.ts b/apps/api/scripts/test-accounting-tahesab.ts index b99dd4d..ff745e1 100644 --- a/apps/api/scripts/test-accounting-tahesab.ts +++ b/apps/api/scripts/test-accounting-tahesab.ts @@ -116,7 +116,7 @@ async function main() { const customer = await prisma.customer.upsert({ where: { mobile }, update: {}, - create: { fullName: 'gold-bank smoke', firstName: 'gold-bank', lastName: 'smoke', mobile, nationalCode: mobile, passwordHash: '' }, + create: { fullName: 'gold-bank smoke', firstName: 'gold-bank', lastName: 'smoke', mobile, nationalCode: null, passwordHash: '!OTP_ONLY_NO_PASSWORD_LOGIN' }, }); await prisma.customer.update({ where: { id: customer.id }, data: { verificationStep: VerificationStep.STEP_TWO_SUBMITTED } }); await customersAdmin.verify({ customer_id: customer.id, sts_verify_step_two: 2 }); @@ -138,7 +138,7 @@ async function main() { const imported = await prisma.accountingLink.findFirst({ where: { origin: 'IMPORTED' } }); if (imported) { const c = await prisma.customer.findUnique({ where: { id: imported.entityId } }); - chk(c?.mobile?.startsWith('09') && c.passwordHash === '' && c.goldCreditLimitGrams.eq(0), 'imported customer keyed by mobile, OTP-only, credit 0'); + chk(c?.mobile?.startsWith('09') && c.passwordHash === '!OTP_ONLY_NO_PASSWORD_LOGIN' && c.goldCreditLimitGrams.eq(0), 'imported customer keyed by mobile, OTP-only, credit 0'); } // ── 5 ── diff --git a/apps/api/scripts/test-complete-profile.cjs b/apps/api/scripts/test-complete-profile.cjs index 87aad48..0512f80 100644 --- a/apps/api/scripts/test-complete-profile.cjs +++ b/apps/api/scripts/test-complete-profile.cjs @@ -90,7 +90,7 @@ const uniq = String(Date.now()).slice(-7); lastName: name.split(' ').slice(1).join(' '), mobile: `0917${uniq.slice(0, 3)}${suffix}`, nationalCode: `nc${uniq}${suffix}`, - passwordHash: '', kind: 'APP_SHOPKEEPER', isActive: true, + passwordHash: '!OTP_ONLY_NO_PASSWORD_LOGIN', kind: 'APP_SHOPKEEPER', isActive: true, goldCreditLimitGrams: '100', verificationStep: 'UNVERIFIED', }, @@ -106,7 +106,7 @@ const uniq = String(Date.now()).slice(-7); data: { fullName: 'کارشناس مدارک', firstName: 'کارشناس', lastName: 'مدارک', mobile: `0918${uniq.slice(0, 3)}22`, - passwordHash: '', role: 'SALES_EXPERT', isActive: true, wagePercent: '3', + passwordHash: '!NO_PASSWORD_SET_YET', role: 'SALES_EXPERT', isActive: true, wagePercent: '3', }, }); await prisma.salesExpertProfile.create({ diff --git a/apps/api/scripts/test-report.cjs b/apps/api/scripts/test-report.cjs index 2cbaedc..53f3904 100644 --- a/apps/api/scripts/test-report.cjs +++ b/apps/api/scripts/test-report.cjs @@ -71,7 +71,7 @@ const uniq = String(Date.now()).slice(-7); lastName: name.split(' ').slice(1).join(' '), mobile: `0919${uniq.slice(0, 3)}${suffix}`, nationalCode: `nc${uniq}${suffix}`, - passwordHash: '', kind: 'APP_SHOPKEEPER', isActive: true, + passwordHash: '!OTP_ONLY_NO_PASSWORD_LOGIN', kind: 'APP_SHOPKEEPER', isActive: true, goldCreditLimitGrams: '500', }, }); diff --git a/apps/api/scripts/test-sales-expert-app-login.cjs b/apps/api/scripts/test-sales-expert-app-login.cjs index a8e13a2..c259e2b 100644 --- a/apps/api/scripts/test-sales-expert-app-login.cjs +++ b/apps/api/scripts/test-sales-expert-app-login.cjs @@ -60,7 +60,7 @@ const uniq = String(Date.now()).slice(-7); data: { fullName: `${first} ${last}`, firstName: first, lastName: last, mobile: `0915${uniq.slice(0, 3)}${suffix}`, - passwordHash: '', role: 'SALES_EXPERT', isActive: true, + passwordHash: '!NO_PASSWORD_SET_YET', role: 'SALES_EXPERT', isActive: true, wagePercent: '4.5', }, }); @@ -82,7 +82,7 @@ const uniq = String(Date.now()).slice(-7); fullName: name, firstName: name.split(' ')[0], lastName: name.split(' ').slice(1).join(' '), mobile: `0916${uniq.slice(0, 3)}${suffix}`, nationalCode: `nc${uniq}${suffix}`, - passwordHash: '', kind: 'APP_SHOPKEEPER', isActive: true, + passwordHash: '!OTP_ONLY_NO_PASSWORD_LOGIN', kind: 'APP_SHOPKEEPER', isActive: true, referredByAgentId: agentId, goldCreditLimitGrams: '100', }, }); diff --git a/apps/api/src/modules/accounting-sync/accounting-sync.service.ts b/apps/api/src/modules/accounting-sync/accounting-sync.service.ts index cd5124c..34da792 100644 --- a/apps/api/src/modules/accounting-sync/accounting-sync.service.ts +++ b/apps/api/src/modules/accounting-sync/accounting-sync.service.ts @@ -1,6 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { AccountingOutbox, Prisma } from '@prisma/client'; import { PrismaService } from '../../prisma/prisma.service'; +import { OTP_ONLY_PASSWORD_HASH } from '../auth/password-hash'; import { ActiveProvider, AccountingConfigService } from './accounting-config.service'; import { AccountingConfig } from './accounting-config'; import { JalaliDate, parseJalali, toJalali } from './jalali'; @@ -181,21 +182,17 @@ export class AccountingSyncService { * An account that exists only in the accounting software becomes an app * customer, keyed by mobile (owner: «اولویت با شماره هست نه کد ملی»). The * national code is the account's own when it is a real one and still free, - * else the mobile — the fallback admin-customers and customer-auth already use. + * else none (the column is optional since 2026-10-07 and holds national + * codes only — never the mobile or a made-up key). * Credit limit 0 and UNVERIFIED: everything beyond identity is the app's. + * OTP-only: an imported account has no app password. */ private async importCustomer(provider: ProviderName, ext: ExternalCustomer): Promise { const mobile = ext.mobile as string; - const candidates = [validNationalCode(ext.nationalCode), mobile, `${mobile}-${provider}-${ext.code}`].filter( - (v): v is string => !!v, - ); - let nationalCode = candidates[candidates.length - 1]; - for (const c of candidates) { - const taken = await this.prisma.customer.findUnique({ where: { nationalCode: c }, select: { id: true } }); - if (!taken) { - nationalCode = c; - break; - } + let nationalCode = validNationalCode(ext.nationalCode); + if (nationalCode) { + const taken = await this.prisma.customer.findUnique({ where: { nationalCode }, select: { id: true } }); + if (taken) nationalCode = null; } const { firstName, lastName } = splitName(ext.name || mobile); @@ -208,7 +205,7 @@ export class AccountingSyncService { lastName: lastName || null, mobile, nationalCode, - passwordHash: '', + passwordHash: OTP_ONLY_PASSWORD_HASH, phone: ext.phone, address: ext.address, }, diff --git a/apps/api/src/modules/admin-branches/admin-branches.service.ts b/apps/api/src/modules/admin-branches/admin-branches.service.ts index e96b18d..82cd20c 100644 --- a/apps/api/src/modules/admin-branches/admin-branches.service.ts +++ b/apps/api/src/modules/admin-branches/admin-branches.service.ts @@ -3,6 +3,7 @@ import { AdminRole, Prisma } from '@prisma/client'; import * as bcrypt from 'bcryptjs'; import { PrismaService } from '../../prisma/prisma.service'; import { TableService } from '../admin-table/table.service'; +import { NO_PASSWORD_YET_HASH } from '../auth/password-hash'; import type { TableQueryDto } from '../admin-table/table-query.dto'; /** @@ -327,7 +328,7 @@ export class AdminBranchesService { fullName, mobile: phone, // No password is set: the supervisor cannot sign in until one is issued. - passwordHash: '', + passwordHash: NO_PASSWORD_YET_HASH, role: AdminRole.WORKSHOP_SUPERVISOR, }, }); diff --git a/apps/api/src/modules/admin-customers/admin-customers.controller.ts b/apps/api/src/modules/admin-customers/admin-customers.controller.ts index 25c9d51..b7a35de 100644 --- a/apps/api/src/modules/admin-customers/admin-customers.controller.ts +++ b/apps/api/src/modules/admin-customers/admin-customers.controller.ts @@ -171,7 +171,13 @@ export class AdminCustomersController { @RequirePermission('persons.list.client.omde.edit') verify( @Body() - body: { customer_id: string; sts_verify_step_one?: number; sts_verify_step_two?: number }, + body: { + customer_id: string; + sts_verify_step_one?: number; + sts_verify_step_two?: number; + /** Optional, only read on a reject. */ + reason?: string | null; + }, ) { return this.customers.verify(body); } diff --git a/apps/api/src/modules/admin-customers/admin-customers.service.spec.ts b/apps/api/src/modules/admin-customers/admin-customers.service.spec.ts new file mode 100644 index 0000000..aa1e3e8 --- /dev/null +++ b/apps/api/src/modules/admin-customers/admin-customers.service.spec.ts @@ -0,0 +1,99 @@ +import { VerificationStep } from '@prisma/client'; +import { OTP_ONLY_PASSWORD_HASH } from '../auth/password-hash'; +import { AdminCustomersService } from './admin-customers.service'; + +/** + * Owner rulings 2026-10-07: + * - a reject stores REJECTED with an optional reason (it used to fall back to + * UNVERIFIED / STEP_ONE_SUBMITTED and leave no trace); + * - every value sits in its own column: no mobile in nationalCode, no '' + * password — an absent national code is NULL, an absent password is the + * explicit OTP-only marker. + */ +describe('AdminCustomersService', () => { + function build(current: Record = { id: 'c1', verificationStep: VerificationStep.STEP_ONE_SUBMITTED }) { + const writes: Record[] = []; + const tx = { + customer: { + update: jest.fn(async ({ data }: { data: Record }) => { + writes.push(data); + return { ...current, ...data }; + }), + create: jest.fn(async ({ data }: { data: Record }) => { + writes.push(data); + return { id: 'new', ...data }; + }), + findUnique: jest.fn(async () => current), + }, + }; + const prisma = { ...tx, $transaction: jest.fn(async (fn: (t: typeof tx) => unknown) => fn(tx)) }; + const accounting = { customerVerified: jest.fn(), customerChanged: jest.fn() }; + const sms = { send: jest.fn(async () => undefined) }; + const service = new AdminCustomersService(prisma as never, {} as never, sms as never, accounting as never); + return { service, writes, accounting }; + } + + describe('verify()', () => { + it('a step-one reject stores REJECTED with the reason and time', async () => { + const { service, writes } = build(); + await service.verify({ customer_id: 'c1', sts_verify_step_one: 3, reason: ' کارت ملی خوانا نیست ' }); + expect(writes[0]).toMatchObject({ + verificationStep: VerificationStep.REJECTED, + verificationRejectionReason: 'کارت ملی خوانا نیست', + }); + expect(writes[0].verificationRejectedAt).toBeInstanceOf(Date); + }); + + it('a step-two reject is REJECTED too, and the reason is optional', async () => { + const { service, writes } = build({ id: 'c1', verificationStep: VerificationStep.STEP_TWO_SUBMITTED }); + await service.verify({ customer_id: 'c1', sts_verify_step_two: 3 }); + expect(writes[0]).toMatchObject({ verificationStep: VerificationStep.REJECTED, verificationRejectionReason: null }); + }); + + it('accepting a rejected customer clears the reason', async () => { + const { service, writes } = build({ id: 'c1', verificationStep: VerificationStep.REJECTED }); + await service.verify({ customer_id: 'c1', sts_verify_step_one: 2 }); + expect(writes[0]).toEqual({ + verificationStep: VerificationStep.STEP_TWO_SUBMITTED, + verificationRejectionReason: null, + verificationRejectedAt: null, + }); + }); + + it('step-two accept verifies and tells accounting once', async () => { + const { service, writes, accounting } = build({ id: 'c1', verificationStep: VerificationStep.STEP_TWO_SUBMITTED }); + await service.verify({ customer_id: 'c1', sts_verify_step_two: 2 }); + expect(writes[0].verificationStep).toBe(VerificationStep.VERIFIED); + expect(accounting.customerVerified).toHaveBeenCalledTimes(1); + }); + + it('a reject never tells accounting', async () => { + const { service, accounting } = build({ id: 'c1', verificationStep: VerificationStep.STEP_TWO_SUBMITTED }); + await service.verify({ customer_id: 'c1', sts_verify_step_two: 3 }); + expect(accounting.customerVerified).not.toHaveBeenCalled(); + }); + }); + + describe('national code and password', () => { + it('create() without a national code or password stores NULL and the OTP-only marker', async () => { + const { service, writes } = build(); + await service.create({ fname: 'علی', lname: 'رضایی', mobile: '09120000000', percent: '3' }); + expect(writes[0].nationalCode).toBeNull(); + expect(writes[0].mobile).toBe('09120000000'); + expect(writes[0].passwordHash).toBe(OTP_ONLY_PASSWORD_HASH); + }); + + it('create() keeps a typed national code, digits normalised', async () => { + const { service, writes } = build(); + await service.create({ fname: 'علی', mobile: '09120000000', percent: '3', national_code: ' ۰۰۱۲۳۴۵۶۷۸ ' }); + expect(writes[0].nationalCode).toBe('0012345678'); + }); + + it('createSingle() never copies the mobile into nationalCode', async () => { + const { service, writes } = build(); + await service.createSingle({ fname: 'علی', mobile: '09120000000' }); + expect(writes[0].nationalCode).toBeNull(); + expect(writes[0].passwordHash).toBe(OTP_ONLY_PASSWORD_HASH); + }); + }); +}); 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 ce786ab..c7905d3 100644 --- a/apps/api/src/modules/admin-customers/admin-customers.service.ts +++ b/apps/api/src/modules/admin-customers/admin-customers.service.ts @@ -5,6 +5,7 @@ import { PrismaService } from '../../prisma/prisma.service'; import { TableService } from '../admin-table/table.service'; import type { TableQueryDto } from '../admin-table/table-query.dto'; import { faToEn } from '../customer-auth/digits'; +import { OTP_ONLY_PASSWORD_HASH } from '../auth/password-hash'; import { SmsService } from '../sms/sms.service'; import { AccountingHooksService } from '../accounting-sync/accounting-hooks.service'; @@ -67,6 +68,16 @@ export type OmdeScope = 'all' | 'banker' | 'shopkeeper' | 'non_verified' | 'arch * `sts_verify_step_one !== 2`, i.e. only an ACCEPTED first step unlocks them. * The new schema models one ordered enum, so these numbers are derived from it. */ +/** + * A national code as the operator typed it, digits normalised; empty -> null. + * The column is optional and unique (2026-10-07): an absent code is NULL, + * never the mobile or '' — two customers without one must not collide, and + * the column must only ever hold a national code. + */ +function nationalCodeOrNull(raw: unknown): string | null { + return faToEn(String(raw ?? '').trim()) || null; +} + function verifyStepOne(step: VerificationStep): number { switch (step) { case VerificationStep.UNVERIFIED: @@ -314,6 +325,8 @@ export class AdminCustomersService { sts_verify_step_one: verifyStepOne(c.verificationStep), sts_verify_step_two: verifyStepTwo(c.verificationStep), verificationStep: c.verificationStep, + verification_rejection_reason: c.verificationRejectionReason, + verification_rejected_at: c.verificationRejectedAt, national_id_image: c.nationalIdImageUrl, business_license: c.businessLicenseUrl, shop_image: c.shopImageUrl, @@ -631,8 +644,9 @@ export class AdminCustomersService { firstName: firstName || null, lastName: lastName || null, mobile, - nationalCode: mobile, // schema requires it and it is unique; singleCustomer.vue collects no ncode - passwordHash: '', + // singleCustomer.vue collects no national code. + nationalCode: null, + passwordHash: OTP_ONLY_PASSWORD_HASH, cityId: (data.city_id as string) || null, kind: CustomerKind.RETAIL, }, @@ -660,7 +674,7 @@ export class AdminCustomersService { // can never log in: the app's own faToEn turns the typed number into ASCII // and the lookup misses. const mobile = faToEn(String(data.mobile ?? '').trim()); - const nationalCode = String(data.national_code ?? data.nationalCode ?? '').trim(); + const nationalCode = nationalCodeOrNull(data.national_code ?? data.nationalCode); if (!fullName || !mobile) { throw new BadRequestException('نام و موبایل الزامی است'); @@ -691,12 +705,12 @@ export class AdminCustomersService { // (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 + nationalCode, // The form collects a password (bulkCustomer.vue / ReagentCustomer.vue). // It used to be discarded and '' stored, so the operator typed a password - // that silently did nothing. Hashed here when supplied; '' still means - // "no password yet", which no bcrypt hash can ever match. - passwordHash: data.password ? await bcrypt.hash(String(data.password), 10) : '', + // that silently did nothing. Hashed here when supplied; otherwise the + // account signs in by OTP only. + passwordHash: data.password ? await bcrypt.hash(String(data.password), 10) : OTP_ONLY_PASSWORD_HASH, cityId: (data.city_id as string) ?? null, goldCreditLimitGrams: data.gram_level ? new Prisma.Decimal(String(data.gram_level)) @@ -761,7 +775,7 @@ export class AdminCustomersService { // that lock NOT ported, so the field stays editable here and is normalised // instead. Logged in ISSUES as PERS-01. if (data.mobile !== undefined) patch.mobile = faToEn(String(data.mobile)); - if (data.national_code !== undefined) patch.nationalCode = String(data.national_code); + if (data.national_code !== undefined) patch.nationalCode = nationalCodeOrNull(data.national_code); if (data.city_id !== undefined) { patch.city = data.city_id ? { connect: { id: String(data.city_id) } } : { disconnect: true }; } @@ -950,35 +964,50 @@ export class AdminCustomersService { * Legacy's payload is `{ customer_id, sts_verify_step_one }` or * `{ customer_id, sts_verify_step_two }`, where the value is 2 (accept) or * 3 (reject) — see OmdeList.vue acceptStepOne/rejectStepOne. Accepted here in - * exactly that shape so the ported view posts unchanged. + * exactly that shape so the ported view posts unchanged, plus an optional + * `reason` on a reject. + * + * A reject at either step records REJECTED with its reason and time (owner + * ruling 2026-10-07). It used to send the customer back to UNVERIFIED / + * STEP_ONE_SUBMITTED, so a rejection left no trace and looked like a + * customer who had simply not finished. Same rule as the legacy import: + * a rejected first OR second step is REJECTED (DATA-MIGRATION-MAP §5.5). + * Accepting again clears the reason. */ async verify(payload: { customer_id: string; sts_verify_step_one?: number; sts_verify_step_two?: number; + reason?: string | null; }) { const id = payload.customer_id; const c = await this.prisma.customer.findUnique({ where: { id } }); if (!c) throw new NotFoundException('مشتری یافت نشد'); - let next: VerificationStep; + const step = payload.sts_verify_step_one ?? payload.sts_verify_step_two; + if (step === undefined) throw new BadRequestException('مرحله‌ی تایید مشخص نشده است'); - if (payload.sts_verify_step_one !== undefined) { - next = - payload.sts_verify_step_one === 2 - ? VerificationStep.STEP_TWO_SUBMITTED // step one accepted -> step two pending - : VerificationStep.UNVERIFIED; // rejected - } else if (payload.sts_verify_step_two !== undefined) { - next = - payload.sts_verify_step_two === 2 - ? VerificationStep.VERIFIED - : VerificationStep.STEP_ONE_SUBMITTED; // rejected -> back to step one accepted + let data: Prisma.CustomerUpdateInput; + if (step === 2) { + data = { + verificationStep: + payload.sts_verify_step_one !== undefined + ? VerificationStep.STEP_TWO_SUBMITTED // step one accepted -> step two pending + : VerificationStep.VERIFIED, + verificationRejectionReason: null, + verificationRejectedAt: null, + }; } else { - throw new BadRequestException('مرحله‌ی تایید مشخص نشده است'); + data = { + verificationStep: VerificationStep.REJECTED, + verificationRejectionReason: String(payload.reason ?? '').trim() || null, + verificationRejectedAt: new Date(), + }; } + const next = data.verificationStep; return this.prisma.$transaction(async (tx) => { - const updated = await tx.customer.update({ where: { id }, data: { verificationStep: next } }); + const updated = await tx.customer.update({ where: { id }, data }); // D27 — a verified customer gets an account in the accounting software // (queued; searched by mobile first, so an existing account is linked). if (next === VerificationStep.VERIFIED && c.verificationStep !== VerificationStep.VERIFIED) { diff --git a/apps/api/src/modules/auth/password-hash.ts b/apps/api/src/modules/auth/password-hash.ts index eaf515e..435a165 100644 --- a/apps/api/src/modules/auth/password-hash.ts +++ b/apps/api/src/modules/auth/password-hash.ts @@ -12,6 +12,13 @@ import * as bcrypt from 'bcryptjs'; */ export const OTP_ONLY_PASSWORD_HASH = '!OTP_ONLY_NO_PASSWORD_LOGIN'; +/** + * Same idea for an admin account created before anyone has issued it a + * password (e.g. a workshop supervisor added from the branch form): it cannot + * sign in until a password is set through the users screen. + */ +export const NO_PASSWORD_YET_HASH = '!NO_PASSWORD_SET_YET'; + /** * A real bcrypt hash always starts with `$2` ($2a$, $2b$, or PHP's $2y$ from * the legacy server — all three verify with bcryptjs). Anything else is not a 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 a68bc71..d91087c 100644 --- a/apps/api/src/modules/customer-auth/customer-auth.service.ts +++ b/apps/api/src/modules/customer-auth/customer-auth.service.ts @@ -10,6 +10,7 @@ import { type IamIdentity, } from '../iam/iam.service'; import { AppSessionService } from '../sessions/app-session.service'; +import { OTP_ONLY_PASSWORD_HASH } from '../auth/password-hash'; import { envOr, envOrNull } from '../../common/env'; import { CUSTOMER_TOKEN_TYPE, EXPERT_TOKEN_TYPE, type CustomerJwtPayload } from './customer-jwt.strategy'; import { faToEn } from './digits'; @@ -185,6 +186,13 @@ export class CustomerAuthService { * Returns null for any mobile that is not an active sales expert, so an * ordinary customer's login is completely unaffected. */ + /** `code` when it is set and no customer holds it yet, else null. */ + private async freeNationalCode(code: string | null): Promise { + if (!code) return null; + const taken = await this.prisma.customer.findUnique({ where: { nationalCode: code }, select: { id: true } }); + return taken ? null : code; + } + private async resolveExpertTradingAccount(mobile: string): Promise<{ id: string } | null> { const admin = await this.prisma.adminUser.findFirst({ where: { mobile, isActive: true, role: AdminRole.SALES_EXPERT }, @@ -252,13 +260,13 @@ export class CustomerAuthService { firstName: admin.firstName, lastName: admin.lastName, mobile: admin.mobile, - // Customer.nationalCode is required and unique. The expert's own code - // when they have one, else the mobile — the same fallback - // admin-customers.service.ts uses when a form omits it. - nationalCode: admin.salesExpertProfile.nationalCode || admin.mobile, + // The expert's own national code when they have one and no other + // customer holds it (the column is unique); else none. Never the + // mobile — the column holds national codes only. + nationalCode: await this.freeNationalCode(admin.salesExpertProfile.nationalCode), // No app password: this account is reached only through the expert's // AdminUser session, never by a password login of its own. - passwordHash: '', + passwordHash: OTP_ONLY_PASSWORD_HASH, kind: CustomerKind.APP_SHOPKEEPER, goldCreditLimitGrams: admin.salesExpertProfile.gramLevel, makingChargeOffsetPercent: admin.wagePercent ?? new Prisma.Decimal(0), diff --git a/apps/user-app-v2/src/api/client.ts b/apps/user-app-v2/src/api/client.ts index 7ca51cf..a215933 100644 --- a/apps/user-app-v2/src/api/client.ts +++ b/apps/user-app-v2/src/api/client.ts @@ -150,7 +150,7 @@ export interface CustomerDetail { id: string; fullName: string; mobile: string; - nationalCode: string; + nationalCode: string | null; cityId: string | null; goldCreditLimitGrams: string; goldDebtGrams: string; diff --git a/apps/user-app-v2/src/views/user/ProfileView.vue b/apps/user-app-v2/src/views/user/ProfileView.vue index 859733a..956b5f6 100644 --- a/apps/user-app-v2/src/views/user/ProfileView.vue +++ b/apps/user-app-v2/src/views/user/ProfileView.vue @@ -47,7 +47,7 @@
کد ملی
-
{{ customer.nationalCode }}
+
{{ customer.nationalCode || '-' }}
وضعیت احراز هویت
diff --git a/apps/user-app/src/api/client.ts b/apps/user-app/src/api/client.ts index 42dc895..d1ba02f 100644 --- a/apps/user-app/src/api/client.ts +++ b/apps/user-app/src/api/client.ts @@ -165,7 +165,7 @@ export interface CustomerDetail { id: string; fullName: string; mobile: string; - nationalCode: string; + nationalCode: string | null; cityId: string | null; goldCreditLimitGrams: string; goldDebtGrams: string; diff --git a/apps/user-app/src/views/user/ProfileView.vue b/apps/user-app/src/views/user/ProfileView.vue index 0eeb527..ba8736f 100644 --- a/apps/user-app/src/views/user/ProfileView.vue +++ b/apps/user-app/src/views/user/ProfileView.vue @@ -47,7 +47,7 @@
کد ملی
-
{{ customer.nationalCode }}
+
{{ customer.nationalCode || '-' }}
وضعیت احراز هویت