migration

This commit is contained in:
s.esmaeili
2026-10-07 20:20:27 +03:30
parent 92831ed33e
commit 8c5d971d1f
17 changed files with 286 additions and 57 deletions
@@ -127,7 +127,12 @@
class="cursor-pointer w-5 ml-1"
:class="stepIconClass(getStepOneIcon(record))"
/>
<div>{{ getStepOneText(record) }}</div>
<div>
<div>{{ getStepOneText(record) }}</div>
<div v-if="record.verificationStep === 'REJECTED'" class="text-danger text-xs">
رد شده{{ record.verification_rejection_reason ? `: ${record.verification_rejection_reason}` : '' }}
</div>
</div>
</div>
</a-popconfirm>
</a-menu-item>
@@ -213,6 +218,26 @@
</template>
</XTable>
<!-- Reject = REJECTED with an optional reason (owner ruling 2026-10-07). -->
<a-modal
:open="rejectDialog.open"
title="رد مدارک مشتری"
ok-text="ثبت رد"
cancel-text="انصراف"
:ok-button-props="{ danger: true }"
:confirm-loading="rejectDialog.saving"
@ok="submitReject"
@cancel="rejectDialog.open = false"
>
<p>دلیل رد (اختیاری) — در پرونده‌ی مشتری ثبت می‌شود.</p>
<a-textarea
v-model:value="rejectDialog.reason"
:rows="3"
:maxlength="500"
placeholder="مثلاً: تصویر کارت ملی خوانا نیست"
/>
</a-modal>
<BulkCustomer
v-if="showAddButton"
:key="editingId ?? 'new'"
@@ -365,12 +390,38 @@ async function postVerify(payload: Record<string, unknown>, 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<string, any>) {
@@ -0,0 +1,31 @@
-- ============================================================================
-- Customer identity cleanup — data only, no schema change. Idempotent.
--
-- Owner ruling 2026-10-07: «هر دیتایی باید سر جای خودش بشینه».
--
-- 1. nationalCode held the MOBILE (or "<mobile>-<provider>-<code>") 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.';
+2 -2
View File
@@ -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 ──
+2 -2
View File
@@ -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({
+1 -1
View File
@@ -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',
},
});
@@ -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',
},
});
@@ -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<string> {
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,
},
@@ -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,
},
});
@@ -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);
}
@@ -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<string, unknown> = { id: 'c1', verificationStep: VerificationStep.STEP_ONE_SUBMITTED }) {
const writes: Record<string, unknown>[] = [];
const tx = {
customer: {
update: jest.fn(async ({ data }: { data: Record<string, unknown> }) => {
writes.push(data);
return { ...current, ...data };
}),
create: jest.fn(async ({ data }: { data: Record<string, unknown> }) => {
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);
});
});
});
@@ -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) {
@@ -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
@@ -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<string | null> {
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),
+1 -1
View File
@@ -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;
@@ -47,7 +47,7 @@
</div>
<div class="prof__spec">
<dt>کد ملی</dt>
<dd>{{ customer.nationalCode }}</dd>
<dd>{{ customer.nationalCode || '-' }}</dd>
</div>
<div class="prof__spec">
<dt>وضعیت احراز هویت</dt>
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -47,7 +47,7 @@
</div>
<div class="prof__spec">
<dt>کد ملی</dt>
<dd>{{ customer.nationalCode }}</dd>
<dd>{{ customer.nationalCode || '-' }}</dd>
</div>
<div class="prof__spec">
<dt>وضعیت احراز هویت</dt>