Expand special orders with form details, codes, and SMS copy.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-10-12 23:44:27 +03:30
co-authored by Cursor
parent 63253f1877
commit 3cc4c89828
7 changed files with 423 additions and 79 deletions
@@ -0,0 +1,17 @@
-- AlterTable
ALTER TABLE "SpecialOrder" ADD COLUMN "neededDate" TIMESTAMP(3),
ADD COLUMN "receiveTime" TEXT NOT NULL DEFAULT '',
ADD COLUMN "servings" INTEGER,
ADD COLUMN "cakeFlavor" TEXT NOT NULL DEFAULT '',
ADD COLUMN "filling" TEXT NOT NULL DEFAULT '',
ADD COLUMN "cakeText" TEXT NOT NULL DEFAULT '',
ADD COLUMN "decorations" TEXT NOT NULL DEFAULT '',
ADD COLUMN "deliveryType" "DeliveryType",
ADD COLUMN "branchId" TEXT,
ADD COLUMN "branchName" TEXT NOT NULL DEFAULT '';
-- CreateIndex
CREATE INDEX "SpecialOrder_branchId_idx" ON "SpecialOrder"("branchId");
-- AddForeignKey
ALTER TABLE "SpecialOrder" ADD CONSTRAINT "SpecialOrder_branchId_fkey" FOREIGN KEY ("branchId") REFERENCES "Branch"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "SpecialOrder" ADD COLUMN "baloutSuggestedCombo" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "coating" TEXT NOT NULL DEFAULT '',
ADD COLUMN "designStyle" TEXT NOT NULL DEFAULT '';
@@ -0,0 +1,9 @@
-- AlterTable
CREATE SEQUENCE "SpecialOrder_number_seq";
ALTER TABLE "SpecialOrder" ADD COLUMN "number" INTEGER NOT NULL DEFAULT nextval('"SpecialOrder_number_seq"');
ALTER SEQUENCE "SpecialOrder_number_seq" OWNED BY "SpecialOrder"."number";
-- CreateIndex
CREATE UNIQUE INDEX "SpecialOrder_number_key" ON "SpecialOrder"("number");
+36 -18
View File
@@ -213,15 +213,16 @@ model ShippingException {
} }
model Branch { model Branch {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
district String district String
address String address String
landline String @default("") landline String @default("")
cellNumber String @default("") cellNumber String @default("")
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
orders Order[] orders Order[]
specialOrders SpecialOrder[]
} }
model Order { model Order {
@@ -313,19 +314,36 @@ enum SpecialOrderCategory {
/// Custom request form (no payment / product link) — shown to admins. /// Custom request form (no payment / product link) — shown to admins.
model SpecialOrder { model SpecialOrder {
id String @id @default(cuid()) id String @id @default(cuid())
userId String number Int @unique @default(autoincrement())
user User @relation(fields: [userId], references: [id], onDelete: Cascade) userId String
category SpecialOrderCategory user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String category SpecialOrderCategory
description String @default("") /// مناسبت سفارش
createdAt DateTime @default(now()) name String
updatedAt DateTime @updatedAt description String @default("")
images SpecialOrderImage[] neededDate DateTime?
receiveTime String @default("")
servings Int?
cakeFlavor String @default("")
filling String @default("")
baloutSuggestedCombo Boolean @default(false)
coating String @default("")
designStyle String @default("")
cakeText String @default("")
decorations String @default("")
deliveryType DeliveryType?
branchId String?
branch Branch? @relation(fields: [branchId], references: [id], onDelete: SetNull)
branchName String @default("")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
images SpecialOrderImage[]
@@index([userId]) @@index([userId])
@@index([category]) @@index([category])
@@index([createdAt]) @@index([createdAt])
@@index([branchId])
} }
model SpecialOrderImage { model SpecialOrderImage {
@@ -1,6 +1,19 @@
import { IsArray, IsEnum, IsOptional, IsString, MinLength, ValidateNested } from 'class-validator'; import {
IsArray,
IsBoolean,
IsEnum,
IsInt,
IsISO8601,
IsOptional,
IsString,
Max,
Min,
MinLength,
ValidateIf,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { SpecialOrderCategory } from '@prisma/client'; import { DeliveryType, SpecialOrderCategory } from '@prisma/client';
export class SpecialOrderImageDto { export class SpecialOrderImageDto {
@IsString() @IsString()
@@ -12,44 +25,82 @@ export class SpecialOrderImageDto {
storageKey!: string; storageKey!: string;
} }
export class CreateSpecialOrderDto { class SpecialOrderDetailsDto {
@IsEnum(SpecialOrderCategory, { message: 'دسته‌بندی نامعتبر است' })
category!: SpecialOrderCategory;
@IsString()
@MinLength(1, { message: 'مناسبت سفارش الزامی است' })
name!: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsISO8601({}, { message: 'تاریخ موردنیاز نامعتبر است' })
neededDate?: string;
@IsOptional()
@IsString()
receiveTime?: string;
@IsOptional()
@Type(() => Number)
@IsInt({ message: 'تعداد نفرات باید عدد باشد' })
@Min(1, { message: 'تعداد نفرات حداقل ۱ است' })
@Max(500, { message: 'تعداد نفرات بیش از حد مجاز است' })
servings?: number;
@IsOptional()
@IsString()
cakeFlavor?: string;
@IsOptional()
@IsString()
filling?: string;
@IsOptional()
@Type(() => Boolean)
@IsBoolean()
baloutSuggestedCombo?: boolean;
@IsOptional()
@IsString()
coating?: string;
@IsOptional()
@IsString()
designStyle?: string;
@IsOptional()
@IsString()
cakeText?: string;
@IsOptional()
@IsString()
decorations?: string;
@IsOptional()
@IsEnum(DeliveryType, { message: 'نوع دریافت نامعتبر است' })
deliveryType?: DeliveryType;
@ValidateIf((dto: SpecialOrderDetailsDto) => dto.deliveryType === DeliveryType.pickup)
@IsString({ message: 'انتخاب شعبه الزامی است' })
@MinLength(1, { message: 'انتخاب شعبه الزامی است' })
branchId?: string;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => SpecialOrderImageDto)
images?: SpecialOrderImageDto[];
}
export class CreateSpecialOrderDto extends SpecialOrderDetailsDto {
@IsString() @IsString()
@MinLength(1) @MinLength(1)
userId!: string; userId!: string;
@IsEnum(SpecialOrderCategory, { message: 'دسته‌بندی نامعتبر است' })
category!: SpecialOrderCategory;
@IsString()
@MinLength(1, { message: 'نام الزامی است' })
name!: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => SpecialOrderImageDto)
images?: SpecialOrderImageDto[];
} }
export class CreateMySpecialOrderDto { export class CreateMySpecialOrderDto extends SpecialOrderDetailsDto {}
@IsEnum(SpecialOrderCategory, { message: 'دسته‌بندی نامعتبر است' })
category!: SpecialOrderCategory;
@IsString()
@MinLength(1, { message: 'نام الزامی است' })
name!: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => SpecialOrderImageDto)
images?: SpecialOrderImageDto[];
}
@@ -1,6 +1,18 @@
import { IsArray, IsEnum, IsOptional, IsString, MinLength, ValidateNested } from 'class-validator'; import {
IsArray,
IsBoolean,
IsEnum,
IsInt,
IsISO8601,
IsOptional,
IsString,
Max,
Min,
MinLength,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { SpecialOrderCategory } from '@prisma/client'; import { DeliveryType, SpecialOrderCategory } from '@prisma/client';
import { SpecialOrderImageDto } from './create-special-order.dto'; import { SpecialOrderImageDto } from './create-special-order.dto';
export class UpdateSpecialOrderDto { export class UpdateSpecialOrderDto {
@@ -15,13 +27,65 @@ export class UpdateSpecialOrderDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@MinLength(1, { message: 'نام الزامی است' }) @MinLength(1, { message: 'مناسبت سفارش الزامی است' })
name?: string; name?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
description?: string; description?: string;
@IsOptional()
@IsISO8601({}, { message: 'تاریخ موردنیاز نامعتبر است' })
neededDate?: string | null;
@IsOptional()
@IsString()
receiveTime?: string;
@IsOptional()
@Type(() => Number)
@IsInt({ message: 'تعداد نفرات باید عدد باشد' })
@Min(1, { message: 'تعداد نفرات حداقل ۱ است' })
@Max(500, { message: 'تعداد نفرات بیش از حد مجاز است' })
servings?: number | null;
@IsOptional()
@IsString()
cakeFlavor?: string;
@IsOptional()
@IsString()
filling?: string;
@IsOptional()
@Type(() => Boolean)
@IsBoolean()
baloutSuggestedCombo?: boolean;
@IsOptional()
@IsString()
coating?: string;
@IsOptional()
@IsString()
designStyle?: string;
@IsOptional()
@IsString()
cakeText?: string;
@IsOptional()
@IsString()
decorations?: string;
@IsOptional()
@IsEnum(DeliveryType, { message: 'نوع دریافت نامعتبر است' })
deliveryType?: DeliveryType | null;
@IsOptional()
@IsString()
branchId?: string | null;
@IsOptional() @IsOptional()
@IsArray() @IsArray()
@ValidateNested({ each: true }) @ValidateNested({ each: true })
+201 -20
View File
@@ -1,10 +1,16 @@
import { import {
BadRequestException,
ConflictException, ConflictException,
Injectable, Injectable,
Logger, Logger,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { Prisma, SpecialOrderCategory, UserRole } from '@prisma/client'; import {
DeliveryType,
Prisma,
SpecialOrderCategory,
UserRole,
} from '@prisma/client';
import { displayName } from '../auth/auth.types'; import { displayName } from '../auth/auth.types';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { SmsService } from '../sms/sms.service'; import { SmsService } from '../sms/sms.service';
@@ -28,20 +34,28 @@ const specialOrderInclude = {
role: true, role: true,
}, },
}, },
branch: {
select: {
id: true,
name: true,
district: true,
address: true,
},
},
images: { images: {
orderBy: { sortOrder: 'asc' as const }, orderBy: { sortOrder: 'asc' as const },
}, },
} satisfies Prisma.SpecialOrderInclude; } satisfies Prisma.SpecialOrderInclude;
const DELIVERY_LABEL: Record<DeliveryType, string> = {
pickup: 'دریافت حضوری',
shipping: 'ارسال با پیک',
};
type SpecialOrderRow = Prisma.SpecialOrderGetPayload<{ type SpecialOrderRow = Prisma.SpecialOrderGetPayload<{
include: typeof specialOrderInclude; include: typeof specialOrderInclude;
}>; }>;
const CATEGORY_LABEL: Record<SpecialOrderCategory, string> = {
specialCake: 'کیک ویژه',
specialPastry: 'شیرینی ویژه',
};
@Injectable() @Injectable()
export class SpecialOrdersService { export class SpecialOrdersService {
private readonly logger = new Logger(SpecialOrdersService.name); private readonly logger = new Logger(SpecialOrdersService.name);
@@ -65,6 +79,17 @@ export class SpecialOrdersService {
OR: [ OR: [
{ name: { contains: q, mode: 'insensitive' as const } }, { name: { contains: q, mode: 'insensitive' as const } },
{ description: { 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: { user: {
OR: [ OR: [
@@ -123,12 +148,26 @@ export class SpecialOrdersService {
async create(dto: CreateSpecialOrderDto) { async create(dto: CreateSpecialOrderDto) {
await this.ensureUser(dto.userId); await this.ensureUser(dto.userId);
const details = await this.resolveDetails(dto);
const row = await this.prisma.specialOrder.create({ const row = await this.prisma.specialOrder.create({
data: { data: {
userId: dto.userId, userId: dto.userId,
category: dto.category, category: dto.category,
name: dto.name.trim(), name: dto.name.trim(),
description: dto.description?.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: { images: {
create: this.imageCreates(dto.images), create: this.imageCreates(dto.images),
}, },
@@ -162,10 +201,7 @@ export class SpecialOrdersService {
return this.create({ return this.create({
userId, userId,
category: dto.category, ...dto,
name: dto.name,
description: dto.description,
images: dto.images,
}); });
} }
@@ -173,6 +209,8 @@ export class SpecialOrdersService {
await this.ensureExists(id); await this.ensureExists(id);
if (dto.userId) await this.ensureUser(dto.userId); if (dto.userId) await this.ensureUser(dto.userId);
const details = await this.resolveDetails(dto, { partial: true });
const row = await this.prisma.$transaction(async (tx) => { const row = await this.prisma.$transaction(async (tx) => {
if (dto.images !== undefined) { if (dto.images !== undefined) {
await tx.specialOrderImage.deleteMany({ await tx.specialOrderImage.deleteMany({
@@ -184,11 +222,7 @@ export class SpecialOrdersService {
where: { id }, where: { id },
data: { data: {
...(dto.userId !== undefined ? { userId: dto.userId } : {}), ...(dto.userId !== undefined ? { userId: dto.userId } : {}),
...(dto.category !== undefined ? { category: dto.category } : {}), ...details,
...(dto.name !== undefined ? { name: dto.name.trim() } : {}),
...(dto.description !== undefined
? { description: dto.description.trim() }
: {}),
...(dto.images !== undefined ...(dto.images !== undefined
? { images: { create: this.imageCreates(dto.images) } } ? { images: { create: this.imageCreates(dto.images) } }
: {}), : {}),
@@ -207,12 +241,11 @@ export class SpecialOrdersService {
} }
private async notifyCreated(row: SpecialOrderRow) { private async notifyCreated(row: SpecialOrderRow) {
const categoryLabel = CATEGORY_LABEL[row.category]; const orderCode = `BL-S-${row.number}`;
const customerName = displayName(row.user);
const customerPhone = row.user.cellNumber; const customerPhone = row.user.cellNumber;
const userMessage = `بلوط: سفارش ویژه «${row.name}» (${categoryLabel}) ثبت شد. به‌زودی برای هماهنگی با شما تماس می‌گیریم.`; const userMessage = `سفارش شما به شماره ی${orderCode} ثبت شد\nاز شما سپاسگزاریم و به زودی با شما تماس خواهیم گرفت`;
const adminMessage = `بلوط: سفارش ویژه جدید\n${categoryLabel} — ${row.name}\nکاربر: ${customerName}\nموبایل: ${customerPhone}`; const adminMessage = `سفارش ویژه ی جدید به شماره سفارش ${orderCode}`;
try { try {
await this.sms.send({ to: customerPhone, message: userMessage }); await this.sms.send({ to: customerPhone, message: userMessage });
@@ -268,6 +301,128 @@ export class SpecialOrdersService {
})); }));
} }
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) { private async ensureUser(userId: string) {
const user = await this.prisma.user.findUnique({ const user = await this.prisma.user.findUnique({
where: { id: userId }, where: { id: userId },
@@ -287,10 +442,36 @@ export class SpecialOrdersService {
private serialize(row: SpecialOrderRow) { private serialize(row: SpecialOrderRow) {
return { return {
id: row.id, id: row.id,
number: row.number,
code: `BL-S-${row.number}`,
userId: row.userId, userId: row.userId,
category: row.category, category: row.category,
name: row.name, name: row.name,
description: row.description, 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(), createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(), updatedAt: row.updatedAt.toISOString(),
images: row.images.map((image) => ({ images: row.images.map((image) => ({