Files
helix-engage-server/src/worklist/worklist.service.ts
saridsa2 2666a10f48 fix: await Ozonetel logout + per-agent sipPassword + campaign name on missed calls
Three changes:

1. Await Ozonetel logout in /auth/logout — prevents race condition when
   agent re-logs in quickly via "Remember me". The fire-and-forget
   logoutAgent() left a window where the next loginAgent() arrived
   while Ozonetel was still processing the previous logout, leaving
   the agent stuck in "Telephony Unavailable". (#559)

2. Use agentConfig.sipPassword (from Agent entity) instead of
   OZONETEL_AGENT_PASSWORD env var for login/logout/force-ready.
   The env var was a single shared credential that ignored per-agent
   passwords. Removed hardcoded "Test123$" fallback. Force-ready
   now looks up the Agent entity by ozonetelAgentId to get the
   correct sipPassword + sipExtension.

3. Missed-calls worklist query now fetches campaign { id campaignName }
   so the frontend Branch column can show the campaign name instead
   of the raw DID phone number.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 16:54:08 +05:30

187 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
patientId
} } 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
campaign { id campaignName }
} } pageInfo { hasNextPage endCursor } } }`,
'calls',
authHeader,
);
}
}