From 1e6ab848e8df126c36d9b4cac07349689372edcf Mon Sep 17 00:00:00 2001 From: Jekabs Karklins Date: Mon, 2 Jun 2025 17:39:25 +0200 Subject: [PATCH 01/51] feat: add co-proposer invite handling and related events to logging --- apps/backend/src/eventHandlers/logging.ts | 41 ++++++++++++++++++- apps/backend/src/events/applicationEvents.ts | 12 ++++++ apps/backend/src/events/event.enum.ts | 2 + apps/backend/src/mutations/InviteMutations.ts | 2 + 4 files changed, 56 insertions(+), 1 deletion(-) diff --git a/apps/backend/src/eventHandlers/logging.ts b/apps/backend/src/eventHandlers/logging.ts index ecd97177f2..b480d9a6bb 100644 --- a/apps/backend/src/eventHandlers/logging.ts +++ b/apps/backend/src/eventHandlers/logging.ts @@ -3,6 +3,7 @@ import { logger } from '@user-office-software/duo-logger'; import { container } from 'tsyringe'; import { Tokens } from '../config/Tokens'; +import { CoProposerClaimDataSource } from '../datasources/CoProposerClaimDataSource'; import { EventLogsDataSource } from '../datasources/EventLogsDataSource'; import { FapDataSource } from '../datasources/FapDataSource'; import { InstrumentDataSource } from '../datasources/InstrumentDataSource'; @@ -33,6 +34,11 @@ export default function createLoggingHandler() { Tokens.ProposalDataSource ); + const coProposerClaimDataSource = + container.resolve( + Tokens.CoProposerClaimDataSource + ); + // Handler that logs every mutation wrapped with the event bus event to logger and event_logs table. return async function loggingHandler(event: ApplicationEvent) { const json = JSON.stringify(event); @@ -63,6 +69,7 @@ export default function createLoggingHandler() { break; case Event.EMAIL_INVITE: case Event.EMAIL_INVITES: + case Event.INVITE_ACCEPTED: let invites; if ('invite' in event) { invites = [event.invite]; @@ -74,10 +81,42 @@ export default function createLoggingHandler() { event.loggedInUserId, event.type, json, - invite.id.toString() + invite.id.toString(), + event.type === Event.INVITE_ACCEPTED + ? `Invite accepted: ${invite.email}` + : `Invite sent: ${invite.email}` + ); + } + break; + case Event.PROPOSAL_INVITES_SENT: + case Event.PROPOSAL_INVITE_ACCEPTED: { + let invites; + if ('invite' in event) { + invites = [event.invite]; + } else { + invites = event.invites; + } + for (const invite of invites) { + const coProposerInvites = + await coProposerClaimDataSource.findByInviteId(invite.id); + + await Promise.all( + coProposerInvites.map(async (coProposerInvite) => { + return eventLogsDataSource.set( + event.loggedInUserId, + event.type, + json, + coProposerInvite.proposalPk.toString(), + event.type === Event.PROPOSAL_INVITE_ACCEPTED + ? `Co-proposer invite accepted: ${invite.email}` + : `Co-proposer invite sent: ${invite.email}` + ); + }) ); } + break; + } case Event.PROPOSAL_INSTRUMENTS_SELECTED: { await Promise.all( event.instrumentshasproposals.proposalPks.map( diff --git a/apps/backend/src/events/applicationEvents.ts b/apps/backend/src/events/applicationEvents.ts index 3011f4de04..af0bf89132 100644 --- a/apps/backend/src/events/applicationEvents.ts +++ b/apps/backend/src/events/applicationEvents.ts @@ -215,6 +215,16 @@ interface ProposalTopicAnsweredEvent extends GeneralEvent { array: AnswerBasic[]; } +interface ProposalInvitesSentEvent extends GeneralEvent { + type: Event.PROPOSAL_INVITES_SENT; + invites: Invite[]; +} + +interface ProposalInviteAcceptedEvent extends GeneralEvent { + type: Event.PROPOSAL_INVITE_ACCEPTED; + invite: Invite; +} + interface UserUpdateEvent extends GeneralEvent { type: Event.USER_UPDATED; user: User; @@ -447,6 +457,8 @@ export type ApplicationEvent = | ProposalAllFapReviewsSubmittedForAllPanelsEvent | ProposalAllFapMeetingsSubmittedEvent | ProposalAllFapInstrumentSubmittedEvent + | ProposalInvitesSentEvent + | ProposalInviteAcceptedEvent | InstrumentCreatedEvent | InstrumentUpdatedEvent | InstrumentDeletedEvent diff --git a/apps/backend/src/events/event.enum.ts b/apps/backend/src/events/event.enum.ts index fca61a8f1f..7ac976fa31 100644 --- a/apps/backend/src/events/event.enum.ts +++ b/apps/backend/src/events/event.enum.ts @@ -66,6 +66,8 @@ export enum Event { PROPOSAL_BOOKING_TIME_COMPLETED = 'PROPOSAL_BOOKING_TIME_COMPLETED', PROPOSAL_BOOKING_TIME_UPDATED = 'PROPOSAL_BOOKING_TIME_UPDATED', PROPOSAL_BOOKING_TIME_REOPENED = 'PROPOSAL_BOOKING_TIME_REOPENED', + PROPOSAL_INVITES_SENT = 'PROPOSAL_INVITES_SENT', + PROPOSAL_INVITE_ACCEPTED = 'PROPOSAL_INVITE_ACCEPTED', INSTRUMENT_CREATED = 'INSTRUMENT_CREATED', INSTRUMENT_UPDATED = 'INSTRUMENT_UPDATED', INSTRUMENT_DELETED = 'INSTRUMENT_DELETED', diff --git a/apps/backend/src/mutations/InviteMutations.ts b/apps/backend/src/mutations/InviteMutations.ts index fd189f8184..282c226bfd 100644 --- a/apps/backend/src/mutations/InviteMutations.ts +++ b/apps/backend/src/mutations/InviteMutations.ts @@ -40,6 +40,7 @@ export default class InviteMutations { @Authorized() @EventBus(Event.INVITE_ACCEPTED) + @EventBus(Event.PROPOSAL_INVITE_ACCEPTED) async accept( agent: UserWithRole | null, code: string @@ -71,6 +72,7 @@ export default class InviteMutations { @Authorized() @EventBus(Event.EMAIL_INVITES) + @EventBus(Event.PROPOSAL_INVITES_SENT) public async setCoProposerInvites( agent: UserWithRole | null, args: SetCoProposerInvitesInput From fbcab2e855afa77665576a1f062fd23e6579c76a Mon Sep 17 00:00:00 2001 From: Jekabs Karklins Date: Wed, 4 Jun 2025 15:05:39 +0200 Subject: [PATCH 02/51] feat: update event handling for co-proposer invitations and logging --- apps/backend/src/eventHandlers/logging.ts | 6 ++-- apps/backend/src/events/applicationEvents.ts | 4 +-- apps/backend/src/events/event.enum.ts | 4 +-- apps/backend/src/mutations/InviteMutations.ts | 29 ++++++++++++------- 4 files changed, 26 insertions(+), 17 deletions(-) diff --git a/apps/backend/src/eventHandlers/logging.ts b/apps/backend/src/eventHandlers/logging.ts index b480d9a6bb..11edddbd3a 100644 --- a/apps/backend/src/eventHandlers/logging.ts +++ b/apps/backend/src/eventHandlers/logging.ts @@ -88,8 +88,8 @@ export default function createLoggingHandler() { ); } break; - case Event.PROPOSAL_INVITES_SENT: - case Event.PROPOSAL_INVITE_ACCEPTED: { + case Event.PROPOSAL_CO_PROPOSER_CLAIM_SENT: + case Event.PROPOSAL_CO_PROPOSER_CLAIM_ACCEPTED: { let invites; if ('invite' in event) { invites = [event.invite]; @@ -107,7 +107,7 @@ export default function createLoggingHandler() { event.type, json, coProposerInvite.proposalPk.toString(), - event.type === Event.PROPOSAL_INVITE_ACCEPTED + event.type === Event.PROPOSAL_CO_PROPOSER_CLAIM_ACCEPTED ? `Co-proposer invite accepted: ${invite.email}` : `Co-proposer invite sent: ${invite.email}` ); diff --git a/apps/backend/src/events/applicationEvents.ts b/apps/backend/src/events/applicationEvents.ts index af0bf89132..8e7962131d 100644 --- a/apps/backend/src/events/applicationEvents.ts +++ b/apps/backend/src/events/applicationEvents.ts @@ -216,12 +216,12 @@ interface ProposalTopicAnsweredEvent extends GeneralEvent { } interface ProposalInvitesSentEvent extends GeneralEvent { - type: Event.PROPOSAL_INVITES_SENT; + type: Event.PROPOSAL_CO_PROPOSER_CLAIM_SENT; invites: Invite[]; } interface ProposalInviteAcceptedEvent extends GeneralEvent { - type: Event.PROPOSAL_INVITE_ACCEPTED; + type: Event.PROPOSAL_CO_PROPOSER_CLAIM_ACCEPTED; invite: Invite; } diff --git a/apps/backend/src/events/event.enum.ts b/apps/backend/src/events/event.enum.ts index 7ac976fa31..6af606ea31 100644 --- a/apps/backend/src/events/event.enum.ts +++ b/apps/backend/src/events/event.enum.ts @@ -66,8 +66,8 @@ export enum Event { PROPOSAL_BOOKING_TIME_COMPLETED = 'PROPOSAL_BOOKING_TIME_COMPLETED', PROPOSAL_BOOKING_TIME_UPDATED = 'PROPOSAL_BOOKING_TIME_UPDATED', PROPOSAL_BOOKING_TIME_REOPENED = 'PROPOSAL_BOOKING_TIME_REOPENED', - PROPOSAL_INVITES_SENT = 'PROPOSAL_INVITES_SENT', - PROPOSAL_INVITE_ACCEPTED = 'PROPOSAL_INVITE_ACCEPTED', + PROPOSAL_CO_PROPOSER_CLAIM_SENT = 'PROPOSAL_CO_PROPOSER_CLAIM_SENT', + PROPOSAL_CO_PROPOSER_CLAIM_ACCEPTED = 'PROPOSAL_CO_PROPOSER_CLAIM_ACCEPTED', INSTRUMENT_CREATED = 'INSTRUMENT_CREATED', INSTRUMENT_UPDATED = 'INSTRUMENT_UPDATED', INSTRUMENT_DELETED = 'INSTRUMENT_DELETED', diff --git a/apps/backend/src/mutations/InviteMutations.ts b/apps/backend/src/mutations/InviteMutations.ts index 282c226bfd..9b735efa41 100644 --- a/apps/backend/src/mutations/InviteMutations.ts +++ b/apps/backend/src/mutations/InviteMutations.ts @@ -10,6 +10,7 @@ import { ProposalDataSource } from '../datasources/ProposalDataSource'; import { RoleClaimDataSource } from '../datasources/RoleClaimDataSource'; import { UserDataSource } from '../datasources/UserDataSource'; import { Authorized, EventBus } from '../decorators'; +import { ApplicationEventBus } from '../events'; import { Event } from '../events/event.enum'; import { Invite } from '../models/Invite'; import { rejection, Rejection } from '../models/Rejection'; @@ -35,12 +36,12 @@ export default class InviteMutations { private proposalAuth: ProposalAuthorization, @inject(Tokens.AdminDataSource) private adminDataSource: AdminDataSource, - @inject(Tokens.UserAuthorization) private userAuth: UserAuthorization + @inject(Tokens.UserAuthorization) private userAuth: UserAuthorization, + @inject(Tokens.EventBus) private eventBus: ApplicationEventBus ) {} @Authorized() @EventBus(Event.INVITE_ACCEPTED) - @EventBus(Event.PROPOSAL_INVITE_ACCEPTED) async accept( agent: UserWithRole | null, code: string @@ -58,8 +59,8 @@ export default class InviteMutations { return rejection('Invite code has expired', { invite: code }); } - await this.processRoleClaims(agent!.id, invite.id); - await this.processCoProposerClaims(agent!.id, invite.id); + await this.processRoleClaims(agent!.id, invite); + await this.processCoProposerClaims(agent!.id, invite); const updatedInvite = await this.inviteDataSource.update({ id: invite.id, @@ -72,7 +73,7 @@ export default class InviteMutations { @Authorized() @EventBus(Event.EMAIL_INVITES) - @EventBus(Event.PROPOSAL_INVITES_SENT) + @EventBus(Event.PROPOSAL_CO_PROPOSER_CLAIM_SENT) public async setCoProposerInvites( agent: UserWithRole | null, args: SetCoProposerInvitesInput @@ -153,7 +154,8 @@ export default class InviteMutations { ]; } - private async processRoleClaims(claimerUserId: number, inviteId: number) { + private async processRoleClaims(claimerUserId: number, invite: Invite) { + const inviteId = invite.id; const roleClaims = await this.roleClaimDataSource.findByInviteId(inviteId); if (roleClaims.length > 0) { @@ -173,10 +175,8 @@ export default class InviteMutations { } } - private async processCoProposerClaims( - claimerUserId: number, - inviteId: number - ) { + private async processCoProposerClaims(claimerUserId: number, invite: Invite) { + const inviteId = invite.id; const coProposerClaim = await this.coProposerClaimDataSource.findByInviteId(inviteId); @@ -194,6 +194,15 @@ export default class InviteMutations { claim.proposalPk, claimerUserId ); + + this.eventBus.publish({ + type: Event.PROPOSAL_CO_PROPOSER_CLAIM_ACCEPTED, + isRejection: false, + key: 'proposal', + loggedInUserId: claimerUserId, + invite: invite, + description: `User with ID ${claimerUserId} accepted invite for proposal ${claim.proposalPk}`, + }); } } From d8834bc36398410829a971f940020aa91b6b2401 Mon Sep 17 00:00:00 2001 From: Jekabs Karklins Date: Wed, 4 Jun 2025 15:11:20 +0200 Subject: [PATCH 03/51] feat: rename proposal invite events to co-proposer claim events --- apps/backend/src/events/applicationEvents.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/backend/src/events/applicationEvents.ts b/apps/backend/src/events/applicationEvents.ts index 8e7962131d..fe60cfc226 100644 --- a/apps/backend/src/events/applicationEvents.ts +++ b/apps/backend/src/events/applicationEvents.ts @@ -215,12 +215,12 @@ interface ProposalTopicAnsweredEvent extends GeneralEvent { array: AnswerBasic[]; } -interface ProposalInvitesSentEvent extends GeneralEvent { +interface ProposalCoProposerClaimSentEvent extends GeneralEvent { type: Event.PROPOSAL_CO_PROPOSER_CLAIM_SENT; invites: Invite[]; } -interface ProposalInviteAcceptedEvent extends GeneralEvent { +interface ProposalCoProposerClaimAcceptedEvent extends GeneralEvent { type: Event.PROPOSAL_CO_PROPOSER_CLAIM_ACCEPTED; invite: Invite; } @@ -457,8 +457,8 @@ export type ApplicationEvent = | ProposalAllFapReviewsSubmittedForAllPanelsEvent | ProposalAllFapMeetingsSubmittedEvent | ProposalAllFapInstrumentSubmittedEvent - | ProposalInvitesSentEvent - | ProposalInviteAcceptedEvent + | ProposalCoProposerClaimSentEvent + | ProposalCoProposerClaimAcceptedEvent | InstrumentCreatedEvent | InstrumentUpdatedEvent | InstrumentDeletedEvent From d48269b31c83b7698414dec5e4356dd495cfe297 Mon Sep 17 00:00:00 2001 From: Jekabs Karklins Date: Wed, 4 Jun 2025 15:41:43 +0200 Subject: [PATCH 04/51] feat: add Participants component for managing user invitations in proposals --- .../proposal/{ProposalParticipants.tsx => Participants.tsx} | 0 .../src/components/proposalBooking/CreateUpdateVisit.tsx | 2 +- .../ProposalBasis/QuestionaryComponentProposalBasis.tsx | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename apps/frontend/src/components/proposal/{ProposalParticipants.tsx => Participants.tsx} (100%) diff --git a/apps/frontend/src/components/proposal/ProposalParticipants.tsx b/apps/frontend/src/components/proposal/Participants.tsx similarity index 100% rename from apps/frontend/src/components/proposal/ProposalParticipants.tsx rename to apps/frontend/src/components/proposal/Participants.tsx diff --git a/apps/frontend/src/components/proposalBooking/CreateUpdateVisit.tsx b/apps/frontend/src/components/proposalBooking/CreateUpdateVisit.tsx index 59f1e757ca..f40862c2d7 100644 --- a/apps/frontend/src/components/proposalBooking/CreateUpdateVisit.tsx +++ b/apps/frontend/src/components/proposalBooking/CreateUpdateVisit.tsx @@ -6,7 +6,7 @@ import * as Yup from 'yup'; import { ActionButtonContainer } from 'components/common/ActionButtonContainer'; import ErrorMessage from 'components/common/ErrorMessage'; import FormikUIAutocomplete from 'components/common/FormikUIAutocomplete'; -import Participants from 'components/proposal/ProposalParticipants'; +import Participants from 'components/proposal/Participants'; import { BasicUserDetails } from 'generated/sdk'; import { UserExperiment } from 'hooks/experiment/useUserExperiments'; import useDataApiWithFeedback from 'utils/useDataApiWithFeedback'; diff --git a/apps/frontend/src/components/questionary/questionaryComponents/ProposalBasis/QuestionaryComponentProposalBasis.tsx b/apps/frontend/src/components/questionary/questionaryComponents/ProposalBasis/QuestionaryComponentProposalBasis.tsx index 6de71d4f98..413e8249ab 100644 --- a/apps/frontend/src/components/questionary/questionaryComponents/ProposalBasis/QuestionaryComponentProposalBasis.tsx +++ b/apps/frontend/src/components/questionary/questionaryComponents/ProposalBasis/QuestionaryComponentProposalBasis.tsx @@ -7,9 +7,9 @@ import ErrorMessage from 'components/common/ErrorMessage'; import TextField from 'components/common/FormikUITextField'; import withPreventSubmit from 'components/common/withPreventSubmit'; import { BasicComponentProps } from 'components/proposal/IBasicComponentProps'; +import Participants from 'components/proposal/Participants'; import { ProposalContextType } from 'components/proposal/ProposalContainer'; import ProposalParticipant from 'components/proposal/ProposalParticipant'; -import Participants from 'components/proposal/ProposalParticipants'; import { createMissingContextErrorMessage, QuestionaryContext, From 85755b8c068abe8b5b744e9122eb90d7870a2f94 Mon Sep 17 00:00:00 2001 From: Jekabs Karklins Date: Wed, 4 Jun 2025 15:53:24 +0200 Subject: [PATCH 05/51] fix: show chips --- .../src/components/proposalBooking/CreateUpdateVisit.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/frontend/src/components/proposalBooking/CreateUpdateVisit.tsx b/apps/frontend/src/components/proposalBooking/CreateUpdateVisit.tsx index f40862c2d7..b92eda4091 100644 --- a/apps/frontend/src/components/proposalBooking/CreateUpdateVisit.tsx +++ b/apps/frontend/src/components/proposalBooking/CreateUpdateVisit.tsx @@ -1,13 +1,13 @@ import { Button, Typography } from '@mui/material'; import { Form, Formik } from 'formik'; -import React from 'react'; +import React, { useState } from 'react'; import * as Yup from 'yup'; import { ActionButtonContainer } from 'components/common/ActionButtonContainer'; import ErrorMessage from 'components/common/ErrorMessage'; import FormikUIAutocomplete from 'components/common/FormikUIAutocomplete'; import Participants from 'components/proposal/Participants'; -import { BasicUserDetails } from 'generated/sdk'; +import { BasicUserDetails, Invite } from 'generated/sdk'; import { UserExperiment } from 'hooks/experiment/useUserExperiments'; import useDataApiWithFeedback from 'utils/useDataApiWithFeedback'; import { getFullUserName } from 'utils/user'; @@ -18,6 +18,7 @@ interface CreateUpdateVisitProps { } function CreateUpdateVisit({ event, close }: CreateUpdateVisitProps) { const { api } = useDataApiWithFeedback(); + const [visitInvites, setVisitInvites] = useState([]); const visit = event.visit; @@ -81,8 +82,8 @@ function CreateUpdateVisit({ event, close }: CreateUpdateVisitProps) { {}} - invites={[]} + setInvites={setVisitInvites} + invites={visitInvites} setUsers={(team: BasicUserDetails[]) => { setFieldValue('team', team); }} From de7156f7bf1a01166661246f811803c371cefb1c Mon Sep 17 00:00:00 2001 From: Jekabs Karklins Date: Thu, 5 Jun 2025 18:12:20 +0200 Subject: [PATCH 06/51] fix: update dialog title in InviteUser component --- apps/frontend/src/components/proposal/InviteUser.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/frontend/src/components/proposal/InviteUser.tsx b/apps/frontend/src/components/proposal/InviteUser.tsx index 4a99193bd3..5e79cf7c28 100644 --- a/apps/frontend/src/components/proposal/InviteUser.tsx +++ b/apps/frontend/src/components/proposal/InviteUser.tsx @@ -221,7 +221,7 @@ function InviteUser({ fullWidth maxWidth="md" onClose={handleClose} - title="Add people to proposal" + title="Add user" > Date: Mon, 9 Jun 2025 13:42:25 +0200 Subject: [PATCH 07/51] feat: add VisitRegistrationClaim model and data sources for managing visit registration claims --- .../0182_AddVisitRegistrationClaim.sql | 23 +++++++ .../VisitRegistrationClaimDataSource.ts | 7 +++ .../VisitRegistrationClaimDataSource.ts | 54 ++++++++++++++++ .../VisitRegistrationClaimDataSource.ts | 62 +++++++++++++++++++ .../src/datasources/postgres/records.ts | 15 +++++ .../src/models/VisitRegistrationClaim.ts | 6 ++ .../src/models/VisitRegistrationInvite.ts | 6 ++ .../resolvers/types/VisitRegistrationClaim.ts | 14 +++++ 8 files changed, 187 insertions(+) create mode 100644 apps/backend/db_patches/0182_AddVisitRegistrationClaim.sql create mode 100644 apps/backend/src/datasources/VisitRegistrationClaimDataSource.ts create mode 100644 apps/backend/src/datasources/mockups/VisitRegistrationClaimDataSource.ts create mode 100644 apps/backend/src/datasources/postgres/VisitRegistrationClaimDataSource.ts create mode 100644 apps/backend/src/models/VisitRegistrationClaim.ts create mode 100644 apps/backend/src/models/VisitRegistrationInvite.ts create mode 100644 apps/backend/src/resolvers/types/VisitRegistrationClaim.ts diff --git a/apps/backend/db_patches/0182_AddVisitRegistrationClaim.sql b/apps/backend/db_patches/0182_AddVisitRegistrationClaim.sql new file mode 100644 index 0000000000..72324229ae --- /dev/null +++ b/apps/backend/db_patches/0182_AddVisitRegistrationClaim.sql @@ -0,0 +1,23 @@ +-- 0181_RemoveUserColumns.sql +DO +$$ +BEGIN + IF register_patch( + '0182_AddVisitRegistrationClaim.sql', + 'Jekabs Karklins', + 'Adding table to store visit registration claims', + '2025-06-04' + ) THEN + BEGIN + -- Create the visit_registration_claims table + CREATE TABLE IF NOT EXISTS visit_registration_claims ( + invite_id SERIAL PRIMARY KEY, + visit_id INT NOT NULL, + FOREIGN KEY (invite_id) REFERENCES invites(invite_id) ON DELETE CASCADE, + FOREIGN KEY (visit_id) REFERENCES visits(visit_id) ON DELETE CASCADE + ); + END; + END IF; +END; +$$ +LANGUAGE plpgsql; diff --git a/apps/backend/src/datasources/VisitRegistrationClaimDataSource.ts b/apps/backend/src/datasources/VisitRegistrationClaimDataSource.ts new file mode 100644 index 0000000000..de55012762 --- /dev/null +++ b/apps/backend/src/datasources/VisitRegistrationClaimDataSource.ts @@ -0,0 +1,7 @@ +import { VisitRegistrationClaim } from '../models/VisitRegistrationClaim'; + +export interface VisitRegistrationClaimDataSource { + create(inviteId: number, visitId: number): Promise; + findByInviteId(inviteId: number): Promise; + findByVisitId(visitId: number): Promise; +} diff --git a/apps/backend/src/datasources/mockups/VisitRegistrationClaimDataSource.ts b/apps/backend/src/datasources/mockups/VisitRegistrationClaimDataSource.ts new file mode 100644 index 0000000000..cae8854760 --- /dev/null +++ b/apps/backend/src/datasources/mockups/VisitRegistrationClaimDataSource.ts @@ -0,0 +1,54 @@ +import { injectable } from 'tsyringe'; + +import { VisitRegistrationClaim } from '../../models/VisitRegistrationClaim'; +import { VisitRegistrationClaimDataSource } from '../VisitRegistrationClaimDataSource'; + +@injectable() +export class VisitRegistrationClaimDataSourceMock + implements VisitRegistrationClaimDataSource +{ + private visitRegistrationClaims: VisitRegistrationClaim[]; + + constructor() { + this.init(); + } + + public init() { + this.visitRegistrationClaims = [ + new VisitRegistrationClaim(1, 1), + new VisitRegistrationClaim(2, 1), + new VisitRegistrationClaim(3, 2), + new VisitRegistrationClaim(1, 3), + ]; + } + + async create( + inviteId: number, + visitId: number + ): Promise { + const newClaim = new VisitRegistrationClaim(inviteId, visitId); + this.visitRegistrationClaims.push(newClaim); + + return newClaim; + } + + async findByInviteId(inviteId: number): Promise { + const claim = this.visitRegistrationClaims.find( + (claim) => claim.inviteId === inviteId + ); + + if (!claim) { + throw new Error( + `VisitRegistrationClaim not found for inviteId: ${inviteId}` + ); + } + + return claim; + } + + async findByVisitId(visitId: number): Promise { + return this.visitRegistrationClaims.filter( + (claim) => claim.visitId === visitId + ); + } +} diff --git a/apps/backend/src/datasources/postgres/VisitRegistrationClaimDataSource.ts b/apps/backend/src/datasources/postgres/VisitRegistrationClaimDataSource.ts new file mode 100644 index 0000000000..a6fcf005e5 --- /dev/null +++ b/apps/backend/src/datasources/postgres/VisitRegistrationClaimDataSource.ts @@ -0,0 +1,62 @@ +import { injectable } from 'tsyringe'; + +import { VisitRegistrationClaim } from '../../models/VisitRegistrationClaim'; +import { VisitRegistrationClaimDataSource } from '../VisitRegistrationClaimDataSource'; +import database from './database'; +import { + VisitRegistrationClaimRecord, + createVisitRegistrationClaimObject, +} from './records'; + +@injectable() +export default class PostgresVisitRegistrationClaimDataSource + implements VisitRegistrationClaimDataSource +{ + async create( + inviteId: number, + visitId: number + ): Promise { + const records = await database('visit_registration_claims').insert( + { + invite_id: inviteId, + visit_id: visitId, + }, + '*' + ); + + if (records.length !== 1) { + throw new Error('Failed to create visit registration claim'); + } + + return createVisitRegistrationClaimObject( + records[0] as VisitRegistrationClaimRecord + ); + } + + async findByInviteId(inviteId: number): Promise { + const record = await database( + 'visit_registration_claims' + ) + .select('*') + .where('invite_id', inviteId) + .first(); + + if (!record) { + throw new Error( + `Visit registration claim with inviteId ${inviteId} not found` + ); + } + + return createVisitRegistrationClaimObject(record); + } + + async findByVisitId(visitId: number): Promise { + const records = await database('visit_registration_claims') + .select('*') + .where('visit_id', visitId); + + return records.map((record: VisitRegistrationClaimRecord) => + createVisitRegistrationClaimObject(record) + ); + } +} diff --git a/apps/backend/src/datasources/postgres/records.ts b/apps/backend/src/datasources/postgres/records.ts index 8a688dafd9..93da806526 100644 --- a/apps/backend/src/datasources/postgres/records.ts +++ b/apps/backend/src/datasources/postgres/records.ts @@ -60,6 +60,7 @@ import { VisitRegistration, VisitRegistrationStatus, } from '../../models/VisitRegistration'; +import { VisitRegistrationClaim } from '../../models/VisitRegistrationClaim'; import { WorkflowType } from '../../models/Workflow'; import { FapInstrument, @@ -254,6 +255,11 @@ export interface VisitRegistrationRecord { status: string; } +export interface VisitRegistrationClaimRecord { + readonly invite_id: number; + readonly visit_id: number; +} + export interface RoleRecord { readonly role_id: number; readonly short_code: string; @@ -1180,6 +1186,15 @@ export const createVisitObject = (visit: VisitRecord) => { ); }; +export const createVisitRegistrationClaimObject = ( + visitRegistrationClaim: VisitRegistrationClaimRecord +) => { + return new VisitRegistrationClaim( + visitRegistrationClaim.invite_id, + visitRegistrationClaim.visit_id + ); +}; + export const createGenericTemplateObject = ( genericTemplate: GenericTemplateRecord ) => { diff --git a/apps/backend/src/models/VisitRegistrationClaim.ts b/apps/backend/src/models/VisitRegistrationClaim.ts new file mode 100644 index 0000000000..5916f3c1a9 --- /dev/null +++ b/apps/backend/src/models/VisitRegistrationClaim.ts @@ -0,0 +1,6 @@ +export class VisitRegistrationClaim { + constructor( + public inviteId: number, + public visitId: number + ) {} +} diff --git a/apps/backend/src/models/VisitRegistrationInvite.ts b/apps/backend/src/models/VisitRegistrationInvite.ts new file mode 100644 index 0000000000..5916f3c1a9 --- /dev/null +++ b/apps/backend/src/models/VisitRegistrationInvite.ts @@ -0,0 +1,6 @@ +export class VisitRegistrationClaim { + constructor( + public inviteId: number, + public visitId: number + ) {} +} diff --git a/apps/backend/src/resolvers/types/VisitRegistrationClaim.ts b/apps/backend/src/resolvers/types/VisitRegistrationClaim.ts new file mode 100644 index 0000000000..406490306e --- /dev/null +++ b/apps/backend/src/resolvers/types/VisitRegistrationClaim.ts @@ -0,0 +1,14 @@ +import { Field, Int, ObjectType } from 'type-graphql'; + +import { VisitRegistrationClaim as VisitRegistrationClaimOrig } from '../../models/VisitRegistrationInvite'; + +@ObjectType() +export class VisitRegistrationClaim + implements Partial +{ + @Field(() => Int) + public userId: number; + + @Field(() => Int) + public visitId: number; +} From d12fe0ae480406b00c5dabd6f5dbcaa31fe1cff7 Mon Sep 17 00:00:00 2001 From: Jekabs Karklins Date: Mon, 9 Jun 2025 15:23:25 +0200 Subject: [PATCH 08/51] Adding resolver --- .../src/datasources/InviteDataSource.ts | 4 +++ .../datasources/mockups/InviteDataSource.ts | 13 ++++++++++ .../datasources/postgres/InviteDataSource.ts | 24 +++++++++++++++++ apps/backend/src/queries/InviteQueries.ts | 26 ++++++++++++++++++- apps/backend/src/resolvers/types/Visit.ts | 12 +++++++++ 5 files changed, 78 insertions(+), 1 deletion(-) diff --git a/apps/backend/src/datasources/InviteDataSource.ts b/apps/backend/src/datasources/InviteDataSource.ts index f94be10ab5..12458db74a 100644 --- a/apps/backend/src/datasources/InviteDataSource.ts +++ b/apps/backend/src/datasources/InviteDataSource.ts @@ -21,6 +21,10 @@ export interface InviteDataSource { proposalPk: number, isClaimed?: boolean ): Promise; + findVisitRegistrationInvites( + visitId: number, + isClaimed?: boolean + ): Promise; getInvites(filter: GetInvitesFilter): Promise; update(args: { diff --git a/apps/backend/src/datasources/mockups/InviteDataSource.ts b/apps/backend/src/datasources/mockups/InviteDataSource.ts index c97b3d1a9d..f3e5e79ce9 100644 --- a/apps/backend/src/datasources/mockups/InviteDataSource.ts +++ b/apps/backend/src/datasources/mockups/InviteDataSource.ts @@ -15,6 +15,7 @@ export class InviteDataSourceMock implements InviteDataSource { ) { this.init(); } + async findCoProposerInvites(proposalPk: number): Promise { const coProposerClaims = await this.coProposerDataSource.findByProposalPk(proposalPk); @@ -25,6 +26,18 @@ export class InviteDataSourceMock implements InviteDataSource { return invites.filter((invite) => invite !== null) as Invite[]; } + async findVisitRegistrationInvites( + visitId: number, + includeExpired: boolean + ): Promise { + const invites = this.invites.filter( + (invite) => + invite.id === visitId && + (includeExpired || !invite.expiresAt || invite.expiresAt > new Date()) + ); + + return invites; + } async delete(id: number): Promise { this.invites = this.invites.filter((invite) => invite.id !== id); } diff --git a/apps/backend/src/datasources/postgres/InviteDataSource.ts b/apps/backend/src/datasources/postgres/InviteDataSource.ts index 4357f9ecd6..1c1cb2105d 100644 --- a/apps/backend/src/datasources/postgres/InviteDataSource.ts +++ b/apps/backend/src/datasources/postgres/InviteDataSource.ts @@ -30,6 +30,30 @@ export default class PostgresInviteDataSource implements InviteDataSource { }) .then((invites: InviteRecord[]) => invites.map(createInviteObject)); } + findVisitRegistrationInvites( + visitId: number, + isClaimed?: boolean + ): Promise { + return database + .select('*') + .from('visit_registration_invites') + .where('visit_id', visitId) + .modify((query) => { + if (isClaimed !== undefined) { + if (isClaimed) { + query.whereNotNull('claimed_at'); + } else { + query.whereNull('claimed_at'); + } + } + }) + .catch((error: Error) => { + throw new Error( + `Could not find visit registration invites: ${error.message}` + ); + }) + .then((invites: InviteRecord[]) => invites.map(createInviteObject)); + } findByCode(code: string): Promise { return database .select('*') diff --git a/apps/backend/src/queries/InviteQueries.ts b/apps/backend/src/queries/InviteQueries.ts index 8bb498d4d3..657f670430 100644 --- a/apps/backend/src/queries/InviteQueries.ts +++ b/apps/backend/src/queries/InviteQueries.ts @@ -1,7 +1,8 @@ -import { inject, injectable } from 'tsyringe'; +import { container, 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 { InviteDataSource } from '../datasources/InviteDataSource'; import { Authorized } from '../decorators'; @@ -17,6 +18,8 @@ export default class InviteQueries { @inject(Tokens.UserAuthorization) private userAuth: UserAuthorization ) {} + private visitAuth = container.resolve(VisitAuthorization); // TODO move into dependency injection + @Authorized() async getCoProposerInvites(agent: UserWithRole | null, proposalPk: number) { const hasReadRights = @@ -34,4 +37,25 @@ export default class InviteQueries { return invites; } + + @Authorized() + async getVisitRegistrationInvites( + agent: UserWithRole | null, + visitId: number + ) { + const hasReadRights = + this.userAuth.isApiToken(agent) || + this.visitAuth.hasReadRights(agent, visitId); + + if (!hasReadRights) { + return []; + } + + const invites = await this.dataSource.findVisitRegistrationInvites( + visitId, + false + ); + + return invites; + } } diff --git a/apps/backend/src/resolvers/types/Visit.ts b/apps/backend/src/resolvers/types/Visit.ts index 5534f54007..8628bbc6ad 100644 --- a/apps/backend/src/resolvers/types/Visit.ts +++ b/apps/backend/src/resolvers/types/Visit.ts @@ -11,6 +11,7 @@ import { import { ResolverContext } from '../../context'; import { Visit as VisitOrigin } from '../../models/Visit'; import { BasicUserDetails } from './BasicUserDetails'; +import { Invite } from './Invite'; import { Proposal } from './Proposal'; import { Sample } from './Sample'; import { VisitRegistration } from './VisitRegistration'; @@ -53,6 +54,17 @@ export class VisitResolver { }); } + @FieldResolver(() => [Invite]) + async visitRegistrationInvites( + @Root() visit: Visit, + @Ctx() context: ResolverContext + ): Promise { + return context.queries.invite.getVisitRegistrationInvites( + context.user, + visit.id + ); + } + @FieldResolver(() => BasicUserDetails) async teamLead( @Root() visit: Visit, From eed93393c11c32acde77c005f97774c575b0b82f Mon Sep 17 00:00:00 2001 From: Jekabs Karklins Date: Mon, 9 Jun 2025 18:51:31 +0200 Subject: [PATCH 09/51] feat: enhance visit management with registration claims and invites --- apps/backend/src/config/Tokens.ts | 2 + .../backend/src/config/dependencyConfigESS.ts | 7 + .../datasources/postgres/InviteDataSource.ts | 7 +- .../datasources/postgres/VisitDataSource.ts | 9 +- apps/backend/src/events/applicationEvents.ts | 6 + apps/backend/src/events/event.enum.ts | 2 + apps/backend/src/mutations/InviteMutations.ts | 151 ++++++++++++++++-- apps/backend/src/mutations/VisitMutations.ts | 1 + apps/backend/src/queries/InviteQueries.ts | 7 +- .../mutations/CreateVisitMutation.ts | 25 ++- .../mutations/UpdateVisitMutation.ts | 20 ++- apps/backend/src/resolvers/types/Visit.ts | 2 +- .../src/components/proposal/ProposalTable.tsx | 4 +- .../proposalBooking/CreateUpdateVisit.tsx | 7 +- .../graphql/experiment/getExperiments.graphql | 3 + .../graphql/user/getUserExperiments.graphql | 3 + .../src/graphql/visit/createVisit.graphql | 7 + .../src/graphql/visit/updateVisit.graphql | 6 + 18 files changed, 237 insertions(+), 32 deletions(-) diff --git a/apps/backend/src/config/Tokens.ts b/apps/backend/src/config/Tokens.ts index 5b88884c34..31f6ecef62 100644 --- a/apps/backend/src/config/Tokens.ts +++ b/apps/backend/src/config/Tokens.ts @@ -37,9 +37,11 @@ export const Tokens = { UnitDataSource: Symbol('UnitDataSource'), UserAuthorization: Symbol('UserAuthorization'), ProposalAuthorization: Symbol('ProposalAuthorization'), + VisitAuthorization: Symbol('VisitAuthorization'), VisitRegistrationAuthorization: Symbol('VisitRegistrationAuthorization'), UserDataSource: Symbol('UserDataSource'), VisitDataSource: Symbol('VisitDataSource'), + VisitRegistrationClaimDataSource: Symbol('VisitRegistrationClaimDataSource'), PredefinedMessageDataSource: Symbol('PredefinedMessageDataSource'), InternalReviewDataSource: Symbol('InternalReviewDataSource'), FapDataColumns: Symbol('FapDataColumns'), diff --git a/apps/backend/src/config/dependencyConfigESS.ts b/apps/backend/src/config/dependencyConfigESS.ts index 76f1f9c089..b8f1af0b17 100644 --- a/apps/backend/src/config/dependencyConfigESS.ts +++ b/apps/backend/src/config/dependencyConfigESS.ts @@ -3,6 +3,7 @@ import 'reflect-metadata'; import { OAuthAuthorization } from '../auth/OAuthAuthorization'; import { ProposalAuthorization } from '../auth/ProposalAuthorization'; +import { VisitAuthorization } from '../auth/VisitAuthorization'; import { VisitRegistrationAuthorization } from '../auth/VisitRegistrationAuthorization'; import { PostgresAdminDataSourceWithAutoUpgrade } from '../datasources/postgres/AdminDataSource'; import PostgresCallDataSource from '../datasources/postgres/CallDataSource'; @@ -35,6 +36,7 @@ import PostgresTemplateDataSource from '../datasources/postgres/TemplateDataSour import PostgresUnitDataSource from '../datasources/postgres/UnitDataSource'; import PostgresUserDataSource from '../datasources/postgres/UserDataSource'; import PostgresVisitDataSource from '../datasources/postgres/VisitDataSource'; +import PostgresVisitRegistrationClaimDataSource from '../datasources/postgres/VisitRegistrationClaimDataSource'; import PostgresWorkflowDataSource from '../datasources/postgres/WorkflowDataSource'; import { essEmailHandler } from '../eventHandlers/email/essEmailHandler'; import createLoggingHandler from '../eventHandlers/logging'; @@ -87,6 +89,11 @@ mapClass(Tokens.TemplateDataSource, PostgresTemplateDataSource); mapClass(Tokens.UnitDataSource, PostgresUnitDataSource); mapClass(Tokens.UserDataSource, PostgresUserDataSource); mapClass(Tokens.VisitDataSource, PostgresVisitDataSource); +mapClass( + Tokens.VisitRegistrationClaimDataSource, + PostgresVisitRegistrationClaimDataSource +); +mapClass(Tokens.VisitAuthorization, VisitAuthorization); mapClass(Tokens.VisitRegistrationAuthorization, VisitRegistrationAuthorization); mapClass(Tokens.InternalReviewDataSource, PostgresInternalReviewDataSource); mapClass(Tokens.TechniqueDataSource, PostgresTechniqueDataSource); diff --git a/apps/backend/src/datasources/postgres/InviteDataSource.ts b/apps/backend/src/datasources/postgres/InviteDataSource.ts index 1c1cb2105d..7c4406d844 100644 --- a/apps/backend/src/datasources/postgres/InviteDataSource.ts +++ b/apps/backend/src/datasources/postgres/InviteDataSource.ts @@ -36,7 +36,7 @@ export default class PostgresInviteDataSource implements InviteDataSource { ): Promise { return database .select('*') - .from('visit_registration_invites') + .from('visit_registration_claims') .where('visit_id', visitId) .modify((query) => { if (isClaimed !== undefined) { @@ -47,6 +47,11 @@ export default class PostgresInviteDataSource implements InviteDataSource { } } }) + .leftJoin( + 'invites', + 'visit_registration_claims.invite_id', + 'invites.invite_id' + ) .catch((error: Error) => { throw new Error( `Could not find visit registration invites: ${error.message}` diff --git a/apps/backend/src/datasources/postgres/VisitDataSource.ts b/apps/backend/src/datasources/postgres/VisitDataSource.ts index df4213894c..653aff0da9 100644 --- a/apps/backend/src/datasources/postgres/VisitDataSource.ts +++ b/apps/backend/src/datasources/postgres/VisitDataSource.ts @@ -43,12 +43,17 @@ class PostgresVisitDataSource implements VisitDataSource { .then((visit) => (visit ? createVisitObject(visit) : null)); } - getRegistration(userId: number, visitId: number): Promise { + getRegistration( + userId: number, + visitId: number + ): Promise { return database('visits_has_users') .where({ visit_id: visitId }) .andWhere({ user_id: userId }) .first() - .then((registration) => createVisitRegistrationObject(registration)); + .then((registration) => + registration ? createVisitRegistrationObject(registration) : null + ); } getRegistrations( diff --git a/apps/backend/src/events/applicationEvents.ts b/apps/backend/src/events/applicationEvents.ts index 3011f4de04..b753de16f4 100644 --- a/apps/backend/src/events/applicationEvents.ts +++ b/apps/backend/src/events/applicationEvents.ts @@ -11,6 +11,7 @@ import { Sample } from '../models/Sample'; import { TechnicalReview } from '../models/TechnicalReview'; import { Technique } from '../models/Technique'; import { User, UserRole } from '../models/User'; +import { Visit } from '../models/Visit'; import { VisitRegistration } from '../models/VisitRegistration'; import { Event } from './event.enum'; @@ -380,6 +381,10 @@ interface InternalReviewDeleted extends GeneralEvent { internalreview: InternalReview; } +interface VisitCreatedEvent extends GeneralEvent { + type: Event.VISIT_CREATED; + visit: Visit; +} interface VisitRegistrationApprovedEvent extends GeneralEvent { type: Event.VISIT_REGISTRATION_APPROVED; visitregistration: VisitRegistration; @@ -461,5 +466,6 @@ export type ApplicationEvent = | InternalReviewCreated | InternalReviewUpdated | InternalReviewDeleted + | VisitCreatedEvent | VisitRegistrationApprovedEvent | VisitRegistrationCancelledEvent; diff --git a/apps/backend/src/events/event.enum.ts b/apps/backend/src/events/event.enum.ts index fca61a8f1f..ee0a487681 100644 --- a/apps/backend/src/events/event.enum.ts +++ b/apps/backend/src/events/event.enum.ts @@ -89,6 +89,7 @@ export enum Event { EXPERIMENT_ESF_REJECTED_BY_IS = 'EXPERIMENT_ESF_REJECTED_BY_IS', EXPERIMENT_ESF_APPROVED_BY_ESR = 'EXPERIMENT_ESF_APPROVED_BY_ESR', EXPERIMENT_ESF_REJECTED_BY_ESR = 'EXPERIMENT_ESF_REJECTED_BY_ESR', + VISIT_CREATED = 'VISIT_CREATED', } export const EventLabel = new Map([ @@ -344,6 +345,7 @@ export const EventLabel = new Map([ Event.PROPOSAL_ASSIGNED_TO_TECHNIQUES, 'Event occurs when a proposal is assigned to techniques', ], + [Event.VISIT_CREATED, 'Event occurs when visit is created'], [ Event.VISIT_REGISTRATION_APPROVED, 'Event occurs when visit registration is approved', diff --git a/apps/backend/src/mutations/InviteMutations.ts b/apps/backend/src/mutations/InviteMutations.ts index fd189f8184..7b14398a4b 100644 --- a/apps/backend/src/mutations/InviteMutations.ts +++ b/apps/backend/src/mutations/InviteMutations.ts @@ -1,14 +1,19 @@ +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, EventBus } from '../decorators'; import { Event } from '../events/event.enum'; import { Invite } from '../models/Invite'; @@ -31,8 +36,14 @@ export default class InviteMutations { 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 @@ -48,7 +59,6 @@ export default class InviteMutations { if (invite === null) { return rejection('Invite code not found', { invite: code }); } - if (invite.claimedByUserId !== null) { return rejection('Invite code already claimed', { invite: code }); } @@ -59,6 +69,7 @@ export default class InviteMutations { await this.processRoleClaims(agent!.id, invite.id); await this.processCoProposerClaims(agent!.id, invite.id); + await this.processVisitRegistrationClaims(agent!.id, invite.id); const updatedInvite = await this.inviteDataSource.update({ id: invite.id, @@ -108,26 +119,75 @@ export default class InviteMutations { deletedInvites.map((invite) => this.inviteDataSource.delete(invite.id)) ); - // Get invite validity period from settings - const inviteValidityPeriodSetting = await this.adminDataSource.getSetting( - SettingsId.INVITE_VALIDITY_PERIOD_DAYS + 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, + }) + ) ); - if (!inviteValidityPeriodSetting?.settingsValue) { - return rejection('Invite validity period setting not found'); + await Promise.all( + newInvites.map(async (newInvite) => { + await this.coProposerClaimDataSource.create(newInvite.id, proposalPk); + await this.roleClaimDataSource.create(newInvite.id, UserRole.USER); + }) + ); + + return [ + ...existingInvites.filter((invite) => !deletedInvites.includes(invite)), + ...newInvites, + ]; + } + + @Authorized() + @EventBus(Event.EMAIL_INVITES) + public async setVisitRegistrationInvites( + agent: UserWithRole | null, + args: { visitId: number; inviteEmails: string[] } + ): Promise { + const { visitId, inviteEmails } = 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 validityPeriodDays = parseInt( - inviteValidityPeriodSetting.settingsValue + const existingClaims = + await this.visitRegistrationClaimDataSource.findByVisitId(visitId); + const existingInvites = (await Promise.all( + existingClaims.map((claim) => + this.inviteDataSource.findById(claim.inviteId) + ) + )) as Invite[]; + const existingEmails = existingInvites.map((invite) => invite.email); + + const deletedEmails = existingEmails.filter( + (email) => !inviteEmails.includes(email) + ); + const newEmails = inviteEmails.filter( + (email) => !existingEmails.includes(email) ); - if (isNaN(validityPeriodDays) || validityPeriodDays <= 0) { - return rejection('Invalid invite validity period value'); - } - const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000; - const expirationDate = new Date( - Date.now() + validityPeriodDays * MILLISECONDS_PER_DAY + 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({ @@ -140,7 +200,10 @@ export default class InviteMutations { ); await Promise.all( newInvites.map(async (newInvite) => { - await this.coProposerClaimDataSource.create(newInvite.id, proposalPk); + await this.visitRegistrationClaimDataSource.create( + newInvite.id, + visitId + ); await this.roleClaimDataSource.create(newInvite.id, UserRole.USER); }) ); @@ -195,6 +258,40 @@ export default class InviteMutations { } } + private async processVisitRegistrationClaims( + claimerUserId: number, + inviteId: number + ) { + const visitRegistrationClaim = + await this.visitRegistrationClaimDataSource.findByInviteId(inviteId); + + if (!visitRegistrationClaim) { + return; + } + + const existingRegistration = await this.visitDataSource.getRegistration( + claimerUserId, + visitRegistrationClaim.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: visitRegistrationClaim.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(); + } + private async proposalHasUser(proposalPk: number, userId: number) { const proposalUsers = await this.userDataSource.getProposalUsers(proposalPk); @@ -216,4 +313,28 @@ export default class InviteMutations { 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/mutations/VisitMutations.ts b/apps/backend/src/mutations/VisitMutations.ts index ee8e3896be..39a5db8b96 100644 --- a/apps/backend/src/mutations/VisitMutations.ts +++ b/apps/backend/src/mutations/VisitMutations.ts @@ -52,6 +52,7 @@ export default class VisitMutations { ) {} @Authorized() + @EventBus(Event.VISIT_CREATED) async createVisit( agent: UserWithRole | null, args: CreateVisitArgs diff --git a/apps/backend/src/queries/InviteQueries.ts b/apps/backend/src/queries/InviteQueries.ts index 657f670430..531b1f0a05 100644 --- a/apps/backend/src/queries/InviteQueries.ts +++ b/apps/backend/src/queries/InviteQueries.ts @@ -1,4 +1,4 @@ -import { container, inject, injectable } from 'tsyringe'; +import { inject, injectable } from 'tsyringe'; import { ProposalAuthorization } from '../auth/ProposalAuthorization'; import { UserAuthorization } from '../auth/UserAuthorization'; @@ -15,11 +15,10 @@ export default class InviteQueries { public dataSource: InviteDataSource, @inject(Tokens.ProposalAuthorization) private proposalAuth: ProposalAuthorization, - @inject(Tokens.UserAuthorization) private userAuth: UserAuthorization + @inject(Tokens.UserAuthorization) private userAuth: UserAuthorization, + @inject(Tokens.VisitAuthorization) private visitAuth: VisitAuthorization ) {} - private visitAuth = container.resolve(VisitAuthorization); // TODO move into dependency injection - @Authorized() async getCoProposerInvites(agent: UserWithRole | null, proposalPk: number) { const hasReadRights = diff --git a/apps/backend/src/resolvers/mutations/CreateVisitMutation.ts b/apps/backend/src/resolvers/mutations/CreateVisitMutation.ts index 9e569d6824..0ff5fb13a3 100644 --- a/apps/backend/src/resolvers/mutations/CreateVisitMutation.ts +++ b/apps/backend/src/resolvers/mutations/CreateVisitMutation.ts @@ -9,24 +9,41 @@ import { } from 'type-graphql'; import { ResolverContext } from '../../context'; +import { isRejection, rejection } from '../../models/Rejection'; import { Visit } from '../types/Visit'; @ArgsType() export class CreateVisitArgs { - @Field(() => Int) + @Field(() => Int!) experimentPk: number; - @Field(() => [Int]) + @Field(() => [Int!]) team: number[]; @Field(() => Int) teamLeadUserId: number; + + @Field(() => [String!], { nullable: true }) + inviteEmails?: string[]; } @Resolver() export class CreateVisitMutation { @Mutation(() => Visit) - createVisit(@Args() args: CreateVisitArgs, @Ctx() context: ResolverContext) { - return context.mutations.visit.createVisit(context.user, args); + async createVisit( + @Args() args: CreateVisitArgs, + @Ctx() context: ResolverContext + ) { + const visit = await context.mutations.visit.createVisit(context.user, args); + if (isRejection(visit)) { + return rejection('CREATE_VISIT_FAILED'); + } + + await context.mutations.invite.setVisitRegistrationInvites(context.user, { + visitId: visit.id, + inviteEmails: args.inviteEmails ?? [], + }); + + return visit; } } diff --git a/apps/backend/src/resolvers/mutations/UpdateVisitMutation.ts b/apps/backend/src/resolvers/mutations/UpdateVisitMutation.ts index cb805e70ec..1fa9a87ba9 100644 --- a/apps/backend/src/resolvers/mutations/UpdateVisitMutation.ts +++ b/apps/backend/src/resolvers/mutations/UpdateVisitMutation.ts @@ -21,12 +21,28 @@ export class UpdateVisitArgs { @Field(() => Int, { nullable: true }) teamLeadUserId?: number; + + @Field(() => [String!], { nullable: true }) + inviteEmails?: string[]; } @Resolver() export class UpdateVisitMutation { @Mutation(() => Visit) - updateVisit(@Args() args: UpdateVisitArgs, @Ctx() context: ResolverContext) { - return context.mutations.visit.updateVisit(context.user, args); + async updateVisit( + @Args() args: UpdateVisitArgs, + @Ctx() context: ResolverContext + ) { + const result = await context.mutations.visit.updateVisit( + context.user, + args + ); + + await context.mutations.invite.setVisitRegistrationInvites(context.user, { + visitId: args.visitId, + inviteEmails: args.inviteEmails ?? [], + }); + + return result; } } diff --git a/apps/backend/src/resolvers/types/Visit.ts b/apps/backend/src/resolvers/types/Visit.ts index 8628bbc6ad..b089e37ee3 100644 --- a/apps/backend/src/resolvers/types/Visit.ts +++ b/apps/backend/src/resolvers/types/Visit.ts @@ -55,7 +55,7 @@ export class VisitResolver { } @FieldResolver(() => [Invite]) - async visitRegistrationInvites( + async registrationInvites( @Root() visit: Visit, @Ctx() context: ResolverContext ): Promise { diff --git a/apps/frontend/src/components/proposal/ProposalTable.tsx b/apps/frontend/src/components/proposal/ProposalTable.tsx index 572449e80d..301012c2c1 100644 --- a/apps/frontend/src/components/proposal/ProposalTable.tsx +++ b/apps/frontend/src/components/proposal/ProposalTable.tsx @@ -306,10 +306,10 @@ const ProposalTable = ({ {isEmailInviteEnabled && ( } - title="Join proposal" + title="Join proposal or visit" > { diff --git a/apps/frontend/src/components/proposalBooking/CreateUpdateVisit.tsx b/apps/frontend/src/components/proposalBooking/CreateUpdateVisit.tsx index b92eda4091..78fea149f9 100644 --- a/apps/frontend/src/components/proposalBooking/CreateUpdateVisit.tsx +++ b/apps/frontend/src/components/proposalBooking/CreateUpdateVisit.tsx @@ -18,13 +18,16 @@ interface CreateUpdateVisitProps { } function CreateUpdateVisit({ event, close }: CreateUpdateVisitProps) { const { api } = useDataApiWithFeedback(); - const [visitInvites, setVisitInvites] = useState([]); + const [visitInvites, setVisitInvites] = useState( + event.visit?.registrationInvites || [] + ); const visit = event.visit; const initialValues = { team: visit?.registrations.map((registration) => registration.user!) || [], teamLeadUserId: visit?.teamLead.id || null, + inviteEmails: visit?.registrationInvites || [], }; return ( @@ -46,6 +49,7 @@ function CreateUpdateVisit({ event, close }: CreateUpdateVisitProps) { .includes(teamLeadUserId); }, }), + inviteEmails: Yup.array().default([]), })} onSubmit={async (values): Promise => { if (visit) { @@ -54,6 +58,7 @@ function CreateUpdateVisit({ event, close }: CreateUpdateVisitProps) { visitId: visit.id, team: values.team.map((user) => user.id), teamLeadUserId: values.teamLeadUserId, + inviteEmails: visitInvites.map((invite) => invite.email), }) .then(({ updateVisit }) => { if (updateVisit) { diff --git a/apps/frontend/src/graphql/experiment/getExperiments.graphql b/apps/frontend/src/graphql/experiment/getExperiments.graphql index 74d66d38f4..90687671d0 100644 --- a/apps/frontend/src/graphql/experiment/getExperiments.graphql +++ b/apps/frontend/src/graphql/experiment/getExperiments.graphql @@ -22,6 +22,9 @@ query getExperiments($filter: ExperimentsFilter, $first: Int, $offset: Int) { teamLead { ...basicUserDetails } + registrationInvites { + ...invite + } } } } diff --git a/apps/frontend/src/graphql/user/getUserExperiments.graphql b/apps/frontend/src/graphql/user/getUserExperiments.graphql index 6d4ee53eb1..5aa37e8e32 100644 --- a/apps/frontend/src/graphql/user/getUserExperiments.graphql +++ b/apps/frontend/src/graphql/user/getUserExperiments.graphql @@ -40,6 +40,9 @@ query getUserExperiments($filter: UserExperimentsFilter) { ...basicUserDetails } } + registrationInvites { + ...invite + } } experimentSafety { ...experimentSafety diff --git a/apps/frontend/src/graphql/visit/createVisit.graphql b/apps/frontend/src/graphql/visit/createVisit.graphql index 93cd910007..81cdfd4a24 100644 --- a/apps/frontend/src/graphql/visit/createVisit.graphql +++ b/apps/frontend/src/graphql/visit/createVisit.graphql @@ -2,11 +2,13 @@ mutation createVisit( $experimentPk: Int! $team: [Int!]! $teamLeadUserId: Int! + $inviteEmails: [String!] ) { createVisit( experimentPk: $experimentPk team: $team teamLeadUserId: $teamLeadUserId + inviteEmails: $inviteEmails ) { ...visit @@ -20,6 +22,11 @@ mutation createVisit( ...basicUserDetails } } + + registrationInvites { + ...invite + } + proposal { ...proposal instruments { diff --git a/apps/frontend/src/graphql/visit/updateVisit.graphql b/apps/frontend/src/graphql/visit/updateVisit.graphql index a9f44f97a9..e976b770bb 100644 --- a/apps/frontend/src/graphql/visit/updateVisit.graphql +++ b/apps/frontend/src/graphql/visit/updateVisit.graphql @@ -2,11 +2,13 @@ mutation updateVisit( $visitId: Int! $team: [Int!] $teamLeadUserId: Int + $inviteEmails: [String!] ) { updateVisit( visitId: $visitId team: $team teamLeadUserId: $teamLeadUserId + inviteEmails: $inviteEmails ) { ...visit @@ -21,6 +23,10 @@ mutation updateVisit( } } + registrationInvites { + ...invite + } + proposal { ...proposal instruments { From 1c11cc661949f2c103637321e407f701698871e6 Mon Sep 17 00:00:00 2001 From: Jekabs Karklins Date: Thu, 12 Jun 2025 14:34:08 +0200 Subject: [PATCH 10/51] feat: add VisitAuthorization and VisitRegistrationClaimDataSource mappings across configuration files --- apps/backend/src/config/dependencyConfigDefault.ts | 7 +++++++ apps/backend/src/config/dependencyConfigE2E.ts | 7 +++++++ apps/backend/src/config/dependencyConfigELI.ts | 7 +++++++ apps/backend/src/config/dependencyConfigSTFC.ts | 7 +++++++ apps/backend/src/config/dependencyConfigTest.ts | 7 +++++++ 5 files changed, 35 insertions(+) diff --git a/apps/backend/src/config/dependencyConfigDefault.ts b/apps/backend/src/config/dependencyConfigDefault.ts index 1d9ea21517..eab061075e 100644 --- a/apps/backend/src/config/dependencyConfigDefault.ts +++ b/apps/backend/src/config/dependencyConfigDefault.ts @@ -7,6 +7,7 @@ import { import 'reflect-metadata'; import { OAuthAuthorization } from '../auth/OAuthAuthorization'; import { ProposalAuthorization } from '../auth/ProposalAuthorization'; +import { VisitAuthorization } from '../auth/VisitAuthorization'; import { VisitRegistrationAuthorization } from '../auth/VisitRegistrationAuthorization'; import { PostgresAdminDataSourceWithAutoUpgrade } from '../datasources/postgres/AdminDataSource'; import PostgresCallDataSource from '../datasources/postgres/CallDataSource'; @@ -39,6 +40,7 @@ import PostgresTemplateDataSource from '../datasources/postgres/TemplateDataSour import PostgresUnitDataSource from '../datasources/postgres/UnitDataSource'; import PostgresUserDataSource from '../datasources/postgres/UserDataSource'; import PostgresVisitDataSource from '../datasources/postgres/VisitDataSource'; +import PostgresVisitRegistrationClaimDataSource from '../datasources/postgres/VisitRegistrationClaimDataSource'; import PostgresWorkflowDataSource from '../datasources/postgres/WorkflowDataSource'; import { createSkipLoggingHandler } from '../eventHandlers/logging'; import { SkipSendMailService } from '../eventHandlers/MailService/SkipSendMailService'; @@ -94,6 +96,11 @@ mapClass(Tokens.TemplateDataSource, PostgresTemplateDataSource); mapClass(Tokens.UnitDataSource, PostgresUnitDataSource); mapClass(Tokens.UserDataSource, PostgresUserDataSource); mapClass(Tokens.VisitDataSource, PostgresVisitDataSource); +mapClass( + Tokens.VisitRegistrationClaimDataSource, + PostgresVisitRegistrationClaimDataSource +); +mapClass(Tokens.VisitAuthorization, VisitAuthorization); mapClass(Tokens.VisitRegistrationAuthorization, VisitRegistrationAuthorization); mapClass(Tokens.TechniqueDataSource, PostgresTechniqueDataSource); mapClass( diff --git a/apps/backend/src/config/dependencyConfigE2E.ts b/apps/backend/src/config/dependencyConfigE2E.ts index cd0a5181c7..283a2691b8 100644 --- a/apps/backend/src/config/dependencyConfigE2E.ts +++ b/apps/backend/src/config/dependencyConfigE2E.ts @@ -3,6 +3,7 @@ import { ConsoleLogger, setLogger } from '@user-office-software/duo-logger'; import 'reflect-metadata'; import { OAuthAuthorization } from '../auth/OAuthAuthorization'; import { ProposalAuthorization } from '../auth/ProposalAuthorization'; +import { VisitAuthorization } from '../auth/VisitAuthorization'; import { VisitRegistrationAuthorization } from '../auth/VisitRegistrationAuthorization'; import PostgresAdminDataSource from '../datasources/postgres/AdminDataSource'; import PostgresCallDataSource from '../datasources/postgres/CallDataSource'; @@ -35,6 +36,7 @@ import PostgresTemplateDataSource from '../datasources/postgres/TemplateDataSour import PostgresUnitDataSource from '../datasources/postgres/UnitDataSource'; import PostgresUserDataSource from '../datasources/postgres/UserDataSource'; import PostgresVisitDataSource from '../datasources/postgres/VisitDataSource'; +import PostgresVisitRegistrationClaimDataSource from '../datasources/postgres/VisitRegistrationClaimDataSource'; import PostgresWorkflowDataSource from '../datasources/postgres/WorkflowDataSource'; import { essEmailHandler } from '../eventHandlers/email/essEmailHandler'; import createLoggingHandler from '../eventHandlers/logging'; @@ -86,6 +88,11 @@ mapClass(Tokens.TemplateDataSource, PostgresTemplateDataSource); mapClass(Tokens.UnitDataSource, PostgresUnitDataSource); mapClass(Tokens.UserDataSource, PostgresUserDataSource); mapClass(Tokens.VisitDataSource, PostgresVisitDataSource); +mapClass( + Tokens.VisitRegistrationClaimDataSource, + PostgresVisitRegistrationClaimDataSource +); +mapClass(Tokens.VisitAuthorization, VisitAuthorization); mapClass(Tokens.VisitRegistrationAuthorization, VisitRegistrationAuthorization); mapClass(Tokens.TechniqueDataSource, PostgresTechniqueDataSource); mapClass( diff --git a/apps/backend/src/config/dependencyConfigELI.ts b/apps/backend/src/config/dependencyConfigELI.ts index 83513f4c1b..c7e7619820 100644 --- a/apps/backend/src/config/dependencyConfigELI.ts +++ b/apps/backend/src/config/dependencyConfigELI.ts @@ -2,6 +2,7 @@ import 'reflect-metadata'; import { OAuthAuthorization } from '../auth/OAuthAuthorization'; import { ProposalAuthorization } from '../auth/ProposalAuthorization'; +import { VisitAuthorization } from '../auth/VisitAuthorization'; import { VisitRegistrationAuthorization } from '../auth/VisitRegistrationAuthorization'; import { PostgresAdminDataSourceWithAutoUpgrade } from '../datasources/postgres/AdminDataSource'; import PostgresCallDataSource from '../datasources/postgres/CallDataSource'; @@ -34,6 +35,7 @@ import PostgresTemplateDataSource from '../datasources/postgres/TemplateDataSour import PostgresUnitDataSource from '../datasources/postgres/UnitDataSource'; import PostgresUserDataSource from '../datasources/postgres/UserDataSource'; import PostgresVisitDataSource from '../datasources/postgres/VisitDataSource'; +import PostgresVisitRegistrationClaimDataSource from '../datasources/postgres/VisitRegistrationClaimDataSource'; import PostgresWorkflowDataSource from '../datasources/postgres/WorkflowDataSource'; import { eliEmailHandler } from '../eventHandlers/email/eliEmailHandler'; import createLoggingHandler from '../eventHandlers/logging'; @@ -88,6 +90,11 @@ mapClass(Tokens.TemplateDataSource, PostgresTemplateDataSource); mapClass(Tokens.UnitDataSource, PostgresUnitDataSource); mapClass(Tokens.UserDataSource, PostgresUserDataSource); mapClass(Tokens.VisitDataSource, PostgresVisitDataSource); +mapClass( + Tokens.VisitRegistrationClaimDataSource, + PostgresVisitRegistrationClaimDataSource +); +mapClass(Tokens.VisitAuthorization, VisitAuthorization); mapClass(Tokens.VisitRegistrationAuthorization, VisitRegistrationAuthorization); mapClass(Tokens.InternalReviewDataSource, PostgresInternalReviewDataSource); mapClass(Tokens.TechniqueDataSource, PostgresTechniqueDataSource); diff --git a/apps/backend/src/config/dependencyConfigSTFC.ts b/apps/backend/src/config/dependencyConfigSTFC.ts index fa66e7ad32..1820fc260b 100644 --- a/apps/backend/src/config/dependencyConfigSTFC.ts +++ b/apps/backend/src/config/dependencyConfigSTFC.ts @@ -2,6 +2,7 @@ import 'reflect-metadata'; import { StfcProposalAuthorization } from '../auth/StfcProposalAuthorization'; import { StfcUserAuthorization } from '../auth/StfcUserAuthorization'; +import { VisitAuthorization } from '../auth/VisitAuthorization'; import { VisitRegistrationAuthorization } from '../auth/VisitRegistrationAuthorization'; import { PostgresAdminDataSourceWithAutoUpgrade } from '../datasources/postgres/AdminDataSource'; import PostgresCallDataSource from '../datasources/postgres/CallDataSource'; @@ -29,6 +30,7 @@ import PostgresSystemDataSource from '../datasources/postgres/SystemDataSource'; import PostgresTemplateDataSource from '../datasources/postgres/TemplateDataSource'; import PostgresUnitDataSource from '../datasources/postgres/UnitDataSource'; import PostgresVisitDataSource from '../datasources/postgres/VisitDataSource'; +import PostgresVisitRegistrationClaimDataSource from '../datasources/postgres/VisitRegistrationClaimDataSource'; import PostgresWorkflowDataSource from '../datasources/postgres/WorkflowDataSource'; import StfcFapDataSource from '../datasources/stfc/StfcFapDataSource'; import StfcInstrumentDataSource from '../datasources/stfc/StfcInstrumentDataSource'; @@ -85,6 +87,11 @@ mapClass(Tokens.TemplateDataSource, PostgresTemplateDataSource); mapClass(Tokens.UnitDataSource, PostgresUnitDataSource); mapClass(Tokens.UserDataSource, StfcUserDataSource); mapClass(Tokens.VisitDataSource, PostgresVisitDataSource); +mapClass( + Tokens.VisitRegistrationClaimDataSource, + PostgresVisitRegistrationClaimDataSource +); +mapClass(Tokens.VisitAuthorization, VisitAuthorization); mapClass(Tokens.VisitRegistrationAuthorization, VisitRegistrationAuthorization); mapClass(Tokens.InternalReviewDataSource, PostgresInternalReviewDataSource); mapClass(Tokens.TechniqueDataSource, StfcTechniqueDataSource); diff --git a/apps/backend/src/config/dependencyConfigTest.ts b/apps/backend/src/config/dependencyConfigTest.ts index 332ae3ba2f..32fee883d2 100644 --- a/apps/backend/src/config/dependencyConfigTest.ts +++ b/apps/backend/src/config/dependencyConfigTest.ts @@ -4,6 +4,7 @@ import { ConsoleLogger, setLogger } from '@user-office-software/duo-logger'; import 'reflect-metadata'; import { UserAuthorizationMock } from '../auth/mockups/UserAuthorization'; import { ProposalAuthorization } from '../auth/ProposalAuthorization'; +import { VisitAuthorization } from '../auth/VisitAuthorization'; import { VisitRegistrationAuthorization } from '../auth/VisitRegistrationAuthorization'; import { AdminDataSourceMock } from '../datasources/mockups/AdminDataSource'; import { CallDataSourceMock } from '../datasources/mockups/CallDataSource'; @@ -34,6 +35,7 @@ import { TechniqueDataSourceMock } from '../datasources/mockups/TechniqueDataSou import { TemplateDataSourceMock } from '../datasources/mockups/TemplateDataSource'; import { UnitDataSourceMock } from '../datasources/mockups/UnitDataSource'; import { UserDataSourceMock } from '../datasources/mockups/UserDataSource'; +import { VisitRegistrationClaimDataSourceMock } from '../datasources/mockups/VisitRegistrationClaimDataSource'; import { WorkflowDataSourceMock } from '../datasources/mockups/WorkflowDataSource'; import PostgresPredefinedMessageDataSource from '../datasources/postgres/PredefinedMessageDataSource'; import { essEmailHandler } from '../eventHandlers/email/essEmailHandler'; @@ -83,6 +85,11 @@ mapClass(Tokens.TemplateDataSource, TemplateDataSourceMock); mapClass(Tokens.UnitDataSource, UnitDataSourceMock); mapClass(Tokens.UserDataSource, UserDataSourceMock); mapClass(Tokens.VisitDataSource, VisitDataSourceMock); +mapClass( + Tokens.VisitRegistrationClaimDataSource, + VisitRegistrationClaimDataSourceMock +); +mapClass(Tokens.VisitAuthorization, VisitAuthorization); mapClass(Tokens.VisitRegistrationAuthorization, VisitRegistrationAuthorization); mapClass( Tokens.PredefinedMessageDataSource, From 219710de21fe0636e6e6d05ec22e271efac72945 Mon Sep 17 00:00:00 2001 From: Jekabs Karklins Date: Mon, 16 Jun 2025 12:56:03 +0200 Subject: [PATCH 11/51] feat: implement visit registration claim acceptance event and related email template handling --- .../jobs/checkInviteReminderJob.spec.ts | 2 +- .../asyncJobs/jobs/checkInviteReminderJob.ts | 15 +- .../eventHandlers/email/essEmailHandler.ts | 131 ++++++------------ apps/backend/src/eventHandlers/logging.ts | 20 +++ apps/backend/src/events/applicationEvents.ts | 5 + apps/backend/src/events/event.enum.ts | 9 +- apps/backend/src/mutations/InviteMutations.ts | 29 ++-- 7 files changed, 96 insertions(+), 115 deletions(-) diff --git a/apps/backend/src/asyncJobs/jobs/checkInviteReminderJob.spec.ts b/apps/backend/src/asyncJobs/jobs/checkInviteReminderJob.spec.ts index e801076837..64ee91614b 100644 --- a/apps/backend/src/asyncJobs/jobs/checkInviteReminderJob.spec.ts +++ b/apps/backend/src/asyncJobs/jobs/checkInviteReminderJob.spec.ts @@ -56,7 +56,7 @@ describe('checkInviteReminderJob', () => { }); getTemplateIdForRoleSpy = sinon - .stub(EssEmailHandler, 'getTemplateIdForRole') + .stub(EssEmailHandler, 'getTemplateIdForInvite') .callsFake(mockGetTemplateIdForRole); mockGetTemplateIdForRole.returns('template_id_for_role'); diff --git a/apps/backend/src/asyncJobs/jobs/checkInviteReminderJob.ts b/apps/backend/src/asyncJobs/jobs/checkInviteReminderJob.ts index 8d93e2844f..676e2a8c3e 100644 --- a/apps/backend/src/asyncJobs/jobs/checkInviteReminderJob.ts +++ b/apps/backend/src/asyncJobs/jobs/checkInviteReminderJob.ts @@ -6,7 +6,7 @@ import { AdminDataSource } from '../../datasources/AdminDataSource'; import { InviteDataSource } from '../../datasources/InviteDataSource'; import { RoleClaimDataSource } from '../../datasources/RoleClaimDataSource'; import { UserDataSource } from '../../datasources/UserDataSource'; -import { getTemplateIdForRole } from '../../eventHandlers/email/essEmailHandler'; +import { getTemplateIdForInvite } from '../../eventHandlers/email/essEmailHandler'; import { MailService } from '../../eventHandlers/MailService/MailService'; import { SettingsId } from '../../models/Settings'; @@ -114,18 +114,7 @@ const checkInviteReminder = async () => { continue; } - const roleInviteClaims = await roleClaimDataSource.findByInviteId( - invite.id - ); - - if (!roleInviteClaims.length) { - logger.logError('No role claims found for invite', { - inviteId: invite.id, - }); - continue; - } - - const templateId = getTemplateIdForRole(roleInviteClaims[0].roleId); + const templateId = await getTemplateIdForInvite(invite.id); try { await mailService.sendMail({ diff --git a/apps/backend/src/eventHandlers/email/essEmailHandler.ts b/apps/backend/src/eventHandlers/email/essEmailHandler.ts index 210c9f795c..ef6eb11661 100644 --- a/apps/backend/src/eventHandlers/email/essEmailHandler.ts +++ b/apps/backend/src/eventHandlers/email/essEmailHandler.ts @@ -7,10 +7,10 @@ import { CoProposerClaimDataSource } from '../../datasources/CoProposerClaimData import { FapDataSource } from '../../datasources/FapDataSource'; import { InviteDataSource } from '../../datasources/InviteDataSource'; import { ProposalDataSource } from '../../datasources/ProposalDataSource'; -import { RedeemCodesDataSource } from '../../datasources/RedeemCodesDataSource'; import { RoleClaimDataSource } from '../../datasources/RoleClaimDataSource'; import { UserDataSource } from '../../datasources/UserDataSource'; import { VisitDataSource } from '../../datasources/VisitDataSource'; +import { VisitRegistrationClaimDataSource } from '../../datasources/VisitRegistrationClaimDataSource'; import { ApplicationEvent } from '../../events/applicationEvents'; import { Event } from '../../events/event.enum'; import { Invite } from '../../models/Invite'; @@ -29,10 +29,6 @@ export async function essEmailHandler(event: ApplicationEvent) { Tokens.UserDataSource ); - const roleClaimDataSource = container.resolve( - Tokens.RoleClaimDataSource - ); - const coProposerDataSource = container.resolve( Tokens.CoProposerClaimDataSource ); @@ -41,9 +37,6 @@ export async function essEmailHandler(event: ApplicationEvent) { Tokens.InviteDataSource ); - const redeemCodesDataSource = container.resolve( - Tokens.RedeemCodesDataSource - ); const callDataSource = container.resolve( Tokens.CallDataSource ); @@ -56,71 +49,6 @@ export async function essEmailHandler(event: ApplicationEvent) { } switch (event.type) { - case Event.EMAIL_INVITE_LEGACY: { - const user = await userDataSource.getUser( - event.emailinviteresponse.userId - ); - const inviter = await userDataSource.getBasicUserInfo( - event.emailinviteresponse.inviterId - ); - - if (!user) { - logger.logError('Failed email invite. No user found', { - event, - }); - - return; - } - - if (!inviter) { - logger.logError('Failed email invite. No inviter found', { - event, - }); - - return; - } - - const redeemCode = await redeemCodesDataSource.getRedeemCodes({ - placeholderUserId: user.id, - }); - - if (!redeemCode[0]?.code) { - logger.logError('Failed email invite. No redeem code found', { - user, - inviter, - event, - }); - - return; - } - - const templateId = getTemplateIdForRole(event.emailinviteresponse.role); - - mailService - .sendMail({ - content: { - template_id: templateId, - }, - substitution_data: { - firstname: user.preferredname, - lastname: user.lastname, - email: user.email, - inviterName: inviter.firstname, - inviterLastname: inviter.lastname, - inviterOrg: inviter.institution, - redeemCode: redeemCode[0].code, - }, - recipients: [{ address: user.email }], - }) - .then((res) => { - logger.logInfo('Successful email transmission', { res }); - }) - .catch((err: string) => { - logger.logException('Failed email transmission', err); - }); - - return; - } case Event.EMAIL_INVITE: case Event.EMAIL_INVITES: { let invites; @@ -149,11 +77,7 @@ export async function essEmailHandler(event: ApplicationEvent) { return; } - const roleInviteClaim = await roleClaimDataSource.findByInviteId( - invite.id - ); - - const templateId = getTemplateIdForRole(roleInviteClaim[0].roleId); + const templateId = await getTemplateIdForInvite(invite.id); mailService .sendMail({ @@ -537,13 +461,48 @@ export async function essEmailHandler(event: ApplicationEvent) { } } -export function getTemplateIdForRole(role: UserRole): string { - switch (role) { - case UserRole.USER: - return 'user-office-registration-invitation-co-proposer'; - case UserRole.INTERNAL_REVIEWER: - return 'user-office-registration-invitation-reviewer'; - default: - throw new Error('No valid user role set for email invitation'); +export async function getTemplateIdForInvite( + inviteId: number +): Promise { + // Resolve all necessary data sources in one go + const roleClaimDS = container.resolve( + Tokens.RoleClaimDataSource + ); + const coProposerDS = container.resolve( + Tokens.CoProposerClaimDataSource + ); + const visitRegDS = container.resolve( + Tokens.VisitRegistrationClaimDataSource + ); + + // Fetch all claims concurrently + const [coProposerClaim, visitRegClaim, roleClaims] = await Promise.all([ + coProposerDS.findByInviteId(inviteId), + visitRegDS.findByInviteId(inviteId), + roleClaimDS.findByInviteId(inviteId), + ]); + + if (coProposerClaim.length > 0) { + return 'user-office-registration-invitation-co-proposer'; } + + if (visitRegClaim) { + return 'user-office-registration-invitation-visit-registration'; + } + + if (roleClaims.length > 0) { + const { roleId } = roleClaims[0]; + switch (roleId) { + case UserRole.INTERNAL_REVIEWER: + return 'user-office-registration-invitation-reviewer'; + case UserRole.USER: + return 'user-office-registration-invitation-user'; + default: + throw new Error( + `Unsupported role \"${roleId}\" for invite ${inviteId}` + ); + } + } + + throw new Error(`No valid claim found for invite ${inviteId}`); } diff --git a/apps/backend/src/eventHandlers/logging.ts b/apps/backend/src/eventHandlers/logging.ts index 523e6d462f..6416ab187d 100644 --- a/apps/backend/src/eventHandlers/logging.ts +++ b/apps/backend/src/eventHandlers/logging.ts @@ -117,6 +117,26 @@ export default function createLoggingHandler() { break; } + case Event.PROPOSAL_VISIT_REGISTRATION_CLAIM_ACCEPTED: { + const invite = event.invite; + + const coProposerInvites = + await coProposerClaimDataSource.findByInviteId(invite.id); + + await Promise.all( + coProposerInvites.map(async (coProposerInvite) => { + return eventLogsDataSource.set( + event.loggedInUserId, + event.type, + json, + coProposerInvite.proposalPk.toString(), + `Visit registration claim accepted: ${invite.email}` + ); + }) + ); + + break; + } case Event.PROPOSAL_INSTRUMENTS_SELECTED: { await Promise.all( event.instrumentshasproposals.proposalPks.map( diff --git a/apps/backend/src/events/applicationEvents.ts b/apps/backend/src/events/applicationEvents.ts index 75c55e10b9..b2d7f6773f 100644 --- a/apps/backend/src/events/applicationEvents.ts +++ b/apps/backend/src/events/applicationEvents.ts @@ -227,6 +227,10 @@ interface ProposalCoProposerClaimAcceptedEvent extends GeneralEvent { invite: Invite; } +interface ProposalVisitClaimAcceptedEvent extends GeneralEvent { + type: Event.PROPOSAL_VISIT_REGISTRATION_CLAIM_ACCEPTED; + invite: Invite; +} interface UserUpdateEvent extends GeneralEvent { type: Event.USER_UPDATED; user: User; @@ -465,6 +469,7 @@ export type ApplicationEvent = | ProposalAllFapInstrumentSubmittedEvent | ProposalCoProposerClaimSentEvent | ProposalCoProposerClaimAcceptedEvent + | ProposalVisitClaimAcceptedEvent | InstrumentCreatedEvent | InstrumentUpdatedEvent | InstrumentDeletedEvent diff --git a/apps/backend/src/events/event.enum.ts b/apps/backend/src/events/event.enum.ts index 271646a2a9..5f3c53d6e8 100644 --- a/apps/backend/src/events/event.enum.ts +++ b/apps/backend/src/events/event.enum.ts @@ -44,8 +44,8 @@ export enum Event { USER_DELETED = 'USER_DELETED', USER_PASSWORD_RESET_EMAIL = 'USER_PASSWORD_RESET_EMAIL', EMAIL_INVITE_LEGACY = 'EMAIL_INVITE_LEGACY', - EMAIL_INVITE = 'EMAIL_INVITE', - EMAIL_INVITES = 'EMAIL_INVITES', + EMAIL_INVITE = 'EMAIL_INVITE', // TODO: this should be called INVITE_CREATED + EMAIL_INVITES = 'EMAIL_INVITES', // TODO: this should be called INVITES_CREATED FAP_CREATED = 'FAP_CREATED', FAP_UPDATED = 'FAP_UPDATED', FAP_MEMBERS_ASSIGNED = 'FAP_MEMBERS_ASSIGNED', @@ -68,6 +68,7 @@ export enum Event { PROPOSAL_BOOKING_TIME_REOPENED = 'PROPOSAL_BOOKING_TIME_REOPENED', PROPOSAL_CO_PROPOSER_CLAIM_SENT = 'PROPOSAL_CO_PROPOSER_CLAIM_SENT', PROPOSAL_CO_PROPOSER_CLAIM_ACCEPTED = 'PROPOSAL_CO_PROPOSER_CLAIM_ACCEPTED', + PROPOSAL_VISIT_REGISTRATION_CLAIM_ACCEPTED = 'PROPOSAL_VISIT_REGISTRATION_CLAIM_ACCEPTED', INSTRUMENT_CREATED = 'INSTRUMENT_CREATED', INSTRUMENT_UPDATED = 'INSTRUMENT_UPDATED', INSTRUMENT_DELETED = 'INSTRUMENT_DELETED', @@ -271,6 +272,10 @@ export const EventLabel = new Map([ Event.PROPOSAL_STATUS_ACTION_EXECUTED, 'Event occurs when the proposal status action is being executed in the status engine', ], + [ + Event.PROPOSAL_VISIT_REGISTRATION_CLAIM_ACCEPTED, + 'Event occurs when user accepts the visit registration claim for a proposal', + ], [ Event.PROPOSAL_STATUS_CHANGED_BY_WORKFLOW, 'Event occurs when the proposal status was changed by the workflow engine', diff --git a/apps/backend/src/mutations/InviteMutations.ts b/apps/backend/src/mutations/InviteMutations.ts index e72528af15..6d15a311ed 100644 --- a/apps/backend/src/mutations/InviteMutations.ts +++ b/apps/backend/src/mutations/InviteMutations.ts @@ -165,15 +165,10 @@ export default class InviteMutations { ); } - const existingClaims = - await this.visitRegistrationClaimDataSource.findByVisitId(visitId); - const existingInvites = (await Promise.all( - existingClaims.map((claim) => - this.inviteDataSource.findById(claim.inviteId) - ) - )) as Invite[]; - const existingEmails = existingInvites.map((invite) => invite.email); + const existingInvites = + await this.inviteDataSource.findVisitRegistrationInvites(visitId, false); + const existingEmails = existingInvites.map((invite) => invite.email); const deletedEmails = existingEmails.filter( (email) => !inviteEmails.includes(email) ); @@ -207,7 +202,6 @@ export default class InviteMutations { newInvite.id, visitId ); - await this.roleClaimDataSource.create(newInvite.id, UserRole.USER); }) ); @@ -273,16 +267,16 @@ export default class InviteMutations { invite: Invite ) { const inviteId = invite.id; - const visitRegistrationClaim = + const claim = await this.visitRegistrationClaimDataSource.findByInviteId(inviteId); - if (!visitRegistrationClaim) { + if (!claim) { return; } const existingRegistration = await this.visitDataSource.getRegistration( claimerUserId, - visitRegistrationClaim.visitId + claim.visitId ); if (existingRegistration) { @@ -292,7 +286,7 @@ export default class InviteMutations { // Insert the user into the visits_has_users table to create a new visit registration await database('visits_has_users') .insert({ - visit_id: visitRegistrationClaim.visitId, + visit_id: claim.visitId, user_id: claimerUserId, registration_questionary_id: null, starts_at: null, @@ -301,6 +295,15 @@ export default class InviteMutations { }) .onConflict(['user_id', 'visit_id']) .ignore(); + + this.eventBus.publish({ + type: Event.PROPOSAL_VISIT_REGISTRATION_CLAIM_ACCEPTED, + isRejection: false, + key: 'proposal', + loggedInUserId: claimerUserId, + invite: invite, + description: `User with ID ${claimerUserId} accepted visit invite`, + }); } private async proposalHasUser(proposalPk: number, userId: number) { From 5044a375ccdc71a2fc4d763698ab304dd89daf5f Mon Sep 17 00:00:00 2001 From: Jekabs Karklins Date: Mon, 16 Jun 2025 13:16:36 +0200 Subject: [PATCH 12/51] fix: update findByInviteId method to return an array of VisitRegistrationClaim --- .../VisitRegistrationClaimDataSource.ts | 2 +- .../VisitRegistrationClaimDataSource.ts | 12 +--- .../VisitRegistrationClaimDataSource.ts | 17 ++--- apps/backend/src/mutations/InviteMutations.ts | 62 +++++++++---------- 4 files changed, 39 insertions(+), 54 deletions(-) diff --git a/apps/backend/src/datasources/VisitRegistrationClaimDataSource.ts b/apps/backend/src/datasources/VisitRegistrationClaimDataSource.ts index de55012762..0159e74419 100644 --- a/apps/backend/src/datasources/VisitRegistrationClaimDataSource.ts +++ b/apps/backend/src/datasources/VisitRegistrationClaimDataSource.ts @@ -2,6 +2,6 @@ import { VisitRegistrationClaim } from '../models/VisitRegistrationClaim'; export interface VisitRegistrationClaimDataSource { create(inviteId: number, visitId: number): Promise; - findByInviteId(inviteId: number): Promise; + findByInviteId(inviteId: number): Promise; findByVisitId(visitId: number): Promise; } diff --git a/apps/backend/src/datasources/mockups/VisitRegistrationClaimDataSource.ts b/apps/backend/src/datasources/mockups/VisitRegistrationClaimDataSource.ts index cae8854760..f8ec9ea7c5 100644 --- a/apps/backend/src/datasources/mockups/VisitRegistrationClaimDataSource.ts +++ b/apps/backend/src/datasources/mockups/VisitRegistrationClaimDataSource.ts @@ -32,18 +32,10 @@ export class VisitRegistrationClaimDataSourceMock return newClaim; } - async findByInviteId(inviteId: number): Promise { - const claim = this.visitRegistrationClaims.find( + async findByInviteId(inviteId: number): Promise { + return this.visitRegistrationClaims.filter( (claim) => claim.inviteId === inviteId ); - - if (!claim) { - throw new Error( - `VisitRegistrationClaim not found for inviteId: ${inviteId}` - ); - } - - return claim; } async findByVisitId(visitId: number): Promise { diff --git a/apps/backend/src/datasources/postgres/VisitRegistrationClaimDataSource.ts b/apps/backend/src/datasources/postgres/VisitRegistrationClaimDataSource.ts index a6fcf005e5..27a02c80b1 100644 --- a/apps/backend/src/datasources/postgres/VisitRegistrationClaimDataSource.ts +++ b/apps/backend/src/datasources/postgres/VisitRegistrationClaimDataSource.ts @@ -33,21 +33,16 @@ export default class PostgresVisitRegistrationClaimDataSource ); } - async findByInviteId(inviteId: number): Promise { - const record = await database( + async findByInviteId(inviteId: number): Promise { + const records = await database( 'visit_registration_claims' ) .select('*') - .where('invite_id', inviteId) - .first(); + .where('invite_id', inviteId); - if (!record) { - throw new Error( - `Visit registration claim with inviteId ${inviteId} not found` - ); - } - - return createVisitRegistrationClaimObject(record); + return records.map((record: VisitRegistrationClaimRecord) => + createVisitRegistrationClaimObject(record) + ); } async findByVisitId(visitId: number): Promise { diff --git a/apps/backend/src/mutations/InviteMutations.ts b/apps/backend/src/mutations/InviteMutations.ts index 6d15a311ed..d7d269f421 100644 --- a/apps/backend/src/mutations/InviteMutations.ts +++ b/apps/backend/src/mutations/InviteMutations.ts @@ -267,43 +267,41 @@ export default class InviteMutations { invite: Invite ) { const inviteId = invite.id; - const claim = + const claims = await this.visitRegistrationClaimDataSource.findByInviteId(inviteId); - if (!claim) { - return; - } + for await (const claim of claims) { + const existingRegistration = await this.visitDataSource.getRegistration( + claimerUserId, + claim.visitId + ); - const existingRegistration = await this.visitDataSource.getRegistration( - claimerUserId, - claim.visitId - ); + if (existingRegistration) { + return; + } - 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(); - // 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(); - - this.eventBus.publish({ - type: Event.PROPOSAL_VISIT_REGISTRATION_CLAIM_ACCEPTED, - isRejection: false, - key: 'proposal', - loggedInUserId: claimerUserId, - invite: invite, - description: `User with ID ${claimerUserId} accepted visit invite`, - }); + this.eventBus.publish({ + type: Event.PROPOSAL_VISIT_REGISTRATION_CLAIM_ACCEPTED, + isRejection: false, + key: 'proposal', + loggedInUserId: claimerUserId, + invite: invite, + description: `User with ID ${claimerUserId} accepted visit invite`, + }); + } } private async proposalHasUser(proposalPk: number, userId: number) { From 0d29afbf0ba8c445637bd1905ccc6eccb1803428 Mon Sep 17 00:00:00 2001 From: Jekabs Karklins Date: Mon, 16 Jun 2025 13:21:15 +0200 Subject: [PATCH 13/51] fix: update import path for VisitRegistrationClaim model --- apps/backend/src/models/VisitRegistrationInvite.ts | 6 ------ apps/backend/src/resolvers/types/VisitRegistrationClaim.ts | 2 +- 2 files changed, 1 insertion(+), 7 deletions(-) delete mode 100644 apps/backend/src/models/VisitRegistrationInvite.ts diff --git a/apps/backend/src/models/VisitRegistrationInvite.ts b/apps/backend/src/models/VisitRegistrationInvite.ts deleted file mode 100644 index 5916f3c1a9..0000000000 --- a/apps/backend/src/models/VisitRegistrationInvite.ts +++ /dev/null @@ -1,6 +0,0 @@ -export class VisitRegistrationClaim { - constructor( - public inviteId: number, - public visitId: number - ) {} -} diff --git a/apps/backend/src/resolvers/types/VisitRegistrationClaim.ts b/apps/backend/src/resolvers/types/VisitRegistrationClaim.ts index 406490306e..32c26d2809 100644 --- a/apps/backend/src/resolvers/types/VisitRegistrationClaim.ts +++ b/apps/backend/src/resolvers/types/VisitRegistrationClaim.ts @@ -1,6 +1,6 @@ import { Field, Int, ObjectType } from 'type-graphql'; -import { VisitRegistrationClaim as VisitRegistrationClaimOrig } from '../../models/VisitRegistrationInvite'; +import { VisitRegistrationClaim as VisitRegistrationClaimOrig } from '../../models/VisitRegistrationClaim'; @ObjectType() export class VisitRegistrationClaim From e9227a2a4484a2831c15130ad4d5087a7ec6d8b3 Mon Sep 17 00:00:00 2001 From: Jekabs Karklins Date: Mon, 16 Jun 2025 14:52:41 +0200 Subject: [PATCH 14/51] fix: update button label and title for joining proposal --- apps/frontend/src/components/proposal/ProposalTable.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/frontend/src/components/proposal/ProposalTable.tsx b/apps/frontend/src/components/proposal/ProposalTable.tsx index 301012c2c1..572449e80d 100644 --- a/apps/frontend/src/components/proposal/ProposalTable.tsx +++ b/apps/frontend/src/components/proposal/ProposalTable.tsx @@ -306,10 +306,10 @@ const ProposalTable = ({ {isEmailInviteEnabled && ( } - title="Join proposal or visit" + title="Join proposal" > { From 53283f1fa6c408dec06815aa25a1a55a61e4b54c Mon Sep 17 00:00:00 2001 From: Jekabs Karklins Date: Mon, 16 Jun 2025 15:26:12 +0200 Subject: [PATCH 15/51] feat: add co-proposer claim events and logging to invite mutations --- apps/backend/src/eventHandlers/logging.ts | 36 +++++++++++++++++++ apps/backend/src/events/applicationEvents.ts | 12 +++++++ apps/backend/src/events/event.enum.ts | 2 ++ apps/backend/src/mutations/InviteMutations.ts | 16 ++++++++- 4 files changed, 65 insertions(+), 1 deletion(-) diff --git a/apps/backend/src/eventHandlers/logging.ts b/apps/backend/src/eventHandlers/logging.ts index ad926a5696..0ba8770ce7 100644 --- a/apps/backend/src/eventHandlers/logging.ts +++ b/apps/backend/src/eventHandlers/logging.ts @@ -3,6 +3,7 @@ import { logger } from '@user-office-software/duo-logger'; import { container } from 'tsyringe'; import { Tokens } from '../config/Tokens'; +import { CoProposerClaimDataSource } from '../datasources/CoProposerClaimDataSource'; import { EventLogsDataSource } from '../datasources/EventLogsDataSource'; import { FapDataSource } from '../datasources/FapDataSource'; import { InstrumentDataSource } from '../datasources/InstrumentDataSource'; @@ -33,6 +34,11 @@ export default function createLoggingHandler() { Tokens.ProposalDataSource ); + const coProposerClaimDataSource = + container.resolve( + Tokens.CoProposerClaimDataSource + ); + // Handler that logs every mutation wrapped with the event bus event to logger and event_logs table. return async function loggingHandler(event: ApplicationEvent) { const json = JSON.stringify(event); @@ -78,6 +84,36 @@ export default function createLoggingHandler() { ); } break; + + case Event.PROPOSAL_CO_PROPOSER_CLAIM_SENT: + case Event.PROPOSAL_CO_PROPOSER_CLAIM_ACCEPTED: { + let invites; + if ('invite' in event) { + invites = [event.invite]; + } else { + invites = event.array; + } + for (const invite of invites) { + const coProposerInvites = + await coProposerClaimDataSource.findByInviteId(invite.id); + + await Promise.all( + coProposerInvites.map(async (coProposerInvite) => { + return eventLogsDataSource.set( + event.loggedInUserId, + event.type, + json, + coProposerInvite.proposalPk.toString(), + event.type === Event.PROPOSAL_CO_PROPOSER_CLAIM_ACCEPTED + ? `Co-proposer invite accepted: ${invite.email}` + : `Co-proposer invite sent: ${invite.email}` + ); + }) + ); + } + + break; + } case Event.PROPOSAL_INSTRUMENTS_SELECTED: { await Promise.all( event.instrumentshasproposals.proposalPks.map( diff --git a/apps/backend/src/events/applicationEvents.ts b/apps/backend/src/events/applicationEvents.ts index 2471a18416..fbbbc17b75 100644 --- a/apps/backend/src/events/applicationEvents.ts +++ b/apps/backend/src/events/applicationEvents.ts @@ -216,6 +216,16 @@ interface ProposalTopicAnsweredEvent extends GeneralEvent { array: AnswerBasic[]; } +interface ProposalCoProposerClaimSentEvent extends GeneralEvent { + type: Event.PROPOSAL_CO_PROPOSER_CLAIM_SENT; + array: Invite[]; +} + +interface ProposalCoProposerClaimAcceptedEvent extends GeneralEvent { + type: Event.PROPOSAL_CO_PROPOSER_CLAIM_ACCEPTED; + invite: Invite; +} + interface UserUpdateEvent extends GeneralEvent { type: Event.USER_UPDATED; user: User; @@ -448,6 +458,8 @@ export type ApplicationEvent = | ProposalAllFapReviewsSubmittedForAllPanelsEvent | ProposalAllFapMeetingsSubmittedEvent | ProposalAllFapInstrumentSubmittedEvent + | ProposalCoProposerClaimSentEvent + | ProposalCoProposerClaimAcceptedEvent | InstrumentCreatedEvent | InstrumentUpdatedEvent | InstrumentDeletedEvent diff --git a/apps/backend/src/events/event.enum.ts b/apps/backend/src/events/event.enum.ts index fca61a8f1f..6af606ea31 100644 --- a/apps/backend/src/events/event.enum.ts +++ b/apps/backend/src/events/event.enum.ts @@ -66,6 +66,8 @@ export enum Event { PROPOSAL_BOOKING_TIME_COMPLETED = 'PROPOSAL_BOOKING_TIME_COMPLETED', PROPOSAL_BOOKING_TIME_UPDATED = 'PROPOSAL_BOOKING_TIME_UPDATED', PROPOSAL_BOOKING_TIME_REOPENED = 'PROPOSAL_BOOKING_TIME_REOPENED', + PROPOSAL_CO_PROPOSER_CLAIM_SENT = 'PROPOSAL_CO_PROPOSER_CLAIM_SENT', + PROPOSAL_CO_PROPOSER_CLAIM_ACCEPTED = 'PROPOSAL_CO_PROPOSER_CLAIM_ACCEPTED', INSTRUMENT_CREATED = 'INSTRUMENT_CREATED', INSTRUMENT_UPDATED = 'INSTRUMENT_UPDATED', INSTRUMENT_DELETED = 'INSTRUMENT_DELETED', diff --git a/apps/backend/src/mutations/InviteMutations.ts b/apps/backend/src/mutations/InviteMutations.ts index fd189f8184..68d400df7e 100644 --- a/apps/backend/src/mutations/InviteMutations.ts +++ b/apps/backend/src/mutations/InviteMutations.ts @@ -10,6 +10,7 @@ import { ProposalDataSource } from '../datasources/ProposalDataSource'; import { RoleClaimDataSource } from '../datasources/RoleClaimDataSource'; import { UserDataSource } from '../datasources/UserDataSource'; import { Authorized, EventBus } from '../decorators'; +import { ApplicationEventBus } from '../events'; import { Event } from '../events/event.enum'; import { Invite } from '../models/Invite'; import { rejection, Rejection } from '../models/Rejection'; @@ -35,7 +36,8 @@ export default class InviteMutations { private proposalAuth: ProposalAuthorization, @inject(Tokens.AdminDataSource) private adminDataSource: AdminDataSource, - @inject(Tokens.UserAuthorization) private userAuth: UserAuthorization + @inject(Tokens.UserAuthorization) private userAuth: UserAuthorization, + @inject(Tokens.EventBus) private eventBus: ApplicationEventBus ) {} @Authorized() @@ -71,6 +73,7 @@ export default class InviteMutations { @Authorized() @EventBus(Event.EMAIL_INVITES) + @EventBus(Event.PROPOSAL_CO_PROPOSER_CLAIM_SENT) public async setCoProposerInvites( agent: UserWithRole | null, args: SetCoProposerInvitesInput @@ -192,6 +195,17 @@ export default class InviteMutations { claim.proposalPk, claimerUserId ); + + const invite = await this.inviteDataSource.findById(inviteId); + + this.eventBus.publish({ + type: Event.PROPOSAL_CO_PROPOSER_CLAIM_ACCEPTED, + isRejection: false, + key: 'proposal', + loggedInUserId: claimerUserId, + invite: invite!, + description: `User with ID ${claimerUserId} accepted invite for proposal ${claim.proposalPk}`, + }); } } From 8de732ab323b33c539444de23a3278d230660816 Mon Sep 17 00:00:00 2001 From: Jekabs Karklins Date: Mon, 16 Jun 2025 16:06:35 +0200 Subject: [PATCH 16/51] refactor: remove legacy email invite handling and improve template ID retrieval --- .../jobs/checkInviteReminderJob.spec.ts | 22 ++-- .../asyncJobs/jobs/checkInviteReminderJob.ts | 15 +-- .../eventHandlers/email/essEmailHandler.ts | 122 ++++++------------ 3 files changed, 49 insertions(+), 110 deletions(-) diff --git a/apps/backend/src/asyncJobs/jobs/checkInviteReminderJob.spec.ts b/apps/backend/src/asyncJobs/jobs/checkInviteReminderJob.spec.ts index e801076837..153195e5a2 100644 --- a/apps/backend/src/asyncJobs/jobs/checkInviteReminderJob.spec.ts +++ b/apps/backend/src/asyncJobs/jobs/checkInviteReminderJob.spec.ts @@ -27,11 +27,11 @@ const mockAdminDataSource = { getSetting: sinon.stub(), }; -const mockGetTemplateIdForRole = sinon.stub(); +const mockGetTemplateIdForInvite = sinon.stub(); describe('checkInviteReminderJob', () => { let clock: sinon.SinonFakeTimers; - let getTemplateIdForRoleSpy: sinon.SinonSpy; + let getTemplateIdForInviteSpy: sinon.SinonSpy; beforeEach(() => { mockUserDataSource.getBasicUserInfo.reset(); @@ -39,7 +39,7 @@ describe('checkInviteReminderJob', () => { mockRoleClaimDataSource.findByInviteId.reset(); mockMailService.sendMail.reset(); mockAdminDataSource.getSetting.reset(); - mockGetTemplateIdForRole.reset(); + mockGetTemplateIdForInvite.reset(); container.clearInstances(); @@ -55,10 +55,10 @@ describe('checkInviteReminderJob', () => { useValue: mockAdminDataSource, }); - getTemplateIdForRoleSpy = sinon - .stub(EssEmailHandler, 'getTemplateIdForRole') - .callsFake(mockGetTemplateIdForRole); - mockGetTemplateIdForRole.returns('template_id_for_role'); + getTemplateIdForInviteSpy = sinon + .stub(EssEmailHandler, 'getTemplateIdForInvite') + .callsFake(mockGetTemplateIdForInvite); + mockGetTemplateIdForInvite.returns('template_id_for_invite'); const now = new Date('2025-05-14T10:00:00.000Z'); clock = sinon.useFakeTimers(now.getTime()); @@ -67,8 +67,8 @@ describe('checkInviteReminderJob', () => { afterEach(() => { sinon.restore(); clock.restore(); - if (getTemplateIdForRoleSpy) { - getTemplateIdForRoleSpy.restore(); + if (getTemplateIdForInviteSpy) { + getTemplateIdForInviteSpy.restore(); } }); @@ -166,7 +166,7 @@ describe('checkInviteReminderJob', () => { .withArgs(mockInvite.id) .resolves([mockRoleClaim]); mockMailService.sendMail.resolves(); - mockGetTemplateIdForRole + mockGetTemplateIdForInvite .withArgs(UserRole.USER) .returns('user_template_id'); }); @@ -181,7 +181,7 @@ describe('checkInviteReminderJob', () => { const expectedEndDate = new Date(targetCreationDate); expectedEndDate.setHours(23, 59, 59, 999); - expect(mockGetTemplateIdForRole.calledWith(UserRole.USER)).toBe(true); + expect(mockGetTemplateIdForInvite.calledWith(mockInvite.id)).toBe(true); expect(mockMailService.sendMail.calledOnce).toBe(true); }); diff --git a/apps/backend/src/asyncJobs/jobs/checkInviteReminderJob.ts b/apps/backend/src/asyncJobs/jobs/checkInviteReminderJob.ts index 8d93e2844f..676e2a8c3e 100644 --- a/apps/backend/src/asyncJobs/jobs/checkInviteReminderJob.ts +++ b/apps/backend/src/asyncJobs/jobs/checkInviteReminderJob.ts @@ -6,7 +6,7 @@ import { AdminDataSource } from '../../datasources/AdminDataSource'; import { InviteDataSource } from '../../datasources/InviteDataSource'; import { RoleClaimDataSource } from '../../datasources/RoleClaimDataSource'; import { UserDataSource } from '../../datasources/UserDataSource'; -import { getTemplateIdForRole } from '../../eventHandlers/email/essEmailHandler'; +import { getTemplateIdForInvite } from '../../eventHandlers/email/essEmailHandler'; import { MailService } from '../../eventHandlers/MailService/MailService'; import { SettingsId } from '../../models/Settings'; @@ -114,18 +114,7 @@ const checkInviteReminder = async () => { continue; } - const roleInviteClaims = await roleClaimDataSource.findByInviteId( - invite.id - ); - - if (!roleInviteClaims.length) { - logger.logError('No role claims found for invite', { - inviteId: invite.id, - }); - continue; - } - - const templateId = getTemplateIdForRole(roleInviteClaims[0].roleId); + const templateId = await getTemplateIdForInvite(invite.id); try { await mailService.sendMail({ diff --git a/apps/backend/src/eventHandlers/email/essEmailHandler.ts b/apps/backend/src/eventHandlers/email/essEmailHandler.ts index 210c9f795c..b1b1b75d0c 100644 --- a/apps/backend/src/eventHandlers/email/essEmailHandler.ts +++ b/apps/backend/src/eventHandlers/email/essEmailHandler.ts @@ -7,7 +7,6 @@ import { CoProposerClaimDataSource } from '../../datasources/CoProposerClaimData import { FapDataSource } from '../../datasources/FapDataSource'; import { InviteDataSource } from '../../datasources/InviteDataSource'; import { ProposalDataSource } from '../../datasources/ProposalDataSource'; -import { RedeemCodesDataSource } from '../../datasources/RedeemCodesDataSource'; import { RoleClaimDataSource } from '../../datasources/RoleClaimDataSource'; import { UserDataSource } from '../../datasources/UserDataSource'; import { VisitDataSource } from '../../datasources/VisitDataSource'; @@ -29,10 +28,6 @@ export async function essEmailHandler(event: ApplicationEvent) { Tokens.UserDataSource ); - const roleClaimDataSource = container.resolve( - Tokens.RoleClaimDataSource - ); - const coProposerDataSource = container.resolve( Tokens.CoProposerClaimDataSource ); @@ -41,9 +36,6 @@ export async function essEmailHandler(event: ApplicationEvent) { Tokens.InviteDataSource ); - const redeemCodesDataSource = container.resolve( - Tokens.RedeemCodesDataSource - ); const callDataSource = container.resolve( Tokens.CallDataSource ); @@ -56,71 +48,6 @@ export async function essEmailHandler(event: ApplicationEvent) { } switch (event.type) { - case Event.EMAIL_INVITE_LEGACY: { - const user = await userDataSource.getUser( - event.emailinviteresponse.userId - ); - const inviter = await userDataSource.getBasicUserInfo( - event.emailinviteresponse.inviterId - ); - - if (!user) { - logger.logError('Failed email invite. No user found', { - event, - }); - - return; - } - - if (!inviter) { - logger.logError('Failed email invite. No inviter found', { - event, - }); - - return; - } - - const redeemCode = await redeemCodesDataSource.getRedeemCodes({ - placeholderUserId: user.id, - }); - - if (!redeemCode[0]?.code) { - logger.logError('Failed email invite. No redeem code found', { - user, - inviter, - event, - }); - - return; - } - - const templateId = getTemplateIdForRole(event.emailinviteresponse.role); - - mailService - .sendMail({ - content: { - template_id: templateId, - }, - substitution_data: { - firstname: user.preferredname, - lastname: user.lastname, - email: user.email, - inviterName: inviter.firstname, - inviterLastname: inviter.lastname, - inviterOrg: inviter.institution, - redeemCode: redeemCode[0].code, - }, - recipients: [{ address: user.email }], - }) - .then((res) => { - logger.logInfo('Successful email transmission', { res }); - }) - .catch((err: string) => { - logger.logException('Failed email transmission', err); - }); - - return; - } case Event.EMAIL_INVITE: case Event.EMAIL_INVITES: { let invites; @@ -149,11 +76,7 @@ export async function essEmailHandler(event: ApplicationEvent) { return; } - const roleInviteClaim = await roleClaimDataSource.findByInviteId( - invite.id - ); - - const templateId = getTemplateIdForRole(roleInviteClaim[0].roleId); + const templateId = await getTemplateIdForInvite(invite.id); mailService .sendMail({ @@ -537,13 +460,40 @@ export async function essEmailHandler(event: ApplicationEvent) { } } -export function getTemplateIdForRole(role: UserRole): string { - switch (role) { - case UserRole.USER: - return 'user-office-registration-invitation-co-proposer'; - case UserRole.INTERNAL_REVIEWER: - return 'user-office-registration-invitation-reviewer'; - default: - throw new Error('No valid user role set for email invitation'); +export async function getTemplateIdForInvite( + inviteId: number +): Promise { + // Resolve all necessary data sources in one go + const roleClaimDS = container.resolve( + Tokens.RoleClaimDataSource + ); + const coProposerDS = container.resolve( + Tokens.CoProposerClaimDataSource + ); + + // Fetch all claims concurrently + const [coProposerClaim, roleClaims] = await Promise.all([ + coProposerDS.findByInviteId(inviteId), + roleClaimDS.findByInviteId(inviteId), + ]); + + if (coProposerClaim.length > 0) { + return 'user-office-registration-invitation-co-proposer'; + } + + if (roleClaims.length > 0) { + const { roleId } = roleClaims[0]; + switch (roleId) { + case UserRole.INTERNAL_REVIEWER: + return 'user-office-registration-invitation-reviewer'; + case UserRole.USER: + return 'user-office-registration-invitation-user'; + default: + throw new Error( + `Unsupported role \"${roleId}\" for invite ${inviteId}` + ); + } } + + throw new Error(`No valid claim found for invite ${inviteId}`); } From 25df8b2bbd8caaa9dd9ac2de3900f75a220e0ba2 Mon Sep 17 00:00:00 2001 From: Jekabs Karklins Date: Mon, 16 Jun 2025 16:31:35 +0200 Subject: [PATCH 17/51] refactor: rename mock template ID stubs for clarity in checkInviteReminderJob tests --- .../jobs/checkInviteReminderJob.spec.ts | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/backend/src/asyncJobs/jobs/checkInviteReminderJob.spec.ts b/apps/backend/src/asyncJobs/jobs/checkInviteReminderJob.spec.ts index 64ee91614b..153195e5a2 100644 --- a/apps/backend/src/asyncJobs/jobs/checkInviteReminderJob.spec.ts +++ b/apps/backend/src/asyncJobs/jobs/checkInviteReminderJob.spec.ts @@ -27,11 +27,11 @@ const mockAdminDataSource = { getSetting: sinon.stub(), }; -const mockGetTemplateIdForRole = sinon.stub(); +const mockGetTemplateIdForInvite = sinon.stub(); describe('checkInviteReminderJob', () => { let clock: sinon.SinonFakeTimers; - let getTemplateIdForRoleSpy: sinon.SinonSpy; + let getTemplateIdForInviteSpy: sinon.SinonSpy; beforeEach(() => { mockUserDataSource.getBasicUserInfo.reset(); @@ -39,7 +39,7 @@ describe('checkInviteReminderJob', () => { mockRoleClaimDataSource.findByInviteId.reset(); mockMailService.sendMail.reset(); mockAdminDataSource.getSetting.reset(); - mockGetTemplateIdForRole.reset(); + mockGetTemplateIdForInvite.reset(); container.clearInstances(); @@ -55,10 +55,10 @@ describe('checkInviteReminderJob', () => { useValue: mockAdminDataSource, }); - getTemplateIdForRoleSpy = sinon + getTemplateIdForInviteSpy = sinon .stub(EssEmailHandler, 'getTemplateIdForInvite') - .callsFake(mockGetTemplateIdForRole); - mockGetTemplateIdForRole.returns('template_id_for_role'); + .callsFake(mockGetTemplateIdForInvite); + mockGetTemplateIdForInvite.returns('template_id_for_invite'); const now = new Date('2025-05-14T10:00:00.000Z'); clock = sinon.useFakeTimers(now.getTime()); @@ -67,8 +67,8 @@ describe('checkInviteReminderJob', () => { afterEach(() => { sinon.restore(); clock.restore(); - if (getTemplateIdForRoleSpy) { - getTemplateIdForRoleSpy.restore(); + if (getTemplateIdForInviteSpy) { + getTemplateIdForInviteSpy.restore(); } }); @@ -166,7 +166,7 @@ describe('checkInviteReminderJob', () => { .withArgs(mockInvite.id) .resolves([mockRoleClaim]); mockMailService.sendMail.resolves(); - mockGetTemplateIdForRole + mockGetTemplateIdForInvite .withArgs(UserRole.USER) .returns('user_template_id'); }); @@ -181,7 +181,7 @@ describe('checkInviteReminderJob', () => { const expectedEndDate = new Date(targetCreationDate); expectedEndDate.setHours(23, 59, 59, 999); - expect(mockGetTemplateIdForRole.calledWith(UserRole.USER)).toBe(true); + expect(mockGetTemplateIdForInvite.calledWith(mockInvite.id)).toBe(true); expect(mockMailService.sendMail.calledOnce).toBe(true); }); From 5daa231b0d5337eda578a1f5b08e69f6d5ff41d7 Mon Sep 17 00:00:00 2001 From: Jekabs Karklins Date: Mon, 23 Jun 2025 11:31:56 +0200 Subject: [PATCH 18/51] fix: update invite property to array in ProposalCoProposerClaimSentEvent --- apps/backend/src/eventHandlers/logging.ts | 2 +- apps/backend/src/events/applicationEvents.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/backend/src/eventHandlers/logging.ts b/apps/backend/src/eventHandlers/logging.ts index 6416ab187d..52a7624a28 100644 --- a/apps/backend/src/eventHandlers/logging.ts +++ b/apps/backend/src/eventHandlers/logging.ts @@ -94,7 +94,7 @@ export default function createLoggingHandler() { if ('invite' in event) { invites = [event.invite]; } else { - invites = event.invites; + invites = event.array; } for (const invite of invites) { const coProposerInvites = diff --git a/apps/backend/src/events/applicationEvents.ts b/apps/backend/src/events/applicationEvents.ts index b2d7f6773f..08e6daa300 100644 --- a/apps/backend/src/events/applicationEvents.ts +++ b/apps/backend/src/events/applicationEvents.ts @@ -219,7 +219,7 @@ interface ProposalTopicAnsweredEvent extends GeneralEvent { interface ProposalCoProposerClaimSentEvent extends GeneralEvent { type: Event.PROPOSAL_CO_PROPOSER_CLAIM_SENT; - invites: Invite[]; + array: Invite[]; } interface ProposalCoProposerClaimAcceptedEvent extends GeneralEvent { From 67977d5edbaa19c1ee74813834cf86b1db47d705 Mon Sep 17 00:00:00 2001 From: Jekabs Karklins Date: Mon, 23 Jun 2025 12:48:21 +0200 Subject: [PATCH 19/51] feat: add visit_registration_claims table to store visit registration claims --- ...egistrationClaim.sql => 0183_AddVisitRegistrationClaim.sql} | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) rename apps/backend/db_patches/{0182_AddVisitRegistrationClaim.sql => 0183_AddVisitRegistrationClaim.sql} (89%) diff --git a/apps/backend/db_patches/0182_AddVisitRegistrationClaim.sql b/apps/backend/db_patches/0183_AddVisitRegistrationClaim.sql similarity index 89% rename from apps/backend/db_patches/0182_AddVisitRegistrationClaim.sql rename to apps/backend/db_patches/0183_AddVisitRegistrationClaim.sql index 72324229ae..76e786111c 100644 --- a/apps/backend/db_patches/0182_AddVisitRegistrationClaim.sql +++ b/apps/backend/db_patches/0183_AddVisitRegistrationClaim.sql @@ -1,9 +1,8 @@ --- 0181_RemoveUserColumns.sql DO $$ BEGIN IF register_patch( - '0182_AddVisitRegistrationClaim.sql', + 'AddVisitRegistrationClaim.sql', 'Jekabs Karklins', 'Adding table to store visit registration claims', '2025-06-04' From 80427f2124252237b16de59f178425eca22962a1 Mon Sep 17 00:00:00 2001 From: Jekabs Karklins Date: Mon, 23 Jun 2025 13:18:28 +0200 Subject: [PATCH 20/51] feat: update visit_registration_claims table structure and add conflict handling in insert --- .../0183_AddVisitRegistrationClaim.sql | 5 +++-- .../datasources/postgres/InviteDataSource.ts | 2 +- .../VisitRegistrationClaimDataSource.ts | 17 ++++++++++------- apps/backend/src/mutations/InviteMutations.ts | 3 +-- 4 files changed, 15 insertions(+), 12 deletions(-) diff --git a/apps/backend/db_patches/0183_AddVisitRegistrationClaim.sql b/apps/backend/db_patches/0183_AddVisitRegistrationClaim.sql index 76e786111c..4243391947 100644 --- a/apps/backend/db_patches/0183_AddVisitRegistrationClaim.sql +++ b/apps/backend/db_patches/0183_AddVisitRegistrationClaim.sql @@ -10,10 +10,11 @@ BEGIN BEGIN -- Create the visit_registration_claims table CREATE TABLE IF NOT EXISTS visit_registration_claims ( - invite_id SERIAL PRIMARY KEY, + invite_id INT NOT NULL, visit_id INT NOT NULL, FOREIGN KEY (invite_id) REFERENCES invites(invite_id) ON DELETE CASCADE, - FOREIGN KEY (visit_id) REFERENCES visits(visit_id) ON DELETE CASCADE + FOREIGN KEY (visit_id) REFERENCES visits(visit_id) ON DELETE CASCADE, + UNIQUE (invite_id, visit_id) ); END; END IF; diff --git a/apps/backend/src/datasources/postgres/InviteDataSource.ts b/apps/backend/src/datasources/postgres/InviteDataSource.ts index 7c4406d844..56cfe058ee 100644 --- a/apps/backend/src/datasources/postgres/InviteDataSource.ts +++ b/apps/backend/src/datasources/postgres/InviteDataSource.ts @@ -35,7 +35,7 @@ export default class PostgresInviteDataSource implements InviteDataSource { isClaimed?: boolean ): Promise { return database - .select('*') + .select('invites.*') .from('visit_registration_claims') .where('visit_id', visitId) .modify((query) => { diff --git a/apps/backend/src/datasources/postgres/VisitRegistrationClaimDataSource.ts b/apps/backend/src/datasources/postgres/VisitRegistrationClaimDataSource.ts index 27a02c80b1..8cc4a8ba42 100644 --- a/apps/backend/src/datasources/postgres/VisitRegistrationClaimDataSource.ts +++ b/apps/backend/src/datasources/postgres/VisitRegistrationClaimDataSource.ts @@ -16,13 +16,16 @@ export default class PostgresVisitRegistrationClaimDataSource inviteId: number, visitId: number ): Promise { - const records = await database('visit_registration_claims').insert( - { - invite_id: inviteId, - visit_id: visitId, - }, - '*' - ); + const records = await database('visit_registration_claims') + .insert( + { + invite_id: inviteId, + visit_id: visitId, + }, + '*' + ) + .onConflict(['invite_id', 'visit_id']) + .ignore(); if (records.length !== 1) { throw new Error('Failed to create visit registration claim'); diff --git a/apps/backend/src/mutations/InviteMutations.ts b/apps/backend/src/mutations/InviteMutations.ts index d7d269f421..ff180bbc32 100644 --- a/apps/backend/src/mutations/InviteMutations.ts +++ b/apps/backend/src/mutations/InviteMutations.ts @@ -261,7 +261,6 @@ export default class InviteMutations { }); } } - private async processVisitRegistrationClaims( claimerUserId: number, invite: Invite @@ -270,7 +269,7 @@ export default class InviteMutations { const claims = await this.visitRegistrationClaimDataSource.findByInviteId(inviteId); - for await (const claim of claims) { + for (const claim of claims) { const existingRegistration = await this.visitDataSource.getRegistration( claimerUserId, claim.visitId From 7123ffeb09ca1024fe2d984476b36f7aaaf07b99 Mon Sep 17 00:00:00 2001 From: Jekabs Karklins Date: Tue, 24 Jun 2025 10:43:07 +0200 Subject: [PATCH 21/51] fix test --- apps/backend/src/mutations/InviteMutations.spec.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/backend/src/mutations/InviteMutations.spec.ts b/apps/backend/src/mutations/InviteMutations.spec.ts index 8447bd0553..2b9ab69773 100644 --- a/apps/backend/src/mutations/InviteMutations.spec.ts +++ b/apps/backend/src/mutations/InviteMutations.spec.ts @@ -12,6 +12,7 @@ import { dummyUserOfficerWithRole, dummyUserWithRole, } from '../datasources/mockups/UserDataSource'; +import { VisitDataSourceMock } from '../datasources/mockups/VisitDataSource'; import { Invite } from '../models/Invite'; import { Rejection } from '../models/Rejection'; import InviteMutations from './InviteMutations'; @@ -28,6 +29,7 @@ describe('Test Invite Mutations', () => { .resolve(Tokens.CoProposerClaimDataSource) .init(); container.resolve(Tokens.AdminDataSource).init(); + container.resolve(Tokens.VisitDataSource).init(); }); test('A user can accept valid invite code', () => { From 8de30d57eb195532ae4ccf80ceb02d67e5cd398f Mon Sep 17 00:00:00 2001 From: Jekabs Karklins Date: Tue, 24 Jun 2025 16:51:33 +0200 Subject: [PATCH 22/51] feat: enhance invite mutations to include email handling and logging --- .../src/config/dependencyConfigTest.ts | 11 +-- .../src/datasources/mockups/UserDataSource.ts | 14 ++- apps/backend/src/decorators/EventBus.ts | 44 +++++---- .../src/mutations/InviteMutations.spec.ts | 94 +++++++++++++++++++ apps/backend/src/mutations/InviteMutations.ts | 31 +++--- .../mutations/CreateVisitMutation.ts | 2 +- .../mutations/UpdateVisitMutation.ts | 2 +- 7 files changed, 148 insertions(+), 50 deletions(-) diff --git a/apps/backend/src/config/dependencyConfigTest.ts b/apps/backend/src/config/dependencyConfigTest.ts index 32fee883d2..c8ac82215c 100644 --- a/apps/backend/src/config/dependencyConfigTest.ts +++ b/apps/backend/src/config/dependencyConfigTest.ts @@ -39,12 +39,13 @@ import { VisitRegistrationClaimDataSourceMock } from '../datasources/mockups/Vis import { WorkflowDataSourceMock } from '../datasources/mockups/WorkflowDataSource'; import PostgresPredefinedMessageDataSource from '../datasources/postgres/PredefinedMessageDataSource'; import { essEmailHandler } from '../eventHandlers/email/essEmailHandler'; +import createLoggingHandler from '../eventHandlers/logging'; import { SkipSendMailService } from '../eventHandlers/MailService/SkipSendMailService'; import { createSkipListeningHandler, createSkipPostingHandler, } from '../eventHandlers/messageBroker'; -import { EventBus } from '../events/eventBus'; +import { createApplicationEventBus } from '../events'; import { DefaultDownloadService } from '../factory/DefaultDownloadService'; import BasicUserDetailsLoader from '../loaders/BasicUserDetailsLoader'; import { SkipAssetRegistrar } from '../services/assetRegistrar/skip/SkipAssetRegistrar'; @@ -103,8 +104,7 @@ mapClass(Tokens.ProposalAuthorization, ProposalAuthorization); mapClass(Tokens.AssetRegistrar, SkipAssetRegistrar); mapValue(Tokens.PostToMessageQueue, createSkipPostingHandler()); -mapValue(Tokens.LoggingHandler, jest.mocked(new EventBus())); -mapValue(Tokens.EventBus, jest.mocked(new EventBus())); +mapValue(Tokens.LoggingHandler, createLoggingHandler()); mapValue(Tokens.ListenToMessageQueue, createSkipListeningHandler()); mapClass(Tokens.MailService, SkipSendMailService); @@ -119,7 +119,4 @@ mapValue(Tokens.ConfigureLogger, () => mapClass(Tokens.DownloadService, DefaultDownloadService); mapClass(Tokens.BasicUserDetailsLoader, BasicUserDetailsLoader); - -jest.mock('../decorators/EventBus', () => { - return () => jest.fn(); -}); +mapValue(Tokens.EventBus, createApplicationEventBus()); diff --git a/apps/backend/src/datasources/mockups/UserDataSource.ts b/apps/backend/src/datasources/mockups/UserDataSource.ts index 6149ac8a32..d2a1ca07e5 100644 --- a/apps/backend/src/datasources/mockups/UserDataSource.ts +++ b/apps/backend/src/datasources/mockups/UserDataSource.ts @@ -269,10 +269,14 @@ export class UserDataSourceMock implements UserDataSource { async getProposalUsersFull(proposalPk: number): Promise { throw new Error('Method not implemented.'); } - async getBasicUserInfo( - id: number - ): Promise { - throw new Error('Method not implemented.'); + async getBasicUserInfo(id: number): Promise { + if (id === dummyUser.id) { + return basicDummyUser; + } else if (id === dummyUserNotOnProposal.id) { + return basicDummyUserNotOnProposal; + } + + return null; } async getBasicUsersInfo(ids: readonly number[]): Promise { throw new Error('Method not implemented.'); @@ -281,7 +285,7 @@ export class UserDataSourceMock implements UserDataSource { async getBasicUserDetailsByEmail( email: string, role?: UserRole - ): Promise { + ): Promise { return new BasicUserDetails( 1, 'John', diff --git a/apps/backend/src/decorators/EventBus.ts b/apps/backend/src/decorators/EventBus.ts index 6144f7e916..611ee89f10 100644 --- a/apps/backend/src/decorators/EventBus.ts +++ b/apps/backend/src/decorators/EventBus.ts @@ -31,29 +31,27 @@ const EventBusDecorator = (eventType: Event) => { } // NOTE: Do not log the event in testing environment. - if (process.env.NODE_ENV !== 'test') { - // NOTE: Get the name of the object or class like: 'Fap', 'USER', 'Proposal' and lowercase it. - const resultKey = (result.constructor.name as string).toLowerCase(); - - const event = { - type: eventType, - [resultKey]: result, - key: resultKey, - loggedInUserId: loggedInUser ? loggedInUser.id : null, - isRejection: isRejection(result), - inputArgs: JSON.stringify(restArgs), - impersonatingUserId: loggedInUser - ? loggedInUser.impersonatingUserId - : null, - } as ApplicationEvent; - - const eventBus = resolveApplicationEventBus(); - eventBus - .publish(event) - .catch((e) => - logger.logError(`EventBus publish failed ${event.type}`, e) - ); - } + // NOTE: Get the name of the object or class like: 'Fap', 'USER', 'Proposal' and lowercase it. + const resultKey = (result.constructor.name as string).toLowerCase(); + + const event = { + type: eventType, + [resultKey]: result, + key: resultKey, + loggedInUserId: loggedInUser ? loggedInUser.id : null, + isRejection: isRejection(result), + inputArgs: JSON.stringify(restArgs), + impersonatingUserId: loggedInUser + ? loggedInUser.impersonatingUserId + : null, + } as ApplicationEvent; + + const eventBus = resolveApplicationEventBus(); + eventBus + .publish(event) + .catch((e) => + logger.logError(`EventBus publish failed ${event.type}`, e) + ); return result; }; diff --git a/apps/backend/src/mutations/InviteMutations.spec.ts b/apps/backend/src/mutations/InviteMutations.spec.ts index 2b9ab69773..baa60feac0 100644 --- a/apps/backend/src/mutations/InviteMutations.spec.ts +++ b/apps/backend/src/mutations/InviteMutations.spec.ts @@ -3,6 +3,7 @@ import { faker } from '@faker-js/faker'; import { container } from 'tsyringe'; import { Tokens } from '../config/Tokens'; +import { EventLogsDataSource } from '../datasources/EventLogsDataSource'; import { AdminDataSourceMock } from '../datasources/mockups/AdminDataSource'; import { CoProposerClaimDataSourceMock } from '../datasources/mockups/CoProposerClaimDataSource'; import { InviteDataSourceMock } from '../datasources/mockups/InviteDataSource'; @@ -13,6 +14,8 @@ import { dummyUserWithRole, } from '../datasources/mockups/UserDataSource'; import { VisitDataSourceMock } from '../datasources/mockups/VisitDataSource'; +import { MailService } from '../eventHandlers/MailService/MailService'; +import { Event } from '../events/event.enum'; import { Invite } from '../models/Invite'; import { Rejection } from '../models/Rejection'; import InviteMutations from './InviteMutations'; @@ -32,6 +35,10 @@ describe('Test Invite Mutations', () => { container.resolve(Tokens.VisitDataSource).init(); }); + afterEach(() => { + jest.clearAllMocks(); + }); + test('A user can accept valid invite code', () => { return expect( inviteMutations.accept(dummyUserWithRole, 'invite-code') @@ -202,4 +209,91 @@ describe('Test Invite Mutations', () => { expect(response).toBeInstanceOf(Rejection); }); + + test('A co-proposer should receive an invite email when co-proposer invite is created', async () => { + const email = faker.internet.email(); + const proposalPk = 1; + + const mailService = container.resolve(Tokens.MailService); + const sendMailSpy = jest.spyOn(mailService, 'sendMail'); + + const response = await inviteMutations.setCoProposerInvites( + dummyUserWithRole, + { + proposalPk, + emails: [email], + } + ); + + expect(response).not.toBeInstanceOf(Rejection); + + // Wait for async handlers to complete + await new Promise((resolve) => setTimeout(resolve, 200)); + + expect(sendMailSpy).toHaveBeenCalledTimes(1); + expect(sendMailSpy).toHaveBeenCalledWith( + expect.objectContaining({ + recipients: [{ address: email }], + }) + ); + }); + + test('An log should be added when co-proposer invite is created', async () => { + const email = faker.internet.email(); + const proposalPk = 1; + + // Get the exact same instance that the logging handler will use + const eventLogDataSource = container.resolve( + Tokens.EventLogsDataSource + ); + const setEventInDataSourceSpy = jest.spyOn(eventLogDataSource, 'set'); + + const response = await inviteMutations.setCoProposerInvites( + dummyUserWithRole, + { + proposalPk, + emails: [email], + } + ); + expect(response).not.toBeInstanceOf(Rejection); + + await new Promise((resolve) => setTimeout(resolve, 200)); + + expect(setEventInDataSourceSpy).toHaveBeenCalledTimes(1); + expect(setEventInDataSourceSpy).toHaveBeenCalledWith( + expect.any(Number), // changedBy (userId) + expect.stringMatching(Event.PROPOSAL_CO_PROPOSER_CLAIM_SENT), // eventType + expect.stringContaining(email), // rowData (JSON string containing the email) + expect.any(String), // rowData (JSON string containing the email) + expect.any(String) // changedObjectId (should be the invite ID) + ); + }); + + test('A visitor should receive an invite email when visitor invite is created', async () => { + const email = faker.internet.email(); + const visitId = 1; + + const mailService = container.resolve(Tokens.MailService); + const sendMailSpy = jest.spyOn(mailService, 'sendMail'); + + const response = await inviteMutations.setVisitRegistrationInvites( + dummyUserWithRole, + { + visitId: visitId, + emails: [email], + } + ); + + expect(response).not.toBeInstanceOf(Rejection); + + // Wait for async handlers to complete + await new Promise((resolve) => setTimeout(resolve, 200)); + + expect(sendMailSpy).toHaveBeenCalledTimes(1); + expect(sendMailSpy).toHaveBeenCalledWith( + expect.objectContaining({ + recipients: [{ address: email }], + }) + ); + }); }); diff --git a/apps/backend/src/mutations/InviteMutations.ts b/apps/backend/src/mutations/InviteMutations.ts index ff180bbc32..827af88886 100644 --- a/apps/backend/src/mutations/InviteMutations.ts +++ b/apps/backend/src/mutations/InviteMutations.ts @@ -82,6 +82,19 @@ export default class InviteMutations { 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() @EventBus(Event.EMAIL_INVITES) @EventBus(Event.PROPOSAL_CO_PROPOSER_CLAIM_SENT) @@ -100,13 +113,7 @@ export default class InviteMutations { ); } - const existingClaims = - await this.coProposerClaimDataSource.findByProposalPk(proposalPk); - const existingInvites = (await Promise.all( - existingClaims.map((claim) => - this.inviteDataSource.findById(claim.inviteId) - ) - )) as Invite[]; + const existingInvites = await this.getCoProposerInvites(proposalPk); const existingEmails = existingInvites.map((invite) => invite.email); const deletedEmails = existingEmails.filter( @@ -151,9 +158,9 @@ export default class InviteMutations { @EventBus(Event.EMAIL_INVITES) public async setVisitRegistrationInvites( agent: UserWithRole | null, - args: { visitId: number; inviteEmails: string[] } + args: { visitId: number; emails: string[] } ): Promise { - const { visitId, inviteEmails } = args; + const { visitId, emails } = args; const hasWriteRights = this.userAuth.isApiToken(agent) || @@ -170,11 +177,9 @@ export default class InviteMutations { const existingEmails = existingInvites.map((invite) => invite.email); const deletedEmails = existingEmails.filter( - (email) => !inviteEmails.includes(email) - ); - const newEmails = inviteEmails.filter( - (email) => !existingEmails.includes(email) + (email) => !emails.includes(email) ); + const newEmails = emails.filter((email) => !existingEmails.includes(email)); const deletedInvites = existingInvites.filter((invite) => deletedEmails.includes(invite.email) diff --git a/apps/backend/src/resolvers/mutations/CreateVisitMutation.ts b/apps/backend/src/resolvers/mutations/CreateVisitMutation.ts index 0ff5fb13a3..2db70eabdf 100644 --- a/apps/backend/src/resolvers/mutations/CreateVisitMutation.ts +++ b/apps/backend/src/resolvers/mutations/CreateVisitMutation.ts @@ -41,7 +41,7 @@ export class CreateVisitMutation { await context.mutations.invite.setVisitRegistrationInvites(context.user, { visitId: visit.id, - inviteEmails: args.inviteEmails ?? [], + emails: args.inviteEmails ?? [], }); return visit; diff --git a/apps/backend/src/resolvers/mutations/UpdateVisitMutation.ts b/apps/backend/src/resolvers/mutations/UpdateVisitMutation.ts index 1fa9a87ba9..304ec46859 100644 --- a/apps/backend/src/resolvers/mutations/UpdateVisitMutation.ts +++ b/apps/backend/src/resolvers/mutations/UpdateVisitMutation.ts @@ -40,7 +40,7 @@ export class UpdateVisitMutation { await context.mutations.invite.setVisitRegistrationInvites(context.user, { visitId: args.visitId, - inviteEmails: args.inviteEmails ?? [], + emails: args.inviteEmails ?? [], }); return result; From 3a456f6e2272762d1c812450428fdba979aa9489 Mon Sep 17 00:00:00 2001 From: Jekabs Karklins Date: Tue, 24 Jun 2025 22:28:05 +0200 Subject: [PATCH 23/51] feat: update invite handling and logging for co-proposer and visit registration invites --- .../src/config/dependencyConfigTest.ts | 9 +- .../eventHandlers/email/eliEmailHandler.ts | 62 ------- .../eventHandlers/email/essEmailHandler.ts | 162 +++++++++++------- apps/backend/src/eventHandlers/logging.ts | 92 +++++----- .../src/eventHandlers/messageBroker.ts | 4 +- apps/backend/src/events/applicationEvents.ts | 59 +++---- apps/backend/src/events/event.enum.ts | 39 +++-- .../src/mutations/InviteMutations.spec.ts | 43 ++++- apps/backend/src/mutations/InviteMutations.ts | 10 +- apps/backend/src/test-decorator-debug.ts | 0 10 files changed, 245 insertions(+), 235 deletions(-) create mode 100644 apps/backend/src/test-decorator-debug.ts diff --git a/apps/backend/src/config/dependencyConfigTest.ts b/apps/backend/src/config/dependencyConfigTest.ts index c8ac82215c..d58abfdae1 100644 --- a/apps/backend/src/config/dependencyConfigTest.ts +++ b/apps/backend/src/config/dependencyConfigTest.ts @@ -1,7 +1,8 @@ /* eslint-disable @typescript-eslint/no-empty-function */ -import { ConsoleLogger, setLogger } from '@user-office-software/duo-logger'; import 'reflect-metadata'; +import { setLogger } from '@user-office-software/duo-logger'; + import { UserAuthorizationMock } from '../auth/mockups/UserAuthorization'; import { ProposalAuthorization } from '../auth/ProposalAuthorization'; import { VisitAuthorization } from '../auth/VisitAuthorization'; @@ -112,9 +113,9 @@ mapClass(Tokens.MailService, SkipSendMailService); mapValue(Tokens.EmailEventHandler, essEmailHandler); mapValue(Tokens.ConfigureEnvironment, () => {}); -mapValue(Tokens.ConfigureLogger, () => - setLogger(new ConsoleLogger({ colorize: true })) -); +mapValue(Tokens.ConfigureLogger, () => { + setLogger([]); +}); mapClass(Tokens.DownloadService, DefaultDownloadService); diff --git a/apps/backend/src/eventHandlers/email/eliEmailHandler.ts b/apps/backend/src/eventHandlers/email/eliEmailHandler.ts index 85104f7eb2..2c8395e30c 100644 --- a/apps/backend/src/eventHandlers/email/eliEmailHandler.ts +++ b/apps/backend/src/eventHandlers/email/eliEmailHandler.ts @@ -108,68 +108,6 @@ export async function eliEmailHandler(event: ApplicationEvent) { return; } - case Event.EMAIL_INVITE: - case Event.EMAIL_INVITES: { - let invites; - if ('invite' in event) { - // single invite in response - invites = [event.invite]; - } else { - // multiple invites in response - invites = event.array; - } - - for (const invite of invites) { - if (invite.isEmailSent) { - continue; - } - const inviter = await userDataSource.getBasicUserInfo( - invite.createdByUserId - ); - - if (!inviter) { - logger.logError('Failed email invite. No inviter found', { - inviter, - event, - }); - - return; - } - - const roleInviteClaim = await roleClaimDataSource.findByInviteId( - invite.id - ); - - const templateId = getTemplateIdForRole(roleInviteClaim[0].roleId); - - mailService - .sendMail({ - content: { - template_id: templateId, - }, - substitution_data: { - email: invite.email, - inviterName: inviter.firstname, - inviterLastname: inviter.lastname, - redeemCode: invite.code, - }, - recipients: [{ address: invite.email }], - }) - .then(async (res) => { - await inviteDataSource.update({ - id: invite.id, - isEmailSent: true, - }); - logger.logInfo('Successful email transmission', { res }); - }) - .catch((err: string) => { - logger.logException('Failed email transmission', err); - }); - } - - return; - } - case Event.PROPOSAL_CREATED: { const principalInvestigator = await userDataSource.getUser( event.proposal.proposerId diff --git a/apps/backend/src/eventHandlers/email/essEmailHandler.ts b/apps/backend/src/eventHandlers/email/essEmailHandler.ts index ef6eb11661..b3f7a653a1 100644 --- a/apps/backend/src/eventHandlers/email/essEmailHandler.ts +++ b/apps/backend/src/eventHandlers/email/essEmailHandler.ts @@ -13,9 +13,10 @@ import { VisitDataSource } from '../../datasources/VisitDataSource'; import { VisitRegistrationClaimDataSource } from '../../datasources/VisitRegistrationClaimDataSource'; import { ApplicationEvent } from '../../events/applicationEvents'; import { Event } from '../../events/event.enum'; +import { EventBus } from '../../events/eventBus'; import { Invite } from '../../models/Invite'; import { ProposalEndStatus } from '../../models/Proposal'; -import { UserRole } from '../../models/User'; +import { BasicUserDetails, UserRole } from '../../models/User'; import EmailSettings from '../MailService/EmailSettings'; import { MailService } from '../MailService/MailService'; @@ -43,72 +44,16 @@ export async function essEmailHandler(event: ApplicationEvent) { const visitDataSource = container.resolve( Tokens.VisitDataSource ); + const eventBus = container.resolve>( + Tokens.EventBus + ); if (event.isRejection) { return; } switch (event.type) { - case Event.EMAIL_INVITE: - case Event.EMAIL_INVITES: { - let invites; - if ('invite' in event) { - // single invite in response - invites = [event.invite]; - } else { - // multiple invites in response - invites = event.array; - } - - 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; - } - - const templateId = await getTemplateIdForInvite(invite.id); - - mailService - .sendMail({ - content: { - template_id: templateId, - }, - substitution_data: { - email: invite.email, - inviterName: inviter.firstname, - inviterLastname: inviter.lastname, - inviterOrg: inviter.institution, - redeemCode: invite.code, - }, - recipients: [{ address: invite.email }], - }) - .then(async (res) => { - await inviteDataSource.update({ - id: invite.id, - isEmailSent: true, - }); - logger.logInfo('Successful email transmission', { res }); - }) - .catch((err: string) => { - logger.logException('Failed email transmission', err); - }); - } - - return; - } - - case Event.INVITE_ACCEPTED: { + case Event.PROPOSAL_CO_PROPOSER_INVITE_ACCEPTED: { const invite: Invite = event.invite; const coProposerClaims = await coProposerDataSource.findByInviteId( @@ -324,6 +269,67 @@ export async function essEmailHandler(event: ApplicationEvent) { return; } + case Event.PROPOSAL_VISIT_REGISTRATION_INVITES_UPDATED: { + const invites = event.array; + + 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).then(async () => { + await eventBus.publish({ + ...event, + type: Event.PROPOSAL_VISIT_REGISTRATION_INVITE_SENT, + description: 'Visit registration invite sent', + invite, + }); + }); + } + break; + } + case Event.PROPOSAL_CO_PROPOSER_INVITES_UPDATED: { + const invites = event.array; + + 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).then(async () => { + await eventBus.publish({ + ...event, + type: Event.PROPOSAL_CO_PROPOSER_INVITE_SENT, + invite, + }); + }); + } + break; + } case Event.FAP_REVIEWER_NOTIFIED: { const { id: reviewId, userID, proposalPk } = event.fapReview; const fapReviewer = await userDataSource.getUser(userID); @@ -506,3 +512,37 @@ export async function getTemplateIdForInvite( throw new Error(`No valid claim found for invite ${inviteId}`); } + +async function sendInviteEmail(invite: Invite, inviter: BasicUserDetails) { + const mailService = container.resolve(Tokens.MailService); + const inviteDataSource = container.resolve( + Tokens.InviteDataSource + ); + + const templateId = await getTemplateIdForInvite(invite.id); + + return mailService + .sendMail({ + content: { + template_id: templateId, + }, + substitution_data: { + email: invite.email, + inviterName: inviter.firstname, + inviterLastname: inviter.lastname, + inviterOrg: inviter.institution, + redeemCode: invite.code, + }, + recipients: [{ address: invite.email }], + }) + .then(async (res) => { + await inviteDataSource.update({ + id: invite.id, + isEmailSent: true, + }); + logger.logInfo('Successful email transmission', { res }); + }) + .catch((err: string) => { + logger.logException('Failed email transmission', err); + }); +} diff --git a/apps/backend/src/eventHandlers/logging.ts b/apps/backend/src/eventHandlers/logging.ts index 52a7624a28..e7d8d7ed23 100644 --- a/apps/backend/src/eventHandlers/logging.ts +++ b/apps/backend/src/eventHandlers/logging.ts @@ -39,6 +39,11 @@ export default function createLoggingHandler() { Tokens.CoProposerClaimDataSource ); + // const visitRegistrationClaimDataSource = + // container.resolve( + // Tokens.VisitRegistrationClaimDataSource + // ); + // Handler that logs every mutation wrapped with the event bus event to logger and event_logs table. return async function loggingHandler(event: ApplicationEvent) { const json = JSON.stringify(event); @@ -67,58 +72,9 @@ export default function createLoggingHandler() { event.emailinviteresponse.userId.toString() ); break; - case Event.EMAIL_INVITE: - case Event.EMAIL_INVITES: - case Event.INVITE_ACCEPTED: - let invites; - if ('invite' in event) { - invites = [event.invite]; - } else { - invites = event.array; - } - for (const invite of invites) { - await eventLogsDataSource.set( - event.loggedInUserId, - event.type, - json, - invite.id.toString(), - event.type === Event.INVITE_ACCEPTED - ? `Invite accepted: ${invite.email}` - : `Invite sent: ${invite.email}` - ); - } - break; - case Event.PROPOSAL_CO_PROPOSER_CLAIM_SENT: - case Event.PROPOSAL_CO_PROPOSER_CLAIM_ACCEPTED: { - let invites; - if ('invite' in event) { - invites = [event.invite]; - } else { - invites = event.array; - } - for (const invite of invites) { - const coProposerInvites = - await coProposerClaimDataSource.findByInviteId(invite.id); - - await Promise.all( - coProposerInvites.map(async (coProposerInvite) => { - return eventLogsDataSource.set( - event.loggedInUserId, - event.type, - json, - coProposerInvite.proposalPk.toString(), - event.type === Event.PROPOSAL_CO_PROPOSER_CLAIM_ACCEPTED - ? `Co-proposer invite accepted: ${invite.email}` - : `Co-proposer invite sent: ${invite.email}` - ); - }) - ); - } - - break; - } - case Event.PROPOSAL_VISIT_REGISTRATION_CLAIM_ACCEPTED: { - const invite = event.invite; + case Event.PROPOSAL_CO_PROPOSER_INVITE_ACCEPTED: + case Event.PROPOSAL_VISIT_REGISTRATION_INVITE_ACCEPTED: { + const { invite } = event; const coProposerInvites = await coProposerClaimDataSource.findByInviteId(invite.id); @@ -137,6 +93,38 @@ export default function createLoggingHandler() { break; } + case Event.PROPOSAL_CO_PROPOSER_INVITE_SENT: { + const { invite } = event; + const proposal = await proposalDataSource.getProposalByVisitId( + invite.id + ); + + return eventLogsDataSource.set( + event.loggedInUserId, + event.type, + json, + proposal.primaryKey.toString(), + `Co-proposer invite sent to: ${invite.email}`, + event.impersonatingUserId + ); + } + + case Event.PROPOSAL_VISIT_REGISTRATION_INVITE_SENT: { + const { invite } = event; + const proposal = await proposalDataSource.getProposalByVisitId( + invite.id + ); + + return eventLogsDataSource.set( + event.loggedInUserId, + event.type, + json, + proposal.primaryKey.toString(), + `Visit registration invite sent to: ${invite.email}`, + event.impersonatingUserId + ); + } + case Event.PROPOSAL_INSTRUMENTS_SELECTED: { await Promise.all( event.instrumentshasproposals.proposalPks.map( diff --git a/apps/backend/src/eventHandlers/messageBroker.ts b/apps/backend/src/eventHandlers/messageBroker.ts index 95c0541f46..a06b40201d 100644 --- a/apps/backend/src/eventHandlers/messageBroker.ts +++ b/apps/backend/src/eventHandlers/messageBroker.ts @@ -237,8 +237,8 @@ export async function createPostToRabbitMQHandler() { ); break; } - case Event.INVITE_ACCEPTED: { - const invite = event.invite; + case Event.PROPOSAL_CO_PROPOSER_INVITE_ACCEPTED: { + const { invite } = event; const claims = await coProposerClaimDataSource.findByInviteId( invite.id diff --git a/apps/backend/src/events/applicationEvents.ts b/apps/backend/src/events/applicationEvents.ts index 08e6daa300..6a5fac484a 100644 --- a/apps/backend/src/events/applicationEvents.ts +++ b/apps/backend/src/events/applicationEvents.ts @@ -217,20 +217,36 @@ interface ProposalTopicAnsweredEvent extends GeneralEvent { array: AnswerBasic[]; } -interface ProposalCoProposerClaimSentEvent extends GeneralEvent { - type: Event.PROPOSAL_CO_PROPOSER_CLAIM_SENT; +interface ProposalCoProposerInvitesUpdatedEvent extends GeneralEvent { + type: Event.PROPOSAL_CO_PROPOSER_INVITES_UPDATED; array: Invite[]; } -interface ProposalCoProposerClaimAcceptedEvent extends GeneralEvent { - type: Event.PROPOSAL_CO_PROPOSER_CLAIM_ACCEPTED; +interface ProposalCoProposerInviteSentEvent extends GeneralEvent { + type: Event.PROPOSAL_CO_PROPOSER_INVITE_SENT; invite: Invite; } -interface ProposalVisitClaimAcceptedEvent extends GeneralEvent { - type: Event.PROPOSAL_VISIT_REGISTRATION_CLAIM_ACCEPTED; +interface ProposalCoProposerInviteAcceptedEvent extends GeneralEvent { + type: Event.PROPOSAL_CO_PROPOSER_INVITE_ACCEPTED; invite: Invite; } + +interface ProposalVisitRegistrationInvitesUpdatedEvent extends GeneralEvent { + type: Event.PROPOSAL_VISIT_REGISTRATION_INVITES_UPDATED; + array: Invite[]; +} + +interface ProposalVisitRegistrationInviteSentEvent extends GeneralEvent { + type: Event.PROPOSAL_VISIT_REGISTRATION_INVITE_SENT; + invite: Invite; +} + +interface ProposalVisitRegistrationInviteAcceptedEvent extends GeneralEvent { + type: Event.PROPOSAL_VISIT_REGISTRATION_INVITE_ACCEPTED; + invite: Invite; +} + interface UserUpdateEvent extends GeneralEvent { type: Event.USER_UPDATED; user: User; @@ -255,21 +271,6 @@ interface EmailInviteOld extends GeneralEvent { }; } -type InviteResponse = Pick< - Invite, - 'id' | 'code' | 'email' | 'createdByUserId' | 'isEmailSent' ->; - -interface EmailInvite extends GeneralEvent { - type: Event.EMAIL_INVITE; - invite: InviteResponse; -} - -interface EmailInvites extends GeneralEvent { - type: Event.EMAIL_INVITES; - array: InviteResponse[]; -} - interface FapCreatedEvent extends GeneralEvent { type: Event.FAP_CREATED; fap: Fap; @@ -328,10 +329,6 @@ interface CallFapReviewEndedEvent extends GeneralEvent { type: Event.CALL_FAP_REVIEW_ENDED; call: Call; } -interface InviteAcceptedEvent extends GeneralEvent { - type: Event.INVITE_ACCEPTED; - invite: Invite; -} interface InstrumentCreatedEvent extends GeneralEvent { type: Event.INSTRUMENT_CREATED; instrument: Instrument; @@ -425,8 +422,6 @@ export type ApplicationEvent = | ProposalManagementDecisionUpdatedEvent | ProposalManagementDecisionSubmittedEvent | EmailInviteOld - | EmailInvite - | EmailInvites | UserUpdateEvent | UserRoleUpdateEvent | FapCreatedEvent @@ -443,7 +438,6 @@ export type ApplicationEvent = | CallEndedInternalEvent | CallReviewEndedEvent | CallFapReviewEndedEvent - | InviteAcceptedEvent | ProposalFeasibilityReviewUpdatedEvent | ProposalFeasibilityReviewSubmittedEvent | ProposalALLFeasibilityReviewSubmittedEvent @@ -467,9 +461,12 @@ export type ApplicationEvent = | ProposalAllFapReviewsSubmittedForAllPanelsEvent | ProposalAllFapMeetingsSubmittedEvent | ProposalAllFapInstrumentSubmittedEvent - | ProposalCoProposerClaimSentEvent - | ProposalCoProposerClaimAcceptedEvent - | ProposalVisitClaimAcceptedEvent + | ProposalCoProposerInvitesUpdatedEvent + | ProposalCoProposerInviteSentEvent + | ProposalCoProposerInviteAcceptedEvent + | ProposalVisitRegistrationInvitesUpdatedEvent + | ProposalVisitRegistrationInviteSentEvent + | ProposalVisitRegistrationInviteAcceptedEvent | InstrumentCreatedEvent | InstrumentUpdatedEvent | InstrumentDeletedEvent diff --git a/apps/backend/src/events/event.enum.ts b/apps/backend/src/events/event.enum.ts index 5f3c53d6e8..9f175ea812 100644 --- a/apps/backend/src/events/event.enum.ts +++ b/apps/backend/src/events/event.enum.ts @@ -38,14 +38,11 @@ export enum Event { CALL_ENDED_INTERNAL = 'CALL_ENDED_INTERNAL', CALL_REVIEW_ENDED = 'CALL_REVIEW_ENDED', CALL_FAP_REVIEW_ENDED = 'CALL_FAP_REVIEW_ENDED', - INVITE_ACCEPTED = 'INVITE_ACCEPTED', USER_UPDATED = 'USER_UPDATED', USER_ROLE_UPDATED = 'USER_ROLE_UPDATED', USER_DELETED = 'USER_DELETED', USER_PASSWORD_RESET_EMAIL = 'USER_PASSWORD_RESET_EMAIL', EMAIL_INVITE_LEGACY = 'EMAIL_INVITE_LEGACY', - EMAIL_INVITE = 'EMAIL_INVITE', // TODO: this should be called INVITE_CREATED - EMAIL_INVITES = 'EMAIL_INVITES', // TODO: this should be called INVITES_CREATED FAP_CREATED = 'FAP_CREATED', FAP_UPDATED = 'FAP_UPDATED', FAP_MEMBERS_ASSIGNED = 'FAP_MEMBERS_ASSIGNED', @@ -66,9 +63,12 @@ export enum Event { PROPOSAL_BOOKING_TIME_COMPLETED = 'PROPOSAL_BOOKING_TIME_COMPLETED', PROPOSAL_BOOKING_TIME_UPDATED = 'PROPOSAL_BOOKING_TIME_UPDATED', PROPOSAL_BOOKING_TIME_REOPENED = 'PROPOSAL_BOOKING_TIME_REOPENED', - PROPOSAL_CO_PROPOSER_CLAIM_SENT = 'PROPOSAL_CO_PROPOSER_CLAIM_SENT', - PROPOSAL_CO_PROPOSER_CLAIM_ACCEPTED = 'PROPOSAL_CO_PROPOSER_CLAIM_ACCEPTED', - PROPOSAL_VISIT_REGISTRATION_CLAIM_ACCEPTED = 'PROPOSAL_VISIT_REGISTRATION_CLAIM_ACCEPTED', + 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_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', INSTRUMENT_CREATED = 'INSTRUMENT_CREATED', INSTRUMENT_UPDATED = 'INSTRUMENT_UPDATED', INSTRUMENT_DELETED = 'INSTRUMENT_DELETED', @@ -238,11 +238,6 @@ export const EventLabel = new Map([ Event.EMAIL_INVITE_LEGACY, '[Deprecated] Event occurs when user is created using email invite', ], - [Event.EMAIL_INVITE, 'Event occurs when user is invited by email'], - [ - Event.EMAIL_INVITES, - 'Event occurs when multiple users are invited by email', - ], [Event.FAP_CREATED, 'Event occurs when FAP is created'], [Event.FAP_UPDATED, 'Event occurs when FAP is updated'], [Event.FAP_MEMBERS_ASSIGNED, 'Event occurs when we assign member/s to a FAP'], @@ -273,7 +268,27 @@ export const EventLabel = new Map([ 'Event occurs when the proposal status action is being executed in the status engine', ], [ - Event.PROPOSAL_VISIT_REGISTRATION_CLAIM_ACCEPTED, + Event.PROPOSAL_CO_PROPOSER_INVITES_UPDATED, + 'Event occurs when user sets the visit registration claim for a proposal', + ], + [ + Event.PROPOSAL_CO_PROPOSER_INVITE_SENT, + 'Event occurs when co-proposer invite is sent to a user', + ], + [ + Event.PROPOSAL_CO_PROPOSER_INVITE_ACCEPTED, + 'Event occurs when user accepts the co-proposer claim for a proposal', + ], + [ + Event.PROPOSAL_VISIT_REGISTRATION_INVITES_UPDATED, + 'Event occurs when user sets the visit registration claim for a proposal', + ], + [ + Event.PROPOSAL_VISIT_REGISTRATION_INVITE_SENT, + 'Event occurs when visit registration invite is sent to a user', + ], + [ + Event.PROPOSAL_VISIT_REGISTRATION_INVITE_ACCEPTED, 'Event occurs when user accepts the visit registration claim for a proposal', ], [ diff --git a/apps/backend/src/mutations/InviteMutations.spec.ts b/apps/backend/src/mutations/InviteMutations.spec.ts index baa60feac0..384b41d014 100644 --- a/apps/backend/src/mutations/InviteMutations.spec.ts +++ b/apps/backend/src/mutations/InviteMutations.spec.ts @@ -238,7 +238,7 @@ describe('Test Invite Mutations', () => { ); }); - test('An log should be added when co-proposer invite is created', async () => { + test('A log should be added when co-proposer invite is sent', async () => { const email = faker.internet.email(); const proposalPk = 1; @@ -261,11 +261,12 @@ describe('Test Invite Mutations', () => { expect(setEventInDataSourceSpy).toHaveBeenCalledTimes(1); expect(setEventInDataSourceSpy).toHaveBeenCalledWith( - expect.any(Number), // changedBy (userId) - expect.stringMatching(Event.PROPOSAL_CO_PROPOSER_CLAIM_SENT), // eventType + dummyUserWithRole.id, // changedBy (userId) + expect.stringMatching(Event.PROPOSAL_CO_PROPOSER_INVITE_SENT), // eventType expect.stringContaining(email), // rowData (JSON string containing the email) - expect.any(String), // rowData (JSON string containing the email) - expect.any(String) // changedObjectId (should be the invite ID) + expect.any(String), // changedObjectId (should be the invite ID) + expect.stringContaining(`Co-proposer invite sent to: ${email}`), // description + undefined ); }); @@ -296,4 +297,36 @@ describe('Test Invite Mutations', () => { }) ); }); + + test('A log should be added when visitor invite is sent', async () => { + const email = faker.internet.email(); + const visitId = 1; + + // Get the exact same instance that the logging handler will use + const eventLogDataSource = container.resolve( + Tokens.EventLogsDataSource + ); + const setEventInDataSourceSpy = jest.spyOn(eventLogDataSource, 'set'); + + const response = await inviteMutations.setVisitRegistrationInvites( + dummyUserWithRole, + { + visitId: visitId, + emails: [email], + } + ); + expect(response).not.toBeInstanceOf(Rejection); + + await new Promise((resolve) => setTimeout(resolve, 200)); + + expect(setEventInDataSourceSpy).toHaveBeenCalledTimes(1); + expect(setEventInDataSourceSpy).toHaveBeenCalledWith( + dummyUserWithRole.id, // changedBy (userId) + expect.stringMatching(Event.PROPOSAL_VISIT_REGISTRATION_INVITE_SENT), // eventType + expect.stringContaining(email), // rowData (JSON string containing the email) + expect.any(String), // changedObjectId (should be the invite ID) + expect.stringContaining(`Visit registration invite sent to: ${email}`), // description + undefined + ); + }); }); diff --git a/apps/backend/src/mutations/InviteMutations.ts b/apps/backend/src/mutations/InviteMutations.ts index 827af88886..c61d296231 100644 --- a/apps/backend/src/mutations/InviteMutations.ts +++ b/apps/backend/src/mutations/InviteMutations.ts @@ -52,7 +52,6 @@ export default class InviteMutations { ) {} @Authorized() - @EventBus(Event.INVITE_ACCEPTED) async accept( agent: UserWithRole | null, code: string @@ -96,8 +95,7 @@ export default class InviteMutations { } @Authorized() - @EventBus(Event.EMAIL_INVITES) - @EventBus(Event.PROPOSAL_CO_PROPOSER_CLAIM_SENT) + @EventBus(Event.PROPOSAL_CO_PROPOSER_INVITES_UPDATED) public async setCoProposerInvites( agent: UserWithRole | null, args: SetCoProposerInvitesInput @@ -155,7 +153,7 @@ export default class InviteMutations { } @Authorized() - @EventBus(Event.EMAIL_INVITES) + @EventBus(Event.PROPOSAL_VISIT_REGISTRATION_INVITES_UPDATED) public async setVisitRegistrationInvites( agent: UserWithRole | null, args: { visitId: number; emails: string[] } @@ -257,7 +255,7 @@ export default class InviteMutations { ); this.eventBus.publish({ - type: Event.PROPOSAL_CO_PROPOSER_CLAIM_ACCEPTED, + type: Event.PROPOSAL_CO_PROPOSER_INVITE_ACCEPTED, isRejection: false, key: 'proposal', loggedInUserId: claimerUserId, @@ -298,7 +296,7 @@ export default class InviteMutations { .ignore(); this.eventBus.publish({ - type: Event.PROPOSAL_VISIT_REGISTRATION_CLAIM_ACCEPTED, + type: Event.PROPOSAL_VISIT_REGISTRATION_INVITE_ACCEPTED, isRejection: false, key: 'proposal', loggedInUserId: claimerUserId, diff --git a/apps/backend/src/test-decorator-debug.ts b/apps/backend/src/test-decorator-debug.ts new file mode 100644 index 0000000000..e69de29bb2 From 4d7ad7a75290570097f298b2f722a05e2011f1e4 Mon Sep 17 00:00:00 2001 From: Jekabs Karklins Date: Wed, 25 Jun 2025 11:23:42 +0200 Subject: [PATCH 24/51] fix: update dummy review values so after enabling evenbus the test passes --- apps/backend/src/datasources/mockups/ReviewDataSource.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/backend/src/datasources/mockups/ReviewDataSource.ts b/apps/backend/src/datasources/mockups/ReviewDataSource.ts index 2fc2d8686f..e5d3084d28 100644 --- a/apps/backend/src/datasources/mockups/ReviewDataSource.ts +++ b/apps/backend/src/datasources/mockups/ReviewDataSource.ts @@ -13,7 +13,7 @@ import { dummyProposalTechnicalReview } from './ProposalDataSource'; export const dummyReview = new Review( 4, - 10, + 1, 1, 'Good proposal', 9, @@ -28,7 +28,7 @@ export const dummyReview = new Review( ); export const dummySubmittedReview = new Review( 5, - 10, + 1, 1, 'Good proposal', 9, @@ -44,7 +44,7 @@ export const dummySubmittedReview = new Review( export const dummyTechnicalReview = new TechnicalReview( 6, - 10, + 1, 'Good proposal', 'Good proposal (public)', 1, @@ -59,7 +59,7 @@ export const dummyTechnicalReview = new TechnicalReview( export const dummyReviewBad = new Review( 1, - 9, + 1, 1, 'bad proposal', 1, From 04266e066488db0da50b82e8fefc032bd914be86 Mon Sep 17 00:00:00 2001 From: Jekabs Karklins Date: Wed, 25 Jun 2025 11:26:55 +0200 Subject: [PATCH 25/51] fix: update event descriptions for co-proposer and visit registration invites --- apps/backend/src/events/event.enum.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/backend/src/events/event.enum.ts b/apps/backend/src/events/event.enum.ts index 9f175ea812..396d3e7288 100644 --- a/apps/backend/src/events/event.enum.ts +++ b/apps/backend/src/events/event.enum.ts @@ -269,7 +269,7 @@ export const EventLabel = new Map([ ], [ Event.PROPOSAL_CO_PROPOSER_INVITES_UPDATED, - 'Event occurs when user sets the visit registration claim for a proposal', + 'Event occurs when co-proposer invites are updated for a proposal', ], [ Event.PROPOSAL_CO_PROPOSER_INVITE_SENT, @@ -281,7 +281,7 @@ export const EventLabel = new Map([ ], [ Event.PROPOSAL_VISIT_REGISTRATION_INVITES_UPDATED, - 'Event occurs when user sets the visit registration claim for a proposal', + 'Event occurs when visit registration invites are updated for a proposal', ], [ Event.PROPOSAL_VISIT_REGISTRATION_INVITE_SENT, From e48dea7a44fe495dbce85fc3cb823e3c56cdcf02 Mon Sep 17 00:00:00 2001 From: Jekabs Karklins Date: Wed, 25 Jun 2025 15:19:09 +0200 Subject: [PATCH 26/51] feat: add email template IDs for co-proposer and visitor invite emails --- apps/backend/src/mutations/InviteMutations.spec.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/backend/src/mutations/InviteMutations.spec.ts b/apps/backend/src/mutations/InviteMutations.spec.ts index 384b41d014..607b160373 100644 --- a/apps/backend/src/mutations/InviteMutations.spec.ts +++ b/apps/backend/src/mutations/InviteMutations.spec.ts @@ -234,6 +234,9 @@ describe('Test Invite Mutations', () => { expect(sendMailSpy).toHaveBeenCalledWith( expect.objectContaining({ recipients: [{ address: email }], + content: { + template_id: 'user-office-registration-invitation-co-proposer', + }, }) ); }); @@ -294,6 +297,9 @@ describe('Test Invite Mutations', () => { expect(sendMailSpy).toHaveBeenCalledWith( expect.objectContaining({ recipients: [{ address: email }], + content: { + template_id: 'user-office-registration-invitation-visitor', + }, }) ); }); From d4b83ecdee1328880447e8c9143ee4d499660ef7 Mon Sep 17 00:00:00 2001 From: Jekabs Karklins Date: Wed, 25 Jun 2025 16:17:13 +0200 Subject: [PATCH 27/51] feat: enhance email template IDs for co-proposer and visit registration invites --- .../eventHandlers/email/eliEmailHandler.ts | 201 +++++++++++++++--- .../eventHandlers/email/essEmailHandler.ts | 39 ++-- .../src/mutations/InviteMutations.spec.ts | 23 +- 3 files changed, 219 insertions(+), 44 deletions(-) diff --git a/apps/backend/src/eventHandlers/email/eliEmailHandler.ts b/apps/backend/src/eventHandlers/email/eliEmailHandler.ts index 2c8395e30c..17a25f5d3c 100644 --- a/apps/backend/src/eventHandlers/email/eliEmailHandler.ts +++ b/apps/backend/src/eventHandlers/email/eliEmailHandler.ts @@ -3,6 +3,7 @@ import { container } from 'tsyringe'; import { Tokens } from '../../config/Tokens'; import { CallDataSource } from '../../datasources/CallDataSource'; +import { CoProposerClaimDataSource } from '../../datasources/CoProposerClaimDataSource'; import { FapDataSource } from '../../datasources/FapDataSource'; import { InviteDataSource } from '../../datasources/InviteDataSource'; import { ProposalDataSource } from '../../datasources/ProposalDataSource'; @@ -10,13 +11,33 @@ import { RedeemCodesDataSource } from '../../datasources/RedeemCodesDataSource'; import { ReviewDataSource } from '../../datasources/ReviewDataSource'; import { RoleClaimDataSource } from '../../datasources/RoleClaimDataSource'; import { UserDataSource } from '../../datasources/UserDataSource'; +import { VisitRegistrationClaimDataSource } from '../../datasources/VisitRegistrationClaimDataSource'; import { ApplicationEvent } from '../../events/applicationEvents'; import { Event } from '../../events/event.enum'; +import { EventBus } from '../../events/eventBus'; +import { Invite } from '../../models/Invite'; import { ProposalEndStatus } from '../../models/Proposal'; -import { UserRole } from '../../models/User'; +import { BasicUserDetails, UserRole } from '../../models/User'; import EmailSettings from '../MailService/EmailSettings'; import { MailService } from '../MailService/MailService'; +export enum EmailTemplateId { + USER_OFFICE_REGISTRATION_INVITATION = 'user-office-registration-invitation', + USER_OFFICE_REGISTRATION_INVITATION_REVIEWER = 'user-office-registration-invitation-reviewer', + PROPOSAL_CREATED = 'proposal-created', + PROPOSAL_SUBMITTED = 'proposal-submitted', + ACCEPTED_PROPOSAL = 'Accepted-Proposal', + REJECTED_PROPOSAL = 'Rejected-Proposal', + RESERVED_PROPOSAL = 'Reserved-Proposal', + REVIEW_REMINDER = 'review-reminder', + INTERNAL_REVIEW_CREATED = 'internal-review-created', + INTERNAL_REVIEW_UPDATED = 'internal-review-updated', + INTERNAL_REVIEW_DELETED = 'internal-review-deleted', + USER_OFFICE_REGISTRATION_INVITATION_CO_PROPOSER = 'user-office-registration-invitation-co-proposer', + USER_OFFICE_REGISTRATION_INVITATION_VISIT_REGISTRATION = 'user-office-registration-invitation-visit-registration', + USER_OFFICE_REGISTRATION_INVITATION_USER = 'user-office-registration-invitation-user', +} + export async function eliEmailHandler(event: ApplicationEvent) { const mailService = container.resolve(Tokens.MailService); const proposalDataSource = container.resolve( @@ -27,14 +48,6 @@ export async function eliEmailHandler(event: ApplicationEvent) { Tokens.UserDataSource ); - const roleClaimDataSource = container.resolve( - Tokens.RoleClaimDataSource - ); - - const inviteDataSource = container.resolve( - Tokens.InviteDataSource - ); - const callDataSource = container.resolve( Tokens.CallDataSource ); @@ -46,6 +59,9 @@ export async function eliEmailHandler(event: ApplicationEvent) { const technicalReviewDataSource = container.resolve( Tokens.ReviewDataSource ); + const eventBus = container.resolve>( + Tokens.EventBus + ); if (event.isRejection) { return; @@ -85,8 +101,8 @@ export async function eliEmailHandler(event: ApplicationEvent) { content: { template_id: event.emailinviteresponse.role === UserRole.USER - ? 'user-office-registration-invitation' - : 'user-office-registration-invitation-reviewer', + ? EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION + : EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_REVIEWER, }, substitution_data: { firstname: user.preferredname, @@ -121,7 +137,7 @@ export async function eliEmailHandler(event: ApplicationEvent) { const options: EmailSettings = { content: { - template_id: 'proposal-created', + template_id: EmailTemplateId.PROPOSAL_CREATED, }, substitution_data: { piPreferredname: principalInvestigator.preferredname, @@ -164,7 +180,7 @@ export async function eliEmailHandler(event: ApplicationEvent) { const options: EmailSettings = { content: { - template_id: 'proposal-submitted', + template_id: EmailTemplateId.PROPOSAL_SUBMITTED, }, substitution_data: { piPreferredname: principalInvestigator.preferredname, @@ -217,11 +233,11 @@ export async function eliEmailHandler(event: ApplicationEvent) { const { finalStatus } = event.proposal; let templateId = ''; if (finalStatus === ProposalEndStatus.ACCEPTED) { - templateId = 'Accepted-Proposal'; + templateId = EmailTemplateId.ACCEPTED_PROPOSAL; } else if (finalStatus === ProposalEndStatus.REJECTED) { - templateId = 'Rejected-Proposal'; + templateId = EmailTemplateId.REJECTED_PROPOSAL; } else if (finalStatus === ProposalEndStatus.RESERVED) { - templateId = 'Reserved-Proposal'; + templateId = EmailTemplateId.RESERVED_PROPOSAL; } else { logger.logError('Failed email notification', { event }); @@ -257,6 +273,67 @@ export async function eliEmailHandler(event: ApplicationEvent) { return; } + case Event.PROPOSAL_VISIT_REGISTRATION_INVITES_UPDATED: { + const invites = event.array; + + 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).then(async () => { + await eventBus.publish({ + ...event, + type: Event.PROPOSAL_VISIT_REGISTRATION_INVITE_SENT, + description: 'Visit registration invite sent', + invite, + }); + }); + } + break; + } + case Event.PROPOSAL_CO_PROPOSER_INVITES_UPDATED: { + const invites = event.array; + + 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).then(async () => { + await eventBus.publish({ + ...event, + type: Event.PROPOSAL_CO_PROPOSER_INVITE_SENT, + invite, + }); + }); + } + break; + } case Event.FAP_REVIEWER_NOTIFIED: { const { id: reviewId, userID, proposalPk } = event.fapReview; const fapReviewer = await userDataSource.getUser(userID); @@ -269,7 +346,7 @@ export async function eliEmailHandler(event: ApplicationEvent) { mailService .sendMail({ content: { - template_id: 'review-reminder', + template_id: EmailTemplateId.REVIEW_REMINDER, }, substitution_data: { fapReviewerPreferredName: fapReviewer.preferredname, @@ -345,12 +422,12 @@ export async function eliEmailHandler(event: ApplicationEvent) { } } - let templateId = 'internal-review-created'; + let templateId = EmailTemplateId.INTERNAL_REVIEW_CREATED; if (event.type === Event.INTERNAL_REVIEW_UPDATED) { - templateId = 'internal-review-updated'; + templateId = EmailTemplateId.INTERNAL_REVIEW_UPDATED; } else if (event.type === Event.INTERNAL_REVIEW_DELETED) { - templateId = 'internal-review-deleted'; + templateId = EmailTemplateId.INTERNAL_REVIEW_DELETED; } mailService @@ -387,13 +464,81 @@ export async function eliEmailHandler(event: ApplicationEvent) { } } -function getTemplateIdForRole(role: UserRole): string { - switch (role) { - case UserRole.USER: - return 'user-office-registration-invitation'; - case UserRole.INTERNAL_REVIEWER: - return 'user-office-registration-invitation-reviewer'; - default: - throw new Error('No valid user role set for email invitation'); +export async function getTemplateIdForInvite( + inviteId: number +): Promise { + const roleClaimDS = container.resolve( + Tokens.RoleClaimDataSource + ); + const coProposerDS = container.resolve( + Tokens.CoProposerClaimDataSource + ); + const visitRegDS = container.resolve( + Tokens.VisitRegistrationClaimDataSource + ); + + // Fetch all claims concurrently + const [coProposerClaim, visitRegClaim, roleClaims] = await Promise.all([ + coProposerDS.findByInviteId(inviteId), + visitRegDS.findByInviteId(inviteId), + roleClaimDS.findByInviteId(inviteId), + ]); + + if (coProposerClaim.length > 0) { + return EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_CO_PROPOSER; + } + + if (visitRegClaim.length > 0) { + return EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_VISIT_REGISTRATION; } + + if (roleClaims.length > 0) { + const { roleId } = roleClaims[0]; + switch (roleId) { + case UserRole.INTERNAL_REVIEWER: + return EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_REVIEWER; + case UserRole.USER: + return EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_USER; + default: + throw new Error( + `Unsupported role \"${roleId}\" for invite ${inviteId}` + ); + } + } + + throw new Error(`No valid claim found for invite ${inviteId}`); +} + +async function sendInviteEmail(invite: Invite, inviter: BasicUserDetails) { + const mailService = container.resolve(Tokens.MailService); + const inviteDataSource = container.resolve( + Tokens.InviteDataSource + ); + + const templateId = await getTemplateIdForInvite(invite.id); + + return mailService + .sendMail({ + content: { + template_id: templateId, + }, + substitution_data: { + email: invite.email, + inviterName: inviter.firstname, + inviterLastname: inviter.lastname, + inviterOrg: inviter.institution, + redeemCode: invite.code, + }, + recipients: [{ address: invite.email }], + }) + .then(async (res) => { + await inviteDataSource.update({ + id: invite.id, + isEmailSent: true, + }); + logger.logInfo('Successful email transmission', { res }); + }) + .catch((err: string) => { + logger.logException('Failed email transmission', err); + }); } diff --git a/apps/backend/src/eventHandlers/email/essEmailHandler.ts b/apps/backend/src/eventHandlers/email/essEmailHandler.ts index b3f7a653a1..356cbc8b29 100644 --- a/apps/backend/src/eventHandlers/email/essEmailHandler.ts +++ b/apps/backend/src/eventHandlers/email/essEmailHandler.ts @@ -20,6 +20,21 @@ import { BasicUserDetails, UserRole } from '../../models/User'; import EmailSettings from '../MailService/EmailSettings'; import { MailService } from '../MailService/MailService'; +export enum EmailTemplateId { + CO_PROPOSER_INVITE_ACCEPTED = 'co-proposer-invite-accepted', + PROPOSAL_SUBMITTED = 'proposal-submitted', + ACCEPTED_PROPOSAL = 'Accepted-Proposal', + REJECTED_PROPOSAL = 'Rejected-Proposal', + RESERVED_PROPOSAL = 'Reserved-Proposal', + REVIEW_REMINDER = 'review-reminder', + 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_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', +} + export async function essEmailHandler(event: ApplicationEvent) { const mailService = container.resolve(Tokens.MailService); const proposalDataSource = container.resolve( @@ -110,7 +125,7 @@ export async function essEmailHandler(event: ApplicationEvent) { mailService .sendMail({ content: { - template_id: 'co-proposer-invite-accepted', + template_id: EmailTemplateId.CO_PROPOSER_INVITE_ACCEPTED, }, substitution_data: { piPreferredname: principalInvestigator.preferredname, @@ -158,7 +173,7 @@ export async function essEmailHandler(event: ApplicationEvent) { const options: EmailSettings = { content: { - template_id: 'proposal-submitted', + template_id: EmailTemplateId.PROPOSAL_SUBMITTED, }, substitution_data: { piPreferredname: principalInvestigator.preferredname, @@ -220,11 +235,11 @@ export async function essEmailHandler(event: ApplicationEvent) { const { finalStatus } = event.proposal; let templateId = ''; if (finalStatus === ProposalEndStatus.ACCEPTED) { - templateId = 'Accepted-Proposal'; + templateId = EmailTemplateId.ACCEPTED_PROPOSAL; } else if (finalStatus === ProposalEndStatus.REJECTED) { - templateId = 'Rejected-Proposal'; + templateId = EmailTemplateId.REJECTED_PROPOSAL; } else if (finalStatus === ProposalEndStatus.RESERVED) { - templateId = 'Reserved-Proposal'; + templateId = EmailTemplateId.RESERVED_PROPOSAL; } else { logger.logError('Failed email notification', { event }); @@ -344,7 +359,7 @@ export async function essEmailHandler(event: ApplicationEvent) { mailService .sendMail({ content: { - template_id: 'review-reminder', + template_id: EmailTemplateId.REVIEW_REMINDER, }, substitution_data: { fapReviewerPreferredName: fapReviewer.preferredname, @@ -418,8 +433,8 @@ export async function essEmailHandler(event: ApplicationEvent) { const templateId = event.type === Event.VISIT_REGISTRATION_APPROVED - ? 'visit-registration-approved' - : 'visit-registration-cancelled'; + ? EmailTemplateId.VISIT_REGISTRATION_APPROVED + : EmailTemplateId.VISIT_REGISTRATION_CANCELLED; mailService .sendMail({ @@ -489,20 +504,20 @@ export async function getTemplateIdForInvite( ]); if (coProposerClaim.length > 0) { - return 'user-office-registration-invitation-co-proposer'; + return EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_CO_PROPOSER; } if (visitRegClaim) { - return 'user-office-registration-invitation-visit-registration'; + return EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_VISIT_REGISTRATION; } if (roleClaims.length > 0) { const { roleId } = roleClaims[0]; switch (roleId) { case UserRole.INTERNAL_REVIEWER: - return 'user-office-registration-invitation-reviewer'; + return EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_REVIEWER; case UserRole.USER: - return 'user-office-registration-invitation-user'; + return EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_USER; default: throw new Error( `Unsupported role \"${roleId}\" for invite ${inviteId}` diff --git a/apps/backend/src/mutations/InviteMutations.spec.ts b/apps/backend/src/mutations/InviteMutations.spec.ts index 607b160373..924c70e1da 100644 --- a/apps/backend/src/mutations/InviteMutations.spec.ts +++ b/apps/backend/src/mutations/InviteMutations.spec.ts @@ -14,6 +14,8 @@ import { dummyUserWithRole, } from '../datasources/mockups/UserDataSource'; import { VisitDataSourceMock } from '../datasources/mockups/VisitDataSource'; +import { VisitDataSource } from '../datasources/VisitDataSource'; +import { EmailTemplateId } from '../eventHandlers/email/essEmailHandler'; import { MailService } from '../eventHandlers/MailService/MailService'; import { Event } from '../events/event.enum'; import { Invite } from '../models/Invite'; @@ -21,6 +23,9 @@ import { Rejection } from '../models/Rejection'; import InviteMutations from './InviteMutations'; const inviteMutations = container.resolve(InviteMutations); +const visitDataSource = container.resolve( + Tokens.VisitDataSource +); describe('Test Invite Mutations', () => { beforeEach(() => { @@ -235,7 +240,8 @@ describe('Test Invite Mutations', () => { expect.objectContaining({ recipients: [{ address: email }], content: { - template_id: 'user-office-registration-invitation-co-proposer', + template_id: + EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_CO_PROPOSER, }, }) ); @@ -275,15 +281,23 @@ describe('Test Invite Mutations', () => { test('A visitor should receive an invite email when visitor invite is created', async () => { const email = faker.internet.email(); - const visitId = 1; const mailService = container.resolve(Tokens.MailService); const sendMailSpy = jest.spyOn(mailService, 'sendMail'); + const newVisit = await visitDataSource.createVisit( + { + teamLeadUserId: dummyUserWithRole.id, + experimentPk: 1, + team: [dummyUserWithRole.id], + }, + dummyUserWithRole.id, + 1 + ); const response = await inviteMutations.setVisitRegistrationInvites( dummyUserWithRole, { - visitId: visitId, + visitId: newVisit.id, emails: [email], } ); @@ -298,7 +312,8 @@ describe('Test Invite Mutations', () => { expect.objectContaining({ recipients: [{ address: email }], content: { - template_id: 'user-office-registration-invitation-visitor', + template_id: + EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_VISIT_REGISTRATION, }, }) ); From 16cf3bc7851ffd261274bfefd93ef006668446cd Mon Sep 17 00:00:00 2001 From: Jekabs Karklins Date: Tue, 1 Jul 2025 10:49:57 +0200 Subject: [PATCH 28/51] refactor: remove unused file --- apps/backend/src/test-decorator-debug.ts | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 apps/backend/src/test-decorator-debug.ts diff --git a/apps/backend/src/test-decorator-debug.ts b/apps/backend/src/test-decorator-debug.ts deleted file mode 100644 index e69de29bb2..0000000000 From 515905d36e51d974face66417e88f8929496e889 Mon Sep 17 00:00:00 2001 From: Jekabs Karklins Date: Tue, 1 Jul 2025 11:28:40 +0200 Subject: [PATCH 29/51] refactor: adjust eliEmailHandler to only have neccesary changes --- .../eventHandlers/email/eliEmailHandler.ts | 170 ++++++++---------- 1 file changed, 77 insertions(+), 93 deletions(-) diff --git a/apps/backend/src/eventHandlers/email/eliEmailHandler.ts b/apps/backend/src/eventHandlers/email/eliEmailHandler.ts index 17a25f5d3c..77bbe4eadb 100644 --- a/apps/backend/src/eventHandlers/email/eliEmailHandler.ts +++ b/apps/backend/src/eventHandlers/email/eliEmailHandler.ts @@ -21,23 +21,6 @@ import { BasicUserDetails, UserRole } from '../../models/User'; import EmailSettings from '../MailService/EmailSettings'; import { MailService } from '../MailService/MailService'; -export enum EmailTemplateId { - USER_OFFICE_REGISTRATION_INVITATION = 'user-office-registration-invitation', - USER_OFFICE_REGISTRATION_INVITATION_REVIEWER = 'user-office-registration-invitation-reviewer', - PROPOSAL_CREATED = 'proposal-created', - PROPOSAL_SUBMITTED = 'proposal-submitted', - ACCEPTED_PROPOSAL = 'Accepted-Proposal', - REJECTED_PROPOSAL = 'Rejected-Proposal', - RESERVED_PROPOSAL = 'Reserved-Proposal', - REVIEW_REMINDER = 'review-reminder', - INTERNAL_REVIEW_CREATED = 'internal-review-created', - INTERNAL_REVIEW_UPDATED = 'internal-review-updated', - INTERNAL_REVIEW_DELETED = 'internal-review-deleted', - USER_OFFICE_REGISTRATION_INVITATION_CO_PROPOSER = 'user-office-registration-invitation-co-proposer', - USER_OFFICE_REGISTRATION_INVITATION_VISIT_REGISTRATION = 'user-office-registration-invitation-visit-registration', - USER_OFFICE_REGISTRATION_INVITATION_USER = 'user-office-registration-invitation-user', -} - export async function eliEmailHandler(event: ApplicationEvent) { const mailService = container.resolve(Tokens.MailService); const proposalDataSource = container.resolve( @@ -101,8 +84,8 @@ export async function eliEmailHandler(event: ApplicationEvent) { content: { template_id: event.emailinviteresponse.role === UserRole.USER - ? EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION - : EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_REVIEWER, + ? 'user-office-registration-invitation' + : 'user-office-registration-invitation-reviewer', }, substitution_data: { firstname: user.preferredname, @@ -124,6 +107,68 @@ export async function eliEmailHandler(event: ApplicationEvent) { return; } + case Event.PROPOSAL_VISIT_REGISTRATION_INVITES_UPDATED: { + const invites = event.array; + + 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).then(async () => { + await eventBus.publish({ + ...event, + type: Event.PROPOSAL_VISIT_REGISTRATION_INVITE_SENT, + description: 'Visit registration invite sent', + invite, + }); + }); + } + break; + } + case Event.PROPOSAL_CO_PROPOSER_INVITES_UPDATED: { + const invites = event.array; + + 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).then(async () => { + await eventBus.publish({ + ...event, + type: Event.PROPOSAL_CO_PROPOSER_INVITE_SENT, + invite, + }); + }); + } + break; + } + case Event.PROPOSAL_CREATED: { const principalInvestigator = await userDataSource.getUser( event.proposal.proposerId @@ -137,7 +182,7 @@ export async function eliEmailHandler(event: ApplicationEvent) { const options: EmailSettings = { content: { - template_id: EmailTemplateId.PROPOSAL_CREATED, + template_id: 'proposal-created', }, substitution_data: { piPreferredname: principalInvestigator.preferredname, @@ -180,7 +225,7 @@ export async function eliEmailHandler(event: ApplicationEvent) { const options: EmailSettings = { content: { - template_id: EmailTemplateId.PROPOSAL_SUBMITTED, + template_id: 'proposal-submitted', }, substitution_data: { piPreferredname: principalInvestigator.preferredname, @@ -233,11 +278,11 @@ export async function eliEmailHandler(event: ApplicationEvent) { const { finalStatus } = event.proposal; let templateId = ''; if (finalStatus === ProposalEndStatus.ACCEPTED) { - templateId = EmailTemplateId.ACCEPTED_PROPOSAL; + templateId = 'Accepted-Proposal'; } else if (finalStatus === ProposalEndStatus.REJECTED) { - templateId = EmailTemplateId.REJECTED_PROPOSAL; + templateId = 'Rejected-Proposal'; } else if (finalStatus === ProposalEndStatus.RESERVED) { - templateId = EmailTemplateId.RESERVED_PROPOSAL; + templateId = 'Reserved-Proposal'; } else { logger.logError('Failed email notification', { event }); @@ -273,67 +318,6 @@ export async function eliEmailHandler(event: ApplicationEvent) { return; } - case Event.PROPOSAL_VISIT_REGISTRATION_INVITES_UPDATED: { - const invites = event.array; - - 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).then(async () => { - await eventBus.publish({ - ...event, - type: Event.PROPOSAL_VISIT_REGISTRATION_INVITE_SENT, - description: 'Visit registration invite sent', - invite, - }); - }); - } - break; - } - case Event.PROPOSAL_CO_PROPOSER_INVITES_UPDATED: { - const invites = event.array; - - 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).then(async () => { - await eventBus.publish({ - ...event, - type: Event.PROPOSAL_CO_PROPOSER_INVITE_SENT, - invite, - }); - }); - } - break; - } case Event.FAP_REVIEWER_NOTIFIED: { const { id: reviewId, userID, proposalPk } = event.fapReview; const fapReviewer = await userDataSource.getUser(userID); @@ -346,7 +330,7 @@ export async function eliEmailHandler(event: ApplicationEvent) { mailService .sendMail({ content: { - template_id: EmailTemplateId.REVIEW_REMINDER, + template_id: 'review-reminder', }, substitution_data: { fapReviewerPreferredName: fapReviewer.preferredname, @@ -422,12 +406,12 @@ export async function eliEmailHandler(event: ApplicationEvent) { } } - let templateId = EmailTemplateId.INTERNAL_REVIEW_CREATED; + let templateId = 'internal-review-created'; if (event.type === Event.INTERNAL_REVIEW_UPDATED) { - templateId = EmailTemplateId.INTERNAL_REVIEW_UPDATED; + templateId = 'internal-review-updated'; } else if (event.type === Event.INTERNAL_REVIEW_DELETED) { - templateId = EmailTemplateId.INTERNAL_REVIEW_DELETED; + templateId = 'internal-review-deleted'; } mailService @@ -485,20 +469,20 @@ export async function getTemplateIdForInvite( ]); if (coProposerClaim.length > 0) { - return EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_CO_PROPOSER; + return 'user-office-registration-invitation-co-proposer'; } if (visitRegClaim.length > 0) { - return EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_VISIT_REGISTRATION; + return 'user-office-registration-invitation-visit-registration'; } if (roleClaims.length > 0) { const { roleId } = roleClaims[0]; switch (roleId) { case UserRole.INTERNAL_REVIEWER: - return EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_REVIEWER; + return 'user-office-registration-invitation-reviewer'; case UserRole.USER: - return EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_USER; + return 'user-office-registration-invitation-user'; default: throw new Error( `Unsupported role \"${roleId}\" for invite ${inviteId}` From ee04b77210c89684c69ac724635085b38cba7379 Mon Sep 17 00:00:00 2001 From: Jekabs Karklins Date: Wed, 2 Jul 2025 08:08:59 +0200 Subject: [PATCH 30/51] feat: add template_id to invites and update related email handling logic --- .../0184_AddTemplateIdToInvites.sql | 17 ++++ .../jobs/checkInviteReminderJob.spec.ts | 18 +--- .../asyncJobs/jobs/checkInviteReminderJob.ts | 17 ++-- .../src/datasources/InviteDataSource.ts | 2 + .../datasources/mockups/InviteDataSource.ts | 14 ++- .../datasources/postgres/InviteDataSource.ts | 6 +- .../src/datasources/postgres/records.ts | 4 +- .../eventHandlers/email/eliEmailHandler.ts | 94 +++---------------- .../eventHandlers/email/essEmailHandler.ts | 75 +++++---------- apps/backend/src/eventHandlers/logging.ts | 27 ++---- apps/backend/src/events/applicationEvents.ts | 4 + apps/backend/src/models/Invite.ts | 3 +- .../src/mutations/InviteMutations.spec.ts | 5 - apps/backend/src/mutations/InviteMutations.ts | 36 ++++++- 14 files changed, 128 insertions(+), 194 deletions(-) create mode 100644 apps/backend/db_patches/0184_AddTemplateIdToInvites.sql diff --git a/apps/backend/db_patches/0184_AddTemplateIdToInvites.sql b/apps/backend/db_patches/0184_AddTemplateIdToInvites.sql new file mode 100644 index 0000000000..4e75121de0 --- /dev/null +++ b/apps/backend/db_patches/0184_AddTemplateIdToInvites.sql @@ -0,0 +1,17 @@ +DO +$$ +BEGIN + IF register_patch( + 'AddTemplateIdToInvites.sql', + 'Jekabs Karklins', + 'Adding template_id column to invites table so that we can track which template was used for the invite', + '2025-06-04' + ) THEN + BEGIN + -- Add the template_id column to the invites table + ALTER TABLE invites ADD COLUMN template_id VARCHAR(255) NULL; + END; + END IF; +END; +$$ +LANGUAGE plpgsql; diff --git a/apps/backend/src/asyncJobs/jobs/checkInviteReminderJob.spec.ts b/apps/backend/src/asyncJobs/jobs/checkInviteReminderJob.spec.ts index 153195e5a2..68a13d4bbf 100644 --- a/apps/backend/src/asyncJobs/jobs/checkInviteReminderJob.spec.ts +++ b/apps/backend/src/asyncJobs/jobs/checkInviteReminderJob.spec.ts @@ -4,7 +4,6 @@ import sinon from 'sinon'; import { container } from 'tsyringe'; import { Tokens } from '../../config/Tokens'; -import * as EssEmailHandler from '../../eventHandlers/email/essEmailHandler'; import { Invite } from '../../models/Invite'; import { RoleClaim } from '../../models/RoleClaim'; import { SettingsId } from '../../models/Settings'; @@ -27,11 +26,8 @@ const mockAdminDataSource = { getSetting: sinon.stub(), }; -const mockGetTemplateIdForInvite = sinon.stub(); - describe('checkInviteReminderJob', () => { let clock: sinon.SinonFakeTimers; - let getTemplateIdForInviteSpy: sinon.SinonSpy; beforeEach(() => { mockUserDataSource.getBasicUserInfo.reset(); @@ -39,7 +35,6 @@ describe('checkInviteReminderJob', () => { mockRoleClaimDataSource.findByInviteId.reset(); mockMailService.sendMail.reset(); mockAdminDataSource.getSetting.reset(); - mockGetTemplateIdForInvite.reset(); container.clearInstances(); @@ -55,11 +50,6 @@ describe('checkInviteReminderJob', () => { useValue: mockAdminDataSource, }); - getTemplateIdForInviteSpy = sinon - .stub(EssEmailHandler, 'getTemplateIdForInvite') - .callsFake(mockGetTemplateIdForInvite); - mockGetTemplateIdForInvite.returns('template_id_for_invite'); - const now = new Date('2025-05-14T10:00:00.000Z'); clock = sinon.useFakeTimers(now.getTime()); }); @@ -67,9 +57,6 @@ describe('checkInviteReminderJob', () => { afterEach(() => { sinon.restore(); clock.restore(); - if (getTemplateIdForInviteSpy) { - getTemplateIdForInviteSpy.restore(); - } }); it('should not send reminders if no reminder days are configured (settingsValue is empty)', async () => { @@ -138,6 +125,7 @@ describe('checkInviteReminderJob', () => { claimedByUserId: null, isEmailSent: true, expiresAt: new Date('2025-12-31T00:00:00.000Z'), + templateId: 'user_template_id', }; mockInviter = { @@ -166,9 +154,6 @@ describe('checkInviteReminderJob', () => { .withArgs(mockInvite.id) .resolves([mockRoleClaim]); mockMailService.sendMail.resolves(); - mockGetTemplateIdForInvite - .withArgs(UserRole.USER) - .returns('user_template_id'); }); it('should send a reminder and update invite if eligible and reminder not yet sent', async () => { @@ -181,7 +166,6 @@ describe('checkInviteReminderJob', () => { const expectedEndDate = new Date(targetCreationDate); expectedEndDate.setHours(23, 59, 59, 999); - expect(mockGetTemplateIdForInvite.calledWith(mockInvite.id)).toBe(true); expect(mockMailService.sendMail.calledOnce).toBe(true); }); diff --git a/apps/backend/src/asyncJobs/jobs/checkInviteReminderJob.ts b/apps/backend/src/asyncJobs/jobs/checkInviteReminderJob.ts index 676e2a8c3e..a893f51945 100644 --- a/apps/backend/src/asyncJobs/jobs/checkInviteReminderJob.ts +++ b/apps/backend/src/asyncJobs/jobs/checkInviteReminderJob.ts @@ -4,9 +4,7 @@ import { container } from 'tsyringe'; import { Tokens } from '../../config/Tokens'; import { AdminDataSource } from '../../datasources/AdminDataSource'; import { InviteDataSource } from '../../datasources/InviteDataSource'; -import { RoleClaimDataSource } from '../../datasources/RoleClaimDataSource'; import { UserDataSource } from '../../datasources/UserDataSource'; -import { getTemplateIdForInvite } from '../../eventHandlers/email/essEmailHandler'; import { MailService } from '../../eventHandlers/MailService/MailService'; import { SettingsId } from '../../models/Settings'; @@ -28,9 +26,6 @@ const checkInviteReminder = async () => { const inviteDataSource = container.resolve( Tokens.InviteDataSource ); - const roleClaimDataSource = container.resolve( - Tokens.RoleClaimDataSource - ); const mailService = container.resolve(Tokens.MailService); const settingsDataSource = container.resolve( Tokens.AdminDataSource @@ -114,7 +109,17 @@ const checkInviteReminder = async () => { continue; } - const templateId = await getTemplateIdForInvite(invite.id); + const { templateId } = invite; + if (!templateId) { + logger.logError( + 'Invite does not have a template ID, skipping reminder email', + { + inviteId: invite.id, + email: invite.email, + } + ); + continue; + } try { await mailService.sendMail({ diff --git a/apps/backend/src/datasources/InviteDataSource.ts b/apps/backend/src/datasources/InviteDataSource.ts index 12458db74a..ca73fc5670 100644 --- a/apps/backend/src/datasources/InviteDataSource.ts +++ b/apps/backend/src/datasources/InviteDataSource.ts @@ -13,6 +13,7 @@ export interface InviteDataSource { code: string; email: string; expiresAt: Date | null; + templateId?: string | null; }): Promise; findByCode(code: string): Promise; @@ -36,6 +37,7 @@ export interface InviteDataSource { claimedByUserId?: number | null; isEmailSent?: boolean; expiresAt?: Date | null; + templateId?: string | null; }): Promise; delete(id: number): Promise; diff --git a/apps/backend/src/datasources/mockups/InviteDataSource.ts b/apps/backend/src/datasources/mockups/InviteDataSource.ts index f3e5e79ce9..540eedcbd3 100644 --- a/apps/backend/src/datasources/mockups/InviteDataSource.ts +++ b/apps/backend/src/datasources/mockups/InviteDataSource.ts @@ -57,6 +57,7 @@ export class InviteDataSourceMock implements InviteDataSource { null, null, true, + null, null ), new Invite( @@ -68,7 +69,8 @@ export class InviteDataSourceMock implements InviteDataSource { null, 1, true, - null + null, + 'email-template' ), new Invite( 3, @@ -79,7 +81,8 @@ export class InviteDataSourceMock implements InviteDataSource { new Date(), null, false, - new Date('2022-01-01') + new Date('2022-01-01'), + null ), ]; } @@ -131,8 +134,9 @@ export class InviteDataSourceMock implements InviteDataSource { note: string; createdByUserId: number; expiresAt: Date | null; + template?: string | null; }): Promise { - const { code, email, note, createdByUserId, expiresAt } = args; + const { code, email, createdByUserId, expiresAt, template } = args; const newInvite = new Invite( this.invites.length + 1, // Generate new ID @@ -143,7 +147,8 @@ export class InviteDataSourceMock implements InviteDataSource { null, null, false, - expiresAt ?? null + expiresAt ?? null, + template ?? null ); this.invites.push(newInvite); @@ -160,6 +165,7 @@ export class InviteDataSourceMock implements InviteDataSource { claimedByUserId?: number | null; isEmailSent?: boolean; expiresAt?: Date | null; + template?: string | null; }): Promise { const invite = await this.findById(args.id); if (!invite) { diff --git a/apps/backend/src/datasources/postgres/InviteDataSource.ts b/apps/backend/src/datasources/postgres/InviteDataSource.ts index 56cfe058ee..91982e815c 100644 --- a/apps/backend/src/datasources/postgres/InviteDataSource.ts +++ b/apps/backend/src/datasources/postgres/InviteDataSource.ts @@ -110,8 +110,9 @@ export default class PostgresInviteDataSource implements InviteDataSource { note: string; createdByUserId: number; expiresAt: Date | null; + template?: string | null; }): Promise { - const { code, email, createdByUserId, expiresAt } = args; + const { code, email, createdByUserId, expiresAt, template } = args; return database .insert({ @@ -119,6 +120,7 @@ export default class PostgresInviteDataSource implements InviteDataSource { email: email, created_by: createdByUserId, expires_at: expiresAt, + template_id: template, }) .into('invites') .returning('*') @@ -137,6 +139,7 @@ export default class PostgresInviteDataSource implements InviteDataSource { claimedByUserId?: number | null; isEmailSent?: boolean; expiresAt?: Date | null; + template?: string | null; }): Promise { return database .update({ @@ -147,6 +150,7 @@ export default class PostgresInviteDataSource implements InviteDataSource { claimed_by: args.claimedByUserId, is_email_sent: args.isEmailSent, expires_at: args.expiresAt, + template_id: args.template, }) .from('invites') .where('invite_id', args.id) diff --git a/apps/backend/src/datasources/postgres/records.ts b/apps/backend/src/datasources/postgres/records.ts index 93da806526..011959b65a 100644 --- a/apps/backend/src/datasources/postgres/records.ts +++ b/apps/backend/src/datasources/postgres/records.ts @@ -1362,6 +1362,7 @@ export interface InviteRecord { readonly claimed_at: Date | null; readonly is_email_sent: boolean; readonly expires_at: Date | null; + readonly template_id: string | null; } export const createInviteObject = (invite: InviteRecord) => @@ -1374,7 +1375,8 @@ export const createInviteObject = (invite: InviteRecord) => invite.claimed_at, invite.claimed_by, invite.is_email_sent, - invite.expires_at + invite.expires_at, + invite.template_id ); export interface RoleClaimRecord { diff --git a/apps/backend/src/eventHandlers/email/eliEmailHandler.ts b/apps/backend/src/eventHandlers/email/eliEmailHandler.ts index 77bbe4eadb..f5f93dd1a8 100644 --- a/apps/backend/src/eventHandlers/email/eliEmailHandler.ts +++ b/apps/backend/src/eventHandlers/email/eliEmailHandler.ts @@ -3,15 +3,12 @@ import { container } from 'tsyringe'; import { Tokens } from '../../config/Tokens'; import { CallDataSource } from '../../datasources/CallDataSource'; -import { CoProposerClaimDataSource } from '../../datasources/CoProposerClaimDataSource'; import { FapDataSource } from '../../datasources/FapDataSource'; import { InviteDataSource } from '../../datasources/InviteDataSource'; import { ProposalDataSource } from '../../datasources/ProposalDataSource'; import { RedeemCodesDataSource } from '../../datasources/RedeemCodesDataSource'; import { ReviewDataSource } from '../../datasources/ReviewDataSource'; -import { RoleClaimDataSource } from '../../datasources/RoleClaimDataSource'; import { UserDataSource } from '../../datasources/UserDataSource'; -import { VisitRegistrationClaimDataSource } from '../../datasources/VisitRegistrationClaimDataSource'; import { ApplicationEvent } from '../../events/applicationEvents'; import { Event } from '../../events/event.enum'; import { EventBus } from '../../events/eventBus'; @@ -107,37 +104,6 @@ export async function eliEmailHandler(event: ApplicationEvent) { return; } - case Event.PROPOSAL_VISIT_REGISTRATION_INVITES_UPDATED: { - const invites = event.array; - - 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).then(async () => { - await eventBus.publish({ - ...event, - type: Event.PROPOSAL_VISIT_REGISTRATION_INVITE_SENT, - description: 'Visit registration invite sent', - invite, - }); - }); - } - break; - } case Event.PROPOSAL_CO_PROPOSER_INVITES_UPDATED: { const invites = event.array; @@ -158,7 +124,11 @@ export async function eliEmailHandler(event: ApplicationEvent) { return; } - await sendInviteEmail(invite, inviter).then(async () => { + await sendInviteEmail( + invite, + inviter, + 'user-office-registration-invitation' + ).then(async () => { await eventBus.publish({ ...event, type: Event.PROPOSAL_CO_PROPOSER_INVITE_SENT, @@ -448,59 +418,16 @@ export async function eliEmailHandler(event: ApplicationEvent) { } } -export async function getTemplateIdForInvite( - inviteId: number -): Promise { - const roleClaimDS = container.resolve( - Tokens.RoleClaimDataSource - ); - const coProposerDS = container.resolve( - Tokens.CoProposerClaimDataSource - ); - const visitRegDS = container.resolve( - Tokens.VisitRegistrationClaimDataSource - ); - - // Fetch all claims concurrently - const [coProposerClaim, visitRegClaim, roleClaims] = await Promise.all([ - coProposerDS.findByInviteId(inviteId), - visitRegDS.findByInviteId(inviteId), - roleClaimDS.findByInviteId(inviteId), - ]); - - if (coProposerClaim.length > 0) { - return 'user-office-registration-invitation-co-proposer'; - } - - if (visitRegClaim.length > 0) { - return 'user-office-registration-invitation-visit-registration'; - } - - if (roleClaims.length > 0) { - const { roleId } = roleClaims[0]; - switch (roleId) { - case UserRole.INTERNAL_REVIEWER: - return 'user-office-registration-invitation-reviewer'; - case UserRole.USER: - return 'user-office-registration-invitation-user'; - default: - throw new Error( - `Unsupported role \"${roleId}\" for invite ${inviteId}` - ); - } - } - - throw new Error(`No valid claim found for invite ${inviteId}`); -} - -async function sendInviteEmail(invite: Invite, inviter: BasicUserDetails) { +async function sendInviteEmail( + invite: Invite, + inviter: BasicUserDetails, + templateId: string +) { const mailService = container.resolve(Tokens.MailService); const inviteDataSource = container.resolve( Tokens.InviteDataSource ); - const templateId = await getTemplateIdForInvite(invite.id); - return mailService .sendMail({ content: { @@ -519,6 +446,7 @@ async function sendInviteEmail(invite: Invite, inviter: BasicUserDetails) { await inviteDataSource.update({ id: invite.id, isEmailSent: true, + templateId: templateId, }); logger.logInfo('Successful email transmission', { res }); }) diff --git a/apps/backend/src/eventHandlers/email/essEmailHandler.ts b/apps/backend/src/eventHandlers/email/essEmailHandler.ts index 356cbc8b29..3fee820361 100644 --- a/apps/backend/src/eventHandlers/email/essEmailHandler.ts +++ b/apps/backend/src/eventHandlers/email/essEmailHandler.ts @@ -7,16 +7,14 @@ import { CoProposerClaimDataSource } from '../../datasources/CoProposerClaimData import { FapDataSource } from '../../datasources/FapDataSource'; import { InviteDataSource } from '../../datasources/InviteDataSource'; 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 { ApplicationEvent } from '../../events/applicationEvents'; import { Event } from '../../events/event.enum'; import { EventBus } from '../../events/eventBus'; import { Invite } from '../../models/Invite'; import { ProposalEndStatus } from '../../models/Proposal'; -import { BasicUserDetails, UserRole } from '../../models/User'; +import { BasicUserDetails } from '../../models/User'; import EmailSettings from '../MailService/EmailSettings'; import { MailService } from '../MailService/MailService'; @@ -284,6 +282,7 @@ export async function essEmailHandler(event: ApplicationEvent) { return; } + case Event.PROPOSAL_VISIT_REGISTRATION_INVITES_UPDATED: { const invites = event.array; @@ -304,7 +303,11 @@ export async function essEmailHandler(event: ApplicationEvent) { return; } - await sendInviteEmail(invite, inviter).then(async () => { + await sendInviteEmail( + invite, + inviter, + EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_VISIT_REGISTRATION + ).then(async () => { await eventBus.publish({ ...event, type: Event.PROPOSAL_VISIT_REGISTRATION_INVITE_SENT, @@ -315,6 +318,7 @@ export async function essEmailHandler(event: ApplicationEvent) { } break; } + case Event.PROPOSAL_CO_PROPOSER_INVITES_UPDATED: { const invites = event.array; @@ -335,7 +339,11 @@ export async function essEmailHandler(event: ApplicationEvent) { return; } - await sendInviteEmail(invite, inviter).then(async () => { + await sendInviteEmail( + invite, + inviter, + EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_CO_PROPOSER + ).then(async () => { await eventBus.publish({ ...event, type: Event.PROPOSAL_CO_PROPOSER_INVITE_SENT, @@ -345,6 +353,7 @@ export async function essEmailHandler(event: ApplicationEvent) { } break; } + case Event.FAP_REVIEWER_NOTIFIED: { const { id: reviewId, userID, proposalPk } = event.fapReview; const fapReviewer = await userDataSource.getUser(userID); @@ -399,6 +408,7 @@ export async function essEmailHandler(event: ApplicationEvent) { return; } + case Event.VISIT_REGISTRATION_APPROVED: case Event.VISIT_REGISTRATION_CANCELLED: { const visitRegistration = await visitDataSource.getRegistration( @@ -482,60 +492,16 @@ export async function essEmailHandler(event: ApplicationEvent) { } } -export async function getTemplateIdForInvite( - inviteId: number -): Promise { - // Resolve all necessary data sources in one go - const roleClaimDS = container.resolve( - Tokens.RoleClaimDataSource - ); - const coProposerDS = container.resolve( - Tokens.CoProposerClaimDataSource - ); - const visitRegDS = container.resolve( - Tokens.VisitRegistrationClaimDataSource - ); - - // Fetch all claims concurrently - const [coProposerClaim, visitRegClaim, roleClaims] = await Promise.all([ - coProposerDS.findByInviteId(inviteId), - visitRegDS.findByInviteId(inviteId), - roleClaimDS.findByInviteId(inviteId), - ]); - - if (coProposerClaim.length > 0) { - return EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_CO_PROPOSER; - } - - if (visitRegClaim) { - return EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_VISIT_REGISTRATION; - } - - if (roleClaims.length > 0) { - const { roleId } = roleClaims[0]; - switch (roleId) { - case UserRole.INTERNAL_REVIEWER: - return EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_REVIEWER; - case UserRole.USER: - return EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_USER; - default: - throw new Error( - `Unsupported role \"${roleId}\" for invite ${inviteId}` - ); - } - } - - throw new Error(`No valid claim found for invite ${inviteId}`); -} - -async function sendInviteEmail(invite: Invite, inviter: BasicUserDetails) { +async function sendInviteEmail( + invite: Invite, + inviter: BasicUserDetails, + templateId: EmailTemplateId +) { const mailService = container.resolve(Tokens.MailService); const inviteDataSource = container.resolve( Tokens.InviteDataSource ); - const templateId = await getTemplateIdForInvite(invite.id); - return mailService .sendMail({ content: { @@ -554,6 +520,7 @@ async function sendInviteEmail(invite: Invite, inviter: BasicUserDetails) { await inviteDataSource.update({ id: invite.id, isEmailSent: true, + templateId: templateId, }); logger.logInfo('Successful email transmission', { res }); }) diff --git a/apps/backend/src/eventHandlers/logging.ts b/apps/backend/src/eventHandlers/logging.ts index e7d8d7ed23..4ea8910124 100644 --- a/apps/backend/src/eventHandlers/logging.ts +++ b/apps/backend/src/eventHandlers/logging.ts @@ -39,11 +39,6 @@ export default function createLoggingHandler() { Tokens.CoProposerClaimDataSource ); - // const visitRegistrationClaimDataSource = - // container.resolve( - // Tokens.VisitRegistrationClaimDataSource - // ); - // Handler that logs every mutation wrapped with the event bus event to logger and event_logs table. return async function loggingHandler(event: ApplicationEvent) { const json = JSON.stringify(event); @@ -94,35 +89,33 @@ export default function createLoggingHandler() { break; } case Event.PROPOSAL_CO_PROPOSER_INVITE_SENT: { - const { invite } = event; - const proposal = await proposalDataSource.getProposalByVisitId( - invite.id - ); + const { invite, proposalPk } = event; - return eventLogsDataSource.set( + eventLogsDataSource.set( event.loggedInUserId, event.type, json, - proposal.primaryKey.toString(), + proposalPk.toString(), `Co-proposer invite sent to: ${invite.email}`, event.impersonatingUserId ); + + break; } case Event.PROPOSAL_VISIT_REGISTRATION_INVITE_SENT: { - const { invite } = event; - const proposal = await proposalDataSource.getProposalByVisitId( - invite.id - ); + const { invite, proposalPk } = event; - return eventLogsDataSource.set( + eventLogsDataSource.set( event.loggedInUserId, event.type, json, - proposal.primaryKey.toString(), + proposalPk.toString(), `Visit registration invite sent to: ${invite.email}`, event.impersonatingUserId ); + + break; } case Event.PROPOSAL_INSTRUMENTS_SELECTED: { diff --git a/apps/backend/src/events/applicationEvents.ts b/apps/backend/src/events/applicationEvents.ts index 6a5fac484a..a68680e18c 100644 --- a/apps/backend/src/events/applicationEvents.ts +++ b/apps/backend/src/events/applicationEvents.ts @@ -220,11 +220,13 @@ interface ProposalTopicAnsweredEvent extends GeneralEvent { interface ProposalCoProposerInvitesUpdatedEvent extends GeneralEvent { type: Event.PROPOSAL_CO_PROPOSER_INVITES_UPDATED; array: Invite[]; + proposalPk: number; } interface ProposalCoProposerInviteSentEvent extends GeneralEvent { type: Event.PROPOSAL_CO_PROPOSER_INVITE_SENT; invite: Invite; + proposalPk: number; } interface ProposalCoProposerInviteAcceptedEvent extends GeneralEvent { @@ -235,11 +237,13 @@ interface ProposalCoProposerInviteAcceptedEvent extends GeneralEvent { interface ProposalVisitRegistrationInvitesUpdatedEvent extends GeneralEvent { type: Event.PROPOSAL_VISIT_REGISTRATION_INVITES_UPDATED; array: Invite[]; + proposalPk: number; } interface ProposalVisitRegistrationInviteSentEvent extends GeneralEvent { type: Event.PROPOSAL_VISIT_REGISTRATION_INVITE_SENT; invite: Invite; + proposalPk: number; } interface ProposalVisitRegistrationInviteAcceptedEvent extends GeneralEvent { diff --git a/apps/backend/src/models/Invite.ts b/apps/backend/src/models/Invite.ts index 89c6e9ced8..d8b788bd22 100644 --- a/apps/backend/src/models/Invite.ts +++ b/apps/backend/src/models/Invite.ts @@ -8,6 +8,7 @@ export class Invite { public claimedAt: Date | null, public claimedByUserId: number | null, public isEmailSent: boolean, - public expiresAt: Date | null + public expiresAt: Date | null, + public templateId: string | null ) {} } diff --git a/apps/backend/src/mutations/InviteMutations.spec.ts b/apps/backend/src/mutations/InviteMutations.spec.ts index 924c70e1da..86a4887a22 100644 --- a/apps/backend/src/mutations/InviteMutations.spec.ts +++ b/apps/backend/src/mutations/InviteMutations.spec.ts @@ -232,9 +232,6 @@ describe('Test Invite Mutations', () => { expect(response).not.toBeInstanceOf(Rejection); - // Wait for async handlers to complete - await new Promise((resolve) => setTimeout(resolve, 200)); - expect(sendMailSpy).toHaveBeenCalledTimes(1); expect(sendMailSpy).toHaveBeenCalledWith( expect.objectContaining({ @@ -266,8 +263,6 @@ describe('Test Invite Mutations', () => { ); expect(response).not.toBeInstanceOf(Rejection); - await new Promise((resolve) => setTimeout(resolve, 200)); - expect(setEventInDataSourceSpy).toHaveBeenCalledTimes(1); expect(setEventInDataSourceSpy).toHaveBeenCalledWith( dummyUserWithRole.id, // changedBy (userId) diff --git a/apps/backend/src/mutations/InviteMutations.ts b/apps/backend/src/mutations/InviteMutations.ts index c61d296231..25151dc27a 100644 --- a/apps/backend/src/mutations/InviteMutations.ts +++ b/apps/backend/src/mutations/InviteMutations.ts @@ -14,8 +14,9 @@ import { RoleClaimDataSource } from '../datasources/RoleClaimDataSource'; import { UserDataSource } from '../datasources/UserDataSource'; import { VisitDataSource } from '../datasources/VisitDataSource'; import { VisitRegistrationClaimDataSource } from '../datasources/VisitRegistrationClaimDataSource'; -import { Authorized, EventBus } from '../decorators'; +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'; @@ -95,7 +96,6 @@ export default class InviteMutations { } @Authorized() - @EventBus(Event.PROPOSAL_CO_PROPOSER_INVITES_UPDATED) public async setCoProposerInvites( agent: UserWithRole | null, args: SetCoProposerInvitesInput @@ -146,14 +146,25 @@ export default class InviteMutations { }) ); - return [ + const invites = [ ...existingInvites.filter((invite) => !deletedInvites.includes(invite)), ...newInvites, ]; + + 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, + proposalPk: proposalPk, + } as ApplicationEvent); + + return invites; } @Authorized() - @EventBus(Event.PROPOSAL_VISIT_REGISTRATION_INVITES_UPDATED) public async setVisitRegistrationInvites( agent: UserWithRole | null, args: { visitId: number; emails: string[] } @@ -208,10 +219,25 @@ export default class InviteMutations { }) ); - return [ + 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, + proposalPk: proposalPk, + } as ApplicationEvent); + + return invites; } private async processRoleClaims(claimerUserId: number, invite: Invite) { const inviteId = invite.id; From 4b30486fc0234eb26ef6b16ee33f9efcb3b81910 Mon Sep 17 00:00:00 2001 From: Jekabs Karklins Date: Wed, 2 Jul 2025 09:03:16 +0200 Subject: [PATCH 31/51] feat: add tests for co-proposer invite email and logging functionality in InviteMutations --- .../src/config/dependencyConfigTest.ts | 19 +++--- .../src/datasources/mockups/UserDataSource.ts | 14 +++-- apps/backend/src/decorators/EventBus.ts | 44 +++++++------- .../src/mutations/InviteMutations.spec.ts | 58 +++++++++++++++++++ 4 files changed, 96 insertions(+), 39 deletions(-) diff --git a/apps/backend/src/config/dependencyConfigTest.ts b/apps/backend/src/config/dependencyConfigTest.ts index 332ae3ba2f..c5abfa02d9 100644 --- a/apps/backend/src/config/dependencyConfigTest.ts +++ b/apps/backend/src/config/dependencyConfigTest.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-empty-function */ -import { ConsoleLogger, setLogger } from '@user-office-software/duo-logger'; +import { setLogger } from '@user-office-software/duo-logger'; import 'reflect-metadata'; import { UserAuthorizationMock } from '../auth/mockups/UserAuthorization'; @@ -37,12 +37,13 @@ import { UserDataSourceMock } from '../datasources/mockups/UserDataSource'; import { WorkflowDataSourceMock } from '../datasources/mockups/WorkflowDataSource'; import PostgresPredefinedMessageDataSource from '../datasources/postgres/PredefinedMessageDataSource'; import { essEmailHandler } from '../eventHandlers/email/essEmailHandler'; +import createLoggingHandler from '../eventHandlers/logging'; import { SkipSendMailService } from '../eventHandlers/MailService/SkipSendMailService'; import { createSkipListeningHandler, createSkipPostingHandler, } from '../eventHandlers/messageBroker'; -import { EventBus } from '../events/eventBus'; +import { createApplicationEventBus } from '../events'; import { DefaultDownloadService } from '../factory/DefaultDownloadService'; import BasicUserDetailsLoader from '../loaders/BasicUserDetailsLoader'; import { SkipAssetRegistrar } from '../services/assetRegistrar/skip/SkipAssetRegistrar'; @@ -96,8 +97,7 @@ mapClass(Tokens.ProposalAuthorization, ProposalAuthorization); mapClass(Tokens.AssetRegistrar, SkipAssetRegistrar); mapValue(Tokens.PostToMessageQueue, createSkipPostingHandler()); -mapValue(Tokens.LoggingHandler, jest.mocked(new EventBus())); -mapValue(Tokens.EventBus, jest.mocked(new EventBus())); +mapValue(Tokens.LoggingHandler, createLoggingHandler()); mapValue(Tokens.ListenToMessageQueue, createSkipListeningHandler()); mapClass(Tokens.MailService, SkipSendMailService); @@ -105,14 +105,11 @@ mapClass(Tokens.MailService, SkipSendMailService); mapValue(Tokens.EmailEventHandler, essEmailHandler); mapValue(Tokens.ConfigureEnvironment, () => {}); -mapValue(Tokens.ConfigureLogger, () => - setLogger(new ConsoleLogger({ colorize: true })) -); +mapValue(Tokens.ConfigureLogger, () => { + setLogger([]); +}); mapClass(Tokens.DownloadService, DefaultDownloadService); mapClass(Tokens.BasicUserDetailsLoader, BasicUserDetailsLoader); - -jest.mock('../decorators/EventBus', () => { - return () => jest.fn(); -}); +mapValue(Tokens.EventBus, createApplicationEventBus()); diff --git a/apps/backend/src/datasources/mockups/UserDataSource.ts b/apps/backend/src/datasources/mockups/UserDataSource.ts index 6149ac8a32..d2a1ca07e5 100644 --- a/apps/backend/src/datasources/mockups/UserDataSource.ts +++ b/apps/backend/src/datasources/mockups/UserDataSource.ts @@ -269,10 +269,14 @@ export class UserDataSourceMock implements UserDataSource { async getProposalUsersFull(proposalPk: number): Promise { throw new Error('Method not implemented.'); } - async getBasicUserInfo( - id: number - ): Promise { - throw new Error('Method not implemented.'); + async getBasicUserInfo(id: number): Promise { + if (id === dummyUser.id) { + return basicDummyUser; + } else if (id === dummyUserNotOnProposal.id) { + return basicDummyUserNotOnProposal; + } + + return null; } async getBasicUsersInfo(ids: readonly number[]): Promise { throw new Error('Method not implemented.'); @@ -281,7 +285,7 @@ export class UserDataSourceMock implements UserDataSource { async getBasicUserDetailsByEmail( email: string, role?: UserRole - ): Promise { + ): Promise { return new BasicUserDetails( 1, 'John', diff --git a/apps/backend/src/decorators/EventBus.ts b/apps/backend/src/decorators/EventBus.ts index 6144f7e916..611ee89f10 100644 --- a/apps/backend/src/decorators/EventBus.ts +++ b/apps/backend/src/decorators/EventBus.ts @@ -31,29 +31,27 @@ const EventBusDecorator = (eventType: Event) => { } // NOTE: Do not log the event in testing environment. - if (process.env.NODE_ENV !== 'test') { - // NOTE: Get the name of the object or class like: 'Fap', 'USER', 'Proposal' and lowercase it. - const resultKey = (result.constructor.name as string).toLowerCase(); - - const event = { - type: eventType, - [resultKey]: result, - key: resultKey, - loggedInUserId: loggedInUser ? loggedInUser.id : null, - isRejection: isRejection(result), - inputArgs: JSON.stringify(restArgs), - impersonatingUserId: loggedInUser - ? loggedInUser.impersonatingUserId - : null, - } as ApplicationEvent; - - const eventBus = resolveApplicationEventBus(); - eventBus - .publish(event) - .catch((e) => - logger.logError(`EventBus publish failed ${event.type}`, e) - ); - } + // NOTE: Get the name of the object or class like: 'Fap', 'USER', 'Proposal' and lowercase it. + const resultKey = (result.constructor.name as string).toLowerCase(); + + const event = { + type: eventType, + [resultKey]: result, + key: resultKey, + loggedInUserId: loggedInUser ? loggedInUser.id : null, + isRejection: isRejection(result), + inputArgs: JSON.stringify(restArgs), + impersonatingUserId: loggedInUser + ? loggedInUser.impersonatingUserId + : null, + } as ApplicationEvent; + + const eventBus = resolveApplicationEventBus(); + eventBus + .publish(event) + .catch((e) => + logger.logError(`EventBus publish failed ${event.type}`, e) + ); return result; }; diff --git a/apps/backend/src/mutations/InviteMutations.spec.ts b/apps/backend/src/mutations/InviteMutations.spec.ts index 8447bd0553..10830716cd 100644 --- a/apps/backend/src/mutations/InviteMutations.spec.ts +++ b/apps/backend/src/mutations/InviteMutations.spec.ts @@ -3,6 +3,7 @@ import { faker } from '@faker-js/faker'; import { container } from 'tsyringe'; import { Tokens } from '../config/Tokens'; +import { EventLogsDataSource } from '../datasources/EventLogsDataSource'; import { AdminDataSourceMock } from '../datasources/mockups/AdminDataSource'; import { CoProposerClaimDataSourceMock } from '../datasources/mockups/CoProposerClaimDataSource'; import { InviteDataSourceMock } from '../datasources/mockups/InviteDataSource'; @@ -12,6 +13,7 @@ import { dummyUserOfficerWithRole, dummyUserWithRole, } from '../datasources/mockups/UserDataSource'; +import { MailService } from '../eventHandlers/MailService/MailService'; import { Invite } from '../models/Invite'; import { Rejection } from '../models/Rejection'; import InviteMutations from './InviteMutations'; @@ -200,4 +202,60 @@ describe('Test Invite Mutations', () => { expect(response).toBeInstanceOf(Rejection); }); + + test('A co-proposer should receive an invite email when co-proposer invite is created', async () => { + const email = faker.internet.email(); + const proposalPk = 1; + + const mailService = container.resolve(Tokens.MailService); + const sendMailSpy = jest.spyOn(mailService, 'sendMail'); + + const response = await inviteMutations.setCoProposerInvites( + dummyUserWithRole, + { + proposalPk, + emails: [email], + } + ); + + expect(response).not.toBeInstanceOf(Rejection); + + // wait 200ms for the email to be sent + await new Promise((resolve) => setTimeout(resolve, 200)); + + expect(sendMailSpy).toHaveBeenCalledTimes(1); + expect(sendMailSpy).toHaveBeenCalledWith( + expect.objectContaining({ + recipients: [{ address: email }], + content: { + template_id: 'user-office-registration-invitation-co-proposer', + }, + }) + ); + }); + + test('A log should be added when co-proposer invite is sent', async () => { + const email = faker.internet.email(); + const proposalPk = 1; + + // Get the exact same instance that the logging handler will use + const eventLogDataSource = container.resolve( + Tokens.EventLogsDataSource + ); + const setEventInDataSourceSpy = jest.spyOn(eventLogDataSource, 'set'); + + const response = await inviteMutations.setCoProposerInvites( + dummyUserWithRole, + { + proposalPk, + emails: [email], + } + ); + expect(response).not.toBeInstanceOf(Rejection); + + // wait 200ms for the email to be sent + await new Promise((resolve) => setTimeout(resolve, 200)); + + expect(setEventInDataSourceSpy).toHaveBeenCalled(); + }); }); From c48d5092c4435ea7aad20379bf00eb790f6d1bb4 Mon Sep 17 00:00:00 2001 From: Jekabs Karklins Date: Wed, 2 Jul 2025 11:35:05 +0200 Subject: [PATCH 32/51] feat: add tests for essEmailHandler and getTemplateIdForInvite functionality --- .../email/eliEmailHandler.spec.ts | 351 ++++++++++++++++++ .../email/essEmailHandler.spec.ts | 96 +++++ 2 files changed, 447 insertions(+) create mode 100644 apps/backend/src/eventHandlers/email/eliEmailHandler.spec.ts create mode 100644 apps/backend/src/eventHandlers/email/essEmailHandler.spec.ts diff --git a/apps/backend/src/eventHandlers/email/eliEmailHandler.spec.ts b/apps/backend/src/eventHandlers/email/eliEmailHandler.spec.ts new file mode 100644 index 0000000000..b73da34b6e --- /dev/null +++ b/apps/backend/src/eventHandlers/email/eliEmailHandler.spec.ts @@ -0,0 +1,351 @@ +import 'reflect-metadata'; +import { faker } from '@faker-js/faker'; +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { CoProposerClaimDataSourceMock } from '../../datasources/mockups/CoProposerClaimDataSource'; +import { RoleClaimDataSourceMock } from '../../datasources/mockups/RoleClaimDataSource'; +import { ApplicationEvent } from '../../events/applicationEvents'; +import { Event } from '../../events/event.enum'; +import { UserRole } from '../../models/User'; +import { eliEmailHandler } from './eliEmailHandler'; +import { getTemplateIdForInvite } from './essEmailHandler'; + +// Mock MailService +const mockMailService = { + sendMail: jest.fn(), +}; + +describe('eliEmailHandler', () => { + let coProposerDataSourceMock: CoProposerClaimDataSourceMock; + let roleClaimDataSourceMock: RoleClaimDataSourceMock; + + beforeAll(() => { + container.registerInstance(Tokens.MailService, mockMailService); + }); + + beforeEach(() => { + // Initialize mocks + coProposerDataSourceMock = container.resolve( + Tokens.CoProposerClaimDataSource + ); + roleClaimDataSourceMock = container.resolve( + Tokens.RoleClaimDataSource + ); + + coProposerDataSourceMock.init(); + roleClaimDataSourceMock.init(); + + // Reset mock + mockMailService.sendMail.mockClear(); + mockMailService.sendMail.mockResolvedValue({ success: true }); + }); + + describe('getTemplateIdForInvite', () => { + test('should return co-proposer template when co-proposer claim exists', async () => { + const inviteId = faker.number.int(); + const mockCoProposerClaim = { + inviteId, + proposalPk: faker.number.int(), + }; + + // Mock co-proposer claim exists + jest + .spyOn(coProposerDataSourceMock, 'findByInviteId') + .mockResolvedValue([mockCoProposerClaim]); + + // Mock no role claims + jest + .spyOn(roleClaimDataSourceMock, 'findByInviteId') + .mockResolvedValue([]); + + const result = await getTemplateIdForInvite(inviteId); + + expect(result).toBe('user-office-registration-invitation-co-proposer'); + }); + + test('should return reviewer template when internal reviewer role claim exists', async () => { + const inviteId = faker.number.int(); + const mockRoleClaim = { + roleClaimId: faker.number.int(), + inviteId, + roleId: UserRole.INTERNAL_REVIEWER, + }; + + // Mock no co-proposer claims + jest + .spyOn(coProposerDataSourceMock, 'findByInviteId') + .mockResolvedValue([]); + + // Mock internal reviewer role claim + jest + .spyOn(roleClaimDataSourceMock, 'findByInviteId') + .mockResolvedValue([mockRoleClaim]); + + const result = await getTemplateIdForInvite(inviteId); + + expect(result).toBe('user-office-registration-invitation-reviewer'); + }); + + test('should return user template when user role claim exists', async () => { + const inviteId = faker.number.int(); + const mockRoleClaim = { + roleClaimId: faker.number.int(), + inviteId, + roleId: UserRole.USER, + }; + + // Mock no co-proposer claims + jest + .spyOn(coProposerDataSourceMock, 'findByInviteId') + .mockResolvedValue([]); + + // Mock user role claim + jest + .spyOn(roleClaimDataSourceMock, 'findByInviteId') + .mockResolvedValue([mockRoleClaim]); + + const result = await getTemplateIdForInvite(inviteId); + + expect(result).toBe('user-office-registration-invitation-user'); + }); + + test('should use template proposal-created', async () => { + // Create a mock event for PROPOSAL_CREATED + const mockEvent: ApplicationEvent = { + type: Event.PROPOSAL_CREATED, + proposal: { + primaryKey: 1, + title: faker.lorem.sentence(), + proposerId: 1, + proposalId: faker.string.alphanumeric(), + callId: 1, + }, + isRejection: false, + } as ApplicationEvent; + + // Call the eliEmailHandler with the mock event + await eliEmailHandler(mockEvent); + + // Verify that sendMail was called with the correct template_id + expect(mockMailService.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + content: { + template_id: 'proposal-created', + }, + }) + ); + }); + + test('should use template Accepted-Proposal', async () => { + // Create a mock event for PROPOSAL_NOTIFIED with ACCEPTED status + const mockEvent: ApplicationEvent = { + type: Event.PROPOSAL_NOTIFIED, + proposal: { + primaryKey: faker.number.int(), + title: faker.lorem.sentence(), + proposerId: faker.number.int(), + proposalId: faker.string.alphanumeric(), + callId: faker.number.int(), + finalStatus: 1, // ProposalEndStatus.ACCEPTED + commentForUser: faker.lorem.sentence(), + }, + isRejection: false, + } as ApplicationEvent; + + // Call the eliEmailHandler with the mock event + await eliEmailHandler(mockEvent); + + // Verify that sendMail was called with the correct template_id + expect(mockMailService.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + content: { + template_id: 'Accepted-Proposal', + }, + }) + ); + }); + + test('should use template Rejected-Proposal', async () => { + // Create a mock event for PROPOSAL_NOTIFIED with REJECTED status + const mockEvent: ApplicationEvent = { + type: Event.PROPOSAL_NOTIFIED, + proposal: { + primaryKey: faker.number.int(), + title: faker.lorem.sentence(), + proposerId: faker.number.int(), + proposalId: faker.string.alphanumeric(), + callId: faker.number.int(), + finalStatus: 3, // ProposalEndStatus.REJECTED + commentForUser: faker.lorem.sentence(), + }, + isRejection: false, + } as ApplicationEvent; + + // Call the eliEmailHandler with the mock event + await eliEmailHandler(mockEvent); + + // Verify that sendMail was called with the correct template_id + expect(mockMailService.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + content: { + template_id: 'Rejected-Proposal', + }, + }) + ); + }); + + test('should use template Reserved-Proposal', async () => { + // Create a mock event for PROPOSAL_NOTIFIED with RESERVED status + const mockEvent: ApplicationEvent = { + type: Event.PROPOSAL_NOTIFIED, + proposal: { + primaryKey: faker.number.int(), + title: faker.lorem.sentence(), + proposerId: faker.number.int(), + proposalId: faker.string.alphanumeric(), + callId: faker.number.int(), + finalStatus: 2, // ProposalEndStatus.RESERVED + commentForUser: faker.lorem.sentence(), + }, + isRejection: false, + } as ApplicationEvent; + + // Call the eliEmailHandler with the mock event + await eliEmailHandler(mockEvent); + + // Verify that sendMail was called with the correct template_id + expect(mockMailService.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + content: { + template_id: 'Reserved-Proposal', + }, + }) + ); + }); + + test('should use template review-reminder', async () => { + // Create a mock event for FAP_REVIEWER_NOTIFIED + const mockEvent: ApplicationEvent = { + type: Event.FAP_REVIEWER_NOTIFIED, + fapReview: { + id: 1, + userID: 1, + proposalPk: 1, + }, + isRejection: false, + } as ApplicationEvent; + + // Call the eliEmailHandler with the mock event + await eliEmailHandler(mockEvent); + + // Verify that sendMail was called with the correct template_id + expect(mockMailService.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + content: { + template_id: 'review-reminder', + }, + }) + ); + }); + + test('should use template internal-review-created', async () => { + // Create a mock event for INTERNAL_REVIEW_CREATED + const mockEvent: ApplicationEvent = { + type: Event.INTERNAL_REVIEW_CREATED, + key: 'internal-review-created', + loggedInUserId: faker.number.int({ min: 1, max: 100 }), + internalreview: { + id: faker.number.int({ min: 1, max: 1000 }), + title: `Internal Review: ${faker.company.buzzPhrase()}`, + reviewerId: faker.number.int({ min: 1, max: 100 }), + assignedBy: faker.number.int({ min: 1, max: 100 }), + proposalPk: faker.number.int({ min: 1, max: 1000 }), + technicalReviewId: faker.number.int({ min: 1, max: 1000 }), + comment: faker.lorem.paragraph(), + files: faker.system.fileName(), + createdAt: faker.date.recent(), + }, + isRejection: false, + } as ApplicationEvent; + + // Call the eliEmailHandler with the mock event + await eliEmailHandler(mockEvent); + + // Verify that sendMail was called with the correct template_id + expect(mockMailService.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + content: { + template_id: 'internal-review-created', + }, + }) + ); + }); + + test('should use template internal-review-updated', async () => { + // Create a mock event for INTERNAL_REVIEW_UPDATED + const mockEvent: ApplicationEvent = { + type: Event.INTERNAL_REVIEW_UPDATED, + key: 'internal-review-updated', + loggedInUserId: faker.number.int({ min: 1, max: 100 }), + internalreview: { + id: faker.number.int({ min: 1, max: 1000 }), + title: `Updated Review: ${faker.company.buzzPhrase()}`, + reviewerId: faker.number.int({ min: 1, max: 100 }), + assignedBy: faker.number.int({ min: 1, max: 100 }), + proposalPk: faker.number.int({ min: 1, max: 1000 }), + technicalReviewId: faker.number.int({ min: 1, max: 1000 }), + comment: faker.lorem.paragraph(), + files: faker.system.fileName(), + createdAt: faker.date.recent(), + }, + isRejection: false, + } as ApplicationEvent; + + // Call the eliEmailHandler with the mock event + await eliEmailHandler(mockEvent); + + // Verify that sendMail was called with the correct template_id + expect(mockMailService.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + content: { + template_id: 'internal-review-updated', + }, + }) + ); + }); + + test('should use template internal-review-deleted', async () => { + // Create a mock event for INTERNAL_REVIEW_DELETED + const mockEvent: ApplicationEvent = { + type: Event.INTERNAL_REVIEW_DELETED, + key: 'internal-review-deleted', + loggedInUserId: faker.number.int({ min: 1, max: 100 }), + internalreview: { + id: faker.number.int({ min: 1, max: 1000 }), + title: `Deleted Review: ${faker.company.buzzPhrase()}`, + reviewerId: faker.number.int({ min: 1, max: 100 }), + assignedBy: faker.number.int({ min: 1, max: 100 }), + proposalPk: faker.number.int({ min: 1, max: 1000 }), + technicalReviewId: faker.number.int({ min: 1, max: 1000 }), + comment: faker.lorem.paragraph(), + files: faker.system.fileName(), + createdAt: faker.date.recent(), + }, + isRejection: false, + } as ApplicationEvent; + + // Call the eliEmailHandler with the mock event + await eliEmailHandler(mockEvent); + + // Verify that sendMail was called with the correct template_id + expect(mockMailService.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + content: { + template_id: 'internal-review-deleted', + }, + }) + ); + }); + }); +}); diff --git a/apps/backend/src/eventHandlers/email/essEmailHandler.spec.ts b/apps/backend/src/eventHandlers/email/essEmailHandler.spec.ts new file mode 100644 index 0000000000..aa76b19020 --- /dev/null +++ b/apps/backend/src/eventHandlers/email/essEmailHandler.spec.ts @@ -0,0 +1,96 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { CoProposerClaimDataSourceMock } from '../../datasources/mockups/CoProposerClaimDataSource'; +import { RoleClaimDataSourceMock } from '../../datasources/mockups/RoleClaimDataSource'; +import { UserRole } from '../../models/User'; +import { getTemplateIdForInvite } from './essEmailHandler'; + +describe('essEmailHandler', () => { + let coProposerDataSourceMock: CoProposerClaimDataSourceMock; + let roleClaimDataSourceMock: RoleClaimDataSourceMock; + + beforeEach(() => { + // Initialize mocks + coProposerDataSourceMock = container.resolve( + Tokens.CoProposerClaimDataSource + ); + roleClaimDataSourceMock = container.resolve( + Tokens.RoleClaimDataSource + ); + + coProposerDataSourceMock.init(); + roleClaimDataSourceMock.init(); + }); + + describe('getTemplateIdForInvite', () => { + test('should return co-proposer template when co-proposer claim exists', async () => { + const inviteId = 1; + const mockCoProposerClaim = { + inviteId, + proposalPk: 1, + }; + + // Mock co-proposer claim exists + jest + .spyOn(coProposerDataSourceMock, 'findByInviteId') + .mockResolvedValue([mockCoProposerClaim]); + + // Mock no role claims + jest + .spyOn(roleClaimDataSourceMock, 'findByInviteId') + .mockResolvedValue([]); + + const result = await getTemplateIdForInvite(inviteId); + + expect(result).toBe('user-office-registration-invitation-co-proposer'); + }); + + test('should return reviewer template when internal reviewer role claim exists', async () => { + const inviteId = 2; + const mockRoleClaim = { + roleClaimId: 1, + inviteId, + roleId: UserRole.INTERNAL_REVIEWER, + }; + + // Mock no co-proposer claims + jest + .spyOn(coProposerDataSourceMock, 'findByInviteId') + .mockResolvedValue([]); + + // Mock internal reviewer role claim + jest + .spyOn(roleClaimDataSourceMock, 'findByInviteId') + .mockResolvedValue([mockRoleClaim]); + + const result = await getTemplateIdForInvite(inviteId); + + expect(result).toBe('user-office-registration-invitation-reviewer'); + }); + + test('should return user template when user role claim exists', async () => { + const inviteId = 3; + const mockRoleClaim = { + roleClaimId: 2, + inviteId, + roleId: UserRole.USER, + }; + + // Mock no co-proposer claims + jest + .spyOn(coProposerDataSourceMock, 'findByInviteId') + .mockResolvedValue([]); + + // Mock user role claim + jest + .spyOn(roleClaimDataSourceMock, 'findByInviteId') + .mockResolvedValue([mockRoleClaim]); + + const result = await getTemplateIdForInvite(inviteId); + + expect(result).toBe('user-office-registration-invitation-user'); + }); + }); +}); From 08f872b313868a1822e9a0b270a0bf64cf0fc512 Mon Sep 17 00:00:00 2001 From: Jekabs Karklins Date: Wed, 2 Jul 2025 14:21:24 +0200 Subject: [PATCH 33/51] refactor how email template id is obtained --- .../db_patches/0184_AddTemplateIdToInvites.sql | 17 +++++++++++++++++ .../jobs/checkInviteReminderJob.spec.ts | 18 +----------------- .../asyncJobs/jobs/checkInviteReminderJob.ts | 17 +++++++++++------ .../src/datasources/InviteDataSource.ts | 2 ++ .../datasources/mockups/InviteDataSource.ts | 15 +++++++++++---- .../datasources/postgres/InviteDataSource.ts | 6 +++++- .../src/datasources/postgres/records.ts | 4 +++- .../src/eventHandlers/email/eliEmailHandler.ts | 6 ++++++ .../src/eventHandlers/email/essEmailHandler.ts | 6 ++++++ apps/backend/src/models/Invite.ts | 3 ++- 10 files changed, 64 insertions(+), 30 deletions(-) create mode 100644 apps/backend/db_patches/0184_AddTemplateIdToInvites.sql diff --git a/apps/backend/db_patches/0184_AddTemplateIdToInvites.sql b/apps/backend/db_patches/0184_AddTemplateIdToInvites.sql new file mode 100644 index 0000000000..4e75121de0 --- /dev/null +++ b/apps/backend/db_patches/0184_AddTemplateIdToInvites.sql @@ -0,0 +1,17 @@ +DO +$$ +BEGIN + IF register_patch( + 'AddTemplateIdToInvites.sql', + 'Jekabs Karklins', + 'Adding template_id column to invites table so that we can track which template was used for the invite', + '2025-06-04' + ) THEN + BEGIN + -- Add the template_id column to the invites table + ALTER TABLE invites ADD COLUMN template_id VARCHAR(255) NULL; + END; + END IF; +END; +$$ +LANGUAGE plpgsql; diff --git a/apps/backend/src/asyncJobs/jobs/checkInviteReminderJob.spec.ts b/apps/backend/src/asyncJobs/jobs/checkInviteReminderJob.spec.ts index 153195e5a2..68a13d4bbf 100644 --- a/apps/backend/src/asyncJobs/jobs/checkInviteReminderJob.spec.ts +++ b/apps/backend/src/asyncJobs/jobs/checkInviteReminderJob.spec.ts @@ -4,7 +4,6 @@ import sinon from 'sinon'; import { container } from 'tsyringe'; import { Tokens } from '../../config/Tokens'; -import * as EssEmailHandler from '../../eventHandlers/email/essEmailHandler'; import { Invite } from '../../models/Invite'; import { RoleClaim } from '../../models/RoleClaim'; import { SettingsId } from '../../models/Settings'; @@ -27,11 +26,8 @@ const mockAdminDataSource = { getSetting: sinon.stub(), }; -const mockGetTemplateIdForInvite = sinon.stub(); - describe('checkInviteReminderJob', () => { let clock: sinon.SinonFakeTimers; - let getTemplateIdForInviteSpy: sinon.SinonSpy; beforeEach(() => { mockUserDataSource.getBasicUserInfo.reset(); @@ -39,7 +35,6 @@ describe('checkInviteReminderJob', () => { mockRoleClaimDataSource.findByInviteId.reset(); mockMailService.sendMail.reset(); mockAdminDataSource.getSetting.reset(); - mockGetTemplateIdForInvite.reset(); container.clearInstances(); @@ -55,11 +50,6 @@ describe('checkInviteReminderJob', () => { useValue: mockAdminDataSource, }); - getTemplateIdForInviteSpy = sinon - .stub(EssEmailHandler, 'getTemplateIdForInvite') - .callsFake(mockGetTemplateIdForInvite); - mockGetTemplateIdForInvite.returns('template_id_for_invite'); - const now = new Date('2025-05-14T10:00:00.000Z'); clock = sinon.useFakeTimers(now.getTime()); }); @@ -67,9 +57,6 @@ describe('checkInviteReminderJob', () => { afterEach(() => { sinon.restore(); clock.restore(); - if (getTemplateIdForInviteSpy) { - getTemplateIdForInviteSpy.restore(); - } }); it('should not send reminders if no reminder days are configured (settingsValue is empty)', async () => { @@ -138,6 +125,7 @@ describe('checkInviteReminderJob', () => { claimedByUserId: null, isEmailSent: true, expiresAt: new Date('2025-12-31T00:00:00.000Z'), + templateId: 'user_template_id', }; mockInviter = { @@ -166,9 +154,6 @@ describe('checkInviteReminderJob', () => { .withArgs(mockInvite.id) .resolves([mockRoleClaim]); mockMailService.sendMail.resolves(); - mockGetTemplateIdForInvite - .withArgs(UserRole.USER) - .returns('user_template_id'); }); it('should send a reminder and update invite if eligible and reminder not yet sent', async () => { @@ -181,7 +166,6 @@ describe('checkInviteReminderJob', () => { const expectedEndDate = new Date(targetCreationDate); expectedEndDate.setHours(23, 59, 59, 999); - expect(mockGetTemplateIdForInvite.calledWith(mockInvite.id)).toBe(true); expect(mockMailService.sendMail.calledOnce).toBe(true); }); diff --git a/apps/backend/src/asyncJobs/jobs/checkInviteReminderJob.ts b/apps/backend/src/asyncJobs/jobs/checkInviteReminderJob.ts index 676e2a8c3e..a893f51945 100644 --- a/apps/backend/src/asyncJobs/jobs/checkInviteReminderJob.ts +++ b/apps/backend/src/asyncJobs/jobs/checkInviteReminderJob.ts @@ -4,9 +4,7 @@ import { container } from 'tsyringe'; import { Tokens } from '../../config/Tokens'; import { AdminDataSource } from '../../datasources/AdminDataSource'; import { InviteDataSource } from '../../datasources/InviteDataSource'; -import { RoleClaimDataSource } from '../../datasources/RoleClaimDataSource'; import { UserDataSource } from '../../datasources/UserDataSource'; -import { getTemplateIdForInvite } from '../../eventHandlers/email/essEmailHandler'; import { MailService } from '../../eventHandlers/MailService/MailService'; import { SettingsId } from '../../models/Settings'; @@ -28,9 +26,6 @@ const checkInviteReminder = async () => { const inviteDataSource = container.resolve( Tokens.InviteDataSource ); - const roleClaimDataSource = container.resolve( - Tokens.RoleClaimDataSource - ); const mailService = container.resolve(Tokens.MailService); const settingsDataSource = container.resolve( Tokens.AdminDataSource @@ -114,7 +109,17 @@ const checkInviteReminder = async () => { continue; } - const templateId = await getTemplateIdForInvite(invite.id); + const { templateId } = invite; + if (!templateId) { + logger.logError( + 'Invite does not have a template ID, skipping reminder email', + { + inviteId: invite.id, + email: invite.email, + } + ); + continue; + } try { await mailService.sendMail({ diff --git a/apps/backend/src/datasources/InviteDataSource.ts b/apps/backend/src/datasources/InviteDataSource.ts index f94be10ab5..07974c0250 100644 --- a/apps/backend/src/datasources/InviteDataSource.ts +++ b/apps/backend/src/datasources/InviteDataSource.ts @@ -13,6 +13,7 @@ export interface InviteDataSource { code: string; email: string; expiresAt: Date | null; + templateId?: string | null; }): Promise; findByCode(code: string): Promise; @@ -32,6 +33,7 @@ export interface InviteDataSource { claimedByUserId?: number | null; isEmailSent?: boolean; expiresAt?: Date | null; + templateId?: string | null; }): Promise; delete(id: number): Promise; diff --git a/apps/backend/src/datasources/mockups/InviteDataSource.ts b/apps/backend/src/datasources/mockups/InviteDataSource.ts index c97b3d1a9d..c1f9b97f9d 100644 --- a/apps/backend/src/datasources/mockups/InviteDataSource.ts +++ b/apps/backend/src/datasources/mockups/InviteDataSource.ts @@ -15,6 +15,7 @@ export class InviteDataSourceMock implements InviteDataSource { ) { this.init(); } + async findCoProposerInvites(proposalPk: number): Promise { const coProposerClaims = await this.coProposerDataSource.findByProposalPk(proposalPk); @@ -44,6 +45,7 @@ export class InviteDataSourceMock implements InviteDataSource { null, null, true, + null, null ), new Invite( @@ -55,7 +57,8 @@ export class InviteDataSourceMock implements InviteDataSource { null, 1, true, - null + null, + 'email-template' ), new Invite( 3, @@ -66,7 +69,8 @@ export class InviteDataSourceMock implements InviteDataSource { new Date(), null, false, - new Date('2022-01-01') + new Date('2022-01-01'), + null ), ]; } @@ -118,8 +122,9 @@ export class InviteDataSourceMock implements InviteDataSource { note: string; createdByUserId: number; expiresAt: Date | null; + template?: string | null; }): Promise { - const { code, email, note, createdByUserId, expiresAt } = args; + const { code, email, createdByUserId, expiresAt, template } = args; const newInvite = new Invite( this.invites.length + 1, // Generate new ID @@ -130,7 +135,8 @@ export class InviteDataSourceMock implements InviteDataSource { null, null, false, - expiresAt ?? null + expiresAt ?? null, + template ?? null ); this.invites.push(newInvite); @@ -147,6 +153,7 @@ export class InviteDataSourceMock implements InviteDataSource { claimedByUserId?: number | null; isEmailSent?: boolean; expiresAt?: Date | null; + templateId?: string | null; }): Promise { const invite = await this.findById(args.id); if (!invite) { diff --git a/apps/backend/src/datasources/postgres/InviteDataSource.ts b/apps/backend/src/datasources/postgres/InviteDataSource.ts index 4357f9ecd6..9598db9e8d 100644 --- a/apps/backend/src/datasources/postgres/InviteDataSource.ts +++ b/apps/backend/src/datasources/postgres/InviteDataSource.ts @@ -81,8 +81,9 @@ export default class PostgresInviteDataSource implements InviteDataSource { note: string; createdByUserId: number; expiresAt: Date | null; + template?: string | null; }): Promise { - const { code, email, createdByUserId, expiresAt } = args; + const { code, email, createdByUserId, expiresAt, template } = args; return database .insert({ @@ -90,6 +91,7 @@ export default class PostgresInviteDataSource implements InviteDataSource { email: email, created_by: createdByUserId, expires_at: expiresAt, + template_id: template, }) .into('invites') .returning('*') @@ -108,6 +110,7 @@ export default class PostgresInviteDataSource implements InviteDataSource { claimedByUserId?: number | null; isEmailSent?: boolean; expiresAt?: Date | null; + templateId?: string | null; }): Promise { return database .update({ @@ -118,6 +121,7 @@ export default class PostgresInviteDataSource implements InviteDataSource { claimed_by: args.claimedByUserId, is_email_sent: args.isEmailSent, expires_at: args.expiresAt, + template_id: args.templateId, }) .from('invites') .where('invite_id', args.id) diff --git a/apps/backend/src/datasources/postgres/records.ts b/apps/backend/src/datasources/postgres/records.ts index 8a688dafd9..bd485f83b1 100644 --- a/apps/backend/src/datasources/postgres/records.ts +++ b/apps/backend/src/datasources/postgres/records.ts @@ -1347,6 +1347,7 @@ export interface InviteRecord { readonly claimed_at: Date | null; readonly is_email_sent: boolean; readonly expires_at: Date | null; + readonly template_id: string | null; } export const createInviteObject = (invite: InviteRecord) => @@ -1359,7 +1360,8 @@ export const createInviteObject = (invite: InviteRecord) => invite.claimed_at, invite.claimed_by, invite.is_email_sent, - invite.expires_at + invite.expires_at, + invite.template_id ); export interface RoleClaimRecord { diff --git a/apps/backend/src/eventHandlers/email/eliEmailHandler.ts b/apps/backend/src/eventHandlers/email/eliEmailHandler.ts index 85104f7eb2..ce99d14d34 100644 --- a/apps/backend/src/eventHandlers/email/eliEmailHandler.ts +++ b/apps/backend/src/eventHandlers/email/eliEmailHandler.ts @@ -164,6 +164,12 @@ export async function eliEmailHandler(event: ApplicationEvent) { }) .catch((err: string) => { logger.logException('Failed email transmission', err); + }) + .finally(() => { + inviteDataSource.update({ + id: invite.id, + templateId: templateId, + }); }); } diff --git a/apps/backend/src/eventHandlers/email/essEmailHandler.ts b/apps/backend/src/eventHandlers/email/essEmailHandler.ts index b1b1b75d0c..3cf5d31441 100644 --- a/apps/backend/src/eventHandlers/email/essEmailHandler.ts +++ b/apps/backend/src/eventHandlers/email/essEmailHandler.ts @@ -101,6 +101,12 @@ export async function essEmailHandler(event: ApplicationEvent) { }) .catch((err: string) => { logger.logException('Failed email transmission', err); + }) + .finally(() => { + inviteDataSource.update({ + id: invite.id, + templateId: templateId, + }); }); } diff --git a/apps/backend/src/models/Invite.ts b/apps/backend/src/models/Invite.ts index 89c6e9ced8..d8b788bd22 100644 --- a/apps/backend/src/models/Invite.ts +++ b/apps/backend/src/models/Invite.ts @@ -8,6 +8,7 @@ export class Invite { public claimedAt: Date | null, public claimedByUserId: number | null, public isEmailSent: boolean, - public expiresAt: Date | null + public expiresAt: Date | null, + public templateId: string | null ) {} } From b2eff61b4f0e1741f94eb3ef94f36e24a8da6edb Mon Sep 17 00:00:00 2001 From: Jekabs Karklins Date: Wed, 2 Jul 2025 14:28:22 +0200 Subject: [PATCH 34/51] fix test --- apps/backend/src/datasources/mockups/ReviewDataSource.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/backend/src/datasources/mockups/ReviewDataSource.ts b/apps/backend/src/datasources/mockups/ReviewDataSource.ts index 2fc2d8686f..e5d3084d28 100644 --- a/apps/backend/src/datasources/mockups/ReviewDataSource.ts +++ b/apps/backend/src/datasources/mockups/ReviewDataSource.ts @@ -13,7 +13,7 @@ import { dummyProposalTechnicalReview } from './ProposalDataSource'; export const dummyReview = new Review( 4, - 10, + 1, 1, 'Good proposal', 9, @@ -28,7 +28,7 @@ export const dummyReview = new Review( ); export const dummySubmittedReview = new Review( 5, - 10, + 1, 1, 'Good proposal', 9, @@ -44,7 +44,7 @@ export const dummySubmittedReview = new Review( export const dummyTechnicalReview = new TechnicalReview( 6, - 10, + 1, 'Good proposal', 'Good proposal (public)', 1, @@ -59,7 +59,7 @@ export const dummyTechnicalReview = new TechnicalReview( export const dummyReviewBad = new Review( 1, - 9, + 1, 1, 'bad proposal', 1, From 0fe4edc479aeb5d0c47c555b5322335ad19fbf3c Mon Sep 17 00:00:00 2001 From: Jekabs Karklins Date: Wed, 2 Jul 2025 15:38:31 +0200 Subject: [PATCH 35/51] refactor: rename template to templateId in InviteDataSource classes --- .../src/datasources/mockups/InviteDataSource.ts | 8 ++++---- .../src/datasources/postgres/InviteDataSource.ts | 10 +++++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/backend/src/datasources/mockups/InviteDataSource.ts b/apps/backend/src/datasources/mockups/InviteDataSource.ts index 540eedcbd3..c0ee5f7070 100644 --- a/apps/backend/src/datasources/mockups/InviteDataSource.ts +++ b/apps/backend/src/datasources/mockups/InviteDataSource.ts @@ -134,9 +134,9 @@ export class InviteDataSourceMock implements InviteDataSource { note: string; createdByUserId: number; expiresAt: Date | null; - template?: string | null; + templateId?: string | null; }): Promise { - const { code, email, createdByUserId, expiresAt, template } = args; + const { code, email, createdByUserId, expiresAt, templateId } = args; const newInvite = new Invite( this.invites.length + 1, // Generate new ID @@ -148,7 +148,7 @@ export class InviteDataSourceMock implements InviteDataSource { null, false, expiresAt ?? null, - template ?? null + templateId ?? null ); this.invites.push(newInvite); @@ -165,7 +165,7 @@ export class InviteDataSourceMock implements InviteDataSource { claimedByUserId?: number | null; isEmailSent?: boolean; expiresAt?: Date | null; - template?: string | null; + templateId?: string | null; }): Promise { const invite = await this.findById(args.id); if (!invite) { diff --git a/apps/backend/src/datasources/postgres/InviteDataSource.ts b/apps/backend/src/datasources/postgres/InviteDataSource.ts index 91982e815c..58a819267d 100644 --- a/apps/backend/src/datasources/postgres/InviteDataSource.ts +++ b/apps/backend/src/datasources/postgres/InviteDataSource.ts @@ -110,9 +110,9 @@ export default class PostgresInviteDataSource implements InviteDataSource { note: string; createdByUserId: number; expiresAt: Date | null; - template?: string | null; + templateId?: string | null; }): Promise { - const { code, email, createdByUserId, expiresAt, template } = args; + const { code, email, createdByUserId, expiresAt, templateId } = args; return database .insert({ @@ -120,7 +120,7 @@ export default class PostgresInviteDataSource implements InviteDataSource { email: email, created_by: createdByUserId, expires_at: expiresAt, - template_id: template, + template_id: templateId, }) .into('invites') .returning('*') @@ -139,7 +139,7 @@ export default class PostgresInviteDataSource implements InviteDataSource { claimedByUserId?: number | null; isEmailSent?: boolean; expiresAt?: Date | null; - template?: string | null; + templateId?: string | null; }): Promise { return database .update({ @@ -150,7 +150,7 @@ export default class PostgresInviteDataSource implements InviteDataSource { claimed_by: args.claimedByUserId, is_email_sent: args.isEmailSent, expires_at: args.expiresAt, - template_id: args.template, + template_id: args.templateId, }) .from('invites') .where('invite_id', args.id) From 6409ce4c42210c0fbbe66bf968a4b80b2378be3d Mon Sep 17 00:00:00 2001 From: Jekabs Karklins Date: Wed, 2 Jul 2025 15:45:11 +0200 Subject: [PATCH 36/51] refactor: remove findByVisitId method from VisitRegistrationClaimDataSource interfaces and implementations --- .../datasources/VisitRegistrationClaimDataSource.ts | 1 - .../mockups/VisitRegistrationClaimDataSource.ts | 6 ------ .../postgres/VisitRegistrationClaimDataSource.ts | 10 ---------- 3 files changed, 17 deletions(-) diff --git a/apps/backend/src/datasources/VisitRegistrationClaimDataSource.ts b/apps/backend/src/datasources/VisitRegistrationClaimDataSource.ts index 0159e74419..6ab7626e21 100644 --- a/apps/backend/src/datasources/VisitRegistrationClaimDataSource.ts +++ b/apps/backend/src/datasources/VisitRegistrationClaimDataSource.ts @@ -3,5 +3,4 @@ import { VisitRegistrationClaim } from '../models/VisitRegistrationClaim'; export interface VisitRegistrationClaimDataSource { create(inviteId: number, visitId: number): Promise; findByInviteId(inviteId: number): Promise; - findByVisitId(visitId: number): Promise; } diff --git a/apps/backend/src/datasources/mockups/VisitRegistrationClaimDataSource.ts b/apps/backend/src/datasources/mockups/VisitRegistrationClaimDataSource.ts index f8ec9ea7c5..5fab6e1fce 100644 --- a/apps/backend/src/datasources/mockups/VisitRegistrationClaimDataSource.ts +++ b/apps/backend/src/datasources/mockups/VisitRegistrationClaimDataSource.ts @@ -37,10 +37,4 @@ export class VisitRegistrationClaimDataSourceMock (claim) => claim.inviteId === inviteId ); } - - async findByVisitId(visitId: number): Promise { - return this.visitRegistrationClaims.filter( - (claim) => claim.visitId === visitId - ); - } } diff --git a/apps/backend/src/datasources/postgres/VisitRegistrationClaimDataSource.ts b/apps/backend/src/datasources/postgres/VisitRegistrationClaimDataSource.ts index 8cc4a8ba42..4b48acc5cd 100644 --- a/apps/backend/src/datasources/postgres/VisitRegistrationClaimDataSource.ts +++ b/apps/backend/src/datasources/postgres/VisitRegistrationClaimDataSource.ts @@ -47,14 +47,4 @@ export default class PostgresVisitRegistrationClaimDataSource createVisitRegistrationClaimObject(record) ); } - - async findByVisitId(visitId: number): Promise { - const records = await database('visit_registration_claims') - .select('*') - .where('visit_id', visitId); - - return records.map((record: VisitRegistrationClaimRecord) => - createVisitRegistrationClaimObject(record) - ); - } } From 59ec9e63838b733381b682388b733c6bfc9bda64 Mon Sep 17 00:00:00 2001 From: Jekabs Karklins Date: Wed, 2 Jul 2025 15:49:37 +0200 Subject: [PATCH 37/51] refactor: improve initialization of invites in InviteDataSourceMock --- apps/backend/src/datasources/mockups/InviteDataSource.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/backend/src/datasources/mockups/InviteDataSource.ts b/apps/backend/src/datasources/mockups/InviteDataSource.ts index c1f9b97f9d..b7eb5a634e 100644 --- a/apps/backend/src/datasources/mockups/InviteDataSource.ts +++ b/apps/backend/src/datasources/mockups/InviteDataSource.ts @@ -122,9 +122,9 @@ export class InviteDataSourceMock implements InviteDataSource { note: string; createdByUserId: number; expiresAt: Date | null; - template?: string | null; + templateId?: string | null; }): Promise { - const { code, email, createdByUserId, expiresAt, template } = args; + const { code, email, createdByUserId, expiresAt, templateId } = args; const newInvite = new Invite( this.invites.length + 1, // Generate new ID @@ -136,7 +136,7 @@ export class InviteDataSourceMock implements InviteDataSource { null, false, expiresAt ?? null, - template ?? null + templateId ?? null ); this.invites.push(newInvite); From 06a57f1327f06e7f60d09665cd01ca4c905f14c9 Mon Sep 17 00:00:00 2001 From: Jekabs Karklins Date: Wed, 2 Jul 2025 15:51:03 +0200 Subject: [PATCH 38/51] refactor: improve initialization of invites in InviteDataSource --- apps/backend/src/datasources/postgres/InviteDataSource.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/backend/src/datasources/postgres/InviteDataSource.ts b/apps/backend/src/datasources/postgres/InviteDataSource.ts index 9598db9e8d..3b54669135 100644 --- a/apps/backend/src/datasources/postgres/InviteDataSource.ts +++ b/apps/backend/src/datasources/postgres/InviteDataSource.ts @@ -81,9 +81,9 @@ export default class PostgresInviteDataSource implements InviteDataSource { note: string; createdByUserId: number; expiresAt: Date | null; - template?: string | null; + templateId?: string | null; }): Promise { - const { code, email, createdByUserId, expiresAt, template } = args; + const { code, email, createdByUserId, expiresAt, templateId } = args; return database .insert({ @@ -91,7 +91,7 @@ export default class PostgresInviteDataSource implements InviteDataSource { email: email, created_by: createdByUserId, expires_at: expiresAt, - template_id: template, + template_id: templateId, }) .into('invites') .returning('*') From b25cdda5c46bff377dede7c0a60bf4b12095a1a4 Mon Sep 17 00:00:00 2001 From: Jekabs Karklins Date: Wed, 2 Jul 2025 17:23:11 +0200 Subject: [PATCH 39/51] feat: update logger configuration to use ConsoleLogger with colorization --- apps/backend/src/config/dependencyConfigTest.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/backend/src/config/dependencyConfigTest.ts b/apps/backend/src/config/dependencyConfigTest.ts index c5abfa02d9..1c883cda18 100644 --- a/apps/backend/src/config/dependencyConfigTest.ts +++ b/apps/backend/src/config/dependencyConfigTest.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-empty-function */ -import { setLogger } from '@user-office-software/duo-logger'; +import { ConsoleLogger, setLogger } from '@user-office-software/duo-logger'; import 'reflect-metadata'; import { UserAuthorizationMock } from '../auth/mockups/UserAuthorization'; @@ -105,9 +105,9 @@ mapClass(Tokens.MailService, SkipSendMailService); mapValue(Tokens.EmailEventHandler, essEmailHandler); mapValue(Tokens.ConfigureEnvironment, () => {}); -mapValue(Tokens.ConfigureLogger, () => { - setLogger([]); -}); +mapValue(Tokens.ConfigureLogger, () => + setLogger(new ConsoleLogger({ colorize: true })) +); mapClass(Tokens.DownloadService, DefaultDownloadService); From d5d254dc534bd6cee54fa6d6f0e27a0a46c1185b Mon Sep 17 00:00:00 2001 From: Jekabs Karklins Date: Wed, 2 Jul 2025 18:26:02 +0200 Subject: [PATCH 40/51] test: add validation for templateId in co-proposer invite --- .../src/mutations/InviteMutations.spec.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/apps/backend/src/mutations/InviteMutations.spec.ts b/apps/backend/src/mutations/InviteMutations.spec.ts index 10830716cd..1828cb6bd7 100644 --- a/apps/backend/src/mutations/InviteMutations.spec.ts +++ b/apps/backend/src/mutations/InviteMutations.spec.ts @@ -4,6 +4,7 @@ import { container } from 'tsyringe'; import { Tokens } from '../config/Tokens'; import { EventLogsDataSource } from '../datasources/EventLogsDataSource'; +import { InviteDataSource } from '../datasources/InviteDataSource'; import { AdminDataSourceMock } from '../datasources/mockups/AdminDataSource'; import { CoProposerClaimDataSourceMock } from '../datasources/mockups/CoProposerClaimDataSource'; import { InviteDataSourceMock } from '../datasources/mockups/InviteDataSource'; @@ -258,4 +259,29 @@ describe('Test Invite Mutations', () => { expect(setEventInDataSourceSpy).toHaveBeenCalled(); }); + + test('Invite should have the templateId set', async () => { + const email = faker.internet.email(); + const proposalPk = 1; + + const response = await inviteMutations.setCoProposerInvites( + dummyUserWithRole, + { + proposalPk, + emails: [email], + } + ); + + expect(response).not.toBeInstanceOf(Rejection); + + await new Promise((resolve) => setTimeout(resolve, 200)); + + const invite = (await container + .resolve(Tokens.InviteDataSource) + .findById((response as Invite[])[0].id)) as Invite; + + expect(invite.templateId).toBe( + 'user-office-registration-invitation-co-proposer' + ); + }); }); From 66c010c11a5df51024b23823e38b7ac78abeaee4 Mon Sep 17 00:00:00 2001 From: Jekabs Karklins Date: Thu, 3 Jul 2025 09:15:09 +0200 Subject: [PATCH 41/51] feat: enhance email handling and logging for co-proposer invites and visit registrations --- .../eventHandlers/email/eliEmailHandler.ts | 121 +++++----- .../email/essEmailHandler.spec.ts | 133 +++++------ .../eventHandlers/email/essEmailHandler.ts | 209 +++++++++--------- apps/backend/src/eventHandlers/logging.ts | 76 +++---- .../src/eventHandlers/messageBroker.ts | 4 +- apps/backend/src/events/applicationEvents.ts | 59 ++--- apps/backend/src/events/event.enum.ts | 22 +- .../src/mutations/InviteMutations.spec.ts | 27 ++- apps/backend/src/mutations/InviteMutations.ts | 92 +++++--- 9 files changed, 368 insertions(+), 375 deletions(-) diff --git a/apps/backend/src/eventHandlers/email/eliEmailHandler.ts b/apps/backend/src/eventHandlers/email/eliEmailHandler.ts index ce99d14d34..f5f93dd1a8 100644 --- a/apps/backend/src/eventHandlers/email/eliEmailHandler.ts +++ b/apps/backend/src/eventHandlers/email/eliEmailHandler.ts @@ -8,12 +8,13 @@ import { InviteDataSource } from '../../datasources/InviteDataSource'; import { ProposalDataSource } from '../../datasources/ProposalDataSource'; import { RedeemCodesDataSource } from '../../datasources/RedeemCodesDataSource'; import { ReviewDataSource } from '../../datasources/ReviewDataSource'; -import { RoleClaimDataSource } from '../../datasources/RoleClaimDataSource'; import { UserDataSource } from '../../datasources/UserDataSource'; import { ApplicationEvent } from '../../events/applicationEvents'; import { Event } from '../../events/event.enum'; +import { EventBus } from '../../events/eventBus'; +import { Invite } from '../../models/Invite'; import { ProposalEndStatus } from '../../models/Proposal'; -import { UserRole } from '../../models/User'; +import { BasicUserDetails, UserRole } from '../../models/User'; import EmailSettings from '../MailService/EmailSettings'; import { MailService } from '../MailService/MailService'; @@ -27,14 +28,6 @@ export async function eliEmailHandler(event: ApplicationEvent) { Tokens.UserDataSource ); - const roleClaimDataSource = container.resolve( - Tokens.RoleClaimDataSource - ); - - const inviteDataSource = container.resolve( - Tokens.InviteDataSource - ); - const callDataSource = container.resolve( Tokens.CallDataSource ); @@ -46,6 +39,9 @@ export async function eliEmailHandler(event: ApplicationEvent) { const technicalReviewDataSource = container.resolve( Tokens.ReviewDataSource ); + const eventBus = container.resolve>( + Tokens.EventBus + ); if (event.isRejection) { return; @@ -108,16 +104,8 @@ export async function eliEmailHandler(event: ApplicationEvent) { return; } - case Event.EMAIL_INVITE: - case Event.EMAIL_INVITES: { - let invites; - if ('invite' in event) { - // single invite in response - invites = [event.invite]; - } else { - // multiple invites in response - invites = event.array; - } + case Event.PROPOSAL_CO_PROPOSER_INVITES_UPDATED: { + const invites = event.array; for (const invite of invites) { if (invite.isEmailSent) { @@ -128,7 +116,7 @@ export async function eliEmailHandler(event: ApplicationEvent) { ); if (!inviter) { - logger.logError('Failed email invite. No inviter found', { + logger.logError('No inviter found when trying to send email', { inviter, event, }); @@ -136,44 +124,19 @@ export async function eliEmailHandler(event: ApplicationEvent) { return; } - const roleInviteClaim = await roleClaimDataSource.findByInviteId( - invite.id - ); - - const templateId = getTemplateIdForRole(roleInviteClaim[0].roleId); - - mailService - .sendMail({ - content: { - template_id: templateId, - }, - substitution_data: { - email: invite.email, - inviterName: inviter.firstname, - inviterLastname: inviter.lastname, - redeemCode: invite.code, - }, - recipients: [{ address: invite.email }], - }) - .then(async (res) => { - await inviteDataSource.update({ - id: invite.id, - isEmailSent: true, - }); - logger.logInfo('Successful email transmission', { res }); - }) - .catch((err: string) => { - logger.logException('Failed email transmission', err); - }) - .finally(() => { - inviteDataSource.update({ - id: invite.id, - templateId: templateId, - }); + await sendInviteEmail( + invite, + inviter, + 'user-office-registration-invitation' + ).then(async () => { + await eventBus.publish({ + ...event, + type: Event.PROPOSAL_CO_PROPOSER_INVITE_SENT, + invite, }); + }); } - - return; + break; } case Event.PROPOSAL_CREATED: { @@ -455,13 +418,39 @@ export async function eliEmailHandler(event: ApplicationEvent) { } } -function getTemplateIdForRole(role: UserRole): string { - switch (role) { - case UserRole.USER: - return 'user-office-registration-invitation'; - case UserRole.INTERNAL_REVIEWER: - return 'user-office-registration-invitation-reviewer'; - default: - throw new Error('No valid user role set for email invitation'); - } +async function sendInviteEmail( + invite: Invite, + inviter: BasicUserDetails, + templateId: string +) { + const mailService = container.resolve(Tokens.MailService); + const inviteDataSource = container.resolve( + Tokens.InviteDataSource + ); + + return mailService + .sendMail({ + content: { + template_id: templateId, + }, + substitution_data: { + email: invite.email, + inviterName: inviter.firstname, + inviterLastname: inviter.lastname, + inviterOrg: inviter.institution, + redeemCode: invite.code, + }, + recipients: [{ address: invite.email }], + }) + .then(async (res) => { + await inviteDataSource.update({ + id: invite.id, + isEmailSent: true, + templateId: templateId, + }); + logger.logInfo('Successful email transmission', { res }); + }) + .catch((err: string) => { + logger.logException('Failed email transmission', err); + }); } diff --git a/apps/backend/src/eventHandlers/email/essEmailHandler.spec.ts b/apps/backend/src/eventHandlers/email/essEmailHandler.spec.ts index aa76b19020..91d20debd5 100644 --- a/apps/backend/src/eventHandlers/email/essEmailHandler.spec.ts +++ b/apps/backend/src/eventHandlers/email/essEmailHandler.spec.ts @@ -1,96 +1,79 @@ import 'reflect-metadata'; +import { faker } from '@faker-js/faker'; import { container } from 'tsyringe'; import { Tokens } from '../../config/Tokens'; import { CoProposerClaimDataSourceMock } from '../../datasources/mockups/CoProposerClaimDataSource'; -import { RoleClaimDataSourceMock } from '../../datasources/mockups/RoleClaimDataSource'; -import { UserRole } from '../../models/User'; -import { getTemplateIdForInvite } from './essEmailHandler'; +import { ProposalDataSourceMock } from '../../datasources/mockups/ProposalDataSource'; +import { UserDataSourceMock } from '../../datasources/mockups/UserDataSource'; +import { ApplicationEvent } from '../../events/applicationEvents'; +import { Event } from '../../events/event.enum'; +import { essEmailHandler, EmailTemplateId } from './essEmailHandler'; + +// Mock MailService +const mockMailService = { + sendMail: jest.fn(), +}; describe('essEmailHandler', () => { + let proposalDataSourceMock: ProposalDataSourceMock; let coProposerDataSourceMock: CoProposerClaimDataSourceMock; - let roleClaimDataSourceMock: RoleClaimDataSourceMock; + + beforeAll(() => { + container.registerInstance(Tokens.MailService, mockMailService); + }); beforeEach(() => { // Initialize mocks + proposalDataSourceMock = container.resolve( + Tokens.ProposalDataSource + ); + container.resolve(Tokens.UserDataSource); coProposerDataSourceMock = container.resolve( Tokens.CoProposerClaimDataSource ); - roleClaimDataSourceMock = container.resolve( - Tokens.RoleClaimDataSource - ); + // Initialize mock data sources that have init method + proposalDataSourceMock.init(); coProposerDataSourceMock.init(); - roleClaimDataSourceMock.init(); - }); - - describe('getTemplateIdForInvite', () => { - test('should return co-proposer template when co-proposer claim exists', async () => { - const inviteId = 1; - const mockCoProposerClaim = { - inviteId, - proposalPk: 1, - }; - - // Mock co-proposer claim exists - jest - .spyOn(coProposerDataSourceMock, 'findByInviteId') - .mockResolvedValue([mockCoProposerClaim]); - - // Mock no role claims - jest - .spyOn(roleClaimDataSourceMock, 'findByInviteId') - .mockResolvedValue([]); - - const result = await getTemplateIdForInvite(inviteId); - - expect(result).toBe('user-office-registration-invitation-co-proposer'); - }); - - test('should return reviewer template when internal reviewer role claim exists', async () => { - const inviteId = 2; - const mockRoleClaim = { - roleClaimId: 1, - inviteId, - roleId: UserRole.INTERNAL_REVIEWER, - }; - - // Mock no co-proposer claims - jest - .spyOn(coProposerDataSourceMock, 'findByInviteId') - .mockResolvedValue([]); - // Mock internal reviewer role claim - jest - .spyOn(roleClaimDataSourceMock, 'findByInviteId') - .mockResolvedValue([mockRoleClaim]); - - const result = await getTemplateIdForInvite(inviteId); - - expect(result).toBe('user-office-registration-invitation-reviewer'); - }); - - test('should return user template when user role claim exists', async () => { - const inviteId = 3; - const mockRoleClaim = { - roleClaimId: 2, - inviteId, - roleId: UserRole.USER, - }; - - // Mock no co-proposer claims - jest - .spyOn(coProposerDataSourceMock, 'findByInviteId') - .mockResolvedValue([]); - - // Mock user role claim - jest - .spyOn(roleClaimDataSourceMock, 'findByInviteId') - .mockResolvedValue([mockRoleClaim]); - - const result = await getTemplateIdForInvite(inviteId); + // Reset mocks + mockMailService.sendMail.mockClear(); + mockMailService.sendMail.mockResolvedValue({ success: true }); + }); - expect(result).toBe('user-office-registration-invitation-user'); + test('mailService should be invoked when PROPOSAL_CO_PROPOSER_INVITE_ACCEPTED event is sent', async () => { + const inviteEmail = faker.internet.email(); + + const mockEvent = { + type: Event.PROPOSAL_CO_PROPOSER_INVITE_ACCEPTED, + invite: { + id: 1, // This will match the default mock data + email: inviteEmail, + code: faker.string.alphanumeric(10), + claimedByUserId: 2, + createdByUserId: 1, + isEmailSent: false, + }, + isRejection: false, + } as ApplicationEvent; + + await essEmailHandler(mockEvent); + + expect(mockMailService.sendMail).toHaveBeenCalledWith({ + content: { + template_id: EmailTemplateId.CO_PROPOSER_INVITE_ACCEPTED, + }, + substitution_data: { + piPreferredname: expect.any(String), + piLastname: expect.any(String), + email: inviteEmail, + proposalTitle: expect.any(String), + proposalId: expect.any(String), + claimerPreferredname: expect.any(String), + claimerLastname: expect.any(String), + }, + recipients: [{ address: expect.any(String) }], }); }); }); diff --git a/apps/backend/src/eventHandlers/email/essEmailHandler.ts b/apps/backend/src/eventHandlers/email/essEmailHandler.ts index 3cf5d31441..41c93630a2 100644 --- a/apps/backend/src/eventHandlers/email/essEmailHandler.ts +++ b/apps/backend/src/eventHandlers/email/essEmailHandler.ts @@ -7,17 +7,32 @@ import { CoProposerClaimDataSource } from '../../datasources/CoProposerClaimData import { FapDataSource } from '../../datasources/FapDataSource'; import { InviteDataSource } from '../../datasources/InviteDataSource'; import { ProposalDataSource } from '../../datasources/ProposalDataSource'; -import { RoleClaimDataSource } from '../../datasources/RoleClaimDataSource'; import { UserDataSource } from '../../datasources/UserDataSource'; import { VisitDataSource } from '../../datasources/VisitDataSource'; import { ApplicationEvent } from '../../events/applicationEvents'; import { Event } from '../../events/event.enum'; +import { EventBus } from '../../events/eventBus'; import { Invite } from '../../models/Invite'; import { ProposalEndStatus } from '../../models/Proposal'; -import { UserRole } from '../../models/User'; +import { BasicUserDetails } from '../../models/User'; import EmailSettings from '../MailService/EmailSettings'; import { MailService } from '../MailService/MailService'; +export enum EmailTemplateId { + CO_PROPOSER_INVITE_ACCEPTED = 'co-proposer-invite-accepted', + PROPOSAL_SUBMITTED = 'proposal-submitted', + ACCEPTED_PROPOSAL = 'Accepted-Proposal', + REJECTED_PROPOSAL = 'Rejected-Proposal', + RESERVED_PROPOSAL = 'Reserved-Proposal', + REVIEW_REMINDER = 'review-reminder', + 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_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', +} + export async function essEmailHandler(event: ApplicationEvent) { const mailService = container.resolve(Tokens.MailService); const proposalDataSource = container.resolve( @@ -42,78 +57,16 @@ export async function essEmailHandler(event: ApplicationEvent) { const visitDataSource = container.resolve( Tokens.VisitDataSource ); + const eventBus = container.resolve>( + Tokens.EventBus + ); if (event.isRejection) { return; } switch (event.type) { - case Event.EMAIL_INVITE: - case Event.EMAIL_INVITES: { - let invites; - if ('invite' in event) { - // single invite in response - invites = [event.invite]; - } else { - // multiple invites in response - invites = event.array; - } - - 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; - } - - const templateId = await getTemplateIdForInvite(invite.id); - - mailService - .sendMail({ - content: { - template_id: templateId, - }, - substitution_data: { - email: invite.email, - inviterName: inviter.firstname, - inviterLastname: inviter.lastname, - inviterOrg: inviter.institution, - redeemCode: invite.code, - }, - recipients: [{ address: invite.email }], - }) - .then(async (res) => { - await inviteDataSource.update({ - id: invite.id, - isEmailSent: true, - }); - logger.logInfo('Successful email transmission', { res }); - }) - .catch((err: string) => { - logger.logException('Failed email transmission', err); - }) - .finally(() => { - inviteDataSource.update({ - id: invite.id, - templateId: templateId, - }); - }); - } - - return; - } - - case Event.INVITE_ACCEPTED: { + case Event.PROPOSAL_CO_PROPOSER_INVITE_ACCEPTED: { const invite: Invite = event.invite; const coProposerClaims = await coProposerDataSource.findByInviteId( @@ -170,7 +123,7 @@ export async function essEmailHandler(event: ApplicationEvent) { mailService .sendMail({ content: { - template_id: 'co-proposer-invite-accepted', + template_id: EmailTemplateId.CO_PROPOSER_INVITE_ACCEPTED, }, substitution_data: { piPreferredname: principalInvestigator.preferredname, @@ -218,7 +171,7 @@ export async function essEmailHandler(event: ApplicationEvent) { const options: EmailSettings = { content: { - template_id: 'proposal-submitted', + template_id: EmailTemplateId.PROPOSAL_SUBMITTED, }, substitution_data: { piPreferredname: principalInvestigator.preferredname, @@ -280,11 +233,11 @@ export async function essEmailHandler(event: ApplicationEvent) { const { finalStatus } = event.proposal; let templateId = ''; if (finalStatus === ProposalEndStatus.ACCEPTED) { - templateId = 'Accepted-Proposal'; + templateId = EmailTemplateId.ACCEPTED_PROPOSAL; } else if (finalStatus === ProposalEndStatus.REJECTED) { - templateId = 'Rejected-Proposal'; + templateId = EmailTemplateId.REJECTED_PROPOSAL; } else if (finalStatus === ProposalEndStatus.RESERVED) { - templateId = 'Reserved-Proposal'; + templateId = EmailTemplateId.RESERVED_PROPOSAL; } else { logger.logError('Failed email notification', { event }); @@ -329,6 +282,42 @@ export async function essEmailHandler(event: ApplicationEvent) { return; } + + case Event.PROPOSAL_CO_PROPOSER_INVITES_UPDATED: { + const invites = event.array; + + 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 + ).then(async () => { + await eventBus.publish({ + ...event, + type: Event.PROPOSAL_CO_PROPOSER_INVITE_SENT, + invite, + }); + }); + } + break; + } + case Event.FAP_REVIEWER_NOTIFIED: { const { id: reviewId, userID, proposalPk } = event.fapReview; const fapReviewer = await userDataSource.getUser(userID); @@ -343,7 +332,7 @@ export async function essEmailHandler(event: ApplicationEvent) { mailService .sendMail({ content: { - template_id: 'review-reminder', + template_id: EmailTemplateId.REVIEW_REMINDER, }, substitution_data: { fapReviewerPreferredName: fapReviewer.preferredname, @@ -383,6 +372,7 @@ export async function essEmailHandler(event: ApplicationEvent) { return; } + case Event.VISIT_REGISTRATION_APPROVED: case Event.VISIT_REGISTRATION_CANCELLED: { const visitRegistration = await visitDataSource.getRegistration( @@ -417,8 +407,8 @@ export async function essEmailHandler(event: ApplicationEvent) { const templateId = event.type === Event.VISIT_REGISTRATION_APPROVED - ? 'visit-registration-approved' - : 'visit-registration-cancelled'; + ? EmailTemplateId.VISIT_REGISTRATION_APPROVED + : EmailTemplateId.VISIT_REGISTRATION_CANCELLED; mailService .sendMail({ @@ -466,40 +456,39 @@ export async function essEmailHandler(event: ApplicationEvent) { } } -export async function getTemplateIdForInvite( - inviteId: number -): Promise { - // Resolve all necessary data sources in one go - const roleClaimDS = container.resolve( - Tokens.RoleClaimDataSource - ); - const coProposerDS = container.resolve( - Tokens.CoProposerClaimDataSource +async function sendInviteEmail( + invite: Invite, + inviter: BasicUserDetails, + templateId: EmailTemplateId +) { + const mailService = container.resolve(Tokens.MailService); + const inviteDataSource = container.resolve( + Tokens.InviteDataSource ); - // Fetch all claims concurrently - const [coProposerClaim, roleClaims] = await Promise.all([ - coProposerDS.findByInviteId(inviteId), - roleClaimDS.findByInviteId(inviteId), - ]); - - if (coProposerClaim.length > 0) { - return 'user-office-registration-invitation-co-proposer'; - } - - if (roleClaims.length > 0) { - const { roleId } = roleClaims[0]; - switch (roleId) { - case UserRole.INTERNAL_REVIEWER: - return 'user-office-registration-invitation-reviewer'; - case UserRole.USER: - return 'user-office-registration-invitation-user'; - default: - throw new Error( - `Unsupported role \"${roleId}\" for invite ${inviteId}` - ); - } - } - - throw new Error(`No valid claim found for invite ${inviteId}`); + return mailService + .sendMail({ + content: { + template_id: templateId, + }, + substitution_data: { + email: invite.email, + inviterName: inviter.firstname, + inviterLastname: inviter.lastname, + inviterOrg: inviter.institution, + redeemCode: invite.code, + }, + recipients: [{ address: invite.email }], + }) + .then(async (res) => { + await inviteDataSource.update({ + id: invite.id, + isEmailSent: true, + templateId: templateId, + }); + logger.logInfo('Successful email transmission', { res }); + }) + .catch((err: string) => { + logger.logException('Failed email transmission', err); + }); } diff --git a/apps/backend/src/eventHandlers/logging.ts b/apps/backend/src/eventHandlers/logging.ts index 7a5472233b..d3ef09b7a1 100644 --- a/apps/backend/src/eventHandlers/logging.ts +++ b/apps/backend/src/eventHandlers/logging.ts @@ -67,54 +67,38 @@ export default function createLoggingHandler() { event.emailinviteresponse.userId.toString() ); break; - case Event.EMAIL_INVITE: - case Event.EMAIL_INVITES: - case Event.INVITE_ACCEPTED: - let invites; - if ('invite' in event) { - invites = [event.invite]; - } else { - invites = event.array; - } - for (const invite of invites) { - await eventLogsDataSource.set( - event.loggedInUserId, - event.type, - json, - invite.id.toString(), - event.type === Event.INVITE_ACCEPTED - ? `Invite accepted: ${invite.email}` - : `Invite sent: ${invite.email}` - ); - } - break; + case Event.PROPOSAL_CO_PROPOSER_INVITE_ACCEPTED: { + const { invite } = event; - case Event.PROPOSAL_CO_PROPOSER_CLAIM_SENT: - case Event.PROPOSAL_CO_PROPOSER_CLAIM_ACCEPTED: { - let invites; - if ('invite' in event) { - invites = [event.invite]; - } else { - invites = event.array; - } - for (const invite of invites) { - const coProposerInvites = - await coProposerClaimDataSource.findByInviteId(invite.id); + const coProposerInvites = + await coProposerClaimDataSource.findByInviteId(invite.id); - await Promise.all( - coProposerInvites.map(async (coProposerInvite) => { - return eventLogsDataSource.set( - event.loggedInUserId, - event.type, - json, - coProposerInvite.proposalPk.toString(), - event.type === Event.PROPOSAL_CO_PROPOSER_CLAIM_ACCEPTED - ? `Co-proposer invite accepted: ${invite.email}` - : `Co-proposer invite sent: ${invite.email}` - ); - }) - ); - } + await Promise.all( + coProposerInvites.map(async (coProposerInvite) => { + return eventLogsDataSource.set( + event.loggedInUserId, + event.type, + json, + coProposerInvite.proposalPk.toString(), + `Visit registration claim accepted: ${invite.email}`, + event.impersonatingUserId + ); + }) + ); + + break; + } + case Event.PROPOSAL_CO_PROPOSER_INVITE_SENT: { + const { invite, proposalPk } = event; + + eventLogsDataSource.set( + event.loggedInUserId, + event.type, + json, + proposalPk.toString(), + `Co-proposer invite sent to: ${invite.email}`, + event.impersonatingUserId + ); break; } diff --git a/apps/backend/src/eventHandlers/messageBroker.ts b/apps/backend/src/eventHandlers/messageBroker.ts index 95c0541f46..a06b40201d 100644 --- a/apps/backend/src/eventHandlers/messageBroker.ts +++ b/apps/backend/src/eventHandlers/messageBroker.ts @@ -237,8 +237,8 @@ export async function createPostToRabbitMQHandler() { ); break; } - case Event.INVITE_ACCEPTED: { - const invite = event.invite; + case Event.PROPOSAL_CO_PROPOSER_INVITE_ACCEPTED: { + const { invite } = event; const claims = await coProposerClaimDataSource.findByInviteId( invite.id diff --git a/apps/backend/src/events/applicationEvents.ts b/apps/backend/src/events/applicationEvents.ts index fbbbc17b75..38052b4f14 100644 --- a/apps/backend/src/events/applicationEvents.ts +++ b/apps/backend/src/events/applicationEvents.ts @@ -216,13 +216,37 @@ interface ProposalTopicAnsweredEvent extends GeneralEvent { array: AnswerBasic[]; } -interface ProposalCoProposerClaimSentEvent extends GeneralEvent { - type: Event.PROPOSAL_CO_PROPOSER_CLAIM_SENT; +interface ProposalCoProposerInvitesUpdatedEvent extends GeneralEvent { + type: Event.PROPOSAL_CO_PROPOSER_INVITES_UPDATED; array: Invite[]; + proposalPk: number; } -interface ProposalCoProposerClaimAcceptedEvent extends GeneralEvent { - type: Event.PROPOSAL_CO_PROPOSER_CLAIM_ACCEPTED; +interface ProposalCoProposerInviteSentEvent extends GeneralEvent { + type: Event.PROPOSAL_CO_PROPOSER_INVITE_SENT; + invite: Invite; + proposalPk: number; +} + +interface ProposalCoProposerInviteAcceptedEvent extends GeneralEvent { + type: Event.PROPOSAL_CO_PROPOSER_INVITE_ACCEPTED; + invite: Invite; +} + +interface ProposalVisitRegistrationInvitesUpdatedEvent extends GeneralEvent { + type: Event.PROPOSAL_VISIT_REGISTRATION_INVITES_UPDATED; + array: Invite[]; + proposalPk: number; +} + +interface ProposalVisitRegistrationInviteSentEvent extends GeneralEvent { + type: Event.PROPOSAL_VISIT_REGISTRATION_INVITE_SENT; + invite: Invite; + proposalPk: number; +} + +interface ProposalVisitRegistrationInviteAcceptedEvent extends GeneralEvent { + type: Event.PROPOSAL_VISIT_REGISTRATION_INVITE_ACCEPTED; invite: Invite; } @@ -250,21 +274,6 @@ interface EmailInviteOld extends GeneralEvent { }; } -type InviteResponse = Pick< - Invite, - 'id' | 'code' | 'email' | 'createdByUserId' | 'isEmailSent' ->; - -interface EmailInvite extends GeneralEvent { - type: Event.EMAIL_INVITE; - invite: InviteResponse; -} - -interface EmailInvites extends GeneralEvent { - type: Event.EMAIL_INVITES; - array: InviteResponse[]; -} - interface FapCreatedEvent extends GeneralEvent { type: Event.FAP_CREATED; fap: Fap; @@ -323,10 +332,6 @@ interface CallFapReviewEndedEvent extends GeneralEvent { type: Event.CALL_FAP_REVIEW_ENDED; call: Call; } -interface InviteAcceptedEvent extends GeneralEvent { - type: Event.INVITE_ACCEPTED; - invite: Invite; -} interface InstrumentCreatedEvent extends GeneralEvent { type: Event.INSTRUMENT_CREATED; instrument: Instrument; @@ -416,8 +421,6 @@ export type ApplicationEvent = | ProposalManagementDecisionUpdatedEvent | ProposalManagementDecisionSubmittedEvent | EmailInviteOld - | EmailInvite - | EmailInvites | UserUpdateEvent | UserRoleUpdateEvent | FapCreatedEvent @@ -434,7 +437,6 @@ export type ApplicationEvent = | CallEndedInternalEvent | CallReviewEndedEvent | CallFapReviewEndedEvent - | InviteAcceptedEvent | ProposalFeasibilityReviewUpdatedEvent | ProposalFeasibilityReviewSubmittedEvent | ProposalALLFeasibilityReviewSubmittedEvent @@ -458,8 +460,9 @@ export type ApplicationEvent = | ProposalAllFapReviewsSubmittedForAllPanelsEvent | ProposalAllFapMeetingsSubmittedEvent | ProposalAllFapInstrumentSubmittedEvent - | ProposalCoProposerClaimSentEvent - | ProposalCoProposerClaimAcceptedEvent + | ProposalCoProposerInvitesUpdatedEvent + | ProposalCoProposerInviteSentEvent + | ProposalCoProposerInviteAcceptedEvent | InstrumentCreatedEvent | InstrumentUpdatedEvent | InstrumentDeletedEvent diff --git a/apps/backend/src/events/event.enum.ts b/apps/backend/src/events/event.enum.ts index 6af606ea31..e735a0466c 100644 --- a/apps/backend/src/events/event.enum.ts +++ b/apps/backend/src/events/event.enum.ts @@ -66,8 +66,9 @@ export enum Event { PROPOSAL_BOOKING_TIME_COMPLETED = 'PROPOSAL_BOOKING_TIME_COMPLETED', PROPOSAL_BOOKING_TIME_UPDATED = 'PROPOSAL_BOOKING_TIME_UPDATED', PROPOSAL_BOOKING_TIME_REOPENED = 'PROPOSAL_BOOKING_TIME_REOPENED', - PROPOSAL_CO_PROPOSER_CLAIM_SENT = 'PROPOSAL_CO_PROPOSER_CLAIM_SENT', - PROPOSAL_CO_PROPOSER_CLAIM_ACCEPTED = 'PROPOSAL_CO_PROPOSER_CLAIM_ACCEPTED', + 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', INSTRUMENT_CREATED = 'INSTRUMENT_CREATED', INSTRUMENT_UPDATED = 'INSTRUMENT_UPDATED', INSTRUMENT_DELETED = 'INSTRUMENT_DELETED', @@ -236,11 +237,6 @@ export const EventLabel = new Map([ Event.EMAIL_INVITE_LEGACY, '[Deprecated] Event occurs when user is created using email invite', ], - [Event.EMAIL_INVITE, 'Event occurs when user is invited by email'], - [ - Event.EMAIL_INVITES, - 'Event occurs when multiple users are invited by email', - ], [Event.FAP_CREATED, 'Event occurs when FAP is created'], [Event.FAP_UPDATED, 'Event occurs when FAP is updated'], [Event.FAP_MEMBERS_ASSIGNED, 'Event occurs when we assign member/s to a FAP'], @@ -270,6 +266,18 @@ export const EventLabel = new Map([ Event.PROPOSAL_STATUS_ACTION_EXECUTED, 'Event occurs when the proposal status action is being executed in the status engine', ], + [ + Event.PROPOSAL_CO_PROPOSER_INVITES_UPDATED, + 'Event occurs when co-proposer invites are updated for a proposal', + ], + [ + Event.PROPOSAL_CO_PROPOSER_INVITE_SENT, + 'Event occurs when co-proposer invite is sent to a user', + ], + [ + Event.PROPOSAL_CO_PROPOSER_INVITE_ACCEPTED, + 'Event occurs when user accepts the co-proposer claim for a proposal', + ], [ Event.PROPOSAL_STATUS_CHANGED_BY_WORKFLOW, 'Event occurs when the proposal status was changed by the workflow engine', diff --git a/apps/backend/src/mutations/InviteMutations.spec.ts b/apps/backend/src/mutations/InviteMutations.spec.ts index 1828cb6bd7..c7c9b18819 100644 --- a/apps/backend/src/mutations/InviteMutations.spec.ts +++ b/apps/backend/src/mutations/InviteMutations.spec.ts @@ -14,7 +14,10 @@ import { dummyUserOfficerWithRole, dummyUserWithRole, } from '../datasources/mockups/UserDataSource'; +import { VisitDataSourceMock } from '../datasources/mockups/VisitDataSource'; +import { EmailTemplateId } from '../eventHandlers/email/essEmailHandler'; import { MailService } from '../eventHandlers/MailService/MailService'; +import { Event } from '../events/event.enum'; import { Invite } from '../models/Invite'; import { Rejection } from '../models/Rejection'; import InviteMutations from './InviteMutations'; @@ -31,6 +34,11 @@ describe('Test Invite Mutations', () => { .resolve(Tokens.CoProposerClaimDataSource) .init(); container.resolve(Tokens.AdminDataSource).init(); + container.resolve(Tokens.VisitDataSource).init(); + }); + + afterEach(() => { + jest.clearAllMocks(); }); test('A user can accept valid invite code', () => { @@ -221,15 +229,13 @@ describe('Test Invite Mutations', () => { expect(response).not.toBeInstanceOf(Rejection); - // wait 200ms for the email to be sent - await new Promise((resolve) => setTimeout(resolve, 200)); - expect(sendMailSpy).toHaveBeenCalledTimes(1); expect(sendMailSpy).toHaveBeenCalledWith( expect.objectContaining({ recipients: [{ address: email }], content: { - template_id: 'user-office-registration-invitation-co-proposer', + template_id: + EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_CO_PROPOSER, }, }) ); @@ -254,10 +260,15 @@ describe('Test Invite Mutations', () => { ); expect(response).not.toBeInstanceOf(Rejection); - // wait 200ms for the email to be sent - await new Promise((resolve) => setTimeout(resolve, 200)); - - expect(setEventInDataSourceSpy).toHaveBeenCalled(); + expect(setEventInDataSourceSpy).toHaveBeenCalledTimes(1); + expect(setEventInDataSourceSpy).toHaveBeenCalledWith( + dummyUserWithRole.id, // changedBy (userId) + expect.stringMatching(Event.PROPOSAL_CO_PROPOSER_INVITE_SENT), // eventType + expect.stringContaining(email), // rowData (JSON string containing the email) + expect.any(String), // changedObjectId (should be the invite ID) + expect.stringContaining(`Co-proposer invite sent to: ${email}`), // description + undefined + ); }); test('Invite should have the templateId set', async () => { diff --git a/apps/backend/src/mutations/InviteMutations.ts b/apps/backend/src/mutations/InviteMutations.ts index 9b735efa41..5c521bbac9 100644 --- a/apps/backend/src/mutations/InviteMutations.ts +++ b/apps/backend/src/mutations/InviteMutations.ts @@ -1,3 +1,4 @@ +import { GraphQLError } from 'graphql'; import { inject, injectable } from 'tsyringe'; import { ProposalAuthorization } from '../auth/ProposalAuthorization'; @@ -9,8 +10,10 @@ import { InviteDataSource } from '../datasources/InviteDataSource'; import { ProposalDataSource } from '../datasources/ProposalDataSource'; import { RoleClaimDataSource } from '../datasources/RoleClaimDataSource'; import { UserDataSource } from '../datasources/UserDataSource'; -import { Authorized, EventBus } from '../decorators'; +import { VisitDataSource } from '../datasources/VisitDataSource'; +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'; @@ -32,6 +35,8 @@ export default class InviteMutations { private roleClaimDataSource: RoleClaimDataSource, @inject(Tokens.CoProposerClaimDataSource) private coProposerClaimDataSource: CoProposerClaimDataSource, + @inject(Tokens.VisitDataSource) + private visitDataSource: VisitDataSource, @inject(Tokens.ProposalAuthorization) private proposalAuth: ProposalAuthorization, @inject(Tokens.AdminDataSource) @@ -41,7 +46,6 @@ export default class InviteMutations { ) {} @Authorized() - @EventBus(Event.INVITE_ACCEPTED) async accept( agent: UserWithRole | null, code: string @@ -50,7 +54,6 @@ export default class InviteMutations { if (invite === null) { return rejection('Invite code not found', { invite: code }); } - if (invite.claimedByUserId !== null) { return rejection('Invite code already claimed', { invite: code }); } @@ -71,9 +74,20 @@ export default class InviteMutations { 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() - @EventBus(Event.EMAIL_INVITES) - @EventBus(Event.PROPOSAL_CO_PROPOSER_CLAIM_SENT) public async setCoProposerInvites( agent: UserWithRole | null, args: SetCoProposerInvitesInput @@ -89,13 +103,7 @@ export default class InviteMutations { ); } - const existingClaims = - await this.coProposerClaimDataSource.findByProposalPk(proposalPk); - const existingInvites = (await Promise.all( - existingClaims.map((claim) => - this.inviteDataSource.findById(claim.inviteId) - ) - )) as Invite[]; + const existingInvites = await this.getCoProposerInvites(proposalPk); const existingEmails = existingInvites.map((invite) => invite.email); const deletedEmails = existingEmails.filter( @@ -111,25 +119,7 @@ export default class InviteMutations { deletedInvites.map((invite) => this.inviteDataSource.delete(invite.id)) ); - // Get invite validity period from settings - const inviteValidityPeriodSetting = await this.adminDataSource.getSetting( - SettingsId.INVITE_VALIDITY_PERIOD_DAYS - ); - if (!inviteValidityPeriodSetting?.settingsValue) { - return rejection('Invite validity period setting not found'); - } - - const validityPeriodDays = parseInt( - inviteValidityPeriodSetting.settingsValue - ); - if (isNaN(validityPeriodDays) || validityPeriodDays <= 0) { - return rejection('Invalid invite validity period value'); - } - - const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000; - const expirationDate = new Date( - Date.now() + validityPeriodDays * MILLISECONDS_PER_DAY - ); + const expirationDate = await this.getInviteExpirationDate(); const newInvites = await Promise.all( newEmails.map(async (email) => @@ -148,10 +138,22 @@ export default class InviteMutations { }) ); - return [ + const invites = [ ...existingInvites.filter((invite) => !deletedInvites.includes(invite)), ...newInvites, ]; + + 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, + proposalPk: proposalPk, + } as ApplicationEvent); + + return invites; } private async processRoleClaims(claimerUserId: number, invite: Invite) { @@ -196,7 +198,7 @@ export default class InviteMutations { ); this.eventBus.publish({ - type: Event.PROPOSAL_CO_PROPOSER_CLAIM_ACCEPTED, + type: Event.PROPOSAL_CO_PROPOSER_INVITE_ACCEPTED, isRejection: false, key: 'proposal', loggedInUserId: claimerUserId, @@ -227,4 +229,28 @@ export default class InviteMutations { 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 307ed17796e29ffe32c9172bafa91995dea0305a Mon Sep 17 00:00:00 2001 From: Jekabs Karklins Date: Thu, 3 Jul 2025 16:04:28 +0200 Subject: [PATCH 42/51] Add email template IDs and new event interfaces for visit registration invites --- .../src/eventHandlers/email/essEmailHandler.ts | 15 +++++++++++++++ apps/backend/src/events/applicationEvents.ts | 17 +++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/apps/backend/src/eventHandlers/email/essEmailHandler.ts b/apps/backend/src/eventHandlers/email/essEmailHandler.ts index 2830c557e7..3fee820361 100644 --- a/apps/backend/src/eventHandlers/email/essEmailHandler.ts +++ b/apps/backend/src/eventHandlers/email/essEmailHandler.ts @@ -18,6 +18,21 @@ import { BasicUserDetails } from '../../models/User'; import EmailSettings from '../MailService/EmailSettings'; import { MailService } from '../MailService/MailService'; +export enum EmailTemplateId { + CO_PROPOSER_INVITE_ACCEPTED = 'co-proposer-invite-accepted', + PROPOSAL_SUBMITTED = 'proposal-submitted', + ACCEPTED_PROPOSAL = 'Accepted-Proposal', + REJECTED_PROPOSAL = 'Rejected-Proposal', + RESERVED_PROPOSAL = 'Reserved-Proposal', + REVIEW_REMINDER = 'review-reminder', + 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_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', +} + export async function essEmailHandler(event: ApplicationEvent) { const mailService = container.resolve(Tokens.MailService); const proposalDataSource = container.resolve( diff --git a/apps/backend/src/events/applicationEvents.ts b/apps/backend/src/events/applicationEvents.ts index 08b9674320..a68680e18c 100644 --- a/apps/backend/src/events/applicationEvents.ts +++ b/apps/backend/src/events/applicationEvents.ts @@ -234,6 +234,23 @@ interface ProposalCoProposerInviteAcceptedEvent extends GeneralEvent { invite: Invite; } +interface ProposalVisitRegistrationInvitesUpdatedEvent extends GeneralEvent { + type: Event.PROPOSAL_VISIT_REGISTRATION_INVITES_UPDATED; + array: Invite[]; + proposalPk: number; +} + +interface ProposalVisitRegistrationInviteSentEvent extends GeneralEvent { + type: Event.PROPOSAL_VISIT_REGISTRATION_INVITE_SENT; + invite: Invite; + proposalPk: number; +} + +interface ProposalVisitRegistrationInviteAcceptedEvent extends GeneralEvent { + type: Event.PROPOSAL_VISIT_REGISTRATION_INVITE_ACCEPTED; + invite: Invite; +} + interface UserUpdateEvent extends GeneralEvent { type: Event.USER_UPDATED; user: User; From ff7bcad67d799d220600d2a2916745e06959e05c Mon Sep 17 00:00:00 2001 From: Jekabs Karklins Date: Thu, 3 Jul 2025 16:57:20 +0200 Subject: [PATCH 43/51] Remove deprecated email invite events from Event enum --- apps/backend/src/events/event.enum.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/apps/backend/src/events/event.enum.ts b/apps/backend/src/events/event.enum.ts index e735a0466c..d0213b042c 100644 --- a/apps/backend/src/events/event.enum.ts +++ b/apps/backend/src/events/event.enum.ts @@ -44,8 +44,6 @@ export enum Event { USER_DELETED = 'USER_DELETED', USER_PASSWORD_RESET_EMAIL = 'USER_PASSWORD_RESET_EMAIL', EMAIL_INVITE_LEGACY = 'EMAIL_INVITE_LEGACY', - EMAIL_INVITE = 'EMAIL_INVITE', - EMAIL_INVITES = 'EMAIL_INVITES', FAP_CREATED = 'FAP_CREATED', FAP_UPDATED = 'FAP_UPDATED', FAP_MEMBERS_ASSIGNED = 'FAP_MEMBERS_ASSIGNED', @@ -274,10 +272,6 @@ export const EventLabel = new Map([ Event.PROPOSAL_CO_PROPOSER_INVITE_SENT, 'Event occurs when co-proposer invite is sent to a user', ], - [ - Event.PROPOSAL_CO_PROPOSER_INVITE_ACCEPTED, - 'Event occurs when user accepts the co-proposer claim for a proposal', - ], [ Event.PROPOSAL_STATUS_CHANGED_BY_WORKFLOW, 'Event occurs when the proposal status was changed by the workflow engine', From a7cd6e3f0c48fcfa663e6e880e240a38b83871f7 Mon Sep 17 00:00:00 2001 From: Jekabs Karklins Date: Thu, 3 Jul 2025 17:02:45 +0200 Subject: [PATCH 44/51] Update log message for co-proposer invite acceptance --- apps/backend/src/eventHandlers/logging.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/src/eventHandlers/logging.ts b/apps/backend/src/eventHandlers/logging.ts index d3ef09b7a1..05bc862fe1 100644 --- a/apps/backend/src/eventHandlers/logging.ts +++ b/apps/backend/src/eventHandlers/logging.ts @@ -80,7 +80,7 @@ export default function createLoggingHandler() { event.type, json, coProposerInvite.proposalPk.toString(), - `Visit registration claim accepted: ${invite.email}`, + `Co-proposer invite accepted: ${invite.email}`, event.impersonatingUserId ); }) From 4d4c7216080cc7ed732d693a07d710dd5508d1ac Mon Sep 17 00:00:00 2001 From: Jekabs Karklins Date: Thu, 3 Jul 2025 18:01:28 +0200 Subject: [PATCH 45/51] Refactor co-proposer invite handling to use proposalPKey instead of proposalPk --- apps/backend/src/eventHandlers/logging.ts | 35 ++++++------------- apps/backend/src/events/applicationEvents.ts | 5 +-- apps/backend/src/mutations/InviteMutations.ts | 3 +- 3 files changed, 16 insertions(+), 27 deletions(-) diff --git a/apps/backend/src/eventHandlers/logging.ts b/apps/backend/src/eventHandlers/logging.ts index 05bc862fe1..60425209f7 100644 --- a/apps/backend/src/eventHandlers/logging.ts +++ b/apps/backend/src/eventHandlers/logging.ts @@ -3,7 +3,6 @@ import { logger } from '@user-office-software/duo-logger'; import { container } from 'tsyringe'; import { Tokens } from '../config/Tokens'; -import { CoProposerClaimDataSource } from '../datasources/CoProposerClaimDataSource'; import { EventLogsDataSource } from '../datasources/EventLogsDataSource'; import { FapDataSource } from '../datasources/FapDataSource'; import { InstrumentDataSource } from '../datasources/InstrumentDataSource'; @@ -34,11 +33,6 @@ export default function createLoggingHandler() { Tokens.ProposalDataSource ); - const coProposerClaimDataSource = - container.resolve( - Tokens.CoProposerClaimDataSource - ); - // Handler that logs every mutation wrapped with the event bus event to logger and event_logs table. return async function loggingHandler(event: ApplicationEvent) { const json = JSON.stringify(event); @@ -68,35 +62,28 @@ export default function createLoggingHandler() { ); break; case Event.PROPOSAL_CO_PROPOSER_INVITE_ACCEPTED: { - const { invite } = event; - - const coProposerInvites = - await coProposerClaimDataSource.findByInviteId(invite.id); + const { invite, proposalPKey } = event; - await Promise.all( - coProposerInvites.map(async (coProposerInvite) => { - return eventLogsDataSource.set( - event.loggedInUserId, - event.type, - json, - coProposerInvite.proposalPk.toString(), - `Co-proposer invite accepted: ${invite.email}`, - event.impersonatingUserId - ); - }) + await eventLogsDataSource.set( + event.loggedInUserId, + event.type, + json, + proposalPKey.toString(), + `Co-proposer invite issued to ${invite.email} accepted by userId: ${event.loggedInUserId}`, + event.impersonatingUserId ); break; } case Event.PROPOSAL_CO_PROPOSER_INVITE_SENT: { - const { invite, proposalPk } = event; + const { invite, proposalPKey } = event; eventLogsDataSource.set( event.loggedInUserId, event.type, json, - proposalPk.toString(), - `Co-proposer invite sent to: ${invite.email}`, + proposalPKey.toString(), + `Co-proposer invite sent to: ${invite.email} by userId: ${event.loggedInUserId}`, event.impersonatingUserId ); diff --git a/apps/backend/src/events/applicationEvents.ts b/apps/backend/src/events/applicationEvents.ts index 45503b0bfd..83aca4ee7b 100644 --- a/apps/backend/src/events/applicationEvents.ts +++ b/apps/backend/src/events/applicationEvents.ts @@ -219,18 +219,19 @@ interface ProposalTopicAnsweredEvent extends GeneralEvent { interface ProposalCoProposerInvitesUpdatedEvent extends GeneralEvent { type: Event.PROPOSAL_CO_PROPOSER_INVITES_UPDATED; array: Invite[]; - proposalPk: number; + proposalPKey: number; } interface ProposalCoProposerInviteSentEvent extends GeneralEvent { type: Event.PROPOSAL_CO_PROPOSER_INVITE_SENT; invite: Invite; - proposalPk: number; + proposalPKey: number; } interface ProposalCoProposerInviteAcceptedEvent extends GeneralEvent { type: Event.PROPOSAL_CO_PROPOSER_INVITE_ACCEPTED; invite: Invite; + proposalPKey: number; } interface UserUpdateEvent extends GeneralEvent { diff --git a/apps/backend/src/mutations/InviteMutations.ts b/apps/backend/src/mutations/InviteMutations.ts index 5c521bbac9..183957ac49 100644 --- a/apps/backend/src/mutations/InviteMutations.ts +++ b/apps/backend/src/mutations/InviteMutations.ts @@ -150,7 +150,7 @@ export default class InviteMutations { loggedInUserId: agent?.id, inputArgs: JSON.stringify(args), impersonatingUserId: agent ? agent.impersonatingUserId : null, - proposalPk: proposalPk, + proposalPKey: proposalPk, } as ApplicationEvent); return invites; @@ -204,6 +204,7 @@ export default class InviteMutations { loggedInUserId: claimerUserId, invite: invite, description: `User with ID ${claimerUserId} accepted invite for proposal ${claim.proposalPk}`, + proposalPKey: claim.proposalPk, }); } } From c76322cea26c65ea7fac780d96e59a0bdb81ca4a Mon Sep 17 00:00:00 2001 From: Jekabs Karklins Date: Thu, 3 Jul 2025 21:09:04 +0200 Subject: [PATCH 46/51] Update invite email descriptions to include user ID of the sender --- apps/backend/src/mutations/InviteMutations.spec.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/backend/src/mutations/InviteMutations.spec.ts b/apps/backend/src/mutations/InviteMutations.spec.ts index 4f9bbf95ac..e1f8a1e907 100644 --- a/apps/backend/src/mutations/InviteMutations.spec.ts +++ b/apps/backend/src/mutations/InviteMutations.spec.ts @@ -270,7 +270,9 @@ describe('Test Invite Mutations', () => { expect.stringMatching(Event.PROPOSAL_CO_PROPOSER_INVITE_SENT), // eventType expect.stringContaining(email), // rowData (JSON string containing the email) expect.any(String), // changedObjectId (should be the invite ID) - expect.stringContaining(`Co-proposer invite sent to: ${email}`), // description + expect.stringContaining( + `Co-proposer invite issued to ${email} by userId ${dummyUserWithRole.id}` + ), // description undefined ); }); @@ -342,7 +344,9 @@ describe('Test Invite Mutations', () => { expect.stringMatching(Event.PROPOSAL_VISIT_REGISTRATION_INVITE_SENT), // eventType expect.stringContaining(email), // rowData (JSON string containing the email) expect.any(String), // changedObjectId (should be the invite ID) - expect.stringContaining(`Visit registration invite sent to: ${email}`), // description + expect.stringContaining( + `Visit invite issued to ${email} by userId ${dummyUserWithRole.id}` + ), // description undefined ); }); From e278df73d133c301055c31a93bd7f696eb2f6264 Mon Sep 17 00:00:00 2001 From: Jekabs Karklins Date: Thu, 3 Jul 2025 21:48:01 +0200 Subject: [PATCH 47/51] Enhance invite acceptance test to verify log entries and add data attribute for proposal review tabs --- apps/e2e/cypress/e2e/invites.cy.ts | 14 +++++++++++++- .../components/review/ProposalReviewContent.tsx | 1 + 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/apps/e2e/cypress/e2e/invites.cy.ts b/apps/e2e/cypress/e2e/invites.cy.ts index 986d8590c7..6b812af512 100644 --- a/apps/e2e/cypress/e2e/invites.cy.ts +++ b/apps/e2e/cypress/e2e/invites.cy.ts @@ -143,7 +143,7 @@ context('Invites tests', () => { cy.getAndStoreFeaturesEnabled(); }); - it('Should be able to accept invite', function () { + it('Should be able to accept invite and then see that in log', function () { if (!featureFlags.getEnabledFeatures().get(FeatureId.EMAIL_INVITE)) { this.skip(); } @@ -162,7 +162,19 @@ context('Invites tests', () => { 'contain.text', initialDBData.users.user3.lastName ); + cy.logout(); + + cy.login('officer', initialDBData.roles.userOfficer); + cy.visit('/'); }); + cy.get('[data-testid="VisibilityIcon"] > path').click(); + cy.get('[data-cy="proposal-review-tabs"]').contains('Logs').click(); + cy.get('[data-cy="event-logs-table"]').contains( + 'PROPOSAL_CO_PROPOSER_INVITE_SENT' + ); + cy.get('[data-cy="event-logs-table"]').contains( + 'PROPOSAL_CO_PROPOSER_INVITE_ACCEPTED' + ); }); }); }); diff --git a/apps/frontend/src/components/review/ProposalReviewContent.tsx b/apps/frontend/src/components/review/ProposalReviewContent.tsx index a03e079ba7..b066f9c436 100644 --- a/apps/frontend/src/components/review/ProposalReviewContent.tsx +++ b/apps/frontend/src/components/review/ProposalReviewContent.tsx @@ -288,6 +288,7 @@ const ProposalReviewContent = ({ t(name))} isInsideModal={isInsideModal} + data-cy="proposal-review-tabs" > {tabsContent} From d8b50cdf192dd27381e614b94d4475898291637f Mon Sep 17 00:00:00 2001 From: Jekabs Karklins <58165815+jekabs-karklins@users.noreply.github.com> Date: Fri, 4 Jul 2025 10:23:44 +0200 Subject: [PATCH 48/51] fix: remove leftover comment --- apps/backend/src/decorators/EventBus.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/backend/src/decorators/EventBus.ts b/apps/backend/src/decorators/EventBus.ts index 611ee89f10..2933064a43 100644 --- a/apps/backend/src/decorators/EventBus.ts +++ b/apps/backend/src/decorators/EventBus.ts @@ -30,7 +30,6 @@ const EventBusDecorator = (eventType: Event) => { loggedInUser = result.user; } - // NOTE: Do not log the event in testing environment. // NOTE: Get the name of the object or class like: 'Fap', 'USER', 'Proposal' and lowercase it. const resultKey = (result.constructor.name as string).toLowerCase(); From 1b8dbec7c72a698d85a1b0e2b819014ffda97db6 Mon Sep 17 00:00:00 2001 From: jekabskarklins Date: Thu, 18 Sep 2025 08:29:11 +0200 Subject: [PATCH 49/51] refactor: remove unnecessary blank line in InviteDataSourceMock constructor --- apps/backend/src/datasources/mockups/InviteDataSource.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/backend/src/datasources/mockups/InviteDataSource.ts b/apps/backend/src/datasources/mockups/InviteDataSource.ts index 0f3158cb1c..5447b1ed66 100644 --- a/apps/backend/src/datasources/mockups/InviteDataSource.ts +++ b/apps/backend/src/datasources/mockups/InviteDataSource.ts @@ -17,7 +17,6 @@ export class InviteDataSourceMock implements InviteDataSource { this.init(); } - async findCoProposerInvites(proposalPk: number): Promise { const coProposerClaims = await this.coProposerDataSource.findByProposalPk(proposalPk); From 342807cbc03524e6ab605693f1133c1693cee806 Mon Sep 17 00:00:00 2001 From: jekabskarklins Date: Fri, 19 Sep 2025 14:51:21 +0200 Subject: [PATCH 50/51] refactor: rename claim processing methods for clarity and consistency --- apps/backend/src/mutations/InviteMutations.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/apps/backend/src/mutations/InviteMutations.ts b/apps/backend/src/mutations/InviteMutations.ts index 12ce696f39..010a08ea80 100644 --- a/apps/backend/src/mutations/InviteMutations.ts +++ b/apps/backend/src/mutations/InviteMutations.ts @@ -69,9 +69,9 @@ export default class InviteMutations { return rejection('Invite code has expired', { invite: code }); } - await this.processRoleClaims(agent!.id, invite); - await this.processCoProposerClaims(agent!.id, invite); - await this.processVisitRegistrationClaims(agent!.id, invite); + 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, @@ -239,7 +239,10 @@ export default class InviteMutations { return invites; } - private async processRoleClaims(claimerUserId: number, invite: Invite) { + private async processAcceptedRoleClaims( + claimerUserId: number, + invite: Invite + ) { const inviteId = invite.id; const roleClaims = await this.roleClaimDataSource.findByInviteId(inviteId); @@ -260,7 +263,10 @@ export default class InviteMutations { } } - private async processCoProposerClaims(claimerUserId: number, invite: Invite) { + private async processAcceptedCoProposerClaims( + claimerUserId: number, + invite: Invite + ) { const inviteId = invite.id; const coProposerClaim = await this.coProposerClaimDataSource.findByInviteId(inviteId); @@ -291,7 +297,7 @@ export default class InviteMutations { }); } } - private async processVisitRegistrationClaims( + private async processAcceptedVisitRegistrationClaims( claimerUserId: number, invite: Invite ) { From 884fcadd18b557ca70fba4a23d9397057772334f Mon Sep 17 00:00:00 2001 From: Yoganandan Pandiyan Date: Wed, 15 Oct 2025 16:59:38 +0200 Subject: [PATCH 51/51] fixed e2e test clicking first proposal in the list --- apps/e2e/cypress/e2e/invites.cy.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/e2e/cypress/e2e/invites.cy.ts b/apps/e2e/cypress/e2e/invites.cy.ts index bb3daafdf0..329da96baa 100644 --- a/apps/e2e/cypress/e2e/invites.cy.ts +++ b/apps/e2e/cypress/e2e/invites.cy.ts @@ -256,7 +256,7 @@ context('Invites tests', () => { cy.get('[data-cy=join-proposal-btn]').click(); cy.get('#code').type(response.setCoProposerInvites[0].code ?? ''); cy.get('[data-cy="invitation-submit"]').click(); - cy.get('[data-testid="VisibilityIcon"]').click(); + cy.get('[data-testid="VisibilityIcon"]').first().click(); cy.get('.MuiTabs-flexContainer > #horizontal-tab-1').click(); cy.get('[data-cy=questionary-details-view]').should( 'contain.text', @@ -267,7 +267,7 @@ context('Invites tests', () => { cy.login('officer', initialDBData.roles.userOfficer); cy.visit('/'); }); - cy.get('[data-testid="VisibilityIcon"] > path').click(); + cy.get('[data-testid="VisibilityIcon"] > path').first().click(); cy.get('[data-cy="proposal-review-tabs"]').contains('Logs').click(); cy.get('[data-cy="event-logs-table"]').contains( 'PROPOSAL_CO_PROPOSER_INVITE_SENT'