changes till 01/06/2026

changes till 01/06/2026
main
Manu Krishna 2026-06-01 11:42:22 +05:30
parent d8a0920ed0
commit da5147a1b8
11 changed files with 2046 additions and 9 deletions

File diff suppressed because it is too large Load Diff

110
package-lock.json generated
View File

@ -22,6 +22,7 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3",
"date-fns": "^4.1.0",
"mysql2": "^3.22.4",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2",
@ -4186,6 +4187,15 @@
"dev": true,
"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": {
"version": "30.2.0",
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.2.0.tgz",
@ -5119,6 +5129,15 @@
"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": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@ -6071,6 +6090,15 @@
"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": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@ -6357,9 +6385,9 @@
}
},
"node_modules/iconv-lite": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.1.tgz",
"integrity": "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==",
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
@ -6552,6 +6580,12 @@
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"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": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
@ -7737,6 +7771,12 @@
"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": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@ -7747,6 +7787,21 @@
"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": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
@ -8055,6 +8110,40 @@
"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": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz",
@ -9180,6 +9269,21 @@
"dev": true,
"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": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",

View File

@ -33,6 +33,7 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3",
"date-fns": "^4.1.0",
"mysql2": "^3.22.4",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2",

View File

@ -204,8 +204,6 @@ model Opportunity {
closingOwnerId String?
demoOwnerId String?
isDemoDone Boolean @default(false)
closingProbability Int? @default(0)
expectedClosingTimeframe String?
activities Followup[]
user User @relation("opportunity_assignedToTouser", fields: [assignedTo], references: [id], map: "Opportunity_assignedTo_fkey")
client Client @relation(fields: [clientId], references: [id], map: "Opportunity_clientId_fkey")

168
scratch/compare_schemas.js Normal file
View File

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

36
scratch/fix_live_db.sql Normal file
View File

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

View File

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

View File

@ -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 { AuthGuard } from '@nestjs/passport';
@ -11,4 +11,18 @@ export class DashboardController {
getStats(@Request() req) {
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);
}
}

View File

@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { UsersService } from '../users/users.service';
import { user_role } from '@prisma/client';
import { startOfDay, startOfMonth } from 'date-fns';
import { startOfDay, startOfMonth, endOfMonth, startOfQuarter, endOfQuarter, subMonths } from 'date-fns';
@Injectable()
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,
};
}
}

View File

@ -15,6 +15,8 @@ async function bootstrap() {
app.useGlobalPipes(new ValidationPipe({ transform: true }));
app.useGlobalFilters(new GlobalExceptionFilter());
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');
}
bootstrap();

View File

@ -1,9 +1,25 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@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() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}