mirror of
https://git.meshkee.com/BaloutPastry/backend.git
synced 2026-08-11 22:31:00 +04:30
Initial commit of Balout Pastry NestJS API.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,30 @@
|
|||||||
|
# PostgreSQL (Docker Compose)
|
||||||
|
POSTGRES_HOST=localhost
|
||||||
|
POSTGRES_PORT=5434
|
||||||
|
POSTGRES_USER=balout
|
||||||
|
POSTGRES_PASSWORD=balout_secret
|
||||||
|
POSTGRES_DB=balout_pastry
|
||||||
|
|
||||||
|
DATABASE_URL=postgresql://balout:balout_secret@localhost:5434/balout_pastry
|
||||||
|
|
||||||
|
# API
|
||||||
|
PORT=3100
|
||||||
|
CORS_ORIGIN=http://localhost:5173
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
JWT_ACCESS_SECRET=change-me-balout-access-secret-min-32-chars
|
||||||
|
JWT_REFRESH_SECRET=change-me-balout-refresh-secret-min-32-chars
|
||||||
|
JWT_ACCESS_EXPIRES_IN=15m
|
||||||
|
JWT_REFRESH_EXPIRES_IN=7d
|
||||||
|
|
||||||
|
# Object storage (Parspack / S3-compatible) — same bucket as Meshkee, prefix balout/
|
||||||
|
STORAGE_DISK=s3
|
||||||
|
S3_ENDPOINT=https://c804387.parspack.net
|
||||||
|
S3_BUCKET=c804387
|
||||||
|
S3_PUBLIC_URL=https://c804387.parspack.net/c804387
|
||||||
|
S3_REGION=us-east-1
|
||||||
|
S3_FORCE_PATH_STYLE=true
|
||||||
|
S3_ACCESS_KEY_ID=
|
||||||
|
S3_SECRET_ACCESS_KEY=
|
||||||
|
|
||||||
|
MEDIA_MAX_FILE_SIZE_MB=10
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
# compiled output
|
||||||
|
/dist
|
||||||
|
/node_modules
|
||||||
|
/build
|
||||||
|
|
||||||
|
# logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# tests
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# IDEs
|
||||||
|
.idea
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
|
||||||
|
# env
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# prisma
|
||||||
|
/generated
|
||||||
|
|
||||||
|
*.pem
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all"
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Balout Pastry API
|
||||||
|
|
||||||
|
NestJS + Prisma + PostgreSQL + JWT admin API for شیرینیفروشی بلوط.
|
||||||
|
|
||||||
|
Lives in `Balout Pastry/Backend` next to `Dashboards/`. Not wired to the UI yet.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- NestJS 11 / TypeScript
|
||||||
|
- Prisma 6 + PostgreSQL 16 (Docker Compose)
|
||||||
|
- JWT access + refresh tokens
|
||||||
|
- Parspack S3 (same credentials as Meshkee, prefix `balout/`)
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
From this folder (`Backend/`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
npm install
|
||||||
|
npm run db:up
|
||||||
|
npx prisma migrate deploy
|
||||||
|
npm run create-super-admin -- --phone 09120000000 --password secret123
|
||||||
|
npm run start:dev # http://localhost:3100/api/v1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Auth rules
|
||||||
|
|
||||||
|
- Only `admin` and `superAdmin` can log in
|
||||||
|
- Only `superAdmin` can assign `admin` or `superAdmin` roles
|
||||||
|
- Customers exist for a future customer dashboard
|
||||||
|
|
||||||
|
## Main routes (`/api/v1`)
|
||||||
|
|
||||||
|
| Area | Methods |
|
||||||
|
|------|---------|
|
||||||
|
| Auth | `POST /auth/login`, `/auth/refresh`, `/auth/logout`, `GET /auth/me` |
|
||||||
|
| Users | CRUD + `PATCH /users/:id/role`, `/password` + addresses under `/users/:id/addresses` |
|
||||||
|
| Flavors | CRUD |
|
||||||
|
| Categories | tree CRUD + `GET|PUT /categories/:id/options` |
|
||||||
|
| Products | CRUD (options must match category templates); list filters `q`, `categoryId`, `minPrice`, `maxPrice` |
|
||||||
|
| Orders | `GET /orders`, `GET /orders/:id`, `POST /orders`, `PATCH /orders/:id/status` |
|
||||||
|
| Media | `POST /media/upload?kind=main\|gallery\|temp` |
|
||||||
|
| Settings | districts, shipping exceptions, branches |
|
||||||
|
|
||||||
|
## Orders
|
||||||
|
|
||||||
|
Create body:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"customerId": "...",
|
||||||
|
"deliveryType": "pickup",
|
||||||
|
"branchId": "...",
|
||||||
|
"note": "optional",
|
||||||
|
"items": [
|
||||||
|
{ "productId": "...", "quantity": 2, "optionIds": ["..."] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For shipping, use `deliveryType: "shipping"` and `shippingAddressId` (from user addresses). Prices and names are snapshotted at create time. Display code is `BL-{number}`.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Prices are integer **تومان**
|
||||||
|
- Port **3100**; Postgres **5434** (Meshkee uses 3000 / 5432)
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: balout-postgres
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${POSTGRES_PORT:-5434}:5432"
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-balout}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-balout_secret}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-balout_pastry}
|
||||||
|
volumes:
|
||||||
|
- balout_postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD-SHELL",
|
||||||
|
"pg_isready -U ${POSTGRES_USER:-balout} -d ${POSTGRES_DB:-balout_pastry}",
|
||||||
|
]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
balout_postgres_data:
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// @ts-check
|
||||||
|
import eslint from '@eslint/js';
|
||||||
|
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
||||||
|
import globals from 'globals';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{
|
||||||
|
ignores: ['eslint.config.mjs'],
|
||||||
|
},
|
||||||
|
eslint.configs.recommended,
|
||||||
|
...tseslint.configs.recommendedTypeChecked,
|
||||||
|
eslintPluginPrettierRecommended,
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...globals.node,
|
||||||
|
...globals.jest,
|
||||||
|
},
|
||||||
|
sourceType: 'commonjs',
|
||||||
|
parserOptions: {
|
||||||
|
projectService: true,
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
|
'@typescript-eslint/no-floating-promises': 'warn',
|
||||||
|
'@typescript-eslint/no-unsafe-argument': 'warn',
|
||||||
|
"prettier/prettier": ["error", { endOfLine: "auto" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+11346
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,91 @@
|
|||||||
|
{
|
||||||
|
"name": "balout-pastry-api",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "",
|
||||||
|
"author": "",
|
||||||
|
"private": true,
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"scripts": {
|
||||||
|
"build": "nest build",
|
||||||
|
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||||
|
"start": "nest start",
|
||||||
|
"start:dev": "nest start --watch",
|
||||||
|
"start:debug": "nest start --debug --watch",
|
||||||
|
"start:prod": "node dist/main",
|
||||||
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
|
"test": "jest",
|
||||||
|
"test:watch": "jest --watch",
|
||||||
|
"test:cov": "jest --coverage",
|
||||||
|
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||||
|
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||||
|
"prisma:generate": "prisma generate",
|
||||||
|
"prisma:migrate": "prisma migrate dev",
|
||||||
|
"prisma:deploy": "prisma migrate deploy",
|
||||||
|
"db:up": "docker compose up -d",
|
||||||
|
"db:down": "docker compose down",
|
||||||
|
"create-super-admin": "ts-node -r tsconfig-paths/register scripts/create-super-admin.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.1101.0",
|
||||||
|
"@nestjs/common": "^11.0.1",
|
||||||
|
"@nestjs/config": "^4.0.4",
|
||||||
|
"@nestjs/core": "^11.0.1",
|
||||||
|
"@nestjs/jwt": "^11.0.2",
|
||||||
|
"@nestjs/passport": "^11.0.5",
|
||||||
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
|
"@prisma/client": "^6.19.3",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.15.1",
|
||||||
|
"passport": "^0.7.0",
|
||||||
|
"passport-jwt": "^4.0.1",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"rxjs": "^7.8.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
|
"@eslint/js": "^9.18.0",
|
||||||
|
"@nestjs/cli": "^11.0.0",
|
||||||
|
"@nestjs/schematics": "^11.0.0",
|
||||||
|
"@nestjs/testing": "^11.0.1",
|
||||||
|
"@types/bcrypt": "^6.0.0",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/jest": "^30.0.0",
|
||||||
|
"@types/multer": "^2.2.0",
|
||||||
|
"@types/node": "^24.0.0",
|
||||||
|
"@types/passport-jwt": "^4.0.1",
|
||||||
|
"@types/supertest": "^7.0.0",
|
||||||
|
"eslint": "^9.18.0",
|
||||||
|
"eslint-config-prettier": "^10.0.1",
|
||||||
|
"eslint-plugin-prettier": "^5.2.2",
|
||||||
|
"globals": "^17.0.0",
|
||||||
|
"jest": "^30.0.0",
|
||||||
|
"prettier": "^3.4.2",
|
||||||
|
"prisma": "^6.19.3",
|
||||||
|
"source-map-support": "^0.5.21",
|
||||||
|
"supertest": "^7.0.0",
|
||||||
|
"ts-jest": "^29.2.5",
|
||||||
|
"ts-loader": "^9.5.2",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"tsconfig-paths": "^4.2.0",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"typescript-eslint": "^8.20.0"
|
||||||
|
},
|
||||||
|
"jest": {
|
||||||
|
"moduleFileExtensions": [
|
||||||
|
"js",
|
||||||
|
"json",
|
||||||
|
"ts"
|
||||||
|
],
|
||||||
|
"rootDir": "src",
|
||||||
|
"testRegex": ".*\\.spec\\.ts$",
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
},
|
||||||
|
"collectCoverageFrom": [
|
||||||
|
"**/*.(t|j)s"
|
||||||
|
],
|
||||||
|
"coverageDirectory": "../coverage",
|
||||||
|
"testEnvironment": "node"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
-- CreateSchema
|
||||||
|
CREATE SCHEMA IF NOT EXISTS "public";
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "UserRole" AS ENUM ('customer', 'admin', 'superAdmin');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "SellUnit" AS ENUM ('unit', 'kilo');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "User" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"firstName" TEXT NOT NULL,
|
||||||
|
"lastName" TEXT NOT NULL,
|
||||||
|
"category" TEXT,
|
||||||
|
"cellNumber" TEXT NOT NULL,
|
||||||
|
"passwordHash" TEXT NOT NULL,
|
||||||
|
"role" "UserRole" NOT NULL DEFAULT 'customer',
|
||||||
|
"disabled" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"totalOrders" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"totalTransaction" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "RefreshToken" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tokenHash" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "RefreshToken_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Flavor" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"nameFa" TEXT NOT NULL,
|
||||||
|
"nameEn" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Flavor_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Category" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"nameFa" TEXT NOT NULL,
|
||||||
|
"nameEn" TEXT NOT NULL,
|
||||||
|
"parentId" TEXT,
|
||||||
|
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Category_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "CategoryOptionBlock" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"categoryId" TEXT NOT NULL,
|
||||||
|
"flavorId" TEXT NOT NULL,
|
||||||
|
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
CONSTRAINT "CategoryOptionBlock_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "CategoryOptionEntry" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"blockId" TEXT NOT NULL,
|
||||||
|
"amount" TEXT NOT NULL,
|
||||||
|
"price" INTEGER NOT NULL,
|
||||||
|
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
CONSTRAINT "CategoryOptionEntry_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Product" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"nameFa" TEXT NOT NULL,
|
||||||
|
"nameEn" TEXT NOT NULL,
|
||||||
|
"price" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"sellUnit" "SellUnit" NOT NULL DEFAULT 'unit',
|
||||||
|
"categoryId" TEXT NOT NULL,
|
||||||
|
"intro" TEXT NOT NULL DEFAULT '',
|
||||||
|
"description" TEXT NOT NULL DEFAULT '',
|
||||||
|
"mainImageUrl" TEXT,
|
||||||
|
"mainImageKey" TEXT,
|
||||||
|
"tags" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Product_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ProductGalleryImage" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"productId" TEXT NOT NULL,
|
||||||
|
"url" TEXT NOT NULL,
|
||||||
|
"storageKey" TEXT NOT NULL,
|
||||||
|
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
CONSTRAINT "ProductGalleryImage_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ProductOption" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"productId" TEXT NOT NULL,
|
||||||
|
"flavorId" TEXT NOT NULL,
|
||||||
|
"amount" TEXT NOT NULL,
|
||||||
|
"price" INTEGER NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "ProductOption_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ShippingException" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"district" TEXT NOT NULL,
|
||||||
|
"price" INTEGER NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "ShippingException_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Branch" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"district" TEXT NOT NULL,
|
||||||
|
"address" TEXT NOT NULL,
|
||||||
|
"landline" TEXT NOT NULL DEFAULT '',
|
||||||
|
"cellNumber" TEXT NOT NULL DEFAULT '',
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Branch_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_cellNumber_key" ON "User"("cellNumber");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "User_role_idx" ON "User"("role");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "User_disabled_idx" ON "User"("disabled");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "RefreshToken_userId_idx" ON "RefreshToken"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "RefreshToken_tokenHash_idx" ON "RefreshToken"("tokenHash");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Category_parentId_idx" ON "Category"("parentId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "CategoryOptionBlock_categoryId_idx" ON "CategoryOptionBlock"("categoryId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "CategoryOptionBlock_flavorId_idx" ON "CategoryOptionBlock"("flavorId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "CategoryOptionEntry_blockId_idx" ON "CategoryOptionEntry"("blockId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Product_categoryId_idx" ON "Product"("categoryId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ProductGalleryImage_productId_idx" ON "ProductGalleryImage"("productId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ProductOption_productId_idx" ON "ProductOption"("productId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ProductOption_flavorId_idx" ON "ProductOption"("flavorId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ShippingException_district_key" ON "ShippingException"("district");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "RefreshToken" ADD CONSTRAINT "RefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Category" ADD CONSTRAINT "Category_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "Category"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "CategoryOptionBlock" ADD CONSTRAINT "CategoryOptionBlock_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "CategoryOptionBlock" ADD CONSTRAINT "CategoryOptionBlock_flavorId_fkey" FOREIGN KEY ("flavorId") REFERENCES "Flavor"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "CategoryOptionEntry" ADD CONSTRAINT "CategoryOptionEntry_blockId_fkey" FOREIGN KEY ("blockId") REFERENCES "CategoryOptionBlock"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Product" ADD CONSTRAINT "Product_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ProductGalleryImage" ADD CONSTRAINT "ProductGalleryImage_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ProductOption" ADD CONSTRAINT "ProductOption_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ProductOption" ADD CONSTRAINT "ProductOption_flavorId_fkey" FOREIGN KEY ("flavorId") REFERENCES "Flavor"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "OrderStatus" AS ENUM ('pending', 'confirmed', 'preparing', 'ready', 'delivered', 'cancelled');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "DeliveryType" AS ENUM ('shipping', 'pickup');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
CREATE INDEX "Product_price_idx" ON "Product"("price");
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "UserAddress" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"district" TEXT NOT NULL,
|
||||||
|
"address" TEXT NOT NULL,
|
||||||
|
"landline" TEXT NOT NULL DEFAULT '',
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "UserAddress_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Order" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"number" SERIAL NOT NULL,
|
||||||
|
"customerId" TEXT NOT NULL,
|
||||||
|
"customerName" TEXT NOT NULL,
|
||||||
|
"customerPhone" TEXT NOT NULL,
|
||||||
|
"status" "OrderStatus" NOT NULL DEFAULT 'pending',
|
||||||
|
"deliveryType" "DeliveryType" NOT NULL,
|
||||||
|
"branchId" TEXT,
|
||||||
|
"branchName" TEXT,
|
||||||
|
"shippingAddressId" TEXT,
|
||||||
|
"shippingName" TEXT,
|
||||||
|
"shippingDistrict" TEXT,
|
||||||
|
"shippingAddressLine" TEXT,
|
||||||
|
"shippingLandline" TEXT,
|
||||||
|
"note" TEXT NOT NULL DEFAULT '',
|
||||||
|
"itemCount" INTEGER NOT NULL,
|
||||||
|
"totalPrice" INTEGER NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Order_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "OrderItem" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"orderId" TEXT NOT NULL,
|
||||||
|
"productId" TEXT,
|
||||||
|
"nameFa" TEXT NOT NULL,
|
||||||
|
"quantity" INTEGER NOT NULL,
|
||||||
|
"unitPrice" INTEGER NOT NULL,
|
||||||
|
"sellUnit" "SellUnit" NOT NULL,
|
||||||
|
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
CONSTRAINT "OrderItem_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "OrderItemOption" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"orderItemId" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"price" INTEGER NOT NULL,
|
||||||
|
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
CONSTRAINT "OrderItemOption_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "UserAddress_userId_idx" ON "UserAddress"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Order_number_key" ON "Order"("number");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Order_customerId_idx" ON "Order"("customerId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Order_status_idx" ON "Order"("status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Order_createdAt_idx" ON "Order"("createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Order_branchId_idx" ON "Order"("branchId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "OrderItem_orderId_idx" ON "OrderItem"("orderId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "OrderItem_productId_idx" ON "OrderItem"("productId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "OrderItemOption_orderItemId_idx" ON "OrderItemOption"("orderItemId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "UserAddress" ADD CONSTRAINT "UserAddress_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Order" ADD CONSTRAINT "Order_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Order" ADD CONSTRAINT "Order_branchId_fkey" FOREIGN KEY ("branchId") REFERENCES "Branch"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "OrderItem" ADD CONSTRAINT "OrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "OrderItem" ADD CONSTRAINT "OrderItem_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "OrderItemOption" ADD CONSTRAINT "OrderItemOption_orderItemId_fkey" FOREIGN KEY ("orderItemId") REFERENCES "OrderItem"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
provider = "postgresql"
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum UserRole {
|
||||||
|
customer
|
||||||
|
admin
|
||||||
|
superAdmin
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SellUnit {
|
||||||
|
unit
|
||||||
|
kilo
|
||||||
|
}
|
||||||
|
|
||||||
|
enum OrderStatus {
|
||||||
|
pending
|
||||||
|
confirmed
|
||||||
|
preparing
|
||||||
|
ready
|
||||||
|
delivered
|
||||||
|
cancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
enum DeliveryType {
|
||||||
|
shipping
|
||||||
|
pickup
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
title String
|
||||||
|
firstName String
|
||||||
|
lastName String
|
||||||
|
category String?
|
||||||
|
cellNumber String @unique
|
||||||
|
passwordHash String
|
||||||
|
role UserRole @default(customer)
|
||||||
|
disabled Boolean @default(false)
|
||||||
|
totalOrders Int @default(0)
|
||||||
|
totalTransaction Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
refreshTokens RefreshToken[]
|
||||||
|
addresses UserAddress[]
|
||||||
|
orders Order[]
|
||||||
|
|
||||||
|
@@index([role])
|
||||||
|
@@index([disabled])
|
||||||
|
}
|
||||||
|
|
||||||
|
model UserAddress {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
name String
|
||||||
|
district String
|
||||||
|
address String
|
||||||
|
landline String @default("")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model RefreshToken {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
tokenHash String
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
expiresAt DateTime
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([tokenHash])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Flavor {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
nameFa String
|
||||||
|
nameEn String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
categoryOptionBlocks CategoryOptionBlock[]
|
||||||
|
productOptions ProductOption[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Category {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
nameFa String
|
||||||
|
nameEn String
|
||||||
|
parentId String?
|
||||||
|
parent Category? @relation("CategoryTree", fields: [parentId], references: [id], onDelete: Restrict)
|
||||||
|
children Category[] @relation("CategoryTree")
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
optionBlocks CategoryOptionBlock[]
|
||||||
|
products Product[]
|
||||||
|
|
||||||
|
@@index([parentId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model CategoryOptionBlock {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
categoryId String
|
||||||
|
flavorId String
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
|
||||||
|
flavor Flavor @relation(fields: [flavorId], references: [id], onDelete: Restrict)
|
||||||
|
entries CategoryOptionEntry[]
|
||||||
|
|
||||||
|
@@index([categoryId])
|
||||||
|
@@index([flavorId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model CategoryOptionEntry {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
blockId String
|
||||||
|
amount String
|
||||||
|
price Int
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
block CategoryOptionBlock @relation(fields: [blockId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([blockId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Product {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
nameFa String
|
||||||
|
nameEn String
|
||||||
|
price Int @default(0)
|
||||||
|
sellUnit SellUnit @default(unit)
|
||||||
|
categoryId String
|
||||||
|
category Category @relation(fields: [categoryId], references: [id], onDelete: Restrict)
|
||||||
|
intro String @default("")
|
||||||
|
description String @default("")
|
||||||
|
mainImageUrl String?
|
||||||
|
mainImageKey String?
|
||||||
|
tags String[] @default([])
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
gallery ProductGalleryImage[]
|
||||||
|
options ProductOption[]
|
||||||
|
orderItems OrderItem[]
|
||||||
|
|
||||||
|
@@index([categoryId])
|
||||||
|
@@index([price])
|
||||||
|
}
|
||||||
|
|
||||||
|
model ProductGalleryImage {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
productId String
|
||||||
|
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||||
|
url String
|
||||||
|
storageKey String
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
|
||||||
|
@@index([productId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model ProductOption {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
productId String
|
||||||
|
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||||
|
flavorId String
|
||||||
|
flavor Flavor @relation(fields: [flavorId], references: [id], onDelete: Restrict)
|
||||||
|
amount String
|
||||||
|
price Int
|
||||||
|
|
||||||
|
@@index([productId])
|
||||||
|
@@index([flavorId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model ShippingException {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
district String @unique
|
||||||
|
price Int
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
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[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Order {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
number Int @unique @default(autoincrement())
|
||||||
|
customerId String
|
||||||
|
customer User @relation(fields: [customerId], references: [id], onDelete: Restrict)
|
||||||
|
customerName String
|
||||||
|
customerPhone String
|
||||||
|
status OrderStatus @default(pending)
|
||||||
|
deliveryType DeliveryType
|
||||||
|
branchId String?
|
||||||
|
branch Branch? @relation(fields: [branchId], references: [id], onDelete: SetNull)
|
||||||
|
branchName String?
|
||||||
|
shippingAddressId String?
|
||||||
|
shippingName String?
|
||||||
|
shippingDistrict String?
|
||||||
|
shippingAddressLine String?
|
||||||
|
shippingLandline String?
|
||||||
|
note String @default("")
|
||||||
|
itemCount Int
|
||||||
|
totalPrice Int
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
items OrderItem[]
|
||||||
|
|
||||||
|
@@index([customerId])
|
||||||
|
@@index([status])
|
||||||
|
@@index([createdAt])
|
||||||
|
@@index([branchId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model OrderItem {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
orderId String
|
||||||
|
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||||||
|
productId String?
|
||||||
|
product Product? @relation(fields: [productId], references: [id], onDelete: SetNull)
|
||||||
|
nameFa String
|
||||||
|
quantity Int
|
||||||
|
unitPrice Int
|
||||||
|
sellUnit SellUnit
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
options OrderItemOption[]
|
||||||
|
|
||||||
|
@@index([orderId])
|
||||||
|
@@index([productId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model OrderItemOption {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
orderItemId String
|
||||||
|
orderItem OrderItem @relation(fields: [orderItemId], references: [id], onDelete: Cascade)
|
||||||
|
name String
|
||||||
|
price Int
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
|
||||||
|
@@index([orderItemId])
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* Create the first superAdmin (no seed data).
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* npx ts-node -r tsconfig-paths/register scripts/create-super-admin.ts \
|
||||||
|
* --phone 09120000000 --password secret123 --first علی --last رضایی
|
||||||
|
*/
|
||||||
|
import { PrismaClient, UserRole } from '@prisma/client';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
function arg(name: string, fallback?: string): string {
|
||||||
|
const idx = process.argv.indexOf(`--${name}`);
|
||||||
|
if (idx >= 0 && process.argv[idx + 1]) return process.argv[idx + 1];
|
||||||
|
if (fallback !== undefined) return fallback;
|
||||||
|
throw new Error(`Missing --${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const cellNumber = arg('phone');
|
||||||
|
const password = arg('password');
|
||||||
|
const firstName = arg('first', 'مدیر');
|
||||||
|
const lastName = arg('last', 'بلوط');
|
||||||
|
const title = arg('title', 'جناب آقای');
|
||||||
|
|
||||||
|
if (!/^09\d{9}$/.test(cellNumber)) {
|
||||||
|
throw new Error('phone must match 09xxxxxxxxx');
|
||||||
|
}
|
||||||
|
if (password.length < 4) {
|
||||||
|
throw new Error('password min length 4');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.user.findUnique({ where: { cellNumber } });
|
||||||
|
if (existing) {
|
||||||
|
throw new Error(`User already exists: ${cellNumber}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(password, 10);
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
title,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
cellNumber,
|
||||||
|
passwordHash,
|
||||||
|
role: UserRole.superAdmin,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Created superAdmin:', {
|
||||||
|
id: user.id,
|
||||||
|
cellNumber: user.cellNumber,
|
||||||
|
name: `${user.title} ${user.firstName} ${user.lastName}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(err.message || err);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { AuthModule } from './auth/auth.module';
|
||||||
|
import { CategoriesModule } from './categories/categories.module';
|
||||||
|
import { FlavorsModule } from './flavors/flavors.module';
|
||||||
|
import { MediaModule } from './media/media.module';
|
||||||
|
import { OrdersModule } from './orders/orders.module';
|
||||||
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
|
import { ProductsModule } from './products/products.module';
|
||||||
|
import { SettingsModule } from './settings/settings.module';
|
||||||
|
import { StorageModule } from './storage/storage.module';
|
||||||
|
import { UsersModule } from './users/users.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ConfigModule.forRoot({ isGlobal: true }),
|
||||||
|
PrismaModule,
|
||||||
|
StorageModule,
|
||||||
|
AuthModule,
|
||||||
|
UsersModule,
|
||||||
|
FlavorsModule,
|
||||||
|
CategoriesModule,
|
||||||
|
ProductsModule,
|
||||||
|
OrdersModule,
|
||||||
|
MediaModule,
|
||||||
|
SettingsModule,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||||
|
import { UserRole } from '@prisma/client';
|
||||||
|
import type { AuthUser } from './auth.types';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { CurrentUser } from './decorators/current-user.decorator';
|
||||||
|
import { Roles } from './decorators/roles.decorator';
|
||||||
|
import { LoginDto } from './dto/login.dto';
|
||||||
|
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||||
|
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||||
|
import { RolesGuard } from './guards/roles.guard';
|
||||||
|
|
||||||
|
@Controller('auth')
|
||||||
|
export class AuthController {
|
||||||
|
constructor(private readonly auth: AuthService) {}
|
||||||
|
|
||||||
|
@Post('login')
|
||||||
|
login(@Body() dto: LoginDto) {
|
||||||
|
return this.auth.login(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('refresh')
|
||||||
|
refresh(@Body() dto: RefreshTokenDto) {
|
||||||
|
return this.auth.refresh(dto.refreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('logout')
|
||||||
|
logout(@Body() dto: RefreshTokenDto) {
|
||||||
|
return this.auth.logout(dto.refreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('me')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.admin, UserRole.superAdmin)
|
||||||
|
me(@CurrentUser() user: AuthUser) {
|
||||||
|
return this.auth.me(user);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
import { PassportModule } from '@nestjs/passport';
|
||||||
|
import { AuthController } from './auth.controller';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||||
|
JwtModule.registerAsync({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (config: ConfigService) => ({
|
||||||
|
secret: config.getOrThrow<string>('JWT_ACCESS_SECRET'),
|
||||||
|
signOptions: {
|
||||||
|
expiresIn: config.get<string>('JWT_ACCESS_EXPIRES_IN', '15m') as
|
||||||
|
| `${number}s`
|
||||||
|
| `${number}m`
|
||||||
|
| `${number}h`
|
||||||
|
| `${number}d`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
controllers: [AuthController],
|
||||||
|
providers: [AuthService, JwtStrategy],
|
||||||
|
exports: [AuthService, JwtModule],
|
||||||
|
})
|
||||||
|
export class AuthModule {}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import {
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
import { createHash, randomBytes } from 'crypto';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { AuthUser, displayName, isElevatedRole } from './auth.types';
|
||||||
|
import { LoginDto } from './dto/login.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly jwt: JwtService,
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async login(dto: LoginDto) {
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { cellNumber: dto.cellNumber },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedException('شماره یا رمز عبور اشتباه است');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.disabled) {
|
||||||
|
throw new ForbiddenException('حساب کاربری غیرفعال است');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isElevatedRole(user.role)) {
|
||||||
|
throw new ForbiddenException('فقط ادمین میتواند وارد پنل شود');
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = await bcrypt.compare(dto.password, user.passwordHash);
|
||||||
|
if (!ok) {
|
||||||
|
throw new UnauthorizedException('شماره یا رمز عبور اشتباه است');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.issueTokens(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async refresh(refreshToken: string) {
|
||||||
|
const tokenHash = this.hashToken(refreshToken);
|
||||||
|
const stored = await this.prisma.refreshToken.findFirst({
|
||||||
|
where: { tokenHash },
|
||||||
|
include: { user: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!stored || stored.expiresAt < new Date()) {
|
||||||
|
if (stored) {
|
||||||
|
await this.prisma.refreshToken.delete({ where: { id: stored.id } });
|
||||||
|
}
|
||||||
|
throw new UnauthorizedException('توکن منقضی شده است');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = stored.user;
|
||||||
|
if (user.disabled || !isElevatedRole(user.role)) {
|
||||||
|
throw new UnauthorizedException('نشست نامعتبر است');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.refreshToken.delete({ where: { id: stored.id } });
|
||||||
|
return this.issueTokens(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async logout(refreshToken?: string) {
|
||||||
|
if (!refreshToken) return { ok: true };
|
||||||
|
const tokenHash = this.hashToken(refreshToken);
|
||||||
|
await this.prisma.refreshToken.deleteMany({ where: { tokenHash } });
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async me(actor: AuthUser) {
|
||||||
|
const user = await this.prisma.user.findUniqueOrThrow({
|
||||||
|
where: { id: actor.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
title: user.title,
|
||||||
|
firstName: user.firstName,
|
||||||
|
lastName: user.lastName,
|
||||||
|
cellNumber: user.cellNumber,
|
||||||
|
role: user.role,
|
||||||
|
name: displayName(user),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async issueTokens(user: {
|
||||||
|
id: string;
|
||||||
|
cellNumber: string;
|
||||||
|
role: AuthUser['role'];
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
title: string;
|
||||||
|
}) {
|
||||||
|
const accessExpiresIn = this.config.get<string>(
|
||||||
|
'JWT_ACCESS_EXPIRES_IN',
|
||||||
|
'15m',
|
||||||
|
);
|
||||||
|
const accessToken = await this.jwt.signAsync(
|
||||||
|
{ sub: user.id, role: user.role },
|
||||||
|
{
|
||||||
|
secret: this.config.getOrThrow<string>('JWT_ACCESS_SECRET'),
|
||||||
|
expiresIn: accessExpiresIn as `${number}${'s' | 'm' | 'h' | 'd'}`,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const refreshToken = randomBytes(48).toString('hex');
|
||||||
|
const refreshDays = this.parseDurationDays(
|
||||||
|
this.config.get<string>('JWT_REFRESH_EXPIRES_IN', '7d'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.prisma.refreshToken.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
tokenHash: this.hashToken(refreshToken),
|
||||||
|
expiresAt: new Date(Date.now() + refreshDays * 24 * 60 * 60 * 1000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
title: user.title,
|
||||||
|
firstName: user.firstName,
|
||||||
|
lastName: user.lastName,
|
||||||
|
cellNumber: user.cellNumber,
|
||||||
|
role: user.role,
|
||||||
|
name: displayName(user),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private hashToken(token: string) {
|
||||||
|
return createHash('sha256').update(token).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseDurationDays(value: string): number {
|
||||||
|
const match = /^(\d+)d$/i.exec(value.trim());
|
||||||
|
return match ? Number(match[1]) : 7;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { UserRole } from '@prisma/client';
|
||||||
|
|
||||||
|
export type AuthUser = {
|
||||||
|
id: string;
|
||||||
|
cellNumber: string;
|
||||||
|
role: UserRole;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isElevatedRole(role: UserRole): boolean {
|
||||||
|
return role === UserRole.admin || role === UserRole.superAdmin;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function displayName(user: {
|
||||||
|
title: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
}): string {
|
||||||
|
return `${user.title} ${user.firstName} ${user.lastName}`.trim();
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||||
|
import { AuthUser } from '../auth.types';
|
||||||
|
|
||||||
|
export const CurrentUser = createParamDecorator(
|
||||||
|
(_data: unknown, ctx: ExecutionContext): AuthUser => {
|
||||||
|
const request = ctx.switchToHttp().getRequest<{ user: AuthUser }>();
|
||||||
|
return request.user;
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
import { UserRole } from '@prisma/client';
|
||||||
|
|
||||||
|
export const ROLES_KEY = 'roles';
|
||||||
|
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { IsString, Matches, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class LoginDto {
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^09\d{9}$/, {
|
||||||
|
message: 'شماره موبایل معتبر نیست',
|
||||||
|
})
|
||||||
|
cellNumber!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(4)
|
||||||
|
password!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { IsString, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class RefreshTokenDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(20)
|
||||||
|
refreshToken!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { UserRole } from '@prisma/client';
|
||||||
|
import { ROLES_KEY } from '../decorators/roles.decorator';
|
||||||
|
import { AuthUser } from '../auth.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RolesGuard implements CanActivate {
|
||||||
|
constructor(private readonly reflector: Reflector) {}
|
||||||
|
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(
|
||||||
|
ROLES_KEY,
|
||||||
|
[context.getHandler(), context.getClass()],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!requiredRoles?.length) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = context.switchToHttp().getRequest<{ user?: AuthUser }>();
|
||||||
|
const user = request.user;
|
||||||
|
|
||||||
|
if (!user || !requiredRoles.includes(user.role)) {
|
||||||
|
throw new ForbiddenException('دسترسی مجاز نیست');
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
|
import { UserRole } from '@prisma/client';
|
||||||
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { AuthUser, isElevatedRole } from '../auth.types';
|
||||||
|
|
||||||
|
type JwtPayload = {
|
||||||
|
sub: string;
|
||||||
|
role: UserRole;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||||
|
constructor(
|
||||||
|
config: ConfigService,
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
) {
|
||||||
|
super({
|
||||||
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
|
ignoreExpiration: false,
|
||||||
|
secretOrKey: config.getOrThrow<string>('JWT_ACCESS_SECRET'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async validate(payload: JwtPayload): Promise<AuthUser> {
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: payload.sub },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || user.disabled || !isElevatedRole(user.role)) {
|
||||||
|
throw new UnauthorizedException('نشست نامعتبر است');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
cellNumber: user.cellNumber,
|
||||||
|
role: user.role,
|
||||||
|
firstName: user.firstName,
|
||||||
|
lastName: user.lastName,
|
||||||
|
title: user.title,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { UserRole } from '@prisma/client';
|
||||||
|
import { Roles } from '../auth/decorators/roles.decorator';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||||
|
import { CategoriesService } from './categories.service';
|
||||||
|
import { CreateCategoryDto } from './dto/create-category.dto';
|
||||||
|
import { ReplaceCategoryOptionsDto } from './dto/replace-category-options.dto';
|
||||||
|
import { UpdateCategoryDto } from './dto/update-category.dto';
|
||||||
|
|
||||||
|
@Controller('categories')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.admin, UserRole.superAdmin)
|
||||||
|
export class CategoriesController {
|
||||||
|
constructor(private readonly categories: CategoriesService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
listTree() {
|
||||||
|
return this.categories.listTree();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
create(@Body() dto: CreateCategoryDto) {
|
||||||
|
return this.categories.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
update(@Param('id') id: string, @Body() dto: UpdateCategoryDto) {
|
||||||
|
return this.categories.update(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
remove(@Param('id') id: string) {
|
||||||
|
return this.categories.remove(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/options')
|
||||||
|
getOptions(@Param('id') id: string) {
|
||||||
|
return this.categories.getOptions(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id/options')
|
||||||
|
replaceOptions(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: ReplaceCategoryOptionsDto,
|
||||||
|
) {
|
||||||
|
return this.categories.replaceOptions(id, dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { CategoriesController } from './categories.controller';
|
||||||
|
import { CategoriesService } from './categories.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [CategoriesController],
|
||||||
|
providers: [CategoriesService],
|
||||||
|
exports: [CategoriesService],
|
||||||
|
})
|
||||||
|
export class CategoriesModule {}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { buildCategoryTree } from './categories.utils';
|
||||||
|
import { CreateCategoryDto } from './dto/create-category.dto';
|
||||||
|
import { ReplaceCategoryOptionsDto } from './dto/replace-category-options.dto';
|
||||||
|
import { UpdateCategoryDto } from './dto/update-category.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CategoriesService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async listTree() {
|
||||||
|
const categories = await this.prisma.category.findMany({
|
||||||
|
orderBy: [{ sortOrder: 'asc' }, { nameFa: 'asc' }],
|
||||||
|
});
|
||||||
|
return buildCategoryTree(categories);
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateCategoryDto) {
|
||||||
|
if (dto.parentId) {
|
||||||
|
await this.ensureExists(dto.parentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.category.create({
|
||||||
|
data: {
|
||||||
|
nameFa: dto.nameFa,
|
||||||
|
nameEn: dto.nameEn,
|
||||||
|
parentId: dto.parentId ?? null,
|
||||||
|
sortOrder: dto.sortOrder ?? 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateCategoryDto) {
|
||||||
|
await this.ensureExists(id);
|
||||||
|
|
||||||
|
if (dto.parentId) {
|
||||||
|
if (dto.parentId === id) {
|
||||||
|
throw new BadRequestException('دستهبندی نمیتواند والد خود باشد');
|
||||||
|
}
|
||||||
|
await this.ensureExists(dto.parentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.category.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(dto.nameFa !== undefined ? { nameFa: dto.nameFa } : {}),
|
||||||
|
...(dto.nameEn !== undefined ? { nameEn: dto.nameEn } : {}),
|
||||||
|
...(dto.parentId !== undefined ? { parentId: dto.parentId } : {}),
|
||||||
|
...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: string) {
|
||||||
|
await this.ensureExists(id);
|
||||||
|
|
||||||
|
const [childrenCount, productsCount] = await Promise.all([
|
||||||
|
this.prisma.category.count({ where: { parentId: id } }),
|
||||||
|
this.prisma.product.count({ where: { categoryId: id } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (childrenCount > 0) {
|
||||||
|
throw new BadRequestException('دستهبندی دارای زیرمجموعه است و قابل حذف نیست');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (productsCount > 0) {
|
||||||
|
throw new BadRequestException('دستهبندی دارای محصول است و قابل حذف نیست');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.category.delete({ where: { id } });
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOptions(id: string) {
|
||||||
|
await this.ensureExists(id);
|
||||||
|
|
||||||
|
return this.prisma.categoryOptionBlock.findMany({
|
||||||
|
where: { categoryId: id },
|
||||||
|
include: {
|
||||||
|
flavor: true,
|
||||||
|
entries: { orderBy: { sortOrder: 'asc' } },
|
||||||
|
},
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async replaceOptions(id: string, dto: ReplaceCategoryOptionsDto) {
|
||||||
|
await this.ensureExists(id);
|
||||||
|
|
||||||
|
const flavorIds = [...new Set(dto.blocks.map((block) => block.flavorId))];
|
||||||
|
const flavors = await this.prisma.flavor.findMany({
|
||||||
|
where: { id: { in: flavorIds } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (flavors.length !== flavorIds.length) {
|
||||||
|
throw new BadRequestException('برخی از طعمها یافت نشدند');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||||
|
await tx.categoryOptionBlock.deleteMany({ where: { categoryId: id } });
|
||||||
|
|
||||||
|
for (const [blockIndex, block] of dto.blocks.entries()) {
|
||||||
|
await tx.categoryOptionBlock.create({
|
||||||
|
data: {
|
||||||
|
categoryId: id,
|
||||||
|
flavorId: block.flavorId,
|
||||||
|
sortOrder: block.sortOrder ?? blockIndex,
|
||||||
|
entries: {
|
||||||
|
create: block.entries.map((entry, entryIndex) => ({
|
||||||
|
amount: entry.amount,
|
||||||
|
price: entry.price,
|
||||||
|
sortOrder: entryIndex,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.getOptions(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureExists(id: string) {
|
||||||
|
const category = await this.prisma.category.findUnique({ where: { id } });
|
||||||
|
if (!category) {
|
||||||
|
throw new NotFoundException('دستهبندی یافت نشد');
|
||||||
|
}
|
||||||
|
return category;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { Category } from '@prisma/client';
|
||||||
|
|
||||||
|
export type CategoryTreeNode = Category & { children: CategoryTreeNode[] };
|
||||||
|
|
||||||
|
export function buildCategoryTree(categories: Category[]): CategoryTreeNode[] {
|
||||||
|
const map = new Map<string, CategoryTreeNode>();
|
||||||
|
const roots: CategoryTreeNode[] = [];
|
||||||
|
|
||||||
|
for (const category of categories) {
|
||||||
|
map.set(category.id, { ...category, children: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const category of categories) {
|
||||||
|
const node = map.get(category.id)!;
|
||||||
|
if (category.parentId && map.has(category.parentId)) {
|
||||||
|
map.get(category.parentId)!.children.push(node);
|
||||||
|
} else if (!category.parentId) {
|
||||||
|
roots.push(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortNodes = (nodes: CategoryTreeNode[]) => {
|
||||||
|
nodes.sort((a, b) => a.sortOrder - b.sortOrder);
|
||||||
|
for (const node of nodes) {
|
||||||
|
sortNodes(node.children);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sortNodes(roots);
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { IsInt, IsOptional, IsString, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateCategoryDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
nameFa!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
nameEn!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
parentId?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
sortOrder?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
ArrayMinSize,
|
||||||
|
IsArray,
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Min,
|
||||||
|
MinLength,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class CategoryOptionEntryDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
amount!: string;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
price!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CategoryOptionBlockDto {
|
||||||
|
@IsString()
|
||||||
|
flavorId!: string;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(1)
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => CategoryOptionEntryDto)
|
||||||
|
entries!: CategoryOptionEntryDto[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
sortOrder?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ReplaceCategoryOptionsDto {
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => CategoryOptionBlockDto)
|
||||||
|
blocks!: CategoryOptionBlockDto[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { IsInt, IsOptional, IsString, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateCategoryDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
nameFa?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
nameEn?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
parentId?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
sortOrder?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/** Districts of Qom used across settings (shipping, branches). */
|
||||||
|
export const DISTRICTS = [
|
||||||
|
'پردیسان',
|
||||||
|
'جعفریه',
|
||||||
|
'زنبیلآباد',
|
||||||
|
'سالاریه',
|
||||||
|
'صفاییه',
|
||||||
|
'عطاران',
|
||||||
|
'انارستان',
|
||||||
|
'بنیاد',
|
||||||
|
'آذر',
|
||||||
|
'هنرستان',
|
||||||
|
'باجک',
|
||||||
|
'دورشهر',
|
||||||
|
'خیابان ارم',
|
||||||
|
'شهرک قدس',
|
||||||
|
'شهرک امام حسن',
|
||||||
|
'شهرک مهدیه',
|
||||||
|
'قلعه کامکار',
|
||||||
|
'نیروگاه',
|
||||||
|
'حرم',
|
||||||
|
'بلوار امین',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type District = (typeof DISTRICTS)[number];
|
||||||
|
|
||||||
|
export function isDistrict(value: string): value is District {
|
||||||
|
return (DISTRICTS as readonly string[]).includes(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { IsString, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateFlavorDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
nameFa!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
nameEn!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { IsOptional, IsString, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateFlavorDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
nameFa?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
nameEn?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { UserRole } from '@prisma/client';
|
||||||
|
import { Roles } from '../auth/decorators/roles.decorator';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||||
|
import { CreateFlavorDto } from './dto/create-flavor.dto';
|
||||||
|
import { UpdateFlavorDto } from './dto/update-flavor.dto';
|
||||||
|
import { FlavorsService } from './flavors.service';
|
||||||
|
|
||||||
|
@Controller('flavors')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.admin, UserRole.superAdmin)
|
||||||
|
export class FlavorsController {
|
||||||
|
constructor(private readonly flavors: FlavorsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list() {
|
||||||
|
return this.flavors.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
create(@Body() dto: CreateFlavorDto) {
|
||||||
|
return this.flavors.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
update(@Param('id') id: string, @Body() dto: UpdateFlavorDto) {
|
||||||
|
return this.flavors.update(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
remove(@Param('id') id: string) {
|
||||||
|
return this.flavors.remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { FlavorsController } from './flavors.controller';
|
||||||
|
import { FlavorsService } from './flavors.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [FlavorsController],
|
||||||
|
providers: [FlavorsService],
|
||||||
|
exports: [FlavorsService],
|
||||||
|
})
|
||||||
|
export class FlavorsModule {}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { CreateFlavorDto } from './dto/create-flavor.dto';
|
||||||
|
import { UpdateFlavorDto } from './dto/update-flavor.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FlavorsService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
list() {
|
||||||
|
return this.prisma.flavor.findMany({ orderBy: { nameFa: 'asc' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateFlavorDto) {
|
||||||
|
return this.prisma.flavor.create({ data: dto });
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateFlavorDto) {
|
||||||
|
await this.ensureExists(id);
|
||||||
|
return this.prisma.flavor.update({ where: { id }, data: dto });
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: string) {
|
||||||
|
await this.ensureExists(id);
|
||||||
|
|
||||||
|
const [categoryBlocks, productOptions] = await Promise.all([
|
||||||
|
this.prisma.categoryOptionBlock.count({ where: { flavorId: id } }),
|
||||||
|
this.prisma.productOption.count({ where: { flavorId: id } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (categoryBlocks > 0 || productOptions > 0) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'این طعم در گزینههای دستهبندی یا محصول استفاده شده و قابل حذف نیست',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.flavor.delete({ where: { id } });
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureExists(id: string) {
|
||||||
|
const flavor = await this.prisma.flavor.findUnique({ where: { id } });
|
||||||
|
if (!flavor) {
|
||||||
|
throw new NotFoundException('طعم یافت نشد');
|
||||||
|
}
|
||||||
|
return flavor;
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const app = await NestFactory.create(AppModule);
|
||||||
|
|
||||||
|
app.setGlobalPrefix('api/v1');
|
||||||
|
app.enableCors({
|
||||||
|
origin: process.env.CORS_ORIGIN?.split(',').map((s) => s.trim()) ?? true,
|
||||||
|
credentials: true,
|
||||||
|
});
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
transform: true,
|
||||||
|
transformOptions: { enableImplicitConversion: true },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const port = Number(process.env.PORT ?? 3100);
|
||||||
|
await app.listen(port);
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`Balout API listening on http://localhost:${port}/api/v1`);
|
||||||
|
}
|
||||||
|
|
||||||
|
void bootstrap();
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { IsIn } from 'class-validator';
|
||||||
|
|
||||||
|
export const MEDIA_KINDS = ['main', 'gallery', 'temp'] as const;
|
||||||
|
export type MediaKind = (typeof MEDIA_KINDS)[number];
|
||||||
|
|
||||||
|
export class MediaUploadQueryDto {
|
||||||
|
@IsIn(MEDIA_KINDS, { message: 'نوع فایل نامعتبر است' })
|
||||||
|
kind!: MediaKind;
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
UploadedFile,
|
||||||
|
UseGuards,
|
||||||
|
UseInterceptors,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
|
import { UserRole } from '@prisma/client';
|
||||||
|
import { memoryStorage } from 'multer';
|
||||||
|
import { Roles } from '../auth/decorators/roles.decorator';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||||
|
import { MediaUploadQueryDto } from './dto/media-upload-query.dto';
|
||||||
|
import { MediaService } from './media.service';
|
||||||
|
|
||||||
|
@Controller('media')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.admin, UserRole.superAdmin)
|
||||||
|
export class MediaController {
|
||||||
|
constructor(private readonly media: MediaService) {}
|
||||||
|
|
||||||
|
@Post('upload')
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor('file', {
|
||||||
|
storage: memoryStorage(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
upload(
|
||||||
|
@Query() query: MediaUploadQueryDto,
|
||||||
|
@UploadedFile() file: Express.Multer.File,
|
||||||
|
) {
|
||||||
|
return this.media.upload(query.kind, file);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { StorageModule } from '../storage/storage.module';
|
||||||
|
import { MediaController } from './media.controller';
|
||||||
|
import { MediaService } from './media.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [ConfigModule, StorageModule],
|
||||||
|
controllers: [MediaController],
|
||||||
|
providers: [MediaService],
|
||||||
|
})
|
||||||
|
export class MediaModule {}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
PayloadTooLargeException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { extname } from 'path';
|
||||||
|
import {
|
||||||
|
productGalleryKey,
|
||||||
|
productMainKey,
|
||||||
|
tempMediaKey,
|
||||||
|
} from '../storage/storage-keys';
|
||||||
|
import { StorageService } from '../storage/storage.service';
|
||||||
|
import { MediaKind } from './dto/media-upload-query.dto';
|
||||||
|
|
||||||
|
const ALLOWED_MIME_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']);
|
||||||
|
|
||||||
|
const MIME_EXTENSIONS: Record<string, string> = {
|
||||||
|
'image/jpeg': '.jpg',
|
||||||
|
'image/png': '.png',
|
||||||
|
'image/webp': '.webp',
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MediaService {
|
||||||
|
constructor(
|
||||||
|
private readonly storage: StorageService,
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async upload(kind: MediaKind, file: Express.Multer.File) {
|
||||||
|
if (!file) {
|
||||||
|
throw new BadRequestException('فایل ارسال نشده است');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ALLOWED_MIME_TYPES.has(file.mimetype)) {
|
||||||
|
throw new BadRequestException('فرمت تصویر مجاز نیست');
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxMb = Number(
|
||||||
|
this.config.get<string>('MEDIA_MAX_FILE_SIZE_MB', '10'),
|
||||||
|
);
|
||||||
|
const maxBytes = maxMb * 1024 * 1024;
|
||||||
|
|
||||||
|
if (file.size > maxBytes) {
|
||||||
|
throw new PayloadTooLargeException(
|
||||||
|
`حجم فایل نباید بیشتر از ${maxMb} مگابایت باشد`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext =
|
||||||
|
MIME_EXTENSIONS[file.mimetype] ?? extname(file.originalname) ?? '';
|
||||||
|
const fileName = `${randomUUID()}${ext}`;
|
||||||
|
const key = this.resolveKey(kind, fileName);
|
||||||
|
|
||||||
|
const stored = await this.storage.upload({
|
||||||
|
key,
|
||||||
|
body: file.buffer,
|
||||||
|
contentType: file.mimetype,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: stored.publicUrl,
|
||||||
|
storageKey: stored.storagePath,
|
||||||
|
storageDisk: stored.storageDisk,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveKey(kind: MediaKind, fileName: string): string {
|
||||||
|
switch (kind) {
|
||||||
|
case 'main':
|
||||||
|
return productMainKey(fileName);
|
||||||
|
case 'gallery':
|
||||||
|
return productGalleryKey(fileName);
|
||||||
|
case 'temp':
|
||||||
|
return tempMediaKey(fileName);
|
||||||
|
default:
|
||||||
|
throw new BadRequestException('نوع فایل نامعتبر است');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
ArrayMinSize,
|
||||||
|
IsArray,
|
||||||
|
IsEnum,
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Min,
|
||||||
|
MinLength,
|
||||||
|
ValidateIf,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { DeliveryType } from '@prisma/client';
|
||||||
|
|
||||||
|
export class CreateOrderItemDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
productId!: string;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
quantity!: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
optionIds?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateOrderDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
customerId!: string;
|
||||||
|
|
||||||
|
@IsEnum(DeliveryType)
|
||||||
|
deliveryType!: DeliveryType;
|
||||||
|
|
||||||
|
@ValidateIf((dto: CreateOrderDto) => dto.deliveryType === DeliveryType.pickup)
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
branchId?: string;
|
||||||
|
|
||||||
|
@ValidateIf(
|
||||||
|
(dto: CreateOrderDto) => dto.deliveryType === DeliveryType.shipping,
|
||||||
|
)
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
shippingAddressId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
note?: string;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(1)
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => CreateOrderItemDto)
|
||||||
|
items!: CreateOrderItemDto[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { OrderStatus } from '@prisma/client';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsEnum,
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Max,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class ListOrdersQueryDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
q?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(OrderStatus)
|
||||||
|
status?: OrderStatus;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
customerId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page?: number = 1;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
pageSize?: number = 20;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { OrderStatus } from '@prisma/client';
|
||||||
|
import { IsEnum } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateOrderStatusDto {
|
||||||
|
@IsEnum(OrderStatus)
|
||||||
|
status!: OrderStatus;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { UserRole } from '@prisma/client';
|
||||||
|
import { Roles } from '../auth/decorators/roles.decorator';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||||
|
import { CreateOrderDto } from './dto/create-order.dto';
|
||||||
|
import { ListOrdersQueryDto } from './dto/list-orders-query.dto';
|
||||||
|
import { UpdateOrderStatusDto } from './dto/update-order-status.dto';
|
||||||
|
import { OrdersService } from './orders.service';
|
||||||
|
|
||||||
|
@Controller('orders')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.admin, UserRole.superAdmin)
|
||||||
|
export class OrdersController {
|
||||||
|
constructor(private readonly orders: OrdersService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(@Query() query: ListOrdersQueryDto) {
|
||||||
|
return this.orders.list(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
findOne(@Param('id') id: string) {
|
||||||
|
return this.orders.findOne(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
create(@Body() dto: CreateOrderDto) {
|
||||||
|
return this.orders.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/status')
|
||||||
|
updateStatus(@Param('id') id: string, @Body() dto: UpdateOrderStatusDto) {
|
||||||
|
return this.orders.updateStatus(id, dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { OrdersController } from './orders.controller';
|
||||||
|
import { OrdersService } from './orders.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [OrdersController],
|
||||||
|
providers: [OrdersService],
|
||||||
|
exports: [OrdersService],
|
||||||
|
})
|
||||||
|
export class OrdersModule {}
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
import {
|
||||||
|
DeliveryType,
|
||||||
|
Order,
|
||||||
|
OrderItem,
|
||||||
|
OrderItemOption,
|
||||||
|
OrderStatus,
|
||||||
|
Prisma,
|
||||||
|
SellUnit,
|
||||||
|
} from '@prisma/client';
|
||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { displayName } from '../auth/auth.types';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { CreateOrderDto } from './dto/create-order.dto';
|
||||||
|
import { ListOrdersQueryDto } from './dto/list-orders-query.dto';
|
||||||
|
import { UpdateOrderStatusDto } from './dto/update-order-status.dto';
|
||||||
|
|
||||||
|
type OrderWithItems = Order & {
|
||||||
|
items: (OrderItem & { options: OrderItemOption[] })[];
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OrdersService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async list(query: ListOrdersQueryDto) {
|
||||||
|
const page = query.page ?? 1;
|
||||||
|
const pageSize = query.pageSize ?? 20;
|
||||||
|
const skip = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
const where: Prisma.OrderWhereInput = {
|
||||||
|
...(query.status ? { status: query.status } : {}),
|
||||||
|
...(query.customerId ? { customerId: query.customerId } : {}),
|
||||||
|
...(query.q
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
customerName: {
|
||||||
|
contains: query.q,
|
||||||
|
mode: 'insensitive' as const,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ customerPhone: { contains: query.q } },
|
||||||
|
...(Number.isFinite(Number(query.q.replace(/\D/g, ''))) &&
|
||||||
|
query.q.replace(/\D/g, '').length > 0
|
||||||
|
? [{ number: Number(query.q.replace(/\D/g, '')) }]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const [rows, total] = await Promise.all([
|
||||||
|
this.prisma.order.findMany({
|
||||||
|
where,
|
||||||
|
skip,
|
||||||
|
take: pageSize,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: {
|
||||||
|
items: {
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
include: { options: { orderBy: { sortOrder: 'asc' } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.order.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: rows.map((order) => this.serializeOrder(order)),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(id: string) {
|
||||||
|
const order = await this.prisma.order.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
items: {
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
include: { options: { orderBy: { sortOrder: 'asc' } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
throw new NotFoundException('سفارش یافت نشد');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.serializeOrder(order);
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateOrderDto) {
|
||||||
|
const customer = await this.prisma.user.findUnique({
|
||||||
|
where: { id: dto.customerId },
|
||||||
|
});
|
||||||
|
if (!customer) {
|
||||||
|
throw new NotFoundException('کاربر یافت نشد');
|
||||||
|
}
|
||||||
|
if (customer.disabled) {
|
||||||
|
throw new BadRequestException('کاربر غیرفعال است');
|
||||||
|
}
|
||||||
|
|
||||||
|
const delivery = await this.resolveDelivery(dto);
|
||||||
|
const lineInputs = await this.resolveItems(dto.items);
|
||||||
|
|
||||||
|
const itemCount = lineInputs.reduce((sum, item) => sum + item.quantity, 0);
|
||||||
|
const totalPrice = lineInputs.reduce(
|
||||||
|
(sum, item) => sum + item.quantity * item.unitTotal,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const order = await this.prisma.$transaction(async (tx) => {
|
||||||
|
const created = await tx.order.create({
|
||||||
|
data: {
|
||||||
|
customerId: customer.id,
|
||||||
|
customerName: displayName(customer),
|
||||||
|
customerPhone: customer.cellNumber,
|
||||||
|
deliveryType: dto.deliveryType,
|
||||||
|
branchId: delivery.branchId,
|
||||||
|
branchName: delivery.branchName,
|
||||||
|
shippingAddressId: delivery.shippingAddressId,
|
||||||
|
shippingName: delivery.shippingName,
|
||||||
|
shippingDistrict: delivery.shippingDistrict,
|
||||||
|
shippingAddressLine: delivery.shippingAddressLine,
|
||||||
|
shippingLandline: delivery.shippingLandline,
|
||||||
|
note: dto.note?.trim() ?? '',
|
||||||
|
itemCount,
|
||||||
|
totalPrice,
|
||||||
|
items: {
|
||||||
|
create: lineInputs.map((item, index) => ({
|
||||||
|
productId: item.productId,
|
||||||
|
nameFa: item.nameFa,
|
||||||
|
quantity: item.quantity,
|
||||||
|
unitPrice: item.unitPrice,
|
||||||
|
sellUnit: item.sellUnit,
|
||||||
|
sortOrder: index,
|
||||||
|
options: item.options.length
|
||||||
|
? {
|
||||||
|
create: item.options.map((option, optionIndex) => ({
|
||||||
|
name: option.name,
|
||||||
|
price: option.price,
|
||||||
|
sortOrder: optionIndex,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
items: {
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
include: { options: { orderBy: { sortOrder: 'asc' } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.user.update({
|
||||||
|
where: { id: customer.id },
|
||||||
|
data: {
|
||||||
|
totalOrders: { increment: 1 },
|
||||||
|
totalTransaction: { increment: totalPrice },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.serializeOrder(order);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateStatus(id: string, dto: UpdateOrderStatusDto) {
|
||||||
|
const existing = await this.prisma.order.findUnique({ where: { id } });
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundException('سفارش یافت نشد');
|
||||||
|
}
|
||||||
|
|
||||||
|
const order = await this.prisma.order.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: dto.status },
|
||||||
|
include: {
|
||||||
|
items: {
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
include: { options: { orderBy: { sortOrder: 'asc' } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.serializeOrder(order);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveDelivery(dto: CreateOrderDto) {
|
||||||
|
if (dto.deliveryType === DeliveryType.pickup) {
|
||||||
|
if (!dto.branchId) {
|
||||||
|
throw new BadRequestException('انتخاب شعبه الزامی است');
|
||||||
|
}
|
||||||
|
const branch = await this.prisma.branch.findUnique({
|
||||||
|
where: { id: dto.branchId },
|
||||||
|
});
|
||||||
|
if (!branch) {
|
||||||
|
throw new NotFoundException('شعبه یافت نشد');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
branchId: branch.id,
|
||||||
|
branchName: branch.name,
|
||||||
|
shippingAddressId: null as string | null,
|
||||||
|
shippingName: null as string | null,
|
||||||
|
shippingDistrict: null as string | null,
|
||||||
|
shippingAddressLine: null as string | null,
|
||||||
|
shippingLandline: null as string | null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dto.shippingAddressId) {
|
||||||
|
throw new BadRequestException('انتخاب آدرس ارسال الزامی است');
|
||||||
|
}
|
||||||
|
|
||||||
|
const address = await this.prisma.userAddress.findFirst({
|
||||||
|
where: { id: dto.shippingAddressId, userId: dto.customerId },
|
||||||
|
});
|
||||||
|
if (!address) {
|
||||||
|
throw new NotFoundException('آدرس ارسال یافت نشد');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
branchId: null as string | null,
|
||||||
|
branchName: null as string | null,
|
||||||
|
shippingAddressId: address.id,
|
||||||
|
shippingName: address.name,
|
||||||
|
shippingDistrict: address.district,
|
||||||
|
shippingAddressLine: address.address,
|
||||||
|
shippingLandline: address.landline,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveItems(items: CreateOrderDto['items']) {
|
||||||
|
const productIds = [...new Set(items.map((item) => item.productId))];
|
||||||
|
const products = await this.prisma.product.findMany({
|
||||||
|
where: { id: { in: productIds } },
|
||||||
|
include: { options: { include: { flavor: true } } },
|
||||||
|
});
|
||||||
|
const productMap = new Map(products.map((product) => [product.id, product]));
|
||||||
|
|
||||||
|
return items.map((item) => {
|
||||||
|
const product = productMap.get(item.productId);
|
||||||
|
if (!product) {
|
||||||
|
throw new NotFoundException(`محصول یافت نشد: ${item.productId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedIds = item.optionIds ?? [];
|
||||||
|
const optionsById = new Map(
|
||||||
|
product.options.map((option) => [option.id, option]),
|
||||||
|
);
|
||||||
|
const options = selectedIds.map((optionId) => {
|
||||||
|
const option = optionsById.get(optionId);
|
||||||
|
if (!option) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`گزینه نامعتبر برای محصول ${product.nameFa}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: `${option.flavor.nameFa} — ${option.amount}`,
|
||||||
|
price: option.price,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const optionsTotal = options.reduce((sum, option) => sum + option.price, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
productId: product.id,
|
||||||
|
nameFa: product.nameFa,
|
||||||
|
quantity: item.quantity,
|
||||||
|
unitPrice: product.price,
|
||||||
|
sellUnit: product.sellUnit as SellUnit,
|
||||||
|
options,
|
||||||
|
unitTotal: product.price + optionsTotal,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private serializeOrder(order: OrderWithItems) {
|
||||||
|
const delivery =
|
||||||
|
order.deliveryType === DeliveryType.pickup
|
||||||
|
? {
|
||||||
|
type: 'pickup' as const,
|
||||||
|
branch: order.branchName ?? '',
|
||||||
|
branchId: order.branchId,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
type: 'shipping' as const,
|
||||||
|
shippingAddress: {
|
||||||
|
id: order.shippingAddressId ?? '',
|
||||||
|
name: order.shippingName ?? '',
|
||||||
|
district: order.shippingDistrict ?? '',
|
||||||
|
address: order.shippingAddressLine ?? '',
|
||||||
|
landline: order.shippingLandline ?? '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: order.id,
|
||||||
|
code: `BL-${order.number}`,
|
||||||
|
number: order.number,
|
||||||
|
createdAt: order.createdAt.toISOString(),
|
||||||
|
customerId: order.customerId,
|
||||||
|
customerName: order.customerName,
|
||||||
|
customerPhone: order.customerPhone,
|
||||||
|
itemCount: order.itemCount,
|
||||||
|
totalPrice: order.totalPrice,
|
||||||
|
status: order.status as OrderStatus,
|
||||||
|
note: order.note || undefined,
|
||||||
|
delivery,
|
||||||
|
items: order.items.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
productId: item.productId,
|
||||||
|
nameFa: item.nameFa,
|
||||||
|
quantity: item.quantity,
|
||||||
|
unitPrice: item.unitPrice,
|
||||||
|
sellUnit: item.sellUnit,
|
||||||
|
options:
|
||||||
|
item.options.length > 0
|
||||||
|
? item.options.map((option) => ({
|
||||||
|
id: option.id,
|
||||||
|
name: option.name,
|
||||||
|
price: option.price,
|
||||||
|
}))
|
||||||
|
: undefined,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { PrismaService } from './prisma.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [PrismaService],
|
||||||
|
exports: [PrismaService],
|
||||||
|
})
|
||||||
|
export class PrismaModule {}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PrismaService
|
||||||
|
extends PrismaClient
|
||||||
|
implements OnModuleInit, OnModuleDestroy
|
||||||
|
{
|
||||||
|
async onModuleInit() {
|
||||||
|
await this.$connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy() {
|
||||||
|
await this.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { SellUnit } from '@prisma/client';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsArray,
|
||||||
|
IsEnum,
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Min,
|
||||||
|
MinLength,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class ProductGalleryItemDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
url!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
storageKey!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ProductOptionItemDto {
|
||||||
|
@IsString()
|
||||||
|
flavorId!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
amount!: string;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
price!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateProductDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
nameFa!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
nameEn!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
categoryId!: string;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
price!: number;
|
||||||
|
|
||||||
|
@IsEnum(SellUnit)
|
||||||
|
sellUnit!: SellUnit;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
intro?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
tags?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
mainImageUrl?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
mainImageKey?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => ProductGalleryItemDto)
|
||||||
|
gallery?: ProductGalleryItemDto[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => ProductOptionItemDto)
|
||||||
|
options?: ProductOptionItemDto[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class ListProductsQueryDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
q?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
categoryId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
minPrice?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
maxPrice?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page?: number = 1;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
pageSize?: number = 20;
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { SellUnit } from '@prisma/client';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsArray,
|
||||||
|
IsEnum,
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Min,
|
||||||
|
MinLength,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator';
|
||||||
|
import {
|
||||||
|
ProductGalleryItemDto,
|
||||||
|
ProductOptionItemDto,
|
||||||
|
} from './create-product.dto';
|
||||||
|
|
||||||
|
export class UpdateProductDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
nameFa?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
nameEn?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
categoryId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
price?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(SellUnit)
|
||||||
|
sellUnit?: SellUnit;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
intro?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
tags?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
mainImageUrl?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
mainImageKey?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => ProductGalleryItemDto)
|
||||||
|
gallery?: ProductGalleryItemDto[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => ProductOptionItemDto)
|
||||||
|
options?: ProductOptionItemDto[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { UserRole } from '@prisma/client';
|
||||||
|
import { Roles } from '../auth/decorators/roles.decorator';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||||
|
import { CreateProductDto } from './dto/create-product.dto';
|
||||||
|
import { ListProductsQueryDto } from './dto/list-products-query.dto';
|
||||||
|
import { UpdateProductDto } from './dto/update-product.dto';
|
||||||
|
import { ProductsService } from './products.service';
|
||||||
|
|
||||||
|
@Controller('products')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.admin, UserRole.superAdmin)
|
||||||
|
export class ProductsController {
|
||||||
|
constructor(private readonly products: ProductsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(@Query() query: ListProductsQueryDto) {
|
||||||
|
return this.products.list(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
findOne(@Param('id') id: string) {
|
||||||
|
return this.products.findOne(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
create(@Body() dto: CreateProductDto) {
|
||||||
|
return this.products.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
update(@Param('id') id: string, @Body() dto: UpdateProductDto) {
|
||||||
|
return this.products.update(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
remove(@Param('id') id: string) {
|
||||||
|
return this.products.remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { StorageModule } from '../storage/storage.module';
|
||||||
|
import { ProductsController } from './products.controller';
|
||||||
|
import { ProductsService } from './products.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [StorageModule],
|
||||||
|
controllers: [ProductsController],
|
||||||
|
providers: [ProductsService],
|
||||||
|
exports: [ProductsService],
|
||||||
|
})
|
||||||
|
export class ProductsModule {}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { StorageService } from '../storage/storage.service';
|
||||||
|
import { CreateProductDto } from './dto/create-product.dto';
|
||||||
|
import { ListProductsQueryDto } from './dto/list-products-query.dto';
|
||||||
|
import { UpdateProductDto } from './dto/update-product.dto';
|
||||||
|
|
||||||
|
type ProductOptionInput = {
|
||||||
|
flavorId: string;
|
||||||
|
amount: string;
|
||||||
|
price: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ProductsService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly storage: StorageService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async list(query: ListProductsQueryDto) {
|
||||||
|
const page = query.page ?? 1;
|
||||||
|
const pageSize = query.pageSize ?? 20;
|
||||||
|
const skip = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
const where = {
|
||||||
|
...(query.categoryId ? { categoryId: query.categoryId } : {}),
|
||||||
|
...(query.minPrice !== undefined || query.maxPrice !== undefined
|
||||||
|
? {
|
||||||
|
price: {
|
||||||
|
...(query.minPrice !== undefined ? { gte: query.minPrice } : {}),
|
||||||
|
...(query.maxPrice !== undefined ? { lte: query.maxPrice } : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(query.q
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ nameFa: { contains: query.q, mode: 'insensitive' as const } },
|
||||||
|
{ nameEn: { contains: query.q, mode: 'insensitive' as const } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
this.prisma.product.findMany({
|
||||||
|
where,
|
||||||
|
skip,
|
||||||
|
take: pageSize,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: { category: true },
|
||||||
|
}),
|
||||||
|
this.prisma.product.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { items, total, page, pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(id: string) {
|
||||||
|
const product = await this.prisma.product.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
category: true,
|
||||||
|
gallery: { orderBy: { sortOrder: 'asc' } },
|
||||||
|
options: { include: { flavor: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
throw new NotFoundException('محصول یافت نشد');
|
||||||
|
}
|
||||||
|
|
||||||
|
return product;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateProductDto) {
|
||||||
|
await this.ensureCategoryExists(dto.categoryId);
|
||||||
|
await this.validateProductOptions(dto.categoryId, dto.options);
|
||||||
|
|
||||||
|
return this.prisma.product.create({
|
||||||
|
data: {
|
||||||
|
nameFa: dto.nameFa,
|
||||||
|
nameEn: dto.nameEn,
|
||||||
|
categoryId: dto.categoryId,
|
||||||
|
price: dto.price,
|
||||||
|
sellUnit: dto.sellUnit,
|
||||||
|
intro: dto.intro ?? '',
|
||||||
|
description: dto.description ?? '',
|
||||||
|
tags: dto.tags ?? [],
|
||||||
|
mainImageUrl: dto.mainImageUrl,
|
||||||
|
mainImageKey: dto.mainImageKey,
|
||||||
|
gallery: dto.gallery?.length
|
||||||
|
? {
|
||||||
|
create: dto.gallery.map((item, index) => ({
|
||||||
|
url: item.url,
|
||||||
|
storageKey: item.storageKey,
|
||||||
|
sortOrder: index,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
options: dto.options?.length
|
||||||
|
? {
|
||||||
|
create: dto.options.map((option) => ({
|
||||||
|
flavorId: option.flavorId,
|
||||||
|
amount: option.amount,
|
||||||
|
price: option.price,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
category: true,
|
||||||
|
gallery: { orderBy: { sortOrder: 'asc' } },
|
||||||
|
options: { include: { flavor: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateProductDto) {
|
||||||
|
const existing = await this.prisma.product.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { gallery: true, options: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundException('محصول یافت نشد');
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryId = dto.categoryId ?? existing.categoryId;
|
||||||
|
|
||||||
|
if (dto.categoryId) {
|
||||||
|
await this.ensureCategoryExists(dto.categoryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.options !== undefined) {
|
||||||
|
await this.validateProductOptions(categoryId, dto.options);
|
||||||
|
} else if (dto.categoryId && dto.categoryId !== existing.categoryId) {
|
||||||
|
await this.validateProductOptions(
|
||||||
|
categoryId,
|
||||||
|
existing.options.map((option) => ({
|
||||||
|
flavorId: option.flavorId,
|
||||||
|
amount: option.amount,
|
||||||
|
price: option.price,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||||
|
if (dto.gallery !== undefined) {
|
||||||
|
await tx.productGalleryImage.deleteMany({ where: { productId: id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.options !== undefined) {
|
||||||
|
await tx.productOption.deleteMany({ where: { productId: id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.product.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(dto.nameFa !== undefined ? { nameFa: dto.nameFa } : {}),
|
||||||
|
...(dto.nameEn !== undefined ? { nameEn: dto.nameEn } : {}),
|
||||||
|
...(dto.categoryId !== undefined ? { categoryId: dto.categoryId } : {}),
|
||||||
|
...(dto.price !== undefined ? { price: dto.price } : {}),
|
||||||
|
...(dto.sellUnit !== undefined ? { sellUnit: dto.sellUnit } : {}),
|
||||||
|
...(dto.intro !== undefined ? { intro: dto.intro } : {}),
|
||||||
|
...(dto.description !== undefined ? { description: dto.description } : {}),
|
||||||
|
...(dto.tags !== undefined ? { tags: dto.tags } : {}),
|
||||||
|
...(dto.mainImageUrl !== undefined
|
||||||
|
? { mainImageUrl: dto.mainImageUrl }
|
||||||
|
: {}),
|
||||||
|
...(dto.mainImageKey !== undefined
|
||||||
|
? { mainImageKey: dto.mainImageKey }
|
||||||
|
: {}),
|
||||||
|
...(dto.gallery !== undefined
|
||||||
|
? {
|
||||||
|
gallery: {
|
||||||
|
create: dto.gallery.map((item, index) => ({
|
||||||
|
url: item.url,
|
||||||
|
storageKey: item.storageKey,
|
||||||
|
sortOrder: index,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(dto.options !== undefined
|
||||||
|
? {
|
||||||
|
options: {
|
||||||
|
create: dto.options.map((option) => ({
|
||||||
|
flavorId: option.flavorId,
|
||||||
|
amount: option.amount,
|
||||||
|
price: option.price,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
category: true,
|
||||||
|
gallery: { orderBy: { sortOrder: 'asc' } },
|
||||||
|
options: { include: { flavor: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: string) {
|
||||||
|
const product = await this.prisma.product.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { gallery: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
throw new NotFoundException('محصول یافت نشد');
|
||||||
|
}
|
||||||
|
|
||||||
|
const keysToDelete = [
|
||||||
|
...(product.mainImageKey ? [product.mainImageKey] : []),
|
||||||
|
...product.gallery.map((image) => image.storageKey),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const key of keysToDelete) {
|
||||||
|
await this.storage.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.product.delete({ where: { id } });
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureCategoryExists(categoryId: string) {
|
||||||
|
const category = await this.prisma.category.findUnique({
|
||||||
|
where: { id: categoryId },
|
||||||
|
});
|
||||||
|
if (!category) {
|
||||||
|
throw new NotFoundException('دستهبندی یافت نشد');
|
||||||
|
}
|
||||||
|
return category;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async validateProductOptions(
|
||||||
|
categoryId: string,
|
||||||
|
options: ProductOptionInput[] | undefined,
|
||||||
|
) {
|
||||||
|
const blocks = await this.prisma.categoryOptionBlock.findMany({
|
||||||
|
where: { categoryId },
|
||||||
|
include: { entries: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (blocks.length === 0) {
|
||||||
|
if (options && options.length > 0) {
|
||||||
|
throw new BadRequestException('این دستهبندی قالب گزینهای ندارد');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options?.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowed = new Set<string>();
|
||||||
|
for (const block of blocks) {
|
||||||
|
for (const entry of block.entries) {
|
||||||
|
allowed.add(`${block.flavorId}:${entry.amount}:${entry.price}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const option of options) {
|
||||||
|
const key = `${option.flavorId}:${option.amount}:${option.price}`;
|
||||||
|
if (!allowed.has(key)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'گزینه محصول با قالب دستهبندی مطابقت ندارد',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { IsOptional, IsString, Matches, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateBranchDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
district!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
address!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
landline?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^09\d{9}$/, { message: 'شماره موبایل معتبر نیست' })
|
||||||
|
cellNumber?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { IsOptional, IsString, Matches, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateBranchDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
district?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
address?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
landline?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^09\d{9}$/, { message: 'شماره موبایل معتبر نیست' })
|
||||||
|
cellNumber?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
ArrayMinSize,
|
||||||
|
IsArray,
|
||||||
|
IsInt,
|
||||||
|
IsString,
|
||||||
|
Min,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator';
|
||||||
|
export class ShippingExceptionItemDto {
|
||||||
|
@IsString()
|
||||||
|
district!: string;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
price!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateShippingDto {
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(0)
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => ShippingExceptionItemDto)
|
||||||
|
exceptions!: ShippingExceptionItemDto[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { UserRole } from '@prisma/client';
|
||||||
|
import { Roles } from '../auth/decorators/roles.decorator';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||||
|
import { CreateBranchDto } from './dto/create-branch.dto';
|
||||||
|
import { UpdateBranchDto } from './dto/update-branch.dto';
|
||||||
|
import { UpdateShippingDto } from './dto/update-shipping.dto';
|
||||||
|
import { SettingsService } from './settings.service';
|
||||||
|
|
||||||
|
@Controller('settings')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.admin, UserRole.superAdmin)
|
||||||
|
export class SettingsController {
|
||||||
|
constructor(private readonly settings: SettingsService) {}
|
||||||
|
|
||||||
|
@Get('districts')
|
||||||
|
getDistricts() {
|
||||||
|
return this.settings.getDistricts();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('shipping')
|
||||||
|
listShipping() {
|
||||||
|
return this.settings.listShippingExceptions();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('shipping')
|
||||||
|
replaceShipping(@Body() dto: UpdateShippingDto) {
|
||||||
|
return this.settings.replaceShippingExceptions(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('branches')
|
||||||
|
listBranches() {
|
||||||
|
return this.settings.listBranches();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('branches')
|
||||||
|
createBranch(@Body() dto: CreateBranchDto) {
|
||||||
|
return this.settings.createBranch(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('branches/:id')
|
||||||
|
updateBranch(@Param('id') id: string, @Body() dto: UpdateBranchDto) {
|
||||||
|
return this.settings.updateBranch(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('branches/:id')
|
||||||
|
removeBranch(@Param('id') id: string) {
|
||||||
|
return this.settings.removeBranch(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { SettingsController } from './settings.controller';
|
||||||
|
import { SettingsService } from './settings.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [SettingsController],
|
||||||
|
providers: [SettingsService],
|
||||||
|
exports: [SettingsService],
|
||||||
|
})
|
||||||
|
export class SettingsModule {}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { DISTRICTS, isDistrict } from '../common/districts';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { CreateBranchDto } from './dto/create-branch.dto';
|
||||||
|
import { UpdateBranchDto } from './dto/update-branch.dto';
|
||||||
|
import { UpdateShippingDto } from './dto/update-shipping.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SettingsService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
getDistricts() {
|
||||||
|
return DISTRICTS;
|
||||||
|
}
|
||||||
|
|
||||||
|
listShippingExceptions() {
|
||||||
|
return this.prisma.shippingException.findMany({
|
||||||
|
orderBy: { district: 'asc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async replaceShippingExceptions(dto: UpdateShippingDto) {
|
||||||
|
for (const item of dto.exceptions) {
|
||||||
|
this.assertDistrict(item.district);
|
||||||
|
}
|
||||||
|
|
||||||
|
const districts = dto.exceptions.map((item) => item.district);
|
||||||
|
const unique = new Set(districts);
|
||||||
|
if (unique.size !== districts.length) {
|
||||||
|
throw new BadRequestException('منطقه تکراری در لیست ارسال وجود دارد');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||||
|
await tx.shippingException.deleteMany();
|
||||||
|
if (dto.exceptions.length > 0) {
|
||||||
|
await tx.shippingException.createMany({
|
||||||
|
data: dto.exceptions.map((item) => ({
|
||||||
|
district: item.district,
|
||||||
|
price: item.price,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.listShippingExceptions();
|
||||||
|
}
|
||||||
|
|
||||||
|
listBranches() {
|
||||||
|
return this.prisma.branch.findMany({ orderBy: { name: 'asc' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
createBranch(dto: CreateBranchDto) {
|
||||||
|
this.assertDistrict(dto.district);
|
||||||
|
|
||||||
|
return this.prisma.branch.create({
|
||||||
|
data: {
|
||||||
|
name: dto.name,
|
||||||
|
district: dto.district,
|
||||||
|
address: dto.address,
|
||||||
|
landline: dto.landline ?? '',
|
||||||
|
cellNumber: dto.cellNumber ?? '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateBranch(id: string, dto: UpdateBranchDto) {
|
||||||
|
await this.ensureBranchExists(id);
|
||||||
|
|
||||||
|
if (dto.district !== undefined) {
|
||||||
|
this.assertDistrict(dto.district);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.branch.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(dto.name !== undefined ? { name: dto.name } : {}),
|
||||||
|
...(dto.district !== undefined ? { district: dto.district } : {}),
|
||||||
|
...(dto.address !== undefined ? { address: dto.address } : {}),
|
||||||
|
...(dto.landline !== undefined ? { landline: dto.landline } : {}),
|
||||||
|
...(dto.cellNumber !== undefined ? { cellNumber: dto.cellNumber } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeBranch(id: string) {
|
||||||
|
await this.ensureBranchExists(id);
|
||||||
|
await this.prisma.branch.delete({ where: { id } });
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertDistrict(district: string) {
|
||||||
|
if (!isDistrict(district)) {
|
||||||
|
throw new BadRequestException('منطقه نامعتبر است');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureBranchExists(id: string) {
|
||||||
|
const branch = await this.prisma.branch.findUnique({ where: { id } });
|
||||||
|
if (!branch) {
|
||||||
|
throw new NotFoundException('شعبه یافت نشد');
|
||||||
|
}
|
||||||
|
return branch;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import {
|
||||||
|
DeleteObjectCommand,
|
||||||
|
GetObjectCommand,
|
||||||
|
PutObjectCommand,
|
||||||
|
S3Client,
|
||||||
|
} from '@aws-sdk/client-s3';
|
||||||
|
import { StoredObject, UploadObjectInput } from './storage.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class S3StorageDriver {
|
||||||
|
private readonly client: S3Client;
|
||||||
|
private readonly bucket: string;
|
||||||
|
private readonly publicUrlBase: string;
|
||||||
|
private readonly storageDisk = 's3';
|
||||||
|
|
||||||
|
constructor(private readonly config: ConfigService) {
|
||||||
|
const endpoint = this.config.getOrThrow<string>('S3_ENDPOINT');
|
||||||
|
const region = this.config.get<string>('S3_REGION', 'us-east-1');
|
||||||
|
const forcePathStyle =
|
||||||
|
this.config.get<string>('S3_FORCE_PATH_STYLE', 'true') === 'true';
|
||||||
|
|
||||||
|
this.bucket = this.config.getOrThrow<string>('S3_BUCKET');
|
||||||
|
this.publicUrlBase = this.config
|
||||||
|
.getOrThrow<string>('S3_PUBLIC_URL')
|
||||||
|
.replace(/\/$/, '');
|
||||||
|
|
||||||
|
this.client = new S3Client({
|
||||||
|
endpoint,
|
||||||
|
region,
|
||||||
|
forcePathStyle,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: this.config.getOrThrow<string>('S3_ACCESS_KEY_ID'),
|
||||||
|
secretAccessKey: this.config.getOrThrow<string>('S3_SECRET_ACCESS_KEY'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async upload(input: UploadObjectInput): Promise<StoredObject> {
|
||||||
|
const key = input.key.replace(/^\/+/, '');
|
||||||
|
|
||||||
|
await this.client.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: this.bucket,
|
||||||
|
Key: key,
|
||||||
|
Body: input.body,
|
||||||
|
ContentType: input.contentType,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
storageDisk: this.storageDisk,
|
||||||
|
storagePath: key,
|
||||||
|
publicUrl: `${this.publicUrlBase}/${key}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBuffer(storagePath: string): Promise<Buffer> {
|
||||||
|
const key = storagePath.replace(/^\/+/, '');
|
||||||
|
const result = await this.client.send(
|
||||||
|
new GetObjectCommand({
|
||||||
|
Bucket: this.bucket,
|
||||||
|
Key: key,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.Body) {
|
||||||
|
throw new Error(`Empty object body for ${key}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Buffer.from(await result.Body.transformToByteArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(storagePath: string): Promise<void> {
|
||||||
|
const key = storagePath.replace(/^\/+/, '');
|
||||||
|
|
||||||
|
await this.client.send(
|
||||||
|
new DeleteObjectCommand({
|
||||||
|
Bucket: this.bucket,
|
||||||
|
Key: key,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* Parspack S3 object key layout (path-style public URLs):
|
||||||
|
*
|
||||||
|
* balout/
|
||||||
|
* products/main/{uuid}{ext}
|
||||||
|
* products/gallery/{uuid}{ext}
|
||||||
|
* categories/{uuid}{ext}
|
||||||
|
* branches/{uuid}{ext}
|
||||||
|
* users/{uuid}{ext}
|
||||||
|
* temp/{uuid}{ext}
|
||||||
|
*/
|
||||||
|
const ROOT = 'balout';
|
||||||
|
|
||||||
|
export function productMainKey(fileName: string): string {
|
||||||
|
return `${ROOT}/products/main/${fileName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function productGalleryKey(fileName: string): string {
|
||||||
|
return `${ROOT}/products/gallery/${fileName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function categoryMediaKey(fileName: string): string {
|
||||||
|
return `${ROOT}/categories/${fileName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function branchMediaKey(fileName: string): string {
|
||||||
|
return `${ROOT}/branches/${fileName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function userMediaKey(fileName: string): string {
|
||||||
|
return `${ROOT}/users/${fileName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tempMediaKey(fileName: string): string {
|
||||||
|
return `${ROOT}/temp/${fileName}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { S3StorageDriver } from './s3-storage.driver';
|
||||||
|
import { StorageService } from './storage.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [S3StorageDriver, StorageService],
|
||||||
|
exports: [StorageService],
|
||||||
|
})
|
||||||
|
export class StorageModule {}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { S3StorageDriver } from './s3-storage.driver';
|
||||||
|
import { StoredObject, UploadObjectInput } from './storage.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class StorageService {
|
||||||
|
constructor(private readonly s3: S3StorageDriver) {}
|
||||||
|
|
||||||
|
upload(input: UploadObjectInput): Promise<StoredObject> {
|
||||||
|
return this.s3.upload(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
getBuffer(storagePath: string): Promise<Buffer> {
|
||||||
|
return this.s3.getBuffer(storagePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(storagePath: string): Promise<void> {
|
||||||
|
return this.s3.delete(storagePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export type StoredObject = {
|
||||||
|
storageDisk: string;
|
||||||
|
storagePath: string;
|
||||||
|
publicUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UploadObjectInput = {
|
||||||
|
key: string;
|
||||||
|
body: Buffer;
|
||||||
|
contentType: string;
|
||||||
|
};
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { IsOptional, IsString, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateUserAddressDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
district!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
address!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
landline?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { UserRole } from '@prisma/client';
|
||||||
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsEnum,
|
||||||
|
IsIn,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Matches,
|
||||||
|
MinLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { USER_CATEGORIES, USER_TITLES } from '../users.constants';
|
||||||
|
|
||||||
|
export class CreateUserDto {
|
||||||
|
@IsIn(USER_TITLES, { message: 'عنوان نامعتبر است' })
|
||||||
|
title!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
firstName!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
lastName!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(USER_CATEGORIES, { message: 'دستهبندی نامعتبر است' })
|
||||||
|
category?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^09\d{9}$/, { message: 'شماره موبایل معتبر نیست' })
|
||||||
|
cellNumber!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(4, { message: 'رمز عبور باید حداقل ۴ کاراکتر باشد' })
|
||||||
|
password!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(UserRole)
|
||||||
|
role?: UserRole;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { UserRole } from '@prisma/client';
|
||||||
|
import { Transform, Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsEnum,
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Max,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class ListUsersQueryDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
q?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(UserRole)
|
||||||
|
role?: UserRole;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => {
|
||||||
|
if (value === 'true') return true;
|
||||||
|
if (value === 'false') return false;
|
||||||
|
return value;
|
||||||
|
})
|
||||||
|
@IsBoolean()
|
||||||
|
disabled?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page?: number = 1;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
pageSize?: number = 20;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { IsOptional, IsString, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateUserAddressDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
district?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
address?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
landline?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { IsString, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateUserPasswordDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(4, { message: 'رمز عبور باید حداقل ۴ کاراکتر باشد' })
|
||||||
|
password!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { UserRole } from '@prisma/client';
|
||||||
|
import { IsEnum } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateUserRoleDto {
|
||||||
|
@IsEnum(UserRole, { message: 'نقش نامعتبر است' })
|
||||||
|
role!: UserRole;
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsIn,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Matches,
|
||||||
|
MinLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { USER_CATEGORIES, USER_TITLES } from '../users.constants';
|
||||||
|
|
||||||
|
export class UpdateUserDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(USER_TITLES, { message: 'عنوان نامعتبر است' })
|
||||||
|
title?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
firstName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
lastName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(USER_CATEGORIES, { message: 'دستهبندی نامعتبر است' })
|
||||||
|
category?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^09\d{9}$/, { message: 'شماره موبایل معتبر نیست' })
|
||||||
|
cellNumber?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export const USER_TITLES = [
|
||||||
|
'سرکار خانم',
|
||||||
|
'جناب آقای',
|
||||||
|
'جناب دکتر',
|
||||||
|
'خانم دکتر',
|
||||||
|
'جناب آقای مهندس',
|
||||||
|
'سرکار خانم مهندس',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const USER_CATEGORIES = ['پزشک', 'کسبه', 'مهندس'] as const;
|
||||||
|
|
||||||
|
export type UserTitle = (typeof USER_TITLES)[number];
|
||||||
|
export type UserCategory = (typeof USER_CATEGORIES)[number];
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
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 { CreateUserAddressDto } from './dto/create-user-address.dto';
|
||||||
|
import { CreateUserDto } from './dto/create-user.dto';
|
||||||
|
import { ListUsersQueryDto } from './dto/list-users-query.dto';
|
||||||
|
import { UpdateUserAddressDto } from './dto/update-user-address.dto';
|
||||||
|
import { UpdateUserDto } from './dto/update-user.dto';
|
||||||
|
import { UpdateUserPasswordDto } from './dto/update-user-password.dto';
|
||||||
|
import { UpdateUserRoleDto } from './dto/update-user-role.dto';
|
||||||
|
import { UsersService } from './users.service';
|
||||||
|
|
||||||
|
@Controller('users')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.admin, UserRole.superAdmin)
|
||||||
|
export class UsersController {
|
||||||
|
constructor(private readonly users: UsersService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(@Query() query: ListUsersQueryDto) {
|
||||||
|
return this.users.list(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/addresses')
|
||||||
|
listAddresses(@Param('id') id: string) {
|
||||||
|
return this.users.listAddresses(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/addresses')
|
||||||
|
createAddress(@Param('id') id: string, @Body() dto: CreateUserAddressDto) {
|
||||||
|
return this.users.createAddress(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/addresses/:addressId')
|
||||||
|
updateAddress(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Param('addressId') addressId: string,
|
||||||
|
@Body() dto: UpdateUserAddressDto,
|
||||||
|
) {
|
||||||
|
return this.users.updateAddress(id, addressId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id/addresses/:addressId')
|
||||||
|
removeAddress(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Param('addressId') addressId: string,
|
||||||
|
) {
|
||||||
|
return this.users.removeAddress(id, addressId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
findOne(@Param('id') id: string) {
|
||||||
|
return this.users.findOne(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
create(@CurrentUser() actor: AuthUser, @Body() dto: CreateUserDto) {
|
||||||
|
return this.users.create(actor, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
update(@Param('id') id: string, @Body() dto: UpdateUserDto) {
|
||||||
|
return this.users.update(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/role')
|
||||||
|
@Roles(UserRole.superAdmin)
|
||||||
|
updateRole(
|
||||||
|
@CurrentUser() actor: AuthUser,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateUserRoleDto,
|
||||||
|
) {
|
||||||
|
return this.users.updateRole(actor, id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/password')
|
||||||
|
updatePassword(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateUserPasswordDto,
|
||||||
|
) {
|
||||||
|
return this.users.updatePassword(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
remove(@CurrentUser() actor: AuthUser, @Param('id') id: string) {
|
||||||
|
return this.users.remove(actor, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { UsersController } from './users.controller';
|
||||||
|
import { UsersService } from './users.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [UsersController],
|
||||||
|
providers: [UsersService],
|
||||||
|
exports: [UsersService],
|
||||||
|
})
|
||||||
|
export class UsersModule {}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { User, UserRole } from '@prisma/client';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
import { isDistrict } from '../common/districts';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { AuthUser, displayName } from '../auth/auth.types';
|
||||||
|
import { CreateUserAddressDto } from './dto/create-user-address.dto';
|
||||||
|
import { CreateUserDto } from './dto/create-user.dto';
|
||||||
|
import { ListUsersQueryDto } from './dto/list-users-query.dto';
|
||||||
|
import { UpdateUserAddressDto } from './dto/update-user-address.dto';
|
||||||
|
import { UpdateUserDto } from './dto/update-user.dto';
|
||||||
|
import { UpdateUserPasswordDto } from './dto/update-user-password.dto';
|
||||||
|
import { UpdateUserRoleDto } from './dto/update-user-role.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UsersService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async list(query: ListUsersQueryDto) {
|
||||||
|
const page = query.page ?? 1;
|
||||||
|
const pageSize = query.pageSize ?? 20;
|
||||||
|
const skip = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
const where = {
|
||||||
|
...(query.role ? { role: query.role } : {}),
|
||||||
|
...(query.disabled !== undefined ? { disabled: query.disabled } : {}),
|
||||||
|
...(query.q
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ firstName: { contains: query.q, mode: 'insensitive' as const } },
|
||||||
|
{ lastName: { contains: query.q, mode: 'insensitive' as const } },
|
||||||
|
{ cellNumber: { contains: query.q } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
this.prisma.user.findMany({
|
||||||
|
where,
|
||||||
|
skip,
|
||||||
|
take: pageSize,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
}),
|
||||||
|
this.prisma.user.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: items.map((user) => this.serializeUser(user)),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(id: string) {
|
||||||
|
const user = await this.prisma.user.findUnique({ where: { id } });
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException('کاربر یافت نشد');
|
||||||
|
}
|
||||||
|
return this.serializeUser(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(actor: AuthUser, dto: CreateUserDto) {
|
||||||
|
const role = dto.role ?? UserRole.customer;
|
||||||
|
this.assertCanAssignRole(actor, role);
|
||||||
|
|
||||||
|
const existing = await this.prisma.user.findUnique({
|
||||||
|
where: { cellNumber: dto.cellNumber },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictException('این شماره موبایل قبلاً ثبت شده است');
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(dto.password, 10);
|
||||||
|
const user = await this.prisma.user.create({
|
||||||
|
data: {
|
||||||
|
title: dto.title,
|
||||||
|
firstName: dto.firstName,
|
||||||
|
lastName: dto.lastName,
|
||||||
|
category: dto.category,
|
||||||
|
cellNumber: dto.cellNumber,
|
||||||
|
passwordHash,
|
||||||
|
role,
|
||||||
|
disabled: dto.disabled ?? false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.serializeUser(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateUserDto) {
|
||||||
|
await this.ensureExists(id);
|
||||||
|
|
||||||
|
if (dto.cellNumber) {
|
||||||
|
const existing = await this.prisma.user.findFirst({
|
||||||
|
where: { cellNumber: dto.cellNumber, NOT: { id } },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictException('این شماره موبایل قبلاً ثبت شده است');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await this.prisma.user.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(dto.title !== undefined ? { title: dto.title } : {}),
|
||||||
|
...(dto.firstName !== undefined ? { firstName: dto.firstName } : {}),
|
||||||
|
...(dto.lastName !== undefined ? { lastName: dto.lastName } : {}),
|
||||||
|
...(dto.category !== undefined ? { category: dto.category } : {}),
|
||||||
|
...(dto.cellNumber !== undefined ? { cellNumber: dto.cellNumber } : {}),
|
||||||
|
...(dto.disabled !== undefined ? { disabled: dto.disabled } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.serializeUser(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateRole(actor: AuthUser, id: string, dto: UpdateUserRoleDto) {
|
||||||
|
if (actor.role !== UserRole.superAdmin) {
|
||||||
|
throw new ForbiddenException('فقط سوپرادمین میتواند نقش را تغییر دهد');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.ensureExists(id);
|
||||||
|
|
||||||
|
const user = await this.prisma.user.update({
|
||||||
|
where: { id },
|
||||||
|
data: { role: dto.role },
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.serializeUser(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updatePassword(id: string, dto: UpdateUserPasswordDto) {
|
||||||
|
await this.ensureExists(id);
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(dto.password, 10);
|
||||||
|
const user = await this.prisma.user.update({
|
||||||
|
where: { id },
|
||||||
|
data: { passwordHash },
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.serializeUser(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(actor: AuthUser, id: string) {
|
||||||
|
if (actor.id === id) {
|
||||||
|
throw new BadRequestException('امکان حذف حساب خود وجود ندارد');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.ensureExists(id);
|
||||||
|
await this.prisma.user.delete({ where: { id } });
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async listAddresses(userId: string) {
|
||||||
|
await this.ensureExists(userId);
|
||||||
|
return this.prisma.userAddress.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async createAddress(userId: string, dto: CreateUserAddressDto) {
|
||||||
|
await this.ensureExists(userId);
|
||||||
|
this.assertDistrict(dto.district);
|
||||||
|
|
||||||
|
return this.prisma.userAddress.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
name: dto.name.trim(),
|
||||||
|
district: dto.district,
|
||||||
|
address: dto.address.trim(),
|
||||||
|
landline: dto.landline?.trim() ?? '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateAddress(
|
||||||
|
userId: string,
|
||||||
|
addressId: string,
|
||||||
|
dto: UpdateUserAddressDto,
|
||||||
|
) {
|
||||||
|
await this.ensureAddress(userId, addressId);
|
||||||
|
if (dto.district !== undefined) {
|
||||||
|
this.assertDistrict(dto.district);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.userAddress.update({
|
||||||
|
where: { id: addressId },
|
||||||
|
data: {
|
||||||
|
...(dto.name !== undefined ? { name: dto.name.trim() } : {}),
|
||||||
|
...(dto.district !== undefined ? { district: dto.district } : {}),
|
||||||
|
...(dto.address !== undefined ? { address: dto.address.trim() } : {}),
|
||||||
|
...(dto.landline !== undefined
|
||||||
|
? { landline: dto.landline.trim() }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeAddress(userId: string, addressId: string) {
|
||||||
|
await this.ensureAddress(userId, addressId);
|
||||||
|
await this.prisma.userAddress.delete({ where: { id: addressId } });
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertDistrict(district: string) {
|
||||||
|
if (!isDistrict(district)) {
|
||||||
|
throw new BadRequestException('منطقه معتبر نیست');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureAddress(userId: string, addressId: string) {
|
||||||
|
await this.ensureExists(userId);
|
||||||
|
const address = await this.prisma.userAddress.findFirst({
|
||||||
|
where: { id: addressId, userId },
|
||||||
|
});
|
||||||
|
if (!address) {
|
||||||
|
throw new NotFoundException('آدرس یافت نشد');
|
||||||
|
}
|
||||||
|
return address;
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertCanAssignRole(actor: AuthUser, role: UserRole) {
|
||||||
|
if (role === UserRole.admin || role === UserRole.superAdmin) {
|
||||||
|
if (actor.role !== UserRole.superAdmin) {
|
||||||
|
throw new ForbiddenException('فقط سوپرادمین میتواند ادمین ایجاد کند');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureExists(id: string) {
|
||||||
|
const user = await this.prisma.user.findUnique({ where: { id } });
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException('کاربر یافت نشد');
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private serializeUser(user: User) {
|
||||||
|
const { passwordHash: _passwordHash, ...rest } = user;
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
name: displayName(user),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { App } from 'supertest/types';
|
||||||
|
import { AppModule } from './../src/app.module';
|
||||||
|
|
||||||
|
describe('Auth (e2e)', () => {
|
||||||
|
let app: INestApplication<App>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||||
|
imports: [AppModule],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
app = moduleFixture.createNestApplication();
|
||||||
|
app.setGlobalPrefix('api/v1');
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
transform: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await app.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects login with invalid body', () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post('/api/v1/auth/login')
|
||||||
|
.send({})
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"moduleFileExtensions": ["js", "json", "ts"],
|
||||||
|
"rootDir": ".",
|
||||||
|
"testEnvironment": "node",
|
||||||
|
"testRegex": ".e2e-spec.ts$",
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "nodenext",
|
||||||
|
"moduleResolution": "nodenext",
|
||||||
|
"resolvePackageJsonExports": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"declaration": true,
|
||||||
|
"removeComments": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"target": "ES2023",
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"baseUrl": "./",
|
||||||
|
"incremental": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"strictBindCallApply": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user