Initialize the API and add managed app banners.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-09 17:08:47 +03:30
co-authored by Cursor
commit b15fefe438
158 changed files with 25624 additions and 0 deletions
+119
View File
@@ -0,0 +1,119 @@
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
Query,
} from '@nestjs/common';
import { AuthType, Roles } from '../common/decorators/auth.decorators';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import type { AuthPrincipal } from '../common/decorators/current-user.decorator';
import { AgentService } from './agent.service';
import {
CompleteReminderDto,
CreateAgentCustomerDto,
CreateMeetingDto,
CreateReminderDto,
} from './dto/agent.dto';
@Controller()
export class AgentController {
constructor(private readonly agentService: AgentService) {}
@AuthType('any')
@Get('catalog/categories')
categories() {
return this.agentService.listCategories();
}
@AuthType('customer')
@Roles('agent')
@Get('agent/profile')
profile(@CurrentUser() user: AuthPrincipal) {
return this.agentService.getProfile(user.id);
}
@AuthType('customer')
@Roles('agent')
@Get('agent/customers')
customers(
@CurrentUser() user: AuthPrincipal,
@Query('q') q?: string,
@Query('status') status?: string,
) {
return this.agentService.listMyCustomers(user.id, q, status);
}
@AuthType('customer')
@Roles('agent')
@Post('agent/customers')
createCustomer(
@CurrentUser() user: AuthPrincipal,
@Body() dto: CreateAgentCustomerDto,
) {
return this.agentService.createCustomer(user.id, dto);
}
@AuthType('customer')
@Roles('agent')
@Get('agent/customers/:id')
customerDetail(
@CurrentUser() user: AuthPrincipal,
@Param('id') id: string,
) {
return this.agentService.getCustomerDetail(user.id, id);
}
@AuthType('customer')
@Roles('agent')
@Post('agent/meetings')
createMeeting(
@CurrentUser() user: AuthPrincipal,
@Body() dto: CreateMeetingDto,
) {
return this.agentService.createMeeting(user.id, dto);
}
@AuthType('customer')
@Roles('agent')
@Get('agent/reminders')
reminders(
@CurrentUser() user: AuthPrincipal,
@Query('filter') filter?: string,
) {
return this.agentService.listReminders(user.id, filter);
}
@AuthType('customer')
@Roles('agent')
@Get('agent/reminders/:id')
reminderDetail(
@CurrentUser() user: AuthPrincipal,
@Param('id') id: string,
) {
return this.agentService.getReminder(user.id, id);
}
@AuthType('customer')
@Roles('agent')
@Post('agent/reminders')
createReminder(
@CurrentUser() user: AuthPrincipal,
@Body() dto: CreateReminderDto,
) {
return this.agentService.createReminder(user.id, dto);
}
@AuthType('customer')
@Roles('agent')
@Patch('agent/reminders/:id/complete')
completeReminder(
@CurrentUser() user: AuthPrincipal,
@Param('id') id: string,
@Body() dto: CompleteReminderDto,
) {
return this.agentService.completeReminder(user.id, id, dto);
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AgentController } from './agent.controller';
import { AgentService } from './agent.service';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [AgentController],
providers: [AgentService],
exports: [AgentService],
})
export class AgentModule {}
+468
View File
@@ -0,0 +1,468 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { AppUserRole, InteractionType, Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import {
isValidPhone,
normalizePhone,
toEnglishDigits,
} from '../common/utils/phone';
import {
CompleteReminderDto,
CreateAgentCustomerDto,
CreateMeetingDto,
CreateReminderDto,
} from './dto/agent.dto';
import { loadCatalogCategories } from '../catalog/category-defs';
const AVATAR_COLORS = ['#f9a8d4', '#c4b5fd', '#93c5fd', '#86efac', '#fcd34d', '#fdba74'];
@Injectable()
export class AgentService {
constructor(private readonly prisma: PrismaService) {}
private async requireAgent(agentId: string) {
const agent = await this.prisma.customer.findUnique({
where: { id: agentId },
});
if (!agent || agent.appRole !== AppUserRole.agent) {
throw new ForbiddenException('فقط واسطه به این بخش دسترسی دارد');
}
return agent;
}
async getProfile(agentId: string) {
const agent = await this.requireAgent(agentId);
const [customersCount, ordersCount] = await Promise.all([
this.prisma.customer.count({
where: { assignedAgentId: agentId, isActive: true },
}),
this.prisma.order.count({
where: {
user: { assignedAgentId: agentId },
},
}),
]);
const sales = await this.prisma.order.aggregate({
where: {
user: { assignedAgentId: agentId },
finalAmount: { not: null },
},
_sum: { finalAmount: true },
});
const salesVolume = sales._sum.finalAmount ?? 0;
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,
phone: agent.phone,
customersCount,
ordersCount,
salesVolumeToman: salesVolume,
profitToman: Math.round(salesVolume * 0.1),
periodLabel: '۳۰۰ روز اخیر',
};
}
async listMyCustomers(agentId: string, q?: string, status?: string) {
await this.requireAgent(agentId);
const where: Prisma.CustomerWhereInput = {
assignedAgentId: agentId,
...(status === 'inactive' ? { isActive: false } : {}),
...(status === 'active' || status === 'new' ? { isActive: true } : {}),
...(q
? {
OR: [
{ firstName: { contains: q, mode: 'insensitive' } },
{ lastName: { contains: q, mode: 'insensitive' } },
{ businessName: { contains: q, mode: 'insensitive' } },
{ phone: { contains: q } },
],
}
: {}),
};
const customers = await this.prisma.customer.findMany({
where,
orderBy: { createdAt: 'desc' },
include: {
orders: {
select: { finalAmount: true, id: true },
},
},
});
const mapped = customers.map((c) => {
const amounts = c.orders
.map((o) => o.finalAmount ?? 0)
.filter((n) => n > 0);
const total = amounts.reduce((a, b) => a + b, 0);
const orderCount = c.orders.length;
return {
id: c.id,
name: c.businessName || `${c.firstName} ${c.lastName}`,
contactName: `${c.firstName} ${c.lastName}`,
phone: c.phone,
status: (!c.isActive
? 'inactive'
: orderCount === 0
? 'new'
: 'active') as 'active' | 'inactive' | 'new',
avatarLetter: c.avatarLetter || c.firstName.charAt(0),
avatarColor: c.avatarColor || '#c4b5fd',
totalPurchaseToman: total,
orderCount,
averageOrderToman: orderCount ? Math.round(total / orderCount) : 0,
levelLabel: c.levelLabel || 'برنزی',
firstPurchaseDate: '—',
agentCode: undefined as string | undefined,
agentName: undefined as string | undefined,
};
});
if (status === 'active' || status === 'new' || status === 'inactive') {
return mapped.filter((c) => c.status === status);
}
return mapped;
}
async createCustomer(agentId: string, dto: CreateAgentCustomerDto) {
await this.requireAgent(agentId);
const phone = normalizePhone(toEnglishDigits(dto.phone));
if (!isValidPhone(phone)) {
throw new BadRequestException('شماره موبایل نامعتبر است');
}
const firstName = dto.firstName.trim();
const lastName = dto.lastName.trim();
const businessName = dto.businessName?.trim() || null;
if (firstName.length < 2) {
throw new BadRequestException('نام الزامی است');
}
if (lastName.length < 2) {
throw new BadRequestException('نام خانوادگی الزامی است');
}
const existing = await this.prisma.customer.findUnique({ where: { phone } });
if (existing) {
if (existing.assignedAgentId === agentId) {
throw new ConflictException('این مشتری از قبل در لیست شماست');
}
throw new ConflictException(
'این شماره قبلاً ثبت شده است. برای انتساب به لیست خود، از ادمین درخواست کنید.',
);
}
const colorIndex =
phone.split('').reduce((sum, ch) => sum + ch.charCodeAt(0), 0) %
AVATAR_COLORS.length;
const avatarColor = AVATAR_COLORS[colorIndex] ?? '#c4b5fd';
try {
const created = await this.prisma.customer.create({
data: {
firstName,
lastName,
phone,
businessName,
assignedAgentId: agentId,
appRole: AppUserRole.customer,
isActive: true,
avatarLetter: firstName.charAt(0),
avatarColor,
},
});
return {
id: created.id,
name: created.businessName || `${created.firstName} ${created.lastName}`,
contactName: `${created.firstName} ${created.lastName}`,
phone: created.phone,
status: 'new' as const,
avatarLetter: created.avatarLetter || created.firstName.charAt(0),
avatarColor: created.avatarColor || avatarColor,
totalPurchaseToman: 0,
orderCount: 0,
averageOrderToman: 0,
levelLabel: created.levelLabel || 'برنزی',
firstPurchaseDate: '—',
};
} catch (e) {
if (
e instanceof Prisma.PrismaClientKnownRequestError &&
e.code === 'P2002'
) {
throw new ConflictException('این شماره قبلاً ثبت شده است');
}
throw e;
}
}
async getCustomerDetail(agentId: string, customerId: string) {
await this.requireAgent(agentId);
const customer = await this.prisma.customer.findFirst({
where: { id: customerId, assignedAgentId: agentId },
include: {
orders: {
orderBy: { createdAt: 'desc' },
take: 20,
},
},
});
if (!customer) throw new NotFoundException('مشتری یافت نشد');
const agent = await this.prisma.customer.findUnique({
where: { id: agentId },
});
const amounts = customer.orders
.map((o) => o.finalAmount ?? 0)
.filter((n) => n > 0);
const total = amounts.reduce((a, b) => a + b, 0);
const orderCount = customer.orders.length;
const meetings = await this.prisma.meetingInteraction.findMany({
where: { customerId, agentId },
orderBy: { createdAt: 'desc' },
take: 50,
});
return {
customer: {
id: customer.id,
name: customer.businessName || `${customer.firstName} ${customer.lastName}`,
contactName: `${customer.firstName} ${customer.lastName}`,
phone: customer.phone,
status: customer.isActive ? 'active' : 'inactive',
avatarLetter: customer.avatarLetter || customer.firstName.charAt(0),
avatarColor: customer.avatarColor || '#f9a8d4',
totalPurchaseToman: total,
orderCount,
averageOrderToman: orderCount ? Math.round(total / orderCount) : 0,
levelLabel: customer.levelLabel || 'برنزی',
firstPurchaseDate: customer.createdAt.toISOString().slice(0, 10),
agentCode: agent?.agentCode,
agentName: agent ? `${agent.firstName} ${agent.lastName}` : undefined,
},
orders: customer.orders.map((o) => ({
id: o.id,
customerId: customer.id,
title: `سفارش #${o.orderNumber}`,
amountToman: o.finalAmount ?? 0,
statusLabel: o.status,
statusTone: 'info' as const,
date: o.createdAt.toISOString().slice(0, 10),
})),
meetings: meetings.map((m) => ({
id: m.id,
customerId: m.customerId,
type: m.type,
title: m.title,
description: m.description ?? '',
date: m.date,
time: m.time ?? undefined,
})),
};
}
async createMeeting(agentId: string, dto: CreateMeetingDto) {
await this.requireAgent(agentId);
const customer = await this.prisma.customer.findFirst({
where: { id: dto.customerId, assignedAgentId: agentId },
});
if (!customer) throw new NotFoundException('مشتری یافت نشد');
const meeting = await this.prisma.meetingInteraction.create({
data: {
agentId,
customerId: dto.customerId,
type: dto.type,
title: dto.title.trim(),
description: dto.description?.trim(),
subject: dto.subject?.trim(),
content: dto.content?.trim(),
date: dto.date,
time: dto.time,
},
});
let reminder = null;
if (dto.createReminder && dto.reminderTitle && dto.reminderDate && dto.reminderTime) {
reminder = await this.prisma.reminder.create({
data: {
ownerId: agentId,
customerId: dto.customerId,
title: dto.reminderTitle.trim(),
description: dto.reminderNote?.trim(),
note: dto.reminderNote?.trim(),
date: dto.reminderDate,
time: dto.reminderTime,
interactionType: dto.type,
},
});
}
return { meeting, reminder };
}
async listReminders(agentId: string, filter?: string) {
await this.requireAgent(agentId);
const where: Prisma.ReminderWhereInput = { ownerId: agentId };
const today = new Date();
const yyyy = today.getFullYear();
const mm = String(today.getMonth() + 1).padStart(2, '0');
const dd = String(today.getDate()).padStart(2, '0');
const todayStr = `${yyyy}-${mm}-${dd}`;
const weekEnd = new Date(today);
weekEnd.setDate(weekEnd.getDate() + 7);
const weekEndStr = `${weekEnd.getFullYear()}-${String(weekEnd.getMonth() + 1).padStart(2, '0')}-${String(weekEnd.getDate()).padStart(2, '0')}`;
if (filter === 'past') {
where.completed = true;
} else if (filter === 'today') {
where.completed = false;
where.date = todayStr;
} else if (filter === 'week') {
where.completed = false;
where.date = { gte: todayStr, lte: weekEndStr };
} else if (filter === 'all') {
// no extra filters
} else {
where.completed = false;
}
const reminders = await this.prisma.reminder.findMany({
where,
orderBy: [{ date: 'asc' }, { time: 'asc' }],
include: {
customer: true,
},
});
return reminders.map((r) => ({
id: r.id,
customerId: r.customerId,
title: r.title,
description: r.description ?? '',
time: r.time,
date: r.date,
interactionType: r.interactionType,
completed: r.completed,
note: r.note ?? undefined,
customerName:
r.customer.businessName ||
`${r.customer.firstName} ${r.customer.lastName}`,
contactName: `${r.customer.firstName} ${r.customer.lastName}`,
avatarLetter: r.customer.avatarLetter || r.customer.firstName.charAt(0),
avatarColor: r.customer.avatarColor || '#c4b5fd',
}));
}
async getReminder(agentId: string, reminderId: string) {
await this.requireAgent(agentId);
const reminder = await this.prisma.reminder.findFirst({
where: { id: reminderId, ownerId: agentId },
include: { customer: true },
});
if (!reminder) throw new NotFoundException('یادآور یافت نشد');
const history = await this.prisma.meetingInteraction.findMany({
where: { customerId: reminder.customerId, agentId },
orderBy: { createdAt: 'desc' },
take: 20,
});
return {
reminder: {
id: reminder.id,
customerId: reminder.customerId,
title: reminder.title,
description: reminder.description ?? '',
time: reminder.time,
date: reminder.date,
interactionType: reminder.interactionType,
completed: reminder.completed,
note: reminder.note ?? undefined,
},
customer: {
id: reminder.customer.id,
name:
reminder.customer.businessName ||
`${reminder.customer.firstName} ${reminder.customer.lastName}`,
contactName: `${reminder.customer.firstName} ${reminder.customer.lastName}`,
phone: reminder.customer.phone,
avatarLetter:
reminder.customer.avatarLetter || reminder.customer.firstName.charAt(0),
avatarColor: reminder.customer.avatarColor || '#f9a8d4',
},
history: history.map((m) => ({
id: m.id,
type: m.type,
title: m.title,
description: m.description ?? '',
date: m.date,
})),
};
}
async createReminder(agentId: string, dto: CreateReminderDto) {
await this.requireAgent(agentId);
const customer = await this.prisma.customer.findFirst({
where: { id: dto.customerId, assignedAgentId: agentId },
});
if (!customer) throw new NotFoundException('مشتری یافت نشد');
return this.prisma.reminder.create({
data: {
ownerId: agentId,
customerId: dto.customerId,
title: dto.title.trim(),
description: dto.description?.trim(),
note: dto.note?.trim(),
date: dto.date,
time: dto.time,
interactionType: dto.interactionType ?? InteractionType.note,
},
});
}
async completeReminder(
agentId: string,
reminderId: string,
dto: CompleteReminderDto,
) {
await this.requireAgent(agentId);
const reminder = await this.prisma.reminder.findFirst({
where: { id: reminderId, ownerId: agentId },
});
if (!reminder) throw new NotFoundException('یادآور یافت نشد');
const completed = dto.completed ?? true;
return this.prisma.reminder.update({
where: { id: reminderId },
data: {
completed,
completedAt: completed ? new Date() : null,
},
});
}
async listCategories() {
return loadCatalogCategories(this.prisma);
}
}
+112
View File
@@ -0,0 +1,112 @@
import {
IsBoolean,
IsEnum,
IsOptional,
IsString,
MaxLength,
MinLength,
} from 'class-validator';
import { InteractionType } from '@prisma/client';
export class CreateMeetingDto {
@IsString()
customerId!: string;
@IsEnum(InteractionType)
type!: InteractionType;
@IsString()
@MinLength(2)
title!: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsString()
subject?: string;
@IsOptional()
@IsString()
@MaxLength(1000)
content?: string;
@IsString()
date!: string;
@IsOptional()
@IsString()
time?: string;
@IsOptional()
@IsBoolean()
createReminder?: boolean;
@IsOptional()
@IsString()
reminderTitle?: string;
@IsOptional()
@IsString()
reminderDate?: string;
@IsOptional()
@IsString()
reminderTime?: string;
@IsOptional()
@IsString()
reminderNote?: string;
}
export class CreateReminderDto {
@IsString()
customerId!: string;
@IsString()
@MinLength(2)
title!: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsString()
note?: string;
@IsString()
date!: string;
@IsString()
time!: string;
@IsOptional()
@IsEnum(InteractionType)
interactionType?: InteractionType;
}
export class CompleteReminderDto {
@IsOptional()
@IsBoolean()
completed?: boolean;
}
export class CreateAgentCustomerDto {
@IsString()
@MinLength(2)
firstName!: string;
@IsString()
@MinLength(2)
lastName!: string;
@IsString()
@MinLength(10)
phone!: string;
@IsOptional()
@IsString()
businessName?: string;
}