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:
2026-04-15 06:49:02 +05:30
parent b6b597fdda
commit fbe782b5ac
17 changed files with 685 additions and 269 deletions

View File

@@ -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