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:
@@ -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
3
backend/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// Module alias registration for production
|
||||
require('module-alias/register')
|
||||
require('./server')
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
110
backend/src/modules/inventory/products.controller.ts
Normal file
110
backend/src/modules/inventory/products.controller.ts
Normal 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();
|
||||
323
backend/src/modules/inventory/products.service.ts
Normal file
323
backend/src/modules/inventory/products.service.ts
Normal 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();
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user