mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
Revert "AI Summary not showing appointments fix."
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
This reverts commit 973614749b.
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -42,7 +42,3 @@ 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
|
|
||||||
|
|||||||
@@ -7,149 +7,107 @@ import { createAiModel } from './ai-provider';
|
|||||||
import { AiConfigService } from '../config/ai-config.service';
|
import { AiConfigService } from '../config/ai-config.service';
|
||||||
|
|
||||||
type LeadContext = {
|
type LeadContext = {
|
||||||
firstName?: string;
|
firstName?: string;
|
||||||
lastName?: string;
|
lastName?: string;
|
||||||
leadSource?: string;
|
leadSource?: string;
|
||||||
interestedService?: string;
|
interestedService?: string;
|
||||||
leadStatus?: string;
|
leadStatus?: string;
|
||||||
contactAttempts?: number;
|
contactAttempts?: number;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
campaignId?: string;
|
campaignId?: string;
|
||||||
patient?: {
|
activities?: { activityType: string; summary: string }[];
|
||||||
age?: number;
|
|
||||||
type?: string; // 'new' | 'returning' | 'vip'
|
|
||||||
hasRecords?: boolean;
|
|
||||||
};
|
|
||||||
upcomingAppointments?: Array<{
|
|
||||||
scheduledAt?: string;
|
|
||||||
doctorName?: string;
|
|
||||||
appointmentType?: string;
|
|
||||||
status?: string;
|
|
||||||
}>;
|
|
||||||
activities?: { activityType: string; summary: string }[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type EnrichmentResult = {
|
type EnrichmentResult = {
|
||||||
aiSummary: string;
|
aiSummary: string;
|
||||||
aiSuggestedAction: string;
|
aiSuggestedAction: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const enrichmentSchema = z.object({
|
const enrichmentSchema = z.object({
|
||||||
aiSummary: z
|
aiSummary: z.string().describe('1-2 sentence summary of who this lead is and their history'),
|
||||||
.string()
|
aiSuggestedAction: z.string().describe('5-10 word suggested action for the agent'),
|
||||||
.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()
|
||||||
export class AiEnrichmentService {
|
export class AiEnrichmentService {
|
||||||
private readonly logger = new Logger(AiEnrichmentService.name);
|
private readonly logger = new Logger(AiEnrichmentService.name);
|
||||||
private readonly aiModel: LanguageModel | null;
|
private readonly aiModel: LanguageModel | null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private config: ConfigService,
|
private config: ConfigService,
|
||||||
private aiConfig: AiConfigService,
|
private aiConfig: AiConfigService,
|
||||||
) {
|
) {
|
||||||
const cfg = aiConfig.getConfig();
|
const cfg = aiConfig.getConfig();
|
||||||
this.aiModel = createAiModel({
|
this.aiModel = createAiModel({
|
||||||
provider: cfg.provider,
|
provider: cfg.provider,
|
||||||
model: cfg.model,
|
model: cfg.model,
|
||||||
anthropicApiKey: config.get<string>('ai.anthropicApiKey'),
|
anthropicApiKey: config.get<string>('ai.anthropicApiKey'),
|
||||||
openaiApiKey: config.get<string>('ai.openaiApiKey'),
|
openaiApiKey: config.get<string>('ai.openaiApiKey'),
|
||||||
});
|
});
|
||||||
if (!this.aiModel) {
|
if (!this.aiModel) {
|
||||||
this.logger.warn('AI not configured — enrichment uses fallback');
|
this.logger.warn('AI not configured — enrichment uses fallback');
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
async enrichLead(lead: LeadContext): Promise<EnrichmentResult> {
|
|
||||||
if (!this.aiModel) {
|
|
||||||
return this.fallbackEnrichment(lead);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
async enrichLead(lead: LeadContext): Promise<EnrichmentResult> {
|
||||||
const daysSince = lead.createdAt
|
if (!this.aiModel) {
|
||||||
? Math.floor(
|
return this.fallbackEnrichment(lead);
|
||||||
(Date.now() - new Date(lead.createdAt).getTime()) /
|
}
|
||||||
(1000 * 60 * 60 * 24),
|
|
||||||
)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
const activitiesText = lead.activities?.length
|
try {
|
||||||
? lead.activities
|
const daysSince = lead.createdAt
|
||||||
.map((a) => `- ${a.activityType}: ${a.summary}`)
|
? Math.floor((Date.now() - new Date(lead.createdAt).getTime()) / (1000 * 60 * 60 * 24))
|
||||||
.join('\n')
|
: 0;
|
||||||
: 'No previous interactions';
|
|
||||||
|
|
||||||
const patientContext = lead.patient
|
const activitiesText = lead.activities?.length
|
||||||
? `Age: ${lead.patient.age ?? 'Unknown'} | Type: ${lead.patient.type ?? 'Unknown'} | Prior Records: ${lead.patient.hasRecords ? 'Yes' : 'No'}`
|
? lead.activities.map(a => `- ${a.activityType}: ${a.summary}`).join('\n')
|
||||||
: 'No patient record linked';
|
: 'No previous interactions';
|
||||||
|
|
||||||
const appointmentContext = lead.upcomingAppointments?.length
|
const { object } = await generateObject({
|
||||||
? lead.upcomingAppointments
|
model: this.aiModel!,
|
||||||
.map(
|
schema: enrichmentSchema,
|
||||||
(a) =>
|
prompt: this.aiConfig.renderPrompt('leadEnrichment', {
|
||||||
`- ${a.scheduledAt ? new Date(a.scheduledAt).toLocaleDateString() : 'TBD'}: ${a.appointmentType ?? 'Appointment'} with ${a.doctorName ?? 'Doctor'} (${a.status ?? 'Scheduled'})`,
|
leadName: `${lead.firstName ?? 'Unknown'} ${lead.lastName ?? ''}`.trim(),
|
||||||
)
|
leadSource: lead.leadSource ?? 'Unknown',
|
||||||
.join('\n')
|
interestedService: lead.interestedService ?? 'Unknown',
|
||||||
: 'No upcoming appointments';
|
leadStatus: lead.leadStatus ?? 'Unknown',
|
||||||
|
daysSince,
|
||||||
|
contactAttempts: lead.contactAttempts ?? 0,
|
||||||
|
activities: activitiesText,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
const { object } = await generateObject({
|
this.logger.log(`AI enrichment generated for lead ${lead.firstName} ${lead.lastName}`);
|
||||||
model: this.aiModel!,
|
return object;
|
||||||
schema: enrichmentSchema,
|
} catch (error) {
|
||||||
prompt: this.aiConfig.renderPrompt('leadEnrichment', {
|
this.logger.error(`AI enrichment failed: ${error}`);
|
||||||
leadName:
|
return this.fallbackEnrichment(lead);
|
||||||
`${lead.firstName ?? 'Unknown'} ${lead.lastName ?? ''}`.trim(),
|
}
|
||||||
leadSource: lead.leadSource ?? 'Unknown',
|
|
||||||
interestedService: lead.interestedService ?? 'Unknown',
|
|
||||||
leadStatus: lead.leadStatus ?? 'Unknown',
|
|
||||||
daysSince,
|
|
||||||
contactAttempts: lead.contactAttempts ?? 0,
|
|
||||||
patientContext,
|
|
||||||
appointmentContext,
|
|
||||||
activities: activitiesText,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log(
|
|
||||||
`AI enrichment generated for lead ${lead.firstName} ${lead.lastName}`,
|
|
||||||
);
|
|
||||||
return object;
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`AI enrichment failed: ${error}`);
|
|
||||||
return this.fallbackEnrichment(lead);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fallbackEnrichment(lead: LeadContext): EnrichmentResult {
|
|
||||||
const daysSince = lead.createdAt
|
|
||||||
? Math.floor(
|
|
||||||
(Date.now() - new Date(lead.createdAt).getTime()) /
|
|
||||||
(1000 * 60 * 60 * 24),
|
|
||||||
)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
const attempts = lead.contactAttempts ?? 0;
|
|
||||||
const service = lead.interestedService ?? 'general inquiry';
|
|
||||||
const source =
|
|
||||||
lead.leadSource?.replace(/_/g, ' ').toLowerCase() ?? 'unknown source';
|
|
||||||
|
|
||||||
let summary: string;
|
|
||||||
let action: string;
|
|
||||||
|
|
||||||
if (attempts === 0) {
|
|
||||||
summary = `First-time inquiry from ${source}. Interested in ${service}. Lead is ${daysSince} day(s) old with no previous contact.`;
|
|
||||||
action = `Introduce services and offer appointment booking`;
|
|
||||||
} else if (attempts === 1) {
|
|
||||||
summary = `Returning inquiry — contacted once before. Interested in ${service} via ${source}. Lead is ${daysSince} day(s) old.`;
|
|
||||||
action = `Follow up on previous conversation, offer appointment`;
|
|
||||||
} else {
|
|
||||||
summary = `Repeat contact (${attempts} previous attempts) for ${service}. Originally from ${source}, ${daysSince} day(s) old. Shows high interest.`;
|
|
||||||
action = `Prioritize appointment booking — high-intent lead`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { aiSummary: summary, aiSuggestedAction: action };
|
private fallbackEnrichment(lead: LeadContext): EnrichmentResult {
|
||||||
}
|
const daysSince = lead.createdAt
|
||||||
|
? Math.floor((Date.now() - new Date(lead.createdAt).getTime()) / (1000 * 60 * 60 * 24))
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const attempts = lead.contactAttempts ?? 0;
|
||||||
|
const service = lead.interestedService ?? 'general inquiry';
|
||||||
|
const source = lead.leadSource?.replace(/_/g, ' ').toLowerCase() ?? 'unknown source';
|
||||||
|
|
||||||
|
let summary: string;
|
||||||
|
let action: string;
|
||||||
|
|
||||||
|
if (attempts === 0) {
|
||||||
|
summary = `First-time inquiry from ${source}. Interested in ${service}. Lead is ${daysSince} day(s) old with no previous contact.`;
|
||||||
|
action = `Introduce services and offer appointment booking`;
|
||||||
|
} else if (attempts === 1) {
|
||||||
|
summary = `Returning inquiry — contacted once before. Interested in ${service} via ${source}. Lead is ${daysSince} day(s) old.`;
|
||||||
|
action = `Follow up on previous conversation, offer appointment`;
|
||||||
|
} else {
|
||||||
|
summary = `Repeat contact (${attempts} previous attempts) for ${service}. Originally from ${source}, ${daysSince} day(s) old. Shows high interest.`;
|
||||||
|
action = `Prioritize appointment booking — high-intent lead`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { aiSummary: summary, aiSuggestedAction: action };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,147 +1,88 @@
|
|||||||
import {
|
import { Controller, Post, Body, Logger, Headers, HttpException } from '@nestjs/common';
|
||||||
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';
|
||||||
|
|
||||||
@Controller('api/call')
|
@Controller('api/call')
|
||||||
export class CallLookupController {
|
export class CallLookupController {
|
||||||
private readonly logger = new Logger(CallLookupController.name);
|
private readonly logger = new Logger(CallLookupController.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly platform: PlatformGraphqlService,
|
private readonly platform: PlatformGraphqlService,
|
||||||
private readonly ai: AiEnrichmentService,
|
private readonly ai: AiEnrichmentService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post('lookup')
|
@Post('lookup')
|
||||||
async lookupCaller(
|
async lookupCaller(
|
||||||
@Body() body: { phoneNumber: string },
|
@Body() body: { phoneNumber: string },
|
||||||
@Headers('authorization') authHeader: string,
|
@Headers('authorization') authHeader: string,
|
||||||
) {
|
) {
|
||||||
if (!authHeader) throw new HttpException('Authorization required', 401);
|
if (!authHeader) throw new HttpException('Authorization required', 401);
|
||||||
if (!body.phoneNumber) throw new HttpException('phoneNumber required', 400);
|
if (!body.phoneNumber) throw new HttpException('phoneNumber required', 400);
|
||||||
|
|
||||||
const phone = body.phoneNumber.replace(/^0+/, '');
|
const phone = body.phoneNumber.replace(/^0+/, '');
|
||||||
this.logger.log(`Looking up caller: ${phone}`);
|
this.logger.log(`Looking up caller: ${phone}`);
|
||||||
|
|
||||||
// Query platform for leads matching this phone number
|
// Query platform for leads matching this phone number
|
||||||
let lead = null;
|
let lead = null;
|
||||||
let activities: any[] = [];
|
let activities: any[] = [];
|
||||||
|
|
||||||
try {
|
|
||||||
lead = await this.platform.findLeadByPhoneWithToken(phone, authHeader);
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.warn(`Lead lookup failed: ${err}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lead) {
|
|
||||||
this.logger.log(
|
|
||||||
`Matched lead: ${lead.id} — ${lead.contactName?.firstName} ${lead.contactName?.lastName}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Get recent activities
|
|
||||||
try {
|
|
||||||
activities = await this.platform.getLeadActivitiesWithToken(
|
|
||||||
lead.id,
|
|
||||||
authHeader,
|
|
||||||
5,
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.warn(`Activity fetch failed: ${err}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch patient context if patientId exists
|
|
||||||
let patientData = null;
|
|
||||||
let upcomingAppointments: any[] = [];
|
|
||||||
if (lead.patientId) {
|
|
||||||
try {
|
try {
|
||||||
patientData = await this.platform.getPatientWithToken(
|
lead = await this.platform.findLeadByPhoneWithToken(phone, authHeader);
|
||||||
lead.patientId,
|
|
||||||
authHeader,
|
|
||||||
);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(`Patient fetch failed: ${err}`);
|
this.logger.warn(`Lead lookup failed: ${err}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (patientData) {
|
if (lead) {
|
||||||
try {
|
this.logger.log(`Matched lead: ${lead.id} — ${lead.contactName?.firstName} ${lead.contactName?.lastName}`);
|
||||||
upcomingAppointments =
|
|
||||||
await this.platform.getUpcomingAppointmentsWithToken(
|
// Get recent activities
|
||||||
lead.patientId,
|
try {
|
||||||
authHeader,
|
activities = await this.platform.getLeadActivitiesWithToken(lead.id, authHeader, 5);
|
||||||
3,
|
} catch (err) {
|
||||||
);
|
this.logger.warn(`Activity fetch failed: ${err}`);
|
||||||
} catch (err) {
|
}
|
||||||
this.logger.warn(`Appointment fetch failed: ${err}`);
|
|
||||||
}
|
// AI enrichment if no existing summary
|
||||||
|
if (!lead.aiSummary) {
|
||||||
|
try {
|
||||||
|
const enrichment = await this.ai.enrichLead({
|
||||||
|
firstName: lead.contactName?.firstName,
|
||||||
|
lastName: lead.contactName?.lastName,
|
||||||
|
leadSource: lead.leadSource ?? undefined,
|
||||||
|
interestedService: lead.interestedService ?? undefined,
|
||||||
|
leadStatus: lead.leadStatus ?? undefined,
|
||||||
|
contactAttempts: lead.contactAttempts ?? undefined,
|
||||||
|
createdAt: lead.createdAt,
|
||||||
|
activities: activities.map((a: any) => ({
|
||||||
|
activityType: a.activityType ?? '',
|
||||||
|
summary: a.summary ?? '',
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
lead.aiSummary = enrichment.aiSummary;
|
||||||
|
lead.aiSuggestedAction = enrichment.aiSuggestedAction;
|
||||||
|
|
||||||
|
// Persist AI enrichment back to platform
|
||||||
|
try {
|
||||||
|
await this.platform.updateLeadWithToken(lead.id, {
|
||||||
|
aiSummary: enrichment.aiSummary,
|
||||||
|
aiSuggestedAction: enrichment.aiSuggestedAction,
|
||||||
|
}, authHeader);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Failed to persist AI enrichment: ${err}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`AI enrichment failed: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.logger.log(`No lead found for phone ${phone}`);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// AI enrichment if no existing summary
|
return {
|
||||||
// generate aiSummary everytime
|
lead,
|
||||||
// if (!lead.aiSummary) {
|
activities,
|
||||||
try {
|
matched: lead !== null,
|
||||||
const enrichment = await this.ai.enrichLead({
|
};
|
||||||
firstName: lead.contactName?.firstName,
|
|
||||||
lastName: lead.contactName?.lastName,
|
|
||||||
leadSource: lead.leadSource ?? undefined,
|
|
||||||
interestedService: lead.interestedService ?? undefined,
|
|
||||||
leadStatus: lead.leadStatus ?? undefined,
|
|
||||||
contactAttempts: lead.contactAttempts ?? undefined,
|
|
||||||
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) => ({
|
|
||||||
activityType: a.activityType ?? '',
|
|
||||||
summary: a.summary ?? '',
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
|
|
||||||
lead.aiSummary = enrichment.aiSummary;
|
|
||||||
lead.aiSuggestedAction = enrichment.aiSuggestedAction;
|
|
||||||
|
|
||||||
// Persist AI enrichment back to platform
|
|
||||||
try {
|
|
||||||
await this.platform.updateLeadWithToken(
|
|
||||||
lead.id,
|
|
||||||
{
|
|
||||||
aiSummary: enrichment.aiSummary,
|
|
||||||
aiSuggestedAction: enrichment.aiSuggestedAction,
|
|
||||||
},
|
|
||||||
authHeader,
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.warn(`Failed to persist AI enrichment: ${err}`);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.warn(`AI enrichment failed: ${err}`);
|
|
||||||
}
|
|
||||||
// }
|
|
||||||
} else {
|
|
||||||
this.logger.log(`No lead found for phone ${phone}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
|
||||||
lead,
|
|
||||||
activities,
|
|
||||||
matched: lead !== null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,47 +24,47 @@ export type AiProvider = 'openai' | 'anthropic';
|
|||||||
// 2. add a default entry in DEFAULT_AI_PROMPTS below
|
// 2. add a default entry in DEFAULT_AI_PROMPTS below
|
||||||
// 3. add the corresponding renderPrompt call in the consuming service
|
// 3. add the corresponding renderPrompt call in the consuming service
|
||||||
export const AI_ACTOR_KEYS = [
|
export const AI_ACTOR_KEYS = [
|
||||||
'widgetChat',
|
'widgetChat',
|
||||||
'ccAgentHelper',
|
'ccAgentHelper',
|
||||||
'supervisorChat',
|
'supervisorChat',
|
||||||
'leadEnrichment',
|
'leadEnrichment',
|
||||||
'callInsight',
|
'callInsight',
|
||||||
'callAssist',
|
'callAssist',
|
||||||
'recordingAnalysis',
|
'recordingAnalysis',
|
||||||
] as const;
|
] as const;
|
||||||
export type AiActorKey = (typeof AI_ACTOR_KEYS)[number];
|
export type AiActorKey = (typeof AI_ACTOR_KEYS)[number];
|
||||||
|
|
||||||
export type AiPromptConfig = {
|
export type AiPromptConfig = {
|
||||||
// Human-readable name shown in the wizard UI.
|
// Human-readable name shown in the wizard UI.
|
||||||
label: string;
|
label: string;
|
||||||
// One-line description of when this persona is invoked.
|
// One-line description of when this persona is invoked.
|
||||||
description: string;
|
description: string;
|
||||||
// Variables the template can reference, with a one-line hint each.
|
// Variables the template can reference, with a one-line hint each.
|
||||||
// Surfaced in the edit slideout so admins know what `{{var}}` they
|
// Surfaced in the edit slideout so admins know what `{{var}}` they
|
||||||
// can use without reading code.
|
// can use without reading code.
|
||||||
variables: Array<{ key: string; description: string }>;
|
variables: Array<{ key: string; description: string }>;
|
||||||
// The current template (may be admin-edited).
|
// The current template (may be admin-edited).
|
||||||
template: string;
|
template: string;
|
||||||
// The original baseline so we can offer a "reset to default" button.
|
// The original baseline so we can offer a "reset to default" button.
|
||||||
defaultTemplate: string;
|
defaultTemplate: string;
|
||||||
// Audit fields — when this prompt was last edited and by whom.
|
// Audit fields — when this prompt was last edited and by whom.
|
||||||
// null on the default-supplied entries.
|
// null on the default-supplied entries.
|
||||||
lastEditedAt: string | null;
|
lastEditedAt: string | null;
|
||||||
lastEditedBy: string | null;
|
lastEditedBy: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AiConfig = {
|
export type AiConfig = {
|
||||||
provider: AiProvider;
|
provider: AiProvider;
|
||||||
model: string;
|
model: string;
|
||||||
// 0..2, controls randomness. Default 0.7 matches the existing hardcoded
|
// 0..2, controls randomness. Default 0.7 matches the existing hardcoded
|
||||||
// values used in WidgetChatService and AI tools.
|
// values used in WidgetChatService and AI tools.
|
||||||
temperature: number;
|
temperature: number;
|
||||||
// Per-actor system prompt templates. Keyed by AiActorKey so callers can
|
// Per-actor system prompt templates. Keyed by AiActorKey so callers can
|
||||||
// do `config.prompts.widgetChat.template` and missing keys are caught
|
// do `config.prompts.widgetChat.template` and missing keys are caught
|
||||||
// at compile time.
|
// at compile time.
|
||||||
prompts: Record<AiActorKey, AiPromptConfig>;
|
prompts: Record<AiActorKey, AiPromptConfig>;
|
||||||
version?: number;
|
version?: number;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -153,16 +153,8 @@ 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.
|
||||||
@@ -193,146 +185,102 @@ Be specific, brief, and actionable. Focus on healthcare context.
|
|||||||
// `template` and `defaultTemplate` — what every actor starts with on a
|
// `template` and `defaultTemplate` — what every actor starts with on a
|
||||||
// fresh boot.
|
// fresh boot.
|
||||||
const promptDefault = (
|
const promptDefault = (
|
||||||
label: string,
|
label: string,
|
||||||
description: string,
|
description: string,
|
||||||
variables: Array<{ key: string; description: string }>,
|
variables: Array<{ key: string; description: string }>,
|
||||||
template: string,
|
template: string,
|
||||||
): AiPromptConfig => ({
|
): AiPromptConfig => ({
|
||||||
label,
|
label,
|
||||||
description,
|
description,
|
||||||
variables,
|
variables,
|
||||||
template,
|
template,
|
||||||
defaultTemplate: template,
|
defaultTemplate: template,
|
||||||
lastEditedAt: null,
|
lastEditedAt: null,
|
||||||
lastEditedBy: null,
|
lastEditedBy: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const DEFAULT_AI_PROMPTS: Record<AiActorKey, AiPromptConfig> = {
|
export const DEFAULT_AI_PROMPTS: Record<AiActorKey, AiPromptConfig> = {
|
||||||
widgetChat: promptDefault(
|
widgetChat: promptDefault(
|
||||||
'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: 'hospitalName',
|
{ key: 'userName', description: 'Visitor first name (or "there" if unknown)' },
|
||||||
description: 'Branded hospital display name from theme.json',
|
{ key: 'branchContext', description: 'Pre-rendered branch-selection instructions block' },
|
||||||
},
|
{ key: 'knowledgeBase', description: 'Pre-rendered list of departments + doctors + clinics' },
|
||||||
{
|
],
|
||||||
key: 'userName',
|
WIDGET_CHAT_DEFAULT,
|
||||||
description: 'Visitor first name (or "there" if unknown)',
|
),
|
||||||
},
|
ccAgentHelper: promptDefault(
|
||||||
{
|
'CC agent helper',
|
||||||
key: 'branchContext',
|
'In-call assistant the CC agent uses to look up patient history, doctor details, and clinic info while on a call.',
|
||||||
description: 'Pre-rendered branch-selection instructions block',
|
[
|
||||||
},
|
{ key: 'hospitalName', description: 'Branded hospital display name' },
|
||||||
{
|
{ key: 'knowledgeBase', description: 'Pre-rendered hospital knowledge base (clinics, doctors, packages)' },
|
||||||
key: 'knowledgeBase',
|
],
|
||||||
description: 'Pre-rendered list of departments + doctors + clinics',
|
CC_AGENT_HELPER_DEFAULT,
|
||||||
},
|
),
|
||||||
],
|
supervisorChat: promptDefault(
|
||||||
WIDGET_CHAT_DEFAULT,
|
'Supervisor assistant',
|
||||||
),
|
'AI tools the supervisor uses to query agent performance, campaign stats, and SLA breaches.',
|
||||||
ccAgentHelper: promptDefault(
|
[
|
||||||
'CC agent helper',
|
{ key: 'hospitalName', description: 'Branded hospital display name' },
|
||||||
'In-call assistant the CC agent uses to look up patient history, doctor details, and clinic info while on a call.',
|
],
|
||||||
[
|
SUPERVISOR_CHAT_DEFAULT,
|
||||||
{ key: 'hospitalName', description: 'Branded hospital display name' },
|
),
|
||||||
{
|
leadEnrichment: promptDefault(
|
||||||
key: 'knowledgeBase',
|
'Lead enrichment',
|
||||||
description:
|
'Generates an AI summary + suggested action for a new inbound lead before the agent picks up.',
|
||||||
'Pre-rendered hospital knowledge base (clinics, doctors, packages)',
|
[
|
||||||
},
|
{ key: 'leadName', description: 'Lead first + last name' },
|
||||||
],
|
{ key: 'leadSource', description: 'Source channel (WHATSAPP, GOOGLE_ADS, etc.)' },
|
||||||
CC_AGENT_HELPER_DEFAULT,
|
{ key: 'interestedService', description: 'What the lead enquired about' },
|
||||||
),
|
{ key: 'leadStatus', description: 'Current lead status' },
|
||||||
supervisorChat: promptDefault(
|
{ key: 'daysSince', description: 'Days since the lead was created' },
|
||||||
'Supervisor assistant',
|
{ key: 'contactAttempts', description: 'Prior contact attempts count' },
|
||||||
'AI tools the supervisor uses to query agent performance, campaign stats, and SLA breaches.',
|
{ key: 'activities', description: 'Pre-rendered recent activity summary' },
|
||||||
[{ key: 'hospitalName', description: 'Branded hospital display name' }],
|
],
|
||||||
SUPERVISOR_CHAT_DEFAULT,
|
LEAD_ENRICHMENT_DEFAULT,
|
||||||
),
|
),
|
||||||
leadEnrichment: promptDefault(
|
callInsight: promptDefault(
|
||||||
'Lead enrichment',
|
'Post-call insight',
|
||||||
'Generates an AI summary + suggested action for a new inbound lead before the agent picks up.',
|
'After each call, generates a 2-3 sentence summary + a single suggested next action for the lead record.',
|
||||||
[
|
[
|
||||||
{ key: 'leadName', description: 'Lead first + last name' },
|
{ key: 'hospitalName', description: 'Branded hospital display name' },
|
||||||
{
|
],
|
||||||
key: 'leadSource',
|
CALL_INSIGHT_DEFAULT,
|
||||||
description: 'Source channel (WHATSAPP, GOOGLE_ADS, etc.)',
|
),
|
||||||
},
|
callAssist: promptDefault(
|
||||||
{ key: 'interestedService', description: 'What the lead enquired about' },
|
'Live call whisper',
|
||||||
{ key: 'leadStatus', description: 'Current lead status' },
|
'Real-time suggestions whispered to the CC agent during a call, based on the running transcript.',
|
||||||
{ key: 'daysSince', description: 'Days since the lead was created' },
|
[
|
||||||
{ key: 'contactAttempts', description: 'Prior contact attempts count' },
|
{ key: 'hospitalName', description: 'Branded hospital display name' },
|
||||||
{
|
{ key: 'context', description: 'Pre-rendered call context (current lead, recent activities, available doctors)' },
|
||||||
key: 'patientContext',
|
],
|
||||||
description:
|
CALL_ASSIST_DEFAULT,
|
||||||
'Patient age, type (new/returning), and whether they have prior records',
|
),
|
||||||
},
|
recordingAnalysis: promptDefault(
|
||||||
{
|
'Call recording analysis',
|
||||||
key: 'appointmentContext',
|
'Analyses post-call recording transcripts to extract key topics, action items, coaching notes, and compliance flags.',
|
||||||
description:
|
[
|
||||||
'Next 3 scheduled appointments (date, doctor, type, status)',
|
{ 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: 'activities',
|
],
|
||||||
description:
|
RECORDING_ANALYSIS_DEFAULT,
|
||||||
'Pre-rendered recent activity summary (last 5 interactions)',
|
),
|
||||||
},
|
|
||||||
],
|
|
||||||
LEAD_ENRICHMENT_DEFAULT,
|
|
||||||
),
|
|
||||||
callInsight: promptDefault(
|
|
||||||
'Post-call insight',
|
|
||||||
'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' }],
|
|
||||||
CALL_INSIGHT_DEFAULT,
|
|
||||||
),
|
|
||||||
callAssist: promptDefault(
|
|
||||||
'Live call whisper',
|
|
||||||
'Real-time suggestions whispered to the CC agent during a call, based on the running transcript.',
|
|
||||||
[
|
|
||||||
{ key: 'hospitalName', description: 'Branded hospital display name' },
|
|
||||||
{
|
|
||||||
key: 'context',
|
|
||||||
description:
|
|
||||||
'Pre-rendered call context (current lead, recent activities, available doctors)',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
CALL_ASSIST_DEFAULT,
|
|
||||||
),
|
|
||||||
recordingAnalysis: promptDefault(
|
|
||||||
'Call recording analysis',
|
|
||||||
'Analyses post-call recording transcripts to extract key topics, action items, coaching notes, and compliance flags.',
|
|
||||||
[
|
|
||||||
{ 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)',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
RECORDING_ANALYSIS_DEFAULT,
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DEFAULT_AI_CONFIG: AiConfig = {
|
export const DEFAULT_AI_CONFIG: AiConfig = {
|
||||||
provider: 'openai',
|
provider: 'openai',
|
||||||
model: 'gpt-4o-mini',
|
model: 'gpt-4o-mini',
|
||||||
temperature: 0.7,
|
temperature: 0.7,
|
||||||
prompts: DEFAULT_AI_PROMPTS,
|
prompts: DEFAULT_AI_PROMPTS,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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<{
|
export const AI_ENV_SEEDS: Array<{ env: string; field: keyof Pick<AiConfig, 'provider' | 'model'> }> = [
|
||||||
env: string;
|
{ env: 'AI_PROVIDER', field: 'provider' },
|
||||||
field: keyof Pick<AiConfig, 'provider' | 'model'>;
|
{ env: 'AI_MODEL', field: 'model' },
|
||||||
}> = [
|
|
||||||
{ env: 'AI_PROVIDER', field: 'provider' },
|
|
||||||
{ env: 'AI_MODEL', field: 'model' },
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,58 +1,48 @@
|
|||||||
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 {
|
import type { LeadNode, LeadActivityNode, CreateCallInput, CreateLeadActivityInput, UpdateLeadInput } from './platform.types';
|
||||||
LeadNode,
|
|
||||||
LeadActivityNode,
|
|
||||||
CreateCallInput,
|
|
||||||
CreateLeadActivityInput,
|
|
||||||
UpdateLeadInput,
|
|
||||||
} from './platform.types';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PlatformGraphqlService {
|
export class PlatformGraphqlService {
|
||||||
private readonly graphqlUrl: string;
|
private readonly graphqlUrl: string;
|
||||||
private readonly apiKey: string;
|
private readonly apiKey: string;
|
||||||
|
|
||||||
constructor(private config: ConfigService) {
|
constructor(private config: ConfigService) {
|
||||||
this.graphqlUrl = config.get<string>('platform.graphqlUrl')!;
|
this.graphqlUrl = config.get<string>('platform.graphqlUrl')!;
|
||||||
this.apiKey = config.get<string>('platform.apiKey')!;
|
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;
|
// 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}`);
|
||||||
|
}
|
||||||
|
|
||||||
async findLeadByPhone(phone: string): Promise<LeadNode | null> {
|
// Query using a passed-through auth header (user JWT)
|
||||||
// Note: The exact filter syntax for PHONES fields depends on the platform
|
async queryWithAuth<T>(query: string, variables: Record<string, any> | undefined, authHeader: string): Promise<T> {
|
||||||
// This queries leads and filters client-side by phone number
|
const response = await axios.post(
|
||||||
const data = await this.query<{ leads: { edges: { node: LeadNode }[] } }>(
|
this.graphqlUrl,
|
||||||
`query FindLeads($first: Int) {
|
{ 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 }) {
|
leads(first: $first, orderBy: { createdAt: DescNullsLast }) {
|
||||||
edges {
|
edges {
|
||||||
node {
|
node {
|
||||||
@@ -68,26 +58,20 @@ export class PlatformGraphqlService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
{ first: 100 },
|
{ 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> {
|
// Client-side phone matching (strip non-digits for comparison)
|
||||||
const data = await this.query<{ lead: LeadNode }>(
|
const normalizedPhone = phone.replace(/\D/g, '');
|
||||||
`query FindLead($id: ID!) {
|
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) {
|
lead(id: $id) {
|
||||||
id createdAt
|
id createdAt
|
||||||
contactName { firstName lastName }
|
contactName { firstName lastName }
|
||||||
@@ -99,58 +83,51 @@ export class PlatformGraphqlService {
|
|||||||
aiSummary aiSuggestedAction
|
aiSummary aiSuggestedAction
|
||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
{ id },
|
{ id },
|
||||||
);
|
);
|
||||||
return data.lead;
|
return data.lead;
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateLead(id: string, input: UpdateLeadInput): Promise<LeadNode> {
|
async updateLead(id: string, input: UpdateLeadInput): Promise<LeadNode> {
|
||||||
const data = await this.query<{ updateLead: LeadNode }>(
|
const data = await this.query<{ updateLead: LeadNode }>(
|
||||||
`mutation UpdateLead($id: ID!, $data: LeadUpdateInput!) {
|
`mutation UpdateLead($id: ID!, $data: LeadUpdateInput!) {
|
||||||
updateLead(id: $id, data: $data) {
|
updateLead(id: $id, data: $data) {
|
||||||
id leadStatus aiSummary aiSuggestedAction
|
id leadStatus aiSummary aiSuggestedAction
|
||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
{ id, data: input },
|
{ id, data: input },
|
||||||
);
|
);
|
||||||
return data.updateLead;
|
return data.updateLead;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createCall(input: CreateCallInput): Promise<{ id: string }> {
|
async createCall(input: CreateCallInput): Promise<{ id: string }> {
|
||||||
const data = await this.query<{ createCall: { id: string } }>(
|
const data = await this.query<{ createCall: { id: string } }>(
|
||||||
`mutation CreateCall($data: CallCreateInput!) {
|
`mutation CreateCall($data: CallCreateInput!) {
|
||||||
createCall(data: $data) { id }
|
createCall(data: $data) { id }
|
||||||
}`,
|
}`,
|
||||||
{ data: input },
|
{ data: input },
|
||||||
);
|
);
|
||||||
return data.createCall;
|
return data.createCall;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createLeadActivity(
|
async createLeadActivity(input: CreateLeadActivityInput): Promise<{ id: string }> {
|
||||||
input: CreateLeadActivityInput,
|
const data = await this.query<{ createLeadActivity: { id: string } }>(
|
||||||
): Promise<{ id: string }> {
|
`mutation CreateLeadActivity($data: LeadActivityCreateInput!) {
|
||||||
const data = await this.query<{ createLeadActivity: { id: string } }>(
|
|
||||||
`mutation CreateLeadActivity($data: LeadActivityCreateInput!) {
|
|
||||||
createLeadActivity(data: $data) { id }
|
createLeadActivity(data: $data) { id }
|
||||||
}`,
|
}`,
|
||||||
{ data: input },
|
{ data: input },
|
||||||
);
|
);
|
||||||
return data.createLeadActivity;
|
return data.createLeadActivity;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Token passthrough versions (for user-driven requests) ---
|
// --- Token passthrough versions (for user-driven requests) ---
|
||||||
|
|
||||||
async findLeadByPhoneWithToken(
|
async findLeadByPhoneWithToken(phone: string, authHeader: string): Promise<LeadNode | null> {
|
||||||
phone: string,
|
const normalizedPhone = phone.replace(/\D/g, '');
|
||||||
authHeader: string,
|
const last10 = normalizedPhone.slice(-10);
|
||||||
): Promise<LeadNode | null> {
|
|
||||||
const normalizedPhone = phone.replace(/\D/g, '');
|
|
||||||
const last10 = normalizedPhone.slice(-10);
|
|
||||||
|
|
||||||
const data = await this.queryWithAuth<{
|
const data = await this.queryWithAuth<{ leads: { edges: { node: LeadNode }[] } }>(
|
||||||
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 {
|
||||||
node {
|
node {
|
||||||
@@ -166,43 +143,28 @@ export class PlatformGraphqlService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
{ first: 200 },
|
{ first: 200 },
|
||||||
authHeader,
|
authHeader,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Client-side phone matching
|
// Client-side phone matching
|
||||||
return (
|
return data.leads.edges.find(edge => {
|
||||||
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(
|
return num.endsWith(last10) || last10.endsWith(num);
|
||||||
/\D/g,
|
});
|
||||||
'',
|
}
|
||||||
);
|
// Handle single phone object
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
// 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(
|
async getLeadActivitiesWithToken(leadId: string, authHeader: string, limit = 5): Promise<LeadActivityNode[]> {
|
||||||
leadId: string,
|
const data = await this.queryWithAuth<{ leadActivities: { edges: { node: LeadActivityNode }[] } }>(
|
||||||
authHeader: string,
|
`query GetLeadActivities($filter: LeadActivityFilterInput, $first: Int) {
|
||||||
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 }]) {
|
leadActivities(filter: $filter, first: $first, orderBy: [{ occurredAt: DescNullsLast }]) {
|
||||||
edges {
|
edges {
|
||||||
node {
|
node {
|
||||||
@@ -211,51 +173,44 @@ 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(
|
async updateLeadWithToken(id: string, input: UpdateLeadInput, authHeader: string): Promise<LeadNode> {
|
||||||
id: string,
|
// Response fragment deliberately excludes `leadStatus` — the staging
|
||||||
input: UpdateLeadInput,
|
// platform schema has this field renamed to `status`. Selecting the
|
||||||
authHeader: string,
|
// old name rejects the whole mutation. Callers don't use the
|
||||||
): Promise<LeadNode> {
|
// returned fragment today, so returning just the id + AI fields
|
||||||
// Response fragment deliberately excludes `leadStatus` — the staging
|
// keeps this working across both schema shapes without a wider
|
||||||
// platform schema has this field renamed to `status`. Selecting the
|
// rename hotfix.
|
||||||
// old name rejects the whole mutation. Callers don't use the
|
const data = await this.queryWithAuth<{ updateLead: LeadNode }>(
|
||||||
// returned fragment today, so returning just the id + AI fields
|
`mutation UpdateLead($id: ID!, $data: LeadUpdateInput!) {
|
||||||
// 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) {
|
updateLead(id: $id, data: $data) {
|
||||||
id aiSummary aiSuggestedAction
|
id aiSummary aiSuggestedAction
|
||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
{ id, data: input },
|
{ id, data: input },
|
||||||
authHeader,
|
authHeader,
|
||||||
);
|
);
|
||||||
return data.updateLead;
|
return data.updateLead;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch a single lead by id with the caller's JWT. Used by the
|
// 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
|
// lead-enrich flow when the agent explicitly renames a caller from
|
||||||
// the appointment/enquiry form and we need to regenerate the lead's
|
// the appointment/enquiry form and we need to regenerate the lead's
|
||||||
// AI summary against fresh identity.
|
// AI summary against fresh identity.
|
||||||
//
|
//
|
||||||
// The selected fields deliberately use the staging-aligned names
|
// The selected fields deliberately use the staging-aligned names
|
||||||
// (`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(
|
async findLeadByIdWithToken(id: string, authHeader: string): Promise<any | null> {
|
||||||
id: string,
|
try {
|
||||||
authHeader: string,
|
const data = await this.queryWithAuth<{ lead: any }>(
|
||||||
): Promise<any | null> {
|
`query FindLead($id: UUID!) {
|
||||||
try {
|
|
||||||
const data = await this.queryWithAuth<{ lead: any }>(
|
|
||||||
`query FindLead($id: UUID!) {
|
|
||||||
lead(filter: { id: { eq: $id } }) {
|
lead(filter: { id: { eq: $id } }) {
|
||||||
id
|
id
|
||||||
createdAt
|
createdAt
|
||||||
@@ -270,17 +225,15 @@ export class PlatformGraphqlService {
|
|||||||
aiSuggestedAction
|
aiSuggestedAction
|
||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
{ id },
|
{ id },
|
||||||
authHeader,
|
authHeader,
|
||||||
);
|
);
|
||||||
return data.lead ?? null;
|
return data.lead ?? null;
|
||||||
} 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<{
|
const data = await this.queryWithAuth<{ leads: { edges: { node: any }[] } }>(
|
||||||
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 {
|
||||||
node {
|
node {
|
||||||
@@ -299,83 +252,18 @@ export class PlatformGraphqlService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
{ id },
|
{ id },
|
||||||
authHeader,
|
authHeader,
|
||||||
);
|
);
|
||||||
return data.leads.edges[0]?.node ?? null;
|
return data.leads.edges[0]?.node ?? null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
async getPatientWithToken(
|
// --- Server-to-server versions (for webhooks, background jobs) ---
|
||||||
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(
|
async getLeadActivities(leadId: string, limit = 3): Promise<LeadActivityNode[]> {
|
||||||
patientId: string,
|
const data = await this.query<{ leadActivities: { edges: { node: LeadActivityNode }[] } }>(
|
||||||
authHeader: string,
|
`query GetLeadActivities($filter: LeadActivityFilterInput, $first: Int) {
|
||||||
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 }) {
|
leadActivities(filter: $filter, first: $first, orderBy: { occurredAt: DescNullsLast }) {
|
||||||
edges {
|
edges {
|
||||||
node {
|
node {
|
||||||
@@ -384,8 +272,8 @@ 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user