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

10
backend/.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
node_modules
npm-debug.log
dist
.env
.env.local
.git
*.md
.DS_Store
coverage
.nyc_output

67
backend/Dockerfile Normal file
View File

@@ -0,0 +1,67 @@
# Backend Dockerfile
FROM node:18-alpine AS base
# Install dependencies only when needed
FROM base AS deps
# Install OpenSSL 3.x which is compatible with Prisma
RUN apk add --no-cache libc6-compat openssl openssl-dev
WORKDIR /app
# Set Prisma environment variables
ENV PRISMA_ENGINES_MIRROR=https://prisma-builds.s3-eu-west-1.amazonaws.com
ENV PRISMA_CLI_BINARY_TARGETS=linux-musl-openssl-3.0.x
# Copy package files
COPY package*.json ./
COPY prisma ./prisma/
# Install dependencies
RUN npm ci
# Build stage
FROM base AS builder
RUN apk add --no-cache libc6-compat openssl openssl-dev
WORKDIR /app
ENV PRISMA_CLI_BINARY_TARGETS=linux-musl-openssl-3.0.x
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Generate Prisma Client with correct binary target
RUN npx prisma generate
# Build TypeScript
RUN npm run build
# Production stage
FROM base AS runner
RUN apk add --no-cache libc6-compat openssl openssl-dev
WORKDIR /app
ENV NODE_ENV=production
ENV PRISMA_CLI_BINARY_TARGETS=linux-musl-openssl-3.0.x
# Create non-root user first
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 expressjs
# Install production dependencies as root
COPY package*.json ./
COPY prisma ./prisma/
RUN npm ci --only=production && \
npx prisma generate && \
npm cache clean --force
# Copy built application
COPY --from=builder /app/dist ./dist
# Change ownership of all files to the nodejs user
RUN chown -R expressjs:nodejs /app
# Switch to non-root user
USER expressjs
EXPOSE 5001
CMD ["node", "dist/server.js"]

View File

@@ -1,11 +1,11 @@
{
"name": "mind14-backend",
"name": "z-crm-backend",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mind14-backend",
"name": "z-crm-backend",
"version": "1.0.0",
"dependencies": {
"@prisma/client": "^5.8.0",
@@ -35,6 +35,7 @@
"prisma": "^5.8.0",
"ts-jest": "^29.1.1",
"ts-node": "^10.9.2",
"tsc-alias": "^1.8.16",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.3.3"
}
@@ -998,6 +999,44 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "2.0.5",
"run-parallel": "^1.1.9"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/@nodelib/fs.stat": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/@nodelib/fs.walk": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.scandir": "2.1.5",
"fastq": "^1.6.0"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/@prisma/client": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz",
@@ -1561,6 +1600,16 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/array-union": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz",
"integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/async": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
@@ -2109,6 +2158,16 @@
"node": ">=12.20"
}
},
"node_modules/commander": {
"version": "9.5.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz",
"integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^12.20.0 || >=14"
}
},
"node_modules/compressible": {
"version": "2.0.18",
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
@@ -2360,6 +2419,19 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/dir-glob": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
"integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-type": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@@ -2641,6 +2713,23 @@
"node": ">= 8.0.0"
}
},
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "^2.0.2",
"@nodelib/fs.walk": "^1.2.3",
"glob-parent": "^5.1.2",
"merge2": "^1.3.0",
"micromatch": "^4.0.8"
},
"engines": {
"node": ">=8.6.0"
}
},
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
@@ -2648,6 +2737,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/fastq": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
"integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
"dev": true,
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4"
}
},
"node_modules/fb-watchman": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz",
@@ -2844,6 +2943,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/get-tsconfig": {
"version": "4.13.6",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
"integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
"dev": true,
"license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
},
"funding": {
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
@@ -2879,6 +2991,27 @@
"node": ">= 6"
}
},
"node_modules/globby": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz",
"integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==",
"dev": true,
"license": "MIT",
"dependencies": {
"array-union": "^2.1.0",
"dir-glob": "^3.0.1",
"fast-glob": "^3.2.9",
"ignore": "^5.2.0",
"merge2": "^1.4.1",
"slash": "^3.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -3012,6 +3145,16 @@
"node": ">=0.10.0"
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/ignore-by-default": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz",
@@ -4224,6 +4367,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
@@ -4358,6 +4511,20 @@
"node": ">= 6.0.0"
}
},
"node_modules/mylas": {
"version": "2.1.14",
"resolved": "https://registry.npmjs.org/mylas/-/mylas-2.1.14.tgz",
"integrity": "sha512-BzQguy9W9NJgoVn2mRWzbFrFWWztGCcng2QI9+41frfk+Athwgx3qhqhvStz7ExeUUu7Kzw427sNzHpEZNINog==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=16.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/raouldeheer"
}
},
"node_modules/natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@@ -4711,6 +4878,16 @@
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT"
},
"node_modules/path-type": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
"integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -4754,6 +4931,19 @@
"node": ">=8"
}
},
"node_modules/plimit-lit": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/plimit-lit/-/plimit-lit-1.6.1.tgz",
"integrity": "sha512-B7+VDyb8Tl6oMJT9oSO2CW8XC/T4UcJGrwOVoNGwOQsQYhlpfajmrMj5xeejqaASq3V/EqThyOeATEOMuSEXiA==",
"dev": true,
"license": "MIT",
"dependencies": {
"queue-lit": "^1.5.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/pretty-format": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
@@ -4874,6 +5064,37 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/queue-lit": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/queue-lit/-/queue-lit-1.5.2.tgz",
"integrity": "sha512-tLc36IOPeMAubu8BkW8YDBV+WyIgKlYU7zUNs0J5Vk9skSZ4JfGlPOqplP0aHdfv7HL0B2Pg6nwiq60Qc6M2Hw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@@ -4993,6 +5214,16 @@
"node": ">=8"
}
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/resolve.exports": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz",
@@ -5003,6 +5234,41 @@
"node": ">=10"
}
},
"node_modules/reusify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
"dev": true,
"license": "MIT",
"engines": {
"iojs": ">=1.0.0",
"node": ">=0.10.0"
}
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"queue-microtask": "^1.2.2"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -5619,6 +5885,28 @@
}
}
},
"node_modules/tsc-alias": {
"version": "1.8.16",
"resolved": "https://registry.npmjs.org/tsc-alias/-/tsc-alias-1.8.16.tgz",
"integrity": "sha512-QjCyu55NFyRSBAl6+MTFwplpFcnm2Pq01rR/uxfqJoLMm6X3O14KEGtaSDZpJYaE1bJBGDjD0eSuiIWPe2T58g==",
"dev": true,
"license": "MIT",
"dependencies": {
"chokidar": "^3.5.3",
"commander": "^9.0.0",
"get-tsconfig": "^4.10.0",
"globby": "^11.0.4",
"mylas": "^2.1.9",
"normalize-path": "^3.0.0",
"plimit-lit": "^1.2.6"
},
"bin": {
"tsc-alias": "dist/bin/index.js"
},
"engines": {
"node": ">=16.20.2"
}
},
"node_modules/tsconfig-paths": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz",

View File

@@ -5,7 +5,7 @@
"main": "dist/server.js",
"scripts": {
"dev": "nodemon src/server.ts",
"build": "tsc",
"build": "tsc && tsc-alias",
"start": "node dist/server.js",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
@@ -44,6 +44,7 @@
"prisma": "^5.8.0",
"ts-jest": "^29.1.1",
"ts-node": "^10.9.2",
"tsc-alias": "^1.8.16",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.3.3"
}

View File

@@ -3,6 +3,7 @@
generator client {
provider = "prisma-client-js"
binaryTargets = ["native", "linux-musl-openssl-3.0.x"]
}
datasource db {

303
backend/prisma/seed-prod.js Normal file
View File

@@ -0,0 +1,303 @@
const { PrismaClient } = require('@prisma/client');
const bcrypt = require('bcryptjs');
const prisma = new PrismaClient();
async function main() {
console.log('🌱 Starting database seeding...');
// Create Departments
const salesDept = await prisma.department.create({
data: {
name: 'Sales Department',
nameAr: 'قسم المبيعات',
code: 'SALES',
description: 'Sales and Business Development',
},
});
const itDept = await prisma.department.create({
data: {
name: 'IT Department',
nameAr: 'قسم تقنية المعلومات',
code: 'IT',
description: 'Information Technology',
},
});
const hrDept = await prisma.department.create({
data: {
name: 'HR Department',
nameAr: 'قسم الموارد البشرية',
code: 'HR',
description: 'Human Resources',
},
});
console.log('✅ Created departments');
// Create Positions
const gmPosition = await prisma.position.create({
data: {
title: 'General Manager',
titleAr: 'المدير العام',
code: 'GM',
departmentId: salesDept.id,
level: 1,
description: 'Chief Executive - Full Access',
},
});
const salesManagerPosition = await prisma.position.create({
data: {
title: 'Sales Manager',
titleAr: 'مدير المبيعات',
code: 'SM',
departmentId: salesDept.id,
level: 2,
description: 'Sales Department Manager',
},
});
const salesRepPosition = await prisma.position.create({
data: {
title: 'Sales Representative',
titleAr: 'مندوب مبيعات',
code: 'SR',
departmentId: salesDept.id,
level: 3,
description: 'Sales Representative',
},
});
console.log('✅ Created positions');
// Create Permissions for GM (Full Access)
const modules = ['contacts', 'crm', 'inventory', 'projects', 'hr', 'marketing'];
for (const module of modules) {
await prisma.positionPermission.create({
data: {
positionId: gmPosition.id,
module: module,
resource: 'all',
actions: ['create', 'read', 'update', 'delete', 'export', 'approve'],
},
});
}
// Create Permissions for Sales Manager
await prisma.positionPermission.create({
data: {
positionId: salesManagerPosition.id,
module: 'contacts',
resource: 'contacts',
actions: ['create', 'read', 'update', 'delete', 'export'],
},
});
await prisma.positionPermission.create({
data: {
positionId: salesManagerPosition.id,
module: 'crm',
resource: 'deals',
actions: ['create', 'read', 'update', 'approve', 'export'],
},
});
// Create Permissions for Sales Rep
await prisma.positionPermission.create({
data: {
positionId: salesRepPosition.id,
module: 'contacts',
resource: 'contacts',
actions: ['create', 'read', 'update'],
},
});
await prisma.positionPermission.create({
data: {
positionId: salesRepPosition.id,
module: 'crm',
resource: 'deals',
actions: ['create', 'read', 'update'],
},
});
console.log('✅ Created permissions');
// Create Employees
const hashedPassword = await bcrypt.hash('Admin@123', 10);
const gmEmployee = await prisma.employee.create({
data: {
uniqueEmployeeId: 'EMP-001',
firstName: 'Ahmed',
lastName: 'Al-Mansour',
firstNameAr: 'أحمد',
lastNameAr: 'المنصور',
email: 'gm@atmata.com',
mobile: '+966501234567',
employmentType: 'Full-time',
hireDate: new Date('2020-01-01'),
departmentId: salesDept.id,
positionId: gmPosition.id,
basicSalary: 50000,
status: 'ACTIVE',
},
});
const salesManager = await prisma.employee.create({
data: {
uniqueEmployeeId: 'EMP-002',
firstName: 'Fahd',
lastName: 'Al-Sayed',
firstNameAr: 'فهد',
lastNameAr: 'السيد',
email: 'sales.manager@atmata.com',
mobile: '+966507654321',
employmentType: 'Full-time',
hireDate: new Date('2021-01-01'),
departmentId: salesDept.id,
positionId: salesManagerPosition.id,
reportingToId: gmEmployee.id,
basicSalary: 30000,
status: 'ACTIVE',
},
});
const salesRep = await prisma.employee.create({
data: {
uniqueEmployeeId: 'EMP-003',
firstName: 'Omar',
lastName: 'Al-Hassan',
firstNameAr: 'عمر',
lastNameAr: 'الحسن',
email: 'sales.rep@atmata.com',
mobile: '+966509876543',
employmentType: 'Full-time',
hireDate: new Date('2022-01-01'),
departmentId: salesDept.id,
positionId: salesRepPosition.id,
reportingToId: salesManager.id,
basicSalary: 15000,
status: 'ACTIVE',
},
});
console.log('✅ Created employees');
// Create Users
await prisma.user.create({
data: {
email: 'gm@atmata.com',
username: 'general.manager',
password: hashedPassword,
isActive: true,
employeeId: gmEmployee.id,
},
});
await prisma.user.create({
data: {
email: 'sales.manager@atmata.com',
username: 'sales.manager',
password: hashedPassword,
isActive: true,
employeeId: salesManager.id,
},
});
await prisma.user.create({
data: {
email: 'sales.rep@atmata.com',
username: 'sales.rep',
password: hashedPassword,
isActive: true,
employeeId: salesRep.id,
},
});
console.log('✅ Created users');
// Create Contact Categories
await prisma.contactCategory.create({
data: {
name: 'Client',
nameAr: 'عميل',
},
});
await prisma.contactCategory.create({
data: {
name: 'Supplier',
nameAr: 'مورّد',
},
});
await prisma.contactCategory.create({
data: {
name: 'Partner',
nameAr: 'شريك',
},
});
console.log('✅ Created contact categories');
// Create Pipelines
await prisma.pipeline.create({
data: {
name: 'B2B Sales Pipeline',
nameAr: 'مسار مبيعات الشركات',
structure: 'B2B',
stages: [
{ name: 'OPEN', order: 1 },
{ name: 'NEGOTIATION', order: 2 },
{ name: 'PENDING_INTERNAL', order: 3 },
{ name: 'PENDING_CLIENT', order: 4 },
{ name: 'WON', order: 5 },
{ name: 'LOST', order: 6 },
],
},
});
await prisma.pipeline.create({
data: {
name: 'B2C Sales Pipeline',
nameAr: 'مسار المبيعات الفردية',
structure: 'B2C',
stages: [
{ name: 'OPEN', order: 1 },
{ name: 'NEGOTIATION', order: 2 },
{ name: 'WON', order: 3 },
{ name: 'LOST', order: 4 },
],
},
});
console.log('✅ Created pipelines');
console.log('\n🎉 Database seeding completed successfully!');
console.log('\n📝 Login Credentials:');
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log('👤 General Manager:');
console.log(' Email: gm@atmata.com');
console.log(' Password: Admin@123');
console.log('');
console.log('👤 Sales Manager:');
console.log(' Email: sales.manager@atmata.com');
console.log(' Password: Admin@123');
console.log('');
console.log('👤 Sales Representative:');
console.log(' Email: sales.rep@atmata.com');
console.log(' Password: Admin@123');
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
}
main()
.catch((e) => {
console.error('❌ Error seeding database:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

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');
}