From 51df2a94e938116f719ac812483323f5ffb116a1 Mon Sep 17 00:00:00 2001 From: Jackson Tran <69227799+javtran@users.noreply.github.com> Date: Mon, 26 Aug 2024 15:14:17 -0700 Subject: [PATCH 1/4] [Closes #95] User can register through invite (#99) * user can accept invite and register * backend changes for register without license * Fixes and API tweaks * Fix dependency array again * license field hides when invite role is not first_responder --------- Co-authored-by: Francis Li --- client/src/App.jsx | 1 + client/src/pages/register/RegisterForm.jsx | 8 +++- client/src/pages/register/register.jsx | 48 +++++++++++++++++++++- server/models/user.js | 4 ++ server/routes/api/v1/users/register.js | 48 +++++++++++++--------- server/test/routes/api/v1/users.test.js | 2 +- 6 files changed, 88 insertions(+), 23 deletions(-) diff --git a/client/src/App.jsx b/client/src/App.jsx index 64d34a46..89331311 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -104,6 +104,7 @@ function App() { element={} > } /> + } /> } /> } /> diff --git a/client/src/pages/register/RegisterForm.jsx b/client/src/pages/register/RegisterForm.jsx index dfd783f2..51ef210e 100644 --- a/client/src/pages/register/RegisterForm.jsx +++ b/client/src/pages/register/RegisterForm.jsx @@ -18,6 +18,7 @@ const registerFormProps = { onSubmit: PropTypes.func.isRequired, setShowLicenseHelper: PropTypes.func.isRequired, formState: PropTypes.number.isRequired, + showLicenseField: PropTypes.string.isRequired, }; /** @@ -33,6 +34,7 @@ export function RegisterForm({ onSubmit, setShowLicenseHelper, formState, + showLicenseField, }) { return ( <> @@ -43,7 +45,7 @@ export function RegisterForm({ }} > - {formState !== 3 && ( + {formState !== 3 && showLicenseField && ( <> {isLoading ? ( diff --git a/client/src/pages/register/register.jsx b/client/src/pages/register/register.jsx index dd0b94b8..4c661ccd 100644 --- a/client/src/pages/register/register.jsx +++ b/client/src/pages/register/register.jsx @@ -1,12 +1,16 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import classes from './register.module.css'; import { RegisterForm } from './RegisterForm'; import { Flex } from '@mantine/core'; +import { useParams } from 'react-router-dom'; +import { useNavigate } from 'react-router-dom'; +import { notifications } from '@mantine/notifications'; /** * Register page component */ function Register() { + const { inviteId } = useParams(); const [user, setUser] = useState({ firstName: '', middleName: '', @@ -19,6 +23,45 @@ function Register() { const [showLicenseHelper, setShowLicenseHelper] = useState(false); const [isLoading, setIsLoading] = useState(false); const [formState, setFormState] = useState(1); + const navigate = useNavigate(); + + useEffect(() => { + if (!inviteId) return; + fetch(`/api/v1/invites/${inviteId}`) + .then((response) => { + if (!response.ok) { + return Promise.reject(response); + } + return response.json(); + }) + .then((data) => { + if (data.acceptedById.length != 0) { + throw Error(); + } + + setUser((prevUser) => ({ + ...prevUser, + firstName: data.firstName, + middleName: data.middleName, + lastName: data.lastName, + email: data.email, + role: data.role, + inviteId: inviteId, + })); + + if (data.role != 'FIRST_RESPONDER') { + setFormState(2); + } + }) + .catch(() => { + notifications.show({ + color: 'red', + title: `Invalid invite`, + autoClose: 5000, + }); + navigate('/register'); + }); + }, [inviteId, navigate]); /** * Handles input fields in the Registration form @@ -137,6 +180,9 @@ function Register() { setShowLicenseHelper(!showLicenseHelper); }} formState={formState} + showLicenseField={ + !inviteId || (inviteId && user.role == 'FIRST_RESPONDER') + } /> diff --git a/server/models/user.js b/server/models/user.js index 41a325c1..6a3cd870 100644 --- a/server/models/user.js +++ b/server/models/user.js @@ -38,6 +38,10 @@ class User extends Base { inviteId: z.string().optional(), }); + static get Role() { + return Role; + } + constructor(data) { super(Prisma.UserScalarFieldEnum, data); } diff --git a/server/routes/api/v1/users/register.js b/server/routes/api/v1/users/register.js index fdd28a35..e6594312 100644 --- a/server/routes/api/v1/users/register.js +++ b/server/routes/api/v1/users/register.js @@ -101,26 +101,31 @@ export default async function (fastify, _opts) { let licenseData; // Validate License Numbers - try { - const licenseResponse = await verifyLicense(licenseNumber); - if (licenseResponse && licenseResponse.status != 'Expired') { - const userFromLicense = await fastify.prisma.user.findUnique({ - where: { licenseNumber: licenseNumber }, - }); + if ( + (!invite && licenseNumber) || + invite.role == User.Role.FIRST_RESPONDER + ) { + try { + const licenseResponse = await verifyLicense(licenseNumber); + if (licenseResponse && licenseResponse.status !== 'Expired') { + const userFromLicense = await fastify.prisma.user.findUnique({ + where: { licenseNumber: licenseNumber }, + }); - if (userFromLicense) { - throw new Error('License already registered'); - } + if (userFromLicense) { + throw new Error('License already registered'); + } - licenseData = licenseResponse; - } else { - throw new Error('Expired or unprocessable license data'); + licenseData = licenseResponse; + } else { + throw new Error('Expired or unprocessable license data'); + } + } catch (error) { + errorList.push({ + path: 'licenseNumber', + message: error.message, + }); } - } catch (error) { - errorList.push({ - path: 'licenseNumber', - message: error.message, - }); } if (errorList.length) { @@ -142,13 +147,18 @@ export default async function (fastify, _opts) { // Hash the password await user.setPassword(password); + const now = new Date().toISOString(); if (invite) { user.role = invite.role; + if (user.role !== User.Role.FIRST_RESPONDER) { + user.licenseNumber = null; + } user.approvedById = invite.invitedById; user.approvedAt = invite.createdAt; + user.emailVerifiedAt = now; } else { // Set role - user.role = 'FIRST_RESPONDER'; + user.role = User.Role.FIRST_RESPONDER; // Generate verification token and send user.generateEmailVerificationToken(); await user.sendVerificationEmail(); @@ -161,7 +171,7 @@ export default async function (fastify, _opts) { where: { id: invite.id }, data: { acceptedById: data.id, - acceptedAt: new Date().toISOString(), + acceptedAt: now, }, }); } diff --git a/server/test/routes/api/v1/users.test.js b/server/test/routes/api/v1/users.test.js index 6bdb7e39..c27c5395 100644 --- a/server/test/routes/api/v1/users.test.js +++ b/server/test/routes/api/v1/users.test.js @@ -220,7 +220,6 @@ describe('/api/v1/users', () => { lastName: 'Doe', email: 'john.doe@test.com', password: 'Test123!', - licenseNumber: 'P39332', inviteId: '6ed61e21-1062-4b10-a967-53b395f5c34c', }); assert.deepStrictEqual(res.statusCode, StatusCodes.CREATED); @@ -241,6 +240,7 @@ describe('/api/v1/users', () => { Date.parse(user.approvedAt), Date.parse('2024-04-07T16:53:41-07:00'), ); + assert.ok(user.emailVerifiedAt); const invite = await t.prisma.invite.findUnique({ where: { id: '6ed61e21-1062-4b10-a967-53b395f5c34c' }, From 91103b1f50c2074cb7bf226250ba800a1a9fdd9a Mon Sep 17 00:00:00 2001 From: Jackson Tran <69227799+javtran@users.noreply.github.com> Date: Mon, 26 Aug 2024 15:20:55 -0700 Subject: [PATCH 2/4] [Closes #96] User can verify their account using email token (#100) * user can verify their account using email * token field is UUID, cleared when verified * adjust verify test * fix verify find * error catch --------- Co-authored-by: Francis Li --- client/src/App.jsx | 6 +- client/src/pages/verify/verify.jsx | 57 +++++++++++++++++++ server/models/user.js | 3 +- .../migration.sql | 5 +- server/prisma/schema.prisma | 2 +- server/routes/api/v1/users/verify.js | 53 +++++++++++++++++ server/test/fixtures/db/User.yml | 1 + server/test/routes/api/v1/users.test.js | 38 +++++++++++++ 8 files changed, 159 insertions(+), 6 deletions(-) create mode 100644 client/src/pages/verify/verify.jsx rename server/prisma/migrations/{20240602204712_initial_data_model => 20240819222221_}/migration.sql (97%) create mode 100644 server/routes/api/v1/users/verify.js diff --git a/client/src/App.jsx b/client/src/App.jsx index 89331311..4a2edcfe 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -20,6 +20,7 @@ import { AdminUsers } from './pages/admin/users/AdminUsers'; import Context from './Context'; import AdminPendingUsers from './pages/admin/pending-users/AdminPendingUsers'; +import Verify from './pages/verify/verify'; const RedirectProps = { isLoading: PropTypes.bool.isRequired, @@ -103,10 +104,11 @@ function App() { } > + } /> + } /> } /> } /> - } /> - } /> + } /> diff --git a/client/src/pages/verify/verify.jsx b/client/src/pages/verify/verify.jsx new file mode 100644 index 00000000..44de472e --- /dev/null +++ b/client/src/pages/verify/verify.jsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { LoadingOverlay } from '@mantine/core'; +import { useQuery } from '@tanstack/react-query'; +import { useNavigate, useParams } from 'react-router-dom'; +import { notifications } from '@mantine/notifications'; +/** + * Email Verification + */ +function Verify() { + const { emailVerificationToken } = useParams(); + const navigate = useNavigate(); + + const { isFetching } = useQuery({ + queryFn: () => + fetch('/api/v1/users/verify', { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + emailVerificationToken: emailVerificationToken, + }), + }) + .then((response) => { + if (!response.ok) { + return Promise.reject(response); + } + }) + .then(() => { + notifications.show({ + color: 'green', + title: 'Your email address was successfully verified.', + autoClose: 5000, + }); + navigate('/login'); + }) + .catch(() => { + notifications.show({ + color: 'red', + title: `Invalid Email Verification Token`, + autoClose: 5000, + }); + navigate('/'); + }), + }); + return ( +
+ +
+ ); +} + +export default Verify; diff --git a/server/models/user.js b/server/models/user.js index 6a3cd870..6d6baff0 100644 --- a/server/models/user.js +++ b/server/models/user.js @@ -77,8 +77,7 @@ class User extends Base { } generateEmailVerificationToken() { - const buffer = crypto.randomBytes(3); - this.emailVerificationToken = buffer.toString('hex').toUpperCase(); + this.emailVerificationToken = crypto.randomUUID(); } async sendVerificationEmail() { diff --git a/server/prisma/migrations/20240602204712_initial_data_model/migration.sql b/server/prisma/migrations/20240819222221_/migration.sql similarity index 97% rename from server/prisma/migrations/20240602204712_initial_data_model/migration.sql rename to server/prisma/migrations/20240819222221_/migration.sql index 9ee1ebda..0d61ea72 100644 --- a/server/prisma/migrations/20240602204712_initial_data_model/migration.sql +++ b/server/prisma/migrations/20240819222221_/migration.sql @@ -26,7 +26,7 @@ CREATE TABLE "User" ( "middleName" TEXT, "lastName" TEXT NOT NULL, "email" CITEXT NOT NULL, - "emailVerificationToken" TEXT, + "emailVerificationToken" UUID, "emailVerifiedAt" TIMESTAMP(3), "role" "Role" NOT NULL, "hashedPassword" TEXT NOT NULL, @@ -167,6 +167,9 @@ CREATE TABLE "_HospitalToPhysician" ( -- CreateIndex CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); +-- CreateIndex +CREATE UNIQUE INDEX "User_emailVerificationToken_key" ON "User"("emailVerificationToken"); + -- CreateIndex CREATE UNIQUE INDEX "User_licenseNumber_key" ON "User"("licenseNumber"); diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index b540301c..4cdbf497 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -25,7 +25,7 @@ model User { middleName String? lastName String email String @unique @db.Citext - emailVerificationToken String? + emailVerificationToken String? @db.Uuid @unique emailVerifiedAt DateTime? role Role hashedPassword String diff --git a/server/routes/api/v1/users/verify.js b/server/routes/api/v1/users/verify.js new file mode 100644 index 00000000..d1f506c6 --- /dev/null +++ b/server/routes/api/v1/users/verify.js @@ -0,0 +1,53 @@ +import { StatusCodes } from 'http-status-codes'; +import User from '../../../../models/user.js'; + +export default async function (fastify, _opts) { + fastify.patch( + '/verify', + { + schema: { + body: { + type: 'object', + required: ['emailVerificationToken'], + properties: { + emailVerificationToken: { type: 'string' }, + }, + }, + response: { + [StatusCodes.OK]: { + type: 'null', + }, + [StatusCodes.NOT_FOUND]: { + type: 'null', + }, + }, + }, + }, + async (request, reply) => { + const { emailVerificationToken } = request.body; + let data; + try { + data = await fastify.prisma.user.findUnique({ + where: { emailVerificationToken }, + }); + } catch (error) { + return reply.notFound(); + } + if (!data) { + return reply.notFound(); + } + const user = new User(data); + + if (!user.isEmailVerified) { + data = await fastify.prisma.user.update({ + where: { id: user.id }, + data: { + emailVerifiedAt: new Date(), + emailVerificationToken: null, + }, + }); + } + reply.code(StatusCodes.OK); + }, + ); +} diff --git a/server/test/fixtures/db/User.yml b/server/test/fixtures/db/User.yml index bd8177bf..cf94fa4d 100644 --- a/server/test/fixtures/db/User.yml +++ b/server/test/fixtures/db/User.yml @@ -17,6 +17,7 @@ items: email: unverified.email@test.com role: FIRST_RESPONDER hashedPassword: $2b$10$ICaCk3VVZUCtO9HySahquuQusQhEnRpXHdzxaceUUJPk0DTwN2e/W # test + emailVerificationToken: be63f7ca-64c5-4eea-a1c0-4c81e7161fa4 user3: id: f4a4be16-e1a5-49dd-9f21-11b1650057f5 firstName: Unapproved diff --git a/server/test/routes/api/v1/users.test.js b/server/test/routes/api/v1/users.test.js index c27c5395..4e0731df 100644 --- a/server/test/routes/api/v1/users.test.js +++ b/server/test/routes/api/v1/users.test.js @@ -695,4 +695,42 @@ describe('/api/v1/users', () => { assert.deepStrictEqual(user.disabledAt, null); }); }); + describe('PATCH /verify', () => { + it('should allow user to verify account through email verification', async (t) => { + const app = await build(t); + await t.loadFixtures(); + + const reply = await app.inject().patch('/api/v1/users/verify').payload({ + emailVerificationToken: 'be63f7ca-64c5-4eea-a1c0-4c81e7161fa4', + }); + + assert.deepStrictEqual(reply.statusCode, StatusCodes.OK); + + const user = await t.prisma.user.findUnique({ + where: { id: 'dab5dff3-360d-4dbb-98dd-1990dfb5c4c5' }, + }); + assert.ok(user); + assert.deepStrictEqual(user.emailVerificationToken, null); + + const date = new Date(user.emailVerifiedAt); + const today = new Date(); + + assert.deepStrictEqual( + date.toISOString().split('T')[0], + today.toISOString().split('T')[0], + ); + }); + }); + + it('should return 404 if no token exist', async (t) => { + const app = await build(t); + await t.loadFixtures(); + + const reply = await app.inject().patch('/api/v1/users/verify').payload({ + emailVerificationToken: 'NOEXIST', + }); + + assert.deepStrictEqual(reply.statusCode, StatusCodes.NOT_FOUND); + assert.deepStrictEqual(reply.statusMessage, 'Not Found'); + }); }); From ae1b6cd1fbfbac6476fdff42e70f268aa919d84e Mon Sep 17 00:00:00 2001 From: Jackson Tran <69227799+javtran@users.noreply.github.com> Date: Mon, 26 Aug 2024 15:28:39 -0700 Subject: [PATCH 3/4] [Closes #94 ] login display error (#102) * basic invalid email and password error display * added error messages for other errors and adjusted test cases * lint fix * changed status code to using http-status-codes * Add http-status-codes to client and replace hard-coded error values --------- Co-authored-by: Francis Li --- client/package.json | 1 + client/src/hooks/useAuthorization.jsx | 18 +++++++++-- client/src/pages/login/login.jsx | 43 ++++++++++++++++---------- package-lock.json | 11 ++++--- server/models/user.js | 2 +- server/routes/api/v1/auth/index.js | 22 +++++++++++-- server/test/routes/api/v1/auth.test.js | 40 ++++++++++++++++++++++++ 7 files changed, 110 insertions(+), 27 deletions(-) diff --git a/client/package.json b/client/package.json index 5e051d84..26ad967b 100644 --- a/client/package.json +++ b/client/package.json @@ -21,6 +21,7 @@ "@mantine/notifications": "^7.12.1", "@tabler/icons-react": "^3.12.0", "@tanstack/react-query": "^5.51.23", + "http-status-codes": "^2.3.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-qrcode-logo": "^3.0.0", diff --git a/client/src/hooks/useAuthorization.jsx b/client/src/hooks/useAuthorization.jsx index c277b108..99a2e9e6 100644 --- a/client/src/hooks/useAuthorization.jsx +++ b/client/src/hooks/useAuthorization.jsx @@ -1,4 +1,4 @@ -import { useContext } from 'react'; +import { useContext, useState } from 'react'; import { useMutation } from '@tanstack/react-query'; import { useNavigate } from 'react-router-dom'; @@ -10,6 +10,7 @@ import Context from '../Context'; * * @returns {{ * user: user, + * error: object, * isLoading: boolean, * handleLogin: (credentials: any) => Promise, * handleLogout: () => Promise, @@ -17,17 +18,23 @@ import Context from '../Context'; */ export function useAuthorization() { const { user, setUser } = useContext(Context); + const [error, setError] = useState(null); const navigate = useNavigate(); const loginMutation = useMutation({ - mutationFn: (credentials) => { - return fetch('/api/v1/auth/login', { + mutationFn: async (credentials) => { + return await fetch('/api/v1/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(credentials), credentials: 'include', + }).then((response) => { + if (!response.ok) { + return Promise.reject(response); + } + return response; }); }, onSuccess: async (data, { redirectTo }) => { @@ -35,6 +42,10 @@ export function useAuthorization() { setUser(result); navigate(redirectTo ?? '/'); }, + onError: async (error) => { + const errorBody = await error.json(); + setError({ ...errorBody, status: error.status }); + }, }); const logoutMutation = useMutation({ @@ -57,6 +68,7 @@ export function useAuthorization() { return { user, + error, isLoading: loginMutation.isPending || logoutMutation.isPending, handleLogin, handleLogout, diff --git a/client/src/pages/login/login.jsx b/client/src/pages/login/login.jsx index d68e443e..e4fae8ec 100644 --- a/client/src/pages/login/login.jsx +++ b/client/src/pages/login/login.jsx @@ -1,5 +1,6 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { useLocation } from 'react-router-dom'; +import { StatusCodes } from 'http-status-codes'; import { useAuthorization } from '../../hooks/useAuthorization'; import { LoginForm } from './LoginForm'; @@ -14,25 +15,35 @@ function Login() { const [password, setPassword] = useState(''); const [emailError, setEmailError] = useState(null); const [passwordError, setPasswordError] = useState(null); - const { handleLogin } = useAuthorization(); + const { handleLogin, error } = useAuthorization(); const location = useLocation(); - const login = () => { - let isValid = true; - if (!email) { - setEmailError('Invalid email'); - isValid = false; - } - if (!password) { - setPasswordError('Invalid password'); - isValid = false; - } - if (isValid) { - const { redirectTo } = location.state ?? {}; - handleLogin({ email, password, redirectTo }); - } + const login = async () => { + setEmailError(null); + setPasswordError(null); + const { redirectTo } = location.state ?? {}; + await handleLogin({ email, password, redirectTo }); }; + useEffect(() => { + if (error && error.status != StatusCodes.OK) { + switch (error.status) { + case StatusCodes.NOT_FOUND: + setEmailError('The email you entered isn’t connected to an account.'); + break; + case StatusCodes.UNAUTHORIZED: + setPasswordError('The password you’ve entered is incorrect.'); + break; + case StatusCodes.FORBIDDEN: + setEmailError(error.message); + break; + default: + setEmailError(`${error.status}: ${error.message}`); + break; + } + } + }, [error]); + return (
Image goes here
diff --git a/package-lock.json b/package-lock.json index e95034ec..8298a193 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "@mantine/notifications": "^7.12.1", "@tabler/icons-react": "^3.12.0", "@tanstack/react-query": "^5.51.23", + "http-status-codes": "^2.3.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-qrcode-logo": "^3.0.0", @@ -10031,7 +10032,8 @@ "node_modules/http-status-codes": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz", - "integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==" + "integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==", + "license": "MIT" }, "node_modules/https-proxy-agent": { "version": "5.0.1", @@ -12301,10 +12303,11 @@ ] }, "node_modules/micromatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz", - "integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, + "license": "MIT", "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" diff --git a/server/models/user.js b/server/models/user.js index 6d6baff0..575a1d7a 100644 --- a/server/models/user.js +++ b/server/models/user.js @@ -59,7 +59,7 @@ class User extends Base { } get isRejected() { - return !!this.isRejectedAt; + return !!this.rejectedAt; } get isDisabled() { diff --git a/server/routes/api/v1/auth/index.js b/server/routes/api/v1/auth/index.js index 20e56e88..1ced6d39 100644 --- a/server/routes/api/v1/auth/index.js +++ b/server/routes/api/v1/auth/index.js @@ -12,7 +12,7 @@ export default async function (fastify, _opts) { type: 'object', required: ['email', 'password'], properties: { - email: { type: 'string', format: 'email' }, + email: { type: 'string' }, password: { type: 'string' }, }, }, @@ -60,9 +60,25 @@ export default async function (fastify, _opts) { if (!result) { return reply.unauthorized(); } - if (!user.isActive) { - return reply.forbidden(); + if (!user.isEmailVerified) { + return reply.status(StatusCodes.FORBIDDEN).send({ + message: + 'Your account has not been verified. Please check your inbox to verify your account.', + }); } + if (user.isRejected || user.isDisabled) { + return reply.status(StatusCodes.FORBIDDEN).send({ + message: + 'Your account has been rejected or disabled by admins. Please contact support for further instructions.', + }); + } + if (user.isUnapproved) { + return reply.status(StatusCodes.FORBIDDEN).send({ + message: + 'Your account has not been approved by admins yet. Please contact support or wait for further instructions.', + }); + } + request.session.set('userId', user.id); reply.send(data); }, diff --git a/server/test/routes/api/v1/auth.test.js b/server/test/routes/api/v1/auth.test.js index 9aa3867f..ac191035 100644 --- a/server/test/routes/api/v1/auth.test.js +++ b/server/test/routes/api/v1/auth.test.js @@ -33,6 +33,11 @@ describe('/api/v1/auth', () => { password: 'test', }); assert.deepStrictEqual(response.statusCode, StatusCodes.FORBIDDEN); + const { message } = JSON.parse(response.body); + assert.deepStrictEqual( + message, + 'Your account has not been verified. Please check your inbox to verify your account.', + ); }); it('should return forbidden for an unapproved user', async (t) => { @@ -43,6 +48,41 @@ describe('/api/v1/auth', () => { password: 'test', }); assert.deepStrictEqual(response.statusCode, StatusCodes.FORBIDDEN); + const { message } = JSON.parse(response.body); + assert.deepStrictEqual( + message, + 'Your account has not been approved by admins yet. Please contact support or wait for further instructions.', + ); + }); + + it('should return forbidden for a rejected user', async (t) => { + const app = await build(t); + await t.loadFixtures(); + const response = await app.inject().post('/api/v1/auth/login').payload({ + email: 'rejected.user@test.com', + password: 'test', + }); + assert.deepStrictEqual(response.statusCode, StatusCodes.FORBIDDEN); + const { message } = JSON.parse(response.body); + assert.deepStrictEqual( + message, + 'Your account has been rejected or disabled by admins. Please contact support for further instructions.', + ); + }); + + it('should return forbidden for a disabled user', async (t) => { + const app = await build(t); + await t.loadFixtures(); + const response = await app.inject().post('/api/v1/auth/login').payload({ + email: 'disabled.user@test.com', + password: 'test', + }); + assert.deepStrictEqual(response.statusCode, StatusCodes.FORBIDDEN); + const { message } = JSON.parse(response.body); + assert.deepStrictEqual( + message, + 'Your account has been rejected or disabled by admins. Please contact support for further instructions.', + ); }); it('should return ok and a secure session cookie for valid credentials and valid user', async (t) => { From bbf402966edf0e75b7e72ea9904c6a0c01f8fba1 Mon Sep 17 00:00:00 2001 From: Sammy Au <69769431+samau3@users.noreply.github.com> Date: Mon, 26 Aug 2024 16:05:41 -0700 Subject: [PATCH 4/4] [Closes #43] API Client with Admin/Staff/Volunteer role can create a new Patient record (#105) * Basic flow of creating a new patient record working * Wrap patient registration in a db transaction * Move update related actions to separate PATCH route * Modify route and update accepted body properties * Format files * Update prisma schema Connecting patient model with allergy, medication, and condition models * Modify route and update accepted body properties Add ability to connect patient model with allergy, medication, and condition models during update and returns entire patient data * Allow updating of patient's existing emergency contact * Remove unnecessary code * Convert date input string to ISO and return id in JSON * Add tests patient registration route * Return all new patient data rather than just id * Enable updating patientData and fix bug in conditions schema * Add patient tests for current route features * Format code to resolve prettier issues * Remove updating of existing contact in favor of being able to add more contacts * Format file * Add fixtures for hospital and physician * Enable updating a patient record with hospital and physician info * format files * Simplify medical data update code * Allow removing of old medical data connections when updating patient * Accept inputs for gender and language * Add ability to accept gender, language and advanced directive * Update tests for changes in routes * Refactor logic to reduce conditional statements * Add error handling when medical data or healthcare choices ids cannot be found * Move model map definition out of loop * Add ability to maintain order of request items The order in which allergies, medications, and conditions are sent in the request is preserved * Remove extraneous returned fields from patient record and update tests * Use throw instead of return to properly rollback db transaction * Add formatting pattern for phone number * Simplify medical data structure * Tweak API route names * Make id a required field for patients --------- Co-authored-by: Francis Li --- .../migration.sql | 45 ++ server/prisma/schema.prisma | 88 ++- server/routes/api/v1/patients/create.js | 100 +++ server/routes/api/v1/patients/update.js | 326 +++++++++ server/test/fixtures/db/Hospital.yml | 17 + server/test/fixtures/db/Patient.yml | 13 + server/test/fixtures/db/Physician.yml | 22 + server/test/routes/api/v1/allergies.test.js | 1 - server/test/routes/api/v1/patients.test.js | 632 ++++++++++++++++++ 9 files changed, 1217 insertions(+), 27 deletions(-) rename server/prisma/migrations/{20240819222221_ => 20240820044831_init}/migration.sql (79%) create mode 100644 server/routes/api/v1/patients/create.js create mode 100644 server/routes/api/v1/patients/update.js create mode 100644 server/test/fixtures/db/Hospital.yml create mode 100644 server/test/fixtures/db/Patient.yml create mode 100644 server/test/fixtures/db/Physician.yml diff --git a/server/prisma/migrations/20240819222221_/migration.sql b/server/prisma/migrations/20240820044831_init/migration.sql similarity index 79% rename from server/prisma/migrations/20240819222221_/migration.sql rename to server/prisma/migrations/20240820044831_init/migration.sql index 0d61ea72..7f112008 100644 --- a/server/prisma/migrations/20240819222221_/migration.sql +++ b/server/prisma/migrations/20240820044831_init/migration.sql @@ -135,6 +135,33 @@ CREATE TABLE "Condition" ( CONSTRAINT "Condition_pkey" PRIMARY KEY ("id") ); +-- CreateTable +CREATE TABLE "PatientAllergy" ( + "patientId" UUID NOT NULL, + "allergyId" UUID NOT NULL, + "sortOrder" INTEGER NOT NULL, + + CONSTRAINT "PatientAllergy_pkey" PRIMARY KEY ("patientId","allergyId") +); + +-- CreateTable +CREATE TABLE "PatientMedication" ( + "patientId" UUID NOT NULL, + "medicationId" UUID NOT NULL, + "sortOrder" INTEGER NOT NULL, + + CONSTRAINT "PatientMedication_pkey" PRIMARY KEY ("patientId","medicationId") +); + +-- CreateTable +CREATE TABLE "PatientCondition" ( + "patientId" UUID NOT NULL, + "conditionId" UUID NOT NULL, + "sortOrder" INTEGER NOT NULL, + + CONSTRAINT "PatientCondition_pkey" PRIMARY KEY ("patientId","conditionId") +); + -- CreateTable CREATE TABLE "Physician" ( "id" UUID NOT NULL DEFAULT gen_random_uuid(), @@ -215,6 +242,24 @@ ALTER TABLE "Patient" ADD CONSTRAINT "Patient_createdById_fkey" FOREIGN KEY ("cr -- AddForeignKey ALTER TABLE "Patient" ADD CONSTRAINT "Patient_updatedById_fkey" FOREIGN KEY ("updatedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +-- AddForeignKey +ALTER TABLE "PatientAllergy" ADD CONSTRAINT "PatientAllergy_patientId_fkey" FOREIGN KEY ("patientId") REFERENCES "Patient"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PatientAllergy" ADD CONSTRAINT "PatientAllergy_allergyId_fkey" FOREIGN KEY ("allergyId") REFERENCES "Allergy"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PatientMedication" ADD CONSTRAINT "PatientMedication_patientId_fkey" FOREIGN KEY ("patientId") REFERENCES "Patient"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PatientMedication" ADD CONSTRAINT "PatientMedication_medicationId_fkey" FOREIGN KEY ("medicationId") REFERENCES "Medication"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PatientCondition" ADD CONSTRAINT "PatientCondition_patientId_fkey" FOREIGN KEY ("patientId") REFERENCES "Patient"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PatientCondition" ADD CONSTRAINT "PatientCondition_conditionId_fkey" FOREIGN KEY ("conditionId") REFERENCES "Condition"("id") ON DELETE CASCADE ON UPDATE CASCADE; + -- AddForeignKey ALTER TABLE "_HospitalToPhysician" ADD CONSTRAINT "_HospitalToPhysician_A_fkey" FOREIGN KEY ("A") REFERENCES "Hospital"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 4cdbf497..5a30be3a 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -2,13 +2,13 @@ // learn more about it in the docs: https://pris.ly/d/prisma-schema generator client { - provider = "prisma-client-js" + provider = "prisma-client-js" previewFeatures = ["postgresqlExtensions"] } datasource db { - provider = "postgresql" - url = env("DATABASE_URL") + provider = "postgresql" + url = env("DATABASE_URL") extensions = [citext] } @@ -73,27 +73,30 @@ model Invite { } model Patient { - id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid firstName String? middleName String? lastName String? dateOfBirth DateTime? gender Gender? language Language? + allergies PatientAllergy[] + medications PatientMedication[] + conditions PatientCondition[] codeStatus CodeStatus? codeStatusAttached Boolean? - hospital Hospital? @relation(fields: [hospitalId], references: [id]) - hospitalId String? @db.Uuid - emergencyContact Contact? @relation(fields: [emergencyContactId], references: [id]) - emergencyContactId String? @unique @db.Uuid - physician Physician? @relation(fields: [physicianId], references: [id]) - physicianId String? @db.Uuid - createdBy User @relation("UserCreatedBy", fields: [createdById], references: [id]) - createdById String @db.Uuid - updatedBy User @relation("UserUpdatedBy", fields: [updatedById], references: [id]) - updatedById String @db.Uuid - updatedAt DateTime @updatedAt - createdAt DateTime @default(now()) + hospital Hospital? @relation(fields: [hospitalId], references: [id]) + hospitalId String? @db.Uuid + emergencyContact Contact? @relation(fields: [emergencyContactId], references: [id]) + emergencyContactId String? @unique @db.Uuid + physician Physician? @relation(fields: [physicianId], references: [id]) + physicianId String? @db.Uuid + createdBy User @relation("UserCreatedBy", fields: [createdById], references: [id]) + createdById String @db.Uuid + updatedBy User @relation("UserUpdatedBy", fields: [updatedById], references: [id]) + updatedById String @db.Uuid + updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) } enum Gender { @@ -137,11 +140,12 @@ model Contact { } model Allergy { - id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid - name String - type AllergyType - system CodingSystem - code String + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + name String + type AllergyType + system CodingSystem + code String + patients PatientAllergy[] } enum AllergyType { @@ -150,19 +154,21 @@ enum AllergyType { } model Medication { - id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid name String altNames String system CodingSystem code String + patients PatientMedication[] } model Condition { - id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid - name String + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + name String category String - system CodingSystem - code String + system CodingSystem + code String + patients PatientCondition[] } enum CodingSystem { @@ -171,6 +177,36 @@ enum CodingSystem { ICD10 } +model PatientAllergy { + patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade) + patientId String @db.Uuid + allergy Allergy @relation(fields: [allergyId], references: [id], onDelete: Cascade) + allergyId String @db.Uuid + sortOrder Int + + @@id([patientId, allergyId]) +} + +model PatientMedication { + patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade) + patientId String @db.Uuid + medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade) + medicationId String @db.Uuid + sortOrder Int + + @@id([patientId, medicationId]) +} + +model PatientCondition { + patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade) + patientId String @db.Uuid + condition Condition @relation(fields: [conditionId], references: [id], onDelete: Cascade) + conditionId String @db.Uuid + sortOrder Int + + @@id([patientId, conditionId]) +} + model Physician { id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid firstName String? diff --git a/server/routes/api/v1/patients/create.js b/server/routes/api/v1/patients/create.js new file mode 100644 index 00000000..dd9b2130 --- /dev/null +++ b/server/routes/api/v1/patients/create.js @@ -0,0 +1,100 @@ +import { Role } from '../../../../models/user.js'; +import { StatusCodes } from 'http-status-codes'; + +export default async function (fastify, _opts) { + fastify.post( + '/', + { + schema: { + body: { + type: 'object', + required: [ + 'id', + 'firstName', + 'lastName', + 'gender', + 'language', + 'dateOfBirth', + ], + properties: { + id: { type: 'string' }, + firstName: { type: 'string' }, + middleName: { type: 'string' }, + lastName: { type: 'string' }, + gender: { + type: 'string', + enum: [ + 'FEMALE', + 'MALE', + 'TRANS_MALE', + 'TRANS_FEMALE', + 'OTHER', + 'UNKNOWN', + ], + }, + language: { + type: 'string', + enum: [ + 'CANTONESE', + 'ENGLISH', + 'MANDARIN', + 'RUSSIAN', + 'SPANISH', + 'TAGALOG', + ], + }, + dateOfBirth: { type: 'string', format: 'date' }, + }, + }, + response: { + [StatusCodes.CREATED]: { + type: 'object', + properties: { + id: { type: 'string' }, + firstName: { type: 'string' }, + middleName: { type: 'string' }, + lastName: { type: 'string' }, + gender: { type: 'string' }, + language: { type: 'string' }, + dateOfBirth: { type: 'string', format: 'date' }, + }, + }, + }, + }, + onRequest: fastify.requireUser([Role.ADMIN, Role.STAFF, Role.VOLUNTEER]), + }, + async (request, reply) => { + const { + id, + firstName, + middleName, + lastName, + gender, + language, + dateOfBirth, + } = request.body; + + const userId = request.user.id; + + const newPatient = await fastify.prisma.$transaction(async (tx) => { + let patient = await tx.patient.create({ + data: { + id, + firstName, + middleName, + lastName, + gender, + language, + dateOfBirth: new Date(dateOfBirth), + createdById: userId, + updatedById: userId, + }, + }); + + return patient; + }); + + reply.code(StatusCodes.CREATED).send(newPatient); + }, + ); +} diff --git a/server/routes/api/v1/patients/update.js b/server/routes/api/v1/patients/update.js new file mode 100644 index 00000000..694d06fd --- /dev/null +++ b/server/routes/api/v1/patients/update.js @@ -0,0 +1,326 @@ +import { Role } from '../../../../models/user.js'; +import { StatusCodes } from 'http-status-codes'; + +export default async function (fastify, _opts) { + fastify.patch( + '/:id', + { + schema: { + body: { + type: 'object', + properties: { + patientData: { + type: 'object', + properties: { + firstName: { type: 'string' }, + middleName: { type: 'string' }, + lastName: { type: 'string' }, + gender: { + type: 'string', + enum: [ + 'FEMALE', + 'MALE', + 'TRANS_MALE', + 'TRANS_FEMALE', + 'OTHER', + 'UNKNOWN', + ], + }, + language: { + type: 'string', + enum: [ + 'CANTONESE', + 'ENGLISH', + 'MANDARIN', + 'RUSSIAN', + 'SPANISH', + 'TAGALOG', + ], + }, + codeStatus: { + type: 'string', + enum: ['COMFORT', 'DNR', 'DNI', 'DNR_DNI', 'FULL'], + }, + dateOfBirth: { type: 'string', format: 'date' }, + }, + }, + contactData: { + type: 'object', + required: ['firstName', 'lastName', 'phone', 'relationship'], + properties: { + firstName: { type: 'string' }, + middleName: { type: 'string' }, + lastName: { type: 'string' }, + phone: { + type: 'string', + pattern: '^[0-9]{3}-[0-9]{3}-[0-9]{4}$', + }, + relationship: { type: 'string' }, + }, + }, + medicalData: { + type: 'object', + properties: { + allergies: { + type: 'array', + items: { + type: 'string', + }, + }, + medications: { + type: 'array', + items: { + type: 'string', + }, + }, + conditions: { + type: 'array', + items: { + type: 'string', + }, + }, + }, + }, + healthcareChoices: { + type: 'object', + required: ['hospitalId', 'physicianId'], + properties: { + hospitalId: { type: 'string' }, + physicianId: { type: 'string' }, + }, + }, + }, + }, + response: { + [StatusCodes.OK]: { + type: 'object', + properties: { + id: { type: 'string' }, + firstName: { type: 'string' }, + middleName: { type: 'string' }, + lastName: { type: 'string' }, + gender: { type: 'string' }, + language: { type: 'string' }, + dateOfBirth: { type: 'string', format: 'date' }, + codeStatus: { type: 'string' }, + emergencyContact: { + type: 'object', + properties: { + id: { type: 'string' }, + firstName: { type: 'string' }, + middleName: { type: 'string' }, + lastName: { type: 'string' }, + phone: { type: 'string' }, + relationship: { type: 'string' }, + }, + }, + allergies: { type: 'array' }, + conditions: { type: 'array' }, + medications: { type: 'array' }, + hospital: { + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + address: { type: 'string' }, + phone: { type: 'string' }, + email: { type: 'string' }, + }, + }, + physician: { + type: 'object', + properties: { + id: { type: 'string' }, + firstName: { type: 'string' }, + middleName: { type: 'string' }, + lastName: { type: 'string' }, + phone: { type: 'string' }, + email: { type: 'string' }, + }, + }, + advancedDirective: { type: 'string' }, + updatedById: { type: 'string' }, + }, + }, + }, + }, + onRequest: fastify.requireUser([Role.ADMIN, Role.STAFF, Role.VOLUNTEER]), + }, + async (request, reply) => { + const { id } = request.params; + const { patientData, contactData, medicalData, healthcareChoices } = + request.body; + + const userId = request.user.id; + + const updatedPatient = await fastify.prisma.$transaction(async (tx) => { + if (patientData) { + const newPatientData = {}; + + // Only update the patient data if the value is truthy + for (const [key, value] of Object.entries(patientData)) { + if (value) newPatientData[key] = value; + if (key === 'dateOfBirth') newPatientData[key] = new Date(value); + } + + await tx.patient.update({ + where: { id }, + data: newPatientData, + }); + } + if (contactData) { + const { firstName, middleName, lastName, phone, relationship } = + contactData; + + let contact = await tx.contact.create({ + data: { + firstName, + middleName, + lastName, + phone, + relationship, + }, + }); + await tx.patient.update({ + where: { id }, + data: { + emergencyContact: { + connect: { id: contact.id }, + }, + updatedBy: { + connect: { id: userId }, + }, + }, + }); + } + + if (medicalData) { + const RELATION_KEYS = { + allergies: 'allergy', + medications: 'medication', + conditions: 'condition', + }; + + const JOIN_MODELS = { + allergies: 'PatientAllergy', + medications: 'PatientMedication', + conditions: 'PatientCondition', + }; + + for (const key of Object.keys(medicalData)) { + const model = JOIN_MODELS[key]; + + // Delete previous connections for the patient + await tx[model].deleteMany({ + where: { + patientId: id, + }, + }); + } + + for (const [key, value] of Object.entries(medicalData)) { + if (value) { + const model = JOIN_MODELS[key]; + const relation = RELATION_KEYS[key]; + + for (let i = 0; i < value.length; i++) { + const item = value[i]; + + // Check if the referenced record exists + const exists = await tx[relation].findUnique({ + where: { id: item }, + }); + if (!exists) + // Use throw instead of return to make sure transaction is rolled back + throw reply.status(StatusCodes.NOT_FOUND).send({ + message: `${key} with ID ${item} does not exist in database.`, + }); + + await tx[model].upsert({ + where: { + [`patientId_${relation}Id`]: { + patientId: id, + [`${relation}Id`]: item, + }, + }, + update: { sortOrder: i }, + + create: { + patientId: id, + [`${relation}Id`]: item, // Use dynamic relation key (allergyId, medicationId, conditionId) + sortOrder: i, // Set sort order based on input array index + }, + }); + } + } + } + + await tx.patient.update({ + where: { id }, + data: { + updatedBy: { connect: { id: userId } }, + }, + }); + } + + if (healthcareChoices) { + // Validate hospital and physician IDs + const hospital = await tx.hospital.findUnique({ + where: { id: healthcareChoices.hospitalId }, + }); + if (!hospital) + // Use throw instead of return to make sure transaction is rolled back + throw reply.status(StatusCodes.NOT_FOUND).send({ + message: `Hospital with ID ${healthcareChoices.hospitalId} does not exist in database.`, + }); + + const physician = await tx.physician.findUnique({ + where: { id: healthcareChoices.physicianId }, + }); + if (!physician) + throw reply.status(StatusCodes.NOT_FOUND).send({ + message: `Physician with ID ${healthcareChoices.physicianId} does not exist in database.`, + }); + + await tx.patient.update({ + where: { id }, + data: { + hospital: { + connect: { id: healthcareChoices.hospitalId }, + }, + physician: { + connect: { id: healthcareChoices.physicianId }, + }, + updatedBy: { + connect: { id: userId }, + }, + }, + }); + } + + return tx.patient.findUnique({ + where: { id }, + include: { + emergencyContact: true, + allergies: { + select: { allergy: true }, + orderBy: { sortOrder: 'asc' }, + }, + medications: { + select: { medication: true }, + orderBy: { sortOrder: 'asc' }, + }, + conditions: { + select: { condition: true }, + orderBy: { sortOrder: 'asc' }, + }, + hospital: true, + physician: true, + }, + }); + }); + + return reply.code(StatusCodes.OK).send(updatedPatient); + }, + ); +} diff --git a/server/test/fixtures/db/Hospital.yml b/server/test/fixtures/db/Hospital.yml new file mode 100644 index 00000000..266268d2 --- /dev/null +++ b/server/test/fixtures/db/Hospital.yml @@ -0,0 +1,17 @@ +entity: hospital +connectedFields: ['physicians', 'patients'] +items: + hospital1: + id: a50538cd-1e10-42a3-8d6b-f9ae1e48a025 + name: Hospital 1 + address: 123 Main Street + phone: 123-456-7890 + email: hospital1@test.com + physicians: ['@physician1', '@physician2'] + hospital2: + id: b50538cd-1e10-42a3-8d6b-f9ae1e48a025 + name: Hospital 2 + address: 456 Main Street + phone: 123-456-9999 + email: hospital2@test.com + physicians: ['@physician3'] diff --git a/server/test/fixtures/db/Patient.yml b/server/test/fixtures/db/Patient.yml new file mode 100644 index 00000000..d40694c8 --- /dev/null +++ b/server/test/fixtures/db/Patient.yml @@ -0,0 +1,13 @@ +entity: patient +connectedFields: ['createdBy', 'updatedBy'] +items: + patient1: + id: 27963f68-ebc1-408a-8bb5-8fbe54671064 + firstName: John + middleName: A + lastName: Doe + gender: MALE + language: ENGLISH + dateOfBirth: '2000-10-05T00:00:00.000Z' + createdBy: '@user1' + updatedBy: '@user1' diff --git a/server/test/fixtures/db/Physician.yml b/server/test/fixtures/db/Physician.yml new file mode 100644 index 00000000..064cfacf --- /dev/null +++ b/server/test/fixtures/db/Physician.yml @@ -0,0 +1,22 @@ +entity: physician +connectedFields: ['hospitals', 'patients'] +items: + physician1: + id: 1ef50c4c-92cb-4298-ab0a-ce7644513bfb + firstName: Physician 1 + middleName: A + lastName: Doe + phone: 123-456-7890 + email: physician1@test.com + physician2: + id: bbbf7f99-36cc-40b5-a26c-cd95daae04b5 + firstName: Physician 2 + lastName: Smith + phone: 123-456-9999 + email: physician2@test.com + physician3: + id: 4f177289-f23a-47df-aa16-d9e54108daae + firstName: Physician 3 + lastName: Smith + phone: 123-456-9998 + email: physician3@test.com diff --git a/server/test/routes/api/v1/allergies.test.js b/server/test/routes/api/v1/allergies.test.js index 6ad35fb5..bfe4f88a 100644 --- a/server/test/routes/api/v1/allergies.test.js +++ b/server/test/routes/api/v1/allergies.test.js @@ -21,7 +21,6 @@ describe('/api/v1/allergies', () => { .headers(headers); assert.deepStrictEqual(reply.statusCode, StatusCodes.OK); - console.log('GET / Headers', reply.payload); assert.deepStrictEqual( reply.headers['link'], '; rel="next"', diff --git a/server/test/routes/api/v1/patients.test.js b/server/test/routes/api/v1/patients.test.js index 3a7925c2..edeed13d 100644 --- a/server/test/routes/api/v1/patients.test.js +++ b/server/test/routes/api/v1/patients.test.js @@ -17,4 +17,636 @@ describe('/api/v1/patients', () => { assert.ok(results[0].startsWith(`${process.env.BASE_URL}/patients/`)); }); }); + + describe('POST /', () => { + it('should return an error if not an ADMIN, STAFF or VOLUNTEER user', async (t) => { + const app = await build(t); + await t.loadFixtures(); + + let reply = await app.inject().post('/api/v1/patients').payload({ + firstName: 'John', + middleName: 'A', + lastName: 'Doe', + gender: 'MALE', + language: 'ENGLISH', + dateOfBirth: '1990-01-01', + }); + + assert.deepStrictEqual(reply.statusCode, StatusCodes.UNAUTHORIZED); + + let headers = await t.authenticate('first.responder@test.com', 'test'); + reply = await app + .inject() + .post('/api/v1/patients') + .payload({ + firstName: 'John', + middleName: 'A', + lastName: 'Doe', + gender: 'MALE', + language: 'ENGLISH', + dateOfBirth: '1990-01-01', + }) + .headers(headers); + + assert.deepStrictEqual(reply.statusCode, StatusCodes.FORBIDDEN); + }); + + it('should allow ADMIN to register a new patient', async (t) => { + const app = await build(t); + await t.loadFixtures(); + const headers = await t.authenticate('admin.user@test.com', 'test'); + const reply = await app + .inject() + .post('/api/v1/patients') + .payload({ + id: '0849219e-e2c6-409b-bea4-1a229c3df805', + firstName: 'John', + middleName: 'A', + lastName: 'Doe', + gender: 'MALE', + language: 'ENGLISH', + dateOfBirth: '1990-01-01', + }) + .headers(headers); + + assert.deepStrictEqual(reply.statusCode, StatusCodes.CREATED); + const result = JSON.parse(reply.body); + assert.deepStrictEqual(result, { + id: '0849219e-e2c6-409b-bea4-1a229c3df805', + firstName: 'John', + middleName: 'A', + lastName: 'Doe', + gender: 'MALE', + language: 'ENGLISH', + dateOfBirth: '1990-01-01', + }); + }); + + it('should allow STAFF to register a new patient', async (t) => { + const app = await build(t); + await t.loadFixtures(); + const headers = await t.authenticate('staff.user@test.com', 'test'); + const reply = await app + .inject() + .post('/api/v1/patients') + .payload({ + id: '0849219e-e2c6-409b-bea4-1a229c3df805', + firstName: 'John', + middleName: 'A', + lastName: 'Doe', + gender: 'MALE', + language: 'ENGLISH', + dateOfBirth: '1990-01-01', + }) + .headers(headers); + + assert.deepStrictEqual(reply.statusCode, StatusCodes.CREATED); + const result = JSON.parse(reply.body); + assert.deepStrictEqual(result, { + id: '0849219e-e2c6-409b-bea4-1a229c3df805', + firstName: 'John', + middleName: 'A', + lastName: 'Doe', + gender: 'MALE', + language: 'ENGLISH', + dateOfBirth: '1990-01-01', + }); + }); + + it('should allow VOLUNTEER to register a new patient', async (t) => { + const app = await build(t); + await t.loadFixtures(); + const headers = await t.authenticate('volunteer.user@test.com', 'test'); + const reply = await app + .inject() + .post('/api/v1/patients') + .payload({ + id: '0849219e-e2c6-409b-bea4-1a229c3df805', + firstName: 'John', + middleName: 'A', + lastName: 'Doe', + gender: 'MALE', + language: 'ENGLISH', + dateOfBirth: '1990-01-01', + }) + .headers(headers); + + assert.deepStrictEqual(reply.statusCode, StatusCodes.CREATED); + const result = JSON.parse(reply.body); + assert.deepStrictEqual(result, { + id: '0849219e-e2c6-409b-bea4-1a229c3df805', + firstName: 'John', + middleName: 'A', + lastName: 'Doe', + gender: 'MALE', + language: 'ENGLISH', + dateOfBirth: '1990-01-01', + }); + }); + + it('errors if missing required fields', async (t) => { + const app = await build(t); + await t.loadFixtures(); + const headers = await t.authenticate('admin.user@test.com', 'test'); + const reply = await app + .inject() + .post('/api/v1/patients') + .payload({ + id: '0849219e-e2c6-409b-bea4-1a229c3df805', + lastName: 'Doe', + dateOfBirth: '1990-01-01', + }) + .headers(headers); + + assert.deepStrictEqual(reply.statusCode, StatusCodes.BAD_REQUEST); + const result = JSON.parse(reply.body); + assert.deepStrictEqual( + result.message, + "body must have required property 'firstName'", + ); + }); + + it('errors if providing a language that is not in the enum', async (t) => { + const app = await build(t); + await t.loadFixtures(); + const headers = await t.authenticate('admin.user@test.com', 'test'); + const reply = await app + .inject() + .post('/api/v1/patients') + .payload({ + id: '0849219e-e2c6-409b-bea4-1a229c3df805', + firstName: 'John', + middleName: 'A', + lastName: 'Doe', + gender: 'MALE', + language: 'UNKNOWN', + dateOfBirth: '1990-01-01', + }) + .headers(headers); + + assert.deepStrictEqual(reply.statusCode, StatusCodes.BAD_REQUEST); + const result = JSON.parse(reply.body); + assert.deepStrictEqual( + result.message, + 'body/language must be equal to one of the allowed values', + ); + }); + }); + + describe('PATCH /:id', () => { + it('should return an error if not an ADMIN, STAFF or VOLUNTEER user', async (t) => { + const app = await build(t); + await t.loadFixtures(); + + let reply = await app + .inject() + .patch('/api/v1/patients/27963f68-ebc1-408a-8bb5-8fbe54671064') + .payload({ + firstName: 'John', + middleName: 'A', + lastName: 'Doe', + gender: 'FEMALE', + language: 'TAGALOG', + dateOfBirth: '1990-01-01', + }); + + assert.deepStrictEqual(reply.statusCode, StatusCodes.UNAUTHORIZED); + + let headers = await t.authenticate('first.responder@test.com', 'test'); + reply = await app + .inject() + .patch('/api/v1/patients/27963f68-ebc1-408a-8bb5-8fbe54671064') + .payload({ + firstName: 'John', + middleName: 'A', + lastName: 'Doe', + gender: 'FEMALE', + language: 'TAGALOG', + dateOfBirth: '1990-01-01', + }) + .headers(headers); + + assert.deepStrictEqual(reply.statusCode, StatusCodes.FORBIDDEN); + }); + + it('should allow ADMIN to update a patient', async (t) => { + const app = await build(t); + await t.loadFixtures(); + const headers = await t.authenticate('admin.user@test.com', 'test'); + const reply = await app + .inject() + .patch('/api/v1/patients/27963f68-ebc1-408a-8bb5-8fbe54671064') + .payload({ + patientData: { + firstName: 'Jane', + dateOfBirth: '1990-01-01', + language: 'RUSSIAN', + codeStatus: 'COMFORT', + }, + }) + .headers(headers); + + assert.deepStrictEqual(reply.statusCode, StatusCodes.OK); + const { + id, + firstName, + middleName, + lastName, + gender, + language, + codeStatus, + dateOfBirth, + } = JSON.parse(reply.body); + assert.deepStrictEqual(id, '27963f68-ebc1-408a-8bb5-8fbe54671064'); + assert.deepStrictEqual(firstName, 'Jane'); + assert.deepStrictEqual(middleName, 'A'); + assert.deepStrictEqual(lastName, 'Doe'); + assert.deepStrictEqual(gender, 'MALE'); + assert.deepStrictEqual(language, 'RUSSIAN'); + assert.deepStrictEqual(codeStatus, 'COMFORT'); + assert.deepStrictEqual(dateOfBirth, '1990-01-01'); + }); + + it('should allow STAFF to update a patient', async (t) => { + const app = await build(t); + await t.loadFixtures(); + const headers = await t.authenticate('staff.user@test.com', 'test'); + const reply = await app + .inject() + .patch('/api/v1/patients/27963f68-ebc1-408a-8bb5-8fbe54671064') + .payload({ + patientData: { + firstName: 'Jack', + dateOfBirth: '1990-02-01', + language: 'SPANISH', + codeStatus: 'DNR', + }, + }) + .headers(headers); + + assert.deepStrictEqual(reply.statusCode, StatusCodes.OK); + const { + id, + firstName, + middleName, + lastName, + gender, + language, + codeStatus, + dateOfBirth, + } = JSON.parse(reply.body); + assert.deepStrictEqual(id, '27963f68-ebc1-408a-8bb5-8fbe54671064'); + assert.deepStrictEqual(firstName, 'Jack'); + assert.deepStrictEqual(middleName, 'A'); + assert.deepStrictEqual(lastName, 'Doe'); + assert.deepStrictEqual(gender, 'MALE'); + assert.deepStrictEqual(language, 'SPANISH'); + assert.deepStrictEqual(codeStatus, 'DNR'); + assert.deepStrictEqual(dateOfBirth, '1990-02-01'); + }); + + it('should allow VOLUNTEER to update a patient', async (t) => { + const app = await build(t); + await t.loadFixtures(); + const headers = await t.authenticate('volunteer.user@test.com', 'test'); + const reply = await app + .inject() + .patch('/api/v1/patients/27963f68-ebc1-408a-8bb5-8fbe54671064') + .payload({ + patientData: { + firstName: 'Jill', + dateOfBirth: '1990-03-01', + gender: 'FEMALE', + language: 'CANTONESE', + codeStatus: 'DNI', + }, + }) + .headers(headers); + + assert.deepStrictEqual(reply.statusCode, StatusCodes.OK); + const { + id, + firstName, + middleName, + lastName, + gender, + language, + codeStatus, + dateOfBirth, + } = JSON.parse(reply.body); + assert.deepStrictEqual(id, '27963f68-ebc1-408a-8bb5-8fbe54671064'); + assert.deepStrictEqual(firstName, 'Jill'); + assert.deepStrictEqual(middleName, 'A'); + assert.deepStrictEqual(lastName, 'Doe'); + assert.deepStrictEqual(gender, 'FEMALE'); + assert.deepStrictEqual(language, 'CANTONESE'); + assert.deepStrictEqual(codeStatus, 'DNI'); + assert.deepStrictEqual(dateOfBirth, '1990-03-01'); + }); + + it('should allow ADMIN to update a patient with contact data', async (t) => { + const app = await build(t); + await t.loadFixtures(); + const headers = await t.authenticate('admin.user@test.com', 'test'); + const reply = await app + .inject() + .patch('/api/v1/patients/27963f68-ebc1-408a-8bb5-8fbe54671064') + .payload({ + contactData: { + firstName: 'Jane', + lastName: 'Doe', + phone: '123-456-7890', + relationship: 'Mother', + }, + }) + .headers(headers); + + assert.deepStrictEqual(reply.statusCode, StatusCodes.OK); + const { emergencyContact } = JSON.parse(reply.body); + assert.deepStrictEqual(emergencyContact, { + id: emergencyContact.id, + firstName: 'Jane', + middleName: '', + lastName: 'Doe', + phone: '123-456-7890', + relationship: 'Mother', + }); + }); + + it('should allow ADMIN to update a patient with medical data', async (t) => { + const app = await build(t); + await t.loadFixtures(); + const headers = await t.authenticate('admin.user@test.com', 'test'); + const reply = await app + .inject() + .patch('/api/v1/patients/27963f68-ebc1-408a-8bb5-8fbe54671064') + .payload({ + medicalData: { + allergies: ['5c057fc3-15d2-40fc-b664-707d04ba66c2'], + medications: ['583c7775-9466-4dab-8a4d-edf1056f097f'], + conditions: ['471c8529-81fc-4129-8ca0-f1b7406ed90c'], + }, + }) + .headers(headers); + assert.deepStrictEqual(reply.statusCode, StatusCodes.OK); + const { + id, + firstName, + middleName, + lastName, + dateOfBirth, + allergies, + medications, + conditions, + } = JSON.parse(reply.body); + assert.deepStrictEqual(id, '27963f68-ebc1-408a-8bb5-8fbe54671064'); + assert.deepStrictEqual(firstName, 'John'); + assert.deepStrictEqual(middleName, 'A'); + assert.deepStrictEqual(lastName, 'Doe'); + assert.deepStrictEqual(dateOfBirth, '2000-10-05'); + assert.deepStrictEqual( + allergies[0].allergy.id, + '5c057fc3-15d2-40fc-b664-707d04ba66c2', + ); + assert.deepStrictEqual( + medications[0].medication.id, + '583c7775-9466-4dab-8a4d-edf1056f097f', + ); + assert.deepStrictEqual( + conditions[0].condition.id, + '471c8529-81fc-4129-8ca0-f1b7406ed90c', + ); + }); + + it('should allow ADMIN to replace medical data of a patient and keep the order of the items', async (t) => { + const app = await build(t); + await t.loadFixtures(); + const headers = await t.authenticate('admin.user@test.com', 'test'); + let reply = await app + .inject() + .patch('/api/v1/patients/27963f68-ebc1-408a-8bb5-8fbe54671064') + .payload({ + medicalData: { + allergies: [ + '5c057fc3-15d2-40fc-b664-707d04ba66c2', + + 'ebcca2da-655f-48d4-be90-307f36870dc0', + ], + medications: ['583c7775-9466-4dab-8a4d-edf1056f097f'], + conditions: ['471c8529-81fc-4129-8ca0-f1b7406ed90c'], + }, + }) + .headers(headers); + + assert.deepStrictEqual(reply.statusCode, StatusCodes.OK); + let { id, allergies, medications, conditions } = JSON.parse(reply.body); + assert.deepStrictEqual(id, '27963f68-ebc1-408a-8bb5-8fbe54671064'); + assert.deepStrictEqual( + allergies[0].allergy.id, + '5c057fc3-15d2-40fc-b664-707d04ba66c2', + ); + assert.deepStrictEqual( + allergies[1].allergy.id, + 'ebcca2da-655f-48d4-be90-307f36870dc0', + ); + assert.deepStrictEqual( + medications[0].medication.id, + '583c7775-9466-4dab-8a4d-edf1056f097f', + ); + assert.deepStrictEqual( + conditions[0].condition.id, + '471c8529-81fc-4129-8ca0-f1b7406ed90c', + ); + + reply = await app + .inject() + .patch('/api/v1/patients/27963f68-ebc1-408a-8bb5-8fbe54671064') + .payload({ + medicalData: { + allergies: [ + 'ebcca2da-655f-48d4-be90-307f36870dc0', + + '5c057fc3-15d2-40fc-b664-707d04ba66c2', + ], + medications: ['583c7775-9466-4dab-8a4d-edf1056f097f'], + conditions: [], + }, + }) + .headers(headers); + + allergies = JSON.parse(reply.body).allergies; + medications = JSON.parse(reply.body).medications; + conditions = JSON.parse(reply.body).conditions; + + assert.deepStrictEqual( + allergies[0].allergy.id, + 'ebcca2da-655f-48d4-be90-307f36870dc0', + ); + assert.deepStrictEqual( + allergies[1].allergy.id, + '5c057fc3-15d2-40fc-b664-707d04ba66c2', + ); + assert.deepStrictEqual(allergies.length, 2); + assert.deepStrictEqual( + medications[0].medication.id, + '583c7775-9466-4dab-8a4d-edf1056f097f', + ); + assert.deepStrictEqual(medications.length, 1); + assert.deepStrictEqual(conditions.length, 0); + }); + + it('should throw an error if a medical data item does not exist in the database', async (t) => { + const app = await build(t); + await t.loadFixtures(); + const headers = await t.authenticate('admin.user@test.com', 'test'); + let reply = await app + .inject() + .patch('/api/v1/patients/27963f68-ebc1-408a-8bb5-8fbe54671064') + .payload({ + medicalData: { + allergies: ['5c057fc3-15d2-40fc-b664-707d04ba66c1'], + }, + }) + .headers(headers); + assert.deepStrictEqual(reply.statusCode, StatusCodes.NOT_FOUND); + let result = JSON.parse(reply.body); + assert.deepStrictEqual( + result.message, + 'allergies with ID 5c057fc3-15d2-40fc-b664-707d04ba66c1 does not exist in database.', + ); + + reply = await app + .inject() + .patch('/api/v1/patients/27963f68-ebc1-408a-8bb5-8fbe54671064') + .payload({ + medicalData: { + medications: ['583c7775-9466-4dab-8a4d-edf1056f097f'], + conditions: ['471c8529-81fc-4129-8ca0-f1b7406ed90a'], + }, + }) + .headers(headers); + assert.deepStrictEqual(reply.statusCode, StatusCodes.NOT_FOUND); + result = JSON.parse(reply.body); + assert.deepStrictEqual( + result.message, + 'conditions with ID 471c8529-81fc-4129-8ca0-f1b7406ed90a does not exist in database.', + ); + }); + + it('should allow ADMIN to update a patient with healthcare choices', async (t) => { + const app = await build(t); + await t.loadFixtures(); + const headers = await t.authenticate('admin.user@test.com', 'test'); + const reply = await app + .inject() + .patch('/api/v1/patients/27963f68-ebc1-408a-8bb5-8fbe54671064') + .payload({ + healthcareChoices: { + hospitalId: 'a50538cd-1e10-42a3-8d6b-f9ae1e48a025', + physicianId: '1ef50c4c-92cb-4298-ab0a-ce7644513bfb', + }, + }) + .headers(headers); + + assert.deepStrictEqual(reply.statusCode, StatusCodes.OK); + const { + id, + firstName, + middleName, + lastName, + dateOfBirth, + hospital, + physician, + } = JSON.parse(reply.body); + + assert.deepStrictEqual(id, '27963f68-ebc1-408a-8bb5-8fbe54671064'); + assert.deepStrictEqual(firstName, 'John'); + assert.deepStrictEqual(middleName, 'A'); + assert.deepStrictEqual(lastName, 'Doe'); + assert.deepStrictEqual(dateOfBirth, '2000-10-05'); + assert.deepStrictEqual( + hospital.id, + 'a50538cd-1e10-42a3-8d6b-f9ae1e48a025', + ); + assert.deepStrictEqual( + physician.id, + '1ef50c4c-92cb-4298-ab0a-ce7644513bfb', + ); + }); + + it('should allow ADMIN to update healthcare choices of a patient', async (t) => { + const app = await build(t); + await t.loadFixtures(); + const headers = await t.authenticate('admin.user@test.com', 'test'); + let reply = await app + .inject() + .patch('/api/v1/patients/27963f68-ebc1-408a-8bb5-8fbe54671064') + .payload({ + healthcareChoices: { + hospitalId: 'a50538cd-1e10-42a3-8d6b-f9ae1e48a025', + physicianId: '1ef50c4c-92cb-4298-ab0a-ce7644513bfb', + }, + }) + .headers(headers); + + assert.deepStrictEqual(reply.statusCode, StatusCodes.OK); + let { id, hospital, physician } = JSON.parse(reply.body); + + assert.deepStrictEqual(id, '27963f68-ebc1-408a-8bb5-8fbe54671064'); + assert.deepStrictEqual( + hospital.id, + 'a50538cd-1e10-42a3-8d6b-f9ae1e48a025', + ); + assert.deepStrictEqual( + physician.id, + '1ef50c4c-92cb-4298-ab0a-ce7644513bfb', + ); + + reply = await app + .inject() + .patch('/api/v1/patients/27963f68-ebc1-408a-8bb5-8fbe54671064') + .payload({ + healthcareChoices: { + hospitalId: 'b50538cd-1e10-42a3-8d6b-f9ae1e48a025', + physicianId: 'bbbf7f99-36cc-40b5-a26c-cd95daae04b5', + }, + }) + .headers(headers); + + hospital = JSON.parse(reply.body).hospital; + physician = JSON.parse(reply.body).physician; + + assert.deepStrictEqual( + hospital.id, + 'b50538cd-1e10-42a3-8d6b-f9ae1e48a025', + ); + assert.deepStrictEqual( + physician.id, + 'bbbf7f99-36cc-40b5-a26c-cd95daae04b5', + ); + }); + + it('should throw an error if a healthcare choices item does not exist in the database', async (t) => { + const app = await build(t); + await t.loadFixtures(); + const headers = await t.authenticate('admin.user@test.com', 'test'); + const reply = await app + .inject() + .patch('/api/v1/patients/27963f68-ebc1-408a-8bb5-8fbe54671064') + .payload({ + healthcareChoices: { + hospitalId: 'a50538cd-1e10-42a3-8d6b-f9ae1e48a022', + physicianId: '1ef50c4c-92cb-4298-ab0a-ce7644513bfb', + }, + }) + .headers(headers); + + assert.deepStrictEqual(reply.statusCode, StatusCodes.NOT_FOUND); + const result = JSON.parse(reply.body); + assert.deepStrictEqual( + result.message, + 'Hospital with ID a50538cd-1e10-42a3-8d6b-f9ae1e48a022 does not exist in database.', + ); + }); + }); });