From f94f1aa2c6909751de0be93beee638e366f2e884 Mon Sep 17 00:00:00 2001 From: david-roper Date: Fri, 24 Oct 2025 16:57:26 -0400 Subject: [PATCH 01/92] feat: create useFindSession Hook --- apps/web/src/hooks/useFindSession.ts | 30 ++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 apps/web/src/hooks/useFindSession.ts diff --git a/apps/web/src/hooks/useFindSession.ts b/apps/web/src/hooks/useFindSession.ts new file mode 100644 index 000000000..b072e5cfb --- /dev/null +++ b/apps/web/src/hooks/useFindSession.ts @@ -0,0 +1,30 @@ +import { reviver } from '@douglasneuroinformatics/libjs'; +import { $Session } from '@opendatacapture/schemas/session'; +import { useQuery } from '@tanstack/react-query'; +import axios from 'axios'; + +type UseSessionOptions = { + enabled?: boolean; + params: { + id?: string; + }; +}; + +export const useFindSession = ( + { enabled, params }: UseSessionOptions = { + enabled: true, + params: {} + } +) => { + return useQuery({ + enabled, + queryFn: async () => { + const response = await axios.get('/v1/Sessions', { + params, + transformResponse: [(data: string) => JSON.parse(data, reviver) as unknown] + }); + return $Session.array().parseAsync(response.data); + }, + queryKey: ['sessions', ...Object.values(params)] + }); +}; From 4c2f8cec88332d84ffd32f1a2616b91830e86491 Mon Sep 17 00:00:00 2001 From: david-roper Date: Mon, 27 Oct 2025 13:08:49 -0400 Subject: [PATCH 02/92] feat: make useSession hook return one session instead of array --- apps/web/src/hooks/useFindSession.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/hooks/useFindSession.ts b/apps/web/src/hooks/useFindSession.ts index b072e5cfb..8b5e5f5b3 100644 --- a/apps/web/src/hooks/useFindSession.ts +++ b/apps/web/src/hooks/useFindSession.ts @@ -23,7 +23,7 @@ export const useFindSession = ( params, transformResponse: [(data: string) => JSON.parse(data, reviver) as unknown] }); - return $Session.array().parseAsync(response.data); + return $Session.parseAsync(response.data); }, queryKey: ['sessions', ...Object.values(params)] }); From afc75ecaf1aef9b38e9f56c803d2d0bd1eca8a9e Mon Sep 17 00:00:00 2001 From: David Roper Date: Mon, 27 Oct 2025 14:52:05 -0400 Subject: [PATCH 03/92] feat: add hook to return list of user ids --- apps/web/src/hooks/useInstrumentVisualization.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/apps/web/src/hooks/useInstrumentVisualization.ts b/apps/web/src/hooks/useInstrumentVisualization.ts index 8246a7475..8e6cde440 100644 --- a/apps/web/src/hooks/useInstrumentVisualization.ts +++ b/apps/web/src/hooks/useInstrumentVisualization.ts @@ -12,6 +12,7 @@ import { useInstrumentInfoQuery } from '@/hooks/useInstrumentInfoQuery'; import { useInstrumentRecords } from '@/hooks/useInstrumentRecords'; import { useAppStore } from '@/store'; import { downloadSubjectTableExcel } from '@/utils/excel'; +import { useFindSession } from './useFindSession'; type InstrumentVisualizationRecord = { [key: string]: unknown; @@ -54,6 +55,19 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio } }); + const usersQuery = recordsQuery.data?.map((item) => { + const sessionInfo = useFindSession({ + enabled: true, + params: { id: item.sessionId } + }); + if (sessionInfo.data) { + return sessionInfo.data.userId; + } + return 'N/A'; + }); + + console.log(usersQuery); + const dl = (option: 'CSV' | 'CSV Long' | 'Excel' | 'Excel Long' | 'JSON' | 'TSV' | 'TSV Long') => { if (!instrument) { notifications.addNotification({ message: t('errors.noInstrumentSelected'), type: 'error' }); From 67a2fec2f48a08ad2e8fbf94836afbedc896b937 Mon Sep 17 00:00:00 2001 From: david-roper Date: Mon, 27 Oct 2025 17:11:44 -0400 Subject: [PATCH 04/92] feat: new api reqs for finding sessions --- apps/api/src/sessions/sessions.controller.ts | 8 ++++++++ apps/api/src/sessions/sessions.service.ts | 15 +++++++++++++++ apps/web/src/hooks/useInstrumentVisualization.ts | 14 -------------- 3 files changed, 23 insertions(+), 14 deletions(-) diff --git a/apps/api/src/sessions/sessions.controller.ts b/apps/api/src/sessions/sessions.controller.ts index 47791d576..ef4fec5a5 100644 --- a/apps/api/src/sessions/sessions.controller.ts +++ b/apps/api/src/sessions/sessions.controller.ts @@ -26,4 +26,12 @@ export class SessionsController { findByID(@Param('id') id: string, @CurrentUser('ability') ability: AppAbility): Promise { return this.sessionsService.findById(id, { ability }); } + + @ApiOperation({ description: 'Find Session by ID' }) + @Post('list') + @RouteAccess({ action: 'read', subject: 'Session' }) + findSessionList(@Query('ids') ids: string[]): Promise { + const idArray = Array.isArray(ids) ? ids : (ids as string).split(','); + return this.sessionsService.findSessionList(idArray); + } } diff --git a/apps/api/src/sessions/sessions.service.ts b/apps/api/src/sessions/sessions.service.ts index 3d01b0fe1..ee84a55a7 100644 --- a/apps/api/src/sessions/sessions.service.ts +++ b/apps/api/src/sessions/sessions.service.ts @@ -104,6 +104,21 @@ export class SessionsService { return session; } + async findSessionList(ids: string[], { ability }: EntityOperationOptions = {}) { + const sessionsArray = await Promise.all( + ids.map(async (id) => { + const session = await this.sessionModel.findFirst({ + where: { AND: [accessibleQuery(ability, 'read', 'Session')], id } + }); + if (!session) { + throw new NotFoundException(`Failed to find session with ID: ${id}`); + } + return session; + }) + ); + return sessionsArray; + } + /** Get the subject if they exist, otherwise create them */ private async resolveSubject(subjectData: CreateSubjectData) { this.loggingService.debug({ message: 'Attempting to resolve subject', subjectData }); diff --git a/apps/web/src/hooks/useInstrumentVisualization.ts b/apps/web/src/hooks/useInstrumentVisualization.ts index 8e6cde440..8246a7475 100644 --- a/apps/web/src/hooks/useInstrumentVisualization.ts +++ b/apps/web/src/hooks/useInstrumentVisualization.ts @@ -12,7 +12,6 @@ import { useInstrumentInfoQuery } from '@/hooks/useInstrumentInfoQuery'; import { useInstrumentRecords } from '@/hooks/useInstrumentRecords'; import { useAppStore } from '@/store'; import { downloadSubjectTableExcel } from '@/utils/excel'; -import { useFindSession } from './useFindSession'; type InstrumentVisualizationRecord = { [key: string]: unknown; @@ -55,19 +54,6 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio } }); - const usersQuery = recordsQuery.data?.map((item) => { - const sessionInfo = useFindSession({ - enabled: true, - params: { id: item.sessionId } - }); - if (sessionInfo.data) { - return sessionInfo.data.userId; - } - return 'N/A'; - }); - - console.log(usersQuery); - const dl = (option: 'CSV' | 'CSV Long' | 'Excel' | 'Excel Long' | 'JSON' | 'TSV' | 'TSV Long') => { if (!instrument) { notifications.addNotification({ message: t('errors.noInstrumentSelected'), type: 'error' }); From 50b12da5fe7bda8880ab887022fcba21ebe68c58 Mon Sep 17 00:00:00 2001 From: David Roper Date: Thu, 6 Nov 2025 14:41:29 -0500 Subject: [PATCH 05/92] feat: add query from nest --- apps/api/src/sessions/sessions.controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/src/sessions/sessions.controller.ts b/apps/api/src/sessions/sessions.controller.ts index ef4fec5a5..34d9f40ed 100644 --- a/apps/api/src/sessions/sessions.controller.ts +++ b/apps/api/src/sessions/sessions.controller.ts @@ -1,5 +1,5 @@ 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 { Session } from '@prisma/client'; From 582d8ffd5379df4f0028ecbe572d79ab1dc4dc43 Mon Sep 17 00:00:00 2001 From: David Roper Date: Thu, 6 Nov 2025 16:41:43 -0500 Subject: [PATCH 06/92] feat: add userinfo method --- .../src/hooks/useInstrumentVisualization.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/apps/web/src/hooks/useInstrumentVisualization.ts b/apps/web/src/hooks/useInstrumentVisualization.ts index 8246a7475..ba1f88cce 100644 --- a/apps/web/src/hooks/useInstrumentVisualization.ts +++ b/apps/web/src/hooks/useInstrumentVisualization.ts @@ -4,6 +4,7 @@ import { toBasicISOString } from '@douglasneuroinformatics/libjs'; import { useDownload, useNotificationsStore, useTranslation } from '@douglasneuroinformatics/libui/hooks'; import type { AnyUnilingualScalarInstrument, InstrumentKind } from '@opendatacapture/runtime-core'; import { removeSubjectIdScope } from '@opendatacapture/subject-utils'; +import axios from 'axios'; import { omit } from 'lodash-es'; import { unparse } from 'papaparse'; @@ -12,6 +13,7 @@ import { useInstrumentInfoQuery } from '@/hooks/useInstrumentInfoQuery'; import { useInstrumentRecords } from '@/hooks/useInstrumentRecords'; import { useAppStore } from '@/store'; import { downloadSubjectTableExcel } from '@/utils/excel'; +import type { Session } from '@opendatacapture/schemas/session'; type InstrumentVisualizationRecord = { [key: string]: unknown; @@ -54,6 +56,21 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio } }); + const userInfo = async (sessionId: string) => { + const userData = await axios + .get(`/v1/sessions/${sessionId}`) + .then(function (response) { + if (response.data) { + return response.data as Session; + } + return null; + }) + .catch(function (error) { + console.error('Error fetching users:', error); + }); + return userData; + }; + const dl = (option: 'CSV' | 'CSV Long' | 'Excel' | 'Excel Long' | 'JSON' | 'TSV' | 'TSV Long') => { if (!instrument) { notifications.addNotification({ message: t('errors.noInstrumentSelected'), type: 'error' }); @@ -199,6 +216,7 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio const records: InstrumentVisualizationRecord[] = []; for (const record of recordsQuery.data) { const props = record.data && typeof record.data === 'object' ? record.data : {}; + const userData = userInfo(record.sessionId); records.push({ __date__: record.date, __time__: record.date.getTime(), From d9864a546c0cd54eae22a79af89672744c0e08d9 Mon Sep 17 00:00:00 2001 From: david-roper Date: Mon, 10 Nov 2025 14:13:38 -0500 Subject: [PATCH 07/92] refactor: remove unused useFindSession hook --- apps/web/src/hooks/useFindSession.ts | 30 ---------------------------- 1 file changed, 30 deletions(-) delete mode 100644 apps/web/src/hooks/useFindSession.ts diff --git a/apps/web/src/hooks/useFindSession.ts b/apps/web/src/hooks/useFindSession.ts deleted file mode 100644 index 8b5e5f5b3..000000000 --- a/apps/web/src/hooks/useFindSession.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { reviver } from '@douglasneuroinformatics/libjs'; -import { $Session } from '@opendatacapture/schemas/session'; -import { useQuery } from '@tanstack/react-query'; -import axios from 'axios'; - -type UseSessionOptions = { - enabled?: boolean; - params: { - id?: string; - }; -}; - -export const useFindSession = ( - { enabled, params }: UseSessionOptions = { - enabled: true, - params: {} - } -) => { - return useQuery({ - enabled, - queryFn: async () => { - const response = await axios.get('/v1/Sessions', { - params, - transformResponse: [(data: string) => JSON.parse(data, reviver) as unknown] - }); - return $Session.parseAsync(response.data); - }, - queryKey: ['sessions', ...Object.values(params)] - }); -}; From 72a3659160aa4c8901a0e1434439935b270633ec Mon Sep 17 00:00:00 2001 From: david-roper Date: Mon, 10 Nov 2025 14:41:12 -0500 Subject: [PATCH 08/92] feat: add userInfo call to useEffect to get userId from the session --- .../src/hooks/useInstrumentVisualization.ts | 67 +++++++++++-------- 1 file changed, 40 insertions(+), 27 deletions(-) diff --git a/apps/web/src/hooks/useInstrumentVisualization.ts b/apps/web/src/hooks/useInstrumentVisualization.ts index ba1f88cce..e99e3daee 100644 --- a/apps/web/src/hooks/useInstrumentVisualization.ts +++ b/apps/web/src/hooks/useInstrumentVisualization.ts @@ -3,6 +3,7 @@ import { useEffect, useMemo, useState } from 'react'; import { toBasicISOString } from '@douglasneuroinformatics/libjs'; import { useDownload, useNotificationsStore, useTranslation } from '@douglasneuroinformatics/libui/hooks'; import type { AnyUnilingualScalarInstrument, InstrumentKind } from '@opendatacapture/runtime-core'; +import type { Session } from '@opendatacapture/schemas/session'; import { removeSubjectIdScope } from '@opendatacapture/subject-utils'; import axios from 'axios'; import { omit } from 'lodash-es'; @@ -13,7 +14,6 @@ import { useInstrumentInfoQuery } from '@/hooks/useInstrumentInfoQuery'; import { useInstrumentRecords } from '@/hooks/useInstrumentRecords'; import { useAppStore } from '@/store'; import { downloadSubjectTableExcel } from '@/utils/excel'; -import type { Session } from '@opendatacapture/schemas/session'; type InstrumentVisualizationRecord = { [key: string]: unknown; @@ -56,19 +56,14 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio } }); - const userInfo = async (sessionId: string) => { - const userData = await axios - .get(`/v1/sessions/${sessionId}`) - .then(function (response) { - if (response.data) { - return response.data as Session; - } - return null; - }) - .catch(function (error) { - console.error('Error fetching users:', error); - }); - return userData; + const userInfo = async (sessionId: string): Promise => { + try { + const response = await axios.get(`/v1/sessions/${sessionId}`); + return response.data ? (response.data as Session) : null; + } catch (error) { + console.error('Error fetching user:', error); + return null; // ensures a resolved value instead of `void` + } }; const dl = (option: 'CSV' | 'CSV Long' | 'Excel' | 'Excel Long' | 'JSON' | 'TSV' | 'TSV Long') => { @@ -212,20 +207,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 : {}; - const userData = userInfo(record.sessionId); - records.push({ - __date__: record.date, - __time__: record.date.getTime(), - ...record.computedMeasures, - ...props - }); + const fetchRecords = async () => { + if (recordsQuery.data) { + const records: InstrumentVisualizationRecord[] = []; + + for (const record of recordsQuery.data) { + const props = record.data && typeof record.data === 'object' ? record.data : {}; + + const userData = await userInfo(record.sessionId); + if (userData?.userId) { + // safely check since userData can be null + records.push({ + __date__: record.date, + __time__: record.date.getTime(), + username: userData.userId, + ...record.computedMeasures, + ...props + }); + continue; + } + records.push({ + __date__: record.date, + __time__: record.date.getTime(), + username: 'N/A', + ...record.computedMeasures, + ...props + }); + } + + setRecords(records); } - setRecords(records); - } + }; + void fetchRecords(); }, [recordsQuery.data]); const instrumentOptions: { [key: string]: string } = useMemo(() => { From 57ed21fe751c14f679ae3bd85510374b18b47ca3 Mon Sep 17 00:00:00 2001 From: david-roper Date: Mon, 10 Nov 2025 15:24:22 -0500 Subject: [PATCH 09/92] chore: rename user id column --- apps/web/src/hooks/useInstrumentVisualization.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/hooks/useInstrumentVisualization.ts b/apps/web/src/hooks/useInstrumentVisualization.ts index e99e3daee..98d0aa114 100644 --- a/apps/web/src/hooks/useInstrumentVisualization.ts +++ b/apps/web/src/hooks/useInstrumentVisualization.ts @@ -220,7 +220,7 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio records.push({ __date__: record.date, __time__: record.date.getTime(), - username: userData.userId, + userId: userData.userId, ...record.computedMeasures, ...props }); @@ -229,7 +229,7 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio records.push({ __date__: record.date, __time__: record.date.getTime(), - username: 'N/A', + userId: 'N/A', ...record.computedMeasures, ...props }); From 3dc66b26e3f40da49b08d71645795b6ed19a498f Mon Sep 17 00:00:00 2001 From: David Roper Date: Wed, 12 Nov 2025 10:02:27 -0500 Subject: [PATCH 10/92] feat: collect username with user api call --- .../src/hooks/useInstrumentVisualization.ts | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/apps/web/src/hooks/useInstrumentVisualization.ts b/apps/web/src/hooks/useInstrumentVisualization.ts index 98d0aa114..b4a99bb60 100644 --- a/apps/web/src/hooks/useInstrumentVisualization.ts +++ b/apps/web/src/hooks/useInstrumentVisualization.ts @@ -4,6 +4,7 @@ import { toBasicISOString } from '@douglasneuroinformatics/libjs'; import { useDownload, useNotificationsStore, useTranslation } from '@douglasneuroinformatics/libui/hooks'; import type { AnyUnilingualScalarInstrument, InstrumentKind } from '@opendatacapture/runtime-core'; import type { Session } from '@opendatacapture/schemas/session'; +import type { User } from '@opendatacapture/schemas/user'; import { removeSubjectIdScope } from '@opendatacapture/subject-utils'; import axios from 'axios'; import { omit } from 'lodash-es'; @@ -56,7 +57,7 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio } }); - const userInfo = async (sessionId: string): Promise => { + const sessionInfo = async (sessionId: string): Promise => { try { const response = await axios.get(`/v1/sessions/${sessionId}`); return response.data ? (response.data as Session) : null; @@ -66,6 +67,16 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio } }; + const userInfo = async (userId: string): Promise => { + try { + const response = await axios.get(`/v1/users/${userId}`); + return response.data ? (response.data as User) : null; + } catch (error) { + console.error('Error fetching user:', error); + return null; // ensures a resolved value instead of `void` + } + }; + const dl = (option: 'CSV' | 'CSV Long' | 'Excel' | 'Excel Long' | 'JSON' | 'TSV' | 'TSV Long') => { if (!instrument) { notifications.addNotification({ message: t('errors.noInstrumentSelected'), type: 'error' }); @@ -214,22 +225,25 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio for (const record of recordsQuery.data) { const props = record.data && typeof record.data === 'object' ? record.data : {}; - const userData = await userInfo(record.sessionId); - if (userData?.userId) { - // safely check since userData can be null + const sessionData = await sessionInfo(record.sessionId); + + if (!sessionData?.userId) { records.push({ __date__: record.date, __time__: record.date.getTime(), - userId: userData.userId, + userId: 'N/A', ...record.computedMeasures, ...props }); continue; } + + const userData = await userInfo(sessionData.userId); + // safely check since userData can be null records.push({ __date__: record.date, __time__: record.date.getTime(), - userId: 'N/A', + userId: userData?.username ?? 'N/A', ...record.computedMeasures, ...props }); From 3106352ebaaecee02df53652ba2192ef9e28c97d Mon Sep 17 00:00:00 2001 From: David Roper Date: Wed, 12 Nov 2025 16:28:58 -0500 Subject: [PATCH 11/92] refactor: move session and user api methods to separate hook files --- apps/web/src/hooks/useFindSession.ts | 12 +++++++++ apps/web/src/hooks/useFindUser.ts | 12 +++++++++ .../src/hooks/useInstrumentVisualization.ts | 26 +++---------------- 3 files changed, 27 insertions(+), 23 deletions(-) create mode 100644 apps/web/src/hooks/useFindSession.ts create mode 100644 apps/web/src/hooks/useFindUser.ts diff --git a/apps/web/src/hooks/useFindSession.ts b/apps/web/src/hooks/useFindSession.ts new file mode 100644 index 000000000..e3ae1592c --- /dev/null +++ b/apps/web/src/hooks/useFindSession.ts @@ -0,0 +1,12 @@ +import type { Session } from '@opendatacapture/schemas/session'; +import axios from 'axios'; + +export const sessionInfo = async (sessionId: string): Promise => { + try { + const response = await axios.get(`/v1/sessions/${sessionId}`); + return response.data ? (response.data as Session) : null; + } catch (error) { + console.error('Error fetching user:', error); + return null; // ensures a resolved value instead of `void` + } +}; diff --git a/apps/web/src/hooks/useFindUser.ts b/apps/web/src/hooks/useFindUser.ts new file mode 100644 index 000000000..a3c13dcab --- /dev/null +++ b/apps/web/src/hooks/useFindUser.ts @@ -0,0 +1,12 @@ +import type { User } from '@opendatacapture/schemas/user'; +import axios from 'axios'; + +export const userInfo = async (userId: string): Promise => { + try { + const response = await axios.get(`/v1/users/${userId}`); + return response.data ? (response.data as User) : null; + } catch (error) { + console.error('Error fetching user:', error); + return null; // ensures a resolved value instead of `void` + } +}; diff --git a/apps/web/src/hooks/useInstrumentVisualization.ts b/apps/web/src/hooks/useInstrumentVisualization.ts index b4a99bb60..635430ddb 100644 --- a/apps/web/src/hooks/useInstrumentVisualization.ts +++ b/apps/web/src/hooks/useInstrumentVisualization.ts @@ -3,10 +3,7 @@ import { useEffect, useMemo, useState } from 'react'; import { toBasicISOString } from '@douglasneuroinformatics/libjs'; import { useDownload, useNotificationsStore, useTranslation } from '@douglasneuroinformatics/libui/hooks'; import type { AnyUnilingualScalarInstrument, InstrumentKind } from '@opendatacapture/runtime-core'; -import type { Session } from '@opendatacapture/schemas/session'; -import type { User } from '@opendatacapture/schemas/user'; import { removeSubjectIdScope } from '@opendatacapture/subject-utils'; -import axios from 'axios'; import { omit } from 'lodash-es'; import { unparse } from 'papaparse'; @@ -16,6 +13,9 @@ import { useInstrumentRecords } from '@/hooks/useInstrumentRecords'; import { useAppStore } from '@/store'; import { downloadSubjectTableExcel } from '@/utils/excel'; +import { sessionInfo } from './useFindSession'; +import { userInfo } from './useFindUser'; + type InstrumentVisualizationRecord = { [key: string]: unknown; __date__: Date; @@ -57,26 +57,6 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio } }); - const sessionInfo = async (sessionId: string): Promise => { - try { - const response = await axios.get(`/v1/sessions/${sessionId}`); - return response.data ? (response.data as Session) : null; - } catch (error) { - console.error('Error fetching user:', error); - return null; // ensures a resolved value instead of `void` - } - }; - - const userInfo = async (userId: string): Promise => { - try { - const response = await axios.get(`/v1/users/${userId}`); - return response.data ? (response.data as User) : null; - } catch (error) { - console.error('Error fetching user:', error); - return null; // ensures a resolved value instead of `void` - } - }; - const dl = (option: 'CSV' | 'CSV Long' | 'Excel' | 'Excel Long' | 'JSON' | 'TSV' | 'TSV Long') => { if (!instrument) { notifications.addNotification({ message: t('errors.noInstrumentSelected'), type: 'error' }); From 6aefd4f5819a86ccd22057aeaa87c651a884a98e Mon Sep 17 00:00:00 2001 From: David Roper Date: Wed, 12 Nov 2025 16:29:27 -0500 Subject: [PATCH 12/92] feat: create mocks for findUser and findSession hooks --- .../__tests__/useInstrumentVisualization.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts b/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts index bfa1e7478..d1be55b24 100644 --- a/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts +++ b/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts @@ -37,6 +37,14 @@ const mockInstrumentRecords = { ] }; +const mockSession = { + sessionId: 123 +}; + +const mockUser = { + username: 'testusername' +}; + vi.mock('@/hooks/useInstrument', () => ({ useInstrument: mockUseInstrument })); @@ -63,6 +71,14 @@ vi.mock('@/hooks/useInstrumentRecords', () => ({ useInstrumentRecords: () => mockInstrumentRecords })); +vi.mock('@/hooks/useFindSession', () => ({ + sessionInfo: () => mockSession +})); + +vi.mock('@/hooks/useFindUser', () => ({ + userInfo: () => mockUser +})); + describe('useInstrumentVisualization', () => { beforeEach(() => { vi.clearAllMocks(); From 1cf8276b2f3eca37a021125a002dee3645fda0ba Mon Sep 17 00:00:00 2001 From: David Roper Date: Thu, 13 Nov 2025 11:52:13 -0500 Subject: [PATCH 13/92] test: fix use tests with wait for methods --- .../useInstrumentVisualization.test.ts | 82 +++++++++++++------ 1 file changed, 56 insertions(+), 26 deletions(-) diff --git a/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts b/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts index d1be55b24..4a4829cd4 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,13 +32,14 @@ const mockInstrumentRecords = { { computedMeasures: {}, data: { someValue: 'abc' }, - date: FIXED_TEST_DATE + date: FIXED_TEST_DATE, + sessionId: '123' } ] }; const mockSession = { - sessionId: 123 + userId: '111' }; const mockUser = { @@ -85,9 +86,12 @@ describe('useInstrumentVisualization', () => { }); 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); @@ -95,30 +99,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,userId,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\tuserId\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); @@ -126,15 +136,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,Value,Variable\r\ntestGroupId,${toBasicISOString(FIXED_TEST_DATE)},testId,testusername,userId\r\ntestGroupId,${toBasicISOString(FIXED_TEST_DATE)},testId,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); @@ -142,15 +155,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\tValue\tVariable\r\ntestGroupId\t${toBasicISOString(FIXED_TEST_DATE)}\ttestId\ttestusername\tuserId\r\ntestGroupId\t${toBasicISOString(FIXED_TEST_DATE)}\ttestId\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] ?? []; @@ -163,16 +179,20 @@ describe('useInstrumentVisualization', () => { subjectId: 'testId', // eslint-disable-next-line perfectionist/sort-objects Date: '2025-04-30', - someValue: 'abc' + someValue: 'abc', + userId: '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); @@ -181,6 +201,13 @@ describe('useInstrumentVisualization', () => { const excelContents = getContentFn; expect(excelContents).toEqual([ + { + Date: '2025-04-30', + GroupID: 'testGroupId', + SubjectID: 'testId', + Value: 'testusername', + Variable: 'userId' + }, { Date: '2025-04-30', GroupID: 'testGroupId', @@ -194,8 +221,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); From 3a2e07ced1107472ce0ab9430754254dc2a22e5f Mon Sep 17 00:00:00 2001 From: david-roper Date: Fri, 14 Nov 2025 11:27:23 -0500 Subject: [PATCH 14/92] refactor: make Username a standalone column in long export formats --- apps/web/src/hooks/useInstrumentVisualization.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/web/src/hooks/useInstrumentVisualization.ts b/apps/web/src/hooks/useInstrumentVisualization.ts index 635430ddb..2ba3c0651 100644 --- a/apps/web/src/hooks/useInstrumentVisualization.ts +++ b/apps/web/src/hooks/useInstrumentVisualization.ts @@ -96,12 +96,17 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio exportRecords.forEach((item) => { let date: Date; + let username: string; Object.entries(item).forEach(([objKey, objVal]) => { if (objKey === '__date__') { date = objVal as Date; return; } + if (objKey === 'userId') { + username = objVal as string; + return; + } if (Array.isArray(objVal)) { objVal.forEach((arrayItem) => { @@ -110,6 +115,7 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio GroupID: currentGroup ? currentGroup.id : 'root', // eslint-disable-next-line perfectionist/sort-objects Date: toBasicISOString(date), + Username: username, SubjectID: removeSubjectIdScope(params.subjectId), Variable: `${objKey}-${arrKey}`, // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, perfectionist/sort-objects @@ -122,6 +128,7 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio GroupID: currentGroup ? currentGroup.id : 'root', // eslint-disable-next-line perfectionist/sort-objects Date: toBasicISOString(date), + Username: username, SubjectID: removeSubjectIdScope(params.subjectId), Value: objVal, Variable: objKey From 7247e539e8c50991dde36d0a63230a261814b6d0 Mon Sep 17 00:00:00 2001 From: david-roper Date: Fri, 14 Nov 2025 11:27:47 -0500 Subject: [PATCH 15/92] test: change tests to include username --- .../__tests__/useInstrumentVisualization.test.ts | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts b/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts index 4a4829cd4..5f4920574 100644 --- a/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts +++ b/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts @@ -136,7 +136,7 @@ describe('useInstrumentVisualization', () => { expect(filename).toContain('.csv'); const csvLongContents = getContentFn(); expect(csvLongContents).toMatch( - `GroupID,Date,SubjectID,Value,Variable\r\ntestGroupId,${toBasicISOString(FIXED_TEST_DATE)},testId,testusername,userId\r\ntestGroupId,${toBasicISOString(FIXED_TEST_DATE)},testId,abc,someValue` + `GroupID,Date,Username,SubjectID,Value,Variable\r\ntestGroupId,${toBasicISOString(FIXED_TEST_DATE)},testusername,testId,abc,someValue` ); }); }); @@ -155,7 +155,7 @@ 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\ttestusername\tuserId\r\ntestGroupId\t${toBasicISOString(FIXED_TEST_DATE)}\ttestId\tabc\tsomeValue` + `GroupID\tDate\tUsername\tSubjectID\tValue\tVariable\r\ntestGroupId\t${toBasicISOString(FIXED_TEST_DATE)}\ttestusername\ttestId\tabc\tsomeValue` ); }); }); @@ -205,13 +205,7 @@ describe('useInstrumentVisualization', () => { Date: '2025-04-30', GroupID: 'testGroupId', SubjectID: 'testId', - Value: 'testusername', - Variable: 'userId' - }, - { - Date: '2025-04-30', - GroupID: 'testGroupId', - SubjectID: 'testId', + Username: 'testusername', Value: 'abc', Variable: 'someValue' } From 784d576470b15e21cea1049d4a660b495ebc3140 Mon Sep 17 00:00:00 2001 From: david-roper Date: Fri, 14 Nov 2025 15:07:47 -0500 Subject: [PATCH 16/92] chore: small changes to test From ebe30a4ba6fa252ab761087095bd23cae7b8210d Mon Sep 17 00:00:00 2001 From: david-roper Date: Mon, 17 Nov 2025 11:37:01 -0500 Subject: [PATCH 17/92] fix: fix description in session controller --- apps/api/src/sessions/sessions.controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/src/sessions/sessions.controller.ts b/apps/api/src/sessions/sessions.controller.ts index 34d9f40ed..437cff75b 100644 --- a/apps/api/src/sessions/sessions.controller.ts +++ b/apps/api/src/sessions/sessions.controller.ts @@ -27,7 +27,7 @@ export class SessionsController { return this.sessionsService.findById(id, { ability }); } - @ApiOperation({ description: 'Find Session by ID' }) + @ApiOperation({ description: 'Find Sessions by ID' }) @Post('list') @RouteAccess({ action: 'read', subject: 'Session' }) findSessionList(@Query('ids') ids: string[]): Promise { From 29a48959ac2fea196a1646384aa4a310fb9f6953 Mon Sep 17 00:00:00 2001 From: david-roper Date: Mon, 17 Nov 2025 11:38:52 -0500 Subject: [PATCH 18/92] fix: add ability to find sessions list --- apps/api/src/sessions/sessions.controller.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/api/src/sessions/sessions.controller.ts b/apps/api/src/sessions/sessions.controller.ts index 437cff75b..286d340c1 100644 --- a/apps/api/src/sessions/sessions.controller.ts +++ b/apps/api/src/sessions/sessions.controller.ts @@ -30,8 +30,8 @@ export class SessionsController { @ApiOperation({ description: 'Find Sessions by ID' }) @Post('list') @RouteAccess({ action: 'read', subject: 'Session' }) - findSessionList(@Query('ids') ids: string[]): Promise { + findSessionList(@Query('ids') ids: string[], @CurrentUser('ability') ability: AppAbility): Promise { const idArray = Array.isArray(ids) ? ids : (ids as string).split(','); - return this.sessionsService.findSessionList(idArray); + return this.sessionsService.findSessionList(idArray, { ability }); } } From aaa9e19d88793f464d2670a072e4275e7814be8f Mon Sep 17 00:00:00 2001 From: david-roper Date: Mon, 17 Nov 2025 11:58:21 -0500 Subject: [PATCH 19/92] fix: error msg in usefindsession --- apps/web/src/hooks/useFindSession.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/hooks/useFindSession.ts b/apps/web/src/hooks/useFindSession.ts index e3ae1592c..08d3ad54e 100644 --- a/apps/web/src/hooks/useFindSession.ts +++ b/apps/web/src/hooks/useFindSession.ts @@ -6,7 +6,7 @@ export const sessionInfo = async (sessionId: string): Promise => const response = await axios.get(`/v1/sessions/${sessionId}`); return response.data ? (response.data as Session) : null; } catch (error) { - console.error('Error fetching user:', error); + console.error('Error fetching session:', error); return null; // ensures a resolved value instead of `void` } }; From 441218d9fbecdaa4d56777e8a0128dc36435c644 Mon Sep 17 00:00:00 2001 From: david-roper Date: Mon, 17 Nov 2025 14:42:17 -0500 Subject: [PATCH 20/92] fix: throw and error to catch instead of returning null when error occurs --- apps/web/src/hooks/useFindSession.ts | 2 +- apps/web/src/hooks/useFindUser.ts | 2 +- .../src/hooks/useInstrumentVisualization.ts | 46 ++++++++++--------- 3 files changed, 27 insertions(+), 23 deletions(-) diff --git a/apps/web/src/hooks/useFindSession.ts b/apps/web/src/hooks/useFindSession.ts index 08d3ad54e..4232834bb 100644 --- a/apps/web/src/hooks/useFindSession.ts +++ b/apps/web/src/hooks/useFindSession.ts @@ -7,6 +7,6 @@ export const sessionInfo = async (sessionId: string): Promise => return response.data ? (response.data as Session) : null; } catch (error) { console.error('Error fetching session:', error); - return null; // ensures a resolved value instead of `void` + throw error; } }; diff --git a/apps/web/src/hooks/useFindUser.ts b/apps/web/src/hooks/useFindUser.ts index a3c13dcab..686efd1c9 100644 --- a/apps/web/src/hooks/useFindUser.ts +++ b/apps/web/src/hooks/useFindUser.ts @@ -7,6 +7,6 @@ export const userInfo = async (userId: string): Promise => { return response.data ? (response.data as User) : null; } catch (error) { console.error('Error fetching user:', error); - return null; // ensures a resolved value instead of `void` + throw error; } }; diff --git a/apps/web/src/hooks/useInstrumentVisualization.ts b/apps/web/src/hooks/useInstrumentVisualization.ts index 2ba3c0651..e015062f9 100644 --- a/apps/web/src/hooks/useInstrumentVisualization.ts +++ b/apps/web/src/hooks/useInstrumentVisualization.ts @@ -206,37 +206,41 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio useEffect(() => { const fetchRecords = async () => { - if (recordsQuery.data) { - const records: InstrumentVisualizationRecord[] = []; - - for (const record of recordsQuery.data) { - const props = record.data && typeof record.data === 'object' ? record.data : {}; - - const sessionData = await sessionInfo(record.sessionId); + try { + if (recordsQuery.data) { + const records: InstrumentVisualizationRecord[] = []; + + for (const record of recordsQuery.data) { + const props = record.data && typeof record.data === 'object' ? record.data : {}; + + const sessionData = await sessionInfo(record.sessionId); + + if (!sessionData?.userId) { + records.push({ + __date__: record.date, + __time__: record.date.getTime(), + userId: 'N/A', + ...record.computedMeasures, + ...props + }); + continue; + } - if (!sessionData?.userId) { + const userData = await userInfo(sessionData.userId); + // safely check since userData can be null records.push({ __date__: record.date, __time__: record.date.getTime(), - userId: 'N/A', + userId: userData?.username ?? 'N/A', ...record.computedMeasures, ...props }); - continue; } - const userData = await userInfo(sessionData.userId); - // safely check since userData can be null - records.push({ - __date__: record.date, - __time__: record.date.getTime(), - userId: userData?.username ?? 'N/A', - ...record.computedMeasures, - ...props - }); + setRecords(records); } - - setRecords(records); + } catch (error) { + console.error('Error occurred: ', error); } }; void fetchRecords(); From c5e9c6d06014215e720e35219ea9ad0437bae012 Mon Sep 17 00:00:00 2001 From: david-roper Date: Mon, 17 Nov 2025 14:58:26 -0500 Subject: [PATCH 21/92] feat: use schema parsing to confirm contents instead of casting it --- apps/web/src/hooks/useFindSession.ts | 5 +++-- apps/web/src/hooks/useFindUser.ts | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/web/src/hooks/useFindSession.ts b/apps/web/src/hooks/useFindSession.ts index 4232834bb..1360a5b66 100644 --- a/apps/web/src/hooks/useFindSession.ts +++ b/apps/web/src/hooks/useFindSession.ts @@ -1,10 +1,11 @@ -import type { Session } from '@opendatacapture/schemas/session'; +import { type Session, $Session } from '@opendatacapture/schemas/session'; import axios from 'axios'; export const sessionInfo = async (sessionId: string): Promise => { try { const response = await axios.get(`/v1/sessions/${sessionId}`); - return response.data ? (response.data as Session) : null; + const parsedResult = $Session.safeParse(response.data); + return parsedResult.success ? parsedResult.data : null; } catch (error) { console.error('Error fetching session:', error); throw error; diff --git a/apps/web/src/hooks/useFindUser.ts b/apps/web/src/hooks/useFindUser.ts index 686efd1c9..b65d53bff 100644 --- a/apps/web/src/hooks/useFindUser.ts +++ b/apps/web/src/hooks/useFindUser.ts @@ -1,10 +1,11 @@ -import type { User } from '@opendatacapture/schemas/user'; +import { type User, $User } from '@opendatacapture/schemas/user'; import axios from 'axios'; export const userInfo = async (userId: string): Promise => { try { const response = await axios.get(`/v1/users/${userId}`); - return response.data ? (response.data as User) : null; + const parsedResult = $User.safeParse(response.data); + return parsedResult.success ? parsedResult.data : null; } catch (error) { console.error('Error fetching user:', error); throw error; From b58dd178378e85fe345a9aa5c70794a59520a1b8 Mon Sep 17 00:00:00 2001 From: david-roper Date: Mon, 17 Nov 2025 15:47:51 -0500 Subject: [PATCH 22/92] feat: adjust username variable to start as N/A, adjust tests --- .../hooks/__tests__/useInstrumentVisualization.test.ts | 7 +++---- apps/web/src/hooks/useInstrumentVisualization.ts | 8 ++++---- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts b/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts index 5f4920574..7b43e4b73 100644 --- a/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts +++ b/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts @@ -99,7 +99,7 @@ describe('useInstrumentVisualization', () => { expect(filename).toContain('.csv'); const csvContents = getContentFn(); expect(csvContents).toMatch( - `GroupID,subjectId,Date,userId,someValue\r\ntestGroupId,testId,${toBasicISOString(FIXED_TEST_DATE)},testusername,abc` + `GroupID,subjectId,Date,username,someValue\r\ntestGroupId,testId,${toBasicISOString(FIXED_TEST_DATE)},testusername,abc` ); }); }); @@ -117,7 +117,7 @@ describe('useInstrumentVisualization', () => { expect(filename).toContain('.tsv'); const tsvContents = getContentFn(); expect(tsvContents).toMatch( - `GroupID\tsubjectId\tDate\tuserId\tsomeValue\r\ntestGroupId\ttestId\t${toBasicISOString(FIXED_TEST_DATE)}\ttestusername\tabc` + `GroupID\tsubjectId\tDate\tusername\tsomeValue\r\ntestGroupId\ttestId\t${toBasicISOString(FIXED_TEST_DATE)}\ttestusername\tabc` ); }); }); @@ -177,10 +177,9 @@ describe('useInstrumentVisualization', () => { { GroupID: 'testGroupId', subjectId: 'testId', - // eslint-disable-next-line perfectionist/sort-objects Date: '2025-04-30', someValue: 'abc', - userId: 'testusername' + username: 'testusername' } ]); }); diff --git a/apps/web/src/hooks/useInstrumentVisualization.ts b/apps/web/src/hooks/useInstrumentVisualization.ts index e015062f9..862275022 100644 --- a/apps/web/src/hooks/useInstrumentVisualization.ts +++ b/apps/web/src/hooks/useInstrumentVisualization.ts @@ -96,14 +96,14 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio exportRecords.forEach((item) => { let date: Date; - let username: string; + let username: string = 'N/A'; Object.entries(item).forEach(([objKey, objVal]) => { if (objKey === '__date__') { date = objVal as Date; return; } - if (objKey === 'userId') { + if (objKey === 'username') { username = objVal as string; return; } @@ -219,7 +219,7 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio records.push({ __date__: record.date, __time__: record.date.getTime(), - userId: 'N/A', + username: 'N/A', ...record.computedMeasures, ...props }); @@ -231,7 +231,7 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio records.push({ __date__: record.date, __time__: record.date.getTime(), - userId: userData?.username ?? 'N/A', + username: userData?.username ?? 'N/A', ...record.computedMeasures, ...props }); From 7c08a6bf973f96bc01fff5a994d3c577b464a7a5 Mon Sep 17 00:00:00 2001 From: david-roper Date: Mon, 17 Nov 2025 15:49:03 -0500 Subject: [PATCH 23/92] fix: fix type exports --- apps/web/src/hooks/useFindSession.ts | 3 ++- apps/web/src/hooks/useFindUser.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/web/src/hooks/useFindSession.ts b/apps/web/src/hooks/useFindSession.ts index 1360a5b66..2c838cf2a 100644 --- a/apps/web/src/hooks/useFindSession.ts +++ b/apps/web/src/hooks/useFindSession.ts @@ -1,4 +1,5 @@ -import { type Session, $Session } from '@opendatacapture/schemas/session'; +import { $Session } from '@opendatacapture/schemas/session'; +import type { Session } from '@opendatacapture/schemas/session'; import axios from 'axios'; export const sessionInfo = async (sessionId: string): Promise => { diff --git a/apps/web/src/hooks/useFindUser.ts b/apps/web/src/hooks/useFindUser.ts index b65d53bff..9dea9202f 100644 --- a/apps/web/src/hooks/useFindUser.ts +++ b/apps/web/src/hooks/useFindUser.ts @@ -1,4 +1,5 @@ -import { type User, $User } from '@opendatacapture/schemas/user'; +import { $User } from '@opendatacapture/schemas/user'; +import type { User } from '@opendatacapture/schemas/user'; import axios from 'axios'; export const userInfo = async (userId: string): Promise => { From 9ce882593d039cd973872f963f91239d6b499329 Mon Sep 17 00:00:00 2001 From: david-roper Date: Mon, 17 Nov 2025 16:09:25 -0500 Subject: [PATCH 24/92] test: change positions of subjectId and username column to make linter happy with itself --- .../src/hooks/__tests__/useInstrumentVisualization.test.ts | 6 +++--- apps/web/src/hooks/useInstrumentVisualization.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts b/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts index 7b43e4b73..88cb1afc4 100644 --- a/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts +++ b/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts @@ -136,7 +136,7 @@ describe('useInstrumentVisualization', () => { expect(filename).toContain('.csv'); const csvLongContents = getContentFn(); expect(csvLongContents).toMatch( - `GroupID,Date,Username,SubjectID,Value,Variable\r\ntestGroupId,${toBasicISOString(FIXED_TEST_DATE)},testusername,testId,abc,someValue` + `GroupID,Date,SubjectID,Username,Value,Variable\r\ntestGroupId,${toBasicISOString(FIXED_TEST_DATE)},testId,testusername,abc,someValue` ); }); }); @@ -155,7 +155,7 @@ describe('useInstrumentVisualization', () => { expect(filename).toMatch('.tsv'); const tsvLongContents = getContentFn(); expect(tsvLongContents).toMatch( - `GroupID\tDate\tUsername\tSubjectID\tValue\tVariable\r\ntestGroupId\t${toBasicISOString(FIXED_TEST_DATE)}\ttestusername\ttestId\tabc\tsomeValue` + `GroupID\tDate\tSubjectID\tUsername\tValue\tVariable\r\ntestGroupId\t${toBasicISOString(FIXED_TEST_DATE)}\ttestId\ttestusername\tabc\tsomeValue` ); }); }); @@ -175,9 +175,9 @@ describe('useInstrumentVisualization', () => { expect(excelContents).toEqual([ { + Date: '2025-04-30', GroupID: 'testGroupId', subjectId: 'testId', - Date: '2025-04-30', someValue: 'abc', username: 'testusername' } diff --git a/apps/web/src/hooks/useInstrumentVisualization.ts b/apps/web/src/hooks/useInstrumentVisualization.ts index 862275022..6eb6c3ce9 100644 --- a/apps/web/src/hooks/useInstrumentVisualization.ts +++ b/apps/web/src/hooks/useInstrumentVisualization.ts @@ -96,7 +96,7 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio exportRecords.forEach((item) => { let date: Date; - let username: string = 'N/A'; + let username = 'N/A'; Object.entries(item).forEach(([objKey, objVal]) => { if (objKey === '__date__') { @@ -115,8 +115,8 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio GroupID: currentGroup ? currentGroup.id : 'root', // eslint-disable-next-line perfectionist/sort-objects Date: toBasicISOString(date), - Username: username, SubjectID: removeSubjectIdScope(params.subjectId), + Username: username, Variable: `${objKey}-${arrKey}`, // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, perfectionist/sort-objects Value: arrItem @@ -128,8 +128,8 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio GroupID: currentGroup ? currentGroup.id : 'root', // eslint-disable-next-line perfectionist/sort-objects Date: toBasicISOString(date), - Username: username, SubjectID: removeSubjectIdScope(params.subjectId), + Username: username, Value: objVal, Variable: objKey }); From e4fca74347234f9cb21c8e7614cdf39265cdc72f Mon Sep 17 00:00:00 2001 From: david-roper Date: Mon, 17 Nov 2025 16:09:50 -0500 Subject: [PATCH 25/92] refactor: remove unused api call --- apps/api/src/sessions/sessions.controller.ts | 8 -------- apps/api/src/sessions/sessions.service.ts | 15 --------------- 2 files changed, 23 deletions(-) diff --git a/apps/api/src/sessions/sessions.controller.ts b/apps/api/src/sessions/sessions.controller.ts index 286d340c1..132299ad2 100644 --- a/apps/api/src/sessions/sessions.controller.ts +++ b/apps/api/src/sessions/sessions.controller.ts @@ -26,12 +26,4 @@ export class SessionsController { findByID(@Param('id') id: string, @CurrentUser('ability') ability: AppAbility): Promise { return this.sessionsService.findById(id, { ability }); } - - @ApiOperation({ description: 'Find Sessions by ID' }) - @Post('list') - @RouteAccess({ action: 'read', subject: 'Session' }) - findSessionList(@Query('ids') ids: string[], @CurrentUser('ability') ability: AppAbility): Promise { - const idArray = Array.isArray(ids) ? ids : (ids as string).split(','); - return this.sessionsService.findSessionList(idArray, { ability }); - } } diff --git a/apps/api/src/sessions/sessions.service.ts b/apps/api/src/sessions/sessions.service.ts index ee84a55a7..3d01b0fe1 100644 --- a/apps/api/src/sessions/sessions.service.ts +++ b/apps/api/src/sessions/sessions.service.ts @@ -104,21 +104,6 @@ export class SessionsService { return session; } - async findSessionList(ids: string[], { ability }: EntityOperationOptions = {}) { - const sessionsArray = await Promise.all( - ids.map(async (id) => { - const session = await this.sessionModel.findFirst({ - where: { AND: [accessibleQuery(ability, 'read', 'Session')], id } - }); - if (!session) { - throw new NotFoundException(`Failed to find session with ID: ${id}`); - } - return session; - }) - ); - return sessionsArray; - } - /** Get the subject if they exist, otherwise create them */ private async resolveSubject(subjectData: CreateSubjectData) { this.loggingService.debug({ message: 'Attempting to resolve subject', subjectData }); From 5248ffeeaff1d80dd8f1735a4ab832e95aa30a3a Mon Sep 17 00:00:00 2001 From: David Roper Date: Tue, 18 Nov 2025 09:44:08 -0500 Subject: [PATCH 26/92] chore: linter fixes --- apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts b/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts index 88cb1afc4..70430d756 100644 --- a/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts +++ b/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts @@ -178,6 +178,7 @@ describe('useInstrumentVisualization', () => { Date: '2025-04-30', GroupID: 'testGroupId', subjectId: 'testId', + // eslint-disable-next-line perfectionist/sort-objects someValue: 'abc', username: 'testusername' } From 78d4456f901242ebd729c262b8fdbcdbc780c989 Mon Sep 17 00:00:00 2001 From: David Roper Date: Tue, 18 Nov 2025 09:53:19 -0500 Subject: [PATCH 27/92] test: add resolved promise is session and userinfo mocked methods --- .../src/hooks/__tests__/useInstrumentVisualization.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts b/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts index 70430d756..51db05b5c 100644 --- a/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts +++ b/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts @@ -73,11 +73,11 @@ vi.mock('@/hooks/useInstrumentRecords', () => ({ })); vi.mock('@/hooks/useFindSession', () => ({ - sessionInfo: () => mockSession + sessionInfo: () => Promise.resolve(mockSession) })); vi.mock('@/hooks/useFindUser', () => ({ - userInfo: () => mockUser + userInfo: () => Promise.resolve(mockUser) })); describe('useInstrumentVisualization', () => { From b366c83347b55e5d12f0df697a64aa9326a6b141 Mon Sep 17 00:00:00 2001 From: David Roper Date: Tue, 18 Nov 2025 11:18:31 -0500 Subject: [PATCH 28/92] fix: remove extra append statement in excel download method --- apps/web/src/utils/excel.ts | 1 - 1 file changed, 1 deletion(-) 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); } From d42bac2b735be2d7b613c58c21d5d87768faa54e Mon Sep 17 00:00:00 2001 From: David Roper Date: Tue, 18 Nov 2025 14:35:32 -0500 Subject: [PATCH 29/92] feat: fix not finding user id issue by making subject inclusion optional --- apps/web/src/hooks/useFindSession.ts | 4 ++-- packages/schemas/src/session/session.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/hooks/useFindSession.ts b/apps/web/src/hooks/useFindSession.ts index 2c838cf2a..ffc1fb7fe 100644 --- a/apps/web/src/hooks/useFindSession.ts +++ b/apps/web/src/hooks/useFindSession.ts @@ -5,8 +5,8 @@ import axios from 'axios'; export const sessionInfo = async (sessionId: string): Promise => { try { const response = await axios.get(`/v1/sessions/${sessionId}`); - const parsedResult = $Session.safeParse(response.data); - return parsedResult.success ? parsedResult.data : null; + const parsedResult = await $Session.parseAsync(response.data); + return parsedResult; } catch (error) { console.error('Error fetching session:', error); throw error; diff --git a/packages/schemas/src/session/session.ts b/packages/schemas/src/session/session.ts index 81d38bead..6b5d982fc 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.optional(), subjectId: z.string(), type: $SessionType, userId: z.string().nullish() From ca014eb6798501862fc92c7053f125484ea351a7 Mon Sep 17 00:00:00 2001 From: David Roper Date: Tue, 18 Nov 2025 14:44:59 -0500 Subject: [PATCH 30/92] chore: revert session schema and parse change --- apps/web/src/hooks/useFindSession.ts | 7 ++++--- packages/schemas/src/session/session.ts | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/web/src/hooks/useFindSession.ts b/apps/web/src/hooks/useFindSession.ts index ffc1fb7fe..01fb77508 100644 --- a/apps/web/src/hooks/useFindSession.ts +++ b/apps/web/src/hooks/useFindSession.ts @@ -1,12 +1,13 @@ -import { $Session } from '@opendatacapture/schemas/session'; import type { Session } from '@opendatacapture/schemas/session'; import axios from 'axios'; export const sessionInfo = async (sessionId: string): Promise => { try { const response = await axios.get(`/v1/sessions/${sessionId}`); - const parsedResult = await $Session.parseAsync(response.data); - return parsedResult; + if (!response.data) { + throw new Error('Session data does not exist'); + } + return response.data as Session; } catch (error) { console.error('Error fetching session:', error); throw error; diff --git a/packages/schemas/src/session/session.ts b/packages/schemas/src/session/session.ts index 6b5d982fc..81d38bead 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.optional(), + subject: $Subject, subjectId: z.string(), type: $SessionType, userId: z.string().nullish() From b534f9b2edca8b252aefc168568ef1b198fd3906 Mon Sep 17 00:00:00 2001 From: David Roper Date: Tue, 18 Nov 2025 14:46:22 -0500 Subject: [PATCH 31/92] fix: make username column in wideRow method and its tests more consistent --- .../src/hooks/__tests__/useInstrumentVisualization.test.ts | 6 +++--- apps/web/src/hooks/useInstrumentVisualization.ts | 4 ++++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts b/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts index 51db05b5c..ded589273 100644 --- a/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts +++ b/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts @@ -99,7 +99,7 @@ describe('useInstrumentVisualization', () => { expect(filename).toContain('.csv'); const csvContents = getContentFn(); expect(csvContents).toMatch( - `GroupID,subjectId,Date,username,someValue\r\ntestGroupId,testId,${toBasicISOString(FIXED_TEST_DATE)},testusername,abc` + `GroupID,subjectId,Date,Username,someValue\r\ntestGroupId,testId,${toBasicISOString(FIXED_TEST_DATE)},testusername,abc` ); }); }); @@ -117,7 +117,7 @@ describe('useInstrumentVisualization', () => { expect(filename).toContain('.tsv'); const tsvContents = getContentFn(); expect(tsvContents).toMatch( - `GroupID\tsubjectId\tDate\tusername\tsomeValue\r\ntestGroupId\ttestId\t${toBasicISOString(FIXED_TEST_DATE)}\ttestusername\tabc` + `GroupID\tsubjectId\tDate\tUsername\tsomeValue\r\ntestGroupId\ttestId\t${toBasicISOString(FIXED_TEST_DATE)}\ttestusername\tabc` ); }); }); @@ -180,7 +180,7 @@ describe('useInstrumentVisualization', () => { subjectId: 'testId', // eslint-disable-next-line perfectionist/sort-objects someValue: 'abc', - username: 'testusername' + Username: 'testusername' } ]); }); diff --git a/apps/web/src/hooks/useInstrumentVisualization.ts b/apps/web/src/hooks/useInstrumentVisualization.ts index 6eb6c3ce9..2e180d5d6 100644 --- a/apps/web/src/hooks/useInstrumentVisualization.ts +++ b/apps/web/src/hooks/useInstrumentVisualization.ts @@ -85,6 +85,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; From 85034fe25a95ddcaf80d5ff799cc7ae765e5ee06 Mon Sep 17 00:00:00 2001 From: David Roper Date: Tue, 18 Nov 2025 15:06:24 -0500 Subject: [PATCH 32/92] feat: remove redundant null return type from useFindSession --- apps/web/src/hooks/useFindSession.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/hooks/useFindSession.ts b/apps/web/src/hooks/useFindSession.ts index 01fb77508..676c908fc 100644 --- a/apps/web/src/hooks/useFindSession.ts +++ b/apps/web/src/hooks/useFindSession.ts @@ -1,7 +1,7 @@ import type { Session } from '@opendatacapture/schemas/session'; import axios from 'axios'; -export const sessionInfo = async (sessionId: string): Promise => { +export const sessionInfo = async (sessionId: string): Promise => { try { const response = await axios.get(`/v1/sessions/${sessionId}`); if (!response.data) { From 3dce5206db4a16dd58af96113fb79e0a03f67344 Mon Sep 17 00:00:00 2001 From: David Roper Date: Tue, 18 Nov 2025 15:10:01 -0500 Subject: [PATCH 33/92] feat: add error notification for useEffect --- apps/web/src/hooks/useInstrumentVisualization.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/web/src/hooks/useInstrumentVisualization.ts b/apps/web/src/hooks/useInstrumentVisualization.ts index 2e180d5d6..23ec89617 100644 --- a/apps/web/src/hooks/useInstrumentVisualization.ts +++ b/apps/web/src/hooks/useInstrumentVisualization.ts @@ -245,6 +245,13 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio } } catch (error) { console.error('Error occurred: ', error); + notifications.addNotification({ + message: t({ + en: 'Error occurred finding records', + fr: "Une erreur s'est produite lors de la recherche des enregistrements." + }), + type: 'error' + }); } }; void fetchRecords(); From 1f2a215de35d3e8b6ae08f4a7dc42dfa52560a6e Mon Sep 17 00:00:00 2001 From: David Roper Date: Wed, 19 Nov 2025 10:16:35 -0500 Subject: [PATCH 34/92] chore: and encodeUriComponent to ids --- apps/web/src/hooks/useFindSession.ts | 2 +- apps/web/src/hooks/useFindUser.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/hooks/useFindSession.ts b/apps/web/src/hooks/useFindSession.ts index 676c908fc..333890c62 100644 --- a/apps/web/src/hooks/useFindSession.ts +++ b/apps/web/src/hooks/useFindSession.ts @@ -3,7 +3,7 @@ import axios from 'axios'; export const sessionInfo = async (sessionId: string): Promise => { try { - const response = await axios.get(`/v1/sessions/${sessionId}`); + const response = await axios.get(`/v1/sessions/${encodeURIComponent(sessionId)}`); if (!response.data) { throw new Error('Session data does not exist'); } diff --git a/apps/web/src/hooks/useFindUser.ts b/apps/web/src/hooks/useFindUser.ts index 9dea9202f..6dc2a95d1 100644 --- a/apps/web/src/hooks/useFindUser.ts +++ b/apps/web/src/hooks/useFindUser.ts @@ -4,7 +4,7 @@ import axios from 'axios'; export const userInfo = async (userId: string): Promise => { try { - const response = await axios.get(`/v1/users/${userId}`); + const response = await axios.get(`/v1/users/${encodeURIComponent(userId)}`); const parsedResult = $User.safeParse(response.data); return parsedResult.success ? parsedResult.data : null; } catch (error) { From 98b220dc9abdf87469546301f9471e0e66e0d06c Mon Sep 17 00:00:00 2001 From: David Roper Date: Wed, 19 Nov 2025 10:43:19 -0500 Subject: [PATCH 35/92] feat: fetch sessions then users in parrallel and update test mock values --- .../useInstrumentVisualization.test.ts | 1 + .../src/hooks/useInstrumentVisualization.ts | 37 +++++++++---------- 2 files changed, 18 insertions(+), 20 deletions(-) diff --git a/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts b/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts index ded589273..db12e8427 100644 --- a/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts +++ b/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts @@ -43,6 +43,7 @@ const mockSession = { }; const mockUser = { + id: '111', username: 'testusername' }; diff --git a/apps/web/src/hooks/useInstrumentVisualization.ts b/apps/web/src/hooks/useInstrumentVisualization.ts index 23ec89617..f023e8e34 100644 --- a/apps/web/src/hooks/useInstrumentVisualization.ts +++ b/apps/web/src/hooks/useInstrumentVisualization.ts @@ -212,34 +212,31 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio const fetchRecords = async () => { try { if (recordsQuery.data) { - const records: InstrumentVisualizationRecord[] = []; + // Fetch all sessions in parallel + const sessionPromises = recordsQuery.data.map((record) => sessionInfo(record.sessionId)); + const sessions = await Promise.all(sessionPromises); - for (const record of recordsQuery.data) { - const props = record.data && typeof record.data === 'object' ? record.data : {}; + // Extract unique userIds and fetch users in parallel + const userIds = [...new Set(sessions.filter((s) => s?.userId).map((s) => s.userId))]; - const sessionData = await sessionInfo(record.sessionId); + const userPromises = userIds.map((userId) => userInfo(userId!)); + const users = await Promise.all(userPromises); + const userMap = new Map(users.filter((u) => u).map((u) => [u!.id, u!.username])); - if (!sessionData?.userId) { - records.push({ - __date__: record.date, - __time__: record.date.getTime(), - username: 'N/A', - ...record.computedMeasures, - ...props - }); - continue; - } + // Build records with looked-up data + const records: InstrumentVisualizationRecord[] = recordsQuery.data.map((record, i) => { + const props = record.data && typeof record.data === 'object' ? record.data : {}; + const session = sessions[i]; + const username = session?.userId ? (userMap.get(session.userId) ?? 'N/A') : 'N/A'; - const userData = await userInfo(sessionData.userId); - // safely check since userData can be null - records.push({ + return { __date__: record.date, __time__: record.date.getTime(), - username: userData?.username ?? 'N/A', + username: username, ...record.computedMeasures, ...props - }); - } + }; + }); setRecords(records); } From f7e7ab6d348bbe98945517d35f1a62b5df1099af Mon Sep 17 00:00:00 2001 From: David Roper Date: Wed, 19 Nov 2025 11:35:17 -0500 Subject: [PATCH 36/92] feat: add cancelled var to avoid race conditions in fetch records --- apps/web/src/hooks/useInstrumentVisualization.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/web/src/hooks/useInstrumentVisualization.ts b/apps/web/src/hooks/useInstrumentVisualization.ts index f023e8e34..42f1c566c 100644 --- a/apps/web/src/hooks/useInstrumentVisualization.ts +++ b/apps/web/src/hooks/useInstrumentVisualization.ts @@ -209,6 +209,7 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio }; useEffect(() => { + let cancelled = false; const fetchRecords = async () => { try { if (recordsQuery.data) { @@ -219,6 +220,7 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio // Extract unique userIds and fetch users in parallel const userIds = [...new Set(sessions.filter((s) => s?.userId).map((s) => s.userId))]; + //assume userId exists in userId set as we already filtered out the non-existing userIds const userPromises = userIds.map((userId) => userInfo(userId!)); const users = await Promise.all(userPromises); const userMap = new Map(users.filter((u) => u).map((u) => [u!.id, u!.username])); @@ -238,7 +240,9 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio }; }); - setRecords(records); + if (!cancelled) { + setRecords(records); + } } } catch (error) { console.error('Error occurred: ', error); @@ -252,6 +256,9 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio } }; void fetchRecords(); + return () => { + cancelled = true; + }; }, [recordsQuery.data]); const instrumentOptions: { [key: string]: string } = useMemo(() => { From 7c4c8076b69f37010dd41a877b9d7dc84f1919ea Mon Sep 17 00:00:00 2001 From: David Roper Date: Wed, 19 Nov 2025 11:51:57 -0500 Subject: [PATCH 37/92] feat: return null on errors userInfo issues --- apps/web/src/hooks/useInstrumentVisualization.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/hooks/useInstrumentVisualization.ts b/apps/web/src/hooks/useInstrumentVisualization.ts index 42f1c566c..ff41b8776 100644 --- a/apps/web/src/hooks/useInstrumentVisualization.ts +++ b/apps/web/src/hooks/useInstrumentVisualization.ts @@ -221,7 +221,7 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio const userIds = [...new Set(sessions.filter((s) => s?.userId).map((s) => s.userId))]; //assume userId exists in userId set as we already filtered out the non-existing userIds - const userPromises = userIds.map((userId) => userInfo(userId!)); + const userPromises = userIds.map((userId) => userInfo(userId!).catch(() => null)); const users = await Promise.all(userPromises); const userMap = new Map(users.filter((u) => u).map((u) => [u!.id, u!.username])); From 688e779a06e5b3e302a7abbca3b944621551be6c Mon Sep 17 00:00:00 2001 From: David Roper Date: Wed, 19 Nov 2025 17:06:02 -0500 Subject: [PATCH 38/92] feat: add new findAllSessionsIncludeUsernames api call and todo comments --- apps/api/src/sessions/sessions.controller.ts | 10 ++++++++++ apps/api/src/sessions/sessions.service.ts | 15 +++++++++++++++ apps/web/src/hooks/useFindSession.ts | 6 ++++++ apps/web/src/hooks/useInstrumentVisualization.ts | 3 +++ packages/schemas/src/session/session.ts | 7 +++++++ 5 files changed, 41 insertions(+) diff --git a/apps/api/src/sessions/sessions.controller.ts b/apps/api/src/sessions/sessions.controller.ts index 132299ad2..8524fe131 100644 --- a/apps/api/src/sessions/sessions.controller.ts +++ b/apps/api/src/sessions/sessions.controller.ts @@ -20,6 +20,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( + @Query('groupId') groupId: string, + @CurrentUser('ability') ability: AppAbility + ) { + 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..451c6083c 100644 --- a/apps/api/src/sessions/sessions.service.ts +++ b/apps/api/src/sessions/sessions.service.ts @@ -94,6 +94,21 @@ export class SessionsService { }); } + async findAllIncludeUsernames(groupId: string, { ability }: EntityOperationOptions = {}) { + return this.sessionModel.findMany({ + include: { + user: { + select: { + username: true + } + } + }, + where: { + AND: [accessibleQuery(ability, 'read', 'Session'), { groupId }] + } + }); + } + 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/hooks/useFindSession.ts b/apps/web/src/hooks/useFindSession.ts index 333890c62..a3aac0c34 100644 --- a/apps/web/src/hooks/useFindSession.ts +++ b/apps/web/src/hooks/useFindSession.ts @@ -1,6 +1,12 @@ import type { Session } from '@opendatacapture/schemas/session'; import axios from 'axios'; +//Change this query to into a hook method and name it useFindSessionQuery + +//Change the api call to have an include tag which includes the username from users + +//Change the return type to + export const sessionInfo = async (sessionId: string): Promise => { try { const response = await axios.get(`/v1/sessions/${encodeURIComponent(sessionId)}`); diff --git a/apps/web/src/hooks/useInstrumentVisualization.ts b/apps/web/src/hooks/useInstrumentVisualization.ts index ff41b8776..48391b337 100644 --- a/apps/web/src/hooks/useInstrumentVisualization.ts +++ b/apps/web/src/hooks/useInstrumentVisualization.ts @@ -57,6 +57,9 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio } }); + // Create a new sessionsUsernameQuery which uses the useFindSessionQuery hook + // have use a different return type with + const dl = (option: 'CSV' | 'CSV Long' | 'Excel' | 'Excel Long' | 'JSON' | 'TSV' | 'TSV Long') => { if (!instrument) { notifications.addNotification({ message: t('errors.noInstrumentSelected'), type: 'error' }); diff --git a/packages/schemas/src/session/session.ts b/packages/schemas/src/session/session.ts index 81d38bead..8b0a89b83 100644 --- a/packages/schemas/src/session/session.ts +++ b/packages/schemas/src/session/session.ts @@ -24,3 +24,10 @@ 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() + }) +}); From b6e51b3ce5f12c44792baa8cdcfad51fc1946547 Mon Sep 17 00:00:00 2001 From: david-roper Date: Thu, 20 Nov 2025 13:40:04 -0500 Subject: [PATCH 39/92] feat: rename function to useFindSessionQuery --- apps/web/src/hooks/useFindSession.ts | 21 ---------- apps/web/src/hooks/useFindSessionQuery.ts | 51 +++++++++++++++++++++++ 2 files changed, 51 insertions(+), 21 deletions(-) delete mode 100644 apps/web/src/hooks/useFindSession.ts create mode 100644 apps/web/src/hooks/useFindSessionQuery.ts diff --git a/apps/web/src/hooks/useFindSession.ts b/apps/web/src/hooks/useFindSession.ts deleted file mode 100644 index a3aac0c34..000000000 --- a/apps/web/src/hooks/useFindSession.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { Session } from '@opendatacapture/schemas/session'; -import axios from 'axios'; - -//Change this query to into a hook method and name it useFindSessionQuery - -//Change the api call to have an include tag which includes the username from users - -//Change the return type to - -export const sessionInfo = async (sessionId: string): Promise => { - try { - const response = await axios.get(`/v1/sessions/${encodeURIComponent(sessionId)}`); - if (!response.data) { - throw new Error('Session data does not exist'); - } - return response.data as Session; - } catch (error) { - console.error('Error fetching session:', error); - throw error; - } -}; diff --git a/apps/web/src/hooks/useFindSessionQuery.ts b/apps/web/src/hooks/useFindSessionQuery.ts new file mode 100644 index 000000000..004912331 --- /dev/null +++ b/apps/web/src/hooks/useFindSessionQuery.ts @@ -0,0 +1,51 @@ +import { reviver } from '@douglasneuroinformatics/libjs'; +import { + $SessionWithUser, + type Session, + type SessionWithUser, + type SessionWithUserQueryParams +} from '@opendatacapture/schemas/session'; +import { useQuery } from '@tanstack/react-query'; +import axios from 'axios'; + +//Change this query to into a hook method and name it useFindSessionQuery + +//Change the api call to have an include tag which includes the username from users + +//Change the return type to + +type UseSessionOptions = { + enabled?: boolean; + params: SessionWithUserQueryParams; +}; + +export const sessionInfo = async (sessionId: string): Promise => { + try { + const response = await axios.get(`/v1/sessions/${encodeURIComponent(sessionId)}`); + if (!response.data) { + throw new Error('Session data does not exist'); + } + return response.data as Session; + } catch (error) { + console.error('Error fetching session:', error); + throw error; + } +}; + +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)] + }); +}; From 33f53b1933a7d402aa94c3c468813d1e7ee97746 Mon Sep 17 00:00:00 2001 From: david-roper Date: Thu, 20 Nov 2025 13:41:40 -0500 Subject: [PATCH 40/92] feat: update types of findAllIncludeUsernames --- apps/api/src/sessions/sessions.controller.ts | 7 ++++--- apps/api/src/sessions/sessions.service.ts | 8 ++++++-- apps/web/src/hooks/useInstrumentVisualization.ts | 9 ++++++++- packages/schemas/src/session/session.ts | 6 +++++- 4 files changed, 23 insertions(+), 7 deletions(-) diff --git a/apps/api/src/sessions/sessions.controller.ts b/apps/api/src/sessions/sessions.controller.ts index 8524fe131..0b9e50773 100644 --- a/apps/api/src/sessions/sessions.controller.ts +++ b/apps/api/src/sessions/sessions.controller.ts @@ -8,6 +8,7 @@ import { RouteAccess } from '@/core/decorators/route-access.decorator'; import { CreateSessionDto } from './dto/create-session.dto'; import { SessionsService } from './sessions.service'; +import type { SessionWithUser } from '@opendatacapture/schemas/session'; @Controller('sessions') export class SessionsController { @@ -24,9 +25,9 @@ export class SessionsController { @Get() @RouteAccess({ action: 'read', subject: 'Session' }) findAllIncludeUsernames( - @Query('groupId') groupId: string, - @CurrentUser('ability') ability: AppAbility - ) { + @CurrentUser('ability') ability: AppAbility, + @Query('groupId') groupId?: string + ): Promise { return this.sessionsService.findAllIncludeUsernames(groupId, { ability }); } diff --git a/apps/api/src/sessions/sessions.service.ts b/apps/api/src/sessions/sessions.service.ts index 451c6083c..8bffa3477 100644 --- a/apps/api/src/sessions/sessions.service.ts +++ b/apps/api/src/sessions/sessions.service.ts @@ -94,8 +94,8 @@ export class SessionsService { }); } - async findAllIncludeUsernames(groupId: string, { ability }: EntityOperationOptions = {}) { - return this.sessionModel.findMany({ + async findAllIncludeUsernames(groupId?: string, { ability }: EntityOperationOptions = {}) { + const sessionsWithUsers = await this.sessionModel.findMany({ include: { user: { select: { @@ -107,6 +107,10 @@ export class SessionsService { AND: [accessibleQuery(ability, 'read', 'Session'), { groupId }] } }); + if (!sessionsWithUsers) { + throw new NotFoundException(`Failed to find users`); + } + return sessionsWithUsers; } async findById(id: string, { ability }: EntityOperationOptions = {}) { diff --git a/apps/web/src/hooks/useInstrumentVisualization.ts b/apps/web/src/hooks/useInstrumentVisualization.ts index 48391b337..6a98a4206 100644 --- a/apps/web/src/hooks/useInstrumentVisualization.ts +++ b/apps/web/src/hooks/useInstrumentVisualization.ts @@ -13,7 +13,7 @@ import { useInstrumentRecords } from '@/hooks/useInstrumentRecords'; import { useAppStore } from '@/store'; import { downloadSubjectTableExcel } from '@/utils/excel'; -import { sessionInfo } from './useFindSession'; +import { sessionInfo, useFindSessionQuery } from './useFindSessionQuery'; import { userInfo } from './useFindUser'; type InstrumentVisualizationRecord = { @@ -57,6 +57,13 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio } }); + // const sessionsUsernameQuery = useFindSessionQuery({ + // enabled: instrumentId !== null, + // params: { + // groupId: currentGroup?.id + // } + // }); + // Create a new sessionsUsernameQuery which uses the useFindSessionQuery hook // have use a different return type with diff --git a/packages/schemas/src/session/session.ts b/packages/schemas/src/session/session.ts index 8b0a89b83..20d2a83b8 100644 --- a/packages/schemas/src/session/session.ts +++ b/packages/schemas/src/session/session.ts @@ -25,9 +25,13 @@ export const $CreateSessionData = z.object({ username: z.string().nullish() }); -export type $SessionWithUser = z.infer; +export type SessionWithUser = z.infer; export const $SessionWithUser = $Session.extend({ user: z.object({ username: z.string().nullish() }) }); + +export type SessionWithUserQueryParams = { + groupId?: string; +}; From 6e8bb648e143c958c2d5ab1f39233c2800b5dcb1 Mon Sep 17 00:00:00 2001 From: david-roper Date: Thu, 20 Nov 2025 15:41:43 -0500 Subject: [PATCH 41/92] feat: update query and type imports --- apps/api/src/sessions/sessions.service.ts | 3 ++- apps/web/src/hooks/useFindSessionQuery.ts | 9 ++------- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/apps/api/src/sessions/sessions.service.ts b/apps/api/src/sessions/sessions.service.ts index 8bffa3477..14f4bf0f3 100644 --- a/apps/api/src/sessions/sessions.service.ts +++ b/apps/api/src/sessions/sessions.service.ts @@ -101,7 +101,8 @@ export class SessionsService { select: { username: true } - } + }, + subject: true }, where: { AND: [accessibleQuery(ability, 'read', 'Session'), { groupId }] diff --git a/apps/web/src/hooks/useFindSessionQuery.ts b/apps/web/src/hooks/useFindSessionQuery.ts index 004912331..f6b027d52 100644 --- a/apps/web/src/hooks/useFindSessionQuery.ts +++ b/apps/web/src/hooks/useFindSessionQuery.ts @@ -1,10 +1,5 @@ -import { reviver } from '@douglasneuroinformatics/libjs'; -import { - $SessionWithUser, - type Session, - type SessionWithUser, - type SessionWithUserQueryParams -} from '@opendatacapture/schemas/session'; +import { $SessionWithUser } from '@opendatacapture/schemas/session'; +import type { Session, SessionWithUserQueryParams } from '@opendatacapture/schemas/session'; import { useQuery } from '@tanstack/react-query'; import axios from 'axios'; From 945e6be12004b11afa3c0e10a34e745873aa4e3a Mon Sep 17 00:00:00 2001 From: david-roper Date: Thu, 20 Nov 2025 17:15:35 -0500 Subject: [PATCH 42/92] feat: changed how we find sessions to useFindSessionQuery instead, test update needed --- apps/api/src/sessions/sessions.service.ts | 4 ++- apps/web/src/hooks/useFindSessionQuery.ts | 36 +++++++++++-------- .../src/hooks/useInstrumentVisualization.ts | 32 +++++++---------- packages/schemas/src/session/session.ts | 10 +++--- 4 files changed, 42 insertions(+), 40 deletions(-) diff --git a/apps/api/src/sessions/sessions.service.ts b/apps/api/src/sessions/sessions.service.ts index 14f4bf0f3..d3bc19a73 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({ diff --git a/apps/web/src/hooks/useFindSessionQuery.ts b/apps/web/src/hooks/useFindSessionQuery.ts index f6b027d52..be00e51d2 100644 --- a/apps/web/src/hooks/useFindSessionQuery.ts +++ b/apps/web/src/hooks/useFindSessionQuery.ts @@ -1,4 +1,4 @@ -import { $SessionWithUser } from '@opendatacapture/schemas/session'; +import { $Session, $SessionWithUser } from '@opendatacapture/schemas/session'; import type { Session, SessionWithUserQueryParams } from '@opendatacapture/schemas/session'; import { useQuery } from '@tanstack/react-query'; import axios from 'axios'; @@ -14,18 +14,19 @@ type UseSessionOptions = { params: SessionWithUserQueryParams; }; -export const sessionInfo = async (sessionId: string): Promise => { - try { - const response = await axios.get(`/v1/sessions/${encodeURIComponent(sessionId)}`); - if (!response.data) { - throw new Error('Session data does not exist'); - } - return response.data as Session; - } catch (error) { - console.error('Error fetching session:', error); - throw error; - } -}; +// export const sessionInfo = async (sessionId: string): Promise => { +// try { +// const response = await axios.get(`/v1/sessions/${encodeURIComponent(sessionId)}`); +// const parsedData = $Session.safeParse(response.data) +// if(!parsedData.success){ +// throw new Error(parsedData.error.message); +// } +// return parsedData.data; +// } catch (error) { +// console.error('Error fetching session:', error); +// throw error; +// } +// }; export const useFindSessionQuery = ( { enabled, params }: UseSessionOptions = { @@ -36,10 +37,15 @@ export const useFindSessionQuery = ( return useQuery({ enabled, queryFn: async () => { - const response = await axios.get('/v1/sessions/', { + const response = await axios.get('/v1/sessions', { params }); - return $SessionWithUser.array().parseAsync(response.data); + const parsedData = $SessionWithUser.array().safeParseAsync(response.data); + if ((await parsedData).error) { + console.log((await parsedData).error); + throw new Error(`cant find data`); + } + return (await parsedData).data; }, queryKey: ['sessions', ...Object.values(params)] }); diff --git a/apps/web/src/hooks/useInstrumentVisualization.ts b/apps/web/src/hooks/useInstrumentVisualization.ts index 6a98a4206..ef932b86d 100644 --- a/apps/web/src/hooks/useInstrumentVisualization.ts +++ b/apps/web/src/hooks/useInstrumentVisualization.ts @@ -13,7 +13,7 @@ import { useInstrumentRecords } from '@/hooks/useInstrumentRecords'; import { useAppStore } from '@/store'; import { downloadSubjectTableExcel } from '@/utils/excel'; -import { sessionInfo, useFindSessionQuery } from './useFindSessionQuery'; +import { useFindSessionQuery } from './useFindSessionQuery'; import { userInfo } from './useFindUser'; type InstrumentVisualizationRecord = { @@ -57,12 +57,12 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio } }); - // const sessionsUsernameQuery = useFindSessionQuery({ - // enabled: instrumentId !== null, - // params: { - // groupId: currentGroup?.id - // } - // }); + const sessionsUsernameQuery = useFindSessionQuery({ + enabled: instrumentId !== null, + params: { + groupId: currentGroup?.id + } + }); // Create a new sessionsUsernameQuery which uses the useFindSessionQuery hook // have use a different return type with @@ -222,24 +222,16 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio let cancelled = false; const fetchRecords = async () => { try { - if (recordsQuery.data) { + const sessions = await sessionsUsernameQuery.data; + if (recordsQuery.data && sessions) { // Fetch all sessions in parallel - const sessionPromises = recordsQuery.data.map((record) => sessionInfo(record.sessionId)); - const sessions = await Promise.all(sessionPromises); - - // Extract unique userIds and fetch users in parallel - const userIds = [...new Set(sessions.filter((s) => s?.userId).map((s) => s.userId))]; - - //assume userId exists in userId set as we already filtered out the non-existing userIds - const userPromises = userIds.map((userId) => userInfo(userId!).catch(() => null)); - const users = await Promise.all(userPromises); - const userMap = new Map(users.filter((u) => u).map((u) => [u!.id, u!.username])); // Build records with looked-up data const records: InstrumentVisualizationRecord[] = recordsQuery.data.map((record, i) => { const props = record.data && typeof record.data === 'object' ? record.data : {}; - const session = sessions[i]; - const username = session?.userId ? (userMap.get(session.userId) ?? 'N/A') : 'N/A'; + const usersSessions = sessions.filter((s) => s.id === record.sessionId); + const session = usersSessions[0]; + const username = session?.user?.username ?? 'N/A'; return { __date__: record.date, diff --git a/packages/schemas/src/session/session.ts b/packages/schemas/src/session/session.ts index 20d2a83b8..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() @@ -27,9 +27,11 @@ export const $CreateSessionData = z.object({ export type SessionWithUser = z.infer; export const $SessionWithUser = $Session.extend({ - user: z.object({ - username: z.string().nullish() - }) + user: z + .object({ + username: z.string().nullish() + }) + .nullable() }); export type SessionWithUserQueryParams = { From 490c3737d7eb50ef929743a40cb7e9d3c580c561 Mon Sep 17 00:00:00 2001 From: David Roper Date: Fri, 21 Nov 2025 10:54:19 -0500 Subject: [PATCH 43/92] feat: cleanup unused sessionInfo method, resolve prettier issues --- apps/api/src/sessions/sessions.service.ts | 4 ++-- apps/web/src/hooks/useFindSessionQuery.ts | 19 ++----------------- .../src/hooks/useInstrumentVisualization.ts | 7 +++---- 3 files changed, 7 insertions(+), 23 deletions(-) diff --git a/apps/api/src/sessions/sessions.service.ts b/apps/api/src/sessions/sessions.service.ts index d3bc19a73..5c406e97c 100644 --- a/apps/api/src/sessions/sessions.service.ts +++ b/apps/api/src/sessions/sessions.service.ts @@ -99,12 +99,12 @@ export class SessionsService { async findAllIncludeUsernames(groupId?: string, { ability }: EntityOperationOptions = {}) { const sessionsWithUsers = await this.sessionModel.findMany({ include: { + subject: true, user: { select: { username: true } - }, - subject: true + } }, where: { AND: [accessibleQuery(ability, 'read', 'Session'), { groupId }] diff --git a/apps/web/src/hooks/useFindSessionQuery.ts b/apps/web/src/hooks/useFindSessionQuery.ts index be00e51d2..782cfb7ab 100644 --- a/apps/web/src/hooks/useFindSessionQuery.ts +++ b/apps/web/src/hooks/useFindSessionQuery.ts @@ -1,5 +1,5 @@ -import { $Session, $SessionWithUser } from '@opendatacapture/schemas/session'; -import type { Session, SessionWithUserQueryParams } from '@opendatacapture/schemas/session'; +import { $SessionWithUser } from '@opendatacapture/schemas/session'; +import type { SessionWithUserQueryParams } from '@opendatacapture/schemas/session'; import { useQuery } from '@tanstack/react-query'; import axios from 'axios'; @@ -14,20 +14,6 @@ type UseSessionOptions = { params: SessionWithUserQueryParams; }; -// export const sessionInfo = async (sessionId: string): Promise => { -// try { -// const response = await axios.get(`/v1/sessions/${encodeURIComponent(sessionId)}`); -// const parsedData = $Session.safeParse(response.data) -// if(!parsedData.success){ -// throw new Error(parsedData.error.message); -// } -// return parsedData.data; -// } catch (error) { -// console.error('Error fetching session:', error); -// throw error; -// } -// }; - export const useFindSessionQuery = ( { enabled, params }: UseSessionOptions = { enabled: true, @@ -42,7 +28,6 @@ export const useFindSessionQuery = ( }); const parsedData = $SessionWithUser.array().safeParseAsync(response.data); if ((await parsedData).error) { - console.log((await parsedData).error); throw new Error(`cant find data`); } return (await parsedData).data; diff --git a/apps/web/src/hooks/useInstrumentVisualization.ts b/apps/web/src/hooks/useInstrumentVisualization.ts index ef932b86d..e7910e6a4 100644 --- a/apps/web/src/hooks/useInstrumentVisualization.ts +++ b/apps/web/src/hooks/useInstrumentVisualization.ts @@ -14,7 +14,6 @@ import { useAppStore } from '@/store'; import { downloadSubjectTableExcel } from '@/utils/excel'; import { useFindSessionQuery } from './useFindSessionQuery'; -import { userInfo } from './useFindUser'; type InstrumentVisualizationRecord = { [key: string]: unknown; @@ -220,14 +219,14 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio useEffect(() => { let cancelled = false; - const fetchRecords = async () => { + const fetchRecords = () => { try { - const sessions = await sessionsUsernameQuery.data; + 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, i) => { + const records: InstrumentVisualizationRecord[] = recordsQuery.data.map((record) => { const props = record.data && typeof record.data === 'object' ? record.data : {}; const usersSessions = sessions.filter((s) => s.id === record.sessionId); const session = usersSessions[0]; From 2b4e691f5af79d5409de51811a3cbd4df342148f Mon Sep 17 00:00:00 2001 From: David Roper Date: Fri, 21 Nov 2025 11:05:57 -0500 Subject: [PATCH 44/92] test: update test mocks --- .../useInstrumentVisualization.test.ts | 24 +++++++++---------- .../src/hooks/useInstrumentVisualization.ts | 8 +------ 2 files changed, 12 insertions(+), 20 deletions(-) diff --git a/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts b/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts index db12e8427..697ccfe7c 100644 --- a/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts +++ b/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts @@ -38,13 +38,15 @@ const mockInstrumentRecords = { ] }; -const mockSession = { - userId: '111' -}; - -const mockUser = { - id: '111', - username: 'testusername' +const mockSessionWithUsername = { + data: [ + { + id: '123', + user: { + username: 'testusername' + } + } + ] }; vi.mock('@/hooks/useInstrument', () => ({ @@ -73,12 +75,8 @@ vi.mock('@/hooks/useInstrumentRecords', () => ({ useInstrumentRecords: () => mockInstrumentRecords })); -vi.mock('@/hooks/useFindSession', () => ({ - sessionInfo: () => Promise.resolve(mockSession) -})); - -vi.mock('@/hooks/useFindUser', () => ({ - userInfo: () => Promise.resolve(mockUser) +vi.mock('@/hooks/useFindSessionQuery', () => ({ + useFindSessionQuery: () => mockSessionWithUsername })); describe('useInstrumentVisualization', () => { diff --git a/apps/web/src/hooks/useInstrumentVisualization.ts b/apps/web/src/hooks/useInstrumentVisualization.ts index e7910e6a4..f2b63fac2 100644 --- a/apps/web/src/hooks/useInstrumentVisualization.ts +++ b/apps/web/src/hooks/useInstrumentVisualization.ts @@ -218,7 +218,6 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio }; useEffect(() => { - let cancelled = false; const fetchRecords = () => { try { const sessions = sessionsUsernameQuery.data; @@ -241,9 +240,7 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio }; }); - if (!cancelled) { - setRecords(records); - } + setRecords(records); } } catch (error) { console.error('Error occurred: ', error); @@ -257,9 +254,6 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio } }; void fetchRecords(); - return () => { - cancelled = true; - }; }, [recordsQuery.data]); const instrumentOptions: { [key: string]: string } = useMemo(() => { From abd0dddecb95f539f2aaa0c68c4f2e6c0077fc11 Mon Sep 17 00:00:00 2001 From: David Roper Date: Fri, 21 Nov 2025 11:15:38 -0500 Subject: [PATCH 45/92] feat: user find method instead of filter to get 1 unique userSession --- apps/api/src/sessions/sessions.controller.ts | 2 +- apps/web/src/hooks/useInstrumentVisualization.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/api/src/sessions/sessions.controller.ts b/apps/api/src/sessions/sessions.controller.ts index 0b9e50773..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, 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'; @@ -8,7 +9,6 @@ import { RouteAccess } from '@/core/decorators/route-access.decorator'; import { CreateSessionDto } from './dto/create-session.dto'; import { SessionsService } from './sessions.service'; -import type { SessionWithUser } from '@opendatacapture/schemas/session'; @Controller('sessions') export class SessionsController { diff --git a/apps/web/src/hooks/useInstrumentVisualization.ts b/apps/web/src/hooks/useInstrumentVisualization.ts index f2b63fac2..4fc1e72bc 100644 --- a/apps/web/src/hooks/useInstrumentVisualization.ts +++ b/apps/web/src/hooks/useInstrumentVisualization.ts @@ -227,9 +227,9 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio // Build records with looked-up data const records: InstrumentVisualizationRecord[] = recordsQuery.data.map((record) => { const props = record.data && typeof record.data === 'object' ? record.data : {}; - const usersSessions = sessions.filter((s) => s.id === record.sessionId); - const session = usersSessions[0]; - const username = session?.user?.username ?? 'N/A'; + const usersSession = sessions.find((s) => s.id === record.sessionId); + + const username = usersSession?.user?.username ?? 'N/A'; return { __date__: record.date, From a5f0b7103b411a15e4f0fb67e2c14e167dccb901 Mon Sep 17 00:00:00 2001 From: David Roper Date: Fri, 21 Nov 2025 11:16:33 -0500 Subject: [PATCH 46/92] fix: adding ! to currentSession subject mentions at they should always contain a subject --- apps/web/src/components/Sidebar/Sidebar.tsx | 4 ++-- apps/web/src/routes/_app/instruments/render/$id.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) 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/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 = () => {
From ddf1ac2db7bd86616b41c3465c141b18fa3c971a Mon Sep 17 00:00:00 2001 From: david-roper Date: Fri, 24 Oct 2025 16:57:26 -0400 Subject: [PATCH 47/92] feat: create useFindSession Hook --- apps/web/src/hooks/useFindSession.ts | 30 ++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 apps/web/src/hooks/useFindSession.ts diff --git a/apps/web/src/hooks/useFindSession.ts b/apps/web/src/hooks/useFindSession.ts new file mode 100644 index 000000000..b072e5cfb --- /dev/null +++ b/apps/web/src/hooks/useFindSession.ts @@ -0,0 +1,30 @@ +import { reviver } from '@douglasneuroinformatics/libjs'; +import { $Session } from '@opendatacapture/schemas/session'; +import { useQuery } from '@tanstack/react-query'; +import axios from 'axios'; + +type UseSessionOptions = { + enabled?: boolean; + params: { + id?: string; + }; +}; + +export const useFindSession = ( + { enabled, params }: UseSessionOptions = { + enabled: true, + params: {} + } +) => { + return useQuery({ + enabled, + queryFn: async () => { + const response = await axios.get('/v1/Sessions', { + params, + transformResponse: [(data: string) => JSON.parse(data, reviver) as unknown] + }); + return $Session.array().parseAsync(response.data); + }, + queryKey: ['sessions', ...Object.values(params)] + }); +}; From 4e86becbc6031966fe309a5183aceb92cf34ff31 Mon Sep 17 00:00:00 2001 From: david-roper Date: Mon, 27 Oct 2025 13:08:49 -0400 Subject: [PATCH 48/92] feat: make useSession hook return one session instead of array --- apps/web/src/hooks/useFindSession.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/hooks/useFindSession.ts b/apps/web/src/hooks/useFindSession.ts index b072e5cfb..8b5e5f5b3 100644 --- a/apps/web/src/hooks/useFindSession.ts +++ b/apps/web/src/hooks/useFindSession.ts @@ -23,7 +23,7 @@ export const useFindSession = ( params, transformResponse: [(data: string) => JSON.parse(data, reviver) as unknown] }); - return $Session.array().parseAsync(response.data); + return $Session.parseAsync(response.data); }, queryKey: ['sessions', ...Object.values(params)] }); From 12a49c4ae9b8d601c1b6315f20c8b0440adcf551 Mon Sep 17 00:00:00 2001 From: David Roper Date: Mon, 27 Oct 2025 14:52:05 -0400 Subject: [PATCH 49/92] feat: add hook to return list of user ids --- apps/web/src/hooks/useInstrumentVisualization.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/apps/web/src/hooks/useInstrumentVisualization.ts b/apps/web/src/hooks/useInstrumentVisualization.ts index 8246a7475..8e6cde440 100644 --- a/apps/web/src/hooks/useInstrumentVisualization.ts +++ b/apps/web/src/hooks/useInstrumentVisualization.ts @@ -12,6 +12,7 @@ import { useInstrumentInfoQuery } from '@/hooks/useInstrumentInfoQuery'; import { useInstrumentRecords } from '@/hooks/useInstrumentRecords'; import { useAppStore } from '@/store'; import { downloadSubjectTableExcel } from '@/utils/excel'; +import { useFindSession } from './useFindSession'; type InstrumentVisualizationRecord = { [key: string]: unknown; @@ -54,6 +55,19 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio } }); + const usersQuery = recordsQuery.data?.map((item) => { + const sessionInfo = useFindSession({ + enabled: true, + params: { id: item.sessionId } + }); + if (sessionInfo.data) { + return sessionInfo.data.userId; + } + return 'N/A'; + }); + + console.log(usersQuery); + const dl = (option: 'CSV' | 'CSV Long' | 'Excel' | 'Excel Long' | 'JSON' | 'TSV' | 'TSV Long') => { if (!instrument) { notifications.addNotification({ message: t('errors.noInstrumentSelected'), type: 'error' }); From 0267288e7c39434b965d3ca8eaa27fa4870a01cf Mon Sep 17 00:00:00 2001 From: david-roper Date: Mon, 27 Oct 2025 17:11:44 -0400 Subject: [PATCH 50/92] feat: new api reqs for finding sessions --- apps/api/src/sessions/sessions.controller.ts | 8 ++++++++ apps/api/src/sessions/sessions.service.ts | 15 +++++++++++++++ apps/web/src/hooks/useInstrumentVisualization.ts | 14 -------------- 3 files changed, 23 insertions(+), 14 deletions(-) diff --git a/apps/api/src/sessions/sessions.controller.ts b/apps/api/src/sessions/sessions.controller.ts index 47791d576..ef4fec5a5 100644 --- a/apps/api/src/sessions/sessions.controller.ts +++ b/apps/api/src/sessions/sessions.controller.ts @@ -26,4 +26,12 @@ export class SessionsController { findByID(@Param('id') id: string, @CurrentUser('ability') ability: AppAbility): Promise { return this.sessionsService.findById(id, { ability }); } + + @ApiOperation({ description: 'Find Session by ID' }) + @Post('list') + @RouteAccess({ action: 'read', subject: 'Session' }) + findSessionList(@Query('ids') ids: string[]): Promise { + const idArray = Array.isArray(ids) ? ids : (ids as string).split(','); + return this.sessionsService.findSessionList(idArray); + } } diff --git a/apps/api/src/sessions/sessions.service.ts b/apps/api/src/sessions/sessions.service.ts index 3d01b0fe1..ee84a55a7 100644 --- a/apps/api/src/sessions/sessions.service.ts +++ b/apps/api/src/sessions/sessions.service.ts @@ -104,6 +104,21 @@ export class SessionsService { return session; } + async findSessionList(ids: string[], { ability }: EntityOperationOptions = {}) { + const sessionsArray = await Promise.all( + ids.map(async (id) => { + const session = await this.sessionModel.findFirst({ + where: { AND: [accessibleQuery(ability, 'read', 'Session')], id } + }); + if (!session) { + throw new NotFoundException(`Failed to find session with ID: ${id}`); + } + return session; + }) + ); + return sessionsArray; + } + /** Get the subject if they exist, otherwise create them */ private async resolveSubject(subjectData: CreateSubjectData) { this.loggingService.debug({ message: 'Attempting to resolve subject', subjectData }); diff --git a/apps/web/src/hooks/useInstrumentVisualization.ts b/apps/web/src/hooks/useInstrumentVisualization.ts index 8e6cde440..8246a7475 100644 --- a/apps/web/src/hooks/useInstrumentVisualization.ts +++ b/apps/web/src/hooks/useInstrumentVisualization.ts @@ -12,7 +12,6 @@ import { useInstrumentInfoQuery } from '@/hooks/useInstrumentInfoQuery'; import { useInstrumentRecords } from '@/hooks/useInstrumentRecords'; import { useAppStore } from '@/store'; import { downloadSubjectTableExcel } from '@/utils/excel'; -import { useFindSession } from './useFindSession'; type InstrumentVisualizationRecord = { [key: string]: unknown; @@ -55,19 +54,6 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio } }); - const usersQuery = recordsQuery.data?.map((item) => { - const sessionInfo = useFindSession({ - enabled: true, - params: { id: item.sessionId } - }); - if (sessionInfo.data) { - return sessionInfo.data.userId; - } - return 'N/A'; - }); - - console.log(usersQuery); - const dl = (option: 'CSV' | 'CSV Long' | 'Excel' | 'Excel Long' | 'JSON' | 'TSV' | 'TSV Long') => { if (!instrument) { notifications.addNotification({ message: t('errors.noInstrumentSelected'), type: 'error' }); From 017cdf56e208c7adcec14646bf0420e78eace827 Mon Sep 17 00:00:00 2001 From: David Roper Date: Thu, 6 Nov 2025 14:41:29 -0500 Subject: [PATCH 51/92] feat: add query from nest --- apps/api/src/sessions/sessions.controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/src/sessions/sessions.controller.ts b/apps/api/src/sessions/sessions.controller.ts index ef4fec5a5..34d9f40ed 100644 --- a/apps/api/src/sessions/sessions.controller.ts +++ b/apps/api/src/sessions/sessions.controller.ts @@ -1,5 +1,5 @@ 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 { Session } from '@prisma/client'; From e4a6175f885cdaffb61ebc2a18034dadfe7353fb Mon Sep 17 00:00:00 2001 From: David Roper Date: Thu, 6 Nov 2025 16:41:43 -0500 Subject: [PATCH 52/92] feat: add userinfo method --- .../src/hooks/useInstrumentVisualization.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/apps/web/src/hooks/useInstrumentVisualization.ts b/apps/web/src/hooks/useInstrumentVisualization.ts index 8246a7475..ba1f88cce 100644 --- a/apps/web/src/hooks/useInstrumentVisualization.ts +++ b/apps/web/src/hooks/useInstrumentVisualization.ts @@ -4,6 +4,7 @@ import { toBasicISOString } from '@douglasneuroinformatics/libjs'; import { useDownload, useNotificationsStore, useTranslation } from '@douglasneuroinformatics/libui/hooks'; import type { AnyUnilingualScalarInstrument, InstrumentKind } from '@opendatacapture/runtime-core'; import { removeSubjectIdScope } from '@opendatacapture/subject-utils'; +import axios from 'axios'; import { omit } from 'lodash-es'; import { unparse } from 'papaparse'; @@ -12,6 +13,7 @@ import { useInstrumentInfoQuery } from '@/hooks/useInstrumentInfoQuery'; import { useInstrumentRecords } from '@/hooks/useInstrumentRecords'; import { useAppStore } from '@/store'; import { downloadSubjectTableExcel } from '@/utils/excel'; +import type { Session } from '@opendatacapture/schemas/session'; type InstrumentVisualizationRecord = { [key: string]: unknown; @@ -54,6 +56,21 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio } }); + const userInfo = async (sessionId: string) => { + const userData = await axios + .get(`/v1/sessions/${sessionId}`) + .then(function (response) { + if (response.data) { + return response.data as Session; + } + return null; + }) + .catch(function (error) { + console.error('Error fetching users:', error); + }); + return userData; + }; + const dl = (option: 'CSV' | 'CSV Long' | 'Excel' | 'Excel Long' | 'JSON' | 'TSV' | 'TSV Long') => { if (!instrument) { notifications.addNotification({ message: t('errors.noInstrumentSelected'), type: 'error' }); @@ -199,6 +216,7 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio const records: InstrumentVisualizationRecord[] = []; for (const record of recordsQuery.data) { const props = record.data && typeof record.data === 'object' ? record.data : {}; + const userData = userInfo(record.sessionId); records.push({ __date__: record.date, __time__: record.date.getTime(), From 9fda50350aee5cc071309f5426c1f638a05e4cd2 Mon Sep 17 00:00:00 2001 From: david-roper Date: Mon, 10 Nov 2025 14:13:38 -0500 Subject: [PATCH 53/92] refactor: remove unused useFindSession hook --- apps/web/src/hooks/useFindSession.ts | 30 ---------------------------- 1 file changed, 30 deletions(-) delete mode 100644 apps/web/src/hooks/useFindSession.ts diff --git a/apps/web/src/hooks/useFindSession.ts b/apps/web/src/hooks/useFindSession.ts deleted file mode 100644 index 8b5e5f5b3..000000000 --- a/apps/web/src/hooks/useFindSession.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { reviver } from '@douglasneuroinformatics/libjs'; -import { $Session } from '@opendatacapture/schemas/session'; -import { useQuery } from '@tanstack/react-query'; -import axios from 'axios'; - -type UseSessionOptions = { - enabled?: boolean; - params: { - id?: string; - }; -}; - -export const useFindSession = ( - { enabled, params }: UseSessionOptions = { - enabled: true, - params: {} - } -) => { - return useQuery({ - enabled, - queryFn: async () => { - const response = await axios.get('/v1/Sessions', { - params, - transformResponse: [(data: string) => JSON.parse(data, reviver) as unknown] - }); - return $Session.parseAsync(response.data); - }, - queryKey: ['sessions', ...Object.values(params)] - }); -}; From 2d5cf94988e0830db3eaae9ee63a90ed4558cee7 Mon Sep 17 00:00:00 2001 From: david-roper Date: Mon, 10 Nov 2025 14:41:12 -0500 Subject: [PATCH 54/92] feat: add userInfo call to useEffect to get userId from the session --- .../src/hooks/useInstrumentVisualization.ts | 67 +++++++++++-------- 1 file changed, 40 insertions(+), 27 deletions(-) diff --git a/apps/web/src/hooks/useInstrumentVisualization.ts b/apps/web/src/hooks/useInstrumentVisualization.ts index ba1f88cce..e99e3daee 100644 --- a/apps/web/src/hooks/useInstrumentVisualization.ts +++ b/apps/web/src/hooks/useInstrumentVisualization.ts @@ -3,6 +3,7 @@ import { useEffect, useMemo, useState } from 'react'; import { toBasicISOString } from '@douglasneuroinformatics/libjs'; import { useDownload, useNotificationsStore, useTranslation } from '@douglasneuroinformatics/libui/hooks'; import type { AnyUnilingualScalarInstrument, InstrumentKind } from '@opendatacapture/runtime-core'; +import type { Session } from '@opendatacapture/schemas/session'; import { removeSubjectIdScope } from '@opendatacapture/subject-utils'; import axios from 'axios'; import { omit } from 'lodash-es'; @@ -13,7 +14,6 @@ import { useInstrumentInfoQuery } from '@/hooks/useInstrumentInfoQuery'; import { useInstrumentRecords } from '@/hooks/useInstrumentRecords'; import { useAppStore } from '@/store'; import { downloadSubjectTableExcel } from '@/utils/excel'; -import type { Session } from '@opendatacapture/schemas/session'; type InstrumentVisualizationRecord = { [key: string]: unknown; @@ -56,19 +56,14 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio } }); - const userInfo = async (sessionId: string) => { - const userData = await axios - .get(`/v1/sessions/${sessionId}`) - .then(function (response) { - if (response.data) { - return response.data as Session; - } - return null; - }) - .catch(function (error) { - console.error('Error fetching users:', error); - }); - return userData; + const userInfo = async (sessionId: string): Promise => { + try { + const response = await axios.get(`/v1/sessions/${sessionId}`); + return response.data ? (response.data as Session) : null; + } catch (error) { + console.error('Error fetching user:', error); + return null; // ensures a resolved value instead of `void` + } }; const dl = (option: 'CSV' | 'CSV Long' | 'Excel' | 'Excel Long' | 'JSON' | 'TSV' | 'TSV Long') => { @@ -212,20 +207,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 : {}; - const userData = userInfo(record.sessionId); - records.push({ - __date__: record.date, - __time__: record.date.getTime(), - ...record.computedMeasures, - ...props - }); + const fetchRecords = async () => { + if (recordsQuery.data) { + const records: InstrumentVisualizationRecord[] = []; + + for (const record of recordsQuery.data) { + const props = record.data && typeof record.data === 'object' ? record.data : {}; + + const userData = await userInfo(record.sessionId); + if (userData?.userId) { + // safely check since userData can be null + records.push({ + __date__: record.date, + __time__: record.date.getTime(), + username: userData.userId, + ...record.computedMeasures, + ...props + }); + continue; + } + records.push({ + __date__: record.date, + __time__: record.date.getTime(), + username: 'N/A', + ...record.computedMeasures, + ...props + }); + } + + setRecords(records); } - setRecords(records); - } + }; + void fetchRecords(); }, [recordsQuery.data]); const instrumentOptions: { [key: string]: string } = useMemo(() => { From b5664b19d427a588c64e5111555fe9ec1d5f14a9 Mon Sep 17 00:00:00 2001 From: david-roper Date: Mon, 10 Nov 2025 15:24:22 -0500 Subject: [PATCH 55/92] chore: rename user id column --- apps/web/src/hooks/useInstrumentVisualization.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/hooks/useInstrumentVisualization.ts b/apps/web/src/hooks/useInstrumentVisualization.ts index e99e3daee..98d0aa114 100644 --- a/apps/web/src/hooks/useInstrumentVisualization.ts +++ b/apps/web/src/hooks/useInstrumentVisualization.ts @@ -220,7 +220,7 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio records.push({ __date__: record.date, __time__: record.date.getTime(), - username: userData.userId, + userId: userData.userId, ...record.computedMeasures, ...props }); @@ -229,7 +229,7 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio records.push({ __date__: record.date, __time__: record.date.getTime(), - username: 'N/A', + userId: 'N/A', ...record.computedMeasures, ...props }); From 350964d5f09696c89cf3c634416b614872f57309 Mon Sep 17 00:00:00 2001 From: David Roper Date: Wed, 12 Nov 2025 10:02:27 -0500 Subject: [PATCH 56/92] feat: collect username with user api call --- .../src/hooks/useInstrumentVisualization.ts | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/apps/web/src/hooks/useInstrumentVisualization.ts b/apps/web/src/hooks/useInstrumentVisualization.ts index 98d0aa114..b4a99bb60 100644 --- a/apps/web/src/hooks/useInstrumentVisualization.ts +++ b/apps/web/src/hooks/useInstrumentVisualization.ts @@ -4,6 +4,7 @@ import { toBasicISOString } from '@douglasneuroinformatics/libjs'; import { useDownload, useNotificationsStore, useTranslation } from '@douglasneuroinformatics/libui/hooks'; import type { AnyUnilingualScalarInstrument, InstrumentKind } from '@opendatacapture/runtime-core'; import type { Session } from '@opendatacapture/schemas/session'; +import type { User } from '@opendatacapture/schemas/user'; import { removeSubjectIdScope } from '@opendatacapture/subject-utils'; import axios from 'axios'; import { omit } from 'lodash-es'; @@ -56,7 +57,7 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio } }); - const userInfo = async (sessionId: string): Promise => { + const sessionInfo = async (sessionId: string): Promise => { try { const response = await axios.get(`/v1/sessions/${sessionId}`); return response.data ? (response.data as Session) : null; @@ -66,6 +67,16 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio } }; + const userInfo = async (userId: string): Promise => { + try { + const response = await axios.get(`/v1/users/${userId}`); + return response.data ? (response.data as User) : null; + } catch (error) { + console.error('Error fetching user:', error); + return null; // ensures a resolved value instead of `void` + } + }; + const dl = (option: 'CSV' | 'CSV Long' | 'Excel' | 'Excel Long' | 'JSON' | 'TSV' | 'TSV Long') => { if (!instrument) { notifications.addNotification({ message: t('errors.noInstrumentSelected'), type: 'error' }); @@ -214,22 +225,25 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio for (const record of recordsQuery.data) { const props = record.data && typeof record.data === 'object' ? record.data : {}; - const userData = await userInfo(record.sessionId); - if (userData?.userId) { - // safely check since userData can be null + const sessionData = await sessionInfo(record.sessionId); + + if (!sessionData?.userId) { records.push({ __date__: record.date, __time__: record.date.getTime(), - userId: userData.userId, + userId: 'N/A', ...record.computedMeasures, ...props }); continue; } + + const userData = await userInfo(sessionData.userId); + // safely check since userData can be null records.push({ __date__: record.date, __time__: record.date.getTime(), - userId: 'N/A', + userId: userData?.username ?? 'N/A', ...record.computedMeasures, ...props }); From 9a505d11714176544bbc85409d92f1cfe15108b3 Mon Sep 17 00:00:00 2001 From: David Roper Date: Wed, 12 Nov 2025 16:28:58 -0500 Subject: [PATCH 57/92] refactor: move session and user api methods to separate hook files --- apps/web/src/hooks/useFindSession.ts | 12 +++++++++ apps/web/src/hooks/useFindUser.ts | 12 +++++++++ .../src/hooks/useInstrumentVisualization.ts | 26 +++---------------- 3 files changed, 27 insertions(+), 23 deletions(-) create mode 100644 apps/web/src/hooks/useFindSession.ts create mode 100644 apps/web/src/hooks/useFindUser.ts diff --git a/apps/web/src/hooks/useFindSession.ts b/apps/web/src/hooks/useFindSession.ts new file mode 100644 index 000000000..e3ae1592c --- /dev/null +++ b/apps/web/src/hooks/useFindSession.ts @@ -0,0 +1,12 @@ +import type { Session } from '@opendatacapture/schemas/session'; +import axios from 'axios'; + +export const sessionInfo = async (sessionId: string): Promise => { + try { + const response = await axios.get(`/v1/sessions/${sessionId}`); + return response.data ? (response.data as Session) : null; + } catch (error) { + console.error('Error fetching user:', error); + return null; // ensures a resolved value instead of `void` + } +}; diff --git a/apps/web/src/hooks/useFindUser.ts b/apps/web/src/hooks/useFindUser.ts new file mode 100644 index 000000000..a3c13dcab --- /dev/null +++ b/apps/web/src/hooks/useFindUser.ts @@ -0,0 +1,12 @@ +import type { User } from '@opendatacapture/schemas/user'; +import axios from 'axios'; + +export const userInfo = async (userId: string): Promise => { + try { + const response = await axios.get(`/v1/users/${userId}`); + return response.data ? (response.data as User) : null; + } catch (error) { + console.error('Error fetching user:', error); + return null; // ensures a resolved value instead of `void` + } +}; diff --git a/apps/web/src/hooks/useInstrumentVisualization.ts b/apps/web/src/hooks/useInstrumentVisualization.ts index b4a99bb60..635430ddb 100644 --- a/apps/web/src/hooks/useInstrumentVisualization.ts +++ b/apps/web/src/hooks/useInstrumentVisualization.ts @@ -3,10 +3,7 @@ import { useEffect, useMemo, useState } from 'react'; import { toBasicISOString } from '@douglasneuroinformatics/libjs'; import { useDownload, useNotificationsStore, useTranslation } from '@douglasneuroinformatics/libui/hooks'; import type { AnyUnilingualScalarInstrument, InstrumentKind } from '@opendatacapture/runtime-core'; -import type { Session } from '@opendatacapture/schemas/session'; -import type { User } from '@opendatacapture/schemas/user'; import { removeSubjectIdScope } from '@opendatacapture/subject-utils'; -import axios from 'axios'; import { omit } from 'lodash-es'; import { unparse } from 'papaparse'; @@ -16,6 +13,9 @@ import { useInstrumentRecords } from '@/hooks/useInstrumentRecords'; import { useAppStore } from '@/store'; import { downloadSubjectTableExcel } from '@/utils/excel'; +import { sessionInfo } from './useFindSession'; +import { userInfo } from './useFindUser'; + type InstrumentVisualizationRecord = { [key: string]: unknown; __date__: Date; @@ -57,26 +57,6 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio } }); - const sessionInfo = async (sessionId: string): Promise => { - try { - const response = await axios.get(`/v1/sessions/${sessionId}`); - return response.data ? (response.data as Session) : null; - } catch (error) { - console.error('Error fetching user:', error); - return null; // ensures a resolved value instead of `void` - } - }; - - const userInfo = async (userId: string): Promise => { - try { - const response = await axios.get(`/v1/users/${userId}`); - return response.data ? (response.data as User) : null; - } catch (error) { - console.error('Error fetching user:', error); - return null; // ensures a resolved value instead of `void` - } - }; - const dl = (option: 'CSV' | 'CSV Long' | 'Excel' | 'Excel Long' | 'JSON' | 'TSV' | 'TSV Long') => { if (!instrument) { notifications.addNotification({ message: t('errors.noInstrumentSelected'), type: 'error' }); From 8daaf56d04bc7ce69a81ce33b52bc27047dc838e Mon Sep 17 00:00:00 2001 From: David Roper Date: Wed, 12 Nov 2025 16:29:27 -0500 Subject: [PATCH 58/92] feat: create mocks for findUser and findSession hooks --- .../__tests__/useInstrumentVisualization.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts b/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts index bfa1e7478..d1be55b24 100644 --- a/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts +++ b/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts @@ -37,6 +37,14 @@ const mockInstrumentRecords = { ] }; +const mockSession = { + sessionId: 123 +}; + +const mockUser = { + username: 'testusername' +}; + vi.mock('@/hooks/useInstrument', () => ({ useInstrument: mockUseInstrument })); @@ -63,6 +71,14 @@ vi.mock('@/hooks/useInstrumentRecords', () => ({ useInstrumentRecords: () => mockInstrumentRecords })); +vi.mock('@/hooks/useFindSession', () => ({ + sessionInfo: () => mockSession +})); + +vi.mock('@/hooks/useFindUser', () => ({ + userInfo: () => mockUser +})); + describe('useInstrumentVisualization', () => { beforeEach(() => { vi.clearAllMocks(); From 78717a1791027bd5f5d6be7bce7be5daae1d69da Mon Sep 17 00:00:00 2001 From: David Roper Date: Thu, 13 Nov 2025 11:52:13 -0500 Subject: [PATCH 59/92] test: fix use tests with wait for methods --- .../useInstrumentVisualization.test.ts | 82 +++++++++++++------ 1 file changed, 56 insertions(+), 26 deletions(-) diff --git a/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts b/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts index d1be55b24..4a4829cd4 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,13 +32,14 @@ const mockInstrumentRecords = { { computedMeasures: {}, data: { someValue: 'abc' }, - date: FIXED_TEST_DATE + date: FIXED_TEST_DATE, + sessionId: '123' } ] }; const mockSession = { - sessionId: 123 + userId: '111' }; const mockUser = { @@ -85,9 +86,12 @@ describe('useInstrumentVisualization', () => { }); 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); @@ -95,30 +99,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,userId,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\tuserId\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); @@ -126,15 +136,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,Value,Variable\r\ntestGroupId,${toBasicISOString(FIXED_TEST_DATE)},testId,testusername,userId\r\ntestGroupId,${toBasicISOString(FIXED_TEST_DATE)},testId,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); @@ -142,15 +155,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\tValue\tVariable\r\ntestGroupId\t${toBasicISOString(FIXED_TEST_DATE)}\ttestId\ttestusername\tuserId\r\ntestGroupId\t${toBasicISOString(FIXED_TEST_DATE)}\ttestId\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] ?? []; @@ -163,16 +179,20 @@ describe('useInstrumentVisualization', () => { subjectId: 'testId', // eslint-disable-next-line perfectionist/sort-objects Date: '2025-04-30', - someValue: 'abc' + someValue: 'abc', + userId: '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); @@ -181,6 +201,13 @@ describe('useInstrumentVisualization', () => { const excelContents = getContentFn; expect(excelContents).toEqual([ + { + Date: '2025-04-30', + GroupID: 'testGroupId', + SubjectID: 'testId', + Value: 'testusername', + Variable: 'userId' + }, { Date: '2025-04-30', GroupID: 'testGroupId', @@ -194,8 +221,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); From 2a3f220a5400575785b2a3ee1a27954f9d149fec Mon Sep 17 00:00:00 2001 From: david-roper Date: Fri, 14 Nov 2025 11:27:23 -0500 Subject: [PATCH 60/92] refactor: make Username a standalone column in long export formats --- apps/web/src/hooks/useInstrumentVisualization.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/web/src/hooks/useInstrumentVisualization.ts b/apps/web/src/hooks/useInstrumentVisualization.ts index 635430ddb..2ba3c0651 100644 --- a/apps/web/src/hooks/useInstrumentVisualization.ts +++ b/apps/web/src/hooks/useInstrumentVisualization.ts @@ -96,12 +96,17 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio exportRecords.forEach((item) => { let date: Date; + let username: string; Object.entries(item).forEach(([objKey, objVal]) => { if (objKey === '__date__') { date = objVal as Date; return; } + if (objKey === 'userId') { + username = objVal as string; + return; + } if (Array.isArray(objVal)) { objVal.forEach((arrayItem) => { @@ -110,6 +115,7 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio GroupID: currentGroup ? currentGroup.id : 'root', // eslint-disable-next-line perfectionist/sort-objects Date: toBasicISOString(date), + Username: username, SubjectID: removeSubjectIdScope(params.subjectId), Variable: `${objKey}-${arrKey}`, // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, perfectionist/sort-objects @@ -122,6 +128,7 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio GroupID: currentGroup ? currentGroup.id : 'root', // eslint-disable-next-line perfectionist/sort-objects Date: toBasicISOString(date), + Username: username, SubjectID: removeSubjectIdScope(params.subjectId), Value: objVal, Variable: objKey From db573d1f84e134e66d0dc91511f6c250e342b445 Mon Sep 17 00:00:00 2001 From: david-roper Date: Fri, 14 Nov 2025 11:27:47 -0500 Subject: [PATCH 61/92] test: change tests to include username --- .../__tests__/useInstrumentVisualization.test.ts | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts b/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts index 4a4829cd4..5f4920574 100644 --- a/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts +++ b/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts @@ -136,7 +136,7 @@ describe('useInstrumentVisualization', () => { expect(filename).toContain('.csv'); const csvLongContents = getContentFn(); expect(csvLongContents).toMatch( - `GroupID,Date,SubjectID,Value,Variable\r\ntestGroupId,${toBasicISOString(FIXED_TEST_DATE)},testId,testusername,userId\r\ntestGroupId,${toBasicISOString(FIXED_TEST_DATE)},testId,abc,someValue` + `GroupID,Date,Username,SubjectID,Value,Variable\r\ntestGroupId,${toBasicISOString(FIXED_TEST_DATE)},testusername,testId,abc,someValue` ); }); }); @@ -155,7 +155,7 @@ 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\ttestusername\tuserId\r\ntestGroupId\t${toBasicISOString(FIXED_TEST_DATE)}\ttestId\tabc\tsomeValue` + `GroupID\tDate\tUsername\tSubjectID\tValue\tVariable\r\ntestGroupId\t${toBasicISOString(FIXED_TEST_DATE)}\ttestusername\ttestId\tabc\tsomeValue` ); }); }); @@ -205,13 +205,7 @@ describe('useInstrumentVisualization', () => { Date: '2025-04-30', GroupID: 'testGroupId', SubjectID: 'testId', - Value: 'testusername', - Variable: 'userId' - }, - { - Date: '2025-04-30', - GroupID: 'testGroupId', - SubjectID: 'testId', + Username: 'testusername', Value: 'abc', Variable: 'someValue' } From fc319514a6c8398207d643dbcfe12c3f5e6da614 Mon Sep 17 00:00:00 2001 From: david-roper Date: Fri, 14 Nov 2025 15:07:47 -0500 Subject: [PATCH 62/92] chore: small changes to test From 02845cd226671e6552815469014c2f03c9891866 Mon Sep 17 00:00:00 2001 From: david-roper Date: Mon, 17 Nov 2025 11:37:01 -0500 Subject: [PATCH 63/92] fix: fix description in session controller --- apps/api/src/sessions/sessions.controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/src/sessions/sessions.controller.ts b/apps/api/src/sessions/sessions.controller.ts index 34d9f40ed..437cff75b 100644 --- a/apps/api/src/sessions/sessions.controller.ts +++ b/apps/api/src/sessions/sessions.controller.ts @@ -27,7 +27,7 @@ export class SessionsController { return this.sessionsService.findById(id, { ability }); } - @ApiOperation({ description: 'Find Session by ID' }) + @ApiOperation({ description: 'Find Sessions by ID' }) @Post('list') @RouteAccess({ action: 'read', subject: 'Session' }) findSessionList(@Query('ids') ids: string[]): Promise { From e071da86d2d41c4ae3b9a57fab9ff451148c6e8d Mon Sep 17 00:00:00 2001 From: david-roper Date: Mon, 17 Nov 2025 11:38:52 -0500 Subject: [PATCH 64/92] fix: add ability to find sessions list --- apps/api/src/sessions/sessions.controller.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/api/src/sessions/sessions.controller.ts b/apps/api/src/sessions/sessions.controller.ts index 437cff75b..286d340c1 100644 --- a/apps/api/src/sessions/sessions.controller.ts +++ b/apps/api/src/sessions/sessions.controller.ts @@ -30,8 +30,8 @@ export class SessionsController { @ApiOperation({ description: 'Find Sessions by ID' }) @Post('list') @RouteAccess({ action: 'read', subject: 'Session' }) - findSessionList(@Query('ids') ids: string[]): Promise { + findSessionList(@Query('ids') ids: string[], @CurrentUser('ability') ability: AppAbility): Promise { const idArray = Array.isArray(ids) ? ids : (ids as string).split(','); - return this.sessionsService.findSessionList(idArray); + return this.sessionsService.findSessionList(idArray, { ability }); } } From d81b8c5a28d8176598ff743d6b8cd2771272395b Mon Sep 17 00:00:00 2001 From: david-roper Date: Mon, 17 Nov 2025 11:58:21 -0500 Subject: [PATCH 65/92] fix: error msg in usefindsession --- apps/web/src/hooks/useFindSession.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/hooks/useFindSession.ts b/apps/web/src/hooks/useFindSession.ts index e3ae1592c..08d3ad54e 100644 --- a/apps/web/src/hooks/useFindSession.ts +++ b/apps/web/src/hooks/useFindSession.ts @@ -6,7 +6,7 @@ export const sessionInfo = async (sessionId: string): Promise => const response = await axios.get(`/v1/sessions/${sessionId}`); return response.data ? (response.data as Session) : null; } catch (error) { - console.error('Error fetching user:', error); + console.error('Error fetching session:', error); return null; // ensures a resolved value instead of `void` } }; From 0fc8f52d3d7fc0fc922b97dcbe77a998bf95c603 Mon Sep 17 00:00:00 2001 From: david-roper Date: Mon, 17 Nov 2025 14:42:17 -0500 Subject: [PATCH 66/92] fix: throw and error to catch instead of returning null when error occurs --- apps/web/src/hooks/useFindSession.ts | 2 +- apps/web/src/hooks/useFindUser.ts | 2 +- .../src/hooks/useInstrumentVisualization.ts | 46 ++++++++++--------- 3 files changed, 27 insertions(+), 23 deletions(-) diff --git a/apps/web/src/hooks/useFindSession.ts b/apps/web/src/hooks/useFindSession.ts index 08d3ad54e..4232834bb 100644 --- a/apps/web/src/hooks/useFindSession.ts +++ b/apps/web/src/hooks/useFindSession.ts @@ -7,6 +7,6 @@ export const sessionInfo = async (sessionId: string): Promise => return response.data ? (response.data as Session) : null; } catch (error) { console.error('Error fetching session:', error); - return null; // ensures a resolved value instead of `void` + throw error; } }; diff --git a/apps/web/src/hooks/useFindUser.ts b/apps/web/src/hooks/useFindUser.ts index a3c13dcab..686efd1c9 100644 --- a/apps/web/src/hooks/useFindUser.ts +++ b/apps/web/src/hooks/useFindUser.ts @@ -7,6 +7,6 @@ export const userInfo = async (userId: string): Promise => { return response.data ? (response.data as User) : null; } catch (error) { console.error('Error fetching user:', error); - return null; // ensures a resolved value instead of `void` + throw error; } }; diff --git a/apps/web/src/hooks/useInstrumentVisualization.ts b/apps/web/src/hooks/useInstrumentVisualization.ts index 2ba3c0651..e015062f9 100644 --- a/apps/web/src/hooks/useInstrumentVisualization.ts +++ b/apps/web/src/hooks/useInstrumentVisualization.ts @@ -206,37 +206,41 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio useEffect(() => { const fetchRecords = async () => { - if (recordsQuery.data) { - const records: InstrumentVisualizationRecord[] = []; - - for (const record of recordsQuery.data) { - const props = record.data && typeof record.data === 'object' ? record.data : {}; - - const sessionData = await sessionInfo(record.sessionId); + try { + if (recordsQuery.data) { + const records: InstrumentVisualizationRecord[] = []; + + for (const record of recordsQuery.data) { + const props = record.data && typeof record.data === 'object' ? record.data : {}; + + const sessionData = await sessionInfo(record.sessionId); + + if (!sessionData?.userId) { + records.push({ + __date__: record.date, + __time__: record.date.getTime(), + userId: 'N/A', + ...record.computedMeasures, + ...props + }); + continue; + } - if (!sessionData?.userId) { + const userData = await userInfo(sessionData.userId); + // safely check since userData can be null records.push({ __date__: record.date, __time__: record.date.getTime(), - userId: 'N/A', + userId: userData?.username ?? 'N/A', ...record.computedMeasures, ...props }); - continue; } - const userData = await userInfo(sessionData.userId); - // safely check since userData can be null - records.push({ - __date__: record.date, - __time__: record.date.getTime(), - userId: userData?.username ?? 'N/A', - ...record.computedMeasures, - ...props - }); + setRecords(records); } - - setRecords(records); + } catch (error) { + console.error('Error occurred: ', error); } }; void fetchRecords(); From 30dc81c819740fdac18a5bb20ceaba807131d831 Mon Sep 17 00:00:00 2001 From: david-roper Date: Mon, 17 Nov 2025 14:58:26 -0500 Subject: [PATCH 67/92] feat: use schema parsing to confirm contents instead of casting it --- apps/web/src/hooks/useFindSession.ts | 5 +++-- apps/web/src/hooks/useFindUser.ts | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/web/src/hooks/useFindSession.ts b/apps/web/src/hooks/useFindSession.ts index 4232834bb..1360a5b66 100644 --- a/apps/web/src/hooks/useFindSession.ts +++ b/apps/web/src/hooks/useFindSession.ts @@ -1,10 +1,11 @@ -import type { Session } from '@opendatacapture/schemas/session'; +import { type Session, $Session } from '@opendatacapture/schemas/session'; import axios from 'axios'; export const sessionInfo = async (sessionId: string): Promise => { try { const response = await axios.get(`/v1/sessions/${sessionId}`); - return response.data ? (response.data as Session) : null; + const parsedResult = $Session.safeParse(response.data); + return parsedResult.success ? parsedResult.data : null; } catch (error) { console.error('Error fetching session:', error); throw error; diff --git a/apps/web/src/hooks/useFindUser.ts b/apps/web/src/hooks/useFindUser.ts index 686efd1c9..b65d53bff 100644 --- a/apps/web/src/hooks/useFindUser.ts +++ b/apps/web/src/hooks/useFindUser.ts @@ -1,10 +1,11 @@ -import type { User } from '@opendatacapture/schemas/user'; +import { type User, $User } from '@opendatacapture/schemas/user'; import axios from 'axios'; export const userInfo = async (userId: string): Promise => { try { const response = await axios.get(`/v1/users/${userId}`); - return response.data ? (response.data as User) : null; + const parsedResult = $User.safeParse(response.data); + return parsedResult.success ? parsedResult.data : null; } catch (error) { console.error('Error fetching user:', error); throw error; From cb271dac0b2eeb71b5e4098f936853c4e2543545 Mon Sep 17 00:00:00 2001 From: david-roper Date: Mon, 17 Nov 2025 15:47:51 -0500 Subject: [PATCH 68/92] feat: adjust username variable to start as N/A, adjust tests --- .../hooks/__tests__/useInstrumentVisualization.test.ts | 7 +++---- apps/web/src/hooks/useInstrumentVisualization.ts | 8 ++++---- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts b/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts index 5f4920574..7b43e4b73 100644 --- a/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts +++ b/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts @@ -99,7 +99,7 @@ describe('useInstrumentVisualization', () => { expect(filename).toContain('.csv'); const csvContents = getContentFn(); expect(csvContents).toMatch( - `GroupID,subjectId,Date,userId,someValue\r\ntestGroupId,testId,${toBasicISOString(FIXED_TEST_DATE)},testusername,abc` + `GroupID,subjectId,Date,username,someValue\r\ntestGroupId,testId,${toBasicISOString(FIXED_TEST_DATE)},testusername,abc` ); }); }); @@ -117,7 +117,7 @@ describe('useInstrumentVisualization', () => { expect(filename).toContain('.tsv'); const tsvContents = getContentFn(); expect(tsvContents).toMatch( - `GroupID\tsubjectId\tDate\tuserId\tsomeValue\r\ntestGroupId\ttestId\t${toBasicISOString(FIXED_TEST_DATE)}\ttestusername\tabc` + `GroupID\tsubjectId\tDate\tusername\tsomeValue\r\ntestGroupId\ttestId\t${toBasicISOString(FIXED_TEST_DATE)}\ttestusername\tabc` ); }); }); @@ -177,10 +177,9 @@ describe('useInstrumentVisualization', () => { { GroupID: 'testGroupId', subjectId: 'testId', - // eslint-disable-next-line perfectionist/sort-objects Date: '2025-04-30', someValue: 'abc', - userId: 'testusername' + username: 'testusername' } ]); }); diff --git a/apps/web/src/hooks/useInstrumentVisualization.ts b/apps/web/src/hooks/useInstrumentVisualization.ts index e015062f9..862275022 100644 --- a/apps/web/src/hooks/useInstrumentVisualization.ts +++ b/apps/web/src/hooks/useInstrumentVisualization.ts @@ -96,14 +96,14 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio exportRecords.forEach((item) => { let date: Date; - let username: string; + let username: string = 'N/A'; Object.entries(item).forEach(([objKey, objVal]) => { if (objKey === '__date__') { date = objVal as Date; return; } - if (objKey === 'userId') { + if (objKey === 'username') { username = objVal as string; return; } @@ -219,7 +219,7 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio records.push({ __date__: record.date, __time__: record.date.getTime(), - userId: 'N/A', + username: 'N/A', ...record.computedMeasures, ...props }); @@ -231,7 +231,7 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio records.push({ __date__: record.date, __time__: record.date.getTime(), - userId: userData?.username ?? 'N/A', + username: userData?.username ?? 'N/A', ...record.computedMeasures, ...props }); From a4584e9deedc271676c5faaa5ad641b8ef7ecbeb Mon Sep 17 00:00:00 2001 From: david-roper Date: Mon, 17 Nov 2025 15:49:03 -0500 Subject: [PATCH 69/92] fix: fix type exports --- apps/web/src/hooks/useFindSession.ts | 3 ++- apps/web/src/hooks/useFindUser.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/web/src/hooks/useFindSession.ts b/apps/web/src/hooks/useFindSession.ts index 1360a5b66..2c838cf2a 100644 --- a/apps/web/src/hooks/useFindSession.ts +++ b/apps/web/src/hooks/useFindSession.ts @@ -1,4 +1,5 @@ -import { type Session, $Session } from '@opendatacapture/schemas/session'; +import { $Session } from '@opendatacapture/schemas/session'; +import type { Session } from '@opendatacapture/schemas/session'; import axios from 'axios'; export const sessionInfo = async (sessionId: string): Promise => { diff --git a/apps/web/src/hooks/useFindUser.ts b/apps/web/src/hooks/useFindUser.ts index b65d53bff..9dea9202f 100644 --- a/apps/web/src/hooks/useFindUser.ts +++ b/apps/web/src/hooks/useFindUser.ts @@ -1,4 +1,5 @@ -import { type User, $User } from '@opendatacapture/schemas/user'; +import { $User } from '@opendatacapture/schemas/user'; +import type { User } from '@opendatacapture/schemas/user'; import axios from 'axios'; export const userInfo = async (userId: string): Promise => { From 539205303a22f3e54a93f4a63e5aeb565f479802 Mon Sep 17 00:00:00 2001 From: david-roper Date: Mon, 17 Nov 2025 16:09:25 -0500 Subject: [PATCH 70/92] test: change positions of subjectId and username column to make linter happy with itself --- .../src/hooks/__tests__/useInstrumentVisualization.test.ts | 6 +++--- apps/web/src/hooks/useInstrumentVisualization.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts b/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts index 7b43e4b73..88cb1afc4 100644 --- a/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts +++ b/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts @@ -136,7 +136,7 @@ describe('useInstrumentVisualization', () => { expect(filename).toContain('.csv'); const csvLongContents = getContentFn(); expect(csvLongContents).toMatch( - `GroupID,Date,Username,SubjectID,Value,Variable\r\ntestGroupId,${toBasicISOString(FIXED_TEST_DATE)},testusername,testId,abc,someValue` + `GroupID,Date,SubjectID,Username,Value,Variable\r\ntestGroupId,${toBasicISOString(FIXED_TEST_DATE)},testId,testusername,abc,someValue` ); }); }); @@ -155,7 +155,7 @@ describe('useInstrumentVisualization', () => { expect(filename).toMatch('.tsv'); const tsvLongContents = getContentFn(); expect(tsvLongContents).toMatch( - `GroupID\tDate\tUsername\tSubjectID\tValue\tVariable\r\ntestGroupId\t${toBasicISOString(FIXED_TEST_DATE)}\ttestusername\ttestId\tabc\tsomeValue` + `GroupID\tDate\tSubjectID\tUsername\tValue\tVariable\r\ntestGroupId\t${toBasicISOString(FIXED_TEST_DATE)}\ttestId\ttestusername\tabc\tsomeValue` ); }); }); @@ -175,9 +175,9 @@ describe('useInstrumentVisualization', () => { expect(excelContents).toEqual([ { + Date: '2025-04-30', GroupID: 'testGroupId', subjectId: 'testId', - Date: '2025-04-30', someValue: 'abc', username: 'testusername' } diff --git a/apps/web/src/hooks/useInstrumentVisualization.ts b/apps/web/src/hooks/useInstrumentVisualization.ts index 862275022..6eb6c3ce9 100644 --- a/apps/web/src/hooks/useInstrumentVisualization.ts +++ b/apps/web/src/hooks/useInstrumentVisualization.ts @@ -96,7 +96,7 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio exportRecords.forEach((item) => { let date: Date; - let username: string = 'N/A'; + let username = 'N/A'; Object.entries(item).forEach(([objKey, objVal]) => { if (objKey === '__date__') { @@ -115,8 +115,8 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio GroupID: currentGroup ? currentGroup.id : 'root', // eslint-disable-next-line perfectionist/sort-objects Date: toBasicISOString(date), - Username: username, SubjectID: removeSubjectIdScope(params.subjectId), + Username: username, Variable: `${objKey}-${arrKey}`, // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, perfectionist/sort-objects Value: arrItem @@ -128,8 +128,8 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio GroupID: currentGroup ? currentGroup.id : 'root', // eslint-disable-next-line perfectionist/sort-objects Date: toBasicISOString(date), - Username: username, SubjectID: removeSubjectIdScope(params.subjectId), + Username: username, Value: objVal, Variable: objKey }); From 14a2220ee93e2a106598775d8e8ccb72c66b63c7 Mon Sep 17 00:00:00 2001 From: david-roper Date: Mon, 17 Nov 2025 16:09:50 -0500 Subject: [PATCH 71/92] refactor: remove unused api call --- apps/api/src/sessions/sessions.controller.ts | 8 -------- apps/api/src/sessions/sessions.service.ts | 15 --------------- 2 files changed, 23 deletions(-) diff --git a/apps/api/src/sessions/sessions.controller.ts b/apps/api/src/sessions/sessions.controller.ts index 286d340c1..132299ad2 100644 --- a/apps/api/src/sessions/sessions.controller.ts +++ b/apps/api/src/sessions/sessions.controller.ts @@ -26,12 +26,4 @@ export class SessionsController { findByID(@Param('id') id: string, @CurrentUser('ability') ability: AppAbility): Promise { return this.sessionsService.findById(id, { ability }); } - - @ApiOperation({ description: 'Find Sessions by ID' }) - @Post('list') - @RouteAccess({ action: 'read', subject: 'Session' }) - findSessionList(@Query('ids') ids: string[], @CurrentUser('ability') ability: AppAbility): Promise { - const idArray = Array.isArray(ids) ? ids : (ids as string).split(','); - return this.sessionsService.findSessionList(idArray, { ability }); - } } diff --git a/apps/api/src/sessions/sessions.service.ts b/apps/api/src/sessions/sessions.service.ts index ee84a55a7..3d01b0fe1 100644 --- a/apps/api/src/sessions/sessions.service.ts +++ b/apps/api/src/sessions/sessions.service.ts @@ -104,21 +104,6 @@ export class SessionsService { return session; } - async findSessionList(ids: string[], { ability }: EntityOperationOptions = {}) { - const sessionsArray = await Promise.all( - ids.map(async (id) => { - const session = await this.sessionModel.findFirst({ - where: { AND: [accessibleQuery(ability, 'read', 'Session')], id } - }); - if (!session) { - throw new NotFoundException(`Failed to find session with ID: ${id}`); - } - return session; - }) - ); - return sessionsArray; - } - /** Get the subject if they exist, otherwise create them */ private async resolveSubject(subjectData: CreateSubjectData) { this.loggingService.debug({ message: 'Attempting to resolve subject', subjectData }); From e1f9a75f3e1456c15141487654e5775f00bb6ebe Mon Sep 17 00:00:00 2001 From: David Roper Date: Tue, 18 Nov 2025 09:44:08 -0500 Subject: [PATCH 72/92] chore: linter fixes --- apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts b/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts index 88cb1afc4..70430d756 100644 --- a/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts +++ b/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts @@ -178,6 +178,7 @@ describe('useInstrumentVisualization', () => { Date: '2025-04-30', GroupID: 'testGroupId', subjectId: 'testId', + // eslint-disable-next-line perfectionist/sort-objects someValue: 'abc', username: 'testusername' } From e7161a8bf0325fec8eecdce76c86ce427b0b319e Mon Sep 17 00:00:00 2001 From: David Roper Date: Tue, 18 Nov 2025 09:53:19 -0500 Subject: [PATCH 73/92] test: add resolved promise is session and userinfo mocked methods --- .../src/hooks/__tests__/useInstrumentVisualization.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts b/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts index 70430d756..51db05b5c 100644 --- a/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts +++ b/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts @@ -73,11 +73,11 @@ vi.mock('@/hooks/useInstrumentRecords', () => ({ })); vi.mock('@/hooks/useFindSession', () => ({ - sessionInfo: () => mockSession + sessionInfo: () => Promise.resolve(mockSession) })); vi.mock('@/hooks/useFindUser', () => ({ - userInfo: () => mockUser + userInfo: () => Promise.resolve(mockUser) })); describe('useInstrumentVisualization', () => { From 48900541f6185abc1b8ce3e5cf3306c484f4442e Mon Sep 17 00:00:00 2001 From: David Roper Date: Tue, 18 Nov 2025 11:18:31 -0500 Subject: [PATCH 74/92] fix: remove extra append statement in excel download method --- apps/web/src/utils/excel.ts | 1 - 1 file changed, 1 deletion(-) 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); } From 58407e18a6b1a047cd46134f9f24a8161d702203 Mon Sep 17 00:00:00 2001 From: David Roper Date: Tue, 18 Nov 2025 14:35:32 -0500 Subject: [PATCH 75/92] feat: fix not finding user id issue by making subject inclusion optional --- apps/web/src/hooks/useFindSession.ts | 4 ++-- packages/schemas/src/session/session.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/hooks/useFindSession.ts b/apps/web/src/hooks/useFindSession.ts index 2c838cf2a..ffc1fb7fe 100644 --- a/apps/web/src/hooks/useFindSession.ts +++ b/apps/web/src/hooks/useFindSession.ts @@ -5,8 +5,8 @@ import axios from 'axios'; export const sessionInfo = async (sessionId: string): Promise => { try { const response = await axios.get(`/v1/sessions/${sessionId}`); - const parsedResult = $Session.safeParse(response.data); - return parsedResult.success ? parsedResult.data : null; + const parsedResult = await $Session.parseAsync(response.data); + return parsedResult; } catch (error) { console.error('Error fetching session:', error); throw error; diff --git a/packages/schemas/src/session/session.ts b/packages/schemas/src/session/session.ts index 81d38bead..6b5d982fc 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.optional(), subjectId: z.string(), type: $SessionType, userId: z.string().nullish() From 191127e26c377fbb42eb7605ba71868236dea83f Mon Sep 17 00:00:00 2001 From: David Roper Date: Tue, 18 Nov 2025 14:44:59 -0500 Subject: [PATCH 76/92] chore: revert session schema and parse change --- apps/web/src/hooks/useFindSession.ts | 7 ++++--- packages/schemas/src/session/session.ts | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/web/src/hooks/useFindSession.ts b/apps/web/src/hooks/useFindSession.ts index ffc1fb7fe..01fb77508 100644 --- a/apps/web/src/hooks/useFindSession.ts +++ b/apps/web/src/hooks/useFindSession.ts @@ -1,12 +1,13 @@ -import { $Session } from '@opendatacapture/schemas/session'; import type { Session } from '@opendatacapture/schemas/session'; import axios from 'axios'; export const sessionInfo = async (sessionId: string): Promise => { try { const response = await axios.get(`/v1/sessions/${sessionId}`); - const parsedResult = await $Session.parseAsync(response.data); - return parsedResult; + if (!response.data) { + throw new Error('Session data does not exist'); + } + return response.data as Session; } catch (error) { console.error('Error fetching session:', error); throw error; diff --git a/packages/schemas/src/session/session.ts b/packages/schemas/src/session/session.ts index 6b5d982fc..81d38bead 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.optional(), + subject: $Subject, subjectId: z.string(), type: $SessionType, userId: z.string().nullish() From 6d8fcca6c1a01749392a6394140d5b429b47d21f Mon Sep 17 00:00:00 2001 From: David Roper Date: Tue, 18 Nov 2025 14:46:22 -0500 Subject: [PATCH 77/92] fix: make username column in wideRow method and its tests more consistent --- .../src/hooks/__tests__/useInstrumentVisualization.test.ts | 6 +++--- apps/web/src/hooks/useInstrumentVisualization.ts | 4 ++++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts b/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts index 51db05b5c..ded589273 100644 --- a/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts +++ b/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts @@ -99,7 +99,7 @@ describe('useInstrumentVisualization', () => { expect(filename).toContain('.csv'); const csvContents = getContentFn(); expect(csvContents).toMatch( - `GroupID,subjectId,Date,username,someValue\r\ntestGroupId,testId,${toBasicISOString(FIXED_TEST_DATE)},testusername,abc` + `GroupID,subjectId,Date,Username,someValue\r\ntestGroupId,testId,${toBasicISOString(FIXED_TEST_DATE)},testusername,abc` ); }); }); @@ -117,7 +117,7 @@ describe('useInstrumentVisualization', () => { expect(filename).toContain('.tsv'); const tsvContents = getContentFn(); expect(tsvContents).toMatch( - `GroupID\tsubjectId\tDate\tusername\tsomeValue\r\ntestGroupId\ttestId\t${toBasicISOString(FIXED_TEST_DATE)}\ttestusername\tabc` + `GroupID\tsubjectId\tDate\tUsername\tsomeValue\r\ntestGroupId\ttestId\t${toBasicISOString(FIXED_TEST_DATE)}\ttestusername\tabc` ); }); }); @@ -180,7 +180,7 @@ describe('useInstrumentVisualization', () => { subjectId: 'testId', // eslint-disable-next-line perfectionist/sort-objects someValue: 'abc', - username: 'testusername' + Username: 'testusername' } ]); }); diff --git a/apps/web/src/hooks/useInstrumentVisualization.ts b/apps/web/src/hooks/useInstrumentVisualization.ts index 6eb6c3ce9..2e180d5d6 100644 --- a/apps/web/src/hooks/useInstrumentVisualization.ts +++ b/apps/web/src/hooks/useInstrumentVisualization.ts @@ -85,6 +85,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; From 31da08f2c8e0bb36ae2323f0f829fea3cad9b852 Mon Sep 17 00:00:00 2001 From: David Roper Date: Tue, 18 Nov 2025 15:06:24 -0500 Subject: [PATCH 78/92] feat: remove redundant null return type from useFindSession --- apps/web/src/hooks/useFindSession.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/hooks/useFindSession.ts b/apps/web/src/hooks/useFindSession.ts index 01fb77508..676c908fc 100644 --- a/apps/web/src/hooks/useFindSession.ts +++ b/apps/web/src/hooks/useFindSession.ts @@ -1,7 +1,7 @@ import type { Session } from '@opendatacapture/schemas/session'; import axios from 'axios'; -export const sessionInfo = async (sessionId: string): Promise => { +export const sessionInfo = async (sessionId: string): Promise => { try { const response = await axios.get(`/v1/sessions/${sessionId}`); if (!response.data) { From 1c3d9553836b1b44215893d41eccf22a5e3a26c8 Mon Sep 17 00:00:00 2001 From: David Roper Date: Tue, 18 Nov 2025 15:10:01 -0500 Subject: [PATCH 79/92] feat: add error notification for useEffect --- apps/web/src/hooks/useInstrumentVisualization.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/web/src/hooks/useInstrumentVisualization.ts b/apps/web/src/hooks/useInstrumentVisualization.ts index 2e180d5d6..23ec89617 100644 --- a/apps/web/src/hooks/useInstrumentVisualization.ts +++ b/apps/web/src/hooks/useInstrumentVisualization.ts @@ -245,6 +245,13 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio } } catch (error) { console.error('Error occurred: ', error); + notifications.addNotification({ + message: t({ + en: 'Error occurred finding records', + fr: "Une erreur s'est produite lors de la recherche des enregistrements." + }), + type: 'error' + }); } }; void fetchRecords(); From 85809da27bad1e75764ce40da1d7aa96f18b4e09 Mon Sep 17 00:00:00 2001 From: David Roper Date: Wed, 19 Nov 2025 10:16:35 -0500 Subject: [PATCH 80/92] chore: and encodeUriComponent to ids --- apps/web/src/hooks/useFindSession.ts | 2 +- apps/web/src/hooks/useFindUser.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/hooks/useFindSession.ts b/apps/web/src/hooks/useFindSession.ts index 676c908fc..333890c62 100644 --- a/apps/web/src/hooks/useFindSession.ts +++ b/apps/web/src/hooks/useFindSession.ts @@ -3,7 +3,7 @@ import axios from 'axios'; export const sessionInfo = async (sessionId: string): Promise => { try { - const response = await axios.get(`/v1/sessions/${sessionId}`); + const response = await axios.get(`/v1/sessions/${encodeURIComponent(sessionId)}`); if (!response.data) { throw new Error('Session data does not exist'); } diff --git a/apps/web/src/hooks/useFindUser.ts b/apps/web/src/hooks/useFindUser.ts index 9dea9202f..6dc2a95d1 100644 --- a/apps/web/src/hooks/useFindUser.ts +++ b/apps/web/src/hooks/useFindUser.ts @@ -4,7 +4,7 @@ import axios from 'axios'; export const userInfo = async (userId: string): Promise => { try { - const response = await axios.get(`/v1/users/${userId}`); + const response = await axios.get(`/v1/users/${encodeURIComponent(userId)}`); const parsedResult = $User.safeParse(response.data); return parsedResult.success ? parsedResult.data : null; } catch (error) { From 066c5a9d8906fd563377fe5264e8b7081e9764bc Mon Sep 17 00:00:00 2001 From: David Roper Date: Wed, 19 Nov 2025 10:43:19 -0500 Subject: [PATCH 81/92] feat: fetch sessions then users in parrallel and update test mock values --- .../useInstrumentVisualization.test.ts | 1 + .../src/hooks/useInstrumentVisualization.ts | 37 +++++++++---------- 2 files changed, 18 insertions(+), 20 deletions(-) diff --git a/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts b/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts index ded589273..db12e8427 100644 --- a/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts +++ b/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts @@ -43,6 +43,7 @@ const mockSession = { }; const mockUser = { + id: '111', username: 'testusername' }; diff --git a/apps/web/src/hooks/useInstrumentVisualization.ts b/apps/web/src/hooks/useInstrumentVisualization.ts index 23ec89617..f023e8e34 100644 --- a/apps/web/src/hooks/useInstrumentVisualization.ts +++ b/apps/web/src/hooks/useInstrumentVisualization.ts @@ -212,34 +212,31 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio const fetchRecords = async () => { try { if (recordsQuery.data) { - const records: InstrumentVisualizationRecord[] = []; + // Fetch all sessions in parallel + const sessionPromises = recordsQuery.data.map((record) => sessionInfo(record.sessionId)); + const sessions = await Promise.all(sessionPromises); - for (const record of recordsQuery.data) { - const props = record.data && typeof record.data === 'object' ? record.data : {}; + // Extract unique userIds and fetch users in parallel + const userIds = [...new Set(sessions.filter((s) => s?.userId).map((s) => s.userId))]; - const sessionData = await sessionInfo(record.sessionId); + const userPromises = userIds.map((userId) => userInfo(userId!)); + const users = await Promise.all(userPromises); + const userMap = new Map(users.filter((u) => u).map((u) => [u!.id, u!.username])); - if (!sessionData?.userId) { - records.push({ - __date__: record.date, - __time__: record.date.getTime(), - username: 'N/A', - ...record.computedMeasures, - ...props - }); - continue; - } + // Build records with looked-up data + const records: InstrumentVisualizationRecord[] = recordsQuery.data.map((record, i) => { + const props = record.data && typeof record.data === 'object' ? record.data : {}; + const session = sessions[i]; + const username = session?.userId ? (userMap.get(session.userId) ?? 'N/A') : 'N/A'; - const userData = await userInfo(sessionData.userId); - // safely check since userData can be null - records.push({ + return { __date__: record.date, __time__: record.date.getTime(), - username: userData?.username ?? 'N/A', + username: username, ...record.computedMeasures, ...props - }); - } + }; + }); setRecords(records); } From a954235e72b0e7b6185da6c72049cec1aaa3421c Mon Sep 17 00:00:00 2001 From: David Roper Date: Wed, 19 Nov 2025 11:35:17 -0500 Subject: [PATCH 82/92] feat: add cancelled var to avoid race conditions in fetch records --- apps/web/src/hooks/useInstrumentVisualization.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/web/src/hooks/useInstrumentVisualization.ts b/apps/web/src/hooks/useInstrumentVisualization.ts index f023e8e34..42f1c566c 100644 --- a/apps/web/src/hooks/useInstrumentVisualization.ts +++ b/apps/web/src/hooks/useInstrumentVisualization.ts @@ -209,6 +209,7 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio }; useEffect(() => { + let cancelled = false; const fetchRecords = async () => { try { if (recordsQuery.data) { @@ -219,6 +220,7 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio // Extract unique userIds and fetch users in parallel const userIds = [...new Set(sessions.filter((s) => s?.userId).map((s) => s.userId))]; + //assume userId exists in userId set as we already filtered out the non-existing userIds const userPromises = userIds.map((userId) => userInfo(userId!)); const users = await Promise.all(userPromises); const userMap = new Map(users.filter((u) => u).map((u) => [u!.id, u!.username])); @@ -238,7 +240,9 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio }; }); - setRecords(records); + if (!cancelled) { + setRecords(records); + } } } catch (error) { console.error('Error occurred: ', error); @@ -252,6 +256,9 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio } }; void fetchRecords(); + return () => { + cancelled = true; + }; }, [recordsQuery.data]); const instrumentOptions: { [key: string]: string } = useMemo(() => { From 856d824d557466f334f082a81e07e8f7280afb91 Mon Sep 17 00:00:00 2001 From: David Roper Date: Wed, 19 Nov 2025 11:51:57 -0500 Subject: [PATCH 83/92] feat: return null on errors userInfo issues --- apps/web/src/hooks/useInstrumentVisualization.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/hooks/useInstrumentVisualization.ts b/apps/web/src/hooks/useInstrumentVisualization.ts index 42f1c566c..ff41b8776 100644 --- a/apps/web/src/hooks/useInstrumentVisualization.ts +++ b/apps/web/src/hooks/useInstrumentVisualization.ts @@ -221,7 +221,7 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio const userIds = [...new Set(sessions.filter((s) => s?.userId).map((s) => s.userId))]; //assume userId exists in userId set as we already filtered out the non-existing userIds - const userPromises = userIds.map((userId) => userInfo(userId!)); + const userPromises = userIds.map((userId) => userInfo(userId!).catch(() => null)); const users = await Promise.all(userPromises); const userMap = new Map(users.filter((u) => u).map((u) => [u!.id, u!.username])); From efe9d87d6a004c72c507e8ab80c48b955107b90d Mon Sep 17 00:00:00 2001 From: David Roper Date: Wed, 19 Nov 2025 17:06:02 -0500 Subject: [PATCH 84/92] feat: add new findAllSessionsIncludeUsernames api call and todo comments --- apps/api/src/sessions/sessions.controller.ts | 10 ++++++++++ apps/api/src/sessions/sessions.service.ts | 15 +++++++++++++++ apps/web/src/hooks/useFindSession.ts | 6 ++++++ apps/web/src/hooks/useInstrumentVisualization.ts | 3 +++ packages/schemas/src/session/session.ts | 7 +++++++ 5 files changed, 41 insertions(+) diff --git a/apps/api/src/sessions/sessions.controller.ts b/apps/api/src/sessions/sessions.controller.ts index 132299ad2..8524fe131 100644 --- a/apps/api/src/sessions/sessions.controller.ts +++ b/apps/api/src/sessions/sessions.controller.ts @@ -20,6 +20,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( + @Query('groupId') groupId: string, + @CurrentUser('ability') ability: AppAbility + ) { + 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..451c6083c 100644 --- a/apps/api/src/sessions/sessions.service.ts +++ b/apps/api/src/sessions/sessions.service.ts @@ -94,6 +94,21 @@ export class SessionsService { }); } + async findAllIncludeUsernames(groupId: string, { ability }: EntityOperationOptions = {}) { + return this.sessionModel.findMany({ + include: { + user: { + select: { + username: true + } + } + }, + where: { + AND: [accessibleQuery(ability, 'read', 'Session'), { groupId }] + } + }); + } + 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/hooks/useFindSession.ts b/apps/web/src/hooks/useFindSession.ts index 333890c62..a3aac0c34 100644 --- a/apps/web/src/hooks/useFindSession.ts +++ b/apps/web/src/hooks/useFindSession.ts @@ -1,6 +1,12 @@ import type { Session } from '@opendatacapture/schemas/session'; import axios from 'axios'; +//Change this query to into a hook method and name it useFindSessionQuery + +//Change the api call to have an include tag which includes the username from users + +//Change the return type to + export const sessionInfo = async (sessionId: string): Promise => { try { const response = await axios.get(`/v1/sessions/${encodeURIComponent(sessionId)}`); diff --git a/apps/web/src/hooks/useInstrumentVisualization.ts b/apps/web/src/hooks/useInstrumentVisualization.ts index ff41b8776..48391b337 100644 --- a/apps/web/src/hooks/useInstrumentVisualization.ts +++ b/apps/web/src/hooks/useInstrumentVisualization.ts @@ -57,6 +57,9 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio } }); + // Create a new sessionsUsernameQuery which uses the useFindSessionQuery hook + // have use a different return type with + const dl = (option: 'CSV' | 'CSV Long' | 'Excel' | 'Excel Long' | 'JSON' | 'TSV' | 'TSV Long') => { if (!instrument) { notifications.addNotification({ message: t('errors.noInstrumentSelected'), type: 'error' }); diff --git a/packages/schemas/src/session/session.ts b/packages/schemas/src/session/session.ts index 81d38bead..8b0a89b83 100644 --- a/packages/schemas/src/session/session.ts +++ b/packages/schemas/src/session/session.ts @@ -24,3 +24,10 @@ 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() + }) +}); From 9c156076aa4b9903434a3998f1d7a637ee3648c8 Mon Sep 17 00:00:00 2001 From: david-roper Date: Thu, 20 Nov 2025 13:40:04 -0500 Subject: [PATCH 85/92] feat: rename function to useFindSessionQuery --- apps/web/src/hooks/useFindSession.ts | 21 ---------- apps/web/src/hooks/useFindSessionQuery.ts | 51 +++++++++++++++++++++++ 2 files changed, 51 insertions(+), 21 deletions(-) delete mode 100644 apps/web/src/hooks/useFindSession.ts create mode 100644 apps/web/src/hooks/useFindSessionQuery.ts diff --git a/apps/web/src/hooks/useFindSession.ts b/apps/web/src/hooks/useFindSession.ts deleted file mode 100644 index a3aac0c34..000000000 --- a/apps/web/src/hooks/useFindSession.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { Session } from '@opendatacapture/schemas/session'; -import axios from 'axios'; - -//Change this query to into a hook method and name it useFindSessionQuery - -//Change the api call to have an include tag which includes the username from users - -//Change the return type to - -export const sessionInfo = async (sessionId: string): Promise => { - try { - const response = await axios.get(`/v1/sessions/${encodeURIComponent(sessionId)}`); - if (!response.data) { - throw new Error('Session data does not exist'); - } - return response.data as Session; - } catch (error) { - console.error('Error fetching session:', error); - throw error; - } -}; diff --git a/apps/web/src/hooks/useFindSessionQuery.ts b/apps/web/src/hooks/useFindSessionQuery.ts new file mode 100644 index 000000000..004912331 --- /dev/null +++ b/apps/web/src/hooks/useFindSessionQuery.ts @@ -0,0 +1,51 @@ +import { reviver } from '@douglasneuroinformatics/libjs'; +import { + $SessionWithUser, + type Session, + type SessionWithUser, + type SessionWithUserQueryParams +} from '@opendatacapture/schemas/session'; +import { useQuery } from '@tanstack/react-query'; +import axios from 'axios'; + +//Change this query to into a hook method and name it useFindSessionQuery + +//Change the api call to have an include tag which includes the username from users + +//Change the return type to + +type UseSessionOptions = { + enabled?: boolean; + params: SessionWithUserQueryParams; +}; + +export const sessionInfo = async (sessionId: string): Promise => { + try { + const response = await axios.get(`/v1/sessions/${encodeURIComponent(sessionId)}`); + if (!response.data) { + throw new Error('Session data does not exist'); + } + return response.data as Session; + } catch (error) { + console.error('Error fetching session:', error); + throw error; + } +}; + +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)] + }); +}; From 8349bd0b501da030daed76f01a9de384069ba0f2 Mon Sep 17 00:00:00 2001 From: david-roper Date: Thu, 20 Nov 2025 13:41:40 -0500 Subject: [PATCH 86/92] feat: update types of findAllIncludeUsernames --- apps/api/src/sessions/sessions.controller.ts | 7 ++++--- apps/api/src/sessions/sessions.service.ts | 8 ++++++-- apps/web/src/hooks/useInstrumentVisualization.ts | 9 ++++++++- packages/schemas/src/session/session.ts | 6 +++++- 4 files changed, 23 insertions(+), 7 deletions(-) diff --git a/apps/api/src/sessions/sessions.controller.ts b/apps/api/src/sessions/sessions.controller.ts index 8524fe131..0b9e50773 100644 --- a/apps/api/src/sessions/sessions.controller.ts +++ b/apps/api/src/sessions/sessions.controller.ts @@ -8,6 +8,7 @@ import { RouteAccess } from '@/core/decorators/route-access.decorator'; import { CreateSessionDto } from './dto/create-session.dto'; import { SessionsService } from './sessions.service'; +import type { SessionWithUser } from '@opendatacapture/schemas/session'; @Controller('sessions') export class SessionsController { @@ -24,9 +25,9 @@ export class SessionsController { @Get() @RouteAccess({ action: 'read', subject: 'Session' }) findAllIncludeUsernames( - @Query('groupId') groupId: string, - @CurrentUser('ability') ability: AppAbility - ) { + @CurrentUser('ability') ability: AppAbility, + @Query('groupId') groupId?: string + ): Promise { return this.sessionsService.findAllIncludeUsernames(groupId, { ability }); } diff --git a/apps/api/src/sessions/sessions.service.ts b/apps/api/src/sessions/sessions.service.ts index 451c6083c..8bffa3477 100644 --- a/apps/api/src/sessions/sessions.service.ts +++ b/apps/api/src/sessions/sessions.service.ts @@ -94,8 +94,8 @@ export class SessionsService { }); } - async findAllIncludeUsernames(groupId: string, { ability }: EntityOperationOptions = {}) { - return this.sessionModel.findMany({ + async findAllIncludeUsernames(groupId?: string, { ability }: EntityOperationOptions = {}) { + const sessionsWithUsers = await this.sessionModel.findMany({ include: { user: { select: { @@ -107,6 +107,10 @@ export class SessionsService { AND: [accessibleQuery(ability, 'read', 'Session'), { groupId }] } }); + if (!sessionsWithUsers) { + throw new NotFoundException(`Failed to find users`); + } + return sessionsWithUsers; } async findById(id: string, { ability }: EntityOperationOptions = {}) { diff --git a/apps/web/src/hooks/useInstrumentVisualization.ts b/apps/web/src/hooks/useInstrumentVisualization.ts index 48391b337..6a98a4206 100644 --- a/apps/web/src/hooks/useInstrumentVisualization.ts +++ b/apps/web/src/hooks/useInstrumentVisualization.ts @@ -13,7 +13,7 @@ import { useInstrumentRecords } from '@/hooks/useInstrumentRecords'; import { useAppStore } from '@/store'; import { downloadSubjectTableExcel } from '@/utils/excel'; -import { sessionInfo } from './useFindSession'; +import { sessionInfo, useFindSessionQuery } from './useFindSessionQuery'; import { userInfo } from './useFindUser'; type InstrumentVisualizationRecord = { @@ -57,6 +57,13 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio } }); + // const sessionsUsernameQuery = useFindSessionQuery({ + // enabled: instrumentId !== null, + // params: { + // groupId: currentGroup?.id + // } + // }); + // Create a new sessionsUsernameQuery which uses the useFindSessionQuery hook // have use a different return type with diff --git a/packages/schemas/src/session/session.ts b/packages/schemas/src/session/session.ts index 8b0a89b83..20d2a83b8 100644 --- a/packages/schemas/src/session/session.ts +++ b/packages/schemas/src/session/session.ts @@ -25,9 +25,13 @@ export const $CreateSessionData = z.object({ username: z.string().nullish() }); -export type $SessionWithUser = z.infer; +export type SessionWithUser = z.infer; export const $SessionWithUser = $Session.extend({ user: z.object({ username: z.string().nullish() }) }); + +export type SessionWithUserQueryParams = { + groupId?: string; +}; From adf6df712f6f04c858aaff75a58b1db3fbf723ba Mon Sep 17 00:00:00 2001 From: david-roper Date: Thu, 20 Nov 2025 15:41:43 -0500 Subject: [PATCH 87/92] feat: update query and type imports --- apps/api/src/sessions/sessions.service.ts | 3 ++- apps/web/src/hooks/useFindSessionQuery.ts | 9 ++------- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/apps/api/src/sessions/sessions.service.ts b/apps/api/src/sessions/sessions.service.ts index 8bffa3477..14f4bf0f3 100644 --- a/apps/api/src/sessions/sessions.service.ts +++ b/apps/api/src/sessions/sessions.service.ts @@ -101,7 +101,8 @@ export class SessionsService { select: { username: true } - } + }, + subject: true }, where: { AND: [accessibleQuery(ability, 'read', 'Session'), { groupId }] diff --git a/apps/web/src/hooks/useFindSessionQuery.ts b/apps/web/src/hooks/useFindSessionQuery.ts index 004912331..f6b027d52 100644 --- a/apps/web/src/hooks/useFindSessionQuery.ts +++ b/apps/web/src/hooks/useFindSessionQuery.ts @@ -1,10 +1,5 @@ -import { reviver } from '@douglasneuroinformatics/libjs'; -import { - $SessionWithUser, - type Session, - type SessionWithUser, - type SessionWithUserQueryParams -} from '@opendatacapture/schemas/session'; +import { $SessionWithUser } from '@opendatacapture/schemas/session'; +import type { Session, SessionWithUserQueryParams } from '@opendatacapture/schemas/session'; import { useQuery } from '@tanstack/react-query'; import axios from 'axios'; From 19515b80ff65a39e5d9f03e51d57a8696342818f Mon Sep 17 00:00:00 2001 From: David Roper <60201980+david-roper@users.noreply.github.com> Date: Mon, 24 Nov 2025 21:25:23 -0500 Subject: [PATCH 88/92] Remove duplicate import for SessionWithUser type --- apps/api/src/sessions/sessions.controller.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/api/src/sessions/sessions.controller.ts b/apps/api/src/sessions/sessions.controller.ts index 59ed4c8af..1ff3e2806 100644 --- a/apps/api/src/sessions/sessions.controller.ts +++ b/apps/api/src/sessions/sessions.controller.ts @@ -9,7 +9,6 @@ import { RouteAccess } from '@/core/decorators/route-access.decorator'; import { CreateSessionDto } from './dto/create-session.dto'; import { SessionsService } from './sessions.service'; -import type { SessionWithUser } from '@opendatacapture/schemas/session'; @Controller('sessions') export class SessionsController { From 895495bb7d754e389aabc0b481135f59487fd6e8 Mon Sep 17 00:00:00 2001 From: David Roper Date: Wed, 26 Nov 2025 11:53:21 -0500 Subject: [PATCH 89/92] fix: use zod error message for useFindSessionQuery --- apps/web/src/hooks/useFindSessionQuery.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/hooks/useFindSessionQuery.ts b/apps/web/src/hooks/useFindSessionQuery.ts index 782cfb7ab..238c5503c 100644 --- a/apps/web/src/hooks/useFindSessionQuery.ts +++ b/apps/web/src/hooks/useFindSessionQuery.ts @@ -28,7 +28,8 @@ export const useFindSessionQuery = ( }); const parsedData = $SessionWithUser.array().safeParseAsync(response.data); if ((await parsedData).error) { - throw new Error(`cant find data`); + const message = (await parsedData).error?.message; + throw new Error(message); } return (await parsedData).data; }, From c070deccdb81ea9017c927054ceaf20f24faae6f Mon Sep 17 00:00:00 2001 From: David Roper Date: Wed, 26 Nov 2025 12:03:59 -0500 Subject: [PATCH 90/92] fix: remove unused parts of tests, remove unused userInfo method --- .../__tests__/useInstrumentVisualization.test.ts | 9 --------- apps/web/src/hooks/useFindUser.ts | 14 -------------- 2 files changed, 23 deletions(-) delete mode 100644 apps/web/src/hooks/useFindUser.ts diff --git a/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts b/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts index 2e235761e..697ccfe7c 100644 --- a/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts +++ b/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts @@ -49,15 +49,6 @@ const mockSessionWithUsername = { ] }; -const mockSession = { - userId: '111' -}; - -const mockUser = { - id: '111', - username: 'testusername' -}; - vi.mock('@/hooks/useInstrument', () => ({ useInstrument: mockUseInstrument })); diff --git a/apps/web/src/hooks/useFindUser.ts b/apps/web/src/hooks/useFindUser.ts deleted file mode 100644 index 6dc2a95d1..000000000 --- a/apps/web/src/hooks/useFindUser.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { $User } from '@opendatacapture/schemas/user'; -import type { User } from '@opendatacapture/schemas/user'; -import axios from 'axios'; - -export const userInfo = async (userId: string): Promise => { - try { - const response = await axios.get(`/v1/users/${encodeURIComponent(userId)}`); - const parsedResult = $User.safeParse(response.data); - return parsedResult.success ? parsedResult.data : null; - } catch (error) { - console.error('Error fetching user:', error); - throw error; - } -}; From 20c046c7cd909e7a8d3dbf3cb37b80247ee3cd60 Mon Sep 17 00:00:00 2001 From: David Roper Date: Wed, 26 Nov 2025 13:27:32 -0500 Subject: [PATCH 91/92] chore: add linter format changes --- apps/api/src/sessions/sessions.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/api/src/sessions/sessions.service.ts b/apps/api/src/sessions/sessions.service.ts index d3bc19a73..5c406e97c 100644 --- a/apps/api/src/sessions/sessions.service.ts +++ b/apps/api/src/sessions/sessions.service.ts @@ -99,12 +99,12 @@ export class SessionsService { async findAllIncludeUsernames(groupId?: string, { ability }: EntityOperationOptions = {}) { const sessionsWithUsers = await this.sessionModel.findMany({ include: { + subject: true, user: { select: { username: true } - }, - subject: true + } }, where: { AND: [accessibleQuery(ability, 'read', 'Session'), { groupId }] From 7e71a3260f692596f96fd7cb65aadfafd15871ae Mon Sep 17 00:00:00 2001 From: David Roper Date: Wed, 26 Nov 2025 15:18:40 -0500 Subject: [PATCH 92/92] chore: update parse to parseAsync in useFindSessionQuery --- apps/web/src/hooks/useFindSessionQuery.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/apps/web/src/hooks/useFindSessionQuery.ts b/apps/web/src/hooks/useFindSessionQuery.ts index 238c5503c..76b50fdba 100644 --- a/apps/web/src/hooks/useFindSessionQuery.ts +++ b/apps/web/src/hooks/useFindSessionQuery.ts @@ -26,12 +26,7 @@ export const useFindSessionQuery = ( const response = await axios.get('/v1/sessions', { params }); - const parsedData = $SessionWithUser.array().safeParseAsync(response.data); - if ((await parsedData).error) { - const message = (await parsedData).error?.message; - throw new Error(message); - } - return (await parsedData).data; + return $SessionWithUser.array().parseAsync(response.data); }, queryKey: ['sessions', ...Object.values(params)] });