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; } } // --- 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); } }