Production deployment with Docker and full system fixes

- Added Docker support (Dockerfiles, docker-compose.yml)
- Fixed authentication and authorization (token storage, CORS, permissions)
- Fixed API response transformations for all modules
- Added production deployment scripts and guides
- Fixed frontend permission checks and module access
- Added database seeding script for production
- Complete documentation for deployment and configuration

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Talal Sharabi
2026-02-11 11:25:20 +04:00
parent 35daa52767
commit f31d71ff5a
52 changed files with 9359 additions and 1578 deletions

View File

@@ -18,7 +18,7 @@ export const config = {
},
cors: {
origin: 'http://localhost:3000',
origin: process.env.CORS_ORIGIN?.split(',') || ['http://localhost:3000'],
},
upload: {

3
backend/src/index.ts Normal file
View File

@@ -0,0 +1,3 @@
// Module alias registration for production
require('module-alias/register')
require('./server')

View File

@@ -1,31 +1,98 @@
import { Router } from 'express';
import { body, param } from 'express-validator';
import { authenticate, authorize } from '../../shared/middleware/auth';
import { validate } from '../../shared/middleware/validation';
import { productsController } from './products.controller';
import prisma from '../../config/database';
import { ResponseFormatter } from '../../shared/utils/responseFormatter';
const router = Router();
router.use(authenticate);
// Products
router.get('/products', authorize('inventory', 'products', 'read'), async (req, res, next) => {
try {
const products = await prisma.product.findMany({
include: { category: true },
orderBy: { createdAt: 'desc' },
});
res.json(ResponseFormatter.success(products));
} catch (error) {
next(error);
}
});
// ============= PRODUCTS =============
router.post('/products', authorize('inventory', 'products', 'create'), async (req, res, next) => {
// Get all products
router.get(
'/products',
authorize('inventory', 'products', 'read'),
productsController.findAll
);
// Get product by ID
router.get(
'/products/:id',
authorize('inventory', 'products', 'read'),
param('id').isUUID(),
validate,
productsController.findById
);
// Get product history
router.get(
'/products/:id/history',
authorize('inventory', 'products', 'read'),
param('id').isUUID(),
validate,
productsController.getHistory
);
// Create product
router.post(
'/products',
authorize('inventory', 'products', 'create'),
[
body('sku').notEmpty().trim(),
body('name').notEmpty().trim(),
body('categoryId').isUUID(),
body('costPrice').isNumeric(),
body('sellingPrice').isNumeric(),
validate,
],
productsController.create
);
// Update product
router.put(
'/products/:id',
authorize('inventory', 'products', 'update'),
param('id').isUUID(),
validate,
productsController.update
);
// Delete product
router.delete(
'/products/:id',
authorize('inventory', 'products', 'delete'),
param('id').isUUID(),
validate,
productsController.delete
);
// Adjust stock
router.post(
'/products/:id/adjust-stock',
authorize('inventory', 'products', 'update'),
[
param('id').isUUID(),
body('warehouseId').isUUID(),
body('quantity').isNumeric(),
body('type').isIn(['ADD', 'REMOVE']),
validate,
],
productsController.adjustStock
);
// ============= CATEGORIES =============
router.get('/categories', authorize('inventory', 'categories', 'read'), async (req, res, next) => {
try {
const product = await prisma.product.create({
data: req.body,
include: { category: true },
const categories = await prisma.productCategory.findMany({
where: { isActive: true },
include: { parent: true, children: true },
orderBy: { name: 'asc' },
});
res.status(201).json(ResponseFormatter.success(product));
res.json(ResponseFormatter.success(categories));
} catch (error) {
next(error);
}

View File

@@ -0,0 +1,110 @@
import { Response, NextFunction } from 'express';
import { AuthRequest } from '../../shared/middleware/auth';
import { productsService } from './products.service';
import { ResponseFormatter } from '../../shared/utils/responseFormatter';
export class ProductsController {
async create(req: AuthRequest, res: Response, next: NextFunction) {
try {
const product = await productsService.create(req.body, req.user!.id);
res.status(201).json(
ResponseFormatter.success(product, 'تم إنشاء المنتج بنجاح - Product created successfully')
);
} catch (error) {
next(error);
}
}
async findAll(req: AuthRequest, res: Response, next: NextFunction) {
try {
const page = parseInt(req.query.page as string) || 1;
const pageSize = parseInt(req.query.pageSize as string) || 20;
const filters = {
search: req.query.search,
categoryId: req.query.categoryId,
brand: req.query.brand,
};
const result = await productsService.findAll(filters, page, pageSize);
res.json(ResponseFormatter.paginated(
result.products,
result.total,
result.page,
result.pageSize
));
} catch (error) {
next(error);
}
}
async findById(req: AuthRequest, res: Response, next: NextFunction) {
try {
const product = await productsService.findById(req.params.id);
res.json(ResponseFormatter.success(product));
} catch (error) {
next(error);
}
}
async update(req: AuthRequest, res: Response, next: NextFunction) {
try {
const product = await productsService.update(
req.params.id,
req.body,
req.user!.id
);
res.json(
ResponseFormatter.success(product, 'تم تحديث المنتج بنجاح - Product updated successfully')
);
} catch (error) {
next(error);
}
}
async delete(req: AuthRequest, res: Response, next: NextFunction) {
try {
await productsService.delete(req.params.id, req.user!.id);
res.json(
ResponseFormatter.success(null, 'تم حذف المنتج بنجاح - Product deleted successfully')
);
} catch (error) {
next(error);
}
}
async adjustStock(req: AuthRequest, res: Response, next: NextFunction) {
try {
const { warehouseId, quantity, type } = req.body;
const result = await productsService.adjustStock(
req.params.id,
warehouseId,
quantity,
type,
req.user!.id
);
res.json(
ResponseFormatter.success(result, 'تم تعديل المخزون بنجاح - Stock adjusted successfully')
);
} catch (error) {
next(error);
}
}
async getHistory(req: AuthRequest, res: Response, next: NextFunction) {
try {
const history = await productsService.getHistory(req.params.id);
res.json(ResponseFormatter.success(history));
} catch (error) {
next(error);
}
}
}
export const productsController = new ProductsController();

View File

@@ -0,0 +1,323 @@
import prisma from '../../config/database';
import { AppError } from '../../shared/middleware/errorHandler';
import { AuditLogger } from '../../shared/utils/auditLogger';
import { Prisma } from '@prisma/client';
interface CreateProductData {
sku: string;
name: string;
nameAr?: string;
description?: string;
categoryId: string;
brand?: string;
model?: string;
specifications?: any;
trackBy?: string;
costPrice: number;
sellingPrice: number;
minStock?: number;
maxStock?: number;
}
interface UpdateProductData extends Partial<CreateProductData> {}
class ProductsService {
async create(data: CreateProductData, userId: string) {
// Check if SKU already exists
const existing = await prisma.product.findUnique({
where: { sku: data.sku },
});
if (existing) {
throw new AppError(400, 'SKU already exists');
}
const product = await prisma.product.create({
data: {
sku: data.sku,
name: data.name,
nameAr: data.nameAr,
description: data.description,
categoryId: data.categoryId,
brand: data.brand,
model: data.model,
specifications: data.specifications,
trackBy: data.trackBy || 'QUANTITY',
costPrice: data.costPrice,
sellingPrice: data.sellingPrice,
minStock: data.minStock || 0,
maxStock: data.maxStock,
unit: 'PCS', // Default unit
},
include: {
category: true,
},
});
await AuditLogger.log({
entityType: 'PRODUCT',
entityId: product.id,
action: 'CREATE',
userId,
});
return product;
}
async findAll(filters: any, page: number, pageSize: number) {
const skip = (page - 1) * pageSize;
const where: Prisma.ProductWhereInput = {};
if (filters.search) {
where.OR = [
{ name: { contains: filters.search, mode: 'insensitive' } },
{ nameAr: { contains: filters.search, mode: 'insensitive' } },
{ sku: { contains: filters.search, mode: 'insensitive' } },
{ description: { contains: filters.search, mode: 'insensitive' } },
];
}
if (filters.categoryId) {
where.categoryId = filters.categoryId;
}
if (filters.brand) {
where.brand = { contains: filters.brand, mode: 'insensitive' };
}
const total = await prisma.product.count({ where });
const products = await prisma.product.findMany({
where,
skip,
take: pageSize,
include: {
category: true,
inventoryItems: {
include: {
warehouse: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
});
// Calculate total stock for each product
const productsWithStock = products.map((product) => {
const totalStock = product.inventoryItems.reduce(
(sum, item) => sum + item.quantity,
0
);
return {
...product,
totalStock,
};
});
return {
products: productsWithStock,
total,
page,
pageSize,
};
}
async findById(id: string) {
const product = await prisma.product.findUnique({
where: { id },
include: {
category: true,
inventoryItems: {
include: {
warehouse: true,
},
},
movements: {
take: 10,
orderBy: {
createdAt: 'desc',
},
},
},
});
if (!product) {
throw new AppError(404, 'Product not found');
}
return product;
}
async update(id: string, data: UpdateProductData, userId: string) {
const existing = await prisma.product.findUnique({ where: { id } });
if (!existing) {
throw new AppError(404, 'Product not found');
}
// Check SKU uniqueness if it's being updated
if (data.sku && data.sku !== existing.sku) {
const skuExists = await prisma.product.findUnique({
where: { sku: data.sku },
});
if (skuExists) {
throw new AppError(400, 'SKU already exists');
}
}
const product = await prisma.product.update({
where: { id },
data: {
sku: data.sku,
name: data.name,
nameAr: data.nameAr,
description: data.description,
categoryId: data.categoryId,
brand: data.brand,
model: data.model,
specifications: data.specifications,
trackBy: data.trackBy,
costPrice: data.costPrice,
sellingPrice: data.sellingPrice,
minStock: data.minStock,
maxStock: data.maxStock,
},
include: {
category: true,
},
});
await AuditLogger.log({
entityType: 'PRODUCT',
entityId: product.id,
action: 'UPDATE',
userId,
changes: {
before: existing,
after: product,
},
});
return product;
}
async delete(id: string, userId: string) {
const product = await prisma.product.findUnique({ where: { id } });
if (!product) {
throw new AppError(404, 'Product not found');
}
// Check if product has inventory
const hasInventory = await prisma.inventoryItem.findFirst({
where: { productId: id, quantity: { gt: 0 } },
});
if (hasInventory) {
throw new AppError(
400,
'Cannot delete product that has inventory stock'
);
}
await prisma.product.delete({ where: { id } });
await AuditLogger.log({
entityType: 'PRODUCT',
entityId: product.id,
action: 'DELETE',
userId,
});
return { message: 'Product deleted successfully' };
}
async adjustStock(
productId: string,
warehouseId: string,
quantity: number,
type: 'ADD' | 'REMOVE',
userId: string
) {
const product = await prisma.product.findUnique({ where: { id: productId } });
if (!product) {
throw new AppError(404, 'Product not found');
}
// Find or create inventory item
let inventoryItem = await prisma.inventoryItem.findFirst({
where: {
productId,
warehouseId,
},
});
const adjustedQuantity = type === 'ADD' ? quantity : -quantity;
if (!inventoryItem) {
if (type === 'REMOVE') {
throw new AppError(400, 'Cannot remove from non-existent inventory');
}
const costPrice = Number(product.costPrice);
inventoryItem = await prisma.inventoryItem.create({
data: {
productId,
warehouseId,
quantity: adjustedQuantity,
availableQty: adjustedQuantity,
averageCost: costPrice,
totalValue: costPrice * adjustedQuantity,
},
});
} else {
const newQuantity = inventoryItem.quantity + adjustedQuantity;
if (newQuantity < 0) {
throw new AppError(400, 'Insufficient stock');
}
inventoryItem = await prisma.inventoryItem.update({
where: { id: inventoryItem.id },
data: {
quantity: newQuantity,
},
});
}
// Create inventory movement record
await prisma.inventoryMovement.create({
data: {
warehouseId,
productId,
type: type === 'ADD' ? 'IN' : 'OUT',
quantity: Math.abs(quantity),
unitCost: Number(product.costPrice),
notes: `Stock ${type === 'ADD' ? 'addition' : 'removal'} by user`,
},
});
await AuditLogger.log({
entityType: 'INVENTORY',
entityId: inventoryItem.id,
action: 'STOCK_ADJUSTMENT',
userId,
changes: {
type,
quantity,
productId,
warehouseId,
},
});
return inventoryItem;
}
async getHistory(id: string) {
return AuditLogger.getEntityHistory('PRODUCT', id);
}
}
export const productsService = new ProductsService();

View File

@@ -84,18 +84,18 @@ export const authorize = (module: string, resource: string, action: string) => {
throw new AppError(403, 'الوصول مرفوض - Access denied');
}
// Find permission for this module and resource
// Find permission for this module and resource (check exact match or wildcard)
const permission = req.user.employee.position.permissions.find(
(p: any) => p.module === module && p.resource === resource
(p: any) => p.module === module && (p.resource === resource || p.resource === '*' || p.resource === 'all')
);
if (!permission) {
throw new AppError(403, 'الوصول مرفوض - Access denied');
}
// Check if action is allowed
// Check if action is allowed (check exact match or wildcard)
const actions = permission.actions as string[];
if (!actions.includes(action) && !actions.includes('*')) {
if (!actions.includes(action) && !actions.includes('*') && !actions.includes('all')) {
throw new AppError(403, 'الوصول مرفوض - Access denied');
}