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