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 itmain
parent
370c14f93a
commit
6f4b5aa67c
|
|
@ -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());
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -27,28 +27,48 @@ model Attendance {
|
||||||
}
|
}
|
||||||
|
|
||||||
model Client {
|
model Client {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
name String
|
name String
|
||||||
phone String
|
phone String
|
||||||
email String?
|
email String?
|
||||||
address String?
|
address String?
|
||||||
lat Float?
|
lat Float?
|
||||||
lng Float?
|
lng Float?
|
||||||
landmark String?
|
landmark String?
|
||||||
status client_status @default(LEAD)
|
status client_status @default(LEAD)
|
||||||
assignedTo String?
|
assignedTo String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
user User? @relation(fields: [assignedTo], references: [id], map: "Client_assignedTo_fkey")
|
companyName String?
|
||||||
enquiries Enquiry[]
|
contactName String?
|
||||||
followups Followup[]
|
closingProbability Int? @default(0)
|
||||||
meetings Meeting[]
|
expectedClosingTimeframe String?
|
||||||
opportunities Opportunity[]
|
isDemoDone Boolean @default(false)
|
||||||
|
user User? @relation(fields: [assignedTo], references: [id], map: "Client_assignedTo_fkey")
|
||||||
|
enquiries Enquiry[]
|
||||||
|
followups Followup[]
|
||||||
|
meetings Meeting[]
|
||||||
|
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")
|
||||||
|
|
@ -85,23 +105,32 @@ model Expense {
|
||||||
}
|
}
|
||||||
|
|
||||||
model Followup {
|
model Followup {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
clientId String
|
clientId String
|
||||||
enquiryId String?
|
enquiryId String?
|
||||||
userId String
|
userId String
|
||||||
date DateTime
|
date DateTime
|
||||||
notes String?
|
notes String?
|
||||||
status String @default("PENDING")
|
status String @default("PENDING")
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
contentType String?
|
contentType String?
|
||||||
stage followup_stage @default(LEAD)
|
stage followup_stage @default(LEAD)
|
||||||
client Client @relation(fields: [clientId], references: [id], map: "Followup_clientId_fkey")
|
competitorMention String?
|
||||||
enquiry Enquiry? @relation(fields: [enquiryId], references: [id], map: "Followup_enquiryId_fkey")
|
demoContactDetails String?
|
||||||
user User @relation(fields: [userId], references: [id], map: "Followup_userId_fkey")
|
demoPersonName String?
|
||||||
|
keyQueries String?
|
||||||
|
objections String?
|
||||||
|
opportunityId String?
|
||||||
|
type activity_type @default(FOLLOWUP)
|
||||||
|
client Client @relation(fields: [clientId], references: [id], map: "Followup_clientId_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")
|
||||||
|
|
||||||
@@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,25 +261,28 @@ 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?
|
||||||
userId String
|
opportunityId String?
|
||||||
items String @db.LongText
|
userId String
|
||||||
totalAmount Float
|
items String @db.LongText
|
||||||
status quote_status @default(DRAFT)
|
totalAmount Float
|
||||||
pdfUrl String?
|
status quote_status @default(DRAFT)
|
||||||
createdAt DateTime @default(now())
|
pdfUrl String?
|
||||||
updatedAt DateTime @updatedAt
|
createdAt DateTime @default(now())
|
||||||
enquiry Enquiry @relation(fields: [enquiryId], references: [id], map: "Quote_enquiryId_fkey")
|
updatedAt DateTime @updatedAt
|
||||||
user User @relation(fields: [userId], references: [id], map: "Quote_userId_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")
|
||||||
|
|
||||||
@@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")
|
||||||
}
|
}
|
||||||
|
|
@ -270,24 +305,24 @@ model StrategicActivity {
|
||||||
}
|
}
|
||||||
|
|
||||||
model Target {
|
model Target {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String
|
userId String
|
||||||
month Int
|
month Int
|
||||||
year Int
|
year Int
|
||||||
monthlyTarget Float
|
monthlyTarget Float
|
||||||
minTarget Float
|
minTarget Float
|
||||||
weeklyTarget Float?
|
weeklyTarget Float?
|
||||||
dailyLeadTarget Int?
|
dailyLeadTarget Int?
|
||||||
requiredLeads Int?
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
avgDealValue Float?
|
||||||
|
closureRatio Float?
|
||||||
|
requiredClosures Int?
|
||||||
|
requiredDemos Int?
|
||||||
|
requiredLeads Int?
|
||||||
|
requiredPotential Int?
|
||||||
requiredQualityLeads Int?
|
requiredQualityLeads Int?
|
||||||
requiredPotential Int?
|
user User @relation(fields: [userId], references: [id], map: "Target_userId_fkey")
|
||||||
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")
|
@@index([userId], map: "Target_userId_fkey")
|
||||||
@@map("target")
|
@@map("target")
|
||||||
|
|
@ -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")
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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[];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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(', ')}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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 baseUrl = process.env.API_BASE_URL || 'http://localhost:3000';
|
const client = quote.opportunity?.client || quote.enquiry?.client;
|
||||||
const mediaUrl = quote.pdfUrl ? `${baseUrl}${quote.pdfUrl}` : undefined;
|
console.log('Quote created. Attempting to send WhatsApp to client:', client?.name, 'Phone:', client?.phone);
|
||||||
await this.whatsappService.sendQuoteCreation(
|
if (client) {
|
||||||
quote.enquiry.client.phone,
|
const baseUrl = process.env.API_BASE_URL || 'http://localhost:3000';
|
||||||
quote.enquiry.client.name,
|
const mediaUrl = quote.pdfUrl ? `${baseUrl}${quote.pdfUrl}` : undefined;
|
||||||
quote.totalAmount.toString(),
|
console.log('Calling whatsappService.sendQuoteCreation with mediaUrl:', mediaUrl);
|
||||||
mediaUrl
|
await this.whatsappService.sendQuoteCreation(
|
||||||
);
|
client.phone,
|
||||||
|
client.name,
|
||||||
|
quote.totalAmount.toString(),
|
||||||
|
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,11 +113,11 @@ 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');
|
||||||
|
|
|
||||||
|
|
@ -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: [
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -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.
Binary file not shown.
Loading…
Reference in New Issue