parent
d8a0920ed0
commit
da5147a1b8
1224
API_REQUEST_LOG.txt
1224
API_REQUEST_LOG.txt
File diff suppressed because it is too large
Load Diff
|
|
@ -22,6 +22,7 @@
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.3",
|
"class-validator": "^0.14.3",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"mysql2": "^3.22.4",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
|
|
@ -4186,6 +4187,15 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/aws-ssl-profiles": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/babel-jest": {
|
"node_modules/babel-jest": {
|
||||||
"version": "30.2.0",
|
"version": "30.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.2.0.tgz",
|
||||||
|
|
@ -5119,6 +5129,15 @@
|
||||||
"node": ">=0.4.0"
|
"node": ">=0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/denque": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/depd": {
|
"node_modules/depd": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||||
|
|
@ -6071,6 +6090,15 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/generate-function": {
|
||||||
|
"version": "2.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
|
||||||
|
"integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"is-property": "^1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/gensync": {
|
"node_modules/gensync": {
|
||||||
"version": "1.0.0-beta.2",
|
"version": "1.0.0-beta.2",
|
||||||
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
||||||
|
|
@ -6357,9 +6385,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/iconv-lite": {
|
"node_modules/iconv-lite": {
|
||||||
"version": "0.7.1",
|
"version": "0.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||||
"integrity": "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==",
|
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||||
|
|
@ -6552,6 +6580,12 @@
|
||||||
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
|
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/is-property": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/is-stream": {
|
"node_modules/is-stream": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
|
||||||
|
|
@ -7737,6 +7771,12 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/long": {
|
||||||
|
"version": "5.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
||||||
|
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/lru-cache": {
|
"node_modules/lru-cache": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||||
|
|
@ -7747,6 +7787,21 @@
|
||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lru.min": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"bun": ">=1.0.0",
|
||||||
|
"deno": ">=1.30.0",
|
||||||
|
"node": ">=8.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/wellwelwel"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/luxon": {
|
"node_modules/luxon": {
|
||||||
"version": "3.7.2",
|
"version": "3.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
|
||||||
|
|
@ -8055,6 +8110,40 @@
|
||||||
"node": "^18.17.0 || >=20.5.0"
|
"node": "^18.17.0 || >=20.5.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mysql2": {
|
||||||
|
"version": "3.22.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.22.4.tgz",
|
||||||
|
"integrity": "sha512-CtXYlmL7ZamiYKbmqkamQHWJROUHSfm+f3kByzGfknw7kW51mcB2ouMUqYq1XfYxbXmnWo6RhPydx6OCqdgcmQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"aws-ssl-profiles": "^1.1.2",
|
||||||
|
"denque": "^2.1.0",
|
||||||
|
"generate-function": "^2.3.1",
|
||||||
|
"iconv-lite": "^0.7.2",
|
||||||
|
"long": "^5.3.2",
|
||||||
|
"lru.min": "^1.1.4",
|
||||||
|
"named-placeholders": "^1.1.6",
|
||||||
|
"sql-escaper": "^1.3.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/named-placeholders": {
|
||||||
|
"version": "1.1.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz",
|
||||||
|
"integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"lru.min": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/napi-postinstall": {
|
"node_modules/napi-postinstall": {
|
||||||
"version": "0.3.4",
|
"version": "0.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz",
|
||||||
|
|
@ -9180,6 +9269,21 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/sql-escaper": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"bun": ">=1.0.0",
|
||||||
|
"deno": ">=2.0.0",
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/mysqljs/sql-escaper?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/stack-utils": {
|
"node_modules/stack-utils": {
|
||||||
"version": "2.0.6",
|
"version": "2.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.3",
|
"class-validator": "^0.14.3",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"mysql2": "^3.22.4",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
|
|
|
||||||
|
|
@ -204,8 +204,6 @@ model Opportunity {
|
||||||
closingOwnerId String?
|
closingOwnerId String?
|
||||||
demoOwnerId String?
|
demoOwnerId String?
|
||||||
isDemoDone Boolean @default(false)
|
isDemoDone Boolean @default(false)
|
||||||
closingProbability Int? @default(0)
|
|
||||||
expectedClosingTimeframe String?
|
|
||||||
activities Followup[]
|
activities Followup[]
|
||||||
user User @relation("opportunity_assignedToTouser", fields: [assignedTo], references: [id], map: "Opportunity_assignedTo_fkey")
|
user User @relation("opportunity_assignedToTouser", fields: [assignedTo], references: [id], map: "Opportunity_assignedTo_fkey")
|
||||||
client Client @relation(fields: [clientId], references: [id], map: "Opportunity_clientId_fkey")
|
client Client @relation(fields: [clientId], references: [id], map: "Opportunity_clientId_fkey")
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,168 @@
|
||||||
|
const mysql = require('mysql2/promise');
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('Comparing database structures between "igcrm" (local) and "crmlive1" (live)...');
|
||||||
|
|
||||||
|
// Parse credentials from local DATABASE_URL or default to root@127.0.0.1:3306
|
||||||
|
let connectionConfig = {
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 3306,
|
||||||
|
user: 'root',
|
||||||
|
password: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const dbUrl = process.env.DATABASE_URL;
|
||||||
|
if (dbUrl) {
|
||||||
|
try {
|
||||||
|
const url = dbUrl.replace('mysql://', '');
|
||||||
|
const [auth, hostPortDb] = url.split('@');
|
||||||
|
const [user, password] = auth.split(':');
|
||||||
|
const [hostPort, database] = hostPortDb.split('/');
|
||||||
|
const [host, port] = hostPort.split(':');
|
||||||
|
connectionConfig.user = user;
|
||||||
|
connectionConfig.password = password || '';
|
||||||
|
connectionConfig.host = host;
|
||||||
|
connectionConfig.port = parseInt(port) || 3306;
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Error parsing DATABASE_URL, using defaults.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Connecting to MySQL on ${connectionConfig.host}:${connectionConfig.port}...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const connection = await mysql.createConnection(connectionConfig);
|
||||||
|
console.log('Connected successfully!');
|
||||||
|
|
||||||
|
// Check if databases exist
|
||||||
|
const [dbs] = await connection.query('SHOW DATABASES');
|
||||||
|
const dbNames = dbs.map(d => d.Database);
|
||||||
|
|
||||||
|
const localDb = 'igcrm';
|
||||||
|
const liveDb = 'crmlive1';
|
||||||
|
|
||||||
|
if (!dbNames.includes(localDb)) {
|
||||||
|
console.error(`Local database "${localDb}" not found! Available databases:`, dbNames);
|
||||||
|
await connection.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!dbNames.includes(liveDb)) {
|
||||||
|
console.error(`Live database "${liveDb}" not found! Available databases:`, dbNames);
|
||||||
|
await connection.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Querying information_schema for ${localDb} and ${liveDb}...`);
|
||||||
|
|
||||||
|
// Fetch column definitions for local
|
||||||
|
const [localCols] = await connection.query(`
|
||||||
|
SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE, COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT, COLUMN_KEY
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE TABLE_SCHEMA = ?
|
||||||
|
ORDER BY TABLE_NAME, ORDINAL_POSITION
|
||||||
|
`, [localDb]);
|
||||||
|
|
||||||
|
// Fetch column definitions for live
|
||||||
|
const [liveCols] = await connection.query(`
|
||||||
|
SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE, COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT, COLUMN_KEY
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE TABLE_SCHEMA = ?
|
||||||
|
ORDER BY TABLE_NAME, ORDINAL_POSITION
|
||||||
|
`, [liveDb]);
|
||||||
|
|
||||||
|
await connection.end();
|
||||||
|
|
||||||
|
// Map columns to structured objects
|
||||||
|
const localSchema = {};
|
||||||
|
localCols.forEach(col => {
|
||||||
|
if (!localSchema[col.TABLE_NAME]) localSchema[col.TABLE_NAME] = {};
|
||||||
|
localSchema[col.TABLE_NAME][col.COLUMN_NAME] = col;
|
||||||
|
});
|
||||||
|
|
||||||
|
const liveSchema = {};
|
||||||
|
liveCols.forEach(col => {
|
||||||
|
if (!liveSchema[col.TABLE_NAME]) liveSchema[col.TABLE_NAME] = {};
|
||||||
|
liveSchema[col.TABLE_NAME][col.COLUMN_NAME] = col;
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('\n--- SCHEMA COMPARISON RESULTS ---\n');
|
||||||
|
|
||||||
|
// 1. Check for missing tables
|
||||||
|
const localTables = Object.keys(localSchema);
|
||||||
|
const liveTables = Object.keys(liveSchema);
|
||||||
|
|
||||||
|
const missingInLive = localTables.filter(t => !liveTables.includes(t));
|
||||||
|
const extraInLive = liveTables.filter(t => !localTables.includes(t));
|
||||||
|
|
||||||
|
if (missingInLive.length > 0) {
|
||||||
|
console.log(`❌ Tables in Local (${localDb}) but missing in Live (${liveDb}):`, missingInLive);
|
||||||
|
}
|
||||||
|
if (extraInLive.length > 0) {
|
||||||
|
console.log(`ℹ️ Tables in Live (${liveDb}) but missing in Local (${localDb}) (pre-existing/extra):`, extraInLive);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Compare shared tables
|
||||||
|
const sharedTables = localTables.filter(t => liveTables.includes(t));
|
||||||
|
let differencesFound = false;
|
||||||
|
|
||||||
|
sharedTables.forEach(tableName => {
|
||||||
|
const localTab = localSchema[tableName];
|
||||||
|
const liveTab = liveSchema[tableName];
|
||||||
|
|
||||||
|
const localColsList = Object.keys(localTab);
|
||||||
|
const liveColsList = Object.keys(liveTab);
|
||||||
|
|
||||||
|
const missingColsInLive = localColsList.filter(c => !liveColsList.includes(c));
|
||||||
|
const extraColsInLive = liveColsList.filter(c => !localColsList.includes(c));
|
||||||
|
|
||||||
|
let tableDiffHeaderPrinted = false;
|
||||||
|
const printTableDiffHeader = () => {
|
||||||
|
if (!tableDiffHeaderPrinted) {
|
||||||
|
console.log(`\nTable: "${tableName}"`);
|
||||||
|
tableDiffHeaderPrinted = true;
|
||||||
|
differencesFound = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (missingColsInLive.length > 0) {
|
||||||
|
printTableDiffHeader();
|
||||||
|
console.log(` ❌ Columns in Local but MISSING in Live:`, missingColsInLive);
|
||||||
|
}
|
||||||
|
if (extraColsInLive.length > 0) {
|
||||||
|
printTableDiffHeader();
|
||||||
|
console.log(` ℹ️ Columns in Live but MISSING in Local:`, extraColsInLive);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare details of shared columns
|
||||||
|
const sharedCols = localColsList.filter(c => liveColsList.includes(c));
|
||||||
|
sharedCols.forEach(colName => {
|
||||||
|
const localCol = localTab[colName];
|
||||||
|
const liveCol = liveTab[colName];
|
||||||
|
|
||||||
|
const typeDiff = localCol.COLUMN_TYPE !== liveCol.COLUMN_TYPE;
|
||||||
|
const nullDiff = localCol.IS_NULLABLE !== liveCol.IS_NULLABLE;
|
||||||
|
const defaultDiff = localCol.COLUMN_DEFAULT !== liveCol.COLUMN_DEFAULT;
|
||||||
|
const keyDiff = localCol.COLUMN_KEY !== liveCol.COLUMN_KEY;
|
||||||
|
|
||||||
|
if (typeDiff || nullDiff || defaultDiff || keyDiff) {
|
||||||
|
printTableDiffHeader();
|
||||||
|
console.log(` Column: "${colName}" has differences:`);
|
||||||
|
if (typeDiff) console.log(` - Type: Local="${localCol.COLUMN_TYPE}", Live="${liveCol.COLUMN_TYPE}"`);
|
||||||
|
if (nullDiff) console.log(` - Nullable: Local="${localCol.IS_NULLABLE}", Live="${liveCol.IS_NULLABLE}"`);
|
||||||
|
if (defaultDiff) console.log(` - Default: Local="${localCol.COLUMN_DEFAULT}", Live="${liveCol.COLUMN_DEFAULT}"`);
|
||||||
|
if (keyDiff) console.log(` - Key: Local="${localCol.COLUMN_KEY}", Live="${liveCol.COLUMN_KEY}"`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!differencesFound && missingInLive.length === 0 && extraInLive.length === 0) {
|
||||||
|
console.log('✅ Local and Live schemas are identical!');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Comparison failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
-- ============================================================
|
||||||
|
-- LIVE DB MIGRATION SCRIPT
|
||||||
|
-- Target DB: crmlive1 (or the production database)
|
||||||
|
-- Purpose: Align live DB schema with local (igcrm) schema
|
||||||
|
-- Run: mysql -u root -p crmlive1 < fix_live_db.sql
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
USE crmlive1;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 1. FIX followup.type enum
|
||||||
|
-- Local has: CALL, MESSAGE, DEMO_SCHEDULED, DEMO_COMPLETED,
|
||||||
|
-- QUOTE_REQUEST, QUOTE_SEND, VISIT_SCHEDULED,
|
||||||
|
-- VISIT_COMPLETED, NEGOTIATION, FOLLOWUP, DEMO, QUOTE
|
||||||
|
-- Live has: FOLLOWUP, DEMO, QUOTE, NEGOTIATION (old/smaller enum)
|
||||||
|
-- ============================================================
|
||||||
|
ALTER TABLE followup
|
||||||
|
MODIFY COLUMN `type`
|
||||||
|
enum('CALL','MESSAGE','DEMO_SCHEDULED','DEMO_COMPLETED','QUOTE_REQUEST','QUOTE_SEND','VISIT_SCHEDULED','VISIT_COMPLETED','NEGOTIATION','FOLLOWUP','DEMO','QUOTE')
|
||||||
|
NOT NULL DEFAULT 'FOLLOWUP';
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 2. ADD MISSING columns in opportunity table
|
||||||
|
-- Local has: closingProbability, expectedClosingTimeframe
|
||||||
|
-- Live: these columns are missing
|
||||||
|
-- ============================================================
|
||||||
|
ALTER TABLE opportunity
|
||||||
|
ADD COLUMN IF NOT EXISTS `closingProbability` INT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS `expectedClosingTimeframe` VARCHAR(191) NULL;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- VERIFICATION QUERIES (run after migration to confirm)
|
||||||
|
-- ============================================================
|
||||||
|
-- SHOW COLUMNS FROM followup WHERE Field = 'type';
|
||||||
|
-- SHOW COLUMNS FROM opportunity LIKE 'closing%';
|
||||||
|
-- SHOW COLUMNS FROM opportunity LIKE 'expected%';
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('Testing Prisma follow-up creation...');
|
||||||
|
try {
|
||||||
|
// 1. Fetch a user and client to get valid IDs
|
||||||
|
const user = await prisma.user.findFirst();
|
||||||
|
const client = await prisma.client.findFirst();
|
||||||
|
|
||||||
|
if (!user || !client) {
|
||||||
|
console.error('Could not find user or client in DB to test. Make sure db is seeded.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Using User ID: ${user.id}, Client ID: ${client.id}`);
|
||||||
|
|
||||||
|
// 2. Mock a CreateFollowupDto payload
|
||||||
|
const createDto = {
|
||||||
|
userId: user.id,
|
||||||
|
clientId: client.id,
|
||||||
|
notes: 'Test notes',
|
||||||
|
date: new Date().toISOString(),
|
||||||
|
status: 'PENDING',
|
||||||
|
type: 'FOLLOWUP',
|
||||||
|
stage: 'LEAD',
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Inserting followup via Prisma...');
|
||||||
|
const followup = await prisma.followup.create({
|
||||||
|
data: createDto,
|
||||||
|
include: { client: true }
|
||||||
|
});
|
||||||
|
console.log('Followup created successfully:', followup.id);
|
||||||
|
|
||||||
|
// 3. Mock notification raw query
|
||||||
|
console.log('Inserting notification via raw query...');
|
||||||
|
const notifId = require('crypto').randomUUID();
|
||||||
|
const metaStr = null;
|
||||||
|
const title = 'New Follow-up Assigned 📅';
|
||||||
|
const body = `You have been assigned a new follow-up task for client. Deadline: ${new Date(createDto.date).toLocaleString()}`;
|
||||||
|
const type = 'FOLLOWUP_ASSIGNED';
|
||||||
|
|
||||||
|
await prisma.$executeRaw`
|
||||||
|
INSERT INTO notification (id, userId, title, body, type, metadata)
|
||||||
|
VALUES (${notifId}, ${user.id}, ${title}, ${body}, ${type}, ${metaStr})
|
||||||
|
`;
|
||||||
|
console.log('Notification raw insert succeeded!');
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
console.log('Cleaning up test data...');
|
||||||
|
await prisma.followup.delete({ where: { id: followup.id } });
|
||||||
|
await prisma.$executeRaw`
|
||||||
|
DELETE FROM notification WHERE id = ${notifId}
|
||||||
|
`;
|
||||||
|
console.log('Clean up done.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error encountered:', error);
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Controller, Get, UseGuards, Request } from '@nestjs/common';
|
import { Controller, Get, UseGuards, Request, ForbiddenException } from '@nestjs/common';
|
||||||
import { DashboardService } from './dashboard.service';
|
import { DashboardService } from './dashboard.service';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
|
||||||
|
|
@ -11,4 +11,18 @@ export class DashboardController {
|
||||||
getStats(@Request() req) {
|
getStats(@Request() req) {
|
||||||
return this.dashboardService.getStats(req.user);
|
return this.dashboardService.getStats(req.user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('my')
|
||||||
|
getMyDashboard(@Request() req) {
|
||||||
|
return this.dashboardService.getMyDashboard(req.user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('manager')
|
||||||
|
getManagerDashboard(@Request() req) {
|
||||||
|
const allowedRoles = ['ADMIN', 'GENERAL_MANAGER', 'MANAGER'];
|
||||||
|
if (!allowedRoles.includes(req.user.role)) {
|
||||||
|
throw new ForbiddenException('Access restricted to managers and admins.');
|
||||||
|
}
|
||||||
|
return this.dashboardService.getManagerDashboard(req.user);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
import { user_role } from '@prisma/client';
|
import { user_role } from '@prisma/client';
|
||||||
import { startOfDay, startOfMonth } from 'date-fns';
|
import { startOfDay, startOfMonth, endOfMonth, startOfQuarter, endOfQuarter, subMonths } from 'date-fns';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DashboardService {
|
export class DashboardService {
|
||||||
|
|
@ -204,4 +204,412 @@ export class DashboardService {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// MY DASHBOARD (salesperson / normal user)
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
async getMyDashboard(user: any) {
|
||||||
|
const now = new Date();
|
||||||
|
const monthStart = startOfMonth(now);
|
||||||
|
const monthEnd = endOfMonth(now);
|
||||||
|
const userId = user.id;
|
||||||
|
|
||||||
|
// Week buckets (fixed date ranges within the month)
|
||||||
|
const year = now.getFullYear();
|
||||||
|
const month = now.getMonth();
|
||||||
|
const weeks = [
|
||||||
|
{ label: 'Week 1', start: new Date(year, month, 1), end: new Date(year, month, 7, 23, 59, 59) },
|
||||||
|
{ label: 'Week 2', start: new Date(year, month, 8), end: new Date(year, month, 14, 23, 59, 59) },
|
||||||
|
{ label: 'Week 3', start: new Date(year, month, 15), end: new Date(year, month, 21, 23, 59, 59) },
|
||||||
|
{ label: 'Week 4', start: new Date(year, month, 22), end: monthEnd },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Fetch target for current month
|
||||||
|
const target = await this.prisma.target.findFirst({
|
||||||
|
where: { userId, month: now.getMonth() + 1, year: now.getFullYear() }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch all WON opportunities this month where user is involved
|
||||||
|
const wonOpps = await this.prisma.opportunity.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ assignedTo: userId },
|
||||||
|
{ creatorId: userId },
|
||||||
|
{ closingOwnerId: userId },
|
||||||
|
],
|
||||||
|
stage: 'SALES',
|
||||||
|
updatedAt: { gte: monthStart, lte: monthEnd }
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
value: true,
|
||||||
|
assignedTo: true,
|
||||||
|
creatorId: true,
|
||||||
|
closingOwnerId: true,
|
||||||
|
updatedAt: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch all pipeline opportunities (non-won) this month
|
||||||
|
const pipelineOpps = await this.prisma.opportunity.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [{ assignedTo: userId }, { creatorId: userId }],
|
||||||
|
stage: { not: 'SALES' }
|
||||||
|
},
|
||||||
|
select: { value: true, stage: true, assignedTo: true, creatorId: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Weekly actual calculation (user's contribution per week)
|
||||||
|
const weeklyActual = weeks.map(w => {
|
||||||
|
const oppsInWeek = wonOpps.filter(o => o.updatedAt >= w.start && o.updatedAt <= w.end);
|
||||||
|
return oppsInWeek.reduce((sum, o) => {
|
||||||
|
const isCloser = (o.closingOwnerId || o.assignedTo) === userId;
|
||||||
|
const isCreator = o.creatorId === userId;
|
||||||
|
if (isCloser && isCreator) return sum + o.value;
|
||||||
|
if (isCloser || isCreator) return sum + o.value * 0.5;
|
||||||
|
return sum;
|
||||||
|
}, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const weeklyExpected = target ? weeks.map(() => target.weeklyTarget) : [0, 0, 0, 0];
|
||||||
|
|
||||||
|
// Total MTD achieved (user contribution)
|
||||||
|
const totalActual = wonOpps.reduce((sum, o) => {
|
||||||
|
const isCloser = (o.closingOwnerId || o.assignedTo) === userId;
|
||||||
|
const isCreator = o.creatorId === userId;
|
||||||
|
if (isCloser && isCreator) return sum + o.value;
|
||||||
|
if (isCloser || isCreator) return sum + o.value * 0.5;
|
||||||
|
return sum;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const totalExpected = target?.monthlyTarget || 0;
|
||||||
|
const achievementPct = totalExpected > 0 ? Math.min(100, (totalActual / totalExpected) * 100) : 0;
|
||||||
|
const remaining = Math.max(0, totalExpected - totalActual);
|
||||||
|
const remainingPct = totalExpected > 0 ? Math.max(0, 100 - achievementPct) : 0;
|
||||||
|
|
||||||
|
// Stage-wise pipeline (count + value)
|
||||||
|
const stages = ['LEAD', 'QUALIFIED', 'POTENTIAL', 'DEMO', 'SALES'];
|
||||||
|
const stageData = stages.map(stage => {
|
||||||
|
const oppsInStage = stage === 'SALES'
|
||||||
|
? wonOpps
|
||||||
|
: pipelineOpps.filter(o => o.stage === stage);
|
||||||
|
return {
|
||||||
|
stage,
|
||||||
|
count: oppsInStage.length,
|
||||||
|
value: oppsInStage.reduce((s, o) => s + (o.value || 0), 0)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Own sales = user is both creator AND closer
|
||||||
|
const ownSalesOpps = wonOpps.filter(o => {
|
||||||
|
const closer = o.closingOwnerId || o.assignedTo;
|
||||||
|
return closer === userId && o.creatorId === userId;
|
||||||
|
});
|
||||||
|
const ownSalesValue = ownSalesOpps.reduce((s, o) => s + o.value, 0);
|
||||||
|
|
||||||
|
// Shared sales = user is either creator OR closer, but not both
|
||||||
|
const sharedSalesOpps = wonOpps.filter(o => {
|
||||||
|
const closer = o.closingOwnerId || o.assignedTo;
|
||||||
|
const isCloser = closer === userId;
|
||||||
|
const isCreator = o.creatorId === userId;
|
||||||
|
return (isCloser || isCreator) && !(isCloser && isCreator);
|
||||||
|
});
|
||||||
|
// Contribution from shared (50% each)
|
||||||
|
const sharedSalesValue = sharedSalesOpps.reduce((s, o) => s + o.value * 0.5, 0);
|
||||||
|
|
||||||
|
const totalSalesValue = ownSalesValue + sharedSalesValue;
|
||||||
|
const ownSalesPct = totalSalesValue > 0 ? (ownSalesValue / totalSalesValue) * 100 : 0;
|
||||||
|
const sharedSalesPct = totalSalesValue > 0 ? (sharedSalesValue / totalSalesValue) * 100 : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
target: target ? {
|
||||||
|
monthly: target.monthlyTarget,
|
||||||
|
minimum: target.minTarget,
|
||||||
|
weekly: target.weeklyTarget,
|
||||||
|
dailyLead: target.dailyLeadTarget,
|
||||||
|
requiredClosures: (target as any).requiredClosures || null,
|
||||||
|
requiredDemos: (target as any).requiredDemos || null,
|
||||||
|
} : null,
|
||||||
|
weekly: weeks.map((w, i) => ({
|
||||||
|
label: w.label,
|
||||||
|
expected: weeklyExpected[i],
|
||||||
|
actual: weeklyActual[i],
|
||||||
|
})),
|
||||||
|
totalExpected,
|
||||||
|
totalActual,
|
||||||
|
achievementPct: Math.round(achievementPct * 10) / 10,
|
||||||
|
remaining,
|
||||||
|
remainingPct: Math.round(remainingPct * 10) / 10,
|
||||||
|
stageData,
|
||||||
|
ownSales: {
|
||||||
|
value: ownSalesValue,
|
||||||
|
count: ownSalesOpps.length,
|
||||||
|
pct: Math.round(ownSalesPct * 10) / 10,
|
||||||
|
},
|
||||||
|
sharedSales: {
|
||||||
|
value: sharedSalesValue,
|
||||||
|
count: sharedSalesOpps.length,
|
||||||
|
pct: Math.round(sharedSalesPct * 10) / 10,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// MANAGER DASHBOARD
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
async getManagerDashboard(user: any) {
|
||||||
|
const now = new Date();
|
||||||
|
const monthStart = startOfMonth(now);
|
||||||
|
const monthEnd = endOfMonth(now);
|
||||||
|
const lastMonthStart = startOfMonth(subMonths(now, 1));
|
||||||
|
const lastMonthEnd = endOfMonth(subMonths(now, 1));
|
||||||
|
const quarterStart = startOfQuarter(now);
|
||||||
|
const quarterEnd = endOfQuarter(now);
|
||||||
|
|
||||||
|
// Determine visible team members
|
||||||
|
let teamUserIds: string[] = [];
|
||||||
|
if (user.role === user_role.ADMIN || user.role === user_role.GENERAL_MANAGER) {
|
||||||
|
const allUsers = await this.prisma.user.findMany({
|
||||||
|
where: { status: 'APPROVED' },
|
||||||
|
select: { id: true }
|
||||||
|
});
|
||||||
|
teamUserIds = allUsers.map(u => u.id);
|
||||||
|
} else {
|
||||||
|
const subs = await this.usersService.getSubordinateIds(user.id);
|
||||||
|
teamUserIds = [user.id, ...subs];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Team Performance ──
|
||||||
|
const teamUsers = await this.prisma.user.findMany({
|
||||||
|
where: { id: { in: teamUserIds }, status: 'APPROVED' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
role: true,
|
||||||
|
performanceScores: {
|
||||||
|
orderBy: { date: 'desc' },
|
||||||
|
take: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const teamPerformance = teamUsers.map(u => ({
|
||||||
|
id: u.id,
|
||||||
|
name: u.name,
|
||||||
|
role: u.role,
|
||||||
|
score: u.performanceScores[0]?.score || 0,
|
||||||
|
tag: u.performanceScores[0]?.tag || 'N/A',
|
||||||
|
revenueScore: (u.performanceScores[0] as any)?.revenueScore || 0,
|
||||||
|
activityScore: (u.performanceScores[0] as any)?.activityScore || 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── Pipeline Health (stage-wise aggregate for team) ──
|
||||||
|
const allOpps = await this.prisma.opportunity.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [{ assignedTo: { in: teamUserIds } }, { creatorId: { in: teamUserIds } }]
|
||||||
|
},
|
||||||
|
select: { stage: true, value: true, assignedTo: true, creatorId: true, updatedAt: true, closingOwnerId: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
const stages = ['LEAD', 'QUALIFIED', 'POTENTIAL', 'DEMO', 'SALES'];
|
||||||
|
const pipelineHealth = stages.map(stage => {
|
||||||
|
const inStage = allOpps.filter(o => o.stage === stage);
|
||||||
|
return {
|
||||||
|
stage,
|
||||||
|
count: inStage.length,
|
||||||
|
value: inStage.reduce((s, o) => s + (o.value || 0), 0)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Target vs Sales Conversion Ratio ──
|
||||||
|
const computeConversionRatio = async (start: Date, end: Date) => {
|
||||||
|
const [totalTarget, wonRevenue, totalEnquiries] = await Promise.all([
|
||||||
|
this.prisma.target.aggregate({
|
||||||
|
where: { userId: { in: teamUserIds }, month: start.getMonth() + 1, year: start.getFullYear() },
|
||||||
|
_sum: { monthlyTarget: true }
|
||||||
|
}),
|
||||||
|
this.prisma.opportunity.aggregate({
|
||||||
|
where: {
|
||||||
|
assignedTo: { in: teamUserIds },
|
||||||
|
stage: 'SALES',
|
||||||
|
updatedAt: { gte: start, lte: end }
|
||||||
|
},
|
||||||
|
_sum: { value: true },
|
||||||
|
_count: true
|
||||||
|
}),
|
||||||
|
this.prisma.enquiry.count({
|
||||||
|
where: {
|
||||||
|
userId: { in: teamUserIds },
|
||||||
|
createdAt: { gte: start, lte: end }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
const target = totalTarget._sum?.monthlyTarget || 0;
|
||||||
|
const revenue = wonRevenue._sum?.value || 0;
|
||||||
|
const closures = wonRevenue._count || 0;
|
||||||
|
const ratio = target > 0 ? Math.round((revenue / target) * 100) : 0;
|
||||||
|
const conversionRate = totalEnquiries > 0 ? Math.round((closures / totalEnquiries) * 100) : 0;
|
||||||
|
return { target, revenue, closures, ratio, enquiries: totalEnquiries, conversionRate };
|
||||||
|
};
|
||||||
|
|
||||||
|
const [thisMonth, lastMonth, quarter] = await Promise.all([
|
||||||
|
computeConversionRatio(monthStart, monthEnd),
|
||||||
|
computeConversionRatio(lastMonthStart, lastMonthEnd),
|
||||||
|
computeConversionRatio(quarterStart, quarterEnd),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ── Activities Analysis ──
|
||||||
|
const activityTypes = ['DEMO', 'VISIT', 'MANAGER_HELP', 'CALL', 'MEETING'];
|
||||||
|
const scheduledFollowups = await this.prisma.followup.findMany({
|
||||||
|
where: {
|
||||||
|
userId: { in: teamUserIds },
|
||||||
|
date: { gte: monthStart, lte: monthEnd }
|
||||||
|
},
|
||||||
|
select: { type: true, status: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
const activitiesAnalysis = activityTypes.map(type => {
|
||||||
|
const typeFollowups = scheduledFollowups.filter(f => (f as any).type === type);
|
||||||
|
const scheduled = typeFollowups.length;
|
||||||
|
const completed = typeFollowups.filter(f => f.status === 'DONE').length;
|
||||||
|
return { type, scheduled, completed, completionRate: scheduled > 0 ? Math.round((completed / scheduled) * 100) : 0 };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also get strategic activities (demos, visits)
|
||||||
|
const strategicActivities = await this.prisma.strategicActivity.findMany({
|
||||||
|
where: {
|
||||||
|
userId: { in: teamUserIds },
|
||||||
|
date: { gte: monthStart, lte: monthEnd } as any
|
||||||
|
},
|
||||||
|
select: { type: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
const strategicByType: Record<string, number> = {};
|
||||||
|
strategicActivities.forEach(a => {
|
||||||
|
strategicByType[a.type] = (strategicByType[a.type] || 0) + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Performance Weightage ──
|
||||||
|
// 70% Sales Performance + 30% Activity Performance per user
|
||||||
|
const performanceWeightage = teamUsers.map(u => {
|
||||||
|
const ps = u.performanceScores[0];
|
||||||
|
const salesScore = ps ? ((ps as any).revenueScore || 0) + ((ps as any).conversionScore || 0) : 0;
|
||||||
|
const activityScore = ps ? ((ps as any).activityScore || 0) + ((ps as any).disciplineScore || 0) : 0;
|
||||||
|
// Sales max = 60 (40 revenue + 20 conversion), Activity max = 30 (15 + 15)
|
||||||
|
const weightedSales = Math.min(70, (salesScore / 60) * 70);
|
||||||
|
const weightedActivity = Math.min(30, (activityScore / 30) * 30);
|
||||||
|
return {
|
||||||
|
id: u.id,
|
||||||
|
name: u.name,
|
||||||
|
weightedSales: Math.round(weightedSales * 10) / 10,
|
||||||
|
weightedActivity: Math.round(weightedActivity * 10) / 10,
|
||||||
|
totalWeighted: Math.round((weightedSales + weightedActivity) * 10) / 10,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Performer / Underperformer (Month) ──
|
||||||
|
const sorted = [...teamPerformance].sort((a, b) => b.score - a.score);
|
||||||
|
const topPerformers = sorted.slice(0, 3);
|
||||||
|
const underPerformers = sorted.slice(-3).reverse();
|
||||||
|
|
||||||
|
// ── Performer / Underperformer (Quarter) ──
|
||||||
|
const quarterScores = await this.prisma.performanceScore.findMany({
|
||||||
|
where: {
|
||||||
|
userId: { in: teamUserIds },
|
||||||
|
date: { gte: quarterStart, lte: quarterEnd }
|
||||||
|
},
|
||||||
|
select: { userId: true, score: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
const quarterAvgByUser: Record<string, { total: number; count: number }> = {};
|
||||||
|
quarterScores.forEach(s => {
|
||||||
|
if (!quarterAvgByUser[s.userId]) quarterAvgByUser[s.userId] = { total: 0, count: 0 };
|
||||||
|
quarterAvgByUser[s.userId].total += s.score;
|
||||||
|
quarterAvgByUser[s.userId].count += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const quarterUserScores = teamUsers.map(u => ({
|
||||||
|
id: u.id,
|
||||||
|
name: u.name,
|
||||||
|
role: u.role,
|
||||||
|
score: quarterAvgByUser[u.id]
|
||||||
|
? Math.round(quarterAvgByUser[u.id].total / quarterAvgByUser[u.id].count)
|
||||||
|
: 0,
|
||||||
|
})).sort((a, b) => b.score - a.score);
|
||||||
|
|
||||||
|
const topPerformersQuarter = quarterUserScores.slice(0, 3);
|
||||||
|
const underPerformersQuarter = quarterUserScores.slice(-3).reverse();
|
||||||
|
|
||||||
|
// ── Lead vs Shared Conversion ──
|
||||||
|
const thisMonthWonOpps = allOpps.filter(o =>
|
||||||
|
o.stage === 'SALES' &&
|
||||||
|
o.updatedAt >= monthStart &&
|
||||||
|
o.updatedAt <= monthEnd
|
||||||
|
);
|
||||||
|
|
||||||
|
const ownBusinessOpps = thisMonthWonOpps.filter(o => {
|
||||||
|
const closer = o.closingOwnerId || o.assignedTo;
|
||||||
|
return closer === o.creatorId;
|
||||||
|
});
|
||||||
|
|
||||||
|
const sharedBusinessOpps = thisMonthWonOpps.filter(o => {
|
||||||
|
const closer = o.closingOwnerId || o.assignedTo;
|
||||||
|
return closer !== o.creatorId;
|
||||||
|
});
|
||||||
|
|
||||||
|
const ownBusinessValue = ownBusinessOpps.reduce((s, o) => s + o.value, 0);
|
||||||
|
const sharedBusinessValue = sharedBusinessOpps.reduce((s, o) => s + o.value, 0);
|
||||||
|
|
||||||
|
// Enquiries for conversion calc
|
||||||
|
const totalEnquiriesMonth = await this.prisma.enquiry.count({
|
||||||
|
where: { userId: { in: teamUserIds }, createdAt: { gte: monthStart, lte: monthEnd } }
|
||||||
|
});
|
||||||
|
|
||||||
|
const ownConvRate = totalEnquiriesMonth > 0
|
||||||
|
? Math.round((ownBusinessOpps.length / totalEnquiriesMonth) * 100) : 0;
|
||||||
|
const sharedConvRate = totalEnquiriesMonth > 0
|
||||||
|
? Math.round((sharedBusinessOpps.length / totalEnquiriesMonth) * 100) : 0;
|
||||||
|
|
||||||
|
// ── Revenue Contribution Tracker ──
|
||||||
|
const revenueContribution = thisMonthWonOpps.map(o => {
|
||||||
|
const leadOwner = teamUsers.find(u => u.id === o.creatorId)?.name || 'Unknown';
|
||||||
|
const closingOwner = teamUsers.find(u => u.id === (o.closingOwnerId || o.assignedTo))?.name || 'Unknown';
|
||||||
|
const isOwn = (o.closingOwnerId || o.assignedTo) === o.creatorId;
|
||||||
|
return {
|
||||||
|
value: o.value,
|
||||||
|
leadOwner,
|
||||||
|
closingOwner,
|
||||||
|
isOwn,
|
||||||
|
leadOwnerShare: isOwn ? o.value : o.value * 0.5,
|
||||||
|
closingOwnerShare: isOwn ? o.value : o.value * 0.5,
|
||||||
|
};
|
||||||
|
}).slice(0, 20); // Cap at 20 for display
|
||||||
|
|
||||||
|
return {
|
||||||
|
teamPerformance,
|
||||||
|
pipelineHealth,
|
||||||
|
conversionRatio: { thisMonth, lastMonth, quarter },
|
||||||
|
activitiesAnalysis,
|
||||||
|
strategicByType,
|
||||||
|
performanceWeightage,
|
||||||
|
performers: {
|
||||||
|
month: { top: topPerformers, under: underPerformers },
|
||||||
|
quarter: { top: topPerformersQuarter, under: underPerformersQuarter },
|
||||||
|
},
|
||||||
|
leadVsShared: {
|
||||||
|
own: {
|
||||||
|
count: ownBusinessOpps.length,
|
||||||
|
value: ownBusinessValue,
|
||||||
|
conversionRate: ownConvRate,
|
||||||
|
},
|
||||||
|
shared: {
|
||||||
|
count: sharedBusinessOpps.length,
|
||||||
|
value: sharedBusinessValue,
|
||||||
|
conversionRate: sharedConvRate,
|
||||||
|
},
|
||||||
|
totalEnquiries: totalEnquiriesMonth,
|
||||||
|
},
|
||||||
|
revenueContribution,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ async function bootstrap() {
|
||||||
app.useGlobalPipes(new ValidationPipe({ transform: true }));
|
app.useGlobalPipes(new ValidationPipe({ transform: true }));
|
||||||
app.useGlobalFilters(new GlobalExceptionFilter());
|
app.useGlobalFilters(new GlobalExceptionFilter());
|
||||||
app.useGlobalInterceptors(new RequestLoggerInterceptor());
|
app.useGlobalInterceptors(new RequestLoggerInterceptor());
|
||||||
|
// Enable graceful shutdown so Prisma can properly disconnect on restart/stop
|
||||||
|
app.enableShutdownHooks();
|
||||||
await app.listen(process.env.PORT ?? 3004, '0.0.0.0');
|
await app.listen(process.env.PORT ?? 3004, '0.0.0.0');
|
||||||
}
|
}
|
||||||
bootstrap();
|
bootstrap();
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,25 @@
|
||||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PrismaService extends PrismaClient implements OnModuleInit {
|
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||||
|
constructor() {
|
||||||
|
super({
|
||||||
|
datasources: {
|
||||||
|
db: {
|
||||||
|
url: process.env.DATABASE_URL,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Limit connection pool to avoid connection bloat
|
||||||
|
log: ['warn', 'error'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
await this.$connect();
|
await this.$connect();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy() {
|
||||||
|
await this.$disconnect();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue