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))" /> -
دلیل رد (اختیاری) — در پروندهی مشتری ثبت میشود.
+") 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 || '-' }}
وضعیت احراز هویت