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:
10
backend/.dockerignore
Normal file
10
backend/.dockerignore
Normal 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
67
backend/Dockerfile
Normal 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"]
|
||||
292
backend/package-lock.json
generated
292
backend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
303
backend/prisma/seed-prod.js
Normal 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();
|
||||
});
|
||||
@@ -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