mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
- 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>
47 lines
1.7 KiB
TypeScript
47 lines
1.7 KiB
TypeScript
import { CanActivate, ExecutionContext, Injectable, HttpException, Logger } from '@nestjs/common';
|
|
|
|
// Cloudflare Turnstile verification endpoint
|
|
const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
|
|
|
|
@Injectable()
|
|
export class CaptchaGuard implements CanActivate {
|
|
private readonly logger = new Logger(CaptchaGuard.name);
|
|
private readonly secretKey: string;
|
|
|
|
constructor() {
|
|
this.secretKey = process.env.RECAPTCHA_SECRET_KEY ?? '';
|
|
}
|
|
|
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
if (!this.secretKey) {
|
|
this.logger.warn('RECAPTCHA_SECRET_KEY not set — captcha disabled');
|
|
return true;
|
|
}
|
|
|
|
const request = context.switchToHttp().getRequest();
|
|
const token = request.body?.captchaToken;
|
|
|
|
if (!token) throw new HttpException('Captcha token required', 400);
|
|
|
|
try {
|
|
const res = await fetch(TURNSTILE_VERIFY_URL, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ secret: this.secretKey, response: token }),
|
|
});
|
|
const data = await res.json();
|
|
|
|
if (!data.success) {
|
|
this.logger.warn(`Captcha failed: success=${data.success} errors=${JSON.stringify(data['error-codes'] ?? [])}`);
|
|
throw new HttpException('Captcha verification failed', 403);
|
|
}
|
|
|
|
return true;
|
|
} catch (err: any) {
|
|
if (err instanceof HttpException) throw err;
|
|
this.logger.error(`Captcha verification error: ${err.message}`);
|
|
return true;
|
|
}
|
|
}
|
|
}
|