From 6257fe2770224365a36d0b21070798a4aa5bd86c Mon Sep 17 00:00:00 2001 From: safavimohadeseh78 Date: Sun, 4 Oct 2026 10:38:15 +0330 Subject: [PATCH] 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 --- .../migration.sql | 2 + prisma/schema.prisma | 19 +++--- .../migration.sql | 2 + runtime/schema.prisma | 19 +++--- src/auth/auth.service.ts | 51 ++++++++++++++ src/auth/dto/auth.dto.ts | 6 ++ src/common/utils/agent-code.spec.ts | 29 ++++++++ src/common/utils/agent-code.ts | 68 +++++++++++++++++++ src/customers/customers.service.ts | 37 ++++++++-- 9 files changed, 208 insertions(+), 25 deletions(-) create mode 100644 prisma/migrations/20261004100000_otp_referrer_agent/migration.sql create mode 100644 runtime/migrations/20261004100000_otp_referrer_agent/migration.sql create mode 100644 src/common/utils/agent-code.spec.ts create mode 100644 src/common/utils/agent-code.ts diff --git a/prisma/migrations/20261004100000_otp_referrer_agent/migration.sql b/prisma/migrations/20261004100000_otp_referrer_agent/migration.sql new file mode 100644 index 0000000..d2ad1b1 --- /dev/null +++ b/prisma/migrations/20261004100000_otp_referrer_agent/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "OtpChallenge" ADD COLUMN "assignedAgentId" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0db8344..ffd9ea4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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]) } diff --git a/runtime/migrations/20261004100000_otp_referrer_agent/migration.sql b/runtime/migrations/20261004100000_otp_referrer_agent/migration.sql new file mode 100644 index 0000000..d2ad1b1 --- /dev/null +++ b/runtime/migrations/20261004100000_otp_referrer_agent/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "OtpChallenge" ADD COLUMN "assignedAgentId" TEXT; diff --git a/runtime/schema.prisma b/runtime/schema.prisma index 4dfd902..f4c6af6 100644 --- a/runtime/schema.prisma +++ b/runtime/schema.prisma @@ -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]) } diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 9084e81..92e276f 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -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 { + 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; diff --git a/src/auth/dto/auth.dto.ts b/src/auth/dto/auth.dto.ts index 297b14d..2f4cbd9 100644 --- a/src/auth/dto/auth.dto.ts +++ b/src/auth/dto/auth.dto.ts @@ -22,6 +22,12 @@ export class RequestOtpDto { @IsOptional() @IsBoolean() isRegister?: boolean; + + /** کد معرفی واسطه؛ اختیاری — در ثبت‌نام برای assignedAgentId استفاده می‌شود */ + @IsOptional() + @IsString() + @MaxLength(32) + referrerAgentCode?: string; } export class VerifyOtpDto { diff --git a/src/common/utils/agent-code.spec.ts b/src/common/utils/agent-code.spec.ts new file mode 100644 index 0000000..5b89ffd --- /dev/null +++ b/src/common/utils/agent-code.spec.ts @@ -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'); + }); +}); diff --git a/src/common/utils/agent-code.ts b/src/common/utils/agent-code.ts new file mode 100644 index 0000000..84db74c --- /dev/null +++ b/src/common/utils/agent-code.ts @@ -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 + | Prisma.TransactionClient; + +export async function resolveAgentCodeForRole( + db: AgentCodeDb, + appRole: AppUserRole, + agentCode: string | null | undefined, + excludeCustomerId?: string, +): Promise { + 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 { + 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('کد واسطه قبلاً استفاده شده است'); + } +} diff --git a/src/customers/customers.service.ts b/src/customers/customers.service.ts index 16fd5a9..3f1ba67 100644 --- a/src/customers/customers.service.ts +++ b/src/customers/customers.service.ts @@ -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 } : {}), }, });