mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
392 lines
13 KiB
TypeScript
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);
|
|
}
|
|
}
|