From 20d1509213d811415126f5417e7e5a40b1d16b6f Mon Sep 17 00:00:00 2001 From: Thomas Cottee Meldrum Date: Mon, 24 Nov 2025 17:16:42 +0000 Subject: [PATCH 1/6] fix: ProposalTableReviewer call and instrument filters --- .../backend/src/datasources/CallDataSource.ts | 1 + .../src/datasources/InstrumentDataSource.ts | 1 + .../mockups/InstrumentDataSource.ts | 4 ++ .../datasources/postgres/CallDataSource.ts | 11 +++++ .../postgres/InstrumentDataSource.ts | 18 +++++++ apps/backend/src/queries/CallQueries.ts | 22 ++++++++- apps/backend/src/queries/InstrumentQueries.ts | 23 ++++++++- .../src/resolvers/queries/CallsQuery.ts | 5 ++ .../src/resolvers/queries/InstrumentQuery.ts | 7 +++ .../review/ProposalTableReviewer.tsx | 8 ++-- .../src/graphql/call/getReviewerCalls.graphql | 6 +++ .../instrument/getReviewerInstruments.graphql | 5 ++ .../src/hooks/call/useReviewerCallData.ts | 47 +++++++++++++++++++ .../instrument/useReviewerInstrumentData.ts | 47 +++++++++++++++++++ 14 files changed, 199 insertions(+), 6 deletions(-) create mode 100644 apps/frontend/src/graphql/call/getReviewerCalls.graphql create mode 100644 apps/frontend/src/graphql/instrument/getReviewerInstruments.graphql create mode 100644 apps/frontend/src/hooks/call/useReviewerCallData.ts create mode 100644 apps/frontend/src/hooks/instrument/useReviewerInstrumentData.ts diff --git a/apps/backend/src/datasources/CallDataSource.ts b/apps/backend/src/datasources/CallDataSource.ts index 5328caef3d..6a7f684690 100644 --- a/apps/backend/src/datasources/CallDataSource.ts +++ b/apps/backend/src/datasources/CallDataSource.ts @@ -34,4 +34,5 @@ export interface CallDataSource { getCallByAnswerIdProposal(answerId: number): Promise; getProposalWorkflowByCall(callId: number): Promise; getExperimentWorkflowByCall(callId: number): Promise; + getCallsOfFaps(fapIds: number[]): Promise; } diff --git a/apps/backend/src/datasources/InstrumentDataSource.ts b/apps/backend/src/datasources/InstrumentDataSource.ts index d134c05f6b..b1885de401 100644 --- a/apps/backend/src/datasources/InstrumentDataSource.ts +++ b/apps/backend/src/datasources/InstrumentDataSource.ts @@ -95,4 +95,5 @@ export interface InstrumentDataSource { instrumentId: number, proposalPk: number ): Promise; + getInstrumentsByFapIds(fapId: number[]): Promise; } diff --git a/apps/backend/src/datasources/mockups/InstrumentDataSource.ts b/apps/backend/src/datasources/mockups/InstrumentDataSource.ts index 7760ad3399..13b9f40276 100644 --- a/apps/backend/src/datasources/mockups/InstrumentDataSource.ts +++ b/apps/backend/src/datasources/mockups/InstrumentDataSource.ts @@ -277,4 +277,8 @@ export class InstrumentDataSourceMock implements InstrumentDataSource { ): Promise { throw new Error('Method not implemented.'); } + + getInstrumentsByFapIds(fapId: number[]): Promise { + throw new Error('Method not implemented.'); + } } diff --git a/apps/backend/src/datasources/postgres/CallDataSource.ts b/apps/backend/src/datasources/postgres/CallDataSource.ts index 00520c5e18..23e3dd2767 100644 --- a/apps/backend/src/datasources/postgres/CallDataSource.ts +++ b/apps/backend/src/datasources/postgres/CallDataSource.ts @@ -623,4 +623,15 @@ export default class PostgresCallDataSource implements CallDataSource { : null ); } + + async getCallsOfFaps(fapIds: number[]): Promise { + return database + .distinct('call.*') + .from('call') + .join('call_has_faps as chf', 'chf.call_id', 'call.call_id') + .whereIn('chf.fap_id', fapIds) + .then((calls: CallRecord[]) => { + return calls.map((call) => createCallObject(call)); + }); + } } diff --git a/apps/backend/src/datasources/postgres/InstrumentDataSource.ts b/apps/backend/src/datasources/postgres/InstrumentDataSource.ts index 9d9b55da68..9650cb37e0 100644 --- a/apps/backend/src/datasources/postgres/InstrumentDataSource.ts +++ b/apps/backend/src/datasources/postgres/InstrumentDataSource.ts @@ -796,4 +796,22 @@ export default class PostgresInstrumentDataSource return result?.count === '1'; }); } + + async getInstrumentsByFapIds(fapId: number[]): Promise { + return database + .select('i.*') + .from('instruments as i') + .join('call_has_instruments as chi', { + 'i.instrument_id': 'chi.instrument_id', + }) + .where('chi.fap_id', 'in', fapId) + .distinct() + .then((instruments: InstrumentRecord[]) => { + const result = instruments.map((instrument) => + this.createInstrumentObject(instrument) + ); + + return result; + }); + } } diff --git a/apps/backend/src/queries/CallQueries.ts b/apps/backend/src/queries/CallQueries.ts index c190a7f521..4069653c4d 100644 --- a/apps/backend/src/queries/CallQueries.ts +++ b/apps/backend/src/queries/CallQueries.ts @@ -3,6 +3,7 @@ import { inject, injectable } from 'tsyringe'; import { UserAuthorization } from '../auth/UserAuthorization'; import { Tokens } from '../config/Tokens'; import { CallDataSource } from '../datasources/CallDataSource'; +import { FapDataSource } from '../datasources/FapDataSource'; import { Authorized } from '../decorators'; import { Roles } from '../models/Role'; import { UserWithRole } from '../models/User'; @@ -12,7 +13,9 @@ import { CallsFilter } from '../resolvers/queries/CallsQuery'; export default class CallQueries { constructor( @inject(Tokens.CallDataSource) public dataSource: CallDataSource, - @inject(Tokens.UserAuthorization) private userAuth: UserAuthorization + @inject(Tokens.UserAuthorization) private userAuth: UserAuthorization, + @inject(Tokens.FapDataSource) + public fapDataSource: FapDataSource ) {} @Authorized() @@ -57,4 +60,21 @@ export default class CallQueries { async getCallOfAnswersProposal(user: UserWithRole | null, answerId: number) { return this.dataSource.getCallByAnswerIdProposal(answerId); } + + async getCallsOfReviewer(agent: UserWithRole | null) { + if (!agent || !agent.id) { + return []; + } + + if (!agent.currentRole) { + return []; + } + + const faps = await this.fapDataSource.getUserFapsByRoleAndFapId( + agent.id, + agent.currentRole + ); + + return this.dataSource.getCallsOfFaps(faps.map((fap) => fap.id)); + } } diff --git a/apps/backend/src/queries/InstrumentQueries.ts b/apps/backend/src/queries/InstrumentQueries.ts index caa1143a8a..fae23523bf 100644 --- a/apps/backend/src/queries/InstrumentQueries.ts +++ b/apps/backend/src/queries/InstrumentQueries.ts @@ -2,6 +2,7 @@ import { inject, injectable } from 'tsyringe'; import { UserAuthorization } from '../auth/UserAuthorization'; import { Tokens } from '../config/Tokens'; +import { FapDataSource } from '../datasources/FapDataSource'; import { InstrumentDataSource } from '../datasources/InstrumentDataSource'; import { Authorized } from '../decorators'; import { Instrument, InstrumentWithManagementTime } from '../models/Instrument'; @@ -14,7 +15,9 @@ export default class InstrumentQueries { @inject(Tokens.InstrumentDataSource) public dataSource: InstrumentDataSource, @inject(Tokens.UserAuthorization) - private userAuth: UserAuthorization + private userAuth: UserAuthorization, + @inject(Tokens.FapDataSource) + public fapDataSource: FapDataSource ) {} @Authorized() @@ -98,6 +101,24 @@ export default class InstrumentQueries { } } + @Authorized() + async getFapReviewerInstruments(agent: UserWithRole | null) { + if (!agent || !agent.id) { + return []; + } + + if (!agent.currentRole) { + return []; + } + + const faps = await this.fapDataSource.getUserFapsByRoleAndFapId( + agent.id, + agent.currentRole + ); + + return this.dataSource.getInstrumentsByFapIds(faps.map((fap) => fap.id)); + } + @Authorized() async hasInstrumentScientistInstrument( agent: UserWithRole | null, diff --git a/apps/backend/src/resolvers/queries/CallsQuery.ts b/apps/backend/src/resolvers/queries/CallsQuery.ts index 845c7a516f..701c6f3a85 100644 --- a/apps/backend/src/resolvers/queries/CallsQuery.ts +++ b/apps/backend/src/resolvers/queries/CallsQuery.ts @@ -80,4 +80,9 @@ export class CallsQuery { scientistId ); } + + @Query(() => [Call], { nullable: true }) + callsOfReviewer(@Ctx() context: ResolverContext) { + return context.queries.call.getCallsOfReviewer(context.user); + } } diff --git a/apps/backend/src/resolvers/queries/InstrumentQuery.ts b/apps/backend/src/resolvers/queries/InstrumentQuery.ts index 887c62b9c0..d69b8593dd 100644 --- a/apps/backend/src/resolvers/queries/InstrumentQuery.ts +++ b/apps/backend/src/resolvers/queries/InstrumentQuery.ts @@ -90,4 +90,11 @@ export class InstrumentQuery { proposalPk ); } + + @Query(() => [Instrument], { nullable: true }) + async getInstrumentsOfReviewer( + @Ctx() context: ResolverContext + ): Promise { + return context.queries.instrument.getFapReviewerInstruments(context.user); + } } diff --git a/apps/frontend/src/components/review/ProposalTableReviewer.tsx b/apps/frontend/src/components/review/ProposalTableReviewer.tsx index 3e96bfd751..7c6cf56855 100644 --- a/apps/frontend/src/components/review/ProposalTableReviewer.tsx +++ b/apps/frontend/src/components/review/ProposalTableReviewer.tsx @@ -22,9 +22,9 @@ import { UserRole, UserWithReviewsQuery, } from 'generated/sdk'; -import { useCallsData } from 'hooks/call/useCallsData'; +import { useReviewerCallsData } from 'hooks/call/useReviewerCallData'; import { useCheckAccess } from 'hooks/common/useCheckAccess'; -import { useInstrumentsMinimalData } from 'hooks/instrument/useInstrumentsMinimalData'; +import { useReviewerInstrumentData } from 'hooks/instrument/useReviewerInstrumentData'; import { useDownloadPDFProposal } from 'hooks/proposal/useDownloadPDFProposal'; import { useUserWithReviewsData } from 'hooks/user/useUserData'; import { capitalize, setSortDirectionOnSortField } from 'utils/helperFunctions'; @@ -83,8 +83,8 @@ const columns: ( const ProposalTableReviewer = ({ confirm }: { confirm: WithConfirmType }) => { const downloadPDFProposal = useDownloadPDFProposal(); - const { calls, loadingCalls } = useCallsData(); - const { instruments, loadingInstruments } = useInstrumentsMinimalData(); + const { calls, loadingCalls } = useReviewerCallsData(); + const { instruments, loadingInstruments } = useReviewerInstrumentData(); const { api } = useDataApiWithFeedback(); const { t } = useTranslation(); const isFapReviewer = useCheckAccess([UserRole.FAP_REVIEWER]); diff --git a/apps/frontend/src/graphql/call/getReviewerCalls.graphql b/apps/frontend/src/graphql/call/getReviewerCalls.graphql new file mode 100644 index 0000000000..ab124a9df7 --- /dev/null +++ b/apps/frontend/src/graphql/call/getReviewerCalls.graphql @@ -0,0 +1,6 @@ +query getReviewerCalls { + callsOfReviewer { + id + shortCode + } +} diff --git a/apps/frontend/src/graphql/instrument/getReviewerInstruments.graphql b/apps/frontend/src/graphql/instrument/getReviewerInstruments.graphql new file mode 100644 index 0000000000..fd37a8144a --- /dev/null +++ b/apps/frontend/src/graphql/instrument/getReviewerInstruments.graphql @@ -0,0 +1,5 @@ +query getReviewerInstruments { + getInstrumentsOfReviewer { + ...instrumentMinimal + } +} diff --git a/apps/frontend/src/hooks/call/useReviewerCallData.ts b/apps/frontend/src/hooks/call/useReviewerCallData.ts new file mode 100644 index 0000000000..4da518c015 --- /dev/null +++ b/apps/frontend/src/hooks/call/useReviewerCallData.ts @@ -0,0 +1,47 @@ +import { useEffect, useState, SetStateAction } from 'react'; + +import { Call } from 'generated/sdk'; +import { useDataApi } from 'hooks/common/useDataApi'; + +export enum CallsDataQuantity { + EXTENDED, + MINIMAL, +} + +export function useReviewerCallsData() { + const [calls, setCalls] = useState([]); + const [loadingCalls, setLoadingCalls] = useState(true); + + const api = useDataApi(); + + const setCallsWithLoading = (data: SetStateAction) => { + setLoadingCalls(true); + setCalls(data); + setLoadingCalls(false); + }; + + useEffect(() => { + let unmounted = false; + + setLoadingCalls(true); + + api() + .getReviewerCalls() + .then((data) => { + if (unmounted) { + return; + } + + if (data.callsOfReviewer) { + setCalls(data.callsOfReviewer as Call[]); + } + setLoadingCalls(false); + }); + + return () => { + unmounted = true; + }; + }, [api]); + + return { loadingCalls, calls, setCallsWithLoading }; +} diff --git a/apps/frontend/src/hooks/instrument/useReviewerInstrumentData.ts b/apps/frontend/src/hooks/instrument/useReviewerInstrumentData.ts new file mode 100644 index 0000000000..51b97fdffd --- /dev/null +++ b/apps/frontend/src/hooks/instrument/useReviewerInstrumentData.ts @@ -0,0 +1,47 @@ +import { useEffect, useState, SetStateAction, Dispatch } from 'react'; + +import { InstrumentMinimalFragment } from 'generated/sdk'; +import { useDataApi } from 'hooks/common/useDataApi'; + +export function useReviewerInstrumentData(): { + loadingInstruments: boolean; + instruments: InstrumentMinimalFragment[]; + setInstruments: Dispatch>; +} { + const api = useDataApi(); + + const [instruments, setInstruments] = useState( + [] + ); + const [loadingInstruments, setLoadingInstruments] = useState(true); + + useEffect(() => { + let unmounted = false; + + setLoadingInstruments(true); + + api() + .getReviewerInstruments() + .then((data) => { + if (unmounted) { + return; + } + + if (data.getInstrumentsOfReviewer) { + setInstruments(data.getInstrumentsOfReviewer); + } + setLoadingInstruments(false); + }); + + return () => { + // used to avoid unmounted component state update error + unmounted = true; + }; + }, [api]); + + return { + loadingInstruments, + instruments, + setInstruments, + }; +} From 49252a9828a431d3523c4a73a74e0f59c28ad3e8 Mon Sep 17 00:00:00 2001 From: Thomas Cottee Meldrum Date: Thu, 27 Nov 2025 14:06:08 +0000 Subject: [PATCH 2/6] missed mock ds --- apps/backend/src/datasources/mockups/CallDataSource.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/backend/src/datasources/mockups/CallDataSource.ts b/apps/backend/src/datasources/mockups/CallDataSource.ts index 982429fa07..09ca53980d 100644 --- a/apps/backend/src/datasources/mockups/CallDataSource.ts +++ b/apps/backend/src/datasources/mockups/CallDataSource.ts @@ -221,4 +221,8 @@ export class CallDataSourceMock implements CallDataSource { async getExperimentWorkflowByCall(callId: number): Promise { return dummyWorkflow; } + + getCallsOfFaps(fapIds: number[]): Promise { + throw new Error('Method not implemented.'); + } } From e9a27dc209f08e593287854d013f070adf0cac8b Mon Sep 17 00:00:00 2001 From: Thomas Cottee Meldrum Date: Fri, 23 Jan 2026 16:34:51 +0000 Subject: [PATCH 3/6] Get instuments from fap_proposals rather than calls_has_instruments --- .../src/datasources/postgres/InstrumentDataSource.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/apps/backend/src/datasources/postgres/InstrumentDataSource.ts b/apps/backend/src/datasources/postgres/InstrumentDataSource.ts index 9650cb37e0..eafdce770d 100644 --- a/apps/backend/src/datasources/postgres/InstrumentDataSource.ts +++ b/apps/backend/src/datasources/postgres/InstrumentDataSource.ts @@ -800,11 +800,9 @@ export default class PostgresInstrumentDataSource async getInstrumentsByFapIds(fapId: number[]): Promise { return database .select('i.*') - .from('instruments as i') - .join('call_has_instruments as chi', { - 'i.instrument_id': 'chi.instrument_id', - }) - .where('chi.fap_id', 'in', fapId) + .from('fap_proposals as fp') + .join('instruments as i', { 'fp.instrument_id': 'i.instrument_id' }) + .where('fp.fap_id', 'in', fapId) .distinct() .then((instruments: InstrumentRecord[]) => { const result = instruments.map((instrument) => From 0ef05fe1cc2d4efcc869a14073569f46ffb34401 Mon Sep 17 00:00:00 2001 From: Thomas Cottee Meldrum Date: Fri, 20 Feb 2026 14:12:56 +0000 Subject: [PATCH 4/6] clean up --- apps/backend/src/queries/CallQueries.ts | 7 ++----- apps/backend/src/queries/InstrumentQueries.ts | 6 +----- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/apps/backend/src/queries/CallQueries.ts b/apps/backend/src/queries/CallQueries.ts index 4069653c4d..220bb5fca4 100644 --- a/apps/backend/src/queries/CallQueries.ts +++ b/apps/backend/src/queries/CallQueries.ts @@ -61,12 +61,9 @@ export default class CallQueries { return this.dataSource.getCallByAnswerIdProposal(answerId); } + @Authorized() async getCallsOfReviewer(agent: UserWithRole | null) { - if (!agent || !agent.id) { - return []; - } - - if (!agent.currentRole) { + if (!agent || !agent.id || !agent.currentRole) { return []; } diff --git a/apps/backend/src/queries/InstrumentQueries.ts b/apps/backend/src/queries/InstrumentQueries.ts index fae23523bf..f010b393c5 100644 --- a/apps/backend/src/queries/InstrumentQueries.ts +++ b/apps/backend/src/queries/InstrumentQueries.ts @@ -103,11 +103,7 @@ export default class InstrumentQueries { @Authorized() async getFapReviewerInstruments(agent: UserWithRole | null) { - if (!agent || !agent.id) { - return []; - } - - if (!agent.currentRole) { + if (!agent || !agent.id || !agent.currentRole) { return []; } From 17d0854c8d3f64cb05bb1ae3b11eaf54eeefcdf7 Mon Sep 17 00:00:00 2001 From: Thomas Cottee Meldrum Date: Fri, 20 Feb 2026 14:14:44 +0000 Subject: [PATCH 5/6] Update authorizers --- apps/backend/src/queries/CallQueries.ts | 2 +- apps/backend/src/queries/InstrumentQueries.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/backend/src/queries/CallQueries.ts b/apps/backend/src/queries/CallQueries.ts index 220bb5fca4..c6e80c5305 100644 --- a/apps/backend/src/queries/CallQueries.ts +++ b/apps/backend/src/queries/CallQueries.ts @@ -61,7 +61,7 @@ export default class CallQueries { return this.dataSource.getCallByAnswerIdProposal(answerId); } - @Authorized() + @Authorized([Roles.FAP_REVIEWER, Roles.FAP_CHAIR, Roles.FAP_SECRETARY]) async getCallsOfReviewer(agent: UserWithRole | null) { if (!agent || !agent.id || !agent.currentRole) { return []; diff --git a/apps/backend/src/queries/InstrumentQueries.ts b/apps/backend/src/queries/InstrumentQueries.ts index f010b393c5..cab45b926c 100644 --- a/apps/backend/src/queries/InstrumentQueries.ts +++ b/apps/backend/src/queries/InstrumentQueries.ts @@ -101,7 +101,7 @@ export default class InstrumentQueries { } } - @Authorized() + @Authorized([Roles.FAP_REVIEWER, Roles.FAP_CHAIR, Roles.FAP_SECRETARY]) async getFapReviewerInstruments(agent: UserWithRole | null) { if (!agent || !agent.id || !agent.currentRole) { return []; From 9929dde601838493a25f0ce18d29b9a010e9abe7 Mon Sep 17 00:00:00 2001 From: Thomas Cottee Meldrum Date: Fri, 27 Feb 2026 15:41:10 +0000 Subject: [PATCH 6/6] remove auth from fap roles for instruments query --- apps/backend/src/queries/InstrumentQueries.ts | 8 +------- .../src/hooks/instrument/useInstrumentsMinimalData.ts | 8 +------- 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/apps/backend/src/queries/InstrumentQueries.ts b/apps/backend/src/queries/InstrumentQueries.ts index cab45b926c..ab932ae427 100644 --- a/apps/backend/src/queries/InstrumentQueries.ts +++ b/apps/backend/src/queries/InstrumentQueries.ts @@ -35,13 +35,7 @@ export default class InstrumentQueries { return await this.dataSource.getInstrumentsByIds(instrumentIds); } - @Authorized([ - Roles.USER_OFFICER, - Roles.FAP_REVIEWER, - Roles.FAP_CHAIR, - Roles.FAP_SECRETARY, - Roles.INSTRUMENT_SCIENTIST, - ]) + @Authorized([Roles.USER_OFFICER, Roles.INSTRUMENT_SCIENTIST]) async getAll(agent: UserWithRole | null, callIds: number[]) { if (!callIds || callIds.length === 0) { return await this.dataSource.getInstruments(); diff --git a/apps/frontend/src/hooks/instrument/useInstrumentsMinimalData.ts b/apps/frontend/src/hooks/instrument/useInstrumentsMinimalData.ts index c6b0f2ddcc..851b2a23ea 100644 --- a/apps/frontend/src/hooks/instrument/useInstrumentsMinimalData.ts +++ b/apps/frontend/src/hooks/instrument/useInstrumentsMinimalData.ts @@ -29,13 +29,7 @@ export function useInstrumentsMinimalData(callIds?: number[]): { setLoadingInstruments(true); if ( currentRole && - [ - UserRole.USER_OFFICER, - UserRole.FAP_REVIEWER, - UserRole.FAP_CHAIR, - UserRole.FAP_SECRETARY, - UserRole.USER, - ].includes(currentRole) + [UserRole.USER_OFFICER, UserRole.USER].includes(currentRole) ) { api() .getInstrumentsMinimal({ callIds })