mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
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:
@@ -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: {
|
||||
|
||||
@@ -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}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
57
src/messaging/qr.service.ts
Normal file
57
src/messaging/qr.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user