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. ุฅุนุฏุงุฏ ุงู„ู…ุดุฑูˆุน

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

# 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

// 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
// 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)
  }
}
// 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/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

# ุฅู†ุดุงุก migration
npx prisma migrate dev --name init

# ุชูˆู„ูŠุฏ Prisma Client
npx prisma generate

# Seed (ุฅู†ุดุงุก ุฃุฏู…ู† ุฃูˆู„ูŠ + landing content)
npx prisma db seed
// 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

// 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

// 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

// 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

// 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

// 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

// 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

// 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

// 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

// 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

// 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

// 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)

// 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

// 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

// 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

// 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

// 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
// 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 ุงู„ูƒุงู…ู„ุฉ

// 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

// 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

-- ุงู„ู€ 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

// 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

// 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)

# /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