mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
Compare commits
5 Commits
96977e84a1
...
6adb3985cb
| Author | SHA1 | Date | |
|---|---|---|---|
| 6adb3985cb | |||
| 67c41f4783 | |||
| d459d6469a | |||
| 60d2329dd8 | |||
| f375e7736c |
@@ -55,6 +55,26 @@ export class SessionService {
|
|||||||
await this.redis.del(this.key(agentId));
|
await this.redis.del(this.key(agentId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enumerate every active session lock so the maint UI can show which
|
||||||
|
// agentIds are currently held (and by whom) vs free. Uses SCAN, not
|
||||||
|
// KEYS, to avoid blocking Redis on workspaces with many keys.
|
||||||
|
async listLockedSessions(): Promise<Array<{ agentId: string; memberId: string; ip: string; lockedAt: string }>> {
|
||||||
|
const out: Array<{ agentId: string; memberId: string; ip: string; lockedAt: string }> = [];
|
||||||
|
const stream = this.redis.scanStream({ match: 'agent:session:*', count: 100 });
|
||||||
|
const keys: string[] = [];
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
stream.on('data', (chunk: string[]) => keys.push(...chunk));
|
||||||
|
stream.on('end', resolve);
|
||||||
|
stream.on('error', reject);
|
||||||
|
});
|
||||||
|
for (const key of keys) {
|
||||||
|
const agentId = key.slice('agent:session:'.length);
|
||||||
|
const session = await this.getSession(agentId);
|
||||||
|
if (session) out.push({ agentId, ...session });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
// Generic cache operations for any module
|
// Generic cache operations for any module
|
||||||
async getCache(key: string): Promise<string | null> {
|
async getCache(key: string): Promise<string | null> {
|
||||||
return this.redis.get(key);
|
return this.redis.get(key);
|
||||||
|
|||||||
@@ -53,4 +53,20 @@ export class SetupStateController {
|
|||||||
const updated = this.setupState.resetState();
|
const updated = this.setupState.resetState();
|
||||||
return { ...updated, wizardRequired: this.setupState.isWizardRequired() };
|
return { ...updated, wizardRequired: this.setupState.isWizardRequired() };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UI-level flags the frontend reads at app boot to tailor which admin
|
||||||
|
// surfaces are available. Driven by sidecar env vars so each workspace
|
||||||
|
// can be configured independently without touching the frontend build.
|
||||||
|
//
|
||||||
|
// setupManaged=true means "the product team handles setup for this
|
||||||
|
// workspace" — hide the Settings nav, routes, and the resume-setup
|
||||||
|
// banner. The wizard + setup-state APIs stay functional for ops use
|
||||||
|
// (a support engineer can still PUT /steps/:step or hit the routes
|
||||||
|
// directly); only the end-user admin UI is hidden.
|
||||||
|
@Get('ui-flags')
|
||||||
|
uiFlags() {
|
||||||
|
return {
|
||||||
|
setupManaged: process.env.HELIX_SETUP_MANAGED === 'true',
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,59 @@ export class MaintController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Returns the current per-agent session state — which ozonetelAgentIds
|
||||||
|
// are currently locked (held by a member IP) and which are free. Used
|
||||||
|
// by the maint OTP modal to render a picker so a supervisor can unlock
|
||||||
|
// the right agent without knowing the id off the top of their head.
|
||||||
|
// Read-only; OTP-guarded like the rest of /api/maint.
|
||||||
|
@Post('session-status')
|
||||||
|
async sessionStatus() {
|
||||||
|
const data = await this.platform.query<any>(
|
||||||
|
`{ agents(first: 100) { edges { node { id name ozonetelAgentId ozonetelDisplayName } } } }`,
|
||||||
|
).catch(() => ({ agents: { edges: [] } }));
|
||||||
|
|
||||||
|
const allAgents = (data?.agents?.edges ?? []).map((e: any) => e.node).filter((a: any) => a.ozonetelAgentId);
|
||||||
|
const sessions = await this.session.listLockedSessions();
|
||||||
|
const sessionByAgent = new Map(sessions.map((s) => [s.agentId.toLowerCase(), s]));
|
||||||
|
|
||||||
|
const locked: Array<any> = [];
|
||||||
|
const free: Array<any> = [];
|
||||||
|
const seenAgentIds = new Set<string>();
|
||||||
|
|
||||||
|
for (const agent of allAgents) {
|
||||||
|
const key = String(agent.ozonetelAgentId).toLowerCase();
|
||||||
|
seenAgentIds.add(key);
|
||||||
|
const session = sessionByAgent.get(key);
|
||||||
|
const row = {
|
||||||
|
agentId: agent.ozonetelAgentId,
|
||||||
|
displayName: agent.name ?? agent.ozonetelDisplayName ?? agent.ozonetelAgentId,
|
||||||
|
};
|
||||||
|
if (session) {
|
||||||
|
locked.push({ ...row, heldByIp: session.ip, lockedAt: session.lockedAt });
|
||||||
|
} else {
|
||||||
|
free.push(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Surface orphan locks (Redis holds a session for an ozonetelAgentId
|
||||||
|
// with no matching Agent entity). Rare but possible after SDK renames
|
||||||
|
// or workspace resets — without surfacing them, the operator can't
|
||||||
|
// clear the stale lock via the UI.
|
||||||
|
for (const session of sessions) {
|
||||||
|
const key = session.agentId.toLowerCase();
|
||||||
|
if (!seenAgentIds.has(key)) {
|
||||||
|
locked.push({
|
||||||
|
agentId: session.agentId,
|
||||||
|
displayName: `${session.agentId} (orphan — no Agent record)`,
|
||||||
|
heldByIp: session.ip,
|
||||||
|
lockedAt: session.lockedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { locked, free };
|
||||||
|
}
|
||||||
|
|
||||||
@Post('unlock-agent')
|
@Post('unlock-agent')
|
||||||
async unlockAgent(@Body() body: { agentId: string }) {
|
async unlockAgent(@Body() body: { agentId: string }) {
|
||||||
if (!body?.agentId) throw new HttpException('agentId required', 400);
|
if (!body?.agentId) throw new HttpException('agentId required', 400);
|
||||||
|
|||||||
@@ -63,10 +63,18 @@ export class CdrEnrichmentService implements OnModuleInit, OnModuleDestroy {
|
|||||||
if (cdrRows.length === 0) continue;
|
if (cdrRows.length === 0) continue;
|
||||||
|
|
||||||
// Build UCID → cdr-row map so we can O(1) join per Call.
|
// Build UCID → cdr-row map so we can O(1) join per Call.
|
||||||
|
// Ozonetel emits two identifiers per call — `UCID` (caller-leg)
|
||||||
|
// and `monitorUCID` (agent-leg). The webhook stores `monitorUCID`,
|
||||||
|
// but the bulk CDR rows are keyed on caller-leg `UCID`. Index
|
||||||
|
// both so the lookup at line ~79 finds the row regardless of
|
||||||
|
// which side was persisted. Without this, transferred inbound
|
||||||
|
// calls never get their agent relation enriched.
|
||||||
const byUcid = new Map<string, any>();
|
const byUcid = new Map<string, any>();
|
||||||
for (const row of cdrRows) {
|
for (const row of cdrRows) {
|
||||||
const ucid = String(row.UCID ?? '').trim();
|
const ucid = String(row.UCID ?? '').trim();
|
||||||
|
const monitorUcid = String(row.monitorUCID ?? '').trim();
|
||||||
if (ucid) byUcid.set(ucid, row);
|
if (ucid) byUcid.set(ucid, row);
|
||||||
|
if (monitorUcid && monitorUcid !== ucid) byUcid.set(monitorUcid, row);
|
||||||
}
|
}
|
||||||
if (byUcid.size === 0) continue;
|
if (byUcid.size === 0) continue;
|
||||||
|
|
||||||
@@ -80,9 +88,25 @@ export class CdrEnrichmentService implements OnModuleInit, OnModuleDestroy {
|
|||||||
if (!cdrRow) { skipped++; continue; }
|
if (!cdrRow) { skipped++; continue; }
|
||||||
|
|
||||||
const patch: Record<string, any> = {};
|
const patch: Record<string, any> = {};
|
||||||
|
if (!call.agentId) {
|
||||||
|
// Primary resolution: use AgentID from CDR (unique lowercase id).
|
||||||
const cdrAgentId = cdrRow.AgentID;
|
const cdrAgentId = cdrRow.AgentID;
|
||||||
if (cdrAgentId && !call.agentId) {
|
let uuid = cdrAgentId
|
||||||
const uuid = await this.agentLookup.resolveByOzonetelId(cdrAgentId);
|
? await this.agentLookup.resolveByOzonetelId(cdrAgentId)
|
||||||
|
: null;
|
||||||
|
// Fallback: CDR AgentName may be a chain ("A -> B") for
|
||||||
|
// transferred calls. Pick the final handler (last segment)
|
||||||
|
// and look it up by display name or ozonetelId. Matches
|
||||||
|
// the write-time resolution in missed-call-webhook.
|
||||||
|
if (!uuid && cdrRow.AgentName) {
|
||||||
|
const segments = String(cdrRow.AgentName).split('->').map((s) => s.trim()).filter(Boolean);
|
||||||
|
const finalHandler = segments[segments.length - 1];
|
||||||
|
if (finalHandler) {
|
||||||
|
uuid =
|
||||||
|
(await this.agentLookup.resolveByOzonetelId(finalHandler)) ??
|
||||||
|
(await this.agentLookup.resolveByDisplayName(finalHandler));
|
||||||
|
}
|
||||||
|
}
|
||||||
if (uuid) patch.agentId = uuid;
|
if (uuid) patch.agentId = uuid;
|
||||||
if (cdrRow.AgentName) patch.agentName = cdrRow.AgentName;
|
if (cdrRow.AgentName) patch.agentName = cdrRow.AgentName;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { EventBusService } from '../events/event-bus.service';
|
|||||||
import { Topics } from '../events/event-types';
|
import { Topics } from '../events/event-types';
|
||||||
import { TelephonyConfigService } from '../config/telephony-config.service';
|
import { TelephonyConfigService } from '../config/telephony-config.service';
|
||||||
import { SupervisorService } from '../supervisor/supervisor.service';
|
import { SupervisorService } from '../supervisor/supervisor.service';
|
||||||
|
import { AgentHistoryService } from '../supervisor/agent-history.service';
|
||||||
|
|
||||||
// Convert Ozonetel "HH:MM:SS" (or null/empty) to integer seconds.
|
// Convert Ozonetel "HH:MM:SS" (or null/empty) to integer seconds.
|
||||||
// Returns null when input is missing or all-zero.
|
// Returns null when input is missing or all-zero.
|
||||||
@@ -30,6 +31,7 @@ export class OzonetelAgentController {
|
|||||||
private readonly eventBus: EventBusService,
|
private readonly eventBus: EventBusService,
|
||||||
private readonly supervisor: SupervisorService,
|
private readonly supervisor: SupervisorService,
|
||||||
private readonly agentLookup: AgentLookupService,
|
private readonly agentLookup: AgentLookupService,
|
||||||
|
private readonly agentHistory: AgentHistoryService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private requireAgentId(agentId: string | undefined | null): string {
|
private requireAgentId(agentId: string | undefined | null): string {
|
||||||
@@ -405,12 +407,28 @@ export class OzonetelAgentController {
|
|||||||
const targetDate = date ?? new Date().toISOString().split('T')[0];
|
const targetDate = date ?? new Date().toISOString().split('T')[0];
|
||||||
this.logger.log(`Performance: date=${targetDate} agent=${agent}`);
|
this.logger.log(`Performance: date=${targetDate} agent=${agent}`);
|
||||||
|
|
||||||
const [cdr, summary, aht] = await Promise.all([
|
// Trigger an on-demand rollup for the requested date so the
|
||||||
|
// AgentSession row reflects the current open session (caps at now)
|
||||||
|
// instead of waiting up to 15 min for the background tick. Fire-and-
|
||||||
|
// forget with a short await so we don't block the whole response on
|
||||||
|
// cache-refresh tail but still hand the read a fresh row when Redpanda
|
||||||
|
// is quiet. Safe to error — AgentSession just stays stale.
|
||||||
|
await this.agentHistory.rollupSessions(targetDate).catch(() => {});
|
||||||
|
|
||||||
|
const [cdr, summary, aht, agentSessionBreakdown] = await Promise.all([
|
||||||
this.ozonetelAgent.fetchCDR({ date: targetDate }),
|
this.ozonetelAgent.fetchCDR({ date: targetDate }),
|
||||||
this.ozonetelAgent.getAgentSummary(agent, targetDate),
|
this.ozonetelAgent.getAgentSummary(agent, targetDate),
|
||||||
this.ozonetelAgent.getAHT(agent),
|
this.ozonetelAgent.getAHT(agent),
|
||||||
|
this.fetchAgentSessionTimeBreakdown(agent, targetDate),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Prefer our AgentSession rollup when present — it correctly counts
|
||||||
|
// the current OPEN session (caps at now), while Ozonetel's summaryReport
|
||||||
|
// only tallies CLOSED login→logout pairs. Fall back to Ozonetel if
|
||||||
|
// our rollup hasn't captured this agent yet (e.g., brand-new agent,
|
||||||
|
// workspace without AgentEvent entity synced).
|
||||||
|
const timeUtilization = agentSessionBreakdown ?? summary;
|
||||||
|
|
||||||
// Filter CDR to this agent only — fetchCDR returns all agents' calls
|
// Filter CDR to this agent only — fetchCDR returns all agents' calls
|
||||||
// Use case-insensitive matching — Ozonetel field casing varies
|
// Use case-insensitive matching — Ozonetel field casing varies
|
||||||
const agentLower = agent.toLowerCase();
|
const agentLower = agent.toLowerCase();
|
||||||
@@ -460,7 +478,7 @@ export class OzonetelAgentController {
|
|||||||
avgHandlingTime: aht,
|
avgHandlingTime: aht,
|
||||||
conversionRate: totalCalls > 0 ? Math.round((appointmentsBooked / totalCalls) * 100) : 0,
|
conversionRate: totalCalls > 0 ? Math.round((appointmentsBooked / totalCalls) * 100) : 0,
|
||||||
appointmentsBooked,
|
appointmentsBooked,
|
||||||
timeUtilization: summary,
|
timeUtilization,
|
||||||
dispositions,
|
dispositions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -480,4 +498,52 @@ export class OzonetelAgentController {
|
|||||||
};
|
};
|
||||||
return map[disposition] ?? 'General Enquiry';
|
return map[disposition] ?? 'General Enquiry';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert our AgentSession rollup (seconds per category) into the HH:MM:SS
|
||||||
|
// shape the frontend expects — so My Performance gets LOGIN TIME with the
|
||||||
|
// current open session included, not just closed sessions from Ozonetel.
|
||||||
|
private async fetchAgentSessionTimeBreakdown(ozonetelAgentId: string, date: string): Promise<{
|
||||||
|
totalLoginDuration: string;
|
||||||
|
totalBusyTime: string;
|
||||||
|
totalIdleTime: string;
|
||||||
|
totalPauseTime: string;
|
||||||
|
totalWrapupTime: string;
|
||||||
|
totalDialTime: string;
|
||||||
|
} | null> {
|
||||||
|
try {
|
||||||
|
const agentUuid = await this.agentLookup.resolveByOzonetelId(ozonetelAgentId);
|
||||||
|
if (!agentUuid) return null;
|
||||||
|
const data = await this.platform.query<any>(
|
||||||
|
`{ agentSessions(first: 1, filter: {
|
||||||
|
agentId: { eq: "${agentUuid}" },
|
||||||
|
date: { eq: "${date}" }
|
||||||
|
}) { edges { node {
|
||||||
|
loginDurationS busyTimeS idleTimeS pauseTimeS wrapupTimeS dialTimeS
|
||||||
|
} } } }`,
|
||||||
|
);
|
||||||
|
const node = data?.agentSessions?.edges?.[0]?.node;
|
||||||
|
if (!node) return null;
|
||||||
|
const hms = (sec: number | null | undefined): string => {
|
||||||
|
const s = Math.max(0, Math.round(sec ?? 0));
|
||||||
|
const h = Math.floor(s / 3600);
|
||||||
|
const m = Math.floor((s % 3600) / 60);
|
||||||
|
const r = s % 60;
|
||||||
|
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${r.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
// If the entire rollup is zero, treat as "no data yet" — fall back
|
||||||
|
// to Ozonetel's summaryReport so the KPI isn't all zeroes.
|
||||||
|
const total = (node.loginDurationS ?? 0) + (node.busyTimeS ?? 0) + (node.idleTimeS ?? 0) + (node.pauseTimeS ?? 0) + (node.wrapupTimeS ?? 0);
|
||||||
|
if (total === 0) return null;
|
||||||
|
return {
|
||||||
|
totalLoginDuration: hms(node.loginDurationS),
|
||||||
|
totalBusyTime: hms(node.busyTimeS),
|
||||||
|
totalIdleTime: hms(node.idleTimeS),
|
||||||
|
totalPauseTime: hms(node.pauseTimeS),
|
||||||
|
totalWrapupTime: hms(node.wrapupTimeS),
|
||||||
|
totalDialTime: hms(node.dialTimeS),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Controller, Post, Body, Headers, Logger } from '@nestjs/common';
|
import { Controller, Post, Body, Headers, Logger } from '@nestjs/common';
|
||||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
import { AgentLookupService } from '../platform/agent-lookup.service';
|
||||||
import { CallerResolutionService } from '../caller/caller-resolution.service';
|
import { CallerResolutionService } from '../caller/caller-resolution.service';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ export class MissedCallWebhookController {
|
|||||||
private readonly platform: PlatformGraphqlService,
|
private readonly platform: PlatformGraphqlService,
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
private readonly caller: CallerResolutionService,
|
private readonly caller: CallerResolutionService,
|
||||||
|
private readonly agentLookup: AgentLookupService,
|
||||||
) {
|
) {
|
||||||
this.apiKey = config.get<string>('platform.apiKey') ?? '';
|
this.apiKey = config.get<string>('platform.apiKey') ?? '';
|
||||||
}
|
}
|
||||||
@@ -197,6 +199,25 @@ export class MissedCallWebhookController {
|
|||||||
callData.recording = { primaryLinkUrl: data.recordingUrl, primaryLinkLabel: 'Recording' };
|
callData.recording = { primaryLinkUrl: data.recordingUrl, primaryLinkLabel: 'Recording' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve agent relation at write-time so the supervisor dashboard
|
||||||
|
// can bucket the row immediately. Ozonetel sends transferred calls
|
||||||
|
// with a chain-style AgentName like "RamaiahAdmin -> GlobalHealthX" —
|
||||||
|
// the final handler is the last segment, so split on " -> " and
|
||||||
|
// resolve that. Try both ozonetelAgentId (lowercase unique) and
|
||||||
|
// ozonetelDisplayName (mixed-case human label) since Ozonetel mixes
|
||||||
|
// formats across webhook payloads. Leaves agentId null on miss so
|
||||||
|
// the cdr-enrichment cron can still attempt a match by UCID later.
|
||||||
|
if (data.agentName) {
|
||||||
|
const segments = data.agentName.split('->').map((s) => s.trim()).filter(Boolean);
|
||||||
|
const finalHandler = segments[segments.length - 1];
|
||||||
|
if (finalHandler) {
|
||||||
|
const uuid =
|
||||||
|
(await this.agentLookup.resolveByOzonetelId(finalHandler)) ??
|
||||||
|
(await this.agentLookup.resolveByDisplayName(finalHandler));
|
||||||
|
if (uuid) callData.agentId = uuid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const result = await this.platform.queryWithAuth<any>(
|
const result = await this.platform.queryWithAuth<any>(
|
||||||
`mutation($data: CallCreateInput!) { createCall(data: $data) { id } }`,
|
`mutation($data: CallCreateInput!) { createCall(data: $data) { id } }`,
|
||||||
{ data: callData },
|
{ data: callData },
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { Test } from '@nestjs/testing';
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { MissedCallWebhookController } from './missed-call-webhook.controller';
|
import { MissedCallWebhookController } from './missed-call-webhook.controller';
|
||||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
import { AgentLookupService } from '../platform/agent-lookup.service';
|
||||||
|
import { CallerResolutionService } from '../caller/caller-resolution.service';
|
||||||
import {
|
import {
|
||||||
WEBHOOK_INBOUND_ANSWERED,
|
WEBHOOK_INBOUND_ANSWERED,
|
||||||
WEBHOOK_INBOUND_MISSED,
|
WEBHOOK_INBOUND_MISSED,
|
||||||
@@ -48,11 +50,28 @@ describe('MissedCallWebhookController', () => {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mockCaller = {
|
||||||
|
resolve: jest.fn().mockResolvedValue({
|
||||||
|
leadId: '',
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
patientId: '',
|
||||||
|
isNew: true,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockAgentLookup = {
|
||||||
|
resolveByOzonetelId: jest.fn().mockResolvedValue(null),
|
||||||
|
resolveByDisplayName: jest.fn().mockResolvedValue(null),
|
||||||
|
};
|
||||||
|
|
||||||
const module = await Test.createTestingModule({
|
const module = await Test.createTestingModule({
|
||||||
controllers: [MissedCallWebhookController],
|
controllers: [MissedCallWebhookController],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: PlatformGraphqlService, useValue: mockPlatformGql },
|
{ provide: PlatformGraphqlService, useValue: mockPlatformGql },
|
||||||
{ provide: ConfigService, useValue: mockConfig },
|
{ provide: ConfigService, useValue: mockConfig },
|
||||||
|
{ provide: CallerResolutionService, useValue: mockCaller },
|
||||||
|
{ provide: AgentLookupService, useValue: mockAgentLookup },
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ export class WorklistService {
|
|||||||
assignedAgent campaignId
|
assignedAgent campaignId
|
||||||
contactAttempts spamScore isSpam
|
contactAttempts spamScore isSpam
|
||||||
aiSummary aiSuggestedAction
|
aiSummary aiSuggestedAction
|
||||||
|
patientId
|
||||||
} } pageInfo { hasNextPage endCursor } } }`,
|
} } pageInfo { hasNextPage endCursor } } }`,
|
||||||
'leads',
|
'leads',
|
||||||
authHeader,
|
authHeader,
|
||||||
|
|||||||
Reference in New Issue
Block a user