Skip to content

Commit c60aa8b

Browse files
authored
Merge pull request #1244 from DouglasNeuroInformatics/add-username-to-export
Add username to export
2 parents e5915a4 + cea17b2 commit c60aa8b

File tree

11 files changed

+198
-47
lines changed

11 files changed

+198
-47
lines changed

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ export class InstrumentRecordsService {
186186
subjectId: removeSubjectIdScope(record.subject.id),
187187
subjectSex: record.subject.sex,
188188
timestamp: record.date.toISOString(),
189-
userId: record.session.user?.username ?? 'N/A',
189+
username: record.session.user?.username ?? 'N/A',
190190
value: measureValue
191191
});
192192
}
@@ -210,7 +210,7 @@ export class InstrumentRecordsService {
210210
subjectId: removeSubjectIdScope(record.subject.id),
211211
subjectSex: record.subject.sex,
212212
timestamp: record.date.toISOString(),
213-
userId: record.session.user?.username ?? 'N/A',
213+
username: record.session.user?.username ?? 'N/A',
214214
value: arrayEntry.measureValue
215215
});
216216
});

apps/api/src/sessions/sessions.controller.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { CurrentUser } from '@douglasneuroinformatics/libnest';
2-
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
2+
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
33
import { ApiOperation } from '@nestjs/swagger';
4+
import type { SessionWithUser } from '@opendatacapture/schemas/session';
45
import type { Session } from '@prisma/client';
56

67
import type { AppAbility } from '@/auth/auth.types';
@@ -20,6 +21,16 @@ export class SessionsController {
2021
return this.sessionsService.create(data);
2122
}
2223

24+
@ApiOperation({ description: 'Find all sessions and usernames attached to them' })
25+
@Get()
26+
@RouteAccess({ action: 'read', subject: 'Session' })
27+
findAllIncludeUsernames(
28+
@CurrentUser('ability') ability: AppAbility,
29+
@Query('groupId') groupId?: string
30+
): Promise<SessionWithUser[]> {
31+
return this.sessionsService.findAllIncludeUsernames(groupId, { ability });
32+
}
33+
2334
@ApiOperation({ description: 'Find Session by ID' })
2435
@Get(':id')
2536
@RouteAccess({ action: 'read', subject: 'Session' })

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

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,9 @@ export class SessionsService {
4646
let group: Group | null = null;
4747
if (groupId && !subject.groupIds.includes(groupId)) {
4848
group = await this.groupsService.findById(groupId);
49-
await this.subjectsService.addGroupForSubject(subject.id, group.id);
49+
if (group) {
50+
await this.subjectsService.addGroupForSubject(subject.id, group.id);
51+
}
5052
}
5153

5254
const { id } = await this.sessionModel.create({
@@ -94,6 +96,26 @@ export class SessionsService {
9496
});
9597
}
9698

99+
async findAllIncludeUsernames(groupId?: string, { ability }: EntityOperationOptions = {}) {
100+
const sessionsWithUsers = await this.sessionModel.findMany({
101+
include: {
102+
subject: true,
103+
user: {
104+
select: {
105+
username: true
106+
}
107+
}
108+
},
109+
where: {
110+
AND: [accessibleQuery(ability, 'read', 'Session'), { groupId }]
111+
}
112+
});
113+
if (sessionsWithUsers.length < 1) {
114+
throw new NotFoundException(`Failed to find users`);
115+
}
116+
return sessionsWithUsers;
117+
}
118+
97119
async findById(id: string, { ability }: EntityOperationOptions = {}) {
98120
const session = await this.sessionModel.findFirst({
99121
where: { AND: [accessibleQuery(ability, 'read', 'Session')], id }

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ export const Sidebar = () => {
8888
>
8989
<h5 className="text-sm font-medium">{t('common.sessionInProgress')}</h5>
9090
<hr className="my-1.5 h-[1px] border-none bg-slate-700" />
91-
{isSubjectWithPersonalInfo(currentSession.subject) ? (
91+
{isSubjectWithPersonalInfo(currentSession.subject!) ? (
9292
<div data-testid="current-session-info">
9393
<p>{`${t('core.fullName')}: ${currentSession.subject.firstName} ${currentSession.subject.lastName}`}</p>
9494
<p>
@@ -100,7 +100,7 @@ export const Sidebar = () => {
100100
</div>
101101
) : (
102102
<div data-testid="current-session-info">
103-
<p>ID: {removeSubjectIdScope(currentSession.subject.id)}</p>
103+
<p>ID: {removeSubjectIdScope(currentSession.subject!.id)}</p>
104104
</div>
105105
)}
106106
</motion.div>

apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts

Lines changed: 65 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { toBasicISOString } from '@douglasneuroinformatics/libjs';
2-
import { act, renderHook } from '@testing-library/react';
2+
import { act, renderHook, waitFor } from '@testing-library/react';
33
import { beforeEach, describe, expect, it, vi } from 'vitest';
44

55
import { useInstrumentVisualization } from '../useInstrumentVisualization';
@@ -32,7 +32,19 @@ const mockInstrumentRecords = {
3232
{
3333
computedMeasures: {},
3434
data: { someValue: 'abc' },
35-
date: FIXED_TEST_DATE
35+
date: FIXED_TEST_DATE,
36+
sessionId: '123'
37+
}
38+
]
39+
};
40+
41+
const mockSessionWithUsername = {
42+
data: [
43+
{
44+
id: '123',
45+
user: {
46+
username: 'testusername'
47+
}
3648
}
3749
]
3850
};
@@ -63,78 +75,97 @@ vi.mock('@/hooks/useInstrumentRecords', () => ({
6375
useInstrumentRecords: () => mockInstrumentRecords
6476
}));
6577

78+
vi.mock('@/hooks/useFindSessionQuery', () => ({
79+
useFindSessionQuery: () => mockSessionWithUsername
80+
}));
81+
6682
describe('useInstrumentVisualization', () => {
6783
beforeEach(() => {
6884
vi.clearAllMocks();
6985
});
7086

7187
describe('CSV', () => {
72-
it('Should download', () => {
88+
it('Should download', async () => {
7389
const { result } = renderHook(() => useInstrumentVisualization({ params: { subjectId: 'testId' } }));
7490
const { records } = result.current;
91+
await waitFor(() => {
92+
expect(result.current.records.length).toBeGreaterThan(0);
93+
});
7594
act(() => result.current.dl('CSV'));
7695
expect(records).toBeDefined();
7796
expect(mockDownloadFn).toHaveBeenCalledTimes(1);
7897
const [filename, getContentFn] = mockDownloadFn.mock.calls[0] ?? [];
7998
expect(filename).toContain('.csv');
8099
const csvContents = getContentFn();
81100
expect(csvContents).toMatch(
82-
`GroupID,subjectId,Date,someValue\r\ntestGroupId,testId,${toBasicISOString(FIXED_TEST_DATE)},abc`
101+
`GroupID,subjectId,Date,Username,someValue\r\ntestGroupId,testId,${toBasicISOString(FIXED_TEST_DATE)},testusername,abc`
83102
);
84103
});
85104
});
86105
describe('TSV', () => {
87-
it('Should download', () => {
106+
it('Should download', async () => {
88107
const { result } = renderHook(() => useInstrumentVisualization({ params: { subjectId: 'testId' } }));
89-
const { dl, records } = result.current;
90-
act(() => dl('TSV'));
108+
const { records } = result.current;
109+
await waitFor(() => {
110+
expect(result.current.records.length).toBeGreaterThan(0);
111+
});
112+
act(() => result.current.dl('TSV'));
91113
expect(records).toBeDefined();
92114
expect(mockDownloadFn).toHaveBeenCalledTimes(1);
93115
const [filename, getContentFn] = mockDownloadFn.mock.calls[0] ?? [];
94116
expect(filename).toContain('.tsv');
95117
const tsvContents = getContentFn();
96118
expect(tsvContents).toMatch(
97-
`GroupID\tsubjectId\tDate\tsomeValue\r\ntestGroupId\ttestId\t${toBasicISOString(FIXED_TEST_DATE)}\tabc`
119+
`GroupID\tsubjectId\tDate\tUsername\tsomeValue\r\ntestGroupId\ttestId\t${toBasicISOString(FIXED_TEST_DATE)}\ttestusername\tabc`
98120
);
99121
});
100122
});
101123
describe('CSV Long', () => {
102-
it('Should download', () => {
124+
it('Should download', async () => {
103125
const { result } = renderHook(() => useInstrumentVisualization({ params: { subjectId: 'testId' } }));
104-
const { dl, records } = result.current;
105-
act(() => dl('CSV Long'));
126+
const { records } = result.current;
127+
await waitFor(() => {
128+
expect(result.current.records.length).toBeGreaterThan(0);
129+
});
130+
act(() => result.current.dl('CSV Long'));
106131
expect(records).toBeDefined();
107132
expect(mockDownloadFn).toHaveBeenCalledTimes(1);
108133

109134
const [filename, getContentFn] = mockDownloadFn.mock.calls[0] ?? [];
110135
expect(filename).toContain('.csv');
111136
const csvLongContents = getContentFn();
112137
expect(csvLongContents).toMatch(
113-
`GroupID,Date,SubjectID,Value,Variable\r\ntestGroupId,${toBasicISOString(FIXED_TEST_DATE)},testId,abc,someValue`
138+
`GroupID,Date,SubjectID,Username,Value,Variable\r\ntestGroupId,${toBasicISOString(FIXED_TEST_DATE)},testId,testusername,abc,someValue`
114139
);
115140
});
116141
});
117142
describe('TSV Long', () => {
118-
it('Should download', () => {
143+
it('Should download', async () => {
119144
const { result } = renderHook(() => useInstrumentVisualization({ params: { subjectId: 'testId' } }));
120-
const { dl, records } = result.current;
121-
act(() => dl('TSV Long'));
145+
const { records } = result.current;
146+
await waitFor(() => {
147+
expect(result.current.records.length).toBeGreaterThan(0);
148+
});
149+
act(() => result.current.dl('TSV Long'));
122150
expect(records).toBeDefined();
123151
expect(mockDownloadFn).toHaveBeenCalledTimes(1);
124152

125153
const [filename, getContentFn] = mockDownloadFn.mock.calls[0] ?? [];
126154
expect(filename).toMatch('.tsv');
127155
const tsvLongContents = getContentFn();
128156
expect(tsvLongContents).toMatch(
129-
`GroupID\tDate\tSubjectID\tValue\tVariable\r\ntestGroupId\t${toBasicISOString(FIXED_TEST_DATE)}\ttestId\tabc\tsomeValue`
157+
`GroupID\tDate\tSubjectID\tUsername\tValue\tVariable\r\ntestGroupId\t${toBasicISOString(FIXED_TEST_DATE)}\ttestId\ttestusername\tabc\tsomeValue`
130158
);
131159
});
132160
});
133161
describe('Excel', () => {
134-
it('Should download', () => {
162+
it('Should download', async () => {
135163
const { result } = renderHook(() => useInstrumentVisualization({ params: { subjectId: 'testId' } }));
136-
const { dl, records } = result.current;
137-
act(() => dl('Excel'));
164+
const { records } = result.current;
165+
await waitFor(() => {
166+
expect(result.current.records.length).toBeGreaterThan(0);
167+
});
168+
act(() => result.current.dl('Excel'));
138169
expect(records).toBeDefined();
139170
expect(mockExcelDownloadFn).toHaveBeenCalledTimes(1);
140171
const [filename, getContentFn] = mockExcelDownloadFn.mock.calls[0] ?? [];
@@ -143,20 +174,24 @@ describe('useInstrumentVisualization', () => {
143174

144175
expect(excelContents).toEqual([
145176
{
177+
Date: '2025-04-30',
146178
GroupID: 'testGroupId',
147179
subjectId: 'testId',
148180
// eslint-disable-next-line perfectionist/sort-objects
149-
Date: '2025-04-30',
150-
someValue: 'abc'
181+
someValue: 'abc',
182+
Username: 'testusername'
151183
}
152184
]);
153185
});
154186
});
155187
describe('Excel Long', () => {
156-
it('Should download', () => {
188+
it('Should download', async () => {
157189
const { result } = renderHook(() => useInstrumentVisualization({ params: { subjectId: 'testId' } }));
158-
const { dl, records } = result.current;
159-
act(() => dl('Excel Long'));
190+
const { records } = result.current;
191+
await waitFor(() => {
192+
expect(result.current.records.length).toBeGreaterThan(0);
193+
});
194+
act(() => result.current.dl('Excel Long'));
160195
expect(records).toBeDefined();
161196
expect(mockExcelDownloadFn).toHaveBeenCalledTimes(1);
162197

@@ -169,6 +204,7 @@ describe('useInstrumentVisualization', () => {
169204
Date: '2025-04-30',
170205
GroupID: 'testGroupId',
171206
SubjectID: 'testId',
207+
Username: 'testusername',
172208
Value: 'abc',
173209
Variable: 'someValue'
174210
}
@@ -178,8 +214,11 @@ describe('useInstrumentVisualization', () => {
178214
describe('JSON', () => {
179215
it('Should download', async () => {
180216
const { result } = renderHook(() => useInstrumentVisualization({ params: { subjectId: 'testId' } }));
181-
const { dl, records } = result.current;
182-
act(() => dl('JSON'));
217+
const { records } = result.current;
218+
await waitFor(() => {
219+
expect(result.current.records.length).toBeGreaterThan(0);
220+
});
221+
act(() => result.current.dl('JSON'));
183222
expect(records).toBeDefined();
184223
expect(mockDownloadFn).toHaveBeenCalledTimes(1);
185224

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { $SessionWithUser } from '@opendatacapture/schemas/session';
2+
import type { SessionWithUserQueryParams } from '@opendatacapture/schemas/session';
3+
import { useQuery } from '@tanstack/react-query';
4+
import axios from 'axios';
5+
6+
type UseSessionOptions = {
7+
enabled?: boolean;
8+
params: SessionWithUserQueryParams;
9+
};
10+
11+
export const useFindSessionQuery = (
12+
{ enabled, params }: UseSessionOptions = {
13+
enabled: true,
14+
params: {}
15+
}
16+
) => {
17+
return useQuery({
18+
enabled,
19+
queryFn: async () => {
20+
const response = await axios.get('/v1/sessions', {
21+
params
22+
});
23+
return $SessionWithUser.array().parseAsync(response.data);
24+
},
25+
queryKey: ['sessions', ...Object.values(params)]
26+
});
27+
};

0 commit comments

Comments
 (0)