Skip to content

Commit 8a90c0f

Browse files
committed
Release vintasend-medplum@0.9.0
1 parent 6fd716c commit 8a90c0f

3 files changed

Lines changed: 184 additions & 3 deletions

File tree

package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "vintasend-medplum",
3-
"version": "0.8.2",
3+
"version": "0.9.0",
44
"description": "",
55
"main": "dist/index.js",
66
"scripts": {
@@ -25,7 +25,7 @@
2525
"@medplum/fhirtypes": "^5.0.10",
2626
"pdfmake": "^0.2.23",
2727
"pug": "^3.0.3",
28-
"vintasend": "^0.8.2"
28+
"vintasend": "^0.9.0"
2929
},
3030
"devDependencies": {
3131
"@medplum/definitions": "^5.0.10",
@@ -37,6 +37,7 @@
3737
"jest": "^30.2.0",
3838
"ts-jest": "^29.4.6",
3939
"ts-node": "^10.9.2",
40-
"typescript": "^5.9.3"
40+
"typescript": "^5.9.3",
41+
"vintasend": "file:../../.."
4142
}
4243
}

src/__tests__/medplum-backend.test.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -695,6 +695,103 @@ describe('MedplumNotificationBackend', () => {
695695
});
696696
});
697697

698+
describe('applyReplicationSnapshotIfNewer', () => {
699+
it('should skip apply when destination notification is newer', async () => {
700+
const snapshot = {
701+
id: 'notif-1',
702+
userId: 'user-123',
703+
notificationType: 'EMAIL',
704+
title: 'Incoming',
705+
bodyTemplate: 'body',
706+
contextName: 'testContext',
707+
contextParameters: { param1: 'value1' },
708+
sendAfter: null,
709+
subjectTemplate: 'subject',
710+
status: 'SENT',
711+
contextUsed: null,
712+
extraParams: null,
713+
adapterUsed: null,
714+
sentAt: null,
715+
readAt: null,
716+
gitCommitSha: null,
717+
createdAt: new Date('2026-02-28T10:00:00.000Z'),
718+
updatedAt: new Date('2026-02-28T11:00:00.000Z'),
719+
};
720+
const destinationNewer = {
721+
...snapshot,
722+
updatedAt: new Date('2026-02-28T12:00:00.000Z'),
723+
};
724+
725+
jest.spyOn(backend, 'getNotification').mockResolvedValue(
726+
// biome-ignore lint/suspicious/noExplicitAny: test-only cast
727+
destinationNewer as any,
728+
);
729+
const persistNotificationUpdateSpy = jest
730+
.spyOn(backend, 'persistNotificationUpdate')
731+
.mockResolvedValue(
732+
// biome-ignore lint/suspicious/noExplicitAny: test-only cast
733+
destinationNewer as any,
734+
);
735+
736+
const result = await backend.applyReplicationSnapshotIfNewer(
737+
// biome-ignore lint/suspicious/noExplicitAny: test-only cast
738+
snapshot as any,
739+
);
740+
741+
expect(result).toEqual({ applied: false });
742+
expect(persistNotificationUpdateSpy).not.toHaveBeenCalled();
743+
});
744+
745+
it('should apply update when destination notification is older', async () => {
746+
const snapshot = {
747+
id: 'notif-1',
748+
userId: 'user-123',
749+
notificationType: 'EMAIL',
750+
title: 'Incoming title',
751+
bodyTemplate: 'body',
752+
contextName: 'testContext',
753+
contextParameters: { param1: 'value1' },
754+
sendAfter: null,
755+
subjectTemplate: 'subject',
756+
status: 'SENT',
757+
contextUsed: null,
758+
extraParams: null,
759+
adapterUsed: null,
760+
sentAt: null,
761+
readAt: null,
762+
gitCommitSha: null,
763+
createdAt: new Date('2026-02-28T10:00:00.000Z'),
764+
updatedAt: new Date('2026-02-28T11:00:00.000Z'),
765+
};
766+
const destinationOlder = {
767+
...snapshot,
768+
updatedAt: new Date('2026-02-28T09:00:00.000Z'),
769+
};
770+
771+
jest.spyOn(backend, 'getNotification').mockResolvedValue(
772+
// biome-ignore lint/suspicious/noExplicitAny: test-only cast
773+
destinationOlder as any,
774+
);
775+
const persistNotificationUpdateSpy = jest
776+
.spyOn(backend, 'persistNotificationUpdate')
777+
.mockResolvedValue(
778+
// biome-ignore lint/suspicious/noExplicitAny: test-only cast
779+
snapshot as any,
780+
);
781+
782+
const result = await backend.applyReplicationSnapshotIfNewer(
783+
// biome-ignore lint/suspicious/noExplicitAny: test-only cast
784+
snapshot as any,
785+
);
786+
787+
expect(result).toEqual({ applied: true });
788+
expect(persistNotificationUpdateSpy).toHaveBeenCalledWith(
789+
'notif-1',
790+
expect.objectContaining({ title: 'Incoming title' }),
791+
);
792+
});
793+
});
794+
698795
describe('filterNotifications', () => {
699796
it('should map sendAfterRange to sent date comparators in FHIR search params', async () => {
700797
const from = new Date('2026-01-01T00:00:00.000Z');

src/medplum-backend.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,17 @@ export class MedplumNotificationBackend<Config extends BaseNotificationTypeConfi
121121
};
122122
}
123123

124+
private isDuplicateReplicationConflict(error: unknown): boolean {
125+
const normalizedError = String(error).toLowerCase();
126+
127+
return (
128+
normalizedError.includes('duplicate') ||
129+
normalizedError.includes('unique') ||
130+
normalizedError.includes('already exists') ||
131+
normalizedError.includes('conflict')
132+
);
133+
}
134+
124135
private convertNotificationOrderByToFhirSort(orderBy: NotificationOrderBy): string {
125136
const fieldMap: Record<NotificationOrderBy['field'], string> = {
126137
sendAfter: 'sent',
@@ -439,6 +450,78 @@ export class MedplumNotificationBackend<Config extends BaseNotificationTypeConfi
439450
return this.mapToDatabaseNotification(result) as DatabaseNotification<Config>;
440451
}
441452

453+
async applyReplicationSnapshotIfNewer(
454+
snapshot: AnyDatabaseNotification<Config>,
455+
): Promise<{ applied: boolean }> {
456+
const existingNotification = await this.getNotification(snapshot.id, false);
457+
458+
if (
459+
existingNotification?.updatedAt &&
460+
snapshot.updatedAt &&
461+
existingNotification.updatedAt >= snapshot.updatedAt
462+
) {
463+
return { applied: false };
464+
}
465+
466+
if ('emailOrPhone' in snapshot) {
467+
if (existingNotification) {
468+
await this.persistOneOffNotificationUpdate(
469+
snapshot.id,
470+
snapshot as unknown as Partial<Omit<OneOffNotificationInput<Config>, 'id'>>,
471+
);
472+
473+
return { applied: true };
474+
}
475+
476+
try {
477+
await this.persistOneOffNotification(
478+
snapshot as unknown as Omit<OneOffNotificationInput<Config>, 'id'> & {
479+
id?: Config['NotificationIdType'];
480+
},
481+
);
482+
} catch (createError) {
483+
if (!this.isDuplicateReplicationConflict(createError)) {
484+
throw createError;
485+
}
486+
487+
await this.persistOneOffNotificationUpdate(
488+
snapshot.id,
489+
snapshot as unknown as Partial<Omit<OneOffNotificationInput<Config>, 'id'>>,
490+
);
491+
}
492+
493+
return { applied: true };
494+
}
495+
496+
if (existingNotification) {
497+
await this.persistNotificationUpdate(
498+
snapshot.id,
499+
snapshot as unknown as Partial<Omit<Notification<Config>, 'id'>>,
500+
);
501+
502+
return { applied: true };
503+
}
504+
505+
try {
506+
await this.persistNotification(
507+
snapshot as unknown as Omit<Notification<Config>, 'id'> & {
508+
id?: Config['NotificationIdType'];
509+
},
510+
);
511+
} catch (createError) {
512+
if (!this.isDuplicateReplicationConflict(createError)) {
513+
throw createError;
514+
}
515+
516+
await this.persistNotificationUpdate(
517+
snapshot.id,
518+
snapshot as unknown as Partial<Omit<Notification<Config>, 'id'>>,
519+
);
520+
}
521+
522+
return { applied: true };
523+
}
524+
442525
/**
443526
* Update FHIR identifiers from a partial notification update.
444527
* Replaces identifier values for known systems when new values are provided.

0 commit comments

Comments
 (0)