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
+5
-5
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
NestJS API for شیرینیفروشی بلوط. Serves the admin Dashboards SPA (and future customer apps).
|
NestJS API for شیرینیفروشی بلوط. Serves the admin Dashboards SPA (and future customer apps).
|
||||||
|
|
||||||
Repo: `https://git.meshkee.com/BaloutPastry/backend.git`
|
Repo: `https://gitea.meshkee.com/balout-pastry/backend.git`
|
||||||
Dashboards repo: `https://git.meshkee.com/BaloutPastry/dashboards.git`
|
Dashboards repo: `https://gitea.meshkee.com/balout-pastry/dashboards.git`
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ Dashboards repo: `https://git.meshkee.com/BaloutPastry/dashboards.git`
|
|||||||
## Setup on a new device
|
## Setup on a new device
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.meshkee.com/BaloutPastry/backend.git
|
git clone https://gitea.meshkee.com/balout-pastry/backend.git
|
||||||
cd backend
|
cd backend
|
||||||
|
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
@@ -191,6 +191,6 @@ Ensure Postgres is reachable via `DATABASE_URL` and `CORS_ORIGIN` lists the real
|
|||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
- Dashboards setup: clone `BaloutPastry/dashboards` and read `CONTEXT.md`
|
- Dashboards setup: clone `balout-pastry/dashboards` and read `CONTEXT.md`
|
||||||
- Website (storefront): clone `BaloutPastry/website` and read `CONTEXT.md` (dev port **5174**)
|
- Website (storefront): clone `balout-pastry/website` and read `CONTEXT.md` (dev port **5174**)
|
||||||
- Short API overview also in `README.md`
|
- Short API overview also in `README.md`
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -49,6 +49,7 @@ model User {
|
|||||||
refreshTokens RefreshToken[]
|
refreshTokens RefreshToken[]
|
||||||
addresses UserAddress[]
|
addresses UserAddress[]
|
||||||
orders Order[]
|
orders Order[]
|
||||||
|
specialOrders SpecialOrder[]
|
||||||
discounts Discount[] @relation("DiscountAssignee")
|
discounts Discount[] @relation("DiscountAssignee")
|
||||||
discountsCreated Discount[] @relation("DiscountCreatedBy")
|
discountsCreated Discount[] @relation("DiscountCreatedBy")
|
||||||
|
|
||||||
@@ -304,3 +305,36 @@ model Discount {
|
|||||||
@@index([categoryId])
|
@@index([categoryId])
|
||||||
@@index([active, expiresAt])
|
@@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])
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ async function main() {
|
|||||||
const password = arg('password');
|
const password = arg('password');
|
||||||
const firstName = arg('first', 'مدیر');
|
const firstName = arg('first', 'مدیر');
|
||||||
const lastName = arg('last', 'بلوط');
|
const lastName = arg('last', 'بلوط');
|
||||||
const title = arg('title', 'جناب آقای');
|
const title = arg('title', '');
|
||||||
|
|
||||||
if (!/^09\d{9}$/.test(cellNumber)) {
|
if (!/^09\d{9}$/.test(cellNumber)) {
|
||||||
throw new Error('phone must match 09xxxxxxxxx');
|
throw new Error('phone must match 09xxxxxxxxx');
|
||||||
@@ -51,7 +51,9 @@ async function main() {
|
|||||||
console.log('Created superAdmin:', {
|
console.log('Created superAdmin:', {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
cellNumber: user.cellNumber,
|
cellNumber: user.cellNumber,
|
||||||
name: `${user.title} ${user.firstName} ${user.lastName}`,
|
name: [user.title, user.firstName, user.lastName]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' '),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { PrismaModule } from './prisma/prisma.module';
|
|||||||
import { ProductsModule } from './products/products.module';
|
import { ProductsModule } from './products/products.module';
|
||||||
import { SettingsModule } from './settings/settings.module';
|
import { SettingsModule } from './settings/settings.module';
|
||||||
import { SmsModule } from './sms/sms.module';
|
import { SmsModule } from './sms/sms.module';
|
||||||
|
import { SpecialOrdersModule } from './special-orders/special-orders.module';
|
||||||
import { StorageModule } from './storage/storage.module';
|
import { StorageModule } from './storage/storage.module';
|
||||||
import { UsersModule } from './users/users.module';
|
import { UsersModule } from './users/users.module';
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ import { UsersModule } from './users/users.module';
|
|||||||
ProductsModule,
|
ProductsModule,
|
||||||
OrdersModule,
|
OrdersModule,
|
||||||
DiscountsModule,
|
DiscountsModule,
|
||||||
|
SpecialOrdersModule,
|
||||||
MediaModule,
|
MediaModule,
|
||||||
SettingsModule,
|
SettingsModule,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import { OtpVerifyDto } from './dto/otp-verify.dto';
|
|||||||
import { RegisterSendCodeDto } from './dto/register-send-code.dto';
|
import { RegisterSendCodeDto } from './dto/register-send-code.dto';
|
||||||
import { RegisterVerifyDto } from './dto/register-verify.dto';
|
import { RegisterVerifyDto } from './dto/register-verify.dto';
|
||||||
|
|
||||||
const DEFAULT_CUSTOMER_TITLE = 'جناب آقای';
|
|
||||||
const OTP_TTL_MS = 5 * 60 * 1000;
|
const OTP_TTL_MS = 5 * 60 * 1000;
|
||||||
const OTP_RESEND_COOLDOWN_MS = 60 * 1000;
|
const OTP_RESEND_COOLDOWN_MS = 60 * 1000;
|
||||||
const OTP_MAX_ATTEMPTS = 5;
|
const OTP_MAX_ATTEMPTS = 5;
|
||||||
@@ -155,7 +154,7 @@ export class AuthService {
|
|||||||
const payload: RegisterOtpPayload = {
|
const payload: RegisterOtpPayload = {
|
||||||
firstName: dto.firstName.trim(),
|
firstName: dto.firstName.trim(),
|
||||||
lastName: dto.lastName.trim(),
|
lastName: dto.lastName.trim(),
|
||||||
title: DEFAULT_CUSTOMER_TITLE,
|
title: '',
|
||||||
passwordHash,
|
passwordHash,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -176,12 +175,7 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const payload = otp.payload as RegisterOtpPayload | null;
|
const payload = otp.payload as RegisterOtpPayload | null;
|
||||||
if (
|
if (!payload?.firstName || !payload?.lastName || !payload?.passwordHash) {
|
||||||
!payload?.firstName ||
|
|
||||||
!payload?.lastName ||
|
|
||||||
!payload?.passwordHash ||
|
|
||||||
!payload?.title
|
|
||||||
) {
|
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'اطلاعات ثبتنام نامعتبر است. دوباره شروع کنید',
|
'اطلاعات ثبتنام نامعتبر است. دوباره شروع کنید',
|
||||||
);
|
);
|
||||||
@@ -197,7 +191,7 @@ export class AuthService {
|
|||||||
|
|
||||||
const user = await this.prisma.user.create({
|
const user = await this.prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
title: payload.title,
|
title: payload.title?.trim() ?? '',
|
||||||
firstName: payload.firstName,
|
firstName: payload.firstName,
|
||||||
lastName: payload.lastName,
|
lastName: payload.lastName,
|
||||||
cellNumber: dto.cellNumber,
|
cellNumber: dto.cellNumber,
|
||||||
|
|||||||
@@ -18,5 +18,8 @@ export function displayName(user: {
|
|||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
}): string {
|
}): string {
|
||||||
return `${user.title} ${user.firstName} ${user.lastName}`.trim();
|
return [user.title, user.firstName, user.lastName]
|
||||||
|
.map((part) => part?.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { IsIn } from 'class-validator';
|
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 type MediaKind = (typeof MEDIA_KINDS)[number];
|
||||||
|
|
||||||
export class MediaUploadQueryDto {
|
export class MediaUploadQueryDto {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
|
ForbiddenException,
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
UploadedFile,
|
UploadedFile,
|
||||||
@@ -9,6 +10,8 @@ import {
|
|||||||
import { FileInterceptor } from '@nestjs/platform-express';
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
import { UserRole } from '@prisma/client';
|
import { UserRole } from '@prisma/client';
|
||||||
import { memoryStorage } from 'multer';
|
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 { Roles } from '../auth/decorators/roles.decorator';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||||
@@ -17,7 +20,7 @@ import { MediaService } from './media.service';
|
|||||||
|
|
||||||
@Controller('media')
|
@Controller('media')
|
||||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
@Roles(UserRole.admin, UserRole.superAdmin)
|
@Roles(UserRole.customer, UserRole.admin, UserRole.superAdmin)
|
||||||
export class MediaController {
|
export class MediaController {
|
||||||
constructor(private readonly media: MediaService) {}
|
constructor(private readonly media: MediaService) {}
|
||||||
|
|
||||||
@@ -28,9 +31,15 @@ export class MediaController {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
upload(
|
upload(
|
||||||
|
@CurrentUser() actor: AuthUser,
|
||||||
@Query() query: MediaUploadQueryDto,
|
@Query() query: MediaUploadQueryDto,
|
||||||
@UploadedFile() file: Express.Multer.File,
|
@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);
|
return this.media.upload(query.kind, file);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { extname } from 'path';
|
|||||||
import {
|
import {
|
||||||
productGalleryKey,
|
productGalleryKey,
|
||||||
productMainKey,
|
productMainKey,
|
||||||
|
specialOrderMediaKey,
|
||||||
tempMediaKey,
|
tempMediaKey,
|
||||||
} from '../storage/storage-keys';
|
} from '../storage/storage-keys';
|
||||||
import { StorageService } from '../storage/storage.service';
|
import { StorageService } from '../storage/storage.service';
|
||||||
@@ -73,6 +74,8 @@ export class MediaService {
|
|||||||
return productMainKey(fileName);
|
return productMainKey(fileName);
|
||||||
case 'gallery':
|
case 'gallery':
|
||||||
return productGalleryKey(fileName);
|
return productGalleryKey(fileName);
|
||||||
|
case 'specialOrder':
|
||||||
|
return specialOrderMediaKey(fileName);
|
||||||
case 'temp':
|
case 'temp':
|
||||||
return tempMediaKey(fileName);
|
return tempMediaKey(fileName);
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
* categories/{uuid}{ext}
|
* categories/{uuid}{ext}
|
||||||
* branches/{uuid}{ext}
|
* branches/{uuid}{ext}
|
||||||
* users/{uuid}{ext}
|
* users/{uuid}{ext}
|
||||||
|
* special-orders/{uuid}{ext}
|
||||||
* temp/{uuid}{ext}
|
* temp/{uuid}{ext}
|
||||||
*/
|
*/
|
||||||
const ROOT = 'balout';
|
const ROOT = 'balout';
|
||||||
@@ -31,6 +32,10 @@ export function userMediaKey(fileName: string): string {
|
|||||||
return `${ROOT}/users/${fileName}`;
|
return `${ROOT}/users/${fileName}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function specialOrderMediaKey(fileName: string): string {
|
||||||
|
return `${ROOT}/special-orders/${fileName}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function tempMediaKey(fileName: string): string {
|
export function tempMediaKey(fileName: string): string {
|
||||||
return `${ROOT}/temp/${fileName}`;
|
return `${ROOT}/temp/${fileName}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ import {
|
|||||||
import { USER_CATEGORIES, USER_TITLES } from '../users.constants';
|
import { USER_CATEGORIES, USER_TITLES } from '../users.constants';
|
||||||
|
|
||||||
export class CreateUserDto {
|
export class CreateUserDto {
|
||||||
@IsIn(USER_TITLES, { message: 'عنوان نامعتبر است' })
|
@IsOptional()
|
||||||
title!: string;
|
@IsIn([...USER_TITLES, ''], { message: 'عنوان نامعتبر است' })
|
||||||
|
title?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(1)
|
@MinLength(1)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { USER_CATEGORIES, USER_TITLES } from '../users.constants';
|
|||||||
|
|
||||||
export class UpdateUserDto {
|
export class UpdateUserDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn(USER_TITLES, { message: 'عنوان نامعتبر است' })
|
@IsIn([...USER_TITLES, ''], { message: 'عنوان نامعتبر است' })
|
||||||
title?: string;
|
title?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export class UsersService {
|
|||||||
const passwordHash = await bcrypt.hash(dto.password, 10);
|
const passwordHash = await bcrypt.hash(dto.password, 10);
|
||||||
const user = await this.prisma.user.create({
|
const user = await this.prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
title: dto.title,
|
title: dto.title?.trim() ?? '',
|
||||||
firstName: dto.firstName,
|
firstName: dto.firstName,
|
||||||
lastName: dto.lastName,
|
lastName: dto.lastName,
|
||||||
category: dto.category,
|
category: dto.category,
|
||||||
|
|||||||
Reference in New Issue
Block a user