feat(auth): referral agent code and auto-assign agent codes
Validate optional referrer code at OTP registration and persist assignedAgentId. Auto-generate unique W-XXXX codes when creating or promoting agents and backfill on agent login when missing. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "OtpChallenge" ADD COLUMN "assignedAgentId" TEXT;
|
||||
@@ -296,6 +296,7 @@ model OtpChallenge {
|
||||
firstName String?
|
||||
lastName String?
|
||||
isRegister Boolean @default(false)
|
||||
assignedAgentId String?
|
||||
attempts Int @default(0)
|
||||
expiresAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "OtpChallenge" ADD COLUMN "assignedAgentId" TEXT;
|
||||
@@ -296,6 +296,7 @@ model OtpChallenge {
|
||||
firstName String?
|
||||
lastName String?
|
||||
isRegister Boolean @default(false)
|
||||
assignedAgentId String?
|
||||
attempts Int @default(0)
|
||||
expiresAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
normalizePhone,
|
||||
toEnglishDigits,
|
||||
} from '../common/utils/phone';
|
||||
import { generateUniqueAgentCode } from '../common/utils/agent-code';
|
||||
|
||||
const MAX_OTP_ATTEMPTS = 5;
|
||||
|
||||
@@ -62,6 +63,13 @@ export class AuthService {
|
||||
throw new UnauthorizedException('حساب کاربری شما غیرفعال است');
|
||||
}
|
||||
|
||||
let assignedAgentId: string | null = null;
|
||||
if (isRegister) {
|
||||
assignedAgentId = await this.resolveAssignedAgentIdForReferral(
|
||||
dto.referrerAgentCode,
|
||||
);
|
||||
}
|
||||
|
||||
const smsEnabled = this.sms.isEnabled();
|
||||
const code = smsEnabled
|
||||
? String(randomInt(1000, 10000))
|
||||
@@ -78,6 +86,7 @@ export class AuthService {
|
||||
firstName: dto.firstName?.trim(),
|
||||
lastName: dto.lastName?.trim(),
|
||||
isRegister,
|
||||
assignedAgentId,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
@@ -148,6 +157,7 @@ export class AuthService {
|
||||
phone,
|
||||
firstName: challenge.firstName ?? 'کاربر',
|
||||
lastName: challenge.lastName ?? 'جدید',
|
||||
assignedAgentId: challenge.assignedAgentId ?? null,
|
||||
},
|
||||
});
|
||||
} else if (!user) {
|
||||
@@ -159,6 +169,7 @@ export class AuthService {
|
||||
await this.prisma.otpChallenge.deleteMany({ where: { phone } });
|
||||
|
||||
if (user.appRole === AppUserRole.agent) {
|
||||
user = await this.ensureAgentHasReferralCode(user);
|
||||
await this.agentLevelService.syncAgentLevel(user.id);
|
||||
user =
|
||||
(await this.prisma.customer.findUnique({ where: { id: user.id } })) ??
|
||||
@@ -233,6 +244,7 @@ export class AuthService {
|
||||
if (!user) throw new UnauthorizedException();
|
||||
|
||||
if (user.appRole === AppUserRole.agent) {
|
||||
user = await this.ensureAgentHasReferralCode(user);
|
||||
await this.agentLevelService.syncAgentLevel(user.id);
|
||||
user =
|
||||
(await this.prisma.customer.findUnique({ where: { id: userId } })) ??
|
||||
@@ -282,6 +294,30 @@ export class AuthService {
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* کد واسطه را به شناسه واسطه فعال تبدیل میکند.
|
||||
* خالی = مشتری مستقیم (null). نامعتبر = خطای 400 با پیام ثابت برای UI.
|
||||
*/
|
||||
private async resolveAssignedAgentIdForReferral(
|
||||
rawCode?: string | null,
|
||||
): Promise<string | null> {
|
||||
const code = rawCode?.trim();
|
||||
if (!code) return null;
|
||||
|
||||
const agent = await this.prisma.customer.findFirst({
|
||||
where: {
|
||||
appRole: AppUserRole.agent,
|
||||
isActive: true,
|
||||
agentCode: { equals: code, mode: 'insensitive' },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (!agent) {
|
||||
throw new BadRequestException('کد واسطه معتبر نیست');
|
||||
}
|
||||
return agent.id;
|
||||
}
|
||||
|
||||
private async signCustomerToken(
|
||||
id: string,
|
||||
phone: string,
|
||||
@@ -295,6 +331,21 @@ export class AuthService {
|
||||
});
|
||||
}
|
||||
|
||||
private async ensureAgentHasReferralCode(user: {
|
||||
id: string;
|
||||
appRole: AppUserRole;
|
||||
agentCode: string | null;
|
||||
}) {
|
||||
if (user.appRole !== AppUserRole.agent || user.agentCode?.trim()) {
|
||||
return user;
|
||||
}
|
||||
const agentCode = await generateUniqueAgentCode(this.prisma);
|
||||
return this.prisma.customer.update({
|
||||
where: { id: user.id },
|
||||
data: { agentCode },
|
||||
});
|
||||
}
|
||||
|
||||
private mapCustomer(user: {
|
||||
id: string;
|
||||
firstName: string;
|
||||
|
||||
@@ -22,6 +22,12 @@ export class RequestOtpDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isRegister?: boolean;
|
||||
|
||||
/** کد معرفی واسطه؛ اختیاری — در ثبتنام برای assignedAgentId استفاده میشود */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
referrerAgentCode?: string;
|
||||
}
|
||||
|
||||
export class VerifyOtpDto {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { AppUserRole } from '@prisma/client';
|
||||
import { resolveAgentCodeForRole } from './agent-code';
|
||||
|
||||
describe('resolveAgentCodeForRole', () => {
|
||||
it('returns null for customer role', async () => {
|
||||
const db = { customer: { findFirst: jest.fn(), count: jest.fn() } };
|
||||
await expect(
|
||||
resolveAgentCodeForRole(db as never, AppUserRole.customer, 'W-1'),
|
||||
).resolves.toBeNull();
|
||||
expect(db.customer.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps a unique provided agent code', async () => {
|
||||
const db = {
|
||||
customer: {
|
||||
findFirst: jest.fn().mockResolvedValue(null),
|
||||
count: jest.fn(),
|
||||
},
|
||||
};
|
||||
await expect(
|
||||
resolveAgentCodeForRole(
|
||||
db as never,
|
||||
AppUserRole.agent,
|
||||
'W-1247',
|
||||
'cust-1',
|
||||
),
|
||||
).resolves.toBe('W-1247');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { ConflictException } from '@nestjs/common';
|
||||
import { AppUserRole, Prisma } from '@prisma/client';
|
||||
import { randomInt } from 'node:crypto';
|
||||
import type { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
type AgentCodeDb =
|
||||
| Pick<PrismaService, 'customer'>
|
||||
| Prisma.TransactionClient;
|
||||
|
||||
export async function resolveAgentCodeForRole(
|
||||
db: AgentCodeDb,
|
||||
appRole: AppUserRole,
|
||||
agentCode: string | null | undefined,
|
||||
excludeCustomerId?: string,
|
||||
): Promise<string | null> {
|
||||
if (appRole !== AppUserRole.agent) return null;
|
||||
|
||||
const trimmed = agentCode?.trim();
|
||||
if (trimmed) {
|
||||
await assertAgentCodeAvailable(db, trimmed, excludeCustomerId);
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
return generateUniqueAgentCode(db);
|
||||
}
|
||||
|
||||
export async function generateUniqueAgentCode(db: AgentCodeDb): Promise<string> {
|
||||
for (let attempt = 0; attempt < 40; attempt++) {
|
||||
const code = formatAgentCode(randomInt(1000, 10000));
|
||||
const taken = await findAgentByCode(db, code);
|
||||
if (!taken) return code;
|
||||
}
|
||||
|
||||
const count = await db.customer.count({
|
||||
where: { appRole: AppUserRole.agent },
|
||||
});
|
||||
const fallback = formatAgentCode(1000 + count);
|
||||
await assertAgentCodeAvailable(db, fallback);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function formatAgentCode(numericPart: number): string {
|
||||
return `W-${numericPart}`;
|
||||
}
|
||||
|
||||
async function findAgentByCode(db: AgentCodeDb, code: string) {
|
||||
return db.customer.findFirst({
|
||||
where: { agentCode: { equals: code, mode: 'insensitive' } },
|
||||
select: { id: true },
|
||||
});
|
||||
}
|
||||
|
||||
async function assertAgentCodeAvailable(
|
||||
db: AgentCodeDb,
|
||||
code: string,
|
||||
excludeCustomerId?: string,
|
||||
) {
|
||||
const existing = await db.customer.findFirst({
|
||||
where: {
|
||||
agentCode: { equals: code, mode: 'insensitive' },
|
||||
...(excludeCustomerId ? { id: { not: excludeCustomerId } } : {}),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictException('کد واسطه قبلاً استفاده شده است');
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
toEnglishDigits,
|
||||
} from '../common/utils/phone';
|
||||
import { joinFullName, splitFullName } from '../common/utils/name';
|
||||
import { resolveAgentCodeForRole } from '../common/utils/agent-code';
|
||||
|
||||
const customerInclude = {
|
||||
company: { select: { name: true } },
|
||||
@@ -73,8 +74,15 @@ export class CustomersService {
|
||||
await this.assertLevel(dto.levelDefId);
|
||||
await this.assertAgent(dto.assignedAgentId);
|
||||
|
||||
const appRole = dto.appRole ?? AppUserRole.customer;
|
||||
|
||||
try {
|
||||
const customer = await this.prisma.$transaction(async (tx) => {
|
||||
const agentCode = await resolveAgentCodeForRole(
|
||||
tx,
|
||||
appRole,
|
||||
dto.agentCode?.trim() || null,
|
||||
);
|
||||
const created = await tx.customer.create({
|
||||
data: {
|
||||
firstName,
|
||||
@@ -86,8 +94,8 @@ export class CustomersService {
|
||||
isActive: dto.isActive ?? true,
|
||||
levelDefId: dto.levelDefId || null,
|
||||
assignedAgentId: dto.assignedAgentId || null,
|
||||
appRole: dto.appRole ?? AppUserRole.customer,
|
||||
agentCode: dto.agentCode?.trim() || null,
|
||||
appRole,
|
||||
agentCode,
|
||||
},
|
||||
});
|
||||
return tx.customer.findUniqueOrThrow({
|
||||
@@ -108,7 +116,7 @@ export class CustomersService {
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateCustomerDto) {
|
||||
await this.ensure(id);
|
||||
const existing = await this.ensure(id);
|
||||
const phone =
|
||||
dto.phone !== undefined
|
||||
? normalizePhone(toEnglishDigits(dto.phone))
|
||||
@@ -135,8 +143,26 @@ export class CustomersService {
|
||||
await this.assertAgent(dto.assignedAgentId, id);
|
||||
}
|
||||
|
||||
const nextAppRole = dto.appRole ?? existing.appRole;
|
||||
const proposedAgentCode =
|
||||
dto.agentCode !== undefined
|
||||
? dto.agentCode?.trim() || null
|
||||
: existing.agentCode;
|
||||
|
||||
try {
|
||||
const customer = await this.prisma.$transaction(async (tx) => {
|
||||
const agentCode =
|
||||
nextAppRole === AppUserRole.agent
|
||||
? await resolveAgentCodeForRole(
|
||||
tx,
|
||||
nextAppRole,
|
||||
proposedAgentCode,
|
||||
id,
|
||||
)
|
||||
: dto.appRole !== undefined
|
||||
? null
|
||||
: undefined;
|
||||
|
||||
await tx.customer.update({
|
||||
where: { id },
|
||||
data: {
|
||||
@@ -158,10 +184,7 @@ export class CustomersService {
|
||||
? undefined
|
||||
: dto.assignedAgentId || null,
|
||||
appRole: dto.appRole,
|
||||
agentCode:
|
||||
dto.agentCode === undefined
|
||||
? undefined
|
||||
: dto.agentCode?.trim() || null,
|
||||
...(agentCode !== undefined ? { agentCode } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user