mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
feat: widget chat with generative UI, branch selection, captcha gate, lead dedup
- Streaming AI chat via Vercel AI SDK v6 UI message stream — tool-based
generative UI (pick_branch, list_departments, show_clinic_timings,
show_doctors, show_doctor_slots, suggest_booking). Typing indicator,
markdown suppressed, text parts hidden when widgets are rendered.
- Centralized Preact store (store.tsx) for visitor, leadId, captchaToken,
bookingPrefill, doctors roster, branches, selectedBranch — replaces prop
drilling across chat/book/contact tabs.
- Cloudflare Turnstile captcha gate rendered via light-DOM portal so it
renders correctly inside the shadow DOM (Turnstile CSS doesn't cross
shadow boundaries).
- Lead dedup helper (findOrCreateLeadByPhone, 24h phone window) shared
across chat-start / book / contact so one visitor == one lead. Booking
upgrades existing lead status NEW → APPOINTMENT_SET via updateLeadStatus.
- Pre-chat name+phone form captures the visitor; chat transcript logged
to leadActivity records after each stream.
- Booking wizard gains a branch step 0 (skipped for single-branch
hospitals); departments + doctors filtered by selectedBranch. Chat slot
picks prefill the booking details step and lock the branch.
- Window-level captcha gate, modal maximize mode, header badge showing
selected branch, widget font inherits from host page (fix :host { all:
initial } override).
- 23 FA Pro 7.1 duotone icons bundled — medical departments, nav, actions,
hospital/location-dot for branch context.
- main.ts: resolve public/ from process.cwd() so widget.js serves in both
dev and prod. tsconfig: exclude widget-src/public/data from server tsc.
- captcha.guard: switch from reCAPTCHA v3 to Cloudflare Turnstile verify.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,17 @@ import { ConfigService } from '@nestjs/config';
|
||||
import type { WidgetInitResponse, WidgetBookRequest, WidgetLeadRequest } from './widget.types';
|
||||
import { ThemeService } from '../config/theme.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);
|
||||
@@ -21,6 +32,91 @@ export class WidgetService {
|
||||
return `Bearer ${this.apiKey}`;
|
||||
}
|
||||
|
||||
private normalizePhone(raw: string): string {
|
||||
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.
|
||||
async findOrCreateLeadByPhone(
|
||||
name: string,
|
||||
rawPhone: string,
|
||||
opts: FindOrCreateLeadOpts = {},
|
||||
): Promise<string> {
|
||||
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 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',
|
||||
},
|
||||
},
|
||||
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;
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -62,7 +158,7 @@ export class WidgetService {
|
||||
}
|
||||
|
||||
async bookAppointment(req: WidgetBookRequest): Promise<{ appointmentId: string; reference: string }> {
|
||||
const phone = req.patientPhone.replace(/[^0-9]/g, '').slice(-10);
|
||||
const phone = this.normalizePhone(req.patientPhone);
|
||||
|
||||
// Find or create patient
|
||||
let patientId: string | null = null;
|
||||
@@ -105,22 +201,25 @@ export class WidgetService {
|
||||
this.auth,
|
||||
);
|
||||
|
||||
// Create lead
|
||||
const firstName = req.patientName.split(' ')[0];
|
||||
const lastName = req.patientName.split(' ').slice(1).join(' ') || '';
|
||||
await this.platform.queryWithAuth<any>(
|
||||
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
||||
{ data: {
|
||||
name: req.patientName,
|
||||
contactName: { firstName, lastName },
|
||||
contactPhone: { primaryPhoneNumber: `+91${phone}` },
|
||||
// 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',
|
||||
patientId,
|
||||
} },
|
||||
this.auth,
|
||||
).catch(err => this.logger.warn(`Widget lead creation failed: ${err}`));
|
||||
});
|
||||
// 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})`);
|
||||
@@ -129,24 +228,12 @@ export class WidgetService {
|
||||
}
|
||||
|
||||
async createLead(req: WidgetLeadRequest): Promise<{ leadId: string }> {
|
||||
const phone = req.phone.replace(/[^0-9]/g, '').slice(-10);
|
||||
const firstName = req.name.split(' ')[0];
|
||||
const lastName = req.name.split(' ').slice(1).join(' ') || '';
|
||||
|
||||
const data = await this.platform.queryWithAuth<any>(
|
||||
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
||||
{ data: {
|
||||
name: req.name,
|
||||
contactName: { firstName, lastName },
|
||||
contactPhone: { primaryPhoneNumber: `+91${phone}` },
|
||||
source: 'WEBSITE',
|
||||
status: 'NEW',
|
||||
interestedService: req.interest ?? 'Website Enquiry',
|
||||
} },
|
||||
this.auth,
|
||||
);
|
||||
|
||||
this.logger.log(`Widget lead: ${req.name} (${phone}) — ${req.interest ?? 'general'}`);
|
||||
return { leadId: data.createLead.id };
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user