Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
# SPDX-FileCopyrightText: 2023 Stanford University
# SPDX-License-Identifier: MIT

*.local.*
.DS_Store
credentials.json

Expand Down
2 changes: 2 additions & 0 deletions functions/models/src/functions/createInvitation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@
//

import { z } from "zod";
import { optionalish } from "../helpers/optionalish.js";
import { userAuthConverter } from "../types/userAuth.js";
import { userRegistrationConverter } from "../types/userRegistration.js";

export const createInvitationInputSchema = z.object({
auth: z.lazy(() => userAuthConverter.value.schema),
user: z.lazy(() => userRegistrationConverter.value.schema),
permanent: optionalish(z.boolean()),
});
export type CreateInvitationInput = z.input<typeof createInvitationInputSchema>;

Expand Down
5 changes: 5 additions & 0 deletions functions/models/src/types/invitation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,14 @@ export const invitationConverter = new Lazy(
code: z.string(),
auth: optionalish(z.lazy(() => userAuthConverter.value.schema)),
user: z.lazy(() => userRegistrationConverter.value.schema),
permanent: optionalish(z.boolean()),
})
.transform((values) => new Invitation(values)),
encode: (object) => ({
code: object.code,
auth: object.auth ? userAuthConverter.value.encode(object.auth) : null,
user: userRegistrationConverter.value.encode(object.user),
permanent: object.permanent,
}),
}),
);
Expand All @@ -40,16 +42,19 @@ export class Invitation {
readonly code: string;
readonly auth?: UserAuth;
readonly user: UserRegistration;
readonly permanent: boolean;

// Constructor

constructor(input: {
code: string;
auth?: UserAuth;
user: UserRegistration;
permanent?: boolean;
}) {
this.code = input.code;
this.auth = input.auth;
this.user = input.user;
this.permanent = input.permanent ?? false;
}
}
31 changes: 31 additions & 0 deletions functions/src/functions/createInvitation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,37 @@ describeWithEmulators("function: createInvitation", (env) => {
const invitation = invitations.docs[0].data();
expect(invitation.code).toHaveLength(8);
expect(invitation.code).toMatch(/^[A-Z0-9]{8}$/);
expect(invitation.permanent).toBe(false);
});

it("should create a permanent invitation", async () => {
const input: z.input<typeof createInvitationInputSchema> = {
auth: {
displayName: "Test User",
email: "engagehf-test@stanford.edu",
},
permanent: true,
user: {
type: UserType.patient,
organization: "stanford",
receivesAppointmentReminders: false,
receivesInactivityReminders: true,
receivesMedicationUpdates: true,
receivesQuestionnaireReminders: false,
receivesRecommendationUpdates: true,
receivesVitalsReminders: false,
receivesWeightAlerts: false,
},
};

await env.call(createInvitation, input, {
uid: "test",
token: { type: UserType.clinician, organization: "stanford" },
});

const invitations = await env.collections.invitations.get();
expect(invitations.docs).toHaveLength(1);
expect(invitations.docs[0].data().permanent).toBe(true);
});

it("should not allow clinician to create a clinician invitation", async () => {
Expand Down
86 changes: 86 additions & 0 deletions functions/src/functions/enrollUser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,4 +282,90 @@ describeWithEmulators("function: enrollUser", (env) => {
QuestionnaireReference.registration_en_US,
);
});

function patientInvitation(permanent: boolean): Invitation {
return new Invitation({
auth: new UserAuth({ email: "engagehf-test@stanford.edu" }),
code: "TESTCODE",
permanent,
user: new UserRegistration({
type: UserType.patient,
disabled: false,
selfManaged: false,
organization: "stanford",
receivesAppointmentReminders: true,
receivesInactivityReminders: true,
receivesMedicationUpdates: true,
receivesQuestionnaireReminders: true,
receivesRecommendationUpdates: true,
receivesVitalsReminders: true,
receivesWeightAlerts: true,
}),
});
}

async function enrollAndFinish(): Promise<string> {
const authUser = await env.auth.createUser({});
await env.call(
enrollUser,
{ invitationCode: "TESTCODE" },
{ uid: authUser.uid },
);
const userService = env.factory.user();
const dbUser = await userService.getUser(authUser.uid);
expect(dbUser).toBeDefined();
if (dbUser !== undefined) await userService.finishUserEnrollment(dbUser);
return authUser.uid;
}

it("allows a permanent invitation to be reused by multiple users", async () => {
const invitationRef = env.collections.invitations.doc();
await invitationRef.set(patientInvitation(true));

const seededAppointment = new FHIRAppointment({
status: FHIRAppointmentStatus.booked,
created: new Date("2023-12-24"),
start: new Date("2023-12-31"),
end: new Date("2024-01-01"),
participant: [],
});
await env.collections
.invitationAppointments(invitationRef.id)
.doc()
.set(seededAppointment);

// First enrollee receives the seeded data and the invitation survives.
const firstUserId = await enrollAndFinish();

const invitationsAfterFirst = await env.collections.invitations.get();
expect(invitationsAfterFirst.docs).toHaveLength(1);
const remainingAppointments = await env.collections
.invitationAppointments(invitationRef.id)
.get();
expect(remainingAppointments.docs).toHaveLength(1);
const firstUserAppointments = await env.collections
.userAppointments(firstUserId)
.get();
expect(firstUserAppointments.docs).toHaveLength(1);

// Second enrollee can reuse the same code and also receives the seeded data.
const secondUserId = await enrollAndFinish();

const invitationsAfterSecond = await env.collections.invitations.get();
expect(invitationsAfterSecond.docs).toHaveLength(1);
const secondUserAppointments = await env.collections
.userAppointments(secondUserId)
.get();
expect(secondUserAppointments.docs).toHaveLength(1);
});

it("deletes a non-permanent invitation after enrollment", async () => {
const invitationRef = env.collections.invitations.doc();
await invitationRef.set(patientInvitation(false));

await enrollAndFinish();

const invitations = await env.collections.invitations.get();
expect(invitations.docs).toHaveLength(0);
});
});
121 changes: 121 additions & 0 deletions functions/src/functions/exportData.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ import { writeFileSync } from "fs";
import {
CachingStrategy,
DebugDataComponent,
DrugReference,
FHIRMedicationRequest,
fhirMedicationRequestConverter,
StaticDataComponent,
UserDebugDataComponent,
UserObservationCollection,
Expand Down Expand Up @@ -223,6 +226,124 @@ describeWithEmulators("function: exportData", (env) => {
}
}, 10_000);

it("reconstructs medication history with lifecycle dates", async () => {
const patient = await env.createUser({
type: UserType.patient,
organization: "stanford",
});
const admin = await env.createUser({ type: UserType.admin });

const history = env.factory.history();
const encode = (request: FHIRMedicationRequest) =>
fhirMedicationRequestConverter.value.encode(request);

// Medication that was added and later removed: expect exactly one row with
// both `created` and `removed` set and no live document.
const removedId = "med-removed";
const removedPath = `users/${patient}/medicationRequests/${removedId}`;
const removedMedication = FHIRMedicationRequest.create({
medicationReference: DrugReference.carvedilol3_125,
frequencyPerDay: 1,
quantity: 1,
});
await history.recordChange(
env.createChange(removedPath, undefined, encode(removedMedication)),
);
await history.recordChange(
env.createChange(removedPath, encode(removedMedication), undefined),
);

// Medication whose dose changed and is still active: expect one row showing
// the final dose, `created` set and `removed` empty.
const changedId = "med-changed";
const changedPath = `users/${patient}/medicationRequests/${changedId}`;
const initialDose = FHIRMedicationRequest.create({
medicationReference: DrugReference.carvedilol3_125,
frequencyPerDay: 1,
quantity: 1,
});
const finalDose = FHIRMedicationRequest.create({
medicationReference: DrugReference.carvedilol3_125,
frequencyPerDay: 2,
quantity: 1,
});
await history.recordChange(
env.createChange(changedPath, undefined, encode(initialDose)),
);
await history.recordChange(
env.createChange(changedPath, encode(initialDose), encode(finalDose)),
);
await env.collections
.userMedicationRequests(patient)
.doc(changedId)
.set(finalDose);

// Medication that exists live but has no history (predates tracking):
// expect one row with empty lifecycle dates.
const liveOnlyId = "med-liveonly";
const liveOnlyMedication = FHIRMedicationRequest.create({
medicationReference: DrugReference.carvedilol25,
frequencyPerDay: 1,
quantity: 1,
});
await env.collections
.userMedicationRequests(patient)
.doc(liveOnlyId)
.set(liveOnlyMedication);

const result = await env.call(
exportData,
{ userId: patient },
{ uid: admin, token: { type: UserType.admin } },
);
const zip = await yauzl.fromBuffer(Buffer.from(result.content, "base64"));
const entries = await zip.readEntries();
const entry = entries.find(
(entry) => entry.filename === `${patient}/medicationRequests.csv`,
);
expect(entry).toBeDefined();

const lines = (await entryBuffer(entry!))
.toString("utf-8")
.split("\n")
.filter((line) => line.trim().length > 0);

expect(lines[0].split(";")).toEqual([
"id",
"medicationCode (RxNorm)",
"drugCode (RxNorm)",
"quantity",
"quantityUnit",
"frequencyPerDay",
"created",
"lastUpdated",
"removed",
]);

// One row per medication (add+remove collapses to a single row).
expect(lines).toHaveLength(4);
const rows = new Map(
lines.slice(1).map((line) => {
const columns = line.split(";");
return [columns[0], columns];
}),
);

const removedRow = rows.get(removedId)!;
expect(removedRow).toBeDefined();
expect(removedRow[6]).not.toBe(""); // created
expect(removedRow[8]).not.toBe(""); // removed

const changedRow = rows.get(changedId)!;
expect(changedRow[5]).toBe("2"); // final frequencyPerDay
expect(changedRow[6]).not.toBe(""); // created
expect(changedRow[8]).toBe(""); // still active, not removed

const liveOnlyRow = rows.get(liveOnlyId)!;
expect(liveOnlyRow[6]).toBe(""); // no history -> empty created
expect(liveOnlyRow[8]).toBe(""); // not removed
}, 10_000);

async function expectZipToBeEquivalent(
file0: ZipFile,
file1: ZipFile,
Expand Down
Loading
Loading