Skip to content

Commit c25b21e

Browse files
committed
refactor: code review changes for upload instrument feature
1 parent 6029185 commit c25b21e

21 files changed

Lines changed: 825 additions & 626 deletions

File tree

apps/api/src/instrument-records/dto/upload-instrument-record.dto.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { ValidationSchema } from '@douglasneuroinformatics/libnest/core';
22
import type { Json } from '@opendatacapture/schemas/core';
3-
import { $UploadInstrumentRecordData } from '@opendatacapture/schemas/instrument-records';
3+
import { $UploadInstrumentRecordsData } from '@opendatacapture/schemas/instrument-records';
44

5-
@ValidationSchema($UploadInstrumentRecordData)
6-
export class UploadInstrumentRecordDto {
5+
@ValidationSchema($UploadInstrumentRecordsData)
6+
export class UploadInstrumentRecordsDto {
77
groupId?: string;
88
instrumentId: string;
99
records: {

apps/api/src/instrument-records/instrument-records.controller.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,9 @@ import { RouteAccess } from '@/core/decorators/route-access.decorator';
1010
import type { AppAbility } from '@/core/types';
1111

1212
import { CreateInstrumentRecordDto } from './dto/create-instrument-record.dto';
13+
import { UploadInstrumentRecordsDto } from './dto/upload-instrument-record.dto';
1314
import { InstrumentRecordsService } from './instrument-records.service';
1415

15-
import { UploadInstrumentRecordDto } from './dto/upload-instrument-record.dto';
16-
1716
@ApiTags('Instrument Records')
1817
@Controller('instrument-records')
1918
export class InstrumentRecordsController {
@@ -26,10 +25,10 @@ export class InstrumentRecordsController {
2625
return this.instrumentRecordsService.create(data, { ability });
2726
}
2827

29-
@ApiOperation({ summary: 'Upload multiple instrument records' })
28+
@ApiOperation({ summary: 'Upload Multiple Instrument Records' })
3029
@Post('upload')
3130
@RouteAccess({ action: 'create', subject: 'InstrumentRecord' })
32-
upload(@Body() data: UploadInstrumentRecordDto, @CurrentUser('ability') ability: AppAbility) {
31+
upload(@Body() data: UploadInstrumentRecordsDto, @CurrentUser('ability') ability: AppAbility) {
3332
return this.instrumentRecordsService.upload(data, { ability });
3433
}
3534

apps/api/src/instrument-records/instrument-records.service.ts

Lines changed: 79 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,9 @@ import type {
88
InstrumentRecordQueryParams,
99
InstrumentRecordsExport,
1010
LinearRegressionResults,
11-
UploadInstrumentRecordData
11+
UploadInstrumentRecordsData
1212
} from '@opendatacapture/schemas/instrument-records';
13-
import type { SessionType } from '@opendatacapture/schemas/session';
14-
import type { CreateSubjectData } from '@opendatacapture/schemas/subject';
15-
import type { InstrumentRecordModel, Prisma } from '@prisma/generated-client';
13+
import type { InstrumentRecordModel, Prisma, SessionModel } from '@prisma/generated-client';
1614
import { isNumber, pickBy } from 'lodash-es';
1715

1816
import { accessibleQuery } from '@/ability/ability.utils';
@@ -253,91 +251,106 @@ export class InstrumentRecordsService {
253251
}
254252

255253
async upload(
256-
{ groupId, instrumentId, records }: UploadInstrumentRecordData,
254+
{ groupId, instrumentId, records }: UploadInstrumentRecordsData,
257255
options?: EntityOperationOptions
258256
): Promise<InstrumentRecordModel[]> {
259257
if (groupId) {
260258
await this.groupsService.findById(groupId, options);
261259
}
262260

263261
const instrument = await this.instrumentsService.findById(instrumentId);
264-
265-
const createdModelsArray: InstrumentRecordModel[] = [];
266262
if (instrument.kind === 'SERIES') {
267263
throw new UnprocessableEntityException(
268264
`Cannot create instrument record for series instrument '${instrument.id}'`
269265
);
270266
}
271267

272-
for (const record of records) {
273-
const { data, date, subjectId } = record;
274-
275-
//create a new subject id in subjects service if they dont exist
276-
try {
277-
await this.subjectsService.findById(subjectId);
278-
} catch (exception) {
279-
if (exception instanceof NotFoundException) {
280-
const addedSubject: CreateSubjectDto = {
281-
id: subjectId
282-
};
283-
await this.subjectsService.create(addedSubject);
284-
} else {
285-
throw exception;
286-
}
287-
}
288-
289-
let subjectInfo: CreateSubjectData = {
290-
dateOfBirth: null,
291-
firstName: null,
292-
id: subjectId,
293-
lastName: null,
294-
sex: null
295-
};
268+
const createdRecordsArray: InstrumentRecordModel[] = [];
269+
const createdSessionsArray: SessionModel[] = [];
296270

297-
let sessionType: SessionType = 'RETROSPECTIVE';
271+
try {
272+
for (let i = 0; i < records.length; i++) {
273+
const { data, date, subjectId } = records[i]!;
274+
await this.createSubjectIfNotFound(subjectId);
298275

299-
let sessionId = (
300-
await this.sessionsService.create({
276+
const session = await this.sessionsService.create({
301277
date: date,
302278
groupId: groupId ? groupId : null,
303-
subjectData: subjectInfo,
304-
type: sessionType
305-
})
306-
).id;
307-
308-
const createdModel = await this.instrumentRecordModel.create({
309-
data: {
310-
computedMeasures: instrument.measures
311-
? this.instrumentMeasuresService.computeMeasures(instrument.measures, data)
312-
: null,
313-
data,
314-
date,
315-
group: groupId
316-
? {
317-
connect: { id: groupId }
318-
}
319-
: undefined,
320-
instrument: {
321-
connect: {
322-
id: instrumentId
323-
}
324-
},
325-
session: {
326-
connect: {
327-
id: sessionId
328-
}
279+
subjectData: {
280+
id: subjectId
329281
},
330-
subject: {
331-
connect: {
332-
id: subjectId
282+
type: 'RETROSPECTIVE'
283+
});
284+
285+
createdSessionsArray.push(session);
286+
287+
const sessionId = session.id;
288+
289+
if (!instrument.validationSchema.safeParse(data).success) {
290+
throw new UnprocessableEntityException(
291+
`Data received for record at index '${i}' does not pass validation schema of instrument '${instrument.id}'`
292+
);
293+
}
294+
295+
const createdRecord = await this.instrumentRecordModel.create({
296+
data: {
297+
computedMeasures: instrument.measures
298+
? this.instrumentMeasuresService.computeMeasures(instrument.measures, data)
299+
: null,
300+
data,
301+
date,
302+
group: groupId
303+
? {
304+
connect: { id: groupId }
305+
}
306+
: undefined,
307+
instrument: {
308+
connect: {
309+
id: instrumentId
310+
}
311+
},
312+
session: {
313+
connect: {
314+
id: sessionId
315+
}
316+
},
317+
subject: {
318+
connect: {
319+
id: subjectId
320+
}
333321
}
334322
}
323+
});
324+
325+
createdRecordsArray.push(createdRecord);
326+
}
327+
} catch (err) {
328+
await this.instrumentRecordModel.deleteMany({
329+
where: {
330+
id: {
331+
in: createdRecordsArray.map((record) => record.id)
332+
}
335333
}
336334
});
337-
338-
createdModelsArray.push(createdModel);
335+
await this.sessionsService.deleteByIds(createdSessionsArray.map((session) => session.id));
336+
throw err;
339337
}
340338

341-
return createdModelsArray;
339+
return createdRecordsArray;
340+
}
341+
342+
private async createSubjectIfNotFound(subjectId: string) {
343+
try {
344+
await this.subjectsService.findById(subjectId);
345+
} catch (exception) {
346+
if (exception instanceof NotFoundException) {
347+
const addedSubject: CreateSubjectDto = {
348+
id: subjectId
349+
};
350+
await this.subjectsService.create(addedSubject);
351+
} else {
352+
throw exception;
353+
}
354+
}
342355
}
343356
}

apps/api/src/sessions/sessions.service.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,23 @@ export class SessionsService {
5656
}))!;
5757
}
5858

59+
async deleteById(id: string, { ability }: EntityOperationOptions = {}) {
60+
return this.sessionModel.delete({
61+
where: { AND: [accessibleQuery(ability, 'delete', 'Session')], id }
62+
});
63+
}
64+
65+
async deleteByIds(ids: string[], { ability }: EntityOperationOptions = {}) {
66+
return this.sessionModel.deleteMany({
67+
where: {
68+
AND: [accessibleQuery(ability, 'delete', 'Session')],
69+
id: {
70+
in: ids
71+
}
72+
}
73+
});
74+
}
75+
5976
async findById(id: string, { ability }: EntityOperationOptions = {}) {
6077
const session = await this.sessionModel.findFirst({
6178
where: { AND: [accessibleQuery(ability, 'read', 'Session')], id }

apps/web/src/components/Sidebar/Sidebar.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import { useAppStore } from '@/store';
1313
import { NavButton } from '../NavButton';
1414
import { UserDropup } from '../UserDropup';
1515

16-
// eslint-disable-next-line max-lines-per-function
1716
export const Sidebar = () => {
1817
const navItems = useNavItems();
1918
const currentSession = useAppStore((store) => store.currentSession);

apps/web/src/features/about/components/TimeValue.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export type TimeValueProps = {
99

1010
export const TimeValue = ({ value }: TimeValueProps) => {
1111
const format = useCallback((uptime: number) => {
12+
// eslint-disable-next-line prefer-const
1213
let { days, hours, minutes, seconds } = parseDuration(uptime * 1000);
1314
hours += days * 24;
1415
return [hours, minutes, seconds].map((value) => (value < 10 ? '0' + value : value)).join(':');

apps/web/src/features/admin/pages/ManageGroupsPage.tsx

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import { useEffect, useState } from 'react';
1+
import { useState } from 'react';
22

33
import { Button, ClientTable, Heading, SearchBar, Sheet } from '@douglasneuroinformatics/libui/components';
44
import { useTranslation } from '@douglasneuroinformatics/libui/hooks';
55
import type { Group } from '@opendatacapture/schemas/group';
66
import { Link } from 'react-router-dom';
77

88
import { PageHeader } from '@/components/PageHeader';
9+
import { useSearch } from '@/hooks/useSearch';
910

1011
import { useDeleteGroupMutation } from '../hooks/useDeleteGroupMutation';
1112
import { useGroupsQuery } from '../hooks/useGroupsQuery';
@@ -15,12 +16,7 @@ export const ManageGroupsPage = () => {
1516
const groupsQuery = useGroupsQuery();
1617
const deleteGroupMutation = useDeleteGroupMutation();
1718
const [selectedGroup, setSelectedGroup] = useState<Group | null>(null);
18-
const [groups, setGroups] = useState<Group[]>(groupsQuery.data ?? []);
19-
const [searchTerm, setSearchTerm] = useState('');
20-
21-
useEffect(() => {
22-
setGroups((groupsQuery.data ?? []).filter((group) => group.name.toLowerCase().includes(searchTerm.toLowerCase())));
23-
}, [groupsQuery.data, searchTerm]);
19+
const { filteredData, searchTerm, setSearchTerm } = useSearch(groupsQuery.data ?? [], 'name');
2420

2521
return (
2622
<Sheet open={Boolean(selectedGroup)} onOpenChange={() => setSelectedGroup(null)}>
@@ -69,7 +65,7 @@ export const ManageGroupsPage = () => {
6965
label: t('common.groupType')
7066
}
7167
]}
72-
data={groups}
68+
data={filteredData}
7369
entriesPerPage={15}
7470
minRows={15}
7571
onEntryClick={setSelectedGroup}

apps/web/src/features/admin/pages/ManageUsersPage.tsx

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useState } from 'react';
1+
import { useState } from 'react';
22

33
import { snakeToCamelCase } from '@douglasneuroinformatics/libjs';
44
import { Button, ClientTable, Heading, SearchBar, Sheet } from '@douglasneuroinformatics/libui/components';
@@ -7,24 +7,19 @@ import type { User } from '@opendatacapture/schemas/user';
77
import { Link } from 'react-router-dom';
88

99
import { PageHeader } from '@/components/PageHeader';
10+
import { useSearch } from '@/hooks/useSearch';
1011
import { useAppStore } from '@/store';
1112

1213
import { useDeleteUserMutation } from '../hooks/useDeleteUserMutation';
1314
import { useUsersQuery } from '../hooks/useUsersQuery';
1415

15-
// eslint-disable-next-line max-lines-per-function
1616
export const ManageUsersPage = () => {
1717
const currentUser = useAppStore((store) => store.currentUser);
1818
const { t } = useTranslation();
1919
const usersQuery = useUsersQuery();
2020
const deleteUserMutation = useDeleteUserMutation();
21-
const [users, setUsers] = useState<User[]>(usersQuery.data ?? []);
2221
const [selectedUser, setSelectedUser] = useState<null | User>(null);
23-
const [searchTerm, setSearchTerm] = useState('');
24-
25-
useEffect(() => {
26-
setUsers((usersQuery.data ?? []).filter((user) => user.username.toLowerCase().includes(searchTerm.toLowerCase())));
27-
}, [usersQuery.data, searchTerm]);
22+
const { filteredData, searchTerm, setSearchTerm } = useSearch(usersQuery.data ?? [], 'username');
2823

2924
const currentUserIsSelected = selectedUser?.username === currentUser?.username;
3025

@@ -76,7 +71,7 @@ export const ManageUsersPage = () => {
7671
label: t('common.basePermissionLevel')
7772
}
7873
]}
79-
data={users}
74+
data={filteredData}
8075
entriesPerPage={15}
8176
minRows={15}
8277
onEntryClick={setSelectedUser}

apps/web/src/features/datahub/pages/DataHubPage.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ import { downloadExcel } from '@/utils/excel';
1818
import { MasterDataTable } from '../components/MasterDataTable';
1919
import { useSubjectsQuery } from '../hooks/useSubjectsQuery';
2020

21-
// eslint-disable-next-line max-lines-per-function
2221
export const DataHubPage = () => {
2322
const [isLookupOpen, setIsLookupOpen] = useState(false);
2423

apps/web/src/features/upload/components/UploadSelectTable.tsx

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,37 @@
11
import { ClientTable } from '@douglasneuroinformatics/libui/components';
22
import { useTranslation } from '@douglasneuroinformatics/libui/hooks';
3-
import type { InstrumentInfo } from '@opendatacapture/schemas/instrument';
3+
import type { UnilingualInstrumentInfo } from '@opendatacapture/schemas/instrument';
44

55
export type UploadSelectTableProps = {
6-
data: InstrumentInfo[];
7-
onSelect: (instrument: InstrumentInfo) => void;
6+
data: UnilingualInstrumentInfo[];
7+
onSelect: (instrument: UnilingualInstrumentInfo) => void;
88
};
99

1010
export const UploadSelectTable = ({ data, onSelect }: UploadSelectTableProps) => {
1111
const { t } = useTranslation();
1212

1313
// Renders a table for selecting an instrument to upload data for
1414
return (
15-
<ClientTable<InstrumentInfo>
15+
<ClientTable<UnilingualInstrumentInfo>
1616
columns={[
1717
{
18-
field: (instrument) => (instrument.details?.title as string) ?? 'N/A',
18+
field: (instrument) => instrument.details.title,
1919
label: t({
20-
en: 'Select an instrument',
21-
fr: 'Selectionnez un instrument'
20+
en: 'Title',
21+
fr: 'Titre'
22+
})
23+
},
24+
{
25+
field: (instrument) => instrument.kind,
26+
label: t({
27+
en: 'Kind',
28+
fr: 'Genre'
2229
})
2330
}
2431
]}
2532
data={data}
2633
entriesPerPage={15}
34+
minRows={15}
2735
onEntryClick={onSelect}
2836
/>
2937
);

0 commit comments

Comments
 (0)