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.
Files changed (284) hide show
  1. package/README.md +5 -0
  2. package/dist/examples/vintasend-medplum-example/.env.example +11 -0
  3. package/dist/examples/vintasend-medplum-example/IMPLEMENTATION_PLAN_FILE_ATTACHMENTS.md +597 -0
  4. package/dist/examples/vintasend-medplum-example/README.md +190 -0
  5. package/dist/examples/vintasend-medplum-example/TUTORIAL_EMAIL_NOTIFICATIONS.md +2596 -0
  6. package/dist/examples/vintasend-medplum-example/bots/handlers/send-pending-notifications-bot.ts +39 -0
  7. package/dist/examples/vintasend-medplum-example/bots/handlers/send-task-assignment-email.ts +41 -0
  8. package/dist/examples/vintasend-medplum-example/bots/handlers/task-due-soon-notification-bot.ts +86 -0
  9. package/dist/examples/vintasend-medplum-example/bots/index.ts +53 -0
  10. package/dist/examples/vintasend-medplum-example/bots/services/emails/schedule-task-due-soon-email.ts +84 -0
  11. package/dist/examples/vintasend-medplum-example/bots/services/emails/send-task-assignment-email.test.ts +388 -0
  12. package/dist/examples/vintasend-medplum-example/bots/services/emails/send-task-assignment-email.ts +113 -0
  13. package/dist/examples/vintasend-medplum-example/bots/shared/task-due-soon-helpers.ts +115 -0
  14. package/dist/examples/vintasend-medplum-example/bots/task-assignment-bot.ts +41 -0
  15. package/dist/examples/vintasend-medplum-example/compiled-notification-templates.json +6 -0
  16. package/dist/examples/vintasend-medplum-example/esbuild-script.mjs +71 -0
  17. package/dist/examples/vintasend-medplum-example/index.html +14 -0
  18. package/dist/examples/vintasend-medplum-example/lib/constants.ts +32 -0
  19. package/dist/examples/vintasend-medplum-example/lib/extensions.ts +1 -0
  20. package/dist/examples/vintasend-medplum-example/lib/file-upload.test.ts +389 -0
  21. package/dist/examples/vintasend-medplum-example/lib/file-upload.ts +222 -0
  22. package/dist/examples/vintasend-medplum-example/lib/medplum-singleton.ts +18 -0
  23. package/dist/examples/vintasend-medplum-example/lib/notification-service.test.ts +293 -0
  24. package/dist/examples/vintasend-medplum-example/lib/notification-service.ts +284 -0
  25. package/dist/examples/vintasend-medplum-example/lib/patients.ts +20 -0
  26. package/dist/examples/vintasend-medplum-example/notification-templates/emails/task-assignment/body.html.pug +37 -0
  27. package/dist/examples/vintasend-medplum-example/notification-templates/emails/task-assignment/subject.txt.pug +4 -0
  28. package/dist/examples/vintasend-medplum-example/notification-templates/emails/task-due-soon/body.html.pug +34 -0
  29. package/dist/examples/vintasend-medplum-example/notification-templates/emails/task-due-soon/subject.txt.pug +4 -0
  30. package/dist/examples/vintasend-medplum-example/package.json +75 -0
  31. package/dist/examples/vintasend-medplum-example/plugins/gql-plugin.mjs +31 -0
  32. package/dist/examples/vintasend-medplum-example/postcss.config.mjs +21 -0
  33. package/dist/examples/vintasend-medplum-example/public/favicon.ico +0 -0
  34. package/dist/examples/vintasend-medplum-example/public/img/integrations/acuity.png +0 -0
  35. package/dist/examples/vintasend-medplum-example/public/img/integrations/auth0.png +0 -0
  36. package/dist/examples/vintasend-medplum-example/public/img/integrations/azure.png +0 -0
  37. package/dist/examples/vintasend-medplum-example/public/img/integrations/calcom.png +0 -0
  38. package/dist/examples/vintasend-medplum-example/public/img/integrations/candid.png +0 -0
  39. package/dist/examples/vintasend-medplum-example/public/img/integrations/claude.png +0 -0
  40. package/dist/examples/vintasend-medplum-example/public/img/integrations/datadog.png +0 -0
  41. package/dist/examples/vintasend-medplum-example/public/img/integrations/deepseek.png +0 -0
  42. package/dist/examples/vintasend-medplum-example/public/img/integrations/entra.png +0 -0
  43. package/dist/examples/vintasend-medplum-example/public/img/integrations/epic.png +0 -0
  44. package/dist/examples/vintasend-medplum-example/public/img/integrations/google.png +0 -0
  45. package/dist/examples/vintasend-medplum-example/public/img/integrations/healthgorilla.png +0 -0
  46. package/dist/examples/vintasend-medplum-example/public/img/integrations/healthie.png +0 -0
  47. package/dist/examples/vintasend-medplum-example/public/img/integrations/labcorp.png +0 -0
  48. package/dist/examples/vintasend-medplum-example/public/img/integrations/okta.png +0 -0
  49. package/dist/examples/vintasend-medplum-example/public/img/integrations/openai.png +0 -0
  50. package/dist/examples/vintasend-medplum-example/public/img/integrations/particle.png +0 -0
  51. package/dist/examples/vintasend-medplum-example/public/img/integrations/quest.png +0 -0
  52. package/dist/examples/vintasend-medplum-example/public/img/integrations/recaptcha.png +0 -0
  53. package/dist/examples/vintasend-medplum-example/public/img/integrations/snowflake.png +0 -0
  54. package/dist/examples/vintasend-medplum-example/public/img/integrations/stedi.png +0 -0
  55. package/dist/examples/vintasend-medplum-example/public/img/integrations/stripe.png +0 -0
  56. package/dist/examples/vintasend-medplum-example/public/img/integrations/sumo.png +0 -0
  57. package/dist/examples/vintasend-medplum-example/scripts/README.md +162 -0
  58. package/dist/examples/vintasend-medplum-example/scripts/client.ts +18 -0
  59. package/dist/examples/vintasend-medplum-example/scripts/deploy-bots.ts +171 -0
  60. package/dist/examples/vintasend-medplum-example/src/App.tsx +185 -0
  61. package/dist/examples/vintasend-medplum-example/src/components/ChargeItem/ChargeItemList.test.tsx +350 -0
  62. package/dist/examples/vintasend-medplum-example/src/components/ChargeItem/ChargeItemList.tsx +241 -0
  63. package/dist/examples/vintasend-medplum-example/src/components/ChargeItem/ChargeItemPanel.test.tsx +616 -0
  64. package/dist/examples/vintasend-medplum-example/src/components/ChargeItem/ChargeItemPanel.tsx +138 -0
  65. package/dist/examples/vintasend-medplum-example/src/components/Conditions/ConditionItem.test.tsx +92 -0
  66. package/dist/examples/vintasend-medplum-example/src/components/Conditions/ConditionItem.tsx +47 -0
  67. package/dist/examples/vintasend-medplum-example/src/components/Conditions/ConditionList.test.tsx +464 -0
  68. package/dist/examples/vintasend-medplum-example/src/components/Conditions/ConditionList.tsx +186 -0
  69. package/dist/examples/vintasend-medplum-example/src/components/Conditions/ConditionModal.test.tsx +80 -0
  70. package/dist/examples/vintasend-medplum-example/src/components/Conditions/ConditionModal.tsx +82 -0
  71. package/dist/examples/vintasend-medplum-example/src/components/DoseSpotIcon.test.tsx +100 -0
  72. package/dist/examples/vintasend-medplum-example/src/components/DoseSpotIcon.tsx +20 -0
  73. package/dist/examples/vintasend-medplum-example/src/components/IntegrationCard.module.css +3 -0
  74. package/dist/examples/vintasend-medplum-example/src/components/IntegrationCard.tsx +62 -0
  75. package/dist/examples/vintasend-medplum-example/src/components/MessageWithLinks.tsx +47 -0
  76. package/dist/examples/vintasend-medplum-example/src/components/PerformingLabInput.test.tsx +299 -0
  77. package/dist/examples/vintasend-medplum-example/src/components/PerformingLabInput.tsx +52 -0
  78. package/dist/examples/vintasend-medplum-example/src/components/ResourceFormWithRequiredProfile.tsx +82 -0
  79. package/dist/examples/vintasend-medplum-example/src/components/encounter/BillingTab.test.tsx +1016 -0
  80. package/dist/examples/vintasend-medplum-example/src/components/encounter/BillingTab.tsx +298 -0
  81. package/dist/examples/vintasend-medplum-example/src/components/encounter/EncounterChart.test.tsx +732 -0
  82. package/dist/examples/vintasend-medplum-example/src/components/encounter/EncounterChart.tsx +282 -0
  83. package/dist/examples/vintasend-medplum-example/src/components/encounter/EncounterHeader.test.tsx +268 -0
  84. package/dist/examples/vintasend-medplum-example/src/components/encounter/EncounterHeader.tsx +224 -0
  85. package/dist/examples/vintasend-medplum-example/src/components/encounter/SignAddendum.test.tsx +255 -0
  86. package/dist/examples/vintasend-medplum-example/src/components/encounter/SignAddendum.tsx +212 -0
  87. package/dist/examples/vintasend-medplum-example/src/components/encounter/SignLockDialog.test.tsx +120 -0
  88. package/dist/examples/vintasend-medplum-example/src/components/encounter/SignLockDialog.tsx +57 -0
  89. package/dist/examples/vintasend-medplum-example/src/components/encounter/VisitDetailsPanel.test.tsx +224 -0
  90. package/dist/examples/vintasend-medplum-example/src/components/encounter/VisitDetailsPanel.tsx +100 -0
  91. package/dist/examples/vintasend-medplum-example/src/components/labs/CoverageInput.test.tsx +431 -0
  92. package/dist/examples/vintasend-medplum-example/src/components/labs/CoverageInput.tsx +130 -0
  93. package/dist/examples/vintasend-medplum-example/src/components/labs/LabListItem.module.css +31 -0
  94. package/dist/examples/vintasend-medplum-example/src/components/labs/LabListItem.test.tsx +234 -0
  95. package/dist/examples/vintasend-medplum-example/src/components/labs/LabListItem.tsx +143 -0
  96. package/dist/examples/vintasend-medplum-example/src/components/labs/LabOrderDetails.module.css +11 -0
  97. package/dist/examples/vintasend-medplum-example/src/components/labs/LabOrderDetails.test.tsx +875 -0
  98. package/dist/examples/vintasend-medplum-example/src/components/labs/LabOrderDetails.tsx +943 -0
  99. package/dist/examples/vintasend-medplum-example/src/components/labs/LabResultDetails.test.tsx +413 -0
  100. package/dist/examples/vintasend-medplum-example/src/components/labs/LabResultDetails.tsx +203 -0
  101. package/dist/examples/vintasend-medplum-example/src/components/labs/LabSelectEmpty.tsx +22 -0
  102. package/dist/examples/vintasend-medplum-example/src/components/labs/README.md +104 -0
  103. package/dist/examples/vintasend-medplum-example/src/components/labs/TestMetadataCardInput.test.tsx +318 -0
  104. package/dist/examples/vintasend-medplum-example/src/components/labs/TestMetadataCardInput.tsx +87 -0
  105. package/dist/examples/vintasend-medplum-example/src/components/messages/ChatList.test.tsx +126 -0
  106. package/dist/examples/vintasend-medplum-example/src/components/messages/ChatList.tsx +38 -0
  107. package/dist/examples/vintasend-medplum-example/src/components/messages/ChatListItem.module.css +23 -0
  108. package/dist/examples/vintasend-medplum-example/src/components/messages/ChatListItem.test.tsx +167 -0
  109. package/dist/examples/vintasend-medplum-example/src/components/messages/ChatListItem.tsx +53 -0
  110. package/dist/examples/vintasend-medplum-example/src/components/messages/NewTopicDialog.test.tsx +94 -0
  111. package/dist/examples/vintasend-medplum-example/src/components/messages/NewTopicDialog.tsx +165 -0
  112. package/dist/examples/vintasend-medplum-example/src/components/messages/ParticipantFilter.module.css +8 -0
  113. package/dist/examples/vintasend-medplum-example/src/components/messages/ParticipantFilter.test.tsx +523 -0
  114. package/dist/examples/vintasend-medplum-example/src/components/messages/ParticipantFilter.tsx +230 -0
  115. package/dist/examples/vintasend-medplum-example/src/components/messages/ThreadInbox.module.css +23 -0
  116. package/dist/examples/vintasend-medplum-example/src/components/messages/ThreadInbox.test.tsx +567 -0
  117. package/dist/examples/vintasend-medplum-example/src/components/messages/ThreadInbox.tsx +358 -0
  118. package/dist/examples/vintasend-medplum-example/src/components/plandefinition/AddPlanDefinition.module.css +40 -0
  119. package/dist/examples/vintasend-medplum-example/src/components/plandefinition/AddPlanDefinition.tsx +257 -0
  120. package/dist/examples/vintasend-medplum-example/src/components/schedule/CreateVisit.module.css +7 -0
  121. package/dist/examples/vintasend-medplum-example/src/components/schedule/CreateVisit.test.tsx +279 -0
  122. package/dist/examples/vintasend-medplum-example/src/components/schedule/CreateVisit.tsx +156 -0
  123. package/dist/examples/vintasend-medplum-example/src/components/spaces/HistoryList.module.css +45 -0
  124. package/dist/examples/vintasend-medplum-example/src/components/spaces/HistoryList.test.tsx +90 -0
  125. package/dist/examples/vintasend-medplum-example/src/components/spaces/HistoryList.tsx +84 -0
  126. package/dist/examples/vintasend-medplum-example/src/components/spaces/ResourceBox.module.css +26 -0
  127. package/dist/examples/vintasend-medplum-example/src/components/spaces/ResourceBox.tsx +90 -0
  128. package/dist/examples/vintasend-medplum-example/src/components/spaces/ResourcePanel.test.tsx +305 -0
  129. package/dist/examples/vintasend-medplum-example/src/components/spaces/ResourcePanel.tsx +46 -0
  130. package/dist/examples/vintasend-medplum-example/src/components/spaces/SpacesInbox.module.css +262 -0
  131. package/dist/examples/vintasend-medplum-example/src/components/spaces/SpacesInbox.test.tsx +622 -0
  132. package/dist/examples/vintasend-medplum-example/src/components/spaces/SpacesInbox.tsx +286 -0
  133. package/dist/examples/vintasend-medplum-example/src/components/tasks/NewTaskModal.tsx +275 -0
  134. package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskAttachmentList.tsx +132 -0
  135. package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskBoard.module.css +45 -0
  136. package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskBoard.test.tsx +749 -0
  137. package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskBoard.tsx +416 -0
  138. package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskDetailPanel.test.tsx +278 -0
  139. package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskDetailPanel.tsx +133 -0
  140. package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskDetailsModal.module.css +16 -0
  141. package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskDetailsModal.test.tsx +255 -0
  142. package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskDetailsModal.tsx +203 -0
  143. package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskFileUpload.tsx +129 -0
  144. package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskFilterMenu.test.tsx +156 -0
  145. package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskFilterMenu.tsx +142 -0
  146. package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskFilterMenu.utils.ts +28 -0
  147. package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskInputNote.test.tsx +134 -0
  148. package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskInputNote.tsx +250 -0
  149. package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskListItem.module.css +23 -0
  150. package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskListItem.test.tsx +149 -0
  151. package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskListItem.tsx +53 -0
  152. package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskNoteItem.test.tsx +68 -0
  153. package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskNoteItem.tsx +46 -0
  154. package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskProperties.test.tsx +555 -0
  155. package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskProperties.tsx +170 -0
  156. package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskSelectEmpty.test.tsx +32 -0
  157. package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskSelectEmpty.tsx +34 -0
  158. package/dist/examples/vintasend-medplum-example/src/components/tasks/encounter/SimpleTask.test.tsx +47 -0
  159. package/dist/examples/vintasend-medplum-example/src/components/tasks/encounter/SimpleTask.tsx +29 -0
  160. package/dist/examples/vintasend-medplum-example/src/components/tasks/encounter/TaskPanel.test.tsx +285 -0
  161. package/dist/examples/vintasend-medplum-example/src/components/tasks/encounter/TaskPanel.tsx +129 -0
  162. package/dist/examples/vintasend-medplum-example/src/components/tasks/encounter/TaskQuestionnaireForm.test.tsx +455 -0
  163. package/dist/examples/vintasend-medplum-example/src/components/tasks/encounter/TaskQuestionnaireForm.tsx +167 -0
  164. package/dist/examples/vintasend-medplum-example/src/components/tasks/encounter/TaskServiceRequest.test.tsx +435 -0
  165. package/dist/examples/vintasend-medplum-example/src/components/tasks/encounter/TaskServiceRequest.tsx +116 -0
  166. package/dist/examples/vintasend-medplum-example/src/components/tasks/encounter/TaskStatusPanel.module.css +38 -0
  167. package/dist/examples/vintasend-medplum-example/src/components/tasks/encounter/TaskStatusPanel.test.tsx +200 -0
  168. package/dist/examples/vintasend-medplum-example/src/components/tasks/encounter/TaskStatusPanel.tsx +84 -0
  169. package/dist/examples/vintasend-medplum-example/src/components/utils.test.ts +176 -0
  170. package/dist/examples/vintasend-medplum-example/src/components/utils.ts +17 -0
  171. package/dist/examples/vintasend-medplum-example/src/config/constants.ts +3 -0
  172. package/dist/examples/vintasend-medplum-example/src/hooks/useDebouncedUpdateResource.test.tsx +166 -0
  173. package/dist/examples/vintasend-medplum-example/src/hooks/useDebouncedUpdateResource.ts +28 -0
  174. package/dist/examples/vintasend-medplum-example/src/hooks/useEncounter.test.tsx +94 -0
  175. package/dist/examples/vintasend-medplum-example/src/hooks/useEncounter.ts +11 -0
  176. package/dist/examples/vintasend-medplum-example/src/hooks/useEncounterChart.test.tsx +477 -0
  177. package/dist/examples/vintasend-medplum-example/src/hooks/useEncounterChart.ts +191 -0
  178. package/dist/examples/vintasend-medplum-example/src/hooks/usePatient.test.tsx +100 -0
  179. package/dist/examples/vintasend-medplum-example/src/hooks/usePatient.ts +18 -0
  180. package/dist/examples/vintasend-medplum-example/src/hooks/useThreadInbox.test.tsx +379 -0
  181. package/dist/examples/vintasend-medplum-example/src/hooks/useThreadInbox.ts +194 -0
  182. package/dist/examples/vintasend-medplum-example/src/index.css +8 -0
  183. package/dist/examples/vintasend-medplum-example/src/main.tsx +57 -0
  184. package/dist/examples/vintasend-medplum-example/src/pages/SearchPage.module.css +6 -0
  185. package/dist/examples/vintasend-medplum-example/src/pages/SearchPage.test.tsx +295 -0
  186. package/dist/examples/vintasend-medplum-example/src/pages/SearchPage.tsx +124 -0
  187. package/dist/examples/vintasend-medplum-example/src/pages/SignInPage.test.tsx +77 -0
  188. package/dist/examples/vintasend-medplum-example/src/pages/SignInPage.tsx +22 -0
  189. package/dist/examples/vintasend-medplum-example/src/pages/encounter/EncounterChartPage.test.tsx +87 -0
  190. package/dist/examples/vintasend-medplum-example/src/pages/encounter/EncounterChartPage.tsx +27 -0
  191. package/dist/examples/vintasend-medplum-example/src/pages/encounter/EncounterModal.module.css +16 -0
  192. package/dist/examples/vintasend-medplum-example/src/pages/encounter/EncounterModal.test.tsx +287 -0
  193. package/dist/examples/vintasend-medplum-example/src/pages/encounter/EncounterModal.tsx +151 -0
  194. package/dist/examples/vintasend-medplum-example/src/pages/integrations/DoseSpotFavoritesPage.test.tsx +519 -0
  195. package/dist/examples/vintasend-medplum-example/src/pages/integrations/DoseSpotFavoritesPage.tsx +179 -0
  196. package/dist/examples/vintasend-medplum-example/src/pages/integrations/FavoriteMedicationsTable.tsx +76 -0
  197. package/dist/examples/vintasend-medplum-example/src/pages/integrations/IntegrationsPage.test.tsx +234 -0
  198. package/dist/examples/vintasend-medplum-example/src/pages/integrations/IntegrationsPage.tsx +222 -0
  199. package/dist/examples/vintasend-medplum-example/src/pages/labs/OrderLabsPage.test.tsx +356 -0
  200. package/dist/examples/vintasend-medplum-example/src/pages/labs/OrderLabsPage.tsx +275 -0
  201. package/dist/examples/vintasend-medplum-example/src/pages/messages/MessagesPage.module.css +8 -0
  202. package/dist/examples/vintasend-medplum-example/src/pages/messages/MessagesPage.test.tsx +103 -0
  203. package/dist/examples/vintasend-medplum-example/src/pages/messages/MessagesPage.tsx +78 -0
  204. package/dist/examples/vintasend-medplum-example/src/pages/patient/CommunicationTab.test.tsx +84 -0
  205. package/dist/examples/vintasend-medplum-example/src/pages/patient/CommunicationTab.tsx +82 -0
  206. package/dist/examples/vintasend-medplum-example/src/pages/patient/DoseSpotAdvancedOptions.test.tsx +364 -0
  207. package/dist/examples/vintasend-medplum-example/src/pages/patient/DoseSpotAdvancedOptions.tsx +149 -0
  208. package/dist/examples/vintasend-medplum-example/src/pages/patient/DoseSpotTab.test.tsx +159 -0
  209. package/dist/examples/vintasend-medplum-example/src/pages/patient/DoseSpotTab.tsx +37 -0
  210. package/dist/examples/vintasend-medplum-example/src/pages/patient/EditTab.test.tsx +140 -0
  211. package/dist/examples/vintasend-medplum-example/src/pages/patient/EditTab.tsx +72 -0
  212. package/dist/examples/vintasend-medplum-example/src/pages/patient/ExportTab.test.tsx +57 -0
  213. package/dist/examples/vintasend-medplum-example/src/pages/patient/ExportTab.tsx +14 -0
  214. package/dist/examples/vintasend-medplum-example/src/pages/patient/IntakeFormPage.test.tsx +241 -0
  215. package/dist/examples/vintasend-medplum-example/src/pages/patient/IntakeFormPage.tsx +710 -0
  216. package/dist/examples/vintasend-medplum-example/src/pages/patient/LabsPage.module.css +37 -0
  217. package/dist/examples/vintasend-medplum-example/src/pages/patient/LabsPage.test.tsx +428 -0
  218. package/dist/examples/vintasend-medplum-example/src/pages/patient/LabsPage.tsx +334 -0
  219. package/dist/examples/vintasend-medplum-example/src/pages/patient/PatientPage.module.css +24 -0
  220. package/dist/examples/vintasend-medplum-example/src/pages/patient/PatientPage.test.tsx +154 -0
  221. package/dist/examples/vintasend-medplum-example/src/pages/patient/PatientPage.tsx +115 -0
  222. package/dist/examples/vintasend-medplum-example/src/pages/patient/PatientPage.utils.test.ts +223 -0
  223. package/dist/examples/vintasend-medplum-example/src/pages/patient/PatientPage.utils.ts +89 -0
  224. package/dist/examples/vintasend-medplum-example/src/pages/patient/PatientSearchPage.test.tsx +147 -0
  225. package/dist/examples/vintasend-medplum-example/src/pages/patient/PatientSearchPage.tsx +79 -0
  226. package/dist/examples/vintasend-medplum-example/src/pages/patient/PatientTabsNavigation.tsx +35 -0
  227. package/dist/examples/vintasend-medplum-example/src/pages/patient/TasksTab.test.tsx +185 -0
  228. package/dist/examples/vintasend-medplum-example/src/pages/patient/TasksTab.tsx +115 -0
  229. package/dist/examples/vintasend-medplum-example/src/pages/patient/TimelineTab.tsx +14 -0
  230. package/dist/examples/vintasend-medplum-example/src/pages/resource/ResourceCreatePage.test.tsx +170 -0
  231. package/dist/examples/vintasend-medplum-example/src/pages/resource/ResourceCreatePage.tsx +117 -0
  232. package/dist/examples/vintasend-medplum-example/src/pages/resource/ResourceDetailPage.tsx +28 -0
  233. package/dist/examples/vintasend-medplum-example/src/pages/resource/ResourceEditPage.test.tsx +131 -0
  234. package/dist/examples/vintasend-medplum-example/src/pages/resource/ResourceEditPage.tsx +65 -0
  235. package/dist/examples/vintasend-medplum-example/src/pages/resource/ResourceHistoryPage.test.tsx +108 -0
  236. package/dist/examples/vintasend-medplum-example/src/pages/resource/ResourceHistoryPage.tsx +16 -0
  237. package/dist/examples/vintasend-medplum-example/src/pages/resource/ResourcePage.module.css +7 -0
  238. package/dist/examples/vintasend-medplum-example/src/pages/resource/ResourcePage.test.tsx +37 -0
  239. package/dist/examples/vintasend-medplum-example/src/pages/resource/ResourcePage.tsx +44 -0
  240. package/dist/examples/vintasend-medplum-example/src/pages/resource/useResourceType.ts +44 -0
  241. package/dist/examples/vintasend-medplum-example/src/pages/resource/utils.ts +9 -0
  242. package/dist/examples/vintasend-medplum-example/src/pages/schedule/SchedulePage.test.tsx +302 -0
  243. package/dist/examples/vintasend-medplum-example/src/pages/schedule/SchedulePage.tsx +416 -0
  244. package/dist/examples/vintasend-medplum-example/src/pages/spaces/ChatInput.tsx +91 -0
  245. package/dist/examples/vintasend-medplum-example/src/pages/spaces/SpacesPage.module.css +6 -0
  246. package/dist/examples/vintasend-medplum-example/src/pages/spaces/SpacesPage.test.tsx +102 -0
  247. package/dist/examples/vintasend-medplum-example/src/pages/spaces/SpacesPage.tsx +44 -0
  248. package/dist/examples/vintasend-medplum-example/src/pages/tasks/TasksPage.module.css +7 -0
  249. package/dist/examples/vintasend-medplum-example/src/pages/tasks/TasksPage.test.tsx +133 -0
  250. package/dist/examples/vintasend-medplum-example/src/pages/tasks/TasksPage.tsx +91 -0
  251. package/dist/examples/vintasend-medplum-example/src/test-utils/render.tsx +20 -0
  252. package/dist/examples/vintasend-medplum-example/src/test.setup.ts +49 -0
  253. package/dist/examples/vintasend-medplum-example/src/types/encounter.ts +8 -0
  254. package/dist/examples/vintasend-medplum-example/src/types/spaces.ts +10 -0
  255. package/dist/examples/vintasend-medplum-example/src/utils/chargeitems.test.ts +141 -0
  256. package/dist/examples/vintasend-medplum-example/src/utils/chargeitems.ts +59 -0
  257. package/dist/examples/vintasend-medplum-example/src/utils/claims.test.ts +153 -0
  258. package/dist/examples/vintasend-medplum-example/src/utils/claims.ts +65 -0
  259. package/dist/examples/vintasend-medplum-example/src/utils/communication-search.ts +47 -0
  260. package/dist/examples/vintasend-medplum-example/src/utils/coverage.test.ts +48 -0
  261. package/dist/examples/vintasend-medplum-example/src/utils/coverage.ts +33 -0
  262. package/dist/examples/vintasend-medplum-example/src/utils/documentReference.test.ts +102 -0
  263. package/dist/examples/vintasend-medplum-example/src/utils/documentReference.ts +55 -0
  264. package/dist/examples/vintasend-medplum-example/src/utils/encounter.test.ts +169 -0
  265. package/dist/examples/vintasend-medplum-example/src/utils/encounter.ts +261 -0
  266. package/dist/examples/vintasend-medplum-example/src/utils/intake-form.test.ts +154 -0
  267. package/dist/examples/vintasend-medplum-example/src/utils/intake-form.ts +272 -0
  268. package/dist/examples/vintasend-medplum-example/src/utils/intake-utils.test.ts +1137 -0
  269. package/dist/examples/vintasend-medplum-example/src/utils/intake-utils.ts +827 -0
  270. package/dist/examples/vintasend-medplum-example/src/utils/notifications.test.ts +27 -0
  271. package/dist/examples/vintasend-medplum-example/src/utils/notifications.ts +15 -0
  272. package/dist/examples/vintasend-medplum-example/src/utils/spaceMessaging.ts +249 -0
  273. package/dist/examples/vintasend-medplum-example/src/utils/spacePersistence.test.ts +450 -0
  274. package/dist/examples/vintasend-medplum-example/src/utils/spacePersistence.ts +147 -0
  275. package/dist/examples/vintasend-medplum-example/src/utils/task-search.ts +63 -0
  276. package/dist/examples/vintasend-medplum-example/src/vite-env.d.ts +3 -0
  277. package/dist/examples/vintasend-medplum-example/tsconfig.bots.json +4 -0
  278. package/dist/examples/vintasend-medplum-example/tsconfig.json +19 -0
  279. package/dist/examples/vintasend-medplum-example/vercel.json +3 -0
  280. package/dist/examples/vintasend-medplum-example/vite.config.ts +44 -0
  281. package/dist/services/notification-backends/base-notification-backend.d.ts +5 -0
  282. package/dist/services/notification-service.js +11 -1
  283. package/dist/services/notification-template-renderers/base-email-template-renderer.d.ts +5 -0
  284. 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)