Skip to content

Commit 2a24e43

Browse files
authored
Merge pull request #990 from DouglasNeuroInformatics/upload-feature
2 parents edfc117 + 31c8de2 commit 2a24e43

27 files changed

Lines changed: 1558 additions & 361 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { ValidationSchema } from '@douglasneuroinformatics/libnest/core';
2+
import type { Json } from '@opendatacapture/schemas/core';
3+
import { $UploadInstrumentRecordsData } from '@opendatacapture/schemas/instrument-records';
4+
5+
@ValidationSchema($UploadInstrumentRecordsData)
6+
export class UploadInstrumentRecordsDto {
7+
groupId?: string;
8+
instrumentId: string;
9+
records: {
10+
data: Json;
11+
date: Date;
12+
subjectId: string;
13+
}[];
14+
}

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ 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

1516
@ApiTags('Instrument Records')
@@ -24,6 +25,13 @@ export class InstrumentRecordsController {
2425
return this.instrumentRecordsService.create(data, { ability });
2526
}
2627

28+
@ApiOperation({ summary: 'Upload Multiple Instrument Records' })
29+
@Post('upload')
30+
@RouteAccess({ action: 'create', subject: 'InstrumentRecord' })
31+
upload(@Body() data: UploadInstrumentRecordsDto, @CurrentUser('ability') ability: AppAbility) {
32+
return this.instrumentRecordsService.upload(data, { ability });
33+
}
34+
2735
@ApiOperation({ summary: 'Get Records for Instrument ' })
2836
@Get()
2937
@RouteAccess({ action: 'read', subject: 'InstrumentRecord' })

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

Lines changed: 114 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
import { yearsPassed } from '@douglasneuroinformatics/libjs';
22
import { linearRegression } from '@douglasneuroinformatics/libstats';
3-
import { Injectable, UnprocessableEntityException } from '@nestjs/common';
3+
import { Injectable, NotFoundException, UnprocessableEntityException } from '@nestjs/common';
44
import type { ScalarInstrument } from '@opendatacapture/runtime-core';
55
import type {
66
CreateInstrumentRecordData,
77
InstrumentRecord,
88
InstrumentRecordQueryParams,
99
InstrumentRecordsExport,
10-
LinearRegressionResults
10+
LinearRegressionResults,
11+
UploadInstrumentRecordsData
1112
} from '@opendatacapture/schemas/instrument-records';
12-
import type { InstrumentRecordModel, Prisma } from '@prisma/generated-client';
13+
import type { InstrumentRecordModel, Prisma, SessionModel } from '@prisma/generated-client';
1314
import { isNumber, pickBy } from 'lodash-es';
1415

1516
import { accessibleQuery } from '@/ability/ability.utils';
@@ -19,6 +20,7 @@ import { InstrumentsService } from '@/instruments/instruments.service';
1920
import { InjectModel } from '@/prisma/prisma.decorators';
2021
import type { Model } from '@/prisma/prisma.types';
2122
import { SessionsService } from '@/sessions/sessions.service';
23+
import type { CreateSubjectDto } from '@/subjects/dto/create-subject.dto';
2224
import { SubjectsService } from '@/subjects/subjects.service';
2325
import { VirtualizationService } from '@/virtualization/virtualization.service';
2426

@@ -61,7 +63,11 @@ export class InstrumentRecordsService {
6163

6264
await this.subjectsService.findById(subjectId);
6365
await this.sessionsService.findById(sessionId);
64-
66+
if (!instrument.validationSchema.safeParse(data).success) {
67+
throw new UnprocessableEntityException(
68+
`Data received does not pass validation schema of instrument '${instrument.id}'`
69+
);
70+
}
6571
return this.instrumentRecordModel.create({
6672
data: {
6773
computedMeasures: instrument.measures
@@ -243,4 +249,108 @@ export class InstrumentRecordsService {
243249
}
244250
return results;
245251
}
252+
253+
async upload(
254+
{ groupId, instrumentId, records }: UploadInstrumentRecordsData,
255+
options?: EntityOperationOptions
256+
): Promise<InstrumentRecordModel[]> {
257+
if (groupId) {
258+
await this.groupsService.findById(groupId, options);
259+
}
260+
261+
const instrument = await this.instrumentsService.findById(instrumentId);
262+
if (instrument.kind === 'SERIES') {
263+
throw new UnprocessableEntityException(
264+
`Cannot create instrument record for series instrument '${instrument.id}'`
265+
);
266+
}
267+
268+
const createdRecordsArray: InstrumentRecordModel[] = [];
269+
const createdSessionsArray: SessionModel[] = [];
270+
271+
try {
272+
for (let i = 0; i < records.length; i++) {
273+
const { data, date, subjectId } = records[i]!;
274+
await this.createSubjectIfNotFound(subjectId);
275+
276+
const session = await this.sessionsService.create({
277+
date: date,
278+
groupId: groupId ? groupId : null,
279+
subjectData: {
280+
id: subjectId
281+
},
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+
}
321+
}
322+
}
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+
}
333+
}
334+
});
335+
await this.sessionsService.deleteByIds(createdSessionsArray.map((session) => session.id));
336+
throw err;
337+
}
338+
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+
}
355+
}
246356
}

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/Routes.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { datahubRoute } from './features/datahub';
1313
import { groupRoute } from './features/group';
1414
import { instrumentsRoute } from './features/instruments';
1515
import { sessionRoute } from './features/session';
16+
import { uploadRoute } from './features/upload';
1617
import { userRoute } from './features/user';
1718
import { DisclaimerProvider } from './providers/DisclaimerProvider';
1819
import { WalkthroughProvider } from './providers/WalkthroughProvider';
@@ -45,7 +46,8 @@ const protectedRoutes: RouteObject[] = [
4546
groupRoute,
4647
instrumentsRoute,
4748
sessionRoute,
48-
userRoute
49+
userRoute,
50+
uploadRoute
4951
]
5052
}
5153
];

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/components/MasterDataTable.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export const MasterDataTable = ({ data, onSelect }: MasterDataTableProps) => {
1515
<ClientTable<Subject>
1616
columns={[
1717
{
18-
field: (subject) => removeSubjectIdScope(subject.id).slice(0, 7),
18+
field: (subject) => removeSubjectIdScope(subject.id).slice(0, 9),
1919
label: t('datahub.index.table.subject')
2020
},
2121
{

0 commit comments

Comments
 (0)