Deploy rule, CRM enhancements, Company Employee category, bilingual, contacts module completion
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
121
backend/package-lock.json
generated
121
backend/package-lock.json
generated
@@ -12,6 +12,7 @@
|
||||
"bcryptjs": "^2.4.3",
|
||||
"compression": "^1.7.4",
|
||||
"cors": "^2.8.5",
|
||||
"csv-parser": "^3.2.0",
|
||||
"date-fns": "^3.0.6",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
@@ -19,7 +20,8 @@
|
||||
"helmet": "^7.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"winston": "^3.11.0"
|
||||
"winston": "^3.11.0",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
@@ -28,7 +30,7 @@
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jest": "^29.5.11",
|
||||
"@types/jsonwebtoken": "^9.0.5",
|
||||
"@types/multer": "^1.4.11",
|
||||
"@types/multer": "^1.4.13",
|
||||
"@types/node": "^20.10.6",
|
||||
"jest": "^29.7.0",
|
||||
"nodemon": "^3.0.2",
|
||||
@@ -1515,6 +1517,15 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/adler-32": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
|
||||
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-escapes": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz",
|
||||
@@ -1975,6 +1986,19 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/cfb": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
|
||||
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"crc-32": "~1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
@@ -2076,6 +2100,15 @@
|
||||
"node": ">= 0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/codepage": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
||||
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/collect-v8-coverage": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz",
|
||||
@@ -2282,6 +2315,18 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/crc-32": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"crc32": "bin/crc32.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/create-jest": {
|
||||
"version": "29.7.0",
|
||||
"resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz",
|
||||
@@ -2326,6 +2371,18 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/csv-parser": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/csv-parser/-/csv-parser-3.2.0.tgz",
|
||||
"integrity": "sha512-fgKbp+AJbn1h2dcAHKIdKNSSjfp43BZZykXsCjzALjKy80VXQNHPFJ6T9Afwdzoj24aMkq8GwDS7KGcDPpejrA==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"csv-parser": "bin/csv-parser"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
|
||||
@@ -2823,6 +2880,15 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/frac": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
|
||||
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/fresh": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||
@@ -5538,6 +5604,18 @@
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/ssf": {
|
||||
"version": "0.11.2",
|
||||
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
||||
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"frac": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/stack-trace": {
|
||||
"version": "0.0.10",
|
||||
"resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz",
|
||||
@@ -6201,6 +6279,24 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/wmf": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
||||
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/word": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
|
||||
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/wordwrap": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
|
||||
@@ -6247,6 +6343,27 @@
|
||||
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xlsx": {
|
||||
"version": "0.18.5",
|
||||
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
|
||||
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"cfb": "~1.2.1",
|
||||
"codepage": "~1.15.0",
|
||||
"crc-32": "~1.2.1",
|
||||
"ssf": "~0.11.2",
|
||||
"wmf": "~1.0.1",
|
||||
"word": "~0.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"xlsx": "bin/xlsx.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:seed": "ts-node prisma/seed.ts",
|
||||
"prisma:studio": "prisma studio",
|
||||
"db:clean-and-seed": "node prisma/clean-and-seed.js",
|
||||
"test": "jest"
|
||||
},
|
||||
"prisma": {
|
||||
@@ -21,6 +22,7 @@
|
||||
"bcryptjs": "^2.4.3",
|
||||
"compression": "^1.7.4",
|
||||
"cors": "^2.8.5",
|
||||
"csv-parser": "^3.2.0",
|
||||
"date-fns": "^3.0.6",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
@@ -28,7 +30,8 @@
|
||||
"helmet": "^7.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"winston": "^3.11.0"
|
||||
"winston": "^3.11.0",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
@@ -37,7 +40,7 @@
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jest": "^29.5.11",
|
||||
"@types/jsonwebtoken": "^9.0.5",
|
||||
"@types/multer": "^1.4.11",
|
||||
"@types/multer": "^1.4.13",
|
||||
"@types/node": "^20.10.6",
|
||||
"jest": "^29.7.0",
|
||||
"nodemon": "^3.0.2",
|
||||
|
||||
97
backend/prisma/clean-and-seed.js
Normal file
97
backend/prisma/clean-and-seed.js
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Production database cleanup + re-seed.
|
||||
* Truncates all tables (data only), then runs the seed to restore base data.
|
||||
*
|
||||
* Usage (from backend directory):
|
||||
* node prisma/clean-and-seed.js
|
||||
*
|
||||
* Or: npm run db:clean-and-seed
|
||||
*
|
||||
* Ensure DATABASE_URL is set (e.g. production). Back up the DB before running.
|
||||
*/
|
||||
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const { execSync } = require('child_process');
|
||||
const path = require('path');
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// All tables from schema (Prisma @@map names) – order does not matter with CASCADE
|
||||
const TABLES = [
|
||||
'audit_logs',
|
||||
'approvals',
|
||||
'notifications',
|
||||
'custom_fields',
|
||||
'attachments',
|
||||
'notes',
|
||||
'activities',
|
||||
'campaigns',
|
||||
'project_expenses',
|
||||
'project_members',
|
||||
'tasks',
|
||||
'project_phases',
|
||||
'projects',
|
||||
'asset_maintenances',
|
||||
'assets',
|
||||
'warehouse_transfers',
|
||||
'inventory_movements',
|
||||
'inventory_items',
|
||||
'product_categories',
|
||||
'products',
|
||||
'warehouses',
|
||||
'invoices',
|
||||
'contracts',
|
||||
'cost_sheets',
|
||||
'quotes',
|
||||
'deals',
|
||||
'pipelines',
|
||||
'contact_relationships',
|
||||
'contact_categories',
|
||||
'contacts',
|
||||
'disciplinary_actions',
|
||||
'employee_trainings',
|
||||
'performance_evaluations',
|
||||
'commissions',
|
||||
'allowances',
|
||||
'salaries',
|
||||
'leaves',
|
||||
'attendances',
|
||||
'position_permissions',
|
||||
'positions',
|
||||
'departments',
|
||||
'employees',
|
||||
'users',
|
||||
];
|
||||
|
||||
async function clean() {
|
||||
console.log('🧹 Truncating all tables...');
|
||||
const quoted = TABLES.map((t) => `"${t}"`).join(', ');
|
||||
await prisma.$executeRawUnsafe(
|
||||
`TRUNCATE TABLE ${quoted} RESTART IDENTITY CASCADE;`
|
||||
);
|
||||
console.log('✅ All tables truncated.');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const env = process.env.NODE_ENV || 'development';
|
||||
if (process.env.DATABASE_URL?.includes('prod') || env === 'production') {
|
||||
console.log('⚠️ DATABASE_URL appears to be PRODUCTION. Ensure you have a backup.\n');
|
||||
}
|
||||
|
||||
await clean();
|
||||
await prisma.$disconnect();
|
||||
|
||||
console.log('\n🌱 Running seed...\n');
|
||||
const backendDir = path.resolve(__dirname, '..');
|
||||
execSync('node prisma/seed-prod.js', {
|
||||
stdio: 'inherit',
|
||||
cwd: backendDir,
|
||||
env: process.env,
|
||||
});
|
||||
console.log('\n✅ Clean and seed completed.');
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('❌ Error:', e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
-- CreateIndex
|
||||
CREATE INDEX "contacts_source_idx" ON "contacts"("source");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "contacts_createdAt_idx" ON "contacts"("createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "contacts_parentId_idx" ON "contacts"("parentId");
|
||||
@@ -0,0 +1,8 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "contacts" ADD COLUMN "employeeId" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "contacts_employeeId_key" ON "contacts"("employeeId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "contacts" ADD CONSTRAINT "contacts_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "employees"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -134,6 +134,7 @@ model Employee {
|
||||
|
||||
// Relations
|
||||
user User?
|
||||
contact Contact?
|
||||
attendances Attendance[]
|
||||
leaves Leave[]
|
||||
salaries Salary[]
|
||||
@@ -401,6 +402,10 @@ model Contact {
|
||||
categories ContactCategory[]
|
||||
tags String[]
|
||||
|
||||
// HR Link - for Company Employee category
|
||||
employeeId String? @unique
|
||||
employee Employee? @relation(fields: [employeeId], references: [id])
|
||||
|
||||
// Hierarchy - for companies/entities
|
||||
parentId String?
|
||||
parent Contact? @relation("ContactHierarchy", fields: [parentId], references: [id])
|
||||
@@ -442,6 +447,9 @@ model Contact {
|
||||
@@index([mobile])
|
||||
@@index([taxNumber])
|
||||
@@index([commercialRegister])
|
||||
@@index([source])
|
||||
@@index([createdAt])
|
||||
@@index([parentId])
|
||||
@@map("contacts")
|
||||
}
|
||||
|
||||
|
||||
@@ -251,6 +251,7 @@ async function main() {
|
||||
{ name: 'Supplier', nameAr: 'مورد', description: 'Product/Service suppliers' },
|
||||
{ name: 'Partner', nameAr: 'شريك', description: 'Business partners' },
|
||||
{ name: 'Lead', nameAr: 'عميل محتمل', description: 'Potential customers' },
|
||||
{ name: 'Company Employee', nameAr: 'موظف الشركة', description: 'Internal company staff' },
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
56
backend/scripts/run-production-clean-and-seed.sh
Executable file
56
backend/scripts/run-production-clean-and-seed.sh
Executable file
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run database backup + clean-and-seed on PRODUCTION.
|
||||
# Usage: on the production server, from repo root or backend:
|
||||
# ./backend/scripts/run-production-clean-and-seed.sh
|
||||
# Or: bash backend/scripts/run-production-clean-and-seed.sh
|
||||
#
|
||||
# Requires: DATABASE_URL in environment or in backend/.env
|
||||
# Requires: pg_dump (for backup) and Node/npm (for clean-and-seed)
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
BACKEND_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
REPO_ROOT="$(cd "$BACKEND_DIR/.." && pwd)"
|
||||
|
||||
# Load .env from backend if present
|
||||
if [ -f "$BACKEND_DIR/.env" ]; then
|
||||
set -a
|
||||
source "$BACKEND_DIR/.env"
|
||||
set +a
|
||||
fi
|
||||
|
||||
if [ -z "$DATABASE_URL" ]; then
|
||||
echo "❌ DATABASE_URL is not set. Set it in backend/.env or export it."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "⚠️ This will TRUNCATE all tables and re-seed the database."
|
||||
echo " DATABASE_URL is set (database will be modified)."
|
||||
echo ""
|
||||
read -p "Type YES to continue: " confirm
|
||||
if [ "$confirm" != "YES" ]; then
|
||||
echo "Aborted."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
BACKUP_DIR="${BACKUP_DIR:-$REPO_ROOT/backups}"
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
BACKUP_FILE="$BACKUP_DIR/backup_before_cleanup_$(date +%Y%m%d_%H%M%S).sql"
|
||||
|
||||
echo "📦 Backing up database to $BACKUP_FILE ..."
|
||||
if pg_dump "$DATABASE_URL" > "$BACKUP_FILE"; then
|
||||
echo "✅ Backup saved."
|
||||
else
|
||||
echo "❌ Backup failed. Aborting."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🧹 Running clean-and-seed..."
|
||||
cd "$BACKEND_DIR"
|
||||
npm run db:clean-and-seed
|
||||
|
||||
echo ""
|
||||
echo "✅ Done. Restart the application so it uses the cleaned database."
|
||||
echo " Default logins: gm@atmata.com / sales.manager@atmata.com / sales.rep@atmata.com (Password: Admin@123)"
|
||||
93
backend/src/modules/contacts/categories.controller.ts
Normal file
93
backend/src/modules/contacts/categories.controller.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { Request, Response, NextFunction } from 'express'
|
||||
import { categoriesService } from './categories.service'
|
||||
import { AuthRequest } from '@/shared/middleware/auth'
|
||||
|
||||
export class CategoriesController {
|
||||
// Get all categories
|
||||
async findAll(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const categories = await categoriesService.findAll()
|
||||
res.json({
|
||||
success: true,
|
||||
data: categories
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
// Get category tree
|
||||
async getTree(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const tree = await categoriesService.getTree()
|
||||
res.json({
|
||||
success: true,
|
||||
data: tree
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
// Get category by ID
|
||||
async findById(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const category = await categoriesService.findById(id)
|
||||
res.json({
|
||||
success: true,
|
||||
data: category
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
// Create category
|
||||
async create(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const data = req.body
|
||||
const category = await categoriesService.create(data)
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Category created successfully',
|
||||
data: category
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
// Update category
|
||||
async update(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const data = req.body
|
||||
const category = await categoriesService.update(id, data)
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Category updated successfully',
|
||||
data: category
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete category
|
||||
async delete(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const result = await categoriesService.delete(id)
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Category deleted successfully',
|
||||
data: result
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const categoriesController = new CategoriesController()
|
||||
54
backend/src/modules/contacts/categories.routes.ts
Normal file
54
backend/src/modules/contacts/categories.routes.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Router } from 'express'
|
||||
import { authenticate, authorize } from '@/shared/middleware/auth'
|
||||
import { categoriesController } from './categories.controller'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// All routes require authentication
|
||||
router.use(authenticate)
|
||||
|
||||
// ========== CATEGORIES ==========
|
||||
|
||||
// Get all categories (flat list)
|
||||
router.get(
|
||||
'/',
|
||||
authorize('contacts', 'all', 'read'),
|
||||
categoriesController.findAll.bind(categoriesController)
|
||||
)
|
||||
|
||||
// Get category tree (hierarchical)
|
||||
router.get(
|
||||
'/tree',
|
||||
authorize('contacts', 'all', 'read'),
|
||||
categoriesController.getTree.bind(categoriesController)
|
||||
)
|
||||
|
||||
// Get category by ID
|
||||
router.get(
|
||||
'/:id',
|
||||
authorize('contacts', 'all', 'read'),
|
||||
categoriesController.findById.bind(categoriesController)
|
||||
)
|
||||
|
||||
// Create category
|
||||
router.post(
|
||||
'/',
|
||||
authorize('contacts', 'all', 'create'),
|
||||
categoriesController.create.bind(categoriesController)
|
||||
)
|
||||
|
||||
// Update category
|
||||
router.put(
|
||||
'/:id',
|
||||
authorize('contacts', 'all', 'update'),
|
||||
categoriesController.update.bind(categoriesController)
|
||||
)
|
||||
|
||||
// Delete category
|
||||
router.delete(
|
||||
'/:id',
|
||||
authorize('contacts', 'all', 'delete'),
|
||||
categoriesController.delete.bind(categoriesController)
|
||||
)
|
||||
|
||||
export default router
|
||||
214
backend/src/modules/contacts/categories.service.ts
Normal file
214
backend/src/modules/contacts/categories.service.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
export class CategoriesService {
|
||||
// Find all categories (tree structure)
|
||||
async findAll() {
|
||||
const categories = await prisma.contactCategory.findMany({
|
||||
where: {
|
||||
isActive: true
|
||||
},
|
||||
include: {
|
||||
parent: true,
|
||||
children: true,
|
||||
_count: {
|
||||
select: {
|
||||
contacts: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
name: 'asc'
|
||||
}
|
||||
})
|
||||
|
||||
return categories
|
||||
}
|
||||
|
||||
// Find category by ID
|
||||
async findById(id: string) {
|
||||
const category = await prisma.contactCategory.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
parent: true,
|
||||
children: true,
|
||||
_count: {
|
||||
select: {
|
||||
contacts: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!category) {
|
||||
throw new Error('Category not found')
|
||||
}
|
||||
|
||||
return category
|
||||
}
|
||||
|
||||
// Create category
|
||||
async create(data: {
|
||||
name: string
|
||||
nameAr?: string
|
||||
parentId?: string
|
||||
description?: string
|
||||
}) {
|
||||
// Validate parent exists if provided
|
||||
if (data.parentId) {
|
||||
const parent = await prisma.contactCategory.findUnique({
|
||||
where: { id: data.parentId }
|
||||
})
|
||||
if (!parent) {
|
||||
throw new Error('Parent category not found')
|
||||
}
|
||||
}
|
||||
|
||||
const category = await prisma.contactCategory.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
nameAr: data.nameAr,
|
||||
parentId: data.parentId,
|
||||
description: data.description
|
||||
},
|
||||
include: {
|
||||
parent: true,
|
||||
children: true
|
||||
}
|
||||
})
|
||||
|
||||
return category
|
||||
}
|
||||
|
||||
// Update category
|
||||
async update(id: string, data: {
|
||||
name?: string
|
||||
nameAr?: string
|
||||
parentId?: string
|
||||
description?: string
|
||||
isActive?: boolean
|
||||
}) {
|
||||
// Check if category exists
|
||||
const existing = await prisma.contactCategory.findUnique({
|
||||
where: { id }
|
||||
})
|
||||
|
||||
if (!existing) {
|
||||
throw new Error('Category not found')
|
||||
}
|
||||
|
||||
// Validate parent exists if provided and prevent circular reference
|
||||
if (data.parentId) {
|
||||
if (data.parentId === id) {
|
||||
throw new Error('Category cannot be its own parent')
|
||||
}
|
||||
|
||||
const parent = await prisma.contactCategory.findUnique({
|
||||
where: { id: data.parentId }
|
||||
})
|
||||
if (!parent) {
|
||||
throw new Error('Parent category not found')
|
||||
}
|
||||
|
||||
// Check for circular reference
|
||||
let currentParent = parent
|
||||
while (currentParent.parentId) {
|
||||
if (currentParent.parentId === id) {
|
||||
throw new Error('Circular reference detected')
|
||||
}
|
||||
const nextParent = await prisma.contactCategory.findUnique({
|
||||
where: { id: currentParent.parentId }
|
||||
})
|
||||
if (!nextParent) break
|
||||
currentParent = nextParent
|
||||
}
|
||||
}
|
||||
|
||||
const category = await prisma.contactCategory.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: {
|
||||
parent: true,
|
||||
children: true
|
||||
}
|
||||
})
|
||||
|
||||
return category
|
||||
}
|
||||
|
||||
// Delete category (soft delete)
|
||||
async delete(id: string) {
|
||||
// Check if category exists
|
||||
const existing = await prisma.contactCategory.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
children: true,
|
||||
_count: {
|
||||
select: {
|
||||
contacts: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!existing) {
|
||||
throw new Error('Category not found')
|
||||
}
|
||||
|
||||
// Check if category has children
|
||||
if (existing.children.length > 0) {
|
||||
throw new Error('Cannot delete category with subcategories')
|
||||
}
|
||||
|
||||
// Check if category is in use
|
||||
if (existing._count.contacts > 0) {
|
||||
// Soft delete by setting isActive to false
|
||||
const category = await prisma.contactCategory.update({
|
||||
where: { id },
|
||||
data: { isActive: false }
|
||||
})
|
||||
return category
|
||||
}
|
||||
|
||||
// Hard delete if no contacts use it
|
||||
await prisma.contactCategory.delete({
|
||||
where: { id }
|
||||
})
|
||||
|
||||
return { id, deleted: true }
|
||||
}
|
||||
|
||||
// Get category tree (hierarchical structure)
|
||||
async getTree() {
|
||||
const categories = await prisma.contactCategory.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
parentId: null // Only root categories
|
||||
},
|
||||
include: {
|
||||
children: {
|
||||
include: {
|
||||
children: {
|
||||
include: {
|
||||
children: true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
contacts: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
name: 'asc'
|
||||
}
|
||||
})
|
||||
|
||||
return categories
|
||||
}
|
||||
}
|
||||
|
||||
export const categoriesService = new CategoriesService()
|
||||
@@ -129,14 +129,16 @@ class ContactsController {
|
||||
|
||||
async addRelationship(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { toContactId, type, startDate } = req.body;
|
||||
const { toContactId, type, startDate, endDate, notes } = req.body;
|
||||
|
||||
const relationship = await contactsService.addRelationship(
|
||||
req.params.id,
|
||||
toContactId,
|
||||
type,
|
||||
new Date(startDate),
|
||||
req.user!.id
|
||||
req.user!.id,
|
||||
endDate ? new Date(endDate) : undefined,
|
||||
notes
|
||||
);
|
||||
|
||||
res.status(201).json(
|
||||
@@ -147,6 +149,55 @@ class ContactsController {
|
||||
}
|
||||
}
|
||||
|
||||
async getRelationships(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const relationships = await contactsService.getRelationships(req.params.id);
|
||||
res.json(ResponseFormatter.success(relationships));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async updateRelationship(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { type, startDate, endDate, notes, isActive } = req.body;
|
||||
const data: any = {};
|
||||
|
||||
if (type) data.type = type;
|
||||
if (startDate) data.startDate = new Date(startDate);
|
||||
if (endDate) data.endDate = new Date(endDate);
|
||||
if (notes !== undefined) data.notes = notes;
|
||||
if (isActive !== undefined) data.isActive = isActive;
|
||||
|
||||
const relationship = await contactsService.updateRelationship(
|
||||
req.params.relationshipId,
|
||||
data,
|
||||
req.user!.id
|
||||
);
|
||||
|
||||
res.json(
|
||||
ResponseFormatter.success(relationship, 'تم تحديث العلاقة بنجاح - Relationship updated successfully')
|
||||
);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteRelationship(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
await contactsService.deleteRelationship(
|
||||
req.params.relationshipId,
|
||||
req.user!.id
|
||||
);
|
||||
|
||||
res.json(
|
||||
ResponseFormatter.success(null, 'تم حذف العلاقة بنجاح - Relationship deleted successfully')
|
||||
);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async getHistory(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const history = await contactsService.getHistory(req.params.id);
|
||||
@@ -155,6 +206,80 @@ class ContactsController {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async import(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json(
|
||||
ResponseFormatter.error('ملف مطلوب - File required')
|
||||
);
|
||||
}
|
||||
|
||||
const result = await contactsService.import(
|
||||
req.file.buffer,
|
||||
req.user!.id
|
||||
);
|
||||
|
||||
res.json(
|
||||
ResponseFormatter.success(
|
||||
result,
|
||||
`تم استيراد ${result.success} جهة اتصال بنجاح - Imported ${result.success} contacts successfully`
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async export(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const filters = {
|
||||
search: req.query.search as string,
|
||||
type: req.query.type as string,
|
||||
status: req.query.status as string,
|
||||
source: req.query.source as string,
|
||||
category: req.query.category as string,
|
||||
rating: req.query.rating ? parseInt(req.query.rating as string) : undefined,
|
||||
excludeCompanyEmployees: req.query.excludeCompanyEmployees === 'true',
|
||||
};
|
||||
|
||||
const buffer = await contactsService.export(filters);
|
||||
|
||||
res.setHeader(
|
||||
'Content-Type',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
);
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`attachment; filename=contacts-${Date.now()}.xlsx`
|
||||
);
|
||||
res.send(buffer);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async checkDuplicates(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { email, phone, mobile, taxNumber, commercialRegister, excludeId } = req.body;
|
||||
|
||||
const duplicates = await contactsService.findDuplicates(
|
||||
{ email, phone, mobile, taxNumber, commercialRegister },
|
||||
excludeId
|
||||
);
|
||||
|
||||
res.json(
|
||||
ResponseFormatter.success(
|
||||
duplicates,
|
||||
duplicates.length > 0
|
||||
? `تم العثور على ${duplicates.length} جهات اتصال مشابهة - Found ${duplicates.length} similar contacts`
|
||||
: 'لا توجد تكرارات - No duplicates found'
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const contactsController = new ContactsController();
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Router } from 'express';
|
||||
import { body, param } from 'express-validator';
|
||||
import multer from 'multer';
|
||||
import { contactsController } from './contacts.controller';
|
||||
import { authenticate, authorize } from '../../shared/middleware/auth';
|
||||
import { validate } from '../../shared/middleware/validation';
|
||||
import categoriesRouter from './categories.routes';
|
||||
|
||||
const router = Router();
|
||||
const upload = multer({ storage: multer.memoryStorage() });
|
||||
|
||||
// All routes require authentication
|
||||
router.use(authenticate);
|
||||
@@ -94,6 +97,15 @@ router.post(
|
||||
contactsController.merge
|
||||
);
|
||||
|
||||
// Get relationships for a contact
|
||||
router.get(
|
||||
'/:id/relationships',
|
||||
authorize('contacts', 'contacts', 'read'),
|
||||
param('id').isUUID(),
|
||||
validate,
|
||||
contactsController.getRelationships
|
||||
);
|
||||
|
||||
// Add relationship
|
||||
router.post(
|
||||
'/:id/relationships',
|
||||
@@ -103,10 +115,75 @@ router.post(
|
||||
body('toContactId').isUUID(),
|
||||
body('type').notEmpty(),
|
||||
body('startDate').isISO8601(),
|
||||
body('endDate').optional().isISO8601(),
|
||||
body('notes').optional(),
|
||||
validate,
|
||||
],
|
||||
contactsController.addRelationship
|
||||
);
|
||||
|
||||
// Update relationship
|
||||
router.put(
|
||||
'/:id/relationships/:relationshipId',
|
||||
authorize('contacts', 'contacts', 'update'),
|
||||
[
|
||||
param('id').isUUID(),
|
||||
param('relationshipId').isUUID(),
|
||||
body('type').optional(),
|
||||
body('startDate').optional().isISO8601(),
|
||||
body('endDate').optional().isISO8601(),
|
||||
body('notes').optional(),
|
||||
body('isActive').optional().isBoolean(),
|
||||
validate,
|
||||
],
|
||||
contactsController.updateRelationship
|
||||
);
|
||||
|
||||
// Delete relationship
|
||||
router.delete(
|
||||
'/:id/relationships/:relationshipId',
|
||||
authorize('contacts', 'contacts', 'delete'),
|
||||
[
|
||||
param('id').isUUID(),
|
||||
param('relationshipId').isUUID(),
|
||||
validate,
|
||||
],
|
||||
contactsController.deleteRelationship
|
||||
);
|
||||
|
||||
// Check for duplicates
|
||||
router.post(
|
||||
'/check-duplicates',
|
||||
authorize('contacts', 'contacts', 'read'),
|
||||
[
|
||||
body('email').optional().isEmail(),
|
||||
body('phone').optional(),
|
||||
body('mobile').optional(),
|
||||
body('taxNumber').optional(),
|
||||
body('commercialRegister').optional(),
|
||||
body('excludeId').optional().isUUID(),
|
||||
validate,
|
||||
],
|
||||
contactsController.checkDuplicates
|
||||
);
|
||||
|
||||
// Import contacts
|
||||
router.post(
|
||||
'/import',
|
||||
authorize('contacts', 'contacts', 'create'),
|
||||
upload.single('file'),
|
||||
contactsController.import
|
||||
);
|
||||
|
||||
// Export contacts
|
||||
router.get(
|
||||
'/export',
|
||||
authorize('contacts', 'contacts', 'read'),
|
||||
contactsController.export
|
||||
);
|
||||
|
||||
// Mount categories router
|
||||
router.use('/categories', categoriesRouter);
|
||||
|
||||
export default router;
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ interface CreateContactData {
|
||||
categories?: string[];
|
||||
tags?: string[];
|
||||
parentId?: string;
|
||||
employeeId?: string | null;
|
||||
source: string;
|
||||
customFields?: any;
|
||||
createdById: string;
|
||||
@@ -41,6 +42,7 @@ interface SearchFilters {
|
||||
rating?: number;
|
||||
createdFrom?: Date;
|
||||
createdTo?: Date;
|
||||
excludeCompanyEmployees?: boolean;
|
||||
}
|
||||
|
||||
class ContactsService {
|
||||
@@ -48,6 +50,16 @@ class ContactsService {
|
||||
// Check for duplicates based on email, phone, or tax number
|
||||
await this.checkDuplicates(data);
|
||||
|
||||
// Validate employeeId if provided
|
||||
if (data.employeeId) {
|
||||
const employee = await prisma.employee.findUnique({
|
||||
where: { id: data.employeeId },
|
||||
});
|
||||
if (!employee) {
|
||||
throw new AppError(400, 'Employee not found - الموظف غير موجود');
|
||||
}
|
||||
}
|
||||
|
||||
// Generate unique contact ID
|
||||
const uniqueContactId = await this.generateUniqueContactId();
|
||||
|
||||
@@ -75,6 +87,7 @@ class ContactsService {
|
||||
} : undefined,
|
||||
tags: data.tags || [],
|
||||
parentId: data.parentId,
|
||||
employeeId: data.employeeId || undefined,
|
||||
source: data.source,
|
||||
customFields: data.customFields || {},
|
||||
createdById: data.createdById,
|
||||
@@ -82,6 +95,15 @@ class ContactsService {
|
||||
include: {
|
||||
categories: true,
|
||||
parent: true,
|
||||
employee: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
uniqueEmployeeId: true,
|
||||
},
|
||||
},
|
||||
createdBy: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -138,6 +160,12 @@ class ContactsService {
|
||||
where.rating = filters.rating;
|
||||
}
|
||||
|
||||
if (filters.category) {
|
||||
where.categories = {
|
||||
some: { id: filters.category }
|
||||
};
|
||||
}
|
||||
|
||||
if (filters.createdFrom || filters.createdTo) {
|
||||
where.createdAt = {};
|
||||
if (filters.createdFrom) {
|
||||
@@ -165,6 +193,15 @@ class ContactsService {
|
||||
type: true,
|
||||
},
|
||||
},
|
||||
employee: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
uniqueEmployeeId: true,
|
||||
},
|
||||
},
|
||||
createdBy: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -193,6 +230,15 @@ class ContactsService {
|
||||
include: {
|
||||
categories: true,
|
||||
parent: true,
|
||||
employee: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
uniqueEmployeeId: true,
|
||||
},
|
||||
},
|
||||
children: true,
|
||||
relationships: {
|
||||
include: {
|
||||
@@ -270,6 +316,16 @@ class ContactsService {
|
||||
await this.checkDuplicates(data as CreateContactData, id);
|
||||
}
|
||||
|
||||
// Validate employeeId if provided
|
||||
if (data.employeeId !== undefined && data.employeeId !== null) {
|
||||
const employee = await prisma.employee.findUnique({
|
||||
where: { id: data.employeeId },
|
||||
});
|
||||
if (!employee) {
|
||||
throw new AppError(400, 'Employee not found - الموظف غير موجود');
|
||||
}
|
||||
}
|
||||
|
||||
// Update contact
|
||||
const contact = await prisma.contact.update({
|
||||
where: { id },
|
||||
@@ -292,6 +348,7 @@ class ContactsService {
|
||||
set: data.categories.map(id => ({ id }))
|
||||
} : undefined,
|
||||
tags: data.tags,
|
||||
employeeId: data.employeeId !== undefined ? (data.employeeId || null) : undefined,
|
||||
source: data.source,
|
||||
status: data.status,
|
||||
rating: data.rating,
|
||||
@@ -300,6 +357,15 @@ class ContactsService {
|
||||
include: {
|
||||
categories: true,
|
||||
parent: true,
|
||||
employee: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
uniqueEmployeeId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -421,7 +487,9 @@ class ContactsService {
|
||||
toContactId: string,
|
||||
type: string,
|
||||
startDate: Date,
|
||||
userId: string
|
||||
userId: string,
|
||||
endDate?: Date,
|
||||
notes?: string
|
||||
) {
|
||||
const relationship = await prisma.contactRelationship.create({
|
||||
data: {
|
||||
@@ -429,18 +497,28 @@ class ContactsService {
|
||||
toContactId,
|
||||
type,
|
||||
startDate,
|
||||
endDate,
|
||||
notes,
|
||||
},
|
||||
include: {
|
||||
fromContact: {
|
||||
select: {
|
||||
id: true,
|
||||
uniqueContactId: true,
|
||||
type: true,
|
||||
name: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
},
|
||||
},
|
||||
toContact: {
|
||||
select: {
|
||||
id: true,
|
||||
uniqueContactId: true,
|
||||
type: true,
|
||||
name: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -456,12 +534,344 @@ class ContactsService {
|
||||
return relationship;
|
||||
}
|
||||
|
||||
async getRelationships(contactId: string) {
|
||||
const relationships = await prisma.contactRelationship.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ fromContactId: contactId },
|
||||
{ toContactId: contactId }
|
||||
],
|
||||
isActive: true,
|
||||
},
|
||||
include: {
|
||||
fromContact: {
|
||||
select: {
|
||||
id: true,
|
||||
uniqueContactId: true,
|
||||
type: true,
|
||||
name: true,
|
||||
nameAr: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
toContact: {
|
||||
select: {
|
||||
id: true,
|
||||
uniqueContactId: true,
|
||||
type: true,
|
||||
name: true,
|
||||
nameAr: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
return relationships;
|
||||
}
|
||||
|
||||
async updateRelationship(
|
||||
id: string,
|
||||
data: {
|
||||
type?: string;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
notes?: string;
|
||||
isActive?: boolean;
|
||||
},
|
||||
userId: string
|
||||
) {
|
||||
const relationship = await prisma.contactRelationship.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: {
|
||||
fromContact: {
|
||||
select: {
|
||||
id: true,
|
||||
uniqueContactId: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
toContact: {
|
||||
select: {
|
||||
id: true,
|
||||
uniqueContactId: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'CONTACT_RELATIONSHIP',
|
||||
entityId: relationship.id,
|
||||
action: 'UPDATE',
|
||||
userId,
|
||||
changes: data,
|
||||
});
|
||||
|
||||
return relationship;
|
||||
}
|
||||
|
||||
async deleteRelationship(id: string, userId: string) {
|
||||
// Soft delete by marking as inactive
|
||||
const relationship = await prisma.contactRelationship.update({
|
||||
where: { id },
|
||||
data: { isActive: false },
|
||||
});
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'CONTACT_RELATIONSHIP',
|
||||
entityId: id,
|
||||
action: 'DELETE',
|
||||
userId,
|
||||
});
|
||||
|
||||
return relationship;
|
||||
}
|
||||
|
||||
async getHistory(id: string) {
|
||||
return AuditLogger.getEntityHistory('CONTACT', id);
|
||||
}
|
||||
|
||||
// Private helper methods
|
||||
private async checkDuplicates(data: CreateContactData, excludeId?: string) {
|
||||
// Import contacts from Excel/CSV
|
||||
async import(fileBuffer: Buffer, userId: string): Promise<{
|
||||
success: number;
|
||||
failed: number;
|
||||
duplicates: number;
|
||||
errors: Array<{ row: number; field: string; message: string; data?: any }>;
|
||||
}> {
|
||||
const xlsx = require('xlsx');
|
||||
const workbook = xlsx.read(fileBuffer, { type: 'buffer' });
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
const worksheet = workbook.Sheets[sheetName];
|
||||
const data = xlsx.utils.sheet_to_json(worksheet);
|
||||
|
||||
const results = {
|
||||
success: 0,
|
||||
failed: 0,
|
||||
duplicates: 0,
|
||||
errors: [] as Array<{ row: number; field: string; message: string; data?: any }>,
|
||||
};
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const row: any = data[i];
|
||||
const rowNumber = i + 2; // Excel rows start at 1, header is row 1
|
||||
|
||||
try {
|
||||
// Validate required fields
|
||||
if (!row.name || !row.type || !row.source) {
|
||||
results.errors.push({
|
||||
row: rowNumber,
|
||||
field: !row.name ? 'name' : !row.type ? 'type' : 'source',
|
||||
message: 'Required field missing',
|
||||
data: row,
|
||||
});
|
||||
results.failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate type
|
||||
if (!['INDIVIDUAL', 'COMPANY', 'HOLDING', 'GOVERNMENT'].includes(row.type)) {
|
||||
results.errors.push({
|
||||
row: rowNumber,
|
||||
field: 'type',
|
||||
message: 'Invalid type. Must be INDIVIDUAL, COMPANY, HOLDING, or GOVERNMENT',
|
||||
data: row,
|
||||
});
|
||||
results.failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for duplicates
|
||||
try {
|
||||
const contactData: CreateContactData = {
|
||||
type: row.type,
|
||||
name: row.name,
|
||||
nameAr: row.nameAr || row.name_ar,
|
||||
email: row.email,
|
||||
phone: row.phone,
|
||||
mobile: row.mobile,
|
||||
website: row.website,
|
||||
companyName: row.companyName || row.company_name,
|
||||
companyNameAr: row.companyNameAr || row.company_name_ar,
|
||||
taxNumber: row.taxNumber || row.tax_number,
|
||||
commercialRegister: row.commercialRegister || row.commercial_register,
|
||||
address: row.address,
|
||||
city: row.city,
|
||||
country: row.country,
|
||||
postalCode: row.postalCode || row.postal_code,
|
||||
source: row.source,
|
||||
tags: row.tags ? (typeof row.tags === 'string' ? row.tags.split(',').map((t: string) => t.trim()) : row.tags) : [],
|
||||
customFields: {},
|
||||
createdById: userId,
|
||||
};
|
||||
|
||||
await this.checkDuplicates(contactData);
|
||||
|
||||
// Create contact
|
||||
await this.create(contactData, userId);
|
||||
results.success++;
|
||||
} catch (error: any) {
|
||||
if (error.statusCode === 409) {
|
||||
results.duplicates++;
|
||||
results.errors.push({
|
||||
row: rowNumber,
|
||||
field: 'duplicate',
|
||||
message: error.message,
|
||||
data: row,
|
||||
});
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
results.failed++;
|
||||
results.errors.push({
|
||||
row: rowNumber,
|
||||
field: 'general',
|
||||
message: error.message || 'Unknown error',
|
||||
data: row,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// Export contacts to Excel
|
||||
async export(filters: SearchFilters): Promise<Buffer> {
|
||||
const xlsx = require('xlsx');
|
||||
|
||||
// Build query
|
||||
const where: Prisma.ContactWhereInput = {
|
||||
status: { not: 'DELETED' },
|
||||
};
|
||||
|
||||
if (filters.search) {
|
||||
where.OR = [
|
||||
{ name: { contains: filters.search, mode: 'insensitive' } },
|
||||
{ email: { contains: filters.search, mode: 'insensitive' } },
|
||||
{ companyName: { contains: filters.search, mode: 'insensitive' } },
|
||||
];
|
||||
}
|
||||
|
||||
if (filters.type) where.type = filters.type;
|
||||
if (filters.status) where.status = filters.status;
|
||||
if (filters.source) where.source = filters.source;
|
||||
if (filters.rating) where.rating = filters.rating;
|
||||
|
||||
if (filters.excludeCompanyEmployees) {
|
||||
const companyEmployeeCategory = await prisma.contactCategory.findFirst({
|
||||
where: { name: 'Company Employee', isActive: true },
|
||||
});
|
||||
if (companyEmployeeCategory) {
|
||||
where.NOT = {
|
||||
categories: {
|
||||
some: { id: companyEmployeeCategory.id },
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch all contacts (no pagination for export)
|
||||
const contacts = await prisma.contact.findMany({
|
||||
where,
|
||||
include: {
|
||||
categories: true,
|
||||
parent: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
createdBy: {
|
||||
select: {
|
||||
username: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
// Transform data for Excel
|
||||
const exportData = contacts.map(contact => ({
|
||||
'Contact ID': contact.uniqueContactId,
|
||||
'Type': contact.type,
|
||||
'Name': contact.name,
|
||||
'Name (Arabic)': contact.nameAr || '',
|
||||
'Email': contact.email || '',
|
||||
'Phone': contact.phone || '',
|
||||
'Mobile': contact.mobile || '',
|
||||
'Website': contact.website || '',
|
||||
'Company Name': contact.companyName || '',
|
||||
'Company Name (Arabic)': contact.companyNameAr || '',
|
||||
'Tax Number': contact.taxNumber || '',
|
||||
'Commercial Register': contact.commercialRegister || '',
|
||||
'Address': contact.address || '',
|
||||
'City': contact.city || '',
|
||||
'Country': contact.country || '',
|
||||
'Postal Code': contact.postalCode || '',
|
||||
'Source': contact.source,
|
||||
'Rating': contact.rating || '',
|
||||
'Status': contact.status,
|
||||
'Tags': contact.tags?.join(', ') || '',
|
||||
'Categories': contact.categories?.map((c: any) => c.name).join(', ') || '',
|
||||
'Parent Company': contact.parent?.name || '',
|
||||
'Created By': contact.createdBy?.username || '',
|
||||
'Created At': contact.createdAt.toISOString(),
|
||||
}));
|
||||
|
||||
// Create workbook and worksheet
|
||||
const worksheet = xlsx.utils.json_to_sheet(exportData);
|
||||
const workbook = xlsx.utils.book_new();
|
||||
xlsx.utils.book_append_sheet(workbook, worksheet, 'Contacts');
|
||||
|
||||
// Set column widths
|
||||
const columnWidths = [
|
||||
{ wch: 15 }, // Contact ID
|
||||
{ wch: 12 }, // Type
|
||||
{ wch: 25 }, // Name
|
||||
{ wch: 25 }, // Name (Arabic)
|
||||
{ wch: 30 }, // Email
|
||||
{ wch: 15 }, // Phone
|
||||
{ wch: 15 }, // Mobile
|
||||
{ wch: 30 }, // Website
|
||||
{ wch: 25 }, // Company Name
|
||||
{ wch: 25 }, // Company Name (Arabic)
|
||||
{ wch: 20 }, // Tax Number
|
||||
{ wch: 20 }, // Commercial Register
|
||||
{ wch: 30 }, // Address
|
||||
{ wch: 15 }, // City
|
||||
{ wch: 15 }, // Country
|
||||
{ wch: 12 }, // Postal Code
|
||||
{ wch: 15 }, // Source
|
||||
{ wch: 8 }, // Rating
|
||||
{ wch: 10 }, // Status
|
||||
{ wch: 30 }, // Tags
|
||||
{ wch: 30 }, // Categories
|
||||
{ wch: 25 }, // Parent Company
|
||||
{ wch: 15 }, // Created By
|
||||
{ wch: 20 }, // Created At
|
||||
];
|
||||
worksheet['!cols'] = columnWidths;
|
||||
|
||||
// Generate buffer
|
||||
const buffer = xlsx.write(workbook, { type: 'buffer', bookType: 'xlsx' });
|
||||
return buffer;
|
||||
}
|
||||
|
||||
// Check for potential duplicates (public method for API endpoint)
|
||||
async findDuplicates(data: Partial<CreateContactData>, excludeId?: string) {
|
||||
const conditions: Prisma.ContactWhereInput[] = [];
|
||||
|
||||
if (data.email) {
|
||||
@@ -484,31 +894,47 @@ class ContactsService {
|
||||
conditions.push({ commercialRegister: data.commercialRegister });
|
||||
}
|
||||
|
||||
if (conditions.length === 0) return;
|
||||
if (conditions.length === 0) return [];
|
||||
|
||||
const where: Prisma.ContactWhereInput = {
|
||||
OR: conditions,
|
||||
status: { not: 'DELETED' },
|
||||
};
|
||||
|
||||
if (excludeId) {
|
||||
where.NOT = { id: excludeId };
|
||||
}
|
||||
|
||||
const duplicate = await prisma.contact.findFirst({
|
||||
const duplicates = await prisma.contact.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
uniqueContactId: true,
|
||||
type: true,
|
||||
name: true,
|
||||
nameAr: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
mobile: true,
|
||||
taxNumber: true,
|
||||
commercialRegister: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
},
|
||||
take: 10, // Limit to 10 potential duplicates
|
||||
});
|
||||
|
||||
if (duplicate) {
|
||||
return duplicates;
|
||||
}
|
||||
|
||||
// Private helper methods
|
||||
private async checkDuplicates(data: CreateContactData, excludeId?: string) {
|
||||
const duplicates = await this.findDuplicates(data, excludeId);
|
||||
|
||||
if (duplicates.length > 0) {
|
||||
throw new AppError(
|
||||
409,
|
||||
`جهة اتصال مكررة - Duplicate contact found: ${duplicate.name}`
|
||||
`جهة اتصال مكررة - Duplicate contact found: ${duplicates[0].name}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,15 +2,41 @@ import { Response, NextFunction } from 'express';
|
||||
import { AuthRequest } from '../../shared/middleware/auth';
|
||||
import { dealsService } from './deals.service';
|
||||
import { quotesService } from './quotes.service';
|
||||
import { pipelinesService } from './pipelines.service';
|
||||
import { ResponseFormatter } from '../../shared/utils/responseFormatter';
|
||||
|
||||
export class PipelinesController {
|
||||
async findAll(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const structure = req.query.structure as string | undefined;
|
||||
const pipelines = await pipelinesService.findAll({ structure });
|
||||
res.json(ResponseFormatter.success(pipelines));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async findById(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const pipeline = await pipelinesService.findById(req.params.id);
|
||||
res.json(ResponseFormatter.success(pipeline));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class DealsController {
|
||||
async create(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const expectedCloseDate = req.body.expectedCloseDate
|
||||
? new Date(req.body.expectedCloseDate)
|
||||
: undefined;
|
||||
const data = {
|
||||
...req.body,
|
||||
ownerId: req.body.ownerId || req.user!.id,
|
||||
fiscalYear: req.body.fiscalYear || new Date().getFullYear(),
|
||||
expectedCloseDate,
|
||||
};
|
||||
|
||||
const deal = await dealsService.create(data, req.user!.id);
|
||||
@@ -61,9 +87,12 @@ export class DealsController {
|
||||
|
||||
async update(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const body = { ...req.body } as Record<string, unknown>;
|
||||
if (body.expectedCloseDate) body.expectedCloseDate = new Date(body.expectedCloseDate as string);
|
||||
if (body.actualCloseDate) body.actualCloseDate = new Date(body.actualCloseDate as string);
|
||||
const deal = await dealsService.update(
|
||||
req.params.id,
|
||||
req.body,
|
||||
body as any,
|
||||
req.user!.id
|
||||
);
|
||||
|
||||
@@ -197,6 +226,7 @@ export class QuotesController {
|
||||
}
|
||||
}
|
||||
|
||||
export const pipelinesController = new PipelinesController();
|
||||
export const dealsController = new DealsController();
|
||||
export const quotesController = new QuotesController();
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Router } from 'express';
|
||||
import { body, param } from 'express-validator';
|
||||
import { dealsController, quotesController } from './crm.controller';
|
||||
import { pipelinesController, dealsController, quotesController } from './crm.controller';
|
||||
import { authenticate, authorize } from '../../shared/middleware/auth';
|
||||
import { validate } from '../../shared/middleware/validation';
|
||||
|
||||
@@ -9,6 +9,24 @@ const router = Router();
|
||||
// All routes require authentication
|
||||
router.use(authenticate);
|
||||
|
||||
// ============= PIPELINES =============
|
||||
|
||||
// Get all pipelines
|
||||
router.get(
|
||||
'/pipelines',
|
||||
authorize('crm', 'deals', 'read'),
|
||||
pipelinesController.findAll
|
||||
);
|
||||
|
||||
// Get pipeline by ID
|
||||
router.get(
|
||||
'/pipelines/:id',
|
||||
authorize('crm', 'deals', 'read'),
|
||||
param('id').isUUID(),
|
||||
validate,
|
||||
pipelinesController.findById
|
||||
);
|
||||
|
||||
// ============= DEALS =============
|
||||
|
||||
// Get all deals
|
||||
|
||||
60
backend/src/modules/crm/pipelines.service.ts
Normal file
60
backend/src/modules/crm/pipelines.service.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import prisma from '../../config/database';
|
||||
import { AppError } from '../../shared/middleware/errorHandler';
|
||||
|
||||
interface PipelineFilters {
|
||||
structure?: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
class PipelinesService {
|
||||
async findAll(filters: PipelineFilters = {}) {
|
||||
const where: any = {};
|
||||
|
||||
if (filters.structure) {
|
||||
where.structure = filters.structure;
|
||||
}
|
||||
|
||||
if (filters.isActive !== undefined) {
|
||||
where.isActive = filters.isActive;
|
||||
} else {
|
||||
where.isActive = true;
|
||||
}
|
||||
|
||||
const pipelines = await prisma.pipeline.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
nameAr: true,
|
||||
structure: true,
|
||||
stages: true,
|
||||
isActive: true,
|
||||
},
|
||||
orderBy: [{ structure: 'asc' }, { name: 'asc' }],
|
||||
});
|
||||
|
||||
return pipelines;
|
||||
}
|
||||
|
||||
async findById(id: string) {
|
||||
const pipeline = await prisma.pipeline.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
nameAr: true,
|
||||
structure: true,
|
||||
stages: true,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!pipeline) {
|
||||
throw new AppError(404, 'المسار غير موجود - Pipeline not found');
|
||||
}
|
||||
|
||||
return pipeline;
|
||||
}
|
||||
}
|
||||
|
||||
export const pipelinesService = new PipelinesService();
|
||||
Reference in New Issue
Block a user