import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException, } from '@nestjs/common'; import { DeliveryType, Prisma, SpecialOrderCategory, UserRole, } from '@prisma/client'; import { displayName } from '../auth/auth.types'; import { PrismaService } from '../prisma/prisma.service'; import { SmsService } from '../sms/sms.service'; import { CreateMySpecialOrderDto, CreateSpecialOrderDto, SpecialOrderImageDto, } from './dto/create-special-order.dto'; import { ListSpecialOrdersQueryDto } from './dto/list-special-orders-query.dto'; import { UpdateSpecialOrderDto } from './dto/update-special-order.dto'; const SUBMIT_COOLDOWN_MS = 60_000; const specialOrderInclude = { user: { select: { id: true, title: true, firstName: true, lastName: true, cellNumber: true, role: true, }, }, branch: { select: { id: true, name: true, district: true, address: true, }, }, images: { orderBy: { sortOrder: 'asc' as const }, }, } satisfies Prisma.SpecialOrderInclude; const DELIVERY_LABEL: Record = { pickup: 'دریافت حضوری', shipping: 'ارسال با پیک', }; type SpecialOrderRow = Prisma.SpecialOrderGetPayload<{ include: typeof specialOrderInclude; }>; @Injectable() export class SpecialOrdersService { private readonly logger = new Logger(SpecialOrdersService.name); constructor( private readonly prisma: PrismaService, private readonly sms: SmsService, ) {} async list(query: ListSpecialOrdersQueryDto) { const page = query.page ?? 1; const pageSize = query.pageSize ?? 20; const skip = (page - 1) * pageSize; const q = query.q?.trim(); const where: Prisma.SpecialOrderWhereInput = { ...(query.userId ? { userId: query.userId } : {}), ...(query.category ? { category: query.category } : {}), ...(q ? { OR: [ { name: { contains: q, mode: 'insensitive' as const } }, { description: { contains: q, mode: 'insensitive' as const } }, { cakeFlavor: { contains: q, mode: 'insensitive' as const } }, { filling: { contains: q, mode: 'insensitive' as const } }, { coating: { contains: q, mode: 'insensitive' as const } }, { designStyle: { contains: q, mode: 'insensitive' as const } }, { cakeText: { contains: q, mode: 'insensitive' as const } }, { decorations: { contains: q, mode: 'insensitive' as const } }, { branchName: { contains: q, mode: 'insensitive' as const } }, ...(Number.isFinite(Number(q.replace(/\D/g, ''))) && q.replace(/\D/g, '') ? [{ number: Number(q.replace(/\D/g, '')) }] : []), { user: { OR: [ { firstName: { contains: q, mode: 'insensitive' as const, }, }, { lastName: { contains: q, mode: 'insensitive' as const, }, }, { cellNumber: { contains: q } }, ], }, }, ], } : {}), }; const [rows, total] = await Promise.all([ this.prisma.specialOrder.findMany({ where, skip, take: pageSize, orderBy: { createdAt: 'desc' }, include: specialOrderInclude, }), this.prisma.specialOrder.count({ where }), ]); return { items: rows.map((row) => this.serialize(row)), total, page, pageSize, }; } async listMine(userId: string, query: ListSpecialOrdersQueryDto) { return this.list({ ...query, userId }); } async findOne(id: string) { const row = await this.prisma.specialOrder.findUnique({ where: { id }, include: specialOrderInclude, }); if (!row) throw new NotFoundException('سفارش ویژه یافت نشد'); return this.serialize(row); } async create(dto: CreateSpecialOrderDto) { await this.ensureUser(dto.userId); const details = await this.resolveDetails(dto); const row = await this.prisma.specialOrder.create({ data: { userId: dto.userId, category: dto.category, name: dto.name.trim(), description: details.description ?? '', neededDate: details.neededDate ?? null, receiveTime: details.receiveTime ?? '', servings: details.servings ?? null, cakeFlavor: details.cakeFlavor ?? '', filling: details.filling ?? '', baloutSuggestedCombo: details.baloutSuggestedCombo ?? false, coating: details.coating ?? '', designStyle: details.designStyle ?? '', cakeText: details.cakeText ?? '', decorations: details.decorations ?? '', deliveryType: details.deliveryType ?? null, branchId: details.branchId ?? null, branchName: details.branchName ?? '', images: { create: this.imageCreates(dto.images), }, }, include: specialOrderInclude, }); void this.notifyCreated(row); return this.serialize(row); } async createMine(userId: string, dto: CreateMySpecialOrderDto) { const recent = await this.prisma.specialOrder.findFirst({ where: { userId, createdAt: { gte: new Date(Date.now() - SUBMIT_COOLDOWN_MS) }, }, select: { id: true, createdAt: true }, orderBy: { createdAt: 'desc' }, }); if (recent) { const waitSec = Math.ceil( (SUBMIT_COOLDOWN_MS - (Date.now() - recent.createdAt.getTime())) / 1000, ); throw new ConflictException( `لطفاً ${Math.max(waitSec, 1)} ثانیه دیگر دوباره تلاش کنید`, ); } return this.create({ userId, ...dto, }); } async update(id: string, dto: UpdateSpecialOrderDto) { await this.ensureExists(id); if (dto.userId) await this.ensureUser(dto.userId); const details = await this.resolveDetails(dto, { partial: true }); const row = await this.prisma.$transaction(async (tx) => { if (dto.images !== undefined) { await tx.specialOrderImage.deleteMany({ where: { specialOrderId: id }, }); } return tx.specialOrder.update({ where: { id }, data: { ...(dto.userId !== undefined ? { userId: dto.userId } : {}), ...details, ...(dto.images !== undefined ? { images: { create: this.imageCreates(dto.images) } } : {}), }, include: specialOrderInclude, }); }); return this.serialize(row); } async remove(id: string) { await this.ensureExists(id); await this.prisma.specialOrder.delete({ where: { id } }); return { ok: true }; } private async notifyCreated(row: SpecialOrderRow) { const orderCode = `BL-S-${row.number}`; const customerPhone = row.user.cellNumber; const userMessage = `سفارش شما به شماره ی${orderCode} ثبت شد\nاز شما سپاسگزاریم و به زودی با شما تماس خواهیم گرفت`; const adminMessage = `سفارش ویژه ی جدید به شماره سفارش ${orderCode}`; try { await this.sms.send({ to: customerPhone, message: userMessage }); } catch (err) { this.logger.error( `Failed to SMS customer ${customerPhone} for special order ${row.id}`, err instanceof Error ? err.stack : err, ); } try { const admins = await this.prisma.user.findMany({ where: { disabled: false, role: { in: [UserRole.admin, UserRole.superAdmin] }, }, select: { cellNumber: true }, }); const phones = [ ...new Set( admins .map((admin) => admin.cellNumber) .filter((phone) => /^09\d{9}$/.test(phone)), ), ].filter((phone) => phone !== customerPhone); await Promise.all( phones.map(async (phone) => { try { await this.sms.send({ to: phone, message: adminMessage }); } catch (err) { this.logger.error( `Failed to SMS admin ${phone} for special order ${row.id}`, err instanceof Error ? err.stack : err, ); } }), ); } catch (err) { this.logger.error( `Failed to resolve admins for special order ${row.id}`, err instanceof Error ? err.stack : err, ); } } private imageCreates(images?: SpecialOrderImageDto[]) { return (images ?? []).map((image, index) => ({ url: image.url, storageKey: image.storageKey, sortOrder: index, })); } private async resolveDetails( dto: { category?: SpecialOrderCategory; name?: string; description?: string; neededDate?: string | null; receiveTime?: string; servings?: number | null; cakeFlavor?: string; filling?: string; baloutSuggestedCombo?: boolean; coating?: string; designStyle?: string; cakeText?: string; decorations?: string; deliveryType?: DeliveryType | null; branchId?: string | null; }, options?: { partial?: boolean }, ) { const partial = options?.partial ?? false; const data: { category?: SpecialOrderCategory; name?: string; description?: string; neededDate?: Date | null; receiveTime?: string; servings?: number | null; cakeFlavor?: string; filling?: string; baloutSuggestedCombo?: boolean; coating?: string; designStyle?: string; cakeText?: string; decorations?: string; deliveryType?: DeliveryType | null; branchId?: string | null; branchName?: string; } = {}; if (dto.category !== undefined) data.category = dto.category; if (dto.name !== undefined) data.name = dto.name.trim(); if (dto.description !== undefined) { data.description = dto.description.trim(); } else if (!partial) { data.description = ''; } if (!partial || dto.neededDate !== undefined) { data.neededDate = dto.neededDate === null || dto.neededDate === undefined ? null : new Date(dto.neededDate); } if (!partial || dto.receiveTime !== undefined) { data.receiveTime = dto.receiveTime?.trim() ?? ''; } if (!partial || dto.servings !== undefined) { data.servings = dto.servings === null || dto.servings === undefined ? null : dto.servings; } if (!partial || dto.cakeFlavor !== undefined) { data.cakeFlavor = dto.cakeFlavor?.trim() ?? ''; } if (!partial || dto.filling !== undefined) { data.filling = dto.filling?.trim() ?? ''; } if (!partial || dto.baloutSuggestedCombo !== undefined) { data.baloutSuggestedCombo = Boolean(dto.baloutSuggestedCombo); } if (!partial || dto.coating !== undefined) { data.coating = dto.coating?.trim() ?? ''; } if (!partial || dto.designStyle !== undefined) { data.designStyle = dto.designStyle?.trim() ?? ''; } if (!partial || dto.cakeText !== undefined) { data.cakeText = dto.cakeText?.trim() ?? ''; } if (!partial || dto.decorations !== undefined) { data.decorations = dto.decorations?.trim() ?? ''; } if (!partial || dto.deliveryType !== undefined) { data.deliveryType = dto.deliveryType === undefined ? null : dto.deliveryType; } if (!partial || dto.branchId !== undefined) { const branchId = dto.branchId === undefined || dto.branchId === null || dto.branchId === '' ? null : dto.branchId; if (branchId) { const branch = await this.prisma.branch.findUnique({ where: { id: branchId }, select: { id: true, name: true }, }); if (!branch) throw new NotFoundException('شعبه یافت نشد'); data.branchId = branch.id; data.branchName = branch.name; } else { data.branchId = null; data.branchName = ''; } } if ( !partial && dto.deliveryType === DeliveryType.pickup && !data.branchId ) { throw new BadRequestException( 'انتخاب شعبه برای دریافت حضوری الزامی است', ); } return data; } private async ensureUser(userId: string) { const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { id: true }, }); if (!user) throw new NotFoundException('کاربر یافت نشد'); } private async ensureExists(id: string) { const row = await this.prisma.specialOrder.findUnique({ where: { id }, select: { id: true }, }); if (!row) throw new NotFoundException('سفارش ویژه یافت نشد'); } private serialize(row: SpecialOrderRow) { return { id: row.id, number: row.number, code: `BL-S-${row.number}`, userId: row.userId, category: row.category, name: row.name, description: row.description, neededDate: row.neededDate?.toISOString() ?? null, receiveTime: row.receiveTime, servings: row.servings, cakeFlavor: row.cakeFlavor, filling: row.filling, baloutSuggestedCombo: row.baloutSuggestedCombo, coating: row.coating, designStyle: row.designStyle, cakeText: row.cakeText, decorations: row.decorations, deliveryType: row.deliveryType, deliveryTypeLabel: row.deliveryType ? DELIVERY_LABEL[row.deliveryType] : null, branchId: row.branchId, branchName: row.branchName || row.branch?.name || '', branch: row.branch ? { id: row.branch.id, name: row.branch.name, district: row.branch.district, address: row.branch.address, } : null, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), images: row.images.map((image) => ({ id: image.id, url: image.url, storageKey: image.storageKey, sortOrder: image.sortOrder, })), user: { id: row.user.id, title: row.user.title, firstName: row.user.firstName, lastName: row.user.lastName, cellNumber: row.user.cellNumber, role: row.user.role, name: displayName(row.user), }, }; } }