Files
helix-engage-server/src/widget/widget.controller.ts
saridsa2 e6c8d950ea feat: widget config via admin-editable data/widget.json
Mirrors the existing theme config pattern so website widget settings can be
edited from the admin portal instead of baked into frontend env vars. Fixes
the current symptom where the staging widget is silently disabled because
VITE_WIDGET_KEY is missing from .env.production.

Backend (sidecar):
- src/config/widget.defaults.ts — WidgetConfig type + defaults
  (enabled, key, siteId, url, allowedOrigins, hospitalName,
  embed.loginPage, version, updatedAt)
- src/config/widget-config.service.ts — file-backed load / update /
  rotate-key / reset with backups, mirroring ThemeService. On module init:
    * first boot → auto-generates an HMAC-signed site key via
      WidgetKeysService, persists both to data/widget.json and to Redis
    * subsequent boots → re-registers the key in Redis if missing (handles
      Redis flushes so validateKey() keeps working without admin action)
- src/config/widget-config.controller.ts — new endpoints under /api/config:
    GET  /api/config/widget            public subset {enabled, key, url, embed}
    GET  /api/config/widget/admin      full config for the settings UI
    PUT  /api/config/widget            admin update (partial merge)
    POST /api/config/widget/rotate-key revoke old siteId + mint a new key
    POST /api/config/widget/reset      reset to defaults + regenerate
- Move src/widget/widget-keys.service.ts → src/config/widget-keys.service.ts
  (it's a config-layer concern now, not widget-layer). config-theme.module
  becomes the owner, imports AuthModule for SessionService, and exports
  WidgetKeysService + WidgetConfigService alongside ThemeService.
- widget.module stops providing WidgetKeysService (it imports ConfigThemeModule
  already, so the guard + controller still get it via DI).
- .gitignore data/widget.json + data/widget-backups/ so each environment
  auto-generates its own instance-specific key instead of sharing one via git.

TODO (flagged, out of scope for this pass):
- Protect admin endpoints with an auth guard when settings UI ships.
- Set WIDGET_SECRET env var in staging (currently falls back to the
  hardcoded default in widget-keys.service.ts).
- Admin portal settings page for editing widget config (mirror branding-settings).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 17:33:25 +05:30

173 lines
6.8 KiB
TypeScript

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 '../config/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,
) {}
@Get('init')
@UseGuards(WidgetKeyGuard)
init() {
return this.widget.getInitData();
}
@Get('doctors')
@UseGuards(WidgetKeyGuard)
async doctors() {
return this.widget.getDoctors();
}
@Get('slots')
@UseGuards(WidgetKeyGuard)
async slots(@Query('doctorId') doctorId: string, @Query('date') date: string) {
if (!doctorId || !date) throw new HttpException('doctorId and date required', 400);
return this.widget.getSlots(doctorId, date);
}
@Post('book')
@UseGuards(WidgetKeyGuard, CaptchaGuard)
async book(@Body() body: WidgetBookRequest) {
if (!body.patientName || !body.patientPhone || !body.doctorId || !body.scheduledAt) {
throw new HttpException('patientName, patientPhone, doctorId, and scheduledAt required', 400);
}
return this.widget.bookAppointment(body);
}
@Post('lead')
@UseGuards(WidgetKeyGuard, CaptchaGuard)
async lead(@Body() body: WidgetLeadRequest) {
if (!body.name || !body.phone) {
throw new HttpException('name and phone required', 400);
}
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[] }) {
if (!body.hospitalName) throw new HttpException('hospitalName required', 400);
const { key, siteKey } = this.keys.generateKey(body.hospitalName, body.allowedOrigins ?? []);
await this.keys.saveKey(siteKey);
return { key, siteKey };
}
@Get('keys')
async listKeys() {
return this.keys.listKeys();
}
@Delete('keys/:siteId')
async revokeKey(@Param('siteId') siteId: string) {
const revoked = await this.keys.revokeKey(siteId);
if (!revoked) throw new HttpException('Key not found', 404);
return { status: 'revoked' };
}
}