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('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 { 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( `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( `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( `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( `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 { try { await this.platform.queryWithAuth( `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 { const data = await this.platform.queryWithAuth( `{ 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 { const data = await this.platform.queryWithAuth( `{ 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( `{ 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( `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( `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 }; } }