mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
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>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { WidgetController } from './widget.controller';
|
||||
import { WebhooksController } from './webhooks.controller';
|
||||
import { WidgetService } from './widget.service';
|
||||
@@ -6,12 +6,13 @@ import { WidgetChatService } from './widget-chat.service';
|
||||
import { PlatformModule } from '../platform/platform.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { ConfigThemeModule } from '../config/config-theme.module';
|
||||
import { CallerResolutionModule } from '../caller/caller-resolution.module';
|
||||
|
||||
// WidgetKeysService lives in ConfigThemeModule now — injected here via the
|
||||
// module's exports. This module only owns the widget-facing API endpoints
|
||||
// (init / chat / book / lead) plus the NestJS guards that consume the keys.
|
||||
@Module({
|
||||
imports: [PlatformModule, AuthModule, ConfigThemeModule],
|
||||
imports: [PlatformModule, AuthModule, ConfigThemeModule, forwardRef(() => CallerResolutionModule)],
|
||||
controllers: [WidgetController, WebhooksController],
|
||||
providers: [WidgetService, WidgetChatService],
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ConfigService } from '@nestjs/config';
|
||||
import type { WidgetInitResponse, WidgetBookRequest, WidgetLeadRequest } from './widget.types';
|
||||
import { ThemeService } from '../config/theme.service';
|
||||
import { DOCTOR_VISIT_SLOTS_FRAGMENT, normalizeDoctors, type NormalizedDoctor } from '../shared/doctor-utils';
|
||||
import { CallerResolutionService } from '../caller/caller-resolution.service';
|
||||
|
||||
// Dedup window: any lead created for this phone within the last 24h is
|
||||
// considered the same visitor's lead — chat + book + contact by the same
|
||||
@@ -25,6 +26,7 @@ export class WidgetService {
|
||||
private platform: PlatformGraphqlService,
|
||||
private theme: ThemeService,
|
||||
private config: ConfigService,
|
||||
private caller: CallerResolutionService,
|
||||
) {
|
||||
this.apiKey = config.get<string>('platform.apiKey') ?? '';
|
||||
}
|
||||
@@ -37,8 +39,10 @@ export class WidgetService {
|
||||
return raw.replace(/[^0-9]/g, '').slice(-10);
|
||||
}
|
||||
|
||||
// Shared lead dedup: finds a lead created in the last 24h for the same
|
||||
// phone, or creates a new one. Public so WidgetChatService can reuse it.
|
||||
// Shared lead dedup. Resolves via CallerResolutionService; when isNew
|
||||
// (no prior Lead/Patient), we have a name here (widget form field),
|
||||
// so we create both records inline. When an existing record is
|
||||
// returned we update it with the latest channel + name.
|
||||
async findOrCreateLeadByPhone(
|
||||
name: string,
|
||||
rawPhone: string,
|
||||
@@ -47,53 +51,85 @@ export class WidgetService {
|
||||
const phone = this.normalizePhone(rawPhone);
|
||||
if (!phone) throw new Error('Invalid phone number');
|
||||
|
||||
const since = new Date(Date.now() - LEAD_DEDUP_WINDOW_MS).toISOString();
|
||||
|
||||
try {
|
||||
const existing = await this.platform.queryWithAuth<any>(
|
||||
`query($phone: String!, $since: DateTime!) {
|
||||
leads(
|
||||
first: 1,
|
||||
filter: {
|
||||
contactPhone: { primaryPhoneNumber: { like: $phone } },
|
||||
createdAt: { gte: $since }
|
||||
},
|
||||
orderBy: [{ createdAt: DescNullsLast }]
|
||||
) { edges { node { id createdAt } } }
|
||||
}`,
|
||||
{ phone: `%${phone}`, since },
|
||||
this.auth,
|
||||
);
|
||||
const match = existing?.leads?.edges?.[0]?.node;
|
||||
if (match?.id) {
|
||||
this.logger.log(`Lead dedup: reusing ${match.id} for phone ${phone}`);
|
||||
return match.id as string;
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(`Lead dedup lookup failed, falling through to create: ${err}`);
|
||||
}
|
||||
|
||||
const firstName = name.split(' ')[0] || name;
|
||||
const resolved = await this.caller.resolve(phone, this.auth);
|
||||
const firstName = name.split(' ')[0] || name || 'Unknown';
|
||||
const lastName = name.split(' ').slice(1).join(' ') || '';
|
||||
|
||||
const created = await this.platform.queryWithAuth<any>(
|
||||
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
||||
{
|
||||
data: {
|
||||
name,
|
||||
contactName: { firstName, lastName },
|
||||
contactPhone: { primaryPhoneNumber: `+91${phone}` },
|
||||
source: opts.source ?? 'WEBSITE',
|
||||
status: opts.status ?? 'NEW',
|
||||
interestedService: opts.interestedService ?? 'Website Enquiry',
|
||||
if (resolved.isNew) {
|
||||
// Net-new visitor — create Patient + Lead with the widget-
|
||||
// collected name. Both records get the real name from the
|
||||
// first moment they exist.
|
||||
let patientId: string | undefined;
|
||||
try {
|
||||
const p = await this.platform.queryWithAuth<any>(
|
||||
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
|
||||
{
|
||||
data: {
|
||||
fullName: { firstName, lastName },
|
||||
phones: { primaryPhoneNumber: `+91${phone}` },
|
||||
patientType: 'NEW',
|
||||
},
|
||||
},
|
||||
this.auth,
|
||||
);
|
||||
patientId = p?.createPatient?.id;
|
||||
} catch (err) {
|
||||
this.logger.warn(`Widget patient create failed (${phone}): ${err}`);
|
||||
}
|
||||
const created = await this.platform.queryWithAuth<any>(
|
||||
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
||||
{
|
||||
data: {
|
||||
name,
|
||||
contactName: { firstName, lastName },
|
||||
contactPhone: { primaryPhoneNumber: `+91${phone}` },
|
||||
source: opts.source ?? 'WEBSITE',
|
||||
status: opts.status ?? 'NEW',
|
||||
interestedService: opts.interestedService ?? 'Website Enquiry',
|
||||
...(patientId ? { patientId } : {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
this.auth,
|
||||
);
|
||||
const id = created?.createLead?.id;
|
||||
if (!id) throw new Error('Lead creation returned no id');
|
||||
this.logger.log(`Lead dedup: created ${id} for ${name} (${phone})`);
|
||||
return id as string;
|
||||
this.auth,
|
||||
);
|
||||
const leadId = created?.createLead?.id;
|
||||
if (!leadId) throw new Error('Lead creation returned no id');
|
||||
this.logger.log(`Widget lead created: ${leadId} (patient ${patientId ?? 'none'}) for ${name} (${phone})`);
|
||||
return leadId;
|
||||
}
|
||||
|
||||
// Existing Lead found — update with widget-supplied details.
|
||||
const leadId = resolved.leadId;
|
||||
try {
|
||||
await this.platform.queryWithAuth<any>(
|
||||
`mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`,
|
||||
{
|
||||
id: leadId,
|
||||
data: {
|
||||
name,
|
||||
contactName: { firstName, lastName },
|
||||
source: opts.source ?? 'WEBSITE',
|
||||
status: opts.status ?? 'NEW',
|
||||
interestedService: opts.interestedService ?? 'Website Enquiry',
|
||||
},
|
||||
},
|
||||
this.auth,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Lead update after resolve failed (lead=${leadId}): ${err}`);
|
||||
}
|
||||
if (resolved.patientId) {
|
||||
try {
|
||||
await this.platform.queryWithAuth<any>(
|
||||
`mutation($id: UUID!, $data: PatientUpdateInput!) { updatePatient(id: $id, data: $data) { id } }`,
|
||||
{ id: resolved.patientId, data: { fullName: { firstName, lastName } } },
|
||||
this.auth,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Patient rename after resolve failed (patient=${resolved.patientId}): ${err}`);
|
||||
}
|
||||
}
|
||||
this.logger.log(`Widget lead updated: ${leadId} (patient ${resolved.patientId}) for ${name} (${phone})`);
|
||||
return leadId;
|
||||
}
|
||||
|
||||
// Upgrade a lead's status — used when an existing lead is promoted from
|
||||
|
||||
Reference in New Issue
Block a user