495 lines
14 KiB
TypeScript
495 lines
14 KiB
TypeScript
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<DeliveryType, string> = {
|
|
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),
|
|
},
|
|
};
|
|
}
|
|
}
|