From d819888351e59cdb8aad5cd543eb8360f6d52f43 Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Mon, 20 Apr 2026 20:23:06 +0530 Subject: [PATCH] =?UTF-8?q?feat:=20appointment=20QR=20code=20=E2=80=94=20g?= =?UTF-8?q?enerated=20and=20sent=20via=20WhatsApp=20after=20booking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- package-lock.json | 188 ++++++++++++++++-- package.json | 2 + src/config/configuration.ts | 1 + .../default-flows/appointment-booking.json | 13 ++ src/messaging/flow/flow-variable.service.ts | 12 +- src/messaging/flow/tool-registry.ts | 25 +++ src/messaging/messaging.controller.ts | 18 +- src/messaging/messaging.module.ts | 2 + src/messaging/providers/gupshup.provider.ts | 10 + .../providers/messaging-provider.interface.ts | 3 + src/messaging/qr.service.ts | 57 ++++++ 11 files changed, 313 insertions(+), 18 deletions(-) create mode 100644 src/messaging/qr.service.ts diff --git a/package-lock.json b/package-lock.json index ab3867b..2b22f5f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,11 +21,13 @@ "@nestjs/platform-express": "^11.0.1", "@nestjs/platform-socket.io": "^11.1.17", "@nestjs/websockets": "^11.1.17", + "@types/qrcode": "^1.5.6", "ai": "^6.0.116", "axios": "^1.13.6", "ioredis": "^5.10.1", "json-rules-engine": "^6.6.0", "kafkajs": "^2.2.4", + "qrcode": "^1.5.4", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "socket.io": "^4.8.3", @@ -5160,6 +5162,15 @@ "integrity": "sha512-MIiyZI4/MK9UGUXWt0jJcCZhVw7YdhBuTOuqP/BjuLDLZ2PmmViMIQgZiWxtaMicQfAz/kMrZ5T7PKxFSkTeUA==", "license": "MIT" }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/qs": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", @@ -6223,7 +6234,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6233,7 +6243,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -6728,7 +6737,6 @@ "version": "5.3.1", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -7013,7 +7021,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -7026,7 +7033,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/colorette": { @@ -7266,6 +7272,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/dedent": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", @@ -7434,6 +7449,12 @@ "node": ">=0.3.1" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dotenv": { "version": "17.2.3", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", @@ -7533,7 +7554,6 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/encodeurl": { @@ -8656,7 +8676,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -9255,7 +9274,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -11240,7 +11258,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -11298,7 +11315,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -11657,6 +11673,15 @@ "node": ">=4" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -11822,6 +11847,127 @@ ], "license": "MIT" }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/qrcode/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", @@ -11954,7 +12100,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -11984,6 +12129,12 @@ "node": ">=8.6.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -12269,6 +12420,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -12676,7 +12833,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -12707,7 +12863,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -13929,6 +14084,12 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -13950,7 +14111,6 @@ "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", diff --git a/package.json b/package.json index b85cedc..aaa4a97 100644 --- a/package.json +++ b/package.json @@ -32,11 +32,13 @@ "@nestjs/platform-express": "^11.0.1", "@nestjs/platform-socket.io": "^11.1.17", "@nestjs/websockets": "^11.1.17", + "@types/qrcode": "^1.5.6", "ai": "^6.0.116", "axios": "^1.13.6", "ioredis": "^5.10.1", "json-rules-engine": "^6.6.0", "kafkajs": "^2.2.4", + "qrcode": "^1.5.4", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "socket.io": "^4.8.3", diff --git a/src/config/configuration.ts b/src/config/configuration.ts index cbbd79a..3baeff0 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -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: { diff --git a/src/messaging/flow/default-flows/appointment-booking.json b/src/messaging/flow/default-flows/appointment-booking.json index fb91dbf..efa4abf 100644 --- a/src/messaging/flow/default-flows/appointment-booking.json +++ b/src/messaging/flow/default-flows/appointment-booking.json @@ -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}}" + } } ] }, diff --git a/src/messaging/flow/flow-variable.service.ts b/src/messaging/flow/flow-variable.service.ts index 6c5d96e..51ed7e7 100644 --- a/src/messaging/flow/flow-variable.service.ts +++ b/src/messaging/flow/flow-variable.service.ts @@ -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 { - 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); }); diff --git a/src/messaging/flow/tool-registry.ts b/src/messaging/flow/tool-registry.ts index e4c5e6d..2d687a3 100644 --- a/src/messaging/flow/tool-registry.ts +++ b/src/messaging/flow/tool-registry.ts @@ -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 = new Map(); + private readonly sidecarUrl: string; + constructor( private platform: PlatformGraphqlService, private caller: CallerResolutionService, + private qr: QrService, + private config: ConfigService, ) { + this.sidecarUrl = config.get('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 }; + }); } } diff --git a/src/messaging/messaging.controller.ts b/src/messaging/messaging.controller.ts index b3e4738..1d5f140 100644 --- a/src/messaging/messaging.controller.ts +++ b/src/messaging/messaging.controller.ts @@ -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); + } } diff --git a/src/messaging/messaging.module.ts b/src/messaging/messaging.module.ts index 966d76d..fc7eead 100644 --- a/src/messaging/messaging.module.ts +++ b/src/messaging/messaging.module.ts @@ -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) => { diff --git a/src/messaging/providers/gupshup.provider.ts b/src/messaging/providers/gupshup.provider.ts index 279887a..ead105a 100644 --- a/src/messaging/providers/gupshup.provider.ts +++ b/src/messaging/providers/gupshup.provider.ts @@ -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 { + 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 { const message = { type: 'list', diff --git a/src/messaging/providers/messaging-provider.interface.ts b/src/messaging/providers/messaging-provider.interface.ts index dd78ba3..2573fec 100644 --- a/src/messaging/providers/messaging-provider.interface.ts +++ b/src/messaging/providers/messaging-provider.interface.ts @@ -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; + /** Send an image with optional caption */ + abstract sendImage(to: string, imageUrl: string, caption?: string): Promise; + /** Validate that inbound webhook is authentic */ abstract validateWebhook(body: any): boolean; } diff --git a/src/messaging/qr.service.ts b/src/messaging/qr.service.ts new file mode 100644 index 0000000..20598a5 --- /dev/null +++ b/src/messaging/qr.service.ts @@ -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(); +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 { + // 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; + } +}