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?
createdAt DateTime @default(now())
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")
enquiries Enquiry[]
followups Followup[]
meetings Meeting[]
opportunities Opportunity[]
files ClientFile[]
@@index([assignedTo], map: "Client_assignedTo_fkey")
@@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 {
id String @id @default(uuid())
clientId String
@ -61,7 +81,7 @@ model Enquiry {
user User @relation(fields: [userId], references: [id], map: "Enquiry_userId_fkey")
followups Followup[]
quotes Quote[]
products Product[] @relation("enquirytoproduct")
products Product[] @relation("enquiry_products")
@@index([clientId], map: "Enquiry_clientId_fkey")
@@index([userId], map: "Enquiry_userId_fkey")
@ -96,12 +116,21 @@ model Followup {
updatedAt DateTime @updatedAt
contentType String?
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")
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([enquiryId], map: "Followup_enquiryId_fkey")
@@index([opportunityId], map: "Followup_opportunityId_fkey")
@@index([userId], map: "Followup_userId_fkey")
@@map("followup")
}
@ -172,14 +201,17 @@ model Opportunity {
paymentMode String?
specialRate Float?
creatorId String?
demoOwnerId String?
closingOwnerId String?
demoOwnerId String?
isDemoDone Boolean @default(false)
activities Followup[]
user User @relation("opportunity_assignedToTouser", fields: [assignedTo], references: [id], map: "Opportunity_assignedTo_fkey")
client Client @relation(fields: [clientId], references: [id], map: "Opportunity_clientId_fkey")
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")
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[]
quotes Quote[]
@@index([assignedTo], map: "Opportunity_assignedTo_fkey")
@@index([clientId], map: "Opportunity_clientId_fkey")
@ -229,14 +261,15 @@ model Product {
imageUrl String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
enquiries Enquiry[] @relation("enquirytoproduct")
enquiries Enquiry[] @relation("enquiry_products")
@@map("product")
}
model Quote {
id String @id @default(uuid())
enquiryId String
enquiryId String?
opportunityId String?
userId String
items String @db.LongText
totalAmount Float
@ -244,10 +277,12 @@ model Quote {
pdfUrl String?
createdAt DateTime @default(now())
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")
@@index([enquiryId], map: "Quote_enquiryId_fkey")
@@index([opportunityId], map: "Quote_opportunityId_fkey")
@@index([userId], map: "Quote_userId_fkey")
@@map("quote")
}
@ -278,15 +313,15 @@ model Target {
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
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")
@@index([userId], map: "Target_userId_fkey")
@ -323,10 +358,10 @@ model User {
password String
name String?
role user_role @default(TELESALES_EXECUTIVE)
status user_status @default(APPROVED)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
managerId String?
status user_status @default(APPROVED)
attendance Attendance[]
clients Client[]
enquiries Enquiry[]
@ -335,15 +370,15 @@ model User {
incentives Incentive[]
locations Location[]
meetings Meeting[]
notifications Notification[]
opportunities Opportunity[] @relation("opportunity_assignedToTouser")
closedOpportunities Opportunity[] @relation("opportunity_closingOwnerIdTouser")
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")
@ -365,11 +400,34 @@ model 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 {
LEAD
QUALIFIED
POTENTIAL
DEMO
WON
LOST
}
@ -411,7 +469,6 @@ enum client_status {
LEAD
QUALITY
POTENTIAL
DEMO
SALES
CLOSED
}
@ -420,31 +477,13 @@ 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")
enum activity_type {
FOLLOWUP
DEMO
QUOTE
NEGOTIATION
}

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) {
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() {
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: {
stage: 'DEMO',
isDemoDone: false,
stage: { in: ['QUALIFIED', 'POTENTIAL'] },
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) {
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
}
}

View File

@ -16,8 +16,27 @@ export class ClientsService {
async create(createClientDto: CreateClientDto) {
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({
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
@ -35,7 +54,7 @@ export class ClientsService {
async findAll(user: any) {
if (user.role === user_role.ADMIN) {
return this.prisma.client.findMany({
include: { user: true }
include: { user: true, files: true }
});
}
@ -46,21 +65,41 @@ export class ClientsService {
where: {
assignedTo: { in: allowedUserIds }
},
include: { user: true }
include: { user: true, files: true }
});
}
findOne(id: string) {
return this.prisma.client.findUnique({
where: { id },
include: { user: true, meetings: true }
include: { user: true, meetings: true, files: true }
});
}
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({
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';
export class CreateClientDto {
@ -6,6 +6,14 @@ export class CreateClientDto {
@IsNotEmpty()
name: string;
@IsString()
@IsOptional()
companyName?: string;
@IsString()
@IsOptional()
contactName?: string;
@IsString()
@IsNotEmpty()
phone: string;
@ -37,4 +45,20 @@ export class CreateClientDto {
@IsNumber()
@IsOptional()
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()
@IsOptional()
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) {
// Map description to notes if provided
const { description, ...rest } = createFollowupDto;
// Map description to notes if provided, and exclude 'time' which is only used by the frontend
const { description, time, ...rest } = createFollowupDto as any;
const data = {
...rest,
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;
}

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';
export class CreateOpportunityDto {
@ -22,6 +22,10 @@ export class CreateOpportunityDto {
@IsEnum(opportunity_stage)
stage?: opportunity_stage;
@IsOptional()
@IsBoolean()
isDemoDone?: boolean;
@IsOptional()
@IsString()
priority?: string;

View File

@ -86,8 +86,8 @@ export class OpportunitiesService {
const newStage = updateOpportunityDto.stage || current.stage;
// Validation Logic for DEMO stage
if (newStage === 'DEMO') {
// Validation Logic for Demo
if (updateOpportunityDto.isDemoDone === true && !current.isDemoDone) {
const missing: string[] = [];
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 (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;
// 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 dataQualityScore = opportunitiesWithDemo.length > 0 ? (qualityOpps.length / opportunitiesWithDemo.length) * 10 : 10;
@ -160,9 +160,9 @@ export class PerformanceService implements OnModuleInit {
// 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 actualQuality = opportunities.filter(o => ['QUALIFIED', 'POTENTIAL', 'WON'].includes(o.stage) || o.isDemoDone).length;
const actualPotential = opportunities.filter(o => ['POTENTIAL', 'WON'].includes(o.stage) || o.isDemoDone).length;
const actualDemo = opportunities.filter(o => o.isDemoDone || o.stage === 'WON').length;
const actualWon = opportunities.filter(o => o.stage === 'WON').length;
// Default ratios (configurable)

View File

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

View File

@ -13,8 +13,16 @@ export class QuotesService {
async create(createQuoteDto: CreateQuoteDto) {
const quote = await this.prisma.quote.create({
data: createQuoteDto as any,
data: {
...createQuoteDto,
items: JSON.stringify(createQuoteDto.items)
} as any,
include: {
opportunity: {
include: {
client: true,
},
},
enquiry: {
include: {
client: true,
@ -24,14 +32,19 @@ export class QuotesService {
});
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 mediaUrl = quote.pdfUrl ? `${baseUrl}${quote.pdfUrl}` : undefined;
console.log('Calling whatsappService.sendQuoteCreation with mediaUrl:', mediaUrl);
await this.whatsappService.sendQuoteCreation(
quote.enquiry.client.phone,
quote.enquiry.client.name,
client.phone,
client.name,
quote.totalAmount.toString(),
mediaUrl
);
}
} catch (err) {
console.error('Failed to send quote creation WhatsApp:', err.message);
}
@ -42,6 +55,11 @@ export class QuotesService {
findAll() {
return this.prisma.quote.findMany({
include: {
opportunity: {
include: {
client: true,
},
},
enquiry: {
include: {
client: true,
@ -56,6 +74,11 @@ export class QuotesService {
return this.prisma.quote.findUnique({
where: { id },
include: {
opportunity: {
include: {
client: true,
},
},
enquiry: {
include: {
client: true,
@ -67,9 +90,13 @@ export class QuotesService {
}
update(id: string, updateQuoteDto: UpdateQuoteDto) {
const data = { ...updateQuoteDto } as any;
if (updateQuoteDto.items) {
data.items = JSON.stringify(updateQuoteDto.items);
}
return this.prisma.quote.update({
where: { id },
data: updateQuoteDto as any,
data,
});
}
@ -86,12 +113,12 @@ export class QuotesService {
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');
}
const client = quote.enquiry.client;
if (!client.phone) {
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;
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(
client.phone,
`Hello ${client.name}, please find your quote attached.`,
client.name,
quote.totalAmount.toString(),
mediaUrl
);
console.log('WhatsApp send result:', result);
if (result && !(result as any).success) {
throw new BadRequestException((result as any).error || 'Failed to send WhatsApp message');

View File

@ -73,7 +73,7 @@ export class ReportsService {
// Opportunity breakdown
this.prisma.opportunity.findMany({
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
@ -177,7 +177,7 @@ export class ReportsService {
{ name: 'Lead', count: opportunities.filter(o => o.stage === 'LEAD').length },
{ name: 'Qualified', count: opportunities.filter(o => o.stage === 'QUALIFIED').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 },
],
expenses: [

View File

@ -41,8 +41,8 @@ export class UploadController {
if (!fs.existsSync(filePath)) {
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);
}
}

View File

@ -30,16 +30,16 @@ export class WhatsappService {
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
if (!process.env.WHATSAPP_API_URL) {
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() };
}
// 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) {
@ -57,7 +57,7 @@ export class WhatsappService {
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 cid = process.env.WHATSAPP_CID;
@ -70,20 +70,37 @@ export class WhatsappService {
template: template.templateName,
ln: template.language,
mn: mobile,
category: 'utility', // Gateway expects 'category' not 'cat'
fields: fields.join('|'),
type: 'whatsapp',
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 {
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, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: body
body: formData
});
const resultText = await response.text();
@ -127,8 +144,8 @@ export class WhatsappService {
}
}
async sendOnboarding(to: string, clientName: string, memberId: string, company: string = "Ignosi") {
return this.sendTemplateMessage(to, 'ONBOARDING', [clientName, memberId, company]);
async sendOnboarding(to: string, clientName: string, memberId: string) {
return this.sendTemplateMessage(to, 'ONBOARDING', [clientName, memberId]);
}
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",
"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.