Files
helix-engage-server/src/worklist/worklist.service.ts
saridsa2 fbe782b5ac 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>
2026-04-15 06:49:02 +05:30

185 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
);
}
}