mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-11 18:08:16 +00:00
feat: add call lookup endpoint with lead matching + AI enrichment, token passthrough on platform service
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { Controller, Post, Body, Logger, HttpException } from '@nestjs/common';
|
import { Controller, Post, Body, Logger, HttpException } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
|
||||||
|
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
@@ -9,7 +10,10 @@ export class AuthController {
|
|||||||
private readonly workspaceSubdomain: string;
|
private readonly workspaceSubdomain: string;
|
||||||
private readonly origin: string;
|
private readonly origin: string;
|
||||||
|
|
||||||
constructor(private config: ConfigService) {
|
constructor(
|
||||||
|
private config: ConfigService,
|
||||||
|
private ozonetelAgent: OzonetelAgentService,
|
||||||
|
) {
|
||||||
this.graphqlUrl = config.get<string>('platform.graphqlUrl')!;
|
this.graphqlUrl = config.get<string>('platform.graphqlUrl')!;
|
||||||
this.workspaceSubdomain = process.env.PLATFORM_WORKSPACE_SUBDOMAIN ?? 'fortytwo-dev';
|
this.workspaceSubdomain = process.env.PLATFORM_WORKSPACE_SUBDOMAIN ?? 'fortytwo-dev';
|
||||||
this.origin = process.env.PLATFORM_ORIGIN ?? 'http://fortytwo-dev.localhost:4010';
|
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;
|
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 {
|
return {
|
||||||
accessToken: tokens.accessOrWorkspaceAgnosticToken.token,
|
accessToken: tokens.accessOrWorkspaceAgnosticToken.token,
|
||||||
refreshToken: tokens.refreshToken.token,
|
refreshToken: tokens.refreshToken.token,
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
|
import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [OzonetelAgentModule],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import { PlatformModule } from '../platform/platform.module';
|
|||||||
import { AiModule } from '../ai/ai.module';
|
import { AiModule } from '../ai/ai.module';
|
||||||
import { CallEventsService } from './call-events.service';
|
import { CallEventsService } from './call-events.service';
|
||||||
import { CallEventsGateway } from './call-events.gateway';
|
import { CallEventsGateway } from './call-events.gateway';
|
||||||
|
import { CallLookupController } from './call-lookup.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PlatformModule, AiModule],
|
imports: [PlatformModule, AiModule],
|
||||||
|
controllers: [CallLookupController],
|
||||||
providers: [CallEventsService, CallEventsGateway],
|
providers: [CallEventsService, CallEventsGateway],
|
||||||
exports: [CallEventsService, CallEventsGateway],
|
exports: [CallEventsService, CallEventsGateway],
|
||||||
})
|
})
|
||||||
|
|||||||
88
src/call-events/call-lookup.controller.ts
Normal file
88
src/call-events/call-lookup.controller.ts
Normal file
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,14 +13,20 @@ export class PlatformGraphqlService {
|
|||||||
this.apiKey = config.get<string>('platform.apiKey')!;
|
this.apiKey = config.get<string>('platform.apiKey')!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Server-to-server query using API key
|
||||||
private async query<T>(query: string, variables?: Record<string, any>): Promise<T> {
|
private async query<T>(query: string, variables?: Record<string, any>): Promise<T> {
|
||||||
|
return this.queryWithAuth<T>(query, variables, `Bearer ${this.apiKey}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query using a passed-through auth header (user JWT)
|
||||||
|
private async queryWithAuth<T>(query: string, variables: Record<string, any> | undefined, authHeader: string): Promise<T> {
|
||||||
const response = await axios.post(
|
const response = await axios.post(
|
||||||
this.graphqlUrl,
|
this.graphqlUrl,
|
||||||
{ query, variables },
|
{ query, variables },
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Authorization': `Bearer ${this.apiKey}`,
|
'Authorization': authHeader,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -114,6 +120,80 @@ export class PlatformGraphqlService {
|
|||||||
return data.createLeadActivity;
|
return data.createLeadActivity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Token passthrough versions (for user-driven requests) ---
|
||||||
|
|
||||||
|
async findLeadByPhoneWithToken(phone: string, authHeader: string): Promise<LeadNode | null> {
|
||||||
|
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<LeadActivityNode[]> {
|
||||||
|
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<LeadNode> {
|
||||||
|
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<LeadActivityNode[]> {
|
async getLeadActivities(leadId: string, limit = 3): Promise<LeadActivityNode[]> {
|
||||||
const data = await this.query<{ leadActivities: { edges: { node: LeadActivityNode }[] } }>(
|
const data = await this.query<{ leadActivities: { edges: { node: LeadActivityNode }[] } }>(
|
||||||
`query GetLeadActivities($filter: LeadActivityFilterInput, $first: Int) {
|
`query GetLeadActivities($filter: LeadActivityFilterInput, $first: Int) {
|
||||||
|
|||||||
Reference in New Issue
Block a user