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 {
id String @id @default(cuid())
name String
district String
address String
landline String @default("")
cellNumber String @default("")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
orders Order[]
id String @id @default(cuid())
name String
district String
address String
landline String @default("")
cellNumber String @default("")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
orders Order[]
specialOrders SpecialOrder[]
}
model Order {
@@ -313,19 +314,36 @@ enum SpecialOrderCategory {
/// Custom request form (no payment / product link) — shown to admins.
model SpecialOrder {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
category SpecialOrderCategory
name String
description String @default("")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
images SpecialOrderImage[]
id String @id @default(cuid())
number Int @unique @default(autoincrement())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
category SpecialOrderCategory
/// مناسبت سفارش
name String
description String @default("")
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([category])
@@index([createdAt])
@@index([branchId])
}
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 { SpecialOrderCategory } from '@prisma/client';
import { DeliveryType, SpecialOrderCategory } from '@prisma/client';
export class SpecialOrderImageDto {
@IsString()
@@ -12,44 +25,82 @@ export class SpecialOrderImageDto {
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()
@MinLength(1)
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 {
@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 extends SpecialOrderDetailsDto {}
@@ -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 { SpecialOrderCategory } from '@prisma/client';
import { DeliveryType, SpecialOrderCategory } from '@prisma/client';
import { SpecialOrderImageDto } from './create-special-order.dto';
export class UpdateSpecialOrderDto {
@@ -15,13 +27,65 @@ export class UpdateSpecialOrderDto {
@IsOptional()
@IsString()
@MinLength(1, { message: 'نام الزامی است' })
@MinLength(1, { message: 'مناسبت سفارش الزامی است' })
name?: string;
@IsOptional()
@IsString()
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()
@IsArray()
@ValidateNested({ each: true })
+201 -20
View File
@@ -1,10 +1,16 @@
import {
BadRequestException,
ConflictException,
Injectable,
Logger,
NotFoundException,
} 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 { PrismaService } from '../prisma/prisma.service';
import { SmsService } from '../sms/sms.service';
@@ -28,20 +34,28 @@ const specialOrderInclude = {
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;
}>;
const CATEGORY_LABEL: Record<SpecialOrderCategory, string> = {
specialCake: 'کیک ویژه',
specialPastry: 'شیرینی ویژه',
};
@Injectable()
export class SpecialOrdersService {
private readonly logger = new Logger(SpecialOrdersService.name);
@@ -65,6 +79,17 @@ export class SpecialOrdersService {
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: [
@@ -123,12 +148,26 @@ export class SpecialOrdersService {
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: 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: {
create: this.imageCreates(dto.images),
},
@@ -162,10 +201,7 @@ export class SpecialOrdersService {
return this.create({
userId,
category: dto.category,
name: dto.name,
description: dto.description,
images: dto.images,
...dto,
});
}
@@ -173,6 +209,8 @@ export class SpecialOrdersService {
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({
@@ -184,11 +222,7 @@ export class SpecialOrdersService {
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() }
: {}),
...details,
...(dto.images !== undefined
? { images: { create: this.imageCreates(dto.images) } }
: {}),
@@ -207,12 +241,11 @@ export class SpecialOrdersService {
}
private async notifyCreated(row: SpecialOrderRow) {
const categoryLabel = CATEGORY_LABEL[row.category];
const customerName = displayName(row.user);
const orderCode = `BL-S-${row.number}`;
const customerPhone = row.user.cellNumber;
const userMessage = `بلوط: سفارش ویژه «${row.name}» (${categoryLabel}) ثبت شد. به‌زودی برای هماهنگی با شما تماس می‌گیریم.`;
const adminMessage = `بلوط: سفارش ویژه جدید\n${categoryLabel} — ${row.name}\nکاربر: ${customerName}\nموبایل: ${customerPhone}`;
const userMessage = `سفارش شما به شماره ی${orderCode} ثبت شد\nاز شما سپاسگزاریم و به زودی با شما تماس خواهیم گرفت`;
const adminMessage = `سفارش ویژه ی جدید به شماره سفارش ${orderCode}`;
try {
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) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
@@ -287,10 +442,36 @@ export class SpecialOrdersService {
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) => ({