Files
helix-engage-server/src/widget/widget.service.ts
saridsa2 048545317d fix: set platform name on every entity create — patients/appts/calls/etc no longer "Untitled"
Audited all 23 sidecar create-mutation call sites; 7 were missing the
top-level data.name field that the platform uses as record title:

- caller-resolution.service.ts createPatient — full name from first/last
- maint.controller.ts createPatient (backfill-lead-patient-links) — same
- widget.service.ts createPatient (chat path + booking path) — full name
- widget.service.ts createAppointment — "<Patient> — <date>"
- worklist/missed-queue.service.ts createCall — "Missed — <phone>"
- rules-engine/actions/escalate.action.ts createPerformanceAlert —
  "<agent>: <message> (<value>)"
- supervisor/agent-history.service.ts createAgentEvent / createAgentSession

Cosmetic only — the app fetches fullName/agentName for display, so
end users never saw "Untitled". Fixes platform-side admin browsing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 09:32:28 +05:30

287 lines
12 KiB
TypeScript

import { Injectable, Logger } from '@nestjs/common';
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
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
// phone all roll into one record in the CRM.
const LEAD_DEDUP_WINDOW_MS = 24 * 60 * 60 * 1000;
export type FindOrCreateLeadOpts = {
source?: string;
status?: string;
interestedService?: string;
};
@Injectable()
export class WidgetService {
private readonly logger = new Logger(WidgetService.name);
private readonly apiKey: string;
constructor(
private platform: PlatformGraphqlService,
private theme: ThemeService,
private config: ConfigService,
private caller: CallerResolutionService,
) {
this.apiKey = config.get<string>('platform.apiKey') ?? '';
}
private get auth() {
return `Bearer ${this.apiKey}`;
}
private normalizePhone(raw: string): string {
return raw.replace(/[^0-9]/g, '').slice(-10);
}
// 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,
opts: FindOrCreateLeadOpts = {},
): Promise<string> {
const phone = this.normalizePhone(rawPhone);
if (!phone) throw new Error('Invalid phone number');
const resolved = await this.caller.resolve(phone, this.auth);
const firstName = name.split(' ')[0] || name || 'Unknown';
const lastName = name.split(' ').slice(1).join(' ') || '';
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: {
name: `${firstName} ${lastName}`.trim() || 'Unknown',
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 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
// NEW/chat to APPOINTMENT_SET after the visitor books. Non-fatal on failure.
async updateLeadStatus(leadId: string, status: string, interestedService?: string): Promise<void> {
try {
await this.platform.queryWithAuth<any>(
`mutation($id: UUID!, $data: LeadUpdateInput!) {
updateLead(id: $id, data: $data) { id }
}`,
{
id: leadId,
data: {
status,
...(interestedService ? { interestedService } : {}),
},
},
this.auth,
);
} catch (err) {
this.logger.warn(`Failed to update lead ${leadId} status → ${status}: ${err}`);
}
}
getInitData(): WidgetInitResponse {
const t = this.theme.getTheme();
return {
brand: { name: t.brand.hospitalName, logo: t.brand.logo },
colors: {
primary: t.colors.brand['600'] ?? 'rgb(29 78 216)',
primaryLight: t.colors.brand['50'] ?? 'rgb(219 234 254)',
text: t.colors.brand['950'] ?? 'rgb(15 23 42)',
textLight: t.colors.brand['400'] ?? 'rgb(100 116 139)',
},
captchaSiteKey: process.env.RECAPTCHA_SITE_KEY ?? '',
};
}
// Returns NormalizedDoctor[] — the raw GraphQL fields plus three
// derived bridge fields (`clinics`, `clinic`, `visitingHours`)
// built from the visit-slots reverse relation. See
// shared/doctor-utils.ts for the rationale and the format of the
// visiting-hours summary string.
async getDoctors(): Promise<NormalizedDoctor[]> {
const data = await this.platform.queryWithAuth<any>(
`{ doctors(first: 50) { edges { node {
id name fullName { firstName lastName } department specialty
consultationFeeNew { amountMicros currencyCode }
${DOCTOR_VISIT_SLOTS_FRAGMENT}
} } } }`,
undefined, this.auth,
);
const raws = data.doctors.edges.map((e: any) => e.node);
return normalizeDoctors(raws);
}
async getSlots(doctorId: string, date: string): Promise<any> {
const data = await this.platform.queryWithAuth<any>(
`{ appointments(first: 50, filter: { doctorId: { eq: "${doctorId}" }, scheduledAt: { gte: "${date}T00:00:00Z", lte: "${date}T23:59:59Z" } }) { edges { node { scheduledAt } } } }`,
undefined, this.auth,
);
const booked = data.appointments.edges.map((e: any) => {
const dt = new Date(e.node.scheduledAt);
return `${dt.getHours().toString().padStart(2, '0')}:${dt.getMinutes().toString().padStart(2, '0')}`;
});
const allSlots = ['09:00', '09:30', '10:00', '10:30', '11:00', '11:30', '14:00', '14:30', '15:00', '15:30', '16:00'];
return allSlots.map(s => ({ time: s, available: !booked.includes(s) }));
}
async bookAppointment(req: WidgetBookRequest): Promise<{ appointmentId: string; reference: string }> {
const phone = this.normalizePhone(req.patientPhone);
// Find or create patient
let patientId: string | null = null;
try {
const existing = await this.platform.queryWithAuth<any>(
`{ patients(first: 1, filter: { phones: { primaryPhoneNumber: { like: "%${phone}" } } }) { edges { node { id } } } }`,
undefined, this.auth,
);
patientId = existing.patients.edges[0]?.node?.id ?? null;
} catch { /* continue */ }
if (!patientId) {
const firstName = req.patientName.split(' ')[0];
const lastName = req.patientName.split(' ').slice(1).join(' ') || '';
const created = await this.platform.queryWithAuth<any>(
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
{ data: {
name: req.patientName.trim() || 'Unknown',
fullName: { firstName, lastName },
phones: { primaryPhoneNumber: `+91${phone}` },
patientType: 'NEW',
} },
this.auth,
);
patientId = created.createPatient.id;
}
// Create appointment
const appt = await this.platform.queryWithAuth<any>(
`mutation($data: AppointmentCreateInput!) { createAppointment(data: $data) { id } }`,
{ data: {
name: `${req.patientName.trim() || 'Patient'}${new Date(req.scheduledAt).toISOString().slice(0, 10)}`,
scheduledAt: req.scheduledAt,
durationMin: 30,
appointmentType: 'CONSULTATION',
status: 'SCHEDULED',
doctorId: req.doctorId,
department: req.departmentId,
reasonForVisit: req.chiefComplaint ?? '',
patientId,
...(req.clinicId ? { clinicId: req.clinicId } : {}),
} },
this.auth,
);
// Find-or-create lead (dedups within 24h across chat + contact + book)
// and upgrade its status to APPOINTMENT_SET. Non-fatal on failure —
// we don't want to fail the booking if lead bookkeeping hiccups.
try {
const leadId = await this.findOrCreateLeadByPhone(req.patientName, phone, {
source: 'WEBSITE',
status: 'APPOINTMENT_SET',
interestedService: req.chiefComplaint ?? 'Appointment Booking',
});
// Idempotent upgrade: if the lead was reused from an earlier chat/
// contact, promote its status and reflect the new interest.
await this.updateLeadStatus(
leadId,
'APPOINTMENT_SET',
req.chiefComplaint ?? 'Appointment Booking',
);
} catch (err) {
this.logger.warn(`Widget lead upsert failed during booking: ${err}`);
}
const reference = appt.createAppointment.id.substring(0, 8).toUpperCase();
this.logger.log(`Widget booking: ${req.patientName}${req.doctorId} at ${req.scheduledAt} (ref: ${reference})`);
return { appointmentId: appt.createAppointment.id, reference };
}
async createLead(req: WidgetLeadRequest): Promise<{ leadId: string }> {
const leadId = await this.findOrCreateLeadByPhone(req.name, req.phone, {
source: 'WEBSITE',
status: 'NEW',
interestedService: req.interest ?? 'Website Enquiry',
});
this.logger.log(`Widget contact: ${req.name} (${this.normalizePhone(req.phone)}) — ${req.interest ?? 'general'}`);
return { leadId };
}
}