Deploy rule, CRM enhancements, Company Employee category, bilingual, contacts module completion

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Talal Sharabi
2026-02-19 14:59:34 +04:00
parent 0b126cb676
commit 680ba3871e
51 changed files with 11456 additions and 477 deletions

View File

@@ -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",

View File

@@ -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",

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

View File

@@ -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");

View File

@@ -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;

View File

@@ -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")
}

View File

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

View 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)"

View 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()

View 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

View 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()

View File

@@ -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();

View File

@@ -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;

View File

@@ -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}`
);
}
}

View File

@@ -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();

View File

@@ -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

View 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();