diff --git a/apps/backend/db_patches/0183_AddVisitRegistrationClaim.sql b/apps/backend/db_patches/0183_AddVisitRegistrationClaim.sql new file mode 100644 index 0000000000..e485bd4761 --- /dev/null +++ b/apps/backend/db_patches/0183_AddVisitRegistrationClaim.sql @@ -0,0 +1,26 @@ +DO +$$ +BEGIN + IF register_patch( + '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 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, + UNIQUE (invite_id, visit_id) + ); + + ALTER TABLE visit_registration_claims ADD CONSTRAINT visit_registration_unique + UNIQUE (invite_id, visit_id); + END; + END IF; +END; +$$ +LANGUAGE plpgsql; diff --git a/apps/backend/src/config/Tokens.ts b/apps/backend/src/config/Tokens.ts index baa5ef58b8..44cbf95bc4 100644 --- a/apps/backend/src/config/Tokens.ts +++ b/apps/backend/src/config/Tokens.ts @@ -42,9 +42,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/dependencyConfigDefault.ts b/apps/backend/src/config/dependencyConfigDefault.ts index fb64da1316..f271d12ed8 100644 --- a/apps/backend/src/config/dependencyConfigDefault.ts +++ b/apps/backend/src/config/dependencyConfigDefault.ts @@ -8,6 +8,7 @@ import 'reflect-metadata'; import { DataAccessUsersAuthorization } from '../auth/DataAccessUsersAuthorization'; 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'; @@ -43,6 +44,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'; @@ -106,6 +108,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 0f8e44279c..f99f9948cd 100644 --- a/apps/backend/src/config/dependencyConfigE2E.ts +++ b/apps/backend/src/config/dependencyConfigE2E.ts @@ -4,6 +4,7 @@ import 'reflect-metadata'; import { DataAccessUsersAuthorization } from '../auth/DataAccessUsersAuthorization'; 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'; @@ -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 { essEmailHandler } from '../eventHandlers/email/essEmailHandler'; import createLoggingHandler from '../eventHandlers/logging'; @@ -97,6 +99,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 24ce242c91..6a158f0b8c 100644 --- a/apps/backend/src/config/dependencyConfigELI.ts +++ b/apps/backend/src/config/dependencyConfigELI.ts @@ -3,6 +3,7 @@ import 'reflect-metadata'; import { DataAccessUsersAuthorization } from '../auth/DataAccessUsersAuthorization'; 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'; @@ -38,6 +39,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'; @@ -99,6 +101,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/dependencyConfigESS.ts b/apps/backend/src/config/dependencyConfigESS.ts index 8be279641b..ddf4cdaa05 100644 --- a/apps/backend/src/config/dependencyConfigESS.ts +++ b/apps/backend/src/config/dependencyConfigESS.ts @@ -4,6 +4,7 @@ import 'reflect-metadata'; import { DataAccessUsersAuthorization } from '../auth/DataAccessUsersAuthorization'; 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 { essEmailHandler } from '../eventHandlers/email/essEmailHandler'; import createLoggingHandler from '../eventHandlers/logging'; @@ -98,6 +100,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 a9e860d130..bcc7995ff9 100644 --- a/apps/backend/src/config/dependencyConfigSTFC.ts +++ b/apps/backend/src/config/dependencyConfigSTFC.ts @@ -3,6 +3,7 @@ import 'reflect-metadata'; import { DataAccessUsersAuthorization } from '../auth/DataAccessUsersAuthorization'; 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'; @@ -32,6 +33,7 @@ import PostgresTagDataSource from '../datasources/postgres/TagDataSource'; 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'; @@ -96,6 +98,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, StfcInternalReviewDataSource); mapClass(Tokens.TechniqueDataSource, StfcTechniqueDataSource); diff --git a/apps/backend/src/config/dependencyConfigTest.ts b/apps/backend/src/config/dependencyConfigTest.ts index 3bd3819650..b3f0c39c0a 100644 --- a/apps/backend/src/config/dependencyConfigTest.ts +++ b/apps/backend/src/config/dependencyConfigTest.ts @@ -5,6 +5,7 @@ import 'reflect-metadata'; import { DataAccessUsersAuthorization } from '../auth/DataAccessUsersAuthorization'; 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'; @@ -39,6 +40,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 { essEmailHandler } from '../eventHandlers/email/essEmailHandler'; import createLoggingHandler from '../eventHandlers/logging'; @@ -95,6 +97,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, PredefinedMessageDataSourceMock); mapClass(Tokens.StatusActionsLogsDataSource, StatusActionsLogsDataSourceMock); diff --git a/apps/backend/src/datasources/InviteDataSource.ts b/apps/backend/src/datasources/InviteDataSource.ts index 07974c0250..ca73fc5670 100644 --- a/apps/backend/src/datasources/InviteDataSource.ts +++ b/apps/backend/src/datasources/InviteDataSource.ts @@ -22,6 +22,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/VisitRegistrationClaimDataSource.ts b/apps/backend/src/datasources/VisitRegistrationClaimDataSource.ts new file mode 100644 index 0000000000..6ab7626e21 --- /dev/null +++ b/apps/backend/src/datasources/VisitRegistrationClaimDataSource.ts @@ -0,0 +1,6 @@ +import { VisitRegistrationClaim } from '../models/VisitRegistrationClaim'; + +export interface VisitRegistrationClaimDataSource { + create(inviteId: number, visitId: number): Promise; + findByInviteId(inviteId: number): Promise; +} diff --git a/apps/backend/src/datasources/mockups/InviteDataSource.ts b/apps/backend/src/datasources/mockups/InviteDataSource.ts index 855822aec8..5447b1ed66 100644 --- a/apps/backend/src/datasources/mockups/InviteDataSource.ts +++ b/apps/backend/src/datasources/mockups/InviteDataSource.ts @@ -27,6 +27,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/mockups/VisitRegistrationClaimDataSource.ts b/apps/backend/src/datasources/mockups/VisitRegistrationClaimDataSource.ts new file mode 100644 index 0000000000..5fab6e1fce --- /dev/null +++ b/apps/backend/src/datasources/mockups/VisitRegistrationClaimDataSource.ts @@ -0,0 +1,40 @@ +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 { + return this.visitRegistrationClaims.filter( + (claim) => claim.inviteId === inviteId + ); + } +} diff --git a/apps/backend/src/datasources/postgres/InviteDataSource.ts b/apps/backend/src/datasources/postgres/InviteDataSource.ts index 3b54669135..58a819267d 100644 --- a/apps/backend/src/datasources/postgres/InviteDataSource.ts +++ b/apps/backend/src/datasources/postgres/InviteDataSource.ts @@ -30,6 +30,35 @@ export default class PostgresInviteDataSource implements InviteDataSource { }) .then((invites: InviteRecord[]) => invites.map(createInviteObject)); } + findVisitRegistrationInvites( + visitId: number, + isClaimed?: boolean + ): Promise { + return database + .select('invites.*') + .from('visit_registration_claims') + .where('visit_id', visitId) + .modify((query) => { + if (isClaimed !== undefined) { + if (isClaimed) { + query.whereNotNull('claimed_at'); + } else { + query.whereNull('claimed_at'); + } + } + }) + .leftJoin( + 'invites', + 'visit_registration_claims.invite_id', + 'invites.invite_id' + ) + .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/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/datasources/postgres/VisitRegistrationClaimDataSource.ts b/apps/backend/src/datasources/postgres/VisitRegistrationClaimDataSource.ts new file mode 100644 index 0000000000..4b48acc5cd --- /dev/null +++ b/apps/backend/src/datasources/postgres/VisitRegistrationClaimDataSource.ts @@ -0,0 +1,50 @@ +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, + }, + '*' + ) + .onConflict(['invite_id', 'visit_id']) + .ignore(); + + 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 records = await database( + 'visit_registration_claims' + ) + .select('*') + .where('invite_id', inviteId); + + 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 0137fdb483..50310bd5a6 100644 --- a/apps/backend/src/datasources/postgres/records.ts +++ b/apps/backend/src/datasources/postgres/records.ts @@ -66,6 +66,7 @@ import { VisitRegistration, VisitRegistrationStatus, } from '../../models/VisitRegistration'; +import { VisitRegistrationClaim } from '../../models/VisitRegistrationClaim'; import { WorkflowType } from '../../models/Workflow'; import { FapInstrument, @@ -275,6 +276,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; @@ -1214,6 +1220,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/eventHandlers/email/eliEmailHandler.ts b/apps/backend/src/eventHandlers/email/eliEmailHandler.ts index a785f61f2d..f5f93dd1a8 100644 --- a/apps/backend/src/eventHandlers/email/eliEmailHandler.ts +++ b/apps/backend/src/eventHandlers/email/eliEmailHandler.ts @@ -131,7 +131,7 @@ export async function eliEmailHandler(event: ApplicationEvent) { ).then(async () => { await eventBus.publish({ ...event, - type: Event.PROPOSAL_CO_PROPOSER_INVITE_EMAIL_SENT, + type: Event.PROPOSAL_CO_PROPOSER_INVITE_SENT, invite, }); }); diff --git a/apps/backend/src/eventHandlers/email/essEmailHandler.spec.ts b/apps/backend/src/eventHandlers/email/essEmailHandler.spec.ts index ef7a4208bf..95dca29597 100644 --- a/apps/backend/src/eventHandlers/email/essEmailHandler.spec.ts +++ b/apps/backend/src/eventHandlers/email/essEmailHandler.spec.ts @@ -24,7 +24,7 @@ const mockMailService = { sendMail: jest.fn(), }; -describe('essEmailHandler', () => { +describe('essEmailHandler co-proposer invites', () => { let proposalDataSourceMock: ProposalDataSourceMock; let coProposerDataSourceMock: CoProposerClaimDataSourceMock; let userDataSourceMock: UserDataSourceMock; @@ -98,6 +98,56 @@ describe('essEmailHandler', () => { }); }); + test('mailService should be invoked when PROPOSAL_VISIT_REGISTRATION_INVITES_UPDATED event is sent', async () => { + const inviteEmail = faker.internet.email(); + const inviterId = 1; + const inviteId = 123; + const redeemCode = faker.string.alphanumeric(10); + + // Mock UserDataSource.getBasicUserInfo to return a dummy inviter + const userDataSourceMock = container.resolve( + Tokens.UserDataSource + ); + jest.spyOn(userDataSourceMock, 'getBasicUserInfo').mockResolvedValue({ + id: inviterId, + firstname: 'Inviter', + lastname: 'User', + institution: 'TestOrg', + email: 'inviter@email.com', + } as any); + + const mockEvent = { + type: Event.PROPOSAL_VISIT_REGISTRATION_INVITES_UPDATED, + array: [ + { + id: inviteId, + email: inviteEmail, + code: redeemCode, + createdByUserId: inviterId, + isEmailSent: false, + }, + ], + isRejection: false, + } as ApplicationEvent; + + await essEmailHandler(mockEvent); + + expect(mockMailService.sendMail).toHaveBeenCalledWith({ + content: { + template_id: + EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_VISIT_REGISTRATION, + }, + substitution_data: { + email: inviteEmail, + inviterName: 'Inviter', + inviterLastname: 'User', + inviterOrg: 'TestOrg', + redeemCode: redeemCode, + }, + recipients: [{ address: inviteEmail }], + }); + }); + it('should log error when proposal is not found', async () => { const mockInvite = new Invite( 1, diff --git a/apps/backend/src/eventHandlers/email/essEmailHandler.ts b/apps/backend/src/eventHandlers/email/essEmailHandler.ts index 6196131776..9d1b64cdbe 100644 --- a/apps/backend/src/eventHandlers/email/essEmailHandler.ts +++ b/apps/backend/src/eventHandlers/email/essEmailHandler.ts @@ -17,6 +17,7 @@ import { ProposalEndStatus } from '../../models/Proposal'; 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', @@ -270,6 +271,42 @@ 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, + EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_VISIT_REGISTRATION + ).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; @@ -297,7 +334,7 @@ export async function essEmailHandler(event: ApplicationEvent) { ).then(async () => { await eventBus.publish({ ...event, - type: Event.PROPOSAL_CO_PROPOSER_INVITE_EMAIL_SENT, + type: Event.PROPOSAL_CO_PROPOSER_INVITE_SENT, invite, }); }); diff --git a/apps/backend/src/eventHandlers/logging.ts b/apps/backend/src/eventHandlers/logging.ts index 410c58c97c..3d193a76b6 100644 --- a/apps/backend/src/eventHandlers/logging.ts +++ b/apps/backend/src/eventHandlers/logging.ts @@ -69,13 +69,13 @@ export default function createLoggingHandler() { event.type, json, proposalPKey.toString(), - `Co-proposer invite issued to ${invite.email} accepted by userId: ${event.loggedInUserId}`, + `Co-proposer invite issued to ${invite.email} accepted by userId ${event.loggedInUserId}`, event.impersonatingUserId ); break; } - case Event.PROPOSAL_CO_PROPOSER_INVITE_EMAIL_SENT: { + case Event.PROPOSAL_CO_PROPOSER_INVITE_SENT: { const { invite, proposalPKey } = event; eventLogsDataSource.set( @@ -83,12 +83,41 @@ export default function createLoggingHandler() { event.type, json, proposalPKey.toString(), - `Co-proposer invite sent to: ${invite.email} by userId: ${event.loggedInUserId}`, + `Co-proposer invite issued to ${invite.email} by userId ${event.loggedInUserId}`, event.impersonatingUserId ); break; } + case Event.PROPOSAL_VISIT_REGISTRATION_INVITE_SENT: { + const { invite, proposalPKey: proposalPk } = event; + + eventLogsDataSource.set( + event.loggedInUserId, + event.type, + json, + proposalPk.toString(), + `Visit invite issued to ${invite.email} by userId ${event.loggedInUserId}`, + event.impersonatingUserId + ); + + break; + } + case Event.PROPOSAL_VISIT_REGISTRATION_INVITE_ACCEPTED: { + const { invite, proposalPKey } = event; + + await eventLogsDataSource.set( + event.loggedInUserId, + event.type, + json, + proposalPKey.toString(), + `Visit invite sent to ${invite.email} accepted by userId ${event.loggedInUserId}`, + event.impersonatingUserId + ); + + 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 b90214d997..2bff444905 100644 --- a/apps/backend/src/events/applicationEvents.ts +++ b/apps/backend/src/events/applicationEvents.ts @@ -12,6 +12,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'; @@ -224,7 +225,7 @@ interface ProposalCoProposerInvitesUpdatedEvent extends GeneralEvent { } interface ProposalCoProposerInviteSentEvent extends GeneralEvent { - type: Event.PROPOSAL_CO_PROPOSER_INVITE_EMAIL_SENT; + type: Event.PROPOSAL_CO_PROPOSER_INVITE_SENT; invite: Invite; proposalPKey: number; } @@ -235,6 +236,24 @@ interface ProposalCoProposerInviteAcceptedEvent extends GeneralEvent { proposalPKey: number; } +interface ProposalVisitRegistrationInvitesUpdatedEvent extends GeneralEvent { + type: Event.PROPOSAL_VISIT_REGISTRATION_INVITES_UPDATED; + array: Invite[]; + proposalPKey: number; +} + +interface ProposalVisitRegistrationInviteSentEvent extends GeneralEvent { + type: Event.PROPOSAL_VISIT_REGISTRATION_INVITE_SENT; + invite: Invite; + proposalPKey: number; +} + +interface ProposalVisitRegistrationInviteAcceptedEvent extends GeneralEvent { + type: Event.PROPOSAL_VISIT_REGISTRATION_INVITE_ACCEPTED; + invite: Invite; + proposalPKey: number; +} + interface UserUpdateEvent extends GeneralEvent { type: Event.USER_UPDATED; user: User; @@ -381,6 +400,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; @@ -494,6 +517,9 @@ export type ApplicationEvent = | ProposalCoProposerInvitesUpdatedEvent | ProposalCoProposerInviteSentEvent | ProposalCoProposerInviteAcceptedEvent + | ProposalVisitRegistrationInvitesUpdatedEvent + | ProposalVisitRegistrationInviteSentEvent + | ProposalVisitRegistrationInviteAcceptedEvent | InstrumentCreatedEvent | InstrumentUpdatedEvent | InstrumentDeletedEvent @@ -508,6 +534,7 @@ export type ApplicationEvent = | InternalReviewCreated | InternalReviewUpdated | InternalReviewDeleted + | VisitCreatedEvent | VisitRegistrationApprovedEvent | VisitRegistrationCancelledEvent | UserDataAccessUpdatedEvent diff --git a/apps/backend/src/events/event.enum.ts b/apps/backend/src/events/event.enum.ts index a9e589cc76..e98a24165e 100644 --- a/apps/backend/src/events/event.enum.ts +++ b/apps/backend/src/events/event.enum.ts @@ -38,7 +38,6 @@ 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', @@ -65,8 +64,11 @@ export enum Event { PROPOSAL_BOOKING_TIME_UPDATED = 'PROPOSAL_BOOKING_TIME_UPDATED', PROPOSAL_BOOKING_TIME_REOPENED = 'PROPOSAL_BOOKING_TIME_REOPENED', PROPOSAL_CO_PROPOSER_INVITES_UPDATED = 'PROPOSAL_CO_PROPOSER_INVITES_UPDATED', - PROPOSAL_CO_PROPOSER_INVITE_EMAIL_SENT = 'PROPOSAL_CO_PROPOSER_INVITE_EMAIL_SENT', + 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', @@ -95,6 +97,7 @@ export enum Event { EXPERIMENT_SAFETY_MANAGEMENT_DECISION_SUBMITTED_BY_ESR = 'EXPERIMENT_SAFETY_MANAGEMENT_DECISION_SUBMITTED_BY_ESR', EXPERIMENT_SAFETY_STATUS_CHANGED_BY_USER = 'EXPERIMENT_SAFETY_STATUS_CHANGED_BY_USER', EXPERIMENT_SAFETY_STATUS_CHANGED_BY_WORKFLOW = 'EXPERIMENT_SAFETY_STATUS_CHANGED_BY_WORKFLOW', + VISIT_CREATED = 'VISIT_CREATED', } export const EventLabel = new Map([ @@ -274,9 +277,25 @@ export const EventLabel = new Map([ 'Event occurs when co-proposer invites are updated for a proposal', ], [ - Event.PROPOSAL_CO_PROPOSER_INVITE_EMAIL_SENT, + 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 visit registration invites are updated 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', + ], [ Event.PROPOSAL_STATUS_CHANGED_BY_WORKFLOW, 'Event occurs when the proposal status was changed by the workflow engine', @@ -353,6 +372,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/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/mutations/InviteMutations.spec.ts b/apps/backend/src/mutations/InviteMutations.spec.ts index bb66b5c6d0..e1f8a1e907 100644 --- a/apps/backend/src/mutations/InviteMutations.spec.ts +++ b/apps/backend/src/mutations/InviteMutations.spec.ts @@ -15,6 +15,7 @@ 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'; @@ -23,6 +24,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(() => { @@ -263,10 +267,86 @@ describe('Test Invite Mutations', () => { expect(setEventInDataSourceSpy).toHaveBeenCalledTimes(1); expect(setEventInDataSourceSpy).toHaveBeenCalledWith( dummyUserWithRole.id, // changedBy (userId) - expect.stringMatching(Event.PROPOSAL_CO_PROPOSER_INVITE_EMAIL_SENT), // eventType + 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 + ); + }); + + test('A visitor should receive an invite email when visitor invite is created', async () => { + const email = faker.internet.email(); + + 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: newVisit.id, + 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 }], + content: { + template_id: + EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_VISIT_REGISTRATION, + }, + }) + ); + }); + + 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 invite issued to ${email} by userId ${dummyUserWithRole.id}` + ), // description undefined ); }); @@ -292,7 +372,7 @@ describe('Test Invite Mutations', () => { .findById((response as Invite[])[0].id)) as Invite; expect(invite.templateId).toBe( - EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_CO_PROPOSER + 'user-office-registration-invitation-co-proposer' ); }); }); diff --git a/apps/backend/src/mutations/InviteMutations.ts b/apps/backend/src/mutations/InviteMutations.ts index 0f02d7f45d..65fb8385e2 100644 --- a/apps/backend/src/mutations/InviteMutations.ts +++ b/apps/backend/src/mutations/InviteMutations.ts @@ -3,14 +3,17 @@ import { inject, injectable } from 'tsyringe'; import { ProposalAuthorization } from '../auth/ProposalAuthorization'; import { UserAuthorization } from '../auth/UserAuthorization'; +import { VisitAuthorization } from '../auth/VisitAuthorization'; import { Tokens } from '../config/Tokens'; import { AdminDataSource } from '../datasources/AdminDataSource'; import { CoProposerClaimDataSource } from '../datasources/CoProposerClaimDataSource'; import { InviteDataSource } from '../datasources/InviteDataSource'; +import database from '../datasources/postgres/database'; import { ProposalDataSource } from '../datasources/ProposalDataSource'; import { RoleClaimDataSource } from '../datasources/RoleClaimDataSource'; import { UserDataSource } from '../datasources/UserDataSource'; import { VisitDataSource } from '../datasources/VisitDataSource'; +import { VisitRegistrationClaimDataSource } from '../datasources/VisitRegistrationClaimDataSource'; import { Authorized } from '../decorators'; import { ApplicationEventBus } from '../events'; import { ApplicationEvent } from '../events/applicationEvents'; @@ -35,10 +38,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, @@ -62,8 +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.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, @@ -158,7 +166,85 @@ export default class InviteMutations { return invites; } - private async processRoleClaims(claimerUserId: number, invite: Invite) { + @Authorized() + public async setVisitRegistrationInvites( + agent: UserWithRole | null, + args: { visitId: number; emails: string[] } + ): Promise { + const { visitId, emails } = args; + + const hasWriteRights = + this.userAuth.isApiToken(agent) || + (await this.visitAuthorization.hasWriteRights(agent, visitId)); + + if (!hasWriteRights) { + return rejection( + 'User is not authorized to create invites for this visit' + ); + } + + const existingInvites = + await this.inviteDataSource.findVisitRegistrationInvites(visitId, false); + + const existingEmails = existingInvites.map((invite) => invite.email); + const deletedEmails = existingEmails.filter( + (email) => !emails.includes(email) + ); + const newEmails = emails.filter((email) => !existingEmails.includes(email)); + + const deletedInvites = existingInvites.filter((invite) => + deletedEmails.includes(invite.email) + ); + + await Promise.all( + deletedInvites.map((invite) => this.inviteDataSource.delete(invite.id)) + ); + + const expirationDate = await this.getInviteExpirationDate(); + + const newInvites = await Promise.all( + newEmails.map(async (email) => + this.inviteDataSource.create({ + createdByUserId: agent!.id, + code: await this.generateInviteCode(), + email: email, + expiresAt: expirationDate, + }) + ) + ); + await Promise.all( + newInvites.map(async (newInvite) => { + await this.visitRegistrationClaimDataSource.create( + newInvite.id, + visitId + ); + }) + ); + + const invites = [ + ...existingInvites.filter((invite) => !deletedInvites.includes(invite)), + ...newInvites, + ]; + + const { primaryKey: proposalPk } = + await this.proposalDataSource.getProposalByVisitId(visitId); + + await this.eventBus.publish({ + type: Event.PROPOSAL_VISIT_REGISTRATION_INVITES_UPDATED, + array: invites, + key: 'array', + loggedInUserId: agent?.id, + inputArgs: JSON.stringify(args), + impersonatingUserId: agent ? agent.impersonatingUserId : null, + proposalPKey: proposalPk, + } as ApplicationEvent); + + return invites; + } + private async processAcceptedRoleClaims( + claimerUserId: number, + invite: Invite + ) { const inviteId = invite.id; const roleClaims = await this.roleClaimDataSource.findByInviteId(inviteId); @@ -179,7 +265,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); @@ -210,6 +299,52 @@ export default class InviteMutations { }); } } + private async processAcceptedVisitRegistrationClaims( + claimerUserId: number, + invite: Invite + ) { + const inviteId = invite.id; + const claims = + await this.visitRegistrationClaimDataSource.findByInviteId(inviteId); + + for (const claim of claims) { + const existingRegistration = await this.visitDataSource.getRegistration( + claimerUserId, + claim.visitId + ); + + if (existingRegistration) { + return; + } + + // Insert the user into the visits_has_users table to create a new visit registration + await database('visits_has_users') + .insert({ + visit_id: claim.visitId, + user_id: claimerUserId, + registration_questionary_id: null, + starts_at: null, + ends_at: null, + // status will default to 'DRAFTED' as defined in the database schema + }) + .onConflict(['user_id', 'visit_id']) + .ignore(); + + const proposal = await this.proposalDataSource.getProposalByVisitId( + claim.visitId + ); + + this.eventBus.publish({ + type: Event.PROPOSAL_VISIT_REGISTRATION_INVITE_ACCEPTED, + isRejection: false, + key: 'proposal', + loggedInUserId: claimerUserId, + invite: invite, + description: `User with ID ${claimerUserId} accepted visit invite`, + proposalPKey: proposal.primaryKey, + }); + } + } private async proposalHasUser(proposalPk: number, userId: number) { const proposalUsers = 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 8bb498d4d3..531b1f0a05 100644 --- a/apps/backend/src/queries/InviteQueries.ts +++ b/apps/backend/src/queries/InviteQueries.ts @@ -2,6 +2,7 @@ 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 { InviteDataSource } from '../datasources/InviteDataSource'; import { Authorized } from '../decorators'; @@ -14,7 +15,8 @@ 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 ) {} @Authorized() @@ -34,4 +36,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/mutations/CreateVisitMutation.ts b/apps/backend/src/resolvers/mutations/CreateVisitMutation.ts index 9e569d6824..2db70eabdf 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, + emails: args.inviteEmails ?? [], + }); + + return visit; } } diff --git a/apps/backend/src/resolvers/mutations/UpdateVisitMutation.ts b/apps/backend/src/resolvers/mutations/UpdateVisitMutation.ts index cb805e70ec..304ec46859 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, + emails: args.inviteEmails ?? [], + }); + + return result; } } diff --git a/apps/backend/src/resolvers/types/Visit.ts b/apps/backend/src/resolvers/types/Visit.ts index 5534f54007..b089e37ee3 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 registrationInvites( + @Root() visit: Visit, + @Ctx() context: ResolverContext + ): Promise { + return context.queries.invite.getVisitRegistrationInvites( + context.user, + visit.id + ); + } + @FieldResolver(() => BasicUserDetails) async teamLead( @Root() visit: Visit, diff --git a/apps/backend/src/resolvers/types/VisitRegistrationClaim.ts b/apps/backend/src/resolvers/types/VisitRegistrationClaim.ts new file mode 100644 index 0000000000..32c26d2809 --- /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/VisitRegistrationClaim'; + +@ObjectType() +export class VisitRegistrationClaim + implements Partial +{ + @Field(() => Int) + public userId: number; + + @Field(() => Int) + public visitId: number; +} diff --git a/apps/e2e/cypress/e2e/invites.cy.ts b/apps/e2e/cypress/e2e/invites.cy.ts index be7e4b1f46..329da96baa 100644 --- a/apps/e2e/cypress/e2e/invites.cy.ts +++ b/apps/e2e/cypress/e2e/invites.cy.ts @@ -243,7 +243,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(); } @@ -256,13 +256,25 @@ 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', initialDBData.users.user3.lastName ); + cy.logout(); + + cy.login('officer', initialDBData.roles.userOfficer); + cy.visit('/'); }); + 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' + ); + cy.get('[data-cy="event-logs-table"]').contains( + 'PROPOSAL_CO_PROPOSER_INVITE_ACCEPTED' + ); }); }); diff --git a/apps/frontend/src/components/proposal/Participants.tsx b/apps/frontend/src/components/proposal/Participants.tsx new file mode 100644 index 0000000000..c472d7fc64 --- /dev/null +++ b/apps/frontend/src/components/proposal/Participants.tsx @@ -0,0 +1,47 @@ +import React, { useContext } from 'react'; + +import UserManagementTable, { + UserManagementTableProps, +} from 'components/common/UserManagementTable'; +import { FeatureContext } from 'context/FeatureContextProvider'; +import { BasicUserDetails, FeatureId } from 'generated/sdk'; +import { BasicUserData } from 'hooks/user/useUserData'; + +type ProposalParticipantsProps = Omit< + UserManagementTableProps, + 'onUserAction' | 'excludeUserIds' | 'disabled' +> & { + principalInvestigator?: BasicUserData | null; + setPrincipalInvestigator?: (user: BasicUserDetails) => void; + loadingPrincipalInvestigator?: boolean; +}; + +const ProposalParticipants = ({ + principalInvestigator, + setPrincipalInvestigator, + loadingPrincipalInvestigator, + ...props +}: ProposalParticipantsProps) => { + const handleUserAction = (action: string, user: BasicUserDetails) => { + if (action === 'setPrincipalInvestigator' && setPrincipalInvestigator) { + setPrincipalInvestigator(user); + } + }; + + const featureContext = useContext(FeatureContext); + const allowInviteByEmail = !!featureContext.featuresMap.get( + FeatureId.EMAIL_INVITE + )?.isEnabled; + + return ( + + ); +}; + +export default ProposalParticipants; diff --git a/apps/frontend/src/components/proposalBooking/CreateUpdateVisit.tsx b/apps/frontend/src/components/proposalBooking/CreateUpdateVisit.tsx index e2838153ad..38f70bd478 100644 --- a/apps/frontend/src/components/proposalBooking/CreateUpdateVisit.tsx +++ b/apps/frontend/src/components/proposalBooking/CreateUpdateVisit.tsx @@ -1,6 +1,6 @@ import { Button, Typography } from '@mui/material'; import { Form, Formik } from 'formik'; -import React, { useContext } from 'react'; +import React, { useContext, useState } from 'react'; import * as Yup from 'yup'; import { ActionButtonContainer } from 'components/common/ActionButtonContainer'; @@ -8,7 +8,7 @@ import ErrorMessage from 'components/common/ErrorMessage'; import FormikUIAutocomplete from 'components/common/FormikUIAutocomplete'; import UserManagementTable from 'components/common/UserManagementTable'; import { FeatureContext } from 'context/FeatureContextProvider'; -import { BasicUserDetails, FeatureId } from 'generated/sdk'; +import { BasicUserDetails, FeatureId, Invite } from 'generated/sdk'; import { UserExperiment } from 'hooks/experiment/useUserExperiments'; import useDataApiWithFeedback from 'utils/useDataApiWithFeedback'; import { getFullUserName } from 'utils/user'; @@ -19,12 +19,16 @@ interface CreateUpdateVisitProps { } function CreateUpdateVisit({ event, close }: CreateUpdateVisitProps) { const { api } = useDataApiWithFeedback(); + const [visitInvites, setVisitInvites] = useState( + event.visit?.registrationInvites || [] + ); const { visit } = event; const initialValues = { team: visit?.registrations.map((registration) => registration.user!) || [], teamLeadUserId: visit?.teamLead.id || null, + inviteEmails: visit?.registrationInvites || [], }; const featureContext = useContext(FeatureContext); @@ -51,6 +55,7 @@ function CreateUpdateVisit({ event, close }: CreateUpdateVisitProps) { .includes(teamLeadUserId); }, }), + inviteEmails: Yup.array().default([]), })} onSubmit={async (values): Promise => { if (visit) { @@ -59,6 +64,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) { @@ -87,8 +93,8 @@ function CreateUpdateVisit({ event, close }: CreateUpdateVisitProps) { {}} - invites={[]} + setInvites={setVisitInvites} + invites={visitInvites} setUsers={(team: BasicUserDetails[]) => { setFieldValue('team', team); }} diff --git a/apps/frontend/src/components/review/ProposalReviewContent.tsx b/apps/frontend/src/components/review/ProposalReviewContent.tsx index f8234a8dd7..dd61f3db4c 100644 --- a/apps/frontend/src/components/review/ProposalReviewContent.tsx +++ b/apps/frontend/src/components/review/ProposalReviewContent.tsx @@ -289,6 +289,7 @@ const ProposalReviewContent = ({ t(name))} isInsideModal={isInsideModal} + data-cy="proposal-review-tabs" > {tabsContent} 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 {