AI Summary not showing appointments fix.

This commit is contained in:
Kartik Datrika
2026-04-16 11:36:10 +05:30
parent 6adb3985cb
commit 973614749b
5 changed files with 702 additions and 433 deletions

4
.gitignore vendored
View File

@@ -42,3 +42,7 @@ lerna-debug.log*
# Each environment mints its own HMAC-signed site key. # Each environment mints its own HMAC-signed site key.
data/widget.json data/widget.json
data/widget-backups/ data/widget-backups/
.env.local
.grepai/config.yaml
.grepai/symbols.gob
.grepai/symbols.gob.lock

View File

@@ -15,6 +15,17 @@ type LeadContext = {
contactAttempts?: number; contactAttempts?: number;
createdAt?: string; createdAt?: string;
campaignId?: string; campaignId?: string;
patient?: {
age?: number;
type?: string; // 'new' | 'returning' | 'vip'
hasRecords?: boolean;
};
upcomingAppointments?: Array<{
scheduledAt?: string;
doctorName?: string;
appointmentType?: string;
status?: string;
}>;
activities?: { activityType: string; summary: string }[]; activities?: { activityType: string; summary: string }[];
}; };
@@ -24,8 +35,12 @@ type EnrichmentResult = {
}; };
const enrichmentSchema = z.object({ const enrichmentSchema = z.object({
aiSummary: z.string().describe('1-2 sentence summary of who this lead is and their history'), aiSummary: z
aiSuggestedAction: z.string().describe('5-10 word suggested action for the agent'), .string()
.describe('1-2 sentence summary of who this lead is and their history'),
aiSuggestedAction: z
.string()
.describe('5-10 word suggested action for the agent'),
}); });
@Injectable() @Injectable()
@@ -56,28 +71,51 @@ export class AiEnrichmentService {
try { try {
const daysSince = lead.createdAt const daysSince = lead.createdAt
? Math.floor((Date.now() - new Date(lead.createdAt).getTime()) / (1000 * 60 * 60 * 24)) ? Math.floor(
(Date.now() - new Date(lead.createdAt).getTime()) /
(1000 * 60 * 60 * 24),
)
: 0; : 0;
const activitiesText = lead.activities?.length const activitiesText = lead.activities?.length
? lead.activities.map(a => `- ${a.activityType}: ${a.summary}`).join('\n') ? lead.activities
.map((a) => `- ${a.activityType}: ${a.summary}`)
.join('\n')
: 'No previous interactions'; : 'No previous interactions';
const patientContext = lead.patient
? `Age: ${lead.patient.age ?? 'Unknown'} | Type: ${lead.patient.type ?? 'Unknown'} | Prior Records: ${lead.patient.hasRecords ? 'Yes' : 'No'}`
: 'No patient record linked';
const appointmentContext = lead.upcomingAppointments?.length
? lead.upcomingAppointments
.map(
(a) =>
`- ${a.scheduledAt ? new Date(a.scheduledAt).toLocaleDateString() : 'TBD'}: ${a.appointmentType ?? 'Appointment'} with ${a.doctorName ?? 'Doctor'} (${a.status ?? 'Scheduled'})`,
)
.join('\n')
: 'No upcoming appointments';
const { object } = await generateObject({ const { object } = await generateObject({
model: this.aiModel!, model: this.aiModel!,
schema: enrichmentSchema, schema: enrichmentSchema,
prompt: this.aiConfig.renderPrompt('leadEnrichment', { prompt: this.aiConfig.renderPrompt('leadEnrichment', {
leadName: `${lead.firstName ?? 'Unknown'} ${lead.lastName ?? ''}`.trim(), leadName:
`${lead.firstName ?? 'Unknown'} ${lead.lastName ?? ''}`.trim(),
leadSource: lead.leadSource ?? 'Unknown', leadSource: lead.leadSource ?? 'Unknown',
interestedService: lead.interestedService ?? 'Unknown', interestedService: lead.interestedService ?? 'Unknown',
leadStatus: lead.leadStatus ?? 'Unknown', leadStatus: lead.leadStatus ?? 'Unknown',
daysSince, daysSince,
contactAttempts: lead.contactAttempts ?? 0, contactAttempts: lead.contactAttempts ?? 0,
patientContext,
appointmentContext,
activities: activitiesText, activities: activitiesText,
}), }),
}); });
this.logger.log(`AI enrichment generated for lead ${lead.firstName} ${lead.lastName}`); this.logger.log(
`AI enrichment generated for lead ${lead.firstName} ${lead.lastName}`,
);
return object; return object;
} catch (error) { } catch (error) {
this.logger.error(`AI enrichment failed: ${error}`); this.logger.error(`AI enrichment failed: ${error}`);
@@ -87,12 +125,16 @@ export class AiEnrichmentService {
private fallbackEnrichment(lead: LeadContext): EnrichmentResult { private fallbackEnrichment(lead: LeadContext): EnrichmentResult {
const daysSince = lead.createdAt const daysSince = lead.createdAt
? Math.floor((Date.now() - new Date(lead.createdAt).getTime()) / (1000 * 60 * 60 * 24)) ? Math.floor(
(Date.now() - new Date(lead.createdAt).getTime()) /
(1000 * 60 * 60 * 24),
)
: 0; : 0;
const attempts = lead.contactAttempts ?? 0; const attempts = lead.contactAttempts ?? 0;
const service = lead.interestedService ?? 'general inquiry'; const service = lead.interestedService ?? 'general inquiry';
const source = lead.leadSource?.replace(/_/g, ' ').toLowerCase() ?? 'unknown source'; const source =
lead.leadSource?.replace(/_/g, ' ').toLowerCase() ?? 'unknown source';
let summary: string; let summary: string;
let action: string; let action: string;

View File

@@ -1,4 +1,11 @@
import { Controller, Post, Body, Logger, Headers, HttpException } from '@nestjs/common'; import {
Controller,
Post,
Body,
Logger,
Headers,
HttpException,
} from '@nestjs/common';
import { PlatformGraphqlService } from '../platform/platform-graphql.service'; import { PlatformGraphqlService } from '../platform/platform-graphql.service';
import { AiEnrichmentService } from '../ai/ai-enrichment.service'; import { AiEnrichmentService } from '../ai/ai-enrichment.service';
@@ -33,17 +40,51 @@ export class CallLookupController {
} }
if (lead) { if (lead) {
this.logger.log(`Matched lead: ${lead.id}${lead.contactName?.firstName} ${lead.contactName?.lastName}`); this.logger.log(
`Matched lead: ${lead.id}${lead.contactName?.firstName} ${lead.contactName?.lastName}`,
);
// Get recent activities // Get recent activities
try { try {
activities = await this.platform.getLeadActivitiesWithToken(lead.id, authHeader, 5); activities = await this.platform.getLeadActivitiesWithToken(
lead.id,
authHeader,
5,
);
} catch (err) { } catch (err) {
this.logger.warn(`Activity fetch failed: ${err}`); this.logger.warn(`Activity fetch failed: ${err}`);
} }
// Fetch patient context if patientId exists
let patientData = null;
let upcomingAppointments: any[] = [];
if (lead.patientId) {
try {
patientData = await this.platform.getPatientWithToken(
lead.patientId,
authHeader,
);
} catch (err) {
this.logger.warn(`Patient fetch failed: ${err}`);
}
if (patientData) {
try {
upcomingAppointments =
await this.platform.getUpcomingAppointmentsWithToken(
lead.patientId,
authHeader,
3,
);
} catch (err) {
this.logger.warn(`Appointment fetch failed: ${err}`);
}
}
}
// AI enrichment if no existing summary // AI enrichment if no existing summary
if (!lead.aiSummary) { // generate aiSummary everytime
// if (!lead.aiSummary) {
try { try {
const enrichment = await this.ai.enrichLead({ const enrichment = await this.ai.enrichLead({
firstName: lead.contactName?.firstName, firstName: lead.contactName?.firstName,
@@ -53,6 +94,20 @@ export class CallLookupController {
leadStatus: lead.leadStatus ?? undefined, leadStatus: lead.leadStatus ?? undefined,
contactAttempts: lead.contactAttempts ?? undefined, contactAttempts: lead.contactAttempts ?? undefined,
createdAt: lead.createdAt, createdAt: lead.createdAt,
patient: patientData
? {
age: patientData.dateOfBirth
? Math.floor(
(Date.now() -
new Date(patientData.dateOfBirth).getTime()) /
(1000 * 60 * 60 * 24 * 365.25),
)
: undefined,
type: patientData.patientType,
hasRecords: true,
}
: undefined,
upcomingAppointments,
activities: activities.map((a: any) => ({ activities: activities.map((a: any) => ({
activityType: a.activityType ?? '', activityType: a.activityType ?? '',
summary: a.summary ?? '', summary: a.summary ?? '',
@@ -64,17 +119,21 @@ export class CallLookupController {
// Persist AI enrichment back to platform // Persist AI enrichment back to platform
try { try {
await this.platform.updateLeadWithToken(lead.id, { await this.platform.updateLeadWithToken(
lead.id,
{
aiSummary: enrichment.aiSummary, aiSummary: enrichment.aiSummary,
aiSuggestedAction: enrichment.aiSuggestedAction, aiSuggestedAction: enrichment.aiSuggestedAction,
}, authHeader); },
authHeader,
);
} catch (err) { } catch (err) {
this.logger.warn(`Failed to persist AI enrichment: ${err}`); this.logger.warn(`Failed to persist AI enrichment: ${err}`);
} }
} catch (err) { } catch (err) {
this.logger.warn(`AI enrichment failed: ${err}`); this.logger.warn(`AI enrichment failed: ${err}`);
} }
} // }
} else { } else {
this.logger.log(`No lead found for phone ${phone}`); this.logger.log(`No lead found for phone ${phone}`);
} }

View File

@@ -153,8 +153,16 @@ Lead details:
- Lead age: {{daysSince}} days - Lead age: {{daysSince}} days
- Contact attempts: {{contactAttempts}} - Contact attempts: {{contactAttempts}}
Patient Context:
{{patientContext}}
Upcoming Appointments:
{{appointmentContext}}
Recent activity: Recent activity:
{{activities}}`; {{activities}}
Generate a 1-2 sentence summary of the lead and a 5-10 word suggested action for the agent.`;
const CALL_INSIGHT_DEFAULT = `You are a CRM assistant for {{hospitalName}}. const CALL_INSIGHT_DEFAULT = `You are a CRM assistant for {{hospitalName}}.
Generate a brief, actionable insight about this lead based on their interaction history. Generate a brief, actionable insight about this lead based on their interaction history.
@@ -204,10 +212,22 @@ export const DEFAULT_AI_PROMPTS: Record<AiActorKey, AiPromptConfig> = {
'Website widget chat', 'Website widget chat',
'Patient-facing bot embedded on the hospital website. Handles general questions, finds doctors, suggests appointments.', 'Patient-facing bot embedded on the hospital website. Handles general questions, finds doctors, suggests appointments.',
[ [
{ key: 'hospitalName', description: 'Branded hospital display name from theme.json' }, {
{ key: 'userName', description: 'Visitor first name (or "there" if unknown)' }, key: 'hospitalName',
{ key: 'branchContext', description: 'Pre-rendered branch-selection instructions block' }, description: 'Branded hospital display name from theme.json',
{ key: 'knowledgeBase', description: 'Pre-rendered list of departments + doctors + clinics' }, },
{
key: 'userName',
description: 'Visitor first name (or "there" if unknown)',
},
{
key: 'branchContext',
description: 'Pre-rendered branch-selection instructions block',
},
{
key: 'knowledgeBase',
description: 'Pre-rendered list of departments + doctors + clinics',
},
], ],
WIDGET_CHAT_DEFAULT, WIDGET_CHAT_DEFAULT,
), ),
@@ -216,16 +236,18 @@ export const DEFAULT_AI_PROMPTS: Record<AiActorKey, AiPromptConfig> = {
'In-call assistant the CC agent uses to look up patient history, doctor details, and clinic info while on a call.', 'In-call assistant the CC agent uses to look up patient history, doctor details, and clinic info while on a call.',
[ [
{ key: 'hospitalName', description: 'Branded hospital display name' }, { key: 'hospitalName', description: 'Branded hospital display name' },
{ key: 'knowledgeBase', description: 'Pre-rendered hospital knowledge base (clinics, doctors, packages)' }, {
key: 'knowledgeBase',
description:
'Pre-rendered hospital knowledge base (clinics, doctors, packages)',
},
], ],
CC_AGENT_HELPER_DEFAULT, CC_AGENT_HELPER_DEFAULT,
), ),
supervisorChat: promptDefault( supervisorChat: promptDefault(
'Supervisor assistant', 'Supervisor assistant',
'AI tools the supervisor uses to query agent performance, campaign stats, and SLA breaches.', 'AI tools the supervisor uses to query agent performance, campaign stats, and SLA breaches.',
[ [{ key: 'hospitalName', description: 'Branded hospital display name' }],
{ key: 'hospitalName', description: 'Branded hospital display name' },
],
SUPERVISOR_CHAT_DEFAULT, SUPERVISOR_CHAT_DEFAULT,
), ),
leadEnrichment: promptDefault( leadEnrichment: promptDefault(
@@ -233,21 +255,36 @@ export const DEFAULT_AI_PROMPTS: Record<AiActorKey, AiPromptConfig> = {
'Generates an AI summary + suggested action for a new inbound lead before the agent picks up.', 'Generates an AI summary + suggested action for a new inbound lead before the agent picks up.',
[ [
{ key: 'leadName', description: 'Lead first + last name' }, { key: 'leadName', description: 'Lead first + last name' },
{ key: 'leadSource', description: 'Source channel (WHATSAPP, GOOGLE_ADS, etc.)' }, {
key: 'leadSource',
description: 'Source channel (WHATSAPP, GOOGLE_ADS, etc.)',
},
{ key: 'interestedService', description: 'What the lead enquired about' }, { key: 'interestedService', description: 'What the lead enquired about' },
{ key: 'leadStatus', description: 'Current lead status' }, { key: 'leadStatus', description: 'Current lead status' },
{ key: 'daysSince', description: 'Days since the lead was created' }, { key: 'daysSince', description: 'Days since the lead was created' },
{ key: 'contactAttempts', description: 'Prior contact attempts count' }, { key: 'contactAttempts', description: 'Prior contact attempts count' },
{ key: 'activities', description: 'Pre-rendered recent activity summary' }, {
key: 'patientContext',
description:
'Patient age, type (new/returning), and whether they have prior records',
},
{
key: 'appointmentContext',
description:
'Next 3 scheduled appointments (date, doctor, type, status)',
},
{
key: 'activities',
description:
'Pre-rendered recent activity summary (last 5 interactions)',
},
], ],
LEAD_ENRICHMENT_DEFAULT, LEAD_ENRICHMENT_DEFAULT,
), ),
callInsight: promptDefault( callInsight: promptDefault(
'Post-call insight', 'Post-call insight',
'After each call, generates a 2-3 sentence summary + a single suggested next action for the lead record.', 'After each call, generates a 2-3 sentence summary + a single suggested next action for the lead record.',
[ [{ key: 'hospitalName', description: 'Branded hospital display name' }],
{ key: 'hospitalName', description: 'Branded hospital display name' },
],
CALL_INSIGHT_DEFAULT, CALL_INSIGHT_DEFAULT,
), ),
callAssist: promptDefault( callAssist: promptDefault(
@@ -255,7 +292,11 @@ export const DEFAULT_AI_PROMPTS: Record<AiActorKey, AiPromptConfig> = {
'Real-time suggestions whispered to the CC agent during a call, based on the running transcript.', 'Real-time suggestions whispered to the CC agent during a call, based on the running transcript.',
[ [
{ key: 'hospitalName', description: 'Branded hospital display name' }, { key: 'hospitalName', description: 'Branded hospital display name' },
{ key: 'context', description: 'Pre-rendered call context (current lead, recent activities, available doctors)' }, {
key: 'context',
description:
'Pre-rendered call context (current lead, recent activities, available doctors)',
},
], ],
CALL_ASSIST_DEFAULT, CALL_ASSIST_DEFAULT,
), ),
@@ -264,8 +305,16 @@ export const DEFAULT_AI_PROMPTS: Record<AiActorKey, AiPromptConfig> = {
'Analyses post-call recording transcripts to extract key topics, action items, coaching notes, and compliance flags.', 'Analyses post-call recording transcripts to extract key topics, action items, coaching notes, and compliance flags.',
[ [
{ key: 'hospitalName', description: 'Branded hospital display name' }, { key: 'hospitalName', description: 'Branded hospital display name' },
{ key: 'summaryBlock', description: 'Optional pre-rendered "Call summary: ..." line (empty when none)' }, {
{ key: 'topicsBlock', description: 'Optional pre-rendered "Detected topics: ..." line (empty when none)' }, key: 'summaryBlock',
description:
'Optional pre-rendered "Call summary: ..." line (empty when none)',
},
{
key: 'topicsBlock',
description:
'Optional pre-rendered "Detected topics: ..." line (empty when none)',
},
], ],
RECORDING_ANALYSIS_DEFAULT, RECORDING_ANALYSIS_DEFAULT,
), ),
@@ -280,7 +329,10 @@ export const DEFAULT_AI_CONFIG: AiConfig = {
// Field-by-field mapping from the legacy env vars used by ai-provider.ts // Field-by-field mapping from the legacy env vars used by ai-provider.ts
// (AI_PROVIDER + AI_MODEL). API keys are NOT seeded — they remain in env. // (AI_PROVIDER + AI_MODEL). API keys are NOT seeded — they remain in env.
export const AI_ENV_SEEDS: Array<{ env: string; field: keyof Pick<AiConfig, 'provider' | 'model'> }> = [ export const AI_ENV_SEEDS: Array<{
env: string;
field: keyof Pick<AiConfig, 'provider' | 'model'>;
}> = [
{ env: 'AI_PROVIDER', field: 'provider' }, { env: 'AI_PROVIDER', field: 'provider' },
{ env: 'AI_MODEL', field: 'model' }, { env: 'AI_MODEL', field: 'model' },
]; ];

View File

@@ -1,7 +1,13 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import axios from 'axios'; import axios from 'axios';
import type { LeadNode, LeadActivityNode, CreateCallInput, CreateLeadActivityInput, UpdateLeadInput } from './platform.types'; import type {
LeadNode,
LeadActivityNode,
CreateCallInput,
CreateLeadActivityInput,
UpdateLeadInput,
} from './platform.types';
@Injectable() @Injectable()
export class PlatformGraphqlService { export class PlatformGraphqlService {
@@ -19,14 +25,18 @@ export class PlatformGraphqlService {
} }
// Query using a passed-through auth header (user JWT) // Query using a passed-through auth header (user JWT)
async queryWithAuth<T>(query: string, variables: Record<string, any> | undefined, authHeader: string): Promise<T> { 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': authHeader, Authorization: authHeader,
}, },
}, },
); );
@@ -63,10 +73,16 @@ export class PlatformGraphqlService {
// Client-side phone matching (strip non-digits for comparison) // Client-side phone matching (strip non-digits for comparison)
const normalizedPhone = phone.replace(/\D/g, ''); const normalizedPhone = phone.replace(/\D/g, '');
return data.leads.edges.find(edge => { return (
data.leads.edges.find((edge) => {
const leadPhones = edge.node.contactPhone ?? []; const leadPhones = edge.node.contactPhone ?? [];
return leadPhones.some(p => p.number.replace(/\D/g, '').endsWith(normalizedPhone) || normalizedPhone.endsWith(p.number.replace(/\D/g, ''))); return leadPhones.some(
})?.node ?? null; (p) =>
p.number.replace(/\D/g, '').endsWith(normalizedPhone) ||
normalizedPhone.endsWith(p.number.replace(/\D/g, '')),
);
})?.node ?? null
);
} }
async findLeadById(id: string): Promise<LeadNode | null> { async findLeadById(id: string): Promise<LeadNode | null> {
@@ -110,7 +126,9 @@ export class PlatformGraphqlService {
return data.createCall; return data.createCall;
} }
async createLeadActivity(input: CreateLeadActivityInput): Promise<{ id: string }> { async createLeadActivity(
input: CreateLeadActivityInput,
): Promise<{ id: string }> {
const data = await this.query<{ createLeadActivity: { id: string } }>( const data = await this.query<{ createLeadActivity: { id: string } }>(
`mutation CreateLeadActivity($data: LeadActivityCreateInput!) { `mutation CreateLeadActivity($data: LeadActivityCreateInput!) {
createLeadActivity(data: $data) { id } createLeadActivity(data: $data) { id }
@@ -122,11 +140,16 @@ export class PlatformGraphqlService {
// --- Token passthrough versions (for user-driven requests) --- // --- Token passthrough versions (for user-driven requests) ---
async findLeadByPhoneWithToken(phone: string, authHeader: string): Promise<LeadNode | null> { async findLeadByPhoneWithToken(
phone: string,
authHeader: string,
): Promise<LeadNode | null> {
const normalizedPhone = phone.replace(/\D/g, ''); const normalizedPhone = phone.replace(/\D/g, '');
const last10 = normalizedPhone.slice(-10); const last10 = normalizedPhone.slice(-10);
const data = await this.queryWithAuth<{ leads: { edges: { node: LeadNode }[] } }>( const data = await this.queryWithAuth<{
leads: { edges: { node: LeadNode }[] };
}>(
`query FindLeads($first: Int) { `query FindLeads($first: Int) {
leads(first: $first, orderBy: [{ createdAt: DescNullsLast }]) { leads(first: $first, orderBy: [{ createdAt: DescNullsLast }]) {
edges { edges {
@@ -148,22 +171,37 @@ export class PlatformGraphqlService {
); );
// Client-side phone matching // Client-side phone matching
return data.leads.edges.find(edge => { return (
data.leads.edges.find((edge) => {
const phones = edge.node.contactPhone ?? []; const phones = edge.node.contactPhone ?? [];
if (Array.isArray(phones)) { if (Array.isArray(phones)) {
return phones.some((p: any) => { return phones.some((p: any) => {
const num = (p.number ?? p.primaryPhoneNumber ?? '').replace(/\D/g, ''); const num = (p.number ?? p.primaryPhoneNumber ?? '').replace(
/\D/g,
'',
);
return num.endsWith(last10) || last10.endsWith(num); return num.endsWith(last10) || last10.endsWith(num);
}); });
} }
// Handle single phone object // Handle single phone object
const num = ((phones as any).primaryPhoneNumber ?? (phones as any).number ?? '').replace(/\D/g, ''); const num = (
(phones as any).primaryPhoneNumber ??
(phones as any).number ??
''
).replace(/\D/g, '');
return num.endsWith(last10) || last10.endsWith(num); return num.endsWith(last10) || last10.endsWith(num);
})?.node ?? null; })?.node ?? null
);
} }
async getLeadActivitiesWithToken(leadId: string, authHeader: string, limit = 5): Promise<LeadActivityNode[]> { async getLeadActivitiesWithToken(
const data = await this.queryWithAuth<{ leadActivities: { edges: { node: LeadActivityNode }[] } }>( leadId: string,
authHeader: string,
limit = 5,
): Promise<LeadActivityNode[]> {
const data = await this.queryWithAuth<{
leadActivities: { edges: { node: LeadActivityNode }[] };
}>(
`query GetLeadActivities($filter: LeadActivityFilterInput, $first: Int) { `query GetLeadActivities($filter: LeadActivityFilterInput, $first: Int) {
leadActivities(filter: $filter, first: $first, orderBy: [{ occurredAt: DescNullsLast }]) { leadActivities(filter: $filter, first: $first, orderBy: [{ occurredAt: DescNullsLast }]) {
edges { edges {
@@ -176,10 +214,14 @@ export class PlatformGraphqlService {
{ filter: { leadId: { eq: leadId } }, first: limit }, { filter: { leadId: { eq: leadId } }, first: limit },
authHeader, authHeader,
); );
return data.leadActivities.edges.map(e => e.node); return data.leadActivities.edges.map((e) => e.node);
} }
async updateLeadWithToken(id: string, input: UpdateLeadInput, authHeader: string): Promise<LeadNode> { async updateLeadWithToken(
id: string,
input: UpdateLeadInput,
authHeader: string,
): Promise<LeadNode> {
// Response fragment deliberately excludes `leadStatus` — the staging // Response fragment deliberately excludes `leadStatus` — the staging
// platform schema has this field renamed to `status`. Selecting the // platform schema has this field renamed to `status`. Selecting the
// old name rejects the whole mutation. Callers don't use the // old name rejects the whole mutation. Callers don't use the
@@ -207,7 +249,10 @@ export class PlatformGraphqlService {
// (`status`, `source`, `lastContacted`) rather than the older // (`status`, `source`, `lastContacted`) rather than the older
// `leadStatus`/`leadSource`/`lastContactedAt` names — otherwise the // `leadStatus`/`leadSource`/`lastContactedAt` names — otherwise the
// query would be rejected on staging. // query would be rejected on staging.
async findLeadByIdWithToken(id: string, authHeader: string): Promise<any | null> { async findLeadByIdWithToken(
id: string,
authHeader: string,
): Promise<any | null> {
try { try {
const data = await this.queryWithAuth<{ lead: any }>( const data = await this.queryWithAuth<{ lead: any }>(
`query FindLead($id: UUID!) { `query FindLead($id: UUID!) {
@@ -232,7 +277,9 @@ export class PlatformGraphqlService {
} catch { } catch {
// Fall back to edge-style query in case the singular field // Fall back to edge-style query in case the singular field
// doesn't exist on this platform version. // doesn't exist on this platform version.
const data = await this.queryWithAuth<{ leads: { edges: { node: any }[] } }>( const data = await this.queryWithAuth<{
leads: { edges: { node: any }[] };
}>(
`query FindLead($id: UUID!) { `query FindLead($id: UUID!) {
leads(filter: { id: { eq: $id } }, first: 1) { leads(filter: { id: { eq: $id } }, first: 1) {
edges { edges {
@@ -259,10 +306,75 @@ export class PlatformGraphqlService {
} }
} }
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) --- // --- Server-to-server versions (for webhooks, background jobs) ---
async getLeadActivities(leadId: string, limit = 3): Promise<LeadActivityNode[]> { async getLeadActivities(
const data = await this.query<{ leadActivities: { edges: { node: LeadActivityNode }[] } }>( leadId: string,
limit = 3,
): Promise<LeadActivityNode[]> {
const data = await this.query<{
leadActivities: { edges: { node: LeadActivityNode }[] };
}>(
`query GetLeadActivities($filter: LeadActivityFilterInput, $first: Int) { `query GetLeadActivities($filter: LeadActivityFilterInput, $first: Int) {
leadActivities(filter: $filter, first: $first, orderBy: { occurredAt: DescNullsLast }) { leadActivities(filter: $filter, first: $first, orderBy: { occurredAt: DescNullsLast }) {
edges { edges {
@@ -274,6 +386,6 @@ export class PlatformGraphqlService {
}`, }`,
{ filter: { leadId: { eq: leadId } }, first: limit }, { filter: { leadId: { eq: leadId } }, first: limit },
); );
return data.leadActivities.edges.map(e => e.node); return data.leadActivities.edges.map((e) => e.node);
} }
} }