mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
fix+feat: morning QA fixes, worklist pagination, misc sidecar improvements
- 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>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
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';
|
||||
|
||||
@@ -16,8 +17,49 @@ export class WorklistService {
|
||||
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),
|
||||
@@ -49,69 +91,94 @@ export class WorklistService {
|
||||
}
|
||||
|
||||
private async getAssignedLeads(agentName: string, authHeader: string): Promise<any[]> {
|
||||
try {
|
||||
const data = await this.platform.queryWithAuth<any>(
|
||||
`{ leads(first: 20, 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
|
||||
} } } }`,
|
||||
undefined,
|
||||
authHeader,
|
||||
);
|
||||
return data.leads.edges.map((e: any) => e.node);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to fetch assigned leads: ${err}`);
|
||||
return [];
|
||||
}
|
||||
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 {
|
||||
const data = await this.platform.queryWithAuth<any>(
|
||||
`{ followUps(first: 20, filter: { assignedAgent: { eq: "${agentName}" } }) { edges { node {
|
||||
id name createdAt
|
||||
typeCustom status scheduledAt completedAt
|
||||
priority assignedAgent
|
||||
patientId
|
||||
} } } }`,
|
||||
undefined,
|
||||
authHeader,
|
||||
|
||||
// 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)),
|
||||
);
|
||||
// Filter to PENDING/OVERDUE client-side since platform may not support in-filter on remapped fields
|
||||
return data.followUps.edges
|
||||
.map((e: any) => e.node)
|
||||
.filter((f: any) => f.status === 'PENDING' || f.status === 'OVERDUE');
|
||||
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[]> {
|
||||
try {
|
||||
// FIFO ordering (AscNullsLast) — oldest first. No agentName filter — missed calls are a shared queue.
|
||||
const data = await this.platform.queryWithAuth<any>(
|
||||
`{ calls(first: 20, 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
|
||||
callbackStatus callSourceNumber missedCallCount callbackAttemptedAt
|
||||
} } } }`,
|
||||
undefined,
|
||||
authHeader,
|
||||
);
|
||||
return data.calls.edges.map((e: any) => e.node);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to fetch missed calls: ${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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user