Add special orders API and related media/auth updates.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
ffc5f8977e
commit
63253f1877
@@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user