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.
@@ -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;