vintasend-medplum 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +506 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +11 -0
- package/dist/medplum-adapter.d.ts +19 -0
- package/dist/medplum-adapter.js +49 -0
- package/dist/medplum-attachment-manager.d.ts +82 -0
- package/dist/medplum-attachment-manager.js +228 -0
- package/dist/medplum-backend.d.ts +86 -0
- package/dist/medplum-backend.js +831 -0
- package/dist/medplum-logger.d.ts +8 -0
- package/dist/medplum-logger.js +18 -0
- package/package.json +33 -0
|
@@ -0,0 +1,831 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.MedplumNotificationBackendFactory = exports.MedplumNotificationBackend = void 0;
|
|
37
|
+
class MedplumNotificationBackend {
|
|
38
|
+
constructor(medplum, options = {
|
|
39
|
+
emailNotificationSubjectExtensionUrl: 'http://vintasend.com/fhir/StructureDefinition/email-notification-subject',
|
|
40
|
+
}) {
|
|
41
|
+
this.medplum = medplum;
|
|
42
|
+
this.options = options;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Inject attachment manager (called by VintaSend when both service and backend exist)
|
|
46
|
+
*/
|
|
47
|
+
injectAttachmentManager(manager) {
|
|
48
|
+
this.attachmentManager = manager;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Get attachment manager with null check
|
|
52
|
+
*/
|
|
53
|
+
getAttachmentManager() {
|
|
54
|
+
if (!this.attachmentManager) {
|
|
55
|
+
throw new Error('AttachmentManager is required');
|
|
56
|
+
}
|
|
57
|
+
return this.attachmentManager;
|
|
58
|
+
}
|
|
59
|
+
mapToDatabaseNotification(communication) {
|
|
60
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t, _u, _v, _w;
|
|
61
|
+
const notificationId = communication.id;
|
|
62
|
+
const referenceString = (_b = (_a = communication.recipient) === null || _a === void 0 ? void 0 : _a[0]) === null || _b === void 0 ? void 0 : _b.reference;
|
|
63
|
+
// Extract subjectTemplate from payload extension
|
|
64
|
+
const subjectTemplateExtension = (_e = (_d = (_c = communication.payload) === null || _c === void 0 ? void 0 : _c[0]) === null || _d === void 0 ? void 0 : _d.extension) === null || _e === void 0 ? void 0 : _e.find((ext) => ext.url === this.options.emailNotificationSubjectExtensionUrl);
|
|
65
|
+
const subjectTemplate = (subjectTemplateExtension === null || subjectTemplateExtension === void 0 ? void 0 : subjectTemplateExtension.valueString) || null;
|
|
66
|
+
// Extract one-off notification fields from extensions
|
|
67
|
+
const emailOrPhoneExtension = (_f = communication.extension) === null || _f === void 0 ? void 0 : _f.find((ext) => ext.url === 'http://vintasend.com/fhir/StructureDefinition/emailOrPhone');
|
|
68
|
+
const firstNameExtension = (_g = communication.extension) === null || _g === void 0 ? void 0 : _g.find((ext) => ext.url === 'http://vintasend.com/fhir/StructureDefinition/firstName');
|
|
69
|
+
const lastNameExtension = (_h = communication.extension) === null || _h === void 0 ? void 0 : _h.find((ext) => ext.url === 'http://vintasend.com/fhir/StructureDefinition/lastName');
|
|
70
|
+
const emailOrPhone = emailOrPhoneExtension === null || emailOrPhoneExtension === void 0 ? void 0 : emailOrPhoneExtension.valueString;
|
|
71
|
+
const firstName = firstNameExtension === null || firstNameExtension === void 0 ? void 0 : firstNameExtension.valueString;
|
|
72
|
+
const lastName = lastNameExtension === null || lastNameExtension === void 0 ? void 0 : lastNameExtension.valueString;
|
|
73
|
+
const baseNotification = {
|
|
74
|
+
id: notificationId,
|
|
75
|
+
notificationType: (_l = (_k = (_j = communication.meta) === null || _j === void 0 ? void 0 : _j.tag) === null || _k === void 0 ? void 0 : _k[2]) === null || _l === void 0 ? void 0 : _l.code,
|
|
76
|
+
title: ((_m = communication.topic) === null || _m === void 0 ? void 0 : _m.text) || null,
|
|
77
|
+
contextName: (_q = (_p = (_o = communication.meta) === null || _o === void 0 ? void 0 : _o.tag) === null || _p === void 0 ? void 0 : _p[1]) === null || _q === void 0 ? void 0 : _q.code,
|
|
78
|
+
contextParameters: JSON.parse(((_s = (_r = communication.note) === null || _r === void 0 ? void 0 : _r[0]) === null || _s === void 0 ? void 0 : _s.text) || '{}'),
|
|
79
|
+
sendAfter: communication.sent ? new Date(communication.sent) : null,
|
|
80
|
+
bodyTemplate: ((_u = (_t = communication.payload) === null || _t === void 0 ? void 0 : _t[0]) === null || _u === void 0 ? void 0 : _u.contentString) || '',
|
|
81
|
+
subjectTemplate: subjectTemplate,
|
|
82
|
+
extraParams: {},
|
|
83
|
+
status: communication.status === 'completed' ? 'SENT' : communication.status === 'stopped' ? 'FAILED' : 'PENDING_SEND',
|
|
84
|
+
contextUsed: null,
|
|
85
|
+
adapterUsed: null,
|
|
86
|
+
sentAt: communication.status === 'completed' ? new Date(communication.sent || new Date()) : null,
|
|
87
|
+
readAt: null,
|
|
88
|
+
createdAt: new Date(((_v = communication.meta) === null || _v === void 0 ? void 0 : _v.lastUpdated) || new Date()),
|
|
89
|
+
updatedAt: new Date(((_w = communication.meta) === null || _w === void 0 ? void 0 : _w.lastUpdated) || new Date()),
|
|
90
|
+
};
|
|
91
|
+
// Check if this is a one-off notification
|
|
92
|
+
if (emailOrPhone && firstName && lastName) {
|
|
93
|
+
return {
|
|
94
|
+
...baseNotification,
|
|
95
|
+
emailOrPhone,
|
|
96
|
+
firstName,
|
|
97
|
+
lastName,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
// Regular notification
|
|
101
|
+
return {
|
|
102
|
+
...baseNotification,
|
|
103
|
+
userId: referenceString,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
async getAllPendingNotifications() {
|
|
107
|
+
const communications = await this.medplum.searchResources('Communication', {
|
|
108
|
+
status: 'in-progress',
|
|
109
|
+
_tag: 'notification',
|
|
110
|
+
});
|
|
111
|
+
return communications.map((comm) => this.mapToDatabaseNotification(comm));
|
|
112
|
+
}
|
|
113
|
+
async getPendingNotifications(page, pageSize) {
|
|
114
|
+
const now = new Date().toISOString();
|
|
115
|
+
const communications = await this.medplum.searchResources('Communication', {
|
|
116
|
+
status: 'in-progress',
|
|
117
|
+
_tag: 'notification',
|
|
118
|
+
'sent:le': now,
|
|
119
|
+
_count: pageSize.toString(),
|
|
120
|
+
_offset: (page * pageSize).toString(),
|
|
121
|
+
});
|
|
122
|
+
return communications.map((comm) => this.mapToDatabaseNotification(comm));
|
|
123
|
+
}
|
|
124
|
+
async getAllFutureNotifications() {
|
|
125
|
+
const now = new Date().toISOString();
|
|
126
|
+
const communications = await this.medplum.searchResources('Communication', {
|
|
127
|
+
status: 'in-progress',
|
|
128
|
+
_tag: 'notification',
|
|
129
|
+
'sent:gt': now,
|
|
130
|
+
});
|
|
131
|
+
return communications.map((comm) => this.mapToDatabaseNotification(comm));
|
|
132
|
+
}
|
|
133
|
+
async getFutureNotifications(page, pageSize) {
|
|
134
|
+
const now = new Date().toISOString();
|
|
135
|
+
const communications = await this.medplum.searchResources('Communication', {
|
|
136
|
+
status: 'in-progress',
|
|
137
|
+
_tag: 'notification',
|
|
138
|
+
'sent:gt': now,
|
|
139
|
+
_count: pageSize.toString(),
|
|
140
|
+
_offset: (page * pageSize).toString(),
|
|
141
|
+
});
|
|
142
|
+
return communications.map((comm) => this.mapToDatabaseNotification(comm));
|
|
143
|
+
}
|
|
144
|
+
async getAllFutureNotificationsFromUser(referenceString) {
|
|
145
|
+
const now = new Date().toISOString();
|
|
146
|
+
const communications = await this.medplum.searchResources('Communication', {
|
|
147
|
+
status: 'in-progress',
|
|
148
|
+
_tag: 'notification',
|
|
149
|
+
'sent:gt': now,
|
|
150
|
+
recipient: referenceString,
|
|
151
|
+
});
|
|
152
|
+
return communications
|
|
153
|
+
.map((comm) => this.mapToDatabaseNotification(comm))
|
|
154
|
+
.filter((n) => 'userId' in n);
|
|
155
|
+
}
|
|
156
|
+
async getFutureNotificationsFromUser(referenceString, page, pageSize) {
|
|
157
|
+
const now = new Date().toISOString();
|
|
158
|
+
const communications = await this.medplum.searchResources('Communication', {
|
|
159
|
+
status: 'in-progress',
|
|
160
|
+
_tag: 'notification',
|
|
161
|
+
'sent:gt': now,
|
|
162
|
+
recipient: referenceString,
|
|
163
|
+
_count: pageSize.toString(),
|
|
164
|
+
_offset: (page * pageSize).toString(),
|
|
165
|
+
});
|
|
166
|
+
return communications
|
|
167
|
+
.map((comm) => this.mapToDatabaseNotification(comm))
|
|
168
|
+
.filter((n) => 'userId' in n);
|
|
169
|
+
}
|
|
170
|
+
async persistNotification(notification) {
|
|
171
|
+
var _a;
|
|
172
|
+
// Build base payload with body template
|
|
173
|
+
const payload = notification.bodyTemplate
|
|
174
|
+
? [
|
|
175
|
+
{
|
|
176
|
+
contentString: notification.bodyTemplate,
|
|
177
|
+
extension: notification.subjectTemplate && this.options.emailNotificationSubjectExtensionUrl
|
|
178
|
+
? [{ url: this.options.emailNotificationSubjectExtensionUrl, valueString: notification.subjectTemplate }]
|
|
179
|
+
: undefined,
|
|
180
|
+
},
|
|
181
|
+
]
|
|
182
|
+
: [];
|
|
183
|
+
// Handle attachments if present
|
|
184
|
+
if (notification.attachments && notification.attachments.length > 0) {
|
|
185
|
+
const attachmentPayload = await this.processAttachments(notification.attachments);
|
|
186
|
+
if (attachmentPayload) {
|
|
187
|
+
payload.push(...attachmentPayload);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const communication = {
|
|
191
|
+
resourceType: 'Communication',
|
|
192
|
+
status: 'in-progress',
|
|
193
|
+
sent: (_a = notification.sendAfter) === null || _a === void 0 ? void 0 : _a.toISOString(),
|
|
194
|
+
topic: { text: notification.title || undefined },
|
|
195
|
+
payload,
|
|
196
|
+
recipient: [{ reference: notification.userId }],
|
|
197
|
+
note: [{ text: JSON.stringify(notification.contextParameters) }],
|
|
198
|
+
meta: {
|
|
199
|
+
tag: [
|
|
200
|
+
{ code: 'notification' },
|
|
201
|
+
{ code: notification.contextName },
|
|
202
|
+
{ code: notification.notificationType },
|
|
203
|
+
],
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
const created = await this.medplum.createResource(communication);
|
|
207
|
+
return this.mapToDatabaseNotification(created);
|
|
208
|
+
}
|
|
209
|
+
async persistNotificationUpdate(notificationId, notification) {
|
|
210
|
+
const existing = await this.medplum.readResource('Communication', notificationId);
|
|
211
|
+
const updated = {
|
|
212
|
+
...existing,
|
|
213
|
+
...(notification.status === 'SENT' && { status: 'completed' }),
|
|
214
|
+
...(notification.status === 'FAILED' && { status: 'stopped' }),
|
|
215
|
+
meta: {
|
|
216
|
+
...existing.meta,
|
|
217
|
+
lastUpdated: new Date().toISOString(),
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
const result = await this.medplum.updateResource(updated);
|
|
221
|
+
return this.mapToDatabaseNotification(result);
|
|
222
|
+
}
|
|
223
|
+
async getAllNotifications() {
|
|
224
|
+
const communications = await this.medplum.searchResources('Communication', {
|
|
225
|
+
_tag: 'notification',
|
|
226
|
+
});
|
|
227
|
+
return communications.map((comm) => this.mapToDatabaseNotification(comm));
|
|
228
|
+
}
|
|
229
|
+
async getNotifications(page, pageSize) {
|
|
230
|
+
const communications = await this.medplum.searchResources('Communication', {
|
|
231
|
+
_tag: 'notification',
|
|
232
|
+
_count: pageSize.toString(),
|
|
233
|
+
_offset: (page * pageSize).toString(),
|
|
234
|
+
});
|
|
235
|
+
return communications.map((comm) => this.mapToDatabaseNotification(comm));
|
|
236
|
+
}
|
|
237
|
+
async bulkPersistNotifications(notifications) {
|
|
238
|
+
var _a;
|
|
239
|
+
const ids = [];
|
|
240
|
+
for (const notification of notifications) {
|
|
241
|
+
const communication = {
|
|
242
|
+
resourceType: 'Communication',
|
|
243
|
+
status: 'in-progress',
|
|
244
|
+
sent: (_a = notification.sendAfter) === null || _a === void 0 ? void 0 : _a.toISOString(),
|
|
245
|
+
topic: { text: notification.title || undefined },
|
|
246
|
+
payload: notification.bodyTemplate
|
|
247
|
+
? [
|
|
248
|
+
{
|
|
249
|
+
contentString: notification.bodyTemplate,
|
|
250
|
+
extension: notification.subjectTemplate && this.options.emailNotificationSubjectExtensionUrl
|
|
251
|
+
? [{ url: this.options.emailNotificationSubjectExtensionUrl, valueString: notification.subjectTemplate }]
|
|
252
|
+
: undefined,
|
|
253
|
+
},
|
|
254
|
+
]
|
|
255
|
+
: [],
|
|
256
|
+
recipient: [{ reference: notification.userId }],
|
|
257
|
+
note: [{ text: JSON.stringify(notification.contextParameters) }],
|
|
258
|
+
meta: {
|
|
259
|
+
tag: [
|
|
260
|
+
{ code: 'notification' },
|
|
261
|
+
{ code: notification.contextName },
|
|
262
|
+
{ code: notification.notificationType },
|
|
263
|
+
],
|
|
264
|
+
},
|
|
265
|
+
};
|
|
266
|
+
const created = await this.medplum.createResource(communication);
|
|
267
|
+
ids.push(created.id);
|
|
268
|
+
}
|
|
269
|
+
return ids;
|
|
270
|
+
}
|
|
271
|
+
async markAsSent(notificationId, checkIsPending = true) {
|
|
272
|
+
const notification = await this.getNotification(notificationId, false);
|
|
273
|
+
if (checkIsPending && (notification === null || notification === void 0 ? void 0 : notification.status) !== 'PENDING_SEND') {
|
|
274
|
+
throw new Error('Notification is not pending');
|
|
275
|
+
}
|
|
276
|
+
// Use internal update that returns AnyDatabaseNotification
|
|
277
|
+
const existing = await this.medplum.readResource('Communication', notificationId);
|
|
278
|
+
const updated = {
|
|
279
|
+
...existing,
|
|
280
|
+
status: 'completed',
|
|
281
|
+
meta: {
|
|
282
|
+
...existing.meta,
|
|
283
|
+
lastUpdated: new Date().toISOString(),
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
const result = await this.medplum.updateResource(updated);
|
|
287
|
+
return this.mapToDatabaseNotification(result);
|
|
288
|
+
}
|
|
289
|
+
async markAsFailed(notificationId, checkIsPending = true) {
|
|
290
|
+
const notification = await this.getNotification(notificationId, false);
|
|
291
|
+
if (checkIsPending && (notification === null || notification === void 0 ? void 0 : notification.status) !== 'PENDING_SEND') {
|
|
292
|
+
throw new Error('Notification is not pending');
|
|
293
|
+
}
|
|
294
|
+
// Use internal update that returns AnyDatabaseNotification
|
|
295
|
+
const existing = await this.medplum.readResource('Communication', notificationId);
|
|
296
|
+
const updated = {
|
|
297
|
+
...existing,
|
|
298
|
+
status: 'stopped',
|
|
299
|
+
meta: {
|
|
300
|
+
...existing.meta,
|
|
301
|
+
lastUpdated: new Date().toISOString(),
|
|
302
|
+
},
|
|
303
|
+
};
|
|
304
|
+
const result = await this.medplum.updateResource(updated);
|
|
305
|
+
return this.mapToDatabaseNotification(result);
|
|
306
|
+
}
|
|
307
|
+
async markAsRead(notificationId, checkIsSent = true) {
|
|
308
|
+
const notification = await this.getNotification(notificationId, false);
|
|
309
|
+
if (checkIsSent && (notification === null || notification === void 0 ? void 0 : notification.status) !== 'SENT') {
|
|
310
|
+
throw new Error('Notification is not sent');
|
|
311
|
+
}
|
|
312
|
+
// Ensure it's a regular notification (not one-off)
|
|
313
|
+
if (notification && !('userId' in notification)) {
|
|
314
|
+
throw new Error('Cannot mark one-off notification as read');
|
|
315
|
+
}
|
|
316
|
+
return this.persistNotificationUpdate(notificationId, { readAt: new Date() });
|
|
317
|
+
}
|
|
318
|
+
async cancelNotification(notificationId) {
|
|
319
|
+
await this.medplum.deleteResource('Communication', notificationId);
|
|
320
|
+
}
|
|
321
|
+
async getNotification(notificationId,
|
|
322
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
323
|
+
_forUpdate) {
|
|
324
|
+
try {
|
|
325
|
+
const communication = await this.medplum.readResource('Communication', notificationId);
|
|
326
|
+
return this.mapToDatabaseNotification(communication);
|
|
327
|
+
}
|
|
328
|
+
catch (_a) {
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
async filterAllInAppUnreadNotifications(refenrenceString) {
|
|
333
|
+
const communications = await this.medplum.searchResources('Communication', {
|
|
334
|
+
status: 'completed',
|
|
335
|
+
_tag: 'notification,in-app',
|
|
336
|
+
recipient: refenrenceString,
|
|
337
|
+
});
|
|
338
|
+
return communications
|
|
339
|
+
.map((comm) => this.mapToDatabaseNotification(comm))
|
|
340
|
+
.filter((notif) => 'userId' in notif && !notif.readAt);
|
|
341
|
+
}
|
|
342
|
+
async filterInAppUnreadNotifications(refenrenceString, page, pageSize) {
|
|
343
|
+
const communications = await this.medplum.searchResources('Communication', {
|
|
344
|
+
status: 'completed',
|
|
345
|
+
_tag: 'notification,in-app',
|
|
346
|
+
recipient: refenrenceString,
|
|
347
|
+
_count: pageSize.toString(),
|
|
348
|
+
_offset: (page * pageSize).toString(),
|
|
349
|
+
});
|
|
350
|
+
return communications
|
|
351
|
+
.map((comm) => this.mapToDatabaseNotification(comm))
|
|
352
|
+
.filter((notif) => 'userId' in notif && !notif.readAt);
|
|
353
|
+
}
|
|
354
|
+
async getUserEmailFromNotification(notificationId) {
|
|
355
|
+
var _a, _b, _c, _d;
|
|
356
|
+
try {
|
|
357
|
+
const communication = await this.medplum.readResource('Communication', notificationId);
|
|
358
|
+
const practitionerRef = (_b = (_a = communication.recipient) === null || _a === void 0 ? void 0 : _a[0]) === null || _b === void 0 ? void 0 : _b.reference;
|
|
359
|
+
if (!practitionerRef) {
|
|
360
|
+
return undefined;
|
|
361
|
+
}
|
|
362
|
+
const [resourceType, id] = practitionerRef.split('/');
|
|
363
|
+
if (!id) {
|
|
364
|
+
// eslint-disable-next-line no-console
|
|
365
|
+
console.error('[getUserEmailFromNotification] Invalid reference format - no ID found');
|
|
366
|
+
return undefined;
|
|
367
|
+
}
|
|
368
|
+
const resource = await this.medplum.readResource(resourceType, id);
|
|
369
|
+
return (_d = (_c = resource.telecom) === null || _c === void 0 ? void 0 : _c.find((t) => t.system === 'email')) === null || _d === void 0 ? void 0 : _d.value;
|
|
370
|
+
}
|
|
371
|
+
catch (error) {
|
|
372
|
+
// eslint-disable-next-line no-console
|
|
373
|
+
console.error('[getUserEmailFromNotification] Error fetching user email:', error);
|
|
374
|
+
return undefined;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
async storeContextUsed(notificationId, context) {
|
|
378
|
+
const communication = await this.medplum.readResource('Communication', notificationId);
|
|
379
|
+
const updated = {
|
|
380
|
+
...communication,
|
|
381
|
+
note: [{ text: JSON.stringify(context) }],
|
|
382
|
+
};
|
|
383
|
+
await this.medplum.updateResource(updated);
|
|
384
|
+
}
|
|
385
|
+
/* One-off notification methods */
|
|
386
|
+
async persistOneOffNotification(notification) {
|
|
387
|
+
var _a;
|
|
388
|
+
// Build base payload with body template
|
|
389
|
+
const payload = notification.bodyTemplate
|
|
390
|
+
? [
|
|
391
|
+
{
|
|
392
|
+
contentString: notification.bodyTemplate,
|
|
393
|
+
extension: notification.subjectTemplate && this.options.emailNotificationSubjectExtensionUrl
|
|
394
|
+
? [{ url: this.options.emailNotificationSubjectExtensionUrl, valueString: notification.subjectTemplate }]
|
|
395
|
+
: undefined,
|
|
396
|
+
},
|
|
397
|
+
]
|
|
398
|
+
: [];
|
|
399
|
+
// Handle attachments if present
|
|
400
|
+
if (notification.attachments && notification.attachments.length > 0) {
|
|
401
|
+
const attachmentPayload = await this.processAttachments(notification.attachments);
|
|
402
|
+
if (attachmentPayload) {
|
|
403
|
+
payload.push(...attachmentPayload);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
const communication = {
|
|
407
|
+
resourceType: 'Communication',
|
|
408
|
+
status: 'in-progress',
|
|
409
|
+
sent: (_a = notification.sendAfter) === null || _a === void 0 ? void 0 : _a.toISOString(),
|
|
410
|
+
topic: { text: notification.title || undefined },
|
|
411
|
+
payload,
|
|
412
|
+
note: [{ text: JSON.stringify(notification.contextParameters) }],
|
|
413
|
+
extension: [
|
|
414
|
+
{ url: 'http://vintasend.com/fhir/StructureDefinition/emailOrPhone', valueString: notification.emailOrPhone },
|
|
415
|
+
{ url: 'http://vintasend.com/fhir/StructureDefinition/firstName', valueString: notification.firstName },
|
|
416
|
+
{ url: 'http://vintasend.com/fhir/StructureDefinition/lastName', valueString: notification.lastName },
|
|
417
|
+
],
|
|
418
|
+
meta: {
|
|
419
|
+
tag: [
|
|
420
|
+
{ code: 'notification' },
|
|
421
|
+
{ code: notification.contextName },
|
|
422
|
+
{ code: notification.notificationType },
|
|
423
|
+
{ code: 'one-off' },
|
|
424
|
+
],
|
|
425
|
+
},
|
|
426
|
+
};
|
|
427
|
+
const created = await this.medplum.createResource(communication);
|
|
428
|
+
return this.mapToDatabaseNotification(created);
|
|
429
|
+
}
|
|
430
|
+
async persistOneOffNotificationUpdate(notificationId, notification) {
|
|
431
|
+
const existing = await this.medplum.readResource('Communication', notificationId);
|
|
432
|
+
const updated = {
|
|
433
|
+
...existing,
|
|
434
|
+
...(notification.status === 'SENT' && { status: 'completed' }),
|
|
435
|
+
...(notification.status === 'FAILED' && { status: 'stopped' }),
|
|
436
|
+
meta: {
|
|
437
|
+
...existing.meta,
|
|
438
|
+
lastUpdated: new Date().toISOString(),
|
|
439
|
+
},
|
|
440
|
+
};
|
|
441
|
+
const result = await this.medplum.updateResource(updated);
|
|
442
|
+
return this.mapToDatabaseNotification(result);
|
|
443
|
+
}
|
|
444
|
+
async getOneOffNotification(notificationId, forUpdate) {
|
|
445
|
+
const notification = await this.getNotification(notificationId, forUpdate);
|
|
446
|
+
if (!notification)
|
|
447
|
+
return null;
|
|
448
|
+
// Check if it's a one-off notification
|
|
449
|
+
if ('emailOrPhone' in notification) {
|
|
450
|
+
return notification;
|
|
451
|
+
}
|
|
452
|
+
return null;
|
|
453
|
+
}
|
|
454
|
+
async getAllOneOffNotifications() {
|
|
455
|
+
const communications = await this.medplum.searchResources('Communication', {
|
|
456
|
+
_tag: 'notification,one-off',
|
|
457
|
+
});
|
|
458
|
+
return communications.map((comm) => this.mapToDatabaseNotification(comm));
|
|
459
|
+
}
|
|
460
|
+
async getOneOffNotifications(page, pageSize) {
|
|
461
|
+
const communications = await this.medplum.searchResources('Communication', {
|
|
462
|
+
_tag: 'notification,one-off',
|
|
463
|
+
_count: pageSize.toString(),
|
|
464
|
+
_offset: (page * pageSize).toString(),
|
|
465
|
+
});
|
|
466
|
+
return communications.map((comm) => this.mapToDatabaseNotification(comm));
|
|
467
|
+
}
|
|
468
|
+
/* Attachment management methods */
|
|
469
|
+
/**
|
|
470
|
+
* Helper: Convert FileAttachment to Buffer
|
|
471
|
+
*/
|
|
472
|
+
async fileToBuffer(file, filename) {
|
|
473
|
+
if (Buffer.isBuffer(file)) {
|
|
474
|
+
return file;
|
|
475
|
+
}
|
|
476
|
+
if (typeof file === 'string') {
|
|
477
|
+
// File path - read from filesystem
|
|
478
|
+
const fs = await Promise.resolve().then(() => __importStar(require('node:fs/promises')));
|
|
479
|
+
return await fs.readFile(file);
|
|
480
|
+
}
|
|
481
|
+
// ReadableStream or Readable
|
|
482
|
+
const chunks = [];
|
|
483
|
+
if ('getReader' in file) {
|
|
484
|
+
// Web ReadableStream
|
|
485
|
+
const reader = file.getReader();
|
|
486
|
+
try {
|
|
487
|
+
while (true) {
|
|
488
|
+
const { done, value } = await reader.read();
|
|
489
|
+
if (done)
|
|
490
|
+
break;
|
|
491
|
+
chunks.push(Buffer.from(value));
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
finally {
|
|
495
|
+
reader.releaseLock();
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
else {
|
|
499
|
+
// Node.js Readable
|
|
500
|
+
for await (const chunk of file) {
|
|
501
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
return Buffer.concat(chunks);
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* Helper: Convert FHIR Media resource to AttachmentFileRecord
|
|
508
|
+
*/
|
|
509
|
+
mediaToAttachmentFileRecord(media) {
|
|
510
|
+
var _a, _b, _c, _d;
|
|
511
|
+
const content = media.content;
|
|
512
|
+
if (!content || !media.id) {
|
|
513
|
+
throw new Error('Invalid Media resource: missing content or id');
|
|
514
|
+
}
|
|
515
|
+
return {
|
|
516
|
+
id: media.id,
|
|
517
|
+
filename: content.title || 'untitled',
|
|
518
|
+
contentType: content.contentType || 'application/octet-stream',
|
|
519
|
+
size: content.size || 0,
|
|
520
|
+
checksum: ((_b = (_a = media.identifier) === null || _a === void 0 ? void 0 : _a[0]) === null || _b === void 0 ? void 0 : _b.value) || '',
|
|
521
|
+
storageMetadata: {
|
|
522
|
+
url: content.url,
|
|
523
|
+
creation: content.creation,
|
|
524
|
+
},
|
|
525
|
+
createdAt: ((_c = media.meta) === null || _c === void 0 ? void 0 : _c.lastUpdated) ? new Date(media.meta.lastUpdated) : new Date(),
|
|
526
|
+
updatedAt: ((_d = media.meta) === null || _d === void 0 ? void 0 : _d.lastUpdated) ? new Date(media.meta.lastUpdated) : new Date(),
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Helper: Create FHIR Attachment from file record
|
|
531
|
+
*/
|
|
532
|
+
async createFhirAttachment(fileRecord) {
|
|
533
|
+
var _a;
|
|
534
|
+
const url = (_a = fileRecord.storageMetadata) === null || _a === void 0 ? void 0 : _a.url;
|
|
535
|
+
return {
|
|
536
|
+
contentType: fileRecord.contentType,
|
|
537
|
+
url: url,
|
|
538
|
+
size: fileRecord.size,
|
|
539
|
+
title: fileRecord.filename,
|
|
540
|
+
creation: fileRecord.createdAt.toISOString(),
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* Helper: Create MedplumAttachmentFile from storage metadata
|
|
545
|
+
*/
|
|
546
|
+
createMedplumAttachmentFile(fileRecord) {
|
|
547
|
+
const manager = this.getAttachmentManager();
|
|
548
|
+
return manager.reconstructAttachmentFile(fileRecord.storageMetadata);
|
|
549
|
+
}
|
|
550
|
+
async getAttachmentFile(fileId) {
|
|
551
|
+
try {
|
|
552
|
+
const media = await this.medplum.readResource('Media', fileId);
|
|
553
|
+
return this.mediaToAttachmentFileRecord(media);
|
|
554
|
+
}
|
|
555
|
+
catch (_a) {
|
|
556
|
+
return null;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
async findAttachmentFileByChecksum(checksum) {
|
|
560
|
+
try {
|
|
561
|
+
const results = await this.medplum.searchResources('Media', `identifier=${checksum}&_tag=attachment-file`);
|
|
562
|
+
if (results.length === 0) {
|
|
563
|
+
return null;
|
|
564
|
+
}
|
|
565
|
+
return this.mediaToAttachmentFileRecord(results[0]);
|
|
566
|
+
}
|
|
567
|
+
catch (_a) {
|
|
568
|
+
return null;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
/**
|
|
572
|
+
* Find multiple attachment files by their checksums in a single query
|
|
573
|
+
*/
|
|
574
|
+
async findAttachmentFilesByChecksums(checksums) {
|
|
575
|
+
var _a, _b;
|
|
576
|
+
if (checksums.length === 0) {
|
|
577
|
+
return new Map();
|
|
578
|
+
}
|
|
579
|
+
try {
|
|
580
|
+
// Build query with all checksums
|
|
581
|
+
const identifierQuery = checksums.join(',');
|
|
582
|
+
const results = await this.medplum.searchResources('Media', `identifier=${identifierQuery}&_tag=attachment-file`);
|
|
583
|
+
// Map results by checksum
|
|
584
|
+
const filesByChecksum = new Map();
|
|
585
|
+
for (const media of results) {
|
|
586
|
+
const checksum = (_b = (_a = media.identifier) === null || _a === void 0 ? void 0 : _a[0]) === null || _b === void 0 ? void 0 : _b.value;
|
|
587
|
+
if (checksum) {
|
|
588
|
+
filesByChecksum.set(checksum, this.mediaToAttachmentFileRecord(media));
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
return filesByChecksum;
|
|
592
|
+
}
|
|
593
|
+
catch (_c) {
|
|
594
|
+
return new Map();
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* Helper: Process attachments and return payload items
|
|
599
|
+
* Optimizes by batching checksum lookups
|
|
600
|
+
*/
|
|
601
|
+
async processAttachments(attachments) {
|
|
602
|
+
const manager = this.getAttachmentManager();
|
|
603
|
+
const payload = [];
|
|
604
|
+
// Separate attachments by type: file references vs new uploads
|
|
605
|
+
const fileReferences = [];
|
|
606
|
+
const newUploads = [];
|
|
607
|
+
for (let i = 0; i < attachments.length; i++) {
|
|
608
|
+
const attachment = attachments[i];
|
|
609
|
+
if ('fileId' in attachment) {
|
|
610
|
+
fileReferences.push({
|
|
611
|
+
index: i,
|
|
612
|
+
fileId: attachment.fileId,
|
|
613
|
+
description: attachment.description
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
else if ('file' in attachment) {
|
|
617
|
+
newUploads.push({
|
|
618
|
+
index: i,
|
|
619
|
+
file: attachment.file,
|
|
620
|
+
filename: attachment.filename,
|
|
621
|
+
contentType: attachment.contentType || 'application/octet-stream',
|
|
622
|
+
description: attachment.description
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
// Fetch all referenced files in parallel
|
|
627
|
+
const referencedFiles = await Promise.all(fileReferences.map(async ({ fileId }) => ({
|
|
628
|
+
fileId,
|
|
629
|
+
record: await this.getAttachmentFile(fileId)
|
|
630
|
+
})));
|
|
631
|
+
// Calculate checksums for new uploads
|
|
632
|
+
const uploadsWithChecksums = await Promise.all(newUploads.map(async (upload) => {
|
|
633
|
+
const buffer = await this.fileToBuffer(upload.file, upload.filename);
|
|
634
|
+
const checksum = await manager.calculateChecksum(buffer);
|
|
635
|
+
return { ...upload, buffer, checksum };
|
|
636
|
+
}));
|
|
637
|
+
// Batch lookup existing files by checksums
|
|
638
|
+
const checksums = uploadsWithChecksums.map(u => u.checksum);
|
|
639
|
+
const existingFilesByChecksum = await this.findAttachmentFilesByChecksums(checksums);
|
|
640
|
+
// Process file references
|
|
641
|
+
const fileRecords = [];
|
|
642
|
+
for (let i = 0; i < fileReferences.length; i++) {
|
|
643
|
+
const { index, description } = fileReferences[i];
|
|
644
|
+
const { record } = referencedFiles[i];
|
|
645
|
+
if (!record) {
|
|
646
|
+
throw new Error(`Attachment file not found: ${fileReferences[i].fileId}`);
|
|
647
|
+
}
|
|
648
|
+
fileRecords.push({
|
|
649
|
+
index,
|
|
650
|
+
record,
|
|
651
|
+
description
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
// Process new uploads (using cached checksum lookups or uploading)
|
|
655
|
+
for (const upload of uploadsWithChecksums) {
|
|
656
|
+
let fileRecord;
|
|
657
|
+
const existingFile = existingFilesByChecksum.get(upload.checksum);
|
|
658
|
+
if (existingFile) {
|
|
659
|
+
// File already exists, reuse it
|
|
660
|
+
fileRecord = existingFile;
|
|
661
|
+
}
|
|
662
|
+
else {
|
|
663
|
+
// Upload new file
|
|
664
|
+
fileRecord = await manager.uploadFile(upload.file, upload.filename, upload.contentType);
|
|
665
|
+
}
|
|
666
|
+
fileRecords.push({
|
|
667
|
+
index: upload.index,
|
|
668
|
+
record: fileRecord,
|
|
669
|
+
description: upload.description
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
// Sort by original index and build payload
|
|
673
|
+
fileRecords.sort((a, b) => a.index - b.index);
|
|
674
|
+
for (const { record, description } of fileRecords) {
|
|
675
|
+
const fhirAttachment = await this.createFhirAttachment(record);
|
|
676
|
+
payload.push({
|
|
677
|
+
contentAttachment: {
|
|
678
|
+
...fhirAttachment,
|
|
679
|
+
url: `Media/${record.id}`,
|
|
680
|
+
title: description || record.filename,
|
|
681
|
+
},
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
return payload;
|
|
685
|
+
}
|
|
686
|
+
async deleteAttachmentFile(fileId) {
|
|
687
|
+
var _a;
|
|
688
|
+
// Check if file exists first
|
|
689
|
+
const fileRecord = await this.getAttachmentFile(fileId);
|
|
690
|
+
if (!fileRecord) {
|
|
691
|
+
// File not found, return early
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
// Check if file is still referenced by any notifications
|
|
695
|
+
const communications = await this.medplum.searchResources('Communication', {
|
|
696
|
+
_tag: 'notification',
|
|
697
|
+
});
|
|
698
|
+
const isReferenced = communications.some((comm) => { var _a; return (_a = comm.payload) === null || _a === void 0 ? void 0 : _a.some((p) => { var _a, _b; return (_b = (_a = p.contentAttachment) === null || _a === void 0 ? void 0 : _a.url) === null || _b === void 0 ? void 0 : _b.includes(fileId); }); });
|
|
699
|
+
if (isReferenced) {
|
|
700
|
+
throw new Error('Cannot delete attachment file: still referenced by notifications');
|
|
701
|
+
}
|
|
702
|
+
// Delete from storage backend
|
|
703
|
+
if (fileRecord && this.attachmentManager) {
|
|
704
|
+
await this.attachmentManager.deleteFile(fileId);
|
|
705
|
+
}
|
|
706
|
+
// Delete Binary resource if it exists
|
|
707
|
+
if ((_a = fileRecord.storageMetadata) === null || _a === void 0 ? void 0 : _a.url) {
|
|
708
|
+
const binaryId = fileRecord.storageMetadata.url.replace('Binary/', '');
|
|
709
|
+
try {
|
|
710
|
+
await this.medplum.deleteResource('Binary', binaryId);
|
|
711
|
+
}
|
|
712
|
+
catch (_b) {
|
|
713
|
+
// Binary may not exist or already deleted
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
// Delete Media resource
|
|
717
|
+
await this.medplum.deleteResource('Media', fileId);
|
|
718
|
+
}
|
|
719
|
+
async getOrphanedAttachmentFiles() {
|
|
720
|
+
var _a;
|
|
721
|
+
// Get all Media resources tagged as attachment-files
|
|
722
|
+
const allMedia = await this.medplum.searchResources('Media', {
|
|
723
|
+
_tag: 'attachment-file',
|
|
724
|
+
});
|
|
725
|
+
// Get all notifications
|
|
726
|
+
const communications = await this.medplum.searchResources('Communication', {
|
|
727
|
+
_tag: 'notification',
|
|
728
|
+
});
|
|
729
|
+
// Build set of referenced file IDs
|
|
730
|
+
const referencedIds = new Set();
|
|
731
|
+
for (const comm of communications) {
|
|
732
|
+
for (const payload of comm.payload || []) {
|
|
733
|
+
const url = (_a = payload.contentAttachment) === null || _a === void 0 ? void 0 : _a.url;
|
|
734
|
+
if (url) {
|
|
735
|
+
// Extract Media ID from URL (assuming format like Media/{id})
|
|
736
|
+
const match = url.match(/Media\/([^/]+)/);
|
|
737
|
+
if (match)
|
|
738
|
+
referencedIds.add(match[1]);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
// Filter orphaned media
|
|
743
|
+
const orphaned = allMedia.filter((media) => media.id && !referencedIds.has(media.id));
|
|
744
|
+
return orphaned.map((media) => this.mediaToAttachmentFileRecord(media));
|
|
745
|
+
}
|
|
746
|
+
async getAttachments(notificationId) {
|
|
747
|
+
this.getAttachmentManager(); // Ensure attachment manager is injected
|
|
748
|
+
try {
|
|
749
|
+
const communication = await this.medplum.readResource('Communication', notificationId);
|
|
750
|
+
const attachments = [];
|
|
751
|
+
// Extract all Media IDs from payload
|
|
752
|
+
const mediaIds = [];
|
|
753
|
+
const payloadMap = new Map();
|
|
754
|
+
for (const payload of communication.payload || []) {
|
|
755
|
+
const attachment = payload.contentAttachment;
|
|
756
|
+
if (!attachment || !attachment.url)
|
|
757
|
+
continue;
|
|
758
|
+
// Extract Media ID from URL
|
|
759
|
+
const match = attachment.url.match(/Media\/([^/]+)/);
|
|
760
|
+
if (!match)
|
|
761
|
+
continue;
|
|
762
|
+
const mediaId = match[1];
|
|
763
|
+
mediaIds.push(mediaId);
|
|
764
|
+
payloadMap.set(mediaId, { description: attachment.title });
|
|
765
|
+
}
|
|
766
|
+
// Fetch all Media resources in a single query
|
|
767
|
+
if (mediaIds.length === 0) {
|
|
768
|
+
return [];
|
|
769
|
+
}
|
|
770
|
+
const mediaResources = await this.medplum.searchResources('Media', {
|
|
771
|
+
_id: mediaIds.join(','),
|
|
772
|
+
});
|
|
773
|
+
// Build attachments from the fetched Media resources
|
|
774
|
+
for (const media of mediaResources) {
|
|
775
|
+
if (!media.id)
|
|
776
|
+
continue;
|
|
777
|
+
const fileRecord = this.mediaToAttachmentFileRecord(media);
|
|
778
|
+
const attachmentFile = this.createMedplumAttachmentFile(fileRecord);
|
|
779
|
+
const payloadData = payloadMap.get(media.id);
|
|
780
|
+
attachments.push({
|
|
781
|
+
id: `${notificationId}-${media.id}`,
|
|
782
|
+
fileId: media.id,
|
|
783
|
+
filename: fileRecord.filename,
|
|
784
|
+
contentType: fileRecord.contentType,
|
|
785
|
+
size: fileRecord.size,
|
|
786
|
+
checksum: fileRecord.checksum,
|
|
787
|
+
createdAt: fileRecord.createdAt,
|
|
788
|
+
file: attachmentFile,
|
|
789
|
+
description: payloadData === null || payloadData === void 0 ? void 0 : payloadData.description,
|
|
790
|
+
storageMetadata: fileRecord.storageMetadata,
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
return attachments;
|
|
794
|
+
}
|
|
795
|
+
catch (_a) {
|
|
796
|
+
return [];
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
async deleteNotificationAttachment(notificationId, attachmentId) {
|
|
800
|
+
const communication = await this.medplum.readResource('Communication', notificationId);
|
|
801
|
+
// Check if attachment exists
|
|
802
|
+
const hasAttachment = (communication.payload || []).some((p) => {
|
|
803
|
+
var _a;
|
|
804
|
+
const url = (_a = p.contentAttachment) === null || _a === void 0 ? void 0 : _a.url;
|
|
805
|
+
return url && url.includes(attachmentId);
|
|
806
|
+
});
|
|
807
|
+
if (!hasAttachment) {
|
|
808
|
+
throw new Error(`Attachment ${attachmentId} not found for notification ${notificationId}`);
|
|
809
|
+
}
|
|
810
|
+
// Filter out the attachment from payload
|
|
811
|
+
const updatedPayload = (communication.payload || []).filter((p) => {
|
|
812
|
+
var _a;
|
|
813
|
+
const url = (_a = p.contentAttachment) === null || _a === void 0 ? void 0 : _a.url;
|
|
814
|
+
if (!url)
|
|
815
|
+
return true;
|
|
816
|
+
return !url.includes(attachmentId);
|
|
817
|
+
});
|
|
818
|
+
const updated = {
|
|
819
|
+
...communication,
|
|
820
|
+
payload: updatedPayload,
|
|
821
|
+
};
|
|
822
|
+
await this.medplum.updateResource(updated);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
exports.MedplumNotificationBackend = MedplumNotificationBackend;
|
|
826
|
+
class MedplumNotificationBackendFactory {
|
|
827
|
+
create(medplum) {
|
|
828
|
+
return new MedplumNotificationBackend(medplum);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
exports.MedplumNotificationBackendFactory = MedplumNotificationBackendFactory;
|