This commit is contained in:
2026-09-19 17:49:02 +03:30
parent be00221bef
commit 2198574e4d
9 changed files with 202 additions and 33 deletions
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Customer" ADD COLUMN "address" TEXT;
+2
View File
@@ -252,6 +252,8 @@ model Customer {
avatarLetter String? avatarLetter String?
avatarColor String? avatarColor String?
avatarUrl String? avatarUrl String?
/// آدرس واسطه (در پروفایل اپ کاربر)
address String?
companyId String? companyId String?
levelDefId String? levelDefId String?
isActive Boolean @default(true) isActive Boolean @default(true)
+2
View File
@@ -252,6 +252,8 @@ model Customer {
avatarLetter String? avatarLetter String?
avatarColor String? avatarColor String?
avatarUrl String? avatarUrl String?
/// آدرس واسطه (در پروفایل اپ کاربر)
address String?
companyId String? companyId String?
levelDefId String? levelDefId String?
isActive Boolean @default(true) isActive Boolean @default(true)
+12
View File
@@ -16,6 +16,7 @@ import {
CreateAgentCustomerDto, CreateAgentCustomerDto,
CreateMeetingDto, CreateMeetingDto,
CreateReminderDto, CreateReminderDto,
UpdateAgentCustomerDto,
} from './dto/agent.dto'; } from './dto/agent.dto';
@Controller() @Controller()
@@ -66,6 +67,17 @@ export class AgentController {
return this.agentService.getCustomerDetail(user.id, id); return this.agentService.getCustomerDetail(user.id, id);
} }
@AuthType('customer')
@Roles('agent')
@Patch('agent/customers/:id')
updateCustomer(
@CurrentUser() user: AuthPrincipal,
@Param('id') id: string,
@Body() dto: UpdateAgentCustomerDto,
) {
return this.agentService.updateCustomer(user.id, id, dto);
}
@AuthType('customer') @AuthType('customer')
@Roles('agent') @Roles('agent')
@Post('agent/meetings') @Post('agent/meetings')
+82
View File
@@ -209,6 +209,85 @@ export class AgentService {
} }
} }
async updateCustomer(
agentId: string,
customerId: string,
dto: CreateAgentCustomerDto,
) {
await this.requireAgent(agentId);
const customer = await this.prisma.customer.findFirst({
where: { id: customerId, assignedAgentId: agentId },
});
if (!customer) throw new NotFoundException('مشتری یافت نشد');
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 && existing.id !== customerId) {
throw new ConflictException('این شماره قبلاً برای مشتری دیگری ثبت شده است.');
}
try {
const updated = await this.prisma.customer.update({
where: { id: customerId },
data: {
firstName,
lastName,
phone,
businessName,
avatarLetter: firstName.charAt(0),
},
});
const amounts = await this.prisma.order.findMany({
where: { customerId },
select: { finalAmount: true },
});
const total = amounts.reduce((sum, o) => sum + (o.finalAmount ?? 0), 0);
const orderCount = amounts.length;
return {
id: updated.id,
name: updated.businessName || `${updated.firstName} ${updated.lastName}`,
contactName: `${updated.firstName} ${updated.lastName}`,
firstName: updated.firstName,
lastName: updated.lastName,
businessName: updated.businessName,
phone: updated.phone,
status: updated.isActive ? 'active' : 'inactive',
avatarLetter: updated.avatarLetter || updated.firstName.charAt(0),
avatarColor: updated.avatarColor || customer.avatarColor || '#f9a8d4',
totalPurchaseToman: total,
orderCount,
averageOrderToman: orderCount ? Math.round(total / orderCount) : 0,
levelLabel: updated.levelLabel || 'برنزی',
firstPurchaseDate: updated.createdAt.toISOString().slice(0, 10),
};
} catch (e) {
if (
e instanceof Prisma.PrismaClientKnownRequestError &&
e.code === 'P2002'
) {
throw new ConflictException('این شماره قبلاً ثبت شده است');
}
throw e;
}
}
async getCustomerDetail(agentId: string, customerId: string) { async getCustomerDetail(agentId: string, customerId: string) {
await this.requireAgent(agentId); await this.requireAgent(agentId);
const customer = await this.prisma.customer.findFirst({ const customer = await this.prisma.customer.findFirst({
@@ -243,6 +322,9 @@ export class AgentService {
id: customer.id, id: customer.id,
name: customer.businessName || `${customer.firstName} ${customer.lastName}`, name: customer.businessName || `${customer.firstName} ${customer.lastName}`,
contactName: `${customer.firstName} ${customer.lastName}`, contactName: `${customer.firstName} ${customer.lastName}`,
firstName: customer.firstName,
lastName: customer.lastName,
businessName: customer.businessName,
phone: customer.phone, phone: customer.phone,
status: customer.isActive ? 'active' : 'inactive', status: customer.isActive ? 'active' : 'inactive',
avatarLetter: customer.avatarLetter || customer.firstName.charAt(0), avatarLetter: customer.avatarLetter || customer.firstName.charAt(0),
+2
View File
@@ -110,3 +110,5 @@ export class CreateAgentCustomerDto {
@IsString() @IsString()
businessName?: string; businessName?: string;
} }
export class UpdateAgentCustomerDto extends CreateAgentCustomerDto {}
+11
View File
@@ -241,17 +241,26 @@ export class AuthService {
} }
async updateProfile(userId: string, dto: UpdateProfileDto) { async updateProfile(userId: string, dto: UpdateProfileDto) {
const existing = await this.prisma.customer.findUnique({
where: { id: userId },
});
if (!existing) throw new UnauthorizedException();
const data: { const data: {
firstName?: string; firstName?: string;
lastName?: string; lastName?: string;
nationalId?: string | null; nationalId?: string | null;
avatarUrl?: string | null; avatarUrl?: string | null;
address?: string | null;
} = {}; } = {};
if (dto.firstName !== undefined) data.firstName = dto.firstName.trim(); if (dto.firstName !== undefined) data.firstName = dto.firstName.trim();
if (dto.lastName !== undefined) data.lastName = dto.lastName.trim(); if (dto.lastName !== undefined) data.lastName = dto.lastName.trim();
if (dto.nationalId !== undefined) data.nationalId = dto.nationalId.trim() || null; if (dto.nationalId !== undefined) data.nationalId = dto.nationalId.trim() || null;
if (dto.avatarUrl !== undefined) data.avatarUrl = this.normalizeAvatar(dto.avatarUrl); if (dto.avatarUrl !== undefined) data.avatarUrl = this.normalizeAvatar(dto.avatarUrl);
if (dto.address !== undefined) {
data.address = dto.address.trim() || null;
}
const user = await this.prisma.customer.update({ const user = await this.prisma.customer.update({
where: { id: userId }, where: { id: userId },
@@ -299,6 +308,7 @@ export class AuthService {
levelProgress?: number | null; levelProgress?: number | null;
businessName?: string | null; businessName?: string | null;
avatarUrl?: string | null; avatarUrl?: string | null;
address?: string | null;
}) { }) {
return { return {
id: user.id, id: user.id,
@@ -315,6 +325,7 @@ export class AuthService {
levelProgress: user.levelProgress ?? undefined, levelProgress: user.levelProgress ?? undefined,
businessName: user.businessName ?? undefined, businessName: user.businessName ?? undefined,
avatarUrl: user.avatarUrl ?? undefined, avatarUrl: user.avatarUrl ?? undefined,
address: user.address ?? undefined,
}; };
} }
} }
+6
View File
@@ -3,6 +3,7 @@ import {
IsOptional, IsOptional,
IsString, IsString,
Length, Length,
MaxLength,
MinLength, MinLength,
} from 'class-validator'; } from 'class-validator';
@@ -56,4 +57,9 @@ export class UpdateProfileDto {
@IsOptional() @IsOptional()
avatarUrl?: string | null; avatarUrl?: string | null;
@IsOptional()
@IsString()
@MaxLength(500)
address?: string;
} }
+83 -33
View File
@@ -1,8 +1,28 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { NotificationTone } from '@prisma/client'; import { NotificationTone } from '@prisma/client';
import { NotificationsService } from '../notifications/notifications.service'; import { NotificationsService } from '../notifications/notifications.service';
import {
mergeProductImages,
parseColorEntries,
} from '../products/color-entries';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
const PRODUCT_INCLUDE = {
product: {
select: {
name: true,
code: true,
color: true,
subtitle: true,
isNew: true,
colorsJson: true,
imageUrlsJson: true,
catalog: { select: { name: true, code: true } },
},
},
customer: { select: { firstName: true, lastName: true, phone: true } },
};
@Injectable() @Injectable()
export class SuggestionsService { export class SuggestionsService {
constructor( constructor(
@@ -16,10 +36,7 @@ export class SuggestionsService {
...(customerId ? { customerId } : {}), ...(customerId ? { customerId } : {}),
}, },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
include: { include: PRODUCT_INCLUDE,
customer: { select: { firstName: true, lastName: true, phone: true } },
product: { select: { name: true, code: true, color: true } },
},
}); });
return rows.map((r) => this.map(r)); return rows.map((r) => this.map(r));
} }
@@ -28,10 +45,7 @@ export class SuggestionsService {
const rows = await this.prisma.productSuggestion.findMany({ const rows = await this.prisma.productSuggestion.findMany({
where: { customerId, isActive: true }, where: { customerId, isActive: true },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
include: { include: PRODUCT_INCLUDE,
customer: { select: { firstName: true, lastName: true, phone: true } },
product: { select: { name: true, code: true, color: true } },
},
}); });
return rows.map((r) => this.map(r)); return rows.map((r) => this.map(r));
} }
@@ -44,31 +58,48 @@ export class SuggestionsService {
}) { }) {
await this.ensureCustomer(input.customerId); await this.ensureCustomer(input.customerId);
await this.ensureProduct(input.productId); await this.ensureProduct(input.productId);
const row = await this.prisma.productSuggestion.create({
data: { const existing = await this.prisma.productSuggestion.findFirst({
where: {
customerId: input.customerId, customerId: input.customerId,
productId: input.productId, productId: input.productId,
note: input.note?.trim() || null,
createdByAdminId: input.adminId,
isActive: true,
},
include: {
customer: { select: { firstName: true, lastName: true, phone: true } },
product: { select: { name: true, code: true, color: true } },
}, },
}); });
const note = row.note?.trim(); const row = existing
this.notifications.dispatchCustomer({ ? await this.prisma.productSuggestion.update({
customerId: input.customerId, where: { id: existing.id },
event: 'product_suggestion', data: {
title: 'پیشنهاد محصول برای شما', isActive: true,
body: note note: input.note?.trim() || existing.note,
? `کارشناسان ما محصول «${row.product.name}» را به شما پیشنهاد داده‌اند: ${note}` createdByAdminId: input.adminId ?? existing.createdByAdminId,
: `کارشناسان ما محصول «${row.product.name}» را به شما پیشنهاد داده‌اند.`, },
tone: NotificationTone.info, include: PRODUCT_INCLUDE,
link: `/product?id=${input.productId}`, })
}); : await this.prisma.productSuggestion.create({
data: {
customerId: input.customerId,
productId: input.productId,
note: input.note?.trim() || null,
createdByAdminId: input.adminId,
isActive: true,
},
include: PRODUCT_INCLUDE,
});
if (!existing) {
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); return this.map(row);
} }
@@ -81,10 +112,7 @@ export class SuggestionsService {
const row = await this.prisma.productSuggestion.update({ const row = await this.prisma.productSuggestion.update({
where: { id }, where: { id },
data: { isActive }, data: { isActive },
include: { include: PRODUCT_INCLUDE,
customer: { select: { firstName: true, lastName: true, phone: true } },
product: { select: { name: true, code: true, color: true } },
},
}); });
return this.map(row); return this.map(row);
} }
@@ -107,8 +135,25 @@ export class SuggestionsService {
isActive: boolean; isActive: boolean;
createdAt: Date; createdAt: Date;
customer: { firstName: string; lastName: string; phone: string }; customer: { firstName: string; lastName: string; phone: string };
product: { name: string; code: string; color: string }; product: {
name: string;
code: string;
color: string;
subtitle: string | null;
isNew: boolean;
colorsJson: unknown;
imageUrlsJson: unknown;
catalog: { name: string; code: string } | null;
};
}) { }) {
const colorEntries = parseColorEntries(r.product.colorsJson, r.product.color);
const images = mergeProductImages(colorEntries, r.product.imageUrlsJson);
const catalogName = r.product.catalog?.name;
const subtitle =
r.product.subtitle?.trim() ||
[catalogName, r.product.color].filter(Boolean).join(' · ') ||
undefined;
return { return {
id: r.id, id: r.id,
customerId: r.customerId, customerId: r.customerId,
@@ -117,7 +162,12 @@ export class SuggestionsService {
productId: r.productId, productId: r.productId,
productName: r.product.name, productName: r.product.name,
productCode: r.product.code, productCode: r.product.code,
catalogName,
catalogCode: r.product.catalog?.code,
color: r.product.color, color: r.product.color,
subtitle,
imageUrl: images[0] ?? undefined,
isNew: r.product.isNew,
note: r.note ?? undefined, note: r.note ?? undefined,
isActive: r.isActive, isActive: r.isActive,
createdAt: r.createdAt.toISOString(), createdAt: r.createdAt.toISOString(),