feat: add worklist engine with prioritized missed calls, follow-ups, and leads

This commit is contained in:
2026-03-18 11:26:04 +05:30
parent f0d3d2c9f1
commit 6f7d408724
8 changed files with 484 additions and 1 deletions

View File

@@ -0,0 +1,16 @@
import { Controller, Post, Body, Logger } from '@nestjs/common';
@Controller('webhooks/ozonetel')
export class MissedCallWebhookController {
private readonly logger = new Logger(MissedCallWebhookController.name);
@Post('missed-call')
async handleMissedCall(@Body() body: Record<string, any>) {
this.logger.log(`Received missed call webhook: ${JSON.stringify(body)}`);
// TODO: Auto-assignment, duplicate merging, worklist insertion
// For now, just acknowledge receipt
return { received: true };
}
}

View File

@@ -0,0 +1,45 @@
import { Controller, Get, Headers, HttpException, Logger } from '@nestjs/common';
import { WorklistService } from './worklist.service';
@Controller('api/worklist')
export class WorklistController {
private readonly logger = new Logger(WorklistController.name);
constructor(private readonly worklist: WorklistService) {}
@Get()
async getWorklist(@Headers('authorization') authHeader: string) {
if (!authHeader) {
throw new HttpException('Authorization required', 401);
}
// Decode the JWT to extract the agent name
// The platform JWT payload contains user info — we extract the name
const agentName = this.extractAgentName(authHeader);
if (!agentName) {
throw new HttpException('Could not determine agent identity from token', 400);
}
this.logger.log(`Fetching worklist for agent: ${agentName}`);
return this.worklist.getWorklist(agentName, authHeader);
}
private extractAgentName(authHeader: string): string | null {
try {
const token = authHeader.replace(/^Bearer\s+/i, '');
// JWT payload is the second segment, base64url-encoded
const payload = JSON.parse(
Buffer.from(token.split('.')[1], 'base64url').toString('utf8'),
);
// The platform JWT includes sub (userId) and workspace info
// The agent name comes from firstName + lastName in the token
const firstName = payload.firstName ?? payload.given_name ?? '';
const lastName = payload.lastName ?? payload.family_name ?? '';
const fullName = `${firstName} ${lastName}`.trim();
return fullName || payload.email || payload.sub || null;
} catch {
return null;
}
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { PlatformModule } from '../platform/platform.module';
import { WorklistController } from './worklist.controller';
import { WorklistService } from './worklist.service';
import { MissedCallWebhookController } from './missed-call-webhook.controller';
@Module({
imports: [PlatformModule],
controllers: [WorklistController, MissedCallWebhookController],
providers: [WorklistService],
})
export class WorklistModule {}

View File

@@ -0,0 +1,139 @@
import { Injectable, Logger } from '@nestjs/common';
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
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) {}
async getWorklist(agentName: string, authHeader: string): Promise<WorklistResponse> {
const [missedCalls, followUps, marketingLeads] = await Promise.all([
this.getMissedCallsWithToken(agentName, authHeader),
this.getPendingFollowUpsWithToken(agentName, authHeader),
this.getAssignedLeadsWithToken(agentName, authHeader),
]);
return {
missedCalls,
followUps,
marketingLeads,
totalPending: missedCalls.length + followUps.length + marketingLeads.length,
};
}
private async getAssignedLeadsWithToken(agentName: string, authHeader: string): Promise<any[]> {
try {
const data = await this.platform.queryWithAuth<{
leads: { edges: { node: any }[] };
}>(
`query GetAssignedLeads($filter: LeadFilterInput, $first: Int, $orderBy: [LeadOrderByInput]) {
leads(filter: $filter, first: $first, orderBy: $orderBy) {
edges {
node {
id createdAt
contactName { firstName lastName }
contactPhone { number callingCode }
contactEmail { address }
leadSource leadStatus interestedService
assignedAgent campaignId adId
contactAttempts spamScore isSpam
aiSummary aiSuggestedAction
}
}
}
}`,
{
filter: { assignedAgent: { eq: agentName } },
first: 20,
orderBy: [{ createdAt: 'AscNullsLast' }],
},
authHeader,
);
return data.leads.edges.map((e) => e.node);
} catch (err) {
this.logger.warn(`Failed to fetch assigned leads: ${err}`);
return [];
}
}
private async getPendingFollowUpsWithToken(agentName: string, authHeader: string): Promise<any[]> {
try {
const data = await this.platform.queryWithAuth<{
followUps: { edges: { node: any }[] };
}>(
`query GetPendingFollowUps($filter: FollowUpFilterInput, $first: Int) {
followUps(filter: $filter, first: $first) {
edges {
node {
id createdAt
followUpType followUpStatus
scheduledAt completedAt
priority assignedAgent
patientId callId
}
}
}
}`,
{
filter: {
assignedAgent: { eq: agentName },
followUpStatus: { in: ['PENDING', 'OVERDUE'] },
},
first: 20,
},
authHeader,
);
return data.followUps.edges.map((e) => e.node);
} catch (err) {
this.logger.warn(`Failed to fetch follow-ups: ${err}`);
return [];
}
}
private async getMissedCallsWithToken(agentName: string, authHeader: string): Promise<any[]> {
try {
const data = await this.platform.queryWithAuth<{
calls: { edges: { node: any }[] };
}>(
`query GetMissedCalls($filter: CallFilterInput, $first: Int, $orderBy: [CallOrderByInput]) {
calls(filter: $filter, first: $first, orderBy: $orderBy) {
edges {
node {
id createdAt
callDirection callStatus
callerNumber { number callingCode }
agentName startedAt endedAt
durationSeconds disposition
callNotes leadId
}
}
}
}`,
{
filter: {
callStatus: { eq: 'MISSED' },
agentName: { eq: agentName },
},
first: 20,
orderBy: [{ createdAt: 'AscNullsLast' }],
},
authHeader,
);
return data.calls.edges.map((e) => e.node);
} catch (err) {
this.logger.warn(`Failed to fetch missed calls: ${err}`);
return [];
}
}
}