Skip to content

Commit 6029185

Browse files
authored
Merge pull request #986 from david-roper/upload-data-session
2 parents fd99aea + 7f2f688 commit 6029185

19 files changed

Lines changed: 1000 additions & 8 deletions

File tree

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 { $UploadInstrumentRecordData } from '@opendatacapture/schemas/instrument-records';
4+
5+
@ValidationSchema($UploadInstrumentRecordData)
6+
export class UploadInstrumentRecordDto {
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: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import type { AppAbility } from '@/core/types';
1212
import { CreateInstrumentRecordDto } from './dto/create-instrument-record.dto';
1313
import { InstrumentRecordsService } from './instrument-records.service';
1414

15+
import { UploadInstrumentRecordDto } from './dto/upload-instrument-record.dto';
16+
1517
@ApiTags('Instrument Records')
1618
@Controller('instrument-records')
1719
export class InstrumentRecordsController {
@@ -24,6 +26,13 @@ export class InstrumentRecordsController {
2426
return this.instrumentRecordsService.create(data, { ability });
2527
}
2628

29+
@ApiOperation({ summary: 'Upload multiple instrument records' })
30+
@Post('upload')
31+
@RouteAccess({ action: 'create', subject: 'InstrumentRecord' })
32+
upload(@Body() data: UploadInstrumentRecordDto, @CurrentUser('ability') ability: AppAbility) {
33+
return this.instrumentRecordsService.upload(data, { ability });
34+
}
35+
2736
@ApiOperation({ summary: 'Get Records for Instrument ' })
2837
@Get()
2938
@RouteAccess({ action: 'read', subject: 'InstrumentRecord' })

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

Lines changed: 100 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
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+
UploadInstrumentRecordData
1112
} from '@opendatacapture/schemas/instrument-records';
13+
import type { SessionType } from '@opendatacapture/schemas/session';
14+
import type { CreateSubjectData } from '@opendatacapture/schemas/subject';
1215
import type { InstrumentRecordModel, Prisma } from '@prisma/generated-client';
1316
import { isNumber, pickBy } from 'lodash-es';
1417

@@ -19,6 +22,7 @@ import { InstrumentsService } from '@/instruments/instruments.service';
1922
import { InjectModel } from '@/prisma/prisma.decorators';
2023
import type { Model } from '@/prisma/prisma.types';
2124
import { SessionsService } from '@/sessions/sessions.service';
25+
import type { CreateSubjectDto } from '@/subjects/dto/create-subject.dto';
2226
import { SubjectsService } from '@/subjects/subjects.service';
2327
import { VirtualizationService } from '@/virtualization/virtualization.service';
2428

@@ -61,7 +65,11 @@ export class InstrumentRecordsService {
6165

6266
await this.subjectsService.findById(subjectId);
6367
await this.sessionsService.findById(sessionId);
64-
68+
if (!instrument.validationSchema.safeParse(data).success) {
69+
throw new UnprocessableEntityException(
70+
`Data received does not pass validation schema of instrument '${instrument.id}'`
71+
);
72+
}
6573
return this.instrumentRecordModel.create({
6674
data: {
6775
computedMeasures: instrument.measures
@@ -243,4 +251,93 @@ export class InstrumentRecordsService {
243251
}
244252
return results;
245253
}
254+
255+
async upload(
256+
{ groupId, instrumentId, records }: UploadInstrumentRecordData,
257+
options?: EntityOperationOptions
258+
): Promise<InstrumentRecordModel[]> {
259+
if (groupId) {
260+
await this.groupsService.findById(groupId, options);
261+
}
262+
263+
const instrument = await this.instrumentsService.findById(instrumentId);
264+
265+
const createdModelsArray: InstrumentRecordModel[] = [];
266+
if (instrument.kind === 'SERIES') {
267+
throw new UnprocessableEntityException(
268+
`Cannot create instrument record for series instrument '${instrument.id}'`
269+
);
270+
}
271+
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+
};
296+
297+
let sessionType: SessionType = 'RETROSPECTIVE';
298+
299+
let sessionId = (
300+
await this.sessionsService.create({
301+
date: date,
302+
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+
}
329+
},
330+
subject: {
331+
connect: {
332+
id: subjectId
333+
}
334+
}
335+
}
336+
});
337+
338+
createdModelsArray.push(createdModel);
339+
}
340+
341+
return createdModelsArray;
342+
}
246343
}

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/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
{

apps/web/src/features/datahub/components/SubjectLayout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export const SubjectLayout = () => {
2626
en: 'Instrument Records for Subject {}',
2727
fr: "Dossiers d'instruments pour le client {}"
2828
},
29-
removeSubjectIdScope(subjectId).slice(0, 7)
29+
removeSubjectIdScope(subjectId).slice(0, 9)
3030
)}
3131
</Heading>
3232
</PageHeader>
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { ClientTable } from '@douglasneuroinformatics/libui/components';
2+
import { useTranslation } from '@douglasneuroinformatics/libui/hooks';
3+
import type { InstrumentInfo } from '@opendatacapture/schemas/instrument';
4+
5+
export type UploadSelectTableProps = {
6+
data: InstrumentInfo[];
7+
onSelect: (instrument: InstrumentInfo) => void;
8+
};
9+
10+
export const UploadSelectTable = ({ data, onSelect }: UploadSelectTableProps) => {
11+
const { t } = useTranslation();
12+
13+
// Renders a table for selecting an instrument to upload data for
14+
return (
15+
<ClientTable<InstrumentInfo>
16+
columns={[
17+
{
18+
field: (instrument) => (instrument.details?.title as string) ?? 'N/A',
19+
label: t({
20+
en: 'Select an instrument',
21+
fr: 'Selectionnez un instrument'
22+
})
23+
}
24+
]}
25+
data={data}
26+
entriesPerPage={15}
27+
onEntryClick={onSelect}
28+
/>
29+
);
30+
};

apps/web/src/features/upload/hooks/useProcessUploadData.ts

Whitespace-only changes.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { type RouteObject } from 'react-router-dom';
2+
3+
import { UploadPage } from './pages/UploadPage';
4+
import { UploadSelectPage } from './pages/UploadSelectPage';
5+
6+
export const uploadRoute: RouteObject = {
7+
children: [
8+
{
9+
element: <UploadSelectPage />,
10+
index: true
11+
},
12+
{
13+
element: <UploadPage />,
14+
path: ':id' //instrument id
15+
}
16+
],
17+
path: 'upload'
18+
};
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { useState } from 'react';
2+
3+
import { FileDropzone } from '@douglasneuroinformatics/libui/components';
4+
import { Button } from '@douglasneuroinformatics/libui/components';
5+
import { useDownload } from '@douglasneuroinformatics/libui/hooks';
6+
import { useNotificationsStore } from '@douglasneuroinformatics/libui/hooks';
7+
import type { AnyUnilingualFormInstrument, FormTypes, Json } from '@opendatacapture/runtime-core';
8+
import type { UploadInstrumentRecordData } from '@opendatacapture/schemas/instrument-records';
9+
import { encodeScopedSubjectId } from '@opendatacapture/subject-utils';
10+
import axios from 'axios';
11+
import { DownloadIcon } from 'lucide-react';
12+
import { useParams } from 'react-router-dom';
13+
14+
import { useInstrument } from '@/hooks/useInstrument';
15+
import { useAppStore } from '@/store';
16+
17+
import { createUploadTemplateCSV, processInstrumentCSV } from '../utils';
18+
19+
export const UploadPage = () => {
20+
const [file, setFile] = useState<File | null>(null);
21+
const download = useDownload();
22+
const addNotification = useNotificationsStore((store) => store.addNotification);
23+
const acceptedFiles = {
24+
'text/csv': ['.csv']
25+
};
26+
const currentGroup = useAppStore((store) => store.currentGroup);
27+
28+
const params = useParams();
29+
const instrument = useInstrument(params.id!) as AnyUnilingualFormInstrument;
30+
31+
const sendInstrumentData = async (data: FormTypes.Data[]) => {
32+
const reformatForSending = reformatInstrumentData(data);
33+
34+
try {
35+
await axios.post('/v1/instrument-records/upload', reformatForSending satisfies UploadInstrumentRecordData);
36+
addNotification({ type: 'success' });
37+
} catch (error) {
38+
console.error(error);
39+
}
40+
};
41+
42+
const reformatInstrumentData = (data: FormTypes.Data[]): UploadInstrumentRecordData => {
43+
const recordsList = [];
44+
45+
for (const dataInfo of data) {
46+
const { date: dataDate, subjectID: dataSubjectId, ...restOfData } = dataInfo; // Destructure and extract the rest of the data
47+
48+
const createdRecord = {
49+
data: restOfData as Json,
50+
date: dataDate as Date,
51+
subjectId: encodeScopedSubjectId(dataSubjectId as string, {
52+
groupName: currentGroup?.name ?? 'root'
53+
})
54+
};
55+
recordsList.push(createdRecord);
56+
}
57+
58+
const reformatForSending: UploadInstrumentRecordData = {
59+
groupId: undefined,
60+
instrumentId: instrument.id!,
61+
records: recordsList
62+
};
63+
64+
return reformatForSending;
65+
};
66+
67+
const handleTemplateDownload = () => {
68+
const { content, fileName } = createUploadTemplateCSV(instrument);
69+
void download(fileName, content);
70+
};
71+
72+
const handleInstrumentCSV = async () => {
73+
const input = file!;
74+
75+
const processedData = await processInstrumentCSV(input, instrument);
76+
77+
if (processedData.success) {
78+
await sendInstrumentData(processedData.value);
79+
} else {
80+
addNotification({
81+
message: processedData.message,
82+
type: 'error'
83+
});
84+
}
85+
};
86+
87+
return (
88+
<div className="align-center items-center justify-center">
89+
<FileDropzone acceptedFileTypes={acceptedFiles} file={file} setFile={setFile} />
90+
<div className="mt-4 flex justify-between space-x-2">
91+
<Button disabled={!(file && instrument)} variant={'primary'} onClick={handleInstrumentCSV}>
92+
Submit
93+
</Button>
94+
<Button disabled={!instrument} variant={'primary'} onClick={handleTemplateDownload}>
95+
<DownloadIcon />
96+
Download Template
97+
</Button>
98+
</div>
99+
</div>
100+
);
101+
};

0 commit comments

Comments
 (0)