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