diff --git a/check_logs.js b/check_logs.js new file mode 100644 index 0000000..788ba68 --- /dev/null +++ b/check_logs.js @@ -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()); diff --git a/fetch_whatsapp_info.ts b/fetch_whatsapp_info.ts new file mode 100644 index 0000000..2e6802c --- /dev/null +++ b/fetch_whatsapp_info.ts @@ -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(); diff --git a/list_whatsapp_templates.ts b/list_whatsapp_templates.ts new file mode 100644 index 0000000..12aaef3 --- /dev/null +++ b/list_whatsapp_templates.ts @@ -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(); diff --git a/log_check.txt b/log_check.txt new file mode 100644 index 0000000..e69de29 diff --git a/logs.txt b/logs.txt new file mode 100644 index 0000000..e69de29 diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e602cbc..0741c2c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -27,28 +27,48 @@ model Attendance { } model Client { - id String @id @default(uuid()) - name String - phone String - email String? - address String? - lat Float? - lng Float? - landmark String? - status client_status @default(LEAD) - assignedTo String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - user User? @relation(fields: [assignedTo], references: [id], map: "Client_assignedTo_fkey") - enquiries Enquiry[] - followups Followup[] - meetings Meeting[] - opportunities Opportunity[] + id String @id @default(uuid()) + name String + phone String + email String? + address String? + lat Float? + lng Float? + landmark String? + status client_status @default(LEAD) + assignedTo String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + 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") @@ -85,23 +105,32 @@ model Expense { } model Followup { - id String @id @default(uuid()) - clientId String - enquiryId String? - userId String - date DateTime - notes String? - status String @default("PENDING") - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - contentType String? - stage followup_stage @default(LEAD) - client Client @relation(fields: [clientId], references: [id], map: "Followup_clientId_fkey") - enquiry Enquiry? @relation(fields: [enquiryId], references: [id], map: "Followup_enquiryId_fkey") - user User @relation(fields: [userId], references: [id], map: "Followup_userId_fkey") + id String @id @default(uuid()) + clientId String + enquiryId String? + userId String + date DateTime + notes String? + status String @default("PENDING") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + contentType String? + stage followup_stage @default(LEAD) + 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,25 +261,28 @@ 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 - userId String - items String @db.LongText - totalAmount Float - status quote_status @default(DRAFT) - pdfUrl String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - enquiry Enquiry @relation(fields: [enquiryId], references: [id], map: "Quote_enquiryId_fkey") - user User @relation(fields: [userId], references: [id], map: "Quote_userId_fkey") + id String @id @default(uuid()) + enquiryId String? + opportunityId String? + userId String + items String @db.LongText + totalAmount Float + status quote_status @default(DRAFT) + pdfUrl String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + enquiry Enquiry? @relation(fields: [enquiryId], references: [id], map: "Quote_enquiryId_fkey") + 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") } @@ -270,24 +305,24 @@ model StrategicActivity { } model Target { - id String @id @default(uuid()) - userId String - month Int - year Int - monthlyTarget Float - minTarget Float - weeklyTarget Float? - dailyLeadTarget Int? - requiredLeads Int? + id String @id @default(uuid()) + userId String + month Int + year Int + monthlyTarget Float + minTarget Float + weeklyTarget Float? + dailyLeadTarget Int? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + avgDealValue Float? + closureRatio Float? + requiredClosures Int? + requiredDemos Int? + requiredLeads Int? + requiredPotential Int? requiredQualityLeads Int? - requiredPotential Int? - requiredDemos Int? - requiredClosures Int? - closureRatio Float? - avgDealValue Float? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - user User @relation(fields: [userId], references: [id], map: "Target_userId_fkey") + user User @relation(fields: [userId], references: [id], map: "Target_userId_fkey") @@index([userId], map: "Target_userId_fkey") @@map("target") @@ -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 } diff --git a/service_log.txt b/service_log.txt new file mode 100644 index 0000000..86dcf02 --- /dev/null +++ b/service_log.txt @@ -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 diff --git a/src/automation/automation.service.ts b/src/automation/automation.service.ts index 8dd8cfe..7af04c1 100644 --- a/src/automation/automation.service.ts +++ b/src/automation/automation.service.ts @@ -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({ + private async processEscalations() { + // 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 } } diff --git a/src/clients/clients.service.ts b/src/clients/clients.service.ts index 039aec4..2812060 100644 --- a/src/clients/clients.service.ts +++ b/src/clients/clients.service.ts @@ -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 } }); } diff --git a/src/clients/dto/create-client.dto.ts b/src/clients/dto/create-client.dto.ts index 0f29f25..f60190e 100644 --- a/src/clients/dto/create-client.dto.ts +++ b/src/clients/dto/create-client.dto.ts @@ -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[]; } diff --git a/src/followups/dto/create-followup.dto.ts b/src/followups/dto/create-followup.dto.ts index 70f1d52..71a8d51 100644 --- a/src/followups/dto/create-followup.dto.ts +++ b/src/followups/dto/create-followup.dto.ts @@ -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; } diff --git a/src/followups/followups.service.ts b/src/followups/followups.service.ts index ef52e70..4d33a3d 100644 --- a/src/followups/followups.service.ts +++ b/src/followups/followups.service.ts @@ -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; } diff --git a/src/opportunities/dto/create-opportunity.dto.ts b/src/opportunities/dto/create-opportunity.dto.ts index e28a302..4a30ecf 100644 --- a/src/opportunities/dto/create-opportunity.dto.ts +++ b/src/opportunities/dto/create-opportunity.dto.ts @@ -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; diff --git a/src/opportunities/opportunities.service.ts b/src/opportunities/opportunities.service.ts index c87e3be..8ea705a 100644 --- a/src/opportunities/opportunities.service.ts +++ b/src/opportunities/opportunities.service.ts @@ -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(', ')}`); } } diff --git a/src/performance/performance.service.ts b/src/performance/performance.service.ts index 49f88dd..2e9b0fc 100644 --- a/src/performance/performance.service.ts +++ b/src/performance/performance.service.ts @@ -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) diff --git a/src/quotes/dto/create-quote.dto.ts b/src/quotes/dto/create-quote.dto.ts index 9c8021b..f71da31 100644 --- a/src/quotes/dto/create-quote.dto.ts +++ b/src/quotes/dto/create-quote.dto.ts @@ -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() diff --git a/src/quotes/quotes.service.ts b/src/quotes/quotes.service.ts index 073c7e9..2f038df 100644 --- a/src/quotes/quotes.service.ts +++ b/src/quotes/quotes.service.ts @@ -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 baseUrl = process.env.API_BASE_URL || 'http://localhost:3000'; - const mediaUrl = quote.pdfUrl ? `${baseUrl}${quote.pdfUrl}` : undefined; - await this.whatsappService.sendQuoteCreation( - quote.enquiry.client.phone, - quote.enquiry.client.name, - quote.totalAmount.toString(), - mediaUrl - ); + 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( + 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,11 +113,11 @@ 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'); diff --git a/src/reports/reports.service.ts b/src/reports/reports.service.ts index 4847cad..fe2c988 100644 --- a/src/reports/reports.service.ts +++ b/src/reports/reports.service.ts @@ -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: [ diff --git a/src/upload/upload.controller.ts b/src/upload/upload.controller.ts index d9091c9..22f35a4 100644 --- a/src/upload/upload.controller.ts +++ b/src/upload/upload.controller.ts @@ -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); } } diff --git a/src/whatsapp/whatsapp.service.ts b/src/whatsapp/whatsapp.service.ts index 78ee893..c334eba 100644 --- a/src/whatsapp/whatsapp.service.ts +++ b/src/whatsapp/whatsapp.service.ts @@ -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) { diff --git a/test_env.js b/test_env.js new file mode 100644 index 0000000..867114a --- /dev/null +++ b/test_env.js @@ -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(); diff --git a/tsconfig.build.json b/tsconfig.build.json index 64f86c6..2c8f61f 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -1,4 +1,4 @@ { "extends": "./tsconfig.json", - "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] + "exclude": ["node_modules", "test", "dist", "**/*spec.ts", "prisma"] } diff --git a/uploads/7c8adab31db9a231894a74d1045c9ce99.pdf b/uploads/7c8adab31db9a231894a74d1045c9ce99.pdf new file mode 100644 index 0000000..02f1802 Binary files /dev/null and b/uploads/7c8adab31db9a231894a74d1045c9ce99.pdf differ diff --git a/uploads/b07866d2bb27edb12e41a946171020947.jpg b/uploads/b07866d2bb27edb12e41a946171020947.jpg new file mode 100644 index 0000000..719e48c Binary files /dev/null and b/uploads/b07866d2bb27edb12e41a946171020947.jpg differ diff --git a/uploads/b2ff85106dde453be6a12e85c8e7eaca9.pdf b/uploads/b2ff85106dde453be6a12e85c8e7eaca9.pdf new file mode 100644 index 0000000..02f1802 Binary files /dev/null and b/uploads/b2ff85106dde453be6a12e85c8e7eaca9.pdf differ diff --git a/uploads/c101f2622e10753fef9ea5c9513e1e6ff2.pdf b/uploads/c101f2622e10753fef9ea5c9513e1e6ff2.pdf new file mode 100644 index 0000000..02f1802 Binary files /dev/null and b/uploads/c101f2622e10753fef9ea5c9513e1e6ff2.pdf differ diff --git a/uploads/cfc6624722ea043b688eb5d54573e11c.docx b/uploads/cfc6624722ea043b688eb5d54573e11c.docx new file mode 100644 index 0000000..dbe3dfa Binary files /dev/null and b/uploads/cfc6624722ea043b688eb5d54573e11c.docx differ diff --git a/whatsapp_info.json b/whatsapp_info.json new file mode 100644 index 0000000..4602d1f Binary files /dev/null and b/whatsapp_info.json differ