diff --git a/apps/backend/src/auth/DLSUserAuthorization.ts b/apps/backend/src/auth/DLSUserAuthorization.ts new file mode 100644 index 0000000000..f82f968e62 --- /dev/null +++ b/apps/backend/src/auth/DLSUserAuthorization.ts @@ -0,0 +1,83 @@ +import 'reflect-metadata'; + +import { logger } from '@user-office-software/duo-logger'; +import { OpenIdClient } from '@user-office-software/openid'; +import { ValidTokenSet } from '@user-office-software/openid/lib/model/ValidTokenSet'; +import { ValidUserInfo } from '@user-office-software/openid/lib/model/ValidUserInfo'; + +import { OAuthAuthorization } from './OAuthAuthorization'; +import { User, UserRole } from '../models/User'; +import { GetOrCreateInstitutionInput } from '../resolvers/mutations/UpsertUserMutation'; + +export class DLSUserAuthorization extends OAuthAuthorization { + constructor() { + super(); + } + + protected async upsertUser( + userInfo: ValidUserInfo, + tokenSet: ValidTokenSet + ): Promise { + const client = await OpenIdClient.getInstance(); + let institutionInput: GetOrCreateInstitutionInput = null; + if (userInfo.institution_ror_id) { + institutionInput = userInfo.institution_ror_id as string; + } else if (userInfo.institution_name && userInfo.institution_country) { + institutionInput = { + country: userInfo.institution_country as string, + name: userInfo.institution_name as string, + }; + } + + const userId = this.getUniqueId(userInfo); + const userWithOAuthSubMatch = + await this.userDataSource.getByOIDCSub(userId); + + const userWithEmailMatch = await this.userDataSource.getByEmail( + userInfo.email + ); + + const user = userWithOAuthSubMatch ?? userWithEmailMatch; + + if (user) { + const updatedUser = await this.userDataSource.update({ + ...user, + email: userInfo.email, + oauthIssuer: client.issuer.metadata.issuer, + oauthRefreshToken: tokenSet.refresh_token ?? '', + oidcSub: userId, + }); + + return updatedUser; + } else { + const institution = + await this.getOrCreateUserInstitution(institutionInput); + const newUser = await this.userDataSource.create( + (userInfo.title as string) ?? 'unspecified', + userInfo.given_name, + userInfo.family_name, + userInfo.given_name ?? '', // Using given_name as preferred_name from the oauth provider is a federation id + userId, + tokenSet.refresh_token ?? '', + client.issuer.metadata.issuer, + institution?.id ?? 1, + userInfo.email + ); + + const roleID = this.getUserRole(newUser); + + await this.userDataSource.addUserRole({ + userID: newUser.id, + roleID, + }); + + if (roleID === UserRole.USER_OFFICER) { + logger.logInfo('Initial User Officer created', { + email: newUser.email, + }); + } + + return newUser; + } + } +} diff --git a/apps/backend/src/auth/OAuthAuthorization.ts b/apps/backend/src/auth/OAuthAuthorization.ts index 619bf2e132..5b1fae0641 100644 --- a/apps/backend/src/auth/OAuthAuthorization.ts +++ b/apps/backend/src/auth/OAuthAuthorization.ts @@ -162,7 +162,7 @@ export class OAuthAuthorization extends UserAuthorization { return institution; } - private async upsertUser( + protected async upsertUser( userInfo: ValidUserInfo, tokenSet: ValidTokenSet ): Promise { @@ -233,7 +233,7 @@ export class OAuthAuthorization extends UserAuthorization { } } - private getUserRole(newUser: { id: number; email: string }): UserRole { + protected getUserRole(newUser: { id: number; email: string }): UserRole { const roleID = env.INITIAL_USER_OFFICER_EMAIL && newUser.email === env.INITIAL_USER_OFFICER_EMAIL diff --git a/apps/backend/src/config/dependencyConfigDLS.ts b/apps/backend/src/config/dependencyConfigDLS.ts new file mode 100644 index 0000000000..00554eaf2f --- /dev/null +++ b/apps/backend/src/config/dependencyConfigDLS.ts @@ -0,0 +1,145 @@ +import { ConsoleLogger, setLogger } from '@user-office-software/duo-logger'; + +import 'reflect-metadata'; +import { Tokens } from './Tokens'; +import { DataAccessUsersAuthorization } from '../auth/DataAccessUsersAuthorization'; +import { DLSUserAuthorization } from '../auth/DLSUserAuthorization'; +import { ProposalAuthorization } from '../auth/ProposalAuthorization'; +import { VisitAuthorization } from '../auth/VisitAuthorization'; +import { VisitRegistrationAuthorization } from '../auth/VisitRegistrationAuthorization'; +import { configureDLSEnvironment } from './dls/configureDLSEnvironment'; +import { mapClass, mapValue } from './utils'; +import { PostgresAdminDataSourceWithAutoUpgrade } from '../datasources/postgres/AdminDataSource'; +import PostgresCallDataSource from '../datasources/postgres/CallDataSource'; +import PostgresCoProposerClaimDataSource from '../datasources/postgres/CoProposerClaimDataSource'; +import PostgresDataAccessUsersDataSource from '../datasources/postgres/DataAccessUsersDataSource'; +import PostgresEmailTemplateDataSource from '../datasources/postgres/EmailTemplateDataSource'; +import PostgresEventLogsDataSource from '../datasources/postgres/EventLogsDataSource'; +import PostgresExperimentDataSource from '../datasources/postgres/ExperimentDataSource'; +import PostgresExperimentSafetyPdfTemplateDataSource from '../datasources/postgres/ExperimentSafetyPdfTemplateDataSource'; +import PostgresFapDataSource from '../datasources/postgres/FapDataSource'; +import PostgresFeedbackDataSource from '../datasources/postgres/FeedbackDataSource'; +import PostgresFileDataSource from '../datasources/postgres/FileDataSource'; +import PostgresGenericTemplateDataSource from '../datasources/postgres/GenericTemplateDataSource'; +import PostgresInstrumentDataSource from '../datasources/postgres/InstrumentDataSource'; +import PostgresInternalReviewDataSource from '../datasources/postgres/InternalReviewDataSource'; +import PostgresInviteDataSource from '../datasources/postgres/InviteDataSource'; +import PostgresPredefinedMessageDataSource from '../datasources/postgres/PredefinedMessageDataSource'; +import PostgresProposalDataSource from '../datasources/postgres/ProposalDataSource'; +import PostgresProposalInternalCommentsDataSource from '../datasources/postgres/ProposalInternalCommentsDataSource'; +import PostgresProposalPdfTemplateDataSource from '../datasources/postgres/ProposalPdfTemplateDataSource'; +import PostgresQuestionaryDataSource from '../datasources/postgres/QuestionaryDataSource'; +import PostgresReviewDataSource from '../datasources/postgres/ReviewDataSource'; +import PostgresRoleClaimDataSource from '../datasources/postgres/RoleClaimsDataSource'; +import PostgresRoleDataSource from '../datasources/postgres/RoleDataSource'; +import PostgresSampleDataSource from '../datasources/postgres/SampleDataSource'; +import PostgresShipmentDataSource from '../datasources/postgres/ShipmentDataSource'; +import PostgresStatusActionsDataSource from '../datasources/postgres/StatusActionsDataSource'; +import StatusActionsLogsDataSource from '../datasources/postgres/StatusActionsLogsDataSource'; +import PostgresStatusDataSource from '../datasources/postgres/StatusDataSource'; +import PostgresSystemDataSource from '../datasources/postgres/SystemDataSource'; +import PostgresTagDataSource from '../datasources/postgres/TagDataSource'; +import PostgresTechniqueDataSource from '../datasources/postgres/TechniqueDataSource'; +import PostgresTemplateDataSource from '../datasources/postgres/TemplateDataSource'; +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 { DLSEmailHandler } from '../eventHandlers/email/DLS/DLSEmailHandler'; +import createLoggingHandler from '../eventHandlers/logging'; +import { SMTPMailService } from '../eventHandlers/MailService/SMTP/SMTPMailService'; +import { + createListenToRabbitMQHandler, + createPostToRabbitMQHandler, +} from '../eventHandlers/messageBroker'; +import { createApplicationEventBus } from '../events'; +import { FapDataColumns } from '../factory/xlsx/FapDataColumns'; +import { + callFapPopulateRow, + getDataRow, + populateRow, +} from '../factory/xlsx/FapDataRow'; +import BasicUserDetailsLoader from '../loaders/BasicUserDetailsLoader'; +import { SkipAssetRegistrar } from '../services/assetRegistrar/skip/SkipAssetRegistrar'; + +mapClass(Tokens.AdminDataSource, PostgresAdminDataSourceWithAutoUpgrade); +mapClass(Tokens.CoProposerClaimDataSource, PostgresCoProposerClaimDataSource); +mapClass(Tokens.DataAccessUsersDataSource, PostgresDataAccessUsersDataSource); +mapClass(Tokens.CallDataSource, PostgresCallDataSource); +mapClass(Tokens.EventLogsDataSource, PostgresEventLogsDataSource); +mapClass(Tokens.FeedbackDataSource, PostgresFeedbackDataSource); +mapClass(Tokens.FileDataSource, PostgresFileDataSource); +mapClass(Tokens.GenericTemplateDataSource, PostgresGenericTemplateDataSource); +mapClass(Tokens.InstrumentDataSource, PostgresInstrumentDataSource); +mapClass(Tokens.InviteDataSource, PostgresInviteDataSource); +mapClass(Tokens.RoleDataSource, PostgresRoleDataSource); +mapClass(Tokens.RoleClaimDataSource, PostgresRoleClaimDataSource); +mapClass(Tokens.InternalReviewDataSource, PostgresInternalReviewDataSource); +mapClass( + Tokens.ProposalPdfTemplateDataSource, + PostgresProposalPdfTemplateDataSource +); + +mapClass( + Tokens.ExperimentSafetyPdfTemplateDataSource, + PostgresExperimentSafetyPdfTemplateDataSource +); +mapClass(Tokens.ProposalDataSource, PostgresProposalDataSource); +mapClass( + Tokens.ProposalInternalCommentsDataSource, + PostgresProposalInternalCommentsDataSource +); +mapClass(Tokens.StatusActionsDataSource, PostgresStatusActionsDataSource); +mapClass(Tokens.QuestionaryDataSource, PostgresQuestionaryDataSource); +mapClass(Tokens.ReviewDataSource, PostgresReviewDataSource); +mapClass(Tokens.FapDataSource, PostgresFapDataSource); +mapClass(Tokens.SampleDataSource, PostgresSampleDataSource); +mapClass(Tokens.ShipmentDataSource, PostgresShipmentDataSource); +mapClass(Tokens.SystemDataSource, PostgresSystemDataSource); +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( + Tokens.PredefinedMessageDataSource, + PostgresPredefinedMessageDataSource +); +mapClass(Tokens.StatusActionsLogsDataSource, StatusActionsLogsDataSource); +mapClass(Tokens.WorkflowDataSource, PostgresWorkflowDataSource); +mapClass(Tokens.StatusDataSource, PostgresStatusDataSource); +mapClass(Tokens.ExperimentDataSource, PostgresExperimentDataSource); +mapClass(Tokens.TagDataSource, PostgresTagDataSource); + +mapClass(Tokens.UserAuthorization, DLSUserAuthorization); +mapClass(Tokens.ProposalAuthorization, ProposalAuthorization); +mapClass(Tokens.DataAccessUsersAuthorization, DataAccessUsersAuthorization); + +mapClass(Tokens.AssetRegistrar, SkipAssetRegistrar); + +mapClass(Tokens.MailService, SMTPMailService); + +mapValue(Tokens.FapDataColumns, FapDataColumns); +mapValue(Tokens.FapDataRow, getDataRow); +mapValue(Tokens.PopulateRow, populateRow); +mapValue(Tokens.PopulateCallRow, callFapPopulateRow); + +mapValue(Tokens.EmailEventHandler, DLSEmailHandler); +mapClass(Tokens.EmailTemplateDataSource, PostgresEmailTemplateDataSource); + +mapValue(Tokens.PostToMessageQueue, createPostToRabbitMQHandler()); +mapValue(Tokens.LoggingHandler, createLoggingHandler()); +mapValue(Tokens.EventBus, createApplicationEventBus()); +mapValue(Tokens.ListenToMessageQueue, createListenToRabbitMQHandler()); + +mapValue(Tokens.ConfigureEnvironment, configureDLSEnvironment); +mapValue(Tokens.ConfigureLogger, () => setLogger(new ConsoleLogger())); + +mapClass(Tokens.BasicUserDetailsLoader, BasicUserDetailsLoader); diff --git a/apps/backend/src/config/dls/configureDLSEnvironment.ts b/apps/backend/src/config/dls/configureDLSEnvironment.ts new file mode 100644 index 0000000000..cff8fe28e9 --- /dev/null +++ b/apps/backend/src/config/dls/configureDLSEnvironment.ts @@ -0,0 +1,154 @@ +import { container } from 'tsyringe'; + +import { AdminDataSource } from '../../datasources/AdminDataSource'; +import { FeatureId } from '../../models/Feature'; +import { SettingsId } from '../../models/Settings'; +import { setTimezone, setDateTimeFormats } from '../setTimezoneAndFormat'; +import { Tokens } from '../Tokens'; +import { updateOIDCSettings } from '../updateOIDCSettings'; + +function getBaseURL() { + let url = process.env.BASE_URL || 'https://uos.diamond.ac.uk'; + if (url.endsWith('/')) { + url = url.slice(0, -1); + } + + return url; +} + +async function setDLSColourTheme() { + const db = container.resolve(Tokens.AdminDataSource); + + await db.waitForDBUpgrade(); + + await Promise.all([ + db.updateSettings({ + settingsId: SettingsId.PALETTE_PRIMARY_DARK, + settingsValue: '#202945', + }), + db.updateSettings({ + settingsId: SettingsId.PALETTE_PRIMARY_MAIN, + settingsValue: '#202945', + }), + db.updateSettings({ + settingsId: SettingsId.PALETTE_PRIMARY_LIGHT, + settingsValue: '#202945', + }), + db.updateSettings({ + settingsId: SettingsId.PALETTE_PRIMARY_ACCENT, + settingsValue: '#000000', + }), + db.updateSettings({ + settingsId: SettingsId.PALETTE_PRIMARY_CONTRAST, + settingsValue: '#ffffff', + }), + db.updateSettings({ + settingsId: SettingsId.PALETTE_SECONDARY_DARK, + settingsValue: '#202945', + }), + db.updateSettings({ + settingsId: SettingsId.PALETTE_SECONDARY_MAIN, + settingsValue: '#202945', + }), + db.updateSettings({ + settingsId: SettingsId.PALETTE_SECONDARY_LIGHT, + settingsValue: '#202945', + }), + db.updateSettings({ + settingsId: SettingsId.PALETTE_SECONDARY_CONTRAST, + settingsValue: '#ffffff', + }), + db.updateSettings({ + settingsId: SettingsId.PALETTE_ERROR_MAIN, + settingsValue: '#bd0000ff', + }), + db.updateSettings({ + settingsId: SettingsId.PALETTE_SUCCESS_MAIN, + settingsValue: '#14ac00ff', + }), + db.updateSettings({ + settingsId: SettingsId.PALETTE_WARNING_MAIN, + settingsValue: '#ceb902ff', + }), + db.updateSettings({ + settingsId: SettingsId.PALETTE_INFO_MAIN, + settingsValue: '#202945', + }), + db.updateSettings({ + settingsId: SettingsId.HEADER_LOGO_FILENAME, + settingsValue: 'diamond-white.svg', + }), + ]); +} + +async function enableDefaultDLSFeatures() { + const db = container.resolve(Tokens.AdminDataSource); + + await Promise.all([ + db.setFeatures( + [ + FeatureId.PREGENERATED_PROPOSAL_PDF, + FeatureId.OAUTH, + FeatureId.RISK_ASSESSMENT, + FeatureId.INSTRUMENT_MANAGEMENT, + FeatureId.TECHNICAL_REVIEW, + FeatureId.USER_MANAGEMENT, + FeatureId.FAP_REVIEW, + FeatureId.USER_SEARCH_FILTER, + FeatureId.CONFLICT_OF_INTEREST_WARNING, + FeatureId.EXPERIMENT_SAFETY_REVIEW, + FeatureId.EMAIL_INVITE, + ], + true + ), + db.setFeatures( + [ + FeatureId.EMAIL_SEARCH, + FeatureId.SCHEDULER, + FeatureId.SHIPPING, + FeatureId.VISIT_MANAGEMENT, + FeatureId.TECHNIQUE_PROPOSALS, + FeatureId.TAGS, + FeatureId.STFC_IDLE_TIMER, + FeatureId.DATA_ACCESS_USERS, + ], + false + ), + db.updateSettings({ + settingsId: SettingsId.DISPLAY_PRIVACY_STATEMENT_LINK, + settingsValue: 'true', + }), + db.updateSettings({ + settingsId: SettingsId.DEFAULT_INST_SCI_REVIEWER_FILTER, + settingsValue: 'ME', + }), + db.updateSettings({ + settingsId: SettingsId.DEFAULT_INST_SCI_STATUS_FILTER, + settingsValue: 'FEASIBILITY_REVIEW', + }), + db.updateSettings({ + settingsId: SettingsId.INVITE_VALIDITY_PERIOD_DAYS, + settingsValue: '180', + }), + db.updateSettings({ + settingsId: SettingsId.DISPLAY_FAQ_LINK, + settingsValue: 'true', + }), + db.updateSettings({ + settingsId: SettingsId.PROFILE_PAGE_LINK, + settingsValue: 'https://uas.diamond.ac.uk/uas/#PersonalDetailsPlace:', + }), + ]); +} + +async function configureDLSEnvironment() { + await Promise.all([ + setDLSColourTheme(), + enableDefaultDLSFeatures(), + setTimezone(), + setDateTimeFormats(), + updateOIDCSettings(), + ]); +} + +export { configureDLSEnvironment, getBaseURL }; diff --git a/apps/backend/src/config/index.ts b/apps/backend/src/config/index.ts index 38705d57d1..31aa979875 100644 --- a/apps/backend/src/config/index.ts +++ b/apps/backend/src/config/index.ts @@ -15,6 +15,9 @@ switch (process.env.DEPENDENCY_CONFIG) { case 'eli': require('./dependencyConfigELI'); break; + case 'dls': + require('./dependencyConfigDLS'); + break; case 'test': require('./dependencyConfigTest'); break; diff --git a/apps/backend/src/datasources/mockups/EmailTemplateDataSource.ts b/apps/backend/src/datasources/mockups/EmailTemplateDataSource.ts index 15c4b71f10..2a32b2a557 100644 --- a/apps/backend/src/datasources/mockups/EmailTemplateDataSource.ts +++ b/apps/backend/src/datasources/mockups/EmailTemplateDataSource.ts @@ -103,6 +103,24 @@ export class EmailTemplateDataSourceMock implements EmailTemplateDataSource { 'Welcome to Our Service', 'Hello, thank you for signing up for our service. We are excited to have you on board!' ), + new EmailTemplate( + 10, + 1, + EmailTemplateId.PROPOSAL_SUBMITTED, + 'This is a dummy email template for testing purposes.', + false, + 'Proposal Submitted', + 'Proposal submitted body text' + ), + new EmailTemplate( + 11, + 1, + EmailTemplateId.CO_PROPOSER_INVITE, + 'This is a dummy email template for testing purposes.', + false, + 'Co-Proposer Invite', + 'You have been invited to be a co-proposer on a proposal.' + ), ]; } diff --git a/apps/backend/src/datasources/mockups/UserDataSource.ts b/apps/backend/src/datasources/mockups/UserDataSource.ts index bf461da975..a3c92032ee 100644 --- a/apps/backend/src/datasources/mockups/UserDataSource.ts +++ b/apps/backend/src/datasources/mockups/UserDataSource.ts @@ -414,7 +414,7 @@ export class UserDataSourceMock implements UserDataSource { return 1; } async getProposalUsersFull(proposalPk: number): Promise { - throw new Error('Method not implemented.'); + return [dummyUser]; } async getBasicUserInfo(id: number): Promise { if (id === dummyUser.id) { diff --git a/apps/backend/src/eventHandlers/email/DLS/DLSEmailHandler.spec.ts b/apps/backend/src/eventHandlers/email/DLS/DLSEmailHandler.spec.ts new file mode 100644 index 0000000000..269e036fa2 --- /dev/null +++ b/apps/backend/src/eventHandlers/email/DLS/DLSEmailHandler.spec.ts @@ -0,0 +1,119 @@ +import { faker } from '@faker-js/faker'; +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { DLSEmailHandler } from './DLSEmailHandler'; +import { Tokens } from '../../../config/Tokens'; +import { GetInvitesFilter } from '../../../datasources/InviteDataSource'; +import { CallDataSourceMock } from '../../../datasources/mockups/CallDataSource'; +import { EmailTemplateDataSourceMock } from '../../../datasources/mockups/EmailTemplateDataSource'; +import { InstrumentDataSourceMock } from '../../../datasources/mockups/InstrumentDataSource'; +import { InviteDataSourceMock } from '../../../datasources/mockups/InviteDataSource'; +import { ProposalDataSourceMock } from '../../../datasources/mockups/ProposalDataSource'; +import { QuestionaryDataSourceMock } from '../../../datasources/mockups/QuestionaryDataSource'; +import { ApplicationEvent } from '../../../events/applicationEvents'; +import { Event } from '../../../events/event.enum'; +import { AnswerBasic } from '../../../models/Questionary'; +import { EmailTemplateId } from '../emailTemplateId'; + +// Mock MailService +const mockMailService = { + sendMail: jest.fn(), +}; + +describe('DLSEmailHandler', () => { + let emailTemplateDataSourceMock: EmailTemplateDataSourceMock; + let proposalDataSourceMock: ProposalDataSourceMock; + let instrumentDataSourceMock: InstrumentDataSourceMock; + let callDataSourceMock: CallDataSourceMock; + let questionaryDataSourceMock: QuestionaryDataSourceMock; + let inviteDataSourceMock: InviteDataSourceMock; + + beforeAll(() => { + container.registerInstance(Tokens.MailService, mockMailService); + }); + + beforeEach(() => { + emailTemplateDataSourceMock = + container.resolve( + Tokens.EmailTemplateDataSource + ); + proposalDataSourceMock = container.resolve( + Tokens.ProposalDataSource + ); + instrumentDataSourceMock = container.resolve( + Tokens.InstrumentDataSource + ); + callDataSourceMock = container.resolve( + Tokens.CallDataSource + ); + questionaryDataSourceMock = container.resolve( + Tokens.QuestionaryDataSource + ); + inviteDataSourceMock = container.resolve( + Tokens.InviteDataSource + ); + emailTemplateDataSourceMock.init(); + proposalDataSourceMock.init(); + questionaryDataSourceMock.init(); + inviteDataSourceMock.init(); + + jest.spyOn(questionaryDataSourceMock, 'getAnswer').mockResolvedValue({ + answer: { + value: [ + { + instrumentId: 1, + timeRequested: 1, + }, + ], + }, + } as AnswerBasic); + + // Reset mock + mockMailService.sendMail.mockClear(); + mockMailService.sendMail.mockResolvedValue({ success: true }); + }); + + const emails = [ + [Event.PROPOSAL_SUBMITTED, EmailTemplateId.PROPOSAL_SUBMITTED], + [ + Event.PROPOSAL_CO_PROPOSER_INVITES_UPDATED, + EmailTemplateId.CO_PROPOSER_INVITE, + ], + ]; + + test.each(emails)( + 'Given event type "%s", should use template "%s"', + async (event, templateId) => { + const mockEvent: ApplicationEvent = { + type: event, + proposal: { + primaryKey: 1, + title: faker.lorem.sentence(), + proposerId: 1, + proposalId: faker.string.alphanumeric(), + callId: 1, + submittedDate: new Date(), + }, + isRejection: false, + array: await inviteDataSourceMock.getInvites({} as GetInvitesFilter), + proposalPKey: 1, + key: 'test', + loggedInUserId: 1, + } as ApplicationEvent; + + const expectedEmailTemplate = + await emailTemplateDataSourceMock.getEmailTemplateByName(templateId); + + await DLSEmailHandler(mockEvent); + + expect(mockMailService.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + content: { + template: expectedEmailTemplate?.id.toString(), + }, + }) + ); + } + ); +}); diff --git a/apps/backend/src/eventHandlers/email/DLS/DLSEmailHandler.ts b/apps/backend/src/eventHandlers/email/DLS/DLSEmailHandler.ts new file mode 100644 index 0000000000..8d3db0fe14 --- /dev/null +++ b/apps/backend/src/eventHandlers/email/DLS/DLSEmailHandler.ts @@ -0,0 +1,24 @@ +import { proposalCoProposerInvitesUpdatedHandler } from './proposalCoProposerInvitesUpdatedHandler'; +import { proposalSubmittedHandler } from './proposalSubmittedHandler'; +import { ApplicationEvent } from '../../../events/applicationEvents'; +import { Event } from '../../../events/event.enum'; + +export async function DLSEmailHandler(event: ApplicationEvent) { + const handlers: Partial< + Record Promise> + > = { + [Event.PROPOSAL_SUBMITTED]: proposalSubmittedHandler, + [Event.PROPOSAL_CO_PROPOSER_INVITES_UPDATED]: + proposalCoProposerInvitesUpdatedHandler, + }; + + if (event.isRejection) return; + + const handler = handlers[event.type]; + + if (!handler) { + throw new Error(`No handler for event type ${event.type}`); + } + + return await handler(event); +} diff --git a/apps/backend/src/eventHandlers/email/DLS/proposalCoProposerInvitesUpdatedHandler.ts b/apps/backend/src/eventHandlers/email/DLS/proposalCoProposerInvitesUpdatedHandler.ts new file mode 100644 index 0000000000..fb13ea2edf --- /dev/null +++ b/apps/backend/src/eventHandlers/email/DLS/proposalCoProposerInvitesUpdatedHandler.ts @@ -0,0 +1,96 @@ +import { logger } from '@user-office-software/duo-logger'; +import { container } from 'tsyringe'; + +import { Tokens } from '../../../config/Tokens'; +import { EmailTemplateDataSource } from '../../../datasources/EmailTemplateDataSource'; +import { InviteDataSource } from '../../../datasources/InviteDataSource'; +import { UserDataSource } from '../../../datasources/UserDataSource'; +import { ApplicationEvent } from '../../../events/applicationEvents'; +import { Event } from '../../../events/event.enum'; +import { EventBus } from '../../../events/eventBus'; +import { MailService } from '../../MailService/MailService'; +import { EmailTemplateId } from '../emailTemplateId'; + +export async function proposalCoProposerInvitesUpdatedHandler( + event: ApplicationEvent +) { + if (event.type != Event.PROPOSAL_CO_PROPOSER_INVITES_UPDATED) return; + + const userDataSource = container.resolve( + Tokens.UserDataSource + ); + const mailService = container.resolve(Tokens.MailService); + const eventBus = container.resolve>( + Tokens.EventBus + ); + const emailTemplateDataSource = container.resolve( + Tokens.EmailTemplateDataSource + ); + const inviteDataSource = container.resolve( + Tokens.InviteDataSource + ); + + const template = EmailTemplateId.CO_PROPOSER_INVITE; + const emailTemplate = + await emailTemplateDataSource.getEmailTemplateByName(template); + if (!emailTemplate) { + throw new Error('Email template not found: ' + template); + } + + for (const invite of event.array) { + 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 options = { + content: { + template: emailTemplate.id.toString(), + }, + substitution_data: { + sender: inviter.preferredname + ' ' + inviter.lastname, + redeem_code: invite.code, + uos_instance: process.env.BASE_URL, + }, + recipients: [{ address: invite.email }], + }; + + mailService + .sendMail(options) + .then(async (res: any) => { + logger.logInfo('Emails sent on proposal invite:', { + result: res, + event, + }); + + await inviteDataSource.update({ + id: invite.id, + isEmailSent: true, + templateId: template, + }); + + await eventBus.publish({ + ...event, + type: Event.PROPOSAL_CO_PROPOSER_INVITE_SENT, + invite, + }); + }) + .catch((err: string) => { + logger.logError('Could not send email(s) on proposal invite:', { + error: err, + event, + }); + }); + } +} diff --git a/apps/backend/src/eventHandlers/email/DLS/proposalSubmittedHandler.ts b/apps/backend/src/eventHandlers/email/DLS/proposalSubmittedHandler.ts new file mode 100644 index 0000000000..414acb4222 --- /dev/null +++ b/apps/backend/src/eventHandlers/email/DLS/proposalSubmittedHandler.ts @@ -0,0 +1,182 @@ +import { logger } from '@user-office-software/duo-logger'; +import { container } from 'tsyringe'; + +import { getBaseURL } from '../../../config/dls/configureDLSEnvironment'; +import { Tokens } from '../../../config/Tokens'; +import { CallDataSource } from '../../../datasources/CallDataSource'; +import { EmailTemplateDataSource } from '../../../datasources/EmailTemplateDataSource'; +import { InstrumentDataSource } from '../../../datasources/InstrumentDataSource'; +import { QuestionaryDataSource } from '../../../datasources/QuestionaryDataSource'; +import { UserDataSource } from '../../../datasources/UserDataSource'; +import { ApplicationEvent } from '../../../events/applicationEvents'; +import { Event } from '../../../events/event.enum'; +import { MailService } from '../../MailService/MailService'; +import { EmailTemplateId } from '../emailTemplateId'; + +export async function proposalSubmittedHandler(event: ApplicationEvent) { + if (event.type != Event.PROPOSAL_SUBMITTED) return; + + const userDataSource = container.resolve( + Tokens.UserDataSource + ); + const callDataSource = container.resolve( + Tokens.CallDataSource + ); + const mailService = container.resolve(Tokens.MailService); + const instrumentSource = container.resolve( + Tokens.InstrumentDataSource + ); + const questionaryDataSource = container.resolve( + Tokens.QuestionaryDataSource + ); + + const emailTemplateDataSource = container.resolve( + Tokens.EmailTemplateDataSource + ); + + const principalInvestigator = await userDataSource.getUser( + event.proposal.proposerId + ); + if (!principalInvestigator) { + return; + } + + const participants = await userDataSource.getProposalUsersFull( + event.proposal.primaryKey + ); + + const call = await callDataSource.getCall(event.proposal.callId); + if (!call) { + return; + } + + const workflow = await callDataSource.getProposalWorkflowByCall( + event.proposal.callId + ); + + const instruments = await instrumentSource.getInstrumentsByProposalPk( + event.proposal.primaryKey + ); + + // Postgres implementation doesn't match interface - impliementation wants questionaryId, not proposalId + const answer = await questionaryDataSource.getAnswer( + event.proposal.questionaryId, + 'instrument_picker' + ); + + ( + answer?.answer as { + value: { instrumentId: number; timeRequested: number }[]; + } + )?.value.forEach((instrumentAnswer: any) => { + const instrument = instruments.find( + (inst) => inst.id === Number(instrumentAnswer.instrumentId) + ); + if (instrument) { + instrument.managementTimeAllocation = instrumentAnswer.timeRequested || 0; + } + }); + + const shortDateFormat = new Intl.DateTimeFormat('en-GB', { + month: 'short', + year: 'numeric', + }); + + const longDateFormat = new Intl.DateTimeFormat('en-GB', { + weekday: 'short', + day: 'numeric', + month: 'short', + year: 'numeric', + }); + + const allocationPeriod = `${shortDateFormat.format(call.startCycle)} - ${shortDateFormat.format(call.endCycle)}`; + + const template = EmailTemplateId.PROPOSAL_SUBMITTED; + const emailTemplate = + await emailTemplateDataSource.getEmailTemplateByName(template); + if (!emailTemplate) { + logger.logError('Email template not found', { + template, + }); + + return; + } + + const options = { + content: { + template: emailTemplate.id.toString(), + }, + substitution_data: { + name: '', + proposal: { + id: event.proposal.primaryKey, + title: event.proposal.title, + refNum: event.proposal.proposalId, + submittedOn: event.proposal.submittedDate!.toLocaleString(), + accessRoute: workflow?.name || 'N/A', + principalInvestigator: + principalInvestigator.preferredname + + ' ' + + principalInvestigator.lastname, + establishment: principalInvestigator.institution, + alternativeContacts: '', + coinvestigators: participants.map( + (partipant) => `${partipant.preferredname} ${partipant.lastname} ` + ), + requested: instruments + .map((instrument) => { + return `${instrument.name}: ${instrument.description} ${instrument.managementTimeAllocation} ${call.allocationTimeUnit}${instrument.managementTimeAllocation > 1 ? 's' : ''}`; + }) + .join(', '), + }, + allocationPeriod: allocationPeriod, + deadline: longDateFormat.format(call.endCall), + uos_instance: getBaseURL(), + }, + recipients: [], + }; + + participants.push(principalInvestigator); // Ensure PI also gets an email + + for (const participant of participants) { + if (!participant.email) { + logger.logError( + 'Could not send email on proposal submission: participant has no email', + { participant, event } + ); + + continue; + } + + // Create a copy of options for each participant to avoid mutation issues + const participantEmailOptions = { + ...options, + substitution_data: { + ...(options.substitution_data as any), + name: participant.preferredname, + }, + recipients: [ + { + address: participant.email, + }, + ], + }; + + mailService + .sendMail(participantEmailOptions) + .then((res: any) => { + logger.logInfo('Emails sent on proposal submission:', { + result: res, + event, + }); + }) + .catch((err: string) => { + logger.logError('Could not send email(s) on proposal submission:', { + error: err, + event, + }); + }); + } + + return; +} diff --git a/apps/backend/src/eventHandlers/email/emailTemplateId.ts b/apps/backend/src/eventHandlers/email/emailTemplateId.ts index 225e319523..e83c8e0853 100644 --- a/apps/backend/src/eventHandlers/email/emailTemplateId.ts +++ b/apps/backend/src/eventHandlers/email/emailTemplateId.ts @@ -17,4 +17,5 @@ export enum EmailTemplateId { INTERNAL_REVIEW_DELETED = 'internal-review-deleted', CALL_CREATED_EMAIL = 'call-created-email', FEEDBACK_REQUEST = 'feedback-request', + CO_PROPOSER_INVITE = 'co-proposer-invite', } diff --git a/apps/frontend/public/images/diamond-white.svg b/apps/frontend/public/images/diamond-white.svg new file mode 100644 index 0000000000..38a7331b40 --- /dev/null +++ b/apps/frontend/public/images/diamond-white.svg @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + +