mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
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>
287 lines
12 KiB
TypeScript
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 };
|
|
}
|
|
}
|