Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
36 changes: 36 additions & 0 deletions apps/api/src/subjects/__tests__/subjects.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,42 @@ describe('SubjectsService', () => {
});
});

describe('findDefaultGroupCustomIds', () => {
const findManyArgs = () =>
subjectModel.findMany.mock.lastCall?.[0] as { select: unknown; where: { AND: unknown[] } };

it('should return only the ids, so no personal information leaves the database', async () => {
subjectModel.findMany.mockResolvedValueOnce([{ id: 'root$a' }]);
await expect(subjectsService.findDefaultGroupCustomIds()).resolves.toStrictEqual(['root$a']);
expect(findManyArgs().select).toStrictEqual({ id: true });
});

it('should constrain the query to what the caller may read, so a group manager is not shown other groups’ subjects', async () => {
const ability = createAppAbility([
{ action: 'read', conditions: { groupIds: { hasSome: ['group-1'] } }, subject: 'Subject' }
]);
subjectModel.findMany.mockResolvedValueOnce([]);
await subjectsService.findDefaultGroupCustomIds({ ability });
expect(findManyArgs().where.AND[0]).toStrictEqual(accessibleQuery(ability, 'read', 'Subject'));
});

it('should match on the default group scope of the id, so a subject later added to a group is still offered', async () => {
subjectModel.findMany.mockResolvedValueOnce([]);
await subjectsService.findDefaultGroupCustomIds();
expect(findManyArgs().where.AND).toContainEqual({ id: { gte: 'root$', lt: 'root%' } });
expect(JSON.stringify(findManyArgs().where)).not.toContain('groupIds');
});

it('should match only subjects identified by a custom id, like the per-group lookup', async () => {
subjectModel.findMany.mockResolvedValueOnce([]);
await subjectsService.findCustomIds('group-1');
const [, , groupedCustomIdClause] = findManyArgs().where.AND;
subjectModel.findMany.mockResolvedValueOnce([]);
await subjectsService.findDefaultGroupCustomIds();
expect(findManyArgs().where.AND).toContainEqual(groupedCustomIdClause);
});
});

describe('findById', () => {
it('should throw a `NotFoundException` if there is no subject with the provided id', async () => {
subjectModel.findFirst.mockResolvedValueOnce(null);
Expand Down
7 changes: 7 additions & 0 deletions apps/api/src/subjects/subjects.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,11 @@ export class SubjectsController {
findCustomIds(@Param('groupId', ValidObjectIdPipe) groupId: string, @CurrentUser('ability') ability: AppAbility) {
return this.subjectsService.findCustomIds(groupId, { ability });
}

@ApiOperation({ summary: 'Get the Custom IDs Scoped to the Default Group' })
@Get('default-group/custom-ids')
@RouteAccess({ action: 'read', subject: 'Subject' })
findDefaultGroupCustomIds(@CurrentUser('ability') ability: AppAbility) {
return this.subjectsService.findDefaultGroupCustomIds({ ability });
}
}
38 changes: 35 additions & 3 deletions apps/api/src/subjects/subjects.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { InjectModel, InjectPrismaClient } from '@douglasneuroinformatics/libnest';
import type { Model } from '@douglasneuroinformatics/libnest';
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { DEFAULT_GROUP_NAME } from '@opendatacapture/schemas/core';
import { encodeScopedSubjectId } from '@opendatacapture/subject-utils';
import type { Prisma } from '@prisma/client';

import { accessibleQuery } from '@/auth/ability.utils';
Expand All @@ -9,6 +11,22 @@ import type { EntityOperationOptions } from '@/core/types';

import { CreateSubjectDto } from './dto/create-subject.dto';

const PERSONAL_INFO_FIELDS = ['dateOfBirth', 'firstName', 'lastName', 'sex'] as const;

/**
* Prisma's MongoDB connector turns `startsWith` into an unescaped regex, so the `$` ending a subject
* id scope would anchor the pattern and match nothing. A lexical range selects the same ids.
*/
function idsWithPrefix(prefix: string): Prisma.StringFilter {
const lastCharCode = prefix.charCodeAt(prefix.length - 1);
return { gte: prefix, lt: prefix.slice(0, -1) + String.fromCharCode(lastCharCode + 1) };
}

/** A subject identified by personal information has every one of these fields set. */
const IDENTIFIED_BY_CUSTOM_ID: Prisma.SubjectWhereInput = {
OR: PERSONAL_INFO_FIELDS.flatMap((field) => [{ [field]: null }, { [field]: { isSet: false } }])
};

@Injectable()
export class SubjectsService {
constructor(
Expand Down Expand Up @@ -176,14 +194,28 @@ export class SubjectsService {
* field is matched on both `null` and `isSet: false`.
*/
async findCustomIds(groupId: string, { ability }: EntityOperationOptions = {}): Promise<string[]> {
const personalInfoFields = ['dateOfBirth', 'firstName', 'lastName', 'sex'] as const;
const subjects = await this.subjectModel.findMany({
select: { id: true },
where: {
AND: [accessibleQuery(ability, 'read', 'Subject'), { groupIds: { has: groupId } }, IDENTIFIED_BY_CUSTOM_ID]
}
});
return subjects.map((subject) => subject.id);
}

/**
* The custom ids a session without a group produces. Matched on the id's scope rather than on
* `groupIds`, because a later session, upload or assignment in a group adds that group to the
* subject without changing its id.
*/
async findDefaultGroupCustomIds({ ability }: EntityOperationOptions = {}): Promise<string[]> {
const subjects = await this.subjectModel.findMany({
select: { id: true },
where: {
AND: [
accessibleQuery(ability, 'read', 'Subject'),
{ groupIds: { has: groupId } },
{ OR: personalInfoFields.flatMap((field) => [{ [field]: null }, { [field]: { isSet: false } }]) }
{ id: idsWithPrefix(encodeScopedSubjectId('', { groupName: DEFAULT_GROUP_NAME })) },
IDENTIFIED_BY_CUSTOM_ID
]
}
});
Expand Down
95 changes: 88 additions & 7 deletions apps/web/src/__tests__/start-session-form.test.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
import { DEFAULT_GROUP_NAME } from '@opendatacapture/schemas/core';
import type { Group } from '@opendatacapture/schemas/group';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import axios from 'axios';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { StartSessionForm } from '@/components/StartSessionForm';

import '@/services/i18n';

vi.mock('axios');

// eslint-disable-next-line @typescript-eslint/unbound-method -- a vitest mock, never invoked as a method
const get = vi.mocked(axios).get;

const onSubmit = vi.fn();

const groupWithIdPattern = (idValidationRegex: string): Group => ({
Expand All @@ -24,13 +31,15 @@ const groupWithIdPattern = (idValidationRegex: string): Group => ({

const renderForm = (customSubjectIds: string[], currentGroup: Group | null = null) => {
render(
<StartSessionForm
currentGroup={currentGroup}
customSubjectIds={customSubjectIds}
readOnly={false}
username="admin"
onSubmit={onSubmit}
/>
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
<StartSessionForm
currentGroup={currentGroup}
customSubjectIds={customSubjectIds}
readOnly={false}
username="admin"
onSubmit={onSubmit}
/>
</QueryClientProvider>
);
const form = screen.getByTestId('start-session-form');
fireEvent.change(form.querySelector('[name="subjectIdentificationMethod"]')!, {
Expand Down Expand Up @@ -61,6 +70,18 @@ const submit = (form: HTMLElement) => {

const submittedSubjectId = () => onSubmit.mock.lastCall?.[0].subjectData.id;

const dateOfBirthInput = () => screen.getByTestId<HTMLInputElement>('date-input');
const sexTrigger = () => screen.getByTestId<HTMLButtonElement>('subjectSex-select-trigger');

const pickIdentifier = (identifier: string) => {
typeIdentifier(identifier);
fireEvent.click(screen.getByTestId(`subjectId-combobox-item-${identifier}`));
};

const existingSubject = (demographics: { dateOfBirth: null | string; sex: null | string }) => {
get.mockResolvedValueOnce({ data: demographics, status: 200 });
};

/** Every error shown, with the field it landed on, so one that moved to the form's own errors fails too. */
const errorMessages = () =>
screen.queryAllByTestId('error-message-text').map((element) => ({
Expand All @@ -74,6 +95,7 @@ beforeEach(() => {
// There are no vitest setup files in this repo, so RTL never auto-unmounts between tests.
cleanup();
vi.clearAllMocks();
get.mockResolvedValue({ data: null, status: 404 });
});

describe('StartSessionForm', () => {
Expand Down Expand Up @@ -171,3 +193,62 @@ describe('StartSessionForm', () => {
expect(submittedSubjectId()).toBe('Group_One$abc');
});
});

describe('StartSessionForm with an existing subject', () => {
it('should look the subject up by the id the form would submit, so the details shown are that subject’s', async () => {
renderForm(['alpha']);
pickIdentifier('alpha');
await waitFor(() =>
expect(get).toHaveBeenCalledWith(`/v1/subjects/${DEFAULT_GROUP_NAME}%24alpha`, expect.anything())
);
});

it('should fill in and lock the date of birth and sex the subject already records', async () => {
existingSubject({ dateOfBirth: '1990-06-15', sex: 'FEMALE' });
renderForm(['alpha']);
pickIdentifier('alpha');
await waitFor(() => expect(dateOfBirthInput().value).toBe('1990-06-15'));
expect(dateOfBirthInput().disabled).toBe(true);
expect(sexTrigger().disabled).toBe(true);
});

it('should submit the filled-in details, so the locked values reach the session', async () => {
existingSubject({ dateOfBirth: '1990-06-15', sex: 'FEMALE' });
const form = renderForm(['alpha']);
pickIdentifier('alpha');
await waitFor(() => expect(sexTrigger().disabled).toBe(true));
submit(form);
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
expect(onSubmit.mock.lastCall?.[0].subjectData).toMatchObject({
dateOfBirth: new Date('1990-06-15'),
sex: 'FEMALE'
});
});

it('should lock only the details the subject records, leaving the rest editable', async () => {
existingSubject({ dateOfBirth: null, sex: 'MALE' });
renderForm(['alpha']);
pickIdentifier('alpha');
await waitFor(() => expect(sexTrigger().disabled).toBe(true));
expect(dateOfBirthInput().disabled).toBe(false);
});

it('should not look up an identifier absent from the options, since no subject can have it', () => {
renderForm(['alpha']);
typeIdentifier('gamma');
closeIdentifierPopup();
expect(get).not.toHaveBeenCalled();
});

it('should clear and unlock the details when the identifier changes to a new one, so a new subject inherits nothing', async () => {
existingSubject({ dateOfBirth: '1990-06-15', sex: 'FEMALE' });
renderForm(['alpha']);
pickIdentifier('alpha');
await waitFor(() => expect(dateOfBirthInput().disabled).toBe(true));
typeIdentifier('gamma');
closeIdentifierPopup();
await waitFor(() => expect(dateOfBirthInput().disabled).toBe(false));
expect(dateOfBirthInput().value).toBe('');
expect(sexTrigger().disabled).toBe(false);
});
});
52 changes: 49 additions & 3 deletions apps/web/src/components/StartSessionForm/StartSessionForm.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
/* eslint-disable perfectionist/sort-objects */

import { useRef, useState } from 'react';
import type { Dispatch, SetStateAction } from 'react';

import { Form } from '@douglasneuroinformatics/libui/components';
import { useTranslation } from '@douglasneuroinformatics/libui/hooks';
import type { FormTypes } from '@opendatacapture/runtime-core';
Expand All @@ -13,10 +16,19 @@ import { encodeScopedSubjectId, generateSubjectHash } from '@opendatacapture/sub
import type { Promisable } from 'type-fest';
import { z } from 'zod/v4';

import { useSubjectDemographicsLookup } from '@/hooks/useSubjectDemographicsLookup';
import { getValueForLanguage } from '@/utils/language';

const currentDate = new Date();

type StartSessionFormValues = FormTypes.PartialData<StartSessionFormData>;

type AutofilledDemographics = Pick<StartSessionFormValues, 'subjectDateOfBirth' | 'subjectSex'>;

/** Empty unless a custom identifier is in use, so switching method clears what it filled in. */
const selectedCustomId = (values: StartSessionFormValues) =>
values.subjectIdentificationMethod === 'CUSTOM_ID' ? (values.subjectId ?? '') : '';

type StartSessionFormData = {
sessionDate: Date;
sessionType: 'IN_PERSON' | 'RETROSPECTIVE';
Expand Down Expand Up @@ -46,10 +58,40 @@ export const StartSessionForm = ({
onSubmit
}: StartSessionFormProps) => {
const { resolvedLanguage, t } = useTranslation();
const lookupDemographics = useSubjectDemographicsLookup();
const [autofilled, setAutofilled] = useState<AutofilledDemographics>({});
const latestCustomId = useRef('');
const minDateOfBirth = currentGroup?.settings.minimumAge
? new Date(currentDate.getTime() - currentGroup.settings.minimumAge * 31556952000)
: undefined;

const scopeCustomId = (customId: string) =>
encodeScopedSubjectId(customId, { groupName: currentGroup?.name ?? DEFAULT_GROUP_NAME });

const autofillExistingSubject = async (
values: StartSessionFormValues,
setValues: Dispatch<SetStateAction<StartSessionFormValues>>
) => {
const customId = selectedCustomId(values);
latestCustomId.current = customId;
const demographics = customSubjectIds.includes(customId) ? await lookupDemographics(scopeCustomId(customId)) : null;
// A slower lookup for an identifier the user has since moved on from must not overwrite the newer one.
if (latestCustomId.current !== customId) {
return;
}
const next: AutofilledDemographics = {
subjectDateOfBirth: demographics?.dateOfBirth ?? undefined,
subjectSex: demographics?.sex ?? undefined
};
setValues((current) => ({
...current,
subjectDateOfBirth:
next.subjectDateOfBirth ?? (autofilled.subjectDateOfBirth ? undefined : current.subjectDateOfBirth),
subjectSex: next.subjectSex ?? (autofilled.subjectSex ? undefined : current.subjectSex)
}));
setAutofilled(next);
};

return (
<Form
preventResetValuesOnReset
Expand Down Expand Up @@ -118,11 +160,13 @@ export const StartSessionForm = ({
}
},
subjectDateOfBirth: {
disabled: autofilled.subjectDateOfBirth !== undefined,
kind: 'date',
label: t('core.identificationData.dateOfBirth.label')
},
subjectSex: {
description: t('core.identificationData.sex.description'),
disabled: autofilled.subjectSex !== undefined,
kind: 'string',
label: t('core.identificationData.sex.label'),
options: {
Expand Down Expand Up @@ -165,6 +209,10 @@ export const StartSessionForm = ({
initialValues={initialValues}
readOnly={readOnly}
submitBtnLabel={t('core.submit')}
subscribe={{
onChange: autofillExistingSubject,
selector: selectedCustomId
}}
validationSchema={z
.object({
subjectFirstName: z.string().optional(),
Expand Down Expand Up @@ -269,9 +317,7 @@ export const StartSessionForm = ({
sex: subjectSex!
});
} else {
subjectId = encodeScopedSubjectId(subjectId, {
groupName: currentGroup?.name ?? DEFAULT_GROUP_NAME
});
subjectId = scopeCustomId(subjectId);
}
await onSubmit({
date: sessionDate,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,10 @@ describe('subjectCustomIdsQueryOptions', () => {
expect(get).toHaveBeenCalledWith('/v1/subjects/groups/group-1/custom-ids');
});

it('should make no request without a group, so a user with no group is offered no subjects', async () => {
await expect(runQuery()).resolves.toStrictEqual([]);
expect(get).not.toHaveBeenCalled();
it('should request the default group custom ids without a group, so an admin with no group is offered the subjects its sessions create', async () => {
get.mockResolvedValueOnce({ data: ['root$a'] });
await expect(runQuery()).resolves.toStrictEqual(['root$a']);
expect(get).toHaveBeenCalledWith('/v1/subjects/default-group/custom-ids');
});

it('should reject a response that is not a list of ids, so nothing unparsed reaches the form', async () => {
Expand Down
Loading
Loading