From 11bf7431c598922f8acdd04b04c8dcf2e33520af Mon Sep 17 00:00:00 2001 From: Shivam K <292110273+shivoomiess@users.noreply.github.com> Date: Wed, 24 Jun 2026 08:58:05 +0200 Subject: [PATCH 01/21] step 1: add backend models for DAU claim --- .../datasources/DataAccessClaimDataSource.ts | 8 ++++ .../datasources/DataAccessUsersDataSource.ts | 1 + .../mockups/DataAccessClaimDataSource.ts | 1 + .../postgres/DataAccessClaimRecord.ts | 39 +++++++++++++++++++ .../src/datasources/postgres/records.ts | 12 ++++++ apps/backend/src/models/DataAccessClaim.ts | 6 +++ 6 files changed, 67 insertions(+) create mode 100644 apps/backend/src/datasources/DataAccessClaimDataSource.ts create mode 100644 apps/backend/src/datasources/mockups/DataAccessClaimDataSource.ts create mode 100644 apps/backend/src/datasources/postgres/DataAccessClaimRecord.ts create mode 100644 apps/backend/src/models/DataAccessClaim.ts diff --git a/apps/backend/src/datasources/DataAccessClaimDataSource.ts b/apps/backend/src/datasources/DataAccessClaimDataSource.ts new file mode 100644 index 0000000000..b763389b91 --- /dev/null +++ b/apps/backend/src/datasources/DataAccessClaimDataSource.ts @@ -0,0 +1,8 @@ +import { DataAccessClaim } from '../models/DataAccessClaim'; + +// TODO: check for relation with DataAccessUserDataSource.ts +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..59d1e4f414 100644 --- a/apps/backend/src/datasources/DataAccessUsersDataSource.ts +++ b/apps/backend/src/datasources/DataAccessUsersDataSource.ts @@ -9,6 +9,7 @@ export type UserWithInstitution = { country: Country; }; +// TODO: how does this entity relate to the invite flow? export interface DataAccessUsersDataSource { findByProposalPk(proposalPk: number): Promise; getDataAccessUsersWithInstitution( diff --git a/apps/backend/src/datasources/mockups/DataAccessClaimDataSource.ts b/apps/backend/src/datasources/mockups/DataAccessClaimDataSource.ts new file mode 100644 index 0000000000..069f2bb119 --- /dev/null +++ b/apps/backend/src/datasources/mockups/DataAccessClaimDataSource.ts @@ -0,0 +1 @@ +// TODO: what should go into this file? diff --git a/apps/backend/src/datasources/postgres/DataAccessClaimRecord.ts b/apps/backend/src/datasources/postgres/DataAccessClaimRecord.ts new file mode 100644 index 0000000000..f795322779 --- /dev/null +++ b/apps/backend/src/datasources/postgres/DataAccessClaimRecord.ts @@ -0,0 +1,39 @@ +import { DataAccessClaim } from '../../models/DataAccessClaim'; +import { DataAccessClaimDataSource } from '../DataAccessClaimDataSource'; +import database from './database'; +import { DataAccessClaimRecord } from './records'; + +export default class PostgresDataAccessClaimDataSource + implements DataAccessClaimDataSource +{ + // TODO: what should be the correct pk + async findByProposalPk(proposalPk: number): Promise { + // TODO: change to correct database + 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/records.ts b/apps/backend/src/datasources/postgres/records.ts index e7be025f9d..1b6c4e6669 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'; @@ -1463,6 +1464,17 @@ export interface CoProposerClaimRecord { export const createCoProposerClaimRecord = (invite: CoProposerClaimRecord) => new CoProposerClaim(invite.invite_id, invite.proposal_pk); +// TODO: create a similar claim record for Data Access User +// what role should they get by default? +// apply db patch to create db table +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/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 + ) {} +} From eebd948d6f4c9aee98210feb3eb75cf942dc1fd5 Mon Sep 17 00:00:00 2001 From: Shivam K <292110273+shivoomiess@users.noreply.github.com> Date: Fri, 26 Jun 2026 14:55:50 +0200 Subject: [PATCH 02/21] chore: comments + planning wip --- .../datasources/DataAccessClaimDataSource.ts | 1 - .../datasources/DataAccessUsersDataSource.ts | 3 ++ .../mockups/DataAccessClaimDataSource.ts | 32 +++++++++++++++++++ ...Record.ts => DataAccessClaimDataSource.ts} | 0 4 files changed, 35 insertions(+), 1 deletion(-) rename apps/backend/src/datasources/postgres/{DataAccessClaimRecord.ts => DataAccessClaimDataSource.ts} (100%) diff --git a/apps/backend/src/datasources/DataAccessClaimDataSource.ts b/apps/backend/src/datasources/DataAccessClaimDataSource.ts index b763389b91..e09585677e 100644 --- a/apps/backend/src/datasources/DataAccessClaimDataSource.ts +++ b/apps/backend/src/datasources/DataAccessClaimDataSource.ts @@ -1,6 +1,5 @@ import { DataAccessClaim } from '../models/DataAccessClaim'; -// TODO: check for relation with DataAccessUserDataSource.ts export interface DataAccessClaimDataSource { create(inviteId: number, proposalPk: number): Promise; findByInviteId(inviteId: number): Promise; diff --git a/apps/backend/src/datasources/DataAccessUsersDataSource.ts b/apps/backend/src/datasources/DataAccessUsersDataSource.ts index 59d1e4f414..11c5eb7c79 100644 --- a/apps/backend/src/datasources/DataAccessUsersDataSource.ts +++ b/apps/backend/src/datasources/DataAccessUsersDataSource.ts @@ -15,9 +15,12 @@ export interface DataAccessUsersDataSource { getDataAccessUsersWithInstitution( proposalPk: number ): Promise; + // add / remove users already in the system; invite flow works separately updateDataAccessUsers( proposalPk: number, userIds: number[] ): Promise; isDataAccessUserOfProposal(id: number, proposalPk: number): Promise; + // inviteDataAccessUser(proposalPk: number): Promise; + // invite -> generate unique invite code -> create claim with user roles -> commit to DB; send email -> send success } diff --git a/apps/backend/src/datasources/mockups/DataAccessClaimDataSource.ts b/apps/backend/src/datasources/mockups/DataAccessClaimDataSource.ts index 069f2bb119..995a9358c6 100644 --- a/apps/backend/src/datasources/mockups/DataAccessClaimDataSource.ts +++ b/apps/backend/src/datasources/mockups/DataAccessClaimDataSource.ts @@ -1 +1,33 @@ // TODO: what should go into this file? +import { DataAccessClaim } from '../../models/DataAccessClaim'; +import { DataAccessClaimDataSource } from '../DataAccessClaimDataSource'; + +export class DataAccessClaimDataSourceMock + implements DataAccessClaimDataSource +{ + private invites: DataAccessClaim[] = []; + + init() { + this.invites = [ + new DataAccessClaim(1, 1), + new DataAccessClaim(2, 2), + new DataAccessClaim(3, 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/postgres/DataAccessClaimRecord.ts b/apps/backend/src/datasources/postgres/DataAccessClaimDataSource.ts similarity index 100% rename from apps/backend/src/datasources/postgres/DataAccessClaimRecord.ts rename to apps/backend/src/datasources/postgres/DataAccessClaimDataSource.ts From e8785bf98b691d03a659afeaf9de5d0186153434 Mon Sep 17 00:00:00 2001 From: Shivam K <292110273+shivoomiess@users.noreply.github.com> Date: Fri, 26 Jun 2026 16:23:03 +0200 Subject: [PATCH 03/21] add: DB patch for DAU claim --- .../db_patches/0213_AddDataAccessClaims.sql | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 apps/backend/db_patches/0213_AddDataAccessClaims.sql 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; From 0a440d4f57e01057d6a6831709ebec843072f9e4 Mon Sep 17 00:00:00 2001 From: Shivam K <292110273+shivoomiess@users.noreply.github.com> Date: Fri, 26 Jun 2026 16:23:40 +0200 Subject: [PATCH 04/21] add: DAU claim datasources --- .../src/datasources/mockups/DataAccessClaimDataSource.ts | 1 - .../src/datasources/postgres/DataAccessClaimDataSource.ts | 2 -- 2 files changed, 3 deletions(-) diff --git a/apps/backend/src/datasources/mockups/DataAccessClaimDataSource.ts b/apps/backend/src/datasources/mockups/DataAccessClaimDataSource.ts index 995a9358c6..1bb6c7a8a8 100644 --- a/apps/backend/src/datasources/mockups/DataAccessClaimDataSource.ts +++ b/apps/backend/src/datasources/mockups/DataAccessClaimDataSource.ts @@ -1,4 +1,3 @@ -// TODO: what should go into this file? import { DataAccessClaim } from '../../models/DataAccessClaim'; import { DataAccessClaimDataSource } from '../DataAccessClaimDataSource'; diff --git a/apps/backend/src/datasources/postgres/DataAccessClaimDataSource.ts b/apps/backend/src/datasources/postgres/DataAccessClaimDataSource.ts index f795322779..4b17a67ab8 100644 --- a/apps/backend/src/datasources/postgres/DataAccessClaimDataSource.ts +++ b/apps/backend/src/datasources/postgres/DataAccessClaimDataSource.ts @@ -6,9 +6,7 @@ import { DataAccessClaimRecord } from './records'; export default class PostgresDataAccessClaimDataSource implements DataAccessClaimDataSource { - // TODO: what should be the correct pk async findByProposalPk(proposalPk: number): Promise { - // TODO: change to correct database return database('data_access_claims') .where({ proposal_pk: proposalPk }) .select('*') From 690a8384d937a3aa76524c42b28a0fbdbd32c736 Mon Sep 17 00:00:00 2001 From: Shivam K <292110273+shivoomiess@users.noreply.github.com> Date: Fri, 26 Jun 2026 16:56:00 +0200 Subject: [PATCH 05/21] add: modify DAU datasources to allow propagate accept invite changes --- .../datasources/DataAccessUsersDataSource.ts | 11 ++++- .../mockups/DataAccessUsersDataSource.ts | 30 ++++++++++++ .../postgres/DataAccessUsersDataSource.ts | 47 +++++++++++++++++++ 3 files changed, 87 insertions(+), 1 deletion(-) diff --git a/apps/backend/src/datasources/DataAccessUsersDataSource.ts b/apps/backend/src/datasources/DataAccessUsersDataSource.ts index 11c5eb7c79..feafc1625b 100644 --- a/apps/backend/src/datasources/DataAccessUsersDataSource.ts +++ b/apps/backend/src/datasources/DataAccessUsersDataSource.ts @@ -12,15 +12,24 @@ export type UserWithInstitution = { // TODO: how does this entity relate to the invite flow? export interface DataAccessUsersDataSource { findByProposalPk(proposalPk: number): Promise; + getDataAccessUsersWithInstitution( proposalPk: number ): Promise; // add / remove users already in the system; invite flow works separately + + // separate function for accepted invites to prevent data races + // proposalPk and userId both needed for uniqueness + addDataAccessUser( + proposalPk: number, + userId: number + ): Promise; + updateDataAccessUsers( proposalPk: number, userIds: number[] ): Promise; + isDataAccessUserOfProposal(id: number, proposalPk: number): Promise; - // inviteDataAccessUser(proposalPk: number): Promise; // invite -> generate unique invite code -> create claim with user roles -> commit to DB; send email -> send success } diff --git a/apps/backend/src/datasources/mockups/DataAccessUsersDataSource.ts b/apps/backend/src/datasources/mockups/DataAccessUsersDataSource.ts index b06f3931a2..63e44a376c 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 addedUser; + } 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/postgres/DataAccessUsersDataSource.ts b/apps/backend/src/datasources/postgres/DataAccessUsersDataSource.ts index fbe6fce097..5da974b045 100644 --- a/apps/backend/src/datasources/postgres/DataAccessUsersDataSource.ts +++ b/apps/backend/src/datasources/postgres/DataAccessUsersDataSource.ts @@ -90,6 +90,53 @@ 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 trx('data_access_user_has_proposal') + .insert(insertData) + .onConflict(['proposal_pk', 'user_id']) + .ignore(); + }); + + const addedUser = await 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', + }) + .where({ 'dauhp.proposal_pk': proposalPk, 'dauhp.user_id': userId }) + .first() + .then( + ( + user: (UserRecord & InstitutionRecord & CountryRecord) | undefined + ) => (user ? createBasicUserObject(user) : undefined) + ); + + if (!addedUser) { + throw new Error(`No data access user created for id ${userId}`); + } + + return addedUser; + } 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 From 3c248cda6185175e19eb189aa5d8f2a9bc901fc1 Mon Sep 17 00:00:00 2001 From: Shivam K <292110273+shivoomiess@users.noreply.github.com> Date: Fri, 26 Jun 2026 16:56:35 +0200 Subject: [PATCH 06/21] add: Dependency injection changes due to DAU claims --- apps/backend/src/config/Tokens.ts | 1 + apps/backend/src/config/dependencyConfigDLS.ts | 2 ++ apps/backend/src/config/dependencyConfigDefault.ts | 2 ++ apps/backend/src/config/dependencyConfigE2E.ts | 2 ++ apps/backend/src/config/dependencyConfigELI.ts | 2 ++ apps/backend/src/config/dependencyConfigESS.ts | 2 ++ apps/backend/src/config/dependencyConfigSTFC.ts | 2 ++ apps/backend/src/config/dependencyConfigTest.ts | 2 ++ 8 files changed, 15 insertions(+) diff --git a/apps/backend/src/config/Tokens.ts b/apps/backend/src/config/Tokens.ts index 8bf5ccbbcd..6accfc97f1 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 00554eaf2f..51b4267cb9 100644 --- a/apps/backend/src/config/dependencyConfigDLS.ts +++ b/apps/backend/src/config/dependencyConfigDLS.ts @@ -12,6 +12,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'; @@ -65,6 +66,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 33c132735d..af5894f4f3 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'; @@ -74,6 +75,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 ff1c9321f9..75ab8a3a2f 100644 --- a/apps/backend/src/config/dependencyConfigE2E.ts +++ b/apps/backend/src/config/dependencyConfigE2E.ts @@ -12,6 +12,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'; @@ -66,6 +67,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 cc79b28b05..63cd0c44fd 100644 --- a/apps/backend/src/config/dependencyConfigELI.ts +++ b/apps/backend/src/config/dependencyConfigELI.ts @@ -12,6 +12,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'; @@ -68,6 +69,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 60e4d60c6d..ddca38ec59 100644 --- a/apps/backend/src/config/dependencyConfigESS.ts +++ b/apps/backend/src/config/dependencyConfigESS.ts @@ -9,6 +9,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'; @@ -68,6 +69,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 69dfbe69ed..471aaa5e0e 100644 --- a/apps/backend/src/config/dependencyConfigSTFC.ts +++ b/apps/backend/src/config/dependencyConfigSTFC.ts @@ -12,6 +12,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'; @@ -66,6 +67,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 dccc904247..dc64ef056b 100644 --- a/apps/backend/src/config/dependencyConfigTest.ts +++ b/apps/backend/src/config/dependencyConfigTest.ts @@ -10,6 +10,7 @@ import { VisitRegistrationAuthorization } from '../auth/VisitRegistrationAuthori 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'; @@ -60,6 +61,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); From eef7581bdb007b61f6a7ed6bba8b241c7a0a46b7 Mon Sep 17 00:00:00 2001 From: Shivam K <292110273+shivoomiess@users.noreply.github.com> Date: Tue, 30 Jun 2026 15:07:22 +0200 Subject: [PATCH 07/21] add: more datasource related functions for invites --- .../src/datasources/InviteDataSource.ts | 4 ++++ .../mockups/DataAccessClaimDataSource.ts | 6 ++--- .../datasources/mockups/InviteDataSource.ts | 17 ++++++++++++- .../datasources/postgres/InviteDataSource.ts | 24 +++++++++++++++++++ 4 files changed, 47 insertions(+), 4 deletions(-) diff --git a/apps/backend/src/datasources/InviteDataSource.ts b/apps/backend/src/datasources/InviteDataSource.ts index 3eed5a0fa7..907eba0200 100644 --- a/apps/backend/src/datasources/InviteDataSource.ts +++ b/apps/backend/src/datasources/InviteDataSource.ts @@ -27,6 +27,10 @@ export interface InviteDataSource { proposalPk: number, isClaimed?: boolean ): Promise; + findDataAccessInvites( + proposalPk: number, + isClaimed?: boolean + ): Promise; findVisitRegistrationInvites( visitId: number, isClaimed?: boolean diff --git a/apps/backend/src/datasources/mockups/DataAccessClaimDataSource.ts b/apps/backend/src/datasources/mockups/DataAccessClaimDataSource.ts index 1bb6c7a8a8..42c563d742 100644 --- a/apps/backend/src/datasources/mockups/DataAccessClaimDataSource.ts +++ b/apps/backend/src/datasources/mockups/DataAccessClaimDataSource.ts @@ -8,9 +8,9 @@ export class DataAccessClaimDataSourceMock init() { this.invites = [ - new DataAccessClaim(1, 1), - new DataAccessClaim(2, 2), - new DataAccessClaim(3, 3), + new DataAccessClaim(7, 1), // use IDs unique from coproposal invites + new DataAccessClaim(8, 2), + new DataAccessClaim(9, 3), ]; } diff --git a/apps/backend/src/datasources/mockups/InviteDataSource.ts b/apps/backend/src/datasources/mockups/InviteDataSource.ts index dc42b8a866..deca3ca697 100644 --- a/apps/backend/src/datasources/mockups/InviteDataSource.ts +++ b/apps/backend/src/datasources/mockups/InviteDataSource.ts @@ -5,6 +5,7 @@ import { EmailTemplateId } from '../../eventHandlers/email/emailTemplateId'; import { CoProposerClaim } from '../../models/CoProposerClaim'; import { Invite } from '../../models/Invite'; import { CoProposerClaimDataSource } from '../CoProposerClaimDataSource'; +import { DataAccessClaimDataSource } from '../DataAccessClaimDataSource'; import { GetCoProposerInvitesFilter, GetInvitesFilter, @@ -18,7 +19,9 @@ export class InviteDataSourceMock implements InviteDataSource { constructor( @inject(Tokens.CoProposerClaimDataSource) - private coProposerDataSource: CoProposerClaimDataSource + private coProposerDataSource: CoProposerClaimDataSource, + @inject(Tokens.DataAccessClaimDataSource) + private dataAccessDataSource: DataAccessClaimDataSource ) { this.init(); } @@ -33,6 +36,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 diff --git a/apps/backend/src/datasources/postgres/InviteDataSource.ts b/apps/backend/src/datasources/postgres/InviteDataSource.ts index 30d52112a6..41b79d795a 100644 --- a/apps/backend/src/datasources/postgres/InviteDataSource.ts +++ b/apps/backend/src/datasources/postgres/InviteDataSource.ts @@ -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 From 440df87629f016d21b81d067b1126c4ceaff15a0 Mon Sep 17 00:00:00 2001 From: Shivam K <292110273+shivoomiess@users.noreply.github.com> Date: Tue, 30 Jun 2026 15:08:28 +0200 Subject: [PATCH 08/21] add: mirror CoProposer invite business logic for DAU --- apps/backend/src/mutations/InviteMutations.ts | 987 ++++++++++-------- apps/backend/src/queries/InviteQueries.ts | 18 + .../mutations/SetDataAccessInvitesMutation.ts | 25 + apps/backend/src/resolvers/types/Proposal.ts | 13 + 4 files changed, 614 insertions(+), 429 deletions(-) create mode 100644 apps/backend/src/resolvers/mutations/SetDataAccessInvitesMutation.ts diff --git a/apps/backend/src/mutations/InviteMutations.ts b/apps/backend/src/mutations/InviteMutations.ts index 679e22b3e8..59d4ad8872 100644 --- a/apps/backend/src/mutations/InviteMutations.ts +++ b/apps/backend/src/mutations/InviteMutations.ts @@ -1,429 +1,558 @@ -import { GraphQLError } from 'graphql'; -import { inject, injectable } from 'tsyringe'; - -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 { InviteDataSource } from '../datasources/InviteDataSource'; -import database from '../datasources/postgres/database'; -import { ProposalDataSource } from '../datasources/ProposalDataSource'; -import { RoleClaimDataSource } from '../datasources/RoleClaimDataSource'; -import { UserDataSource } from '../datasources/UserDataSource'; -import { VisitDataSource } from '../datasources/VisitDataSource'; -import { VisitRegistrationClaimDataSource } from '../datasources/VisitRegistrationClaimDataSource'; -import { Authorized } from '../decorators'; -import { ApplicationEventBus } from '../events'; -import { ApplicationEvent } from '../events/applicationEvents'; -import { Event } from '../events/event.enum'; -import { Invite } from '../models/Invite'; -import { rejection, Rejection } from '../models/Rejection'; -import { Role, Roles } from '../models/Role'; -import { SettingsId } from '../models/Settings'; -import { UserRole, UserWithRole } from '../models/User'; -import { SetCoProposerInvitesInput } from '../resolvers/mutations/SetCoProposerInvitesMutation'; - -@injectable() -export default class InviteMutations { - constructor( - @inject(Tokens.InviteDataSource) - private inviteDataSource: InviteDataSource, - @inject(Tokens.UserDataSource) - private userDataSource: UserDataSource, - @inject(Tokens.ProposalDataSource) - private proposalDataSource: ProposalDataSource, - @inject(Tokens.RoleClaimDataSource) - private roleClaimDataSource: RoleClaimDataSource, - @inject(Tokens.CoProposerClaimDataSource) - private coProposerClaimDataSource: CoProposerClaimDataSource, - @inject(Tokens.VisitRegistrationClaimDataSource) - private visitRegistrationClaimDataSource: VisitRegistrationClaimDataSource, - @inject(Tokens.VisitDataSource) - private visitDataSource: VisitDataSource, - @inject(Tokens.ProposalAuthorization) - private proposalAuth: ProposalAuthorization, - @inject(Tokens.VisitAuthorization) - private visitAuthorization: VisitAuthorization, - @inject(Tokens.AdminDataSource) - private adminDataSource: AdminDataSource, - @inject(Tokens.UserAuthorization) private userAuth: UserAuthorization, - @inject(Tokens.EventBus) private eventBus: ApplicationEventBus - ) {} - - @Authorized() - async acceptWithCode( - agent: UserWithRole | null, - code: string - ): Promise { - if (!agent) { - return rejection('User not found', { invite: code }); - } - const invite = await this.inviteDataSource.findByCode(code); - if (invite === null) { - return rejection('Invite code not found', { invite: code }); - } - if (invite.claimedByUserId !== null) { - return rejection('Invite code already claimed', { invite: code }); - } - - if (invite.expiresAt && invite.expiresAt < new Date()) { - 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); - - const updatedInvite = await this.inviteDataSource.update({ - id: invite.id, - claimedAt: new Date(), - claimedByUserId: agent!.id, - }); - - return updatedInvite; - } - - @Authorized([Roles.USER]) - async acceptCoProposerInvite(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.getCoProposerInvites({ - 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.processAcceptedCoProposerClaims(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); - - const existingInvites = (await Promise.all( - existingClaims.map((claim) => - this.inviteDataSource.findById(claim.inviteId) - ) - )) as Invite[]; - - return existingInvites; - } - - @Authorized() - public async setCoProposerInvites( - agent: UserWithRole | null, - args: SetCoProposerInvitesInput - ): Promise { - const { proposalPk, emails } = args; - const hasWriteRights = - this.userAuth.isApiToken(agent) || - (await this.proposalAuth.hasWriteRights(agent, proposalPk)); - - if (!hasWriteRights) { - return rejection( - 'User is not authorized to create invites for this proposal' - ); - } - - const existingInvites = await this.getCoProposerInvites(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.coProposerClaimDataSource.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_CO_PROPOSER_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, - args: { visitId: number; emails: string[] } - ): Promise { - const { visitId, emails } = args; - - const hasWriteRights = - this.userAuth.isApiToken(agent) || - (await this.visitAuthorization.hasWriteRights(agent, visitId)); - - if (!hasWriteRights) { - return rejection( - 'User is not authorized to create invites for this visit' - ); - } - - const existingInvites = - await this.inviteDataSource.findVisitRegistrationInvites(visitId, false); - - 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.visitRegistrationClaimDataSource.create( - newInvite.id, - visitId - ); - }) - ); - - const invites = [ - ...existingInvites.filter((invite) => !deletedInvites.includes(invite)), - ...newInvites, - ]; - - const { primaryKey: proposalPk } = - await this.proposalDataSource.getProposalByVisitId(visitId); - - await this.eventBus.publish({ - type: Event.PROPOSAL_VISIT_REGISTRATION_INVITES_UPDATED, - array: invites, - key: 'array', - loggedInUserId: agent?.id, - inputArgs: JSON.stringify(args), - impersonatingUserId: agent ? agent.impersonatingUserId : null, - proposalPKey: proposalPk, - } as ApplicationEvent); - - return invites; - } - private async processAcceptedRoleClaims( - claimerUserId: number, - invite: Invite - ) { - const inviteId = invite.id; - const roleClaims = await this.roleClaimDataSource.findByInviteId(inviteId); - - if (roleClaims.length > 0) { - for await (const roleClaim of roleClaims) { - const existingUserRoles: Role[] = - await this.userDataSource.getUserRoles(claimerUserId); - - if (existingUserRoles.find((role) => role.id === roleClaim.roleId)) { - continue; - } - - await this.userDataSource.addUserRole({ - userID: claimerUserId, - roleID: roleClaim.roleId, - }); - } - } - } - - private async processAcceptedCoProposerClaims( - claimerUserId: number, - invite: Invite - ) { - const inviteId = invite.id; - const coProposerClaim = - await this.coProposerClaimDataSource.findByInviteId(inviteId); - - for await (const claim of coProposerClaim) { - const proposalHasUser = await this.proposalHasUser( - claim.proposalPk, - claimerUserId - ); - // already a co-proposer - if (proposalHasUser) { - return; - } - await this.proposalDataSource.addProposalUser( - claim.proposalPk, - claimerUserId - ); - - this.eventBus.publish({ - type: Event.PROPOSAL_CO_PROPOSER_INVITE_ACCEPTED, - isRejection: false, - key: 'proposal', - loggedInUserId: claimerUserId, - invite: invite, - description: `User with ID ${claimerUserId} accepted invite for proposal ${claim.proposalPk}`, - proposalPKey: claim.proposalPk, - }); - } - } - private async processAcceptedVisitRegistrationClaims( - claimerUserId: number, - invite: Invite - ) { - const inviteId = invite.id; - const claims = - await this.visitRegistrationClaimDataSource.findByInviteId(inviteId); - - for (const claim of claims) { - const existingRegistration = await this.visitDataSource.getRegistration( - claimerUserId, - claim.visitId - ); - - if (existingRegistration) { - return; - } - - // Insert the user into the visits_has_users table to create a new visit registration - await database('visits_has_users') - .insert({ - visit_id: claim.visitId, - user_id: claimerUserId, - registration_questionary_id: null, - starts_at: null, - ends_at: null, - // status will default to 'DRAFTED' as defined in the database schema - }) - .onConflict(['user_id', 'visit_id']) - .ignore(); - - const proposal = await this.proposalDataSource.getProposalByVisitId( - claim.visitId - ); - - this.eventBus.publish({ - type: Event.PROPOSAL_VISIT_REGISTRATION_INVITE_ACCEPTED, - isRejection: false, - key: 'proposal', - loggedInUserId: claimerUserId, - invite: invite, - description: `User with ID ${claimerUserId} accepted visit invite`, - proposalPKey: proposal.primaryKey, - }); - } - } - - private async proposalHasUser(proposalPk: number, userId: number) { - const proposalUsers = - await this.userDataSource.getProposalUsers(proposalPk); - - return proposalUsers.some((user) => user.id === userId); - } - - private async generateInviteCode(): Promise { - let code = ''; - let isUnique = false; - - while (!isUnique) { - code = Math.random().toString(36).substring(2, 8).toUpperCase(); - const existingInvite = await this.inviteDataSource.findByCode(code); - if (!existingInvite) { - isUnique = true; - } - } - - return code; - } - - private readonly getInviteExpirationDate = async (): Promise => { - const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000; - - const inviteValidityPeriodSetting = await this.adminDataSource.getSetting( - SettingsId.INVITE_VALIDITY_PERIOD_DAYS - ); - if (!inviteValidityPeriodSetting?.settingsValue) { - throw new GraphQLError('Invite validity period setting not found'); - } - - const validityPeriodDays = parseInt( - inviteValidityPeriodSetting.settingsValue - ); - if (isNaN(validityPeriodDays) || validityPeriodDays <= 0) { - throw new GraphQLError('Invalid invite validity period value'); - } - - const expirationDate = new Date( - Date.now() + validityPeriodDays * MILLISECONDS_PER_DAY - ); - - return expirationDate; - }; -} +import { GraphQLError } from 'graphql'; +import { inject, injectable } from 'tsyringe'; + +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'; +import { RoleClaimDataSource } from '../datasources/RoleClaimDataSource'; +import { UserDataSource } from '../datasources/UserDataSource'; +import { VisitDataSource } from '../datasources/VisitDataSource'; +import { VisitRegistrationClaimDataSource } from '../datasources/VisitRegistrationClaimDataSource'; +import { Authorized } from '../decorators'; +import { ApplicationEventBus } from '../events'; +import { ApplicationEvent } from '../events/applicationEvents'; +import { Event } from '../events/event.enum'; +import { Invite } from '../models/Invite'; +import { rejection, Rejection } from '../models/Rejection'; +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 { + constructor( + @inject(Tokens.InviteDataSource) + private inviteDataSource: InviteDataSource, + @inject(Tokens.UserDataSource) + private userDataSource: UserDataSource, + @inject(Tokens.ProposalDataSource) + private proposalDataSource: ProposalDataSource, + @inject(Tokens.RoleClaimDataSource) + 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.VisitAuthorization) + private visitAuthorization: VisitAuthorization, + @inject(Tokens.AdminDataSource) + private adminDataSource: AdminDataSource, + @inject(Tokens.UserAuthorization) private userAuth: UserAuthorization, + @inject(Tokens.EventBus) private eventBus: ApplicationEventBus + ) {} + + @Authorized() + async acceptWithCode( + agent: UserWithRole | null, + code: string + ): Promise { + if (!agent) { + return rejection('User not found', { invite: code }); + } + const invite = await this.inviteDataSource.findByCode(code); + if (invite === null) { + return rejection('Invite code not found', { invite: code }); + } + if (invite.claimedByUserId !== null) { + return rejection('Invite code already claimed', { invite: code }); + } + + if (invite.expiresAt && invite.expiresAt < new Date()) { + return rejection('Invite code has expired', { invite: code }); + } + + await this.processAcceptedRoleClaims(agent.id, invite); + await this.processAcceptedCoProposerClaims(agent.id, invite); + await this.processAcceptedDataAccessClaims(agent.id, invite); + await this.processAcceptedVisitRegistrationClaims(agent.id, invite); + + const updatedInvite = await this.inviteDataSource.update({ + id: invite.id, + claimedAt: new Date(), + claimedByUserId: agent!.id, + }); + + return updatedInvite; + } + + // make this generic -> accept on signup + @Authorized([Roles.USER]) + async acceptCoProposerInvite(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.getCoProposerInvites({ + 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.processAcceptedCoProposerClaims(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); + + const existingInvites = (await Promise.all( + existingClaims.map((claim) => + this.inviteDataSource.findById(claim.inviteId) + ) + )) as Invite[]; + + 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, + args: SetCoProposerInvitesInput + ): Promise { + const { proposalPk, emails } = args; + const hasWriteRights = + this.userAuth.isApiToken(agent) || + (await this.proposalAuth.hasWriteRights(agent, proposalPk)); + + if (!hasWriteRights) { + return rejection( + 'User is not authorized to create invites for this proposal' + ); + } + + const existingInvites = await this.getCoProposerInvites(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.coProposerClaimDataSource.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_CO_PROPOSER_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 setDataAccessInvites( + agent: UserWithRole | null, + args: SetDataAccessInvitesInput + ): Promise { + const { proposalPk, emails } = args; + const hasWriteRights = + this.userAuth.isApiToken(agent) || + (await this.proposalAuth.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, + args: { visitId: number; emails: string[] } + ): Promise { + const { visitId, emails } = args; + + const hasWriteRights = + this.userAuth.isApiToken(agent) || + (await this.visitAuthorization.hasWriteRights(agent, visitId)); + + if (!hasWriteRights) { + return rejection( + 'User is not authorized to create invites for this visit' + ); + } + + const existingInvites = + await this.inviteDataSource.findVisitRegistrationInvites(visitId, false); + + 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.visitRegistrationClaimDataSource.create( + newInvite.id, + visitId + ); + }) + ); + + const invites = [ + ...existingInvites.filter((invite) => !deletedInvites.includes(invite)), + ...newInvites, + ]; + + const { primaryKey: proposalPk } = + await this.proposalDataSource.getProposalByVisitId(visitId); + + await this.eventBus.publish({ + type: Event.PROPOSAL_VISIT_REGISTRATION_INVITES_UPDATED, + array: invites, + key: 'array', + loggedInUserId: agent?.id, + inputArgs: JSON.stringify(args), + impersonatingUserId: agent ? agent.impersonatingUserId : null, + proposalPKey: proposalPk, + } as ApplicationEvent); + + return invites; + } + private async processAcceptedRoleClaims( + claimerUserId: number, + invite: Invite + ) { + const inviteId = invite.id; + const roleClaims = await this.roleClaimDataSource.findByInviteId(inviteId); + + if (roleClaims.length > 0) { + for await (const roleClaim of roleClaims) { + const existingUserRoles: Role[] = + await this.userDataSource.getUserRoles(claimerUserId); + + if (existingUserRoles.find((role) => role.id === roleClaim.roleId)) { + continue; + } + + await this.userDataSource.addUserRole({ + userID: claimerUserId, + roleID: roleClaim.roleId, + }); + } + } + } + + private async processAcceptedCoProposerClaims( + claimerUserId: number, + invite: Invite + ) { + const inviteId = invite.id; + const coProposerClaim = + await this.coProposerClaimDataSource.findByInviteId(inviteId); + + for await (const claim of coProposerClaim) { + const proposalHasUser = await this.proposalHasUser( + claim.proposalPk, + claimerUserId + ); + // already a co-proposer + if (proposalHasUser) { + return; + } + await this.proposalDataSource.addProposalUser( + claim.proposalPk, + claimerUserId + ); + + this.eventBus.publish({ + type: Event.PROPOSAL_CO_PROPOSER_INVITE_ACCEPTED, + isRejection: false, + key: 'proposal', + loggedInUserId: claimerUserId, + invite: invite, + description: `User with ID ${claimerUserId} accepted invite for proposal ${claim.proposalPk}`, + proposalPKey: claim.proposalPk, + }); + } + } + + 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) { + return; + } + await this.dataAccessUsersDataSource.addDataAccessUser( + claim.proposalPk, + claimerUserId + ); + + 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, + }); + } + } + + private async processAcceptedVisitRegistrationClaims( + claimerUserId: number, + invite: Invite + ) { + const inviteId = invite.id; + const claims = + await this.visitRegistrationClaimDataSource.findByInviteId(inviteId); + + for (const claim of claims) { + const existingRegistration = await this.visitDataSource.getRegistration( + claimerUserId, + claim.visitId + ); + + if (existingRegistration) { + return; + } + + // Insert the user into the visits_has_users table to create a new visit registration + await database('visits_has_users') + .insert({ + visit_id: claim.visitId, + user_id: claimerUserId, + registration_questionary_id: null, + starts_at: null, + ends_at: null, + // status will default to 'DRAFTED' as defined in the database schema + }) + .onConflict(['user_id', 'visit_id']) + .ignore(); + + const proposal = await this.proposalDataSource.getProposalByVisitId( + claim.visitId + ); + + this.eventBus.publish({ + type: Event.PROPOSAL_VISIT_REGISTRATION_INVITE_ACCEPTED, + isRejection: false, + key: 'proposal', + loggedInUserId: claimerUserId, + invite: invite, + description: `User with ID ${claimerUserId} accepted visit invite`, + proposalPKey: proposal.primaryKey, + }); + } + } + + private async proposalHasUser(proposalPk: number, userId: number) { + const proposalUsers = + await this.userDataSource.getProposalUsers(proposalPk); + + return proposalUsers.some((user) => user.id === userId); + } + + private async generateInviteCode(): Promise { + let code = ''; + let isUnique = false; + + while (!isUnique) { + code = Math.random().toString(36).substring(2, 8).toUpperCase(); + const existingInvite = await this.inviteDataSource.findByCode(code); + if (!existingInvite) { + isUnique = true; + } + } + + return code; + } + + private readonly getInviteExpirationDate = async (): Promise => { + const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000; + + const inviteValidityPeriodSetting = await this.adminDataSource.getSetting( + SettingsId.INVITE_VALIDITY_PERIOD_DAYS + ); + if (!inviteValidityPeriodSetting?.settingsValue) { + throw new GraphQLError('Invite validity period setting not found'); + } + + const validityPeriodDays = parseInt( + inviteValidityPeriodSetting.settingsValue + ); + if (isNaN(validityPeriodDays) || validityPeriodDays <= 0) { + throw new GraphQLError('Invalid invite validity period value'); + } + + const expirationDate = new Date( + Date.now() + validityPeriodDays * MILLISECONDS_PER_DAY + ); + + return expirationDate; + }; +} diff --git a/apps/backend/src/queries/InviteQueries.ts b/apps/backend/src/queries/InviteQueries.ts index 5f975cf6f9..dfb19fe105 100644 --- a/apps/backend/src/queries/InviteQueries.ts +++ b/apps/backend/src/queries/InviteQueries.ts @@ -37,6 +37,24 @@ export default class InviteQueries { return invites; } + @Authorized() + async getDataAccessInvites(agent: UserWithRole | null, proposalPk: number) { + const hasReadRights = + this.userAuth.isApiToken(agent) || + this.proposalAuth.hasReadRights(agent, proposalPk); + + if (!hasReadRights) { + return []; + } + + const invites = await this.dataSource.findDataAccessInvites( + proposalPk, + false + ); + + return invites; + } + @Authorized() async getVisitRegistrationInvites( agent: UserWithRole | null, 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, From c981fa5a58c7abe524ebdda4257d478eb15fe14a Mon Sep 17 00:00:00 2001 From: Shivam K <292110273+shivoomiess@users.noreply.github.com> Date: Tue, 30 Jun 2026 15:10:22 +0200 Subject: [PATCH 09/21] add: events for DAU invites and email template --- .../eventHandlers/email/essEmailHandler.ts | 45 +++++++++++++++++++ apps/backend/src/eventHandlers/logging.ts | 14 ++++++ apps/backend/src/events/applicationEvents.ts | 21 +++++++++ apps/backend/src/events/event.enum.ts | 21 +++++++++ 4 files changed, 101 insertions(+) diff --git a/apps/backend/src/eventHandlers/email/essEmailHandler.ts b/apps/backend/src/eventHandlers/email/essEmailHandler.ts index b75e1a0662..694f90f8ad 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_CO_PROPOSER, + { 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, { From e9f2506e0c4ff60435f5d486533c7ce1cf8ff7f4 Mon Sep 17 00:00:00 2001 From: Shivam K <292110273+shivoomiess@users.noreply.github.com> Date: Tue, 30 Jun 2026 15:59:28 +0200 Subject: [PATCH 10/21] add: frontend changes for DAU invite --- .../proposal/DataAccessUsersModal.tsx | 25 ++++++++++++++----- .../setDataAccessInvites.graphql | 5 ++++ .../src/graphql/proposal/getProposal.graphql | 3 +++ 3 files changed, 27 insertions(+), 6 deletions(-) create mode 100644 apps/frontend/src/graphql/dataAccessUser/setDataAccessInvites.graphql diff --git a/apps/frontend/src/components/proposal/DataAccessUsersModal.tsx b/apps/frontend/src/components/proposal/DataAccessUsersModal.tsx index 60df81b840..8f7c7ae5df 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,11 +95,12 @@ const DataAccessUsersModal = ({ {}} + invites={invites} + setInvites={setInvites} title="Data access users" addButtonLabel="Add Data Access User" excludeUserIds={excludeUserIds} + allowInviteByEmail={true} /> )} + {invitedEmails.length > 0 && ( + + {invitedEmails.map((email) => ( + + {email} (invited) + + ))} + + )} ); }; 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' From ce7c095454ba5847479cd69f0bdc8094ecb6dafd Mon Sep 17 00:00:00 2001 From: Shivam K <292110273+shivoomiess@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:34:04 +0200 Subject: [PATCH 13/21] fix: use correct auth for DAU --- apps/backend/src/mutations/InviteMutations.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/backend/src/mutations/InviteMutations.ts b/apps/backend/src/mutations/InviteMutations.ts index 59d4ad8872..5bf5a49de7 100644 --- a/apps/backend/src/mutations/InviteMutations.ts +++ b/apps/backend/src/mutations/InviteMutations.ts @@ -1,6 +1,7 @@ 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'; @@ -51,6 +52,8 @@ export default class InviteMutations { private visitDataSource: VisitDataSource, @inject(Tokens.ProposalAuthorization) private proposalAuth: ProposalAuthorization, + @inject(Tokens.DataAccessUsersAuthorization) + private dataAccessUsersAuth: DataAccessUsersAuthorization, @inject(Tokens.VisitAuthorization) private visitAuthorization: VisitAuthorization, @inject(Tokens.AdminDataSource) @@ -232,7 +235,7 @@ export default class InviteMutations { const { proposalPk, emails } = args; const hasWriteRights = this.userAuth.isApiToken(agent) || - (await this.proposalAuth.hasWriteRights(agent, proposalPk)); + (await this.dataAccessUsersAuth.hasWriteRights(agent, proposalPk)); if (!hasWriteRights) { return rejection( From f132108a0a50ae4a7ce7b2395f7967b54098201d Mon Sep 17 00:00:00 2001 From: Shivam K <292110273+shivoomiess@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:34:59 +0200 Subject: [PATCH 14/21] fix: read invite relevant proposal for both co-prop & DAU --- .../postgres/ProposalDataSource.ts | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) 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; } } From e9637ee3437620e737021452d58e9434f882f4ec Mon Sep 17 00:00:00 2001 From: Shivam K <292110273+shivoomiess@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:06:00 +0200 Subject: [PATCH 15/21] fix: changes from review suggestions --- apps/backend/src/datasources/DataAccessUsersDataSource.ts | 7 +------ .../src/datasources/mockups/DataAccessUsersDataSource.ts | 4 ++-- apps/backend/src/datasources/postgres/records.ts | 3 --- apps/backend/src/eventHandlers/email/emailTemplateId.ts | 1 + apps/backend/src/eventHandlers/email/essEmailHandler.ts | 2 +- apps/backend/src/queries/InviteQueries.ts | 6 +++--- 6 files changed, 8 insertions(+), 15 deletions(-) diff --git a/apps/backend/src/datasources/DataAccessUsersDataSource.ts b/apps/backend/src/datasources/DataAccessUsersDataSource.ts index feafc1625b..3f793ff173 100644 --- a/apps/backend/src/datasources/DataAccessUsersDataSource.ts +++ b/apps/backend/src/datasources/DataAccessUsersDataSource.ts @@ -9,21 +9,17 @@ export type UserWithInstitution = { country: Country; }; -// TODO: how does this entity relate to the invite flow? export interface DataAccessUsersDataSource { findByProposalPk(proposalPk: number): Promise; getDataAccessUsersWithInstitution( proposalPk: number ): Promise; - // add / remove users already in the system; invite flow works separately - // separate function for accepted invites to prevent data races - // proposalPk and userId both needed for uniqueness addDataAccessUser( proposalPk: number, userId: number - ): Promise; + ): Promise; updateDataAccessUsers( proposalPk: number, @@ -31,5 +27,4 @@ export interface DataAccessUsersDataSource { ): Promise; isDataAccessUserOfProposal(id: number, proposalPk: number): Promise; - // invite -> generate unique invite code -> create claim with user roles -> commit to DB; send email -> send success } diff --git a/apps/backend/src/datasources/mockups/DataAccessUsersDataSource.ts b/apps/backend/src/datasources/mockups/DataAccessUsersDataSource.ts index 63e44a376c..47ddb65e9f 100644 --- a/apps/backend/src/datasources/mockups/DataAccessUsersDataSource.ts +++ b/apps/backend/src/datasources/mockups/DataAccessUsersDataSource.ts @@ -126,7 +126,7 @@ export default class MockDataAccessUsersDataSource async addDataAccessUser( proposalPk: number, userId: number - ): Promise { + ): Promise { try { const existingUserIds = this.mockData.get(proposalPk) || []; @@ -142,7 +142,7 @@ export default class MockDataAccessUsersDataSource throw new Error(`No mock user found for id ${userId}`); } - return addedUser; + return; } catch (error) { return new Rejection('Failed to add data access user', { proposalPk, diff --git a/apps/backend/src/datasources/postgres/records.ts b/apps/backend/src/datasources/postgres/records.ts index 1b6c4e6669..60ba79c444 100644 --- a/apps/backend/src/datasources/postgres/records.ts +++ b/apps/backend/src/datasources/postgres/records.ts @@ -1464,9 +1464,6 @@ export interface CoProposerClaimRecord { export const createCoProposerClaimRecord = (invite: CoProposerClaimRecord) => new CoProposerClaim(invite.invite_id, invite.proposal_pk); -// TODO: create a similar claim record for Data Access User -// what role should they get by default? -// apply db patch to create db table export interface DataAccessClaimRecord { readonly invite_id: number; readonly proposal_pk: number; 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 694f90f8ad..fddb22d457 100644 --- a/apps/backend/src/eventHandlers/email/essEmailHandler.ts +++ b/apps/backend/src/eventHandlers/email/essEmailHandler.ts @@ -382,7 +382,7 @@ export async function essEmailHandler(event: ApplicationEvent) { await sendInviteEmail( invite, inviter, - EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_CO_PROPOSER, + EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_DATA_ACCESS_USER, { proposalTitle: proposal.title, proposalId: proposal.proposalId } ).then(async () => { await eventBus.publish({ diff --git a/apps/backend/src/queries/InviteQueries.ts b/apps/backend/src/queries/InviteQueries.ts index dfb19fe105..a8b4c3c9d4 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 []; @@ -41,7 +41,7 @@ export default class InviteQueries { async getDataAccessInvites(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 []; @@ -62,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 []; From ef2df9dcb5e90fd35b0bf7f4d9bd0590ca106d90 Mon Sep 17 00:00:00 2001 From: Shivam K <292110273+shivoomiess@users.noreply.github.com> Date: Fri, 3 Jul 2026 15:05:13 +0200 Subject: [PATCH 16/21] fix: try catch block for DB errors, --- apps/backend/src/mutations/InviteMutations.ts | 49 ++++++++++++------- 1 file changed, 31 insertions(+), 18 deletions(-) diff --git a/apps/backend/src/mutations/InviteMutations.ts b/apps/backend/src/mutations/InviteMutations.ts index 5bf5a49de7..7884c60982 100644 --- a/apps/backend/src/mutations/InviteMutations.ts +++ b/apps/backend/src/mutations/InviteMutations.ts @@ -1,3 +1,4 @@ +import { logger } from '@user-office-software/duo-logger'; import { GraphQLError } from 'graphql'; import { inject, injectable } from 'tsyringe'; @@ -82,10 +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.processAcceptedDataAccessClaims(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, @@ -447,22 +452,30 @@ export default class InviteMutations { ); // already a data access user if (isDataAccessUser) { - return; + continue; } - await this.dataAccessUsersDataSource.addDataAccessUser( - claim.proposalPk, - claimerUserId - ); - 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, - }); + // 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? + } } } From 4a8e52d28f8067a98f34bf00dc96658c7c84319b Mon Sep 17 00:00:00 2001 From: Shivam K <292110273+shivoomiess@users.noreply.github.com> Date: Fri, 3 Jul 2026 15:09:20 +0200 Subject: [PATCH 17/21] fix: fetch DA users without institution, remove redundant user details --- .../postgres/DataAccessUsersDataSource.ts | 30 +++---------------- 1 file changed, 4 insertions(+), 26 deletions(-) diff --git a/apps/backend/src/datasources/postgres/DataAccessUsersDataSource.ts b/apps/backend/src/datasources/postgres/DataAccessUsersDataSource.ts index 5da974b045..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)) @@ -93,7 +93,7 @@ export default class PostgresDataAccessUsersDataSource async addDataAccessUser( proposalPk: number, userId: number - ): Promise { + ): Promise { try { await database.transaction(async (trx) => { const insertData = { @@ -101,33 +101,11 @@ export default class PostgresDataAccessUsersDataSource user_id: userId, }; - await trx('data_access_user_has_proposal') + await database('data_access_user_has_proposal') .insert(insertData) .onConflict(['proposal_pk', 'user_id']) .ignore(); }); - - const addedUser = await 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', - }) - .where({ 'dauhp.proposal_pk': proposalPk, 'dauhp.user_id': userId }) - .first() - .then( - ( - user: (UserRecord & InstitutionRecord & CountryRecord) | undefined - ) => (user ? createBasicUserObject(user) : undefined) - ); - - if (!addedUser) { - throw new Error(`No data access user created for id ${userId}`); - } - - return addedUser; } catch (error) { return new Rejection('Failed to add data access user', { proposalPk, From 709560cb7f9bc5a8e6ceefdbd0957dece575d828 Mon Sep 17 00:00:00 2001 From: Shivam K <292110273+shivoomiess@users.noreply.github.com> Date: Fri, 3 Jul 2026 15:21:58 +0200 Subject: [PATCH 18/21] fix: LF to CRLF --- apps/backend/src/mutations/InviteMutations.ts | 1148 ++++++++--------- 1 file changed, 574 insertions(+), 574 deletions(-) diff --git a/apps/backend/src/mutations/InviteMutations.ts b/apps/backend/src/mutations/InviteMutations.ts index 7884c60982..344c57898a 100644 --- a/apps/backend/src/mutations/InviteMutations.ts +++ b/apps/backend/src/mutations/InviteMutations.ts @@ -1,574 +1,574 @@ -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'; -import { RoleClaimDataSource } from '../datasources/RoleClaimDataSource'; -import { UserDataSource } from '../datasources/UserDataSource'; -import { VisitDataSource } from '../datasources/VisitDataSource'; -import { VisitRegistrationClaimDataSource } from '../datasources/VisitRegistrationClaimDataSource'; -import { Authorized } from '../decorators'; -import { ApplicationEventBus } from '../events'; -import { ApplicationEvent } from '../events/applicationEvents'; -import { Event } from '../events/event.enum'; -import { Invite } from '../models/Invite'; -import { rejection, Rejection } from '../models/Rejection'; -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 { - constructor( - @inject(Tokens.InviteDataSource) - private inviteDataSource: InviteDataSource, - @inject(Tokens.UserDataSource) - private userDataSource: UserDataSource, - @inject(Tokens.ProposalDataSource) - private proposalDataSource: ProposalDataSource, - @inject(Tokens.RoleClaimDataSource) - 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) - private adminDataSource: AdminDataSource, - @inject(Tokens.UserAuthorization) private userAuth: UserAuthorization, - @inject(Tokens.EventBus) private eventBus: ApplicationEventBus - ) {} - - @Authorized() - async acceptWithCode( - agent: UserWithRole | null, - code: string - ): Promise { - if (!agent) { - return rejection('User not found', { invite: code }); - } - const invite = await this.inviteDataSource.findByCode(code); - if (invite === null) { - return rejection('Invite code not found', { invite: code }); - } - if (invite.claimedByUserId !== null) { - return rejection('Invite code already claimed', { invite: code }); - } - - if (invite.expiresAt && invite.expiresAt < new Date()) { - return rejection('Invite code has expired', { invite: code }); - } - - 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, - claimedAt: new Date(), - claimedByUserId: agent!.id, - }); - - return updatedInvite; - } - - // make this generic -> accept on signup - @Authorized([Roles.USER]) - async acceptCoProposerInvite(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.getCoProposerInvites({ - 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.processAcceptedCoProposerClaims(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); - - const existingInvites = (await Promise.all( - existingClaims.map((claim) => - this.inviteDataSource.findById(claim.inviteId) - ) - )) as Invite[]; - - 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, - args: SetCoProposerInvitesInput - ): Promise { - const { proposalPk, emails } = args; - const hasWriteRights = - this.userAuth.isApiToken(agent) || - (await this.proposalAuth.hasWriteRights(agent, proposalPk)); - - if (!hasWriteRights) { - return rejection( - 'User is not authorized to create invites for this proposal' - ); - } - - const existingInvites = await this.getCoProposerInvites(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.coProposerClaimDataSource.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_CO_PROPOSER_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 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, - args: { visitId: number; emails: string[] } - ): Promise { - const { visitId, emails } = args; - - const hasWriteRights = - this.userAuth.isApiToken(agent) || - (await this.visitAuthorization.hasWriteRights(agent, visitId)); - - if (!hasWriteRights) { - return rejection( - 'User is not authorized to create invites for this visit' - ); - } - - const existingInvites = - await this.inviteDataSource.findVisitRegistrationInvites(visitId, false); - - 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.visitRegistrationClaimDataSource.create( - newInvite.id, - visitId - ); - }) - ); - - const invites = [ - ...existingInvites.filter((invite) => !deletedInvites.includes(invite)), - ...newInvites, - ]; - - const { primaryKey: proposalPk } = - await this.proposalDataSource.getProposalByVisitId(visitId); - - await this.eventBus.publish({ - type: Event.PROPOSAL_VISIT_REGISTRATION_INVITES_UPDATED, - array: invites, - key: 'array', - loggedInUserId: agent?.id, - inputArgs: JSON.stringify(args), - impersonatingUserId: agent ? agent.impersonatingUserId : null, - proposalPKey: proposalPk, - } as ApplicationEvent); - - return invites; - } - private async processAcceptedRoleClaims( - claimerUserId: number, - invite: Invite - ) { - const inviteId = invite.id; - const roleClaims = await this.roleClaimDataSource.findByInviteId(inviteId); - - if (roleClaims.length > 0) { - for await (const roleClaim of roleClaims) { - const existingUserRoles: Role[] = - await this.userDataSource.getUserRoles(claimerUserId); - - if (existingUserRoles.find((role) => role.id === roleClaim.roleId)) { - continue; - } - - await this.userDataSource.addUserRole({ - userID: claimerUserId, - roleID: roleClaim.roleId, - }); - } - } - } - - private async processAcceptedCoProposerClaims( - claimerUserId: number, - invite: Invite - ) { - const inviteId = invite.id; - const coProposerClaim = - await this.coProposerClaimDataSource.findByInviteId(inviteId); - - for await (const claim of coProposerClaim) { - const proposalHasUser = await this.proposalHasUser( - claim.proposalPk, - claimerUserId - ); - // already a co-proposer - if (proposalHasUser) { - return; - } - await this.proposalDataSource.addProposalUser( - claim.proposalPk, - claimerUserId - ); - - this.eventBus.publish({ - type: Event.PROPOSAL_CO_PROPOSER_INVITE_ACCEPTED, - isRejection: false, - key: 'proposal', - loggedInUserId: claimerUserId, - invite: invite, - description: `User with ID ${claimerUserId} accepted invite for proposal ${claim.proposalPk}`, - proposalPKey: claim.proposalPk, - }); - } - } - - 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 - ) { - const inviteId = invite.id; - const claims = - await this.visitRegistrationClaimDataSource.findByInviteId(inviteId); - - for (const claim of claims) { - const existingRegistration = await this.visitDataSource.getRegistration( - claimerUserId, - claim.visitId - ); - - if (existingRegistration) { - return; - } - - // Insert the user into the visits_has_users table to create a new visit registration - await database('visits_has_users') - .insert({ - visit_id: claim.visitId, - user_id: claimerUserId, - registration_questionary_id: null, - starts_at: null, - ends_at: null, - // status will default to 'DRAFTED' as defined in the database schema - }) - .onConflict(['user_id', 'visit_id']) - .ignore(); - - const proposal = await this.proposalDataSource.getProposalByVisitId( - claim.visitId - ); - - this.eventBus.publish({ - type: Event.PROPOSAL_VISIT_REGISTRATION_INVITE_ACCEPTED, - isRejection: false, - key: 'proposal', - loggedInUserId: claimerUserId, - invite: invite, - description: `User with ID ${claimerUserId} accepted visit invite`, - proposalPKey: proposal.primaryKey, - }); - } - } - - private async proposalHasUser(proposalPk: number, userId: number) { - const proposalUsers = - await this.userDataSource.getProposalUsers(proposalPk); - - return proposalUsers.some((user) => user.id === userId); - } - - private async generateInviteCode(): Promise { - let code = ''; - let isUnique = false; - - while (!isUnique) { - code = Math.random().toString(36).substring(2, 8).toUpperCase(); - const existingInvite = await this.inviteDataSource.findByCode(code); - if (!existingInvite) { - isUnique = true; - } - } - - return code; - } - - private readonly getInviteExpirationDate = async (): Promise => { - const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000; - - const inviteValidityPeriodSetting = await this.adminDataSource.getSetting( - SettingsId.INVITE_VALIDITY_PERIOD_DAYS - ); - if (!inviteValidityPeriodSetting?.settingsValue) { - throw new GraphQLError('Invite validity period setting not found'); - } - - const validityPeriodDays = parseInt( - inviteValidityPeriodSetting.settingsValue - ); - if (isNaN(validityPeriodDays) || validityPeriodDays <= 0) { - throw new GraphQLError('Invalid invite validity period value'); - } - - const expirationDate = new Date( - Date.now() + validityPeriodDays * MILLISECONDS_PER_DAY - ); - - return expirationDate; - }; -} +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'; +import { RoleClaimDataSource } from '../datasources/RoleClaimDataSource'; +import { UserDataSource } from '../datasources/UserDataSource'; +import { VisitDataSource } from '../datasources/VisitDataSource'; +import { VisitRegistrationClaimDataSource } from '../datasources/VisitRegistrationClaimDataSource'; +import { Authorized } from '../decorators'; +import { ApplicationEventBus } from '../events'; +import { ApplicationEvent } from '../events/applicationEvents'; +import { Event } from '../events/event.enum'; +import { Invite } from '../models/Invite'; +import { rejection, Rejection } from '../models/Rejection'; +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 { + constructor( + @inject(Tokens.InviteDataSource) + private inviteDataSource: InviteDataSource, + @inject(Tokens.UserDataSource) + private userDataSource: UserDataSource, + @inject(Tokens.ProposalDataSource) + private proposalDataSource: ProposalDataSource, + @inject(Tokens.RoleClaimDataSource) + 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) + private adminDataSource: AdminDataSource, + @inject(Tokens.UserAuthorization) private userAuth: UserAuthorization, + @inject(Tokens.EventBus) private eventBus: ApplicationEventBus + ) {} + + @Authorized() + async acceptWithCode( + agent: UserWithRole | null, + code: string + ): Promise { + if (!agent) { + return rejection('User not found', { invite: code }); + } + const invite = await this.inviteDataSource.findByCode(code); + if (invite === null) { + return rejection('Invite code not found', { invite: code }); + } + if (invite.claimedByUserId !== null) { + return rejection('Invite code already claimed', { invite: code }); + } + + if (invite.expiresAt && invite.expiresAt < new Date()) { + return rejection('Invite code has expired', { invite: code }); + } + + 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, + claimedAt: new Date(), + claimedByUserId: agent!.id, + }); + + return updatedInvite; + } + + // make this generic -> accept on signup + @Authorized([Roles.USER]) + async acceptCoProposerInvite(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.getCoProposerInvites({ + 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.processAcceptedCoProposerClaims(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); + + const existingInvites = (await Promise.all( + existingClaims.map((claim) => + this.inviteDataSource.findById(claim.inviteId) + ) + )) as Invite[]; + + 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, + args: SetCoProposerInvitesInput + ): Promise { + const { proposalPk, emails } = args; + const hasWriteRights = + this.userAuth.isApiToken(agent) || + (await this.proposalAuth.hasWriteRights(agent, proposalPk)); + + if (!hasWriteRights) { + return rejection( + 'User is not authorized to create invites for this proposal' + ); + } + + const existingInvites = await this.getCoProposerInvites(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.coProposerClaimDataSource.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_CO_PROPOSER_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 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, + args: { visitId: number; emails: string[] } + ): Promise { + const { visitId, emails } = args; + + const hasWriteRights = + this.userAuth.isApiToken(agent) || + (await this.visitAuthorization.hasWriteRights(agent, visitId)); + + if (!hasWriteRights) { + return rejection( + 'User is not authorized to create invites for this visit' + ); + } + + const existingInvites = + await this.inviteDataSource.findVisitRegistrationInvites(visitId, false); + + 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.visitRegistrationClaimDataSource.create( + newInvite.id, + visitId + ); + }) + ); + + const invites = [ + ...existingInvites.filter((invite) => !deletedInvites.includes(invite)), + ...newInvites, + ]; + + const { primaryKey: proposalPk } = + await this.proposalDataSource.getProposalByVisitId(visitId); + + await this.eventBus.publish({ + type: Event.PROPOSAL_VISIT_REGISTRATION_INVITES_UPDATED, + array: invites, + key: 'array', + loggedInUserId: agent?.id, + inputArgs: JSON.stringify(args), + impersonatingUserId: agent ? agent.impersonatingUserId : null, + proposalPKey: proposalPk, + } as ApplicationEvent); + + return invites; + } + private async processAcceptedRoleClaims( + claimerUserId: number, + invite: Invite + ) { + const inviteId = invite.id; + const roleClaims = await this.roleClaimDataSource.findByInviteId(inviteId); + + if (roleClaims.length > 0) { + for await (const roleClaim of roleClaims) { + const existingUserRoles: Role[] = + await this.userDataSource.getUserRoles(claimerUserId); + + if (existingUserRoles.find((role) => role.id === roleClaim.roleId)) { + continue; + } + + await this.userDataSource.addUserRole({ + userID: claimerUserId, + roleID: roleClaim.roleId, + }); + } + } + } + + private async processAcceptedCoProposerClaims( + claimerUserId: number, + invite: Invite + ) { + const inviteId = invite.id; + const coProposerClaim = + await this.coProposerClaimDataSource.findByInviteId(inviteId); + + for await (const claim of coProposerClaim) { + const proposalHasUser = await this.proposalHasUser( + claim.proposalPk, + claimerUserId + ); + // already a co-proposer + if (proposalHasUser) { + return; + } + await this.proposalDataSource.addProposalUser( + claim.proposalPk, + claimerUserId + ); + + this.eventBus.publish({ + type: Event.PROPOSAL_CO_PROPOSER_INVITE_ACCEPTED, + isRejection: false, + key: 'proposal', + loggedInUserId: claimerUserId, + invite: invite, + description: `User with ID ${claimerUserId} accepted invite for proposal ${claim.proposalPk}`, + proposalPKey: claim.proposalPk, + }); + } + } + + 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 + ) { + const inviteId = invite.id; + const claims = + await this.visitRegistrationClaimDataSource.findByInviteId(inviteId); + + for (const claim of claims) { + const existingRegistration = await this.visitDataSource.getRegistration( + claimerUserId, + claim.visitId + ); + + if (existingRegistration) { + return; + } + + // Insert the user into the visits_has_users table to create a new visit registration + await database('visits_has_users') + .insert({ + visit_id: claim.visitId, + user_id: claimerUserId, + registration_questionary_id: null, + starts_at: null, + ends_at: null, + // status will default to 'DRAFTED' as defined in the database schema + }) + .onConflict(['user_id', 'visit_id']) + .ignore(); + + const proposal = await this.proposalDataSource.getProposalByVisitId( + claim.visitId + ); + + this.eventBus.publish({ + type: Event.PROPOSAL_VISIT_REGISTRATION_INVITE_ACCEPTED, + isRejection: false, + key: 'proposal', + loggedInUserId: claimerUserId, + invite: invite, + description: `User with ID ${claimerUserId} accepted visit invite`, + proposalPKey: proposal.primaryKey, + }); + } + } + + private async proposalHasUser(proposalPk: number, userId: number) { + const proposalUsers = + await this.userDataSource.getProposalUsers(proposalPk); + + return proposalUsers.some((user) => user.id === userId); + } + + private async generateInviteCode(): Promise { + let code = ''; + let isUnique = false; + + while (!isUnique) { + code = Math.random().toString(36).substring(2, 8).toUpperCase(); + const existingInvite = await this.inviteDataSource.findByCode(code); + if (!existingInvite) { + isUnique = true; + } + } + + return code; + } + + private readonly getInviteExpirationDate = async (): Promise => { + const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000; + + const inviteValidityPeriodSetting = await this.adminDataSource.getSetting( + SettingsId.INVITE_VALIDITY_PERIOD_DAYS + ); + if (!inviteValidityPeriodSetting?.settingsValue) { + throw new GraphQLError('Invite validity period setting not found'); + } + + const validityPeriodDays = parseInt( + inviteValidityPeriodSetting.settingsValue + ); + if (isNaN(validityPeriodDays) || validityPeriodDays <= 0) { + throw new GraphQLError('Invalid invite validity period value'); + } + + const expirationDate = new Date( + Date.now() + validityPeriodDays * MILLISECONDS_PER_DAY + ); + + return expirationDate; + }; +} From 311bab13f2032dba1f17bb9c8c93eda6d00ff467 Mon Sep 17 00:00:00 2001 From: Shivam K <292110273+shivoomiess@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:36:41 +0200 Subject: [PATCH 19/21] feat(backend): auto-accept DAU invite onLogin flow to mirror Co-Proposers --- .../src/datasources/InviteDataSource.ts | 5 +- .../datasources/mockups/InviteDataSource.ts | 73 ++++++++++++++++++- .../datasources/postgres/InviteDataSource.ts | 45 +++++++++++- .../src/mutations/InviteMutations.spec.ts | 39 ++++++++++ apps/backend/src/mutations/InviteMutations.ts | 34 ++++++++- apps/backend/src/queries/InviteQueries.ts | 15 ++++ .../AcceptDataAccessInviteMutation.ts | 17 +++++ apps/backend/src/resolvers/types/User.ts | 5 ++ 8 files changed, 226 insertions(+), 7 deletions(-) create mode 100644 apps/backend/src/resolvers/mutations/AcceptDataAccessInviteMutation.ts diff --git a/apps/backend/src/datasources/InviteDataSource.ts b/apps/backend/src/datasources/InviteDataSource.ts index 907eba0200..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; } @@ -36,7 +36,8 @@ export interface InviteDataSource { 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/InviteDataSource.ts b/apps/backend/src/datasources/mockups/InviteDataSource.ts index deca3ca697..c6f40142d4 100644 --- a/apps/backend/src/datasources/mockups/InviteDataSource.ts +++ b/apps/backend/src/datasources/mockups/InviteDataSource.ts @@ -3,11 +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'; @@ -16,6 +17,7 @@ import { export class InviteDataSourceMock implements InviteDataSource { private invites: Invite[]; private coProposerClaims: CoProposerClaim[]; + private dataAccessClaims: DataAccessClaim[]; constructor( @inject(Tokens.CoProposerClaimDataSource) @@ -106,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 { @@ -200,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) { @@ -252,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/InviteDataSource.ts b/apps/backend/src/datasources/postgres/InviteDataSource.ts index 41b79d795a..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'; @@ -135,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') @@ -176,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/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 344c57898a..6d86f0ceda 100644 --- a/apps/backend/src/mutations/InviteMutations.ts +++ b/apps/backend/src/mutations/InviteMutations.ts @@ -101,7 +101,6 @@ export default class InviteMutations { return updatedInvite; } - // make this generic -> accept on signup @Authorized([Roles.USER]) async acceptCoProposerInvite(agent: UserWithRole | null, proposalId: string) { if (!agent) { @@ -135,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); diff --git a/apps/backend/src/queries/InviteQueries.ts b/apps/backend/src/queries/InviteQueries.ts index a8b4c3c9d4..71d9178021 100644 --- a/apps/backend/src/queries/InviteQueries.ts +++ b/apps/backend/src/queries/InviteQueries.ts @@ -90,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/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, From 4259b9301631ccf2e86cf63f1a9be78cfc46525f Mon Sep 17 00:00:00 2001 From: Shivam K <292110273+shivoomiess@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:54:00 +0200 Subject: [PATCH 20/21] feat(frontend): auto-accept DAU invite onLogin flow to mirror Co Proposer --- .../proposal/ProposalInviteNotification.tsx | 187 +++++++++++++----- .../invite/acceptDataAccessInvite.graphql | 5 + .../user/getPendingDataAccessInvites.graphql | 18 ++ .../src/hooks/invite/useProposalInvites.ts | 55 +++++- 4 files changed, 201 insertions(+), 64 deletions(-) create mode 100644 apps/frontend/src/graphql/invite/acceptDataAccessInvite.graphql create mode 100644 apps/frontend/src/graphql/user/getPendingDataAccessInvites.graphql diff --git a/apps/frontend/src/components/proposal/ProposalInviteNotification.tsx b/apps/frontend/src/components/proposal/ProposalInviteNotification.tsx index e678f1de16..945f4bb54b 100644 --- a/apps/frontend/src/components/proposal/ProposalInviteNotification.tsx +++ b/apps/frontend/src/components/proposal/ProposalInviteNotification.tsx @@ -16,21 +16,23 @@ const ProposalInviteNotification = ({ onAccept }: { onAccept: () => void }) => { const [isDialogOpen, setIsDialogOpen] = useState(false); const { proposalInvites, + dataAccessInvites, loading, processingInviteId, acceptCoProposerInvite, + acceptDataAccessInvite, } = useProposalInvites(); const { enqueueSnackbar } = useSnackbar(); - if (proposalInvites.length === 0) { + if (proposalInvites.length === 0 && dataAccessInvites.length === 0) { return null; } - const inviteCount = proposalInvites.length; + const inviteCount = proposalInvites.length + dataAccessInvites.length; const handleAcceptCoProposerInvite = async (inviteId: number) => { try { - await acceptCoProposerInvite(inviteId); + acceptCoProposerInvite(inviteId); enqueueSnackbar( `Invitation for the Proposal "${proposalInvites.find((invite) => invite.id === inviteId)?.proposal?.title || ''}" accepted successfully.`, { @@ -48,6 +50,26 @@ const ProposalInviteNotification = ({ onAccept }: { onAccept: () => void }) => { } }; + const handleAcceptDataAccessInvite = async (inviteId: number) => { + try { + acceptDataAccessInvite(inviteId); + enqueueSnackbar( + `Data access invitation for the Proposal "${dataAccessInvites.find((invite) => invite.id === inviteId)?.proposal?.title || ''}" accepted successfully.`, + { + variant: 'success', + } + ); + onAccept(); + } catch { + enqueueSnackbar( + `Failed to accept the data access invitation for the proposal "${dataAccessInvites.find((invite) => invite.id === inviteId)?.proposal?.title || ''}". Please try again later.`, + { + variant: 'error', + } + ); + } + }; + return ( <> void }) => { Proposal Invitations - {proposalInvites.length === 0 ? ( + {proposalInvites.length === 0 && dataAccessInvites.length === 0 ? ( No pending invitations found. ) : ( - 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} - - - Invited on: - {new Date(invite.createdAt).toLocaleDateString()} - -
-
- -
-
- )} - - )) + <> + {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} + + + Invited on: + {new Date(invite.createdAt).toLocaleDateString()} + +
+
+ +
+
+ )} +
+ ))} + {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'} + + + Data access invitation + + + Principal Investigator: {invite.proposal.proposerName} + + + Invited on: + {new Date(invite.createdAt).toLocaleDateString()} + +
+
+ +
+
+ )} +
+ ))} + )}
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/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, }; } From 7cce30e7665dc29089687eacd641071befb32b37 Mon Sep 17 00:00:00 2001 From: Shivam K <292110273+shivoomiess@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:29:28 +0200 Subject: [PATCH 21/21] refactor: UI changes for invite list --- .../proposal/ProposalInviteNotification.tsx | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/apps/frontend/src/components/proposal/ProposalInviteNotification.tsx b/apps/frontend/src/components/proposal/ProposalInviteNotification.tsx index 945f4bb54b..91423031f8 100644 --- a/apps/frontend/src/components/proposal/ProposalInviteNotification.tsx +++ b/apps/frontend/src/components/proposal/ProposalInviteNotification.tsx @@ -151,8 +151,8 @@ const ProposalInviteNotification = ({ onAccept }: { onAccept: () => void }) => { Principal Investigator: {invite.proposal.proposerName} - Invited on: - {new Date(invite.createdAt).toLocaleDateString()} + {new Date(invite.createdAt).toLocaleDateString()}{' '} + (Co-Proposer)
@@ -198,15 +198,12 @@ const ProposalInviteNotification = ({ onAccept }: { onAccept: () => void }) => { {invite.proposal.title || 'No Title'} - - Data access invitation - Principal Investigator: {invite.proposal.proposerName} - Invited on: - {new Date(invite.createdAt).toLocaleDateString()} + {new Date(invite.createdAt).toLocaleDateString()}{' '} + (Data Access User)