diff --git a/apps/api/src/instrument-records/instrument-records.service.ts b/apps/api/src/instrument-records/instrument-records.service.ts index 0830cd7e3..4d0dcbc35 100644 --- a/apps/api/src/instrument-records/instrument-records.service.ts +++ b/apps/api/src/instrument-records/instrument-records.service.ts @@ -186,7 +186,7 @@ export class InstrumentRecordsService { subjectId: removeSubjectIdScope(record.subject.id), subjectSex: record.subject.sex, timestamp: record.date.toISOString(), - userId: record.session.user?.username ?? 'N/A', + username: record.session.user?.username ?? 'N/A', value: measureValue }); } @@ -210,7 +210,7 @@ export class InstrumentRecordsService { subjectId: removeSubjectIdScope(record.subject.id), subjectSex: record.subject.sex, timestamp: record.date.toISOString(), - userId: record.session.user?.username ?? 'N/A', + username: record.session.user?.username ?? 'N/A', value: arrayEntry.measureValue }); }); diff --git a/apps/api/src/sessions/sessions.controller.ts b/apps/api/src/sessions/sessions.controller.ts index 47791d576..1ff3e2806 100644 --- a/apps/api/src/sessions/sessions.controller.ts +++ b/apps/api/src/sessions/sessions.controller.ts @@ -1,6 +1,7 @@ import { CurrentUser } from '@douglasneuroinformatics/libnest'; -import { Body, Controller, Get, Param, Post } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common'; import { ApiOperation } from '@nestjs/swagger'; +import type { SessionWithUser } from '@opendatacapture/schemas/session'; import type { Session } from '@prisma/client'; import type { AppAbility } from '@/auth/auth.types'; @@ -20,6 +21,16 @@ export class SessionsController { return this.sessionsService.create(data); } + @ApiOperation({ description: 'Find all sessions and usernames attached to them' }) + @Get() + @RouteAccess({ action: 'read', subject: 'Session' }) + findAllIncludeUsernames( + @CurrentUser('ability') ability: AppAbility, + @Query('groupId') groupId?: string + ): Promise { + return this.sessionsService.findAllIncludeUsernames(groupId, { ability }); + } + @ApiOperation({ description: 'Find Session by ID' }) @Get(':id') @RouteAccess({ action: 'read', subject: 'Session' }) diff --git a/apps/api/src/sessions/sessions.service.ts b/apps/api/src/sessions/sessions.service.ts index 3d01b0fe1..2b0889424 100644 --- a/apps/api/src/sessions/sessions.service.ts +++ b/apps/api/src/sessions/sessions.service.ts @@ -46,7 +46,9 @@ export class SessionsService { let group: Group | null = null; if (groupId && !subject.groupIds.includes(groupId)) { group = await this.groupsService.findById(groupId); - await this.subjectsService.addGroupForSubject(subject.id, group.id); + if (group) { + await this.subjectsService.addGroupForSubject(subject.id, group.id); + } } const { id } = await this.sessionModel.create({ @@ -94,6 +96,26 @@ export class SessionsService { }); } + async findAllIncludeUsernames(groupId?: string, { ability }: EntityOperationOptions = {}) { + const sessionsWithUsers = await this.sessionModel.findMany({ + include: { + subject: true, + user: { + select: { + username: true + } + } + }, + where: { + AND: [accessibleQuery(ability, 'read', 'Session'), { groupId }] + } + }); + if (sessionsWithUsers.length < 1) { + throw new NotFoundException(`Failed to find users`); + } + return sessionsWithUsers; + } + async findById(id: string, { ability }: EntityOperationOptions = {}) { const session = await this.sessionModel.findFirst({ where: { AND: [accessibleQuery(ability, 'read', 'Session')], id } diff --git a/apps/web/src/components/Sidebar/Sidebar.tsx b/apps/web/src/components/Sidebar/Sidebar.tsx index 1bfd11457..5799a5fc0 100644 --- a/apps/web/src/components/Sidebar/Sidebar.tsx +++ b/apps/web/src/components/Sidebar/Sidebar.tsx @@ -88,7 +88,7 @@ export const Sidebar = () => { >
{t('common.sessionInProgress')}

- {isSubjectWithPersonalInfo(currentSession.subject) ? ( + {isSubjectWithPersonalInfo(currentSession.subject!) ? (

{`${t('core.fullName')}: ${currentSession.subject.firstName} ${currentSession.subject.lastName}`}

@@ -100,7 +100,7 @@ export const Sidebar = () => {

) : (
-

ID: {removeSubjectIdScope(currentSession.subject.id)}

+

ID: {removeSubjectIdScope(currentSession.subject!.id)}

)} diff --git a/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts b/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts index bfa1e7478..697ccfe7c 100644 --- a/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts +++ b/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts @@ -1,5 +1,5 @@ import { toBasicISOString } from '@douglasneuroinformatics/libjs'; -import { act, renderHook } from '@testing-library/react'; +import { act, renderHook, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { useInstrumentVisualization } from '../useInstrumentVisualization'; @@ -32,7 +32,19 @@ const mockInstrumentRecords = { { computedMeasures: {}, data: { someValue: 'abc' }, - date: FIXED_TEST_DATE + date: FIXED_TEST_DATE, + sessionId: '123' + } + ] +}; + +const mockSessionWithUsername = { + data: [ + { + id: '123', + user: { + username: 'testusername' + } } ] }; @@ -63,15 +75,22 @@ vi.mock('@/hooks/useInstrumentRecords', () => ({ useInstrumentRecords: () => mockInstrumentRecords })); +vi.mock('@/hooks/useFindSessionQuery', () => ({ + useFindSessionQuery: () => mockSessionWithUsername +})); + describe('useInstrumentVisualization', () => { beforeEach(() => { vi.clearAllMocks(); }); describe('CSV', () => { - it('Should download', () => { + it('Should download', async () => { const { result } = renderHook(() => useInstrumentVisualization({ params: { subjectId: 'testId' } })); const { records } = result.current; + await waitFor(() => { + expect(result.current.records.length).toBeGreaterThan(0); + }); act(() => result.current.dl('CSV')); expect(records).toBeDefined(); expect(mockDownloadFn).toHaveBeenCalledTimes(1); @@ -79,30 +98,36 @@ describe('useInstrumentVisualization', () => { expect(filename).toContain('.csv'); const csvContents = getContentFn(); expect(csvContents).toMatch( - `GroupID,subjectId,Date,someValue\r\ntestGroupId,testId,${toBasicISOString(FIXED_TEST_DATE)},abc` + `GroupID,subjectId,Date,Username,someValue\r\ntestGroupId,testId,${toBasicISOString(FIXED_TEST_DATE)},testusername,abc` ); }); }); describe('TSV', () => { - it('Should download', () => { + it('Should download', async () => { const { result } = renderHook(() => useInstrumentVisualization({ params: { subjectId: 'testId' } })); - const { dl, records } = result.current; - act(() => dl('TSV')); + const { records } = result.current; + await waitFor(() => { + expect(result.current.records.length).toBeGreaterThan(0); + }); + act(() => result.current.dl('TSV')); expect(records).toBeDefined(); expect(mockDownloadFn).toHaveBeenCalledTimes(1); const [filename, getContentFn] = mockDownloadFn.mock.calls[0] ?? []; expect(filename).toContain('.tsv'); const tsvContents = getContentFn(); expect(tsvContents).toMatch( - `GroupID\tsubjectId\tDate\tsomeValue\r\ntestGroupId\ttestId\t${toBasicISOString(FIXED_TEST_DATE)}\tabc` + `GroupID\tsubjectId\tDate\tUsername\tsomeValue\r\ntestGroupId\ttestId\t${toBasicISOString(FIXED_TEST_DATE)}\ttestusername\tabc` ); }); }); describe('CSV Long', () => { - it('Should download', () => { + it('Should download', async () => { const { result } = renderHook(() => useInstrumentVisualization({ params: { subjectId: 'testId' } })); - const { dl, records } = result.current; - act(() => dl('CSV Long')); + const { records } = result.current; + await waitFor(() => { + expect(result.current.records.length).toBeGreaterThan(0); + }); + act(() => result.current.dl('CSV Long')); expect(records).toBeDefined(); expect(mockDownloadFn).toHaveBeenCalledTimes(1); @@ -110,15 +135,18 @@ describe('useInstrumentVisualization', () => { expect(filename).toContain('.csv'); const csvLongContents = getContentFn(); expect(csvLongContents).toMatch( - `GroupID,Date,SubjectID,Value,Variable\r\ntestGroupId,${toBasicISOString(FIXED_TEST_DATE)},testId,abc,someValue` + `GroupID,Date,SubjectID,Username,Value,Variable\r\ntestGroupId,${toBasicISOString(FIXED_TEST_DATE)},testId,testusername,abc,someValue` ); }); }); describe('TSV Long', () => { - it('Should download', () => { + it('Should download', async () => { const { result } = renderHook(() => useInstrumentVisualization({ params: { subjectId: 'testId' } })); - const { dl, records } = result.current; - act(() => dl('TSV Long')); + const { records } = result.current; + await waitFor(() => { + expect(result.current.records.length).toBeGreaterThan(0); + }); + act(() => result.current.dl('TSV Long')); expect(records).toBeDefined(); expect(mockDownloadFn).toHaveBeenCalledTimes(1); @@ -126,15 +154,18 @@ describe('useInstrumentVisualization', () => { expect(filename).toMatch('.tsv'); const tsvLongContents = getContentFn(); expect(tsvLongContents).toMatch( - `GroupID\tDate\tSubjectID\tValue\tVariable\r\ntestGroupId\t${toBasicISOString(FIXED_TEST_DATE)}\ttestId\tabc\tsomeValue` + `GroupID\tDate\tSubjectID\tUsername\tValue\tVariable\r\ntestGroupId\t${toBasicISOString(FIXED_TEST_DATE)}\ttestId\ttestusername\tabc\tsomeValue` ); }); }); describe('Excel', () => { - it('Should download', () => { + it('Should download', async () => { const { result } = renderHook(() => useInstrumentVisualization({ params: { subjectId: 'testId' } })); - const { dl, records } = result.current; - act(() => dl('Excel')); + const { records } = result.current; + await waitFor(() => { + expect(result.current.records.length).toBeGreaterThan(0); + }); + act(() => result.current.dl('Excel')); expect(records).toBeDefined(); expect(mockExcelDownloadFn).toHaveBeenCalledTimes(1); const [filename, getContentFn] = mockExcelDownloadFn.mock.calls[0] ?? []; @@ -143,20 +174,24 @@ describe('useInstrumentVisualization', () => { expect(excelContents).toEqual([ { + Date: '2025-04-30', GroupID: 'testGroupId', subjectId: 'testId', // eslint-disable-next-line perfectionist/sort-objects - Date: '2025-04-30', - someValue: 'abc' + someValue: 'abc', + Username: 'testusername' } ]); }); }); describe('Excel Long', () => { - it('Should download', () => { + it('Should download', async () => { const { result } = renderHook(() => useInstrumentVisualization({ params: { subjectId: 'testId' } })); - const { dl, records } = result.current; - act(() => dl('Excel Long')); + const { records } = result.current; + await waitFor(() => { + expect(result.current.records.length).toBeGreaterThan(0); + }); + act(() => result.current.dl('Excel Long')); expect(records).toBeDefined(); expect(mockExcelDownloadFn).toHaveBeenCalledTimes(1); @@ -169,6 +204,7 @@ describe('useInstrumentVisualization', () => { Date: '2025-04-30', GroupID: 'testGroupId', SubjectID: 'testId', + Username: 'testusername', Value: 'abc', Variable: 'someValue' } @@ -178,8 +214,11 @@ describe('useInstrumentVisualization', () => { describe('JSON', () => { it('Should download', async () => { const { result } = renderHook(() => useInstrumentVisualization({ params: { subjectId: 'testId' } })); - const { dl, records } = result.current; - act(() => dl('JSON')); + const { records } = result.current; + await waitFor(() => { + expect(result.current.records.length).toBeGreaterThan(0); + }); + act(() => result.current.dl('JSON')); expect(records).toBeDefined(); expect(mockDownloadFn).toHaveBeenCalledTimes(1); diff --git a/apps/web/src/hooks/useFindSessionQuery.ts b/apps/web/src/hooks/useFindSessionQuery.ts new file mode 100644 index 000000000..a2ca85872 --- /dev/null +++ b/apps/web/src/hooks/useFindSessionQuery.ts @@ -0,0 +1,27 @@ +import { $SessionWithUser } from '@opendatacapture/schemas/session'; +import type { SessionWithUserQueryParams } from '@opendatacapture/schemas/session'; +import { useQuery } from '@tanstack/react-query'; +import axios from 'axios'; + +type UseSessionOptions = { + enabled?: boolean; + params: SessionWithUserQueryParams; +}; + +export const useFindSessionQuery = ( + { enabled, params }: UseSessionOptions = { + enabled: true, + params: {} + } +) => { + return useQuery({ + enabled, + queryFn: async () => { + const response = await axios.get('/v1/sessions', { + params + }); + return $SessionWithUser.array().parseAsync(response.data); + }, + queryKey: ['sessions', ...Object.values(params)] + }); +}; diff --git a/apps/web/src/hooks/useInstrumentVisualization.ts b/apps/web/src/hooks/useInstrumentVisualization.ts index 8246a7475..8da1fe142 100644 --- a/apps/web/src/hooks/useInstrumentVisualization.ts +++ b/apps/web/src/hooks/useInstrumentVisualization.ts @@ -13,6 +13,8 @@ import { useInstrumentRecords } from '@/hooks/useInstrumentRecords'; import { useAppStore } from '@/store'; import { downloadSubjectTableExcel } from '@/utils/excel'; +import { useFindSessionQuery } from './useFindSessionQuery'; + type InstrumentVisualizationRecord = { [key: string]: unknown; __date__: Date; @@ -54,6 +56,13 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio } }); + const sessionsUsernameQuery = useFindSessionQuery({ + enabled: instrumentId !== null, + params: { + groupId: currentGroup?.id + } + }); + const dl = (option: 'CSV' | 'CSV Long' | 'Excel' | 'Excel Long' | 'JSON' | 'TSV' | 'TSV Long') => { if (!instrument) { notifications.addNotification({ message: t('errors.noInstrumentSelected'), type: 'error' }); @@ -82,6 +91,10 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio obj.Date = toBasicISOString(val as Date); continue; } + if (key === 'username') { + obj.Username = val; + continue; + } obj[key] = typeof val === 'object' ? JSON.stringify(val) : val; } return obj; @@ -93,12 +106,17 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio exportRecords.forEach((item) => { let date: Date; + let username = 'N/A'; Object.entries(item).forEach(([objKey, objVal]) => { if (objKey === '__date__') { date = objVal as Date; return; } + if (objKey === 'username') { + username = objVal as string; + return; + } if (Array.isArray(objVal)) { objVal.forEach((arrayItem) => { @@ -108,6 +126,7 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio // eslint-disable-next-line perfectionist/sort-objects Date: toBasicISOString(date), SubjectID: removeSubjectIdScope(params.subjectId), + Username: username, Variable: `${objKey}-${arrKey}`, // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, perfectionist/sort-objects Value: arrItem @@ -120,6 +139,7 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio // eslint-disable-next-line perfectionist/sort-objects Date: toBasicISOString(date), SubjectID: removeSubjectIdScope(params.subjectId), + Username: username, Value: objVal, Variable: objKey }); @@ -195,18 +215,38 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio }; useEffect(() => { - if (recordsQuery.data) { - const records: InstrumentVisualizationRecord[] = []; - for (const record of recordsQuery.data) { - const props = record.data && typeof record.data === 'object' ? record.data : {}; - records.push({ - __date__: record.date, - __time__: record.date.getTime(), - ...record.computedMeasures, - ...props + try { + const sessions = sessionsUsernameQuery.data; + if (recordsQuery.data && sessions) { + // Fetch all sessions in parallel + + // Build records with looked-up data + const records: InstrumentVisualizationRecord[] = recordsQuery.data.map((record) => { + const props = record.data && typeof record.data === 'object' ? record.data : {}; + const usersSession = sessions.find((s) => s.id === record.sessionId); + + const username = usersSession?.user?.username ?? 'N/A'; + + return { + __date__: record.date, + __time__: record.date.getTime(), + username: username, + ...record.computedMeasures, + ...props + }; }); + + setRecords(records); } - setRecords(records); + } catch (error) { + console.error(error); + notifications.addNotification({ + message: t({ + en: 'Error occurred finding records', + fr: "Une erreur s'est produite lors de la recherche des enregistrements." + }), + type: 'error' + }); } }, [recordsQuery.data]); diff --git a/apps/web/src/routes/_app/instruments/render/$id.tsx b/apps/web/src/routes/_app/instruments/render/$id.tsx index 3b548c19c..03e88c70e 100644 --- a/apps/web/src/routes/_app/instruments/render/$id.tsx +++ b/apps/web/src/routes/_app/instruments/render/$id.tsx @@ -42,7 +42,7 @@ const RouteComponent = () => { groupId: currentGroup?.id, instrumentId, sessionId: currentSession!.id, - subjectId: currentSession!.subject.id + subjectId: currentSession!.subject!.id } satisfies CreateInstrumentRecordData); notifications.addNotification({ type: 'success' }); }; @@ -61,7 +61,7 @@ const RouteComponent = () => {
diff --git a/apps/web/src/utils/excel.ts b/apps/web/src/utils/excel.ts index 74fa3a92f..82cec9d44 100644 --- a/apps/web/src/utils/excel.ts +++ b/apps/web/src/utils/excel.ts @@ -16,6 +16,5 @@ export function downloadSubjectTableExcel(filename: string, records: { [key: str .trim() || 'Subject'; // Fallback if empty const workbook = utils.book_new(); utils.book_append_sheet(workbook, utils.json_to_sheet(records), sanitizedName); - utils.book_append_sheet(workbook, utils.json_to_sheet(records), name); writeFileXLSX(workbook, filename); } diff --git a/packages/schemas/src/instrument-records/instrument-records.ts b/packages/schemas/src/instrument-records/instrument-records.ts index 019aaa466..dbeffb6bf 100644 --- a/packages/schemas/src/instrument-records/instrument-records.ts +++ b/packages/schemas/src/instrument-records/instrument-records.ts @@ -64,7 +64,7 @@ export type InstrumentRecordsExport = { subjectId: string; subjectSex: null | string; timestamp: string; - userId: string; + username: string; value: InstrumentMeasureValue; }[]; diff --git a/packages/schemas/src/session/session.ts b/packages/schemas/src/session/session.ts index 81d38bead..5088cd8b2 100644 --- a/packages/schemas/src/session/session.ts +++ b/packages/schemas/src/session/session.ts @@ -10,7 +10,7 @@ export type Session = z.infer; export const $Session = $BaseModel.extend({ date: z.coerce.date(), groupId: z.string().nullable(), - subject: $Subject, + subject: $Subject.nullable(), subjectId: z.string(), type: $SessionType, userId: z.string().nullish() @@ -24,3 +24,16 @@ export const $CreateSessionData = z.object({ type: $SessionType, username: z.string().nullish() }); + +export type SessionWithUser = z.infer; +export const $SessionWithUser = $Session.extend({ + user: z + .object({ + username: z.string().nullish() + }) + .nullable() +}); + +export type SessionWithUserQueryParams = { + groupId?: string; +};