first push

first push on 05/05/2026
main
Manu Krishna 2026-05-05 14:48:48 +05:30
parent 71ca116ad8
commit 370c14f93a
141 changed files with 15778 additions and 0 deletions

58
.gitignore vendored Normal file
View File

@ -0,0 +1,58 @@
# compiled output
/dist
/node_modules
/build
# Logs
logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# temp directory
.temp
.tmp
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
/generated/prisma

4
.prettierrc Normal file
View File

@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

35
eslint.config.mjs Normal file
View File

@ -0,0 +1,35 @@
// @ts-check
import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
eslintPluginPrettierRecommended,
{
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
sourceType: 'commonjs',
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
"prettier/prettier": ["error", { endOfLine: "auto" }],
},
},
);

8
nest-cli.json Normal file
View File

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

10624
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

93
package.json Normal file
View File

@ -0,0 +1,93 @@
{
"name": "api",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.2",
"@nestjs/mapped-types": "*",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/schedule": "^6.1.3",
"@prisma/client": "^5.22.0",
"bcrypt": "^6.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3",
"date-fns": "^4.1.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"uuid": "^14.0.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@types/bcrypt": "^6.0.0",
"@types/cron": "^2.0.1",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^22.10.7",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.2",
"@types/uuid": "^10.0.0",
"dotenv": "^17.2.3",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^16.0.0",
"jest": "^30.0.0",
"prettier": "^3.4.2",
"prisma": "^5.22.0",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
},
"prisma": {
"seed": "ts-node prisma/seed.ts"
}
}

15
prisma.config.ts.bak Normal file
View File

@ -0,0 +1,15 @@
// This file was generated by Prisma, and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig } from "prisma/config";
console.log("DATABASE_URL:", process.env.DATABASE_URL);
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: process.env["DATABASE_URL"],
},
});

View File

@ -0,0 +1,202 @@
-- CreateTable
CREATE TABLE `User` (
`id` VARCHAR(191) NOT NULL,
`email` VARCHAR(191) NOT NULL,
`password` VARCHAR(191) NOT NULL,
`name` VARCHAR(191) NULL,
`role` ENUM('ADMIN', 'SALES_PERSON') NOT NULL DEFAULT 'SALES_PERSON',
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `User_email_key`(`email`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Attendance` (
`id` VARCHAR(191) NOT NULL,
`userId` VARCHAR(191) NOT NULL,
`date` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`checkInTime` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`checkOutTime` DATETIME(3) NULL,
`checkInLat` DOUBLE NULL,
`checkInLng` DOUBLE NULL,
`checkInLoc` VARCHAR(191) NULL,
`checkOutLat` DOUBLE NULL,
`checkOutLng` DOUBLE NULL,
`checkOutLoc` VARCHAR(191) NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Client` (
`id` VARCHAR(191) NOT NULL,
`name` VARCHAR(191) NOT NULL,
`phone` VARCHAR(191) NOT NULL,
`email` VARCHAR(191) NULL,
`address` VARCHAR(191) NULL,
`lat` DOUBLE NULL,
`lng` DOUBLE NULL,
`landmark` VARCHAR(191) NULL,
`status` ENUM('LEAD', 'PROSPECT', 'CUSTOMER', 'CLOSED') NOT NULL DEFAULT 'LEAD',
`assignedTo` VARCHAR(191) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Meeting` (
`id` VARCHAR(191) NOT NULL,
`title` VARCHAR(191) NOT NULL,
`description` VARCHAR(191) NULL,
`date` DATETIME(3) NOT NULL,
`clientId` VARCHAR(191) NOT NULL,
`createdBy` VARCHAR(191) NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Product` (
`id` VARCHAR(191) NOT NULL,
`name` VARCHAR(191) NOT NULL,
`description` VARCHAR(191) NULL,
`price` DOUBLE NOT NULL,
`imageUrl` VARCHAR(191) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Enquiry` (
`id` VARCHAR(191) NOT NULL,
`clientId` VARCHAR(191) NOT NULL,
`userId` VARCHAR(191) NOT NULL,
`conversation` TEXT NULL,
`status` VARCHAR(191) NOT NULL DEFAULT 'OPEN',
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Followup` (
`id` VARCHAR(191) NOT NULL,
`clientId` VARCHAR(191) NOT NULL,
`enquiryId` VARCHAR(191) NULL,
`userId` VARCHAR(191) NOT NULL,
`date` DATETIME(3) NOT NULL,
`notes` VARCHAR(191) NULL,
`status` VARCHAR(191) NOT NULL DEFAULT 'PENDING',
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Expense` (
`id` VARCHAR(191) NOT NULL,
`userId` VARCHAR(191) NOT NULL,
`amount` DOUBLE NOT NULL,
`description` VARCHAR(191) NOT NULL,
`date` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`imageUrl` VARCHAR(191) NULL,
`status` ENUM('PENDING', 'APPROVED', 'REJECTED', 'REIMBURSED') NOT NULL DEFAULT 'PENDING',
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Incentive` (
`id` VARCHAR(191) NOT NULL,
`userId` VARCHAR(191) NOT NULL,
`targetAmount` DOUBLE NOT NULL,
`achievedAmount` DOUBLE NOT NULL DEFAULT 0,
`rewardAmount` DOUBLE NULL,
`type` ENUM('DAILY', 'MONTHLY') NOT NULL,
`startDate` DATETIME(3) NOT NULL,
`endDate` DATETIME(3) NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Quote` (
`id` VARCHAR(191) NOT NULL,
`enquiryId` VARCHAR(191) NOT NULL,
`userId` VARCHAR(191) NOT NULL,
`items` JSON NOT NULL,
`totalAmount` DOUBLE NOT NULL,
`status` ENUM('DRAFT', 'SENT', 'ACCEPTED', 'REJECTED') NOT NULL DEFAULT 'DRAFT',
`pdfUrl` VARCHAR(191) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `_EnquiryToProduct` (
`A` VARCHAR(191) NOT NULL,
`B` VARCHAR(191) NOT NULL,
UNIQUE INDEX `_EnquiryToProduct_AB_unique`(`A`, `B`),
INDEX `_EnquiryToProduct_B_index`(`B`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `Attendance` ADD CONSTRAINT `Attendance_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Client` ADD CONSTRAINT `Client_assignedTo_fkey` FOREIGN KEY (`assignedTo`) REFERENCES `User`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Meeting` ADD CONSTRAINT `Meeting_clientId_fkey` FOREIGN KEY (`clientId`) REFERENCES `Client`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Meeting` ADD CONSTRAINT `Meeting_createdBy_fkey` FOREIGN KEY (`createdBy`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Enquiry` ADD CONSTRAINT `Enquiry_clientId_fkey` FOREIGN KEY (`clientId`) REFERENCES `Client`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Enquiry` ADD CONSTRAINT `Enquiry_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Followup` ADD CONSTRAINT `Followup_clientId_fkey` FOREIGN KEY (`clientId`) REFERENCES `Client`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Followup` ADD CONSTRAINT `Followup_enquiryId_fkey` FOREIGN KEY (`enquiryId`) REFERENCES `Enquiry`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Followup` ADD CONSTRAINT `Followup_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Expense` ADD CONSTRAINT `Expense_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Incentive` ADD CONSTRAINT `Incentive_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Quote` ADD CONSTRAINT `Quote_enquiryId_fkey` FOREIGN KEY (`enquiryId`) REFERENCES `Enquiry`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Quote` ADD CONSTRAINT `Quote_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `_EnquiryToProduct` ADD CONSTRAINT `_EnquiryToProduct_A_fkey` FOREIGN KEY (`A`) REFERENCES `Enquiry`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `_EnquiryToProduct` ADD CONSTRAINT `_EnquiryToProduct_B_fkey` FOREIGN KEY (`B`) REFERENCES `Product`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "mysql"

450
prisma/schema.prisma Normal file
View File

@ -0,0 +1,450 @@
generator client {
provider = "prisma-client-js"
engineType = "library"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
model Attendance {
id String @id @default(uuid())
userId String
date DateTime @default(now())
checkInTime DateTime @default(now())
checkOutTime DateTime?
checkInLat Float?
checkInLng Float?
checkInLoc String?
checkOutLat Float?
checkOutLng Float?
checkOutLoc String?
user User @relation(fields: [userId], references: [id], map: "Attendance_userId_fkey")
@@index([userId], map: "Attendance_userId_fkey")
@@map("attendance")
}
model Client {
id String @id @default(uuid())
name String
phone String
email String?
address String?
lat Float?
lng Float?
landmark String?
status client_status @default(LEAD)
assignedTo String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User? @relation(fields: [assignedTo], references: [id], map: "Client_assignedTo_fkey")
enquiries Enquiry[]
followups Followup[]
meetings Meeting[]
opportunities Opportunity[]
@@index([assignedTo], map: "Client_assignedTo_fkey")
@@map("client")
}
model Enquiry {
id String @id @default(uuid())
clientId String
userId String
conversation String? @db.Text
status String @default("OPEN")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
client Client @relation(fields: [clientId], references: [id], map: "Enquiry_clientId_fkey")
user User @relation(fields: [userId], references: [id], map: "Enquiry_userId_fkey")
followups Followup[]
quotes Quote[]
products Product[] @relation("enquirytoproduct")
@@index([clientId], map: "Enquiry_clientId_fkey")
@@index([userId], map: "Enquiry_userId_fkey")
@@map("enquiry")
}
model Expense {
id String @id @default(uuid())
userId String
amount Float
description String
date DateTime @default(now())
imageUrl String?
status expense_status @default(PENDING)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], map: "Expense_userId_fkey")
@@index([userId], map: "Expense_userId_fkey")
@@map("expense")
}
model Followup {
id String @id @default(uuid())
clientId String
enquiryId String?
userId String
date DateTime
notes String?
status String @default("PENDING")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
contentType String?
stage followup_stage @default(LEAD)
client Client @relation(fields: [clientId], references: [id], map: "Followup_clientId_fkey")
enquiry Enquiry? @relation(fields: [enquiryId], references: [id], map: "Followup_enquiryId_fkey")
user User @relation(fields: [userId], references: [id], map: "Followup_userId_fkey")
@@index([clientId], map: "Followup_clientId_fkey")
@@index([enquiryId], map: "Followup_enquiryId_fkey")
@@index([userId], map: "Followup_userId_fkey")
@@map("followup")
}
model Incentive {
id String @id @default(uuid())
userId String
targetAmount Float
achievedAmount Float @default(0)
rewardAmount Float?
type incentive_type
startDate DateTime
endDate DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], map: "Incentive_userId_fkey")
@@index([userId], map: "Incentive_userId_fkey")
@@map("incentive")
}
model Location {
id String @id @default(uuid())
userId String
lat Float
lng Float
timestamp DateTime @default(now())
user User @relation(fields: [userId], references: [id], map: "Location_userId_fkey")
@@index([userId], map: "Location_userId_fkey")
@@map("location")
}
model Meeting {
id String @id @default(uuid())
title String
description String?
date DateTime
clientId String
createdBy String
createdAt DateTime @default(now())
client Client @relation(fields: [clientId], references: [id], map: "Meeting_clientId_fkey")
user User @relation(fields: [createdBy], references: [id], map: "Meeting_createdBy_fkey")
@@index([clientId], map: "Meeting_clientId_fkey")
@@index([createdBy], map: "Meeting_createdBy_fkey")
@@map("meeting")
}
model Opportunity {
id String @id @default(uuid())
title String
value Float
stage opportunity_stage @default(LEAD)
priority String?
clientId String
assignedTo String
expectedCloseDate DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
competitorMention String?
demoContactDetails String?
demoPersonName String?
freeOffers String?
keyQueries String?
negotiationRemarks String?
objections String?
paymentMode String?
specialRate Float?
creatorId String?
demoOwnerId String?
closingOwnerId String?
user User @relation("opportunity_assignedToTouser", fields: [assignedTo], references: [id], map: "Opportunity_assignedTo_fkey")
client Client @relation(fields: [clientId], references: [id], map: "Opportunity_clientId_fkey")
creator User? @relation("opportunity_creatorIdTouser", fields: [creatorId], references: [id], map: "Opportunity_creatorId_fkey")
demoOwner User? @relation("opportunity_demoOwnerIdTouser", fields: [demoOwnerId], references: [id], map: "Opportunity_demoOwnerId_fkey")
closingOwner User? @relation("opportunity_closingOwnerIdTouser", fields: [closingOwnerId], references: [id], map: "Opportunity_closingOwnerId_fkey")
workOrders WorkOrder[]
@@index([assignedTo], map: "Opportunity_assignedTo_fkey")
@@index([clientId], map: "Opportunity_clientId_fkey")
@@index([creatorId], map: "Opportunity_creatorId_fkey")
@@index([demoOwnerId], map: "Opportunity_demoOwnerId_fkey")
@@index([closingOwnerId], map: "Opportunity_closingOwnerId_fkey")
@@map("opportunity")
}
model Payment {
id String @id @default(uuid())
workOrderId String
amount Float
mode String?
status String @default("PENDING")
paymentLink String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], map: "Payment_workOrderId_fkey")
@@index([workOrderId], map: "Payment_workOrderId_fkey")
@@map("payment")
}
model PerformanceScore {
id String @id @default(uuid())
userId String
score Float @default(0)
revenueScore Float @default(0)
conversionScore Float @default(0)
activityScore Float @default(0)
disciplineScore Float @default(0)
dataQualityScore Float @default(0)
tag String @default("ON_TRACK")
date DateTime @default(now())
user User @relation(fields: [userId], references: [id], map: "PerformanceScore_userId_fkey")
@@index([userId], map: "PerformanceScore_userId_fkey")
@@map("performancescore")
}
model Product {
id String @id @default(uuid())
name String
description String?
price Float
imageUrl String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
enquiries Enquiry[] @relation("enquirytoproduct")
@@map("product")
}
model Quote {
id String @id @default(uuid())
enquiryId String
userId String
items String @db.LongText
totalAmount Float
status quote_status @default(DRAFT)
pdfUrl String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
enquiry Enquiry @relation(fields: [enquiryId], references: [id], map: "Quote_enquiryId_fkey")
user User @relation(fields: [userId], references: [id], map: "Quote_userId_fkey")
@@index([enquiryId], map: "Quote_enquiryId_fkey")
@@index([userId], map: "Quote_userId_fkey")
@@map("quote")
}
model StrategicActivity {
id String @id @default(uuid())
userId String
type String
description String?
leadsGenerated Int @default(0)
revenueGenerated Float @default(0)
date DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
metadata String? @db.LongText
user User @relation(fields: [userId], references: [id], map: "StrategicActivity_userId_fkey")
@@index([userId], map: "StrategicActivity_userId_fkey")
@@map("strategicactivity")
}
model Target {
id String @id @default(uuid())
userId String
month Int
year Int
monthlyTarget Float
minTarget Float
weeklyTarget Float?
dailyLeadTarget Int?
requiredLeads Int?
requiredQualityLeads Int?
requiredPotential Int?
requiredDemos Int?
requiredClosures Int?
closureRatio Float?
avgDealValue Float?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], map: "Target_userId_fkey")
@@index([userId], map: "Target_userId_fkey")
@@map("target")
}
model Notification {
id String @id @default(uuid())
userId String
title String
body String @db.Text
type String @default("INFO")
isRead Boolean @default(false)
metadata String? @db.LongText
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], map: "Notification_userId_fkey")
@@index([userId], map: "Notification_userId_fkey")
@@map("notification")
}
model SystemConfig {
id String @id @default(uuid())
key String @unique
value String @db.Text
updatedAt DateTime @updatedAt
@@map("systemconfig")
}
model User {
id String @id @default(uuid())
email String @unique(map: "User_email_key")
password String
name String?
role user_role @default(TELESALES_EXECUTIVE)
status user_status @default(APPROVED)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
managerId String?
attendance Attendance[]
clients Client[]
enquiries Enquiry[]
expenses Expense[]
followups Followup[]
incentives Incentive[]
locations Location[]
meetings Meeting[]
opportunities Opportunity[] @relation("opportunity_assignedToTouser")
createdOpportunities Opportunity[] @relation("opportunity_creatorIdTouser")
demoOpportunities Opportunity[] @relation("opportunity_demoOwnerIdTouser")
closedOpportunities Opportunity[] @relation("opportunity_closingOwnerIdTouser")
performanceScores PerformanceScore[]
quotes Quote[]
strategicActivities StrategicActivity[]
targets Target[]
notifications Notification[]
manager User? @relation("userTouser", fields: [managerId], references: [id], map: "User_managerId_fkey")
subordinates User[] @relation("userTouser")
@@index([managerId], map: "User_managerId_fkey")
@@map("user")
}
model WorkOrder {
id String @id @default(uuid())
opportunityId String
status String @default("PENDING")
details String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
payments Payment[]
opportunity Opportunity @relation(fields: [opportunityId], references: [id], map: "WorkOrder_opportunityId_fkey")
@@index([opportunityId], map: "WorkOrder_opportunityId_fkey")
@@map("workorder")
}
enum opportunity_stage {
LEAD
QUALIFIED
POTENTIAL
DEMO
WON
LOST
}
enum user_role {
ADMIN
GENERAL_MANAGER
MANAGER
OFFICER
TELESALES_EXECUTIVE
}
enum user_status {
PENDING
APPROVED
REJECTED
}
enum incentive_type {
DAILY
MONTHLY
}
enum quote_status {
DRAFT
SENT
ACCEPTED
REJECTED
}
enum expense_status {
PENDING
APPROVED
REJECTED
REIMBURSED
}
enum client_status {
LEAD
QUALITY
POTENTIAL
DEMO
SALES
CLOSED
}
enum followup_stage {
LEAD
QUALITY
POTENTIAL
DEMO
SALES
CLOSED
}
model WhatsappTemplate {
id String @id @default(uuid())
type String @unique // e.g., 'GREETING', 'QUOTE', 'NUDGE'
templateName String // The tempid/name used by the external API
language String @default("en")
body String? @db.Text // Content with placeholders for reference
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("whatsapp_template")
}
model WhatsappLog {
id String @id @default(uuid())
recipient String
templateName String
payload String? @db.Text
response String? @db.Text
status String @default("SENT")
createdAt DateTime @default(now())
@@map("whatsapp_log")
}

30
prisma/seed.ts Normal file
View File

@ -0,0 +1,30 @@
import { PrismaClient } from '@prisma/client';
import * as bcrypt from 'bcrypt';
const prisma = new PrismaClient();
async function main() {
const password = await bcrypt.hash('password', 10);
const user = await prisma.user.upsert({
where: { email: 'admin@igcrm.com' },
update: {},
create: {
id: 'd9b0a1d0-1e1e-4b1e-8e1e-1e1e1e1e1e1e', // Fixed ID for admin
email: 'admin@igcrm.com',
name: 'Admin User',
password,
role: 'ADMIN',
updatedAt: new Date(),
},
});
console.log('User created:', user);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

21
prisma/seed_whatsapp.sql Normal file
View File

@ -0,0 +1,21 @@
-- Seed WhatsApp Templates for Igcrm (Updated with user preference)
-- 1. Onboarding Template
INSERT INTO whatsapp_template (id, type, templateName, language, body)
VALUES (UUID(), 'ONBOARDING', 'igcrm_membership_activation', 'en', 'Membership activation has been completed for {{1}}. Your Member ID {{2}} is registered with {{3}}. Powered By Ignosi')
ON DUPLICATE KEY UPDATE templateName = 'igcrm_membership_activation', body = 'Membership activation has been completed for {{1}}. Your Member ID {{2}} is registered with {{3}}. Powered By Ignosi';
-- 2. Followup Template
INSERT INTO whatsapp_template (id, type, templateName, language, body)
VALUES (UUID(), 'FOLLOWUP', 'igcrm_followup_reminder', 'en', 'Hi {{1}}, this is a reminder for your scheduled follow-up on {{2}}.')
ON DUPLICATE KEY UPDATE templateName = 'igcrm_followup_reminder';
-- 3. Pipeline Update Template
INSERT INTO whatsapp_template (id, type, templateName, language, body)
VALUES (UUID(), 'PIPELINE_UPDATE', 'igcrm_stage_update', 'en', 'Hi {{1}}, your inquiry has moved from {{2}} to {{3}}.')
ON DUPLICATE KEY UPDATE templateName = 'igcrm_stage_update';
-- 4. Quote Template
INSERT INTO whatsapp_template (id, type, templateName, language, body)
VALUES (UUID(), 'QUOTE', 'igcrm_quote_created', 'en', 'Hi {{1}}, your quote for amount {{2}} has been generated.')
ON DUPLICATE KEY UPDATE templateName = 'igcrm_quote_created';

26
scratch/fix-db-raw.js Normal file
View File

@ -0,0 +1,26 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function main() {
console.log('Running raw SQL to fix statuses...');
try {
// Fix empty strings
const res1 = await prisma.$executeRawUnsafe("UPDATE Client SET status = 'LEAD' WHERE status = ''");
console.log('Fixed empty statuses:', res1);
// Fix 'CUSTOMER' if it exists (mapping it to SALES)
const res2 = await prisma.$executeRawUnsafe("UPDATE Client SET status = 'SALES' WHERE status = 'CUSTOMER'");
console.log('Fixed CUSTOMER statuses:', res2);
// Also check Opportunity stages
const res3 = await prisma.$executeRawUnsafe("UPDATE Opportunity SET stage = 'LEAD' WHERE stage = ''");
console.log('Fixed empty Opportunity stages:', res3);
} catch (e) {
console.error(e);
} finally {
await prisma.$disconnect();
}
}
main();

27
scratch/fix-db.js Normal file
View File

@ -0,0 +1,27 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function main() {
console.log('Fetching distinct statuses...');
try {
const statuses = await prisma.client.groupBy({
by: ['status'],
});
console.log('Current statuses in DB:', statuses);
// Update invalid statuses
// If status is empty string or 'CUSTOMER', change to 'SALES' or 'LEAD'
const results = await prisma.client.updateMany({
where: { status: '' },
data: { status: 'LEAD' }
});
console.log('Updated empty statuses:', results.count);
} catch (e) {
console.error(e);
} finally {
await prisma.$disconnect();
}
}
main();

73
scratch/sync-leads.js Normal file
View File

@ -0,0 +1,73 @@
const { PrismaClient } = require('@prisma/client');
const { v4: uuidv4 } = require('uuid');
const prisma = new PrismaClient();
async function syncLeadsToPipeline() {
console.log('--- Syncing Quality/Potential Clients to Pipeline ---');
// Find clients with statuses that should be in the pipeline
const clients = await prisma.client.findMany({
where: {
status: { in: ['QUALITY', 'POTENTIAL', 'DEMO', 'SALES', 'CLOSED'] }
},
include: {
opportunities: true
}
});
console.log(`Found ${clients.length} clients with relevant status.`);
const stageMap = {
'QUALITY': 'QUALIFIED',
'POTENTIAL': 'POTENTIAL',
'DEMO': 'DEMO',
'SALES': 'WON',
'CLOSED': 'WON'
};
let createdCount = 0;
let updatedCount = 0;
for (const client of clients) {
const targetStage = stageMap[client.status];
if (!targetStage) continue;
// Check if they already have an active opportunity
const activeOpp = client.opportunities.find(o => !['WON', 'LOST'].includes(o.stage));
if (activeOpp) {
if (activeOpp.stage !== targetStage) {
await prisma.opportunity.update({
where: { id: activeOpp.id },
data: { stage: targetStage }
});
updatedCount++;
}
} else {
// Check if they have a WON opportunity if status is SALES/CLOSED
const wonOpp = client.opportunities.find(o => o.stage === 'WON');
if (wonOpp) continue;
// Create new opportunity
await prisma.opportunity.create({
data: {
id: uuidv4(),
title: `${client.name} - ${client.status}`,
value: 0,
clientId: client.id,
assignedTo: client.assignedTo || '805c8cf3-db7c-47c3-b42e-588531ba89a1', // Default to Admin if no owner
stage: targetStage,
updatedAt: new Date()
}
});
createdCount++;
}
}
console.log(`Finished: Created ${createdCount} and updated ${updatedCount} opportunities.`);
}
syncLeadsToPipeline()
.catch(e => console.error(e))
.finally(() => prisma.$disconnect());

View File

@ -0,0 +1,22 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
appController = app.get<AppController>(AppController);
});
describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
});
});
});

12
src/app.controller.ts Normal file
View File

@ -0,0 +1,12 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
}

63
src/app.module.ts Normal file
View File

@ -0,0 +1,63 @@
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { UsersModule } from './users/users.module';
import { AuthModule } from './auth/auth.module';
import { PrismaModule } from './prisma/prisma.module';
import { AttendanceModule } from './attendance/attendance.module';
import { ClientsModule } from './clients/clients.module';
import { MeetingsModule } from './meetings/meetings.module';
import { WhatsappModule } from './whatsapp/whatsapp.module';
import { ConfigModule } from '@nestjs/config';
import { ProductsModule } from './products/products.module';
import { EnquiriesModule } from './enquiries/enquiries.module';
import { ExpensesModule } from './expenses/expenses.module';
import { IncentivesModule } from './incentives/incentives.module';
import { QuotesModule } from './quotes/quotes.module';
import { FollowupsModule } from './followups/followups.module';
import { UploadModule } from './upload/upload.module';
import { OpportunitiesModule } from './opportunities/opportunities.module';
import { DashboardModule } from './dashboard/dashboard.module';
import { ReportsModule } from './reports/reports.module';
import { LocationsModule } from './locations/locations.module';
import { TargetsModule } from './targets/targets.module';
import { PerformanceModule } from './performance/performance.module';
import { ScheduleModule } from '@nestjs/schedule';
import { AutomationModule } from './automation/automation.module';
import { StrategicActivitiesModule } from './strategic-activities/strategic-activities.module';
import { WorkOrdersModule } from './work-orders/work-orders.module';
import { NotificationsModule } from './notifications/notifications.module';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
ScheduleModule.forRoot(),
UsersModule,
AuthModule,
PrismaModule,
AttendanceModule,
ClientsModule,
MeetingsModule,
WhatsappModule,
ProductsModule,
EnquiriesModule,
ExpensesModule,
IncentivesModule,
QuotesModule,
FollowupsModule,
UploadModule,
OpportunitiesModule,
DashboardModule,
ReportsModule,
LocationsModule,
TargetsModule,
PerformanceModule,
AutomationModule,
StrategicActivitiesModule,
WorkOrdersModule,
NotificationsModule,
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule { }

8
src/app.service.ts Normal file
View File

@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}

View File

@ -0,0 +1,36 @@
import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards, Request, Query } from '@nestjs/common';
import { AttendanceService } from './attendance.service';
import { CreateAttendanceDto } from './dto/create-attendance.dto';
import { UpdateAttendanceDto } from './dto/update-attendance.dto';
import { AuthGuard } from '@nestjs/passport';
@Controller('attendance')
@UseGuards(AuthGuard('jwt'))
export class AttendanceController {
constructor(private readonly attendanceService: AttendanceService) { }
@Post('check-in')
checkIn(@Request() req, @Body() createAttendanceDto: CreateAttendanceDto) {
return this.attendanceService.checkIn(req.user.userId, createAttendanceDto);
}
@Patch('check-out/:id')
checkOut(@Param('id') id: string, @Body() updateAttendanceDto: UpdateAttendanceDto) {
return this.attendanceService.checkOut(id, updateAttendanceDto);
}
@Get('my-history')
getMyHistory(@Request() req) {
return this.attendanceService.findMyHistory(req.user.userId);
}
@Get()
findAll(@Request() req, @Query('date') date?: string) {
return this.attendanceService.findAll(req.user, date);
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.attendanceService.findOne(id);
}
}

View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { AttendanceService } from './attendance.service';
import { AttendanceController } from './attendance.controller';
import { UsersModule } from '../users/users.module';
@Module({
imports: [UsersModule],
controllers: [AttendanceController],
providers: [AttendanceService],
})
export class AttendanceModule {}

View File

@ -0,0 +1,95 @@
import { Injectable } from '@nestjs/common';
import { CreateAttendanceDto } from './dto/create-attendance.dto';
import { UpdateAttendanceDto } from './dto/update-attendance.dto';
import { PrismaService } from '../prisma/prisma.service';
import { UsersService } from '../users/users.service';
import { user_role } from '@prisma/client';
@Injectable()
export class AttendanceService {
constructor(
private prisma: PrismaService,
private usersService: UsersService
) { }
private getDateRange(dateStr?: string) {
const start = dateStr ? new Date(dateStr) : new Date();
start.setHours(0, 0, 0, 0);
const end = new Date(start);
end.setHours(23, 59, 59, 999);
return { start, end };
}
async checkIn(userId: string, createAttendanceDto: CreateAttendanceDto) {
// Check if already checked in today? (Optional enhancement)
return this.prisma.attendance.create({
data: {
userId,
checkInLat: createAttendanceDto.latitude,
checkInLng: createAttendanceDto.longitude,
checkInLoc: createAttendanceDto.address,
checkInTime: new Date(),
},
});
}
async checkOut(id: string, updateAttendanceDto: UpdateAttendanceDto) {
return this.prisma.attendance.update({
where: { id },
data: {
checkOutLat: updateAttendanceDto.latitude,
checkOutLng: updateAttendanceDto.longitude,
checkOutLoc: updateAttendanceDto.address,
checkOutTime: new Date(),
}
})
}
async findAll(user: any, date?: string) {
const { start, end } = this.getDateRange(date);
if (user.role === user_role.ADMIN) {
return this.prisma.attendance.findMany({
where: {
checkInTime: { gte: start, lte: end }
},
include: { user: true },
orderBy: { checkInTime: 'desc' }
});
}
const subordinateIds = await this.usersService.getSubordinateIds(user.id);
const allowedUserIds = [user.id, ...subordinateIds];
return this.prisma.attendance.findMany({
where: {
userId: { in: allowedUserIds },
checkInTime: { gte: start, lte: end }
},
include: { user: true },
orderBy: { checkInTime: 'desc' }
});
}
findOne(id: string) {
return this.prisma.attendance.findUnique({ where: { id }, include: { user: true } });
}
// Find active check-in for user
findActiveCheckIn(userId: string) {
// Logic to find if user has a check-in without check-out today
// For simplicity, returning the latest one
return this.prisma.attendance.findFirst({
where: { userId, checkOutTime: null },
orderBy: { checkInTime: 'desc' }
});
}
findMyHistory(userId: string) {
return this.prisma.attendance.findMany({
where: { userId },
orderBy: { checkInTime: 'desc' },
take: 30 // Last 30 records for now
});
}
}

View File

@ -0,0 +1,16 @@
import { IsNotEmpty, IsNumber, IsOptional } from 'class-validator';
export class CreateAttendanceDto {
@IsNumber()
@IsNotEmpty()
latitude: number;
@IsNumber()
@IsNotEmpty()
@IsNumber()
@IsNotEmpty()
longitude: number;
@IsOptional()
address?: string;
}

View File

@ -0,0 +1,9 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateAttendanceDto } from './create-attendance.dto';
import { IsDateString, IsOptional } from 'class-validator';
export class UpdateAttendanceDto extends PartialType(CreateAttendanceDto) {
@IsOptional()
@IsDateString()
checkOutTime?: string;
}

View File

@ -0,0 +1 @@
export class Attendance {}

View File

@ -0,0 +1,26 @@
import { Controller, Request, Post, UseGuards, Body, UnauthorizedException } from '@nestjs/common';
import { AuthService } from './auth.service';
import { UsersService } from '../users/users.service';
import { CreateUserDto } from '../users/dto/create-user.dto';
@Controller('auth')
export class AuthController {
constructor(
private authService: AuthService,
private usersService: UsersService
) { }
@Post('login')
async login(@Body() req) {
const user = await this.authService.validateUser(req.email, req.password);
if (!user) {
throw new UnauthorizedException();
}
return this.authService.login(user);
}
@Post('register')
async register(@Body() createUserDto: CreateUserDto) {
return this.usersService.create(createUserDto);
}
}

23
src/auth/auth.module.ts Normal file
View File

@ -0,0 +1,23 @@
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { UsersModule } from '../users/users.module';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { jwtConstants } from './constants';
import { JwtStrategy } from './jwt.strategy';
@Module({
imports: [
UsersModule,
PassportModule,
JwtModule.register({
secret: jwtConstants.secret,
signOptions: { expiresIn: '7d' },
}),
],
providers: [AuthService, JwtStrategy],
controllers: [AuthController],
exports: [AuthService],
})
export class AuthModule { }

35
src/auth/auth.service.ts Normal file
View File

@ -0,0 +1,35 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { UsersService } from '../users/users.service';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
@Injectable()
export class AuthService {
constructor(
private usersService: UsersService,
private jwtService: JwtService,
) { }
async validateUser(email: string, pass: string): Promise<any> {
const user = await this.usersService.findByEmail(email);
if (user && (await bcrypt.compare(pass, user.password))) {
if ((user as any).status === 'PENDING') {
throw new UnauthorizedException('Account pending admin approval');
}
if ((user as any).status === 'REJECTED') {
throw new UnauthorizedException('Account approval rejected');
}
const { password, ...result } = user;
return result;
}
return null;
}
async login(user: any) {
const payload = { email: user.email, sub: user.id, role: user.role };
return {
access_token: this.jwtService.sign(payload),
user,
};
}
}

3
src/auth/constants.ts Normal file
View File

@ -0,0 +1,3 @@
export class jwtConstants {
static secret = 'secretKey'; // In production, use environment variable
}

19
src/auth/jwt.strategy.ts Normal file
View File

@ -0,0 +1,19 @@
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
import { jwtConstants } from './constants';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: jwtConstants.secret,
});
}
async validate(payload: any) {
return { id: payload.sub, userId: payload.sub, email: payload.email, role: payload.role };
}
}

View File

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { AutomationService } from './automation.service';
import { PrismaModule } from '../prisma/prisma.module';
import { WhatsappModule } from '../whatsapp/whatsapp.module';
@Module({
imports: [PrismaModule, WhatsappModule],
providers: [AutomationService],
})
export class AutomationModule {}

View File

@ -0,0 +1,65 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service';
import { WhatsappService } from '../whatsapp/whatsapp.service';
import { differenceInDays } from 'date-fns';
@Injectable()
export class AutomationService {
private readonly logger = new Logger(AutomationService.name);
constructor(
private prisma: PrismaService,
private whatsapp: WhatsappService,
) {}
@Cron(CronExpression.EVERY_DAY_AT_9AM)
async handleDailyAutomation() {
this.logger.log('Running Daily Follow-up Automation...');
await Promise.all([
this.processPhase1Nudges(),
this.processEscalations(),
]);
}
private async processPhase1Nudges() {
const leads = await this.prisma.client.findMany({
where: { status: 'LEAD' },
include: { user: true },
});
for (const lead of leads) {
const age = differenceInDays(new Date(), lead.createdAt);
let message = '';
if (age === 1) message = `Hi ${lead.name}, thanks for inquiring with Ignosi! Here is a quick intro to our services.`;
else if (age === 2) message = `Hi ${lead.name}, did you know that our CRM can increase your sales by 30%? Check this video.`;
else if (age === 3) message = `Hi ${lead.name}, here is what our customers say about us. [Testimonial Link]`;
else if (age === 5) message = `Still looking for a solution, ${lead.name}? We have a special offer for you today.`;
else if (age === 7) message = `Final follow-up, ${lead.name}. Would you like a free demo this week?`;
if (message && lead.phone) {
this.logger.log(`Triggering Day ${age} nudge for ${lead.name}`);
// await this.whatsapp.sendInternalMessage(lead.phone, message); // Placeholder for actual implementation
}
}
}
private async processEscalations(){
const delayedDemos = await this.prisma.opportunity.findMany({
where: {
stage: 'DEMO',
updatedAt: { lte: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000) },
},
include: { user: { include: { manager: true } } },
});
for (const opp of delayedDemos) {
if (opp.user?.manager?.email) {
this.logger.warn(`Escalating delayed demo: ${opp.title} for user ${opp.user.name}`);
// Send alert to manager via Email/WhatsApp
}
}
}
}

View File

@ -0,0 +1,39 @@
import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards, Request } from '@nestjs/common';
import { ClientsService } from './clients.service';
import { CreateClientDto } from './dto/create-client.dto';
import { UpdateClientDto } from './dto/update-client.dto';
import { AuthGuard } from '@nestjs/passport';
@Controller('clients')
@UseGuards(AuthGuard('jwt'))
export class ClientsController {
constructor(private readonly clientsService: ClientsService) { }
@Post()
create(@Request() req, @Body() createClientDto: CreateClientDto) {
if (!createClientDto.assignedTo) {
createClientDto.assignedTo = req.user.id;
}
return this.clientsService.create(createClientDto);
}
@Get()
findAll(@Request() req) {
return this.clientsService.findAll(req.user);
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.clientsService.findOne(id);
}
@Patch(':id')
update(@Param('id') id: string, @Body() updateClientDto: UpdateClientDto) {
return this.clientsService.update(id, updateClientDto);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.clientsService.remove(id);
}
}

View File

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { ClientsService } from './clients.service';
import { ClientsController } from './clients.controller';
import { PrismaModule } from '../prisma/prisma.module';
import { UsersModule } from '../users/users.module';
import { WhatsappModule } from '../whatsapp/whatsapp.module';
@Module({
imports: [PrismaModule, UsersModule, WhatsappModule],
controllers: [ClientsController],
providers: [ClientsService],
})
export class ClientsModule { }

View File

@ -0,0 +1,70 @@
import { Injectable } from '@nestjs/common';
import { CreateClientDto } from './dto/create-client.dto';
import { UpdateClientDto } from './dto/update-client.dto';
import { PrismaService } from '../prisma/prisma.service';
import { UsersService } from '../users/users.service';
import { WhatsappService } from '../whatsapp/whatsapp.service';
import { user_role, client_status } from '@prisma/client';
@Injectable()
export class ClientsService {
constructor(
private prisma: PrismaService,
private usersService: UsersService,
private whatsappService: WhatsappService
) { }
async create(createClientDto: CreateClientDto) {
console.log('API Received Client DTO:', JSON.stringify(createClientDto, null, 2));
const client = await this.prisma.client.create({
data: createClientDto
});
// Trigger onboarding message if client is created with SALES status
if (client.status === client_status.SALES || client.status === client_status.CLOSED) {
try {
await this.whatsappService.sendOnboarding(client.phone, client.name, client.id.substring(0, 8));
} catch (err) {
console.error('Failed to send onboarding WhatsApp:', err.message);
}
}
return client;
}
async findAll(user: any) {
if (user.role === user_role.ADMIN) {
return this.prisma.client.findMany({
include: { user: true }
});
}
const subordinateIds = await this.usersService.getSubordinateIds(user.id);
const allowedUserIds = [user.id, ...subordinateIds];
return this.prisma.client.findMany({
where: {
assignedTo: { in: allowedUserIds }
},
include: { user: true }
});
}
findOne(id: string) {
return this.prisma.client.findUnique({
where: { id },
include: { user: true, meetings: true }
});
}
update(id: string, updateClientDto: UpdateClientDto) {
return this.prisma.client.update({
where: { id },
data: updateClientDto
});
}
remove(id: string) {
return this.prisma.client.delete({ where: { id } });
}
}

View File

@ -0,0 +1,40 @@
import { IsEnum, IsString, IsNotEmpty, IsOptional, IsEmail, IsNumber } from 'class-validator';
import { client_status } from '@prisma/client';
export class CreateClientDto {
@IsString()
@IsNotEmpty()
name: string;
@IsString()
@IsNotEmpty()
phone: string;
@IsEmail()
@IsOptional()
email?: string;
@IsString()
@IsOptional()
address?: string;
@IsEnum(client_status)
@IsOptional()
status?: client_status;
@IsString()
@IsOptional()
assignedTo?: string; // User ID
@IsString()
@IsOptional()
landmark?: string;
@IsNumber()
@IsOptional()
lat?: number;
@IsNumber()
@IsOptional()
lng?: number;
}

View File

@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateClientDto } from './create-client.dto';
export class UpdateClientDto extends PartialType(CreateClientDto) { }

View File

@ -0,0 +1 @@
export class Client {}

View File

@ -0,0 +1,14 @@
import { Controller, Get, UseGuards, Request } from '@nestjs/common';
import { DashboardService } from './dashboard.service';
import { AuthGuard } from '@nestjs/passport';
@Controller('dashboard')
@UseGuards(AuthGuard('jwt'))
export class DashboardController {
constructor(private readonly dashboardService: DashboardService) { }
@Get('stats')
getStats(@Request() req) {
return this.dashboardService.getStats(req.user);
}
}

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { DashboardService } from './dashboard.service';
import { DashboardController } from './dashboard.controller';
import { PrismaModule } from '../prisma/prisma.module';
import { UsersModule } from '../users/users.module';
@Module({
imports: [PrismaModule, UsersModule],
controllers: [DashboardController],
providers: [DashboardService],
})
export class DashboardModule { }

View File

@ -0,0 +1,207 @@
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';
@Injectable()
export class DashboardService {
constructor(
private readonly prisma: PrismaService,
private readonly usersService: UsersService
) { }
async getStats(user: any) {
let allowedUserIds: string[] = [user.id];
if (user.role === user_role.ADMIN) {
// Admin sees everything, leave allowedUserIds empty or filter differently
// But for simplicity in query, we can use empty where if Admin
} else {
const subordinates = await this.usersService.getSubordinateIds(user.id);
allowedUserIds = [user.id, ...subordinates];
}
const userFilter = user.role === user_role.ADMIN ? {} : { userId: { in: allowedUserIds } };
const leadSharingFilter = user.role === user_role.ADMIN ? {} : { OR: [{ assignedTo: { in: allowedUserIds } }, { creatorId: { in: allowedUserIds } }] };
const clientFilter = user.role === user_role.ADMIN ? {} : { assignedTo: { in: allowedUserIds } };
const today = startOfDay(new Date());
const monthStart = startOfMonth(new Date());
const [
enquiriesCount,
enquiriesToday,
opportunityStats,
monthlyRevenue,
clientsCount,
recentEnquiries,
recentOpportunities,
pendingExpenses,
overdueFollowups,
todayFollowups,
newItems
] = await Promise.all([
// Total Enquiries
this.prisma.enquiry.count({ where: userFilter }),
// Enquiries Today
this.prisma.enquiry.count({
where: {
...userFilter,
createdAt: { gte: today }
}
}),
// Pipeline Stats (Non-WON)
this.prisma.opportunity.aggregate({
where: {
...leadSharingFilter,
stage: { not: 'WON' }
},
_sum: { value: true },
_count: true
}),
// Monthly Revenue (WON this month)
this.prisma.opportunity.aggregate({
where: {
...leadSharingFilter,
stage: 'WON',
updatedAt: { gte: monthStart }
},
_sum: { value: true }
}),
// Total Customers (for conversion rate)
this.prisma.client.count({
where: {
...clientFilter,
status: { in: ['SALES', 'CLOSED'] }
}
}),
// Recent Activity - Enquiries
this.prisma.enquiry.findMany({
where: userFilter,
take: 5,
orderBy: { createdAt: 'desc' },
include: { client: true, user: true }
}),
// Recent Activity - Opportunities
this.prisma.opportunity.findMany({
where: leadSharingFilter,
take: 5,
orderBy: { updatedAt: 'desc' },
include: { client: true, user: true }
}),
// Pending Expenses (Managers/Admins)
this.prisma.expense.count({
where: {
...userFilter,
status: 'PENDING'
}
}),
// Overdue Followups
this.prisma.followup.count({
where: {
...userFilter,
status: 'PENDING',
date: { lt: today }
}
}),
// Today's Followups
this.prisma.followup.count({
where: {
...userFilter,
status: 'PENDING',
date: { gte: today, lt: new Date(today.getTime() + 86400000) }
}
}),
// New Leads/Opportunities (Last 24h)
this.prisma.opportunity.count({
where: {
...leadSharingFilter,
createdAt: { gte: new Date(Date.now() - 24 * 60 * 60 * 1000) }
}
})
]);
// Fetch Latest Performance Score
const latestScore = await this.prisma.performanceScore.findFirst({
where: { userId: user.id },
orderBy: { date: 'desc' }
});
// Fetch Current Target
const target = await this.prisma.target.findFirst({
where: { userId: user.id, month: new Date().getMonth() + 1, year: new Date().getFullYear() }
});
// Calculate Contribution Revenue (50/50 Split)
const wonOpportunities = await this.prisma.opportunity.findMany({
where: {
...leadSharingFilter,
stage: 'WON',
updatedAt: { gte: monthStart }
},
select: { value: true, assignedTo: true, creatorId: true }
});
const contributionRevenue = wonOpportunities.reduce((total, o) => {
let share = 0;
const isCloser = o.assignedTo === user.id;
const isCreator = o.creatorId === user.id;
if (isCloser && isCreator) share = o.value;
else if (isCloser || isCreator) share = o.value * 0.5;
return total + share;
}, 0);
// Calculate Conversion Rate
const conversionRate = enquiriesCount > 0
? Math.round((clientsCount / enquiriesCount) * 100)
: 0;
return {
kpis: {
enquiriesToday,
pipelineValue: opportunityStats._sum.value || 0,
pipelineCount: opportunityStats._count || 0,
monthlyRevenue: monthlyRevenue._sum.value || 0,
contributionRevenue,
conversionRate,
pendingExpenses: (user.role === user_role.ADMIN || [user_role.GENERAL_MANAGER, user_role.MANAGER, user_role.OFFICER].includes(user.role)) ? pendingExpenses : 0,
overdueCount: overdueFollowups,
todayCount: todayFollowups,
newCount: newItems
},
performance: latestScore ? {
score: latestScore.score,
tag: latestScore.tag,
breakdown: {
revenue: latestScore.revenueScore,
conversion: latestScore.conversionScore,
activity: latestScore.activityScore,
discipline: latestScore.disciplineScore,
quality: latestScore.dataQualityScore
}
} : null,
target: target ? {
monthly: target.monthlyTarget,
minimum: target.minTarget,
weekly: target.weeklyTarget,
dailyLead: target.dailyLeadTarget,
achieved: monthlyRevenue._sum.value || 0
} : null,
recentActivity: {
enquiries: recentEnquiries,
opportunities: recentOpportunities
}
};
}
}

View File

@ -0,0 +1,19 @@
import { IsArray, IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class CreateEnquiryDto {
@IsString()
@IsNotEmpty()
clientId: string;
@IsString()
@IsNotEmpty()
userId: string;
@IsString()
@IsOptional()
conversation?: string;
@IsArray()
@IsOptional()
productIds?: string[];
}

View File

@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateEnquiryDto } from './create-enquiry.dto';
export class UpdateEnquiryDto extends PartialType(CreateEnquiryDto) {}

View File

@ -0,0 +1,39 @@
import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards, Request } from '@nestjs/common';
import { EnquiriesService } from './enquiries.service';
import { CreateEnquiryDto } from './dto/create-enquiry.dto';
import { UpdateEnquiryDto } from './dto/update-enquiry.dto';
import { AuthGuard } from '@nestjs/passport';
@Controller('enquiries')
@UseGuards(AuthGuard('jwt'))
export class EnquiriesController {
constructor(private readonly enquiriesService: EnquiriesService) { }
@Post()
create(@Request() req, @Body() createEnquiryDto: CreateEnquiryDto) {
if (!createEnquiryDto.userId) {
createEnquiryDto.userId = req.user.id;
}
return this.enquiriesService.create(createEnquiryDto);
}
@Get()
findAll(@Request() req) {
return this.enquiriesService.findAll(req.user);
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.enquiriesService.findOne(id);
}
@Patch(':id')
update(@Param('id') id: string, @Body() updateEnquiryDto: UpdateEnquiryDto) {
return this.enquiriesService.update(id, updateEnquiryDto);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.enquiriesService.remove(id);
}
}

View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { EnquiriesService } from './enquiries.service';
import { EnquiriesController } from './enquiries.controller';
import { UsersModule } from '../users/users.module';
@Module({
imports: [UsersModule],
controllers: [EnquiriesController],
providers: [EnquiriesService],
})
export class EnquiriesModule {}

View File

@ -0,0 +1,91 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateEnquiryDto } from './dto/create-enquiry.dto';
import { UpdateEnquiryDto } from './dto/update-enquiry.dto';
import { UsersService } from '../users/users.service';
import { user_role } from '@prisma/client';
@Injectable()
export class EnquiriesService {
constructor(
private readonly prisma: PrismaService,
private readonly usersService: UsersService
) { }
create(createEnquiryDto: CreateEnquiryDto) {
const { productIds, ...data } = createEnquiryDto;
return this.prisma.enquiry.create({
data: {
...data,
products: productIds?.length
? { connect: productIds.map((id) => ({ id })) }
: undefined,
},
include: {
products: true,
client: true,
user: true,
},
});
}
async findAll(user: any) {
if (user.role === user_role.ADMIN) {
return this.prisma.enquiry.findMany({
include: {
products: true,
client: true,
user: true,
quotes: true,
},
});
}
const subordinateIds = await this.usersService.getSubordinateIds(user.id);
const allowedUserIds = [user.id, ...subordinateIds];
return this.prisma.enquiry.findMany({
where: {
userId: { in: allowedUserIds }
},
include: {
products: true,
client: true,
user: true,
quotes: true,
},
});
}
findOne(id: string) {
return this.prisma.enquiry.findUnique({
where: { id },
include: {
products: true,
client: true,
user: true,
followups: true,
quotes: true,
},
});
}
update(id: string, updateEnquiryDto: UpdateEnquiryDto) {
const { productIds, ...data } = updateEnquiryDto;
return this.prisma.enquiry.update({
where: { id },
data: {
...data,
products: productIds
? { set: productIds.map((id) => ({ id })) }
: undefined,
},
});
}
remove(id: string) {
return this.prisma.enquiry.delete({
where: { id },
});
}
}

View File

@ -0,0 +1 @@
export class Enquiry {}

View File

@ -0,0 +1,23 @@
import { IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString } from 'class-validator';
export class CreateExpenseDto {
@IsString()
@IsNotEmpty()
userId: string;
@IsNumber()
@IsNotEmpty()
amount: number;
@IsString()
@IsNotEmpty()
description: string;
@IsString()
@IsOptional()
imageUrl?: string;
@IsOptional()
@IsEnum(['PENDING', 'APPROVED', 'REJECTED', 'REIMBURSED'])
status?: 'PENDING' | 'APPROVED' | 'REJECTED' | 'REIMBURSED';
}

View File

@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateExpenseDto } from './create-expense.dto';
export class UpdateExpenseDto extends PartialType(CreateExpenseDto) {}

View File

@ -0,0 +1 @@
export class Expense {}

View File

@ -0,0 +1,43 @@
import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards, Request } from '@nestjs/common';
import { ExpensesService } from './expenses.service';
import { CreateExpenseDto } from './dto/create-expense.dto';
import { UpdateExpenseDto } from './dto/update-expense.dto';
import { AuthGuard } from '@nestjs/passport';
@Controller('expenses')
@UseGuards(AuthGuard('jwt'))
export class ExpensesController {
constructor(private readonly expensesService: ExpensesService) { }
@Post()
create(@Request() req, @Body() createExpenseDto: CreateExpenseDto) {
console.log('Creating expense:', createExpenseDto);
if (!createExpenseDto.userId) {
createExpenseDto.userId = req.user.id;
}
return this.expensesService.create(createExpenseDto).catch(err => {
console.error('Error creating expense:', err);
throw err;
});
}
@Get()
findAll(@Request() req) {
return this.expensesService.findAll(req.user);
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.expensesService.findOne(id);
}
@Patch(':id')
update(@Param('id') id: string, @Body() updateExpenseDto: UpdateExpenseDto) {
return this.expensesService.update(id, updateExpenseDto);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.expensesService.remove(id);
}
}

View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { ExpensesService } from './expenses.service';
import { ExpensesController } from './expenses.controller';
import { UsersModule } from '../users/users.module';
@Module({
imports: [UsersModule],
controllers: [ExpensesController],
providers: [ExpensesService],
})
export class ExpensesModule {}

View File

@ -0,0 +1,64 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateExpenseDto } from './dto/create-expense.dto';
import { UpdateExpenseDto } from './dto/update-expense.dto';
import { UsersService } from '../users/users.service';
import { user_role } from '@prisma/client';
@Injectable()
export class ExpensesService {
constructor(
private readonly prisma: PrismaService,
private readonly usersService: UsersService
) { }
create(createExpenseDto: CreateExpenseDto) {
return this.prisma.expense.create({
data: createExpenseDto,
});
}
async findAll(user: any) {
if (user.role === user_role.ADMIN) {
return this.prisma.expense.findMany({
include: {
user: true,
},
});
}
const subordinateIds = await this.usersService.getSubordinateIds(user.id);
const allowedUserIds = [user.id, ...subordinateIds];
return this.prisma.expense.findMany({
where: {
userId: { in: allowedUserIds }
},
include: {
user: true,
},
});
}
findOne(id: string) {
return this.prisma.expense.findUnique({
where: { id },
include: {
user: true,
},
});
}
update(id: string, updateExpenseDto: UpdateExpenseDto) {
return this.prisma.expense.update({
where: { id },
data: updateExpenseDto as any,
});
}
remove(id: string) {
return this.prisma.expense.delete({
where: { id },
});
}
}

View File

@ -0,0 +1,33 @@
import { IsString, IsNotEmpty, IsOptional, IsDateString, IsEnum } from 'class-validator';
export class CreateFollowupDto {
@IsString()
@IsNotEmpty()
clientId: string;
@IsString()
@IsOptional()
enquiryId?: string;
@IsString()
@IsNotEmpty()
userId: string; // Typically extracted from auth context, but simplified here
@IsDateString()
@IsNotEmpty()
date: string;
@IsString()
@IsOptional()
notes?: string;
// Alias description for frontend compatibility if needed, but schema uses notes.
// Frontend sends 'description', let's map it or support both.
@IsString()
@IsOptional()
description?: string;
@IsString()
@IsOptional()
status?: string;
}

View File

@ -0,0 +1,39 @@
import { Controller, Get, Post, Body, Patch, Param, Delete, Query } from '@nestjs/common';
import { FollowupsService } from './followups.service';
import { CreateFollowupDto } from './dto/create-followup.dto';
@Controller('followups')
export class FollowupsController {
constructor(private readonly followupsService: FollowupsService) { }
@Post()
create(@Body() createFollowupDto: CreateFollowupDto) {
return this.followupsService.create(createFollowupDto);
}
@Get()
findAll(
@Query('userId') userId?: string,
@Query('clientId') clientId?: string,
@Query('dateFrom') dateFrom?: string,
@Query('dateTo') dateTo?: string,
@Query('status') status?: string,
) {
return this.followupsService.findAll({ userId, clientId, dateFrom, dateTo, status });
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.followupsService.findOne(id);
}
@Patch(':id')
update(@Param('id') id: string, @Body() updateFollowupDto: any) {
return this.followupsService.update(id, updateFollowupDto);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.followupsService.remove(id);
}
}

View File

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { FollowupsService } from './followups.service';
import { FollowupsController } from './followups.controller';
import { PrismaModule } from '../prisma/prisma.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { WhatsappModule } from '../whatsapp/whatsapp.module';
@Module({
imports: [PrismaModule, NotificationsModule, WhatsappModule],
controllers: [FollowupsController],
providers: [FollowupsService],
})
export class FollowupsModule { }

View File

@ -0,0 +1,124 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateFollowupDto } from './dto/create-followup.dto';
import { NotificationsService } from '../notifications/notifications.service';
import { WhatsappService } from '../whatsapp/whatsapp.service';
@Injectable()
export class FollowupsService {
constructor(
private prisma: PrismaService,
private notifications: NotificationsService,
private whatsappService: WhatsappService
) { }
async create(createFollowupDto: CreateFollowupDto) {
// Map description to notes if provided
const { description, ...rest } = createFollowupDto;
const data = {
...rest,
notes: rest.notes || description, // Use description if notes is empty
};
const followup = await this.prisma.followup.create({
data,
include: { client: true }
});
// Trigger WhatsApp followup reminder to client
if (followup.client?.phone) {
try {
await this.whatsappService.sendFollowupReminder(
followup.client.phone,
followup.client.name,
new Date(followup.date).toLocaleString()
);
} catch (err) {
console.error('Failed to send followup WhatsApp:', err.message);
}
}
// Send Alert to Assigned User if different from creator
if (data.userId) {
await this.notifications.create(
data.userId,
'New Follow-up Assigned 📅',
`You have been assigned a new follow-up task for client. Deadline: ${new Date(data.date).toLocaleString()}`,
'FOLLOWUP_ASSIGNED'
);
}
return followup;
}
findAll(filters?: { userId?: string; clientId?: string; dateFrom?: string; dateTo?: string; status?: string }) {
const where: any = {};
if (filters?.userId) where.userId = filters.userId;
if (filters?.clientId) where.clientId = filters.clientId;
if (filters?.status) where.status = filters.status;
if (filters?.dateFrom || filters?.dateTo) {
where.date = {};
if (filters.dateFrom) where.date.gte = new Date(filters.dateFrom);
if (filters.dateTo) where.date.lte = new Date(filters.dateTo);
}
return this.prisma.followup.findMany({
where,
include: { client: true, user: true, enquiry: true },
orderBy: { date: 'asc' }
});
}
findOne(id: string) {
return this.prisma.followup.findUnique({
where: { id },
include: { client: true, user: true, enquiry: true }
});
}
async update(id: string, updateFollowupDto: any) {
// Fetch current state before update to detect reassignment
const existing = await this.prisma.followup.findUnique({ where: { id } });
const followup = await this.prisma.followup.update({
where: { id },
data: updateFollowupDto,
include: { user: true, client: true }
});
// Auto-dismiss notification when marked DONE
if (updateFollowupDto.status === 'DONE' && followup.userId) {
await this.prisma.$executeRaw`
UPDATE notification
SET isRead = true
WHERE userId = ${followup.userId}
AND type = 'FOLLOWUP_ASSIGNED'
AND isRead = false
`;
}
// Handle Reassignment — new userId provided and it's different from existing
if (updateFollowupDto.userId && existing && updateFollowupDto.userId !== existing.userId) {
// Dismiss old assignee's notification
if (existing.userId) {
await this.prisma.$executeRaw`
UPDATE notification SET isRead = true
WHERE userId = ${existing.userId} AND type = 'FOLLOWUP_ASSIGNED' AND isRead = false
`;
}
// Notify new assignee
const deadline = new Date(followup.date).toLocaleString();
await this.notifications.create(
updateFollowupDto.userId,
'Follow-up Reassigned to You 📅',
`A follow-up task for ${followup.client?.name || 'a client'} has been reassigned to you. Deadline: ${deadline}`,
'FOLLOWUP_ASSIGNED'
);
}
return followup;
}
remove(id: string) {
return this.prisma.followup.delete({ where: { id } });
}
}

View File

@ -0,0 +1,27 @@
import { IsDateString, IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString } from 'class-validator';
export class CreateIncentiveDto {
@IsString()
@IsNotEmpty()
userId: string;
@IsNumber()
@IsNotEmpty()
targetAmount: number;
@IsNumber()
@IsOptional()
rewardAmount?: number;
@IsNotEmpty()
@IsEnum(['DAILY', 'MONTHLY'])
type: 'DAILY' | 'MONTHLY';
@IsDateString()
@IsNotEmpty()
startDate: string;
@IsDateString()
@IsNotEmpty()
endDate: string;
}

View File

@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateIncentiveDto } from './create-incentive.dto';
export class UpdateIncentiveDto extends PartialType(CreateIncentiveDto) {}

View File

@ -0,0 +1 @@
export class Incentive {}

View File

@ -0,0 +1,39 @@
import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards, Request } from '@nestjs/common';
import { IncentivesService } from './incentives.service';
import { CreateIncentiveDto } from './dto/create-incentive.dto';
import { UpdateIncentiveDto } from './dto/update-incentive.dto';
import { AuthGuard } from '@nestjs/passport';
@Controller('incentives')
@UseGuards(AuthGuard('jwt'))
export class IncentivesController {
constructor(private readonly incentivesService: IncentivesService) { }
@Post()
create(@Request() req, @Body() createIncentiveDto: CreateIncentiveDto) {
if (!createIncentiveDto.userId) {
createIncentiveDto.userId = req.user.id;
}
return this.incentivesService.create(createIncentiveDto);
}
@Get()
findAll(@Request() req) {
return this.incentivesService.findAll(req.user);
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.incentivesService.findOne(id);
}
@Patch(':id')
update(@Param('id') id: string, @Body() updateIncentiveDto: UpdateIncentiveDto) {
return this.incentivesService.update(id, updateIncentiveDto);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.incentivesService.remove(id);
}
}

View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { IncentivesService } from './incentives.service';
import { IncentivesController } from './incentives.controller';
import { UsersModule } from '../users/users.module';
@Module({
imports: [UsersModule],
controllers: [IncentivesController],
providers: [IncentivesService],
})
export class IncentivesModule {}

View File

@ -0,0 +1,67 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateIncentiveDto } from './dto/create-incentive.dto';
import { UpdateIncentiveDto } from './dto/update-incentive.dto';
import { UsersService } from '../users/users.service';
import { user_role } from '@prisma/client';
@Injectable()
export class IncentivesService {
constructor(
private readonly prisma: PrismaService,
private readonly usersService: UsersService
) { }
create(createIncentiveDto: CreateIncentiveDto) {
return this.prisma.incentive.create({
data: {
...createIncentiveDto,
achievedAmount: 0,
} as any,
});
}
async findAll(user: any) {
if (user.role === user_role.ADMIN) {
return this.prisma.incentive.findMany({
include: {
user: true,
},
});
}
const subordinateIds = await this.usersService.getSubordinateIds(user.id);
const allowedUserIds = [user.id, ...subordinateIds];
return this.prisma.incentive.findMany({
where: {
userId: { in: allowedUserIds }
},
include: {
user: true,
},
});
}
findOne(id: string) {
return this.prisma.incentive.findUnique({
where: { id },
include: {
user: true,
},
});
}
update(id: string, updateIncentiveDto: UpdateIncentiveDto) {
return this.prisma.incentive.update({
where: { id },
data: updateIncentiveDto as any,
});
}
remove(id: string) {
return this.prisma.incentive.delete({
where: { id },
});
}
}

View File

@ -0,0 +1,11 @@
import { IsNumber, IsNotEmpty } from 'class-validator';
export class CreateLocationDto {
@IsNumber()
@IsNotEmpty()
lat: number;
@IsNumber()
@IsNotEmpty()
lng: number;
}

View File

@ -0,0 +1,25 @@
import { Controller, Get, Post, Body, Param, UseGuards, Request, Query } from '@nestjs/common';
import { LocationsService } from './locations.service';
import { CreateLocationDto } from './dto/create-location.dto';
import { AuthGuard } from '@nestjs/passport';
@Controller('locations')
@UseGuards(AuthGuard('jwt'))
export class LocationsController {
constructor(private readonly locationsService: LocationsService) {}
@Post()
create(@Request() req, @Body() createLocationDto: CreateLocationDto) {
return this.locationsService.create(req.user.id, createLocationDto);
}
@Get()
findAll(@Request() req, @Query('date') date?: string) {
return this.locationsService.findAll(req.user, date);
}
@Get('user/:userId')
findByUser(@Param('userId') userId: string, @Request() req, @Query('date') date?: string) {
return this.locationsService.findByUser(userId, req.user, date);
}
}

View File

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { LocationsService } from './locations.service';
import { LocationsController } from './locations.controller';
import { PrismaModule } from '../prisma/prisma.module';
import { UsersModule } from '../users/users.module';
@Module({
imports: [PrismaModule, UsersModule],
controllers: [LocationsController],
providers: [LocationsService],
exports: [LocationsService],
})
export class LocationsModule {}

View File

@ -0,0 +1,76 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateLocationDto } from './dto/create-location.dto';
import { UsersService } from '../users/users.service';
import { user_role } from '@prisma/client';
@Injectable()
export class LocationsService {
constructor(
private prisma: PrismaService,
private usersService: UsersService,
) {}
private getDateRange(dateStr?: string) {
const start = dateStr ? new Date(dateStr) : new Date();
start.setHours(0, 0, 0, 0);
const end = new Date(start);
end.setHours(23, 59, 59, 999);
return { start, end };
}
async create(userId: string, createLocationDto: CreateLocationDto) {
return this.prisma.location.create({
data: {
userId,
...createLocationDto,
},
});
}
async findAll(user: any, date?: string) {
const { start, end } = this.getDateRange(date);
if (user.role === user_role.ADMIN) {
return this.prisma.location.findMany({
where: {
timestamp: { gte: start, lte: end },
},
include: { user: { select: { name: true, email: true } } },
orderBy: { timestamp: 'asc' },
});
}
const subordinateIds = await this.usersService.getSubordinateIds(user.id);
const allowedUserIds = [user.id, ...subordinateIds];
return this.prisma.location.findMany({
where: {
userId: { in: allowedUserIds },
timestamp: { gte: start, lte: end },
},
include: { user: { select: { name: true, email: true } } },
orderBy: { timestamp: 'asc' },
});
}
async findByUser(userId: string, requestingUser: any, date?: string) {
// Check if the requesting user is allowed to see this user's location
if (requestingUser.role !== user_role.ADMIN && requestingUser.id !== userId) {
const subordinateIds = await this.usersService.getSubordinateIds(requestingUser.id);
if (!subordinateIds.includes(userId)) {
throw new Error('Unauthorized');
}
}
const { start, end } = this.getDateRange(date);
return this.prisma.location.findMany({
where: {
userId,
timestamp: { gte: start, lte: end },
},
orderBy: { timestamp: 'asc' },
});
}
}

11
src/main.ts Normal file
View File

@ -0,0 +1,11 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableCors(); // Enable CORS for all origins
app.useGlobalPipes(new ValidationPipe({ transform: true }));
await app.listen(process.env.PORT ?? 3000, '0.0.0.0');
}
bootstrap();

View File

@ -0,0 +1,23 @@
import { IsString, IsNotEmpty, IsDateString, IsOptional } from 'class-validator';
export class CreateMeetingDto {
@IsString()
@IsNotEmpty()
title: string;
@IsString()
@IsOptional()
description?: string;
@IsDateString()
@IsNotEmpty()
date: string; // ISO Date string
@IsString()
@IsNotEmpty()
clientId: string;
@IsString()
@IsNotEmpty()
createdBy: string; // User ID
}

View File

@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateMeetingDto } from './create-meeting.dto';
export class UpdateMeetingDto extends PartialType(CreateMeetingDto) { }

View File

@ -0,0 +1 @@
export class Meeting {}

View File

@ -0,0 +1,36 @@
import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards, Request } from '@nestjs/common';
import { MeetingsService } from './meetings.service';
import { CreateMeetingDto } from './dto/create-meeting.dto';
import { UpdateMeetingDto } from './dto/update-meeting.dto';
import { AuthGuard } from '@nestjs/passport';
@Controller('meetings')
@UseGuards(AuthGuard('jwt'))
export class MeetingsController {
constructor(private readonly meetingsService: MeetingsService) { }
@Post()
create(@Body() createMeetingDto: CreateMeetingDto) {
return this.meetingsService.create(createMeetingDto);
}
@Get()
findAll() {
return this.meetingsService.findAll();
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.meetingsService.findOne(id);
}
@Patch(':id')
update(@Param('id') id: string, @Body() updateMeetingDto: UpdateMeetingDto) {
return this.meetingsService.update(id, updateMeetingDto);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.meetingsService.remove(id);
}
}

View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { MeetingsService } from './meetings.service';
import { MeetingsController } from './meetings.controller';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [MeetingsController],
providers: [MeetingsService],
})
export class MeetingsModule { }

View File

@ -0,0 +1,46 @@
import { Injectable } from '@nestjs/common';
import { CreateMeetingDto } from './dto/create-meeting.dto';
import { UpdateMeetingDto } from './dto/update-meeting.dto';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class MeetingsService {
constructor(private prisma: PrismaService) { }
create(createMeetingDto: CreateMeetingDto) {
return this.prisma.meeting.create({
data: {
...createMeetingDto,
date: new Date(createMeetingDto.date)
}
});
}
findAll() {
return this.prisma.meeting.findMany({
include: { client: true, user: true }
});
}
findOne(id: string) {
return this.prisma.meeting.findUnique({
where: { id },
include: { client: true, user: true }
});
}
update(id: string, updateMeetingDto: UpdateMeetingDto) {
const data: any = { ...updateMeetingDto };
if (updateMeetingDto.date) {
data.date = new Date(updateMeetingDto.date);
}
return this.prisma.meeting.update({
where: { id },
data: data
});
}
remove(id: string) {
return this.prisma.meeting.delete({ where: { id } });
}
}

View File

@ -0,0 +1,29 @@
import { Controller, Get, Patch, Param, UseGuards, Request } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { NotificationsService } from './notifications.service';
@UseGuards(AuthGuard('jwt'))
@Controller('notifications')
export class NotificationsController {
constructor(private readonly notificationsService: NotificationsService) {}
@Get('my')
findMine(@Request() req: any) {
return this.notificationsService.findMyNotifications(req.user.userId);
}
@Get('unread-count')
unreadCount(@Request() req: any) {
return this.notificationsService.getUnreadCount(req.user.userId).then(count => ({ count }));
}
@Patch('mark-all-read')
markAllRead(@Request() req: any) {
return this.notificationsService.markAllRead(req.user.userId);
}
@Patch(':id/read')
markOneRead(@Param('id') id: string) {
return this.notificationsService.markOneRead(id);
}
}

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { NotificationsService } from './notifications.service';
import { NotificationsController } from './notifications.controller';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [NotificationsController],
providers: [NotificationsService],
exports: [NotificationsService],
})
export class NotificationsModule {}

View File

@ -0,0 +1,42 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class NotificationsService {
constructor(private prisma: PrismaService) {}
async create(userId: string, title: string, body: string, type = 'INFO', metadata?: object) {
const id = require('crypto').randomUUID();
const metaStr = metadata ? JSON.stringify(metadata) : null;
await this.prisma.$executeRaw`
INSERT INTO notification (id, userId, title, body, type, metadata)
VALUES (${id}, ${userId}, ${title}, ${body}, ${type}, ${metaStr})
`;
return { id, userId, title, body, type, metadata };
}
async findMyNotifications(userId: string) {
return this.prisma.$queryRaw`
SELECT * FROM notification WHERE userId = ${userId} ORDER BY createdAt DESC LIMIT 50
`;
}
async getUnreadCount(userId: string) {
const result: any = await this.prisma.$queryRaw`
SELECT COUNT(*) as count FROM notification WHERE userId = ${userId} AND isRead = false
`;
return Number(result[0]?.count || 0);
}
async markAllRead(userId: string) {
return this.prisma.$executeRaw`
UPDATE notification SET isRead = true WHERE userId = ${userId} AND isRead = false
`;
}
async markOneRead(id: string) {
return this.prisma.$executeRaw`
UPDATE notification SET isRead = true WHERE id = ${id}
`;
}
}

View File

@ -0,0 +1,72 @@
import { IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString } from 'class-validator';
import { opportunity_stage } from '@prisma/client';
export class CreateOpportunityDto {
@IsNotEmpty()
@IsString()
title: string;
@IsNotEmpty()
@IsNumber()
value: number;
@IsNotEmpty()
@IsString()
clientId: string;
@IsNotEmpty()
@IsString()
assignedTo: string;
@IsOptional()
@IsEnum(opportunity_stage)
stage?: opportunity_stage;
@IsOptional()
@IsString()
priority?: string;
@IsOptional()
@IsString()
expectedCloseDate?: string;
@IsOptional()
@IsString()
creatorId?: string;
@IsOptional()
@IsString()
demoPersonName?: string;
@IsOptional()
@IsString()
demoContactDetails?: string;
@IsOptional()
@IsString()
keyQueries?: string;
@IsOptional()
@IsString()
objections?: string;
@IsOptional()
@IsString()
competitorMention?: string;
@IsOptional()
@IsString()
paymentMode?: string;
@IsOptional()
@IsNumber()
specialRate?: number;
@IsOptional()
@IsString()
freeOffers?: string;
@IsOptional()
@IsString()
negotiationRemarks?: string;
}

View File

@ -0,0 +1,6 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateOpportunityDto } from './create-opportunity.dto';
export class UpdateOpportunityDto extends PartialType(CreateOpportunityDto) {
expectedCloseDate?: string;
}

View File

@ -0,0 +1,39 @@
import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards, Request } from '@nestjs/common';
import { OpportunitiesService } from './opportunities.service';
import { CreateOpportunityDto } from './dto/create-opportunity.dto';
import { UpdateOpportunityDto } from './dto/update-opportunity.dto';
import { AuthGuard } from '@nestjs/passport';
@Controller('opportunities')
@UseGuards(AuthGuard('jwt'))
export class OpportunitiesController {
constructor(private readonly opportunitiesService: OpportunitiesService) { }
@Post()
create(@Request() req, @Body() createOpportunityDto: CreateOpportunityDto) {
if (!createOpportunityDto.assignedTo) {
createOpportunityDto.assignedTo = req.user.id;
}
return this.opportunitiesService.create(createOpportunityDto);
}
@Get()
findAll(@Request() req) {
return this.opportunitiesService.findAll(req.user);
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.opportunitiesService.findOne(id);
}
@Patch(':id')
update(@Param('id') id: string, @Body() updateOpportunityDto: UpdateOpportunityDto) {
return this.opportunitiesService.update(id, updateOpportunityDto);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.opportunitiesService.remove(id);
}
}

View File

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { OpportunitiesService } from './opportunities.service';
import { OpportunitiesController } from './opportunities.controller';
import { PrismaModule } from '../prisma/prisma.module';
import { UsersModule } from '../users/users.module';
import { WhatsappModule } from '../whatsapp/whatsapp.module';
@Module({
imports: [PrismaModule, UsersModule, WhatsappModule],
controllers: [OpportunitiesController],
providers: [OpportunitiesService],
})
export class OpportunitiesModule { }

View File

@ -0,0 +1,163 @@
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateOpportunityDto } from './dto/create-opportunity.dto';
import { UpdateOpportunityDto } from './dto/update-opportunity.dto';
import { UsersService } from '../users/users.service';
import { WhatsappService } from '../whatsapp/whatsapp.service';
import { user_role } from '@prisma/client';
import { v4 as uuidv4 } from 'uuid';
@Injectable()
export class OpportunitiesService {
constructor(
private readonly prisma: PrismaService,
private readonly usersService: UsersService,
private readonly whatsappService: WhatsappService
) { }
async create(createOpportunityDto: CreateOpportunityDto) {
try {
return await this.prisma.opportunity.create({
data: {
id: uuidv4(),
title: createOpportunityDto.title,
value: Number(createOpportunityDto.value),
clientId: createOpportunityDto.clientId,
assignedTo: createOpportunityDto.assignedTo,
stage: createOpportunityDto.stage || 'LEAD',
priority: createOpportunityDto.priority,
expectedCloseDate: (createOpportunityDto.expectedCloseDate && createOpportunityDto.expectedCloseDate.trim() !== '')
? new Date(createOpportunityDto.expectedCloseDate)
: null,
creatorId: createOpportunityDto.creatorId,
updatedAt: new Date(),
},
});
} catch (error) {
console.error('CREATE OPPORTUNITY ERROR:', error);
throw error;
}
}
async findAll(user: any) {
// ADMIN and GENERAL_MANAGER see everything
if (user.role === 'ADMIN' || user.role === 'GENERAL_MANAGER') {
return this.prisma.opportunity.findMany({
include: {
client: true,
user: true,
},
orderBy: { updatedAt: 'desc' }
});
}
const subordinateIds = await this.usersService.getSubordinateIds(user.id);
const allowedUserIds = [user.id, ...subordinateIds];
return this.prisma.opportunity.findMany({
where: {
assignedTo: { in: allowedUserIds }
},
include: {
client: true,
user: true,
},
orderBy: { updatedAt: 'desc' }
});
}
async findOne(id: string) {
const opp = await this.prisma.opportunity.findUnique({
where: { id },
include: {
client: true,
user: true,
},
});
if (!opp) throw new NotFoundException('Opportunity not found');
return opp;
}
async update(id: string, updateOpportunityDto: UpdateOpportunityDto) {
try {
// Fetch current state to check if we are changing stage
const current = await this.prisma.opportunity.findUnique({ where: { id } });
if (!current) throw new NotFoundException('Opportunity not found');
const newStage = updateOpportunityDto.stage || current.stage;
// Validation Logic for DEMO stage
if (newStage === 'DEMO') {
const missing: string[] = [];
const check = (val: any) => val === null || val === undefined || (typeof val === 'string' && val.trim() === '');
if (check(updateOpportunityDto.demoPersonName ?? current.demoPersonName)) missing.push('Person Name');
if (check(updateOpportunityDto.demoContactDetails ?? current.demoContactDetails)) missing.push('Contact Details');
if (check(updateOpportunityDto.expectedCloseDate ?? current.expectedCloseDate)) missing.push('Expected Close Date');
if (check(updateOpportunityDto.value ?? current.value)) missing.push('Value');
if (missing.length > 0) {
throw new BadRequestException(`Missing for DEMO stage: ${missing.join(', ')}`);
}
}
if (newStage === 'WON') {
const missing: string[] = [];
const check = (val: any) => val === null || val === undefined || (typeof val === 'string' && val.trim() === '');
// Only Payment Mode is strictly mandatory for WON
if (check(updateOpportunityDto.paymentMode ?? current.paymentMode)) missing.push('Payment Mode');
if (missing.length > 0) {
throw new BadRequestException(`Missing for WON stage: ${missing.join(', ')}`);
}
}
const data: any = { ...updateOpportunityDto };
// Handle expectedCloseDate conversion
if (updateOpportunityDto.expectedCloseDate && updateOpportunityDto.expectedCloseDate.trim() !== '') {
data.expectedCloseDate = new Date(updateOpportunityDto.expectedCloseDate);
} else if (updateOpportunityDto.expectedCloseDate === '') {
data.expectedCloseDate = null;
}
if (data.value) data.value = Number(data.value);
if (data.specialRate) data.specialRate = Number(data.specialRate);
// Fix foreign key constraint errors for empty strings
if (data.creatorId === '') data.creatorId = null;
if (data.demoOwnerId === '') data.demoOwnerId = null;
if (data.closingOwnerId === '') data.closingOwnerId = null;
return await this.prisma.opportunity.update({
where: { id },
data,
include: { client: true }
}).then(async (updated) => {
if (current.stage !== updated.stage) {
try {
await this.whatsappService.sendPipelineUpdate(
updated.client.phone,
updated.client.name,
current.stage,
updated.stage
);
} catch (err) {
console.error('Failed to send pipeline WhatsApp:', err.message);
}
}
return updated;
});
} catch (error) {
console.error('UPDATE OPPORTUNITY ERROR:', error);
throw error;
}
}
async remove(id: string) {
return this.prisma.opportunity.delete({
where: { id },
});
}
}

View File

@ -0,0 +1 @@
export class CreatePerformanceDto {}

View File

@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreatePerformanceDto } from './create-performance.dto';
export class UpdatePerformanceDto extends PartialType(CreatePerformanceDto) {}

View File

@ -0,0 +1 @@
export class Performance {}

View File

@ -0,0 +1,54 @@
import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common';
import { PerformanceService } from './performance.service';
import { CreatePerformanceDto } from './dto/create-performance.dto';
import { UpdatePerformanceDto } from './dto/update-performance.dto';
@Controller('performance')
export class PerformanceController {
constructor(private readonly performanceService: PerformanceService) {}
@Post('calculate/:userId')
calculateScore(@Param('userId') userId: string) {
return this.performanceService.calculateUserScore(userId);
}
@Get('latest/:userId')
getLatestScore(@Param('userId') userId: string) {
return this.performanceService.getLatestScore(userId);
}
@Get('trend/:userId')
getTrend(@Param('userId') userId: string) {
return this.performanceService.getPerformanceTrend(userId);
}
@Get('funnel/:userId')
getFunnel(@Param('userId') userId: string) {
return this.performanceService.getFunnelData(userId);
}
@Get('quarterly/:userId')
getQuarterly(@Param('userId') userId: string) {
return this.performanceService.getQuarterlyStatus(userId);
}
@Get('team')
getTeam() {
return this.performanceService.getTeamPerformance();
}
@Get('funnel-config')
getFunnelConfig() {
return this.performanceService.getFunnelConfig();
}
@Patch('funnel-config')
setFunnelConfig(@Body() body: any) {
return this.performanceService.setFunnelConfig(body);
}
@Get('team-funnel')
getTeamFunnel() {
return this.performanceService.getTeamFunnel();
}
}

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { PerformanceService } from './performance.service';
import { PerformanceController } from './performance.controller';
import { NotificationsModule } from '../notifications/notifications.module';
@Module({
imports: [NotificationsModule],
controllers: [PerformanceController],
providers: [PerformanceService],
})
export class PerformanceModule {}

View File

@ -0,0 +1,295 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { startOfMonth, endOfMonth } from 'date-fns';
import { NotificationsService } from '../notifications/notifications.service';
@Injectable()
export class PerformanceService implements OnModuleInit {
constructor(
private prisma: PrismaService,
private notifications: NotificationsService
) {}
async onModuleInit() {
try {
// Ensure systemconfig table exists (for MySQL)
await this.prisma.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS systemconfig (
id VARCHAR(36) PRIMARY KEY,
\`key\` VARCHAR(100) UNIQUE NOT NULL,
value TEXT,
updatedAt DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
)
`);
// Seed default ratios if not present
const existing: any = await this.prisma.$queryRaw`SELECT id FROM systemconfig WHERE \`key\` = 'funnel_ratios' LIMIT 1`;
if (!existing || existing.length === 0) {
const defaultRatios = JSON.stringify({ callsToQuality: 5, qualityToPotential: 3.33, potentialToDemo: 1.5, demoToWon: 2 });
const id = require('crypto').randomUUID();
await this.prisma.$executeRawUnsafe(`
INSERT INTO systemconfig (id, \`key\`, value, updatedAt)
VALUES ('${id}', 'funnel_ratios', '${defaultRatios}', NOW())
`);
}
} catch (e) {
console.error('Error initializing PerformanceService (systemconfig):', e.message);
}
}
async calculateUserScore(userId: string) {
const start = startOfMonth(new Date());
const end = endOfMonth(new Date());
// 1. Fetch Data
const [target, opportunities, enquiries, followups, strategicActivities] = await Promise.all([
this.prisma.target.findFirst({
where: { userId, month: new Date().getMonth() + 1, year: new Date().getFullYear() },
}),
this.prisma.opportunity.findMany({
where: {
OR: [
{ assignedTo: userId },
{ creatorId: userId },
{ demoOwnerId: userId },
{ closingOwnerId: userId }
],
updatedAt: { gte: start, lte: end }
}
}),
this.prisma.enquiry.findMany({ where: { userId, createdAt: { gte: start, lte: end } } }),
this.prisma.followup.findMany({ where: { userId, date: { gte: start, lte: end } } }),
this.prisma.strategicActivity.findMany({ where: { userId, date: { gte: start, lte: end } } }),
]);
if (!target) return { message: 'No target set for this month' };
// 2. Revenue Achievement (Weight: 40) - LEAD SHARING LOGIC (50% Creator / 50% Closer)
const totalRevenue = opportunities.reduce((sum, o) => {
if (o.stage !== 'WON') return sum;
let contribution = 0;
// 50% goes to the Lead Creator
if (o.creatorId === userId) contribution += o.value * 0.5;
// 50% goes to the Closer (closingOwnerId or assignedTo if not specified)
if (o.closingOwnerId === userId || (!o.closingOwnerId && o.assignedTo === userId)) {
contribution += o.value * 0.5;
}
return sum + contribution;
}, 0);
const revenueScore = Math.min(40, (totalRevenue / target.monthlyTarget) * 40);
// 3. Conversion Efficiency (Weight: 20)
const wonCount = opportunities.filter(o => o.stage === 'WON' && (o.closingOwnerId === userId || o.assignedTo === userId)).length;
const conversionEfficiency = enquiries.length > 0 ? (wonCount / enquiries.length) : 0;
const conversionScore = Math.min(20, conversionEfficiency * 100 * 0.2); // Scaling 100% to 20 points
// 4. Activity Discipline (Weight: 15) - Strategic Activities
const activityCount = strategicActivities.length;
const activityBenchmark = 20; // Assume 20 activities per month benchmark
const activityScore = Math.min(15, (activityCount / activityBenchmark) * 15);
// 5. Follow-up Completion (Weight: 15)
const completedFollowups = followups.filter(f => f.status === 'DONE').length;
const followupScore = followups.length > 0 ? (completedFollowups / followups.length) * 15 : 15;
// 6. Data Quality (Weight: 10) - Mandatory fields in Demo stage
const opportunitiesWithDemo = opportunities.filter(o => o.stage === 'DEMO');
const qualityOpps = opportunitiesWithDemo.filter(o => o.demoPersonName && o.demoContactDetails && o.keyQueries);
const dataQualityScore = opportunitiesWithDemo.length > 0 ? (qualityOpps.length / opportunitiesWithDemo.length) * 10 : 10;
const totalScore = revenueScore + conversionScore + activityScore + followupScore + dataQualityScore;
// 7. Live Performance Tag
let tag = 'ON_TRACK';
if (totalScore < 50) tag = 'UNDERPERFORM';
else if (totalScore < 80) tag = 'RISK';
// 8. Save Score
const result = await this.prisma.performanceScore.create({
data: {
userId,
score: totalScore,
revenueScore,
conversionScore,
activityScore,
disciplineScore: followupScore,
dataQualityScore,
tag,
},
});
// 9. Send Notification
await this.notifications.create(
userId,
'Performance Update',
`Your performance score for this month is ${Math.round(totalScore)}/100 (${tag}).`,
'PERFORMANCE'
);
return result;
}
async getLatestScore(userId: string) {
return this.prisma.performanceScore.findFirst({
where: { userId },
orderBy: { date: 'desc' },
});
}
async getPerformanceTrend(userId: string) {
return this.prisma.performanceScore.findMany({
where: { userId },
orderBy: { date: 'asc' },
take: 12, // Last 12 records
});
}
async getFunnelData(userId: string) {
const start = startOfMonth(new Date());
const end = endOfMonth(new Date());
const [activities, opportunities] = await Promise.all([
this.prisma.strategicActivity.findMany({ where: { userId, date: { gte: start, lte: end } } }),
this.prisma.opportunity.findMany({ where: { assignedTo: userId, updatedAt: { gte: start, lte: end } } }),
]);
// Actual Counts
const actualCalls = activities.length;
const actualQuality = opportunities.filter(o => ['QUALIFIED', 'POTENTIAL', 'DEMO', 'WON'].includes(o.stage)).length;
const actualPotential = opportunities.filter(o => ['POTENTIAL', 'DEMO', 'WON'].includes(o.stage)).length;
const actualDemo = opportunities.filter(o => ['DEMO', 'WON'].includes(o.stage)).length;
const actualWon = opportunities.filter(o => o.stage === 'WON').length;
// Default ratios (configurable)
const DEFAULT_RATIOS = { callsToQuality: 5, qualityToPotential: 3.33, potentialToDemo: 1.5, demoToWon: 2 };
// Try loading custom config from metadata table or fallback
let ratios = DEFAULT_RATIOS;
try {
const cfg: any = await this.prisma.$queryRaw`SELECT value FROM systemconfig WHERE key = 'funnel_ratios' LIMIT 1`;
if (cfg && cfg[0]) ratios = JSON.parse(cfg[0].value);
} catch {}
// Ideal Funnel (derived from actual calls and configured ratios)
const idealQuality = actualCalls / ratios.callsToQuality;
const idealPotential = idealQuality / ratios.qualityToPotential;
const idealDemo = idealPotential / ratios.potentialToDemo;
const idealWon = idealDemo / ratios.demoToWon;
return {
actual: { calls: actualCalls, quality: actualQuality, potential: actualPotential, demo: actualDemo, won: actualWon },
ideal: { calls: actualCalls, quality: Math.round(idealQuality), potential: Math.round(idealPotential), demo: Math.round(idealDemo), won: Math.round(idealWon) },
deviation: { quality: actualQuality - idealQuality, potential: actualPotential - idealPotential, demo: actualDemo - idealDemo, won: actualWon - idealWon },
ratios
};
}
async getFunnelConfig() {
const DEFAULT = { callsToQuality: 5, qualityToPotential: 3.33, potentialToDemo: 1.5, demoToWon: 2 };
try {
const cfg: any = await this.prisma.$queryRaw`SELECT value FROM systemconfig WHERE key = 'funnel_ratios' LIMIT 1`;
return cfg && cfg[0] ? JSON.parse(cfg[0].value) : DEFAULT;
} catch { return DEFAULT; }
}
async setFunnelConfig(ratios: { callsToQuality: number; qualityToPotential: number; potentialToDemo: number; demoToWon: number }) {
const value = JSON.stringify(ratios);
try {
const exists: any = await this.prisma.$queryRaw`SELECT id FROM systemconfig WHERE key = 'funnel_ratios' LIMIT 1`;
if (exists && exists[0]) {
await this.prisma.$executeRaw`UPDATE systemconfig SET value = ${value}, updatedAt = NOW() WHERE key = 'funnel_ratios'`;
} else {
const id = require('crypto').randomUUID();
await this.prisma.$executeRaw`INSERT INTO systemconfig (id, key, value, updatedAt) VALUES (${id}, 'funnel_ratios', ${value}, NOW())`;
}
} catch {}
return ratios;
}
async getTeamFunnel() {
const start = startOfMonth(new Date());
const end = endOfMonth(new Date());
const users = await this.prisma.user.findMany({
where: { status: 'APPROVED' },
select: { id: true, name: true, role: true }
});
const results = await Promise.all(users.map(async u => {
const funnel = await this.getFunnelData(u.id);
return { ...u, funnel };
}));
return results;
}
async getQuarterlyStatus(userId: string) {
const scores = await this.prisma.performanceScore.findMany({
where: { userId },
orderBy: { date: 'desc' },
take: 3,
});
if (scores.length < 1) return { status: 'NORMAL', suggestions: [] };
const lastTwoMonthsBelow = scores.length >= 2 && scores.slice(0, 2).every(s => s.score < 50);
const lastThreeMonthsBelow = scores.length === 3 && scores.every(s => s.score < 50);
let status = 'NORMAL';
if (lastThreeMonthsBelow) status = 'ACTION';
else if (lastTwoMonthsBelow) status = 'WARNING';
// Automated Improvement Suggestions
const latest = scores[0];
const suggestions: string[] = [];
if (latest.score < 80) {
if (latest.revenueScore < 20) suggestions.push('Focus on high-value closures to meet revenue targets.');
if (latest.conversionScore < 10) suggestions.push('Improve conversion rate by qualifying leads better before the Demo stage.');
if (latest.activityScore < 7) suggestions.push('Increase daily call and meeting activity to fill your pipeline.');
if (latest.disciplineScore < 7) suggestions.push('Ensure all follow-ups are marked as complete on time.');
if (latest.dataQualityScore < 5) suggestions.push('Fill in all mandatory fields during the Demo stage to improve data quality.');
} else {
suggestions.push('Keep up the great work! You are exceeding your targets.');
}
if (status !== 'NORMAL') {
await this.notifications.create(
userId,
`PERFORMANCE ${status}`,
`Your performance has been below minimum for ${status === 'ACTION' ? '3' : '2'} months. Please check your improvement plan.`,
'PERFORMANCE_ALERT'
);
}
return {
status,
suggestions,
recentScores: scores.map(s => ({ date: s.date, score: s.score })),
};
}
async getTeamPerformance() {
const users = await this.prisma.user.findMany({
where: { status: 'APPROVED' },
select: {
id: true,
name: true,
role: true,
performanceScores: {
orderBy: { date: 'desc' },
take: 1,
},
},
});
return users.map(u => ({
id: u.id,
name: u.name,
role: u.role,
score: u.performanceScores[0]?.score || 0,
tag: u.performanceScores[0]?.tag || 'N/A',
lastUpdated: u.performanceScores[0]?.date || null,
}));
}
}

View File

@ -0,0 +1,9 @@
import { Module, Global } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule { }

View File

@ -0,0 +1,9 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
async onModuleInit() {
await this.$connect();
}
}

View File

@ -0,0 +1,19 @@
import { IsNotEmpty, IsNumber, IsOptional, IsString } from 'class-validator';
export class CreateProductDto {
@IsString()
@IsNotEmpty()
name: string;
@IsString()
@IsOptional()
description?: string;
@IsNumber()
@IsNotEmpty()
price: number;
@IsString()
@IsOptional()
imageUrl?: string;
}

View File

@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateProductDto } from './create-product.dto';
export class UpdateProductDto extends PartialType(CreateProductDto) {}

View File

@ -0,0 +1 @@
export class Product {}

View File

@ -0,0 +1,34 @@
import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common';
import { ProductsService } from './products.service';
import { CreateProductDto } from './dto/create-product.dto';
import { UpdateProductDto } from './dto/update-product.dto';
@Controller('products')
export class ProductsController {
constructor(private readonly productsService: ProductsService) { }
@Post()
create(@Body() createProductDto: CreateProductDto) {
return this.productsService.create(createProductDto);
}
@Get()
findAll() {
return this.productsService.findAll();
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.productsService.findOne(id);
}
@Patch(':id')
update(@Param('id') id: string, @Body() updateProductDto: UpdateProductDto) {
return this.productsService.update(id, updateProductDto);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.productsService.remove(id);
}
}

View File

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { ProductsService } from './products.service';
import { ProductsController } from './products.controller';
@Module({
controllers: [ProductsController],
providers: [ProductsService],
})
export class ProductsModule {}

View File

@ -0,0 +1,38 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateProductDto } from './dto/create-product.dto';
import { UpdateProductDto } from './dto/update-product.dto';
@Injectable()
export class ProductsService {
constructor(private readonly prisma: PrismaService) { }
create(createProductDto: CreateProductDto) {
return this.prisma.product.create({
data: createProductDto,
});
}
findAll() {
return this.prisma.product.findMany();
}
findOne(id: string) {
return this.prisma.product.findUnique({
where: { id },
});
}
update(id: string, updateProductDto: UpdateProductDto) {
return this.prisma.product.update({
where: { id },
data: updateProductDto,
});
}
remove(id: string) {
return this.prisma.product.delete({
where: { id },
});
}
}

View File

@ -0,0 +1,27 @@
import { IsArray, IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString } from 'class-validator';
export class CreateQuoteDto {
@IsString()
@IsNotEmpty()
enquiryId: string;
@IsString()
@IsNotEmpty()
userId: string;
@IsArray()
@IsNotEmpty()
items: any[];
@IsNumber()
@IsNotEmpty()
totalAmount: number;
@IsString()
@IsOptional()
pdfUrl?: string;
@IsOptional()
@IsEnum(['DRAFT', 'SENT', 'ACCEPTED', 'REJECTED'])
status?: 'DRAFT' | 'SENT' | 'ACCEPTED' | 'REJECTED';
}

View File

@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateQuoteDto } from './create-quote.dto';
export class UpdateQuoteDto extends PartialType(CreateQuoteDto) {}

View File

@ -0,0 +1 @@
export class Quote {}

View File

@ -0,0 +1,39 @@
import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common';
import { QuotesService } from './quotes.service';
import { CreateQuoteDto } from './dto/create-quote.dto';
import { UpdateQuoteDto } from './dto/update-quote.dto';
@Controller('quotes')
export class QuotesController {
constructor(private readonly quotesService: QuotesService) { }
@Post()
create(@Body() createQuoteDto: CreateQuoteDto) {
return this.quotesService.create(createQuoteDto);
}
@Get()
findAll() {
return this.quotesService.findAll();
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.quotesService.findOne(id);
}
@Patch(':id')
update(@Param('id') id: string, @Body() updateQuoteDto: UpdateQuoteDto) {
return this.quotesService.update(id, updateQuoteDto);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.quotesService.remove(id);
}
@Post(':id/send')
send(@Param('id') id: string, @Body('type') type: 'whatsapp' | 'email') {
return this.quotesService.send(id, type);
}
}

Some files were not shown because too many files have changed in this diff Show More