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('worklist.pageSize', 50); } private get maxPages(): number { return this.config.get('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( buildQuery: (cursorClause: string) => string, connectionKey: string, authHeader: string, ): Promise { 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( 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 { 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 { return this.fetchAllPages( (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 { const raw = await this.fetchAllPages( (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( `{ patients(first: ${patientIds.length}, filter: { id: { in: [${idsGql}] } }) { edges { node { id fullName { firstName lastName } phones { primaryPhoneNumber } } } } }`, undefined, authHeader, ); const patientMap = new Map(); 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 { // 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( (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, ); } }