Initial commit of the Mado Motor NestJS API.
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
# PostgreSQL (Docker Compose)
|
||||
POSTGRES_HOST=localhost
|
||||
POSTGRES_PORT=5435
|
||||
POSTGRES_USER=mado
|
||||
POSTGRES_PASSWORD=mado_secret
|
||||
POSTGRES_DB=mado_motor
|
||||
|
||||
DATABASE_URL=postgresql://mado:mado_secret@localhost:5435/mado_motor
|
||||
|
||||
# API
|
||||
PORT=3001
|
||||
PUBLIC_SITE_ORIGIN=https://madomotor.ir
|
||||
CORS_ORIGIN=http://manage.madomotor.com:5175,http://localhost:5175,http://127.0.0.1:5175,http://madomotor.com:5174,http://localhost:5174
|
||||
|
||||
# JWT
|
||||
JWT_ACCESS_SECRET=change-me-mado-access-secret-min-32-chars
|
||||
JWT_REFRESH_SECRET=change-me-mado-refresh-secret-min-32-chars
|
||||
JWT_ACCESS_EXPIRES_IN=15m
|
||||
JWT_REFRESH_EXPIRES_IN=7d
|
||||
|
||||
# Object storage (same Parspack bucket as Balout/Meshkee — prefix mado-motor/)
|
||||
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
|
||||
|
||||
# Meshkee SMS (server-side only — never expose to frontend)
|
||||
# Add madomotor.ir to Meshkee SMS_PARTNERS with this API key
|
||||
MESHKEE_SMS_URL=https://api.meshkee.com/api/v1/public/sms/send
|
||||
MESHKEE_SMS_API_KEY=
|
||||
MESHKEE_SMS_DOMAIN=madomotor.ir
|
||||
# Comma-separated admin mobiles for call/representation alerts
|
||||
SMS_NOTIFY_MOBILES=09127004945
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
scripts/data/
|
||||
@@ -0,0 +1,72 @@
|
||||
# Mado Motor Admin API
|
||||
|
||||
NestJS + Prisma + PostgreSQL backend for the admin dashboard.
|
||||
|
||||
## Features
|
||||
|
||||
- **Auth**: phone (`09…`) + password, OTP one-time login, forgot-password OTP, register OTP
|
||||
- **SMS**: Meshkee partner gateway (same as Balout)
|
||||
- **Images**: Parspack S3 (same bucket as Balout/Meshkee), folder prefix `mado-motor/`
|
||||
- **Admin CRUD**: products, orders, customers, blogs, representations, reviews, call-requests, media upload
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
cp .env.example .env # fill S3 + Meshkee SMS + JWT secrets
|
||||
npm install
|
||||
npm run db:up
|
||||
npx prisma migrate dev
|
||||
npm run create-super-admin -- --phone 09127004945 --password 'your-pass' --first علیرضا --last حسنی
|
||||
npm run start:dev
|
||||
```
|
||||
|
||||
API: `http://localhost:3001/api/v1`
|
||||
Dashboard expects: `VITE_API_URL=http://api.madomotor.com:3001` (add hosts entry → `127.0.0.1`)
|
||||
|
||||
## Storage layout
|
||||
|
||||
```
|
||||
mado-motor/
|
||||
products/main/{uuid}.jpg
|
||||
products/gallery/{uuid}.jpg
|
||||
blogs/{uuid}.jpg
|
||||
users/{uuid}.jpg
|
||||
temp/{uuid}.jpg
|
||||
```
|
||||
|
||||
Upload: `POST /api/v1/media/upload?kind=main|gallery|blog|temp` (multipart field `file`, admin JWT)
|
||||
|
||||
## SMS (Meshkee)
|
||||
|
||||
Add partner on Meshkee production:
|
||||
|
||||
```
|
||||
SMS_PARTNERS=…,madomotor.ir:<long-random-secret>
|
||||
```
|
||||
|
||||
Then set in `.env`:
|
||||
|
||||
```
|
||||
MESHKEE_SMS_URL=https://api.meshkee.com/api/v1/public/sms/send
|
||||
MESHKEE_SMS_API_KEY=<that-secret>
|
||||
MESHKEE_SMS_DOMAIN=madomotor.ir
|
||||
```
|
||||
|
||||
## Auth routes
|
||||
|
||||
| Method | Path | Notes |
|
||||
|--------|------|-------|
|
||||
| POST | `/auth/login` | `{ cellNumber, password }` |
|
||||
| POST | `/auth/login/send-code` | OTP login |
|
||||
| POST | `/auth/login/verify` | `{ cellNumber, code }` |
|
||||
| POST | `/auth/forgot-password/*` | send-code / verify / reset |
|
||||
| POST | `/auth/register/*` | send-code / verify |
|
||||
| POST | `/auth/refresh` | `{ refreshToken }` |
|
||||
| GET | `/auth/me` | Bearer |
|
||||
|
||||
## Test SMS
|
||||
|
||||
```bash
|
||||
npm run send-sms -- --to 09127004945 --message "test"
|
||||
```
|
||||
@@ -0,0 +1,25 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: mado-postgres
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:${POSTGRES_PORT:-5435}:5432"
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-mado}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-mado_secret}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-mado_motor}
|
||||
volumes:
|
||||
- mado_postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"pg_isready -U ${POSTGRES_USER:-mado} -d ${POSTGRES_DB:-mado_motor}",
|
||||
]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
mado_postgres_data:
|
||||
@@ -0,0 +1,35 @@
|
||||
// @ts-check
|
||||
import eslint from '@eslint/js';
|
||||
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
||||
import globals from 'globals';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: ['eslint.config.mjs'],
|
||||
},
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
eslintPluginPrettierRecommended,
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
...globals.jest,
|
||||
},
|
||||
sourceType: 'commonjs',
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-floating-promises': 'warn',
|
||||
'@typescript-eslint/no-unsafe-argument': 'warn',
|
||||
"prettier/prettier": ["error", { endOfLine: "auto" }],
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
Generated
+5613
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "madomotor-api",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"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",
|
||||
"send-sms": "ts-node -r tsconfig-paths/register scripts/send-sms.ts",
|
||||
"import:legacy": "ts-node -r tsconfig-paths/register scripts/import-legacy-export.ts --replace",
|
||||
"migrate:images": "ts-node -r tsconfig-paths/register scripts/migrate-images-to-s3.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": {
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/multer": "^2.2.0",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"prisma": "^6.19.3",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "UserRole" AS ENUM ('User', 'Admin', 'Agent', 'superAdmin');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "OrderStatus" AS ENUM ('pending', 'confirmed', 'preparing', 'shipped', 'delivered', 'cancelled');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "RepresentationStatus" AS ENUM ('pending', 'active', 'rejected');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ReviewStatus" AS ENUM ('pending', 'approved', 'rejected');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "SmsOtpPurpose" AS ENUM ('register', 'login', 'resetPassword');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" TEXT NOT NULL,
|
||||
"firstName" TEXT NOT NULL,
|
||||
"lastName" TEXT NOT NULL,
|
||||
"cellNumber" TEXT NOT NULL,
|
||||
"passwordHash" TEXT NOT NULL,
|
||||
"city" TEXT NOT NULL DEFAULT '',
|
||||
"role" "UserRole" NOT NULL DEFAULT 'User',
|
||||
"disabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
"ordersCount" 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 "SmsOtp" (
|
||||
"id" TEXT NOT NULL,
|
||||
"cellNumber" TEXT NOT NULL,
|
||||
"purpose" "SmsOtpPurpose" NOT NULL,
|
||||
"codeHash" TEXT NOT NULL,
|
||||
"payload" JSONB,
|
||||
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "SmsOtp_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Product" (
|
||||
"id" TEXT NOT NULL,
|
||||
"nameFa" TEXT NOT NULL,
|
||||
"nameEn" TEXT NOT NULL DEFAULT '',
|
||||
"price" INTEGER NOT NULL DEFAULT 0,
|
||||
"stock" INTEGER NOT NULL DEFAULT 0,
|
||||
"category" TEXT NOT NULL DEFAULT '',
|
||||
"imageUrl" TEXT,
|
||||
"imageKey" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Product_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Order" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"customerId" TEXT,
|
||||
"customerName" TEXT NOT NULL,
|
||||
"customerPhone" TEXT NOT NULL,
|
||||
"status" "OrderStatus" NOT NULL DEFAULT 'pending',
|
||||
"itemCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"note" TEXT NOT NULL DEFAULT '',
|
||||
"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" INTEGER NOT NULL,
|
||||
"productId" TEXT,
|
||||
"nameFa" TEXT NOT NULL,
|
||||
"quantity" INTEGER NOT NULL DEFAULT 1,
|
||||
"unitPrice" INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
CONSTRAINT "OrderItem_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "BlogPost" (
|
||||
"id" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"summary" TEXT NOT NULL DEFAULT '',
|
||||
"body" TEXT NOT NULL DEFAULT '',
|
||||
"author" TEXT NOT NULL DEFAULT '',
|
||||
"imageUrl" TEXT,
|
||||
"imageKey" TEXT,
|
||||
"published" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "BlogPost_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Representation" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT,
|
||||
"name" TEXT NOT NULL,
|
||||
"owner" TEXT NOT NULL DEFAULT '',
|
||||
"mobile" TEXT NOT NULL,
|
||||
"city" TEXT NOT NULL,
|
||||
"address" TEXT NOT NULL DEFAULT '',
|
||||
"status" "RepresentationStatus" NOT NULL DEFAULT 'pending',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Representation_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Review" (
|
||||
"id" TEXT NOT NULL,
|
||||
"authorId" TEXT,
|
||||
"author" TEXT NOT NULL,
|
||||
"product" TEXT NOT NULL,
|
||||
"rating" INTEGER NOT NULL,
|
||||
"body" TEXT NOT NULL DEFAULT '',
|
||||
"status" "ReviewStatus" NOT NULL DEFAULT 'pending',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Review_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "CallRequest" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT,
|
||||
"name" TEXT NOT NULL,
|
||||
"mobile" TEXT NOT NULL,
|
||||
"note" TEXT NOT NULL DEFAULT '',
|
||||
"handled" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "CallRequest_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 "SmsOtp_cellNumber_purpose_idx" ON "SmsOtp"("cellNumber", "purpose");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SmsOtp_expiresAt_idx" ON "SmsOtp"("expiresAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Product_category_idx" ON "Product"("category");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Product_price_idx" ON "Product"("price");
|
||||
|
||||
-- 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 "OrderItem_orderId_idx" ON "OrderItem"("orderId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "OrderItem_productId_idx" ON "OrderItem"("productId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Representation_status_idx" ON "Representation"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Representation_userId_idx" ON "Representation"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Review_status_idx" ON "Review"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "CallRequest_handled_idx" ON "CallRequest"("handled");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "CallRequest_createdAt_idx" ON "CallRequest"("createdAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RefreshToken" ADD CONSTRAINT "RefreshToken_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 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 "Representation" ADD CONSTRAINT "Representation_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Review" ADD CONSTRAINT "Review_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CallRequest" ADD CONSTRAINT "CallRequest_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Representation" ADD COLUMN "description" TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN "firstName" TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN "landline" TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN "lastName" TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN "nationalCode" TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN "postalCode" TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN "province" TEXT NOT NULL DEFAULT '';
|
||||
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
@@ -0,0 +1,206 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
enum UserRole {
|
||||
User
|
||||
Admin
|
||||
Agent
|
||||
superAdmin
|
||||
}
|
||||
|
||||
enum OrderStatus {
|
||||
pending
|
||||
confirmed
|
||||
preparing
|
||||
shipped
|
||||
delivered
|
||||
cancelled
|
||||
}
|
||||
|
||||
enum RepresentationStatus {
|
||||
pending
|
||||
active
|
||||
rejected
|
||||
}
|
||||
|
||||
enum ReviewStatus {
|
||||
pending
|
||||
approved
|
||||
rejected
|
||||
}
|
||||
|
||||
enum SmsOtpPurpose {
|
||||
register
|
||||
login
|
||||
resetPassword
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
firstName String
|
||||
lastName String
|
||||
cellNumber String @unique
|
||||
passwordHash String
|
||||
city String @default("")
|
||||
role UserRole @default(User)
|
||||
disabled Boolean @default(false)
|
||||
ordersCount Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
refreshTokens RefreshToken[]
|
||||
orders Order[]
|
||||
reviews Review[]
|
||||
callRequests CallRequest[]
|
||||
representations Representation[]
|
||||
|
||||
@@index([role])
|
||||
@@index([disabled])
|
||||
}
|
||||
|
||||
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 SmsOtp {
|
||||
id String @id @default(cuid())
|
||||
cellNumber String
|
||||
purpose SmsOtpPurpose
|
||||
codeHash String
|
||||
payload Json?
|
||||
attempts Int @default(0)
|
||||
expiresAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([cellNumber, purpose])
|
||||
@@index([expiresAt])
|
||||
}
|
||||
|
||||
model Product {
|
||||
id String @id @default(cuid())
|
||||
nameFa String
|
||||
nameEn String @default("")
|
||||
price Int @default(0)
|
||||
stock Int @default(0)
|
||||
category String @default("")
|
||||
imageUrl String?
|
||||
imageKey String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
orderItems OrderItem[]
|
||||
|
||||
@@index([category])
|
||||
@@index([price])
|
||||
}
|
||||
|
||||
model Order {
|
||||
id Int @id @default(autoincrement())
|
||||
customerId String?
|
||||
customer User? @relation(fields: [customerId], references: [id], onDelete: SetNull)
|
||||
customerName String
|
||||
customerPhone String
|
||||
status OrderStatus @default(pending)
|
||||
itemCount Int @default(0)
|
||||
note String @default("")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
items OrderItem[]
|
||||
|
||||
@@index([customerId])
|
||||
@@index([status])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
model OrderItem {
|
||||
id String @id @default(cuid())
|
||||
orderId Int
|
||||
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||||
productId String?
|
||||
product Product? @relation(fields: [productId], references: [id], onDelete: SetNull)
|
||||
nameFa String
|
||||
quantity Int @default(1)
|
||||
unitPrice Int @default(0)
|
||||
|
||||
@@index([orderId])
|
||||
@@index([productId])
|
||||
}
|
||||
|
||||
model BlogPost {
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
summary String @default("")
|
||||
body String @default("")
|
||||
author String @default("")
|
||||
imageUrl String?
|
||||
imageKey String?
|
||||
published Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Representation {
|
||||
id String @id @default(cuid())
|
||||
userId String?
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
name String
|
||||
owner String @default("")
|
||||
firstName String @default("")
|
||||
lastName String @default("")
|
||||
nationalCode String @default("")
|
||||
mobile String
|
||||
province String @default("")
|
||||
city String
|
||||
landline String @default("")
|
||||
address String @default("")
|
||||
postalCode String @default("")
|
||||
description String @default("")
|
||||
status RepresentationStatus @default(pending)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([status])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model Review {
|
||||
id String @id @default(cuid())
|
||||
authorId String?
|
||||
authorUser User? @relation(fields: [authorId], references: [id], onDelete: SetNull)
|
||||
author String
|
||||
product String
|
||||
rating Int
|
||||
body String @default("")
|
||||
status ReviewStatus @default(pending)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([status])
|
||||
}
|
||||
|
||||
model CallRequest {
|
||||
id String @id @default(cuid())
|
||||
userId String?
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
name String
|
||||
mobile String
|
||||
note String @default("")
|
||||
handled Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([handled])
|
||||
@@index([createdAt])
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Create the first superAdmin.
|
||||
*
|
||||
* Usage:
|
||||
* npx ts-node -r tsconfig-paths/register scripts/create-super-admin.ts \
|
||||
* --phone 09127004945 --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', 'حسنی')
|
||||
|
||||
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: {
|
||||
firstName,
|
||||
lastName,
|
||||
cellNumber,
|
||||
passwordHash,
|
||||
role: UserRole.superAdmin,
|
||||
},
|
||||
})
|
||||
|
||||
console.log('Created superAdmin:', {
|
||||
id: user.id,
|
||||
cellNumber: user.cellNumber,
|
||||
name: `${user.firstName} ${user.lastName}`,
|
||||
})
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error(err.message || err)
|
||||
process.exit(1)
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
@@ -0,0 +1,396 @@
|
||||
/**
|
||||
* Import JSON exported from production MySQL (VM) into local Postgres.
|
||||
*
|
||||
* Usage:
|
||||
* npx ts-node -r tsconfig-paths/register scripts/import-legacy-export.ts
|
||||
*
|
||||
* Optional:
|
||||
* --dir scripts/data
|
||||
* --replace wipe products/blogs/representations (and non-kept users) before import
|
||||
*/
|
||||
import { promises as fs } from 'fs'
|
||||
import * as path from 'path'
|
||||
import {
|
||||
PrismaClient,
|
||||
RepresentationStatus,
|
||||
UserRole,
|
||||
} from '@prisma/client'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
function arg(name: string, fallback?: string): string | undefined {
|
||||
const idx = process.argv.indexOf(`--${name}`)
|
||||
if (idx >= 0 && process.argv[idx + 1] && !process.argv[idx + 1].startsWith('--')) {
|
||||
return process.argv[idx + 1]
|
||||
}
|
||||
if (process.argv.includes(`--${name}`)) return 'true'
|
||||
return fallback
|
||||
}
|
||||
|
||||
function legacyId(prefix: string, id: string | number): string {
|
||||
return `legacy-${prefix}-${id}`
|
||||
}
|
||||
|
||||
function normalizePhone(raw: string | null | undefined): string {
|
||||
const digits = String(raw ?? '').replace(/\D/g, '')
|
||||
if (digits.length === 10 && digits.startsWith('9')) return `0${digits}`
|
||||
if (digits.length === 11 && digits.startsWith('09')) return digits
|
||||
return digits || '09000000000'
|
||||
}
|
||||
|
||||
function mapRole(role: string | null | undefined, cellNumber: string): UserRole {
|
||||
if (cellNumber === '09127004945') return UserRole.superAdmin
|
||||
switch (role) {
|
||||
case 'Admin':
|
||||
return UserRole.Admin
|
||||
case 'Agent':
|
||||
return UserRole.Agent
|
||||
default:
|
||||
return UserRole.User
|
||||
}
|
||||
}
|
||||
|
||||
function mapRequestStatus(status: number): RepresentationStatus {
|
||||
if (status === 2) return RepresentationStatus.active
|
||||
if (status === 1) return RepresentationStatus.rejected
|
||||
return RepresentationStatus.pending
|
||||
}
|
||||
|
||||
function toDate(value: string | null | undefined): Date | undefined {
|
||||
if (!value) return undefined
|
||||
const d = new Date(value)
|
||||
return Number.isNaN(d.getTime()) ? undefined : d
|
||||
}
|
||||
|
||||
function phpBcryptToNode(hash: string): string {
|
||||
// Node bcrypt accepts $2y$ in most versions; normalize to $2b$ just in case.
|
||||
if (hash.startsWith('$2y$')) return `$2b$${hash.slice(4)}`
|
||||
return hash
|
||||
}
|
||||
|
||||
async function readJson<T>(file: string): Promise<T> {
|
||||
const raw = await fs.readFile(file, 'utf8')
|
||||
return JSON.parse(raw) as T
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const dir = path.resolve(
|
||||
process.cwd(),
|
||||
arg('dir', 'scripts/data') ?? 'scripts/data',
|
||||
)
|
||||
const replace = Boolean(arg('replace'))
|
||||
|
||||
type UserRow = {
|
||||
id: string
|
||||
first_name: string
|
||||
last_name: string
|
||||
mobile: string
|
||||
password: string
|
||||
role: string
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
type ProductRow = {
|
||||
id: string
|
||||
title_fa: string
|
||||
title_en: string
|
||||
price: string
|
||||
quantity: string
|
||||
category: string
|
||||
description: string
|
||||
image_url: string | null
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
type BlogRow = {
|
||||
id: string
|
||||
title: string
|
||||
summary: string
|
||||
body: string
|
||||
author: string
|
||||
published: boolean
|
||||
image_url: string | null
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
type AgentRow = {
|
||||
id: string
|
||||
name: string
|
||||
owner: string
|
||||
national_code: string
|
||||
mobile: string
|
||||
phone: string
|
||||
postal_code: string
|
||||
address: string
|
||||
description: string
|
||||
city: string
|
||||
province: string
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
type RequestRow = {
|
||||
id: string
|
||||
first_name: string
|
||||
last_name: string
|
||||
national_code: string
|
||||
mobile: string
|
||||
phone: string
|
||||
address: string
|
||||
postal_code: string
|
||||
description: string
|
||||
status: number
|
||||
city: string
|
||||
province: string
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
const users = await readJson<UserRow[]>(path.join(dir, 'users.json'))
|
||||
const products = await readJson<ProductRow[]>(path.join(dir, 'products.json'))
|
||||
const blogs = await readJson<BlogRow[]>(path.join(dir, 'blogs.json'))
|
||||
const agents = await readJson<AgentRow[]>(path.join(dir, 'agents.json'))
|
||||
const requests = await readJson<RequestRow[]>(
|
||||
path.join(dir, 'agent_requests.json'),
|
||||
)
|
||||
|
||||
if (replace) {
|
||||
console.log('Clearing existing catalog / representation / non-superAdmin users…')
|
||||
await prisma.orderItem.deleteMany({})
|
||||
await prisma.order.deleteMany({})
|
||||
await prisma.review.deleteMany({})
|
||||
await prisma.callRequest.deleteMany({})
|
||||
await prisma.representation.deleteMany({})
|
||||
await prisma.blogPost.deleteMany({})
|
||||
await prisma.product.deleteMany({})
|
||||
await prisma.refreshToken.deleteMany({})
|
||||
await prisma.smsOtp.deleteMany({})
|
||||
await prisma.user.deleteMany({
|
||||
where: { role: { not: UserRole.superAdmin } },
|
||||
})
|
||||
}
|
||||
|
||||
let usersUpserted = 0
|
||||
for (const u of users) {
|
||||
const cellNumber = normalizePhone(u.mobile)
|
||||
const id = legacyId('user', u.id)
|
||||
const role = mapRole(u.role, cellNumber)
|
||||
const passwordHash = phpBcryptToNode(u.password)
|
||||
const data = {
|
||||
firstName: (u.first_name || '—').slice(0, 190),
|
||||
lastName: (u.last_name || '—').slice(0, 190),
|
||||
cellNumber,
|
||||
passwordHash,
|
||||
role,
|
||||
disabled: false,
|
||||
createdAt: toDate(u.created_at),
|
||||
updatedAt: toDate(u.updated_at),
|
||||
}
|
||||
|
||||
const byPhone = await prisma.user.findUnique({ where: { cellNumber } })
|
||||
if (byPhone) {
|
||||
await prisma.user.update({
|
||||
where: { id: byPhone.id },
|
||||
data: {
|
||||
firstName: data.firstName,
|
||||
lastName: data.lastName,
|
||||
passwordHash: data.passwordHash,
|
||||
// keep local superAdmin elevation
|
||||
role:
|
||||
byPhone.role === UserRole.superAdmin || role === UserRole.superAdmin
|
||||
? UserRole.superAdmin
|
||||
: role,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
await prisma.user.upsert({
|
||||
where: { id },
|
||||
create: { id, ...data },
|
||||
update: {
|
||||
firstName: data.firstName,
|
||||
lastName: data.lastName,
|
||||
cellNumber: data.cellNumber,
|
||||
passwordHash: data.passwordHash,
|
||||
role: data.role,
|
||||
},
|
||||
})
|
||||
}
|
||||
usersUpserted += 1
|
||||
}
|
||||
|
||||
let productsUpserted = 0
|
||||
for (const p of products) {
|
||||
const id = legacyId('product', p.id)
|
||||
const price = Number.parseInt(String(p.price || '0'), 10) || 0
|
||||
const stock = Number.parseInt(String(p.quantity || '0'), 10) || 0
|
||||
await prisma.product.upsert({
|
||||
where: { id },
|
||||
create: {
|
||||
id,
|
||||
nameFa: p.title_fa || 'بدون عنوان',
|
||||
nameEn: p.title_en || '',
|
||||
price,
|
||||
stock,
|
||||
category: p.category || '',
|
||||
imageUrl: p.image_url || null,
|
||||
createdAt: toDate(p.created_at),
|
||||
updatedAt: toDate(p.updated_at),
|
||||
},
|
||||
update: {
|
||||
nameFa: p.title_fa || 'بدون عنوان',
|
||||
nameEn: p.title_en || '',
|
||||
price,
|
||||
stock,
|
||||
category: p.category || '',
|
||||
imageUrl: p.image_url || null,
|
||||
},
|
||||
})
|
||||
productsUpserted += 1
|
||||
}
|
||||
|
||||
let blogsUpserted = 0
|
||||
for (const b of blogs) {
|
||||
const id = legacyId('blog', b.id)
|
||||
await prisma.blogPost.upsert({
|
||||
where: { id },
|
||||
create: {
|
||||
id,
|
||||
title: b.title || 'بدون عنوان',
|
||||
summary: b.summary || '',
|
||||
body: b.body || '',
|
||||
author: (b.author || '').trim(),
|
||||
imageUrl: b.image_url || null,
|
||||
published: Boolean(b.published),
|
||||
createdAt: toDate(b.created_at),
|
||||
updatedAt: toDate(b.updated_at),
|
||||
},
|
||||
update: {
|
||||
title: b.title || 'بدون عنوان',
|
||||
summary: b.summary || '',
|
||||
body: b.body || '',
|
||||
author: (b.author || '').trim(),
|
||||
imageUrl: b.image_url || null,
|
||||
published: Boolean(b.published),
|
||||
},
|
||||
})
|
||||
blogsUpserted += 1
|
||||
}
|
||||
|
||||
let repsUpserted = 0
|
||||
for (const a of agents) {
|
||||
const id = legacyId('agent', a.id)
|
||||
const mobile = normalizePhone(a.mobile)
|
||||
const owner = a.owner || a.name || ''
|
||||
const nameParts = owner.trim().split(/\s+/)
|
||||
await prisma.representation.upsert({
|
||||
where: { id },
|
||||
create: {
|
||||
id,
|
||||
name: a.name || owner || `نمایندگی ${a.id}`,
|
||||
owner,
|
||||
firstName: nameParts[0] || '',
|
||||
lastName: nameParts.slice(1).join(' '),
|
||||
nationalCode: a.national_code || '',
|
||||
mobile,
|
||||
province: a.province || '',
|
||||
city: a.city || '',
|
||||
landline: a.phone || '',
|
||||
address: a.address || '',
|
||||
postalCode: a.postal_code || '',
|
||||
description: a.description || '',
|
||||
status: RepresentationStatus.active,
|
||||
createdAt: toDate(a.created_at),
|
||||
updatedAt: toDate(a.updated_at),
|
||||
},
|
||||
update: {
|
||||
name: a.name || owner || `نمایندگی ${a.id}`,
|
||||
owner,
|
||||
nationalCode: a.national_code || '',
|
||||
mobile,
|
||||
province: a.province || '',
|
||||
city: a.city || '',
|
||||
landline: a.phone || '',
|
||||
address: a.address || '',
|
||||
postalCode: a.postal_code || '',
|
||||
description: a.description || '',
|
||||
status: RepresentationStatus.active,
|
||||
},
|
||||
})
|
||||
repsUpserted += 1
|
||||
}
|
||||
|
||||
let requestsUpserted = 0
|
||||
for (const r of requests) {
|
||||
const id = legacyId('agent-request', r.id)
|
||||
const mobile = normalizePhone(r.mobile)
|
||||
const status = mapRequestStatus(Number(r.status) || 0)
|
||||
const name = `${r.first_name || ''} ${r.last_name || ''}`.trim()
|
||||
await prisma.representation.upsert({
|
||||
where: { id },
|
||||
create: {
|
||||
id,
|
||||
name: name || `درخواست ${r.id}`,
|
||||
owner: name,
|
||||
firstName: r.first_name || '',
|
||||
lastName: r.last_name || '',
|
||||
nationalCode: r.national_code || '',
|
||||
mobile,
|
||||
province: r.province || '',
|
||||
city: r.city || '',
|
||||
landline: r.phone || '',
|
||||
address: r.address || '',
|
||||
postalCode: r.postal_code || '',
|
||||
description: r.description || '',
|
||||
status,
|
||||
createdAt: toDate(r.created_at),
|
||||
updatedAt: toDate(r.updated_at),
|
||||
},
|
||||
update: {
|
||||
name: name || `درخواست ${r.id}`,
|
||||
owner: name,
|
||||
firstName: r.first_name || '',
|
||||
lastName: r.last_name || '',
|
||||
nationalCode: r.national_code || '',
|
||||
mobile,
|
||||
province: r.province || '',
|
||||
city: r.city || '',
|
||||
landline: r.phone || '',
|
||||
address: r.address || '',
|
||||
postalCode: r.postal_code || '',
|
||||
description: r.description || '',
|
||||
status,
|
||||
},
|
||||
})
|
||||
requestsUpserted += 1
|
||||
}
|
||||
|
||||
const counts = {
|
||||
users: await prisma.user.count(),
|
||||
products: await prisma.product.count(),
|
||||
blogs: await prisma.blogPost.count(),
|
||||
representations: await prisma.representation.count(),
|
||||
activeReps: await prisma.representation.count({
|
||||
where: { status: RepresentationStatus.active },
|
||||
}),
|
||||
pendingReps: await prisma.representation.count({
|
||||
where: { status: RepresentationStatus.pending },
|
||||
}),
|
||||
}
|
||||
|
||||
console.log('Imported:', {
|
||||
usersUpserted,
|
||||
productsUpserted,
|
||||
blogsUpserted,
|
||||
agentsAsActive: repsUpserted,
|
||||
agentRequests: requestsUpserted,
|
||||
})
|
||||
console.log('DB totals:', counts)
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error(err)
|
||||
process.exitCode = 1
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* Download images from legacy Parmin storage URLs and re-upload to our Parspack S3.
|
||||
* Updates Product/BlogPost imageUrl + imageKey, and rewrites URLs inside blog bodies.
|
||||
*
|
||||
* Usage:
|
||||
* npx ts-node -r tsconfig-paths/register scripts/migrate-images-to-s3.ts
|
||||
*/
|
||||
import { createHash, randomUUID } from 'crypto'
|
||||
import { extname } from 'path'
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
import { config as loadEnv } from 'dotenv'
|
||||
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'
|
||||
import {
|
||||
blogMediaKey,
|
||||
productMainKey,
|
||||
} from '../src/storage/storage-keys'
|
||||
|
||||
loadEnv()
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
const LEGACY_HOST_RE =
|
||||
/madomotor\.sas\.amin\.parminstorage\.ir|sas\.amin\.parminstorage\.ir/i
|
||||
|
||||
const EXT_MIME: Record<string, string> = {
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.webp': 'image/webp',
|
||||
'.gif': 'image/gif',
|
||||
}
|
||||
|
||||
function requireEnv(name: string): string {
|
||||
const v = process.env[name]
|
||||
if (!v) throw new Error(`Missing env ${name}`)
|
||||
return v
|
||||
}
|
||||
|
||||
function publicUrlBase(): string {
|
||||
return requireEnv('S3_PUBLIC_URL').replace(/\/$/, '')
|
||||
}
|
||||
|
||||
function isAlreadyOurs(url: string | null | undefined): boolean {
|
||||
if (!url) return false
|
||||
return url.startsWith(publicUrlBase())
|
||||
}
|
||||
|
||||
function isLegacyUrl(url: string): boolean {
|
||||
try {
|
||||
const u = new URL(url)
|
||||
return LEGACY_HOST_RE.test(u.hostname)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function guessExt(url: string, contentType: string | null): string {
|
||||
const pathPart = decodeURIComponent(new URL(url).pathname)
|
||||
const fromUrl = extname(pathPart).toLowerCase()
|
||||
if (fromUrl && fromUrl.length <= 5) return fromUrl
|
||||
if (contentType?.includes('png')) return '.png'
|
||||
if (contentType?.includes('webp')) return '.webp'
|
||||
if (contentType?.includes('gif')) return '.gif'
|
||||
return '.jpg'
|
||||
}
|
||||
|
||||
function mimeFor(ext: string, contentType: string | null): string {
|
||||
if (contentType && contentType.startsWith('image/')) {
|
||||
return contentType.split(';')[0].trim()
|
||||
}
|
||||
return EXT_MIME[ext] ?? 'image/jpeg'
|
||||
}
|
||||
|
||||
function extractUrls(text: string): string[] {
|
||||
const found = new Set<string>()
|
||||
const patterns = [
|
||||
/https?:\/\/[^\s"'<>]+/g,
|
||||
/src=["'](https?:\/\/[^"']+)["']/gi,
|
||||
]
|
||||
for (const re of patterns) {
|
||||
for (const match of text.matchAll(re)) {
|
||||
const url = (match[1] ?? match[0]).replace(/&/g, '&')
|
||||
found.add(url)
|
||||
}
|
||||
}
|
||||
return [...found]
|
||||
}
|
||||
|
||||
type Kind = 'product' | 'blog' | 'blog-body'
|
||||
|
||||
function encodeFetchUrl(url: string): string {
|
||||
try {
|
||||
const u = new URL(url)
|
||||
// Encode each path segment so Persian filenames download reliably
|
||||
u.pathname = u.pathname
|
||||
.split('/')
|
||||
.map((seg) => {
|
||||
if (!seg) return seg
|
||||
try {
|
||||
return encodeURIComponent(decodeURIComponent(seg))
|
||||
} catch {
|
||||
return encodeURIComponent(seg)
|
||||
}
|
||||
})
|
||||
.join('/')
|
||||
return u.toString()
|
||||
} catch {
|
||||
return encodeURI(url)
|
||||
}
|
||||
}
|
||||
|
||||
async function download(url: string): Promise<{
|
||||
buffer: Buffer
|
||||
contentType: string | null
|
||||
}> {
|
||||
const candidates = [url, encodeFetchUrl(url)]
|
||||
let lastError: Error | null = null
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const res = await fetch(candidate, {
|
||||
redirect: 'follow',
|
||||
headers: {
|
||||
'User-Agent': 'mado-motor-image-migrator/1.0',
|
||||
Accept: 'image/*,*/*',
|
||||
},
|
||||
})
|
||||
if (!res.ok) {
|
||||
lastError = new Error(`HTTP ${res.status} for ${candidate}`)
|
||||
continue
|
||||
}
|
||||
const contentType = res.headers.get('content-type')
|
||||
const buffer = Buffer.from(await res.arrayBuffer())
|
||||
if (buffer.length < 32) {
|
||||
lastError = new Error(`Tiny response (${buffer.length}b) for ${candidate}`)
|
||||
continue
|
||||
}
|
||||
return { buffer, contentType }
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err : new Error(String(err))
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError ?? new Error(`Failed to download ${url}`)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const endpoint = requireEnv('S3_ENDPOINT')
|
||||
const bucket = requireEnv('S3_BUCKET')
|
||||
const base = publicUrlBase()
|
||||
|
||||
const client = new S3Client({
|
||||
endpoint,
|
||||
region: process.env.S3_REGION || 'us-east-1',
|
||||
forcePathStyle: (process.env.S3_FORCE_PATH_STYLE || 'true') === 'true',
|
||||
credentials: {
|
||||
accessKeyId: requireEnv('S3_ACCESS_KEY_ID'),
|
||||
secretAccessKey: requireEnv('S3_SECRET_ACCESS_KEY'),
|
||||
},
|
||||
})
|
||||
|
||||
const products = await prisma.product.findMany({
|
||||
select: { id: true, imageUrl: true, imageKey: true },
|
||||
})
|
||||
const blogs = await prisma.blogPost.findMany({
|
||||
select: { id: true, imageUrl: true, imageKey: true, body: true },
|
||||
})
|
||||
|
||||
type Job = { url: string; kind: Kind; entityId?: string }
|
||||
const jobs: Job[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
const enqueue = (url: string, kind: Kind, entityId?: string) => {
|
||||
const clean = url.replace(/[),.;]+$/, '')
|
||||
if (!isLegacyUrl(clean) || isAlreadyOurs(clean)) return
|
||||
if (seen.has(clean)) return
|
||||
seen.add(clean)
|
||||
jobs.push({ url: clean, kind, entityId })
|
||||
}
|
||||
|
||||
for (const p of products) {
|
||||
if (p.imageUrl) enqueue(p.imageUrl, 'product', p.id)
|
||||
}
|
||||
for (const b of blogs) {
|
||||
if (b.imageUrl) enqueue(b.imageUrl, 'blog', b.id)
|
||||
for (const u of extractUrls(b.body || '')) {
|
||||
enqueue(u, 'blog-body')
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Found ${jobs.length} unique legacy URLs to migrate`)
|
||||
|
||||
const urlMap = new Map<string, { url: string; key: string }>()
|
||||
let ok = 0
|
||||
let failed = 0
|
||||
|
||||
for (const [i, job] of jobs.entries()) {
|
||||
const label = `[${i + 1}/${jobs.length}]`
|
||||
try {
|
||||
const { buffer, contentType } = await download(job.url)
|
||||
const ext = guessExt(job.url, contentType)
|
||||
const fileName = `${randomUUID()}${ext}`
|
||||
const key =
|
||||
job.kind === 'product'
|
||||
? productMainKey(fileName)
|
||||
: blogMediaKey(
|
||||
job.kind === 'blog-body' ? `body/${fileName}` : fileName,
|
||||
)
|
||||
const contentTypeFinal = mimeFor(ext, contentType)
|
||||
|
||||
await client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
Body: buffer,
|
||||
ContentType: contentTypeFinal,
|
||||
}),
|
||||
)
|
||||
|
||||
const publicUrl = `${base}/${key}`
|
||||
urlMap.set(job.url, { url: publicUrl, key })
|
||||
|
||||
// also map common encoded/decoded variants
|
||||
try {
|
||||
urlMap.set(decodeURI(job.url), { url: publicUrl, key })
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
urlMap.set(encodeURI(decodeURIComponent(job.url)), {
|
||||
url: publicUrl,
|
||||
key,
|
||||
})
|
||||
|
||||
ok += 1
|
||||
const hash = createHash('sha1').update(buffer).digest('hex').slice(0, 8)
|
||||
console.log(
|
||||
`${label} OK ${job.kind} ${buffer.length}b ${hash} → ${key}`,
|
||||
)
|
||||
} catch (err) {
|
||||
failed += 1
|
||||
console.error(
|
||||
`${label} FAIL ${job.url}`,
|
||||
err instanceof Error ? err.message : err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Update products
|
||||
let productsUpdated = 0
|
||||
for (const p of products) {
|
||||
if (!p.imageUrl) continue
|
||||
const mapped = urlMap.get(p.imageUrl) || urlMap.get(p.imageUrl.replace(/[),.;]+$/, ''))
|
||||
if (!mapped) continue
|
||||
await prisma.product.update({
|
||||
where: { id: p.id },
|
||||
data: { imageUrl: mapped.url, imageKey: mapped.key },
|
||||
})
|
||||
productsUpdated += 1
|
||||
}
|
||||
|
||||
// Update blogs (cover + body rewrite)
|
||||
let blogsUpdated = 0
|
||||
for (const b of blogs) {
|
||||
let body = b.body || ''
|
||||
let changed = false
|
||||
|
||||
for (const [oldUrl, mapped] of urlMap) {
|
||||
if (body.includes(oldUrl)) {
|
||||
body = body.split(oldUrl).join(mapped.url)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
// also rewrite any remaining legacy host URLs that we mapped by pathname
|
||||
const stillLegacy = extractUrls(body).filter(isLegacyUrl)
|
||||
for (const u of stillLegacy) {
|
||||
const clean = u.replace(/[),.;]+$/, '')
|
||||
const mapped = urlMap.get(clean)
|
||||
if (mapped && body.includes(u)) {
|
||||
body = body.split(u).join(mapped.url)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
const coverMapped = b.imageUrl
|
||||
? urlMap.get(b.imageUrl) ||
|
||||
urlMap.get(b.imageUrl.replace(/[),.;]+$/, ''))
|
||||
: undefined
|
||||
|
||||
if (!changed && !coverMapped) continue
|
||||
|
||||
await prisma.blogPost.update({
|
||||
where: { id: b.id },
|
||||
data: {
|
||||
...(coverMapped
|
||||
? { imageUrl: coverMapped.url, imageKey: coverMapped.key }
|
||||
: {}),
|
||||
...(changed ? { body } : {}),
|
||||
},
|
||||
})
|
||||
blogsUpdated += 1
|
||||
}
|
||||
|
||||
console.log('Done:', {
|
||||
migrated: ok,
|
||||
failed,
|
||||
productsUpdated,
|
||||
blogsUpdated,
|
||||
publicBase: base,
|
||||
})
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error(err)
|
||||
process.exitCode = 1
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Send an SMS via Meshkee (backend-only; never call from frontend).
|
||||
*
|
||||
* Usage:
|
||||
* npm run send-sms -- --to 09127004945 --message "متن پیام"
|
||||
*/
|
||||
import { config as loadEnv } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
|
||||
loadEnv({ path: resolve(__dirname, '../.env') });
|
||||
|
||||
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 to = arg('to');
|
||||
const message = arg('message');
|
||||
const apiUrl =
|
||||
process.env.MESHKEE_SMS_URL ??
|
||||
'https://api.meshkee.com/api/v1/public/sms/send';
|
||||
const apiKey = process.env.MESHKEE_SMS_API_KEY;
|
||||
const domain = process.env.MESHKEE_SMS_DOMAIN ?? 'madomotor.ir';
|
||||
|
||||
if (!apiKey) throw new Error('MESHKEE_SMS_API_KEY is not set in .env');
|
||||
if (!/^09\d{9}$/.test(to)) throw new Error('to must match 09xxxxxxxxx');
|
||||
if (!message.trim()) throw new Error('message is empty');
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Api-Key': apiKey,
|
||||
},
|
||||
body: JSON.stringify({ domain, to, message }),
|
||||
});
|
||||
|
||||
const body = await response.json().catch(() => null);
|
||||
if (!response.ok || !body?.success) {
|
||||
console.error('SMS send failed', { status: response.status, body });
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('SMS sent', { to, serverId: body.serverId });
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { BlogsModule } from './blogs/blogs.module';
|
||||
import { CallRequestsModule } from './call-requests/call-requests.module';
|
||||
import { CustomersModule } from './customers/customers.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 { PublicModule } from './public/public.module';
|
||||
import { RepresentationsModule } from './representations/representations.module';
|
||||
import { ReviewsModule } from './reviews/reviews.module';
|
||||
import { SitemapModule } from './sitemap/sitemap.module';
|
||||
import { SmsModule } from './sms/sms.module';
|
||||
import { StorageModule } from './storage/storage.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
PrismaModule,
|
||||
StorageModule,
|
||||
SmsModule,
|
||||
AuthModule,
|
||||
MediaModule,
|
||||
PublicModule,
|
||||
SitemapModule,
|
||||
ProductsModule,
|
||||
OrdersModule,
|
||||
CustomersModule,
|
||||
BlogsModule,
|
||||
RepresentationsModule,
|
||||
ReviewsModule,
|
||||
CallRequestsModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||
import type { AuthUser } from './auth.types';
|
||||
import { AuthService } from './auth.service';
|
||||
import { CurrentUser } from './decorators/current-user.decorator';
|
||||
import { CellNumberDto } from './dto/cell-number.dto';
|
||||
import { ForgotPasswordResetDto } from './dto/forgot-password-reset.dto';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { OtpVerifyDto } from './dto/otp-verify.dto';
|
||||
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||
import { RegisterSendCodeDto } from './dto/register-send-code.dto';
|
||||
import { RegisterVerifyDto } from './dto/register-verify.dto';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly auth: AuthService) {}
|
||||
|
||||
@Post('login')
|
||||
login(@Body() dto: LoginDto) {
|
||||
return this.auth.login(dto);
|
||||
}
|
||||
|
||||
@Post('login/send-code')
|
||||
loginSendCode(@Body() dto: CellNumberDto) {
|
||||
return this.auth.loginSendCode(dto);
|
||||
}
|
||||
|
||||
@Post('login/verify')
|
||||
loginVerify(@Body() dto: OtpVerifyDto) {
|
||||
return this.auth.loginVerify(dto);
|
||||
}
|
||||
|
||||
@Post('forgot-password/send-code')
|
||||
forgotPasswordSendCode(@Body() dto: CellNumberDto) {
|
||||
return this.auth.forgotPasswordSendCode(dto);
|
||||
}
|
||||
|
||||
@Post('forgot-password/verify')
|
||||
forgotPasswordVerify(@Body() dto: OtpVerifyDto) {
|
||||
return this.auth.forgotPasswordVerify(dto);
|
||||
}
|
||||
|
||||
@Post('forgot-password/reset')
|
||||
forgotPasswordReset(@Body() dto: ForgotPasswordResetDto) {
|
||||
return this.auth.forgotPasswordReset(dto);
|
||||
}
|
||||
|
||||
@Post('register/send-code')
|
||||
registerSendCode(@Body() dto: RegisterSendCodeDto) {
|
||||
return this.auth.registerSendCode(dto);
|
||||
}
|
||||
|
||||
@Post('register/verify')
|
||||
registerVerify(@Body() dto: RegisterVerifyDto) {
|
||||
return this.auth.registerVerify(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)
|
||||
me(@CurrentUser() user: AuthUser) {
|
||||
return this.auth.me(user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { SmsModule } from '../sms/sms.module';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
SmsModule,
|
||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
secret: config.getOrThrow<string>('JWT_ACCESS_SECRET'),
|
||||
signOptions: {
|
||||
expiresIn: config.get<string>('JWT_ACCESS_EXPIRES_IN', '15m') as
|
||||
| `${number}s`
|
||||
| `${number}m`
|
||||
| `${number}h`
|
||||
| `${number}d`,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy],
|
||||
exports: [AuthService, JwtModule],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1,425 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { SmsOtp, SmsOtpPurpose, UserRole } from '@prisma/client';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { createHash, randomBytes, randomInt } from 'crypto';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SmsService } from '../sms/sms.service';
|
||||
import { AuthUser, displayName } from './auth.types';
|
||||
import { CellNumberDto } from './dto/cell-number.dto';
|
||||
import { ForgotPasswordResetDto } from './dto/forgot-password-reset.dto';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { OtpVerifyDto } from './dto/otp-verify.dto';
|
||||
import { RegisterSendCodeDto } from './dto/register-send-code.dto';
|
||||
import { RegisterVerifyDto } from './dto/register-verify.dto';
|
||||
|
||||
const OTP_TTL_MS = 5 * 60 * 1000;
|
||||
const OTP_RESEND_COOLDOWN_MS = 60 * 1000;
|
||||
const OTP_MAX_ATTEMPTS = 5;
|
||||
const OTP_LENGTH = 6;
|
||||
|
||||
type RegisterOtpPayload = {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
passwordHash: string;
|
||||
};
|
||||
|
||||
type OtpSendResult = {
|
||||
ok: true;
|
||||
expiresInSeconds: number;
|
||||
resendAfterSeconds: number;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwt: JwtService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly sms: SmsService,
|
||||
) {}
|
||||
|
||||
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('حساب کاربری غیرفعال است');
|
||||
}
|
||||
|
||||
const ok = await bcrypt.compare(dto.password, user.passwordHash);
|
||||
if (!ok) {
|
||||
throw new UnauthorizedException('شماره یا رمز عبور اشتباه است');
|
||||
}
|
||||
|
||||
return this.issueTokens(user);
|
||||
}
|
||||
|
||||
async loginSendCode(dto: CellNumberDto): Promise<OtpSendResult> {
|
||||
const user = await this.requireActiveUser(dto.cellNumber);
|
||||
return this.createAndSendOtp({
|
||||
cellNumber: user.cellNumber,
|
||||
purpose: SmsOtpPurpose.login,
|
||||
message: (code) => `کد ورود مادو: ${code}`,
|
||||
});
|
||||
}
|
||||
|
||||
async loginVerify(dto: OtpVerifyDto) {
|
||||
await this.requireActiveUser(dto.cellNumber);
|
||||
await this.assertValidOtp({
|
||||
cellNumber: dto.cellNumber,
|
||||
purpose: SmsOtpPurpose.login,
|
||||
code: dto.code,
|
||||
consume: true,
|
||||
});
|
||||
|
||||
const user = await this.prisma.user.findUniqueOrThrow({
|
||||
where: { cellNumber: dto.cellNumber },
|
||||
});
|
||||
if (user.disabled) {
|
||||
throw new ForbiddenException('حساب کاربری غیرفعال است');
|
||||
}
|
||||
|
||||
return this.issueTokens(user);
|
||||
}
|
||||
|
||||
async forgotPasswordSendCode(dto: CellNumberDto): Promise<OtpSendResult> {
|
||||
const user = await this.requireActiveUser(dto.cellNumber);
|
||||
return this.createAndSendOtp({
|
||||
cellNumber: user.cellNumber,
|
||||
purpose: SmsOtpPurpose.resetPassword,
|
||||
message: (code) => `کد بازیابی رمز مادو: ${code}`,
|
||||
});
|
||||
}
|
||||
|
||||
async forgotPasswordVerify(dto: OtpVerifyDto) {
|
||||
await this.requireActiveUser(dto.cellNumber);
|
||||
await this.assertValidOtp({
|
||||
cellNumber: dto.cellNumber,
|
||||
purpose: SmsOtpPurpose.resetPassword,
|
||||
code: dto.code,
|
||||
consume: false,
|
||||
});
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
||||
async forgotPasswordReset(dto: ForgotPasswordResetDto) {
|
||||
await this.requireActiveUser(dto.cellNumber);
|
||||
await this.assertValidOtp({
|
||||
cellNumber: dto.cellNumber,
|
||||
purpose: SmsOtpPurpose.resetPassword,
|
||||
code: dto.code,
|
||||
consume: true,
|
||||
});
|
||||
|
||||
const passwordHash = await bcrypt.hash(dto.newPassword, 10);
|
||||
const user = await this.prisma.user.update({
|
||||
where: { cellNumber: dto.cellNumber },
|
||||
data: { passwordHash },
|
||||
});
|
||||
|
||||
if (user.disabled) {
|
||||
throw new ForbiddenException('حساب کاربری غیرفعال است');
|
||||
}
|
||||
|
||||
// Invalidate existing sessions after password change
|
||||
await this.prisma.refreshToken.deleteMany({ where: { userId: user.id } });
|
||||
|
||||
return this.issueTokens(user);
|
||||
}
|
||||
|
||||
async registerSendCode(dto: RegisterSendCodeDto): Promise<OtpSendResult> {
|
||||
const existing = await this.prisma.user.findUnique({
|
||||
where: { cellNumber: dto.cellNumber },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictException('این شماره قبلاً ثبت شده است');
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(dto.password, 10);
|
||||
const payload: RegisterOtpPayload = {
|
||||
firstName: dto.firstName.trim(),
|
||||
lastName: dto.lastName.trim(),
|
||||
passwordHash,
|
||||
};
|
||||
|
||||
return this.createAndSendOtp({
|
||||
cellNumber: dto.cellNumber,
|
||||
purpose: SmsOtpPurpose.register,
|
||||
payload,
|
||||
message: (code) => `کد تأیید مادو: ${code}`,
|
||||
});
|
||||
}
|
||||
|
||||
async registerVerify(dto: RegisterVerifyDto) {
|
||||
const otp = await this.assertValidOtp({
|
||||
cellNumber: dto.cellNumber,
|
||||
purpose: SmsOtpPurpose.register,
|
||||
code: dto.code,
|
||||
consume: true,
|
||||
});
|
||||
|
||||
const payload = otp.payload as RegisterOtpPayload | null;
|
||||
if (
|
||||
!payload?.firstName ||
|
||||
!payload?.lastName ||
|
||||
!payload?.passwordHash
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
'اطلاعات ثبتنام نامعتبر است. دوباره شروع کنید',
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await this.prisma.user.findUnique({
|
||||
where: { cellNumber: dto.cellNumber },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictException('این شماره قبلاً ثبت شده است');
|
||||
}
|
||||
|
||||
const user = await this.prisma.user.create({
|
||||
data: {
|
||||
firstName: payload.firstName,
|
||||
lastName: payload.lastName,
|
||||
cellNumber: dto.cellNumber,
|
||||
passwordHash: payload.passwordHash,
|
||||
role: UserRole.User,
|
||||
},
|
||||
});
|
||||
|
||||
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) {
|
||||
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,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
cellNumber: user.cellNumber,
|
||||
role: user.role,
|
||||
name: displayName(user),
|
||||
};
|
||||
}
|
||||
|
||||
private async requireActiveUser(cellNumber: string) {
|
||||
const user = await this.prisma.user.findUnique({ where: { cellNumber } });
|
||||
if (!user) {
|
||||
throw new BadRequestException('کاربری با این شماره یافت نشد');
|
||||
}
|
||||
if (user.disabled) {
|
||||
throw new ForbiddenException('حساب کاربری غیرفعال است');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
private async createAndSendOtp(input: {
|
||||
cellNumber: string;
|
||||
purpose: SmsOtpPurpose;
|
||||
payload?: RegisterOtpPayload;
|
||||
message: (code: string) => string;
|
||||
}): Promise<OtpSendResult> {
|
||||
const latest = await this.prisma.smsOtp.findFirst({
|
||||
where: {
|
||||
cellNumber: input.cellNumber,
|
||||
purpose: input.purpose,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
if (
|
||||
latest &&
|
||||
Date.now() - latest.createdAt.getTime() < OTP_RESEND_COOLDOWN_MS
|
||||
) {
|
||||
const waitSec = Math.ceil(
|
||||
(OTP_RESEND_COOLDOWN_MS - (Date.now() - latest.createdAt.getTime())) /
|
||||
1000,
|
||||
);
|
||||
throw new BadRequestException(
|
||||
`لطفاً ${waitSec} ثانیه دیگر دوباره تلاش کنید`,
|
||||
);
|
||||
}
|
||||
|
||||
const code = String(randomInt(0, 10 ** OTP_LENGTH)).padStart(
|
||||
OTP_LENGTH,
|
||||
'0',
|
||||
);
|
||||
|
||||
await this.prisma.smsOtp.deleteMany({
|
||||
where: {
|
||||
cellNumber: input.cellNumber,
|
||||
purpose: input.purpose,
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.smsOtp.create({
|
||||
data: {
|
||||
cellNumber: input.cellNumber,
|
||||
purpose: input.purpose,
|
||||
codeHash: this.hashToken(code),
|
||||
payload: input.payload ?? undefined,
|
||||
expiresAt: new Date(Date.now() + OTP_TTL_MS),
|
||||
},
|
||||
});
|
||||
|
||||
await this.sms.send({
|
||||
to: input.cellNumber,
|
||||
message: input.message(code),
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
expiresInSeconds: Math.floor(OTP_TTL_MS / 1000),
|
||||
resendAfterSeconds: Math.floor(OTP_RESEND_COOLDOWN_MS / 1000),
|
||||
};
|
||||
}
|
||||
|
||||
/** Validates OTP. When consume=true, deletes it on success. */
|
||||
private async assertValidOtp(input: {
|
||||
cellNumber: string;
|
||||
purpose: SmsOtpPurpose;
|
||||
code: string;
|
||||
consume: boolean;
|
||||
}): Promise<SmsOtp> {
|
||||
const otp = await this.prisma.smsOtp.findFirst({
|
||||
where: {
|
||||
cellNumber: input.cellNumber,
|
||||
purpose: input.purpose,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
if (!otp || otp.expiresAt < new Date()) {
|
||||
if (otp) {
|
||||
await this.prisma.smsOtp.delete({ where: { id: otp.id } });
|
||||
}
|
||||
throw new BadRequestException(
|
||||
'کد تأیید منقضی شده است. دوباره درخواست کنید',
|
||||
);
|
||||
}
|
||||
|
||||
if (otp.attempts >= OTP_MAX_ATTEMPTS) {
|
||||
await this.prisma.smsOtp.delete({ where: { id: otp.id } });
|
||||
throw new BadRequestException(
|
||||
'تعداد تلاشها بیش از حد مجاز است. دوباره درخواست کنید',
|
||||
);
|
||||
}
|
||||
|
||||
const codeHash = this.hashToken(input.code.trim());
|
||||
if (codeHash !== otp.codeHash) {
|
||||
await this.prisma.smsOtp.update({
|
||||
where: { id: otp.id },
|
||||
data: { attempts: { increment: 1 } },
|
||||
});
|
||||
throw new BadRequestException('کد تأیید نادرست است');
|
||||
}
|
||||
|
||||
if (input.consume) {
|
||||
await this.prisma.smsOtp.delete({ where: { id: otp.id } });
|
||||
}
|
||||
|
||||
return otp;
|
||||
}
|
||||
|
||||
private async issueTokens(user: {
|
||||
id: string;
|
||||
cellNumber: string;
|
||||
role: AuthUser['role'];
|
||||
firstName: string;
|
||||
lastName: 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,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
cellNumber: user.cellNumber,
|
||||
role: user.role,
|
||||
name: displayName(user),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private hashToken(token: string) {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
private parseDurationDays(value: string): number {
|
||||
const match = /^(\d+)d$/i.exec(value.trim());
|
||||
return match ? Number(match[1]) : 7;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { UserRole } from '@prisma/client';
|
||||
|
||||
export type AuthUser = {
|
||||
id: string
|
||||
cellNumber: string
|
||||
role: UserRole
|
||||
firstName: string
|
||||
lastName: string
|
||||
}
|
||||
|
||||
export function isElevatedRole(role: UserRole): boolean {
|
||||
return role === UserRole.Admin || role === UserRole.superAdmin
|
||||
}
|
||||
|
||||
export function displayName(user: {
|
||||
firstName: string
|
||||
lastName: string
|
||||
}): string {
|
||||
return `${user.firstName} ${user.lastName}`.trim()
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import { AuthUser } from '../auth.types';
|
||||
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(_data: unknown, ctx: ExecutionContext): AuthUser => {
|
||||
const request = ctx.switchToHttp().getRequest<{ user: AuthUser }>();
|
||||
return request.user;
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,5 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import { UserRole } from '@prisma/client';
|
||||
|
||||
export const ROLES_KEY = 'roles';
|
||||
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IsString, Matches } from 'class-validator';
|
||||
|
||||
export class CellNumberDto {
|
||||
@IsString()
|
||||
@Matches(/^09\d{9}$/, { message: 'شماره موبایل معتبر نیست' })
|
||||
cellNumber!: string;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { IsString, Length, Matches, MinLength } from 'class-validator';
|
||||
|
||||
export class ForgotPasswordResetDto {
|
||||
@IsString()
|
||||
@Matches(/^09\d{9}$/, { message: 'شماره موبایل معتبر نیست' })
|
||||
cellNumber!: string;
|
||||
|
||||
@IsString()
|
||||
@Length(4, 8, { message: 'کد تأیید نامعتبر است' })
|
||||
@Matches(/^\d+$/, { message: 'کد تأیید نامعتبر است' })
|
||||
code!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(4, { message: 'رمز عبور باید حداقل ۴ کاراکتر باشد' })
|
||||
newPassword!: string;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { IsString, Matches, MinLength } from 'class-validator';
|
||||
|
||||
export class LoginDto {
|
||||
@IsString()
|
||||
@Matches(/^09\d{9}$/, {
|
||||
message: 'شماره موبایل معتبر نیست',
|
||||
})
|
||||
cellNumber!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(4)
|
||||
password!: string;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { IsString, Length, Matches } from 'class-validator';
|
||||
|
||||
export class OtpVerifyDto {
|
||||
@IsString()
|
||||
@Matches(/^09\d{9}$/, { message: 'شماره موبایل معتبر نیست' })
|
||||
cellNumber!: string;
|
||||
|
||||
@IsString()
|
||||
@Length(4, 8, { message: 'کد تأیید نامعتبر است' })
|
||||
@Matches(/^\d+$/, { message: 'کد تأیید نامعتبر است' })
|
||||
code!: string;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class RefreshTokenDto {
|
||||
@IsString()
|
||||
@MinLength(20)
|
||||
refreshToken!: string;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { IsString, Matches, MinLength } from 'class-validator';
|
||||
|
||||
export class RegisterSendCodeDto {
|
||||
@IsString()
|
||||
@MinLength(1, { message: 'نام الزامی است' })
|
||||
firstName!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1, { message: 'نام خانوادگی الزامی است' })
|
||||
lastName!: string;
|
||||
|
||||
@IsString()
|
||||
@Matches(/^09\d{9}$/, { message: 'شماره موبایل معتبر نیست' })
|
||||
cellNumber!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(4, { message: 'رمز عبور باید حداقل ۴ کاراکتر باشد' })
|
||||
password!: string;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { IsString, Matches, Length } from 'class-validator';
|
||||
|
||||
export class RegisterVerifyDto {
|
||||
@IsString()
|
||||
@Matches(/^09\d{9}$/, { message: 'شماره موبایل معتبر نیست' })
|
||||
cellNumber!: string;
|
||||
|
||||
@IsString()
|
||||
@Length(4, 8, { message: 'کد تأیید نامعتبر است' })
|
||||
@Matches(/^\d+$/, { message: 'کد تأیید نامعتبر است' })
|
||||
code!: string;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { UserRole } from '@prisma/client';
|
||||
import { ROLES_KEY } from '../decorators/roles.decorator';
|
||||
import { AuthUser } from '../auth.types';
|
||||
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(
|
||||
ROLES_KEY,
|
||||
[context.getHandler(), context.getClass()],
|
||||
);
|
||||
|
||||
if (!requiredRoles?.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<{ user?: AuthUser }>();
|
||||
const user = request.user;
|
||||
|
||||
if (!user || !requiredRoles.includes(user.role)) {
|
||||
throw new ForbiddenException('دسترسی مجاز نیست');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
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 } 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) {
|
||||
throw new UnauthorizedException('نشست نامعتبر است');
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
cellNumber: user.cellNumber,
|
||||
role: user.role,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
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 { BlogsService } from './blogs.service';
|
||||
import { CreateBlogDto } from './dto/create-blog.dto';
|
||||
import { UpdateBlogDto } from './dto/update-blog.dto';
|
||||
|
||||
@Controller('blogs')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.Admin, UserRole.superAdmin)
|
||||
export class BlogsController {
|
||||
constructor(private readonly blogs: BlogsService) {}
|
||||
|
||||
@Get()
|
||||
list() {
|
||||
return this.blogs.list();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.blogs.findOne(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@Body() dto: CreateBlogDto) {
|
||||
return this.blogs.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(@Param('id') id: string, @Body() dto: UpdateBlogDto) {
|
||||
return this.blogs.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.blogs.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BlogsController } from './blogs.controller';
|
||||
import { BlogsService } from './blogs.service';
|
||||
|
||||
@Module({
|
||||
controllers: [BlogsController],
|
||||
providers: [BlogsService],
|
||||
exports: [BlogsService],
|
||||
})
|
||||
export class BlogsModule {}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateBlogDto } from './dto/create-blog.dto';
|
||||
import { UpdateBlogDto } from './dto/update-blog.dto';
|
||||
|
||||
@Injectable()
|
||||
export class BlogsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
list() {
|
||||
return this.prisma.blogPost.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const post = await this.prisma.blogPost.findUnique({ where: { id } });
|
||||
if (!post) {
|
||||
throw new NotFoundException('مقاله یافت نشد');
|
||||
}
|
||||
return post;
|
||||
}
|
||||
|
||||
create(dto: CreateBlogDto) {
|
||||
return this.prisma.blogPost.create({
|
||||
data: {
|
||||
title: dto.title,
|
||||
summary: dto.summary ?? '',
|
||||
body: dto.body ?? '',
|
||||
author: dto.author ?? '',
|
||||
imageUrl: dto.imageUrl,
|
||||
imageKey: dto.imageKey,
|
||||
published: dto.published ?? true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateBlogDto) {
|
||||
await this.findOne(id);
|
||||
return this.prisma.blogPost.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(dto.title !== undefined ? { title: dto.title } : {}),
|
||||
...(dto.summary !== undefined ? { summary: dto.summary } : {}),
|
||||
...(dto.body !== undefined ? { body: dto.body } : {}),
|
||||
...(dto.author !== undefined ? { author: dto.author } : {}),
|
||||
...(dto.imageUrl !== undefined ? { imageUrl: dto.imageUrl } : {}),
|
||||
...(dto.imageKey !== undefined ? { imageKey: dto.imageKey } : {}),
|
||||
...(dto.published !== undefined ? { published: dto.published } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async remove(id: string) {
|
||||
await this.findOne(id);
|
||||
await this.prisma.blogPost.delete({ where: { id } });
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { IsBoolean, IsOptional, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class CreateBlogDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
title!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
summary?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
body?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
author?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
imageUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
imageKey?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
published?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { IsBoolean, IsOptional, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class UpdateBlogDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
title?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
summary?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
body?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
author?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
imageUrl?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
imageKey?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
published?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
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 { CallRequestsService } from './call-requests.service';
|
||||
import { CreateCallRequestDto } from './dto/create-call-request.dto';
|
||||
|
||||
@Controller('call-requests')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.Admin, UserRole.superAdmin)
|
||||
export class CallRequestsController {
|
||||
constructor(private readonly callRequests: CallRequestsService) {}
|
||||
|
||||
@Get()
|
||||
list() {
|
||||
return this.callRequests.list();
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@Body() dto: CreateCallRequestDto) {
|
||||
return this.callRequests.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id/handled')
|
||||
markHandled(@Param('id') id: string) {
|
||||
return this.callRequests.markHandled(id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.callRequests.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SmsModule } from '../sms/sms.module';
|
||||
import { CallRequestsController } from './call-requests.controller';
|
||||
import { CallRequestsService } from './call-requests.service';
|
||||
|
||||
@Module({
|
||||
imports: [SmsModule],
|
||||
controllers: [CallRequestsController],
|
||||
providers: [CallRequestsService],
|
||||
exports: [CallRequestsService],
|
||||
})
|
||||
export class CallRequestsModule {}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SmsService } from '../sms/sms.service';
|
||||
import { CreateCallRequestDto } from './dto/create-call-request.dto';
|
||||
|
||||
@Injectable()
|
||||
export class CallRequestsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly sms: SmsService,
|
||||
) {}
|
||||
|
||||
list() {
|
||||
return this.prisma.callRequest.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const request = await this.prisma.callRequest.findUnique({ where: { id } });
|
||||
if (!request) {
|
||||
throw new NotFoundException('درخواست تماس یافت نشد');
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
async create(dto: CreateCallRequestDto) {
|
||||
const created = await this.prisma.callRequest.create({
|
||||
data: {
|
||||
name: dto.name.trim(),
|
||||
mobile: dto.mobile,
|
||||
note: dto.note?.trim() ?? '',
|
||||
},
|
||||
});
|
||||
|
||||
await this.sms.notifyCallRequest({
|
||||
name: created.name,
|
||||
mobile: created.mobile,
|
||||
});
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
async markHandled(id: string) {
|
||||
await this.findOne(id);
|
||||
return this.prisma.callRequest.update({
|
||||
where: { id },
|
||||
data: { handled: true },
|
||||
});
|
||||
}
|
||||
|
||||
async remove(id: string) {
|
||||
await this.findOne(id);
|
||||
await this.prisma.callRequest.delete({ where: { id } });
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { IsOptional, IsString, Matches, MinLength } from 'class-validator'
|
||||
|
||||
export class CreateCallRequestDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
name!: string
|
||||
|
||||
@IsString()
|
||||
@Matches(/^09\d{9}$/, { message: 'شماره موبایل معتبر نیست' })
|
||||
mobile!: string
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
note?: string
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/** Districts of Qom used across settings (shipping, branches). */
|
||||
export const DISTRICTS = [
|
||||
'پردیسان',
|
||||
'جعفریه',
|
||||
'زنبیلآباد',
|
||||
'سالاریه',
|
||||
'صفاییه',
|
||||
'عطاران',
|
||||
'انارستان',
|
||||
'بنیاد',
|
||||
'آذر',
|
||||
'هنرستان',
|
||||
'باجک',
|
||||
'دورشهر',
|
||||
'خیابان ارم',
|
||||
'شهرک قدس',
|
||||
'شهرک امام حسن',
|
||||
'شهرک مهدیه',
|
||||
'قلعه کامکار',
|
||||
'نیروگاه',
|
||||
'حرم',
|
||||
'بلوار امین',
|
||||
] as const;
|
||||
|
||||
export type District = (typeof DISTRICTS)[number];
|
||||
|
||||
export function isDistrict(value: string): value is District {
|
||||
return (DISTRICTS as readonly string[]).includes(value);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/** Build a URL-safe slug from English (or latin) text. */
|
||||
export function slugify(input: string) {
|
||||
const base = input
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/['']/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
return base || 'category'
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
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 { CustomersService } from './customers.service';
|
||||
import { CreateCustomerDto } from './dto/create-customer.dto';
|
||||
import { UpdateCustomerDto } from './dto/update-customer.dto';
|
||||
|
||||
@Controller('customers')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.Admin, UserRole.superAdmin)
|
||||
export class CustomersController {
|
||||
constructor(private readonly customers: CustomersService) {}
|
||||
|
||||
@Get()
|
||||
list() {
|
||||
return this.customers.list();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.customers.findOne(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@Body() dto: CreateCustomerDto) {
|
||||
return this.customers.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(@Param('id') id: string, @Body() dto: UpdateCustomerDto) {
|
||||
return this.customers.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.customers.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CustomersController } from './customers.controller';
|
||||
import { CustomersService } from './customers.service';
|
||||
|
||||
@Module({
|
||||
controllers: [CustomersController],
|
||||
providers: [CustomersService],
|
||||
exports: [CustomersService],
|
||||
})
|
||||
export class CustomersModule {}
|
||||
@@ -0,0 +1,154 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { UserRole } from '@prisma/client';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateCustomerDto } from './dto/create-customer.dto';
|
||||
import { UpdateCustomerDto } from './dto/update-customer.dto';
|
||||
|
||||
@Injectable()
|
||||
export class CustomersService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
private mapCustomer(user: {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
cellNumber: string;
|
||||
city: string;
|
||||
disabled: boolean;
|
||||
ordersCount: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}) {
|
||||
return {
|
||||
id: user.id,
|
||||
name: `${user.firstName} ${user.lastName}`.trim(),
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
cellNumber: user.cellNumber,
|
||||
city: user.city,
|
||||
disabled: user.disabled,
|
||||
ordersCount: user.ordersCount,
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
async list() {
|
||||
const users = await this.prisma.user.findMany({
|
||||
where: { role: UserRole.User },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
cellNumber: true,
|
||||
city: true,
|
||||
disabled: true,
|
||||
ordersCount: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
return users.map((user) => this.mapCustomer(user));
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: { id, role: UserRole.User },
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
cellNumber: true,
|
||||
city: true,
|
||||
disabled: true,
|
||||
ordersCount: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
if (!user) {
|
||||
throw new NotFoundException('مشتری یافت نشد');
|
||||
}
|
||||
return this.mapCustomer(user);
|
||||
}
|
||||
|
||||
async create(dto: CreateCustomerDto) {
|
||||
const existing = await this.prisma.user.findUnique({
|
||||
where: { cellNumber: dto.cellNumber },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictException('این شماره قبلاً ثبت شده است');
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(dto.password, 10);
|
||||
const user = await this.prisma.user.create({
|
||||
data: {
|
||||
firstName: dto.firstName.trim(),
|
||||
lastName: dto.lastName.trim(),
|
||||
cellNumber: dto.cellNumber,
|
||||
city: dto.city?.trim() ?? '',
|
||||
passwordHash,
|
||||
role: UserRole.User,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
cellNumber: true,
|
||||
city: true,
|
||||
disabled: true,
|
||||
ordersCount: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
return this.mapCustomer(user);
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateCustomerDto) {
|
||||
await this.findOne(id);
|
||||
|
||||
let firstName: string | undefined;
|
||||
let lastName: string | undefined;
|
||||
if (dto.name !== undefined) {
|
||||
const parts = dto.name.trim().split(/\s+/);
|
||||
firstName = parts[0] ?? '';
|
||||
lastName = parts.slice(1).join(' ');
|
||||
}
|
||||
|
||||
const user = await this.prisma.user.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(firstName !== undefined ? { firstName } : {}),
|
||||
...(lastName !== undefined ? { lastName } : {}),
|
||||
...(dto.city !== undefined ? { city: dto.city } : {}),
|
||||
...(dto.disabled !== undefined ? { disabled: dto.disabled } : {}),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
cellNumber: true,
|
||||
city: true,
|
||||
disabled: true,
|
||||
ordersCount: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
return this.mapCustomer(user);
|
||||
}
|
||||
|
||||
async remove(id: string) {
|
||||
await this.findOne(id);
|
||||
await this.prisma.user.delete({ where: { id } });
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import {
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MinLength,
|
||||
} from 'class-validator'
|
||||
|
||||
export class CreateCustomerDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
firstName!: string
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
lastName!: string
|
||||
|
||||
@IsString()
|
||||
@Matches(/^09\d{9}$/, { message: 'شماره موبایل معتبر نیست' })
|
||||
cellNumber!: string
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
city?: string
|
||||
|
||||
@IsString()
|
||||
@MinLength(4)
|
||||
password!: string
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { IsBoolean, IsOptional, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class UpdateCustomerDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
city?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
disabled?: boolean;
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import { ValidationPipe } from '@nestjs/common'
|
||||
import { NestFactory } from '@nestjs/core'
|
||||
import { AppModule } from './app.module'
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule)
|
||||
|
||||
app.setGlobalPrefix('api/v1')
|
||||
app.enableCors({
|
||||
origin: process.env.CORS_ORIGIN?.split(',').map((s) => s.trim()) ?? true,
|
||||
credentials: true,
|
||||
})
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
transformOptions: { enableImplicitConversion: true },
|
||||
}),
|
||||
)
|
||||
|
||||
const port = Number(process.env.PORT ?? 3000)
|
||||
await app.listen(port)
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Mado Motor API listening on http://localhost:${port}/api/v1`)
|
||||
}
|
||||
|
||||
void bootstrap()
|
||||
@@ -0,0 +1,9 @@
|
||||
import { IsIn } from 'class-validator';
|
||||
|
||||
export const MEDIA_KINDS = ['main', 'gallery', 'blog', 'temp'] as const;
|
||||
export type MediaKind = (typeof MEDIA_KINDS)[number];
|
||||
|
||||
export class MediaUploadQueryDto {
|
||||
@IsIn(MEDIA_KINDS, { message: 'نوع فایل نامعتبر است' })
|
||||
kind!: MediaKind;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Query,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { UserRole } from '@prisma/client';
|
||||
import { memoryStorage } from 'multer';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { MediaUploadQueryDto } from './dto/media-upload-query.dto';
|
||||
import { MediaService } from './media.service';
|
||||
|
||||
@Controller('media')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.Admin, UserRole.superAdmin)
|
||||
export class MediaController {
|
||||
constructor(private readonly media: MediaService) {}
|
||||
|
||||
@Post('upload')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
storage: memoryStorage(),
|
||||
}),
|
||||
)
|
||||
upload(
|
||||
@Query() query: MediaUploadQueryDto,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
) {
|
||||
return this.media.upload(query.kind, file);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { StorageModule } from '../storage/storage.module';
|
||||
import { MediaController } from './media.controller';
|
||||
import { MediaService } from './media.service';
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule, StorageModule],
|
||||
controllers: [MediaController],
|
||||
providers: [MediaService],
|
||||
})
|
||||
export class MediaModule {}
|
||||
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
PayloadTooLargeException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { extname } from 'path';
|
||||
import {
|
||||
productGalleryKey,
|
||||
productMainKey,
|
||||
blogMediaKey,
|
||||
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 'blog':
|
||||
return blogMediaKey(fileName);
|
||||
case 'temp':
|
||||
return tempMediaKey(fileName);
|
||||
default:
|
||||
throw new BadRequestException('نوع فایل نامعتبر است');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { OrderStatus } from '@prisma/client';
|
||||
import { IsEnum } from 'class-validator';
|
||||
|
||||
export class UpdateOrderStatusDto {
|
||||
@IsEnum(OrderStatus)
|
||||
status!: OrderStatus;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
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 { 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() {
|
||||
return this.orders.list();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.orders.findOne(id);
|
||||
}
|
||||
|
||||
@Patch(':id/status')
|
||||
updateStatus(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() dto: UpdateOrderStatusDto,
|
||||
) {
|
||||
return this.orders.updateStatus(id, dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { OrdersController } from './orders.controller';
|
||||
import { OrdersService } from './orders.service';
|
||||
|
||||
@Module({
|
||||
controllers: [OrdersController],
|
||||
providers: [OrdersService],
|
||||
exports: [OrdersService],
|
||||
})
|
||||
export class OrdersModule {}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { UpdateOrderStatusDto } from './dto/update-order-status.dto';
|
||||
|
||||
@Injectable()
|
||||
export class OrdersService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
list() {
|
||||
return this.prisma.order.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
customerName: true,
|
||||
customerPhone: true,
|
||||
itemCount: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
items: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: number) {
|
||||
const order = await this.prisma.order.findUnique({
|
||||
where: { id },
|
||||
include: { items: true },
|
||||
});
|
||||
if (!order) {
|
||||
throw new NotFoundException('سفارش یافت نشد');
|
||||
}
|
||||
return order;
|
||||
}
|
||||
|
||||
async updateStatus(id: number, dto: UpdateOrderStatusDto) {
|
||||
await this.findOne(id);
|
||||
return this.prisma.order.update({
|
||||
where: { id },
|
||||
data: { status: dto.status },
|
||||
include: { items: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService
|
||||
extends PrismaClient
|
||||
implements OnModuleInit, OnModuleDestroy
|
||||
{
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { IsInt, IsOptional, IsString, Min, MinLength } from 'class-validator';
|
||||
|
||||
export class CreateProductDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
nameFa!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
nameEn?: string;
|
||||
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
price!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
stock?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
category?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
imageUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
imageKey?: string;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { IsInt, IsOptional, IsString, Min, MinLength } from 'class-validator';
|
||||
|
||||
export class UpdateProductDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
nameFa?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
nameEn?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
price?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
stock?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
category?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
imageUrl?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
imageKey?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
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 { CreateProductDto } from './dto/create-product.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() {
|
||||
return this.products.list();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.products.findOne(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@Body() dto: CreateProductDto) {
|
||||
return this.products.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(@Param('id') id: string, @Body() dto: UpdateProductDto) {
|
||||
return this.products.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.products.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ProductsController } from './products.controller';
|
||||
import { ProductsService } from './products.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ProductsController],
|
||||
providers: [ProductsService],
|
||||
exports: [ProductsService],
|
||||
})
|
||||
export class ProductsModule {}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateProductDto } from './dto/create-product.dto';
|
||||
import { UpdateProductDto } from './dto/update-product.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ProductsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
list() {
|
||||
return this.prisma.product.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const product = await this.prisma.product.findUnique({ where: { id } });
|
||||
if (!product) {
|
||||
throw new NotFoundException('محصول یافت نشد');
|
||||
}
|
||||
return product;
|
||||
}
|
||||
|
||||
create(dto: CreateProductDto) {
|
||||
return this.prisma.product.create({
|
||||
data: {
|
||||
nameFa: dto.nameFa,
|
||||
nameEn: dto.nameEn ?? '',
|
||||
price: dto.price,
|
||||
stock: dto.stock ?? 0,
|
||||
category: dto.category ?? '',
|
||||
imageUrl: dto.imageUrl,
|
||||
imageKey: dto.imageKey,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateProductDto) {
|
||||
await this.findOne(id);
|
||||
return this.prisma.product.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(dto.nameFa !== undefined ? { nameFa: dto.nameFa } : {}),
|
||||
...(dto.nameEn !== undefined ? { nameEn: dto.nameEn } : {}),
|
||||
...(dto.price !== undefined ? { price: dto.price } : {}),
|
||||
...(dto.stock !== undefined ? { stock: dto.stock } : {}),
|
||||
...(dto.category !== undefined ? { category: dto.category } : {}),
|
||||
...(dto.imageUrl !== undefined ? { imageUrl: dto.imageUrl } : {}),
|
||||
...(dto.imageKey !== undefined ? { imageKey: dto.imageKey } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async remove(id: string) {
|
||||
await this.findOne(id);
|
||||
await this.prisma.product.delete({ where: { id } });
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { IsOptional, IsString, Matches, MinLength } from 'class-validator'
|
||||
|
||||
export class PublicRepresentationRequestDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
firstName!: string
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
lastName!: string
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
nationalCode?: string
|
||||
|
||||
@IsString()
|
||||
@Matches(/^09\d{9}$/, { message: 'شماره موبایل معتبر نیست' })
|
||||
mobile!: string
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
province?: string
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
city!: string
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
landline?: string
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
address?: string
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
postalCode?: string
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { Body, Controller, Get, NotFoundException, Param, Post } from '@nestjs/common'
|
||||
import { RepresentationStatus } from '@prisma/client'
|
||||
import { CreateCallRequestDto } from '../call-requests/dto/create-call-request.dto'
|
||||
import { PrismaService } from '../prisma/prisma.service'
|
||||
import { SmsService } from '../sms/sms.service'
|
||||
import { PublicRepresentationRequestDto } from './dto/public-representation-request.dto'
|
||||
|
||||
@Controller('public')
|
||||
export class PublicController {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly sms: SmsService,
|
||||
) {}
|
||||
|
||||
@Get('products')
|
||||
listProducts() {
|
||||
return this.prisma.product.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
nameFa: true,
|
||||
nameEn: true,
|
||||
price: true,
|
||||
stock: true,
|
||||
category: true,
|
||||
imageUrl: true,
|
||||
createdAt: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@Get('products/:id')
|
||||
async getProduct(@Param('id') id: string) {
|
||||
const product = await this.prisma.product.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
nameFa: true,
|
||||
nameEn: true,
|
||||
price: true,
|
||||
stock: true,
|
||||
category: true,
|
||||
imageUrl: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
})
|
||||
if (!product) {
|
||||
throw new NotFoundException('محصول یافت نشد')
|
||||
}
|
||||
return product
|
||||
}
|
||||
|
||||
@Get('blogs')
|
||||
listBlogs() {
|
||||
return this.prisma.blogPost.findMany({
|
||||
where: { published: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
summary: true,
|
||||
author: true,
|
||||
imageUrl: true,
|
||||
createdAt: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@Get('blogs/:id')
|
||||
async getBlog(@Param('id') id: string) {
|
||||
const post = await this.prisma.blogPost.findFirst({
|
||||
where: { id, published: true },
|
||||
})
|
||||
if (!post) {
|
||||
throw new NotFoundException('مطلب یافت نشد')
|
||||
}
|
||||
return post
|
||||
}
|
||||
|
||||
@Get('representations')
|
||||
listActiveRepresentations() {
|
||||
return this.prisma.representation.findMany({
|
||||
where: { status: RepresentationStatus.active },
|
||||
orderBy: [{ city: 'asc' }, { createdAt: 'desc' }],
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
owner: true,
|
||||
mobile: true,
|
||||
province: true,
|
||||
city: true,
|
||||
address: true,
|
||||
landline: true,
|
||||
createdAt: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@Post('representations/request')
|
||||
async createRepresentationRequest(@Body() dto: PublicRepresentationRequestDto) {
|
||||
const firstName = dto.firstName.trim()
|
||||
const lastName = dto.lastName.trim()
|
||||
const name = `${firstName} ${lastName}`.trim()
|
||||
const created = await this.prisma.representation.create({
|
||||
data: {
|
||||
name,
|
||||
firstName,
|
||||
lastName,
|
||||
nationalCode: dto.nationalCode?.trim() ?? '',
|
||||
mobile: dto.mobile,
|
||||
province: dto.province?.trim() ?? '',
|
||||
city: dto.city.trim(),
|
||||
landline: dto.landline?.trim() ?? '',
|
||||
address: dto.address?.trim() ?? '',
|
||||
postalCode: dto.postalCode?.trim() ?? '',
|
||||
description: dto.description?.trim() ?? '',
|
||||
owner: '',
|
||||
status: RepresentationStatus.pending,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
city: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
},
|
||||
})
|
||||
|
||||
await this.sms.notifyRepresentationRequest({
|
||||
name,
|
||||
mobile: dto.mobile,
|
||||
city: dto.city,
|
||||
})
|
||||
|
||||
return created
|
||||
}
|
||||
|
||||
@Post('call-requests')
|
||||
async createCallRequest(@Body() dto: CreateCallRequestDto) {
|
||||
const created = await this.prisma.callRequest.create({
|
||||
data: {
|
||||
name: dto.name.trim(),
|
||||
mobile: dto.mobile,
|
||||
note: dto.note?.trim() ?? '',
|
||||
},
|
||||
})
|
||||
|
||||
await this.sms.notifyCallRequest({
|
||||
name: created.name,
|
||||
mobile: created.mobile,
|
||||
})
|
||||
|
||||
return {
|
||||
id: created.id,
|
||||
name: created.name,
|
||||
createdAt: created.createdAt,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
import { PrismaModule } from '../prisma/prisma.module'
|
||||
import { SmsModule } from '../sms/sms.module'
|
||||
import { PublicController } from './public.controller'
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, SmsModule],
|
||||
controllers: [PublicController],
|
||||
})
|
||||
export class PublicModule {}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { IsOptional, IsString, Matches, MinLength } from 'class-validator'
|
||||
|
||||
export class CreateRepresentationDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
name!: string
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
owner?: string
|
||||
|
||||
@IsString()
|
||||
@Matches(/^09\d{9}$/, { message: 'شماره موبایل معتبر نیست' })
|
||||
mobile!: string
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
city!: string
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
address?: string
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { RepresentationStatus } from '@prisma/client';
|
||||
import { IsEnum, IsOptional } from 'class-validator';
|
||||
|
||||
export class ListRepresentationsQueryDto {
|
||||
@IsOptional()
|
||||
@IsEnum(RepresentationStatus)
|
||||
status?: RepresentationStatus;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { IsOptional, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class UpdateRepresentationDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
owner?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
mobile?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
city?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
address?: string;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { RepresentationStatus, 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 { CreateRepresentationDto } from './dto/create-representation.dto';
|
||||
import { ListRepresentationsQueryDto } from './dto/list-representations-query.dto';
|
||||
import { UpdateRepresentationDto } from './dto/update-representation.dto';
|
||||
import { RepresentationsService } from './representations.service';
|
||||
|
||||
@Controller('representations')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.Admin, UserRole.superAdmin)
|
||||
export class RepresentationsController {
|
||||
constructor(private readonly representations: RepresentationsService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query() query: ListRepresentationsQueryDto) {
|
||||
return this.representations.list(query);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@Body() dto: CreateRepresentationDto) {
|
||||
return this.representations.create(dto, RepresentationStatus.pending);
|
||||
}
|
||||
|
||||
@Post('active')
|
||||
createActive(@Body() dto: CreateRepresentationDto) {
|
||||
return this.representations.create(dto, RepresentationStatus.active);
|
||||
}
|
||||
|
||||
@Post(':id/approve')
|
||||
approve(@Param('id') id: string) {
|
||||
return this.representations.approve(id);
|
||||
}
|
||||
|
||||
@Post(':id/reject')
|
||||
reject(@Param('id') id: string) {
|
||||
return this.representations.reject(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(@Param('id') id: string, @Body() dto: UpdateRepresentationDto) {
|
||||
return this.representations.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.representations.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { RepresentationsController } from './representations.controller';
|
||||
import { RepresentationsService } from './representations.service';
|
||||
|
||||
@Module({
|
||||
controllers: [RepresentationsController],
|
||||
providers: [RepresentationsService],
|
||||
exports: [RepresentationsService],
|
||||
})
|
||||
export class RepresentationsModule {}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { RepresentationStatus } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateRepresentationDto } from './dto/create-representation.dto';
|
||||
import { ListRepresentationsQueryDto } from './dto/list-representations-query.dto';
|
||||
import { UpdateRepresentationDto } from './dto/update-representation.dto';
|
||||
|
||||
@Injectable()
|
||||
export class RepresentationsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
list(query: ListRepresentationsQueryDto) {
|
||||
return this.prisma.representation.findMany({
|
||||
where: query.status ? { status: query.status } : undefined,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const representation = await this.prisma.representation.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
if (!representation) {
|
||||
throw new NotFoundException('نمایندگی یافت نشد');
|
||||
}
|
||||
return representation;
|
||||
}
|
||||
|
||||
create(dto: CreateRepresentationDto, status: RepresentationStatus = RepresentationStatus.pending) {
|
||||
return this.prisma.representation.create({
|
||||
data: {
|
||||
name: dto.name.trim(),
|
||||
owner: dto.owner?.trim() ?? '',
|
||||
mobile: dto.mobile,
|
||||
city: dto.city.trim(),
|
||||
address: dto.address?.trim() ?? '',
|
||||
status,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async approve(id: string) {
|
||||
await this.findOne(id);
|
||||
return this.prisma.representation.update({
|
||||
where: { id },
|
||||
data: { status: RepresentationStatus.active },
|
||||
});
|
||||
}
|
||||
|
||||
async reject(id: string) {
|
||||
await this.findOne(id);
|
||||
return this.prisma.representation.update({
|
||||
where: { id },
|
||||
data: { status: RepresentationStatus.rejected },
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateRepresentationDto) {
|
||||
await this.findOne(id);
|
||||
return this.prisma.representation.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(dto.name !== undefined ? { name: dto.name } : {}),
|
||||
...(dto.owner !== undefined ? { owner: dto.owner } : {}),
|
||||
...(dto.mobile !== undefined ? { mobile: dto.mobile } : {}),
|
||||
...(dto.city !== undefined ? { city: dto.city } : {}),
|
||||
...(dto.address !== undefined ? { address: dto.address } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async remove(id: string) {
|
||||
await this.findOne(id);
|
||||
await this.prisma.representation.delete({ where: { id } });
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
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 { ReviewsService } from './reviews.service';
|
||||
|
||||
@Controller('reviews')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.Admin, UserRole.superAdmin)
|
||||
export class ReviewsController {
|
||||
constructor(private readonly reviews: ReviewsService) {}
|
||||
|
||||
@Get()
|
||||
list() {
|
||||
return this.reviews.list();
|
||||
}
|
||||
|
||||
@Post(':id/approve')
|
||||
approve(@Param('id') id: string) {
|
||||
return this.reviews.approve(id);
|
||||
}
|
||||
|
||||
@Post(':id/reject')
|
||||
reject(@Param('id') id: string) {
|
||||
return this.reviews.reject(id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.reviews.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ReviewsController } from './reviews.controller';
|
||||
import { ReviewsService } from './reviews.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ReviewsController],
|
||||
providers: [ReviewsService],
|
||||
exports: [ReviewsService],
|
||||
})
|
||||
export class ReviewsModule {}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { ReviewStatus } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class ReviewsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
list() {
|
||||
return this.prisma.review.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const review = await this.prisma.review.findUnique({ where: { id } });
|
||||
if (!review) {
|
||||
throw new NotFoundException('نظر یافت نشد');
|
||||
}
|
||||
return review;
|
||||
}
|
||||
|
||||
async approve(id: string) {
|
||||
await this.findOne(id);
|
||||
return this.prisma.review.update({
|
||||
where: { id },
|
||||
data: { status: ReviewStatus.approved },
|
||||
});
|
||||
}
|
||||
|
||||
async reject(id: string) {
|
||||
await this.findOne(id);
|
||||
return this.prisma.review.update({
|
||||
where: { id },
|
||||
data: { status: ReviewStatus.rejected },
|
||||
});
|
||||
}
|
||||
|
||||
async remove(id: string) {
|
||||
await this.findOne(id);
|
||||
await this.prisma.review.delete({ where: { id } });
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Controller, Get, Header } from '@nestjs/common'
|
||||
import { SitemapService } from './sitemap.service'
|
||||
|
||||
@Controller()
|
||||
export class SitemapController {
|
||||
constructor(private readonly sitemap: SitemapService) {}
|
||||
|
||||
@Get(['sitemap.xml', 'sitemap'])
|
||||
@Header('Content-Type', 'application/xml; charset=utf-8')
|
||||
@Header('Cache-Control', 'public, max-age=300')
|
||||
getSitemap() {
|
||||
return this.sitemap.buildXml()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
import { PrismaModule } from '../prisma/prisma.module'
|
||||
import { SitemapController } from './sitemap.controller'
|
||||
import { SitemapService } from './sitemap.service'
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [SitemapController],
|
||||
providers: [SitemapService],
|
||||
})
|
||||
export class SitemapModule {}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { Injectable } from '@nestjs/common'
|
||||
import { PrismaService } from '../prisma/prisma.service'
|
||||
|
||||
const SITE_ORIGIN = (process.env.PUBLIC_SITE_ORIGIN ?? 'https://madomotor.ir').replace(/\/$/, '')
|
||||
|
||||
const STATIC_PATHS: { path: string; changefreq: string; priority: string }[] = [
|
||||
{ path: '/', changefreq: 'daily', priority: '1.0' },
|
||||
{ path: '/product/list/محصولات', changefreq: 'daily', priority: '0.9' },
|
||||
{ path: '/blog/list/اخبار-و-مقالات', changefreq: 'daily', priority: '0.9' },
|
||||
{ path: '/representation/list/نمایندگی-ها', changefreq: 'weekly', priority: '0.8' },
|
||||
{ path: '/representation/request', changefreq: 'monthly', priority: '0.6' },
|
||||
{ path: '/contactus/تماس-با-ما', changefreq: 'monthly', priority: '0.7' },
|
||||
{ path: '/aboutus/درباره-ما', changefreq: 'monthly', priority: '0.7' },
|
||||
{ path: '/landing/mado-230/موتور-برقی-مادو-۲۳۰', changefreq: 'weekly', priority: '0.8' },
|
||||
{ path: '/landing/mado-130/موتور-برقی-مادو-۱۳۰', changefreq: 'weekly', priority: '0.8' },
|
||||
{ path: '/landing/mado-330/موتور-برقی-مادو-۳۳۰', changefreq: 'weekly', priority: '0.8' },
|
||||
]
|
||||
|
||||
@Injectable()
|
||||
export class SitemapService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async buildXml() {
|
||||
const [products, blogs] = await Promise.all([
|
||||
this.prisma.product.findMany({
|
||||
select: { id: true, nameFa: true, updatedAt: true },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
}),
|
||||
this.prisma.blogPost.findMany({
|
||||
where: { published: true },
|
||||
select: { id: true, title: true, updatedAt: true },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
}),
|
||||
])
|
||||
|
||||
const latestProduct = products[0]?.updatedAt
|
||||
const latestBlog = blogs[0]?.updatedAt
|
||||
|
||||
const urls: string[] = []
|
||||
|
||||
for (const page of STATIC_PATHS) {
|
||||
let lastmod: Date | undefined
|
||||
if (page.path.startsWith('/product/list') && latestProduct) lastmod = latestProduct
|
||||
if (page.path.startsWith('/blog/list') && latestBlog) lastmod = latestBlog
|
||||
urls.push(urlEntry(page.path, lastmod, page.changefreq, page.priority))
|
||||
}
|
||||
|
||||
for (const product of products) {
|
||||
urls.push(
|
||||
urlEntry(
|
||||
`/product/${product.id}/${toSlug(product.nameFa)}`,
|
||||
product.updatedAt,
|
||||
'weekly',
|
||||
'0.7',
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
for (const post of blogs) {
|
||||
urls.push(
|
||||
urlEntry(`/blog/${post.id}/${toSlug(post.title)}`, post.updatedAt, 'weekly', '0.7'),
|
||||
)
|
||||
}
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
${urls.join('\n')}
|
||||
</urlset>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
function toSlug(value: string) {
|
||||
return value
|
||||
.trim()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^\u0600-\u06FFa-zA-Z0-9-]/g, '')
|
||||
}
|
||||
|
||||
function escapeXml(value: string) {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
function urlEntry(path: string, lastmod: Date | undefined, changefreq: string, priority: string) {
|
||||
const loc = escapeXml(encodeURI(`${SITE_ORIGIN}${path}`))
|
||||
const lastmodTag = lastmod
|
||||
? `\n <lastmod>${lastmod.toISOString().slice(0, 10)}</lastmod>`
|
||||
: ''
|
||||
return ` <url>
|
||||
<loc>${loc}</loc>${lastmodTag}
|
||||
<changefreq>${changefreq}</changefreq>
|
||||
<priority>${priority}</priority>
|
||||
</url>`
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SmsService } from './sms.service';
|
||||
|
||||
@Module({
|
||||
providers: [SmsService],
|
||||
exports: [SmsService],
|
||||
})
|
||||
export class SmsModule {}
|
||||
@@ -0,0 +1,120 @@
|
||||
import {
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
Logger,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { SendSmsInput, SendSmsResult } from './sms.types';
|
||||
|
||||
@Injectable()
|
||||
export class SmsService {
|
||||
private readonly logger = new Logger(SmsService.name);
|
||||
private readonly apiUrl: string;
|
||||
private readonly apiKey: string;
|
||||
private readonly domain: string;
|
||||
|
||||
constructor(private readonly config: ConfigService) {
|
||||
this.apiUrl = this.config.getOrThrow<string>('MESHKEE_SMS_URL');
|
||||
this.apiKey = this.config.getOrThrow<string>('MESHKEE_SMS_API_KEY');
|
||||
this.domain = this.config.getOrThrow<string>('MESHKEE_SMS_DOMAIN');
|
||||
}
|
||||
|
||||
notifyMobiles(): string[] {
|
||||
const raw = this.config.get<string>('SMS_NOTIFY_MOBILES', '');
|
||||
return raw
|
||||
.split(/[,\s]+/)
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => /^09\d{9}$/.test(item));
|
||||
}
|
||||
|
||||
async sendQuietly(input: SendSmsInput): Promise<SendSmsResult | null> {
|
||||
try {
|
||||
return await this.send(input);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Meshkee SMS skipped for ${input.to}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async notifyAdmins(message: string, except?: string) {
|
||||
const targets = this.notifyMobiles().filter((to) => to !== except);
|
||||
await Promise.all(
|
||||
targets.map((to) => this.sendQuietly({ to, message })),
|
||||
);
|
||||
}
|
||||
|
||||
async notifyCallRequest(input: { name: string; mobile: string }) {
|
||||
const name = input.name.trim();
|
||||
await this.sendQuietly({
|
||||
to: input.mobile,
|
||||
message: `${name} عزیز\nدرخواست شما ثبت شد.\nبه زودی با شما تماس خواهیم گرفت.\nمادو موتور.`,
|
||||
});
|
||||
await this.notifyAdmins(
|
||||
`کاربر ${name} با شماره ${input.mobile} درخواست تماس ثبت کرده است.\nمادوموتور.`,
|
||||
input.mobile,
|
||||
);
|
||||
}
|
||||
|
||||
async notifyRepresentationRequest(input: {
|
||||
name: string;
|
||||
mobile: string;
|
||||
city: string;
|
||||
}) {
|
||||
const name = input.name.trim();
|
||||
await this.sendQuietly({
|
||||
to: input.mobile,
|
||||
message: `${name} عزیز\nدرخواست نمایندگی شما ثبت شد.\nبه زودی با شما تماس خواهیم گرفت.\nمادو موتور.`,
|
||||
});
|
||||
await this.notifyAdmins(
|
||||
`درخواست نمایندگی جدید از ${name} با شماره ${input.mobile} در ${input.city.trim()} ثبت شد.\nمادوموتور.`,
|
||||
input.mobile,
|
||||
);
|
||||
}
|
||||
|
||||
async send(input: SendSmsInput): Promise<SendSmsResult> {
|
||||
const to = input.to.trim();
|
||||
const message = input.message.trim();
|
||||
|
||||
if (!/^09\d{9}$/.test(to)) {
|
||||
throw new InternalServerErrorException('Invalid SMS destination');
|
||||
}
|
||||
if (!message) {
|
||||
throw new InternalServerErrorException('SMS message is empty');
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(this.apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Api-Key': this.apiKey,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
domain: this.domain,
|
||||
to,
|
||||
message,
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error('Meshkee SMS request failed', err);
|
||||
throw new ServiceUnavailableException('SMS provider unreachable');
|
||||
}
|
||||
|
||||
const body = (await response.json().catch(() => null)) as
|
||||
| { success?: boolean; serverId?: string; message?: string }
|
||||
| null;
|
||||
|
||||
if (!response.ok || !body?.success || !body.serverId) {
|
||||
this.logger.error(
|
||||
`Meshkee SMS rejected: status=${response.status} body=${JSON.stringify(body)}`,
|
||||
);
|
||||
throw new ServiceUnavailableException('SMS send failed');
|
||||
}
|
||||
|
||||
return { success: true, serverId: body.serverId };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export type SendSmsInput = {
|
||||
to: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type SendSmsResult = {
|
||||
success: true;
|
||||
serverId: string;
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import {
|
||||
DeleteObjectCommand,
|
||||
GetObjectCommand,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { StoredObject, UploadObjectInput } from './storage.types';
|
||||
|
||||
@Injectable()
|
||||
export class S3StorageDriver {
|
||||
private readonly client: S3Client;
|
||||
private readonly bucket: string;
|
||||
private readonly publicUrlBase: string;
|
||||
private readonly storageDisk = 's3';
|
||||
|
||||
constructor(private readonly config: ConfigService) {
|
||||
const endpoint = this.config.getOrThrow<string>('S3_ENDPOINT');
|
||||
const region = this.config.get<string>('S3_REGION', 'us-east-1');
|
||||
const forcePathStyle =
|
||||
this.config.get<string>('S3_FORCE_PATH_STYLE', 'true') === 'true';
|
||||
|
||||
this.bucket = this.config.getOrThrow<string>('S3_BUCKET');
|
||||
this.publicUrlBase = this.config
|
||||
.getOrThrow<string>('S3_PUBLIC_URL')
|
||||
.replace(/\/$/, '');
|
||||
|
||||
this.client = new S3Client({
|
||||
endpoint,
|
||||
region,
|
||||
forcePathStyle,
|
||||
credentials: {
|
||||
accessKeyId: this.config.getOrThrow<string>('S3_ACCESS_KEY_ID'),
|
||||
secretAccessKey: this.config.getOrThrow<string>('S3_SECRET_ACCESS_KEY'),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async upload(input: UploadObjectInput): Promise<StoredObject> {
|
||||
const key = input.key.replace(/^\/+/, '');
|
||||
|
||||
await this.client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: key,
|
||||
Body: input.body,
|
||||
ContentType: input.contentType,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
storageDisk: this.storageDisk,
|
||||
storagePath: key,
|
||||
publicUrl: `${this.publicUrlBase}/${key}`,
|
||||
};
|
||||
}
|
||||
|
||||
async getBuffer(storagePath: string): Promise<Buffer> {
|
||||
const key = storagePath.replace(/^\/+/, '');
|
||||
const result = await this.client.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: key,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!result.Body) {
|
||||
throw new Error(`Empty object body for ${key}`);
|
||||
}
|
||||
|
||||
return Buffer.from(await result.Body.transformToByteArray());
|
||||
}
|
||||
|
||||
async delete(storagePath: string): Promise<void> {
|
||||
const key = storagePath.replace(/^\/+/, '');
|
||||
|
||||
await this.client.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: key,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Parspack S3 object key layout (same bucket as Balout/Meshkee):
|
||||
*
|
||||
* mado-motor/
|
||||
* products/main/{uuid}{ext}
|
||||
* products/gallery/{uuid}{ext}
|
||||
* blogs/{uuid}{ext}
|
||||
* users/{uuid}{ext}
|
||||
* temp/{uuid}{ext}
|
||||
*/
|
||||
const ROOT = 'mado-motor';
|
||||
|
||||
export function productMainKey(fileName: string): string {
|
||||
return `${ROOT}/products/main/${fileName}`;
|
||||
}
|
||||
|
||||
export function productGalleryKey(fileName: string): string {
|
||||
return `${ROOT}/products/gallery/${fileName}`;
|
||||
}
|
||||
|
||||
export function blogMediaKey(fileName: string): string {
|
||||
return `${ROOT}/blogs/${fileName}`;
|
||||
}
|
||||
|
||||
export function userMediaKey(fileName: string): string {
|
||||
return `${ROOT}/users/${fileName}`;
|
||||
}
|
||||
|
||||
export function tempMediaKey(fileName: string): string {
|
||||
return `${ROOT}/temp/${fileName}`;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { S3StorageDriver } from './s3-storage.driver';
|
||||
import { StorageService } from './storage.service';
|
||||
|
||||
@Module({
|
||||
providers: [S3StorageDriver, StorageService],
|
||||
exports: [StorageService],
|
||||
})
|
||||
export class StorageModule {}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { S3StorageDriver } from './s3-storage.driver';
|
||||
import { StoredObject, UploadObjectInput } from './storage.types';
|
||||
|
||||
@Injectable()
|
||||
export class StorageService {
|
||||
constructor(private readonly s3: S3StorageDriver) {}
|
||||
|
||||
upload(input: UploadObjectInput): Promise<StoredObject> {
|
||||
return this.s3.upload(input);
|
||||
}
|
||||
|
||||
getBuffer(storagePath: string): Promise<Buffer> {
|
||||
return this.s3.getBuffer(storagePath);
|
||||
}
|
||||
|
||||
delete(storagePath: string): Promise<void> {
|
||||
return this.s3.delete(storagePath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export type StoredObject = {
|
||||
storageDisk: string;
|
||||
storagePath: string;
|
||||
publicUrl: string;
|
||||
};
|
||||
|
||||
export type UploadObjectInput = {
|
||||
key: string;
|
||||
body: Buffer;
|
||||
contentType: string;
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
"resolvePackageJsonExports": true,
|
||||
"esModuleInterop": true,
|
||||
"isolatedModules": true,
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2023",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user