feat: appointment QR code — generated and sent via WhatsApp after booking

- QrService: generates QR PNG from appointment data, cached in-memory
- GET /api/messaging/qr/:appointmentId serves the image (Gupshup needs URL)
- sendImage added to MessagingProvider + GupshupProvider
- send_appointment_qr tool registered in ToolRegistry
- Flow JSON updated: QR sent after booking confirmation
- Variable interpolation now supports dot notation ({{result.field}})
- SIDECAR_PUBLIC_URL env var for the QR image URL

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-20 20:23:06 +05:30
parent 300fff25c1
commit d819888351
11 changed files with 313 additions and 18 deletions

View File

@@ -38,6 +38,7 @@ export default () => ({
openaiApiKey: process.env.OPENAI_API_KEY ?? '',
model: process.env.AI_MODEL ?? 'gpt-4o-mini',
},
sidecarUrl: process.env.SIDECAR_PUBLIC_URL ?? '',
messaging: {
provider: process.env.MESSAGING_PROVIDER ?? 'gupshup',
gupshup: {

View File

@@ -307,6 +307,19 @@
"format": "text",
"text": "Your appointment is confirmed!\n\nDoctor: {{selectedDoctor_title}}\nDate: {{selectedDate}}\nTime: {{selectedSlot_title}}\nReason: {{reason}}\n\nThank you for choosing Ramaiah Hospital. See you soon!"
}
},
{
"id": "b24a",
"type": "tool_call",
"toolName": "send_appointment_qr",
"inputs": {
"appointmentId": "{{bookingResult.appointmentId}}",
"reference": "{{bookingResult.reference}}",
"patientName": "{{_senderName}}",
"doctorName": "{{selectedDoctor_title}}",
"department": "{{selectedDepartmentTitle}}",
"scheduledAt": "{{scheduledDateTime}}"
}
}
]
},

View File

@@ -4,9 +4,15 @@ import { Injectable } from '@nestjs/common';
export class FlowVariableService {
// Replace {{variableName}} with values from session variables
interpolate(template: string, variables: Record<string, any>): string {
return template.replace(/\{\{(\w+)\}\}/g, (match, name) => {
const value = variables[name];
if (value === undefined || value === null) return match; // keep placeholder if unresolved
return template.replace(/\{\{([\w.]+)\}\}/g, (match, path) => {
// Support dot notation: {{bookingResult.appointmentId}}
const parts = path.split('.');
let value: any = variables;
for (const part of parts) {
value = value?.[part];
if (value === undefined) return match;
}
if (value === null) return match;
if (typeof value === 'object') return JSON.stringify(value);
return String(value);
});

View File

@@ -1,6 +1,8 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PlatformGraphqlService } from '../../platform/platform-graphql.service';
import { CallerResolutionService } from '../../caller/caller-resolution.service';
import { QrService } from '../qr.service';
import { DOCTOR_VISIT_SLOTS_FRAGMENT, normalizeDoctors } from '../../shared/doctor-utils';
import type { ToolHandler, ToolContext } from './flow-types';
import type { ListSection, InteractiveButton } from '../types';
@@ -10,10 +12,15 @@ export class ToolRegistry {
private readonly logger = new Logger(ToolRegistry.name);
private readonly tools: Map<string, ToolHandler> = new Map();
private readonly sidecarUrl: string;
constructor(
private platform: PlatformGraphqlService,
private caller: CallerResolutionService,
private qr: QrService,
private config: ConfigService,
) {
this.sidecarUrl = config.get<string>('sidecarUrl') ?? '';
this.registerDefaults();
}
@@ -215,5 +222,23 @@ export class ToolRegistry {
);
return { appointments: data.appointments.edges.map((e: any) => e.node) };
});
this.register('send_appointment_qr', async (inputs, ctx) => {
const { appointmentId, reference, patientName, doctorName, department, scheduledAt } = inputs;
if (!appointmentId) return { sent: false, message: 'No appointment ID.' };
await this.qr.generate(appointmentId, {
reference: reference ?? appointmentId.substring(0, 8),
patientName: patientName ?? '',
doctorName: doctorName ?? '',
department: department ?? '',
scheduledAt: scheduledAt ?? '',
});
const qrUrl = `${this.sidecarUrl}/api/messaging/qr/${appointmentId}`;
await ctx.provider.sendImage(ctx.phone, qrUrl, `Your appointment QR code — show this at the hospital reception desk.`);
this.logger.log(`[TOOL] send_appointment_qr: sent QR for ${reference ?? appointmentId}`);
return { sent: true, qrUrl };
});
}
}

View File

@@ -1,6 +1,8 @@
import { Controller, Post, Body, Logger } from '@nestjs/common';
import { Controller, Post, Get, Body, Param, Res, Logger } from '@nestjs/common';
import type { Response } from 'express';
import { MessagingProvider } from './providers/messaging-provider.interface';
import { MessagingService } from './messaging.service';
import { QrService } from './qr.service';
@Controller('api/messaging')
export class MessagingController {
@@ -9,6 +11,7 @@ export class MessagingController {
constructor(
private readonly provider: MessagingProvider,
private readonly messaging: MessagingService,
private readonly qr: QrService,
) {}
@Post('webhook')
@@ -33,4 +36,17 @@ export class MessagingController {
return { status: 'ok' };
}
// Serve QR code images — Gupshup needs a public URL to send images
@Get('qr/:appointmentId')
async serveQr(@Param('appointmentId') appointmentId: string, @Res() res: Response) {
const png = this.qr.get(appointmentId);
if (!png) {
res.status(404).json({ error: 'QR code not found or expired' });
return;
}
res.set('Content-Type', 'image/png');
res.set('Cache-Control', 'public, max-age=86400');
res.send(png);
}
}

View File

@@ -12,6 +12,7 @@ import { FlowSessionService } from './flow/flow-session.service';
import { FlowStoreService } from './flow/flow-store.service';
import { FlowVariableService } from './flow/flow-variable.service';
import { ToolRegistry } from './flow/tool-registry';
import { QrService } from './qr.service';
@Module({
imports: [PlatformModule, CallerResolutionModule],
@@ -24,6 +25,7 @@ import { ToolRegistry } from './flow/tool-registry';
FlowStoreService,
FlowVariableService,
ToolRegistry,
QrService,
{
provide: MessagingProvider,
useFactory: (config: ConfigService) => {

View File

@@ -96,6 +96,16 @@ export class GupshupProvider extends MessagingProvider {
await this.send(to, JSON.stringify(message));
}
async sendImage(to: string, imageUrl: string, caption?: string): Promise<void> {
const message: any = {
type: 'image',
originalUrl: imageUrl,
previewUrl: imageUrl,
};
if (caption) message.caption = caption;
await this.send(to, JSON.stringify(message));
}
async sendList(to: string, body: string, buttonText: string, sections: ListSection[]): Promise<void> {
const message = {
type: 'list',

View File

@@ -13,6 +13,9 @@ export abstract class MessagingProvider {
/** Send interactive list (max 10 rows total across sections) */
abstract sendList(to: string, body: string, buttonText: string, sections: ListSection[]): Promise<void>;
/** Send an image with optional caption */
abstract sendImage(to: string, imageUrl: string, caption?: string): Promise<void>;
/** Validate that inbound webhook is authentic */
abstract validateWebhook(body: any): boolean;
}

View File

@@ -0,0 +1,57 @@
import { Injectable, Logger } from '@nestjs/common';
import * as QRCode from 'qrcode';
// In-memory cache for generated QR images. Each entry expires after 24h.
// Key: appointmentId, Value: { png: Buffer, expiresAt: number }
const qrCache = new Map<string, { png: Buffer; expiresAt: number }>();
const TTL_MS = 24 * 60 * 60 * 1000;
@Injectable()
export class QrService {
private readonly logger = new Logger(QrService.name);
// Generate a QR code PNG for an appointment
async generate(appointmentId: string, data: {
reference: string;
patientName: string;
doctorName: string;
department: string;
scheduledAt: string;
}): Promise<Buffer> {
// QR content — JSON with appointment details for kiosk scanning
const qrContent = JSON.stringify({
type: 'helix-appointment',
id: appointmentId,
ref: data.reference,
patient: data.patientName,
doctor: data.doctorName,
department: data.department,
scheduledAt: data.scheduledAt,
});
const png = await QRCode.toBuffer(qrContent, {
type: 'png',
width: 400,
margin: 2,
color: { dark: '#000000', light: '#FFFFFF' },
errorCorrectionLevel: 'M',
});
// Cache for the image hosting endpoint
qrCache.set(appointmentId, { png, expiresAt: Date.now() + TTL_MS });
this.logger.log(`[QR] Generated for appointment ${data.reference} (${png.length} bytes)`);
return png;
}
// Retrieve a cached QR image (for the hosting endpoint)
get(appointmentId: string): Buffer | null {
const entry = qrCache.get(appointmentId);
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
qrCache.delete(appointmentId);
return null;
}
return entry.png;
}
}