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:
2026-04-06 16:04:46 +05:30
parent 517b2661b0
commit aa41a2abb7
23 changed files with 2902 additions and 270 deletions

View File

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