Initial commit of Balout Pastry NestJS API.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-02 17:21:34 +03:30
co-authored by Cursor
commit f40075fd5e
84 changed files with 15435 additions and 0 deletions
+30
View File
@@ -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
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}
+67
View File
@@ -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)
+25
View File
@@ -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:
+35
View File
@@ -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" }],
},
},
);
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
+11346
View File
File diff suppressed because it is too large Load Diff
+91
View File
@@ -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;
+1
View File
@@ -0,0 +1 @@
provider = "postgresql"
+256
View File
@@ -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])
}
+65
View File
@@ -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();
});
+29
View File
@@ -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 {}
+37
View File
@@ -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);
}
}
+31
View File
@@ -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 {}
+149
View File
@@ -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;
}
}
+22
View File
@@ -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;
},
);
+5
View File
@@ -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);
+13
View File
@@ -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;
}
+7
View File
@@ -0,0 +1,7 @@
import { IsString, MinLength } from 'class-validator';
export class RefreshTokenDto {
@IsString()
@MinLength(20)
refreshToken!: string;
}
+5
View File
@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
+35
View File
@@ -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;
}
}
+45
View File
@@ -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,
};
}
}
+59
View File
@@ -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);
}
}
+10
View File
@@ -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 {}
+137
View File
@@ -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;
}
}
+31
View File
@@ -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;
}
+19
View File
@@ -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[];
}
+21
View File
@@ -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;
}
+29
View File
@@ -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);
}
+11
View File
@@ -0,0 +1,11 @@
import { IsString, MinLength } from 'class-validator';
export class CreateFlavorDto {
@IsString()
@MinLength(1)
nameFa!: string;
@IsString()
@MinLength(1)
nameEn!: string;
}
+13
View File
@@ -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;
}
+44
View File
@@ -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);
}
}
+10
View File
@@ -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 {}
+52
View File
@@ -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
View File
@@ -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();
+9
View File
@@ -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;
}
+36
View File
@@ -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);
}
}
+12
View 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 {}
+82
View File
@@ -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('نوع فایل نامعتبر است');
}
}
}
+60
View File
@@ -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[];
}
+37
View File
@@ -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;
}
+45
View File
@@ -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);
}
}
+10
View File
@@ -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 {}
+336
View File
@@ -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,
})),
};
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
+16
View File
@@ -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();
}
}
+88
View File
@@ -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;
}
+74
View File
@@ -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[];
}
+51
View File
@@ -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);
}
}
+12
View File
@@ -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 {}
+281
View File
@@ -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(
'گزینه محصول با قالب دسته‌بندی مطابقت ندارد',
);
}
}
}
}
+23
View File
@@ -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;
}
+26
View File
@@ -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;
}
+25
View File
@@ -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[];
}
+61
View File
@@ -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);
}
}
+10
View File
@@ -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 {}
+105
View File
@@ -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;
}
}
+85
View File
@@ -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,
}),
);
}
}
+36
View File
@@ -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}`;
}
+9
View File
@@ -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 {}
+20
View File
@@ -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);
}
}
+11
View File
@@ -0,0 +1,11 @@
export type StoredObject = {
storageDisk: string;
storagePath: string;
publicUrl: string;
};
export type UploadObjectInput = {
key: string;
body: Buffer;
contentType: string;
};
+19
View File
@@ -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;
}
+44
View File
@@ -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;
}
+43
View File
@@ -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;
}
+22
View File
@@ -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;
}
+7
View File
@@ -0,0 +1,7 @@
import { UserRole } from '@prisma/client';
import { IsEnum } from 'class-validator';
export class UpdateUserRoleDto {
@IsEnum(UserRole, { message: 'نقش نامعتبر است' })
role!: UserRole;
}
+38
View File
@@ -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;
}
+13
View File
@@ -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];
+102
View File
@@ -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);
}
}
+10
View File
@@ -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 {}
+254
View File
@@ -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),
};
}
}
+37
View File
@@ -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();
});
});
+9
View File
@@ -0,0 +1,9 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}
+25
View File
@@ -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
}
}