changes till 09/05/2026

New clients creation from opportunities, client conversion%, time taken for conversion, close the modal when touched outside it
   Client and company name separate, Demo becomes a separate activity, all changes done in mobile app as well
2) transfer of clients, demos followups negotiation etc scheduling, quote opportunity in place of enquiry, In opportunity new product add,           	existing dropdown, added option for adding documents on client creation and showing it
main
Manu Krishna 2026-05-09 15:21:50 +05:30
parent 370c14f93a
commit 6f4b5aa67c
28 changed files with 430 additions and 146 deletions

22
check_logs.js Normal file
View File

@ -0,0 +1,22 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function main() {
const logs = await prisma.whatsappLog.findMany({
orderBy: { createdAt: 'desc' },
take: 3,
select: { templateName: true, payload: true, response: true, status: true, createdAt: true }
});
logs.forEach((l, i) => {
console.log(`\n=== LOG #${i+1} [${l.status}] at ${l.createdAt} ===`);
console.log('Template:', l.templateName);
const payload = JSON.parse(l.payload || '{}');
console.log('Fields:', payload.fields);
console.log('Cat:', payload.cat);
console.log('Headimg:', payload.headimg);
console.log('Response:', l.response);
});
}
main().catch(console.error).finally(() => prisma.$disconnect());

19
fetch_whatsapp_info.ts Normal file
View File

@ -0,0 +1,19 @@
import { PrismaClient } from '@prisma/client';
async function main() {
const prisma = new PrismaClient();
const logs = await prisma.whatsappLog.findMany({
orderBy: { createdAt: 'desc' },
take: 10
});
const templates = await prisma.whatsappTemplate.findMany();
console.log('--- TEMPLATES ---');
console.log(JSON.stringify(templates, null, 2));
console.log('--- LOGS ---');
console.log(JSON.stringify(logs, null, 2));
await prisma.$disconnect();
}
main();

View File

@ -0,0 +1,18 @@
import { PrismaClient } from '@prisma/client';
async function main() {
const prisma = new PrismaClient();
const templates = await prisma.whatsappTemplate.findMany();
console.log('WhatsApp Templates available in the system:');
templates.forEach(t => {
console.log(`\n--- ${t.type} ---`);
console.log(`Template Name: ${t.templateName}`);
console.log(`Language: ${t.language}`);
console.log(`Message Body: ${t.body}`);
});
await prisma.$disconnect();
}
main();

0
log_check.txt Normal file
View File

0
logs.txt Normal file
View File

View File

@ -39,16 +39,36 @@ model Client {
assignedTo String? assignedTo String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
companyName String?
contactName String?
closingProbability Int? @default(0)
expectedClosingTimeframe String?
isDemoDone Boolean @default(false)
user User? @relation(fields: [assignedTo], references: [id], map: "Client_assignedTo_fkey") user User? @relation(fields: [assignedTo], references: [id], map: "Client_assignedTo_fkey")
enquiries Enquiry[] enquiries Enquiry[]
followups Followup[] followups Followup[]
meetings Meeting[] meetings Meeting[]
opportunities Opportunity[] opportunities Opportunity[]
files ClientFile[]
@@index([assignedTo], map: "Client_assignedTo_fkey") @@index([assignedTo], map: "Client_assignedTo_fkey")
@@map("client") @@map("client")
} }
model ClientFile {
id String @id @default(uuid())
clientId String
name String
url String
size Int?
type String?
createdAt DateTime @default(now())
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
@@index([clientId])
@@map("client_file")
}
model Enquiry { model Enquiry {
id String @id @default(uuid()) id String @id @default(uuid())
clientId String clientId String
@ -61,7 +81,7 @@ model Enquiry {
user User @relation(fields: [userId], references: [id], map: "Enquiry_userId_fkey") user User @relation(fields: [userId], references: [id], map: "Enquiry_userId_fkey")
followups Followup[] followups Followup[]
quotes Quote[] quotes Quote[]
products Product[] @relation("enquirytoproduct") products Product[] @relation("enquiry_products")
@@index([clientId], map: "Enquiry_clientId_fkey") @@index([clientId], map: "Enquiry_clientId_fkey")
@@index([userId], map: "Enquiry_userId_fkey") @@index([userId], map: "Enquiry_userId_fkey")
@ -96,12 +116,21 @@ model Followup {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
contentType String? contentType String?
stage followup_stage @default(LEAD) stage followup_stage @default(LEAD)
competitorMention String?
demoContactDetails String?
demoPersonName String?
keyQueries String?
objections String?
opportunityId String?
type activity_type @default(FOLLOWUP)
client Client @relation(fields: [clientId], references: [id], map: "Followup_clientId_fkey") client Client @relation(fields: [clientId], references: [id], map: "Followup_clientId_fkey")
enquiry Enquiry? @relation(fields: [enquiryId], references: [id], map: "Followup_enquiryId_fkey") enquiry Enquiry? @relation(fields: [enquiryId], references: [id], map: "Followup_enquiryId_fkey")
opportunity Opportunity? @relation(fields: [opportunityId], references: [id], map: "Followup_opportunityId_fkey")
user User @relation(fields: [userId], references: [id], map: "Followup_userId_fkey") user User @relation(fields: [userId], references: [id], map: "Followup_userId_fkey")
@@index([clientId], map: "Followup_clientId_fkey") @@index([clientId], map: "Followup_clientId_fkey")
@@index([enquiryId], map: "Followup_enquiryId_fkey") @@index([enquiryId], map: "Followup_enquiryId_fkey")
@@index([opportunityId], map: "Followup_opportunityId_fkey")
@@index([userId], map: "Followup_userId_fkey") @@index([userId], map: "Followup_userId_fkey")
@@map("followup") @@map("followup")
} }
@ -172,14 +201,17 @@ model Opportunity {
paymentMode String? paymentMode String?
specialRate Float? specialRate Float?
creatorId String? creatorId String?
demoOwnerId String?
closingOwnerId String? closingOwnerId String?
demoOwnerId String?
isDemoDone Boolean @default(false)
activities Followup[]
user User @relation("opportunity_assignedToTouser", fields: [assignedTo], references: [id], map: "Opportunity_assignedTo_fkey") user User @relation("opportunity_assignedToTouser", fields: [assignedTo], references: [id], map: "Opportunity_assignedTo_fkey")
client Client @relation(fields: [clientId], references: [id], map: "Opportunity_clientId_fkey") client Client @relation(fields: [clientId], references: [id], map: "Opportunity_clientId_fkey")
closingOwner User? @relation("opportunity_closingOwnerIdTouser", fields: [closingOwnerId], references: [id], map: "Opportunity_closingOwnerId_fkey")
creator User? @relation("opportunity_creatorIdTouser", fields: [creatorId], references: [id], map: "Opportunity_creatorId_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") 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[] workOrders WorkOrder[]
quotes Quote[]
@@index([assignedTo], map: "Opportunity_assignedTo_fkey") @@index([assignedTo], map: "Opportunity_assignedTo_fkey")
@@index([clientId], map: "Opportunity_clientId_fkey") @@index([clientId], map: "Opportunity_clientId_fkey")
@ -229,14 +261,15 @@ model Product {
imageUrl String? imageUrl String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
enquiries Enquiry[] @relation("enquirytoproduct") enquiries Enquiry[] @relation("enquiry_products")
@@map("product") @@map("product")
} }
model Quote { model Quote {
id String @id @default(uuid()) id String @id @default(uuid())
enquiryId String enquiryId String?
opportunityId String?
userId String userId String
items String @db.LongText items String @db.LongText
totalAmount Float totalAmount Float
@ -244,10 +277,12 @@ model Quote {
pdfUrl String? pdfUrl String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
enquiry Enquiry @relation(fields: [enquiryId], references: [id], map: "Quote_enquiryId_fkey") enquiry Enquiry? @relation(fields: [enquiryId], references: [id], map: "Quote_enquiryId_fkey")
opportunity Opportunity? @relation(fields: [opportunityId], references: [id], map: "Quote_opportunityId_fkey")
user User @relation(fields: [userId], references: [id], map: "Quote_userId_fkey") user User @relation(fields: [userId], references: [id], map: "Quote_userId_fkey")
@@index([enquiryId], map: "Quote_enquiryId_fkey") @@index([enquiryId], map: "Quote_enquiryId_fkey")
@@index([opportunityId], map: "Quote_opportunityId_fkey")
@@index([userId], map: "Quote_userId_fkey") @@index([userId], map: "Quote_userId_fkey")
@@map("quote") @@map("quote")
} }
@ -278,15 +313,15 @@ model Target {
minTarget Float minTarget Float
weeklyTarget Float? weeklyTarget Float?
dailyLeadTarget Int? dailyLeadTarget Int?
requiredLeads Int?
requiredQualityLeads Int?
requiredPotential Int?
requiredDemos Int?
requiredClosures Int?
closureRatio Float?
avgDealValue Float?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
avgDealValue Float?
closureRatio Float?
requiredClosures Int?
requiredDemos Int?
requiredLeads Int?
requiredPotential Int?
requiredQualityLeads Int?
user User @relation(fields: [userId], references: [id], map: "Target_userId_fkey") user User @relation(fields: [userId], references: [id], map: "Target_userId_fkey")
@@index([userId], map: "Target_userId_fkey") @@index([userId], map: "Target_userId_fkey")
@ -323,10 +358,10 @@ model User {
password String password String
name String? name String?
role user_role @default(TELESALES_EXECUTIVE) role user_role @default(TELESALES_EXECUTIVE)
status user_status @default(APPROVED)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
managerId String? managerId String?
status user_status @default(APPROVED)
attendance Attendance[] attendance Attendance[]
clients Client[] clients Client[]
enquiries Enquiry[] enquiries Enquiry[]
@ -335,15 +370,15 @@ model User {
incentives Incentive[] incentives Incentive[]
locations Location[] locations Location[]
meetings Meeting[] meetings Meeting[]
notifications Notification[]
opportunities Opportunity[] @relation("opportunity_assignedToTouser") opportunities Opportunity[] @relation("opportunity_assignedToTouser")
closedOpportunities Opportunity[] @relation("opportunity_closingOwnerIdTouser")
createdOpportunities Opportunity[] @relation("opportunity_creatorIdTouser") createdOpportunities Opportunity[] @relation("opportunity_creatorIdTouser")
demoOpportunities Opportunity[] @relation("opportunity_demoOwnerIdTouser") demoOpportunities Opportunity[] @relation("opportunity_demoOwnerIdTouser")
closedOpportunities Opportunity[] @relation("opportunity_closingOwnerIdTouser")
performanceScores PerformanceScore[] performanceScores PerformanceScore[]
quotes Quote[] quotes Quote[]
strategicActivities StrategicActivity[] strategicActivities StrategicActivity[]
targets Target[] targets Target[]
notifications Notification[]
manager User? @relation("userTouser", fields: [managerId], references: [id], map: "User_managerId_fkey") manager User? @relation("userTouser", fields: [managerId], references: [id], map: "User_managerId_fkey")
subordinates User[] @relation("userTouser") subordinates User[] @relation("userTouser")
@ -365,11 +400,34 @@ model WorkOrder {
@@map("workorder") @@map("workorder")
} }
model WhatsappTemplate {
id String @id @default(uuid())
type String @unique
templateName String
language String @default("en")
body String? @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("whatsapp_template")
}
model WhatsappLog {
id String @id @default(uuid())
templateName String
payload String? @db.Text
response String? @db.Text
status String @default("SENT")
createdAt DateTime @default(now())
recipient String
@@map("whatsapp_log")
}
enum opportunity_stage { enum opportunity_stage {
LEAD LEAD
QUALIFIED QUALIFIED
POTENTIAL POTENTIAL
DEMO
WON WON
LOST LOST
} }
@ -411,7 +469,6 @@ enum client_status {
LEAD LEAD
QUALITY QUALITY
POTENTIAL POTENTIAL
DEMO
SALES SALES
CLOSED CLOSED
} }
@ -420,31 +477,13 @@ enum followup_stage {
LEAD LEAD
QUALITY QUALITY
POTENTIAL POTENTIAL
DEMO
SALES SALES
CLOSED CLOSED
} }
model WhatsappTemplate { enum activity_type {
id String @id @default(uuid()) FOLLOWUP
type String @unique // e.g., 'GREETING', 'QUOTE', 'NUDGE' DEMO
templateName String // The tempid/name used by the external API QUOTE
language String @default("en") NEGOTIATION
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")
} }

6
service_log.txt Normal file
View File

@ -0,0 +1,6 @@
[2026-05-07T10:41:17.907Z] Manual send triggered for ID: f51c4391-6df5-4dc4-b3ec-ad628db954ed
[2026-05-07T10:45:40.605Z] Manual send triggered for ID: f51c4391-6df5-4dc4-b3ec-ad628db954ed
[2026-05-07T10:47:45.589Z] Manual send triggered for ID: f51c4391-6df5-4dc4-b3ec-ad628db954ed
[2026-05-07T10:48:13.353Z] Manual send triggered for ID: f51c4391-6df5-4dc4-b3ec-ad628db954ed
[2026-05-07T10:48:50.250Z] Manual send triggered for ID: f51c4391-6df5-4dc4-b3ec-ad628db954ed
[2026-05-09T08:48:57.668Z] Manual send triggered for ID: debedbbf-8473-4b9a-a936-f98a3c030c65

View File

@ -41,23 +41,28 @@ export class AutomationService {
if (message && lead.phone) { if (message && lead.phone) {
this.logger.log(`Triggering Day ${age} nudge for ${lead.name}`); this.logger.log(`Triggering Day ${age} nudge for ${lead.name}`);
// await this.whatsapp.sendInternalMessage(lead.phone, message); // Placeholder for actual implementation // await this.whatsapp.sendInternalMessage(lead.phone, message);
} }
} }
} }
private async processEscalations(){ private async processEscalations() {
const delayedDemos = await this.prisma.opportunity.findMany({ // Find opportunities where a demo has been scheduled but not yet done for 10+ days
const staleDemoOpps = await this.prisma.opportunity.findMany({
where: { where: {
stage: 'DEMO', isDemoDone: false,
stage: { in: ['QUALIFIED', 'POTENTIAL'] },
updatedAt: { lte: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000) }, updatedAt: { lte: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000) },
}, },
include: { user: { include: { manager: true } } }, include: {
user: { include: { manager: true } },
client: true,
},
}); });
for (const opp of delayedDemos) { for (const opp of staleDemoOpps) {
if (opp.user?.manager?.email) { if (opp.user?.manager?.email) {
this.logger.warn(`Escalating delayed demo: ${opp.title} for user ${opp.user.name}`); this.logger.warn(`Escalating stale demo: ${opp.title} for user ${opp.user.name}`);
// Send alert to manager via Email/WhatsApp // Send alert to manager via Email/WhatsApp
} }
} }

View File

@ -16,8 +16,27 @@ export class ClientsService {
async create(createClientDto: CreateClientDto) { async create(createClientDto: CreateClientDto) {
console.log('API Received Client DTO:', JSON.stringify(createClientDto, null, 2)); console.log('API Received Client DTO:', JSON.stringify(createClientDto, null, 2));
const { id: _, user: __, createdAt: ___, updatedAt: ____, enquiries: _____, followups: ______, meetings: _______, opportunities: ________, files, ...data } = createClientDto as any;
// Convert empty strings to null for optional DB fields
if (data.assignedTo === '') data.assignedTo = null;
if (data.companyName === '') data.companyName = null;
if (data.contactName === '') data.contactName = null;
if (data.expectedClosingTimeframe === '') data.expectedClosingTimeframe = null;
const client = await this.prisma.client.create({ const client = await this.prisma.client.create({
data: createClientDto data: {
...data,
files: files && files.length > 0 ? {
create: files.map((f: any) => ({
name: f.name,
url: f.url,
size: f.size,
type: f.type
}))
} : undefined
},
include: { files: true }
}); });
// Trigger onboarding message if client is created with SALES status // Trigger onboarding message if client is created with SALES status
@ -35,7 +54,7 @@ export class ClientsService {
async findAll(user: any) { async findAll(user: any) {
if (user.role === user_role.ADMIN) { if (user.role === user_role.ADMIN) {
return this.prisma.client.findMany({ return this.prisma.client.findMany({
include: { user: true } include: { user: true, files: true }
}); });
} }
@ -46,21 +65,41 @@ export class ClientsService {
where: { where: {
assignedTo: { in: allowedUserIds } assignedTo: { in: allowedUserIds }
}, },
include: { user: true } include: { user: true, files: true }
}); });
} }
findOne(id: string) { findOne(id: string) {
return this.prisma.client.findUnique({ return this.prisma.client.findUnique({
where: { id }, where: { id },
include: { user: true, meetings: true } include: { user: true, meetings: true, files: true }
}); });
} }
update(id: string, updateClientDto: UpdateClientDto) { update(id: string, updateClientDto: UpdateClientDto) {
const { id: _, user: __, createdAt: ___, updatedAt: ____, enquiries: _____, followups: ______, meetings: _______, opportunities: ________, files, ...data } = updateClientDto as any;
// Convert empty strings to null for optional DB fields to avoid foreign key or constraint issues
if (data.assignedTo === '') data.assignedTo = null;
if (data.companyName === '') data.companyName = null;
if (data.contactName === '') data.contactName = null;
if (data.expectedClosingTimeframe === '') data.expectedClosingTimeframe = null;
return this.prisma.client.update({ return this.prisma.client.update({
where: { id }, where: { id },
data: updateClientDto data: {
...data,
files: files ? {
deleteMany: {},
create: files.map((f: any) => ({
name: f.name,
url: f.url,
size: f.size,
type: f.type
}))
} : undefined
},
include: { files: true }
}); });
} }

View File

@ -1,4 +1,4 @@
import { IsEnum, IsString, IsNotEmpty, IsOptional, IsEmail, IsNumber } from 'class-validator'; import { IsEnum, IsString, IsNotEmpty, IsOptional, IsEmail, IsNumber, IsBoolean, IsArray } from 'class-validator';
import { client_status } from '@prisma/client'; import { client_status } from '@prisma/client';
export class CreateClientDto { export class CreateClientDto {
@ -6,6 +6,14 @@ export class CreateClientDto {
@IsNotEmpty() @IsNotEmpty()
name: string; name: string;
@IsString()
@IsOptional()
companyName?: string;
@IsString()
@IsOptional()
contactName?: string;
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
phone: string; phone: string;
@ -37,4 +45,20 @@ export class CreateClientDto {
@IsNumber() @IsNumber()
@IsOptional() @IsOptional()
lng?: number; lng?: number;
@IsNumber()
@IsOptional()
closingProbability?: number;
@IsString()
@IsOptional()
expectedClosingTimeframe?: string;
@IsBoolean()
@IsOptional()
isDemoDone?: boolean;
@IsArray()
@IsOptional()
files?: any[];
} }

View File

@ -30,4 +30,32 @@ export class CreateFollowupDto {
@IsString() @IsString()
@IsOptional() @IsOptional()
status?: string; status?: string;
@IsString()
@IsOptional()
type?: 'FOLLOWUP' | 'DEMO' | 'QUOTE' | 'NEGOTIATION';
@IsString()
@IsOptional()
opportunityId?: string;
@IsString()
@IsOptional()
demoPersonName?: string;
@IsString()
@IsOptional()
demoContactDetails?: string;
@IsString()
@IsOptional()
keyQueries?: string;
@IsString()
@IsOptional()
objections?: string;
@IsString()
@IsOptional()
competitorMention?: string;
} }

View File

@ -13,8 +13,8 @@ export class FollowupsService {
) { } ) { }
async create(createFollowupDto: CreateFollowupDto) { async create(createFollowupDto: CreateFollowupDto) {
// Map description to notes if provided // Map description to notes if provided, and exclude 'time' which is only used by the frontend
const { description, ...rest } = createFollowupDto; const { description, time, ...rest } = createFollowupDto as any;
const data = { const data = {
...rest, ...rest,
notes: rest.notes || description, // Use description if notes is empty notes: rest.notes || description, // Use description if notes is empty
@ -115,6 +115,20 @@ export class FollowupsService {
); );
} }
// Auto-sync DEMO feedback to Opportunity
if (updateFollowupDto.status === 'DONE' && followup.type === 'DEMO' && followup.opportunityId) {
await this.prisma.opportunity.update({
where: { id: followup.opportunityId },
data: {
isDemoDone: true,
demoPersonName: followup.demoPersonName,
demoContactDetails: followup.demoContactDetails,
keyQueries: followup.keyQueries,
competitorMention: followup.competitorMention
}
});
}
return followup; return followup;
} }

View File

@ -1,4 +1,4 @@
import { IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString } from 'class-validator'; import { IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString, IsBoolean } from 'class-validator';
import { opportunity_stage } from '@prisma/client'; import { opportunity_stage } from '@prisma/client';
export class CreateOpportunityDto { export class CreateOpportunityDto {
@ -22,6 +22,10 @@ export class CreateOpportunityDto {
@IsEnum(opportunity_stage) @IsEnum(opportunity_stage)
stage?: opportunity_stage; stage?: opportunity_stage;
@IsOptional()
@IsBoolean()
isDemoDone?: boolean;
@IsOptional() @IsOptional()
@IsString() @IsString()
priority?: string; priority?: string;

View File

@ -86,8 +86,8 @@ export class OpportunitiesService {
const newStage = updateOpportunityDto.stage || current.stage; const newStage = updateOpportunityDto.stage || current.stage;
// Validation Logic for DEMO stage // Validation Logic for Demo
if (newStage === 'DEMO') { if (updateOpportunityDto.isDemoDone === true && !current.isDemoDone) {
const missing: string[] = []; const missing: string[] = [];
const check = (val: any) => val === null || val === undefined || (typeof val === 'string' && val.trim() === ''); const check = (val: any) => val === null || val === undefined || (typeof val === 'string' && val.trim() === '');
@ -97,7 +97,7 @@ export class OpportunitiesService {
if (check(updateOpportunityDto.value ?? current.value)) missing.push('Value'); if (check(updateOpportunityDto.value ?? current.value)) missing.push('Value');
if (missing.length > 0) { if (missing.length > 0) {
throw new BadRequestException(`Missing for DEMO stage: ${missing.join(', ')}`); throw new BadRequestException(`Missing details to mark Demo as done: ${missing.join(', ')}`);
} }
} }

View File

@ -98,7 +98,7 @@ export class PerformanceService implements OnModuleInit {
const followupScore = followups.length > 0 ? (completedFollowups / followups.length) * 15 : 15; const followupScore = followups.length > 0 ? (completedFollowups / followups.length) * 15 : 15;
// 6. Data Quality (Weight: 10) - Mandatory fields in Demo stage // 6. Data Quality (Weight: 10) - Mandatory fields in Demo stage
const opportunitiesWithDemo = opportunities.filter(o => o.stage === 'DEMO'); const opportunitiesWithDemo = opportunities.filter(o => o.isDemoDone);
const qualityOpps = opportunitiesWithDemo.filter(o => o.demoPersonName && o.demoContactDetails && o.keyQueries); const qualityOpps = opportunitiesWithDemo.filter(o => o.demoPersonName && o.demoContactDetails && o.keyQueries);
const dataQualityScore = opportunitiesWithDemo.length > 0 ? (qualityOpps.length / opportunitiesWithDemo.length) * 10 : 10; const dataQualityScore = opportunitiesWithDemo.length > 0 ? (qualityOpps.length / opportunitiesWithDemo.length) * 10 : 10;
@ -160,9 +160,9 @@ export class PerformanceService implements OnModuleInit {
// Actual Counts // Actual Counts
const actualCalls = activities.length; const actualCalls = activities.length;
const actualQuality = opportunities.filter(o => ['QUALIFIED', 'POTENTIAL', 'DEMO', 'WON'].includes(o.stage)).length; const actualQuality = opportunities.filter(o => ['QUALIFIED', 'POTENTIAL', 'WON'].includes(o.stage) || o.isDemoDone).length;
const actualPotential = opportunities.filter(o => ['POTENTIAL', 'DEMO', 'WON'].includes(o.stage)).length; const actualPotential = opportunities.filter(o => ['POTENTIAL', 'WON'].includes(o.stage) || o.isDemoDone).length;
const actualDemo = opportunities.filter(o => ['DEMO', 'WON'].includes(o.stage)).length; const actualDemo = opportunities.filter(o => o.isDemoDone || o.stage === 'WON').length;
const actualWon = opportunities.filter(o => o.stage === 'WON').length; const actualWon = opportunities.filter(o => o.stage === 'WON').length;
// Default ratios (configurable) // Default ratios (configurable)

View File

@ -2,8 +2,12 @@ import { IsArray, IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString } from 'cla
export class CreateQuoteDto { export class CreateQuoteDto {
@IsString() @IsString()
@IsNotEmpty() @IsOptional()
enquiryId: string; enquiryId?: string;
@IsString()
@IsOptional()
opportunityId?: string;
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()

View File

@ -13,8 +13,16 @@ export class QuotesService {
async create(createQuoteDto: CreateQuoteDto) { async create(createQuoteDto: CreateQuoteDto) {
const quote = await this.prisma.quote.create({ const quote = await this.prisma.quote.create({
data: createQuoteDto as any, data: {
...createQuoteDto,
items: JSON.stringify(createQuoteDto.items)
} as any,
include: { include: {
opportunity: {
include: {
client: true,
},
},
enquiry: { enquiry: {
include: { include: {
client: true, client: true,
@ -24,14 +32,19 @@ export class QuotesService {
}); });
try { try {
const client = quote.opportunity?.client || quote.enquiry?.client;
console.log('Quote created. Attempting to send WhatsApp to client:', client?.name, 'Phone:', client?.phone);
if (client) {
const baseUrl = process.env.API_BASE_URL || 'http://localhost:3000'; const baseUrl = process.env.API_BASE_URL || 'http://localhost:3000';
const mediaUrl = quote.pdfUrl ? `${baseUrl}${quote.pdfUrl}` : undefined; const mediaUrl = quote.pdfUrl ? `${baseUrl}${quote.pdfUrl}` : undefined;
console.log('Calling whatsappService.sendQuoteCreation with mediaUrl:', mediaUrl);
await this.whatsappService.sendQuoteCreation( await this.whatsappService.sendQuoteCreation(
quote.enquiry.client.phone, client.phone,
quote.enquiry.client.name, client.name,
quote.totalAmount.toString(), quote.totalAmount.toString(),
mediaUrl mediaUrl
); );
}
} catch (err) { } catch (err) {
console.error('Failed to send quote creation WhatsApp:', err.message); console.error('Failed to send quote creation WhatsApp:', err.message);
} }
@ -42,6 +55,11 @@ export class QuotesService {
findAll() { findAll() {
return this.prisma.quote.findMany({ return this.prisma.quote.findMany({
include: { include: {
opportunity: {
include: {
client: true,
},
},
enquiry: { enquiry: {
include: { include: {
client: true, client: true,
@ -56,6 +74,11 @@ export class QuotesService {
return this.prisma.quote.findUnique({ return this.prisma.quote.findUnique({
where: { id }, where: { id },
include: { include: {
opportunity: {
include: {
client: true,
},
},
enquiry: { enquiry: {
include: { include: {
client: true, client: true,
@ -67,9 +90,13 @@ export class QuotesService {
} }
update(id: string, updateQuoteDto: UpdateQuoteDto) { update(id: string, updateQuoteDto: UpdateQuoteDto) {
const data = { ...updateQuoteDto } as any;
if (updateQuoteDto.items) {
data.items = JSON.stringify(updateQuoteDto.items);
}
return this.prisma.quote.update({ return this.prisma.quote.update({
where: { id }, where: { id },
data: updateQuoteDto as any, data,
}); });
} }
@ -86,12 +113,12 @@ export class QuotesService {
throw new NotFoundException(`Quote with ID ${id} not found`); throw new NotFoundException(`Quote with ID ${id} not found`);
} }
if (!quote.enquiry || !quote.enquiry.client) { const client = quote.opportunity?.client || quote.enquiry?.client;
if (!client) {
throw new BadRequestException('This quote is not linked to a valid client'); throw new BadRequestException('This quote is not linked to a valid client');
} }
const client = quote.enquiry.client;
if (!client.phone) { if (!client.phone) {
throw new BadRequestException('The client associated with this quote has no phone number'); throw new BadRequestException('The client associated with this quote has no phone number');
} }
@ -100,11 +127,16 @@ export class QuotesService {
const mediaUrl = quote.pdfUrl ? `${baseUrl}${quote.pdfUrl}` : undefined; const mediaUrl = quote.pdfUrl ? `${baseUrl}${quote.pdfUrl}` : undefined;
if (type === 'whatsapp') { if (type === 'whatsapp') {
const fs = require('fs');
fs.appendFileSync('service_log.txt', `[${new Date().toISOString()}] Manual send triggered for ID: ${id}\n`);
console.log('Manually sending quote via WhatsApp. ID:', id);
const result = await this.whatsappService.sendQuote( const result = await this.whatsappService.sendQuote(
client.phone, client.phone,
`Hello ${client.name}, please find your quote attached.`, client.name,
quote.totalAmount.toString(),
mediaUrl mediaUrl
); );
console.log('WhatsApp send result:', result);
if (result && !(result as any).success) { if (result && !(result as any).success) {
throw new BadRequestException((result as any).error || 'Failed to send WhatsApp message'); throw new BadRequestException((result as any).error || 'Failed to send WhatsApp message');

View File

@ -73,7 +73,7 @@ export class ReportsService {
// Opportunity breakdown // Opportunity breakdown
this.prisma.opportunity.findMany({ this.prisma.opportunity.findMany({
where: { ...leadSharingFilter, updatedAt: dateRange }, where: { ...leadSharingFilter, updatedAt: dateRange },
select: { id: true, stage: true, value: true, updatedAt: true, assignedTo: true, creatorId: true } select: { id: true, stage: true, value: true, updatedAt: true, assignedTo: true, creatorId: true, isDemoDone: true }
}), }),
// Expense breakdown // Expense breakdown
@ -177,7 +177,7 @@ export class ReportsService {
{ name: 'Lead', count: opportunities.filter(o => o.stage === 'LEAD').length }, { name: 'Lead', count: opportunities.filter(o => o.stage === 'LEAD').length },
{ name: 'Qualified', count: opportunities.filter(o => o.stage === 'QUALIFIED').length }, { name: 'Qualified', count: opportunities.filter(o => o.stage === 'QUALIFIED').length },
{ name: 'Potential', count: opportunities.filter(o => o.stage === 'POTENTIAL').length }, { name: 'Potential', count: opportunities.filter(o => o.stage === 'POTENTIAL').length },
{ name: 'Demo', count: opportunities.filter(o => o.stage === 'DEMO').length }, { name: 'Demo Done', count: opportunities.filter(o => o.isDemoDone).length },
{ name: 'Won', count: opportunities.filter(o => o.stage === 'WON').length }, { name: 'Won', count: opportunities.filter(o => o.stage === 'WON').length },
], ],
expenses: [ expenses: [

View File

@ -41,8 +41,8 @@ export class UploadController {
if (!fs.existsSync(filePath)) { if (!fs.existsSync(filePath)) {
throw new HttpException('File not found', HttpStatus.NOT_FOUND); throw new HttpException('File not found', HttpStatus.NOT_FOUND);
} }
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `inline; filename="${filename}"`); // Let express handle Content-Type and other headers automatically
return res.sendFile(filePath); return res.sendFile(filePath);
} }
} }

View File

@ -30,16 +30,16 @@ export class WhatsappService {
return this.sendTemplateMessage(to, 'GREETING', [clientName]); return this.sendTemplateMessage(to, 'GREETING', [clientName]);
} }
async sendQuote(to: string, quoteDetails: string, mediaUrl?: string) { async sendQuote(to: string, clientName: string, amount: string, mediaUrl?: string) {
// Fallback for mock mode if URL is not configured // Fallback for mock mode if URL is not configured
if (!process.env.WHATSAPP_API_URL) { if (!process.env.WHATSAPP_API_URL) {
this.logger.warn('WHATSAPP_API_URL missing. Running in MOCK MODE.'); this.logger.warn('WHATSAPP_API_URL missing. Running in MOCK MODE.');
this.logger.log(`[MOCK] Would send to ${to}: "${quoteDetails}" with media: ${mediaUrl}`); this.logger.log(`[MOCK] Would send to ${to}: Quote for ${clientName} - Amount: ${amount} with media: ${mediaUrl}`);
return { success: true, sid: 'mock_sid_' + Date.now() }; return { success: true, sid: 'mock_sid_' + Date.now() };
} }
// Templates usually have placeholders. We'll map existing body to fields. // Templates usually have placeholders. We'll map existing body to fields.
return this.sendTemplateMessage(to, 'QUOTE', [quoteDetails], mediaUrl); return this.sendTemplateMessage(to, 'QUOTE', [clientName, amount], mediaUrl);
} }
async sendNudge(to: string, clientName: string, stage: string) { async sendNudge(to: string, clientName: string, stage: string) {
@ -57,7 +57,7 @@ export class WhatsappService {
return { success: false, error: `Template not found: ${type}` }; return { success: false, error: `Template not found: ${type}` };
} }
const mobile = this.formatPhoneNumber(to); const mobile = this.formatPhoneNumber(to).replace('+', ''); // Many APIs don't want the +
const url = process.env.WHATSAPP_API_URL; const url = process.env.WHATSAPP_API_URL;
const cid = process.env.WHATSAPP_CID; const cid = process.env.WHATSAPP_CID;
@ -70,20 +70,37 @@ export class WhatsappService {
template: template.templateName, template: template.templateName,
ln: template.language, ln: template.language,
mn: mobile, mn: mobile,
category: 'utility', // Gateway expects 'category' not 'cat'
fields: fields.join('|'), fields: fields.join('|'),
type: 'whatsapp', type: 'whatsapp',
headimg: headimg || "" headimg: headimg || ""
}; };
let finalHeadImg = headimg || "";
// If headimg is a local IP, the public gateway won't be able to fetch it.
// For testing, we substitute it with a public dummy PDF.
if (finalHeadImg.includes('192.168.') || finalHeadImg.includes('localhost') || finalHeadImg.includes('127.0.0.1')) {
const dummyPdf = 'https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf';
this.logger.warn(`Local PDF detected (${finalHeadImg}). Substituting with public dummy PDF for testing: ${dummyPdf}`);
finalHeadImg = dummyPdf;
}
const formData = new FormData();
Object.entries(payload).forEach(([key, value]) => {
if (key === 'headimg') {
formData.append(key, finalHeadImg);
} else {
formData.append(key, value || "");
}
});
try { try {
const body = new URLSearchParams(payload as any).toString(); this.logger.log(`Attempting to send WhatsApp via POST: ${url}`);
this.logger.log(`Payload: ${JSON.stringify(payload)}`);
const response = await fetch(url, { const response = await fetch(url, {
method: 'POST', method: 'POST',
headers: { body: formData
'Content-Type': 'application/x-www-form-urlencoded',
},
body: body
}); });
const resultText = await response.text(); const resultText = await response.text();
@ -127,8 +144,8 @@ export class WhatsappService {
} }
} }
async sendOnboarding(to: string, clientName: string, memberId: string, company: string = "Ignosi") { async sendOnboarding(to: string, clientName: string, memberId: string) {
return this.sendTemplateMessage(to, 'ONBOARDING', [clientName, memberId, company]); return this.sendTemplateMessage(to, 'ONBOARDING', [clientName, memberId]);
} }
async sendFollowupReminder(to: string, clientName: string, date: string) { async sendFollowupReminder(to: string, clientName: string, date: string) {

13
test_env.js Normal file
View File

@ -0,0 +1,13 @@
async function test() {
try {
const fd = new FormData();
fd.append('test', 'value');
console.log('FormData created successfully');
const res = await fetch('https://google.com');
console.log('Fetch worked. Status:', res.status);
} catch (err) {
console.error('Test failed:', err.message);
}
}
test();

View File

@ -1,4 +1,4 @@
{ {
"extends": "./tsconfig.json", "extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"] "exclude": ["node_modules", "test", "dist", "**/*spec.ts", "prisma"]
} }

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
whatsapp_info.json Normal file

Binary file not shown.