Initial commit of the Mado Motor NestJS API.

This commit is contained in:
2026-09-03 23:41:32 +03:30
commit 9ffd26c318
90 changed files with 9949 additions and 0 deletions
+39
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
.env
*.log
.DS_Store
scripts/data/
+72
View File
@@ -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"
```
+25
View File
@@ -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:
+35
View File
@@ -0,0 +1,35 @@
// @ts-check
import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
eslintPluginPrettierRecommended,
{
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
sourceType: 'commonjs',
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
"prettier/prettier": ["error", { endOfLine: "auto" }],
},
},
);
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
+5613
View File
File diff suppressed because it is too large Load Diff
+52
View File
@@ -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 '';
+3
View File
@@ -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"
+206
View File
@@ -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])
}
+63
View File
@@ -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()
})
+396
View File
@@ -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()
})
+320
View File
@@ -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(/&amp;/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()
})
+53
View File
@@ -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);
});
+37
View File
@@ -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 {}
+73
View File
@@ -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);
}
}
+33
View File
@@ -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 {}
+425
View File
@@ -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;
}
}
+20
View File
@@ -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;
},
);
+5
View File
@@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
import { UserRole } from '@prisma/client';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);
+7
View File
@@ -0,0 +1,7 @@
import { IsString, Matches } from 'class-validator';
export class CellNumberDto {
@IsString()
@Matches(/^09\d{9}$/, { message: 'شماره موبایل معتبر نیست' })
cellNumber!: string;
}
+16
View File
@@ -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;
}
+13
View File
@@ -0,0 +1,13 @@
import { IsString, Matches, MinLength } from 'class-validator';
export class LoginDto {
@IsString()
@Matches(/^09\d{9}$/, {
message: 'شماره موبایل معتبر نیست',
})
cellNumber!: string;
@IsString()
@MinLength(4)
password!: string;
}
+12
View File
@@ -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;
}
+7
View File
@@ -0,0 +1,7 @@
import { IsString, MinLength } from 'class-validator';
export class RefreshTokenDto {
@IsString()
@MinLength(20)
refreshToken!: string;
}
+19
View File
@@ -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;
}
+12
View File
@@ -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;
}
+5
View File
@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
+35
View File
@@ -0,0 +1,35 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { UserRole } from '@prisma/client';
import { ROLES_KEY } from '../decorators/roles.decorator';
import { AuthUser } from '../auth.types';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(
ROLES_KEY,
[context.getHandler(), context.getClass()],
);
if (!requiredRoles?.length) {
return true;
}
const request = context.switchToHttp().getRequest<{ user?: AuthUser }>();
const user = request.user;
if (!user || !requiredRoles.includes(user.role)) {
throw new ForbiddenException('دسترسی مجاز نیست');
}
return true;
}
}
+44
View File
@@ -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,
};
}
}
+49
View File
@@ -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);
}
}
+10
View File
@@ -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 {}
+59
View File
@@ -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 };
}
}
+31
View File
@@ -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;
}
+32
View File
@@ -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);
}
}
+12
View File
@@ -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
}
+29
View File
@@ -0,0 +1,29 @@
/** Districts of Qom used across settings (shipping, branches). */
export const DISTRICTS = [
'پردیسان',
'جعفریه',
'زنبیل‌آباد',
'سالاریه',
'صفاییه',
'عطاران',
'انارستان',
'بنیاد',
'آذر',
'هنرستان',
'باجک',
'دورشهر',
'خیابان ارم',
'شهرک قدس',
'شهرک امام حسن',
'شهرک مهدیه',
'قلعه کامکار',
'نیروگاه',
'حرم',
'بلوار امین',
] as const;
export type District = (typeof DISTRICTS)[number];
export function isDistrict(value: string): value is District {
return (DISTRICTS as readonly string[]).includes(value);
}
+10
View File
@@ -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'
}
+49
View File
@@ -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);
}
}
+10
View File
@@ -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 {}
+154
View File
@@ -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 };
}
}
+28
View File
@@ -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
}
+16
View File
@@ -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
View File
@@ -0,0 +1,28 @@
import { ValidationPipe } from '@nestjs/common'
import { NestFactory } from '@nestjs/core'
import { AppModule } from './app.module'
async function bootstrap() {
const app = await NestFactory.create(AppModule)
app.setGlobalPrefix('api/v1')
app.enableCors({
origin: process.env.CORS_ORIGIN?.split(',').map((s) => s.trim()) ?? true,
credentials: true,
})
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: { enableImplicitConversion: true },
}),
)
const port = Number(process.env.PORT ?? 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()
+9
View File
@@ -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;
}
+36
View File
@@ -0,0 +1,36 @@
import {
Controller,
Post,
Query,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { UserRole } from '@prisma/client';
import { memoryStorage } from 'multer';
import { Roles } from '../auth/decorators/roles.decorator';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { MediaUploadQueryDto } from './dto/media-upload-query.dto';
import { MediaService } from './media.service';
@Controller('media')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.Admin, UserRole.superAdmin)
export class MediaController {
constructor(private readonly media: MediaService) {}
@Post('upload')
@UseInterceptors(
FileInterceptor('file', {
storage: memoryStorage(),
}),
)
upload(
@Query() query: MediaUploadQueryDto,
@UploadedFile() file: Express.Multer.File,
) {
return this.media.upload(query.kind, file);
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { StorageModule } from '../storage/storage.module';
import { MediaController } from './media.controller';
import { MediaService } from './media.service';
@Module({
imports: [ConfigModule, StorageModule],
controllers: [MediaController],
providers: [MediaService],
})
export class MediaModule {}
+85
View File
@@ -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;
}
+40
View File
@@ -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);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { OrdersController } from './orders.controller';
import { OrdersService } from './orders.service';
@Module({
controllers: [OrdersController],
providers: [OrdersService],
exports: [OrdersService],
})
export class OrdersModule {}
+43
View File
@@ -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 },
});
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
+16
View File
@@ -0,0 +1,16 @@
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService
extends PrismaClient
implements OnModuleInit, OnModuleDestroy
{
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}
+32
View File
@@ -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;
}
+34
View File
@@ -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;
}
+49
View File
@@ -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);
}
}
+10
View File
@@ -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 {}
+59
View File
@@ -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
}
+160
View File
@@ -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,
}
}
}
+10
View File
@@ -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 };
}
}
+40
View File
@@ -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);
}
}
+10
View File
@@ -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 {}
+44
View File
@@ -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 };
}
}
+14
View File
@@ -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()
}
}
+11
View File
@@ -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 {}
+98
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
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>`
}
+8
View File
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { SmsService } from './sms.service';
@Module({
providers: [SmsService],
exports: [SmsService],
})
export class SmsModule {}
+120
View File
@@ -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 };
}
}
+9
View File
@@ -0,0 +1,9 @@
export type SendSmsInput = {
to: string;
message: string;
};
export type SendSmsResult = {
success: true;
serverId: string;
};
+85
View File
@@ -0,0 +1,85 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
DeleteObjectCommand,
GetObjectCommand,
PutObjectCommand,
S3Client,
} from '@aws-sdk/client-s3';
import { StoredObject, UploadObjectInput } from './storage.types';
@Injectable()
export class S3StorageDriver {
private readonly client: S3Client;
private readonly bucket: string;
private readonly publicUrlBase: string;
private readonly storageDisk = 's3';
constructor(private readonly config: ConfigService) {
const endpoint = this.config.getOrThrow<string>('S3_ENDPOINT');
const region = this.config.get<string>('S3_REGION', 'us-east-1');
const forcePathStyle =
this.config.get<string>('S3_FORCE_PATH_STYLE', 'true') === 'true';
this.bucket = this.config.getOrThrow<string>('S3_BUCKET');
this.publicUrlBase = this.config
.getOrThrow<string>('S3_PUBLIC_URL')
.replace(/\/$/, '');
this.client = new S3Client({
endpoint,
region,
forcePathStyle,
credentials: {
accessKeyId: this.config.getOrThrow<string>('S3_ACCESS_KEY_ID'),
secretAccessKey: this.config.getOrThrow<string>('S3_SECRET_ACCESS_KEY'),
},
});
}
async upload(input: UploadObjectInput): Promise<StoredObject> {
const key = input.key.replace(/^\/+/, '');
await this.client.send(
new PutObjectCommand({
Bucket: this.bucket,
Key: key,
Body: input.body,
ContentType: input.contentType,
}),
);
return {
storageDisk: this.storageDisk,
storagePath: key,
publicUrl: `${this.publicUrlBase}/${key}`,
};
}
async getBuffer(storagePath: string): Promise<Buffer> {
const key = storagePath.replace(/^\/+/, '');
const result = await this.client.send(
new GetObjectCommand({
Bucket: this.bucket,
Key: key,
}),
);
if (!result.Body) {
throw new Error(`Empty object body for ${key}`);
}
return Buffer.from(await result.Body.transformToByteArray());
}
async delete(storagePath: string): Promise<void> {
const key = storagePath.replace(/^\/+/, '');
await this.client.send(
new DeleteObjectCommand({
Bucket: this.bucket,
Key: key,
}),
);
}
}
+31
View File
@@ -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}`;
}
+9
View File
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { S3StorageDriver } from './s3-storage.driver';
import { StorageService } from './storage.service';
@Module({
providers: [S3StorageDriver, StorageService],
exports: [StorageService],
})
export class StorageModule {}
+20
View File
@@ -0,0 +1,20 @@
import { Injectable } from '@nestjs/common';
import { S3StorageDriver } from './s3-storage.driver';
import { StoredObject, UploadObjectInput } from './storage.types';
@Injectable()
export class StorageService {
constructor(private readonly s3: S3StorageDriver) {}
upload(input: UploadObjectInput): Promise<StoredObject> {
return this.s3.upload(input);
}
getBuffer(storagePath: string): Promise<Buffer> {
return this.s3.getBuffer(storagePath);
}
delete(storagePath: string): Promise<void> {
return this.s3.delete(storagePath);
}
}
+11
View File
@@ -0,0 +1,11 @@
export type StoredObject = {
storageDisk: string;
storagePath: string;
publicUrl: string;
};
export type UploadObjectInput = {
key: string;
body: Buffer;
contentType: string;
};
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"resolvePackageJsonExports": true,
"esModuleInterop": true,
"isolatedModules": true,
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2023",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"noFallthroughCasesInSwitch": true
}
}