mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
- caller-resolution: drop cache, use indexed phone filter (lead.contactPhone.primaryPhoneNumber.like) - worklist: externalize page size (WORKLIST_PAGE_SIZE × WORKLIST_MAX_PAGES), paginate getMissedCalls/getAssignedLeads/getPendingFollowUps - maint: unlock-agent, force-ready, backfill-caller-resolution, clear-analysis-cache, fix-timestamps - ozonetel agent.service: force logout+re-login on "already logged in" - ai chat: context expansion - livekit-agent: updates - widget: session handling - masterdata: clinic list cache Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
185 lines
8.1 KiB
TypeScript
185 lines
8.1 KiB
TypeScript
import { Injectable, Logger } from '@nestjs/common';
|
||
import { ConfigService } from '@nestjs/config';
|
||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||
import { WorklistConsumer } from '../rules-engine/consumers/worklist.consumer';
|
||
|
||
export type WorklistResponse = {
|
||
missedCalls: any[];
|
||
followUps: any[];
|
||
marketingLeads: any[];
|
||
totalPending: number;
|
||
};
|
||
|
||
@Injectable()
|
||
export class WorklistService {
|
||
private readonly logger = new Logger(WorklistService.name);
|
||
|
||
constructor(
|
||
private readonly platform: PlatformGraphqlService,
|
||
private readonly worklistConsumer: WorklistConsumer,
|
||
private readonly config: ConfigService,
|
||
) {}
|
||
|
||
private get pageSize(): number {
|
||
return this.config.get<number>('worklist.pageSize', 50);
|
||
}
|
||
|
||
private get maxPages(): number {
|
||
return this.config.get<number>('worklist.maxPages', 10);
|
||
}
|
||
|
||
// Paginate a Relay connection query. Caller provides a function that
|
||
// builds the query for a given cursor ('' on first page). Stops when
|
||
// the platform reports no more pages OR the safety ceiling hits.
|
||
private async fetchAllPages<T>(
|
||
buildQuery: (cursorClause: string) => string,
|
||
connectionKey: string,
|
||
authHeader: string,
|
||
): Promise<T[]> {
|
||
const all: T[] = [];
|
||
let cursor = '';
|
||
for (let page = 0; page < this.maxPages; page++) {
|
||
const cursorClause = cursor ? `, after: "${cursor}"` : '';
|
||
try {
|
||
const data = await this.platform.queryWithAuth<any>(
|
||
buildQuery(cursorClause),
|
||
undefined,
|
||
authHeader,
|
||
);
|
||
const conn = data?.[connectionKey];
|
||
if (!conn) break;
|
||
all.push(...(conn.edges?.map((e: any) => e.node) ?? []));
|
||
if (!conn.pageInfo?.hasNextPage) break;
|
||
cursor = conn.pageInfo.endCursor ?? '';
|
||
if (!cursor) break;
|
||
} catch (err) {
|
||
this.logger.warn(`[WORKLIST] ${connectionKey} page ${page} failed: ${err}`);
|
||
break;
|
||
}
|
||
}
|
||
return all;
|
||
}
|
||
|
||
async getWorklist(agentName: string, authHeader: string): Promise<WorklistResponse> {
|
||
const [rawMissedCalls, rawFollowUps, rawMarketingLeads] = await Promise.all([
|
||
this.getMissedCalls(agentName, authHeader),
|
||
this.getPendingFollowUps(agentName, authHeader),
|
||
this.getAssignedLeads(agentName, authHeader),
|
||
]);
|
||
|
||
// Tag each item with a type field for the scoring engine
|
||
const combined = [
|
||
...rawMissedCalls.map((item: any) => ({ ...item, type: 'missed' })),
|
||
...rawFollowUps.map((item: any) => ({ ...item, type: 'follow-up' })),
|
||
...rawMarketingLeads.map((item: any) => ({ ...item, type: 'lead' })),
|
||
];
|
||
|
||
// Score and rank via rules engine
|
||
const scored = await this.worklistConsumer.scoreAndRank(combined);
|
||
|
||
// Split back into the 3 categories
|
||
const missedCalls = scored.filter((item: any) => item.type === 'missed');
|
||
const followUps = scored.filter((item: any) => item.type === 'follow-up');
|
||
const marketingLeads = scored.filter((item: any) => item.type === 'lead');
|
||
|
||
return {
|
||
missedCalls,
|
||
followUps,
|
||
marketingLeads,
|
||
totalPending: missedCalls.length + followUps.length + marketingLeads.length,
|
||
};
|
||
}
|
||
|
||
private async getAssignedLeads(agentName: string, authHeader: string): Promise<any[]> {
|
||
return this.fetchAllPages<any>(
|
||
(cursor) => `{ leads(first: ${this.pageSize}${cursor}, filter: { assignedAgent: { eq: "${agentName}" } }, orderBy: [{ createdAt: AscNullsLast }]) { edges { node {
|
||
id createdAt
|
||
contactName { firstName lastName }
|
||
contactPhone { primaryPhoneNumber }
|
||
contactEmail { primaryEmail }
|
||
source status interestedService
|
||
assignedAgent campaignId
|
||
contactAttempts spamScore isSpam
|
||
aiSummary aiSuggestedAction
|
||
} } pageInfo { hasNextPage endCursor } } }`,
|
||
'leads',
|
||
authHeader,
|
||
);
|
||
}
|
||
|
||
private async getPendingFollowUps(agentName: string, authHeader: string): Promise<any[]> {
|
||
const raw = await this.fetchAllPages<any>(
|
||
(cursor) => `{ followUps(first: ${this.pageSize}${cursor}, filter: { assignedAgent: { eq: "${agentName}" } }) { edges { node {
|
||
id name createdAt
|
||
typeCustom status scheduledAt completedAt
|
||
priority assignedAgent
|
||
patientId
|
||
} } pageInfo { hasNextPage endCursor } } }`,
|
||
'followUps',
|
||
authHeader,
|
||
);
|
||
// Filter to PENDING/OVERDUE client-side since platform may not support in-filter on remapped fields
|
||
const followUps = raw.filter((f: any) => f.status === 'PENDING' || f.status === 'OVERDUE');
|
||
try {
|
||
|
||
// Enrich with patient name/phone so the worklist can render them.
|
||
// FollowUp stores only patientId — the name in fu.name is free-form
|
||
// and phone isn't stored at all, so one patient fetch fills both.
|
||
const patientIds: string[] = Array.from(
|
||
new Set(followUps.map((f: any) => f.patientId).filter((id: any): id is string => typeof id === 'string' && id.length > 0)),
|
||
);
|
||
if (patientIds.length > 0) {
|
||
try {
|
||
const idsGql = patientIds.map((id) => `"${id}"`).join(',');
|
||
const patientsData = await this.platform.queryWithAuth<any>(
|
||
`{ patients(first: ${patientIds.length}, filter: { id: { in: [${idsGql}] } }) { edges { node {
|
||
id fullName { firstName lastName } phones { primaryPhoneNumber }
|
||
} } } }`,
|
||
undefined,
|
||
authHeader,
|
||
);
|
||
const patientMap = new Map<string, { name: string; phone: string }>();
|
||
for (const edge of patientsData.patients.edges) {
|
||
const p = edge.node;
|
||
const name = [p.fullName?.firstName, p.fullName?.lastName].filter(Boolean).join(' ').trim();
|
||
const phone = p.phones?.primaryPhoneNumber ?? '';
|
||
patientMap.set(p.id, { name, phone });
|
||
}
|
||
for (const fu of followUps) {
|
||
if (fu.patientId && patientMap.has(fu.patientId)) {
|
||
const p = patientMap.get(fu.patientId)!;
|
||
fu.patientName = p.name;
|
||
fu.patientPhone = p.phone;
|
||
}
|
||
}
|
||
} catch (err) {
|
||
this.logger.warn(`Failed to enrich follow-ups with patient data: ${err}`);
|
||
}
|
||
}
|
||
|
||
return followUps;
|
||
} catch (err) {
|
||
this.logger.warn(`Failed to fetch follow-ups: ${err}`);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
private async getMissedCalls(_agentName: string, authHeader: string): Promise<any[]> {
|
||
// FIFO ordering (AscNullsLast) — oldest first. No agentName filter —
|
||
// missed calls are a shared queue. Paginated via WORKLIST_PAGE_SIZE
|
||
// × WORKLIST_MAX_PAGES ceiling.
|
||
return this.fetchAllPages<any>(
|
||
(cursor) => `{ calls(first: ${this.pageSize}${cursor}, filter: { callStatus: { eq: MISSED }, callbackStatus: { in: [PENDING_CALLBACK, CALLBACK_ATTEMPTED] } }, orderBy: [{ startedAt: AscNullsLast }]) { edges { node {
|
||
id name createdAt
|
||
direction callStatus agentName
|
||
callerNumber { primaryPhoneNumber }
|
||
startedAt endedAt durationSec
|
||
disposition leadId leadName
|
||
callbackStatus callSourceNumber missedCallCount callbackAttemptedAt
|
||
} } pageInfo { hasNextPage endCursor } } }`,
|
||
'calls',
|
||
authHeader,
|
||
);
|
||
}
|
||
}
|