mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
Compare commits
4 Commits
9ee087b898
...
bbea12185d
| Author | SHA1 | Date | |
|---|---|---|---|
| bbea12185d | |||
| f1c026cf7a | |||
| d819888351 | |||
| 300fff25c1 |
203
.claude/skills/generate-whatsapp-flow.md
Normal file
203
.claude/skills/generate-whatsapp-flow.md
Normal file
@@ -0,0 +1,203 @@
|
||||
# Generate WhatsApp Flow
|
||||
|
||||
Generate a config-driven WhatsApp conversation flow JSON for the Helix Engage flow runtime engine.
|
||||
|
||||
## When to use
|
||||
|
||||
When the user asks to create a new WhatsApp flow, chatbot flow, or conversation automation — e.g., "create a WhatsApp flow for prescription refills", "build a feedback collection flow", "add a lab report flow".
|
||||
|
||||
## Flow Runtime Architecture
|
||||
|
||||
The flow engine reads JSON flow definitions from `src/messaging/flow/default-flows/` and executes them at runtime. Each flow is a graph of **Groups** (containers) containing **Blocks** (steps), connected by **Edges**.
|
||||
|
||||
### Execution Model
|
||||
|
||||
```
|
||||
Inbound WhatsApp message → match flow by trigger → create/resume session
|
||||
→ walk forward through Groups → Blocks:
|
||||
MessageBlock → send text/buttons/list to patient
|
||||
InputBlock → PAUSE, wait for next message
|
||||
ConditionBlock → evaluate variable, follow matching edge
|
||||
SetVariableBlock → assign/transform variable
|
||||
ToolCallBlock → call registered tool
|
||||
AIBlock → generate LLM response
|
||||
JumpBlock → jump to another group
|
||||
→ End of group → follow outgoing edge → next group
|
||||
→ No more edges → flow complete, session cleared
|
||||
```
|
||||
|
||||
Session state stored in Redis with 24h TTL. Per-phone execution lock prevents concurrent flows.
|
||||
|
||||
### Flow JSON Schema
|
||||
|
||||
```typescript
|
||||
type Flow = {
|
||||
id: string; // "flow-{kebab-name}"
|
||||
name: string; // Human-readable name
|
||||
description: string; // Admin-facing description
|
||||
trigger: FlowTrigger; // What starts this flow
|
||||
groups: Group[]; // Ordered containers of blocks
|
||||
edges: Edge[]; // Connections between blocks/groups
|
||||
variables: VariableDefinition[];// Flow-scoped variables
|
||||
version: number; // Start at 1
|
||||
status: 'draft' | 'published'; // Only published flows execute
|
||||
};
|
||||
|
||||
type FlowTrigger =
|
||||
| { type: 'message'; conditions?: { keywords?: string[]; regex?: string } }
|
||||
| { type: 'default' }; // Catch-all when no other flow matches
|
||||
|
||||
type Group = {
|
||||
id: string; // "g1", "g2", etc.
|
||||
title: string; // "Greeting", "Department Selection"
|
||||
blocks: Block[]; // Executed in order
|
||||
};
|
||||
|
||||
type Edge = {
|
||||
id: string; // "e1", "e2", etc.
|
||||
from: { blockId: string; conditionId?: string };
|
||||
to: { groupId: string; blockId?: string };
|
||||
};
|
||||
|
||||
type VariableDefinition = {
|
||||
id: string; // "v1", "v2", etc.
|
||||
name: string; // "selectedDepartment"
|
||||
type: 'string' | 'number' | 'boolean' | 'object' | 'array';
|
||||
defaultValue?: any;
|
||||
};
|
||||
```
|
||||
|
||||
### Block Types
|
||||
|
||||
```typescript
|
||||
// Send text, buttons, or list to patient
|
||||
type MessageBlock = {
|
||||
id: string; type: 'message';
|
||||
content:
|
||||
| { format: 'text'; text: string } // Supports {{variables}}
|
||||
| { format: 'buttons'; text: string; buttons: { id: string; title: string }[] } // Max 3 buttons, title max 20 chars
|
||||
| { format: 'list'; text: string; buttonText: string; sections: { title: string; rows: { id: string; title: string; description?: string }[] }[] }; // Section title max 24 chars, row title max 24 chars, max 10 rows total
|
||||
};
|
||||
|
||||
// Wait for patient reply — PAUSES execution
|
||||
type InputBlock = {
|
||||
id: string; type: 'input';
|
||||
inputType: 'text' | 'interactive_reply' | 'any';
|
||||
variableId: string; // Store reply in this variable
|
||||
validation?: { regex?: string; errorMessage?: string };
|
||||
};
|
||||
|
||||
// Branch based on variable value
|
||||
type ConditionBlock = {
|
||||
id: string; type: 'condition';
|
||||
conditions: {
|
||||
id: string; // "c1" — used in edge.from.conditionId
|
||||
variableId: string;
|
||||
operator: 'equals' | 'contains' | 'exists' | 'not_exists' | 'gt' | 'lt' | 'starts_with';
|
||||
value?: string; // Supports {{variables}}
|
||||
}[];
|
||||
};
|
||||
|
||||
// Assign or transform a variable
|
||||
type SetVariableBlock = {
|
||||
id: string; type: 'set_variable';
|
||||
variableId: string;
|
||||
value: string;
|
||||
expression?: 'extract_id' | 'extract_datetime' | 'date_tomorrow' | 'date_day_after';
|
||||
// extract_id: "doc:{uuid}:{name}" → uuid (second segment)
|
||||
// extract_datetime: "slot:{id}:{datetime}" → datetime (third+ segments, rejoined with :)
|
||||
// date_tomorrow/date_day_after: computes date string YYYY-MM-DD
|
||||
};
|
||||
|
||||
// Execute a registered tool
|
||||
type ToolCallBlock = {
|
||||
id: string; type: 'tool_call';
|
||||
toolName: string; // Must be a registered tool (see below)
|
||||
inputs: Record<string, string>; // Values support {{variables}} and {{var.field}} dot notation
|
||||
outputVariableId?: string;
|
||||
};
|
||||
|
||||
// Generate dynamic LLM response
|
||||
type AIBlock = {
|
||||
id: string; type: 'ai';
|
||||
prompt: string; // Supports {{variables}}
|
||||
outputVariableId?: string;
|
||||
sendToPatient: boolean; // true = send as WhatsApp message
|
||||
};
|
||||
|
||||
// Jump to another group
|
||||
type JumpBlock = {
|
||||
id: string; type: 'jump';
|
||||
targetGroupId: string;
|
||||
};
|
||||
```
|
||||
|
||||
### Available Tools (ToolRegistry)
|
||||
|
||||
| Tool Name | Description | Inputs | Output |
|
||||
|---|---|---|---|
|
||||
| `resolve_caller` | Phone → Lead + Patient | phone? (defaults to current) | { leadId, patientId, isNew, phone } |
|
||||
| `send_department_list` | Interactive department list | (none) | { sent, departments[] } |
|
||||
| `send_doctor_list` | Interactive doctor list | department | { sent, count } |
|
||||
| `send_slot_list` | Time slots for doctor+date | doctorId, doctorName, date | { sent, slots } |
|
||||
| `send_confirm_buttons` | Confirm/Cancel buttons | summary | { sent } |
|
||||
| `book_appointment` | Book with conflict check | patientName, phoneNumber, department, doctorName, scheduledAt, reason | { booked, appointmentId, reference } |
|
||||
| `lookup_appointments` | Check existing appointments | (none — uses current caller) | { appointments[] } |
|
||||
| `send_appointment_qr` | Generate and send QR code | appointmentId, reference, patientName, doctorName, department, scheduledAt | { sent, qrUrl } |
|
||||
|
||||
### System Variables (auto-injected)
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `_initialMessage` | The first message the patient sent |
|
||||
| `_senderName` | WhatsApp profile name |
|
||||
| `_phone` | Phone number (E.164 without +) |
|
||||
| `_callerName` | Resolved patient name from platform |
|
||||
| `_leadId` | Lead ID if exists |
|
||||
| `_patientId` | Patient ID if exists |
|
||||
| `_isNew` | true if no prior records |
|
||||
|
||||
### Variable Interpolation
|
||||
|
||||
- `{{variableName}}` — simple substitution
|
||||
- `{{result.fieldName}}` — dot notation for object fields (e.g., `{{bookingResult.appointmentId}}`)
|
||||
- Interactive reply IDs stored in `variableId`, display titles in `variableId_title`
|
||||
|
||||
### WhatsApp Constraints
|
||||
|
||||
- Button title: max 20 characters
|
||||
- List section title: max 24 characters
|
||||
- List row title: max 24 characters
|
||||
- List row description: max 72 characters
|
||||
- Max 3 buttons per message
|
||||
- Max 10 list rows total across all sections
|
||||
- No markdown in text messages (plain text only)
|
||||
- Interactive messages only work within 24h session window
|
||||
|
||||
## How to Generate
|
||||
|
||||
1. **Ask the user** what the flow should do — purpose, steps, what data to collect
|
||||
2. **Design the groups** — each logical phase is a group (Greeting, Selection, Confirmation, etc.)
|
||||
3. **Define variables** — what data flows through the conversation
|
||||
4. **Build blocks** — MessageBlocks for output, InputBlocks to pause for reply, ConditionBlocks for branching, ToolCallBlocks for platform operations, AIBlocks for dynamic responses
|
||||
5. **Wire edges** — connect groups via edges, condition edges for branching
|
||||
6. **Write the JSON** to `src/messaging/flow/default-flows/{flow-name}.json`
|
||||
7. **Register new tools** if needed in `src/messaging/flow/tool-registry.ts`
|
||||
|
||||
## Reference
|
||||
|
||||
See `src/messaging/flow/default-flows/appointment-booking.json` for a complete working example with:
|
||||
- AI greeting
|
||||
- Intent routing (book / check / question)
|
||||
- Interactive lists (departments, doctors, slots)
|
||||
- Date selection with custom date AI parsing
|
||||
- Confirmation buttons
|
||||
- Booking with conflict check
|
||||
- QR code generation
|
||||
|
||||
## Deployment
|
||||
|
||||
After creating the flow JSON:
|
||||
1. `npm run build` — verifies the JSON is copied to dist (via nest-cli.json assets)
|
||||
2. Deploy to EC2 — the flow store auto-seeds on first run if `data/flows/` is empty
|
||||
3. If updating an existing flow: `docker exec sidecar cp /app/dist/.../flow.json /app/data/flows/flow-id.json && docker compose restart sidecar`
|
||||
188
package-lock.json
generated
188
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -145,9 +145,16 @@
|
||||
"type": "condition",
|
||||
"conditions": [
|
||||
{ "id": "c3", "variableId": "dateChoice", "operator": "contains", "value": "tomorrow" },
|
||||
{ "id": "c4", "variableId": "dateChoice", "operator": "contains", "value": "day_after" }
|
||||
{ "id": "c4", "variableId": "dateChoice", "operator": "contains", "value": "day_after" },
|
||||
{ "id": "c7", "variableId": "dateChoice", "operator": "contains", "value": "other" }
|
||||
]
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g4t",
|
||||
"title": "Date - Tomorrow",
|
||||
"blocks": [
|
||||
{
|
||||
"id": "b14",
|
||||
"type": "set_variable",
|
||||
@@ -170,6 +177,33 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g4c",
|
||||
"title": "Date - Custom",
|
||||
"blocks": [
|
||||
{
|
||||
"id": "b15a",
|
||||
"type": "message",
|
||||
"content": {
|
||||
"format": "text",
|
||||
"text": "Please type your preferred date (e.g., April 25 or 25/04/2026)."
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "b15b",
|
||||
"type": "input",
|
||||
"inputType": "text",
|
||||
"variableId": "customDateText"
|
||||
},
|
||||
{
|
||||
"id": "b15c",
|
||||
"type": "ai",
|
||||
"prompt": "The patient typed this date: \"{{customDateText}}\". Convert it to YYYY-MM-DD format. The current year is 2026. Reply with ONLY the date in YYYY-MM-DD format, nothing else.",
|
||||
"outputVariableId": "selectedDate",
|
||||
"sendToPatient": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g5",
|
||||
"title": "Slot Selection",
|
||||
@@ -273,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}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -316,10 +363,12 @@
|
||||
{ "id": "e2", "from": { "blockId": "b4", "conditionId": "c2" }, "to": { "groupId": "g10" } },
|
||||
{ "id": "e3", "from": { "blockId": "b7" }, "to": { "groupId": "g3" } },
|
||||
{ "id": "e4", "from": { "blockId": "b10" }, "to": { "groupId": "g4" } },
|
||||
{ "id": "e5", "from": { "blockId": "b13", "conditionId": "c3" }, "to": { "groupId": "g5" } },
|
||||
{ "id": "e5", "from": { "blockId": "b13", "conditionId": "c3" }, "to": { "groupId": "g4t" } },
|
||||
{ "id": "e6", "from": { "blockId": "b13", "conditionId": "c4" }, "to": { "groupId": "g4a" } },
|
||||
{ "id": "e6a", "from": { "blockId": "b13", "conditionId": "c7" }, "to": { "groupId": "g4c" } },
|
||||
{ "id": "e7", "from": { "blockId": "b14" }, "to": { "groupId": "g5" } },
|
||||
{ "id": "e8", "from": { "blockId": "b15" }, "to": { "groupId": "g5" } },
|
||||
{ "id": "e8a", "from": { "blockId": "b15c" }, "to": { "groupId": "g5" } },
|
||||
{ "id": "e9", "from": { "blockId": "b17a" }, "to": { "groupId": "g6" } },
|
||||
{ "id": "e10", "from": { "blockId": "b19" }, "to": { "groupId": "g7" } },
|
||||
{ "id": "e11", "from": { "blockId": "b22", "conditionId": "c5" }, "to": { "groupId": "g8" } },
|
||||
|
||||
@@ -44,9 +44,28 @@ export class FlowExecutionService {
|
||||
this.auth = apiKey ? `Bearer ${apiKey}` : '';
|
||||
}
|
||||
|
||||
// Per-phone lock to prevent concurrent flow executions
|
||||
private readonly locks = new Map<string, Promise<void>>();
|
||||
|
||||
async handleMessage(message: NormalizedMessage): Promise<void> {
|
||||
const { phone } = message;
|
||||
|
||||
// Serialize executions per phone — prevent two concurrent flows
|
||||
const existing = this.locks.get(phone);
|
||||
const execute = async () => {
|
||||
if (existing) await existing.catch(() => {});
|
||||
await this._handleMessage(message);
|
||||
};
|
||||
const promise = execute();
|
||||
this.locks.set(phone, promise);
|
||||
await promise.finally(() => {
|
||||
if (this.locks.get(phone) === promise) this.locks.delete(phone);
|
||||
});
|
||||
}
|
||||
|
||||
private async _handleMessage(message: NormalizedMessage): Promise<void> {
|
||||
const { phone } = message;
|
||||
|
||||
// 1. Load existing session or start new flow
|
||||
let session = await this.sessions.load(phone);
|
||||
let flow: Flow | null = null;
|
||||
|
||||
@@ -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