From 22ac38310706f5d0d4f7f914364d8741ea3b4032 Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Wed, 18 Mar 2026 09:11:15 +0530 Subject: [PATCH] feat: add call lookup endpoint with lead matching + AI enrichment, token passthrough on platform service --- src/auth/auth.controller.ts | 20 +++++- src/auth/auth.module.ts | 2 + src/call-events/call-events.module.ts | 2 + src/call-events/call-lookup.controller.ts | 88 +++++++++++++++++++++++ src/platform/platform-graphql.service.ts | 82 ++++++++++++++++++++- 5 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 src/call-events/call-lookup.controller.ts diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 9861efd..25d24b1 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -1,6 +1,7 @@ import { Controller, Post, Body, Logger, HttpException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import axios from 'axios'; +import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service'; @Controller('auth') export class AuthController { @@ -9,7 +10,10 @@ export class AuthController { private readonly workspaceSubdomain: string; private readonly origin: string; - constructor(private config: ConfigService) { + constructor( + private config: ConfigService, + private ozonetelAgent: OzonetelAgentService, + ) { this.graphqlUrl = config.get('platform.graphqlUrl')!; this.workspaceSubdomain = process.env.PLATFORM_WORKSPACE_SUBDOMAIN ?? 'fortytwo-dev'; this.origin = process.env.PLATFORM_ORIGIN ?? 'http://fortytwo-dev.localhost:4010'; @@ -78,6 +82,20 @@ export class AuthController { const tokens = tokenRes.data.data.getAuthTokensFromLoginToken.tokens; + // Auto-login Ozonetel agent (fire and forget — don't block auth) + const ozAgentId = process.env.OZONETEL_AGENT_ID ?? 'agent3'; + const ozAgentPassword = process.env.OZONETEL_AGENT_PASSWORD ?? 'Test123$'; + const ozSipId = process.env.OZONETEL_SIP_ID ?? '521814'; + + this.ozonetelAgent.loginAgent({ + agentId: ozAgentId, + password: ozAgentPassword, + phoneNumber: ozSipId, + mode: 'blended', + }).catch(err => { + this.logger.warn(`Ozonetel agent login failed (non-blocking): ${err.message}`); + }); + return { accessToken: tokens.accessOrWorkspaceAgnosticToken.token, refreshToken: tokens.refreshToken.token, diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 2d08216..76842f5 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -1,7 +1,9 @@ import { Module } from '@nestjs/common'; import { AuthController } from './auth.controller'; +import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module'; @Module({ + imports: [OzonetelAgentModule], controllers: [AuthController], }) export class AuthModule {} diff --git a/src/call-events/call-events.module.ts b/src/call-events/call-events.module.ts index ff26097..5eea515 100644 --- a/src/call-events/call-events.module.ts +++ b/src/call-events/call-events.module.ts @@ -3,9 +3,11 @@ import { PlatformModule } from '../platform/platform.module'; import { AiModule } from '../ai/ai.module'; import { CallEventsService } from './call-events.service'; import { CallEventsGateway } from './call-events.gateway'; +import { CallLookupController } from './call-lookup.controller'; @Module({ imports: [PlatformModule, AiModule], + controllers: [CallLookupController], providers: [CallEventsService, CallEventsGateway], exports: [CallEventsService, CallEventsGateway], }) diff --git a/src/call-events/call-lookup.controller.ts b/src/call-events/call-lookup.controller.ts new file mode 100644 index 0000000..331caa4 --- /dev/null +++ b/src/call-events/call-lookup.controller.ts @@ -0,0 +1,88 @@ +import { Controller, Post, Body, Logger, Headers, HttpException } from '@nestjs/common'; +import { PlatformGraphqlService } from '../platform/platform-graphql.service'; +import { AiEnrichmentService } from '../ai/ai-enrichment.service'; + +@Controller('api/call') +export class CallLookupController { + private readonly logger = new Logger(CallLookupController.name); + + constructor( + private readonly platform: PlatformGraphqlService, + private readonly ai: AiEnrichmentService, + ) {} + + @Post('lookup') + async lookupCaller( + @Body() body: { phoneNumber: string }, + @Headers('authorization') authHeader: string, + ) { + if (!authHeader) throw new HttpException('Authorization required', 401); + if (!body.phoneNumber) throw new HttpException('phoneNumber required', 400); + + const phone = body.phoneNumber.replace(/^0+/, ''); + this.logger.log(`Looking up caller: ${phone}`); + + // Query platform for leads matching this phone number + let lead = null; + let activities: any[] = []; + + try { + lead = await this.platform.findLeadByPhoneWithToken(phone, authHeader); + } catch (err) { + this.logger.warn(`Lead lookup failed: ${err}`); + } + + if (lead) { + this.logger.log(`Matched lead: ${lead.id} — ${lead.contactName?.firstName} ${lead.contactName?.lastName}`); + + // Get recent activities + try { + activities = await this.platform.getLeadActivitiesWithToken(lead.id, authHeader, 5); + } catch (err) { + this.logger.warn(`Activity fetch failed: ${err}`); + } + + // AI enrichment if no existing summary + if (!lead.aiSummary) { + try { + const enrichment = await this.ai.enrichLead({ + firstName: lead.contactName?.firstName, + lastName: lead.contactName?.lastName, + leadSource: lead.leadSource ?? undefined, + interestedService: lead.interestedService ?? undefined, + leadStatus: lead.leadStatus ?? undefined, + contactAttempts: lead.contactAttempts ?? undefined, + createdAt: lead.createdAt, + activities: activities.map((a: any) => ({ + activityType: a.activityType ?? '', + summary: a.summary ?? '', + })), + }); + + lead.aiSummary = enrichment.aiSummary; + lead.aiSuggestedAction = enrichment.aiSuggestedAction; + + // Persist AI enrichment back to platform + try { + await this.platform.updateLeadWithToken(lead.id, { + aiSummary: enrichment.aiSummary, + aiSuggestedAction: enrichment.aiSuggestedAction, + }, authHeader); + } catch (err) { + this.logger.warn(`Failed to persist AI enrichment: ${err}`); + } + } catch (err) { + this.logger.warn(`AI enrichment failed: ${err}`); + } + } + } else { + this.logger.log(`No lead found for phone ${phone}`); + } + + return { + lead, + activities, + matched: lead !== null, + }; + } +} diff --git a/src/platform/platform-graphql.service.ts b/src/platform/platform-graphql.service.ts index fa67f52..95c0fa2 100644 --- a/src/platform/platform-graphql.service.ts +++ b/src/platform/platform-graphql.service.ts @@ -13,14 +13,20 @@ export class PlatformGraphqlService { this.apiKey = config.get('platform.apiKey')!; } + // Server-to-server query using API key private async query(query: string, variables?: Record): Promise { + return this.queryWithAuth(query, variables, `Bearer ${this.apiKey}`); + } + + // Query using a passed-through auth header (user JWT) + private async queryWithAuth(query: string, variables: Record | undefined, authHeader: string): Promise { const response = await axios.post( this.graphqlUrl, { query, variables }, { headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${this.apiKey}`, + 'Authorization': authHeader, }, }, ); @@ -114,6 +120,80 @@ export class PlatformGraphqlService { return data.createLeadActivity; } + // --- Token passthrough versions (for user-driven requests) --- + + async findLeadByPhoneWithToken(phone: string, authHeader: string): Promise { + const normalizedPhone = phone.replace(/\D/g, ''); + const last10 = normalizedPhone.slice(-10); + + const data = await this.queryWithAuth<{ leads: { edges: { node: LeadNode }[] } }>( + `query FindLeads($first: Int) { + leads(first: $first, orderBy: [{ createdAt: DescNullsLast }]) { + edges { + node { + id createdAt + contactName { firstName lastName } + contactPhone { number callingCode } + contactEmail { address } + leadSource leadStatus interestedService + assignedAgent campaignId adId + contactAttempts spamScore isSpam + aiSummary aiSuggestedAction + } + } + } + }`, + { first: 200 }, + authHeader, + ); + + // Client-side phone matching + return data.leads.edges.find(edge => { + const phones = edge.node.contactPhone ?? []; + if (Array.isArray(phones)) { + return phones.some((p: any) => { + const num = (p.number ?? p.primaryPhoneNumber ?? '').replace(/\D/g, ''); + return num.endsWith(last10) || last10.endsWith(num); + }); + } + // Handle single phone object + const num = ((phones as any).primaryPhoneNumber ?? (phones as any).number ?? '').replace(/\D/g, ''); + return num.endsWith(last10) || last10.endsWith(num); + })?.node ?? null; + } + + async getLeadActivitiesWithToken(leadId: string, authHeader: string, limit = 5): Promise { + const data = await this.queryWithAuth<{ leadActivities: { edges: { node: LeadActivityNode }[] } }>( + `query GetLeadActivities($filter: LeadActivityFilterInput, $first: Int) { + leadActivities(filter: $filter, first: $first, orderBy: [{ occurredAt: DescNullsLast }]) { + edges { + node { + id activityType summary occurredAt performedBy channel + } + } + } + }`, + { filter: { leadId: { eq: leadId } }, first: limit }, + authHeader, + ); + return data.leadActivities.edges.map(e => e.node); + } + + async updateLeadWithToken(id: string, input: UpdateLeadInput, authHeader: string): Promise { + const data = await this.queryWithAuth<{ updateLead: LeadNode }>( + `mutation UpdateLead($id: ID!, $data: LeadUpdateInput!) { + updateLead(id: $id, data: $data) { + id leadStatus aiSummary aiSuggestedAction + } + }`, + { id, data: input }, + authHeader, + ); + return data.updateLead; + } + + // --- Server-to-server versions (for webhooks, background jobs) --- + async getLeadActivities(leadId: string, limit = 3): Promise { const data = await this.query<{ leadActivities: { edges: { node: LeadActivityNode }[] } }>( `query GetLeadActivities($filter: LeadActivityFilterInput, $first: Int) {