Add agent level progression and extend backend services.

Introduce agent level utilities/service, expand auth and notification flows, update category definitions and seed data, and wire level-aware order and suggestion handling.
This commit is contained in:
2026-09-15 21:42:02 +03:30
parent 4660b4f922
commit be00221bef
15 changed files with 430 additions and 29 deletions
+50 -2
View File
@@ -42,6 +42,8 @@ async function main() {
await prisma.product.deleteMany();
await prisma.catalog.deleteMany();
await prisma.bankAccount.deleteMany();
await prisma.levelBenefit.deleteMany();
await prisma.customerLevelDef.deleteMany();
await prisma.customer.deleteMany();
await prisma.company.deleteMany();
await prisma.adminUser.deleteMany();
@@ -332,6 +334,51 @@ async function main() {
],
});
await prisma.customerLevelDef.create({
data: {
code: 'bronze',
label: 'برنزی',
sortOrder: 1,
criteriaJson: { minSalesToman: 0 },
benefits: {
create: [{ title: 'دسترسی به کاتالوگ پایه' }],
},
},
});
await prisma.customerLevelDef.create({
data: {
code: 'silver',
label: 'نقره‌ای',
sortOrder: 2,
criteriaJson: { minSalesToman: 500_000_000 },
benefits: {
create: [{ title: 'پشتیبانی اولویت‌دار' }],
},
},
});
const goldLevel = await prisma.customerLevelDef.create({
data: {
code: 'gold',
label: 'طلایی',
sortOrder: 3,
criteriaJson: { minSalesToman: 1_500_000_000 },
benefits: {
create: [{ title: 'تخفیف ویژه سفارش‌های عمده' }],
},
},
});
await prisma.customerLevelDef.create({
data: {
code: 'vip',
label: 'VIP',
sortOrder: 4,
criteriaJson: { minSalesToman: 3_000_000_000 },
benefits: {
create: [{ title: 'مشاوره تخصصی و ارسال سریع' }],
},
},
});
await prisma.customer.create({
data: {
id: 'agent-1',
@@ -342,10 +389,11 @@ async function main() {
customerType: CustomerType.wholesale,
appRole: AppUserRole.agent,
agentCode: 'W-1247',
levelDefId: goldLevel.id,
levelLabel: 'طلایی',
nextLevelLabel: 'VIP',
pointsToNext: 550_000_000,
levelProgress: 72,
pointsToNext: 0,
levelProgress: 0,
companyId: 'company-par',
isActive: true,
avatarLetter: 'ع',
+39 -2
View File
@@ -133240,6 +133240,25 @@ var require_notifications_service = __commonJS({
dispatchAdmin(input) {
void this.notifyAdmin(input);
}
async notifyCustomer(input) {
try {
await this.prisma.customerNotification.create({
data: {
customerId: input.customerId,
event: input.event,
title: input.title.trim(),
body: input.body.trim(),
tone: input.tone ?? client_1.NotificationTone.info,
link: input.link ?? null
}
});
} catch (err) {
this.logger.error(`notifyCustomer(${input.event}) failed for customer ${input.customerId}`, err instanceof Error ? err.stack : err);
}
}
dispatchCustomer(input) {
void this.notifyCustomer(input);
}
async listForCustomer(customerId, limit = 50) {
const rows = await this.prisma.customerNotification.findMany({
where: { customerId },
@@ -139500,14 +139519,18 @@ var require_suggestions_service = __commonJS({
Object.defineProperty(exports2, "__esModule", { value: true });
exports2.SuggestionsService = void 0;
var common_12 = require_common();
var client_1 = require_default2();
var notifications_service_1 = require_notifications_service();
var prisma_service_1 = require_prisma_service();
var SuggestionsService = class SuggestionsService {
static {
__name(this, "SuggestionsService");
}
prisma;
constructor(prisma) {
notifications;
constructor(prisma, notifications) {
this.prisma = prisma;
this.notifications = notifications;
}
async listAdmin(customerId) {
const rows = await this.prisma.productSuggestion.findMany({
@@ -139549,6 +139572,15 @@ var require_suggestions_service = __commonJS({
product: { select: { name: true, code: true, color: true } }
}
});
const note = row.note?.trim();
this.notifications.dispatchCustomer({
customerId: input.customerId,
event: "product_suggestion",
title: "\u067E\u06CC\u0634\u0646\u0647\u0627\u062F \u0645\u062D\u0635\u0648\u0644 \u0628\u0631\u0627\u06CC \u0634\u0645\u0627",
body: note ? `\u06A9\u0627\u0631\u0634\u0646\u0627\u0633\u0627\u0646 \u0645\u0627 \u0645\u062D\u0635\u0648\u0644 \xAB${row.product.name}\xBB \u0631\u0627 \u0628\u0647 \u0634\u0645\u0627 \u067E\u06CC\u0634\u0646\u0647\u0627\u062F \u062F\u0627\u062F\u0647\u200C\u0627\u0646\u062F: ${note}` : `\u06A9\u0627\u0631\u0634\u0646\u0627\u0633\u0627\u0646 \u0645\u0627 \u0645\u062D\u0635\u0648\u0644 \xAB${row.product.name}\xBB \u0631\u0627 \u0628\u0647 \u0634\u0645\u0627 \u067E\u06CC\u0634\u0646\u0647\u0627\u062F \u062F\u0627\u062F\u0647\u200C\u0627\u0646\u062F.`,
tone: client_1.NotificationTone.info,
link: `/product?id=${input.productId}`
});
return this.map(row);
}
async setActive(id, isActive) {
@@ -139596,7 +139628,10 @@ var require_suggestions_service = __commonJS({
exports2.SuggestionsService = SuggestionsService;
exports2.SuggestionsService = SuggestionsService = __decorate2([
(0, common_12.Injectable)(),
__metadata2("design:paramtypes", [prisma_service_1.PrismaService])
__metadata2("design:paramtypes", [
prisma_service_1.PrismaService,
notifications_service_1.NotificationsService
])
], SuggestionsService);
}
});
@@ -139738,6 +139773,7 @@ var require_suggestions_module = __commonJS({
Object.defineProperty(exports2, "__esModule", { value: true });
exports2.SuggestionsModule = void 0;
var common_12 = require_common();
var notifications_module_1 = require_notifications_module();
var suggestions_controller_1 = require_suggestions_controller();
var suggestions_service_1 = require_suggestions_service();
var SuggestionsModule = class SuggestionsModule {
@@ -139748,6 +139784,7 @@ var require_suggestions_module = __commonJS({
exports2.SuggestionsModule = SuggestionsModule;
exports2.SuggestionsModule = SuggestionsModule = __decorate2([
(0, common_12.Module)({
imports: [notifications_module_1.NotificationsModule],
controllers: [suggestions_controller_1.SuggestionsController],
providers: [suggestions_service_1.SuggestionsService],
exports: [suggestions_service_1.SuggestionsService]
+103
View File
@@ -0,0 +1,103 @@
import { Injectable } from '@nestjs/common';
import { AppUserRole } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import {
AgentLevelSnapshot,
DEFAULT_AGENT_LEVEL_TIERS,
LevelTier,
computeAgentLevel,
parseLevelMinSales,
} from './agent-level.util';
@Injectable()
export class AgentLevelService {
constructor(private readonly prisma: PrismaService) {}
async getLevelTiers(): Promise<LevelTier[]> {
const rows = await this.prisma.customerLevelDef.findMany({
where: { isActive: true },
orderBy: [{ sortOrder: 'asc' }, { label: 'asc' }],
});
if (!rows.length) {
return DEFAULT_AGENT_LEVEL_TIERS;
}
return rows.map((row) => ({
id: row.id,
code: row.code,
label: row.label,
sortOrder: row.sortOrder,
minSalesToman: parseLevelMinSales(row.criteriaJson),
}));
}
async getAgentSalesVolume(agentId: string): Promise<number> {
const sales = await this.prisma.order.aggregate({
where: {
user: { assignedAgentId: agentId },
finalAmount: { not: null },
},
_sum: { finalAmount: true },
});
return sales._sum.finalAmount ?? 0;
}
async syncAgentLevel(agentId: string): Promise<AgentLevelSnapshot> {
const agent = await this.prisma.customer.findUnique({
where: { id: agentId },
select: { id: true, appRole: true },
});
if (!agent || agent.appRole !== AppUserRole.agent) {
return {
levelLabel: 'برنزی',
nextLevelLabel: 'نقره‌ای',
pointsToNext: 0,
levelProgress: 0,
levelDefId: null,
salesVolumeToman: 0,
};
}
const [salesVolumeToman, tiers] = await Promise.all([
this.getAgentSalesVolume(agentId),
this.getLevelTiers(),
]);
const computed = computeAgentLevel(salesVolumeToman, tiers);
await this.prisma.customer.update({
where: { id: agentId },
data: {
levelLabel: computed.levelLabel,
nextLevelLabel: computed.nextLevelLabel,
pointsToNext: computed.pointsToNext,
levelProgress: computed.levelProgress,
levelDefId: computed.levelDefId,
},
});
return {
...computed,
salesVolumeToman,
};
}
async syncAgentForOrder(orderId: string): Promise<void> {
const order = await this.prisma.order.findUnique({
where: { id: orderId },
select: {
user: {
select: { assignedAgentId: true },
},
},
});
const agentId = order?.user?.assignedAgentId;
if (!agentId) return;
await this.syncAgentLevel(agentId);
}
}
+27
View File
@@ -0,0 +1,27 @@
import { computeAgentLevel, DEFAULT_AGENT_LEVEL_TIERS } from './agent-level.util';
describe('computeAgentLevel', () => {
it('starts at bronze with empty progress', () => {
const result = computeAgentLevel(0, DEFAULT_AGENT_LEVEL_TIERS);
expect(result.levelLabel).toBe('برنزی');
expect(result.nextLevelLabel).toBe('نقره‌ای');
expect(result.pointsToNext).toBe(500_000_000);
expect(result.levelProgress).toBe(0);
});
it('calculates progress between silver and gold', () => {
const result = computeAgentLevel(1_000_000_000, DEFAULT_AGENT_LEVEL_TIERS);
expect(result.levelLabel).toBe('نقره‌ای');
expect(result.nextLevelLabel).toBe('طلایی');
expect(result.pointsToNext).toBe(500_000_000);
expect(result.levelProgress).toBe(50);
});
it('caps at vip with full progress', () => {
const result = computeAgentLevel(4_000_000_000, DEFAULT_AGENT_LEVEL_TIERS);
expect(result.levelLabel).toBe('VIP');
expect(result.nextLevelLabel).toBe('VIP');
expect(result.pointsToNext).toBe(0);
expect(result.levelProgress).toBe(100);
});
});
+93
View File
@@ -0,0 +1,93 @@
export type LevelTier = {
id?: string;
code: string;
label: string;
sortOrder: number;
minSalesToman: number;
};
export type AgentLevelSnapshot = {
levelLabel: string;
nextLevelLabel: string;
pointsToNext: number;
levelProgress: number;
levelDefId: string | null;
salesVolumeToman: number;
};
export const DEFAULT_AGENT_LEVEL_TIERS: LevelTier[] = [
{ code: 'bronze', label: 'برنزی', sortOrder: 1, minSalesToman: 0 },
{ code: 'silver', label: 'نقره‌ای', sortOrder: 2, minSalesToman: 500_000_000 },
{ code: 'gold', label: 'طلایی', sortOrder: 3, minSalesToman: 1_500_000_000 },
{ code: 'vip', label: 'VIP', sortOrder: 4, minSalesToman: 3_000_000_000 },
];
export function parseLevelMinSales(criteriaJson: unknown): number {
if (!criteriaJson || typeof criteriaJson !== 'object') return 0;
const value =
(criteriaJson as Record<string, unknown>).minSalesToman ??
(criteriaJson as Record<string, unknown>).minSales ??
(criteriaJson as Record<string, unknown>).salesVolumeToman ??
0;
const parsed = Number(value);
return Number.isFinite(parsed) && parsed >= 0 ? Math.round(parsed) : 0;
}
export function sortLevelTiers(tiers: LevelTier[]): LevelTier[] {
return [...tiers].sort(
(a, b) => a.minSalesToman - b.minSalesToman || a.sortOrder - b.sortOrder,
);
}
export function computeAgentLevel(
salesVolumeToman: number,
tiers: LevelTier[],
): Omit<AgentLevelSnapshot, 'salesVolumeToman'> {
const sorted = sortLevelTiers(tiers);
const safeSales = Math.max(0, Math.round(salesVolumeToman));
if (!sorted.length) {
return {
levelLabel: 'برنزی',
nextLevelLabel: 'نقره‌ای',
pointsToNext: 0,
levelProgress: 0,
levelDefId: null,
};
}
let currentIndex = 0;
for (let index = 0; index < sorted.length; index += 1) {
if (safeSales >= sorted[index].minSalesToman) {
currentIndex = index;
}
}
const current = sorted[currentIndex];
const next = sorted[currentIndex + 1];
if (!next) {
return {
levelLabel: current.label,
nextLevelLabel: current.label,
pointsToNext: 0,
levelProgress: 100,
levelDefId: current.id ?? null,
};
}
const range = next.minSalesToman - current.minSalesToman;
const progressInRange = safeSales - current.minSalesToman;
const levelProgress =
range <= 0
? 100
: Math.min(100, Math.max(0, Math.round((progressInRange / range) * 100)));
return {
levelLabel: current.label,
nextLevelLabel: next.label,
pointsToNext: Math.max(0, next.minSalesToman - safeSales),
levelProgress,
levelDefId: current.id ?? null,
};
}
+3 -2
View File
@@ -1,12 +1,13 @@
import { Module } from '@nestjs/common';
import { AgentController } from './agent.controller';
import { AgentLevelService } from './agent-level.service';
import { AgentService } from './agent.service';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [AgentController],
providers: [AgentService],
exports: [AgentService],
providers: [AgentService, AgentLevelService],
exports: [AgentService, AgentLevelService],
})
export class AgentModule {}
+12 -15
View File
@@ -19,12 +19,16 @@ import {
CreateReminderDto,
} from './dto/agent.dto';
import { loadCatalogCategories } from '../catalog/category-defs';
import { AgentLevelService } from './agent-level.service';
const AVATAR_COLORS = ['#f9a8d4', '#c4b5fd', '#93c5fd', '#86efac', '#fcd34d', '#fdba74'];
@Injectable()
export class AgentService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly agentLevelService: AgentLevelService,
) {}
private async requireAgent(agentId: string) {
const agent = await this.prisma.customer.findUnique({
@@ -38,7 +42,7 @@ export class AgentService {
async getProfile(agentId: string) {
const agent = await this.requireAgent(agentId);
const [customersCount, ordersCount] = await Promise.all([
const [customersCount, ordersCount, levelSnapshot] = await Promise.all([
this.prisma.customer.count({
where: { assignedAgentId: agentId, isActive: true },
}),
@@ -47,27 +51,20 @@ export class AgentService {
user: { assignedAgentId: agentId },
},
}),
this.agentLevelService.syncAgentLevel(agentId),
]);
const sales = await this.prisma.order.aggregate({
where: {
user: { assignedAgentId: agentId },
finalAmount: { not: null },
},
_sum: { finalAmount: true },
});
const salesVolume = sales._sum.finalAmount ?? 0;
const salesVolume = levelSnapshot.salesVolumeToman;
return {
id: agent.id,
firstName: agent.firstName,
lastName: agent.lastName,
code: agent.agentCode ?? '',
levelLabel: agent.levelLabel ?? 'برنزی',
nextLevelLabel: agent.nextLevelLabel ?? 'نقره‌ای',
pointsToNext: agent.pointsToNext ?? 0,
levelProgress: agent.levelProgress ?? 0,
levelLabel: levelSnapshot.levelLabel,
nextLevelLabel: levelSnapshot.nextLevelLabel,
pointsToNext: levelSnapshot.pointsToNext,
levelProgress: levelSnapshot.levelProgress,
phone: agent.phone,
customersCount,
ordersCount,
+2
View File
@@ -4,11 +4,13 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { SmsModule } from '../sms/sms.module';
import { AgentModule } from '../agent/agent.module';
import { resolveJwtSecret } from '../common/utils/jwt-secret';
@Module({
imports: [
SmsModule,
AgentModule,
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
+19 -1
View File
@@ -8,6 +8,8 @@ import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { randomInt } from 'node:crypto';
import * as bcrypt from 'bcryptjs';
import { AppUserRole } from '@prisma/client';
import { AgentLevelService } from '../agent/agent-level.service';
import { PrismaService } from '../prisma/prisma.service';
import { SmsService } from '../sms/sms.service';
import {
@@ -31,6 +33,7 @@ export class AuthService {
private readonly jwt: JwtService,
private readonly config: ConfigService,
private readonly sms: SmsService,
private readonly agentLevelService: AgentLevelService,
) {}
async requestOtp(dto: RequestOtpDto) {
@@ -153,6 +156,13 @@ export class AuthService {
await this.prisma.otpChallenge.deleteMany({ where: { phone } });
if (user.appRole === AppUserRole.agent) {
await this.agentLevelService.syncAgentLevel(user.id);
user =
(await this.prisma.customer.findUnique({ where: { id: user.id } })) ??
user;
}
const accessToken = await this.signCustomerToken(
user.id,
user.phone,
@@ -215,10 +225,18 @@ export class AuthService {
};
}
const user = await this.prisma.customer.findUnique({
let user = await this.prisma.customer.findUnique({
where: { id: userId },
});
if (!user) throw new UnauthorizedException();
if (user.appRole === AppUserRole.agent) {
await this.agentLevelService.syncAgentLevel(user.id);
user =
(await this.prisma.customer.findUnique({ where: { id: userId } })) ??
user;
}
return this.mapCustomer(user);
}
+17 -5
View File
@@ -10,6 +10,9 @@ export const CATEGORY_ICONS = [
'catalog',
'headset',
'home',
'shirt',
'suit',
'trousers',
] as const
export type CategoryIconId = (typeof CATEGORY_ICONS)[number]
@@ -46,7 +49,7 @@ export const USAGE_CATEGORIES: UsageCategoryDef[] = [
id: 'trousers',
title: 'پارچه‌های شلواری',
subtitle: 'مشاهده محصولات',
icon: 'grid',
icon: 'trousers',
showTitle: true,
showSubtitle: true,
showIcon: true,
@@ -55,7 +58,7 @@ export const USAGE_CATEGORIES: UsageCategoryDef[] = [
id: 'suit',
title: 'پارچه‌های کت‌شلواری',
subtitle: 'مشاهده محصولات',
icon: 'bag',
icon: 'suit',
showTitle: true,
showSubtitle: true,
showIcon: true,
@@ -64,7 +67,7 @@ export const USAGE_CATEGORIES: UsageCategoryDef[] = [
id: 'shirt',
title: 'پارچه‌های پیراهنی',
subtitle: 'مشاهده محصولات',
icon: 'id-card',
icon: 'shirt',
showTitle: true,
showSubtitle: true,
showIcon: true,
@@ -140,11 +143,19 @@ function parseIcon(value: unknown, fallback: CategoryIconId): CategoryIconId {
}
function defaultUsageIcon(id: string): CategoryIconId {
if (id === 'shirt') return 'id-card'
if (id === 'suit') return 'bag'
if (id === 'shirt') return 'shirt'
if (id === 'suit') return 'suit'
if (id === 'trousers') return 'trousers'
return 'grid'
}
function resolveUsageIconForApi(item: UsageCategoryDef): CategoryIconId {
if (item.id === 'shirt' || item.id === 'suit' || item.id === 'trousers') {
return defaultUsageIcon(item.id)
}
return item.icon
}
function defaultSeasonIcon(id: string): CategoryIconId {
if (id === 'summer') return 'bolt'
if (id === 'winter') return 'feather'
@@ -229,6 +240,7 @@ export async function loadCatalogCategories(prisma: PrismaService) {
return {
usage: defs.usage.map((item) => ({
...item,
icon: resolveUsageIconForApi(item),
imageUrl: images[item.id] || undefined,
})),
seasons: defs.seasons.map((item) => ({
@@ -133,6 +133,44 @@ export class NotificationsService {
void this.notifyAdmin(input);
}
async notifyCustomer(input: {
customerId: string;
event: string;
title: string;
body: string;
tone?: NotificationTone;
link?: string;
}): Promise<void> {
try {
await this.prisma.customerNotification.create({
data: {
customerId: input.customerId,
event: input.event,
title: input.title.trim(),
body: input.body.trim(),
tone: input.tone ?? NotificationTone.info,
link: input.link ?? null,
},
});
} catch (err) {
this.logger.error(
`notifyCustomer(${input.event}) failed for customer ${input.customerId}`,
err instanceof Error ? err.stack : err,
);
}
}
dispatchCustomer(input: {
customerId: string;
event: string;
title: string;
body: string;
tone?: NotificationTone;
link?: string;
}): void {
void this.notifyCustomer(input);
}
async listForCustomer(customerId: string, limit = 50) {
const rows = await this.prisma.customerNotification.findMany({
where: { customerId },
+2 -1
View File
@@ -6,9 +6,10 @@ import {
import { OrdersService } from './orders.service';
import { CashInvoiceSchedulerService } from './cash-invoice-scheduler.service';
import { NotificationsModule } from '../notifications/notifications.module';
import { AgentModule } from '../agent/agent.module';
@Module({
imports: [NotificationsModule],
imports: [NotificationsModule, AgentModule],
controllers: [CustomerOrdersController, AdminOrdersController],
providers: [OrdersService, CashInvoiceSchedulerService],
exports: [OrdersService],
+4
View File
@@ -33,6 +33,7 @@ import {
assertTransition,
} from '../common/utils/order-transitions';
import { resolveMaxMeterage } from '../common/utils/meterage';
import { AgentLevelService } from '../agent/agent-level.service';
import { NotificationsService } from '../notifications/notifications.service';
import { eventForStatus } from '../notifications/notification-events';
import { joinFullName, splitFullName } from '../common/utils/name';
@@ -81,6 +82,7 @@ export class OrdersService {
constructor(
private readonly prisma: PrismaService,
private readonly notifications: NotificationsService,
private readonly agentLevelService: AgentLevelService,
) {}
async findMyOrders(userId: string) {
@@ -498,6 +500,8 @@ export class OrdersService {
}
});
await this.agentLevelService.syncAgentForOrder(id);
return this.findAdminOrder(id);
}
+2
View File
@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { NotificationsModule } from '../notifications/notifications.module';
import { SuggestionsController } from './suggestions.controller';
import { SuggestionsService } from './suggestions.service';
@Module({
imports: [NotificationsModule],
controllers: [SuggestionsController],
providers: [SuggestionsService],
exports: [SuggestionsService],
+19 -1
View File
@@ -1,9 +1,14 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { NotificationTone } from '@prisma/client';
import { NotificationsService } from '../notifications/notifications.service';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class SuggestionsService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly notifications: NotificationsService,
) {}
async listAdmin(customerId?: string) {
const rows = await this.prisma.productSuggestion.findMany({
@@ -52,6 +57,19 @@ export class SuggestionsService {
product: { select: { name: true, code: true, color: true } },
},
});
const note = row.note?.trim();
this.notifications.dispatchCustomer({
customerId: input.customerId,
event: 'product_suggestion',
title: 'پیشنهاد محصول برای شما',
body: note
? `کارشناسان ما محصول «${row.product.name}» را به شما پیشنهاد داده‌اند: ${note}`
: `کارشناسان ما محصول «${row.product.name}» را به شما پیشنهاد داده‌اند.`,
tone: NotificationTone.info,
link: `/product?id=${input.productId}`,
});
return this.map(row);
}