From 2198574e4d87ca6aad11aa145b3354576c580f36 Mon Sep 17 00:00:00 2001 From: safavimohadeseh78 Date: Sat, 19 Sep 2026 17:49:02 +0330 Subject: [PATCH] . --- .../migration.sql | 2 + prisma/schema.prisma | 2 + runtime/schema.prisma | 2 + src/agent/agent.controller.ts | 12 ++ src/agent/agent.service.ts | 82 +++++++++++++ src/agent/dto/agent.dto.ts | 2 + src/auth/auth.service.ts | 11 ++ src/auth/dto/auth.dto.ts | 6 + src/suggestions/suggestions.service.ts | 116 +++++++++++++----- 9 files changed, 202 insertions(+), 33 deletions(-) create mode 100644 prisma/migrations/20260919120000_customer_address/migration.sql diff --git a/prisma/migrations/20260919120000_customer_address/migration.sql b/prisma/migrations/20260919120000_customer_address/migration.sql new file mode 100644 index 0000000..83f7f06 --- /dev/null +++ b/prisma/migrations/20260919120000_customer_address/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Customer" ADD COLUMN "address" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 24d6b1d..6065aeb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -252,6 +252,8 @@ model Customer { avatarLetter String? avatarColor String? avatarUrl String? + /// آدرس واسطه (در پروفایل اپ کاربر) + address String? companyId String? levelDefId String? isActive Boolean @default(true) diff --git a/runtime/schema.prisma b/runtime/schema.prisma index 6f30dbb..1f2c2ad 100644 --- a/runtime/schema.prisma +++ b/runtime/schema.prisma @@ -252,6 +252,8 @@ model Customer { avatarLetter String? avatarColor String? avatarUrl String? + /// آدرس واسطه (در پروفایل اپ کاربر) + address String? companyId String? levelDefId String? isActive Boolean @default(true) diff --git a/src/agent/agent.controller.ts b/src/agent/agent.controller.ts index 15f93d9..1593468 100644 --- a/src/agent/agent.controller.ts +++ b/src/agent/agent.controller.ts @@ -16,6 +16,7 @@ import { CreateAgentCustomerDto, CreateMeetingDto, CreateReminderDto, + UpdateAgentCustomerDto, } from './dto/agent.dto'; @Controller() @@ -66,6 +67,17 @@ export class AgentController { 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') @Roles('agent') @Post('agent/meetings') diff --git a/src/agent/agent.service.ts b/src/agent/agent.service.ts index 2ff4684..e3c801b 100644 --- a/src/agent/agent.service.ts +++ b/src/agent/agent.service.ts @@ -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) { await this.requireAgent(agentId); const customer = await this.prisma.customer.findFirst({ @@ -243,6 +322,9 @@ export class AgentService { id: customer.id, name: customer.businessName || `${customer.firstName} ${customer.lastName}`, contactName: `${customer.firstName} ${customer.lastName}`, + firstName: customer.firstName, + lastName: customer.lastName, + businessName: customer.businessName, phone: customer.phone, status: customer.isActive ? 'active' : 'inactive', avatarLetter: customer.avatarLetter || customer.firstName.charAt(0), diff --git a/src/agent/dto/agent.dto.ts b/src/agent/dto/agent.dto.ts index 111aa5d..307b19f 100644 --- a/src/agent/dto/agent.dto.ts +++ b/src/agent/dto/agent.dto.ts @@ -110,3 +110,5 @@ export class CreateAgentCustomerDto { @IsString() businessName?: string; } + +export class UpdateAgentCustomerDto extends CreateAgentCustomerDto {} diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 43c37fb..427dd97 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -241,17 +241,26 @@ export class AuthService { } async updateProfile(userId: string, dto: UpdateProfileDto) { + const existing = await this.prisma.customer.findUnique({ + where: { id: userId }, + }); + if (!existing) throw new UnauthorizedException(); + const data: { firstName?: string; lastName?: string; nationalId?: string | null; avatarUrl?: string | null; + address?: string | null; } = {}; if (dto.firstName !== undefined) data.firstName = dto.firstName.trim(); if (dto.lastName !== undefined) data.lastName = dto.lastName.trim(); if (dto.nationalId !== undefined) data.nationalId = dto.nationalId.trim() || null; 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({ where: { id: userId }, @@ -299,6 +308,7 @@ export class AuthService { levelProgress?: number | null; businessName?: string | null; avatarUrl?: string | null; + address?: string | null; }) { return { id: user.id, @@ -315,6 +325,7 @@ export class AuthService { levelProgress: user.levelProgress ?? undefined, businessName: user.businessName ?? undefined, avatarUrl: user.avatarUrl ?? undefined, + address: user.address ?? undefined, }; } } diff --git a/src/auth/dto/auth.dto.ts b/src/auth/dto/auth.dto.ts index fb42a76..297b14d 100644 --- a/src/auth/dto/auth.dto.ts +++ b/src/auth/dto/auth.dto.ts @@ -3,6 +3,7 @@ import { IsOptional, IsString, Length, + MaxLength, MinLength, } from 'class-validator'; @@ -56,4 +57,9 @@ export class UpdateProfileDto { @IsOptional() avatarUrl?: string | null; + + @IsOptional() + @IsString() + @MaxLength(500) + address?: string; } diff --git a/src/suggestions/suggestions.service.ts b/src/suggestions/suggestions.service.ts index bdaf23e..1efbf95 100644 --- a/src/suggestions/suggestions.service.ts +++ b/src/suggestions/suggestions.service.ts @@ -1,8 +1,28 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { NotificationTone } from '@prisma/client'; import { NotificationsService } from '../notifications/notifications.service'; +import { + mergeProductImages, + parseColorEntries, +} from '../products/color-entries'; 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() export class SuggestionsService { constructor( @@ -16,10 +36,7 @@ export class SuggestionsService { ...(customerId ? { customerId } : {}), }, orderBy: { createdAt: 'desc' }, - include: { - customer: { select: { firstName: true, lastName: true, phone: true } }, - product: { select: { name: true, code: true, color: true } }, - }, + include: PRODUCT_INCLUDE, }); return rows.map((r) => this.map(r)); } @@ -28,10 +45,7 @@ export class SuggestionsService { const rows = await this.prisma.productSuggestion.findMany({ where: { customerId, isActive: true }, orderBy: { createdAt: 'desc' }, - include: { - customer: { select: { firstName: true, lastName: true, phone: true } }, - product: { select: { name: true, code: true, color: true } }, - }, + include: PRODUCT_INCLUDE, }); return rows.map((r) => this.map(r)); } @@ -44,31 +58,48 @@ export class SuggestionsService { }) { await this.ensureCustomer(input.customerId); await this.ensureProduct(input.productId); - const row = await this.prisma.productSuggestion.create({ - data: { + + const existing = await this.prisma.productSuggestion.findFirst({ + where: { customerId: input.customerId, 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(); - 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}`, - }); + const row = existing + ? await this.prisma.productSuggestion.update({ + where: { id: existing.id }, + data: { + isActive: true, + note: input.note?.trim() || existing.note, + createdByAdminId: input.adminId ?? existing.createdByAdminId, + }, + include: PRODUCT_INCLUDE, + }) + : 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); } @@ -81,10 +112,7 @@ export class SuggestionsService { const row = await this.prisma.productSuggestion.update({ where: { id }, data: { isActive }, - include: { - customer: { select: { firstName: true, lastName: true, phone: true } }, - product: { select: { name: true, code: true, color: true } }, - }, + include: PRODUCT_INCLUDE, }); return this.map(row); } @@ -107,8 +135,25 @@ export class SuggestionsService { isActive: boolean; createdAt: Date; 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 { id: r.id, customerId: r.customerId, @@ -117,7 +162,12 @@ export class SuggestionsService { productId: r.productId, productName: r.product.name, productCode: r.product.code, + catalogName, + catalogCode: r.product.catalog?.code, color: r.product.color, + subtitle, + imageUrl: images[0] ?? undefined, + isNew: r.product.isNew, note: r.note ?? undefined, isActive: r.isActive, createdAt: r.createdAt.toISOString(),