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:
2026-10-04 10:38:15 +03:30
co-authored by Cursor
parent 301f3ff1c8
commit 6257fe2770
9 changed files with 208 additions and 25 deletions
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "OtpChallenge" ADD COLUMN "assignedAgentId" TEXT;
+10 -9
View File
@@ -290,15 +290,16 @@ model AdminUser {
}
model OtpChallenge {
id String @id @default(cuid())
phone String
code String
firstName String?
lastName String?
isRegister Boolean @default(false)
attempts Int @default(0)
expiresAt DateTime
createdAt DateTime @default(now())
id String @id @default(cuid())
phone String
code String
firstName String?
lastName String?
isRegister Boolean @default(false)
assignedAgentId String?
attempts Int @default(0)
expiresAt DateTime
createdAt DateTime @default(now())
@@index([phone])
}
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "OtpChallenge" ADD COLUMN "assignedAgentId" TEXT;
+10 -9
View File
@@ -290,15 +290,16 @@ model AdminUser {
}
model OtpChallenge {
id String @id @default(cuid())
phone String
code String
firstName String?
lastName String?
isRegister Boolean @default(false)
attempts Int @default(0)
expiresAt DateTime
createdAt DateTime @default(now())
id String @id @default(cuid())
phone String
code String
firstName String?
lastName String?
isRegister Boolean @default(false)
assignedAgentId String?
attempts Int @default(0)
expiresAt DateTime
createdAt DateTime @default(now())
@@index([phone])
}
+51
View File
@@ -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;
+6
View File
@@ -22,6 +22,12 @@ export class RequestOtpDto {
@IsOptional()
@IsBoolean()
isRegister?: boolean;
/** کد معرفی واسطه؛ اختیاری — در ثبت‌نام برای assignedAgentId استفاده می‌شود */
@IsOptional()
@IsString()
@MaxLength(32)
referrerAgentCode?: string;
}
export class VerifyOtpDto {
+29
View File
@@ -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');
});
});
+68
View File
@@ -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('کد واسطه قبلاً استفاده شده است');
}
}
+30 -7
View File
@@ -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 } : {}),
},
});