Skip to content

Commit

Permalink
Merge pull request #990 from DouglasNeuroInformatics/upload-feature
Browse files Browse the repository at this point in the history
  • Loading branch information
joshunrau authored Oct 18, 2024
2 parents edfc117 + 31c8de2 commit 2a24e43
Show file tree
Hide file tree
Showing 27 changed files with 1,558 additions and 361 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { ValidationSchema } from '@douglasneuroinformatics/libnest/core';
import type { Json } from '@opendatacapture/schemas/core';
import { $UploadInstrumentRecordsData } from '@opendatacapture/schemas/instrument-records';

@ValidationSchema($UploadInstrumentRecordsData)
export class UploadInstrumentRecordsDto {
groupId?: string;
instrumentId: string;
records: {
data: Json;
date: Date;
subjectId: string;
}[];
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { RouteAccess } from '@/core/decorators/route-access.decorator';
import type { AppAbility } from '@/core/types';

import { CreateInstrumentRecordDto } from './dto/create-instrument-record.dto';
import { UploadInstrumentRecordsDto } from './dto/upload-instrument-record.dto';
import { InstrumentRecordsService } from './instrument-records.service';

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

@ApiOperation({ summary: 'Upload Multiple Instrument Records' })
@Post('upload')
@RouteAccess({ action: 'create', subject: 'InstrumentRecord' })
upload(@Body() data: UploadInstrumentRecordsDto, @CurrentUser('ability') ability: AppAbility) {
return this.instrumentRecordsService.upload(data, { ability });
}

@ApiOperation({ summary: 'Get Records for Instrument ' })
@Get()
@RouteAccess({ action: 'read', subject: 'InstrumentRecord' })
Expand Down
118 changes: 114 additions & 4 deletions apps/api/src/instrument-records/instrument-records.service.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import { yearsPassed } from '@douglasneuroinformatics/libjs';
import { linearRegression } from '@douglasneuroinformatics/libstats';
import { Injectable, UnprocessableEntityException } from '@nestjs/common';
import { Injectable, NotFoundException, UnprocessableEntityException } from '@nestjs/common';
import type { ScalarInstrument } from '@opendatacapture/runtime-core';
import type {
CreateInstrumentRecordData,
InstrumentRecord,
InstrumentRecordQueryParams,
InstrumentRecordsExport,
LinearRegressionResults
LinearRegressionResults,
UploadInstrumentRecordsData
} from '@opendatacapture/schemas/instrument-records';
import type { InstrumentRecordModel, Prisma } from '@prisma/generated-client';
import type { InstrumentRecordModel, Prisma, SessionModel } from '@prisma/generated-client';
import { isNumber, pickBy } from 'lodash-es';

import { accessibleQuery } from '@/ability/ability.utils';
Expand All @@ -19,6 +20,7 @@ import { InstrumentsService } from '@/instruments/instruments.service';
import { InjectModel } from '@/prisma/prisma.decorators';
import type { Model } from '@/prisma/prisma.types';
import { SessionsService } from '@/sessions/sessions.service';
import type { CreateSubjectDto } from '@/subjects/dto/create-subject.dto';
import { SubjectsService } from '@/subjects/subjects.service';
import { VirtualizationService } from '@/virtualization/virtualization.service';

Expand Down Expand Up @@ -61,7 +63,11 @@ export class InstrumentRecordsService {

await this.subjectsService.findById(subjectId);
await this.sessionsService.findById(sessionId);

if (!instrument.validationSchema.safeParse(data).success) {
throw new UnprocessableEntityException(
`Data received does not pass validation schema of instrument '${instrument.id}'`
);
}
return this.instrumentRecordModel.create({
data: {
computedMeasures: instrument.measures
Expand Down Expand Up @@ -243,4 +249,108 @@ export class InstrumentRecordsService {
}
return results;
}

async upload(
{ groupId, instrumentId, records }: UploadInstrumentRecordsData,
options?: EntityOperationOptions
): Promise<InstrumentRecordModel[]> {
if (groupId) {
await this.groupsService.findById(groupId, options);
}

const instrument = await this.instrumentsService.findById(instrumentId);
if (instrument.kind === 'SERIES') {
throw new UnprocessableEntityException(
`Cannot create instrument record for series instrument '${instrument.id}'`
);
}

const createdRecordsArray: InstrumentRecordModel[] = [];
const createdSessionsArray: SessionModel[] = [];

try {
for (let i = 0; i < records.length; i++) {
const { data, date, subjectId } = records[i]!;
await this.createSubjectIfNotFound(subjectId);

const session = await this.sessionsService.create({
date: date,
groupId: groupId ? groupId : null,
subjectData: {
id: subjectId
},
type: 'RETROSPECTIVE'
});

createdSessionsArray.push(session);

const sessionId = session.id;

if (!instrument.validationSchema.safeParse(data).success) {
throw new UnprocessableEntityException(
`Data received for record at index '${i}' does not pass validation schema of instrument '${instrument.id}'`
);
}

const createdRecord = await this.instrumentRecordModel.create({
data: {
computedMeasures: instrument.measures
? this.instrumentMeasuresService.computeMeasures(instrument.measures, data)
: null,
data,
date,
group: groupId
? {
connect: { id: groupId }
}
: undefined,
instrument: {
connect: {
id: instrumentId
}
},
session: {
connect: {
id: sessionId
}
},
subject: {
connect: {
id: subjectId
}
}
}
});

createdRecordsArray.push(createdRecord);
}
} catch (err) {
await this.instrumentRecordModel.deleteMany({
where: {
id: {
in: createdRecordsArray.map((record) => record.id)
}
}
});
await this.sessionsService.deleteByIds(createdSessionsArray.map((session) => session.id));
throw err;
}

return createdRecordsArray;
}

private async createSubjectIfNotFound(subjectId: string) {
try {
await this.subjectsService.findById(subjectId);
} catch (exception) {
if (exception instanceof NotFoundException) {
const addedSubject: CreateSubjectDto = {
id: subjectId
};
await this.subjectsService.create(addedSubject);
} else {
throw exception;
}
}
}
}
17 changes: 17 additions & 0 deletions apps/api/src/sessions/sessions.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,23 @@ export class SessionsService {
}))!;
}

async deleteById(id: string, { ability }: EntityOperationOptions = {}) {
return this.sessionModel.delete({
where: { AND: [accessibleQuery(ability, 'delete', 'Session')], id }
});
}

async deleteByIds(ids: string[], { ability }: EntityOperationOptions = {}) {
return this.sessionModel.deleteMany({
where: {
AND: [accessibleQuery(ability, 'delete', 'Session')],
id: {
in: ids
}
}
});
}

async findById(id: string, { ability }: EntityOperationOptions = {}) {
const session = await this.sessionModel.findFirst({
where: { AND: [accessibleQuery(ability, 'read', 'Session')], id }
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/Routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { datahubRoute } from './features/datahub';
import { groupRoute } from './features/group';
import { instrumentsRoute } from './features/instruments';
import { sessionRoute } from './features/session';
import { uploadRoute } from './features/upload';
import { userRoute } from './features/user';
import { DisclaimerProvider } from './providers/DisclaimerProvider';
import { WalkthroughProvider } from './providers/WalkthroughProvider';
Expand Down Expand Up @@ -45,7 +46,8 @@ const protectedRoutes: RouteObject[] = [
groupRoute,
instrumentsRoute,
sessionRoute,
userRoute
userRoute,
uploadRoute
]
}
];
Expand Down
1 change: 0 additions & 1 deletion apps/web/src/components/Sidebar/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import { useAppStore } from '@/store';
import { NavButton } from '../NavButton';
import { UserDropup } from '../UserDropup';

// eslint-disable-next-line max-lines-per-function
export const Sidebar = () => {
const navItems = useNavItems();
const currentSession = useAppStore((store) => store.currentSession);
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/features/about/components/TimeValue.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export type TimeValueProps = {

export const TimeValue = ({ value }: TimeValueProps) => {
const format = useCallback((uptime: number) => {
// eslint-disable-next-line prefer-const
let { days, hours, minutes, seconds } = parseDuration(uptime * 1000);
hours += days * 24;
return [hours, minutes, seconds].map((value) => (value < 10 ? '0' + value : value)).join(':');
Expand Down
12 changes: 4 additions & 8 deletions apps/web/src/features/admin/pages/ManageGroupsPage.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { useEffect, useState } from 'react';
import { useState } from 'react';

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

import { PageHeader } from '@/components/PageHeader';
import { useSearch } from '@/hooks/useSearch';

import { useDeleteGroupMutation } from '../hooks/useDeleteGroupMutation';
import { useGroupsQuery } from '../hooks/useGroupsQuery';
Expand All @@ -15,12 +16,7 @@ export const ManageGroupsPage = () => {
const groupsQuery = useGroupsQuery();
const deleteGroupMutation = useDeleteGroupMutation();
const [selectedGroup, setSelectedGroup] = useState<Group | null>(null);
const [groups, setGroups] = useState<Group[]>(groupsQuery.data ?? []);
const [searchTerm, setSearchTerm] = useState('');

useEffect(() => {
setGroups((groupsQuery.data ?? []).filter((group) => group.name.toLowerCase().includes(searchTerm.toLowerCase())));
}, [groupsQuery.data, searchTerm]);
const { filteredData, searchTerm, setSearchTerm } = useSearch(groupsQuery.data ?? [], 'name');

return (
<Sheet open={Boolean(selectedGroup)} onOpenChange={() => setSelectedGroup(null)}>
Expand Down Expand Up @@ -69,7 +65,7 @@ export const ManageGroupsPage = () => {
label: t('common.groupType')
}
]}
data={groups}
data={filteredData}
entriesPerPage={15}
minRows={15}
onEntryClick={setSelectedGroup}
Expand Down
13 changes: 4 additions & 9 deletions apps/web/src/features/admin/pages/ManageUsersPage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useState } from 'react';

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

import { PageHeader } from '@/components/PageHeader';
import { useSearch } from '@/hooks/useSearch';
import { useAppStore } from '@/store';

import { useDeleteUserMutation } from '../hooks/useDeleteUserMutation';
import { useUsersQuery } from '../hooks/useUsersQuery';

// eslint-disable-next-line max-lines-per-function
export const ManageUsersPage = () => {
const currentUser = useAppStore((store) => store.currentUser);
const { t } = useTranslation();
const usersQuery = useUsersQuery();
const deleteUserMutation = useDeleteUserMutation();
const [users, setUsers] = useState<User[]>(usersQuery.data ?? []);
const [selectedUser, setSelectedUser] = useState<null | User>(null);
const [searchTerm, setSearchTerm] = useState('');

useEffect(() => {
setUsers((usersQuery.data ?? []).filter((user) => user.username.toLowerCase().includes(searchTerm.toLowerCase())));
}, [usersQuery.data, searchTerm]);
const { filteredData, searchTerm, setSearchTerm } = useSearch(usersQuery.data ?? [], 'username');

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

Expand Down Expand Up @@ -76,7 +71,7 @@ export const ManageUsersPage = () => {
label: t('common.basePermissionLevel')
}
]}
data={users}
data={filteredData}
entriesPerPage={15}
minRows={15}
onEntryClick={setSelectedUser}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export const MasterDataTable = ({ data, onSelect }: MasterDataTableProps) => {
<ClientTable<Subject>
columns={[
{
field: (subject) => removeSubjectIdScope(subject.id).slice(0, 7),
field: (subject) => removeSubjectIdScope(subject.id).slice(0, 9),
label: t('datahub.index.table.subject')
},
{
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/features/datahub/components/SubjectLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export const SubjectLayout = () => {
en: 'Instrument Records for Subject {}',
fr: "Dossiers d'instruments pour le client {}"
},
removeSubjectIdScope(subjectId).slice(0, 7)
removeSubjectIdScope(subjectId).slice(0, 9)
)}
</Heading>
</PageHeader>
Expand Down
1 change: 0 additions & 1 deletion apps/web/src/features/datahub/pages/DataHubPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import { downloadExcel } from '@/utils/excel';
import { MasterDataTable } from '../components/MasterDataTable';
import { useSubjectsQuery } from '../hooks/useSubjectsQuery';

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

Expand Down
38 changes: 38 additions & 0 deletions apps/web/src/features/upload/components/UploadSelectTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { ClientTable } from '@douglasneuroinformatics/libui/components';
import { useTranslation } from '@douglasneuroinformatics/libui/hooks';
import type { UnilingualInstrumentInfo } from '@opendatacapture/schemas/instrument';

export type UploadSelectTableProps = {
data: UnilingualInstrumentInfo[];
onSelect: (instrument: UnilingualInstrumentInfo) => void;
};

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

// Renders a table for selecting an instrument to upload data for
return (
<ClientTable<UnilingualInstrumentInfo>
columns={[
{
field: (instrument) => instrument.details.title,
label: t({
en: 'Title',
fr: 'Titre'
})
},
{
field: (instrument) => instrument.kind,
label: t({
en: 'Kind',
fr: 'Genre'
})
}
]}
data={data}
entriesPerPage={15}
minRows={15}
onEntryClick={onSelect}
/>
);
};
Empty file.
Loading

0 comments on commit 2a24e43

Please sign in to comment.