mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
feat: website widget + omnichannel lead webhooks
Widget (embeddable): - Preact + Vite library mode → 35KB IIFE bundle served from sidecar - Shadow DOM for CSS isolation, themed from sidecar theme API - AI chatbot (streaming), appointment booking (4-step wizard), lead capture form - FontAwesome Pro duotone SVGs bundled as inline strings - HMAC-signed site keys (Redis storage, origin validation) - Captcha guard (Cloudflare Turnstile ready) Sidecar endpoints: - GET/PUT/DELETE /api/widget/keys/* — site key management - GET /api/widget/init — theme + config (key-gated) - GET /api/widget/doctors, /slots — doctor list + availability - POST /api/widget/book — appointment booking (captcha-gated) - POST /api/widget/lead — lead capture (captcha-gated) Omnichannel webhooks: - POST /api/webhook/facebook — Meta Lead Ads (verification + lead ingestion) - POST /api/webhook/google — Google Ads lead form extension - POST /api/webhook/whatsapp — Ozonetel WhatsApp callback (receiver ready) - POST /api/webhook/sms — Ozonetel SMS callback (receiver ready) Infrastructure: - SessionService.setCachePersistent() for non-expiring Redis keys - Static file serving from /public (widget.js) - WidgetModule registered in AppModule Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
152
src/widget/widget.service.ts
Normal file
152
src/widget/widget.service.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
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';
|
||||
|
||||
@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,
|
||||
) {
|
||||
this.apiKey = config.get<string>('platform.apiKey') ?? '';
|
||||
}
|
||||
|
||||
private get auth() {
|
||||
return `Bearer ${this.apiKey}`;
|
||||
}
|
||||
|
||||
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 ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
async getDoctors(): Promise<any[]> {
|
||||
const data = await this.platform.queryWithAuth<any>(
|
||||
`{ doctors(first: 50) { edges { node {
|
||||
id name fullName { firstName lastName } department specialty visitingHours
|
||||
consultationFeeNew { amountMicros currencyCode }
|
||||
clinic { clinicName }
|
||||
} } } }`,
|
||||
undefined, this.auth,
|
||||
);
|
||||
return data.doctors.edges.map((e: any) => e.node);
|
||||
}
|
||||
|
||||
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 = req.patientPhone.replace(/[^0-9]/g, '').slice(-10);
|
||||
|
||||
// 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: {
|
||||
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: {
|
||||
scheduledAt: req.scheduledAt,
|
||||
durationMin: 30,
|
||||
appointmentType: 'CONSULTATION',
|
||||
status: 'SCHEDULED',
|
||||
doctorId: req.doctorId,
|
||||
department: req.departmentId,
|
||||
reasonForVisit: req.chiefComplaint ?? '',
|
||||
patientId,
|
||||
} },
|
||||
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}` },
|
||||
source: 'WEBSITE',
|
||||
status: 'APPOINTMENT_SET',
|
||||
interestedService: req.chiefComplaint ?? 'Appointment Booking',
|
||||
patientId,
|
||||
} },
|
||||
this.auth,
|
||||
).catch(err => this.logger.warn(`Widget lead creation failed: ${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 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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user