Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
31760d3
feat: add username to uploadRecord schema
david-roper Apr 28, 2026
27d989e
feat: pass current users username to reformatUpload data method
david-roper Apr 28, 2026
b8c4597
feat: add username to reformatInstrumentData method
david-roper Apr 28, 2026
cfc0651
feat: passing username into reformatInsturmentData in upload utils
david-roper Apr 28, 2026
0cbe556
feat: add checkbox to make upload pass username or not
david-roper Apr 28, 2026
e0e3750
feat: pass state into checked method so checkbox remains consistent, …
david-roper Apr 28, 2026
b7ce6ae
Merge branch 'DouglasNeuroInformatics:main' into username-to-export
david-roper Apr 28, 2026
90033fa
Merge branch 'main' into username-to-export
david-roper May 5, 2026
54bca0e
feat: remove checkbox that makes adding username optional
david-roper May 5, 2026
5b02fea
feat: add groupid as a possible param to pass to usersQuery
david-roper May 5, 2026
dd8e93e
feat: add a select box allow for seperate usernames in group to be se…
david-roper May 5, 2026
db5017c
Merge branch 'DouglasNeuroInformatics:main' into username-upload-always
david-roper May 5, 2026
35b9183
Potential fix for pull request finding
david-roper May 5, 2026
e011af8
Merge branch 'main' into username-upload-always
david-roper May 5, 2026
d487eec
feat: make username stay undefined instead of N/A
david-roper May 6, 2026
842af9e
feat: add a check if username exists on backend
david-roper May 6, 2026
465a738
feat: only attach undefined user if N/A is selected, default value as…
david-roper May 6, 2026
ef9a7b1
test: add users service to records service test
david-roper May 6, 2026
943b03e
Merge branch 'DouglasNeuroInformatics:main' into username-upload-always
david-roper May 6, 2026
ad00c8e
test: add tests for the upload functionality
david-roper May 6, 2026
8aaa40f
Update apps/api/src/instrument-records/instrument-records.service.ts
david-roper May 6, 2026
cd482a3
feat: add correct import for forbidden exception
david-roper May 6, 2026
31450ac
feat: make it so that groupIDs are change from default via manage use…
david-roper May 7, 2026
6958b82
feat: pass current group to usersQuery in dashboard, card will now on…
david-roper May 7, 2026
f55fb94
feat: add a dropdown to select the group if the user is in multiple g…
david-roper May 7, 2026
b078181
feat: make the dropdowns compatible with mobile
david-roper May 7, 2026
1136c70
feat: make it so that non admin users must be part of a group when up…
david-roper May 8, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { NotFoundException } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { beforeEach, describe, expect, it } from 'vitest';

import { UsersService } from '@/users/users.service';

import { GroupsService } from '../../groups/groups.service';
import { InstrumentsService } from '../../instruments/instruments.service';
import { SessionsService } from '../../sessions/sessions.service';
Expand All @@ -19,13 +21,17 @@ describe('InstrumentRecordsService', () => {
let instrumentRecordsService: InstrumentRecordsService;
let instrumentRecordModel: MockedInstance<Model<'InstrumentRecord'>>;
let instrumentsService: MockedInstance<InstrumentsService>;
let sessionsService: MockedInstance<SessionsService>;
let subjectsService: MockedInstance<SubjectsService>;
let usersService: MockedInstance<UsersService>;

beforeEach(async () => {
const moduleRef = await Test.createTestingModule({
providers: [
InstrumentRecordsService,
MockFactory.createForModelToken(getModelToken('InstrumentRecord')),
MockFactory.createForService(GroupsService),
MockFactory.createForService(UsersService),
MockFactory.createForService(InstrumentMeasuresService),
MockFactory.createForService(InstrumentsService),
MockFactory.createForService(SessionsService),
Expand All @@ -36,6 +42,9 @@ describe('InstrumentRecordsService', () => {
instrumentRecordModel = moduleRef.get(getModelToken('InstrumentRecord'));
instrumentRecordsService = moduleRef.get(InstrumentRecordsService);
instrumentsService = moduleRef.get(InstrumentsService);
sessionsService = moduleRef.get(SessionsService);
subjectsService = moduleRef.get(SubjectsService);
usersService = moduleRef.get(UsersService);
});

describe('findById', () => {
Expand Down Expand Up @@ -69,6 +78,67 @@ describe('InstrumentRecordsService', () => {
});
});

describe('upload', () => {
const mockInstrument = {
id: 'instrument-1',
kind: 'FORM',
measures: null,
validationSchema: {
safeParse: (data: unknown) => ({ data, success: true })
}
};

const mockSession = {
date: new Date(),
groupId: null,
id: 'session-1',
type: 'RETROSPECTIVE',
userId: null
};

const baseUploadData = {
instrumentId: 'instrument-1',
records: [{ data: { answer: 1 }, date: new Date(), subjectId: 'subject-1' }]
};

beforeEach(() => {
instrumentsService.findById.mockResolvedValue(mockInstrument as any);
subjectsService.createMany.mockResolvedValue([] as any);
sessionsService.create.mockResolvedValue(mockSession as any);
sessionsService.deleteByIds.mockResolvedValue(undefined as any);
instrumentRecordModel.createMany.mockResolvedValue([] as any);
instrumentRecordModel.findMany.mockResolvedValue([] as any);
});

it('should call sessionsService.create with the provided username', async () => {
usersService.findByUsername.mockResolvedValueOnce({ username: 'validuser' } as any);

await instrumentRecordsService.upload({ ...baseUploadData, username: 'validuser' });

expect(usersService.findByUsername).toHaveBeenCalledWith('validuser', undefined);
expect(sessionsService.create).toHaveBeenCalledWith(expect.objectContaining({ username: 'validuser' }));
});

it('should reject and not create any sessions when an unknown username is provided', async () => {
usersService.findByUsername.mockRejectedValueOnce(
new NotFoundException('Failed to find user with username: spoofed')
);

await expect(instrumentRecordsService.upload({ ...baseUploadData, username: 'spoofed' })).rejects.toBeInstanceOf(
NotFoundException
);

expect(sessionsService.create).not.toHaveBeenCalled();
});

it('should call sessionsService.create with username undefined when no username is provided', async () => {
await instrumentRecordsService.upload({ ...baseUploadData });

expect(usersService.findByUsername).not.toHaveBeenCalled();
expect(sessionsService.create).toHaveBeenCalledWith(expect.objectContaining({ username: undefined }));
});
});

describe('exportRecords', () => {
it('should return an array of export records with correct shape', async () => {
const mockRecords = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { GroupsModule } from '@/groups/groups.module';
import { InstrumentsModule } from '@/instruments/instruments.module';
import { SessionsModule } from '@/sessions/sessions.module';
import { SubjectsModule } from '@/subjects/subjects.module';
import { UsersModule } from '@/users/users.module';

import { InstrumentMeasuresService } from './instrument-measures.service';
import { InstrumentRecordsController } from './instrument-records.controller';
Expand All @@ -12,7 +13,7 @@ import { InstrumentRecordsService } from './instrument-records.service';
@Module({
controllers: [InstrumentRecordsController],
exports: [InstrumentRecordsService],
imports: [GroupsModule, InstrumentsModule, SessionsModule, SubjectsModule],
imports: [GroupsModule, InstrumentsModule, SessionsModule, SubjectsModule, UsersModule],
providers: [InstrumentMeasuresService, InstrumentRecordsService]
})
export class InstrumentRecordsModule {}
22 changes: 19 additions & 3 deletions apps/api/src/instrument-records/instrument-records.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@ import { replacer, reviver } from '@douglasneuroinformatics/libjs';
import { InjectModel } from '@douglasneuroinformatics/libnest';
import type { Model } from '@douglasneuroinformatics/libnest';
import { linearRegression } from '@douglasneuroinformatics/libstats';
import { BadRequestException, Injectable, NotFoundException, UnprocessableEntityException } from '@nestjs/common';
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
UnprocessableEntityException
} from '@nestjs/common';
import type { Json, ScalarInstrument } from '@opendatacapture/runtime-core';
import type {
CreateInstrumentRecordData,
Expand All @@ -28,6 +34,7 @@ import { InstrumentsService } from '@/instruments/instruments.service';
import { SessionsService } from '@/sessions/sessions.service';
import { CreateSubjectDto } from '@/subjects/dto/create-subject.dto';
import { SubjectsService } from '@/subjects/subjects.service';
import { UsersService } from '@/users/users.service';

import { InstrumentMeasuresService } from './instrument-measures.service';

Expand All @@ -45,6 +52,7 @@ export class InstrumentRecordsService {
constructor(
@InjectModel('InstrumentRecord') private readonly instrumentRecordModel: Model<'InstrumentRecord'>,
private readonly groupsService: GroupsService,
private readonly usersService: UsersService,
private readonly instrumentMeasuresService: InstrumentMeasuresService,
private readonly instrumentsService: InstrumentsService,
private readonly sessionsService: SessionsService,
Expand Down Expand Up @@ -330,7 +338,7 @@ export class InstrumentRecordsService {
}

async upload(
{ groupId, instrumentId, records }: UploadInstrumentRecordsData,
{ groupId, instrumentId, records, username }: UploadInstrumentRecordsData,
options?: EntityOperationOptions
): Promise<InstrumentRecord[]> {
if (groupId) {
Expand All @@ -344,6 +352,13 @@ export class InstrumentRecordsService {
);
}

if (username) {
const user = await this.usersService.findByUsername(username, options);
if (groupId && !user.groups.some((g) => g.id === groupId)) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if they are not admin, they should not be able to upload with no group id either

throw new ForbiddenException(`User '${username}' is not a member of group '${groupId}'`);
}
}
Comment thread
david-roper marked this conversation as resolved.

const createdSessionsArray: Session[] = [];

try {
Expand Down Expand Up @@ -372,7 +387,8 @@ export class InstrumentRecordsService {
date: date,
groupId: groupId ?? null,
subjectData: { id: subjectId },
type: 'RETROSPECTIVE'
type: 'RETROSPECTIVE',
username: username ?? undefined
Comment thread
joshunrau marked this conversation as resolved.
});
Comment thread
david-roper marked this conversation as resolved.

createdSessionsArray.push(session);
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/users/users.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ export class UsersService {
data: {
...data,
groups: {
connect: groupIds?.map((id) => ({ id }))
set: groupIds?.map((id) => ({ id }))
},
hashedPassword
},
Expand Down
14 changes: 9 additions & 5 deletions apps/web/src/hooks/useUsersQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,22 @@ import { $User } from '@opendatacapture/schemas/user';
import { queryOptions, useSuspenseQuery } from '@tanstack/react-query';
import axios from 'axios';

type UsersQueryParams = {
groupId?: string;
};

export const USERS_QUERY_KEY = 'users';

export const usersQueryOptions = () => {
export const usersQueryOptions = ({ params }: { params?: UsersQueryParams } = {}) => {
return queryOptions({
queryFn: async () => {
const response = await axios.get('/v1/users');
const response = await axios.get('/v1/users', { params });
return $User.array().parse(response.data);
},
queryKey: [USERS_QUERY_KEY]
queryKey: [USERS_QUERY_KEY, params?.groupId]
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};

export function useUsersQuery() {
return useSuspenseQuery(usersQueryOptions());
export function useUsersQuery({ params }: { params?: UsersQueryParams } = {}) {
return useSuspenseQuery(usersQueryOptions({ params }));
}
15 changes: 15 additions & 0 deletions apps/web/src/routes/_app/admin/users/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ type UpdateUserFormInputData = {
[id: string]: string;
};
initialValues?: FormTypes.PartialNullableData<UpdateUserFormData>;
selectedUserBasePermission?: User['basePermissionLevel'];
};

const UpdateUserForm: React.FC<{
Expand Down Expand Up @@ -91,6 +92,19 @@ const UpdateUserForm: React.FC<{
});
});
})
.check((ctx) => {
if (ctx.value.groupIds.size <= 0 && data.selectedUserBasePermission !== 'ADMIN') {
ctx.issues.push({
code: 'custom',
input: ctx.value.confirmPassword,
message: t({
en: 'Standard user must be part of a group',
fr: "Un utilisateur standard doit faire partie d'un groupe"
}),
path: ['groupIds']
});
}
})
.check((ctx) => {
if (ctx.value.confirmPassword !== ctx.value.password) {
ctx.issues.push({
Expand Down Expand Up @@ -335,6 +349,7 @@ const RouteComponent = () => {
setData({
disableDelete: selectedUser?.username === currentUser?.username,
groupOptions: Object.fromEntries(groups.map((group) => [group.id, group.name])),
selectedUserBasePermission: selectedUser.basePermissionLevel,
initialValues: selectedUser?.additionalPermissions.length
? {
additionalPermissions: selectedUser.additionalPermissions,
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/routes/_app/dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@ const RouteComponent = () => {
const [isUserModalOpen, setIsUserModalOpen] = useState(false);
const [isRecordModalOpen, setIsRecordModalOpen] = useState(false);
const instrumentInfoQuery = useInstrumentInfoQuery();
const userInfoQuery = useUsersQuery();
const userInfoQuery = useUsersQuery({
params: {
groupId: currentGroup?.id
}
});

const recordsQuery = useInstrumentRecords({
enabled: true,
Expand Down
Loading
Loading