diff --git a/apps/backend/src/eventHandlers/email/emailTemplateId.ts b/apps/backend/src/eventHandlers/email/emailTemplateId.ts index e83c8e0853..9a64fe2ca2 100644 --- a/apps/backend/src/eventHandlers/email/emailTemplateId.ts +++ b/apps/backend/src/eventHandlers/email/emailTemplateId.ts @@ -18,4 +18,5 @@ export enum EmailTemplateId { CALL_CREATED_EMAIL = 'call-created-email', FEEDBACK_REQUEST = 'feedback-request', CO_PROPOSER_INVITE = 'co-proposer-invite', + DATA_ACCESS_USER_ADDED = 'data-access-user-added', } diff --git a/apps/backend/src/eventHandlers/email/essEmailHandler.ts b/apps/backend/src/eventHandlers/email/essEmailHandler.ts index b75e1a0662..003f796d4c 100644 --- a/apps/backend/src/eventHandlers/email/essEmailHandler.ts +++ b/apps/backend/src/eventHandlers/email/essEmailHandler.ts @@ -350,6 +350,61 @@ export async function essEmailHandler(event: ApplicationEvent) { break; } + case Event.DATA_ACCESS_USERS_UPDATED: { + // Only newly added users are notified; a pure removal or no-op update + // carries an empty list and sends nothing. + if (event.newlyAddedUserIds.length === 0) { + return; + } + + const proposal = await proposalDataSource.get(event.proposalPKey); + if (!proposal) { + logger.logError('No proposal found when trying to send email', { + proposalPKey: event.proposalPKey, + event, + }); + + return; + } + + const invitedUsers = await userDataSource.getBasicUsersInfo( + event.newlyAddedUserIds + ); + + for (const user of invitedUsers) { + mailService + .sendMail({ + content: { + template: EmailTemplateId.DATA_ACCESS_USER_ADDED, + }, + substitution_data: { + preferredname: user.preferredname, + firstname: user.firstname, + lastname: user.lastname, + proposalTitle: proposal.title, + proposalId: proposal.proposalId, + }, + recipients: [{ address: user.email }], + }) + .then((res) => { + logger.logInfo('Email sent on data access user added', { + result: res, + userId: user.id, + proposalPKey: event.proposalPKey, + }); + }) + .catch((err: string) => { + logger.logError('Could not send email on data access user added', { + error: err, + userId: user.id, + event, + }); + }); + } + + return; + } + case Event.FAP_REVIEWER_NOTIFIED: { const { id: reviewId, userID, proposalPk } = event.fapReview; const fapReviewer = await userDataSource.getUser(userID); diff --git a/apps/backend/src/events/applicationEvents.ts b/apps/backend/src/events/applicationEvents.ts index 7322996f46..c4b51b32d6 100644 --- a/apps/backend/src/events/applicationEvents.ts +++ b/apps/backend/src/events/applicationEvents.ts @@ -420,6 +420,9 @@ interface VisitRegistrationCancelledEvent extends GeneralEvent { interface UserDataAccessUpdatedEvent extends GeneralEvent { type: Event.DATA_ACCESS_USERS_UPDATED; proposalPKey: number; + // Users newly added as data access users in this update (empty on removals + // or no-op updates). Used to notify only the freshly invited users by email. + newlyAddedUserIds: number[]; } interface ExperimentSafetyManagementDecisionSubmittedByISEvent diff --git a/apps/backend/src/mutations/DataAccessUsersMutations.ts b/apps/backend/src/mutations/DataAccessUsersMutations.ts index 147355e382..1557ec06be 100644 --- a/apps/backend/src/mutations/DataAccessUsersMutations.ts +++ b/apps/backend/src/mutations/DataAccessUsersMutations.ts @@ -58,6 +58,17 @@ export default class DataAccessUsersMutations { ); } + // Data access users are stored via delete-all + reinsert, so capture the + // current members first to work out who is genuinely newly invited. + const existingDataAccessUsers = + await this.dataSource.findByProposalPk(proposalPk); + const existingUserIds = new Set( + existingDataAccessUsers.map((user) => user.id) + ); + const newlyAddedUserIds = userIds.filter( + (userId) => !existingUserIds.has(userId) + ); + const result = await this.dataSource.updateDataAccessUsers( proposalPk, userIds @@ -69,6 +80,7 @@ export default class DataAccessUsersMutations { await eventBus.publish({ type: Event.DATA_ACCESS_USERS_UPDATED, proposalPKey: proposalPk, + newlyAddedUserIds: newlyAddedUserIds, key: 'proposalPk', loggedInUserId: agent ? agent.id : null, isRejection: false,