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

@@ -14,7 +14,8 @@ async function bootstrap() {
});
// Serve widget.js and other static files from /public
app.useStaticAssets(join(__dirname, '..', 'public'), {
// In dev mode __dirname = src/, in prod __dirname = dist/ — resolve from process.cwd()
app.useStaticAssets(join(process.cwd(), 'public'), {
setHeaders: (res, path) => {
if (path.endsWith('.js')) {
res.setHeader('Cache-Control', 'public, max-age=3600');

View File

@@ -1,6 +1,7 @@
import { CanActivate, ExecutionContext, Injectable, HttpException, Logger } from '@nestjs/common';
const RECAPTCHA_VERIFY_URL = 'https://www.google.com/recaptcha/api/siteverify';
// Cloudflare Turnstile verification endpoint
const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
@Injectable()
export class CaptchaGuard implements CanActivate {
@@ -23,15 +24,15 @@ export class CaptchaGuard implements CanActivate {
if (!token) throw new HttpException('Captcha token required', 400);
try {
const res = await fetch(RECAPTCHA_VERIFY_URL, {
const res = await fetch(TURNSTILE_VERIFY_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `secret=${this.secretKey}&response=${token}`,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ secret: this.secretKey, response: token }),
});
const data = await res.json();
if (!data.success || (data.score != null && data.score < 0.3)) {
this.logger.warn(`Captcha failed: score=${data.score} success=${data.success}`);
if (!data.success) {
this.logger.warn(`Captcha failed: success=${data.success} errors=${JSON.stringify(data['error-codes'] ?? [])}`);
throw new HttpException('Captcha verification failed', 403);
}

View File

@@ -0,0 +1,407 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { streamText, tool, stepCountIs } from 'ai';
import { z } from 'zod';
import type { LanguageModel, ModelMessage } from 'ai';
import { createAiModel } from '../ai/ai-provider';
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
import { WidgetService } from './widget.service';
@Injectable()
export class WidgetChatService {
private readonly logger = new Logger(WidgetChatService.name);
private readonly aiModel: LanguageModel | null;
private readonly apiKey: string;
private knowledgeBase: string | null = null;
private kbLoadedAt = 0;
private readonly kbTtlMs = 5 * 60 * 1000;
constructor(
private config: ConfigService,
private platform: PlatformGraphqlService,
private widget: WidgetService,
) {
this.aiModel = createAiModel(config);
this.apiKey = config.get<string>('platform.apiKey') ?? '';
if (!this.aiModel) {
this.logger.warn('AI not configured — widget chat will return fallback replies');
}
}
private get auth() {
return `Bearer ${this.apiKey}`;
}
hasAiModel(): boolean {
return this.aiModel !== null;
}
// Find-or-create a lead by phone. Delegates to WidgetService so there's
// a single source of truth for the dedup window + lead shape across
// chat + book + contact.
async findOrCreateLead(name: string, phone: string): Promise<string> {
return this.widget.findOrCreateLeadByPhone(name, phone, {
source: 'WEBSITE',
status: 'NEW',
interestedService: 'Website Chat',
});
}
// Fetch the first name of the lead's primary contact so we can greet the
// visitor by name in the system prompt. Returns 'there' on any failure.
async getLeadFirstName(leadId: string): Promise<string> {
try {
const data = await this.platform.queryWithAuth<any>(
`query($id: UUID!) {
leads(filter: { id: { eq: $id } }, first: 1) {
edges { node { id contactName { firstName } } }
}
}`,
{ id: leadId },
this.auth,
);
const firstName = data?.leads?.edges?.[0]?.node?.contactName?.firstName;
return (typeof firstName === 'string' && firstName.trim()) || 'there';
} catch (err) {
this.logger.warn(`Failed to fetch lead name for ${leadId}: ${err}`);
return 'there';
}
}
// Append an exchange to the lead's activity log. One activity record per
// user/assistant turn. Safe to call in the background (we don't block the
// stream on this).
async logExchange(leadId: string, userText: string, aiText: string): Promise<void> {
const summary = `User: ${userText}\n\nAI: ${aiText}`.slice(0, 4000);
try {
await this.platform.queryWithAuth<any>(
`mutation($data: LeadActivityCreateInput!) {
createLeadActivity(data: $data) { id }
}`,
{
data: {
leadId,
activityType: 'NOTE_ADDED',
channel: 'SYSTEM',
summary,
occurredAt: new Date().toISOString(),
performedBy: 'Website Chat',
outcome: 'SUCCESSFUL',
},
},
this.auth,
);
} catch (err) {
this.logger.warn(`Failed to log chat activity for lead ${leadId}: ${err}`);
}
}
// Build a compact knowledge base of doctor/department info for the system
// prompt. Cached for 5 min to avoid a doctors query on every chat.
private async getKnowledgeBase(): Promise<string> {
const now = Date.now();
if (this.knowledgeBase && now - this.kbLoadedAt < this.kbTtlMs) {
return this.knowledgeBase;
}
try {
const doctors = await this.widget.getDoctors();
const byDept = new Map<string, { name: string; visitingHours?: string; clinic?: string }[]>();
for (const d of doctors) {
const dept = (d.department ?? 'Other').replace(/_/g, ' ');
if (!byDept.has(dept)) byDept.set(dept, []);
byDept.get(dept)!.push({
name: d.name,
visitingHours: d.visitingHours,
clinic: d.clinic?.clinicName,
});
}
const lines: string[] = ['DEPARTMENTS AND DOCTORS:'];
for (const [dept, docs] of byDept) {
lines.push(`\n${dept}:`);
for (const doc of docs) {
const extras: string[] = [];
if (doc.visitingHours) extras.push(doc.visitingHours);
if (doc.clinic) extras.push(doc.clinic);
lines.push(` - ${doc.name}${extras.length ? ` (${extras.join(' • ')})` : ''}`);
}
}
this.knowledgeBase = lines.join('\n');
this.kbLoadedAt = now;
} catch (err) {
this.logger.warn(`Failed to build widget KB: ${err}`);
this.knowledgeBase = 'DEPARTMENTS AND DOCTORS: (unavailable)';
this.kbLoadedAt = now;
}
return this.knowledgeBase;
}
async buildSystemPrompt(userName: string, selectedBranch: string | null): Promise<string> {
const init = this.widget.getInitData();
const kb = await this.getKnowledgeBase();
// Branch context flips the tool-usage rules: no branch = must call
// pick_branch first; branch set = always pass it to branch-aware tools.
const branchContext = selectedBranch
? [
`CURRENT BRANCH: ${selectedBranch}`,
`The visitor is interested in the ${selectedBranch} branch. You MUST pass branch="${selectedBranch}"`,
'to list_departments, show_clinic_timings, show_doctors, and show_doctor_slots every time.',
]
: [
'BRANCH STATUS: NOT SET',
'The visitor has not picked a branch yet. Before calling list_departments, show_clinic_timings,',
'show_doctors, or show_doctor_slots, you MUST call pick_branch first so the visitor can choose.',
'Only skip this if the user asks a pure general question that does not need branch-specific data.',
];
return [
`You are a helpful, concise assistant for ${init.brand.name}.`,
`You are chatting with a website visitor named ${userName}.`,
'',
...branchContext,
'',
'TOOL USAGE RULES (STRICT):',
'- When the user asks about departments, call list_departments and DO NOT also list departments in prose.',
'- When they ask about clinic timings, visiting hours, or "when is X open", call show_clinic_timings.',
'- When they ask about doctors in a department, call show_doctors and DO NOT also list doctors in prose.',
'- When they ask about a specific doctor\'s availability or want to book with them, call show_doctor_slots.',
'- When the conversation is trending toward booking, call suggest_booking.',
'- After calling a tool, DO NOT restate its contents in prose. At most write a single short sentence',
' (under 15 words) framing the widget, or no text at all. The widget already shows the data.',
'- If you are about to write a bulleted or numbered list of departments, doctors, hours, or slots,',
' STOP and call the appropriate tool instead.',
'- NEVER use markdown formatting (no **bold**, no *italic*, no bullet syntax). Plain text only in',
' non-tool replies.',
'- NEVER invent or mention specific dates in prose or tool inputs. The server owns "today".',
' If the visitor asks about a future date, tell them to use the Book tab\'s date picker.',
'',
'OTHER RULES:',
'- Answer other questions (directions, general info) concisely in prose.',
'- If you do not know something, say so and suggest they call the hospital.',
'- Never quote prices. No medical advice. For clinical questions, defer to a doctor.',
'',
kb,
].join('\n');
}
// Streams the assistant reply as an async iterable of UIMessageChunk-shaped
// objects. The controller writes these as SSE `data: ${json}\n\n` lines
// over the HTTP response. Tools return structured payloads the widget
// frontend renders as generative-UI cards.
async *streamReply(systemPrompt: string, messages: ModelMessage[]): AsyncGenerator<any> {
if (!this.aiModel) throw new Error('AI not configured');
const platform = this.platform;
const widgetSvc = this.widget;
// Small helper: does a doctor's clinic match the branch filter?
// Case-insensitive substring match so "Indiranagar" matches
// "Indiranagar Clinic" etc.
const matchesBranch = (d: any, branch: string | undefined): boolean => {
if (!branch) return true;
const clinicName = String(d.clinic?.clinicName ?? '').toLowerCase();
return clinicName.includes(branch.toLowerCase());
};
const tools = {
pick_branch: tool({
description:
'Show the list of hospital branches so the visitor can pick which one they are interested in. Call this BEFORE any branch-sensitive tool (list_departments, show_clinic_timings, show_doctors, show_doctor_slots) when CURRENT BRANCH is NOT SET.',
inputSchema: z.object({}),
execute: async () => {
const doctors = await widgetSvc.getDoctors();
const byBranch = new Map<string, { doctorCount: number; departments: Set<string> }>();
for (const d of doctors) {
const name = d.clinic?.clinicName?.trim();
if (!name) continue;
if (!byBranch.has(name)) {
byBranch.set(name, { doctorCount: 0, departments: new Set() });
}
const entry = byBranch.get(name)!;
entry.doctorCount += 1;
if (d.department) entry.departments.add(String(d.department).replace(/_/g, ' '));
}
return {
branches: Array.from(byBranch.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([name, { doctorCount, departments }]) => ({
name,
doctorCount,
departmentCount: departments.size,
})),
};
},
}),
list_departments: tool({
description:
'List the departments the hospital has. Use when the visitor asks what departments or specialities are available. Pass branch if CURRENT BRANCH is set.',
inputSchema: z.object({
branch: z
.string()
.optional()
.describe('Branch name to filter by. Pass the CURRENT BRANCH when set.'),
}),
execute: async ({ branch }) => {
const doctors = await widgetSvc.getDoctors();
const filtered = doctors.filter((d: any) => matchesBranch(d, branch));
const deps = Array.from(
new Set(filtered.map((d: any) => d.department).filter(Boolean)),
) as string[];
return {
branch: branch ?? null,
departments: deps.map(d => d.replace(/_/g, ' ')),
};
},
}),
show_clinic_timings: tool({
description:
'Show the clinic hours / visiting times for all departments with the doctors who visit during those hours. Use when the visitor asks about clinic timings, visiting hours, when the clinic is open, or what time a department is available. Pass branch if CURRENT BRANCH is set.',
inputSchema: z.object({
branch: z
.string()
.optional()
.describe('Branch name to filter by. Pass the CURRENT BRANCH when set.'),
}),
execute: async ({ branch }) => {
const doctors = await widgetSvc.getDoctors();
const filtered = doctors.filter((d: any) => matchesBranch(d, branch));
const byDept = new Map<
string,
Array<{ name: string; hours: string; clinic: string | null }>
>();
for (const d of filtered) {
const dept = (d.department ?? 'Other').replace(/_/g, ' ');
if (!byDept.has(dept)) byDept.set(dept, []);
if (d.visitingHours) {
byDept.get(dept)!.push({
name: d.name,
hours: d.visitingHours,
clinic: d.clinic?.clinicName ?? null,
});
}
}
return {
branch: branch ?? null,
departments: Array.from(byDept.entries())
.filter(([, entries]) => entries.length > 0)
.map(([name, entries]) => ({ name, entries })),
};
},
}),
show_doctors: tool({
description:
'Show the list of doctors in a specific department with their visiting hours and clinic. Use when the visitor asks about doctors in a department. Pass branch if CURRENT BRANCH is set.',
inputSchema: z.object({
department: z
.string()
.describe('Department name, e.g., "Cardiology", "ENT", "General Medicine".'),
branch: z
.string()
.optional()
.describe('Branch name to filter by. Pass the CURRENT BRANCH when set.'),
}),
execute: async ({ department, branch }) => {
const doctors = await widgetSvc.getDoctors();
const deptKey = department.toLowerCase().replace(/\s+/g, '').replace(/_/g, '');
const matches = doctors
.filter((d: any) => {
const key = String(d.department ?? '')
.toLowerCase()
.replace(/\s+/g, '')
.replace(/_/g, '');
return key.includes(deptKey) && matchesBranch(d, branch);
})
.map((d: any) => ({
id: d.id,
name: d.name,
specialty: d.specialty ?? null,
visitingHours: d.visitingHours ?? null,
clinic: d.clinic?.clinicName ?? null,
}));
return { department, branch: branch ?? null, doctors: matches };
},
}),
show_doctor_slots: tool({
description:
"Show today's available appointment slots for a specific doctor. Use when the visitor wants to see when a doctor is free or wants to book with a specific doctor. The date is always today — do NOT try to specify a date. Pass branch if CURRENT BRANCH is set to disambiguate doctors with the same name across branches.",
inputSchema: z.object({
doctorName: z
.string()
.describe('Full name of the doctor, e.g., "Dr. Lakshmi Reddy".'),
branch: z
.string()
.optional()
.describe('Branch name to disambiguate. Pass the CURRENT BRANCH when set.'),
}),
execute: async ({ doctorName, branch }) => {
// Always use the server's current date. Never trust anything from
// the model here — older LLMs hallucinate their training-data
// "today" and return slots for the wrong day.
const targetDate = new Date().toISOString().slice(0, 10);
const doctors = await widgetSvc.getDoctors();
const scoped = doctors.filter((d: any) => matchesBranch(d, branch));
// Fuzzy match: lowercase + strip "Dr." prefix + collapse spaces.
const norm = (s: string) =>
s.toLowerCase().replace(/^dr\.?\s*/i, '').replace(/\s+/g, ' ').trim();
const target = norm(doctorName);
const doc =
scoped.find((d: any) => norm(d.name) === target) ??
scoped.find((d: any) => norm(d.name).includes(target)) ??
scoped.find((d: any) => target.includes(norm(d.name)));
if (!doc) {
return {
doctor: null,
date: targetDate,
slots: [],
error: `No doctor matching "${doctorName}"${branch ? ` at ${branch}` : ''} was found.`,
};
}
const slots = await widgetSvc.getSlots(doc.id, targetDate);
return {
doctor: {
id: doc.id,
name: doc.name,
department: doc.department ?? null,
clinic: doc.clinic?.clinicName ?? null,
},
date: targetDate,
slots,
};
},
}),
suggest_booking: tool({
description:
'Suggest that the visitor book an appointment. Use when the conversation is trending toward booking, the user has identified a concern, or asks "how do I book".',
inputSchema: z.object({
reason: z.string().describe('Short reason why booking is a good next step.'),
department: z
.string()
.optional()
.describe('Suggested department, if known.'),
}),
execute: async ({ reason, department }) => {
return { reason, department: department ?? null };
},
}),
};
// Bookings / leads are not in scope for the AI — we only wire read/
// suggest tools here. The CC agent/AP engineering team can book.
void platform;
const result = streamText({
model: this.aiModel,
system: systemPrompt,
messages,
tools,
stopWhen: stepCountIs(4),
});
const uiStream = result.toUIMessageStream();
for await (const chunk of uiStream) {
yield chunk;
}
}
}

View File

@@ -1,16 +1,23 @@
import { Controller, Get, Post, Delete, Body, Query, Param, UseGuards, Logger, HttpException } from '@nestjs/common';
import { Controller, Get, Post, Delete, Body, Query, Param, Req, Res, UseGuards, Logger, HttpException } from '@nestjs/common';
import type { Request, Response } from 'express';
import type { ModelMessage } from 'ai';
import { WidgetService } from './widget.service';
import { WidgetChatService } from './widget-chat.service';
import { WidgetKeysService } from './widget-keys.service';
import { WidgetKeyGuard } from './widget-key.guard';
import { CaptchaGuard } from './captcha.guard';
import type { WidgetBookRequest, WidgetLeadRequest } from './widget.types';
type ChatStartBody = { name?: string; phone?: string };
type ChatStreamBody = { leadId?: string; messages?: ModelMessage[]; branch?: string | null };
@Controller('api/widget')
export class WidgetController {
private readonly logger = new Logger(WidgetController.name);
constructor(
private readonly widget: WidgetService,
private readonly chat: WidgetChatService,
private readonly keys: WidgetKeysService,
) {}
@@ -51,6 +58,97 @@ export class WidgetController {
return this.widget.createLead(body);
}
// Start (or resume) a chat session. Dedups by phone in the last 24h so a
// single visitor who books + contacts + chats doesn't create three leads.
// No CaptchaGuard: the window-level gate already verified humanity, and
// Turnstile tokens are single-use so reusing them on every endpoint breaks
// the multi-action flow.
@Post('chat-start')
@UseGuards(WidgetKeyGuard)
async chatStart(@Body() body: ChatStartBody) {
if (!body.name?.trim() || !body.phone?.trim()) {
throw new HttpException('name and phone required', 400);
}
try {
const leadId = await this.chat.findOrCreateLead(body.name.trim(), body.phone.trim());
return { leadId };
} catch (err: any) {
this.logger.error(`chatStart failed: ${err?.message ?? err}`);
throw new HttpException('Failed to start chat session', 500);
}
}
// Stream the AI reply. Requires an active leadId from chat-start. The
// conversation is logged to leadActivity after the stream completes so the
// CC agent can review the transcript when they call the visitor back.
@Post('chat')
@UseGuards(WidgetKeyGuard)
async chat_(@Req() req: Request, @Res() res: Response) {
const body = req.body as ChatStreamBody;
const leadId = body?.leadId?.trim();
const messages = body?.messages ?? [];
const selectedBranch = body?.branch?.trim() || null;
if (!leadId) {
res.status(400).json({ error: 'leadId required' });
return;
}
if (!messages.length) {
res.status(400).json({ error: 'messages required' });
return;
}
if (!this.chat.hasAiModel()) {
res.status(503).json({ error: 'AI not configured' });
return;
}
// Find the last user message up-front so we can log it after the
// stream finishes (reverse-walking messages is cheap).
const lastUser = [...messages].reverse().find(m => m.role === 'user');
const userText = typeof lastUser?.content === 'string'
? lastUser.content
: '';
// Fetch the visitor's first name from the lead so the AI can personalize.
const userName = await this.chat.getLeadFirstName(leadId);
// SSE framing — each UIMessageChunk is serialized as a `data:` event.
// See AI SDK v6 UI_MESSAGE_STREAM_HEADERS for the canonical values.
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
res.setHeader('Cache-Control', 'no-cache, no-transform');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
res.setHeader('X-Vercel-Ai-Ui-Message-Stream', 'v1');
let aiText = '';
try {
const systemPrompt = await this.chat.buildSystemPrompt(userName, selectedBranch);
for await (const chunk of this.chat.streamReply(systemPrompt, messages)) {
// Track accumulated text for transcript logging.
if (chunk?.type === 'text-delta' && typeof chunk.delta === 'string') {
aiText += chunk.delta;
}
res.write(`data: ${JSON.stringify(chunk)}\n\n`);
}
res.write('data: [DONE]\n\n');
res.end();
} catch (err: any) {
this.logger.error(`Chat stream failed for lead ${leadId}: ${err?.message ?? err}`);
if (!res.headersSent) {
res.status(500).json({ error: 'Chat failed' });
} else {
res.write(`data: ${JSON.stringify({ type: 'error', errorText: 'Chat failed' })}\n\n`);
res.end();
}
return;
}
// Fire-and-forget transcript logging. We intentionally do not await
// this so the stream response is not delayed.
if (userText && aiText) {
void this.chat.logExchange(leadId, userText, aiText);
}
}
// Key management (admin endpoints)
@Post('keys/generate')
async generateKey(@Body() body: { hospitalName: string; allowedOrigins: string[] }) {

View File

@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { WidgetController } from './widget.controller';
import { WebhooksController } from './webhooks.controller';
import { WidgetService } from './widget.service';
import { WidgetChatService } from './widget-chat.service';
import { WidgetKeysService } from './widget-keys.service';
import { PlatformModule } from '../platform/platform.module';
import { AuthModule } from '../auth/auth.module';
@@ -10,7 +11,7 @@ import { ConfigThemeModule } from '../config/config-theme.module';
@Module({
imports: [PlatformModule, AuthModule, ConfigThemeModule],
controllers: [WidgetController, WebhooksController],
providers: [WidgetService, WidgetKeysService],
providers: [WidgetService, WidgetChatService, WidgetKeysService],
exports: [WidgetKeysService],
})
export class WidgetModule {}

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