vintasend 0.4.0 → 0.4.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -0
- package/dist/examples/vintasend-medplum-example/.env.example +11 -0
- package/dist/examples/vintasend-medplum-example/IMPLEMENTATION_PLAN_FILE_ATTACHMENTS.md +597 -0
- package/dist/examples/vintasend-medplum-example/README.md +190 -0
- package/dist/examples/vintasend-medplum-example/TUTORIAL_EMAIL_NOTIFICATIONS.md +2596 -0
- package/dist/examples/vintasend-medplum-example/bots/handlers/send-pending-notifications-bot.ts +39 -0
- package/dist/examples/vintasend-medplum-example/bots/handlers/send-task-assignment-email.ts +41 -0
- package/dist/examples/vintasend-medplum-example/bots/handlers/task-due-soon-notification-bot.ts +86 -0
- package/dist/examples/vintasend-medplum-example/bots/index.ts +53 -0
- package/dist/examples/vintasend-medplum-example/bots/services/emails/schedule-task-due-soon-email.ts +84 -0
- package/dist/examples/vintasend-medplum-example/bots/services/emails/send-task-assignment-email.test.ts +388 -0
- package/dist/examples/vintasend-medplum-example/bots/services/emails/send-task-assignment-email.ts +113 -0
- package/dist/examples/vintasend-medplum-example/bots/shared/task-due-soon-helpers.ts +115 -0
- package/dist/examples/vintasend-medplum-example/bots/task-assignment-bot.ts +41 -0
- package/dist/examples/vintasend-medplum-example/compiled-notification-templates.json +6 -0
- package/dist/examples/vintasend-medplum-example/esbuild-script.mjs +71 -0
- package/dist/examples/vintasend-medplum-example/index.html +14 -0
- package/dist/examples/vintasend-medplum-example/lib/constants.ts +32 -0
- package/dist/examples/vintasend-medplum-example/lib/extensions.ts +1 -0
- package/dist/examples/vintasend-medplum-example/lib/file-upload.test.ts +389 -0
- package/dist/examples/vintasend-medplum-example/lib/file-upload.ts +222 -0
- package/dist/examples/vintasend-medplum-example/lib/medplum-singleton.ts +18 -0
- package/dist/examples/vintasend-medplum-example/lib/notification-service.test.ts +293 -0
- package/dist/examples/vintasend-medplum-example/lib/notification-service.ts +284 -0
- package/dist/examples/vintasend-medplum-example/lib/patients.ts +20 -0
- package/dist/examples/vintasend-medplum-example/notification-templates/emails/task-assignment/body.html.pug +37 -0
- package/dist/examples/vintasend-medplum-example/notification-templates/emails/task-assignment/subject.txt.pug +4 -0
- package/dist/examples/vintasend-medplum-example/notification-templates/emails/task-due-soon/body.html.pug +34 -0
- package/dist/examples/vintasend-medplum-example/notification-templates/emails/task-due-soon/subject.txt.pug +4 -0
- package/dist/examples/vintasend-medplum-example/package.json +75 -0
- package/dist/examples/vintasend-medplum-example/plugins/gql-plugin.mjs +31 -0
- package/dist/examples/vintasend-medplum-example/postcss.config.mjs +21 -0
- package/dist/examples/vintasend-medplum-example/public/favicon.ico +0 -0
- package/dist/examples/vintasend-medplum-example/public/img/integrations/acuity.png +0 -0
- package/dist/examples/vintasend-medplum-example/public/img/integrations/auth0.png +0 -0
- package/dist/examples/vintasend-medplum-example/public/img/integrations/azure.png +0 -0
- package/dist/examples/vintasend-medplum-example/public/img/integrations/calcom.png +0 -0
- package/dist/examples/vintasend-medplum-example/public/img/integrations/candid.png +0 -0
- package/dist/examples/vintasend-medplum-example/public/img/integrations/claude.png +0 -0
- package/dist/examples/vintasend-medplum-example/public/img/integrations/datadog.png +0 -0
- package/dist/examples/vintasend-medplum-example/public/img/integrations/deepseek.png +0 -0
- package/dist/examples/vintasend-medplum-example/public/img/integrations/entra.png +0 -0
- package/dist/examples/vintasend-medplum-example/public/img/integrations/epic.png +0 -0
- package/dist/examples/vintasend-medplum-example/public/img/integrations/google.png +0 -0
- package/dist/examples/vintasend-medplum-example/public/img/integrations/healthgorilla.png +0 -0
- package/dist/examples/vintasend-medplum-example/public/img/integrations/healthie.png +0 -0
- package/dist/examples/vintasend-medplum-example/public/img/integrations/labcorp.png +0 -0
- package/dist/examples/vintasend-medplum-example/public/img/integrations/okta.png +0 -0
- package/dist/examples/vintasend-medplum-example/public/img/integrations/openai.png +0 -0
- package/dist/examples/vintasend-medplum-example/public/img/integrations/particle.png +0 -0
- package/dist/examples/vintasend-medplum-example/public/img/integrations/quest.png +0 -0
- package/dist/examples/vintasend-medplum-example/public/img/integrations/recaptcha.png +0 -0
- package/dist/examples/vintasend-medplum-example/public/img/integrations/snowflake.png +0 -0
- package/dist/examples/vintasend-medplum-example/public/img/integrations/stedi.png +0 -0
- package/dist/examples/vintasend-medplum-example/public/img/integrations/stripe.png +0 -0
- package/dist/examples/vintasend-medplum-example/public/img/integrations/sumo.png +0 -0
- package/dist/examples/vintasend-medplum-example/scripts/README.md +162 -0
- package/dist/examples/vintasend-medplum-example/scripts/client.ts +18 -0
- package/dist/examples/vintasend-medplum-example/scripts/deploy-bots.ts +171 -0
- package/dist/examples/vintasend-medplum-example/src/App.tsx +185 -0
- package/dist/examples/vintasend-medplum-example/src/components/ChargeItem/ChargeItemList.test.tsx +350 -0
- package/dist/examples/vintasend-medplum-example/src/components/ChargeItem/ChargeItemList.tsx +241 -0
- package/dist/examples/vintasend-medplum-example/src/components/ChargeItem/ChargeItemPanel.test.tsx +616 -0
- package/dist/examples/vintasend-medplum-example/src/components/ChargeItem/ChargeItemPanel.tsx +138 -0
- package/dist/examples/vintasend-medplum-example/src/components/Conditions/ConditionItem.test.tsx +92 -0
- package/dist/examples/vintasend-medplum-example/src/components/Conditions/ConditionItem.tsx +47 -0
- package/dist/examples/vintasend-medplum-example/src/components/Conditions/ConditionList.test.tsx +464 -0
- package/dist/examples/vintasend-medplum-example/src/components/Conditions/ConditionList.tsx +186 -0
- package/dist/examples/vintasend-medplum-example/src/components/Conditions/ConditionModal.test.tsx +80 -0
- package/dist/examples/vintasend-medplum-example/src/components/Conditions/ConditionModal.tsx +82 -0
- package/dist/examples/vintasend-medplum-example/src/components/DoseSpotIcon.test.tsx +100 -0
- package/dist/examples/vintasend-medplum-example/src/components/DoseSpotIcon.tsx +20 -0
- package/dist/examples/vintasend-medplum-example/src/components/IntegrationCard.module.css +3 -0
- package/dist/examples/vintasend-medplum-example/src/components/IntegrationCard.tsx +62 -0
- package/dist/examples/vintasend-medplum-example/src/components/MessageWithLinks.tsx +47 -0
- package/dist/examples/vintasend-medplum-example/src/components/PerformingLabInput.test.tsx +299 -0
- package/dist/examples/vintasend-medplum-example/src/components/PerformingLabInput.tsx +52 -0
- package/dist/examples/vintasend-medplum-example/src/components/ResourceFormWithRequiredProfile.tsx +82 -0
- package/dist/examples/vintasend-medplum-example/src/components/encounter/BillingTab.test.tsx +1016 -0
- package/dist/examples/vintasend-medplum-example/src/components/encounter/BillingTab.tsx +298 -0
- package/dist/examples/vintasend-medplum-example/src/components/encounter/EncounterChart.test.tsx +732 -0
- package/dist/examples/vintasend-medplum-example/src/components/encounter/EncounterChart.tsx +282 -0
- package/dist/examples/vintasend-medplum-example/src/components/encounter/EncounterHeader.test.tsx +268 -0
- package/dist/examples/vintasend-medplum-example/src/components/encounter/EncounterHeader.tsx +224 -0
- package/dist/examples/vintasend-medplum-example/src/components/encounter/SignAddendum.test.tsx +255 -0
- package/dist/examples/vintasend-medplum-example/src/components/encounter/SignAddendum.tsx +212 -0
- package/dist/examples/vintasend-medplum-example/src/components/encounter/SignLockDialog.test.tsx +120 -0
- package/dist/examples/vintasend-medplum-example/src/components/encounter/SignLockDialog.tsx +57 -0
- package/dist/examples/vintasend-medplum-example/src/components/encounter/VisitDetailsPanel.test.tsx +224 -0
- package/dist/examples/vintasend-medplum-example/src/components/encounter/VisitDetailsPanel.tsx +100 -0
- package/dist/examples/vintasend-medplum-example/src/components/labs/CoverageInput.test.tsx +431 -0
- package/dist/examples/vintasend-medplum-example/src/components/labs/CoverageInput.tsx +130 -0
- package/dist/examples/vintasend-medplum-example/src/components/labs/LabListItem.module.css +31 -0
- package/dist/examples/vintasend-medplum-example/src/components/labs/LabListItem.test.tsx +234 -0
- package/dist/examples/vintasend-medplum-example/src/components/labs/LabListItem.tsx +143 -0
- package/dist/examples/vintasend-medplum-example/src/components/labs/LabOrderDetails.module.css +11 -0
- package/dist/examples/vintasend-medplum-example/src/components/labs/LabOrderDetails.test.tsx +875 -0
- package/dist/examples/vintasend-medplum-example/src/components/labs/LabOrderDetails.tsx +943 -0
- package/dist/examples/vintasend-medplum-example/src/components/labs/LabResultDetails.test.tsx +413 -0
- package/dist/examples/vintasend-medplum-example/src/components/labs/LabResultDetails.tsx +203 -0
- package/dist/examples/vintasend-medplum-example/src/components/labs/LabSelectEmpty.tsx +22 -0
- package/dist/examples/vintasend-medplum-example/src/components/labs/README.md +104 -0
- package/dist/examples/vintasend-medplum-example/src/components/labs/TestMetadataCardInput.test.tsx +318 -0
- package/dist/examples/vintasend-medplum-example/src/components/labs/TestMetadataCardInput.tsx +87 -0
- package/dist/examples/vintasend-medplum-example/src/components/messages/ChatList.test.tsx +126 -0
- package/dist/examples/vintasend-medplum-example/src/components/messages/ChatList.tsx +38 -0
- package/dist/examples/vintasend-medplum-example/src/components/messages/ChatListItem.module.css +23 -0
- package/dist/examples/vintasend-medplum-example/src/components/messages/ChatListItem.test.tsx +167 -0
- package/dist/examples/vintasend-medplum-example/src/components/messages/ChatListItem.tsx +53 -0
- package/dist/examples/vintasend-medplum-example/src/components/messages/NewTopicDialog.test.tsx +94 -0
- package/dist/examples/vintasend-medplum-example/src/components/messages/NewTopicDialog.tsx +165 -0
- package/dist/examples/vintasend-medplum-example/src/components/messages/ParticipantFilter.module.css +8 -0
- package/dist/examples/vintasend-medplum-example/src/components/messages/ParticipantFilter.test.tsx +523 -0
- package/dist/examples/vintasend-medplum-example/src/components/messages/ParticipantFilter.tsx +230 -0
- package/dist/examples/vintasend-medplum-example/src/components/messages/ThreadInbox.module.css +23 -0
- package/dist/examples/vintasend-medplum-example/src/components/messages/ThreadInbox.test.tsx +567 -0
- package/dist/examples/vintasend-medplum-example/src/components/messages/ThreadInbox.tsx +358 -0
- package/dist/examples/vintasend-medplum-example/src/components/plandefinition/AddPlanDefinition.module.css +40 -0
- package/dist/examples/vintasend-medplum-example/src/components/plandefinition/AddPlanDefinition.tsx +257 -0
- package/dist/examples/vintasend-medplum-example/src/components/schedule/CreateVisit.module.css +7 -0
- package/dist/examples/vintasend-medplum-example/src/components/schedule/CreateVisit.test.tsx +279 -0
- package/dist/examples/vintasend-medplum-example/src/components/schedule/CreateVisit.tsx +156 -0
- package/dist/examples/vintasend-medplum-example/src/components/spaces/HistoryList.module.css +45 -0
- package/dist/examples/vintasend-medplum-example/src/components/spaces/HistoryList.test.tsx +90 -0
- package/dist/examples/vintasend-medplum-example/src/components/spaces/HistoryList.tsx +84 -0
- package/dist/examples/vintasend-medplum-example/src/components/spaces/ResourceBox.module.css +26 -0
- package/dist/examples/vintasend-medplum-example/src/components/spaces/ResourceBox.tsx +90 -0
- package/dist/examples/vintasend-medplum-example/src/components/spaces/ResourcePanel.test.tsx +305 -0
- package/dist/examples/vintasend-medplum-example/src/components/spaces/ResourcePanel.tsx +46 -0
- package/dist/examples/vintasend-medplum-example/src/components/spaces/SpacesInbox.module.css +262 -0
- package/dist/examples/vintasend-medplum-example/src/components/spaces/SpacesInbox.test.tsx +622 -0
- package/dist/examples/vintasend-medplum-example/src/components/spaces/SpacesInbox.tsx +286 -0
- package/dist/examples/vintasend-medplum-example/src/components/tasks/NewTaskModal.tsx +275 -0
- package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskAttachmentList.tsx +132 -0
- package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskBoard.module.css +45 -0
- package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskBoard.test.tsx +749 -0
- package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskBoard.tsx +416 -0
- package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskDetailPanel.test.tsx +278 -0
- package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskDetailPanel.tsx +133 -0
- package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskDetailsModal.module.css +16 -0
- package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskDetailsModal.test.tsx +255 -0
- package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskDetailsModal.tsx +203 -0
- package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskFileUpload.tsx +129 -0
- package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskFilterMenu.test.tsx +156 -0
- package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskFilterMenu.tsx +142 -0
- package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskFilterMenu.utils.ts +28 -0
- package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskInputNote.test.tsx +134 -0
- package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskInputNote.tsx +250 -0
- package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskListItem.module.css +23 -0
- package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskListItem.test.tsx +149 -0
- package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskListItem.tsx +53 -0
- package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskNoteItem.test.tsx +68 -0
- package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskNoteItem.tsx +46 -0
- package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskProperties.test.tsx +555 -0
- package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskProperties.tsx +170 -0
- package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskSelectEmpty.test.tsx +32 -0
- package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskSelectEmpty.tsx +34 -0
- package/dist/examples/vintasend-medplum-example/src/components/tasks/encounter/SimpleTask.test.tsx +47 -0
- package/dist/examples/vintasend-medplum-example/src/components/tasks/encounter/SimpleTask.tsx +29 -0
- package/dist/examples/vintasend-medplum-example/src/components/tasks/encounter/TaskPanel.test.tsx +285 -0
- package/dist/examples/vintasend-medplum-example/src/components/tasks/encounter/TaskPanel.tsx +129 -0
- package/dist/examples/vintasend-medplum-example/src/components/tasks/encounter/TaskQuestionnaireForm.test.tsx +455 -0
- package/dist/examples/vintasend-medplum-example/src/components/tasks/encounter/TaskQuestionnaireForm.tsx +167 -0
- package/dist/examples/vintasend-medplum-example/src/components/tasks/encounter/TaskServiceRequest.test.tsx +435 -0
- package/dist/examples/vintasend-medplum-example/src/components/tasks/encounter/TaskServiceRequest.tsx +116 -0
- package/dist/examples/vintasend-medplum-example/src/components/tasks/encounter/TaskStatusPanel.module.css +38 -0
- package/dist/examples/vintasend-medplum-example/src/components/tasks/encounter/TaskStatusPanel.test.tsx +200 -0
- package/dist/examples/vintasend-medplum-example/src/components/tasks/encounter/TaskStatusPanel.tsx +84 -0
- package/dist/examples/vintasend-medplum-example/src/components/utils.test.ts +176 -0
- package/dist/examples/vintasend-medplum-example/src/components/utils.ts +17 -0
- package/dist/examples/vintasend-medplum-example/src/config/constants.ts +3 -0
- package/dist/examples/vintasend-medplum-example/src/hooks/useDebouncedUpdateResource.test.tsx +166 -0
- package/dist/examples/vintasend-medplum-example/src/hooks/useDebouncedUpdateResource.ts +28 -0
- package/dist/examples/vintasend-medplum-example/src/hooks/useEncounter.test.tsx +94 -0
- package/dist/examples/vintasend-medplum-example/src/hooks/useEncounter.ts +11 -0
- package/dist/examples/vintasend-medplum-example/src/hooks/useEncounterChart.test.tsx +477 -0
- package/dist/examples/vintasend-medplum-example/src/hooks/useEncounterChart.ts +191 -0
- package/dist/examples/vintasend-medplum-example/src/hooks/usePatient.test.tsx +100 -0
- package/dist/examples/vintasend-medplum-example/src/hooks/usePatient.ts +18 -0
- package/dist/examples/vintasend-medplum-example/src/hooks/useThreadInbox.test.tsx +379 -0
- package/dist/examples/vintasend-medplum-example/src/hooks/useThreadInbox.ts +194 -0
- package/dist/examples/vintasend-medplum-example/src/index.css +8 -0
- package/dist/examples/vintasend-medplum-example/src/main.tsx +57 -0
- package/dist/examples/vintasend-medplum-example/src/pages/SearchPage.module.css +6 -0
- package/dist/examples/vintasend-medplum-example/src/pages/SearchPage.test.tsx +295 -0
- package/dist/examples/vintasend-medplum-example/src/pages/SearchPage.tsx +124 -0
- package/dist/examples/vintasend-medplum-example/src/pages/SignInPage.test.tsx +77 -0
- package/dist/examples/vintasend-medplum-example/src/pages/SignInPage.tsx +22 -0
- package/dist/examples/vintasend-medplum-example/src/pages/encounter/EncounterChartPage.test.tsx +87 -0
- package/dist/examples/vintasend-medplum-example/src/pages/encounter/EncounterChartPage.tsx +27 -0
- package/dist/examples/vintasend-medplum-example/src/pages/encounter/EncounterModal.module.css +16 -0
- package/dist/examples/vintasend-medplum-example/src/pages/encounter/EncounterModal.test.tsx +287 -0
- package/dist/examples/vintasend-medplum-example/src/pages/encounter/EncounterModal.tsx +151 -0
- package/dist/examples/vintasend-medplum-example/src/pages/integrations/DoseSpotFavoritesPage.test.tsx +519 -0
- package/dist/examples/vintasend-medplum-example/src/pages/integrations/DoseSpotFavoritesPage.tsx +179 -0
- package/dist/examples/vintasend-medplum-example/src/pages/integrations/FavoriteMedicationsTable.tsx +76 -0
- package/dist/examples/vintasend-medplum-example/src/pages/integrations/IntegrationsPage.test.tsx +234 -0
- package/dist/examples/vintasend-medplum-example/src/pages/integrations/IntegrationsPage.tsx +222 -0
- package/dist/examples/vintasend-medplum-example/src/pages/labs/OrderLabsPage.test.tsx +356 -0
- package/dist/examples/vintasend-medplum-example/src/pages/labs/OrderLabsPage.tsx +275 -0
- package/dist/examples/vintasend-medplum-example/src/pages/messages/MessagesPage.module.css +8 -0
- package/dist/examples/vintasend-medplum-example/src/pages/messages/MessagesPage.test.tsx +103 -0
- package/dist/examples/vintasend-medplum-example/src/pages/messages/MessagesPage.tsx +78 -0
- package/dist/examples/vintasend-medplum-example/src/pages/patient/CommunicationTab.test.tsx +84 -0
- package/dist/examples/vintasend-medplum-example/src/pages/patient/CommunicationTab.tsx +82 -0
- package/dist/examples/vintasend-medplum-example/src/pages/patient/DoseSpotAdvancedOptions.test.tsx +364 -0
- package/dist/examples/vintasend-medplum-example/src/pages/patient/DoseSpotAdvancedOptions.tsx +149 -0
- package/dist/examples/vintasend-medplum-example/src/pages/patient/DoseSpotTab.test.tsx +159 -0
- package/dist/examples/vintasend-medplum-example/src/pages/patient/DoseSpotTab.tsx +37 -0
- package/dist/examples/vintasend-medplum-example/src/pages/patient/EditTab.test.tsx +140 -0
- package/dist/examples/vintasend-medplum-example/src/pages/patient/EditTab.tsx +72 -0
- package/dist/examples/vintasend-medplum-example/src/pages/patient/ExportTab.test.tsx +57 -0
- package/dist/examples/vintasend-medplum-example/src/pages/patient/ExportTab.tsx +14 -0
- package/dist/examples/vintasend-medplum-example/src/pages/patient/IntakeFormPage.test.tsx +241 -0
- package/dist/examples/vintasend-medplum-example/src/pages/patient/IntakeFormPage.tsx +710 -0
- package/dist/examples/vintasend-medplum-example/src/pages/patient/LabsPage.module.css +37 -0
- package/dist/examples/vintasend-medplum-example/src/pages/patient/LabsPage.test.tsx +428 -0
- package/dist/examples/vintasend-medplum-example/src/pages/patient/LabsPage.tsx +334 -0
- package/dist/examples/vintasend-medplum-example/src/pages/patient/PatientPage.module.css +24 -0
- package/dist/examples/vintasend-medplum-example/src/pages/patient/PatientPage.test.tsx +154 -0
- package/dist/examples/vintasend-medplum-example/src/pages/patient/PatientPage.tsx +115 -0
- package/dist/examples/vintasend-medplum-example/src/pages/patient/PatientPage.utils.test.ts +223 -0
- package/dist/examples/vintasend-medplum-example/src/pages/patient/PatientPage.utils.ts +89 -0
- package/dist/examples/vintasend-medplum-example/src/pages/patient/PatientSearchPage.test.tsx +147 -0
- package/dist/examples/vintasend-medplum-example/src/pages/patient/PatientSearchPage.tsx +79 -0
- package/dist/examples/vintasend-medplum-example/src/pages/patient/PatientTabsNavigation.tsx +35 -0
- package/dist/examples/vintasend-medplum-example/src/pages/patient/TasksTab.test.tsx +185 -0
- package/dist/examples/vintasend-medplum-example/src/pages/patient/TasksTab.tsx +115 -0
- package/dist/examples/vintasend-medplum-example/src/pages/patient/TimelineTab.tsx +14 -0
- package/dist/examples/vintasend-medplum-example/src/pages/resource/ResourceCreatePage.test.tsx +170 -0
- package/dist/examples/vintasend-medplum-example/src/pages/resource/ResourceCreatePage.tsx +117 -0
- package/dist/examples/vintasend-medplum-example/src/pages/resource/ResourceDetailPage.tsx +28 -0
- package/dist/examples/vintasend-medplum-example/src/pages/resource/ResourceEditPage.test.tsx +131 -0
- package/dist/examples/vintasend-medplum-example/src/pages/resource/ResourceEditPage.tsx +65 -0
- package/dist/examples/vintasend-medplum-example/src/pages/resource/ResourceHistoryPage.test.tsx +108 -0
- package/dist/examples/vintasend-medplum-example/src/pages/resource/ResourceHistoryPage.tsx +16 -0
- package/dist/examples/vintasend-medplum-example/src/pages/resource/ResourcePage.module.css +7 -0
- package/dist/examples/vintasend-medplum-example/src/pages/resource/ResourcePage.test.tsx +37 -0
- package/dist/examples/vintasend-medplum-example/src/pages/resource/ResourcePage.tsx +44 -0
- package/dist/examples/vintasend-medplum-example/src/pages/resource/useResourceType.ts +44 -0
- package/dist/examples/vintasend-medplum-example/src/pages/resource/utils.ts +9 -0
- package/dist/examples/vintasend-medplum-example/src/pages/schedule/SchedulePage.test.tsx +302 -0
- package/dist/examples/vintasend-medplum-example/src/pages/schedule/SchedulePage.tsx +416 -0
- package/dist/examples/vintasend-medplum-example/src/pages/spaces/ChatInput.tsx +91 -0
- package/dist/examples/vintasend-medplum-example/src/pages/spaces/SpacesPage.module.css +6 -0
- package/dist/examples/vintasend-medplum-example/src/pages/spaces/SpacesPage.test.tsx +102 -0
- package/dist/examples/vintasend-medplum-example/src/pages/spaces/SpacesPage.tsx +44 -0
- package/dist/examples/vintasend-medplum-example/src/pages/tasks/TasksPage.module.css +7 -0
- package/dist/examples/vintasend-medplum-example/src/pages/tasks/TasksPage.test.tsx +133 -0
- package/dist/examples/vintasend-medplum-example/src/pages/tasks/TasksPage.tsx +91 -0
- package/dist/examples/vintasend-medplum-example/src/test-utils/render.tsx +20 -0
- package/dist/examples/vintasend-medplum-example/src/test.setup.ts +49 -0
- package/dist/examples/vintasend-medplum-example/src/types/encounter.ts +8 -0
- package/dist/examples/vintasend-medplum-example/src/types/spaces.ts +10 -0
- package/dist/examples/vintasend-medplum-example/src/utils/chargeitems.test.ts +141 -0
- package/dist/examples/vintasend-medplum-example/src/utils/chargeitems.ts +59 -0
- package/dist/examples/vintasend-medplum-example/src/utils/claims.test.ts +153 -0
- package/dist/examples/vintasend-medplum-example/src/utils/claims.ts +65 -0
- package/dist/examples/vintasend-medplum-example/src/utils/communication-search.ts +47 -0
- package/dist/examples/vintasend-medplum-example/src/utils/coverage.test.ts +48 -0
- package/dist/examples/vintasend-medplum-example/src/utils/coverage.ts +33 -0
- package/dist/examples/vintasend-medplum-example/src/utils/documentReference.test.ts +102 -0
- package/dist/examples/vintasend-medplum-example/src/utils/documentReference.ts +55 -0
- package/dist/examples/vintasend-medplum-example/src/utils/encounter.test.ts +169 -0
- package/dist/examples/vintasend-medplum-example/src/utils/encounter.ts +261 -0
- package/dist/examples/vintasend-medplum-example/src/utils/intake-form.test.ts +154 -0
- package/dist/examples/vintasend-medplum-example/src/utils/intake-form.ts +272 -0
- package/dist/examples/vintasend-medplum-example/src/utils/intake-utils.test.ts +1137 -0
- package/dist/examples/vintasend-medplum-example/src/utils/intake-utils.ts +827 -0
- package/dist/examples/vintasend-medplum-example/src/utils/notifications.test.ts +27 -0
- package/dist/examples/vintasend-medplum-example/src/utils/notifications.ts +15 -0
- package/dist/examples/vintasend-medplum-example/src/utils/spaceMessaging.ts +249 -0
- package/dist/examples/vintasend-medplum-example/src/utils/spacePersistence.test.ts +450 -0
- package/dist/examples/vintasend-medplum-example/src/utils/spacePersistence.ts +147 -0
- package/dist/examples/vintasend-medplum-example/src/utils/task-search.ts +63 -0
- package/dist/examples/vintasend-medplum-example/src/vite-env.d.ts +3 -0
- package/dist/examples/vintasend-medplum-example/tsconfig.bots.json +4 -0
- package/dist/examples/vintasend-medplum-example/tsconfig.json +19 -0
- package/dist/examples/vintasend-medplum-example/vercel.json +3 -0
- package/dist/examples/vintasend-medplum-example/vite.config.ts +44 -0
- package/dist/services/notification-backends/base-notification-backend.d.ts +5 -0
- package/dist/services/notification-service.js +11 -1
- package/dist/services/notification-template-renderers/base-email-template-renderer.d.ts +5 -0
- package/package.json +1 -1
|
@@ -0,0 +1,2596 @@
|
|
|
1
|
+
# Building Email Notifications for Task Assignments in Medplum with VintaSend
|
|
2
|
+
|
|
3
|
+
## Introduction
|
|
4
|
+
|
|
5
|
+
In this tutorial, we'll walk through implementing an email notification system for task assignments in a Medplum healthcare application. When a practitioner is assigned a task, they'll automatically receive an email with all the relevant details.
|
|
6
|
+
|
|
7
|
+
We'll be using [VintaSend](https://github.com/vintasoftware/vintasend), a powerful notification framework, along with [VintaSend-Medplum](https://github.com/vintasoftware/vintasend-medplum), which provides Medplum-specific adapters for the VintaSend framework.
|
|
8
|
+
|
|
9
|
+
> **Note**: This project is based on the [Medplum Provider example application](https://github.com/medplum/medplum-provider/), which provides a comprehensive starter template for building healthcare applications with Medplum. We've extended it with VintaSend integration to demonstrate email notifications and file attachment capabilities.
|
|
10
|
+
|
|
11
|
+
## Why VintaSend?
|
|
12
|
+
|
|
13
|
+
VintaSend is a flexible TypeScript package designed specifically for transactional notifications. Here's why it's a great choice:
|
|
14
|
+
|
|
15
|
+
**📦 Database-Backed Notification Management**
|
|
16
|
+
- All notifications are stored in your database, providing a complete audit trail
|
|
17
|
+
- Track notification status (pending, sent, failed) automatically
|
|
18
|
+
- Query past notifications easily for debugging or reporting
|
|
19
|
+
|
|
20
|
+
**📅 Smart Scheduling**
|
|
21
|
+
- Schedule notifications to send at a specific time in the future
|
|
22
|
+
- Context is fetched at send-time, ensuring data is always up-to-date
|
|
23
|
+
- No stale data - if a user's name changes, scheduled emails use the new name
|
|
24
|
+
|
|
25
|
+
**🎯 One-Off Notifications**
|
|
26
|
+
- Send emails to prospects or guests without requiring a user account
|
|
27
|
+
- Perfect for marketing, invitations, or external communications
|
|
28
|
+
- No need to create dummy user records
|
|
29
|
+
|
|
30
|
+
**📎 File Attachment Support**
|
|
31
|
+
- Attach files with automatic deduplication (same file stored once)
|
|
32
|
+
- Flexible storage backends (S3, Azure, GCS, local filesystem)
|
|
33
|
+
- Reuse files across multiple notifications
|
|
34
|
+
|
|
35
|
+
**🔧 Modular Architecture**
|
|
36
|
+
- Swap databases, email providers, or template engines independently
|
|
37
|
+
- No vendor lock-in - change your email provider without rewriting code
|
|
38
|
+
- Easy to test each component in isolation
|
|
39
|
+
|
|
40
|
+
**🏥 Healthcare-Ready**
|
|
41
|
+
- Designed with compliance and auditability in mind
|
|
42
|
+
- Integrates naturally with FHIR-based systems through VintaSend-Medplum
|
|
43
|
+
|
|
44
|
+
## Why VintaSend-Medplum?
|
|
45
|
+
|
|
46
|
+
VintaSend-Medplum brings VintaSend's notification capabilities to Medplum healthcare applications with full FHIR compliance:
|
|
47
|
+
|
|
48
|
+
**🏥 FHIR-Native Storage**
|
|
49
|
+
- Notifications stored as FHIR `Communication` resources
|
|
50
|
+
- File attachments stored as `Binary` and `Media` resources
|
|
51
|
+
- Seamlessly integrates with your existing Medplum data
|
|
52
|
+
|
|
53
|
+
**🔗 Healthcare Integration**
|
|
54
|
+
- Link notifications directly to `Patient` or `Practitioner` resources
|
|
55
|
+
- Search notifications using standard FHIR queries
|
|
56
|
+
- Works with Medplum's existing security and access controls
|
|
57
|
+
|
|
58
|
+
**📧 Flexible Email Providers**
|
|
59
|
+
- Use any email provider through VintaSend adapters (SendGrid, AWS SES, Medplum, etc.)
|
|
60
|
+
- Swap providers without changing your application code
|
|
61
|
+
- This tutorial uses SendGrid for reliable email delivery
|
|
62
|
+
|
|
63
|
+
**🎨 Pre-compiled Templates**
|
|
64
|
+
- Pug templates compiled to JSON at build time
|
|
65
|
+
- No file system access needed at runtime
|
|
66
|
+
- Perfect for serverless/bot environments
|
|
67
|
+
|
|
68
|
+
**✅ Production-Ready**
|
|
69
|
+
- Simple console logging for Medplum bots
|
|
70
|
+
- Status mapping to FHIR Communication statuses
|
|
71
|
+
- Automatic file deduplication via checksums
|
|
72
|
+
|
|
73
|
+
## Prerequisites
|
|
74
|
+
|
|
75
|
+
- A Medplum project set up and running
|
|
76
|
+
- Basic understanding of TypeScript and FHIR resources
|
|
77
|
+
- Node.js and npm installed
|
|
78
|
+
|
|
79
|
+
## What We'll Build
|
|
80
|
+
|
|
81
|
+
By the end of this tutorial, you'll have:
|
|
82
|
+
1. ✅ Email templates for task assignment notifications
|
|
83
|
+
2. ✅ A notification service configured with VintaSend
|
|
84
|
+
3. ✅ A bot service that sends emails when tasks are assigned
|
|
85
|
+
4. ✅ Support for personalized emails with user names
|
|
86
|
+
5. ✅ Priority handling for urgent tasks
|
|
87
|
+
|
|
88
|
+
## Step 1: Install Dependencies
|
|
89
|
+
|
|
90
|
+
First, let's add the VintaSend packages to our project:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
npm install vintasend vintasend-medplum vintasend-sendgrid
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Update your [package.json](package.json):
|
|
97
|
+
|
|
98
|
+
```json
|
|
99
|
+
{
|
|
100
|
+
"dependencies": {
|
|
101
|
+
"vintasend": "^0.4.3",
|
|
102
|
+
"vintasend-medplum": "^0.4.5",
|
|
103
|
+
"vintasend-sendgrid": "^0.4.3"
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
We're using:
|
|
109
|
+
- `vintasend`: The core notification framework
|
|
110
|
+
- `vintasend-medplum`: Medplum-specific adapters for FHIR storage
|
|
111
|
+
- `vintasend-sendgrid`: SendGrid adapter for email delivery
|
|
112
|
+
|
|
113
|
+
## Step 2: Create Email Templates with Pug
|
|
114
|
+
|
|
115
|
+
VintaSend supports Pug templates for creating dynamic emails. Let's create templates for our task assignment emails.
|
|
116
|
+
|
|
117
|
+
### Email Body Template
|
|
118
|
+
|
|
119
|
+
Create [notification-templates/emails/task-assignment/body.html.pug](notification-templates/emails/task-assignment/body.html.pug):
|
|
120
|
+
|
|
121
|
+
```pug
|
|
122
|
+
doctype html
|
|
123
|
+
html
|
|
124
|
+
head
|
|
125
|
+
meta(charset='utf-8')
|
|
126
|
+
style.
|
|
127
|
+
body {
|
|
128
|
+
white-space: pre-line;
|
|
129
|
+
}
|
|
130
|
+
body
|
|
131
|
+
h1 Task Assigned
|
|
132
|
+
|
|
133
|
+
p Hello #{firstName},
|
|
134
|
+
|
|
135
|
+
p You have been assigned a new task by #{requesterName}.
|
|
136
|
+
|
|
137
|
+
p
|
|
138
|
+
strong Task:
|
|
139
|
+
| #{taskTitle}
|
|
140
|
+
if taskDescription
|
|
141
|
+
p
|
|
142
|
+
strong Description:
|
|
143
|
+
| #{taskDescription}
|
|
144
|
+
if taskIsUrgent
|
|
145
|
+
p
|
|
146
|
+
strong URGENT
|
|
147
|
+
|
|
148
|
+
p
|
|
149
|
+
a(href=taskLink) View Task
|
|
150
|
+
|
|
151
|
+
p Please review the task details and take appropriate action.
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### Email Subject Template
|
|
155
|
+
|
|
156
|
+
Create [notification-templates/emails/task-assignment/subject.txt.pug](notification-templates/emails/task-assignment/subject.txt.pug):
|
|
157
|
+
|
|
158
|
+
```pug
|
|
159
|
+
if taskIsUrgent
|
|
160
|
+
| [URGENT] Task assigned to you: #{taskTitle}
|
|
161
|
+
else
|
|
162
|
+
| Task assigned to you: #{taskTitle}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
These templates use Pug syntax with dynamic variables like `#{firstName}`, `#{taskTitle}`, etc. The templates also handle conditional logic - for example, showing "URGENT" when the task has high priority.
|
|
166
|
+
|
|
167
|
+
## Step 3: Compile Templates
|
|
168
|
+
|
|
169
|
+
VintaSend-Medplum requires templates to be pre-compiled into a JSON file. Let's set up the compilation process.
|
|
170
|
+
|
|
171
|
+
Add these scripts to your [package.json](package.json):
|
|
172
|
+
|
|
173
|
+
```json
|
|
174
|
+
{
|
|
175
|
+
"scripts": {
|
|
176
|
+
"compile-templates": "compile-pug-templates ./notification-templates ./compiled-notification-templates.json",
|
|
177
|
+
"bots:build": "npm run clean && npm run compile-templates && tsc && node --no-warnings esbuild-script.mjs",
|
|
178
|
+
"bots:build:dev": "npm run clean && npm run compile-templates && node --no-warnings esbuild-script.mjs"
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
The `compile-pug-templates` command comes from the `vintasend-medplum` package and will generate a [compiled-notification-templates.json](compiled-notification-templates.json) file.
|
|
184
|
+
|
|
185
|
+
## Step 4: Create the Medplum Singleton
|
|
186
|
+
|
|
187
|
+
To use the Medplum client across different parts of our notification system, we'll create a singleton pattern.
|
|
188
|
+
|
|
189
|
+
Create [lib/medplum-singleton.ts](lib/medplum-singleton.ts):
|
|
190
|
+
|
|
191
|
+
```typescript
|
|
192
|
+
import { MedplumClient } from '@medplum/core';
|
|
193
|
+
|
|
194
|
+
export class MedplumSingleton {
|
|
195
|
+
private static instance: MedplumClient;
|
|
196
|
+
|
|
197
|
+
private constructor() {}
|
|
198
|
+
|
|
199
|
+
public static getInstance(): MedplumClient {
|
|
200
|
+
if (!MedplumSingleton.instance) {
|
|
201
|
+
throw new Error('MedplumClient instance not set. Please set it before using.');
|
|
202
|
+
}
|
|
203
|
+
return MedplumSingleton.instance;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
public static setInstance(medplum: MedplumClient): void {
|
|
207
|
+
MedplumSingleton.instance = medplum;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
This pattern ensures we have a single, shared instance of the MedplumClient throughout our notification system.
|
|
213
|
+
|
|
214
|
+
## Step 5: Create Helper Functions for Patient Names
|
|
215
|
+
|
|
216
|
+
Healthcare applications often need to handle patient and practitioner names with care. Let's create utilities for formatting names, including support for preferred names.
|
|
217
|
+
|
|
218
|
+
Create [lib/extensions.ts](lib/extensions.ts):
|
|
219
|
+
|
|
220
|
+
```typescript
|
|
221
|
+
export const PREFERRED_NAME_EXTENSION_URL = 'http://joinrewind.com/preferred-name';
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Create [lib/patients.ts](lib/patients.ts):
|
|
225
|
+
|
|
226
|
+
```typescript
|
|
227
|
+
import { formatHumanName } from '@medplum/core';
|
|
228
|
+
import { HumanName } from '@medplum/fhirtypes';
|
|
229
|
+
import { PREFERRED_NAME_EXTENSION_URL } from './extensions';
|
|
230
|
+
|
|
231
|
+
export function getPatientPreferredName(patientName: HumanName | undefined): string | undefined {
|
|
232
|
+
if (!patientName) return;
|
|
233
|
+
const preferredName = patientName?.extension?.find(
|
|
234
|
+
(extension) => extension.url === PREFERRED_NAME_EXTENSION_URL
|
|
235
|
+
)?.valueString;
|
|
236
|
+
return preferredName;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function formatPatientNameWithPreferredName(patientName: HumanName | undefined) {
|
|
240
|
+
const preferredName = getPatientPreferredName(patientName);
|
|
241
|
+
if (!preferredName) return formatHumanName(patientName);
|
|
242
|
+
const given = patientName?.given?.join(' ');
|
|
243
|
+
const familyName = patientName?.family;
|
|
244
|
+
|
|
245
|
+
return `${given} (${preferredName}) ${familyName}`;
|
|
246
|
+
}
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
These functions handle FHIR's `HumanName` structure and respect preferred names stored in FHIR extensions.
|
|
250
|
+
|
|
251
|
+
## Step 6: Configure the Notification Service
|
|
252
|
+
|
|
253
|
+
Now for the heart of our implementation - the notification service that integrates VintaSend with Medplum.
|
|
254
|
+
|
|
255
|
+
Create [lib/notification-service.ts](lib/notification-service.ts):
|
|
256
|
+
|
|
257
|
+
```typescript
|
|
258
|
+
import { MedplumClient } from '@medplum/core';
|
|
259
|
+
import type { ContextGenerator } from 'vintasend';
|
|
260
|
+
import { VintaSendFactory } from 'vintasend';
|
|
261
|
+
import { MedplumSingleton } from './medplum-singleton';
|
|
262
|
+
import { formatPatientNameWithPreferredName } from './patients';
|
|
263
|
+
import * as compiledTemplates from '../compiled-notification-templates.json';
|
|
264
|
+
import {
|
|
265
|
+
MedplumNotificationBackend,
|
|
266
|
+
MedplumAttachmentManager,
|
|
267
|
+
PugInlineEmailTemplateRendererFactory,
|
|
268
|
+
MedplumLogger
|
|
269
|
+
} from 'vintasend-medplum';
|
|
270
|
+
import { SendgridNotificationAdapterFactory } from 'vintasend-sendgrid';
|
|
271
|
+
|
|
272
|
+
async function getUserById(medplum: MedplumClient, referenceString: string) {
|
|
273
|
+
if (!referenceString) {
|
|
274
|
+
console.error('[getUserById] referenceString is null/undefined/empty!');
|
|
275
|
+
throw new Error('The "id" parameter cannot be null, undefined, or an empty string.');
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const [resourceType, id] = referenceString.split('/');
|
|
279
|
+
|
|
280
|
+
if (!id) {
|
|
281
|
+
console.error('[getUserById] ID extracted from referenceString is empty!');
|
|
282
|
+
throw new Error('The "id" parameter cannot be null, undefined, or an empty string.');
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return medplum.readResource(resourceType as 'Patient' | 'Practitioner', id);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
class TaskAssignmentContextGenerator implements ContextGenerator {
|
|
289
|
+
async generate({
|
|
290
|
+
userId,
|
|
291
|
+
taskTitle,
|
|
292
|
+
taskDescription,
|
|
293
|
+
taskIsUrgent,
|
|
294
|
+
taskLink,
|
|
295
|
+
requesterName,
|
|
296
|
+
}: {
|
|
297
|
+
userId: string;
|
|
298
|
+
taskTitle: string;
|
|
299
|
+
taskDescription: string;
|
|
300
|
+
taskIsUrgent: boolean;
|
|
301
|
+
taskLink: string;
|
|
302
|
+
requesterName: string;
|
|
303
|
+
}): Promise<{
|
|
304
|
+
firstName: string;
|
|
305
|
+
taskTitle: string;
|
|
306
|
+
taskDescription: string;
|
|
307
|
+
taskIsUrgent: boolean;
|
|
308
|
+
taskLink: string;
|
|
309
|
+
requesterName: string;
|
|
310
|
+
}> {
|
|
311
|
+
const medplum = MedplumSingleton.getInstance();
|
|
312
|
+
const user = await getUserById(medplum, userId);
|
|
313
|
+
const firstName = formatPatientNameWithPreferredName(user.name?.[0]) ?? 'Practitioner';
|
|
314
|
+
|
|
315
|
+
return {
|
|
316
|
+
firstName,
|
|
317
|
+
taskTitle,
|
|
318
|
+
taskDescription,
|
|
319
|
+
taskIsUrgent,
|
|
320
|
+
taskLink,
|
|
321
|
+
requesterName,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Context map for generating the context of each notification
|
|
327
|
+
export const contextGeneratorsMap = {
|
|
328
|
+
taskAssignment: new TaskAssignmentContextGenerator(),
|
|
329
|
+
// Add more context generators here for other notification types
|
|
330
|
+
} as const;
|
|
331
|
+
|
|
332
|
+
export type NotificationTypeConfig = {
|
|
333
|
+
ContextMap: typeof contextGeneratorsMap;
|
|
334
|
+
NotificationIdType: string;
|
|
335
|
+
UserIdType: string;
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
export function getNotificationService(medplum: MedplumClient) {
|
|
339
|
+
const backend = new MedplumNotificationBackend<NotificationTypeConfig>(medplum)
|
|
340
|
+
const templateRenderer = new PugInlineEmailTemplateRendererFactory<NotificationTypeConfig>()
|
|
341
|
+
.create(compiledTemplates);
|
|
342
|
+
const adapter = new SendgridNotificationAdapterFactory<NotificationTypeConfig>()
|
|
343
|
+
.create(
|
|
344
|
+
templateRenderer,
|
|
345
|
+
false,
|
|
346
|
+
{
|
|
347
|
+
apiKey: process.env.SENDGRID_API_KEY || '',
|
|
348
|
+
fromEmail: process.env.SENDGRID_FROM_EMAIL || '',
|
|
349
|
+
fromName: process.env.SENDGRID_FROM_NAME,
|
|
350
|
+
}
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
return new VintaSendFactory<NotificationTypeConfig>().create(
|
|
354
|
+
[adapter],
|
|
355
|
+
backend,
|
|
356
|
+
new MedplumLogger(),
|
|
357
|
+
contextGeneratorsMap,
|
|
358
|
+
undefined,
|
|
359
|
+
new MedplumAttachmentManager(medplum),
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
### Understanding the Notification Service
|
|
365
|
+
|
|
366
|
+
This file does several important things:
|
|
367
|
+
|
|
368
|
+
1. **Context Generators**: The `TaskAssignmentContextGenerator` takes the raw notification parameters and enriches them with data from Medplum (like the user's first name). This separates data fetching logic from template rendering.
|
|
369
|
+
|
|
370
|
+
2. **Type Safety**: TypeScript types ensure that context parameters match what the templates expect.
|
|
371
|
+
|
|
372
|
+
3. **VintaSend Setup**: The `getNotificationService` function wires together all the VintaSend components:
|
|
373
|
+
- **Backend**: Stores notification data in Medplum as FHIR `Communication` resources
|
|
374
|
+
- **Template Renderer**: Renders Pug templates from the compiled JSON
|
|
375
|
+
- **Adapter**: Handles email sending through SendGrid with API key authentication
|
|
376
|
+
- **Logger**: Logs notification events for debugging
|
|
377
|
+
- **Attachment Manager**: Handles file attachments stored as Medplum `Binary` resources (if needed)
|
|
378
|
+
|
|
379
|
+
4. **SendGrid Configuration**: The adapter requires three environment variables:
|
|
380
|
+
- `SENDGRID_API_KEY`: Your SendGrid API key for authentication
|
|
381
|
+
- `SENDGRID_FROM_EMAIL`: The verified sender email address
|
|
382
|
+
- `SENDGRID_FROM_NAME`: Optional display name for the sender
|
|
383
|
+
|
|
384
|
+
## Step 7: Create the Task Assignment Email Service
|
|
385
|
+
|
|
386
|
+
Now let's create the bot service that will be called when a task is assigned.
|
|
387
|
+
|
|
388
|
+
Create [bots/services/emails/send-task-assignment-email.ts](bots/services/emails/send-task-assignment-email.ts):
|
|
389
|
+
|
|
390
|
+
```typescript
|
|
391
|
+
import { MedplumClient } from '@medplum/core';
|
|
392
|
+
import { Task } from '@medplum/fhirtypes';
|
|
393
|
+
import { MedplumSingleton } from '../../../lib/medplum-singleton';
|
|
394
|
+
import { getNotificationService, SendGridConfig } from '../../../lib/notification-service';
|
|
395
|
+
import { formatPatientNameWithPreferredName } from '../../../lib/patients';
|
|
396
|
+
|
|
397
|
+
export async function sendTaskAssignmentEmail(
|
|
398
|
+
medplum: MedplumClient,
|
|
399
|
+
task: Task,
|
|
400
|
+
taskLinkBaseUrl: string,
|
|
401
|
+
sendgridConfig: SendGridConfig
|
|
402
|
+
) {
|
|
403
|
+
/* sends a task assignment email to a practitioner */
|
|
404
|
+
|
|
405
|
+
if (!task.owner?.reference) {
|
|
406
|
+
// eslint-disable-next-line no-console
|
|
407
|
+
console.error('[sendTaskAssignmentEmail] Task has no owner reference');
|
|
408
|
+
throw new Error('Task must have an owner reference');
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const referenceString = task.owner.reference;
|
|
412
|
+
|
|
413
|
+
// Validate format (should be "ResourceType/id")
|
|
414
|
+
if (!referenceString.includes('/')) {
|
|
415
|
+
// eslint-disable-next-line no-console
|
|
416
|
+
console.error('[sendTaskAssignmentEmail] Invalid referenceString format:', referenceString);
|
|
417
|
+
throw new Error(`Invalid referenceString format: ${referenceString}`);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Skip sending email if task is assigned to a Group
|
|
421
|
+
const [resourceType] = referenceString.split('/');
|
|
422
|
+
if (resourceType === 'Group') {
|
|
423
|
+
// eslint-disable-next-line no-console
|
|
424
|
+
console.log('[sendTaskAssignmentEmail] Task assigned to Group, skipping email notification');
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
MedplumSingleton.setInstance(medplum);
|
|
429
|
+
const vintasend = getNotificationService(medplum, sendgridConfig);
|
|
430
|
+
|
|
431
|
+
try {
|
|
432
|
+
const taskTitle = task.code?.text || task.description || 'New Task';
|
|
433
|
+
|
|
434
|
+
if (!task.id) {
|
|
435
|
+
// eslint-disable-next-line no-console
|
|
436
|
+
console.error('[sendTaskAssignmentEmail] Task has no id');
|
|
437
|
+
throw new Error('Task must have an id to send task assignment email');
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const taskLink = `${taskLinkBaseUrl}/Task/${task.id}`;
|
|
441
|
+
const taskIsUrgent = task.priority === 'urgent';
|
|
442
|
+
|
|
443
|
+
let requesterName = 'someone';
|
|
444
|
+
if (task.requester?.reference) {
|
|
445
|
+
try {
|
|
446
|
+
const requester = await medplum.readReference(task.requester);
|
|
447
|
+
if ('name' in requester && requester.name && Array.isArray(requester.name)) {
|
|
448
|
+
requesterName = formatPatientNameWithPreferredName(requester.name[0]);
|
|
449
|
+
}
|
|
450
|
+
} catch (error) {
|
|
451
|
+
// eslint-disable-next-line no-console
|
|
452
|
+
console.error('[sendTaskAssignmentEmail] Error fetching requester:', error);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
await vintasend.createNotification({
|
|
457
|
+
userId: referenceString,
|
|
458
|
+
notificationType: 'EMAIL' as const,
|
|
459
|
+
title: 'Task Assignment',
|
|
460
|
+
contextName: 'taskAssignment' as const,
|
|
461
|
+
contextParameters: {
|
|
462
|
+
userId: referenceString,
|
|
463
|
+
taskTitle,
|
|
464
|
+
taskDescription: task.description || '',
|
|
465
|
+
taskIsUrgent,
|
|
466
|
+
taskLink,
|
|
467
|
+
requesterName,
|
|
468
|
+
},
|
|
469
|
+
sendAfter: new Date(),
|
|
470
|
+
bodyTemplate: 'emails/task-assignment/body.html.pug',
|
|
471
|
+
subjectTemplate: 'emails/task-assignment/subject.txt.pug',
|
|
472
|
+
extraParams: {},
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
// eslint-disable-next-line no-console
|
|
476
|
+
console.log('[sendTaskAssignmentEmail] Email sent successfully to:', referenceString);
|
|
477
|
+
} catch (error) {
|
|
478
|
+
// eslint-disable-next-line no-console
|
|
479
|
+
console.error('[sendTaskAssignmentEmail] Error creating/sending notification:', error);
|
|
480
|
+
throw error;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
### Key Features
|
|
486
|
+
|
|
487
|
+
- **Validation**: Checks that the task has an owner before attempting to send
|
|
488
|
+
- **Group Handling**: Skips sending emails when tasks are assigned to Groups (not individual users)
|
|
489
|
+
- **Priority Support**: Detects urgent tasks and passes that information to the template
|
|
490
|
+
- **Error Handling**: Gracefully handles missing data and logs errors
|
|
491
|
+
- **Requester Info**: Fetches the name of the person who created the task for context
|
|
492
|
+
|
|
493
|
+
## Step 8: Set Up the Build Process
|
|
494
|
+
|
|
495
|
+
To deploy bots to Medplum, we need to bundle them properly. Create [esbuild-script.mjs](esbuild-script.mjs):
|
|
496
|
+
|
|
497
|
+
```javascript
|
|
498
|
+
import botLayer from '@medplum/bot-layer/package.json' with { type: 'json' };
|
|
499
|
+
import esbuild from 'esbuild';
|
|
500
|
+
import fastGlob from 'fast-glob';
|
|
501
|
+
|
|
502
|
+
// Find all TypeScript files
|
|
503
|
+
const entryPoints = fastGlob
|
|
504
|
+
.sync(['./src/**/*.ts', './bots/**/*.ts', './lib/**/*.ts'])
|
|
505
|
+
.filter((file) => !file.endsWith('test.ts'));
|
|
506
|
+
|
|
507
|
+
const botLayerDeps = Object.keys(botLayer.dependencies);
|
|
508
|
+
|
|
509
|
+
const additionalExternals = [
|
|
510
|
+
'react-transition-group',
|
|
511
|
+
'react-remove-scroll',
|
|
512
|
+
];
|
|
513
|
+
|
|
514
|
+
const esbuildOptions = {
|
|
515
|
+
entryPoints: entryPoints,
|
|
516
|
+
bundle: true,
|
|
517
|
+
outdir: './dist',
|
|
518
|
+
platform: 'node',
|
|
519
|
+
loader: {
|
|
520
|
+
'.ts': 'ts',
|
|
521
|
+
},
|
|
522
|
+
resolveExtensions: ['.ts', '.js', '.json'],
|
|
523
|
+
external: [...botLayerDeps, ...additionalExternals],
|
|
524
|
+
format: 'cjs',
|
|
525
|
+
target: 'es2020',
|
|
526
|
+
tsconfig: 'tsconfig.json',
|
|
527
|
+
footer: { js: 'Object.assign(exports, module.exports);' },
|
|
528
|
+
sourcemap: true,
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
esbuild
|
|
532
|
+
.build(esbuildOptions)
|
|
533
|
+
.then(() => {
|
|
534
|
+
console.log('Build completed successfully!');
|
|
535
|
+
})
|
|
536
|
+
.catch((error) => {
|
|
537
|
+
console.error('Build failed:', JSON.stringify(error, null, 2));
|
|
538
|
+
process.exit(1);
|
|
539
|
+
});
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
This script bundles all TypeScript files (including bots and lib files) into a format that Medplum can execute.
|
|
543
|
+
|
|
544
|
+
## Step 9: Build and Deploy
|
|
545
|
+
|
|
546
|
+
Now you're ready to build and deploy your bot!
|
|
547
|
+
|
|
548
|
+
### Build the bot:
|
|
549
|
+
```bash
|
|
550
|
+
npm run bots:build
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
This will:
|
|
554
|
+
1. Compile the Pug templates to JSON
|
|
555
|
+
2. Compile TypeScript to JavaScript
|
|
556
|
+
3. Bundle everything with esbuild
|
|
557
|
+
|
|
558
|
+
### Deploy to Medplum:
|
|
559
|
+
```bash
|
|
560
|
+
npm run bots:deploy
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
(Note: You'll need to configure your deployment script based on your Medplum setup)
|
|
564
|
+
|
|
565
|
+
## Step 10: Create the Bot Handler
|
|
566
|
+
|
|
567
|
+
Create the main bot file that will be executed by the subscription at [bots/task-assignment-bot.ts](bots/task-assignment-bot.ts):
|
|
568
|
+
|
|
569
|
+
```typescript
|
|
570
|
+
import { BotEvent, MedplumClient } from '@medplum/core';
|
|
571
|
+
import { Task } from '@medplum/fhirtypes';
|
|
572
|
+
import { sendTaskAssignmentEmail } from './services/emails/send-task-assignment-email';
|
|
573
|
+
import { buildSendGridConfig } from '../lib/notification-service';
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Medplum Bot: Task Assignment Email Notification
|
|
577
|
+
*
|
|
578
|
+
* This bot is triggered by a subscription when a Task is created or updated
|
|
579
|
+
* with an owner. It sends an email notification to the assigned practitioner.
|
|
580
|
+
*
|
|
581
|
+
* Subscription Criteria: Task?owner:exists=true
|
|
582
|
+
* Triggers: create, update
|
|
583
|
+
*/
|
|
584
|
+
|
|
585
|
+
export async function handler(medplum: MedplumClient, event: BotEvent): Promise<Task> {
|
|
586
|
+
const task = event.input as Task;
|
|
587
|
+
const sendGridVariables = buildSendGridConfig(event);
|
|
588
|
+
|
|
589
|
+
console.log(`[TaskAssignmentBot] Processing task: ${task.id}`);
|
|
590
|
+
console.log(`[TaskAssignmentBot] Owner: ${task.owner?.reference}`);
|
|
591
|
+
|
|
592
|
+
// Only send email if task has an owner
|
|
593
|
+
if (task.owner?.reference) {
|
|
594
|
+
const appBaseUrl = process.env.APP_BASE_URL || 'https://your-app-url.com';
|
|
595
|
+
|
|
596
|
+
try {
|
|
597
|
+
await sendTaskAssignmentEmail(medplum, task, appBaseUrl, sendGridVariables);
|
|
598
|
+
console.log(`[TaskAssignmentBot] Email notification sent successfully for task: ${task.id}`);
|
|
599
|
+
} catch (error) {
|
|
600
|
+
console.error(`[TaskAssignmentBot] Failed to send email for task: ${task.id}`, error);
|
|
601
|
+
// Don't throw - we don't want the subscription to fail
|
|
602
|
+
// The notification will be logged in Medplum as failed
|
|
603
|
+
}
|
|
604
|
+
} else {
|
|
605
|
+
console.log(`[TaskAssignmentBot] Task ${task.id} has no owner, skipping email notification`);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Return the task unchanged
|
|
609
|
+
return task;
|
|
610
|
+
}
|
|
611
|
+
```
|
|
612
|
+
|
|
613
|
+
## Step 11: Set Up the Subscription
|
|
614
|
+
|
|
615
|
+
To automatically trigger the bot when tasks are assigned, you need to create a FHIR Subscription resource in Medplum that links to your bot.
|
|
616
|
+
|
|
617
|
+
### Environment Configuration
|
|
618
|
+
|
|
619
|
+
First, create a [.env.example](.env.example) file with the necessary environment variables:
|
|
620
|
+
|
|
621
|
+
```bash
|
|
622
|
+
# Medplum Configuration
|
|
623
|
+
MEDPLUM_BASE_URL=https://api.medplum.com
|
|
624
|
+
MEDPLUM_CLIENT_ID=your-client-id-here
|
|
625
|
+
MEDPLUM_CLIENT_SECRET=your-client-secret-here
|
|
626
|
+
|
|
627
|
+
# SendGrid Configuration
|
|
628
|
+
SENDGRID_API_KEY=your-sendgrid-api-key-here
|
|
629
|
+
SENDGRID_FROM_EMAIL=noreply@yourdomain.com
|
|
630
|
+
SENDGRID_FROM_NAME=Your App Name
|
|
631
|
+
|
|
632
|
+
# Application Configuration
|
|
633
|
+
APP_BASE_URL=https://your-app-url.com
|
|
634
|
+
```
|
|
635
|
+
|
|
636
|
+
Copy this to `.env` and fill in your actual values:
|
|
637
|
+
```bash
|
|
638
|
+
cp .env.example .env
|
|
639
|
+
```
|
|
640
|
+
|
|
641
|
+
**Important**: Add `.env` to your `.gitignore` to keep secrets safe!
|
|
642
|
+
|
|
643
|
+
### Understanding Subscriptions
|
|
644
|
+
|
|
645
|
+
A Subscription in Medplum needs:
|
|
646
|
+
- **Criteria**: A FHIR search query that determines when to trigger
|
|
647
|
+
- **Channel**: Points to your bot via `Bot/{bot-id}`
|
|
648
|
+
- **Interactions**: Which events trigger the subscription (create, update, delete)
|
|
649
|
+
- **Status**: `active` - The subscription is live
|
|
650
|
+
|
|
651
|
+
When a matching resource event occurs, Medplum automatically:
|
|
652
|
+
1. Evaluates the subscription criteria
|
|
653
|
+
2. If matched, invokes the bot with the resource as input
|
|
654
|
+
3. The bot executes its logic (in our case, sending an email)
|
|
655
|
+
|
|
656
|
+
### Creating the Subscription
|
|
657
|
+
|
|
658
|
+
For our task assignment email notification, you'll need to create a subscription with:
|
|
659
|
+
|
|
660
|
+
- **Criteria**: `Task?owner:missing=false`
|
|
661
|
+
- This triggers whenever a Task resource has an `owner` field
|
|
662
|
+
- Matches both newly assigned tasks and tasks where ownership changes
|
|
663
|
+
- **Supported Interactions**: `create` and `update`
|
|
664
|
+
- Sends notifications for new task assignments and reassignments
|
|
665
|
+
- **Channel Endpoint**: `Bot/{your-task-assignment-bot-id}`
|
|
666
|
+
|
|
667
|
+
There are several ways to create subscriptions in Medplum:
|
|
668
|
+
- Through the Medplum console UI
|
|
669
|
+
- Via the Medplum API programmatically
|
|
670
|
+
- As part of your deployment workflow
|
|
671
|
+
|
|
672
|
+
**In this project**, subscription creation is handled automatically as part of our bot deployment workflow. When you run `npm run bots:deploy`, the deployment process creates or updates the subscription with the criteria above, linked to the task assignment bot. This ensures the subscription is always in sync with your deployed bot code.
|
|
673
|
+
|
|
674
|
+
Here's an example of how we configure our bot and its subscription in our deployment configuration:
|
|
675
|
+
|
|
676
|
+
```typescript
|
|
677
|
+
// Example from our deployment configuration
|
|
678
|
+
const botConfig = {
|
|
679
|
+
name: 'Task Assignment Email Bot',
|
|
680
|
+
description: 'Sends email notifications when tasks are assigned to practitioners',
|
|
681
|
+
source: './dist/bots/task-assignment-bot.js',
|
|
682
|
+
subscription: {
|
|
683
|
+
criteria: 'Task?owner:missing=false',
|
|
684
|
+
reason: 'Trigger email notification when a task is assigned',
|
|
685
|
+
supportedInteractions: ['create', 'update'],
|
|
686
|
+
},
|
|
687
|
+
};
|
|
688
|
+
```
|
|
689
|
+
|
|
690
|
+
When the deployment runs, it:
|
|
691
|
+
1. Creates or updates the bot with the specified source code
|
|
692
|
+
2. Creates or updates the subscription with the given criteria
|
|
693
|
+
3. Links the subscription to the bot automatically
|
|
694
|
+
4. Sets the subscription status to `active`
|
|
695
|
+
|
|
696
|
+
This declarative approach means you never have to manually manage subscriptions - they're always kept in sync with your bot deployments.
|
|
697
|
+
|
|
698
|
+
You can check how our deploy configuration works in our [deploy-bots.ts script](https://github.com/vintasoftware/vintasend-medplum-example/blob/main/scripts/deploy-bots.ts).
|
|
699
|
+
|
|
700
|
+
## Step 12: Deploy Everything
|
|
701
|
+
|
|
702
|
+
Now deploy your complete setup:
|
|
703
|
+
|
|
704
|
+
```bash
|
|
705
|
+
# 1. Build the bot
|
|
706
|
+
npm run bots:build
|
|
707
|
+
|
|
708
|
+
# 2. Deploy the bot code (this also handles subscription creation)
|
|
709
|
+
npm run bots:deploy
|
|
710
|
+
```
|
|
711
|
+
|
|
712
|
+
The deployment process will:
|
|
713
|
+
- Upload the compiled bot code to Medplum
|
|
714
|
+
- Create or update the subscription linking to the bot
|
|
715
|
+
- Activate the subscription to start receiving Task events
|
|
716
|
+
|
|
717
|
+
## Step 13: Using the Service
|
|
718
|
+
|
|
719
|
+
The subscription is now active! The bot will automatically run when tasks are assigned. You can also call the service directly in your code:
|
|
720
|
+
|
|
721
|
+
```typescript
|
|
722
|
+
import { MedplumClient } from '@medplum/core';
|
|
723
|
+
import { Task } from '@medplum/fhirtypes';
|
|
724
|
+
import { sendTaskAssignmentEmail } from './bots/services/emails/send-task-assignment-email';
|
|
725
|
+
|
|
726
|
+
// Direct usage (if not using subscription)
|
|
727
|
+
export async function manualTaskAssignment(medplum: MedplumClient, task: Task) {
|
|
728
|
+
await sendTaskAssignmentEmail(
|
|
729
|
+
medplum,
|
|
730
|
+
task,
|
|
731
|
+
'https://your-app-url.com'
|
|
732
|
+
);
|
|
733
|
+
}
|
|
734
|
+
```
|
|
735
|
+
|
|
736
|
+
### Testing the Integration
|
|
737
|
+
|
|
738
|
+
Create or update a Task with an owner to trigger the email:
|
|
739
|
+
|
|
740
|
+
```typescript
|
|
741
|
+
const task = await medplum.createResource({
|
|
742
|
+
resourceType: 'Task',
|
|
743
|
+
status: 'requested',
|
|
744
|
+
intent: 'order',
|
|
745
|
+
priority: 'routine', // or 'urgent' for urgent tasks
|
|
746
|
+
code: {
|
|
747
|
+
text: 'Review patient chart',
|
|
748
|
+
},
|
|
749
|
+
description: 'Please review the patient chart and update the care plan',
|
|
750
|
+
owner: {
|
|
751
|
+
reference: 'Practitioner/123', // The practitioner who will receive the email
|
|
752
|
+
},
|
|
753
|
+
requester: {
|
|
754
|
+
reference: 'Practitioner/456', // Who requested the task
|
|
755
|
+
},
|
|
756
|
+
});
|
|
757
|
+
```
|
|
758
|
+
|
|
759
|
+
The subscription will automatically:
|
|
760
|
+
1. Detect the Task creation
|
|
761
|
+
2. Trigger the bot
|
|
762
|
+
3. Send the email notification
|
|
763
|
+
|
|
764
|
+
### Monitoring
|
|
765
|
+
|
|
766
|
+
Check bot execution in the Medplum console:
|
|
767
|
+
- Navigate to **Bots** → **Task Assignment Email Bot**
|
|
768
|
+
- View execution logs to see successful runs or errors
|
|
769
|
+
- Check notification `Communication` resources for sent emails
|
|
770
|
+
|
|
771
|
+
## How It Works: The Full Flow
|
|
772
|
+
|
|
773
|
+
Let's trace through what happens when a task is assigned:
|
|
774
|
+
|
|
775
|
+
1. **Task Created**: A FHIR `Task` resource is created or updated with an `owner`
|
|
776
|
+
2. **Subscription Evaluates**: Medplum evaluates the subscription criteria (`Task?owner:exists=true`)
|
|
777
|
+
3. **Bot Triggered**: If criteria matches, the bot is invoked with the Task resource
|
|
778
|
+
4. **Service Called**: The bot handler calls `sendTaskAssignmentEmail()`
|
|
779
|
+
5. **Validation**: The service validates the task has an owner and isn't assigned to a Group
|
|
780
|
+
6. **Data Enrichment**:
|
|
781
|
+
- Fetches the requester's name
|
|
782
|
+
- Builds the task link
|
|
783
|
+
- Determines if the task is urgent
|
|
784
|
+
7. **Notification Created**: VintaSend's `createNotification()` is called with:
|
|
785
|
+
- User ID (the FHIR reference)
|
|
786
|
+
- Context parameters (taskTitle, taskDescription, etc.)
|
|
787
|
+
- Template paths
|
|
788
|
+
8. **Context Generation**: The `TaskAssignmentContextGenerator` runs:
|
|
789
|
+
- Fetches the recipient's user record from Medplum
|
|
790
|
+
- Extracts their first name (respecting preferred names)
|
|
791
|
+
- Merges with the provided parameters
|
|
792
|
+
9. **Template Rendering**: Pug templates are rendered with the enriched context
|
|
793
|
+
11. **Email Sent**: The email is sent via SendGrid's API
|
|
794
|
+
12. **Notification Stored**: VintaSend stores the notification record in Medplum as a FHIR `Communication` resource for auditing
|
|
795
|
+
|
|
796
|
+
## Benefits of This Approach
|
|
797
|
+
|
|
798
|
+
✅ **Type-Safe**: TypeScript ensures your context parameters match your templates
|
|
799
|
+
✅ **Testable**: Each component can be tested in isolation
|
|
800
|
+
✅ **Maintainable**: Templates are separate from logic
|
|
801
|
+
✅ **Extensible**: Easy to add new notification types
|
|
802
|
+
✅ **FHIR-Native**: Integrates seamlessly with Medplum's FHIR data model
|
|
803
|
+
✅ **Auditable**: All notifications are stored as FHIR resources
|
|
804
|
+
|
|
805
|
+
## Advanced: File Attachments for Task Notifications
|
|
806
|
+
|
|
807
|
+
One of VintaSend's powerful features is built-in attachment management. Files are stored efficiently in Medplum as FHIR Binary resources with automatic deduplication, and can be easily attached to email notifications.
|
|
808
|
+
|
|
809
|
+
In this section, we'll implement:
|
|
810
|
+
- ✅ File upload utilities for tasks
|
|
811
|
+
- ✅ FHIR-compliant file storage (Binary/Media resources)
|
|
812
|
+
- ✅ Automatic email attachment inclusion
|
|
813
|
+
- ✅ File deduplication via checksums
|
|
814
|
+
- ✅ Support for multiple file types
|
|
815
|
+
|
|
816
|
+
### Why Use VintaSend for Attachments?
|
|
817
|
+
|
|
818
|
+
**📎 Automatic Deduplication**
|
|
819
|
+
- Files with identical content stored once via checksum
|
|
820
|
+
- Multiple notifications can reference the same file
|
|
821
|
+
- Reduces storage costs
|
|
822
|
+
|
|
823
|
+
**🔒 FHIR-Native Storage**
|
|
824
|
+
- Files stored as Binary resources
|
|
825
|
+
- Metadata stored as Media resources
|
|
826
|
+
- Full FHIR compliance and access control
|
|
827
|
+
|
|
828
|
+
**📧 Email Provider Agnostic**
|
|
829
|
+
- Works with SendGrid, AWS SES, or any adapter
|
|
830
|
+
- Attachment handling abstracted from email provider
|
|
831
|
+
- Easy to switch providers without code changes
|
|
832
|
+
|
|
833
|
+
### Prerequisites
|
|
834
|
+
|
|
835
|
+
- Completed the basic task assignment tutorial
|
|
836
|
+
- VintaSend and VintaSend-Medplum installed
|
|
837
|
+
- Understanding of FHIR Binary and Media resources
|
|
838
|
+
|
|
839
|
+
### Step 1: Understanding FHIR File Storage
|
|
840
|
+
|
|
841
|
+
FHIR provides two resource types for handling files:
|
|
842
|
+
|
|
843
|
+
1. **Binary Resource**: Stores the actual file content
|
|
844
|
+
- Contains raw file data (base64 encoded or direct binary)
|
|
845
|
+
- Has minimal metadata (content type, data)
|
|
846
|
+
- Used as the storage layer
|
|
847
|
+
|
|
848
|
+
2. **Media Resource**: Provides file metadata and references
|
|
849
|
+
- Links to a Binary resource via `content.url`
|
|
850
|
+
- Contains rich metadata (title, creation date, subject, etc.)
|
|
851
|
+
- Used as the presentation/reference layer
|
|
852
|
+
|
|
853
|
+
**Why use both?**
|
|
854
|
+
|
|
855
|
+
Using Binary + Media follows FHIR best practices:
|
|
856
|
+
- **Separation of concerns**: Data storage (Binary) vs metadata (Media)
|
|
857
|
+
- **Reusability**: Multiple Media resources can reference the same Binary
|
|
858
|
+
- **Deduplication**: VintaSend automatically detects duplicate files via checksum
|
|
859
|
+
- **Access control**: Fine-grained permissions on Media without exposing Binary directly
|
|
860
|
+
|
|
861
|
+
**Example FHIR Resources**:
|
|
862
|
+
|
|
863
|
+
```json
|
|
864
|
+
// Binary resource (stores file content)
|
|
865
|
+
{
|
|
866
|
+
"resourceType": "Binary",
|
|
867
|
+
"id": "example-pdf-123",
|
|
868
|
+
"contentType": "application/pdf",
|
|
869
|
+
"data": "JVBERi0xLjQKJcOkw7zDtsOfCjIgMC..." // base64 encoded PDF
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// Media resource (provides metadata)
|
|
873
|
+
{
|
|
874
|
+
"resourceType": "Media",
|
|
875
|
+
"id": "example-media-456",
|
|
876
|
+
"status": "completed",
|
|
877
|
+
"content": {
|
|
878
|
+
"contentType": "application/pdf",
|
|
879
|
+
"url": "Binary/example-pdf-123",
|
|
880
|
+
"title": "Lab Results.pdf"
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// Task with attachment (references Media)
|
|
885
|
+
{
|
|
886
|
+
"resourceType": "Task",
|
|
887
|
+
"id": "example-task-789",
|
|
888
|
+
"status": "requested",
|
|
889
|
+
"input": [
|
|
890
|
+
{
|
|
891
|
+
"type": {
|
|
892
|
+
"coding": [{
|
|
893
|
+
"system": "http://vintasend-medplum-example.com/task-input-types",
|
|
894
|
+
"code": "attachment",
|
|
895
|
+
"display": "File Attachment"
|
|
896
|
+
}]
|
|
897
|
+
},
|
|
898
|
+
"valueReference": {
|
|
899
|
+
"reference": "Media/example-media-456"
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
]
|
|
903
|
+
}
|
|
904
|
+
```
|
|
905
|
+
|
|
906
|
+
### Step 2: Create File Upload Utilities
|
|
907
|
+
|
|
908
|
+
Create [lib/file-upload.ts](lib/file-upload.ts):
|
|
909
|
+
|
|
910
|
+
```typescript
|
|
911
|
+
import { MedplumClient } from '@medplum/core';
|
|
912
|
+
import { Binary, Media, Reference, Task, TaskInput } from '@medplum/fhirtypes';
|
|
913
|
+
|
|
914
|
+
export interface FileUploadResult {
|
|
915
|
+
binary: Binary;
|
|
916
|
+
media: Media;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* Uploads a file to Medplum and creates both Binary and Media resources.
|
|
921
|
+
*/
|
|
922
|
+
export async function uploadFileToMedplum(
|
|
923
|
+
medplum: MedplumClient,
|
|
924
|
+
file: File | Buffer,
|
|
925
|
+
filename: string,
|
|
926
|
+
contentType: string
|
|
927
|
+
): Promise<FileUploadResult> {
|
|
928
|
+
// Create Binary resource to store the file content
|
|
929
|
+
const binary = await medplum.createBinary(file, filename, contentType);
|
|
930
|
+
|
|
931
|
+
// Create Media resource as metadata wrapper
|
|
932
|
+
const media = await medplum.createResource<Media>({
|
|
933
|
+
resourceType: 'Media',
|
|
934
|
+
status: 'completed',
|
|
935
|
+
content: {
|
|
936
|
+
contentType,
|
|
937
|
+
url: `Binary/${binary.id}`,
|
|
938
|
+
title: filename,
|
|
939
|
+
},
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
return { binary, media };
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/**
|
|
946
|
+
* Attaches a Media resource to a Task by adding it to the task's input array.
|
|
947
|
+
*/
|
|
948
|
+
export async function attachFileToTask(
|
|
949
|
+
medplum: MedplumClient,
|
|
950
|
+
task: Task,
|
|
951
|
+
mediaReference: Reference<Media>
|
|
952
|
+
): Promise<Task> {
|
|
953
|
+
const currentInputs = task.input || [];
|
|
954
|
+
|
|
955
|
+
const attachmentInput: TaskInput = {
|
|
956
|
+
type: {
|
|
957
|
+
coding: [
|
|
958
|
+
{
|
|
959
|
+
system: 'http://vintasend-medplum-example.com/task-input-types',
|
|
960
|
+
code: 'attachment',
|
|
961
|
+
display: 'File Attachment',
|
|
962
|
+
},
|
|
963
|
+
],
|
|
964
|
+
},
|
|
965
|
+
valueReference: mediaReference,
|
|
966
|
+
};
|
|
967
|
+
|
|
968
|
+
const updatedTask = await medplum.updateResource<Task>({
|
|
969
|
+
...task,
|
|
970
|
+
input: [...currentInputs, attachmentInput],
|
|
971
|
+
});
|
|
972
|
+
|
|
973
|
+
return updatedTask;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/**
|
|
977
|
+
* Retrieves all Media resources attached to a Task.
|
|
978
|
+
*/
|
|
979
|
+
export async function getTaskAttachments(
|
|
980
|
+
medplum: MedplumClient,
|
|
981
|
+
task: Task
|
|
982
|
+
): Promise<Media[]> {
|
|
983
|
+
if (!task.input || task.input.length === 0) {
|
|
984
|
+
return [];
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
const attachmentInputs = task.input.filter((input) => {
|
|
988
|
+
const coding = input.type?.coding?.[0];
|
|
989
|
+
return coding?.code === 'attachment' && input.valueReference?.reference;
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
const mediaPromises = attachmentInputs.map(async (input): Promise<Media | null> => {
|
|
993
|
+
const reference = input.valueReference?.reference;
|
|
994
|
+
if (!reference) return null;
|
|
995
|
+
|
|
996
|
+
try {
|
|
997
|
+
const [resourceType, id] = reference.split('/');
|
|
998
|
+
if (resourceType !== 'Media' || !id) return null;
|
|
999
|
+
|
|
1000
|
+
return await medplum.readResource('Media', id);
|
|
1001
|
+
} catch (error) {
|
|
1002
|
+
console.error(`Failed to fetch Media: ${reference}`, error);
|
|
1003
|
+
return null;
|
|
1004
|
+
}
|
|
1005
|
+
});
|
|
1006
|
+
|
|
1007
|
+
const mediaResources = await Promise.all(mediaPromises);
|
|
1008
|
+
return mediaResources.filter((media): media is Media => media !== null);
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
/**
|
|
1012
|
+
* Gets the Binary content from a Media resource.
|
|
1013
|
+
*/
|
|
1014
|
+
export async function getBinaryFromMedia(
|
|
1015
|
+
medplum: MedplumClient,
|
|
1016
|
+
media: Media
|
|
1017
|
+
): Promise<Binary | null> {
|
|
1018
|
+
const binaryUrl = media.content?.url;
|
|
1019
|
+
if (!binaryUrl) return null;
|
|
1020
|
+
|
|
1021
|
+
try {
|
|
1022
|
+
const [resourceType, id] = binaryUrl.split('/');
|
|
1023
|
+
if (resourceType !== 'Binary' || !id) return null;
|
|
1024
|
+
|
|
1025
|
+
return await medplum.readResource('Binary', id);
|
|
1026
|
+
} catch (error) {
|
|
1027
|
+
console.error(`Failed to fetch Binary: ${binaryUrl}`, error);
|
|
1028
|
+
return null;
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
```
|
|
1032
|
+
|
|
1033
|
+
**Key Points:**
|
|
1034
|
+
|
|
1035
|
+
- **`uploadFileToMedplum`**: Creates both Binary and Media resources in a single operation
|
|
1036
|
+
- **`attachFileToTask`**: Adds attachment to task's `input` array following FHIR standards
|
|
1037
|
+
- **`getTaskAttachments`**: Retrieves all Media resources attached to a task
|
|
1038
|
+
- **`getBinaryFromMedia`**: Utility to fetch the actual file content from a Media reference
|
|
1039
|
+
|
|
1040
|
+
### Step 3: Configure File Upload Constraints
|
|
1041
|
+
|
|
1042
|
+
Update [lib/constants.ts](lib/constants.ts) to add file upload configuration:
|
|
1043
|
+
|
|
1044
|
+
```typescript
|
|
1045
|
+
/**
|
|
1046
|
+
* Task attachment configuration
|
|
1047
|
+
*/
|
|
1048
|
+
export const TASK_ATTACHMENT_INPUT_TYPE = {
|
|
1049
|
+
system: 'http://vintasend-medplum-example.com/task-input-types',
|
|
1050
|
+
code: 'attachment',
|
|
1051
|
+
display: 'File Attachment',
|
|
1052
|
+
};
|
|
1053
|
+
|
|
1054
|
+
/**
|
|
1055
|
+
* Maximum file size for attachments (10MB)
|
|
1056
|
+
*/
|
|
1057
|
+
export const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024;
|
|
1058
|
+
|
|
1059
|
+
/**
|
|
1060
|
+
* Allowed file types for task attachments
|
|
1061
|
+
*/
|
|
1062
|
+
export const ALLOWED_FILE_TYPES = [
|
|
1063
|
+
'application/pdf',
|
|
1064
|
+
'image/jpeg',
|
|
1065
|
+
'image/png',
|
|
1066
|
+
'image/gif',
|
|
1067
|
+
'image/webp',
|
|
1068
|
+
'application/msword',
|
|
1069
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
1070
|
+
'application/vnd.ms-excel',
|
|
1071
|
+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
1072
|
+
'text/plain',
|
|
1073
|
+
'text/csv',
|
|
1074
|
+
];
|
|
1075
|
+
```
|
|
1076
|
+
|
|
1077
|
+
**Configuration Explained:**
|
|
1078
|
+
|
|
1079
|
+
1. **`TASK_ATTACHMENT_INPUT_TYPE`**: FHIR coding system for identifying task attachments
|
|
1080
|
+
- Uses a custom system URL for your application
|
|
1081
|
+
- Code `'attachment'` indicates this input is a file attachment
|
|
1082
|
+
- Display name for human readability
|
|
1083
|
+
|
|
1084
|
+
2. **`MAX_ATTACHMENT_SIZE`**: 10MB limit (10 * 1024 * 1024 bytes)
|
|
1085
|
+
- Prevents excessive storage usage
|
|
1086
|
+
- SendGrid has a 30MB total attachment limit per email
|
|
1087
|
+
- Adjust based on your needs and email provider limits
|
|
1088
|
+
|
|
1089
|
+
3. **`ALLOWED_FILE_TYPES`**: Whitelist of MIME types
|
|
1090
|
+
- **Documents**: PDF, Word, Excel
|
|
1091
|
+
- **Images**: JPEG, PNG, GIF, WebP
|
|
1092
|
+
- **Text**: Plain text, CSV
|
|
1093
|
+
- Add more types as needed (e.g., `'video/mp4'`, `'audio/mpeg'`)
|
|
1094
|
+
|
|
1095
|
+
**Why restrict file types?**
|
|
1096
|
+
|
|
1097
|
+
- **Security**: Prevent malicious file uploads (executables, scripts)
|
|
1098
|
+
- **Compatibility**: Ensure files can be previewed/opened by recipients
|
|
1099
|
+
- **Storage optimization**: Exclude large video/audio files unless needed
|
|
1100
|
+
- **Compliance**: Meet healthcare regulations (e.g., only allow approved formats)
|
|
1101
|
+
|
|
1102
|
+
### Validation Best Practices
|
|
1103
|
+
|
|
1104
|
+
When implementing file upload UI, always validate:
|
|
1105
|
+
|
|
1106
|
+
```typescript
|
|
1107
|
+
function validateFile(file: File): string | null {
|
|
1108
|
+
// Check file size
|
|
1109
|
+
if (file.size > MAX_ATTACHMENT_SIZE) {
|
|
1110
|
+
return `File too large. Maximum size is ${MAX_ATTACHMENT_SIZE / 1024 / 1024}MB`;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
// Check file type
|
|
1114
|
+
if (!ALLOWED_FILE_TYPES.includes(file.type)) {
|
|
1115
|
+
return `File type ${file.type} is not allowed`;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
return null; // No errors
|
|
1119
|
+
}
|
|
1120
|
+
```
|
|
1121
|
+
|
|
1122
|
+
### Next Steps
|
|
1123
|
+
|
|
1124
|
+
Now that we have the foundation for file attachments:
|
|
1125
|
+
- ✅ FHIR Binary and Media resources explained
|
|
1126
|
+
- ✅ File upload utilities created
|
|
1127
|
+
- ✅ File constraints configured
|
|
1128
|
+
|
|
1129
|
+
Next, we'll build the React UI for file uploads and integrate with task forms.
|
|
1130
|
+
|
|
1131
|
+
---
|
|
1132
|
+
|
|
1133
|
+
### Step 4: Build File Upload UI Components
|
|
1134
|
+
|
|
1135
|
+
Now let's create the React components that allow users to upload files and attach them to tasks.
|
|
1136
|
+
|
|
1137
|
+
#### 4.1: Create the File Upload Component
|
|
1138
|
+
|
|
1139
|
+
Create [src/components/tasks/TaskFileUpload.tsx](src/components/tasks/TaskFileUpload.tsx):
|
|
1140
|
+
|
|
1141
|
+
```typescript
|
|
1142
|
+
import { useState, useCallback } from 'react';
|
|
1143
|
+
import { useMedplum } from '@medplum/react';
|
|
1144
|
+
import { FileInput, Group, Text, Alert, Progress } from '@mantine/core';
|
|
1145
|
+
import { IconAlertCircle } from '@tabler/icons-react';
|
|
1146
|
+
import type { JSX } from 'react';
|
|
1147
|
+
import type { Media } from '@medplum/fhirtypes';
|
|
1148
|
+
import { uploadFileToMedplum } from '../../../lib/file-upload';
|
|
1149
|
+
import { MAX_ATTACHMENT_SIZE, ALLOWED_FILE_TYPES } from '../../../lib/constants';
|
|
1150
|
+
|
|
1151
|
+
export interface TaskFileUploadProps {
|
|
1152
|
+
onFileUploaded: (media: Media) => void;
|
|
1153
|
+
disabled?: boolean;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
export function TaskFileUpload({ onFileUploaded, disabled = false }: TaskFileUploadProps): JSX.Element {
|
|
1157
|
+
const medplum = useMedplum();
|
|
1158
|
+
const [uploading, setUploading] = useState(false);
|
|
1159
|
+
const [error, setError] = useState<string | null>(null);
|
|
1160
|
+
const [uploadProgress, setUploadProgress] = useState(0);
|
|
1161
|
+
|
|
1162
|
+
const validateFile = (file: File): string | null => {
|
|
1163
|
+
if (file.size > MAX_ATTACHMENT_SIZE) {
|
|
1164
|
+
const maxSizeMB = MAX_ATTACHMENT_SIZE / (1024 * 1024);
|
|
1165
|
+
return `File size exceeds maximum allowed size of ${maxSizeMB}MB`;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
if (!ALLOWED_FILE_TYPES.includes(file.type)) {
|
|
1169
|
+
return `File type "${file.type}" is not allowed. Allowed types: PDF, images, Word documents, Excel spreadsheets, text files.`;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
return null;
|
|
1173
|
+
};
|
|
1174
|
+
|
|
1175
|
+
const handleFileChange = useCallback(
|
|
1176
|
+
async (file: File | null) => {
|
|
1177
|
+
if (!file) return;
|
|
1178
|
+
|
|
1179
|
+
const validationError = validateFile(file);
|
|
1180
|
+
if (validationError) {
|
|
1181
|
+
setError(validationError);
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
setError(null);
|
|
1186
|
+
setUploading(true);
|
|
1187
|
+
setUploadProgress(0);
|
|
1188
|
+
|
|
1189
|
+
try {
|
|
1190
|
+
// Simulate progress
|
|
1191
|
+
const progressInterval = setInterval(() => {
|
|
1192
|
+
setUploadProgress((prev) => Math.min(prev + 10, 90));
|
|
1193
|
+
}, 100);
|
|
1194
|
+
|
|
1195
|
+
const { media } = await uploadFileToMedplum(medplum, file, file.name, file.type);
|
|
1196
|
+
|
|
1197
|
+
clearInterval(progressInterval);
|
|
1198
|
+
setUploadProgress(100);
|
|
1199
|
+
|
|
1200
|
+
onFileUploaded(media);
|
|
1201
|
+
|
|
1202
|
+
setTimeout(() => {
|
|
1203
|
+
setUploadProgress(0);
|
|
1204
|
+
setUploading(false);
|
|
1205
|
+
}, 500);
|
|
1206
|
+
} catch (err) {
|
|
1207
|
+
setError(err instanceof Error ? err.message : 'Failed to upload file');
|
|
1208
|
+
setUploading(false);
|
|
1209
|
+
setUploadProgress(0);
|
|
1210
|
+
}
|
|
1211
|
+
},
|
|
1212
|
+
[medplum, onFileUploaded]
|
|
1213
|
+
);
|
|
1214
|
+
|
|
1215
|
+
return (
|
|
1216
|
+
<div>
|
|
1217
|
+
<FileInput
|
|
1218
|
+
label="Attach File"
|
|
1219
|
+
placeholder="Click to select file"
|
|
1220
|
+
accept={ALLOWED_FILE_TYPES.join(',')}
|
|
1221
|
+
onChange={handleFileChange}
|
|
1222
|
+
disabled={disabled || uploading}
|
|
1223
|
+
clearable
|
|
1224
|
+
/>
|
|
1225
|
+
|
|
1226
|
+
{uploading && (
|
|
1227
|
+
<Group mt="xs">
|
|
1228
|
+
<Progress value={uploadProgress} style={{ flex: 1 }} />
|
|
1229
|
+
<Text size="xs" c="dimmed">
|
|
1230
|
+
{uploadProgress}%
|
|
1231
|
+
</Text>
|
|
1232
|
+
</Group>
|
|
1233
|
+
)}
|
|
1234
|
+
|
|
1235
|
+
{error && (
|
|
1236
|
+
<Alert icon={<IconAlertCircle size={16} />} title="Upload Error" color="red" mt="xs">
|
|
1237
|
+
{error}
|
|
1238
|
+
</Alert>
|
|
1239
|
+
)}
|
|
1240
|
+
|
|
1241
|
+
<Text size="xs" c="dimmed" mt="xs">
|
|
1242
|
+
Maximum file size: {MAX_ATTACHMENT_SIZE / (1024 * 1024)}MB. Allowed types: PDF, images, Word, Excel, text files.
|
|
1243
|
+
</Text>
|
|
1244
|
+
</div>
|
|
1245
|
+
);
|
|
1246
|
+
}
|
|
1247
|
+
```
|
|
1248
|
+
|
|
1249
|
+
**Component Features:**
|
|
1250
|
+
|
|
1251
|
+
1. **File Validation**: Checks size and type before upload
|
|
1252
|
+
2. **Progress Indicator**: Shows upload progress to user
|
|
1253
|
+
3. **Error Handling**: Displays clear error messages
|
|
1254
|
+
4. **Disabled State**: Can be disabled during form submission
|
|
1255
|
+
5. **User Feedback**: Shows allowed file types and size limits
|
|
1256
|
+
|
|
1257
|
+
#### 4.2: Create the Attachment List Component
|
|
1258
|
+
|
|
1259
|
+
Create [src/components/tasks/TaskAttachmentList.tsx](src/components/tasks/TaskAttachmentList.tsx):
|
|
1260
|
+
|
|
1261
|
+
```typescript
|
|
1262
|
+
import { useMedplum } from '@medplum/react';
|
|
1263
|
+
import type { Media } from '@medplum/fhirtypes';
|
|
1264
|
+
import { Badge, Group, ActionIcon, Text, Stack, Paper, Tooltip } from '@mantine/core';
|
|
1265
|
+
import { IconDownload, IconX, IconFile, IconFileText, IconPhoto } from '@tabler/icons-react';
|
|
1266
|
+
import type { JSX } from 'react';
|
|
1267
|
+
|
|
1268
|
+
export interface TaskAttachmentListProps {
|
|
1269
|
+
attachments: Media[];
|
|
1270
|
+
onRemove?: (mediaId: string) => void;
|
|
1271
|
+
readOnly?: boolean;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
export function TaskAttachmentList({
|
|
1275
|
+
attachments,
|
|
1276
|
+
onRemove,
|
|
1277
|
+
readOnly = false
|
|
1278
|
+
}: TaskAttachmentListProps): JSX.Element {
|
|
1279
|
+
const medplum = useMedplum();
|
|
1280
|
+
|
|
1281
|
+
if (attachments.length === 0) {
|
|
1282
|
+
return (
|
|
1283
|
+
<Text size="sm" c="dimmed">
|
|
1284
|
+
No attachments
|
|
1285
|
+
</Text>
|
|
1286
|
+
);
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
const getFileIcon = (contentType?: string): JSX.Element => {
|
|
1290
|
+
if (!contentType) return <IconFile size={20} />;
|
|
1291
|
+
if (contentType.startsWith('image/')) return <IconPhoto size={20} />;
|
|
1292
|
+
if (contentType.includes('pdf') || contentType.includes('document')) {
|
|
1293
|
+
return <IconFileText size={20} />;
|
|
1294
|
+
}
|
|
1295
|
+
return <IconFile size={20} />;
|
|
1296
|
+
};
|
|
1297
|
+
|
|
1298
|
+
const handleDownload = async (media: Media): Promise<void> => {
|
|
1299
|
+
if (!media.content?.url) return;
|
|
1300
|
+
|
|
1301
|
+
try {
|
|
1302
|
+
const binaryId = media.content.url.split('/')[1];
|
|
1303
|
+
const url = `${medplum.getBaseUrl()}fhir/R4/Binary/${binaryId}`;
|
|
1304
|
+
|
|
1305
|
+
const link = document.createElement('a');
|
|
1306
|
+
link.href = url;
|
|
1307
|
+
link.download = media.content.title || 'download';
|
|
1308
|
+
link.target = '_blank';
|
|
1309
|
+
document.body.appendChild(link);
|
|
1310
|
+
link.click();
|
|
1311
|
+
document.body.removeChild(link);
|
|
1312
|
+
} catch (error) {
|
|
1313
|
+
console.error('Failed to download file:', error);
|
|
1314
|
+
}
|
|
1315
|
+
};
|
|
1316
|
+
|
|
1317
|
+
return (
|
|
1318
|
+
<Stack gap="xs">
|
|
1319
|
+
{attachments.map((media) => (
|
|
1320
|
+
<Paper key={media.id} p="sm" withBorder>
|
|
1321
|
+
<Group justify="space-between">
|
|
1322
|
+
<Group gap="sm">
|
|
1323
|
+
{getFileIcon(media.content?.contentType)}
|
|
1324
|
+
<div>
|
|
1325
|
+
<Text size="sm" fw={500}>
|
|
1326
|
+
{media.content?.title || 'Untitled'}
|
|
1327
|
+
</Text>
|
|
1328
|
+
<Badge size="xs" variant="outline">
|
|
1329
|
+
{media.content?.contentType || 'Unknown type'}
|
|
1330
|
+
</Badge>
|
|
1331
|
+
</div>
|
|
1332
|
+
</Group>
|
|
1333
|
+
|
|
1334
|
+
<Group gap="xs">
|
|
1335
|
+
<Tooltip label="Download file">
|
|
1336
|
+
<ActionIcon
|
|
1337
|
+
variant="subtle"
|
|
1338
|
+
color="blue"
|
|
1339
|
+
onClick={() => handleDownload(media)}
|
|
1340
|
+
aria-label="Download file"
|
|
1341
|
+
>
|
|
1342
|
+
<IconDownload size={18} />
|
|
1343
|
+
</ActionIcon>
|
|
1344
|
+
</Tooltip>
|
|
1345
|
+
|
|
1346
|
+
{!readOnly && onRemove && (
|
|
1347
|
+
<Tooltip label="Remove attachment">
|
|
1348
|
+
<ActionIcon
|
|
1349
|
+
variant="subtle"
|
|
1350
|
+
color="red"
|
|
1351
|
+
onClick={() => onRemove(media.id as string)}
|
|
1352
|
+
aria-label="Remove attachment"
|
|
1353
|
+
>
|
|
1354
|
+
<IconX size={18} />
|
|
1355
|
+
</ActionIcon>
|
|
1356
|
+
</Tooltip>
|
|
1357
|
+
)}
|
|
1358
|
+
</Group>
|
|
1359
|
+
</Group>
|
|
1360
|
+
</Paper>
|
|
1361
|
+
))}
|
|
1362
|
+
</Stack>
|
|
1363
|
+
);
|
|
1364
|
+
}
|
|
1365
|
+
```
|
|
1366
|
+
|
|
1367
|
+
**Component Features:**
|
|
1368
|
+
|
|
1369
|
+
1. **File Icons**: Different icons for images, documents, and generic files
|
|
1370
|
+
2. **Download**: Click to download any attachment
|
|
1371
|
+
3. **Remove**: Option to remove attachments (when not read-only)
|
|
1372
|
+
4. **Metadata Display**: Shows filename and file type
|
|
1373
|
+
5. **Responsive Layout**: Clean card-based design
|
|
1374
|
+
|
|
1375
|
+
#### 4.3: Integrate with Task Creation Form
|
|
1376
|
+
|
|
1377
|
+
Update [src/components/tasks/NewTaskModal.tsx](src/components/tasks/NewTaskModal.tsx) to include file uploads:
|
|
1378
|
+
|
|
1379
|
+
```typescript
|
|
1380
|
+
// Add imports
|
|
1381
|
+
import type { Media } from '@medplum/fhirtypes';
|
|
1382
|
+
import { TaskFileUpload } from './TaskFileUpload';
|
|
1383
|
+
import { TaskAttachmentList } from './TaskAttachmentList';
|
|
1384
|
+
import { TASK_ATTACHMENT_INPUT_TYPE } from '../../../lib/constants';
|
|
1385
|
+
|
|
1386
|
+
// Add state for attachments
|
|
1387
|
+
const [attachments, setAttachments] = useState<Media[]>([]);
|
|
1388
|
+
|
|
1389
|
+
// Add handler functions
|
|
1390
|
+
const handleFileUploaded = (media: Media): void => {
|
|
1391
|
+
setAttachments((prev) => [...prev, media]);
|
|
1392
|
+
};
|
|
1393
|
+
|
|
1394
|
+
const handleRemoveAttachment = (mediaId: string): void => {
|
|
1395
|
+
setAttachments((prev) => prev.filter((m) => m.id !== mediaId));
|
|
1396
|
+
};
|
|
1397
|
+
|
|
1398
|
+
// Update task creation to include attachments
|
|
1399
|
+
const newTask: Task = {
|
|
1400
|
+
resourceType: 'Task',
|
|
1401
|
+
// ... other fields ...
|
|
1402
|
+
input: attachments.map((media) => ({
|
|
1403
|
+
type: {
|
|
1404
|
+
coding: [TASK_ATTACHMENT_INPUT_TYPE],
|
|
1405
|
+
text: 'File Attachment',
|
|
1406
|
+
},
|
|
1407
|
+
valueReference: createReference(media),
|
|
1408
|
+
})),
|
|
1409
|
+
};
|
|
1410
|
+
|
|
1411
|
+
// Add to form UI (in the description section)
|
|
1412
|
+
<Box>
|
|
1413
|
+
<Text size="sm" fw={500} mb="xs">
|
|
1414
|
+
Attachments
|
|
1415
|
+
</Text>
|
|
1416
|
+
<Stack gap="sm">
|
|
1417
|
+
<TaskFileUpload onFileUploaded={handleFileUploaded} disabled={isSubmitting} />
|
|
1418
|
+
{attachments.length > 0 && (
|
|
1419
|
+
<TaskAttachmentList attachments={attachments} onRemove={handleRemoveAttachment} />
|
|
1420
|
+
)}
|
|
1421
|
+
</Stack>
|
|
1422
|
+
</Box>
|
|
1423
|
+
```
|
|
1424
|
+
|
|
1425
|
+
**Integration Highlights:**
|
|
1426
|
+
|
|
1427
|
+
1. **State Management**: Track uploaded Media resources in component state
|
|
1428
|
+
2. **FHIR Compliance**: Store attachments in `task.input` array following FHIR standards
|
|
1429
|
+
3. **User Experience**: Upload → Preview → Remove workflow
|
|
1430
|
+
4. **Form Reset**: Clear attachments when modal closes
|
|
1431
|
+
|
|
1432
|
+
### How It Works: The Upload Flow
|
|
1433
|
+
|
|
1434
|
+
```
|
|
1435
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
1436
|
+
│ 1. User selects file in TaskFileUpload component │
|
|
1437
|
+
└────────────────────┬────────────────────────────────────────┘
|
|
1438
|
+
│
|
|
1439
|
+
▼
|
|
1440
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
1441
|
+
│ 2. Validate file type and size │
|
|
1442
|
+
│ - Check against ALLOWED_FILE_TYPES │
|
|
1443
|
+
│ - Check against MAX_ATTACHMENT_SIZE │
|
|
1444
|
+
└────────────────────┬────────────────────────────────────────┘
|
|
1445
|
+
│
|
|
1446
|
+
▼
|
|
1447
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
1448
|
+
│ 3. Upload to Medplum via uploadFileToMedplum() │
|
|
1449
|
+
│ - Create Binary resource (stores file content) │
|
|
1450
|
+
│ - Create Media resource (stores metadata) │
|
|
1451
|
+
└────────────────────┬────────────────────────────────────────┘
|
|
1452
|
+
│
|
|
1453
|
+
▼
|
|
1454
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
1455
|
+
│ 4. Add Media to attachments array via handleFileUploaded() │
|
|
1456
|
+
└────────────────────┬────────────────────────────────────────┘
|
|
1457
|
+
│
|
|
1458
|
+
▼
|
|
1459
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
1460
|
+
│ 5. Display in TaskAttachmentList (with download/remove) │
|
|
1461
|
+
└────────────────────┬────────────────────────────────────────┘
|
|
1462
|
+
│
|
|
1463
|
+
▼
|
|
1464
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
1465
|
+
│ 6. On task creation, add to task.input array │
|
|
1466
|
+
│ - Each attachment becomes a TaskInput entry │
|
|
1467
|
+
│ - Type: TASK_ATTACHMENT_INPUT_TYPE coding │
|
|
1468
|
+
│ - Value: Reference to Media resource │
|
|
1469
|
+
└─────────────────────────────────────────────────────────────┘
|
|
1470
|
+
```
|
|
1471
|
+
|
|
1472
|
+
### Testing Your File Upload UI
|
|
1473
|
+
|
|
1474
|
+
Try these scenarios to verify everything works:
|
|
1475
|
+
|
|
1476
|
+
1. **Upload valid file**: Select a PDF or image → Should upload successfully
|
|
1477
|
+
2. **Upload invalid type**: Select .exe file → Should show error message
|
|
1478
|
+
3. **Upload large file**: Select 15MB file → Should show size error
|
|
1479
|
+
4. **Multiple files**: Upload 2-3 files → All should appear in list
|
|
1480
|
+
5. **Remove file**: Click X on attachment → Should disappear from list
|
|
1481
|
+
6. **Download file**: Click download icon → Should download file
|
|
1482
|
+
7. **Create task**: Upload files and create task → Attachments saved in task.input
|
|
1483
|
+
|
|
1484
|
+
### UI Best Practices
|
|
1485
|
+
|
|
1486
|
+
**Accessibility:**
|
|
1487
|
+
- Use proper ARIA labels on action icons
|
|
1488
|
+
- Provide keyboard navigation support
|
|
1489
|
+
- Show clear error messages
|
|
1490
|
+
|
|
1491
|
+
**User Feedback:**
|
|
1492
|
+
- Display upload progress
|
|
1493
|
+
- Show file validation errors immediately
|
|
1494
|
+
- Confirm successful uploads visually
|
|
1495
|
+
|
|
1496
|
+
**Performance:**
|
|
1497
|
+
- Validate before upload (don't upload invalid files)
|
|
1498
|
+
- Show loading states during upload
|
|
1499
|
+
- Optimize large images before upload (future enhancement)
|
|
1500
|
+
|
|
1501
|
+
### Summary
|
|
1502
|
+
|
|
1503
|
+
Phase 2 complete! You now have:
|
|
1504
|
+
- ✅ File upload component with validation
|
|
1505
|
+
- ✅ Attachment list with download/remove
|
|
1506
|
+
- ✅ Integration with task creation form
|
|
1507
|
+
- ✅ FHIR-compliant storage in task.input
|
|
1508
|
+
|
|
1509
|
+
Next, we'll integrate these attachments with email notifications so files are automatically included when task assignment emails are sent.
|
|
1510
|
+
|
|
1511
|
+
---
|
|
1512
|
+
|
|
1513
|
+
### Step 5: Add Attachment Support to Email Notifications
|
|
1514
|
+
|
|
1515
|
+
Now that we have the UI for uploading files to tasks, let's integrate those attachments with our email notification system so that files are automatically included when task assignment emails are sent.
|
|
1516
|
+
|
|
1517
|
+
#### 5.1: Add Attachment Conversion Function
|
|
1518
|
+
|
|
1519
|
+
First, we need to add a function that converts FHIR Media/Binary resources to the format expected by VintaSend for email attachments.
|
|
1520
|
+
|
|
1521
|
+
Update [lib/notification-service.ts](lib/notification-service.ts) to import the file upload utilities:
|
|
1522
|
+
|
|
1523
|
+
```typescript
|
|
1524
|
+
import type { Media } from '@medplum/fhirtypes';
|
|
1525
|
+
import { getBinaryFromMedia } from './file-upload';
|
|
1526
|
+
```
|
|
1527
|
+
|
|
1528
|
+
Then add the `convertMediaToAttachment` function after the `getUserById` function:
|
|
1529
|
+
|
|
1530
|
+
```typescript
|
|
1531
|
+
/**
|
|
1532
|
+
* Converts a Media resource to VintaSend attachment format.
|
|
1533
|
+
*
|
|
1534
|
+
* Fetches the Binary resource referenced by the Media and extracts the file data,
|
|
1535
|
+
* then returns it in the format expected by VintaSend for email attachments.
|
|
1536
|
+
*
|
|
1537
|
+
* @param medplum - The Medplum client instance
|
|
1538
|
+
* @param media - The Media resource containing the file metadata
|
|
1539
|
+
* @returns A NotificationAttachmentUpload object with file, filename, and contentType
|
|
1540
|
+
*
|
|
1541
|
+
* @example
|
|
1542
|
+
* const attachment = await convertMediaToAttachment(medplum, media);
|
|
1543
|
+
* // { file: Buffer, filename: 'document.pdf', contentType: 'application/pdf' }
|
|
1544
|
+
*/
|
|
1545
|
+
export async function convertMediaToAttachment(
|
|
1546
|
+
medplum: MedplumClient,
|
|
1547
|
+
media: Media
|
|
1548
|
+
): Promise<{
|
|
1549
|
+
file: Buffer;
|
|
1550
|
+
filename: string;
|
|
1551
|
+
contentType: string;
|
|
1552
|
+
} | null> {
|
|
1553
|
+
try {
|
|
1554
|
+
// Fetch Binary resource from media.content.url
|
|
1555
|
+
const binary = await getBinaryFromMedia(medplum, media);
|
|
1556
|
+
|
|
1557
|
+
if (!binary) {
|
|
1558
|
+
console.error('[convertMediaToAttachment] Failed to fetch Binary resource for Media:', media.id);
|
|
1559
|
+
return null;
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
// Extract file data - Binary.data is base64-encoded
|
|
1563
|
+
let file: Buffer;
|
|
1564
|
+
if (binary.data) {
|
|
1565
|
+
// If data is embedded in the Binary resource as base64
|
|
1566
|
+
file = Buffer.from(binary.data, 'base64');
|
|
1567
|
+
} else {
|
|
1568
|
+
// If Binary is stored externally, we need to fetch it via URL
|
|
1569
|
+
// This is handled by getBinaryFromMedia
|
|
1570
|
+
console.error('[convertMediaToAttachment] Binary resource has no data:', binary.id);
|
|
1571
|
+
return null;
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
// Return in VintaSend NotificationAttachmentUpload format
|
|
1575
|
+
return {
|
|
1576
|
+
file,
|
|
1577
|
+
filename: media.content?.title || 'attachment',
|
|
1578
|
+
contentType: media.content?.contentType || 'application/octet-stream',
|
|
1579
|
+
};
|
|
1580
|
+
} catch (error) {
|
|
1581
|
+
console.error('[convertMediaToAttachment] Error converting Media to attachment:', error);
|
|
1582
|
+
return null;
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
```
|
|
1586
|
+
|
|
1587
|
+
**How it works:**
|
|
1588
|
+
|
|
1589
|
+
1. **Fetch Binary**: Retrieves the Binary resource from the Media reference
|
|
1590
|
+
2. **Extract Content**: Decodes the base64-encoded file data into a Buffer
|
|
1591
|
+
3. **Format for VintaSend**: Returns a `NotificationAttachmentUpload` object with `file`, `filename`, and `contentType`
|
|
1592
|
+
4. **Error Handling**: Returns `null` if conversion fails (which we filter out later)
|
|
1593
|
+
|
|
1594
|
+
Also update the `TaskAssignmentContextGenerator` to accept and return `attachmentCount`:
|
|
1595
|
+
|
|
1596
|
+
```typescript
|
|
1597
|
+
class TaskAssignmentContextGenerator implements ContextGenerator {
|
|
1598
|
+
async generate({
|
|
1599
|
+
userId,
|
|
1600
|
+
taskTitle,
|
|
1601
|
+
taskDescription,
|
|
1602
|
+
taskIsUrgent,
|
|
1603
|
+
taskLink,
|
|
1604
|
+
requesterName,
|
|
1605
|
+
attachmentCount,
|
|
1606
|
+
}: {
|
|
1607
|
+
userId: string;
|
|
1608
|
+
taskTitle: string;
|
|
1609
|
+
taskDescription: string;
|
|
1610
|
+
taskIsUrgent: boolean;
|
|
1611
|
+
taskLink: string;
|
|
1612
|
+
requesterName: string;
|
|
1613
|
+
attachmentCount?: number;
|
|
1614
|
+
}): Promise<{
|
|
1615
|
+
firstName: string;
|
|
1616
|
+
taskTitle: string;
|
|
1617
|
+
taskDescription: string;
|
|
1618
|
+
taskIsUrgent: boolean;
|
|
1619
|
+
taskLink: string;
|
|
1620
|
+
requesterName: string;
|
|
1621
|
+
attachmentCount: number;
|
|
1622
|
+
}> {
|
|
1623
|
+
const medplum = MedplumSingleton.getInstance();
|
|
1624
|
+
const user = await getUserById(medplum, userId);
|
|
1625
|
+
const firstName = formatPatientNameWithPreferredName(user.name?.[0]) ?? 'Practitioner';
|
|
1626
|
+
|
|
1627
|
+
return {
|
|
1628
|
+
firstName,
|
|
1629
|
+
taskTitle,
|
|
1630
|
+
taskDescription,
|
|
1631
|
+
taskIsUrgent,
|
|
1632
|
+
taskLink,
|
|
1633
|
+
requesterName,
|
|
1634
|
+
attachmentCount: attachmentCount || 0,
|
|
1635
|
+
};
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
```
|
|
1639
|
+
|
|
1640
|
+
#### 5.2: Update Task Assignment Email Service
|
|
1641
|
+
|
|
1642
|
+
Now let's update the task assignment email service to retrieve attachments from tasks and include them in the email notification.
|
|
1643
|
+
|
|
1644
|
+
Update [bots/services/emails/send-task-assignment-email.ts](bots/services/emails/send-task-assignment-email.ts):
|
|
1645
|
+
|
|
1646
|
+
```typescript
|
|
1647
|
+
import { MedplumClient } from '@medplum/core';
|
|
1648
|
+
import { Task } from '@medplum/fhirtypes';
|
|
1649
|
+
import { MedplumSingleton } from '../../../lib/medplum-singleton';
|
|
1650
|
+
import { getNotificationService, SendGridConfig, convertMediaToAttachment } from '../../../lib/notification-service';
|
|
1651
|
+
import { formatPatientNameWithPreferredName } from '../../../lib/patients';
|
|
1652
|
+
import { getTaskAttachments } from '../../../lib/file-upload';
|
|
1653
|
+
```
|
|
1654
|
+
|
|
1655
|
+
Then update the notification creation logic to include attachments:
|
|
1656
|
+
|
|
1657
|
+
```typescript
|
|
1658
|
+
// Retrieve task attachments
|
|
1659
|
+
const taskAttachments = await getTaskAttachments(medplum, task);
|
|
1660
|
+
|
|
1661
|
+
console.log(`[sendTaskAssignmentEmail] Found ${taskAttachments.length} attachments for task ${task.id}`);
|
|
1662
|
+
|
|
1663
|
+
// Convert to VintaSend attachment format
|
|
1664
|
+
const attachmentPromises = taskAttachments.map((media) => convertMediaToAttachment(medplum, media));
|
|
1665
|
+
const attachmentResults = await Promise.all(attachmentPromises);
|
|
1666
|
+
|
|
1667
|
+
// Filter out null values (failed conversions)
|
|
1668
|
+
const attachments = attachmentResults.filter((attachment): attachment is NonNullable<typeof attachment> =>
|
|
1669
|
+
attachment !== null
|
|
1670
|
+
);
|
|
1671
|
+
|
|
1672
|
+
console.log(`[sendTaskAssignmentEmail] Successfully converted ${attachments.length} attachments`);
|
|
1673
|
+
|
|
1674
|
+
await vintasend.createNotification({
|
|
1675
|
+
userId: referenceString,
|
|
1676
|
+
notificationType: 'EMAIL' as const,
|
|
1677
|
+
title: 'Task Assignment',
|
|
1678
|
+
contextName: 'taskAssignment' as const,
|
|
1679
|
+
contextParameters: {
|
|
1680
|
+
userId: referenceString,
|
|
1681
|
+
taskTitle,
|
|
1682
|
+
taskDescription: task.description || '',
|
|
1683
|
+
taskIsUrgent,
|
|
1684
|
+
taskLink,
|
|
1685
|
+
requesterName,
|
|
1686
|
+
attachmentCount: attachments.length, // NEW: Pass attachment count to template
|
|
1687
|
+
},
|
|
1688
|
+
sendAfter: new Date(),
|
|
1689
|
+
bodyTemplate: 'emails/task-assignment/body.html.pug',
|
|
1690
|
+
subjectTemplate: 'emails/task-assignment/subject.txt.pug',
|
|
1691
|
+
attachments, // NEW: Add attachments to the notification
|
|
1692
|
+
extraParams: {},
|
|
1693
|
+
});
|
|
1694
|
+
```
|
|
1695
|
+
|
|
1696
|
+
**Key changes:**
|
|
1697
|
+
|
|
1698
|
+
1. **Retrieve Attachments**: Use `getTaskAttachments` to get all Media resources from `task.input`
|
|
1699
|
+
2. **Convert to VintaSend Format**: Map each Media to an attachment object with `convertMediaToAttachment`
|
|
1700
|
+
3. **Filter Failures**: Remove any null results from failed conversions
|
|
1701
|
+
4. **Pass to VintaSend**: Include `attachments` array and `attachmentCount` in notification
|
|
1702
|
+
5. **Logging**: Added console logs for debugging attachment processing
|
|
1703
|
+
|
|
1704
|
+
#### 5.3: Update Email Template
|
|
1705
|
+
|
|
1706
|
+
Finally, let's update the email template to inform users when attachments are included.
|
|
1707
|
+
|
|
1708
|
+
Update [notification-templates/emails/task-assignment/body.html.pug](notification-templates/emails/task-assignment/body.html.pug):
|
|
1709
|
+
|
|
1710
|
+
```pug
|
|
1711
|
+
doctype html
|
|
1712
|
+
html
|
|
1713
|
+
head
|
|
1714
|
+
meta(charset='utf-8')
|
|
1715
|
+
style.
|
|
1716
|
+
body {
|
|
1717
|
+
white-space: pre-line;
|
|
1718
|
+
}
|
|
1719
|
+
body
|
|
1720
|
+
h1 Task Assigned
|
|
1721
|
+
|
|
1722
|
+
p Hello #{firstName},
|
|
1723
|
+
|
|
1724
|
+
p You have been assigned a new task by #{requesterName}.
|
|
1725
|
+
|
|
1726
|
+
p
|
|
1727
|
+
strong Task:
|
|
1728
|
+
| #{taskTitle}
|
|
1729
|
+
if taskDescription
|
|
1730
|
+
p
|
|
1731
|
+
strong Description:
|
|
1732
|
+
| #{taskDescription}
|
|
1733
|
+
|
|
1734
|
+
if attachmentCount > 0
|
|
1735
|
+
p
|
|
1736
|
+
strong Attachments:
|
|
1737
|
+
| #{attachmentCount} file(s) attached
|
|
1738
|
+
p Files are attached to this email for your reference.
|
|
1739
|
+
|
|
1740
|
+
if taskIsUrgent
|
|
1741
|
+
p
|
|
1742
|
+
strong URGENT
|
|
1743
|
+
|
|
1744
|
+
p
|
|
1745
|
+
a(href=taskLink) View Task
|
|
1746
|
+
|
|
1747
|
+
p Please review the task details and take appropriate action.
|
|
1748
|
+
```
|
|
1749
|
+
|
|
1750
|
+
**Template changes:**
|
|
1751
|
+
|
|
1752
|
+
- **Conditional Display**: Only show attachment info when `attachmentCount > 0`
|
|
1753
|
+
- **Count Display**: Shows how many files are attached
|
|
1754
|
+
- **User Guidance**: Informs users that files are attached to the email
|
|
1755
|
+
|
|
1756
|
+
### How It Works: The Complete Email Attachment Flow
|
|
1757
|
+
|
|
1758
|
+
```
|
|
1759
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
1760
|
+
│ 1. Task created with attachments via UI │
|
|
1761
|
+
│ - Files stored as Binary resources │
|
|
1762
|
+
│ - Media resources reference Binaries │
|
|
1763
|
+
│ - Task.input array contains Media references │
|
|
1764
|
+
└────────────────────┬────────────────────────────────────────┘
|
|
1765
|
+
│
|
|
1766
|
+
▼
|
|
1767
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
1768
|
+
│ 2. Task assignment bot triggered │
|
|
1769
|
+
│ - Bot detects task.owner assignment │
|
|
1770
|
+
│ - Calls sendTaskAssignmentEmail() │
|
|
1771
|
+
└────────────────────┬────────────────────────────────────────┘
|
|
1772
|
+
│
|
|
1773
|
+
▼
|
|
1774
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
1775
|
+
│ 3. Retrieve attachments from task │
|
|
1776
|
+
│ - getTaskAttachments() filters task.input │
|
|
1777
|
+
│ - Fetches all Media resources with type 'attachment' │
|
|
1778
|
+
└────────────────────┬────────────────────────────────────────┘
|
|
1779
|
+
│
|
|
1780
|
+
▼
|
|
1781
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
1782
|
+
│ 4. Convert Media to VintaSend attachment format │
|
|
1783
|
+
│ - convertMediaToAttachment() for each Media │
|
|
1784
|
+
│ - Fetches Binary resource and extracts file data │
|
|
1785
|
+
│ - Converts base64 to Buffer │
|
|
1786
|
+
│ - Returns { filename, content, contentType } │
|
|
1787
|
+
└────────────────────┬────────────────────────────────────────┘
|
|
1788
|
+
│
|
|
1789
|
+
▼
|
|
1790
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
1791
|
+
│ 5. VintaSend processes attachments │
|
|
1792
|
+
│ - MedplumAttachmentManager handles deduplication │
|
|
1793
|
+
│ - Stores attachments as Binary resources (if not exists) │
|
|
1794
|
+
│ - Links to Communication resource │
|
|
1795
|
+
└────────────────────┬────────────────────────────────────────┘
|
|
1796
|
+
│
|
|
1797
|
+
▼
|
|
1798
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
1799
|
+
│ 6. Email adapter attaches files │
|
|
1800
|
+
│ - SendGrid adapter receives attachment data │
|
|
1801
|
+
│ - Converts to SendGrid attachment format │
|
|
1802
|
+
│ - Includes in email payload │
|
|
1803
|
+
└────────────────────┬────────────────────────────────────────┘
|
|
1804
|
+
│
|
|
1805
|
+
▼
|
|
1806
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
1807
|
+
│ 7. Email sent with attachments │
|
|
1808
|
+
│ - Recipient receives email with files attached │
|
|
1809
|
+
│ - Template shows attachment count │
|
|
1810
|
+
│ - Files ready to download from email │
|
|
1811
|
+
└─────────────────────────────────────────────────────────────┘
|
|
1812
|
+
```
|
|
1813
|
+
|
|
1814
|
+
### Testing Email Attachments
|
|
1815
|
+
|
|
1816
|
+
After recompiling your templates and deploying your bots, test the attachment functionality:
|
|
1817
|
+
|
|
1818
|
+
1. **Compile templates:**
|
|
1819
|
+
```bash
|
|
1820
|
+
npm run compile-templates
|
|
1821
|
+
```
|
|
1822
|
+
|
|
1823
|
+
2. **Deploy bots:**
|
|
1824
|
+
```bash
|
|
1825
|
+
npm run bots:deploy
|
|
1826
|
+
```
|
|
1827
|
+
|
|
1828
|
+
3. **Create a task with attachments:**
|
|
1829
|
+
- Upload a PDF file when creating a task
|
|
1830
|
+
- Assign the task to a practitioner
|
|
1831
|
+
- Check the practitioner's email
|
|
1832
|
+
|
|
1833
|
+
4. **Verify the email:**
|
|
1834
|
+
- Email should show "1 file(s) attached"
|
|
1835
|
+
- Email should have the PDF attached
|
|
1836
|
+
- Attachment should be downloadable
|
|
1837
|
+
|
|
1838
|
+
5. **Test multiple attachments:**
|
|
1839
|
+
- Upload 2-3 files to a task
|
|
1840
|
+
- Verify all files are attached to the email
|
|
1841
|
+
|
|
1842
|
+
6. **Test without attachments:**
|
|
1843
|
+
- Create a task without files
|
|
1844
|
+
- Email should not mention attachments
|
|
1845
|
+
- Email should send normally
|
|
1846
|
+
|
|
1847
|
+
### Benefits of This Approach
|
|
1848
|
+
|
|
1849
|
+
**✅ FHIR-Compliant Storage**
|
|
1850
|
+
- All files stored as proper FHIR Binary/Media resources
|
|
1851
|
+
- Full audit trail through FHIR resource history
|
|
1852
|
+
- Compatible with existing Medplum security and access controls
|
|
1853
|
+
|
|
1854
|
+
**✅ Automatic Deduplication**
|
|
1855
|
+
- VintaSend's MedplumAttachmentManager prevents duplicate storage
|
|
1856
|
+
- Same file attached to multiple emails only stored once
|
|
1857
|
+
- Uses checksums to identify identical files
|
|
1858
|
+
|
|
1859
|
+
**✅ Provider-Agnostic**
|
|
1860
|
+
- Attachment handling abstracted from email provider
|
|
1861
|
+
- Easy to switch from SendGrid to AWS SES or other providers
|
|
1862
|
+
- No provider-specific code in your application logic
|
|
1863
|
+
|
|
1864
|
+
**✅ Type-Safe Implementation**
|
|
1865
|
+
- TypeScript ensures correct file handling
|
|
1866
|
+
- Compile-time checks for attachment structure
|
|
1867
|
+
- IDE autocomplete for attachment properties
|
|
1868
|
+
|
|
1869
|
+
**✅ Robust Error Handling**
|
|
1870
|
+
- Failed attachment conversions don't break email sending
|
|
1871
|
+
- Logging at each step for debugging
|
|
1872
|
+
- Graceful degradation (email sends without failed attachments)
|
|
1873
|
+
|
|
1874
|
+
### Common Issues and Solutions
|
|
1875
|
+
|
|
1876
|
+
**Issue: Attachments not appearing in emails**
|
|
1877
|
+
|
|
1878
|
+
Solution: Check the logs for attachment conversion errors. Common causes:
|
|
1879
|
+
- Binary resource not found (invalid Media reference)
|
|
1880
|
+
- Binary has no data field (external storage not configured)
|
|
1881
|
+
- File conversion failed (corrupt file or unsupported format)
|
|
1882
|
+
|
|
1883
|
+
**Issue: Email fails to send with large attachments**
|
|
1884
|
+
|
|
1885
|
+
Solution: SendGrid has a 30MB total attachment limit per email:
|
|
1886
|
+
- Reduce MAX_ATTACHMENT_SIZE to prevent individual files being too large
|
|
1887
|
+
- Consider adding validation for total attachment size
|
|
1888
|
+
- For large files, include download links instead of attaching
|
|
1889
|
+
|
|
1890
|
+
**Issue: Attachment filename shows as "attachment"**
|
|
1891
|
+
|
|
1892
|
+
Solution: Ensure Media.content.title is set during upload:
|
|
1893
|
+
```typescript
|
|
1894
|
+
const media = await medplum.createResource<Media>({
|
|
1895
|
+
resourceType: 'Media',
|
|
1896
|
+
status: 'completed',
|
|
1897
|
+
content: {
|
|
1898
|
+
contentType,
|
|
1899
|
+
url: `Binary/${binary.id}`,
|
|
1900
|
+
title: filename, // ← Make sure this is set
|
|
1901
|
+
},
|
|
1902
|
+
});
|
|
1903
|
+
```
|
|
1904
|
+
|
|
1905
|
+
### Summary
|
|
1906
|
+
|
|
1907
|
+
Phase 3 complete! You now have:
|
|
1908
|
+
- ✅ Attachment conversion from FHIR Media/Binary to VintaSend format
|
|
1909
|
+
- ✅ Automatic attachment retrieval for task assignments
|
|
1910
|
+
- ✅ Email templates showing attachment information
|
|
1911
|
+
- ✅ End-to-end file attachment workflow
|
|
1912
|
+
|
|
1913
|
+
Files are now automatically attached to task assignment emails, providing recipients with all necessary context and documentation directly in their inbox.
|
|
1914
|
+
|
|
1915
|
+
---
|
|
1916
|
+
|
|
1917
|
+
## Advanced: Scheduled Notifications with Task Due Soon Reminders
|
|
1918
|
+
|
|
1919
|
+
One of VintaSend's most powerful features is the ability to schedule notifications for future delivery. Instead of sending an email immediately, you can specify a `sendAfter` date and VintaSend will automatically send the notification at the right time.
|
|
1920
|
+
|
|
1921
|
+
In this section, we'll build a task reminder system that:
|
|
1922
|
+
- ✅ Periodically checks for tasks due in 24 hours
|
|
1923
|
+
- ✅ Schedules reminder emails to be sent exactly 24 hours before the due date
|
|
1924
|
+
- ✅ Fetches fresh data at send-time (not when scheduled)
|
|
1925
|
+
- ✅ Processes pending notifications automatically
|
|
1926
|
+
|
|
1927
|
+
### Why Use Scheduled Notifications?
|
|
1928
|
+
|
|
1929
|
+
**📅 Fresh Data at Send Time**
|
|
1930
|
+
- Context is fetched when the notification is sent, not when it's scheduled
|
|
1931
|
+
- If a user's name or task details change, the email will use the latest information
|
|
1932
|
+
- No stale data issues
|
|
1933
|
+
|
|
1934
|
+
**⏰ Scheduled Delivery**
|
|
1935
|
+
- Send reminders at scheduled times (24 hours before, 1 week before, etc.)
|
|
1936
|
+
- Notifications sent within 5 minutes of the scheduled time (based on cron frequency)
|
|
1937
|
+
- No need to manually track when to send each notification
|
|
1938
|
+
|
|
1939
|
+
**📋 Audit Trail**
|
|
1940
|
+
- All notifications stored as FHIR `Communication` resources
|
|
1941
|
+
- Track status changes (pending → sent/failed)
|
|
1942
|
+
- Full history of scheduled and sent notifications
|
|
1943
|
+
|
|
1944
|
+
### Step 1: Create Task Due Soon Templates
|
|
1945
|
+
|
|
1946
|
+
First, let's create email templates for our task reminder notifications.
|
|
1947
|
+
|
|
1948
|
+
#### Email Body Template
|
|
1949
|
+
|
|
1950
|
+
Create [notification-templates/emails/task-due-soon/body.html.pug](notification-templates/emails/task-due-soon/body.html.pug):
|
|
1951
|
+
|
|
1952
|
+
```pug
|
|
1953
|
+
doctype html
|
|
1954
|
+
html
|
|
1955
|
+
head
|
|
1956
|
+
meta(charset='utf-8')
|
|
1957
|
+
style.
|
|
1958
|
+
body {
|
|
1959
|
+
white-space: pre-line;
|
|
1960
|
+
}
|
|
1961
|
+
body
|
|
1962
|
+
h1 Task Due Reminder
|
|
1963
|
+
|
|
1964
|
+
p Hello #{firstName},
|
|
1965
|
+
|
|
1966
|
+
p This is a reminder that you have a task that is due in approximately 24 hours.
|
|
1967
|
+
|
|
1968
|
+
p
|
|
1969
|
+
strong Task:
|
|
1970
|
+
| #{taskTitle}
|
|
1971
|
+
if taskDescription
|
|
1972
|
+
p
|
|
1973
|
+
strong Description:
|
|
1974
|
+
| #{taskDescription}
|
|
1975
|
+
p
|
|
1976
|
+
strong Due Date:
|
|
1977
|
+
| #{dueDate}
|
|
1978
|
+
if taskIsUrgent
|
|
1979
|
+
p
|
|
1980
|
+
strong URGENT
|
|
1981
|
+
|
|
1982
|
+
p
|
|
1983
|
+
a(href=taskLink) View Task
|
|
1984
|
+
|
|
1985
|
+
p Please make sure to complete this task before the due date.
|
|
1986
|
+
```
|
|
1987
|
+
|
|
1988
|
+
#### Email Subject Template
|
|
1989
|
+
|
|
1990
|
+
Create [notification-templates/emails/task-due-soon/subject.txt.pug](notification-templates/emails/task-due-soon/subject.txt.pug):
|
|
1991
|
+
|
|
1992
|
+
```pug
|
|
1993
|
+
if taskIsUrgent
|
|
1994
|
+
| [URGENT] Task due soon: #{taskTitle}
|
|
1995
|
+
else
|
|
1996
|
+
| Reminder: Task due soon - #{taskTitle}
|
|
1997
|
+
```
|
|
1998
|
+
|
|
1999
|
+
### Step 2: Add Context Generator for Task Due Soon
|
|
2000
|
+
|
|
2001
|
+
Update [lib/notification-service.ts](lib/notification-service.ts) to include a new context generator for task due reminders:
|
|
2002
|
+
|
|
2003
|
+
```typescript
|
|
2004
|
+
class TaskDueSoonContextGenerator implements ContextGenerator {
|
|
2005
|
+
async generate({
|
|
2006
|
+
userId,
|
|
2007
|
+
taskTitle,
|
|
2008
|
+
taskDescription,
|
|
2009
|
+
taskIsUrgent,
|
|
2010
|
+
taskLink,
|
|
2011
|
+
dueDate,
|
|
2012
|
+
}: {
|
|
2013
|
+
userId: string;
|
|
2014
|
+
taskTitle: string;
|
|
2015
|
+
taskDescription: string;
|
|
2016
|
+
taskIsUrgent: boolean;
|
|
2017
|
+
taskLink: string;
|
|
2018
|
+
dueDate: string;
|
|
2019
|
+
}): Promise<{
|
|
2020
|
+
firstName: string;
|
|
2021
|
+
taskTitle: string;
|
|
2022
|
+
taskDescription: string;
|
|
2023
|
+
taskIsUrgent: boolean;
|
|
2024
|
+
taskLink: string;
|
|
2025
|
+
dueDate: string;
|
|
2026
|
+
}> {
|
|
2027
|
+
const medplum = MedplumSingleton.getInstance();
|
|
2028
|
+
const user = await getUserById(medplum, userId);
|
|
2029
|
+
const firstName = formatPatientNameWithPreferredName(user.name?.[0]) ?? 'Practitioner';
|
|
2030
|
+
|
|
2031
|
+
return {
|
|
2032
|
+
firstName,
|
|
2033
|
+
taskTitle,
|
|
2034
|
+
taskDescription,
|
|
2035
|
+
taskIsUrgent,
|
|
2036
|
+
taskLink,
|
|
2037
|
+
dueDate,
|
|
2038
|
+
};
|
|
2039
|
+
}
|
|
2040
|
+
}
|
|
2041
|
+
```
|
|
2042
|
+
|
|
2043
|
+
Then add it to the context map:
|
|
2044
|
+
|
|
2045
|
+
```typescript
|
|
2046
|
+
export const contextGeneratorsMap = {
|
|
2047
|
+
taskAssignment: new TaskAssignmentContextGenerator(),
|
|
2048
|
+
taskDueSoon: new TaskDueSoonContextGenerator(),
|
|
2049
|
+
// Add more context generators here for other notification types
|
|
2050
|
+
} as const;
|
|
2051
|
+
```
|
|
2052
|
+
|
|
2053
|
+
### Step 3: Create the Task Due Soon Email Service
|
|
2054
|
+
|
|
2055
|
+
Create [bots/services/emails/schedule-task-due-soon-email.ts](bots/services/emails/schedule-task-due-soon-email.ts):
|
|
2056
|
+
|
|
2057
|
+
```typescript
|
|
2058
|
+
import { MedplumClient } from '@medplum/core';
|
|
2059
|
+
import { Task } from '@medplum/fhirtypes';
|
|
2060
|
+
import { MedplumSingleton } from '../../../lib/medplum-singleton';
|
|
2061
|
+
import { getNotificationService, SendGridConfig } from '../../../lib/notification-service';
|
|
2062
|
+
import {
|
|
2063
|
+
assertTaskOwnerReference,
|
|
2064
|
+
getValidTaskDueDate,
|
|
2065
|
+
parseOwnerReference,
|
|
2066
|
+
computeReminderTime,
|
|
2067
|
+
} from '../../shared/task-due-soon-helpers';
|
|
2068
|
+
|
|
2069
|
+
export async function scheduleTaskDueSoonEmail(
|
|
2070
|
+
medplum: MedplumClient,
|
|
2071
|
+
task: Task,
|
|
2072
|
+
taskLinkBaseUrl: string,
|
|
2073
|
+
sendgridConfig: SendGridConfig
|
|
2074
|
+
) {
|
|
2075
|
+
/* sends a task due soon reminder email to a practitioner 24 hours before the task is due */
|
|
2076
|
+
|
|
2077
|
+
const ownerRef = assertTaskOwnerReference(task);
|
|
2078
|
+
const parsedOwner = parseOwnerReference(ownerRef);
|
|
2079
|
+
if (!parsedOwner) {
|
|
2080
|
+
return;
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2083
|
+
const dueDate = getValidTaskDueDate(task);
|
|
2084
|
+
|
|
2085
|
+
if (!task.id) {
|
|
2086
|
+
// eslint-disable-next-line no-console
|
|
2087
|
+
console.error('[scheduleTaskDueSoonEmail] Task has no id');
|
|
2088
|
+
throw new Error('Task must have an id to send task due soon email');
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
const sendAfter = computeReminderTime(dueDate, 24);
|
|
2092
|
+
if (!sendAfter) {
|
|
2093
|
+
return;
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
MedplumSingleton.setInstance(medplum);
|
|
2097
|
+
const vintasend = getNotificationService(medplum, sendgridConfig);
|
|
2098
|
+
|
|
2099
|
+
const taskTitle = task.code?.text || task.description || 'Task';
|
|
2100
|
+
const taskLink = `${taskLinkBaseUrl}/Task/${task.id}`;
|
|
2101
|
+
const taskIsUrgent = task.priority === 'urgent';
|
|
2102
|
+
const formattedDueDate = dueDate.toLocaleString('en-US', {
|
|
2103
|
+
weekday: 'long',
|
|
2104
|
+
year: 'numeric',
|
|
2105
|
+
month: 'long',
|
|
2106
|
+
day: 'numeric',
|
|
2107
|
+
hour: '2-digit',
|
|
2108
|
+
minute: '2-digit',
|
|
2109
|
+
});
|
|
2110
|
+
|
|
2111
|
+
try {
|
|
2112
|
+
await vintasend.createNotification({
|
|
2113
|
+
userId: ownerRef,
|
|
2114
|
+
notificationType: 'EMAIL' as const,
|
|
2115
|
+
title: 'Task Due Soon Reminder',
|
|
2116
|
+
contextName: 'taskDueSoon' as const,
|
|
2117
|
+
contextParameters: {
|
|
2118
|
+
userId: ownerRef,
|
|
2119
|
+
taskTitle,
|
|
2120
|
+
taskDescription: task.description || '',
|
|
2121
|
+
taskIsUrgent,
|
|
2122
|
+
taskLink,
|
|
2123
|
+
dueDate: formattedDueDate,
|
|
2124
|
+
},
|
|
2125
|
+
sendAfter,
|
|
2126
|
+
bodyTemplate: 'emails/task-due-soon/body.html.pug',
|
|
2127
|
+
subjectTemplate: 'emails/task-due-soon/subject.txt.pug',
|
|
2128
|
+
extraParams: {},
|
|
2129
|
+
});
|
|
2130
|
+
|
|
2131
|
+
// eslint-disable-next-line no-console
|
|
2132
|
+
console.log(
|
|
2133
|
+
`[scheduleTaskDueSoonEmail] Email scheduled for ${sendAfter.toISOString()} to: ${ownerRef} for task due on ${dueDate.toISOString()}`
|
|
2134
|
+
);
|
|
2135
|
+
} catch (error) {
|
|
2136
|
+
// eslint-disable-next-line no-console
|
|
2137
|
+
console.error('[scheduleTaskDueSoonEmail] Error creating/sending notification:', error);
|
|
2138
|
+
throw error;
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
```
|
|
2142
|
+
|
|
2143
|
+
**Key Points:**
|
|
2144
|
+
- Uses helper functions from `task-due-soon-helpers.ts` for validation and date calculations
|
|
2145
|
+
- `assertTaskOwnerReference` validates and returns the owner reference
|
|
2146
|
+
- `parseOwnerReference` validates the reference format and filters out Group assignments
|
|
2147
|
+
- `getValidTaskDueDate` validates the due date and returns a Date object
|
|
2148
|
+
- `computeReminderTime` calculates when to send the reminder (24 hours before) and validates it's in the future
|
|
2149
|
+
- The `sendAfter` parameter tells VintaSend when to send the notification
|
|
2150
|
+
- The notification is stored with status `pending` until `sendAfter` time
|
|
2151
|
+
- Context is fetched at send-time, not when scheduled
|
|
2152
|
+
|
|
2153
|
+
### Step 4: Create the Subscription Bot for Task Due Soon
|
|
2154
|
+
|
|
2155
|
+
Create [bots/handlers/task-due-soon-notification-bot.ts](bots/handlers/task-due-soon-notification-bot.ts):
|
|
2156
|
+
|
|
2157
|
+
```typescript
|
|
2158
|
+
import { BotEvent, MedplumClient } from '@medplum/core';
|
|
2159
|
+
import { Task } from '@medplum/fhirtypes';
|
|
2160
|
+
import { scheduleTaskDueSoonEmail } from '../services/emails/schedule-task-due-soon-email';
|
|
2161
|
+
import { buildSendGridConfig } from '../../lib/notification-service';
|
|
2162
|
+
import { getTaskDueSoonSchedulingReason } from '../shared/task-due-soon-helpers';
|
|
2163
|
+
|
|
2164
|
+
/**
|
|
2165
|
+
* Medplum Bot: Task Due Soon Notification
|
|
2166
|
+
*
|
|
2167
|
+
* This bot triggers on Task creation/update and schedules email notifications
|
|
2168
|
+
* to be sent 24 hours before the task due date.
|
|
2169
|
+
*
|
|
2170
|
+
* The bot uses VintaSend's scheduled messages (sendAfter) to ensure
|
|
2171
|
+
* notifications are sent at the appropriate time. The actual sending is
|
|
2172
|
+
* handled by the send-pending-notifications-bot.
|
|
2173
|
+
*
|
|
2174
|
+
* Subscription: Task (create/update)
|
|
2175
|
+
*/
|
|
2176
|
+
|
|
2177
|
+
export async function handler(medplum: MedplumClient, event: BotEvent): Promise<any> {
|
|
2178
|
+
const task = event.input as Task;
|
|
2179
|
+
const result = getTaskDueSoonSchedulingReason(task);
|
|
2180
|
+
|
|
2181
|
+
switch (result.kind) {
|
|
2182
|
+
case 'invalidResource':
|
|
2183
|
+
console.warn('[TaskDueSoonNotificationBot] Invalid task resource received');
|
|
2184
|
+
return { message: 'Invalid task resource' };
|
|
2185
|
+
case 'noDueDate':
|
|
2186
|
+
console.log(`[TaskDueSoonNotificationBot] Task ${task?.id} has no due date, skipping`);
|
|
2187
|
+
return { message: 'No due date set', taskId: task?.id };
|
|
2188
|
+
case 'invalidDueDate':
|
|
2189
|
+
console.warn(
|
|
2190
|
+
`[TaskDueSoonNotificationBot] Task ${task?.id} has invalid due date: ${result.dueDate}, skipping`
|
|
2191
|
+
);
|
|
2192
|
+
return { message: 'Invalid due date', taskId: task?.id, dueDate: result.dueDate };
|
|
2193
|
+
case 'finalState':
|
|
2194
|
+
console.log(
|
|
2195
|
+
`[TaskDueSoonNotificationBot] Task ${task?.id} is in final state (${result.status}), skipping`
|
|
2196
|
+
);
|
|
2197
|
+
return { message: `Task in final state: ${result.status}`, taskId: task?.id };
|
|
2198
|
+
case 'noOwner':
|
|
2199
|
+
console.log(`[TaskDueSoonNotificationBot] Task ${task?.id} has no owner, skipping`);
|
|
2200
|
+
return { message: 'No owner assigned', taskId: task?.id };
|
|
2201
|
+
case 'tooSoon':
|
|
2202
|
+
console.log(
|
|
2203
|
+
`[TaskDueSoonNotificationBot] Task ${task?.id} is due in ${result.hoursUntilDue.toFixed(
|
|
2204
|
+
2
|
|
2205
|
+
)} hours (less than 24), skipping`
|
|
2206
|
+
);
|
|
2207
|
+
return {
|
|
2208
|
+
message: 'Due date is less than 24 hours away',
|
|
2209
|
+
taskId: task?.id,
|
|
2210
|
+
hoursUntilDue: result.hoursUntilDue,
|
|
2211
|
+
};
|
|
2212
|
+
case 'ok':
|
|
2213
|
+
break;
|
|
2214
|
+
}
|
|
2215
|
+
|
|
2216
|
+
const appBaseUrl = process.env.APP_BASE_URL;
|
|
2217
|
+
if (!appBaseUrl) {
|
|
2218
|
+
console.error('[TaskDueSoonNotificationBot] APP_BASE_URL environment variable is not set');
|
|
2219
|
+
throw new Error('APP_BASE_URL must be configured');
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
const sendgridConfig = buildSendGridConfig(event);
|
|
2223
|
+
|
|
2224
|
+
// Check if task has a due date
|
|
2225
|
+
const dueDate = task.restriction?.period?.end;
|
|
2226
|
+
if (!dueDate) {
|
|
2227
|
+
console.log(`[TaskDueSoonNotificationBot] Task ${task.id} has no due date, skipping`);
|
|
2228
|
+
return { message: 'No due date set', taskId: task.id };
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
// Check if task is in a final state (completed, cancelled, etc.)
|
|
2232
|
+
const finalStates = ['completed', 'cancelled', 'failed', 'rejected', 'entered-in-error'];
|
|
2233
|
+
if (task.status && finalStates.includes(task.status)) {
|
|
2234
|
+
console.log(`[TaskDueSoonNotificationBot] Task ${task.id} is in final state (${task.status}), skipping`);
|
|
2235
|
+
return { message: `Task in final state: ${task.status}`, taskId: task.id };
|
|
2236
|
+
}
|
|
2237
|
+
|
|
2238
|
+
// Check if task has an owner
|
|
2239
|
+
if (!task.owner) {
|
|
2240
|
+
console.log(`[TaskDueSoonNotificationBot] Task ${task.id} has no owner, skipping`);
|
|
2241
|
+
return { message: 'No owner assigned', taskId: task.id };
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
// Calculate if the due date is more than 24 hours away
|
|
2245
|
+
const now = new Date();
|
|
2246
|
+
const dueDateTime = new Date(dueDate);
|
|
2247
|
+
const hoursUntilDue = (dueDateTime.getTime() - now.getTime()) / (1000 * 60 * 60);
|
|
2248
|
+
|
|
2249
|
+
if (hoursUntilDue < 24) {
|
|
2250
|
+
console.log(
|
|
2251
|
+
`[TaskDueSoonNotificationBot] Task ${task.id} is due in ${hoursUntilDue.toFixed(2)} hours (less than 24), skipping`
|
|
2252
|
+
);
|
|
2253
|
+
return { message: 'Due date is less than 24 hours away', taskId: task.id, hoursUntilDue };
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
try {
|
|
2257
|
+
// Schedule the notification to be sent 24 hours before the due date
|
|
2258
|
+
console.log(
|
|
2259
|
+
`[TaskDueSoonNotificationBot] Scheduling notification for task ${task.id}, due in ${hoursUntilDue.toFixed(
|
|
2260
|
+
2
|
|
2261
|
+
)} hours`
|
|
2262
|
+
);
|
|
2263
|
+
await scheduleTaskDueSoonEmail(medplum, task, appBaseUrl, sendgridConfig);
|
|
2264
|
+
|
|
2265
|
+
return {
|
|
2266
|
+
message: 'Notification scheduled successfully',
|
|
2267
|
+
taskId: task.id,
|
|
2268
|
+
dueDate: task.restriction?.period?.end,
|
|
2269
|
+
hoursUntilDue: result.hoursUntilDue.toFixed(2),
|
|
2270
|
+
};
|
|
2271
|
+
} catch (error) {
|
|
2272
|
+
console.error(`[TaskDueSoonNotificationBot] Error scheduling notification for task ${task.id}:`, error);
|
|
2273
|
+
throw error;
|
|
2274
|
+
}
|
|
2275
|
+
}
|
|
2276
|
+
```
|
|
2277
|
+
|
|
2278
|
+
**Key Differences from Task Assignment Bot:**
|
|
2279
|
+
- **Uses Helper Function**: Uses `getTaskDueSoonSchedulingReason` to consolidate validation logic
|
|
2280
|
+
- **Structured Validation**: All validation checks return specific error kinds via discriminated union
|
|
2281
|
+
- **Invalid Date Handling**: Detects and skips tasks with invalid due dates
|
|
2282
|
+
- **Required Configuration**: Throws if `APP_BASE_URL` is not configured (fails fast)
|
|
2283
|
+
- **Scheduling**: Uses `sendAfter` to schedule the notification for 24 hours before due date
|
|
2284
|
+
|
|
2285
|
+
### Step 5: Create the Send Pending Notifications Bot
|
|
2286
|
+
|
|
2287
|
+
VintaSend stores scheduled notifications with a `pending` status. We need a periodic bot to actually send them when their `sendAfter` time arrives.
|
|
2288
|
+
|
|
2289
|
+
Create [bots/handlers/send-pending-notifications-bot.ts](bots/handlers/send-pending-notifications-bot.ts):
|
|
2290
|
+
|
|
2291
|
+
```typescript
|
|
2292
|
+
import { BotEvent, MedplumClient } from '@medplum/core';
|
|
2293
|
+
import { MedplumSingleton } from '../../lib/medplum-singleton';
|
|
2294
|
+
import { getNotificationService, buildSendGridConfig } from '../../lib/notification-service';
|
|
2295
|
+
|
|
2296
|
+
/**
|
|
2297
|
+
* Medplum Bot: Send Pending Notifications
|
|
2298
|
+
*
|
|
2299
|
+
* This bot runs periodically (every 5 minutes) to process and send
|
|
2300
|
+
* all pending scheduled notifications that are due to be sent.
|
|
2301
|
+
*
|
|
2302
|
+
* It uses VintaSend's notification service to check for notifications
|
|
2303
|
+
* where sendAfter <= current time and triggers their delivery.
|
|
2304
|
+
*
|
|
2305
|
+
* Cron: */5 * * * * (every 5 minutes)
|
|
2306
|
+
*/
|
|
2307
|
+
|
|
2308
|
+
export async function handler(medplum: MedplumClient, event: BotEvent): Promise<any> {
|
|
2309
|
+
console.log('[SendPendingNotificationsBot] Starting to process pending notifications');
|
|
2310
|
+
|
|
2311
|
+
const sendgridConfig = buildSendGridConfig(event);
|
|
2312
|
+
|
|
2313
|
+
try {
|
|
2314
|
+
MedplumSingleton.setInstance(medplum);
|
|
2315
|
+
const vintasend = getNotificationService(medplum, sendgridConfig);
|
|
2316
|
+
|
|
2317
|
+
// Send all pending notifications that are ready to be sent
|
|
2318
|
+
const result = await vintasend.sendPendingNotifications();
|
|
2319
|
+
|
|
2320
|
+
console.log('[SendPendingNotificationsBot] Completed processing pending notifications');
|
|
2321
|
+
console.log('[SendPendingNotificationsBot] Result:', JSON.stringify(result, null, 2));
|
|
2322
|
+
|
|
2323
|
+
return {
|
|
2324
|
+
message: 'Pending notifications processed',
|
|
2325
|
+
result,
|
|
2326
|
+
};
|
|
2327
|
+
} catch (error) {
|
|
2328
|
+
console.error('[SendPendingNotificationsBot] Error processing pending notifications:', error);
|
|
2329
|
+
throw error;
|
|
2330
|
+
}
|
|
2331
|
+
}
|
|
2332
|
+
```
|
|
2333
|
+
|
|
2334
|
+
This bot:
|
|
2335
|
+
- Runs every 5 minutes via cron schedule
|
|
2336
|
+
- Calls `vintasend.sendPendingNotifications()` which:
|
|
2337
|
+
- Queries for all `Communication` resources with status `pending` and `sendAfter <= now`
|
|
2338
|
+
- Fetches fresh context data using the context generators
|
|
2339
|
+
- Renders templates with current data
|
|
2340
|
+
- Sends emails via SendGrid
|
|
2341
|
+
- Updates notification status to `sent` or `failed`
|
|
2342
|
+
|
|
2343
|
+
### Step 6: Configure Both Bots in the Deployment
|
|
2344
|
+
|
|
2345
|
+
Update [bots/index.ts](bots/index.ts) to include both new bots:
|
|
2346
|
+
|
|
2347
|
+
```typescript
|
|
2348
|
+
export const BOTS: BotDescription[] = [
|
|
2349
|
+
{
|
|
2350
|
+
name: 'send-task-assignment-email',
|
|
2351
|
+
needsAdminMembership: true,
|
|
2352
|
+
runAsUser: true,
|
|
2353
|
+
criteria: 'Task?owner:missing=false',
|
|
2354
|
+
extension: [
|
|
2355
|
+
{
|
|
2356
|
+
url: 'https://medplum.com/fhir/StructureDefinition/subscription-supported-interaction',
|
|
2357
|
+
valueCode: 'create',
|
|
2358
|
+
},
|
|
2359
|
+
{
|
|
2360
|
+
url: 'https://medplum.com/fhir/StructureDefinition/subscription-supported-interaction',
|
|
2361
|
+
valueCode: 'update',
|
|
2362
|
+
},
|
|
2363
|
+
],
|
|
2364
|
+
},
|
|
2365
|
+
{
|
|
2366
|
+
name: 'task-due-notification-bot',
|
|
2367
|
+
needsAdminMembership: true,
|
|
2368
|
+
runAsUser: true,
|
|
2369
|
+
criteria: 'Task?owner:missing=false',
|
|
2370
|
+
extension: [
|
|
2371
|
+
{
|
|
2372
|
+
url: 'https://medplum.com/fhir/StructureDefinition/subscription-supported-interaction',
|
|
2373
|
+
valueCode: 'create',
|
|
2374
|
+
},
|
|
2375
|
+
{
|
|
2376
|
+
url: 'https://medplum.com/fhir/StructureDefinition/subscription-supported-interaction',
|
|
2377
|
+
valueCode: 'update',
|
|
2378
|
+
},
|
|
2379
|
+
],
|
|
2380
|
+
},
|
|
2381
|
+
{
|
|
2382
|
+
name: 'send-pending-notifications-bot',
|
|
2383
|
+
needsAdminMembership: true,
|
|
2384
|
+
runAsUser: true,
|
|
2385
|
+
cronString: '*/5 * * * *', // Run every 5 minutes
|
|
2386
|
+
timeout: 300, // 5 minutes timeout
|
|
2387
|
+
},
|
|
2388
|
+
];
|
|
2389
|
+
```
|
|
2390
|
+
|
|
2391
|
+
### Step 7: Build and Deploy
|
|
2392
|
+
|
|
2393
|
+
Build and deploy your bots:
|
|
2394
|
+
|
|
2395
|
+
```bash
|
|
2396
|
+
# Compile templates and build bots
|
|
2397
|
+
npm run bots:build
|
|
2398
|
+
|
|
2399
|
+
# Deploy to Medplum
|
|
2400
|
+
npm run bots:deploy
|
|
2401
|
+
```
|
|
2402
|
+
|
|
2403
|
+
### How the Scheduled Notification Flow Works
|
|
2404
|
+
|
|
2405
|
+
Here's the complete lifecycle of a scheduled task reminder:
|
|
2406
|
+
|
|
2407
|
+
1. **Task Created/Updated with Due Date**
|
|
2408
|
+
- A Task is created or updated with `restriction.period.end` set to a future date
|
|
2409
|
+
- Task is assigned to a practitioner via `owner` reference
|
|
2410
|
+
- The subscription criteria `Task?owner:missing=false` matches this event
|
|
2411
|
+
|
|
2412
|
+
2. **Subscription Triggers Bot**
|
|
2413
|
+
- Medplum evaluates the subscription and triggers `task-due-notification-bot`
|
|
2414
|
+
- The bot receives the Task resource as `event.input`
|
|
2415
|
+
|
|
2416
|
+
3. **Validation**
|
|
2417
|
+
- Bot checks if task has a due date, owner, and is not in a final state
|
|
2418
|
+
- Calculates hours until due date
|
|
2419
|
+
- Only proceeds if task is due more than 24 hours from now
|
|
2420
|
+
|
|
2421
|
+
4. **Notification Scheduled**
|
|
2422
|
+
- Bot calls `scheduleTaskDueSoonEmail()`
|
|
2423
|
+
- `scheduleTaskDueSoonEmail()` calls `vintasend.createNotification()`
|
|
2424
|
+
- VintaSend creates a FHIR `Communication` resource with:
|
|
2425
|
+
- Status: `pending`
|
|
2426
|
+
- `sendAfter`: Set to 24 hours before task due date
|
|
2427
|
+
- Context parameters stored in the Communication resource
|
|
2428
|
+
|
|
2429
|
+
5. **Waiting Period**
|
|
2430
|
+
- Notification sits in the database with `pending` status
|
|
2431
|
+
- Task details, user data, etc. can change during this time
|
|
2432
|
+
|
|
2433
|
+
6. **Send Time Arrives**
|
|
2434
|
+
- `send-pending-notifications-bot` runs every 5 minutes via cron
|
|
2435
|
+
- Calls `vintasend.sendPendingNotifications()`
|
|
2436
|
+
- VintaSend finds all notifications where `sendAfter <= now` and status is `pending`
|
|
2437
|
+
|
|
2438
|
+
7. **Context Generation (Fresh Data!)**
|
|
2439
|
+
- For each pending notification, VintaSend calls the context generator
|
|
2440
|
+
- `TaskDueSoonContextGenerator.generate()` fetches current user data
|
|
2441
|
+
- If the user's name changed since scheduling, the new name is used
|
|
2442
|
+
- This ensures all data in the email is current
|
|
2443
|
+
|
|
2444
|
+
8. **Template Rendering**
|
|
2445
|
+
- Pug templates are rendered with fresh context
|
|
2446
|
+
- Email HTML and subject are generated
|
|
2447
|
+
|
|
2448
|
+
9. **Email Sent**
|
|
2449
|
+
- SendGrid adapter sends the email
|
|
2450
|
+
- `Communication` resource status updated to `sent`
|
|
2451
|
+
- Timestamp recorded in `sent` field
|
|
2452
|
+
|
|
2453
|
+
10. **Error Handling**
|
|
2454
|
+
- If sending fails, status is set to `failed`
|
|
2455
|
+
- Error details stored in the Communication resource
|
|
2456
|
+
- Failed notifications remain in the database for manual review or retry
|
|
2457
|
+
|
|
2458
|
+
### Benefits of This Approach
|
|
2459
|
+
|
|
2460
|
+
✅ **Always Current Data**: Context fetched at send-time, not schedule-time
|
|
2461
|
+
✅ **Event-Driven**: Notifications scheduled immediately when tasks are created/updated
|
|
2462
|
+
✅ **Efficient**: Only processes relevant tasks via subscription criteria, no unnecessary searches
|
|
2463
|
+
✅ **Scheduled Delivery**: Emails sent within 5 minutes of scheduled time (based on cron frequency)
|
|
2464
|
+
✅ **Audit Trail**: Every notification stored as a FHIR `Communication` resource
|
|
2465
|
+
✅ **Status Tracking**: Monitor pending, sent, and failed notifications
|
|
2466
|
+
✅ **Scalable**: Works with any number of scheduled notifications
|
|
2467
|
+
✅ **Flexible**: Easy to add more notification types (appointment reminders, etc.)
|
|
2468
|
+
✅ **No Duplicate Notifications**: Each task triggers the bot once per create/update event
|
|
2469
|
+
|
|
2470
|
+
### Testing Scheduled Notifications
|
|
2471
|
+
|
|
2472
|
+
To test the scheduled notification system:
|
|
2473
|
+
|
|
2474
|
+
1. **Create a Task with a Due Date**:
|
|
2475
|
+
```typescript
|
|
2476
|
+
const tomorrow = new Date();
|
|
2477
|
+
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
2478
|
+
tomorrow.setHours(14, 0, 0, 0); // 2 PM tomorrow
|
|
2479
|
+
|
|
2480
|
+
const task = await medplum.createResource({
|
|
2481
|
+
resourceType: 'Task',
|
|
2482
|
+
status: 'requested',
|
|
2483
|
+
intent: 'order',
|
|
2484
|
+
priority: 'routine',
|
|
2485
|
+
code: { text: 'Review lab results' },
|
|
2486
|
+
description: 'Review and approve patient lab results',
|
|
2487
|
+
owner: { reference: 'Practitioner/123' },
|
|
2488
|
+
restriction: {
|
|
2489
|
+
period: {
|
|
2490
|
+
end: tomorrow.toISOString(),
|
|
2491
|
+
},
|
|
2492
|
+
},
|
|
2493
|
+
});
|
|
2494
|
+
```
|
|
2495
|
+
|
|
2496
|
+
2. **Wait for the Periodic Bot to Run**:
|
|
2497
|
+
- Within 5 minutes, the `task-due-soon-notification-bot` should pick up the task
|
|
2498
|
+
- Check bot logs to confirm notification was scheduled
|
|
2499
|
+
|
|
2500
|
+
3. **Check the Communication Resource**:
|
|
2501
|
+
```typescript
|
|
2502
|
+
const communications = await medplum.search('Communication', {
|
|
2503
|
+
'status': 'pending',
|
|
2504
|
+
'subject': `Task/${task.id}`,
|
|
2505
|
+
});
|
|
2506
|
+
// Should show a pending notification with sendAfter timestamp
|
|
2507
|
+
```
|
|
2508
|
+
|
|
2509
|
+
4. **Wait for Send Time**:
|
|
2510
|
+
- The `send-pending-notifications-bot` will send it when `sendAfter` time arrives
|
|
2511
|
+
- Check the practitioner's email inbox
|
|
2512
|
+
- Communication status should change to `sent`
|
|
2513
|
+
|
|
2514
|
+
### Customizing Send Times
|
|
2515
|
+
|
|
2516
|
+
You can easily adjust when reminders are sent:
|
|
2517
|
+
|
|
2518
|
+
```typescript
|
|
2519
|
+
// Send 1 week before
|
|
2520
|
+
const sendAfter = new Date(dueDate.getTime() - 7 * 24 * 60 * 60 * 1000);
|
|
2521
|
+
|
|
2522
|
+
// Send 2 hours before
|
|
2523
|
+
const sendAfter = new Date(dueDate.getTime() - 2 * 60 * 60 * 1000);
|
|
2524
|
+
|
|
2525
|
+
// Send at a specific time on a specific date
|
|
2526
|
+
const sendAfter = new Date('2026-02-15T10:00:00Z');
|
|
2527
|
+
```
|
|
2528
|
+
|
|
2529
|
+
### Multiple Reminders for One Task
|
|
2530
|
+
|
|
2531
|
+
You can schedule multiple reminders for the same task:
|
|
2532
|
+
|
|
2533
|
+
```typescript
|
|
2534
|
+
// Send 1 week before
|
|
2535
|
+
await vintasend.createNotification({
|
|
2536
|
+
// ... notification config
|
|
2537
|
+
sendAfter: new Date(dueDate.getTime() - 7 * 24 * 60 * 60 * 1000),
|
|
2538
|
+
title: 'Task Due in 1 Week',
|
|
2539
|
+
});
|
|
2540
|
+
|
|
2541
|
+
// Send 1 day before
|
|
2542
|
+
await vintasend.createNotification({
|
|
2543
|
+
// ... notification config
|
|
2544
|
+
sendAfter: new Date(dueDate.getTime() - 24 * 60 * 60 * 1000),
|
|
2545
|
+
title: 'Task Due Tomorrow',
|
|
2546
|
+
});
|
|
2547
|
+
|
|
2548
|
+
// Send on due date
|
|
2549
|
+
await vintasend.createNotification({
|
|
2550
|
+
// ... notification config
|
|
2551
|
+
sendAfter: dueDate,
|
|
2552
|
+
title: 'Task Due Today',
|
|
2553
|
+
});
|
|
2554
|
+
```
|
|
2555
|
+
|
|
2556
|
+
## Next Steps
|
|
2557
|
+
|
|
2558
|
+
Now that you have both immediate and scheduled email notifications working, you can:
|
|
2559
|
+
|
|
2560
|
+
1. **Add More Notification Types**: Create context generators for appointment reminders, lab results, etc.
|
|
2561
|
+
2. **Add SMS Support**: VintaSend supports multiple channels
|
|
2562
|
+
3. **Customize Templates**: Add branding, better styling, or more dynamic content
|
|
2563
|
+
4. **Add Preferences**: Let users opt-in/out of certain notifications
|
|
2564
|
+
5. **Add Multiple Reminders**: Send notifications at different intervals (1 week, 1 day, 1 hour before)
|
|
2565
|
+
6. **Add Escalation**: Send reminders to supervisors if tasks remain incomplete
|
|
2566
|
+
|
|
2567
|
+
## Troubleshooting
|
|
2568
|
+
|
|
2569
|
+
**Templates not compiling?**
|
|
2570
|
+
- Ensure `vintasend-medplum` is installed
|
|
2571
|
+
- Check that template paths match exactly
|
|
2572
|
+
|
|
2573
|
+
**Emails not sending?**
|
|
2574
|
+
- Verify SendGrid API key is valid and has sending permissions
|
|
2575
|
+
- Check that `SENDGRID_FROM_EMAIL` is a verified sender in SendGrid
|
|
2576
|
+
- Review bot logs for SendGrid API errors
|
|
2577
|
+
- Ensure the recipient's FHIR resource has a valid `telecom` entry with email
|
|
2578
|
+
|
|
2579
|
+
**Context data missing?**
|
|
2580
|
+
- Verify the context generator is fetching data correctly
|
|
2581
|
+
- Check that FHIR resources have the expected fields
|
|
2582
|
+
|
|
2583
|
+
## Conclusion
|
|
2584
|
+
|
|
2585
|
+
You now have a robust, production-ready email notification system for your Medplum application! This architecture scales well as you add more notification types and channels.
|
|
2586
|
+
|
|
2587
|
+
The combination of VintaSend and Medplum provides a powerful foundation for healthcare communications that respect FHIR standards while remaining developer-friendly.
|
|
2588
|
+
|
|
2589
|
+
## Resources
|
|
2590
|
+
|
|
2591
|
+
- [VintaSend Medplum Example App](https://github.com/vintasoftware/vintasend-medplum-example)
|
|
2592
|
+
- [VintaSend Documentation](https://github.com/vintasoftware/vintasend)
|
|
2593
|
+
- [VintaSend-Medplum Documentation](https://github.com/vintasoftware/vintasend-medplum)
|
|
2594
|
+
- [Medplum Documentation](https://www.medplum.com/docs)
|
|
2595
|
+
- [Medplum Bots](https://www.medplum.com/docs/bots)
|
|
2596
|
+
- [FHIR Task Resource](https://www.hl7.org/fhir/task.html)
|