diff --git a/src/app.module.ts b/src/app.module.ts index 603bf32..36aaada 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -13,6 +13,7 @@ import { WorklistModule } from './worklist/worklist.module'; import { CallAssistModule } from './call-assist/call-assist.module'; import { SearchModule } from './search/search.module'; import { SupervisorModule } from './supervisor/supervisor.module'; +import { EmbedModule } from './embed/embed.module'; @Module({ imports: [ @@ -32,6 +33,7 @@ import { SupervisorModule } from './supervisor/supervisor.module'; CallAssistModule, SearchModule, SupervisorModule, + EmbedModule, ], }) export class AppModule {} diff --git a/src/embed/embed-cors.middleware.ts b/src/embed/embed-cors.middleware.ts new file mode 100644 index 0000000..ff02539 --- /dev/null +++ b/src/embed/embed-cors.middleware.ts @@ -0,0 +1,27 @@ +import { Injectable, NestMiddleware, Logger } from '@nestjs/common'; +import { Request, Response, NextFunction } from 'express'; + +@Injectable() +export class EmbedCorsMiddleware implements NestMiddleware { + private readonly logger = new Logger(EmbedCorsMiddleware.name); + + use(req: Request, res: Response, next: NextFunction) { + const origin = req.headers.origin || '*'; + this.logger.debug(`Embed CORS middleware - ${req.method} ${req.path} from ${origin}`); + + // Set CORS headers for all requests + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Accept, Authorization'); + res.setHeader('Access-Control-Max-Age', '86400'); // 24 hours + + // Handle preflight requests + if (req.method === 'OPTIONS') { + this.logger.debug('Handling OPTIONS preflight request'); + res.status(204).end(); + return; + } + + next(); + } +} diff --git a/src/embed/embed.module.ts b/src/embed/embed.module.ts new file mode 100644 index 0000000..010405f --- /dev/null +++ b/src/embed/embed.module.ts @@ -0,0 +1,18 @@ +import { Module, NestModule, MiddlewareConsumer, RequestMethod } from '@nestjs/common'; +import { PlatformModule } from '../platform/platform.module'; +import { LeadEmbedController } from './lead-embed.controller'; +import { EmbedCorsMiddleware } from './embed-cors.middleware'; + +@Module({ + imports: [PlatformModule], + controllers: [LeadEmbedController], +}) +export class EmbedModule implements NestModule { + configure(consumer: MiddlewareConsumer) { + consumer + .apply(EmbedCorsMiddleware) + .forRoutes( + { path: 'embed/*', method: RequestMethod.ALL } + ); + } +} diff --git a/src/embed/lead-embed.controller.ts b/src/embed/lead-embed.controller.ts new file mode 100644 index 0000000..86b2b5d --- /dev/null +++ b/src/embed/lead-embed.controller.ts @@ -0,0 +1,176 @@ +import { Controller, Post, Body, Logger, HttpException } from '@nestjs/common'; +import { PlatformGraphqlService } from '../platform/platform-graphql.service'; +import { ConfigService } from '@nestjs/config'; + +@Controller('embed/leads') +export class LeadEmbedController { + private readonly logger = new Logger(LeadEmbedController.name); + private readonly apiKey: string; + + constructor( + private readonly platform: PlatformGraphqlService, + private readonly config: ConfigService, + ) { + this.apiKey = config.get('platform.apiKey') ?? ''; + } + + @Post('create') + async handleLeadCreation(@Body() body: Record) { + this.logger.log(`Lead creation from embed received: ${JSON.stringify(body)}`); + + const authHeader = this.apiKey ? `Bearer ${this.apiKey}` : ''; + if (!authHeader) { + this.logger.warn('No PLATFORM_API_KEY configured — cannot create lead'); + throw new HttpException('Server configuration error', 500); + } + + try { + const leadData = this.mapIncomingDataToLead(body); + + if (!leadData.contactPhone && !leadData.contactEmail) { + throw new HttpException('Either contact phone or email is required', 400); + } + + const result = await this.platform.queryWithAuth( + `mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`, + { data: leadData }, + authHeader, + ); + + const leadId = result.createLead.id; + this.logger.log(`Lead created successfully: ${leadId}`); + + if (body.notes || body.type) { + await this.createInitialActivity(leadId, body, authHeader); + } + + return { + success: true, + leadId, + message: 'Lead created successfully', + }; + } catch (error: any) { + const responseData = error?.response?.data ? JSON.stringify(error.response.data) : ''; + this.logger.error(`Lead creation failed: ${error.message} ${responseData}`); + + if (error instanceof HttpException) { + throw error; + } + + throw new HttpException( + error.message || 'Lead creation failed', + error.response?.status || 500, + ); + } + } + + private mapIncomingDataToLead(body: Record): Record { + const leadData: Record = {}; + + const contactName = body.contact_name || body.contactName || 'Unknown'; + const nameParts = contactName.split(' '); + const firstName = nameParts[0] || 'Unknown'; + const lastName = nameParts.slice(1).join(' '); + + leadData.name = contactName; + leadData.contactName = { + firstName, + lastName: lastName || undefined, + }; + + if (body.contact_phone || body.contactPhone) { + const phone = body.contact_phone || body.contactPhone; + const cleanPhone = phone.replace(/\D/g, ''); + leadData.contactPhone = { + primaryPhoneNumber: cleanPhone.startsWith('91') ? `+${cleanPhone}` : `+91${cleanPhone}`, + }; + } + + if (body.contact_email || body.contactEmail) { + leadData.contactEmail = { + primaryEmail: body.contact_email || body.contactEmail, + }; + } + + leadData.source = body.source || 'WEBSITE'; + leadData.status = body.lead_status || body.status || 'NEW'; + + const interestedService = this.mapInterestedService(body); + if (interestedService) { + leadData.interestedService = interestedService; + } + + if (body.assigned_agent || body.assignedAgent) { + leadData.assignedAgent = body.assigned_agent || body.assignedAgent; + } + + if (body.campaign_id || body.campaignId) { + leadData.campaignId = body.campaign_id || body.campaignId; + } + + return leadData; + } + + private mapInterestedService(body: Record): string | null { + const type = body.type || body.interested_service || body.interestedService; + + if (!type) { + return body.department || null; + } + + const serviceMap: Record = { + 'consultation': 'Appointment', + 'follow_up': 'Appointment', + 'procedure': 'Appointment', + 'emergency': 'Appointment', + 'general_enquiry': 'General Enquiry', + 'general': 'General Enquiry', + }; + + return serviceMap[type.toLowerCase()] || type; + } + + private async createInitialActivity( + leadId: string, + body: Record, + authHeader: string, + ): Promise { + try { + const activityType = body.type === 'consultation' || body.type === 'appointment' + ? 'APPOINTMENT_BOOKED' + : 'FORM_SUBMITTED'; + + let summary = 'Lead submitted via web form'; + if (body.type) { + summary = `${body.type.replace(/_/g, ' ')} requested`; + } + if (body.department) { + summary += ` - ${body.department}`; + } + if (body.title) { + summary += ` (from ${body.title})`; + } + + await this.platform.queryWithAuth( + `mutation($data: LeadActivityCreateInput!) { createLeadActivity(data: $data) { id } }`, + { + data: { + name: summary.substring(0, 80), + activityType, + summary, + occurredAt: new Date().toISOString(), + performedBy: 'System', + channel: 'PHONE', + leadId, + }, + }, + authHeader, + ); + + this.logger.log(`Initial activity created for lead ${leadId}`); + } catch (error: any) { + const errorDetails = error?.response?.data ? JSON.stringify(error.response.data) : error.message; + this.logger.error(`Failed to create initial activity: ${errorDetails}`); + } + } +} diff --git a/src/main.ts b/src/main.ts index eb13e25..4d42889 100644 --- a/src/main.ts +++ b/src/main.ts @@ -11,6 +11,7 @@ async function bootstrap() { credentials: true, }); + const port = config.get('port'); await app.listen(port); console.log(`Helix Engage Server running on port ${port}`); diff --git a/src/platform/platform-graphql.service.ts b/src/platform/platform-graphql.service.ts index cc8389a..c66ff57 100644 --- a/src/platform/platform-graphql.service.ts +++ b/src/platform/platform-graphql.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import axios from 'axios'; -import type { LeadNode, LeadActivityNode, CreateCallInput, CreateLeadActivityInput, UpdateLeadInput } from './platform.types'; +import type { LeadNode, LeadActivityNode, CreateCallInput, CreateLeadActivityInput, CreateLeadInput, UpdateLeadInput } from './platform.types'; @Injectable() export class PlatformGraphqlService { @@ -120,6 +120,16 @@ export class PlatformGraphqlService { return data.createLeadActivity; } + async createLead(input: CreateLeadInput): Promise<{ id: string }> { + const data = await this.query<{ createLead: { id: string } }>( + `mutation CreateLead($data: LeadCreateInput!) { + createLead(data: $data) { id } + }`, + { data: input }, + ); + return data.createLead; + } + // --- Token passthrough versions (for user-driven requests) --- async findLeadByPhoneWithToken(phone: string, authHeader: string): Promise { diff --git a/src/platform/platform.types.ts b/src/platform/platform.types.ts index 490d5f3..15ad70c 100644 --- a/src/platform/platform.types.ts +++ b/src/platform/platform.types.ts @@ -62,6 +62,19 @@ export type CreateLeadActivityInput = { leadId: string; }; +export type CreateLeadInput = { + name: string; + contactName?: { firstName: string; lastName?: string }; + contactPhone?: { primaryPhoneNumber: string }; + contactEmail?: { primaryEmailAddress: string }; + source?: string; + status?: string; + interestedService?: string; + assignedAgent?: string; + campaignId?: string; + notes?: string; +}; + export type UpdateLeadInput = { leadStatus?: string; lastContactedAt?: string;