diff --git a/CONTEXT.md b/CONTEXT.md index d14be90..bb54618 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -2,8 +2,8 @@ NestJS API for شیرینی‌فروشی بلوط. Serves the admin Dashboards SPA (and future customer apps). -Repo: `https://git.meshkee.com/BaloutPastry/backend.git` -Dashboards repo: `https://git.meshkee.com/BaloutPastry/dashboards.git` +Repo: `https://gitea.meshkee.com/balout-pastry/backend.git` +Dashboards repo: `https://gitea.meshkee.com/balout-pastry/dashboards.git` ## Stack @@ -22,7 +22,7 @@ Dashboards repo: `https://git.meshkee.com/BaloutPastry/dashboards.git` ## Setup on a new device ```bash -git clone https://git.meshkee.com/BaloutPastry/backend.git +git clone https://gitea.meshkee.com/balout-pastry/backend.git cd backend cp .env.example .env @@ -191,6 +191,6 @@ Ensure Postgres is reachable via `DATABASE_URL` and `CORS_ORIGIN` lists the real ## Related -- Dashboards setup: clone `BaloutPastry/dashboards` and read `CONTEXT.md` -- Website (storefront): clone `BaloutPastry/website` and read `CONTEXT.md` (dev port **5174**) +- Dashboards setup: clone `balout-pastry/dashboards` and read `CONTEXT.md` +- Website (storefront): clone `balout-pastry/website` and read `CONTEXT.md` (dev port **5174**) - Short API overview also in `README.md` diff --git a/prisma/migrations/20260924120000_special_orders/migration.sql b/prisma/migrations/20260924120000_special_orders/migration.sql new file mode 100644 index 0000000..c012458 --- /dev/null +++ b/prisma/migrations/20260924120000_special_orders/migration.sql @@ -0,0 +1,44 @@ +-- CreateEnum +CREATE TYPE "SpecialOrderCategory" AS ENUM ('specialCake', 'specialPastry'); + +-- CreateTable +CREATE TABLE "SpecialOrder" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "category" "SpecialOrderCategory" NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT NOT NULL DEFAULT '', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "SpecialOrder_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "SpecialOrderImage" ( + "id" TEXT NOT NULL, + "specialOrderId" TEXT NOT NULL, + "url" TEXT NOT NULL, + "storageKey" TEXT NOT NULL, + "sortOrder" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "SpecialOrderImage_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "SpecialOrder_userId_idx" ON "SpecialOrder"("userId"); + +-- CreateIndex +CREATE INDEX "SpecialOrder_category_idx" ON "SpecialOrder"("category"); + +-- CreateIndex +CREATE INDEX "SpecialOrder_createdAt_idx" ON "SpecialOrder"("createdAt"); + +-- CreateIndex +CREATE INDEX "SpecialOrderImage_specialOrderId_idx" ON "SpecialOrderImage"("specialOrderId"); + +-- AddForeignKey +ALTER TABLE "SpecialOrder" ADD CONSTRAINT "SpecialOrder_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SpecialOrderImage" ADD CONSTRAINT "SpecialOrderImage_specialOrderId_fkey" FOREIGN KEY ("specialOrderId") REFERENCES "SpecialOrder"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 915e8eb..62cb22f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -49,6 +49,7 @@ model User { refreshTokens RefreshToken[] addresses UserAddress[] orders Order[] + specialOrders SpecialOrder[] discounts Discount[] @relation("DiscountAssignee") discountsCreated Discount[] @relation("DiscountCreatedBy") @@ -304,3 +305,36 @@ model Discount { @@index([categoryId]) @@index([active, expiresAt]) } + +enum SpecialOrderCategory { + specialCake + specialPastry +} + +/// 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[] + + @@index([userId]) + @@index([category]) + @@index([createdAt]) +} + +model SpecialOrderImage { + id String @id @default(cuid()) + specialOrderId String + specialOrder SpecialOrder @relation(fields: [specialOrderId], references: [id], onDelete: Cascade) + url String + storageKey String + sortOrder Int @default(0) + + @@index([specialOrderId]) +} diff --git a/scripts/create-super-admin.ts b/scripts/create-super-admin.ts index 50bbda2..9865cb8 100644 --- a/scripts/create-super-admin.ts +++ b/scripts/create-super-admin.ts @@ -22,7 +22,7 @@ async function main() { const password = arg('password'); const firstName = arg('first', 'مدیر'); const lastName = arg('last', 'بلوط'); - const title = arg('title', 'جناب آقای'); + const title = arg('title', ''); if (!/^09\d{9}$/.test(cellNumber)) { throw new Error('phone must match 09xxxxxxxxx'); @@ -51,7 +51,9 @@ async function main() { console.log('Created superAdmin:', { id: user.id, cellNumber: user.cellNumber, - name: `${user.title} ${user.firstName} ${user.lastName}`, + name: [user.title, user.firstName, user.lastName] + .filter(Boolean) + .join(' '), }); } diff --git a/src/app.module.ts b/src/app.module.ts index 709a7cb..a5b2e45 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -10,6 +10,7 @@ import { PrismaModule } from './prisma/prisma.module'; import { ProductsModule } from './products/products.module'; import { SettingsModule } from './settings/settings.module'; import { SmsModule } from './sms/sms.module'; +import { SpecialOrdersModule } from './special-orders/special-orders.module'; import { StorageModule } from './storage/storage.module'; import { UsersModule } from './users/users.module'; @@ -26,6 +27,7 @@ import { UsersModule } from './users/users.module'; ProductsModule, OrdersModule, DiscountsModule, + SpecialOrdersModule, MediaModule, SettingsModule, ], diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 216da38..26f881b 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -20,7 +20,6 @@ import { OtpVerifyDto } from './dto/otp-verify.dto'; import { RegisterSendCodeDto } from './dto/register-send-code.dto'; import { RegisterVerifyDto } from './dto/register-verify.dto'; -const DEFAULT_CUSTOMER_TITLE = 'جناب آقای'; const OTP_TTL_MS = 5 * 60 * 1000; const OTP_RESEND_COOLDOWN_MS = 60 * 1000; const OTP_MAX_ATTEMPTS = 5; @@ -155,7 +154,7 @@ export class AuthService { const payload: RegisterOtpPayload = { firstName: dto.firstName.trim(), lastName: dto.lastName.trim(), - title: DEFAULT_CUSTOMER_TITLE, + title: '', passwordHash, }; @@ -176,12 +175,7 @@ export class AuthService { }); const payload = otp.payload as RegisterOtpPayload | null; - if ( - !payload?.firstName || - !payload?.lastName || - !payload?.passwordHash || - !payload?.title - ) { + if (!payload?.firstName || !payload?.lastName || !payload?.passwordHash) { throw new BadRequestException( 'اطلاعات ثبت‌نام نامعتبر است. دوباره شروع کنید', ); @@ -197,7 +191,7 @@ export class AuthService { const user = await this.prisma.user.create({ data: { - title: payload.title, + title: payload.title?.trim() ?? '', firstName: payload.firstName, lastName: payload.lastName, cellNumber: dto.cellNumber, diff --git a/src/auth/auth.types.ts b/src/auth/auth.types.ts index 4dd905a..e470dd1 100644 --- a/src/auth/auth.types.ts +++ b/src/auth/auth.types.ts @@ -18,5 +18,8 @@ export function displayName(user: { firstName: string; lastName: string; }): string { - return `${user.title} ${user.firstName} ${user.lastName}`.trim(); + return [user.title, user.firstName, user.lastName] + .map((part) => part?.trim()) + .filter(Boolean) + .join(' '); } diff --git a/src/media/dto/media-upload-query.dto.ts b/src/media/dto/media-upload-query.dto.ts index 5e36592..16d8c3f 100644 --- a/src/media/dto/media-upload-query.dto.ts +++ b/src/media/dto/media-upload-query.dto.ts @@ -1,6 +1,6 @@ import { IsIn } from 'class-validator'; -export const MEDIA_KINDS = ['main', 'gallery', 'temp'] as const; +export const MEDIA_KINDS = ['main', 'gallery', 'temp', 'specialOrder'] as const; export type MediaKind = (typeof MEDIA_KINDS)[number]; export class MediaUploadQueryDto { diff --git a/src/media/media.controller.ts b/src/media/media.controller.ts index d9742e9..efc60a4 100644 --- a/src/media/media.controller.ts +++ b/src/media/media.controller.ts @@ -1,5 +1,6 @@ import { Controller, + ForbiddenException, Post, Query, UploadedFile, @@ -9,6 +10,8 @@ import { import { FileInterceptor } from '@nestjs/platform-express'; import { UserRole } from '@prisma/client'; import { memoryStorage } from 'multer'; +import type { AuthUser } from '../auth/auth.types'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { Roles } from '../auth/decorators/roles.decorator'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { RolesGuard } from '../auth/guards/roles.guard'; @@ -17,7 +20,7 @@ import { MediaService } from './media.service'; @Controller('media') @UseGuards(JwtAuthGuard, RolesGuard) -@Roles(UserRole.admin, UserRole.superAdmin) +@Roles(UserRole.customer, UserRole.admin, UserRole.superAdmin) export class MediaController { constructor(private readonly media: MediaService) {} @@ -28,9 +31,15 @@ export class MediaController { }), ) upload( + @CurrentUser() actor: AuthUser, @Query() query: MediaUploadQueryDto, @UploadedFile() file: Express.Multer.File, ) { + const isElevated = + actor.role === UserRole.admin || actor.role === UserRole.superAdmin; + if (!isElevated && query.kind !== 'specialOrder' && query.kind !== 'temp') { + throw new ForbiddenException('اجازه آپلود این نوع فایل را ندارید'); + } return this.media.upload(query.kind, file); } } diff --git a/src/media/media.service.ts b/src/media/media.service.ts index 76cafaf..9e0c9dd 100644 --- a/src/media/media.service.ts +++ b/src/media/media.service.ts @@ -9,6 +9,7 @@ import { extname } from 'path'; import { productGalleryKey, productMainKey, + specialOrderMediaKey, tempMediaKey, } from '../storage/storage-keys'; import { StorageService } from '../storage/storage.service'; @@ -73,6 +74,8 @@ export class MediaService { return productMainKey(fileName); case 'gallery': return productGalleryKey(fileName); + case 'specialOrder': + return specialOrderMediaKey(fileName); case 'temp': return tempMediaKey(fileName); default: diff --git a/src/special-orders/dto/create-special-order.dto.ts b/src/special-orders/dto/create-special-order.dto.ts new file mode 100644 index 0000000..6c18adf --- /dev/null +++ b/src/special-orders/dto/create-special-order.dto.ts @@ -0,0 +1,55 @@ +import { IsArray, IsEnum, IsOptional, IsString, MinLength, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; +import { SpecialOrderCategory } from '@prisma/client'; + +export class SpecialOrderImageDto { + @IsString() + @MinLength(1) + url!: string; + + @IsString() + @MinLength(1) + storageKey!: string; +} + +export class CreateSpecialOrderDto { + @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[]; +} diff --git a/src/special-orders/dto/list-special-orders-query.dto.ts b/src/special-orders/dto/list-special-orders-query.dto.ts new file mode 100644 index 0000000..3ef575e --- /dev/null +++ b/src/special-orders/dto/list-special-orders-query.dto.ts @@ -0,0 +1,30 @@ +import { Type } from 'class-transformer'; +import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; +import { SpecialOrderCategory } from '@prisma/client'; + +export class ListSpecialOrdersQueryDto { + @IsOptional() + @IsString() + q?: string; + + @IsOptional() + @IsString() + userId?: string; + + @IsOptional() + @IsEnum(SpecialOrderCategory) + category?: SpecialOrderCategory; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + pageSize?: number; +} diff --git a/src/special-orders/dto/update-special-order.dto.ts b/src/special-orders/dto/update-special-order.dto.ts new file mode 100644 index 0000000..8169ff2 --- /dev/null +++ b/src/special-orders/dto/update-special-order.dto.ts @@ -0,0 +1,30 @@ +import { IsArray, IsEnum, IsOptional, IsString, MinLength, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; +import { SpecialOrderCategory } from '@prisma/client'; +import { SpecialOrderImageDto } from './create-special-order.dto'; + +export class UpdateSpecialOrderDto { + @IsOptional() + @IsString() + @MinLength(1) + userId?: string; + + @IsOptional() + @IsEnum(SpecialOrderCategory, { message: 'دسته‌بندی نامعتبر است' }) + category?: SpecialOrderCategory; + + @IsOptional() + @IsString() + @MinLength(1, { message: 'نام الزامی است' }) + name?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => SpecialOrderImageDto) + images?: SpecialOrderImageDto[]; +} diff --git a/src/special-orders/special-orders.controller.ts b/src/special-orders/special-orders.controller.ts new file mode 100644 index 0000000..3f6d4be --- /dev/null +++ b/src/special-orders/special-orders.controller.ts @@ -0,0 +1,78 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { UserRole } from '@prisma/client'; +import type { AuthUser } from '../auth/auth.types'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { + CreateMySpecialOrderDto, + CreateSpecialOrderDto, +} from './dto/create-special-order.dto'; +import { ListSpecialOrdersQueryDto } from './dto/list-special-orders-query.dto'; +import { UpdateSpecialOrderDto } from './dto/update-special-order.dto'; +import { SpecialOrdersService } from './special-orders.service'; + +@Controller('special-orders') +@UseGuards(JwtAuthGuard, RolesGuard) +export class SpecialOrdersController { + constructor(private readonly specialOrders: SpecialOrdersService) {} + + @Get() + @Roles(UserRole.admin, UserRole.superAdmin) + list(@Query() query: ListSpecialOrdersQueryDto) { + return this.specialOrders.list(query); + } + + @Get('mine') + @Roles(UserRole.customer, UserRole.admin, UserRole.superAdmin) + listMine( + @CurrentUser() actor: AuthUser, + @Query() query: ListSpecialOrdersQueryDto, + ) { + return this.specialOrders.listMine(actor.id, query); + } + + @Post('mine') + @Roles(UserRole.customer, UserRole.admin, UserRole.superAdmin) + createMine( + @CurrentUser() actor: AuthUser, + @Body() dto: CreateMySpecialOrderDto, + ) { + return this.specialOrders.createMine(actor.id, dto); + } + + @Get(':id') + @Roles(UserRole.admin, UserRole.superAdmin) + findOne(@Param('id') id: string) { + return this.specialOrders.findOne(id); + } + + @Post() + @Roles(UserRole.admin, UserRole.superAdmin) + create(@Body() dto: CreateSpecialOrderDto) { + return this.specialOrders.create(dto); + } + + @Patch(':id') + @Roles(UserRole.admin, UserRole.superAdmin) + update(@Param('id') id: string, @Body() dto: UpdateSpecialOrderDto) { + return this.specialOrders.update(id, dto); + } + + @Delete(':id') + @Roles(UserRole.admin, UserRole.superAdmin) + remove(@Param('id') id: string) { + return this.specialOrders.remove(id); + } +} diff --git a/src/special-orders/special-orders.module.ts b/src/special-orders/special-orders.module.ts new file mode 100644 index 0000000..e2a2aa7 --- /dev/null +++ b/src/special-orders/special-orders.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { SmsModule } from '../sms/sms.module'; +import { SpecialOrdersController } from './special-orders.controller'; +import { SpecialOrdersService } from './special-orders.service'; + +@Module({ + imports: [SmsModule], + controllers: [SpecialOrdersController], + providers: [SpecialOrdersService], + exports: [SpecialOrdersService], +}) +export class SpecialOrdersModule {} diff --git a/src/special-orders/special-orders.service.ts b/src/special-orders/special-orders.service.ts new file mode 100644 index 0000000..c478886 --- /dev/null +++ b/src/special-orders/special-orders.service.ts @@ -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 = { + 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), + }, + }; + } +} diff --git a/src/storage/storage-keys.ts b/src/storage/storage-keys.ts index 92a97db..61b1581 100644 --- a/src/storage/storage-keys.ts +++ b/src/storage/storage-keys.ts @@ -7,6 +7,7 @@ * categories/{uuid}{ext} * branches/{uuid}{ext} * users/{uuid}{ext} + * special-orders/{uuid}{ext} * temp/{uuid}{ext} */ const ROOT = 'balout'; @@ -31,6 +32,10 @@ export function userMediaKey(fileName: string): string { return `${ROOT}/users/${fileName}`; } +export function specialOrderMediaKey(fileName: string): string { + return `${ROOT}/special-orders/${fileName}`; +} + export function tempMediaKey(fileName: string): string { return `${ROOT}/temp/${fileName}`; } diff --git a/src/users/dto/create-user.dto.ts b/src/users/dto/create-user.dto.ts index 1424541..e29efe7 100644 --- a/src/users/dto/create-user.dto.ts +++ b/src/users/dto/create-user.dto.ts @@ -11,8 +11,9 @@ import { import { USER_CATEGORIES, USER_TITLES } from '../users.constants'; export class CreateUserDto { - @IsIn(USER_TITLES, { message: 'عنوان نامعتبر است' }) - title!: string; + @IsOptional() + @IsIn([...USER_TITLES, ''], { message: 'عنوان نامعتبر است' }) + title?: string; @IsString() @MinLength(1) diff --git a/src/users/dto/update-user.dto.ts b/src/users/dto/update-user.dto.ts index 616ad8d..e37b278 100644 --- a/src/users/dto/update-user.dto.ts +++ b/src/users/dto/update-user.dto.ts @@ -10,7 +10,7 @@ import { USER_CATEGORIES, USER_TITLES } from '../users.constants'; export class UpdateUserDto { @IsOptional() - @IsIn(USER_TITLES, { message: 'عنوان نامعتبر است' }) + @IsIn([...USER_TITLES, ''], { message: 'عنوان نامعتبر است' }) title?: string; @IsOptional() diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 1d912ae..9d97b13 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -81,7 +81,7 @@ export class UsersService { const passwordHash = await bcrypt.hash(dto.password, 10); const user = await this.prisma.user.create({ data: { - title: dto.title, + title: dto.title?.trim() ?? '', firstName: dto.firstName, lastName: dto.lastName, category: dto.category,