Files
helix-engage-server/src/platform/platform-graphql.service.ts
2026-04-16 14:54:17 +05:30

392 lines
13 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios from 'axios';
import type {
LeadNode,
LeadActivityNode,
CreateCallInput,
CreateLeadActivityInput,
UpdateLeadInput,
} from './platform.types';
@Injectable()
export class PlatformGraphqlService {
private readonly graphqlUrl: string;
private readonly apiKey: string;
constructor(private config: ConfigService) {
this.graphqlUrl = config.get<string>('platform.graphqlUrl')!;
this.apiKey = config.get<string>('platform.apiKey')!;
}
// Server-to-server query using API key
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)
async queryWithAuth<T>(
query: string,
variables: Record<string, any> | undefined,
authHeader: string,
): Promise<T> {
const response = await axios.post(
this.graphqlUrl,
{ query, variables },
{
headers: {
'Content-Type': 'application/json',
Authorization: authHeader,
},
},
);
if (response.data.errors) {
throw new Error(`GraphQL error: ${JSON.stringify(response.data.errors)}`);
}
return response.data.data;
}
async findLeadByPhone(phone: string): Promise<LeadNode | null> {
// Note: The exact filter syntax for PHONES fields depends on the platform
// This queries leads and filters client-side by phone number
const data = await this.query<{ 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: 100 },
);
// Client-side phone matching (strip non-digits for comparison)
const normalizedPhone = phone.replace(/\D/g, '');
return (
data.leads.edges.find((edge) => {
const leadPhones = edge.node.contactPhone ?? [];
return leadPhones.some(
(p) =>
p.number.replace(/\D/g, '').endsWith(normalizedPhone) ||
normalizedPhone.endsWith(p.number.replace(/\D/g, '')),
);
})?.node ?? null
);
}
async findLeadById(id: string): Promise<LeadNode | null> {
const data = await this.query<{ lead: LeadNode }>(
`query FindLead($id: ID!) {
lead(id: $id) {
id createdAt
contactName { firstName lastName }
contactPhone { number callingCode }
contactEmail { address }
leadSource leadStatus interestedService
assignedAgent campaignId adId
contactAttempts spamScore isSpam
aiSummary aiSuggestedAction
}
}`,
{ id },
);
return data.lead;
}
async updateLead(id: string, input: UpdateLeadInput): Promise<LeadNode> {
const data = await this.query<{ updateLead: LeadNode }>(
`mutation UpdateLead($id: ID!, $data: LeadUpdateInput!) {
updateLead(id: $id, data: $data) {
id leadStatus aiSummary aiSuggestedAction
}
}`,
{ id, data: input },
);
return data.updateLead;
}
async createCall(input: CreateCallInput): Promise<{ id: string }> {
const data = await this.query<{ createCall: { id: string } }>(
`mutation CreateCall($data: CallCreateInput!) {
createCall(data: $data) { id }
}`,
{ data: input },
);
return data.createCall;
}
async createLeadActivity(
input: CreateLeadActivityInput,
): Promise<{ id: string }> {
const data = await this.query<{ createLeadActivity: { id: string } }>(
`mutation CreateLeadActivity($data: LeadActivityCreateInput!) {
createLeadActivity(data: $data) { id }
}`,
{ data: input },
);
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> {
// Response fragment deliberately excludes `leadStatus` — the staging
// platform schema has this field renamed to `status`. Selecting the
// old name rejects the whole mutation. Callers don't use the
// returned fragment today, so returning just the id + AI fields
// keeps this working across both schema shapes without a wider
// rename hotfix.
const data = await this.queryWithAuth<{ updateLead: LeadNode }>(
`mutation UpdateLead($id: ID!, $data: LeadUpdateInput!) {
updateLead(id: $id, data: $data) {
id aiSummary aiSuggestedAction
}
}`,
{ id, data: input },
authHeader,
);
return data.updateLead;
}
// Fetch a single lead by id with the caller's JWT. Used by the
// lead-enrich flow when the agent explicitly renames a caller from
// the appointment/enquiry form and we need to regenerate the lead's
// AI summary against fresh identity.
//
// The selected fields deliberately use the staging-aligned names
// (`status`, `source`, `lastContacted`) rather than the older
// `leadStatus`/`leadSource`/`lastContactedAt` names — otherwise the
// query would be rejected on staging.
async findLeadByIdWithToken(
id: string,
authHeader: string,
): Promise<any | null> {
try {
const data = await this.queryWithAuth<{ lead: any }>(
`query FindLead($id: UUID!) {
lead(filter: { id: { eq: $id } }) {
id
createdAt
contactName { firstName lastName }
contactPhone { primaryPhoneNumber primaryPhoneCallingCode }
source
status
interestedService
contactAttempts
lastContacted
aiSummary
aiSuggestedAction
}
}`,
{ id },
authHeader,
);
return data.lead ?? null;
} catch {
// Fall back to edge-style query in case the singular field
// doesn't exist on this platform version.
const data = await this.queryWithAuth<{
leads: { edges: { node: any }[] };
}>(
`query FindLead($id: UUID!) {
leads(filter: { id: { eq: $id } }, first: 1) {
edges {
node {
id
createdAt
contactName { firstName lastName }
contactPhone { primaryPhoneNumber primaryPhoneCallingCode }
source
status
interestedService
contactAttempts
lastContacted
aiSummary
aiSuggestedAction
}
}
}
}`,
{ id },
authHeader,
);
return data.leads.edges[0]?.node ?? null;
}
}
async getPatientWithToken(
patientId: string,
authHeader: string,
): Promise<any | null> {
try {
const data = await this.queryWithAuth<{ patient: any }>(
`query GetPatient($id: UUID!) {
patient(filter: { id: { eq: $id } }) {
id
fullName { firstName lastName }
dateOfBirth
patientType
}
}`,
{ id: patientId },
authHeader,
);
return data.patient ?? null;
} catch {
return null;
}
}
async getUpcomingAppointmentsWithToken(
patientId: string,
authHeader: string,
limit = 3,
): Promise<any[]> {
try {
const data = await this.queryWithAuth<{
appointments: { edges: { node: any }[] };
}>(
`query GetAppointments($filter: AppointmentFilterInput, $first: Int) {
appointments(filter: $filter, first: $first, orderBy: [{ scheduledAt: AscNullsLast }]) {
edges {
node {
id
scheduledAt
appointmentType
doctorName
status
}
}
}
}`,
{
filter: {
patientId: { eq: patientId },
scheduledAt: { gte: new Date().toISOString() },
},
first: limit,
},
authHeader,
);
return data.appointments.edges.map((e) => e.node);
} catch {
return [];
}
}
// --- Server-to-server versions (for webhooks, background jobs) ---
async getLeadActivities(
leadId: string,
limit = 3,
): Promise<LeadActivityNode[]> {
const data = await this.query<{
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 },
);
return data.leadActivities.edges.map((e) => e.node);
}
}