Add special orders API and related media/auth updates.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-10-12 23:09:30 +03:30
co-authored by Cursor
parent ffc5f8977e
commit 63253f1877
20 changed files with 638 additions and 23 deletions
@@ -0,0 +1,313 @@
import {
ConflictException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { 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,
},
},
images: {
orderBy: { sortOrder: 'asc' as const },
},
} satisfies Prisma.SpecialOrderInclude;
type SpecialOrderRow = Prisma.SpecialOrderGetPayload<{
include: typeof specialOrderInclude;
}>;
const CATEGORY_LABEL: Record<SpecialOrderCategory, string> = {
specialCake: 'کیک ویژه',
specialPastry: 'شیرینی ویژه',
};
@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 } },
{
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 row = await this.prisma.specialOrder.create({
data: {
userId: dto.userId,
category: dto.category,
name: dto.name.trim(),
description: dto.description?.trim() ?? '',
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,
category: dto.category,
name: dto.name,
description: dto.description,
images: dto.images,
});
}
async update(id: string, dto: UpdateSpecialOrderDto) {
await this.ensureExists(id);
if (dto.userId) await this.ensureUser(dto.userId);
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 } : {}),
...(dto.category !== undefined ? { category: dto.category } : {}),
...(dto.name !== undefined ? { name: dto.name.trim() } : {}),
...(dto.description !== undefined
? { description: dto.description.trim() }
: {}),
...(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 categoryLabel = CATEGORY_LABEL[row.category];
const customerName = displayName(row.user);
const customerPhone = row.user.cellNumber;
const userMessage = `بلوط: سفارش ویژه «${row.name}» (${categoryLabel}) ثبت شد. به‌زودی برای هماهنگی با شما تماس می‌گیریم.`;
const adminMessage = `بلوط: سفارش ویژه جدید\n${categoryLabel} — ${row.name}\nکاربر: ${customerName}\nموبایل: ${customerPhone}`;
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 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,
userId: row.userId,
category: row.category,
name: row.name,
description: row.description,
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),
},
};
}
}