Spaces:
Runtime error
Runtime error
|
Download PHASE_2_BACKEND.md from moustafa7/flwo: direct link, hf CLI and curl.
- Browser
- Download file 54.5 kB
-
https://huggingface.co/spaces/moustafa7/flwo/resolve/main/PHASE_2_BACKEND.md
- Command line
-
hf download hf://spaces/moustafa7/flwo/PHASE_2_BACKEND.md
-
curl -L -o PHASE_2_BACKEND.md https://huggingface.co/spaces/moustafa7/flwo/resolve/main/PHASE_2_BACKEND.md
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* | |