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:
2026-04-06 06:49:02 +05:30
parent 8cc1bdc812
commit 76fa6f51de
13 changed files with 866 additions and 1 deletions

View File

@@ -0,0 +1,25 @@
import { CanActivate, ExecutionContext, Injectable, HttpException } from '@nestjs/common';
import { WidgetKeysService } from './widget-keys.service';
@Injectable()
export class WidgetKeyGuard implements CanActivate {
constructor(private readonly keys: WidgetKeysService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const key = request.query?.key ?? request.headers['x-widget-key'];
if (!key) throw new HttpException('Widget key required', 401);
const siteKey = await this.keys.validateKey(key);
if (!siteKey) throw new HttpException('Invalid widget key', 403);
const origin = request.headers.origin ?? request.headers.referer;
if (!this.keys.validateOrigin(siteKey, origin)) {
throw new HttpException('Origin not allowed', 403);
}
request.widgetSiteKey = siteKey;
return true;
}
}