flwo / PHASE_2_BACKEND.md
moustafa7's picture
Initial commit
24727a3
|
Raw History Blame Contribute Delete
54.5 kB
# DesignFlow โ€” ุงู„ู…ุฑุญู„ุฉ ุงู„ุซุงู†ูŠุฉ: Backend + PostgreSQL + Security
> **ู„ู„ู€ AI ุงู„ู…ู†ูุฐ:** ุงู‚ุฑุฃ ู‡ุฐุง ุงู„ู…ู„ู ูƒุงู…ู„ุงู‹ ู‚ุจู„ ูƒุชุงุจุฉ ุฃูŠ ุณุทุฑ ูƒูˆุฏ.
> ูƒู„ ู‚ุฑุงุฑ ุฃู…ู†ูŠ ู‡ู†ุง **ุฅู„ุฒุงู…ูŠ** ูˆู„ูŠุณ ุงุฎุชูŠุงุฑูŠุงู‹. ุชุฑุชูŠุจ ุงู„ุชู†ููŠุฐ ู…ู‡ู….
> ู„ุง ุชุจุฏุฃ ุฑุจุท ุงู„ู€ Frontend ุฅู„ุง ุจุนุฏ ุงุฎุชุจุงุฑ ูƒู„ endpoint ุจู€ Postman/Thunder Client.
---
## 1. ุฅุนุฏุงุฏ ุงู„ู…ุดุฑูˆุน
```bash
mkdir apps/server && cd apps/server
npm init -y
# Core
npm install express cors helmet cookie-parser express-rate-limit
# Database
npm install prisma @prisma/client
npx prisma init
# Redis
npm install ioredis
# Auth & Security
npm install bcryptjs jsonwebtoken
# Validation
npm install zod
# File Upload
npm install multer
npm install cloudinary multer-storage-cloudinary
# Real-time
npm install socket.io
# Utilities
npm install uuid dotenv morgan compression
# Dev
npm install -D nodemon jest supertest
```
### 1.1 ู‡ูŠูƒู„ ู…ุฌู„ุฏุงุช ุงู„ู€ Backend ุงู„ูƒุงู…ู„
```
apps/server/
โ”œโ”€โ”€ prisma/
โ”‚ โ”œโ”€โ”€ schema.prisma
โ”‚ โ””โ”€โ”€ migrations/
โ”‚ โ””โ”€โ”€ (auto-generated)
โ”‚
โ”œโ”€โ”€ src/
โ”‚ โ”œโ”€โ”€ config/
โ”‚ โ”‚ โ”œโ”€โ”€ index.js โ† env validation + config object
โ”‚ โ”‚ โ”œโ”€โ”€ database.js โ† Prisma singleton
โ”‚ โ”‚ โ””โ”€โ”€ redis.js โ† ioredis singleton
โ”‚ โ”‚
โ”‚ โ”œโ”€โ”€ middleware/
โ”‚ โ”‚ โ”œโ”€โ”€ auth.js โ† verifyToken, requireRole
โ”‚ โ”‚ โ”œโ”€โ”€ validate.js โ† zod validation wrapper
โ”‚ โ”‚ โ”œโ”€โ”€ rateLimiter.js โ† rate limit factories
โ”‚ โ”‚ โ”œโ”€โ”€ upload.js โ† multer + cloudinary config
โ”‚ โ”‚ โ””โ”€โ”€ errorHandler.js โ† global error handler
โ”‚ โ”‚
โ”‚ โ”œโ”€โ”€ modules/
โ”‚ โ”‚ โ”œโ”€โ”€ auth/
โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ auth.routes.js
โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ auth.controller.js
โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ auth.service.js
โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ auth.schema.js
โ”‚ โ”‚ โ”œโ”€โ”€ users/
โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ users.routes.js
โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ users.controller.js
โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ users.service.js
โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ users.schema.js
โ”‚ โ”‚ โ”œโ”€โ”€ templates/
โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ templates.routes.js
โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ templates.controller.js
โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ templates.service.js
โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ templates.schema.js
โ”‚ โ”‚ โ”œโ”€โ”€ orders/
โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ orders.routes.js
โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ orders.controller.js
โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ orders.service.js
โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ orders.schema.js
โ”‚ โ”‚ โ”œโ”€โ”€ chat/
โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ chat.routes.js
โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ chat.controller.js
โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ chat.service.js
โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ chat.gateway.js โ† socket.io handlers
โ”‚ โ”‚ โ”œโ”€โ”€ admin/
โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ admin.routes.js
โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ admin.controller.js
โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ admin.service.js
โ”‚ โ”‚ โ””โ”€โ”€ landing/
โ”‚ โ”‚ โ”œโ”€โ”€ landing.routes.js
โ”‚ โ”‚ โ”œโ”€โ”€ landing.controller.js
โ”‚ โ”‚ โ””โ”€โ”€ landing.service.js
โ”‚ โ”‚
โ”‚ โ”œโ”€โ”€ lib/
โ”‚ โ”‚ โ”œโ”€โ”€ jwt.js โ† sign/verify helpers
โ”‚ โ”‚ โ”œโ”€โ”€ AppError.js โ† custom error class
โ”‚ โ”‚ โ””โ”€โ”€ pagination.js โ† pagination helper
โ”‚ โ”‚
โ”‚ โ”œโ”€โ”€ socket/
โ”‚ โ”‚ โ””โ”€โ”€ index.js โ† socket.io setup
โ”‚ โ”‚
โ”‚ โ”œโ”€โ”€ app.js โ† express app (no listen)
โ”‚ โ””โ”€โ”€ server.js โ† http server + socket + listen
โ”‚
โ”œโ”€โ”€ tests/
โ”‚ โ”œโ”€โ”€ unit/
โ”‚ โ”‚ โ”œโ”€โ”€ auth.service.test.js
โ”‚ โ”‚ โ”œโ”€โ”€ jwt.test.js
โ”‚ โ”‚ โ””โ”€โ”€ validation.test.js
โ”‚ โ””โ”€โ”€ integration/
โ”‚ โ”œโ”€โ”€ auth.test.js
โ”‚ โ”œโ”€โ”€ templates.test.js
โ”‚ โ”œโ”€โ”€ orders.test.js
โ”‚ โ””โ”€โ”€ chat.test.js
โ”‚
โ”œโ”€โ”€ .env
โ”œโ”€โ”€ .env.example
โ”œโ”€โ”€ .env.test
โ”œโ”€โ”€ ecosystem.config.js โ† PM2 config
โ””โ”€โ”€ package.json
```
---
## 2. Environment Variables
```env
# apps/server/.env.example
# ===== App =====
NODE_ENV=development
PORT=5000
CLIENT_URL=http://localhost:5173
# CRITICAL: ุบูŠุฑ ุงู„ู…ุณุงุฑ ุฏู‡ ููŠ ุงู„ู€ production
ADMIN_ROUTE_HINT=x9k2-manage
# ===== Database =====
# Connection string ู…ุน PgBouncer ููŠ ุงู„ู€ production
DATABASE_URL="postgresql://designflow_user:StrongPassword123!@localhost:5432/designflow_db?schema=public"
# ู„ู„ู€ testing: ู‚ุงุนุฏุฉ ู…ู†ูุตู„ุฉ
DATABASE_URL_TEST="postgresql://designflow_user:StrongPassword123!@localhost:5432/designflow_test?schema=public"
# ===== Redis =====
REDIS_URL="redis://localhost:6379"
REDIS_PASSWORD="" # ูุงุถูŠ ููŠ devุŒ ููŠ production ู„ุงุฒู… ูŠูƒูˆู† ู…ูˆุฌูˆุฏ
# ===== JWT =====
# CRITICAL: ุงุณุชุฎุฏู… openssl rand -base64 64 ู„ุชูˆู„ูŠุฏ secrets ุญู‚ูŠู‚ูŠุฉ
JWT_ACCESS_SECRET="CHANGE_ME_USE_OPENSSL_RAND_64_CHARS_MINIMUM_HERE"
JWT_REFRESH_SECRET="CHANGE_ME_DIFFERENT_FROM_ACCESS_SECRET_HERE"
JWT_ACCESS_EXPIRES="15m"
JWT_REFRESH_EXPIRES="7d"
# ===== Cloudinary =====
CLOUDINARY_CLOUD_NAME=""
CLOUDINARY_API_KEY=""
CLOUDINARY_API_SECRET=""
# ===== Rate Limiting =====
RATE_LIMIT_WINDOW_MS=900000 # 15 ุฏู‚ูŠู‚ุฉ
RATE_LIMIT_MAX_GENERAL=100
RATE_LIMIT_MAX_AUTH=5
RATE_LIMIT_MAX_REGISTER=3
```
---
## 3. Config Layer
```javascript
// src/config/index.js
import { z } from 'zod'
import dotenv from 'dotenv'
dotenv.config()
// Validate ENV vars ุนู†ุฏ ุงู„ู€ startup โ€” ุชู‚ู ุงู„ุชุทุจูŠู‚ ู„ูˆ ููŠู‡ ุญุงุฌุฉ ู†ุงู‚ุตุฉ
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
PORT: z.string().default('5000').transform(Number),
CLIENT_URL: z.string().url(),
DATABASE_URL: z.string().url(),
REDIS_URL: z.string(),
JWT_ACCESS_SECRET: z.string().min(32, 'JWT_ACCESS_SECRET must be at least 32 chars'),
JWT_REFRESH_SECRET: z.string().min(32, 'JWT_REFRESH_SECRET must be at least 32 chars'),
JWT_ACCESS_EXPIRES: z.string().default('15m'),
JWT_REFRESH_EXPIRES:z.string().default('7d'),
CLOUDINARY_CLOUD_NAME: z.string(),
CLOUDINARY_API_KEY: z.string(),
CLOUDINARY_API_SECRET: z.string(),
})
const parsed = envSchema.safeParse(process.env)
if (!parsed.success) {
console.error('โŒ Invalid environment variables:', parsed.error.flatten().fieldErrors)
process.exit(1)
}
export const config = parsed.data
```
```javascript
// src/config/database.js
import { PrismaClient } from '@prisma/client'
// Singleton โ€” ู„ุง ุชุนู…ู„ instance ุฌุฏูŠุฏุฉ ููŠ ูƒู„ request
const globalForPrisma = globalThis
export const prisma = globalForPrisma.prisma ?? new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error'] : ['error'],
})
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
// ุงุฎุชุจุงุฑ ุงู„ุงุชุตุงู„
export async function connectDB() {
try {
await prisma.$connect()
console.log('โœ… PostgreSQL connected')
} catch (err) {
console.error('โŒ Database connection failed:', err)
process.exit(1)
}
}
```
```javascript
// src/config/redis.js
import Redis from 'ioredis'
import { config } from './index.js'
export const redis = new Redis(config.REDIS_URL, {
password: process.env.REDIS_PASSWORD || undefined,
retryStrategy: (times) => Math.min(times * 100, 3000), // retry ู…ุน backoff
maxRetriesPerRequest: 3,
enableReadyCheck: true,
})
redis.on('connect', () => console.log('โœ… Redis connected'))
redis.on('error', (err) => console.error('โŒ Redis error:', err))
```
---
## 4. Prisma Schema ุงู„ูƒุงู…ู„
```prisma
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
// ENUMS
// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
enum Role {
CLIENT
SUPPORT
ADMIN
}
enum OrderStatus {
PENDING
ACCEPTED
IN_PROGRESS
COMPLETED
CANCELLED
}
// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
// MODELS
// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
model User {
id String @id @default(cuid())
firstName String @db.VarChar(50)
lastName String @db.VarChar(50)
phone String @unique @db.VarChar(20)
email String @unique @db.VarChar(255)
passwordHash String @db.Text
avatarUrl String? @db.Text
role Role @default(CLIENT)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
orders Order[]
sentMessages Message[] @relation("SentMessages")
refreshTokens RefreshToken[]
@@index([email])
@@index([phone])
@@index([role])
@@map("users")
}
model RefreshToken {
id String @id @default(cuid())
token String @unique @db.Text // hashed token
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
expiresAt DateTime
createdAt DateTime @default(now())
@@index([userId])
@@index([expiresAt]) // ู„ุญุฐู ุงู„ู…ู†ุชู‡ูŠุฉ ุชู„ู‚ุงุฆูŠุงู‹
@@map("refresh_tokens")
}
model Template {
id String @id @default(cuid())
title String @db.VarChar(100)
description String @db.Text
category String @db.VarChar(50)
price Decimal @db.Decimal(10, 2)
previewUrl String @db.Text
demoUrl String? @db.Text
tags String[]
isPublished Boolean @default(false)
defaultColors Json // { primary, secondary, accent, text }
components Json // { sections: [...], colorBindings: {...} }
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
orders Order[]
@@index([isPublished])
@@index([category])
@@index([sortOrder])
@@map("templates")
}
model Order {
id String @id @default(cuid())
orderNumber String @unique
userId String
user User @relation(fields: [userId], references: [id])
templateId String
template Template @relation(fields: [templateId], references: [id])
status OrderStatus @default(PENDING)
customization Json // { sections: [...], colorTokens: {...} }
totalAmount Decimal @db.Decimal(10, 2)
notes String? @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
conversation Conversation?
@@index([userId])
@@index([status])
@@index([createdAt])
@@map("orders")
}
model Conversation {
id String @id @default(cuid())
orderId String @unique
order Order @relation(fields: [orderId], references: [id])
isOpen Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
messages Message[]
@@map("conversations")
}
model Message {
id String @id @default(cuid())
conversationId String
conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
senderId String
sender User @relation("SentMessages", fields: [senderId], references: [id])
content String @db.Text
isRead Boolean @default(false)
createdAt DateTime @default(now())
@@index([conversationId])
@@index([createdAt])
@@index([isRead])
@@map("messages")
}
model LandingContent {
id String @id @default(cuid())
section String @unique @db.VarChar(50) // "hero", "features", etc.
content Json
updatedAt DateTime @updatedAt
@@map("landing_content")
}
```
### 4.1 Migration & Seed
```bash
# ุฅู†ุดุงุก migration
npx prisma migrate dev --name init
# ุชูˆู„ูŠุฏ Prisma Client
npx prisma generate
# Seed (ุฅู†ุดุงุก ุฃุฏู…ู† ุฃูˆู„ูŠ + landing content)
npx prisma db seed
```
```javascript
// prisma/seed.js
import { PrismaClient } from '@prisma/client'
import bcrypt from 'bcryptjs'
const prisma = new PrismaClient()
async function main() {
// Admin user
const hashedPass = await bcrypt.hash('Admin@123456', 12)
await prisma.user.upsert({
where: { email: 'admin@designflow.com' },
update: {},
create: {
firstName: 'Admin',
lastName: 'System',
phone: '+201000000000',
email: 'admin@designflow.com',
passwordHash: hashedPass,
role: 'ADMIN',
}
})
// Default landing content
const sections = ['hero', 'features', 'how_it_works', 'testimonials', 'footer']
for (const section of sections) {
await prisma.landingContent.upsert({
where: { section },
update: {},
create: { section, content: getDefaultContent(section) }
})
}
console.log('โœ… Seed completed')
}
main().catch(console.error).finally(() => prisma.$disconnect())
```
---
## 5. Security Layer โ€” ูƒู„ ุงู„ู€ Middleware
### 5.1 AppError Class
```javascript
// src/lib/AppError.js
export class AppError extends Error {
constructor(message, statusCode = 500, code = 'INTERNAL_ERROR') {
super(message)
this.statusCode = statusCode
this.code = code
this.isOperational = true
Error.captureStackTrace(this, this.constructor)
}
}
// ุงุณุชุฎุฏุงู… ู…ูˆุญุฏ ููŠ ูƒู„ ู…ูƒุงู†:
// throw new AppError('ุงู„ู…ุณุชุฎุฏู… ุบูŠุฑ ู…ูˆุฌูˆุฏ', 404, 'USER_NOT_FOUND')
// throw new AppError('ุงู„ุจูŠุงู†ุงุช ุบูŠุฑ ุตุญูŠุญุฉ', 400, 'VALIDATION_ERROR')
// throw new AppError('ุบูŠุฑ ู…ุตุฑุญ', 401, 'UNAUTHORIZED')
// throw new AppError('ู…ู…ู†ูˆุน ุงู„ูˆุตูˆู„', 403, 'FORBIDDEN')
```
### 5.2 Global Error Handler
```javascript
// src/middleware/errorHandler.js
export function errorHandler(err, req, res, next) {
// Log non-operational errors (programming bugs)
if (!err.isOperational) {
console.error('๐Ÿ’ฅ CRITICAL ERROR:', err)
// ููŠ production: ุฃุฑุณู„ ู„ู€ Sentry/DataDog
}
// Prisma errors
if (err.code === 'P2002') {
return res.status(409).json({ error: 'ู‡ุฐุง ุงู„ุจุฑูŠุฏ ุฃูˆ ุฑู‚ู… ุงู„ู‡ุงุชู ู…ุณุชุฎุฏู… ุจุงู„ูุนู„', code: 'DUPLICATE_ENTRY' })
}
if (err.code === 'P2025') {
return res.status(404).json({ error: 'ุงู„ุนู†ุตุฑ ุบูŠุฑ ู…ูˆุฌูˆุฏ', code: 'NOT_FOUND' })
}
// JWT errors
if (err.name === 'JsonWebTokenError') {
return res.status(401).json({ error: 'ุฑู…ุฒ ุงู„ู…ุตุงุฏู‚ุฉ ุบูŠุฑ ุตุญูŠุญ', code: 'INVALID_TOKEN' })
}
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'ุงู†ุชู‡ุช ุตู„ุงุญูŠุฉ ุงู„ุฌู„ุณุฉ', code: 'TOKEN_EXPIRED' })
}
// Zod validation errors
if (err.name === 'ZodError') {
return res.status(400).json({ error: 'ุจูŠุงู†ุงุช ุบูŠุฑ ุตุญูŠุญุฉ', code: 'VALIDATION_ERROR', details: err.flatten() })
}
// Operational errors (expected)
if (err.isOperational) {
return res.status(err.statusCode).json({ error: err.message, code: err.code })
}
// Unknown errors โ€” ู„ุง ุชูƒุดู ุงู„ุชูุงุตูŠู„
return res.status(500).json({ error: 'ุญุฏุซ ุฎุทุฃ ุฏุงุฎู„ูŠ', code: 'INTERNAL_ERROR' })
}
```
### 5.3 Auth Middleware
```javascript
// src/middleware/auth.js
import jwt from 'jsonwebtoken'
import { config } from '../config/index.js'
import { redis } from '../config/redis.js'
import { AppError } from '../lib/AppError.js'
import { prisma } from '../config/database.js'
export async function authenticate(req, res, next) {
try {
// 1. Extract token
const authHeader = req.headers.authorization
if (!authHeader?.startsWith('Bearer ')) {
throw new AppError('ู„ู… ูŠุชู… ุชูˆููŠุฑ ุฑู…ุฒ ุงู„ู…ุตุงุฏู‚ุฉ', 401, 'NO_TOKEN')
}
const token = authHeader.split(' ')[1]
// 2. Check blacklist (Redis) โ€” ู„ู„ู€ tokens ุงู„ู…ูุจุทู„ุฉ ุจุนุฏ logout
const isBlacklisted = await redis.get(`blacklist:${token}`)
if (isBlacklisted) throw new AppError('ุฑู…ุฒ ุงู„ู…ุตุงุฏู‚ุฉ ู…ู„ุบูŠ', 401, 'TOKEN_REVOKED')
// 3. Verify JWT
const decoded = jwt.verify(token, config.JWT_ACCESS_SECRET)
// 4. Check user still exists and is active
const user = await prisma.user.findUnique({
where: { id: decoded.sub },
select: { id: true, role: true, isActive: true, email: true, firstName: true, lastName: true }
})
if (!user || !user.isActive) {
throw new AppError('ุงู„ู…ุณุชุฎุฏู… ุบูŠุฑ ู…ูˆุฌูˆุฏ ุฃูˆ ู…ุญุธูˆุฑ', 401, 'USER_NOT_FOUND')
}
req.user = user
next()
} catch (err) {
next(err)
}
}
// Role-based access
export function requireRole(...roles) {
return (req, res, next) => {
if (!req.user) return next(new AppError('ุบูŠุฑ ู…ุตุงุฏู‚', 401, 'UNAUTHORIZED'))
if (!roles.includes(req.user.role)) {
return next(new AppError('ู„ูŠุณ ู„ุฏูŠูƒ ุตู„ุงุญูŠุฉ', 403, 'FORBIDDEN'))
}
next()
}
}
// Shortcuts
export const requireAdmin = requireRole('ADMIN')
export const requireSupport = requireRole('ADMIN', 'SUPPORT')
```
### 5.4 Validation Middleware
```javascript
// src/middleware/validate.js
export function validate(schema, source = 'body') {
return (req, res, next) => {
const data = source === 'body' ? req.body
: source === 'params' ? req.params
: req.query
const result = schema.safeParse(data)
if (!result.success) {
return res.status(400).json({
error: 'ุจูŠุงู†ุงุช ุบูŠุฑ ุตุญูŠุญุฉ',
code: 'VALIDATION_ERROR',
details: result.error.flatten().fieldErrors
})
}
req.validatedData = result.data
next()
}
}
```
### 5.5 Rate Limiters
```javascript
// src/middleware/rateLimiter.js
import rateLimit from 'express-rate-limit'
import RedisStore from 'rate-limit-redis'
import { redis } from '../config/redis.js'
const makeStore = (prefix) => new RedisStore({
prefix: `rl:${prefix}:`,
sendCommand: (...args) => redis.call(...args),
})
// General API rate limit
export const generalLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
store: makeStore('general'),
standardHeaders: true,
legacyHeaders: false,
message: { error: 'ุนุฏุฏ ูƒุจูŠุฑ ุฌุฏุงู‹ ู…ู† ุงู„ุทู„ุจุงุชุŒ ุญุงูˆู„ ุจุนุฏ 15 ุฏู‚ูŠู‚ุฉ', code: 'RATE_LIMIT_EXCEEDED' }
})
// Auth endpoints โ€” ุฃุดุฏ ุชู‚ูŠูŠุฏุงู‹
export const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5, // 5 ู…ุญุงูˆู„ุงุช login ูู‚ุท
store: makeStore('login'),
skipSuccessfulRequests: true, // ู„ุง ูŠุญุณุจ ุงู„ู€ successful logins
message: { error: 'ุชุฌุงูˆุฒุช ุงู„ุญุฏ ุงู„ู…ุณู…ูˆุญุŒ ุญุงูˆู„ ุจุนุฏ 15 ุฏู‚ูŠู‚ุฉ', code: 'TOO_MANY_LOGIN_ATTEMPTS' }
})
export const registerLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // ุณุงุนุฉ
max: 3,
store: makeStore('register'),
message: { error: 'ุญุฏ ุงู„ุชุณุฌูŠู„ุŒ ุญุงูˆู„ ุจุนุฏ ุณุงุนุฉ', code: 'TOO_MANY_REGISTRATIONS' }
})
// Upload limiter
export const uploadLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
max: 20,
store: makeStore('upload'),
message: { error: 'ุญุฏ ุงู„ุฑูุนุŒ ุญุงูˆู„ ุจุนุฏ ุณุงุนุฉ', code: 'UPLOAD_RATE_LIMIT' }
})
```
### 5.6 Upload Middleware
```javascript
// src/middleware/upload.js
import multer from 'multer'
import { v2 as cloudinary } from 'cloudinary'
import { CloudinaryStorage } from 'multer-storage-cloudinary'
import { config } from '../config/index.js'
import { AppError } from '../lib/AppError.js'
cloudinary.config({
cloud_name: config.CLOUDINARY_CLOUD_NAME,
api_key: config.CLOUDINARY_API_KEY,
api_secret: config.CLOUDINARY_API_SECRET,
})
const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp']
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
function makeCloudinaryStorage(folder) {
return new CloudinaryStorage({
cloudinary,
params: { folder: `designflow/${folder}`, allowed_formats: ['jpg', 'jpeg', 'png', 'webp'] }
})
}
function fileFilter(req, file, cb) {
if (ALLOWED_MIME_TYPES.includes(file.mimetype)) {
cb(null, true)
} else {
cb(new AppError('ู†ูˆุน ุงู„ู…ู„ู ุบูŠุฑ ู…ุณู…ูˆุญ (ูู‚ุท JPEG, PNG, WebP)', 400, 'INVALID_FILE_TYPE'))
}
}
export const uploadAvatar = multer({
storage: makeCloudinaryStorage('avatars'),
limits: { fileSize: MAX_FILE_SIZE },
fileFilter,
}).single('avatar')
export const uploadTemplateImage = multer({
storage: makeCloudinaryStorage('templates'),
limits: { fileSize: MAX_FILE_SIZE * 2 }, // 10MB ู„ู„ู‚ูˆุงู„ุจ
fileFilter,
}).single('preview')
```
---
## 6. JWT Service
```javascript
// src/lib/jwt.js
import jwt from 'jsonwebtoken'
import { config } from '../config/index.js'
import { redis } from '../config/redis.js'
export function signAccessToken(userId, role) {
return jwt.sign(
{ sub: userId, role, type: 'access' },
config.JWT_ACCESS_SECRET,
{ expiresIn: config.JWT_ACCESS_EXPIRES }
)
}
export function signRefreshToken(userId) {
return jwt.sign(
{ sub: userId, type: 'refresh' },
config.JWT_REFRESH_SECRET,
{ expiresIn: config.JWT_REFRESH_EXPIRES }
)
}
export function verifyAccessToken(token) {
return jwt.verify(token, config.JWT_ACCESS_SECRET)
}
export function verifyRefreshToken(token) {
return jwt.verify(token, config.JWT_REFRESH_SECRET)
}
// Blacklist access token ุนู†ุฏ ุงู„ู€ logout
export async function blacklistToken(token) {
const decoded = jwt.decode(token)
if (!decoded?.exp) return
const ttl = decoded.exp - Math.floor(Date.now() / 1000)
if (ttl > 0) await redis.setex(`blacklist:${token}`, ttl, '1')
}
// Socket.IO auth
export async function verifySocketToken(token) {
try {
const decoded = verifyAccessToken(token)
const isBlacklisted = await redis.get(`blacklist:${token}`)
if (isBlacklisted) return null
return decoded
} catch {
return null
}
}
```
---
## 7. Auth Module ุงู„ูƒุงู…ู„
### 7.1 Schemas
```javascript
// src/modules/auth/auth.schema.js
import { z } from 'zod'
export const registerSchema = z.object({
firstName: z.string().min(2).max(50).regex(/^[\p{L}\s]+$/u, 'ุงุณู… ุบูŠุฑ ุตุญูŠุญ'),
lastName: z.string().min(2).max(50).regex(/^[\p{L}\s]+$/u, 'ุงุณู… ุบูŠุฑ ุตุญูŠุญ'),
phone: z.string().regex(/^\+?[0-9]{10,15}$/, 'ุฑู‚ู… ู‡ุงุชู ุบูŠุฑ ุตุญูŠุญ'),
email: z.string().email('ุจุฑูŠุฏ ุฅู„ูƒุชุฑูˆู†ูŠ ุบูŠุฑ ุตุญูŠุญ').toLowerCase(),
password: z.string()
.min(8, 'ูƒู„ู…ุฉ ุงู„ู…ุฑูˆุฑ 8 ุฃุญุฑู ุนู„ู‰ ุงู„ุฃู‚ู„')
.regex(/^(?=.*[A-Z])/, 'ูŠุฌุจ ุฃู† ุชุญุชูˆูŠ ุนู„ู‰ ุญุฑู ูƒุจูŠุฑ')
.regex(/^(?=.*[0-9])/, 'ูŠุฌุจ ุฃู† ุชุญุชูˆูŠ ุนู„ู‰ ุฑู‚ู…')
.regex(/^(?=.*[^A-Za-z0-9])/, 'ูŠุฌุจ ุฃู† ุชุญุชูˆูŠ ุนู„ู‰ ุฑู…ุฒ ุฎุงุต'),
confirmPassword: z.string()
}).refine(d => d.password === d.confirmPassword, {
message: 'ูƒู„ู…ุชุง ุงู„ู…ุฑูˆุฑ ุบูŠุฑ ู…ุชุทุงุจู‚ุชุงู†',
path: ['confirmPassword']
})
export const loginSchema = z.object({
email: z.string().email().toLowerCase(),
password: z.string().min(1)
})
export const changePasswordSchema = z.object({
currentPassword: z.string().min(1),
newPassword: z.string().min(8)
.regex(/^(?=.*[A-Z])(?=.*[0-9])(?=.*[^A-Za-z0-9]).+$/)
})
```
### 7.2 Service
```javascript
// src/modules/auth/auth.service.js
import bcrypt from 'bcryptjs'
import { v4 as uuidv4 } from 'uuid'
import { prisma } from '../../config/database.js'
import { redis } from '../../config/redis.js'
import { signAccessToken, signRefreshToken, verifyRefreshToken, blacklistToken } from '../../lib/jwt.js'
import { AppError } from '../../lib/AppError.js'
const BCRYPT_ROUNDS = 12
const REFRESH_TOKEN_EXPIRES_DAYS = 7
export async function registerUser(data, avatarUrl = null) {
// Check duplicates
const existing = await prisma.user.findFirst({
where: { OR: [{ email: data.email }, { phone: data.phone }] }
})
if (existing) {
const field = existing.email === data.email ? 'ุงู„ุจุฑูŠุฏ ุงู„ุฅู„ูƒุชุฑูˆู†ูŠ' : 'ุฑู‚ู… ุงู„ู‡ุงุชู'
throw new AppError(`${field} ู…ุณุชุฎุฏู… ุจุงู„ูุนู„`, 409, 'DUPLICATE_ENTRY')
}
const passwordHash = await bcrypt.hash(data.password, BCRYPT_ROUNDS)
const user = await prisma.user.create({
data: {
firstName: data.firstName,
lastName: data.lastName,
phone: data.phone,
email: data.email,
passwordHash,
avatarUrl,
role: 'CLIENT'
},
select: { id: true, firstName: true, lastName: true, email: true, role: true, avatarUrl: true }
})
const { accessToken, refreshToken } = await createTokenPair(user.id, user.role)
return { user, accessToken, refreshToken }
}
export async function loginUser(email, password) {
const user = await prisma.user.findUnique({
where: { email },
select: { id: true, firstName: true, lastName: true, email: true, role: true, avatarUrl: true, passwordHash: true, isActive: true }
})
// SECURITY: ู†ูุณ ุงู„ู€ error message ู„ู„ู€ user ุบูŠุฑ ุงู„ู…ูˆุฌูˆุฏ ูˆุงู„ู€ password ุงู„ุบู„ุท
// ู…ู†ุน User Enumeration attacks
const isValid = user ? await bcrypt.compare(password, user.passwordHash) : await bcrypt.hash(password, 1)
if (!user || !isValid) throw new AppError('ุจูŠุงู†ุงุช ุชุณุฌูŠู„ ุงู„ุฏุฎูˆู„ ุบูŠุฑ ุตุญูŠุญุฉ', 401, 'INVALID_CREDENTIALS')
if (!user.isActive) throw new AppError('ุงู„ุญุณุงุจ ู…ูˆู‚ูˆูุŒ ุชูˆุงุตู„ ู…ุน ุงู„ุฏุนู…', 403, 'ACCOUNT_SUSPENDED')
const { accessToken, refreshToken } = await createTokenPair(user.id, user.role)
const { passwordHash, ...safeUser } = user
return { user: safeUser, accessToken, refreshToken }
}
export async function refreshUserToken(oldRefreshToken) {
if (!oldRefreshToken) throw new AppError('ู„ู… ูŠุชู… ุชูˆููŠุฑ refresh token', 401, 'NO_REFRESH_TOKEN')
// Verify the JWT
let decoded
try { decoded = verifyRefreshToken(oldRefreshToken) }
catch { throw new AppError('Refresh token ู…ู†ุชู‡ูŠ ุฃูˆ ุบูŠุฑ ุตุญูŠุญ', 401, 'INVALID_REFRESH_TOKEN') }
// Check exists in DB (rotation: ูƒู„ refresh โ†’ delete old, create new)
const hashedOld = await hashToken(oldRefreshToken)
const stored = await prisma.refreshToken.findUnique({ where: { token: hashedOld } })
if (!stored || stored.expiresAt < new Date()) {
// Possible token reuse attack โ€” revoke all user tokens
await prisma.refreshToken.deleteMany({ where: { userId: decoded.sub } })
throw new AppError('Refresh token ุบูŠุฑ ุตุญูŠุญ', 401, 'REFRESH_TOKEN_REUSE')
}
// Delete old, create new (rotation)
await prisma.refreshToken.delete({ where: { token: hashedOld } })
const user = await prisma.user.findUnique({
where: { id: decoded.sub },
select: { id: true, firstName: true, lastName: true, email: true, role: true, avatarUrl: true, isActive: true }
})
if (!user || !user.isActive) throw new AppError('ุงู„ู…ุณุชุฎุฏู… ุบูŠุฑ ู…ูˆุฌูˆุฏ', 401, 'USER_NOT_FOUND')
const { accessToken, refreshToken } = await createTokenPair(user.id, user.role)
return { user, accessToken, refreshToken }
}
export async function logoutUser(userId, accessToken) {
// Blacklist current access token
if (accessToken) await blacklistToken(accessToken)
// Delete all refresh tokens for this user
await prisma.refreshToken.deleteMany({ where: { userId } })
}
// โ”€โ”€โ”€ Helpers โ”€โ”€โ”€
async function createTokenPair(userId, role) {
const accessToken = signAccessToken(userId, role)
const refreshToken = signRefreshToken(userId)
const hashedRefresh = await hashToken(refreshToken)
const expiresAt = new Date(Date.now() + REFRESH_TOKEN_EXPIRES_DAYS * 24 * 60 * 60 * 1000)
await prisma.refreshToken.create({
data: { token: hashedRefresh, userId, expiresAt }
})
return { accessToken, refreshToken }
}
async function hashToken(token) {
// Hash the refresh token before storing (bcrypt ู…ุด ุถุฑูˆุฑูŠ ู‡ู†ุงุŒ sha256 ูƒุงููŠ)
const { createHash } = await import('crypto')
return createHash('sha256').update(token).digest('hex')
}
```
### 7.3 Controller
```javascript
// src/modules/auth/auth.controller.js
import * as authService from './auth.service.js'
import { uploadAvatar } from '../../middleware/upload.js'
const REFRESH_COOKIE_OPTIONS = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
path: '/api/auth/refresh' // Scope ู…ุญุฏูˆุฏ ู„ู„ูƒูˆูƒูŠ
}
export async function register(req, res, next) {
try {
const avatarUrl = req.file?.path || null
const { user, accessToken, refreshToken } = await authService.registerUser(req.validatedData, avatarUrl)
res.cookie('refresh_token', refreshToken, REFRESH_COOKIE_OPTIONS)
res.status(201).json({ user, accessToken })
} catch (err) { next(err) }
}
export async function login(req, res, next) {
try {
const { email, password } = req.validatedData
const { user, accessToken, refreshToken } = await authService.loginUser(email, password)
res.cookie('refresh_token', refreshToken, REFRESH_COOKIE_OPTIONS)
res.json({ user, accessToken })
} catch (err) { next(err) }
}
export async function refresh(req, res, next) {
try {
const oldToken = req.cookies.refresh_token
const { user, accessToken, refreshToken } = await authService.refreshUserToken(oldToken)
res.cookie('refresh_token', refreshToken, REFRESH_COOKIE_OPTIONS)
res.json({ user, accessToken })
} catch (err) { next(err) }
}
export async function logout(req, res, next) {
try {
const accessToken = req.headers.authorization?.split(' ')[1]
await authService.logoutUser(req.user.id, accessToken)
res.clearCookie('refresh_token', { path: '/api/auth/refresh' })
res.json({ message: 'ุชู… ุชุณุฌูŠู„ ุงู„ุฎุฑูˆุฌ ุจู†ุฌุงุญ' })
} catch (err) { next(err) }
}
export async function getMe(req, res) {
res.json(req.user)
}
```
### 7.4 Routes
```javascript
// src/modules/auth/auth.routes.js
import { Router } from 'express'
import { uploadAvatar } from '../../middleware/upload.js'
import { validate } from '../../middleware/validate.js'
import { authenticate } from '../../middleware/auth.js'
import { loginLimiter, registerLimiter } from '../../middleware/rateLimiter.js'
import { registerSchema, loginSchema } from './auth.schema.js'
import * as ctrl from './auth.controller.js'
const router = Router()
router.post('/register', registerLimiter, uploadAvatar, validate(registerSchema), ctrl.register)
router.post('/login', loginLimiter, validate(loginSchema), ctrl.login)
router.post('/refresh', ctrl.refresh)
router.post('/logout', authenticate, ctrl.logout)
router.get('/me', authenticate, ctrl.getMe)
export default router
```
---
## 8. Templates Module
### 8.1 Service (ู…ุน Redis Caching)
```javascript
// src/modules/templates/templates.service.js
import { prisma } from '../../config/database.js'
import { redis } from '../../config/redis.js'
import { AppError } from '../../lib/AppError.js'
const CACHE_TTL = 300 // 5 ุฏู‚ุงุฆู‚
const CACHE_PREFIX = 'templates:'
export async function getPublishedTemplates({ category, search, page = 1, limit = 12 }) {
const cacheKey = `${CACHE_PREFIX}list:${JSON.stringify({ category, search, page, limit })}`
// Try cache first
const cached = await redis.get(cacheKey)
if (cached) return JSON.parse(cached)
const where = {
isPublished: true,
...(category && { category }),
...(search && {
OR: [
{ title: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } },
]
})
}
const [templates, total] = await Promise.all([
prisma.template.findMany({
where,
select: {
id: true, title: true, description: true, category: true,
price: true, previewUrl: true, tags: true, defaultColors: true
},
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
take: limit,
skip: (page - 1) * limit,
}),
prisma.template.count({ where })
])
const result = { templates, total, page, totalPages: Math.ceil(total / limit) }
await redis.setex(cacheKey, CACHE_TTL, JSON.stringify(result))
return result
}
export async function getTemplateById(id) {
const cacheKey = `${CACHE_PREFIX}${id}`
const cached = await redis.get(cacheKey)
if (cached) return JSON.parse(cached)
const template = await prisma.template.findUnique({
where: { id, isPublished: true },
select: {
id: true, title: true, description: true, category: true,
price: true, previewUrl: true, demoUrl: true, tags: true,
defaultColors: true, components: true
}
})
if (!template) throw new AppError('ุงู„ู‚ุงู„ุจ ุบูŠุฑ ู…ูˆุฌูˆุฏ', 404, 'TEMPLATE_NOT_FOUND')
await redis.setex(cacheKey, CACHE_TTL, JSON.stringify(template))
return template
}
// Invalidate cache ุนู†ุฏ ุฃูŠ ุชุนุฏูŠู„
export async function invalidateTemplateCache(id = null) {
if (id) await redis.del(`${CACHE_PREFIX}${id}`)
// Delete all list caches (pattern delete)
const keys = await redis.keys(`${CACHE_PREFIX}list:*`)
if (keys.length) await redis.del(...keys)
}
```
---
## 9. Orders Module
```javascript
// src/modules/orders/orders.service.js
import { prisma } from '../../config/database.js'
import { AppError } from '../../lib/AppError.js'
import { createConversationWithWelcome } from '../chat/chat.service.js'
import { v4 as uuidv4 } from 'uuid'
export async function createOrder(userId, { templateId, customization, notes }) {
// Verify template exists
const template = await prisma.template.findUnique({
where: { id: templateId, isPublished: true },
select: { id: true, price: true, title: true }
})
if (!template) throw new AppError('ุงู„ู‚ุงู„ุจ ุบูŠุฑ ู…ูˆุฌูˆุฏ', 404, 'TEMPLATE_NOT_FOUND')
const order = await prisma.order.create({
data: {
orderNumber: `ORD-${Date.now()}-${uuidv4().slice(0, 6).toUpperCase()}`,
userId,
templateId,
customization,
totalAmount: template.price,
notes,
status: 'PENDING'
},
include: { template: { select: { title: true, previewUrl: true } } }
})
return order
}
export async function getUserOrders(userId, { page = 1, limit = 10, status }) {
const where = { userId, ...(status && { status }) }
const [orders, total] = await Promise.all([
prisma.order.findMany({
where,
include: {
template: { select: { title: true, previewUrl: true, category: true } },
conversation: { select: { id: true, isOpen: true } }
},
orderBy: { createdAt: 'desc' },
take: limit,
skip: (page - 1) * limit,
}),
prisma.order.count({ where })
])
return { orders, total, totalPages: Math.ceil(total / limit) }
}
// Admin: ุชุบูŠูŠุฑ ุญุงู„ุฉ ุงู„ุทู„ุจ
export async function updateOrderStatus(orderId, newStatus, adminId) {
const order = await prisma.order.findUnique({
where: { id: orderId },
include: { conversation: true }
})
if (!order) throw new AppError('ุงู„ุทู„ุจ ุบูŠุฑ ู…ูˆุฌูˆุฏ', 404, 'ORDER_NOT_FOUND')
const updated = await prisma.order.update({
where: { id: orderId },
data: { status: newStatus }
})
// ู„ู…ุง ุงู„ุทู„ุจ ูŠุชู‚ุจู„ โ†’ ุฑุณุงู„ุฉ ุชู„ู‚ุงุฆูŠุฉ ู„ู„ุนู…ูŠู„
if (newStatus === 'ACCEPTED') {
await createConversationWithWelcome(orderId, order.userId, adminId)
}
return updated
}
```
---
## 10. Chat Module + Socket.IO
### 10.1 Chat Service
```javascript
// src/modules/chat/chat.service.js
import { prisma } from '../../config/database.js'
import { AppError } from '../../lib/AppError.js'
export async function createConversationWithWelcome(orderId, clientId, adminId) {
// ู„ูˆ ู…ูˆุฌูˆุฏุฉ ุจุงู„ูุนู„ โ†’ return existing
const existing = await prisma.conversation.findUnique({ where: { orderId } })
if (existing) return existing
const conversation = await prisma.conversation.create({
data: {
orderId,
isOpen: true,
messages: {
create: {
senderId: adminId,
content: 'ู…ุฑุญุจุงู‹! ุชู… ู‚ุจูˆู„ ุทู„ุจูƒ ูˆุณูŠุชูˆุงุตู„ ู…ุนูƒ ูุฑูŠู‚ู†ุง ู‚ุฑูŠุจุงู‹ ู„ู…ู†ุงู‚ุดุฉ ุงู„ุชูุงุตูŠู„. ๐ŸŽ‰',
}
}
},
include: { messages: true }
})
return conversation
}
export async function getConversationMessages(conversationId, userId, userRole, { cursor, limit = 20 }) {
// Verify access
const conv = await prisma.conversation.findUnique({
where: { id: conversationId },
include: { order: { select: { userId: true } } }
})
if (!conv) throw new AppError('ุงู„ู…ุญุงุฏุซุฉ ุบูŠุฑ ู…ูˆุฌูˆุฏุฉ', 404, 'CONVERSATION_NOT_FOUND')
const isAdmin = ['ADMIN', 'SUPPORT'].includes(userRole)
if (!isAdmin && conv.order.userId !== userId) {
throw new AppError('ู„ูŠุณ ู„ุฏูŠูƒ ุตู„ุงุญูŠุฉ ุงู„ูˆุตูˆู„ ู„ู‡ุฐู‡ ุงู„ู…ุญุงุฏุซุฉ', 403, 'FORBIDDEN')
}
const messages = await prisma.message.findMany({
where: { conversationId },
include: { sender: { select: { firstName: true, lastName: true, avatarUrl: true, role: true } } },
orderBy: { createdAt: 'asc' },
take: limit,
...(cursor && { cursor: { id: cursor }, skip: 1 })
})
return messages
}
export async function saveMessage(conversationId, senderId, content) {
// Verify conversation is open
const conv = await prisma.conversation.findUnique({ where: { id: conversationId } })
if (!conv?.isOpen) throw new AppError('ุงู„ู…ุญุงุฏุซุฉ ู…ุบู„ู‚ุฉ', 403, 'CONVERSATION_CLOSED')
const message = await prisma.message.create({
data: { conversationId, senderId, content },
include: { sender: { select: { firstName: true, lastName: true, avatarUrl: true, role: true } } }
})
return message
}
```
### 10.2 Socket.IO Gateway
```javascript
// src/modules/chat/chat.gateway.js
import { verifySocketToken } from '../../lib/jwt.js'
import { prisma } from '../../config/database.js'
import { saveMessage } from './chat.service.js'
export function setupChatGateway(io) {
io.use(async (socket, next) => {
const token = socket.handshake.auth.token
const decoded = await verifySocketToken(token)
if (!decoded) return next(new Error('Unauthorized'))
// Attach user to socket
const user = await prisma.user.findUnique({
where: { id: decoded.sub },
select: { id: true, firstName: true, lastName: true, role: true, avatarUrl: true }
})
if (!user) return next(new Error('User not found'))
socket.user = user
next()
})
io.on('connection', (socket) => {
console.log(`๐Ÿ”Œ User connected: ${socket.user.id}`)
// Join personal room
socket.join(`user:${socket.user.id}`)
socket.on('join_conversation', async (conversationId) => {
// Verify access before joining room
try {
const conv = await prisma.conversation.findUnique({
where: { id: conversationId },
include: { order: { select: { userId: true } } }
})
if (!conv) return
const isAdmin = ['ADMIN', 'SUPPORT'].includes(socket.user.role)
const isOwner = conv.order.userId === socket.user.id
if (isAdmin || isOwner) {
socket.join(`conv:${conversationId}`)
socket.emit('joined_conversation', { conversationId })
}
} catch (err) {
socket.emit('error', { message: 'ุฎุทุฃ ููŠ ุงู„ุงู†ุถู…ุงู… ู„ู„ู…ุญุงุฏุซุฉ' })
}
})
socket.on('send_message', async ({ conversationId, content }) => {
try {
// Input validation
if (!content?.trim() || content.length > 2000) {
return socket.emit('error', { message: 'ุฑุณุงู„ุฉ ุบูŠุฑ ุตุญูŠุญุฉ' })
}
const message = await saveMessage(conversationId, socket.user.id, content.trim())
// Emit to all in the conversation room
io.to(`conv:${conversationId}`).emit('new_message', message)
// Notify the other party (push notification style)
const conv = await prisma.conversation.findUnique({
where: { id: conversationId },
include: { order: { select: { userId: true } } }
})
const targetUserId = socket.user.role === 'CLIENT' ? null : conv?.order.userId
if (targetUserId) {
io.to(`user:${targetUserId}`).emit('message_notification', {
conversationId,
senderName: `${socket.user.firstName} ${socket.user.lastName}`,
preview: content.slice(0, 50)
})
}
} catch (err) {
socket.emit('error', { message: err.message })
}
})
socket.on('mark_read', async ({ conversationId }) => {
await prisma.message.updateMany({
where: { conversationId, isRead: false, senderId: { not: socket.user.id } },
data: { isRead: true }
})
io.to(`conv:${conversationId}`).emit('messages_read', { conversationId, readBy: socket.user.id })
})
socket.on('disconnect', () => {
console.log(`๐Ÿ”Œ User disconnected: ${socket.user.id}`)
})
})
}
```
---
## 11. App.js + Server.js
```javascript
// src/app.js
import express from 'express'
import helmet from 'helmet'
import cors from 'cors'
import cookieParser from 'cookie-parser'
import compression from 'compression'
import morgan from 'morgan'
import { config } from './config/index.js'
import { generalLimiter } from './middleware/rateLimiter.js'
import { errorHandler } from './middleware/errorHandler.js'
// Routes
import authRoutes from './modules/auth/auth.routes.js'
import usersRoutes from './modules/users/users.routes.js'
import templatesRoutes from './modules/templates/templates.routes.js'
import ordersRoutes from './modules/orders/orders.routes.js'
import chatRoutes from './modules/chat/chat.routes.js'
import adminRoutes from './modules/admin/admin.routes.js'
import landingRoutes from './modules/landing/landing.routes.js'
const app = express()
// โ”€โ”€โ”€ Security Headers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
app.use(helmet({
crossOriginEmbedderPolicy: false,
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
imgSrc: ["'self'", 'data:', 'res.cloudinary.com'],
connectSrc: ["'self'", config.CLIENT_URL],
}
}
}))
// โ”€โ”€โ”€ CORS โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
app.use(cors({
origin: (origin, callback) => {
const allowed = [config.CLIENT_URL, 'http://localhost:5173']
if (!origin || allowed.includes(origin)) callback(null, true)
else callback(new Error('Not allowed by CORS'))
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
}))
// โ”€โ”€โ”€ Body Parsing โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
app.use(express.json({ limit: '10kb' })) // ู…ู†ุน large JSON payloads
app.use(express.urlencoded({ extended: false }))
app.use(cookieParser())
app.use(compression())
// โ”€โ”€โ”€ Logging โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
if (config.NODE_ENV !== 'test') {
app.use(morgan(config.NODE_ENV === 'development' ? 'dev' : 'combined'))
}
// โ”€โ”€โ”€ Rate Limiting โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
app.use('/api', generalLimiter)
// โ”€โ”€โ”€ Health Check โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
app.get('/health', (req, res) => res.json({ status: 'ok', timestamp: new Date().toISOString() }))
// โ”€โ”€โ”€ Routes โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
app.use('/api/auth', authRoutes)
app.use('/api/users', usersRoutes)
app.use('/api/templates', templatesRoutes)
app.use('/api/orders', ordersRoutes)
app.use('/api/chat', chatRoutes)
app.use('/api/admin', adminRoutes)
app.use('/api/landing', landingRoutes)
// โ”€โ”€โ”€ 404 Handler โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
app.use('*', (req, res) => {
res.status(404).json({ error: 'ุงู„ู…ุณุงุฑ ุบูŠุฑ ู…ูˆุฌูˆุฏ', code: 'ROUTE_NOT_FOUND' })
})
// โ”€โ”€โ”€ Global Error Handler (MUST be last) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
app.use(errorHandler)
export default app
```
```javascript
// src/server.js
import { createServer } from 'http'
import { Server as SocketIOServer } from 'socket.io'
import app from './app.js'
import { connectDB } from './config/database.js'
import { redis } from './config/redis.js'
import { setupChatGateway } from './modules/chat/chat.gateway.js'
import { config } from './config/index.js'
async function bootstrap() {
// Connect DB
await connectDB()
// Create HTTP server
const httpServer = createServer(app)
// Setup Socket.IO
const io = new SocketIOServer(httpServer, {
cors: { origin: config.CLIENT_URL, credentials: true },
transports: ['websocket', 'polling'],
})
setupChatGateway(io)
// Start server
httpServer.listen(config.PORT, () => {
console.log(`๐Ÿš€ Server running on port ${config.PORT}`)
console.log(`๐ŸŒ Environment: ${config.NODE_ENV}`)
})
// Graceful shutdown
const shutdown = async (signal) => {
console.log(`\n${signal} received. Shutting down gracefully...`)
httpServer.close(async () => {
await prisma.$disconnect()
await redis.quit()
console.log('โœ… Server closed.')
process.exit(0)
})
setTimeout(() => process.exit(1), 10000) // Force exit after 10s
}
process.on('SIGTERM', () => shutdown('SIGTERM'))
process.on('SIGINT', () => shutdown('SIGINT'))
// Unhandled rejections
process.on('unhandledRejection', (reason) => {
console.error('๐Ÿ’ฅ Unhandled Rejection:', reason)
shutdown('UNHANDLED_REJECTION')
})
}
bootstrap()
```
---
## 12. Admin Routes ุงู„ูƒุงู…ู„ุฉ
```javascript
// src/modules/admin/admin.routes.js
import { Router } from 'express'
import { authenticate, requireSupport, requireAdmin } from '../../middleware/auth.js'
import * as ctrl from './admin.controller.js'
const router = Router()
// ูƒู„ ุงู„ู€ admin routes ู…ุญู…ูŠุฉ ุจู€ authenticate + requireSupport/requireAdmin
router.use(authenticate)
// Overview
router.get('/stats', requireAdmin, ctrl.getStats)
// Orders
router.get('/orders', requireSupport, ctrl.getAllOrders)
router.get('/orders/:id', requireSupport, ctrl.getOrderById)
router.patch('/orders/:id/status', requireSupport, ctrl.updateOrderStatus)
router.post('/orders/:id/conversation', requireSupport, ctrl.openConversation)
// Templates (Admin only โ€” ู…ุด Support)
router.get('/templates', requireAdmin, ctrl.getAllTemplates)
router.post('/templates', requireAdmin, uploadTemplateImage, ctrl.createTemplate)
router.put('/templates/:id', requireAdmin, ctrl.updateTemplate)
router.delete('/templates/:id', requireAdmin, ctrl.deleteTemplate)
router.patch('/templates/:id/publish', requireAdmin, ctrl.togglePublish)
// Landing Content
router.get('/landing', requireAdmin, ctrl.getLandingContent)
router.put('/landing/:section', requireAdmin, ctrl.updateLandingSection)
// Users
router.get('/users', requireAdmin, ctrl.getUsers)
router.get('/users/:id', requireAdmin, ctrl.getUserById)
// Conversations
router.get('/conversations', requireSupport, ctrl.getConversations)
export default router
```
---
## 13. PostgreSQL Performance
### 13.1 Connection Pooling
```javascript
// DATABASE_URL ู…ุน PgBouncer ููŠ ุงู„ู€ production:
// postgresql://user:pass@pgbouncer:6432/designflow?pgbouncer=true&connection_limit=5
// ุฃูˆ ุจุงุณุชุฎุฏุงู… Prisma's built-in pooling:
// DATABASE_URL="postgresql://...?connection_limit=10&pool_timeout=20&connect_timeout=10"
```
### 13.2 Indexes Strategy
```sql
-- ุงู„ู€ indexes ุงู„ู…ูˆุฌูˆุฏุฉ ููŠ ุงู„ู€ schema ูƒุงููŠุฉ ู„ู„ู€ queries ุงู„ุนุงุฏูŠุฉ
-- ู„ูƒู† ููŠ ุงู„ู€ production ุฑุงุฌุน slow query log ูˆุฃุถู indexes ุญุณุจ ุงู„ุญุงุฌุฉ
-- ู…ุซุงู„: ู„ูˆ ุจุชู€query orders ุจู€ userId + status ูƒุชูŠุฑ:
-- ู‡ูˆ ู…ูˆุฌูˆุฏ ุจุงู„ูุนู„ ููŠ ุงู„ู€ schema ูƒู€ @@index([userId]) ูˆ @@index([status])
-- ู„ู€ full-text search ุนู„ู‰ ุงู„ู€ templates:
CREATE INDEX idx_templates_search ON templates USING gin(to_tsvector('english', title || ' ' || description));
-- EXPLAIN ANALYZE ู„ูƒู„ query ุชุงู‚ู„ ููŠ ุงู„ู€ production
```
### 13.3 Cron: Cleanup Expired Tokens
```javascript
// src/jobs/cleanup.js โ€” ุจูŠุดุชุบู„ ูŠูˆู…ูŠุงู‹
import { prisma } from '../config/database.js'
export async function cleanupExpiredTokens() {
const deleted = await prisma.refreshToken.deleteMany({
where: { expiresAt: { lt: new Date() } }
})
console.log(`๐Ÿงน Deleted ${deleted.count} expired refresh tokens`)
}
// ููŠ server.jsุŒ ุงุดุชุบู„ ูƒู„ 24 ุณุงุนุฉ:
// setInterval(cleanupExpiredTokens, 24 * 60 * 60 * 1000)
```
---
## 14. PM2 Production Config
```javascript
// ecosystem.config.js
module.exports = {
apps: [{
name: 'designflow-api',
script: 'src/server.js',
instances: 'max', // ุงุณุชุฎุฏู… ูƒู„ ุงู„ู€ CPU cores
exec_mode: 'cluster',
max_memory_restart: '512M',
error_file: 'logs/error.log',
out_file: 'logs/out.log',
merge_logs: true,
env_production: {
NODE_ENV: 'production',
PORT: 5000,
},
// Auto restart on crash
autorestart: true,
watch: false,
restart_delay: 5000,
}]
}
```
---
## 15. Nginx Config (Reverse Proxy)
```nginx
# /etc/nginx/sites-available/designflow
upstream api_backend {
least_conn; # Load balance ุจูŠู† PM2 instances
server 127.0.0.1:5000;
keepalive 32;
}
server {
listen 443 ssl http2;
server_name api.designflow.com;
ssl_certificate /etc/ssl/certs/designflow.crt;
ssl_certificate_key /etc/ssl/private/designflow.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# Security headers (backup ู„ู€ helmet)
add_header X-Frame-Options "DENY";
add_header X-Content-Type-Options "nosniff";
add_header Referrer-Policy "strict-origin-when-cross-origin";
# Rate limiting ุนู„ู‰ ู…ุณุชูˆู‰ Nginx (ู‚ุจู„ ู…ุง ูŠูˆุตู„ Express)
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/m;
limit_req zone=api burst=20 nodelay;
location /api {
proxy_pass http://api_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
# Timeouts
proxy_connect_timeout 60s;
proxy_read_timeout 60s;
}
# Socket.IO
location /socket.io {
proxy_pass http://api_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# Static files (Frontend build)
location / {
root /var/www/designflow/client/dist;
try_files $uri $uri/ /index.html;
expires 1y;
add_header Cache-Control "public, immutable";
}
}
# HTTP โ†’ HTTPS redirect
server {
listen 80;
server_name designflow.com api.designflow.com;
return 301 https://$host$request_uri;
}
```
---
## 16. Security Audit Checklist
### Authentication
- [ ] Passwords hashed ุจู€ bcrypt (rounds=12)
- [ ] JWT secrets ุนุดูˆุงุฆูŠุฉ ูˆุทูˆูŠู„ุฉ (64+ chars)
- [ ] Access tokens ู‚ุตูŠุฑุฉ ุงู„ุนู…ุฑ (15 ุฏู‚ูŠู‚ุฉ)
- [ ] Refresh tokens ููŠ httpOnly cookies ูู‚ุท
- [ ] Refresh token rotation ุจุนุฏ ูƒู„ ุงุณุชุฎุฏุงู…
- [ ] Token blacklisting ุนู†ุฏ ุงู„ู€ logout
- [ ] User enumeration protection (ู†ูุณ ุงู„ู€ error ู„ู€ user ุบูŠุฑ ู…ูˆุฌูˆุฏ + password ุบู„ุท)
- [ ] Account lockout ุนู†ุฏ ู…ุญุงูˆู„ุงุช ูƒุซูŠุฑุฉ
### Input Validation
- [ ] Zod validation ุนู„ู‰ ูƒู„ request body
- [ ] File type validation (MIME check + extension)
- [ ] File size limits
- [ ] JSON size limit (10kb)
- [ ] SQL Injection: Prisma parameterized queries
- [ ] XSS Prevention: helmet + content validation
### Authorization
- [ ] ูƒู„ route ู…ุญู…ูŠ ุจู€ authenticate middleware
- [ ] Role-based access control (CLIENT / SUPPORT / ADMIN)
- [ ] Resource ownership checks (ุงู„ุนู…ูŠู„ ูŠุดูˆู ุทู„ุจุงุชู‡ ุจุณ)
- [ ] Admin routes ู…ุฎููŠุฉ (404 ู„ู„ุบูŠุฑ ู…ุตุฑุญ)
### Infrastructure
- [ ] HTTPS ููŠ ุงู„ู€ production
- [ ] Rate limiting ุนู„ู‰ ูƒู„ endpoint ุญุณุงุณ
- [ ] CORS ู…ุญุฏูˆุฏ ุจู€ allowed origins ูู‚ุท
- [ ] Helmet headers
- [ ] Compression
- [ ] Graceful shutdown
- [ ] Error messages ู„ุง ุชูƒุดู stack traces ููŠ ุงู„ู€ production
- [ ] Logs ู„ุง ุชุญุชูˆูŠ ุนู„ู‰ passwords ุฃูˆ tokens
---
## 17. Checklist ู‚ุจู„ ุฅู†ู‡ุงุก ุงู„ู…ุฑุญู„ุฉ
- [ ] ูƒู„ ุงู„ู€ endpoints ุดุบุงู„ุฉ ุจู€ Postman/Thunder Client
- [ ] Auth flow ูƒุงู…ู„ (register โ†’ login โ†’ refresh โ†’ logout)
- [ ] Template CRUD + cache
- [ ] Order creation + status update + auto-conversation
- [ ] Socket.IO chat (ู…ู† admin โ†’ client ูˆุนูƒุณู‡)
- [ ] Rate limiting ุดุบุงู„ (ุงุฎุชุจุฑ ุจู€ 10+ requests ู…ุชุชุงู„ูŠูŠู†)
- [ ] JWT blacklisting ุจุนุฏ logout
- [ ] File upload ูŠุดุชุบู„ ุนู„ู‰ Cloudinary
- [ ] Error handler ุจูŠู…ุณูƒ ูƒู„ ุงู„ุฃุฎุทุงุก
- [ ] Prisma migrations applied
- [ ] PM2 cluster mode ุดุบุงู„
- [ ] /health endpoint ุจูŠุฑุฌุน 200
---
*ุงู„ู…ุฑุญู„ุฉ ุงู„ุซุงู†ูŠุฉ ู…ู†ุชู‡ูŠุฉ โ€” ุงู†ุชู‚ู„ ู„ู€ PHASE_3_TESTING.md*