A complete VintaSend implementation using Medplum as the backend, leveraging FHIR resources for notification management, file storage, and healthcare integration.
This implementation uses FHIR (Fast Healthcare Interoperability Resources) standards to store and manage notifications, making it ideal for healthcare applications that need to integrate notifications with patient care workflows.
- MedplumNotificationBackend: Stores notifications as FHIR
Communicationresources - MedplumNotificationAdapter: Sends email notifications via Medplum's email API
- MedplumAttachmentManager: Manages file attachments using FHIR
BinaryandMediaresources - PugInlineEmailTemplateRenderer: Renders Pug email templates from pre-compiled JSON (ideal for production)
- MedplumLogger: Simple console-based logger
# Install the package
npm install vintasend-medplum @medplum/core
# Compile your Pug templates
npx compile-pug-templates ./templates ./src/compiled-templates.jsonimport { MedplumClient } from '@medplum/core';
import {
MedplumNotificationBackend,
MedplumNotificationAdapter,
PugInlineEmailTemplateRenderer,
MedplumLogger
} from 'vintasend-medplum';
import { NotificationService } from 'vintasend';
import compiledTemplates from './compiled-templates.json';
// Initialize Medplum client
const medplum = new MedplumClient({
baseUrl: 'https://api.medplum.com',
clientId: 'your-client-id',
clientSecret: 'your-client-secret',
});
// Create services
const renderer = new PugInlineEmailTemplateRenderer(compiledTemplates);
const adapter = new MedplumNotificationAdapter(medplum, renderer);
const backend = new MedplumNotificationBackend(medplum);
const logger = new MedplumLogger();
// Initialize notification service
const notificationService = new NotificationService(backend, [adapter], logger);
// Send a notification
await notificationService.createNotification({
userId: 'Patient/123',
notificationType: 'EMAIL',
contextName: 'welcome',
contextParameters: { firstName: 'John' },
title: 'Welcome!',
bodyTemplate: 'welcome.pug',
subjectTemplate: 'subjects/welcome.pug',
sendAfter: new Date(),
});
await notificationService.processPendingNotifications();The implementation maps VintaSend concepts to FHIR resources:
{
resourceType: "Communication",
status: "in-progress", // PENDING_SEND
sent: "2024-01-15T10:00:00Z", // sendAfter
topic: { text: "Welcome!" }, // title
recipient: [{ reference: "Patient/123" }], // userId
payload: [{
contentString: "Hello {{name}}", // bodyTemplate
extension: [{
url: "http://vintasend.com/fhir/StructureDefinition/email-notification-subject",
valueString: "Welcome {{name}}" // subjectTemplate
}]
}],
note: [{ text: '{"userId": "123"}' }], // contextParameters
meta: {
tag: [
{ code: "notification" },
{ code: "user-welcome" }, // contextName
{ code: "EMAIL" } // notificationType
]
}
}Files are stored using two FHIR resources:
- Binary Resource: Stores the actual file data (base64 encoded)
- Media Resource: Stores metadata and links to the Binary
// Binary resource
{
resourceType: "Binary",
contentType: "application/pdf",
data: "base64EncodedData..."
}
// Media resource
{
resourceType: "Media",
status: "completed",
content: {
contentType: "application/pdf",
url: "Binary/binary-id",
size: 12345,
title: "invoice.pdf"
},
identifier: [{
system: "http://vintasend.com/fhir/attachment-checksum",
value: "sha256-checksum"
}]
}| VintaSend Status | FHIR Communication Status |
|---|---|
| PENDING_SEND | in-progress |
| SENT | completed |
| FAILED | stopped |
npm install vintasend-medplum @medplum/coreVintaSend Medplum uses pre-compiled Pug email templates that are embedded in your application as JSON. This approach ensures templates are bundled with your code and don't require file system access at runtime.
Step 1: Organize Your Templates
Create a directory structure for your templates:
templates/
welcome.pug
password-reset.pug
notifications/
order-confirmation.pug
shipment-update.pug
Step 2: Compile Templates
Run the compilation script using npx:
npx compile-pug-templates [input-directory] [output-file]Both arguments are optional:
input-directory: Directory containing .pug templates (default:./templates)output-file: Output JSON file path (default:compiled-templates.json)
Examples:
# Use default values (./templates → compiled-templates.json)
npx compile-pug-templates
# Specify only input directory (output to compiled-templates.json)
npx compile-pug-templates ./email-templates
# Specify both arguments
npx compile-pug-templates ./templates ./src/compiled-templates.jsonOr add it to your package.json scripts:
{
"scripts": {
"compile-templates": "compile-pug-templates ./templates ./src/compiled-templates.json"
}
}This generates a JSON file where keys are relative paths and values are template contents:
{
"welcome.pug": "doctype html\nhtml\n body\n h1 Welcome {{firstName}}!",
"notifications/order-confirmation.pug": "doctype html\n..."
}Step 3: Import and Use Compiled Templates
import { PugInlineEmailTemplateRenderer } from 'vintasend-medplum';
import compiledTemplates from './compiled-templates.json';
// Create the template renderer with compiled templates
const templateRenderer = new PugInlineEmailTemplateRenderer(compiledTemplates);
// Use with notification adapter
const adapter = new MedplumNotificationAdapter(medplum, templateRenderer);import { MedplumClient } from '@medplum/core';
import { MedplumNotificationBackend } from 'vintasend-medplum';
import { MedplumNotificationAdapter } from 'vintasend-medplum';
import { MedplumAttachmentManager } from 'vintasend-medplum';
import { MedplumLogger } from 'vintasend-medplum';
import { PugTemplateRenderer } from 'vintasend-pug';
import { NotificationService } from 'vintasend';
// Initialize Medplum client
const medplum = new MedplumClient({
baseUrl: 'https://api.medplum.com',
clientId: 'your-client-id',
clientSecret: 'your-client-secret',
});
// Create template renderer
const templateRenderer = new PugTemplateRenderer({
templatesDir: './templates',
});
// Create notification adapter
const adapter = new MedplumNotificationAdapter(medplum, templateRenderer);
// Create backend
const backend = new MedplumNotificationBackend(medplum, {
emailNotificationSubjectExtensionUrl: 'http://your-domain.com/fhir/StructureDefinition/email-notification-subject',
});
// Create attachment manager (optional)
const attachmentManager = new MedplumAttachmentManager(medplum);
// Create logger
const logger = new MedplumLogger();
// Initialize notification service
const notificationService = new NotificationService(
backend,
[adapter],
logger,
attachmentManager,
);// Custom extension URL for email subjects
const backend = new MedplumNotificationBackend(medplum, {
emailNotificationSubjectExtensionUrl: 'http://example.com/fhir/email-subject',
});When creating notifications, reference your templates using the same paths used during compilation:
// If you compiled templates/welcome.pug
await notificationService.createNotification({
userId: 'Patient/123',
notificationType: 'EMAIL',
contextName: 'user-welcome',
contextParameters: {
firstName: 'John',
lastName: 'Doe',
},
title: 'Welcome to our platform!',
bodyTemplate: 'welcome.pug', // Path from compiled templates
subjectTemplate: 'subjects/welcome.pug', // Can be in subdirectories
sendAfter: new Date(),
});
// If you compiled templates/notifications/order-confirmation.pug
await notificationService.createNotification({
userId: 'Patient/456',
notificationType: 'EMAIL',
contextName: 'order-confirmation',
contextParameters: {
orderNumber: '12345',
totalAmount: '$99.99',
},
title: 'Order Confirmation',
bodyTemplate: 'notifications/order-confirmation.pug',
subjectTemplate: 'notifications/subjects/order-confirmation.pug',
sendAfter: new Date(),
});Template Example (welcome.pug):
doctype html
html
head
title Welcome
body
h1 Welcome #{firstName} #{lastName}!
p Thank you for joining our platform.
p
| If you have any questions, feel free to
a(href="mailto:support@example.com") contact us// Create a notification
await notificationService.createNotification({
userId: 'Patient/123',
notificationType: 'EMAIL',
contextName: 'user-welcome',
contextParameters: {
firstName: 'John',
lastName: 'Doe',
},
title: 'Welcome to our platform!',
bodyTemplate: 'Hello {{firstName}} {{lastName}}!',
subjectTemplate: 'Welcome {{firstName}}!',
sendAfter: new Date(),
});
// Process pending notifications
await notificationService.processPendingNotifications();import { readFile } from 'fs/promises';
// Create notification with file attachments
await notificationService.createNotification({
userId: 'Patient/123',
notificationType: 'EMAIL',
contextName: 'lab-results',
contextParameters: {
patientName: 'John Doe',
},
title: 'Your lab results are ready',
bodyTemplate: 'Dear {{patientName}}, your lab results are attached.',
subjectTemplate: 'Lab Results - {{patientName}}',
sendAfter: new Date(),
attachments: [
{
file: await readFile('./lab-results.pdf'),
filename: 'lab-results.pdf',
contentType: 'application/pdf',
},
],
});Send notifications to users without storing them in the system:
await notificationService.createOneOffNotification({
emailOrPhone: 'patient@example.com',
firstName: 'Jane',
lastName: 'Smith',
notificationType: 'EMAIL',
contextName: 'appointment-reminder',
contextParameters: {
appointmentDate: '2024-02-01',
doctorName: 'Dr. Johnson',
},
title: 'Appointment Reminder',
bodyTemplate: 'Hi {{firstName}}, reminder for your appointment on {{appointmentDate}}.',
subjectTemplate: 'Appointment on {{appointmentDate}}',
sendAfter: new Date(),
});// Upload a file
const fileRecord = await attachmentManager.uploadFile(
buffer,
'document.pdf',
'application/pdf',
);
// Retrieve file metadata
const file = await attachmentManager.getFile(fileRecord.id);
// Get file data
const attachmentFile = attachmentManager.reconstructAttachmentFile(
file.storageMetadata,
);
const fileBuffer = await attachmentFile.read();
// Generate temporary URL
const url = await attachmentFile.url(3600); // 1 hour expiry
// Delete file
await attachmentManager.deleteFile(fileRecord.id);// Get pending notifications
const pending = await notificationService.getPendingNotifications(0, 10);
// Get future notifications for a user
const future = await notificationService.getFutureNotificationsFromUser(
'Patient/123',
0,
10,
);
// Get unread in-app notifications
const unread = await notificationService.filterInAppUnreadNotifications(
'Patient/123',
0,
10,
);
// Mark as read
await notificationService.markAsRead(notificationId);// Cancel a scheduled notification
await notificationService.cancelNotification(notificationId);This implementation is designed for healthcare applications and integrates naturally with Medplum's FHIR-based infrastructure.
// Create notification linked to a patient
await notificationService.createNotification({
userId: 'Patient/123', // FHIR Patient reference
notificationType: 'EMAIL',
contextName: 'medication-reminder',
contextParameters: {
medicationName: 'Aspirin',
dosage: '100mg',
},
// ...
});// Notify a healthcare provider
await notificationService.createNotification({
userId: 'Practitioner/456', // FHIR Practitioner reference
notificationType: 'EMAIL',
contextName: 'new-patient-alert',
// ...
});// Get all notifications for a patient
const communications = await medplum.searchResources('Communication', {
_tag: 'notification',
recipient: 'Patient/123',
});- Email notifications via Medplum's email API
- File attachments using FHIR Binary and Media resources
- One-off notifications (no user account required)
- Scheduled notifications (send later)
- Notification templates with context parameters
- File deduplication via checksum
- Attachment cleanup for orphaned files
- FHIR-compliant data storage
- SMS notifications (Medplum limitation)
- Push notifications (Medplum limitation)
- In-app notification UI
- Real-time notification delivery (requires polling or webhooks)
class MedplumNotificationBackend<Config extends BaseNotificationTypeConfig>Main backend for storing notifications as FHIR Communication resources.
Constructor:
constructor(
medplum: MedplumClient,
options?: {
emailNotificationSubjectExtensionUrl?: string;
}
)Key Methods:
persistNotification(notification)- Create a new notificationpersistOneOffNotification(notification)- Create a one-off notificationgetNotification(id)- Retrieve a notification by IDgetPendingNotifications(page, pageSize)- Get notifications ready to sendmarkAsSent(id)- Mark notification as successfully sentmarkAsFailed(id)- Mark notification as failedcancelNotification(id)- Cancel a scheduled notification
class MedplumNotificationAdapter<
TemplateRenderer extends BaseEmailTemplateRenderer<Config>,
Config extends BaseNotificationTypeConfig
>Adapter for sending email notifications via Medplum.
Constructor:
constructor(
medplum: MedplumClient,
templateRenderer: TemplateRenderer
)Properties:
supportsAttachments: boolean- Returnstrue
Key Methods:
send(notification, context)- Send an email notification with attachments
class PugInlineEmailTemplateRenderer<Config extends BaseNotificationTypeConfig>
implements BaseEmailTemplateRenderer<Config>Template renderer that compiles Pug templates from pre-compiled JSON strings instead of reading from file paths. This is ideal for production deployments where templates are embedded in the application.
Constructor:
constructor(generatedTemplates: Record<string, string>)Parameters:
generatedTemplates- Object mapping template paths to template content strings (generated bycompile-pug-templatesscript)
Key Methods:
render(notification, context)- Compile and render both subject and body templates using the notification's template paths
Example:
import compiledTemplates from './compiled-templates.json';
const renderer = new PugInlineEmailTemplateRenderer(compiledTemplates);
const adapter = new MedplumNotificationAdapter(medplum, renderer);class MedplumAttachmentManager extends BaseAttachmentManagerManages file attachments using FHIR Binary and Media resources.
Constructor:
constructor(medplum: MedplumClient)Key Methods:
uploadFile(file, filename, contentType?)- Upload a file to Medplum storagegetFile(fileId)- Retrieve file metadata by Media resource IDdeleteFile(fileId)- Delete file and its Binary resourcereconstructAttachmentFile(storageMetadata)- Create AttachmentFile from metadata
class MedplumAttachmentFile implements AttachmentFileProvides access to files stored in FHIR Binary resources.
Methods:
read()- Read entire file into a Bufferstream()- Get a ReadableStream for the fileurl(expiresIn?)- Generate URL for file accessdelete()- Delete the file from storage
Organize your templates in a clear directory structure:
templates/
subjects/ # Email subject templates
welcome.pug
order-confirmation.pug
bodies/ # Or organize by feature
welcome.pug
order-confirmation.pug
notifications/ # Group related templates
orders/
confirmation.pug
shipped.pug
users/
welcome.pug
password-reset.pug
Use consistent, descriptive names:
// ✅ Good - clear and descriptive
bodyTemplate: 'notifications/orders/confirmation.pug'
subjectTemplate: 'subjects/order-confirmation.pug'
// ❌ Bad - unclear purpose
bodyTemplate: 'template1.pug'
subjectTemplate: 'subj.pug'Document the expected context variables in each template:
//- templates/welcome.pug
//- Expected variables: firstName, lastName, loginUrl
doctype html
html
body
h1 Welcome #{firstName} #{lastName}!
a(href=loginUrl) Login to your accountAdd template compilation to your build pipeline:
{
"scripts": {
"compile-templates": "compile-pug-templates ./templates ./src/compiled-templates.json",
"build": "npm run compile-templates && tsc"
}
}This ensures templates are always compiled before building your application.
Always use proper FHIR reference format for user IDs:
// ✅ Good
userId: 'Patient/123'
userId: 'Practitioner/456'
// ❌ Bad
userId: '123'
userId: 'user-456'Use your own domain for extension URLs in production:
const backend = new MedplumNotificationBackend(medplum, {
emailNotificationSubjectExtensionUrl: 'http://your-domain.com/fhir/email-subject',
});For large files, consider:
- Using file size limits
- Implementing file compression
- Using streaming for file operations
// Check file size before upload
const maxSize = 10 * 1024 * 1024; // 10MB
if (buffer.length > maxSize) {
throw new Error('File too large');
}Regularly run cleanup to remove orphaned attachment files:
// Get orphaned files
const orphaned = await backend.getOrphanedAttachmentFiles();
// Delete them
for (const file of orphaned) {
await backend.deleteAttachmentFile(file.id);
}Always paginate when fetching large result sets:
// ✅ Good
const notifications = await notificationService.getPendingNotifications(0, 50);
// ❌ Bad - could return thousands of records
const notifications = await notificationService.getAllPendingNotifications();MIT
For issues and questions:
- VintaSend: GitHub Issues
- Medplum: Documentation