diff --git a/apps/backend/db_patches/0213_AddDataAccessClaims.sql b/apps/backend/db_patches/0213_AddDataAccessClaims.sql new file mode 100644 index 0000000000..ffdab2a278 --- /dev/null +++ b/apps/backend/db_patches/0213_AddDataAccessClaims.sql @@ -0,0 +1,14 @@ +DO +$$ +BEGIN + IF register_patch('0213_AddDataAccessClaims', 'Shivam K', 'Adding data access user claims', '2026-06-26') THEN + BEGIN + CREATE TABLE IF NOT EXISTS data_access_claims ( + invite_id INT NOT NULL REFERENCES invites(invite_id) ON DELETE CASCADE, + proposal_pk INT NOT NULL REFERENCES proposals(proposal_pk) ON DELETE CASCADE, + PRIMARY KEY (invite_id, proposal_pk)); + END; + END IF; +END; +$$ +LANGUAGE plpgsql; diff --git a/apps/backend/src/config/Tokens.ts b/apps/backend/src/config/Tokens.ts index d5a07cacc8..dcf1f1479f 100644 --- a/apps/backend/src/config/Tokens.ts +++ b/apps/backend/src/config/Tokens.ts @@ -6,6 +6,7 @@ export const Tokens = { ConfigureEnvironment: Symbol('ConfigureEnvironment'), ConfigureLogger: Symbol('ConfigureLogger'), CoProposerClaimDataSource: Symbol('CoProposerClaimDataSource'), + DataAccessClaimDataSource: Symbol('DataAccessClaimDataSource'), DataAccessUsersDataSource: Symbol('DataAccessUsersDataSource'), DataAccessUsersAuthorization: Symbol('DataAccessUsersAuthorization'), EmailEventHandler: Symbol('EmailEventHandler'), diff --git a/apps/backend/src/config/dependencyConfigDLS.ts b/apps/backend/src/config/dependencyConfigDLS.ts index 0e506f2ec2..962daf5345 100644 --- a/apps/backend/src/config/dependencyConfigDLS.ts +++ b/apps/backend/src/config/dependencyConfigDLS.ts @@ -13,6 +13,7 @@ import { VisitRegistrationAuthorization } from '../auth/VisitRegistrationAuthori import { PostgresAdminDataSourceWithAutoUpgrade } from '../datasources/postgres/AdminDataSource'; import PostgresCallDataSource from '../datasources/postgres/CallDataSource'; import PostgresCoProposerClaimDataSource from '../datasources/postgres/CoProposerClaimDataSource'; +import PostgresDataAccessClaimDataSource from '../datasources/postgres/DataAccessClaimDataSource'; import PostgresDataAccessUsersDataSource from '../datasources/postgres/DataAccessUsersDataSource'; import PostgresEmailTemplateDataSource from '../datasources/postgres/EmailTemplateDataSource'; import PostgresEventLogsDataSource from '../datasources/postgres/EventLogsDataSource'; @@ -66,6 +67,7 @@ import { SkipAssetRegistrar } from '../services/assetRegistrar/skip/SkipAssetReg mapClass(Tokens.AdminDataSource, PostgresAdminDataSourceWithAutoUpgrade); mapClass(Tokens.CoProposerClaimDataSource, PostgresCoProposerClaimDataSource); +mapClass(Tokens.DataAccessClaimDataSource, PostgresDataAccessClaimDataSource); mapClass(Tokens.DataAccessUsersDataSource, PostgresDataAccessUsersDataSource); mapClass(Tokens.CallDataSource, PostgresCallDataSource); mapClass(Tokens.EventLogsDataSource, PostgresEventLogsDataSource); diff --git a/apps/backend/src/config/dependencyConfigDefault.ts b/apps/backend/src/config/dependencyConfigDefault.ts index cf2ed45df2..8ea42876ec 100644 --- a/apps/backend/src/config/dependencyConfigDefault.ts +++ b/apps/backend/src/config/dependencyConfigDefault.ts @@ -16,6 +16,7 @@ import { mapClass, mapValue } from './utils'; import { PostgresAdminDataSourceWithAutoUpgrade } from '../datasources/postgres/AdminDataSource'; import PostgresCallDataSource from '../datasources/postgres/CallDataSource'; import PostgresCoProposerClaimDataSource from '../datasources/postgres/CoProposerClaimDataSource'; +import PostgresDataAccessClaimDataSource from '../datasources/postgres/DataAccessClaimDataSource'; import PostgresDataAccessUsersDataSource from '../datasources/postgres/DataAccessUsersDataSource'; import PostgresEmailTemplateDataSource from '../datasources/postgres/EmailTemplateDataSource'; import PostgresEventLogsDataSource from '../datasources/postgres/EventLogsDataSource'; @@ -75,6 +76,7 @@ async function skipEmailHandler(event: ApplicationEvent) { mapClass(Tokens.RoleDataSource, PostgresRoleDataSource); mapClass(Tokens.AdminDataSource, PostgresAdminDataSourceWithAutoUpgrade); mapClass(Tokens.CoProposerClaimDataSource, PostgresCoProposerClaimDataSource); +mapClass(Tokens.DataAccessClaimDataSource, PostgresDataAccessClaimDataSource); mapClass(Tokens.DataAccessUsersDataSource, PostgresDataAccessUsersDataSource); mapClass(Tokens.CallDataSource, PostgresCallDataSource); mapClass(Tokens.EventLogsDataSource, PostgresEventLogsDataSource); diff --git a/apps/backend/src/config/dependencyConfigE2E.ts b/apps/backend/src/config/dependencyConfigE2E.ts index a1615819b4..9304749d7c 100644 --- a/apps/backend/src/config/dependencyConfigE2E.ts +++ b/apps/backend/src/config/dependencyConfigE2E.ts @@ -13,6 +13,7 @@ import { mapClass, mapValue } from './utils'; import PostgresAdminDataSource from '../datasources/postgres/AdminDataSource'; import PostgresCallDataSource from '../datasources/postgres/CallDataSource'; import PostgresCoProposerClaimDataSource from '../datasources/postgres/CoProposerClaimDataSource'; +import PostgresDataAccessClaimDataSource from '../datasources/postgres/DataAccessClaimDataSource'; import PostgresDataAccessUsersDataSource from '../datasources/postgres/DataAccessUsersDataSource'; import PostgresEmailTemplateDataSource from '../datasources/postgres/EmailTemplateDataSource'; import PostgresEventLogsDataSource from '../datasources/postgres/EventLogsDataSource'; @@ -67,6 +68,7 @@ import { SkipAssetRegistrar } from '../services/assetRegistrar/skip/SkipAssetReg mapClass(Tokens.RoleDataSource, PostgresRoleDataSource); mapClass(Tokens.AdminDataSource, PostgresAdminDataSource); mapClass(Tokens.CoProposerClaimDataSource, PostgresCoProposerClaimDataSource); +mapClass(Tokens.DataAccessClaimDataSource, PostgresDataAccessClaimDataSource); mapClass(Tokens.DataAccessUsersDataSource, PostgresDataAccessUsersDataSource); mapClass(Tokens.CallDataSource, PostgresCallDataSource); mapClass(Tokens.EventLogsDataSource, PostgresEventLogsDataSource); diff --git a/apps/backend/src/config/dependencyConfigELI.ts b/apps/backend/src/config/dependencyConfigELI.ts index e46324bae6..ff5e9f204b 100644 --- a/apps/backend/src/config/dependencyConfigELI.ts +++ b/apps/backend/src/config/dependencyConfigELI.ts @@ -13,6 +13,7 @@ import { mapClass, mapValue } from './utils'; import { PostgresAdminDataSourceWithAutoUpgrade } from '../datasources/postgres/AdminDataSource'; import PostgresCallDataSource from '../datasources/postgres/CallDataSource'; import PostgresCoProposerClaimDataSource from '../datasources/postgres/CoProposerClaimDataSource'; +import PostgresDataAccessClaimDataSource from '../datasources/postgres/DataAccessClaimDataSource'; import PostgresDataAccessUsersDataSource from '../datasources/postgres/DataAccessUsersDataSource'; import PostgresEmailTemplateDataSource from '../datasources/postgres/EmailTemplateDataSource'; import PostgresEventLogsDataSource from '../datasources/postgres/EventLogsDataSource'; @@ -69,6 +70,7 @@ const isProduction = process.env.NODE_ENV === 'production'; mapClass(Tokens.RoleDataSource, PostgresRoleDataSource); mapClass(Tokens.AdminDataSource, PostgresAdminDataSourceWithAutoUpgrade); mapClass(Tokens.CoProposerClaimDataSource, PostgresCoProposerClaimDataSource); +mapClass(Tokens.DataAccessClaimDataSource, PostgresDataAccessClaimDataSource); mapClass(Tokens.DataAccessUsersDataSource, PostgresDataAccessUsersDataSource); mapClass(Tokens.CallDataSource, PostgresCallDataSource); mapClass(Tokens.EventLogsDataSource, PostgresEventLogsDataSource); diff --git a/apps/backend/src/config/dependencyConfigESS.ts b/apps/backend/src/config/dependencyConfigESS.ts index 9f8d4927e4..ce699b69c4 100644 --- a/apps/backend/src/config/dependencyConfigESS.ts +++ b/apps/backend/src/config/dependencyConfigESS.ts @@ -10,6 +10,7 @@ import { getSecondsFromAllocationTimeUnits } from './base/allocationTimeUnitConv import { PostgresAdminDataSourceWithAutoUpgrade } from '../datasources/postgres/AdminDataSource'; import PostgresCallDataSource from '../datasources/postgres/CallDataSource'; import PostgresCoProposerClaimDataSource from '../datasources/postgres/CoProposerClaimDataSource'; +import PostgresDataAccessClaimDataSource from '../datasources/postgres/DataAccessClaimDataSource'; import PostgresDataAccessUsersDataSource from '../datasources/postgres/DataAccessUsersDataSource'; import PostgresEmailTemplateDataSource from '../datasources/postgres/EmailTemplateDataSource'; import PostgresEventLogsDataSource from '../datasources/postgres/EventLogsDataSource'; @@ -69,6 +70,7 @@ import { mapClass, mapValue } from './utils'; mapClass(Tokens.RoleDataSource, PostgresRoleDataSource); mapClass(Tokens.AdminDataSource, PostgresAdminDataSourceWithAutoUpgrade); mapClass(Tokens.CoProposerClaimDataSource, PostgresCoProposerClaimDataSource); +mapClass(Tokens.DataAccessClaimDataSource, PostgresDataAccessClaimDataSource); mapClass(Tokens.DataAccessUsersDataSource, PostgresDataAccessUsersDataSource); mapClass(Tokens.CallDataSource, PostgresCallDataSource); mapClass(Tokens.EventLogsDataSource, PostgresEventLogsDataSource); diff --git a/apps/backend/src/config/dependencyConfigSTFC.ts b/apps/backend/src/config/dependencyConfigSTFC.ts index 41f88fcc67..cc7408ae96 100644 --- a/apps/backend/src/config/dependencyConfigSTFC.ts +++ b/apps/backend/src/config/dependencyConfigSTFC.ts @@ -13,6 +13,7 @@ import { mapClass, mapValue } from './utils'; import { PostgresAdminDataSourceWithAutoUpgrade } from '../datasources/postgres/AdminDataSource'; import PostgresCallDataSource from '../datasources/postgres/CallDataSource'; import PostgresCoProposerClaimDataSource from '../datasources/postgres/CoProposerClaimDataSource'; +import PostgresDataAccessClaimDataSource from '../datasources/postgres/DataAccessClaimDataSource'; import PostgresDataAccessUsersDataSource from '../datasources/postgres/DataAccessUsersDataSource'; import PostgresEmailTemplateDataSource from '../datasources/postgres/EmailTemplateDataSource'; import PostgresEventLogsDataSource from '../datasources/postgres/EventLogsDataSource'; @@ -67,6 +68,7 @@ import { SkipAssetRegistrar } from '../services/assetRegistrar/skip/SkipAssetReg mapClass(Tokens.RoleDataSource, PostgresRoleDataSource); mapClass(Tokens.AdminDataSource, PostgresAdminDataSourceWithAutoUpgrade); mapClass(Tokens.CoProposerClaimDataSource, PostgresCoProposerClaimDataSource); +mapClass(Tokens.DataAccessClaimDataSource, PostgresDataAccessClaimDataSource); mapClass(Tokens.DataAccessUsersDataSource, PostgresDataAccessUsersDataSource); mapClass(Tokens.CallDataSource, PostgresCallDataSource); mapClass(Tokens.EventLogsDataSource, PostgresEventLogsDataSource); diff --git a/apps/backend/src/config/dependencyConfigTest.ts b/apps/backend/src/config/dependencyConfigTest.ts index e73cedefae..38e5f90d41 100644 --- a/apps/backend/src/config/dependencyConfigTest.ts +++ b/apps/backend/src/config/dependencyConfigTest.ts @@ -12,6 +12,7 @@ import { getSecondsFromAllocationTimeUnits } from './base/allocationTimeUnitConv import { AdminDataSourceMock } from '../datasources/mockups/AdminDataSource'; import { CallDataSourceMock } from '../datasources/mockups/CallDataSource'; import { CoProposerClaimDataSourceMock } from '../datasources/mockups/CoProposerClaimDataSource'; +import { DataAccessClaimDataSourceMock } from '../datasources/mockups/DataAccessClaimDataSource'; import MockDataAccessUsersDataSource from '../datasources/mockups/DataAccessUsersDataSource'; import { EmailTemplateDataSourceMock } from '../datasources/mockups/EmailTemplateDataSource'; import { EventLogsDataSourceMock } from '../datasources/mockups/EventLogsDataSource'; @@ -61,6 +62,7 @@ import { mapClass, mapValue } from './utils'; mapClass(Tokens.RoleDataSource, RoleDataSourceMock); mapClass(Tokens.AdminDataSource, AdminDataSourceMock); mapClass(Tokens.CoProposerClaimDataSource, CoProposerClaimDataSourceMock); +mapClass(Tokens.DataAccessClaimDataSource, DataAccessClaimDataSourceMock); mapClass(Tokens.DataAccessUsersDataSource, MockDataAccessUsersDataSource); mapClass(Tokens.CallDataSource, CallDataSourceMock); mapClass(Tokens.EventLogsDataSource, EventLogsDataSourceMock); diff --git a/apps/backend/src/datasources/DataAccessClaimDataSource.ts b/apps/backend/src/datasources/DataAccessClaimDataSource.ts new file mode 100644 index 0000000000..e09585677e --- /dev/null +++ b/apps/backend/src/datasources/DataAccessClaimDataSource.ts @@ -0,0 +1,7 @@ +import { DataAccessClaim } from '../models/DataAccessClaim'; + +export interface DataAccessClaimDataSource { + create(inviteId: number, proposalPk: number): Promise; + findByInviteId(inviteId: number): Promise; + findByProposalPk(proposalPk: number): Promise; +} diff --git a/apps/backend/src/datasources/DataAccessUsersDataSource.ts b/apps/backend/src/datasources/DataAccessUsersDataSource.ts index d86c1765d4..3f793ff173 100644 --- a/apps/backend/src/datasources/DataAccessUsersDataSource.ts +++ b/apps/backend/src/datasources/DataAccessUsersDataSource.ts @@ -11,12 +11,20 @@ export type UserWithInstitution = { export interface DataAccessUsersDataSource { findByProposalPk(proposalPk: number): Promise; + getDataAccessUsersWithInstitution( proposalPk: number ): Promise; + + addDataAccessUser( + proposalPk: number, + userId: number + ): Promise; + updateDataAccessUsers( proposalPk: number, userIds: number[] ): Promise; + isDataAccessUserOfProposal(id: number, proposalPk: number): Promise; } diff --git a/apps/backend/src/datasources/InviteDataSource.ts b/apps/backend/src/datasources/InviteDataSource.ts index 3eed5a0fa7..a7d0935e9b 100644 --- a/apps/backend/src/datasources/InviteDataSource.ts +++ b/apps/backend/src/datasources/InviteDataSource.ts @@ -8,7 +8,7 @@ export interface GetInvitesFilter { email?: string; } -export interface GetCoProposerInvitesFilter extends GetInvitesFilter { +export interface GetProposalInvitesFilter extends GetInvitesFilter { proposalPk?: number; } @@ -27,12 +27,17 @@ export interface InviteDataSource { proposalPk: number, isClaimed?: boolean ): Promise; + findDataAccessInvites( + proposalPk: number, + isClaimed?: boolean + ): Promise; findVisitRegistrationInvites( visitId: number, isClaimed?: boolean ): Promise; getInvites(filter: GetInvitesFilter): Promise; - getCoProposerInvites(filter: GetCoProposerInvitesFilter): Promise; + getCoProposerInvites(filter: GetProposalInvitesFilter): Promise; + getDataAccessInvites(filter: GetProposalInvitesFilter): Promise; update(args: { id: number; diff --git a/apps/backend/src/datasources/mockups/DataAccessClaimDataSource.ts b/apps/backend/src/datasources/mockups/DataAccessClaimDataSource.ts new file mode 100644 index 0000000000..42c563d742 --- /dev/null +++ b/apps/backend/src/datasources/mockups/DataAccessClaimDataSource.ts @@ -0,0 +1,32 @@ +import { DataAccessClaim } from '../../models/DataAccessClaim'; +import { DataAccessClaimDataSource } from '../DataAccessClaimDataSource'; + +export class DataAccessClaimDataSourceMock + implements DataAccessClaimDataSource +{ + private invites: DataAccessClaim[] = []; + + init() { + this.invites = [ + new DataAccessClaim(7, 1), // use IDs unique from coproposal invites + new DataAccessClaim(8, 2), + new DataAccessClaim(9, 3), + ]; + } + + async findByProposalPk(proposalPk: number): Promise { + return this.invites.filter((invite) => invite.proposalPk === proposalPk); + } + + async findByInviteId(inviteId: number): Promise { + return this.invites.filter((invite) => invite.inviteId === inviteId); + } + + async create(inviteId: number, proposalPk: number): Promise { + const newInvite = new DataAccessClaim(inviteId, proposalPk); + + this.invites.push(newInvite); + + return newInvite; + } +} diff --git a/apps/backend/src/datasources/mockups/DataAccessUsersDataSource.ts b/apps/backend/src/datasources/mockups/DataAccessUsersDataSource.ts index b06f3931a2..47ddb65e9f 100644 --- a/apps/backend/src/datasources/mockups/DataAccessUsersDataSource.ts +++ b/apps/backend/src/datasources/mockups/DataAccessUsersDataSource.ts @@ -46,6 +46,7 @@ export const dummyInstitution2 = new Institution(11, 'Research Center', 2); export const dummyCountry = new Country(1, 'Denmark'); export const dummyCountry2 = new Country(2, 'United Kingdom'); +export const dummyCountry3 = new Country(3, 'Belarus'); export default class MockDataAccessUsersDataSource implements DataAccessUsersDataSource @@ -121,4 +122,33 @@ export default class MockDataAccessUsersDataSource return Promise.resolve(userIds.includes(id)); } + + async addDataAccessUser( + proposalPk: number, + userId: number + ): Promise { + try { + const existingUserIds = this.mockData.get(proposalPk) || []; + + // Idempotent insert - don't add the same user twice + if (!existingUserIds.includes(userId)) { + this.mockData.set(proposalPk, [...existingUserIds, userId]); + } + + const users = await this.findByProposalPk(proposalPk); + const addedUser = users.find((user) => user.id === userId); + + if (!addedUser) { + throw new Error(`No mock user found for id ${userId}`); + } + + return; + } catch (error) { + return new Rejection('Failed to add data access user', { + proposalPk, + userId, + error: error instanceof Error ? error.message : String(error), + }); + } + } } diff --git a/apps/backend/src/datasources/mockups/InviteDataSource.ts b/apps/backend/src/datasources/mockups/InviteDataSource.ts index dc42b8a866..c6f40142d4 100644 --- a/apps/backend/src/datasources/mockups/InviteDataSource.ts +++ b/apps/backend/src/datasources/mockups/InviteDataSource.ts @@ -3,10 +3,12 @@ import { inject, injectable } from 'tsyringe'; import { Tokens } from '../../config/Tokens'; import { EmailTemplateId } from '../../eventHandlers/email/emailTemplateId'; import { CoProposerClaim } from '../../models/CoProposerClaim'; +import { DataAccessClaim } from '../../models/DataAccessClaim'; import { Invite } from '../../models/Invite'; import { CoProposerClaimDataSource } from '../CoProposerClaimDataSource'; +import { DataAccessClaimDataSource } from '../DataAccessClaimDataSource'; import { - GetCoProposerInvitesFilter, + GetProposalInvitesFilter, GetInvitesFilter, InviteDataSource, } from '../InviteDataSource'; @@ -15,10 +17,13 @@ import { export class InviteDataSourceMock implements InviteDataSource { private invites: Invite[]; private coProposerClaims: CoProposerClaim[]; + private dataAccessClaims: DataAccessClaim[]; constructor( @inject(Tokens.CoProposerClaimDataSource) - private coProposerDataSource: CoProposerClaimDataSource + private coProposerDataSource: CoProposerClaimDataSource, + @inject(Tokens.DataAccessClaimDataSource) + private dataAccessDataSource: DataAccessClaimDataSource ) { this.init(); } @@ -33,6 +38,18 @@ export class InviteDataSourceMock implements InviteDataSource { return invites.filter((invite) => invite !== null) as Invite[]; } + + async findDataAccessInvites(proposalPk: number): Promise { + const dataAccessClaims = + await this.dataAccessDataSource.findByProposalPk(proposalPk); + + const invites = await Promise.all( + dataAccessClaims.map((claim) => this.findById(claim.inviteId)) + ); + + return invites.filter((invite) => invite !== null) as Invite[]; + } + async findVisitRegistrationInvites( visitId: number, includeExpired: boolean @@ -91,9 +108,22 @@ export class InviteDataSourceMock implements InviteDataSource { new Date('2022-01-01'), EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_REVIEWER ), + new Invite( + 4, + 'data-invite', + 'test_dau@example.com', + new Date(), + 3, + new Date(), + null, + false, + new Date('2022-01-01'), + EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_DATA_ACCESS_USER + ), ]; this.coProposerClaims = [new CoProposerClaim(2, 1)]; + this.dataAccessClaims = [new DataAccessClaim(4, 1)]; } async findByCode(code: string): Promise { @@ -185,7 +215,8 @@ export class InviteDataSourceMock implements InviteDataSource { return invite; } - getCoProposerInvites(filter: GetCoProposerInvitesFilter): Promise { + + getCoProposerInvites(filter: GetProposalInvitesFilter): Promise { return new Promise((resolve) => { const filteredInvites = this.invites.filter((invite) => { if (filter.createdBefore) { @@ -237,4 +268,57 @@ export class InviteDataSourceMock implements InviteDataSource { resolve(filteredInvites); }); } + + getDataAccessInvites(filter: GetProposalInvitesFilter): Promise { + return new Promise((resolve) => { + const filteredInvites = this.invites.filter((invite) => { + if (filter.createdBefore) { + if (invite.createdAt >= filter.createdBefore) { + return false; + } + } + + if (filter.createdAfter) { + if (invite.createdAt <= filter.createdAfter) { + return false; + } + } + + if (filter.isClaimed !== undefined) { + if (invite.claimedAt === null && filter.isClaimed) { + return false; + } + if (invite.claimedAt !== null && !filter.isClaimed) { + return false; + } + } + + if (filter.isExpired) { + if (invite.expiresAt && invite.expiresAt < new Date()) { + return false; + } + } + + if (filter.email) { + if (invite.email !== filter.email) { + return false; + } + } + + if (filter.proposalPk) { + const inviteIds = this.dataAccessClaims + .filter((claim) => claim.proposalPk === filter.proposalPk) + .map((claim) => claim.inviteId); + + if (!inviteIds.includes(invite.id)) { + return false; + } + } + + return true; + }); + + resolve(filteredInvites); + }); + } } diff --git a/apps/backend/src/datasources/postgres/DataAccessClaimDataSource.ts b/apps/backend/src/datasources/postgres/DataAccessClaimDataSource.ts new file mode 100644 index 0000000000..4b17a67ab8 --- /dev/null +++ b/apps/backend/src/datasources/postgres/DataAccessClaimDataSource.ts @@ -0,0 +1,37 @@ +import { DataAccessClaim } from '../../models/DataAccessClaim'; +import { DataAccessClaimDataSource } from '../DataAccessClaimDataSource'; +import database from './database'; +import { DataAccessClaimRecord } from './records'; + +export default class PostgresDataAccessClaimDataSource + implements DataAccessClaimDataSource +{ + async findByProposalPk(proposalPk: number): Promise { + return database('data_access_claims') + .where({ proposal_pk: proposalPk }) + .select('*') + .then((rows: DataAccessClaimRecord[]) => { + return rows.map((row) => { + return new DataAccessClaim(row.invite_id, row.proposal_pk); + }); + }); + } + async findByInviteId(inviteId: number): Promise { + return database('data_access_claims') + .where({ invite_id: inviteId }) + .select('*') + .then((rows) => + rows.map((row) => new DataAccessClaim(row.invite_id, row.proposal_pk)) + ); + } + async create(inviteId: number, proposalPk: number): Promise { + return database('data_access_claims') + .insert({ invite_id: inviteId, proposal_pk: proposalPk }) + .returning('*') + .then((rows) => { + const row = rows[0]; + + return new DataAccessClaim(row.invite_id, row.proposal_pk); + }); + } +} diff --git a/apps/backend/src/datasources/postgres/DataAccessUsersDataSource.ts b/apps/backend/src/datasources/postgres/DataAccessUsersDataSource.ts index fbe6fce097..93206991fc 100644 --- a/apps/backend/src/datasources/postgres/DataAccessUsersDataSource.ts +++ b/apps/backend/src/datasources/postgres/DataAccessUsersDataSource.ts @@ -22,11 +22,11 @@ export default class PostgresDataAccessUsersDataSource return database .select() .from('users as u') - .join('institutions as i', { 'u.institution_id': 'i.institution_id' }) - .join('countries as c', { 'i.country_id': 'c.country_id' }) .join('data_access_user_has_proposal as dauhp', { 'u.user_id': 'dauhp.user_id', }) + .leftJoin('institutions as i', { 'u.institution_id': 'i.institution_id' }) + .leftJoin('countries as c', { 'i.country_id': 'c.country_id' }) .where('dauhp.proposal_pk', proposalPk) .then((users: Array) => users.map((user) => createBasicUserObject(user)) @@ -90,6 +90,31 @@ export default class PostgresDataAccessUsersDataSource } } + async addDataAccessUser( + proposalPk: number, + userId: number + ): Promise { + try { + await database.transaction(async (trx) => { + const insertData = { + proposal_pk: proposalPk, + user_id: userId, + }; + + await database('data_access_user_has_proposal') + .insert(insertData) + .onConflict(['proposal_pk', 'user_id']) + .ignore(); + }); + } catch (error) { + return new Rejection('Failed to add data access user', { + proposalPk, + userId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + async isDataAccessUserOfProposal( id: number, proposalPk: number diff --git a/apps/backend/src/datasources/postgres/InviteDataSource.ts b/apps/backend/src/datasources/postgres/InviteDataSource.ts index 30d52112a6..22a81911aa 100644 --- a/apps/backend/src/datasources/postgres/InviteDataSource.ts +++ b/apps/backend/src/datasources/postgres/InviteDataSource.ts @@ -2,7 +2,7 @@ import { Invite } from '../../models/Invite'; import { - GetCoProposerInvitesFilter, + GetProposalInvitesFilter, GetInvitesFilter, InviteDataSource, } from '../InviteDataSource'; @@ -33,6 +33,30 @@ export default class PostgresInviteDataSource implements InviteDataSource { }) .then((invites: InviteRecord[]) => invites.map(createInviteObject)); } + findDataAccessInvites( + proposalPk: number, + isClaimed?: boolean + ): Promise { + return database + .select('*') + .from('data_access_claims') + .where('proposal_pk', proposalPk) + .modify((query) => { + if (isClaimed !== undefined) { + if (isClaimed) { + query.whereNotNull('claimed_at'); + } else { + query.whereNull('claimed_at'); + } + } + }) + + .leftJoin('invites', 'data_access_claims.invite_id', 'invites.invite_id') + .catch((error: Error) => { + throw new Error(`Could not find invites: ${error.message}`); + }) + .then((invites: InviteRecord[]) => invites.map(createInviteObject)); + } findVisitRegistrationInvites( visitId: number, isClaimed?: boolean @@ -111,7 +135,7 @@ export default class PostgresInviteDataSource implements InviteDataSource { .then((invites: InviteRecord[]) => invites.map(createInviteObject)); } - getCoProposerInvites(filter: GetCoProposerInvitesFilter): Promise { + getCoProposerInvites(filter: GetProposalInvitesFilter): Promise { return database .select('*') .from('invites') @@ -152,6 +176,47 @@ export default class PostgresInviteDataSource implements InviteDataSource { .then((invites: InviteRecord[]) => invites.map(createInviteObject)); } + getDataAccessInvites(filter: GetProposalInvitesFilter): Promise { + return database + .select('*') + .from('invites') + .join( + 'data_access_claims', + 'invites.invite_id', + 'data_access_claims.invite_id' + ) + .modify((query) => { + if (filter.createdBefore) { + query.where('created_at', '<', filter.createdBefore); + } + + if (filter.createdAfter) { + query.where('created_at', '>', filter.createdAfter); + } + + if (filter.isClaimed !== undefined) { + if (filter.isClaimed) { + query.whereNotNull('claimed_at'); + } else { + query.whereNull('claimed_at'); + } + } + + if (filter.isExpired) { + query.where('expires_at', '<', new Date()); + } + + if (filter.email) { + query.whereRaw('lower(email) = ?', filter.email.toLowerCase()); + } + + if (filter.proposalPk) { + query.where('data_access_claims.proposal_pk', filter.proposalPk); + } + }) + .then((invites: InviteRecord[]) => invites.map(createInviteObject)); + } + async create(args: { code: string; email: string; diff --git a/apps/backend/src/datasources/postgres/ProposalDataSource.ts b/apps/backend/src/datasources/postgres/ProposalDataSource.ts index de29890a80..795897b8d7 100644 --- a/apps/backend/src/datasources/postgres/ProposalDataSource.ts +++ b/apps/backend/src/datasources/postgres/ProposalDataSource.ts @@ -1419,22 +1419,31 @@ export default class PostgresProposalDataSource implements ProposalDataSource { } async getInvitedProposal(inviteId: number): Promise { - const proposals: InvitedProposalRecord[] | undefined = await database + const proposals: InvitedProposalRecord[] = await database .select( 'proposals.proposal_id', 'proposer.firstname as proposer_name', 'proposals.abstract', 'proposals.title' ) - .from('co_proposer_claims') - .join('proposals', { - 'co_proposer_claims.proposal_pk': 'proposals.proposal_pk', - }) + .from('proposals') .join('users as proposer', { 'proposals.proposer_id': 'proposer.user_id', }) - .where('invite_id', inviteId); + .whereIn('proposals.proposal_pk', (builder) => { + builder + .select('proposal_pk') + .from('co_proposer_claims') + .where('invite_id', inviteId) + .union((qb) => { + qb.select('proposal_pk') + .from('data_access_claims') + .where('invite_id', inviteId); + }); + }); - return proposals ? createInvitedProposalObject(proposals[0]) : null; + return proposals.length > 0 + ? createInvitedProposalObject(proposals[0]) + : null; } } diff --git a/apps/backend/src/datasources/postgres/records.ts b/apps/backend/src/datasources/postgres/records.ts index b14d371ac4..959f3904e1 100644 --- a/apps/backend/src/datasources/postgres/records.ts +++ b/apps/backend/src/datasources/postgres/records.ts @@ -9,6 +9,7 @@ import { } from '../../models/ConditionEvaluator'; import { CoProposerClaim } from '../../models/CoProposerClaim'; import { Country } from '../../models/Country'; +import { DataAccessClaim } from '../../models/DataAccessClaim'; import { EmailTemplate } from '../../models/EmailTemplate'; import { Experiment, ExperimentStatus } from '../../models/Experiment'; import { ExperimentSafetyPdfTemplate } from '../../models/ExperimentSafetyPdfTemplate'; @@ -1464,6 +1465,14 @@ export interface CoProposerClaimRecord { export const createCoProposerClaimRecord = (invite: CoProposerClaimRecord) => new CoProposerClaim(invite.invite_id, invite.proposal_pk); +export interface DataAccessClaimRecord { + readonly invite_id: number; + readonly proposal_pk: number; +} + +export const createDataAccessClaimRecord = (invite: DataAccessClaimRecord) => + new DataAccessClaim(invite.invite_id, invite.proposal_pk); + export interface TagRecord { readonly tag_id: number; readonly name: string; diff --git a/apps/backend/src/eventHandlers/email/emailTemplateId.ts b/apps/backend/src/eventHandlers/email/emailTemplateId.ts index e83c8e0853..a6037379a7 100644 --- a/apps/backend/src/eventHandlers/email/emailTemplateId.ts +++ b/apps/backend/src/eventHandlers/email/emailTemplateId.ts @@ -9,6 +9,7 @@ export enum EmailTemplateId { VISIT_REGISTRATION_APPROVED = 'visit-registration-approved', VISIT_REGISTRATION_CANCELLED = 'visit-registration-cancelled', USER_OFFICE_REGISTRATION_INVITATION_CO_PROPOSER = 'user-office-registration-invitation-co-proposer', + USER_OFFICE_REGISTRATION_INVITATION_DATA_ACCESS_USER = 'user-office-registration-invitation-data-access-user', USER_OFFICE_REGISTRATION_INVITATION_VISIT_REGISTRATION = 'user-office-registration-invitation-visit-registration', USER_OFFICE_REGISTRATION_INVITATION_REVIEWER = 'user-office-registration-invitation-reviewer', USER_OFFICE_REGISTRATION_INVITATION_USER = 'user-office-registration-invitation-user', diff --git a/apps/backend/src/eventHandlers/email/essEmailHandler.ts b/apps/backend/src/eventHandlers/email/essEmailHandler.ts index b75e1a0662..fddb22d457 100644 --- a/apps/backend/src/eventHandlers/email/essEmailHandler.ts +++ b/apps/backend/src/eventHandlers/email/essEmailHandler.ts @@ -350,6 +350,51 @@ export async function essEmailHandler(event: ApplicationEvent) { break; } + case Event.PROPOSAL_DATA_ACCESS_INVITES_UPDATED: { + const invites = event.array; + + const proposal = await proposalDataSource.get(event.proposalPKey); + if (!proposal) { + logger.logError('No proposal found when trying to send email', { + proposalPKey: event.proposalPKey, + event, + }); + + return; + } + for (const invite of invites) { + if (invite.isEmailSent) { + continue; + } + const inviter = await userDataSource.getBasicUserInfo( + invite.createdByUserId + ); + + if (!inviter) { + logger.logError('No inviter found when trying to send email', { + inviter, + event, + }); + + return; + } + + await sendInviteEmail( + invite, + inviter, + EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_DATA_ACCESS_USER, + { proposalTitle: proposal.title, proposalId: proposal.proposalId } + ).then(async () => { + await eventBus.publish({ + ...event, + type: Event.PROPOSAL_DATA_ACCESS_INVITE_SENT, + invite, + }); + }); + } + break; + } + case Event.FAP_REVIEWER_NOTIFIED: { const { id: reviewId, userID, proposalPk } = event.fapReview; const fapReviewer = await userDataSource.getUser(userID); diff --git a/apps/backend/src/eventHandlers/logging.ts b/apps/backend/src/eventHandlers/logging.ts index e6ad534b92..2afe1f3336 100644 --- a/apps/backend/src/eventHandlers/logging.ts +++ b/apps/backend/src/eventHandlers/logging.ts @@ -81,6 +81,20 @@ export default function createLoggingHandler() { break; } + case Event.PROPOSAL_DATA_ACCESS_INVITE_SENT: { + const { invite, proposalPKey } = event; + + eventLogsDataSource.set( + event.loggedInUserId, + event.type, + json, + proposalPKey.toString(), + `Data access invite issued to ${invite.email} by userId ${event.loggedInUserId}`, + event.impersonatingUserId + ); + + break; + } case Event.PROPOSAL_VISIT_REGISTRATION_INVITE_SENT: { const { invite, proposalPKey: proposalPk } = event; diff --git a/apps/backend/src/events/applicationEvents.ts b/apps/backend/src/events/applicationEvents.ts index 78af21d208..400598728e 100644 --- a/apps/backend/src/events/applicationEvents.ts +++ b/apps/backend/src/events/applicationEvents.ts @@ -237,6 +237,24 @@ interface ProposalCoProposerInviteAcceptedEvent extends GeneralEvent { proposalPKey: number; } +interface ProposalDataAccessInvitesUpdatedEvent extends GeneralEvent { + type: Event.PROPOSAL_DATA_ACCESS_INVITES_UPDATED; + array: Invite[]; + proposalPKey: number; +} + +interface ProposalDataAccessInviteSentEvent extends GeneralEvent { + type: Event.PROPOSAL_DATA_ACCESS_INVITE_SENT; + invite: Invite; + proposalPKey: number; +} + +interface ProposalDataAccessInviteAcceptedEvent extends GeneralEvent { + type: Event.PROPOSAL_DATA_ACCESS_INVITE_ACCEPTED; + invite: Invite; + proposalPKey: number; +} + interface ProposalVisitRegistrationInvitesUpdatedEvent extends GeneralEvent { type: Event.PROPOSAL_VISIT_REGISTRATION_INVITES_UPDATED; array: Invite[]; @@ -533,6 +551,9 @@ export type ApplicationEvent = | ProposalCoProposerInvitesUpdatedEvent | ProposalCoProposerInviteSentEvent | ProposalCoProposerInviteAcceptedEvent + | ProposalDataAccessInvitesUpdatedEvent + | ProposalDataAccessInviteSentEvent + | ProposalDataAccessInviteAcceptedEvent | ProposalVisitRegistrationInvitesUpdatedEvent | ProposalVisitRegistrationInviteSentEvent | ProposalVisitRegistrationInviteAcceptedEvent diff --git a/apps/backend/src/events/event.enum.ts b/apps/backend/src/events/event.enum.ts index 04013d83e4..9e87c0c54a 100644 --- a/apps/backend/src/events/event.enum.ts +++ b/apps/backend/src/events/event.enum.ts @@ -97,6 +97,9 @@ export enum Event { PROPOSAL_CO_PROPOSER_INVITES_UPDATED = 'PROPOSAL_CO_PROPOSER_INVITES_UPDATED', PROPOSAL_CO_PROPOSER_INVITE_SENT = 'PROPOSAL_CO_PROPOSER_INVITE_SENT', PROPOSAL_CO_PROPOSER_INVITE_ACCEPTED = 'PROPOSAL_CO_PROPOSER_INVITE_ACCEPTED', + PROPOSAL_DATA_ACCESS_INVITES_UPDATED = 'PROPOSAL_DATA_ACCESS_INVITES_UPDATED', + PROPOSAL_DATA_ACCESS_INVITE_SENT = 'PROPOSAL_DATA_ACCESS_INVITE_SENT', + PROPOSAL_DATA_ACCESS_INVITE_ACCEPTED = 'PROPOSAL_DATA_ACCESS_INVITE_ACCEPTED', PROPOSAL_VISIT_REGISTRATION_INVITES_UPDATED = 'PROPOSAL_VISIT_REGISTRATION_INVITES_UPDATED', PROPOSAL_VISIT_REGISTRATION_INVITE_SENT = 'PROPOSAL_VISIT_REGISTRATION_INVITE_SENT', PROPOSAL_VISIT_REGISTRATION_INVITE_ACCEPTED = 'PROPOSAL_VISIT_REGISTRATION_INVITE_ACCEPTED', @@ -462,6 +465,24 @@ export const EventMetadataByEvent = new Map([ 'Event occurs when user accepts the co-proposer claim for a proposal', }, ], + [ + Event.PROPOSAL_DATA_ACCESS_INVITES_UPDATED, + { + label: + 'Event occurs when data access user invites are updated for a proposal', + }, + ], + [ + Event.PROPOSAL_DATA_ACCESS_INVITE_SENT, + { label: 'Event occurs when data access user invite is sent to a user' }, + ], + [ + Event.PROPOSAL_DATA_ACCESS_INVITE_ACCEPTED, + { + label: + 'Event occurs when user accepts the data access claim for a proposal', + }, + ], [ Event.PROPOSAL_VISIT_REGISTRATION_INVITES_UPDATED, { diff --git a/apps/backend/src/models/DataAccessClaim.ts b/apps/backend/src/models/DataAccessClaim.ts new file mode 100644 index 0000000000..705c81916b --- /dev/null +++ b/apps/backend/src/models/DataAccessClaim.ts @@ -0,0 +1,6 @@ +export class DataAccessClaim { + constructor( + public inviteId: number, + public proposalPk: number + ) {} +} diff --git a/apps/backend/src/mutations/InviteMutations.spec.ts b/apps/backend/src/mutations/InviteMutations.spec.ts index f07b4dfa1c..96ad4a1827 100644 --- a/apps/backend/src/mutations/InviteMutations.spec.ts +++ b/apps/backend/src/mutations/InviteMutations.spec.ts @@ -414,4 +414,43 @@ describe('Test Invite Mutations', () => { expect(invite).toBeInstanceOf(Rejection); expect((invite as Rejection).reason).toBe('Invite not found'); }); + + test('A user can accept valid data access invite without code', async () => { + const invite = await inviteMutations.acceptDataAccessInvite( + { ...dummyUserWithRole, email: 'test_dau@example.com' }, + 'shortCode' + ); + + expect(invite).toBeInstanceOf(Invite); + }); + + test('A user can not accept data access invite without code if email does not match', async () => { + const invite = await inviteMutations.acceptDataAccessInvite( + { ...dummyUserWithRole, email: 'mismatch@example.com' }, + 'shortCode' + ); + + expect(invite).toBeInstanceOf(Rejection); + expect((invite as Rejection).reason).toBe('Invite not found'); + }); + + test('A user can not accept data access invite without code if proposal is invalid', async () => { + const invite = await inviteMutations.acceptDataAccessInvite( + dummyUserWithRole, + 'invalid-short-code' + ); + + expect(invite).toBeInstanceOf(Rejection); + expect((invite as Rejection).reason).toBe('Proposal not found'); + }); + + test('A user cannot accept a non-existent data access invite for an existing proposal without code', async () => { + const invite = await inviteMutations.acceptDataAccessInvite( + dummyUserWithRole, + 'no-invite' + ); + + expect(invite).toBeInstanceOf(Rejection); + expect((invite as Rejection).reason).toBe('Invite not found'); + }); }); diff --git a/apps/backend/src/mutations/InviteMutations.ts b/apps/backend/src/mutations/InviteMutations.ts index 679e22b3e8..6d86f0ceda 100644 --- a/apps/backend/src/mutations/InviteMutations.ts +++ b/apps/backend/src/mutations/InviteMutations.ts @@ -1,12 +1,16 @@ +import { logger } from '@user-office-software/duo-logger'; import { GraphQLError } from 'graphql'; import { inject, injectable } from 'tsyringe'; +import { DataAccessUsersAuthorization } from '../auth/DataAccessUsersAuthorization'; import { ProposalAuthorization } from '../auth/ProposalAuthorization'; import { UserAuthorization } from '../auth/UserAuthorization'; import { VisitAuthorization } from '../auth/VisitAuthorization'; import { Tokens } from '../config/Tokens'; import { AdminDataSource } from '../datasources/AdminDataSource'; import { CoProposerClaimDataSource } from '../datasources/CoProposerClaimDataSource'; +import { DataAccessClaimDataSource } from '../datasources/DataAccessClaimDataSource'; +import { DataAccessUsersDataSource } from '../datasources/DataAccessUsersDataSource'; import { InviteDataSource } from '../datasources/InviteDataSource'; import database from '../datasources/postgres/database'; import { ProposalDataSource } from '../datasources/ProposalDataSource'; @@ -24,6 +28,7 @@ import { Role, Roles } from '../models/Role'; import { SettingsId } from '../models/Settings'; import { UserRole, UserWithRole } from '../models/User'; import { SetCoProposerInvitesInput } from '../resolvers/mutations/SetCoProposerInvitesMutation'; +import { SetDataAccessInvitesInput } from '../resolvers/mutations/SetDataAccessInvitesMutation'; @injectable() export default class InviteMutations { @@ -38,12 +43,18 @@ export default class InviteMutations { private roleClaimDataSource: RoleClaimDataSource, @inject(Tokens.CoProposerClaimDataSource) private coProposerClaimDataSource: CoProposerClaimDataSource, + @inject(Tokens.DataAccessClaimDataSource) + private dataAccessClaimDataSource: DataAccessClaimDataSource, + @inject(Tokens.DataAccessUsersDataSource) + private dataAccessUsersDataSource: DataAccessUsersDataSource, @inject(Tokens.VisitRegistrationClaimDataSource) private visitRegistrationClaimDataSource: VisitRegistrationClaimDataSource, @inject(Tokens.VisitDataSource) private visitDataSource: VisitDataSource, @inject(Tokens.ProposalAuthorization) private proposalAuth: ProposalAuthorization, + @inject(Tokens.DataAccessUsersAuthorization) + private dataAccessUsersAuth: DataAccessUsersAuthorization, @inject(Tokens.VisitAuthorization) private visitAuthorization: VisitAuthorization, @inject(Tokens.AdminDataSource) @@ -72,9 +83,14 @@ export default class InviteMutations { return rejection('Invite code has expired', { invite: code }); } - await this.processAcceptedRoleClaims(agent.id, invite); - await this.processAcceptedCoProposerClaims(agent.id, invite); - await this.processAcceptedVisitRegistrationClaims(agent.id, invite); + try { + await this.processAcceptedRoleClaims(agent.id, invite); + await this.processAcceptedCoProposerClaims(agent.id, invite); + await this.processAcceptedDataAccessClaims(agent.id, invite); + await this.processAcceptedVisitRegistrationClaims(agent.id, invite); + } catch (error) { + logger.logException('Error during claim processing', error); + } const updatedInvite = await this.inviteDataSource.update({ id: invite.id, @@ -118,6 +134,39 @@ export default class InviteMutations { return updatedInvite; } + @Authorized([Roles.USER]) + async acceptDataAccessInvite(agent: UserWithRole | null, proposalId: string) { + if (!agent) { + return rejection('User not found', { proposalId }); + } + + const proposal = await this.proposalDataSource.getProposalById(proposalId); + if (!proposal) { + return rejection('Proposal not found', { proposalId }); + } + + const [invite] = await this.inviteDataSource.getDataAccessInvites({ + proposalPk: proposal.primaryKey, + email: agent.email, + isClaimed: false, + isExpired: false, + }); + if (!invite) { + return rejection('Invite not found', { proposalId }); + } + + await this.processAcceptedRoleClaims(agent.id, invite); + await this.processAcceptedDataAccessClaims(agent.id, invite); + + const updatedInvite = await this.inviteDataSource.update({ + id: invite.id, + claimedAt: new Date(), + claimedByUserId: agent.id, + }); + + return updatedInvite; + } + private async getCoProposerInvites(proposalPk: number): Promise { const existingClaims = await this.coProposerClaimDataSource.findByProposalPk(proposalPk); @@ -131,6 +180,19 @@ export default class InviteMutations { return existingInvites; } + private async getDataAccessInvites(proposalPk: number): Promise { + const existingClaims = + await this.dataAccessClaimDataSource.findByProposalPk(proposalPk); + + const existingInvites = (await Promise.all( + existingClaims.map((claim) => + this.inviteDataSource.findById(claim.inviteId) + ) + )) as Invite[]; + + return existingInvites; + } + @Authorized() public async setCoProposerInvites( agent: UserWithRole | null, @@ -202,6 +264,77 @@ export default class InviteMutations { return invites; } + @Authorized() + public async setDataAccessInvites( + agent: UserWithRole | null, + args: SetDataAccessInvitesInput + ): Promise { + const { proposalPk, emails } = args; + const hasWriteRights = + this.userAuth.isApiToken(agent) || + (await this.dataAccessUsersAuth.hasWriteRights(agent, proposalPk)); + + if (!hasWriteRights) { + return rejection( + 'User is not authorized to create data access invites for this proposal' + ); + } + + const existingInvites = await this.getDataAccessInvites(proposalPk); + const existingEmails = existingInvites.map((invite) => invite.email); + + const deletedEmails = existingEmails.filter( + (email) => !emails.includes(email) + ); + const newEmails = emails.filter((email) => !existingEmails.includes(email)); + + const deletedInvites = existingInvites.filter((invite) => + deletedEmails.includes(invite.email) + ); + + await Promise.all( + deletedInvites.map((invite) => this.inviteDataSource.delete(invite.id)) + ); + + const expirationDate = await this.getInviteExpirationDate(); + + const newInvites = await Promise.all( + newEmails.map(async (email) => + this.inviteDataSource.create({ + createdByUserId: agent!.id, + code: await this.generateInviteCode(), + email: email, + expiresAt: expirationDate, + }) + ) + ); + await Promise.all( + newInvites.map(async (newInvite) => { + await this.dataAccessClaimDataSource.create(newInvite.id, proposalPk); + await this.roleClaimDataSource.create(newInvite.id, UserRole.USER); + }) + ); + + const invites = [ + ...existingInvites.filter((invite) => !deletedInvites.includes(invite)), + ...newInvites, + ]; + + if (invites.length > 0) { + await this.eventBus.publish({ + type: Event.PROPOSAL_DATA_ACCESS_INVITES_UPDATED, + array: invites, + key: 'array', + loggedInUserId: agent?.id, + inputArgs: JSON.stringify(args), + impersonatingUserId: agent ? agent.impersonatingUserId : null, + proposalPKey: proposalPk, + } as ApplicationEvent); + } + + return invites; + } + @Authorized() public async setVisitRegistrationInvites( agent: UserWithRole | null, @@ -334,6 +467,50 @@ export default class InviteMutations { }); } } + + private async processAcceptedDataAccessClaims( + claimerUserId: number, + invite: Invite + ) { + const inviteId = invite.id; + const dataAccessClaim = + await this.dataAccessClaimDataSource.findByInviteId(inviteId); + + for await (const claim of dataAccessClaim) { + const isDataAccessUser = + await this.dataAccessUsersDataSource.isDataAccessUserOfProposal( + claimerUserId, + claim.proposalPk + ); + // already a data access user + if (isDataAccessUser) { + continue; + } + + // TODO: what happens if there is an error during accept? + // we need graceful error handling from the database later + const isRejection = + await this.dataAccessUsersDataSource.addDataAccessUser( + claim.proposalPk, + claimerUserId + ); + + if (!isRejection) { + this.eventBus.publish({ + type: Event.PROPOSAL_DATA_ACCESS_INVITE_ACCEPTED, + isRejection: false, + key: 'proposal', + loggedInUserId: claimerUserId, + invite: invite, + description: `User with ID ${claimerUserId} accepted data access invite for proposal ${claim.proposalPk}`, + proposalPKey: claim.proposalPk, + }); + } else { + // TODO: what happens if there is an error during accept? + } + } + } + private async processAcceptedVisitRegistrationClaims( claimerUserId: number, invite: Invite diff --git a/apps/backend/src/queries/InviteQueries.ts b/apps/backend/src/queries/InviteQueries.ts index 5f975cf6f9..71d9178021 100644 --- a/apps/backend/src/queries/InviteQueries.ts +++ b/apps/backend/src/queries/InviteQueries.ts @@ -23,7 +23,7 @@ export default class InviteQueries { async getCoProposerInvites(agent: UserWithRole | null, proposalPk: number) { const hasReadRights = this.userAuth.isApiToken(agent) || - this.proposalAuth.hasReadRights(agent, proposalPk); + (await this.proposalAuth.hasReadRights(agent, proposalPk)); if (!hasReadRights) { return []; @@ -37,6 +37,24 @@ export default class InviteQueries { return invites; } + @Authorized() + async getDataAccessInvites(agent: UserWithRole | null, proposalPk: number) { + const hasReadRights = + this.userAuth.isApiToken(agent) || + (await this.proposalAuth.hasReadRights(agent, proposalPk)); + + if (!hasReadRights) { + return []; + } + + const invites = await this.dataSource.findDataAccessInvites( + proposalPk, + false + ); + + return invites; + } + @Authorized() async getVisitRegistrationInvites( agent: UserWithRole | null, @@ -44,7 +62,7 @@ export default class InviteQueries { ) { const hasReadRights = this.userAuth.isApiToken(agent) || - this.visitAuth.hasReadRights(agent, visitId); + (await this.visitAuth.hasReadRights(agent, visitId)); if (!hasReadRights) { return []; @@ -72,4 +90,19 @@ export default class InviteQueries { return invites; } + + @Authorized() + async getPendingDataAccessInvites(agent: UserWithRole | null) { + if (!agent) { + return []; + } + + const invites = await this.dataSource.getDataAccessInvites({ + email: agent.email, + isClaimed: false, + isExpired: false, + }); + + return invites; + } } diff --git a/apps/backend/src/resolvers/mutations/AcceptDataAccessInviteMutation.ts b/apps/backend/src/resolvers/mutations/AcceptDataAccessInviteMutation.ts new file mode 100644 index 0000000000..f2571c4696 --- /dev/null +++ b/apps/backend/src/resolvers/mutations/AcceptDataAccessInviteMutation.ts @@ -0,0 +1,17 @@ +import { Arg, Ctx, Mutation } from 'type-graphql'; + +import { ResolverContext } from '../../context'; +import { Invite } from '../types/Invite'; + +export class AcceptDataAccessInviteMutation { + @Mutation(() => Invite) + acceptDataAccessInvite( + @Arg('proposalId') proposalId: string, + @Ctx() context: ResolverContext + ) { + return context.mutations.invite.acceptDataAccessInvite( + context.user, + proposalId + ); + } +} diff --git a/apps/backend/src/resolvers/mutations/SetDataAccessInvitesMutation.ts b/apps/backend/src/resolvers/mutations/SetDataAccessInvitesMutation.ts new file mode 100644 index 0000000000..99b9e7d549 --- /dev/null +++ b/apps/backend/src/resolvers/mutations/SetDataAccessInvitesMutation.ts @@ -0,0 +1,25 @@ +import { Arg, Ctx, Int, Mutation, Resolver } from 'type-graphql'; +import { InputType, Field } from 'type-graphql'; + +import { ResolverContext } from '../../context'; +import { Invite } from '../types/Invite'; + +@InputType() +export class SetDataAccessInvitesInput { + @Field(() => Int) + proposalPk: number; + + @Field(() => [String]) + emails: string[]; +} + +@Resolver() +export class SetDataAccessInvites { + @Mutation(() => [Invite]) + setDataAccessInvites( + @Arg('input') input: SetDataAccessInvitesInput, + @Ctx() context: ResolverContext + ) { + return context.mutations.invite.setDataAccessInvites(context.user, input); + } +} diff --git a/apps/backend/src/resolvers/types/Proposal.ts b/apps/backend/src/resolvers/types/Proposal.ts index 2003ca45d5..531af552a8 100644 --- a/apps/backend/src/resolvers/types/Proposal.ts +++ b/apps/backend/src/resolvers/types/Proposal.ts @@ -145,6 +145,19 @@ export class ProposalResolver { return invites; } + @FieldResolver(() => [Invite]) + async dataAccessInvites( + @Root() proposal: Proposal, + @Ctx() context: ResolverContext + ): Promise { + const invites = await context.queries.invite.getDataAccessInvites( + context.user, + proposal.primaryKey + ); + + return invites; + } + @FieldResolver(() => BasicUserDetails, { nullable: true }) async proposer( @Root() proposal: Proposal, diff --git a/apps/backend/src/resolvers/types/User.ts b/apps/backend/src/resolvers/types/User.ts index d65cfd2c0d..b55c3c5e5e 100644 --- a/apps/backend/src/resolvers/types/User.ts +++ b/apps/backend/src/resolvers/types/User.ts @@ -151,6 +151,11 @@ export class UserResolver { return context.queries.invite.getPendingCoProposerInvites(context.user); } + @FieldResolver(() => [Invite]) + async dataAccessInvites(@Root() user: User, @Ctx() context: ResolverContext) { + return context.queries.invite.getPendingDataAccessInvites(context.user); + } + @FieldResolver(() => [Experiment]) async experiments( @Root() user: User, diff --git a/apps/frontend/src/components/proposal/DataAccessUsersModal.tsx b/apps/frontend/src/components/proposal/DataAccessUsersModal.tsx index b5bef8b796..11897757a0 100644 --- a/apps/frontend/src/components/proposal/DataAccessUsersModal.tsx +++ b/apps/frontend/src/components/proposal/DataAccessUsersModal.tsx @@ -5,7 +5,7 @@ import React, { useState } from 'react'; import { ActionButtonContainer } from 'components/common/ActionButtonContainer'; import StyledDialog from 'components/common/StyledDialog'; import UserManagementTable from 'components/common/UserManagementTable'; -import { BasicUserDetails } from 'generated/sdk'; +import { BasicUserDetails, Invite } from 'generated/sdk'; import { useProposalData } from 'hooks/proposal/useProposalData'; import { useDataAccessUsersData } from 'hooks/remoteUser/useDataAccessUsersData'; import useDataApiWithFeedback from 'utils/useDataApiWithFeedback'; @@ -25,6 +25,7 @@ const DataAccessUsersModal = ({ useDataAccessUsersData(proposalPk); const { proposalData } = useProposalData(proposalPk); const [managedUsers, setManagedUsers] = useState([]); + const [invites, setInvites] = useState([]); const { api, isExecutingCall } = useDataApiWithFeedback(); // Update managed users when data access users data changes @@ -34,6 +35,13 @@ const DataAccessUsersModal = ({ } }, [dataAccessUsers, loadingDataAccessUsers]); + // Seed existing data access invites from the proposal + React.useEffect(() => { + if (proposalData?.dataAccessInvites) { + setInvites(proposalData.dataAccessInvites); + } + }, [proposalData]); + // Calculate excludeUserIds from proposal data (proposer + co-proposers) const excludeUserIds = React.useMemo(() => { if (!proposalData) { @@ -52,14 +60,18 @@ const DataAccessUsersModal = ({ } const userIds = managedUsers.map((user) => user.id); + const emails = invites.map((invite) => invite.email); try { - await api({ - toastSuccessMessage: 'Data access users updated successfully!', - }).updateDataAccessUsers({ + await api().updateDataAccessUsers({ proposalPk, userIds, }); + await api({ + toastSuccessMessage: 'Data access users updated successfully!', + }).setDataAccessInvites({ + input: { proposalPk, emails }, + }); onClose(); } catch (error) { // Error handling is done by useDataApiWithFeedback @@ -83,12 +95,13 @@ const DataAccessUsersModal = ({ {}} + invites={invites} + setInvites={setInvites} title="Data access users" addButtonLabel="Add Data Access User" addModalTitle="Add Data Access User" excludeUserIds={excludeUserIds} + allowInviteByEmail={true} /> - - - )} - - )) + <> + {proposalInvites.map((invite) => ( + + {invite.proposal && ( + + alpha(theme.palette.info.main, 0.12), + border: (theme) => + `1px solid ${alpha(theme.palette.info.main, 0.5)}`, + color: 'info.main', + borderRadius: 1, + padding: 2, + marginBottom: 2, + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + width: '100%', + }} + > +
+ + {invite.proposal.title || 'No Title'} + + + Principal Investigator: {invite.proposal.proposerName} + + + {new Date(invite.createdAt).toLocaleDateString()}{' '} + (Co-Proposer) + +
+
+ +
+
+ )} +
+ ))} + {dataAccessInvites.map((invite) => ( + + {invite.proposal && ( + + alpha(theme.palette.info.main, 0.12), + border: (theme) => + `1px solid ${alpha(theme.palette.info.main, 0.5)}`, + color: 'info.main', + borderRadius: 1, + padding: 2, + marginBottom: 2, + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + width: '100%', + }} + > +
+ + {invite.proposal.title || 'No Title'} + + + Principal Investigator: {invite.proposal.proposerName} + + + {new Date(invite.createdAt).toLocaleDateString()}{' '} + (Data Access User) + +
+
+ +
+
+ )} +
+ ))} + )} diff --git a/apps/frontend/src/components/review/ProposalQuestionaryReview.tsx b/apps/frontend/src/components/review/ProposalQuestionaryReview.tsx index ab6f9d362e..518bae47d1 100644 --- a/apps/frontend/src/components/review/ProposalQuestionaryReview.tsx +++ b/apps/frontend/src/components/review/ProposalQuestionaryReview.tsx @@ -44,7 +44,12 @@ export default function ProposalQuestionaryReview( }, { label: 'Co-Proposers', - value: , + value: ( + invite.email)} + /> + ), }, ...(isDataAccessUsersEnabled && data.dataAccessUsers ? [ @@ -53,22 +58,15 @@ export default function ProposalQuestionaryReview( value: ( invite.email + )} data-cy="data-access-users-list" /> ), }, ] : []), - ...(data.coProposerInvites?.length > 0 - ? [ - { - label: 'Invited', - value: data.coProposerInvites - .map((invite) => invite.email) - .join(', '), - }, - ] - : []), ]; return ( diff --git a/apps/frontend/src/components/review/ReviewQuestionary.tsx b/apps/frontend/src/components/review/ReviewQuestionary.tsx index 4ec7380060..0f49aac481 100644 --- a/apps/frontend/src/components/review/ReviewQuestionary.tsx +++ b/apps/frontend/src/components/review/ReviewQuestionary.tsx @@ -75,6 +75,7 @@ export function createFapReviewStub( submitted: false, users: [], coProposerInvites: [], + dataAccessInvites: [], samples: [], genericTemplates: [], commentForManagement: '', diff --git a/apps/frontend/src/components/review/TechnicalReviewQuestionary.tsx b/apps/frontend/src/components/review/TechnicalReviewQuestionary.tsx index 1072c5184b..6e094b7aab 100644 --- a/apps/frontend/src/components/review/TechnicalReviewQuestionary.tsx +++ b/apps/frontend/src/components/review/TechnicalReviewQuestionary.tsx @@ -88,6 +88,7 @@ export function createTechnicalReviewStub( submitted: false, users: [], coProposerInvites: [], + dataAccessInvites: [], samples: [], genericTemplates: [], commentForManagement: '', diff --git a/apps/frontend/src/components/user/UserList.tsx b/apps/frontend/src/components/user/UserList.tsx index 4ab4ccdd08..50f8e0b1ae 100644 --- a/apps/frontend/src/components/user/UserList.tsx +++ b/apps/frontend/src/components/user/UserList.tsx @@ -7,6 +7,8 @@ import UserListItem from './UserListItem'; interface UserListProps { users: BasicUserDetails[]; + /** Emails of people invited but not yet registered; shown with an "(invited)" marker. */ + invitedEmails?: string[]; initVisibleItems?: number; 'data-cy'?: string; } @@ -23,18 +25,20 @@ const StyledList = styled(List)(() => ({ const UserList: React.FC = ({ users, + invitedEmails = [], initVisibleItems = 4, 'data-cy': dataCy = 'user-list', }) => { const [showAll, setShowAll] = useState(false); - if (!users || users.length === 0) { + if ((!users || users.length === 0) && invitedEmails.length === 0) { return null; } - const initialUsers = users.slice(0, initVisibleItems); - const hiddenUsers = users.slice(initVisibleItems); - const hasMore = users.length > initVisibleItems; + const safeUsers = users || []; + const initialUsers = safeUsers.slice(0, initVisibleItems); + const hiddenUsers = safeUsers.slice(initVisibleItems); + const hasMore = safeUsers.length > initVisibleItems; return (
@@ -65,6 +69,15 @@ const UserList: React.FC = ({ {showAll ? 'Show less...' : 'Show more...'} )} + {invitedEmails.length > 0 && ( + + {invitedEmails.map((email) => ( + + {email} (invited) + + ))} + + )}
); }; diff --git a/apps/frontend/src/graphql/dataAccessUser/setDataAccessInvites.graphql b/apps/frontend/src/graphql/dataAccessUser/setDataAccessInvites.graphql new file mode 100644 index 0000000000..f69f36074a --- /dev/null +++ b/apps/frontend/src/graphql/dataAccessUser/setDataAccessInvites.graphql @@ -0,0 +1,5 @@ +mutation setDataAccessInvites($input: SetDataAccessInvitesInput!) { + setDataAccessInvites(input: $input) { + ...invite + } +} diff --git a/apps/frontend/src/graphql/invite/acceptDataAccessInvite.graphql b/apps/frontend/src/graphql/invite/acceptDataAccessInvite.graphql new file mode 100644 index 0000000000..9d4d8b2ad3 --- /dev/null +++ b/apps/frontend/src/graphql/invite/acceptDataAccessInvite.graphql @@ -0,0 +1,5 @@ +mutation acceptDataAccessInvite($proposalId: String!) { + acceptDataAccessInvite(proposalId: $proposalId) { + ...invite + } +} diff --git a/apps/frontend/src/graphql/proposal/getProposal.graphql b/apps/frontend/src/graphql/proposal/getProposal.graphql index d1aa545a5f..cb7eeab489 100644 --- a/apps/frontend/src/graphql/proposal/getProposal.graphql +++ b/apps/frontend/src/graphql/proposal/getProposal.graphql @@ -84,5 +84,8 @@ query getProposal($primaryKey: Int!) { coProposerInvites { ...invite } + dataAccessInvites { + ...invite + } } } diff --git a/apps/frontend/src/graphql/user/getPendingDataAccessInvites.graphql b/apps/frontend/src/graphql/user/getPendingDataAccessInvites.graphql new file mode 100644 index 0000000000..25f786bbb9 --- /dev/null +++ b/apps/frontend/src/graphql/user/getPendingDataAccessInvites.graphql @@ -0,0 +1,18 @@ +query getPendingDataAccessInvites { + me { + dataAccessInvites { + id + code + email + createdAt + createdByUserId + claimedAt + claimedByUserId + isEmailSent + expiresAt + proposal { + ...invitedProposal + } + } + } +} diff --git a/apps/frontend/src/hooks/invite/useProposalInvites.ts b/apps/frontend/src/hooks/invite/useProposalInvites.ts index 1aac30fac6..adb3886826 100644 --- a/apps/frontend/src/hooks/invite/useProposalInvites.ts +++ b/apps/frontend/src/hooks/invite/useProposalInvites.ts @@ -1,12 +1,18 @@ import { useEffect, useState } from 'react'; -import { GetCoProposerInvitesQuery } from 'generated/sdk'; +import { + GetCoProposerInvitesQuery, + GetPendingDataAccessInvitesQuery, +} from 'generated/sdk'; import { useDataApi } from 'hooks/common/useDataApi'; export function useProposalInvites() { const [proposalInvites, setProposalInvites] = useState< NonNullable['coProposerInvites'] >([]); + const [dataAccessInvites, setDataAccessInvites] = useState< + NonNullable['dataAccessInvites'] + >([]); const [loading, setLoading] = useState(true); const [processingInviteId, setProcessingInviteId] = useState( null @@ -18,15 +24,19 @@ export function useProposalInvites() { let unmounted = false; setLoading(true); - api() - .getCoProposerInvites() - .then((data) => { - if (unmounted) { - return; - } - if (data.me) setProposalInvites(data.me.coProposerInvites); - setLoading(false); - }); + Promise.all([ + api().getCoProposerInvites(), + api().getPendingDataAccessInvites(), + ]).then(([coProposerData, dataAccessData]) => { + if (unmounted) { + return; + } + if (coProposerData.me) + setProposalInvites(coProposerData.me.coProposerInvites); + if (dataAccessData.me) + setDataAccessInvites(dataAccessData.me.dataAccessInvites); + setLoading(false); + }); return () => { unmounted = true; @@ -55,10 +65,35 @@ export function useProposalInvites() { }); }; + const acceptDataAccessInvite = (inviteId: number) => { + const proposalId = dataAccessInvites.find( + (invite) => invite.id === inviteId + )?.proposal?.proposalId; + if (!proposalId) { + throw new Error('Failed to accept the invitation.'); + } + setProcessingInviteId(inviteId); + api() + .acceptDataAccessInvite({ proposalId }) + .then(({ acceptDataAccessInvite }) => { + setDataAccessInvites((invites) => + invites.filter((invite) => invite.id !== acceptDataAccessInvite.id) + ); + }) + .catch(() => { + throw new Error('Failed to accept the invitation.'); + }) + .finally(() => { + setProcessingInviteId(null); + }); + }; + return { loading, proposalInvites, + dataAccessInvites, acceptCoProposerInvite, + acceptDataAccessInvite, processingInviteId, }; } diff --git a/apps/frontend/src/models/questionary/proposal/ProposalWithQuestionary.ts b/apps/frontend/src/models/questionary/proposal/ProposalWithQuestionary.ts index 16985411cb..acbb0c5e97 100644 --- a/apps/frontend/src/models/questionary/proposal/ProposalWithQuestionary.ts +++ b/apps/frontend/src/models/questionary/proposal/ProposalWithQuestionary.ts @@ -17,6 +17,7 @@ export type ProposalWithQuestionary = Pick< | 'users' | 'dataAccessUsers' | 'coProposerInvites' + | 'dataAccessInvites' | 'title' | 'proposalId' | 'callId'