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('platform.graphqlUrl')!; this.apiKey = config.get('platform.apiKey')!; } // Server-to-server query using API key async query(query: string, variables?: Record): Promise { return this.queryWithAuth(query, variables, `Bearer ${this.apiKey}`); } // Query using a passed-through auth header (user JWT) 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: authHeader, }, }, ); if (response.data.errors) { throw new Error(`GraphQL error: ${JSON.stringify(response.data.errors)}`); } return response.data.data; } async findLeadByPhone(phone: string): Promise { // 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 { 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 { 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 { 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 { // 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 { 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 { 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 { 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 { 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); } }