diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 17f8cacece..acdc071aea 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -476,6 +476,7 @@ jobs: EMAIL_AUTH_HOST: exchsmtp.stfc.ac.uk EMAIL_TEMPLATE_PATH: /config/emails/ EMAIL_FOOTER_IMAGE_PATH: /config/logos/STFC-Logo-small.png + SKIP_SMTP_EMAIL_SENDING: true EXTERNAL_AUTH_LOGIN_URL: http://localhost:9003/auth/Login.aspx EXTERNAL_AUTH_LOGOUT_URL: http://localhost:9003/auth/Login.aspx EXTERNAL_UOWS_API_URL: http://localhost:1080/users-service diff --git a/apps/backend/db_patches/0202_CreateEmailTemplatesTable.sql b/apps/backend/db_patches/0202_CreateEmailTemplatesTable.sql new file mode 100644 index 0000000000..c3a6f333de --- /dev/null +++ b/apps/backend/db_patches/0202_CreateEmailTemplatesTable.sql @@ -0,0 +1,18 @@ +DO +$$ +BEGIN + IF register_patch('CreateEmailTemplatesTable.sql', 'Gergely Nyiri', 'Create email templates table', '2025-07-15') THEN + CREATE TABLE IF NOT EXISTS email_templates ( + email_template_id serial PRIMARY KEY, + created_by INT NOT NULL REFERENCES users(user_id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + name varchar(100) NOT NULL UNIQUE, + description varchar(255) NOT NULL, + use_template_file boolean DEFAULT FALSE, + subject varchar(160), + body text + ); + END IF; +END; +$$ +LANGUAGE plpgsql; \ No newline at end of file diff --git a/apps/backend/package-lock.json b/apps/backend/package-lock.json index 467d8a538b..411cc06f3a 100644 --- a/apps/backend/package-lock.json +++ b/apps/backend/package-lock.json @@ -71,6 +71,7 @@ "@types/content-disposition": "^0.5.4", "@types/cors": "^2.8.13", "@types/cron": "^1.7.3", + "@types/ejs": "^3.1.5", "@types/email-templates": "^8.0.4", "@types/express": "^4.17.13", "@types/express-jwt": "^6.0.4", @@ -83,6 +84,7 @@ "@types/node": "^22.13.10", "@types/pg": "^8.11.5", "@types/pg-large-object": "^2.0.7", + "@types/pug": "^2.0.10", "@types/sanitize-html": "^2.6.2", "@types/simple-oauth2": "^4.1.1", "@types/sinon": "^9.0.11", @@ -3212,6 +3214,13 @@ "moment": ">=2.14.0" } }, + "node_modules/@types/ejs": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@types/ejs/-/ejs-3.1.5.tgz", + "integrity": "sha512-nv+GSx77ZtXiJzwKdsASqi+YQ5Z7vwHsTP0JY2SiQgjGckkBRKZnk8nIM+7oUZ1VCtuTz0+By4qVR7fqzp/Dfg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/email-templates": { "version": "8.0.4", "resolved": "https://registry.npmjs.org/@types/email-templates/-/email-templates-8.0.4.tgz", @@ -3515,6 +3524,13 @@ "node": ">=12" } }, + "node_modules/@types/pug": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/@types/pug/-/pug-2.0.10.tgz", + "integrity": "sha512-Sk/uYFOBAB7mb74XcpizmH0KOR2Pv3D2Hmrh1Dmy5BmK3MpdSa5kqZcg6EKBdklU0bFXX9gCfzvpnyUehrPIuA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/qs": { "version": "6.9.7", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz", diff --git a/apps/backend/package.json b/apps/backend/package.json index fb08f987e8..7287d6bf0a 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -94,6 +94,7 @@ "@types/content-disposition": "^0.5.4", "@types/cors": "^2.8.13", "@types/cron": "^1.7.3", + "@types/ejs": "^3.1.5", "@types/email-templates": "^8.0.4", "@types/express": "^4.17.13", "@types/express-jwt": "^6.0.4", @@ -106,6 +107,7 @@ "@types/node": "^22.13.10", "@types/pg": "^8.11.5", "@types/pg-large-object": "^2.0.7", + "@types/pug": "^2.0.10", "@types/sanitize-html": "^2.6.2", "@types/simple-oauth2": "^4.1.1", "@types/sinon": "^9.0.11", diff --git a/apps/backend/src/asyncJobs/jobs/checkInviteReminderJob.spec.ts b/apps/backend/src/asyncJobs/jobs/checkInviteReminderJob.spec.ts index ecedf68d6e..89e17b54e6 100644 --- a/apps/backend/src/asyncJobs/jobs/checkInviteReminderJob.spec.ts +++ b/apps/backend/src/asyncJobs/jobs/checkInviteReminderJob.spec.ts @@ -1,10 +1,10 @@ -import 'reflect-metadata'; import { faker } from '@faker-js/faker'; +import 'reflect-metadata'; import sinon from 'sinon'; import { container } from 'tsyringe'; import { Tokens } from '../../config/Tokens'; -import { EmailTemplateId } from '../../eventHandlers/email/essEmailHandler'; +import { EmailTemplateId } from '../../eventHandlers/email/emailTemplateId'; import { Invite } from '../../models/Invite'; import { RoleClaim } from '../../models/RoleClaim'; import { SettingsId } from '../../models/Settings'; diff --git a/apps/backend/src/asyncJobs/jobs/checkInviteReminderJob.ts b/apps/backend/src/asyncJobs/jobs/checkInviteReminderJob.ts index a893f51945..935d2dda35 100644 --- a/apps/backend/src/asyncJobs/jobs/checkInviteReminderJob.ts +++ b/apps/backend/src/asyncJobs/jobs/checkInviteReminderJob.ts @@ -124,7 +124,7 @@ const checkInviteReminder = async () => { try { await mailService.sendMail({ content: { - template_id: templateId, + template: templateId, }, substitution_data: { email: invite.email, diff --git a/apps/backend/src/buildContext.ts b/apps/backend/src/buildContext.ts index 2e82a613ae..5e655c0393 100644 --- a/apps/backend/src/buildContext.ts +++ b/apps/backend/src/buildContext.ts @@ -7,6 +7,7 @@ import PDFServices from './middlewares/factory/factoryServices'; import AdminMutations from './mutations/AdminMutations'; import CallMutations from './mutations/CallMutations'; import DataAccessUsersMutations from './mutations/DataAccessUsersMutations'; +import EmailTemplateMutations from './mutations/EmailTemplateMutations'; import ExperimentMutations from './mutations/ExperimentMutation'; import ExperimentSafetyPdfTemplateMutations from './mutations/ExperimentSafetyPdfTemplateMutations'; import FapMutations from './mutations/FapMutations'; @@ -35,6 +36,7 @@ import WorkflowMutations from './mutations/WorkflowMutations'; import AdminQueries from './queries/AdminQueries'; import CallQueries from './queries/CallQueries'; import DataAccessUsersQueries from './queries/DataAccessUsersQueries'; +import EmailTemplateQueries from './queries/EmailTemplateQueries'; import EventLogQueries from './queries/EventLogQueries'; import ExperimentQueries from './queries/ExperimentQueries'; import ExperimentSafetyPdfTemplateQueries from './queries/ExperimentSafetyPdfTemplateQueries'; @@ -103,6 +105,7 @@ const context: BasicResolverContext = { settings: container.resolve(SettingsQueries), tag: container.resolve(TagQueries), experiment: container.resolve(ExperimentQueries), + emailTemplate: container.resolve(EmailTemplateQueries), }, mutations: { admin: container.resolve(AdminMutations), @@ -135,6 +138,7 @@ const context: BasicResolverContext = { workflow: container.resolve(WorkflowMutations), tag: container.resolve(TagMutations), experiment: container.resolve(ExperimentMutations), + emailTemplate: container.resolve(EmailTemplateMutations), }, clients: { scheduler: async () => { diff --git a/apps/backend/src/config/Tokens.ts b/apps/backend/src/config/Tokens.ts index 59f46ba513..8e5bf17717 100644 --- a/apps/backend/src/config/Tokens.ts +++ b/apps/backend/src/config/Tokens.ts @@ -9,6 +9,7 @@ export const Tokens = { DataAccessUsersDataSource: Symbol('DataAccessUsersDataSource'), DataAccessUsersAuthorization: Symbol('DataAccessUsersAuthorization'), EmailEventHandler: Symbol('EmailEventHandler'), + EmailTemplateDataSource: Symbol('EmailTemplateDataSource'), EventBus: Symbol('EventBus'), EventLogsDataSource: Symbol('EventLogsDataSource'), FeedbackDataSource: Symbol('FeedbackDataSource'), diff --git a/apps/backend/src/config/dependencyConfigDefault.ts b/apps/backend/src/config/dependencyConfigDefault.ts index 6f780e5f23..b7bbd5740e 100644 --- a/apps/backend/src/config/dependencyConfigDefault.ts +++ b/apps/backend/src/config/dependencyConfigDefault.ts @@ -14,6 +14,7 @@ import { PostgresAdminDataSourceWithAutoUpgrade } from '../datasources/postgres/ 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'; @@ -122,6 +123,7 @@ mapClass(Tokens.WorkflowDataSource, PostgresWorkflowDataSource); mapClass(Tokens.StatusDataSource, PostgresStatusDataSource); mapClass(Tokens.ExperimentDataSource, PostgresExperimentDataSource); mapClass(Tokens.TagDataSource, PostgresTagDataSource); +mapClass(Tokens.EmailTemplateDataSource, PostgresEmailTemplateDataSource); mapClass(Tokens.UserAuthorization, OAuthAuthorization); mapClass(Tokens.ProposalAuthorization, ProposalAuthorization); diff --git a/apps/backend/src/config/dependencyConfigE2E.ts b/apps/backend/src/config/dependencyConfigE2E.ts index 330f5e0699..536130aff3 100644 --- a/apps/backend/src/config/dependencyConfigE2E.ts +++ b/apps/backend/src/config/dependencyConfigE2E.ts @@ -10,6 +10,7 @@ import PostgresAdminDataSource 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'; @@ -112,6 +113,7 @@ mapClass(Tokens.StatusActionsLogsDataSource, StatusActionsLogsDataSource); mapClass(Tokens.WorkflowDataSource, PostgresWorkflowDataSource); mapClass(Tokens.StatusDataSource, PostgresStatusDataSource); mapClass(Tokens.TagDataSource, PostgresTagDataSource); +mapClass(Tokens.EmailTemplateDataSource, PostgresEmailTemplateDataSource); mapClass(Tokens.ExperimentDataSource, PostgresExperimentDataSource); mapClass(Tokens.UserAuthorization, OAuthAuthorization); diff --git a/apps/backend/src/config/dependencyConfigELI.ts b/apps/backend/src/config/dependencyConfigELI.ts index b444413f53..ae8beebb78 100644 --- a/apps/backend/src/config/dependencyConfigELI.ts +++ b/apps/backend/src/config/dependencyConfigELI.ts @@ -9,6 +9,7 @@ import { PostgresAdminDataSourceWithAutoUpgrade } from '../datasources/postgres/ 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'; @@ -115,6 +116,7 @@ mapClass(Tokens.StatusActionsLogsDataSource, StatusActionsLogsDataSource); mapClass(Tokens.WorkflowDataSource, PostgresWorkflowDataSource); mapClass(Tokens.StatusDataSource, PostgresStatusDataSource); mapClass(Tokens.TagDataSource, PostgresTagDataSource); +mapClass(Tokens.EmailTemplateDataSource, PostgresEmailTemplateDataSource); mapClass(Tokens.ExperimentDataSource, PostgresExperimentDataSource); mapClass(Tokens.UserAuthorization, ELIUserAuthorization); diff --git a/apps/backend/src/config/dependencyConfigESS.ts b/apps/backend/src/config/dependencyConfigESS.ts index d6e012f402..7483c165a0 100644 --- a/apps/backend/src/config/dependencyConfigESS.ts +++ b/apps/backend/src/config/dependencyConfigESS.ts @@ -10,6 +10,7 @@ import { PostgresAdminDataSourceWithAutoUpgrade } from '../datasources/postgres/ 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'; @@ -116,6 +117,7 @@ mapClass(Tokens.TagDataSource, PostgresTagDataSource); mapClass(Tokens.WorkflowDataSource, PostgresWorkflowDataSource); mapClass(Tokens.StatusDataSource, PostgresStatusDataSource); mapClass(Tokens.ExperimentDataSource, PostgresExperimentDataSource); +mapClass(Tokens.EmailTemplateDataSource, PostgresEmailTemplateDataSource); mapClass(Tokens.UserAuthorization, OAuthAuthorization); mapClass(Tokens.ProposalAuthorization, ProposalAuthorization); diff --git a/apps/backend/src/config/dependencyConfigSTFC.ts b/apps/backend/src/config/dependencyConfigSTFC.ts index 2ae939cf08..4d9590aa3f 100644 --- a/apps/backend/src/config/dependencyConfigSTFC.ts +++ b/apps/backend/src/config/dependencyConfigSTFC.ts @@ -9,6 +9,7 @@ import { PostgresAdminDataSourceWithAutoUpgrade } from '../datasources/postgres/ 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'; @@ -112,6 +113,7 @@ mapClass(Tokens.StatusActionsLogsDataSource, StatusActionsLogsDataSource); mapClass(Tokens.WorkflowDataSource, PostgresWorkflowDataSource); mapClass(Tokens.StatusDataSource, PostgresStatusDataSource); mapClass(Tokens.TagDataSource, PostgresTagDataSource); +mapClass(Tokens.EmailTemplateDataSource, PostgresEmailTemplateDataSource); mapClass(Tokens.ExperimentDataSource, PostgresExperimentDataSource); mapClass(Tokens.UserAuthorization, StfcUserAuthorization); diff --git a/apps/backend/src/config/dependencyConfigTest.ts b/apps/backend/src/config/dependencyConfigTest.ts index 8d3a7d9b0d..de430eeaf7 100644 --- a/apps/backend/src/config/dependencyConfigTest.ts +++ b/apps/backend/src/config/dependencyConfigTest.ts @@ -11,6 +11,7 @@ import { AdminDataSourceMock } from '../datasources/mockups/AdminDataSource'; import { CallDataSourceMock } from '../datasources/mockups/CallDataSource'; import { CoProposerClaimDataSourceMock } from '../datasources/mockups/CoProposerClaimDataSource'; import MockDataAccessUsersDataSource from '../datasources/mockups/DataAccessUsersDataSource'; +import { EmailTemplateDataSourceMock } from '../datasources/mockups/EmailTemplateDataSource'; import { EventLogsDataSourceMock } from '../datasources/mockups/EventLogsDataSource'; import { ExperimentDataSourceMock } from '../datasources/mockups/ExperimentDataSource'; import { ExperimentSafetyPdfTemplateDataSourceMock } from '../datasources/mockups/ExperimentSafetyPdfTemplateDataSource'; @@ -104,6 +105,7 @@ mapClass(Tokens.VisitRegistrationAuthorization, VisitRegistrationAuthorization); mapClass(Tokens.PredefinedMessageDataSource, PredefinedMessageDataSourceMock); mapClass(Tokens.StatusActionsLogsDataSource, StatusActionsLogsDataSourceMock); mapClass(Tokens.TagDataSource, TagDataSourceMock); +mapClass(Tokens.EmailTemplateDataSource, EmailTemplateDataSourceMock); mapClass(Tokens.UserAuthorization, UserAuthorizationMock); mapClass(Tokens.ProposalAuthorization, ProposalAuthorization); diff --git a/apps/backend/src/context/index.ts b/apps/backend/src/context/index.ts index 033d7c24ba..c315303c2a 100644 --- a/apps/backend/src/context/index.ts +++ b/apps/backend/src/context/index.ts @@ -6,6 +6,7 @@ import { UserWithRole } from '../models/User'; import AdminMutations from '../mutations/AdminMutations'; import CallMutations from '../mutations/CallMutations'; import DataAccessUsersMutations from '../mutations/DataAccessUsersMutations'; +import EmailTemplateMutations from '../mutations/EmailTemplateMutations'; import ExperimentMutations from '../mutations/ExperimentMutation'; import ExperimentSafetyPdfTemplateMutations from '../mutations/ExperimentSafetyPdfTemplateMutations'; import FapMutations from '../mutations/FapMutations'; @@ -34,6 +35,7 @@ import WorkflowMutations from '../mutations/WorkflowMutations'; import AdminQueries from '../queries/AdminQueries'; import CallQueries from '../queries/CallQueries'; import DataAccessUsersQueries from '../queries/DataAccessUsersQueries'; +import EmailTemplateQueries from '../queries/EmailTemplateQueries'; import EventLogQueries from '../queries/EventLogQueries'; import ExperimentQueries from '../queries/ExperimentQueries'; import ExperimentSafetyPdfTemplateQueries from '../queries/ExperimentSafetyPdfTemplateQueries'; @@ -99,6 +101,7 @@ interface ResolverContextQueries { statusAction: StatusActionQueries; tag: TagQueries; experiment: ExperimentQueries; + emailTemplate: EmailTemplateQueries; } interface ResolverContextMutations { @@ -130,6 +133,7 @@ interface ResolverContextMutations { workflow: WorkflowMutations; tag: TagMutations; experiment: ExperimentMutations; + emailTemplate: EmailTemplateMutations; } interface ResolverContextServices { pdfServices: PDFServices; diff --git a/apps/backend/src/datasources/EmailTemplateDataSource.ts b/apps/backend/src/datasources/EmailTemplateDataSource.ts new file mode 100644 index 0000000000..bd85c60156 --- /dev/null +++ b/apps/backend/src/datasources/EmailTemplateDataSource.ts @@ -0,0 +1,30 @@ +import { EmailTemplate } from '../models/EmailTemplate'; +import { EmailTemplatesFilter } from '../resolvers/queries/EmailTemplatesQuery'; + +export interface EmailTemplateDataSource { + getEmailTemplate(id: number): Promise; + getEmailTemplateByName(name: string): Promise; + getEmailTemplates( + filter?: EmailTemplatesFilter + ): Promise<{ totalCount: number; emailTemplates: EmailTemplate[] }>; + + create( + createdByUserId: number, + name: string, + description: string, + useTemplateFile: boolean, + subject?: string, + body?: string + ): Promise; + + update( + id: number, + name: string, + description: string, + useTemplateFile: boolean, + subject?: string, + body?: string + ): Promise; + + delete(id: number): Promise; +} diff --git a/apps/backend/src/datasources/StatusActionsDataSource.ts b/apps/backend/src/datasources/StatusActionsDataSource.ts index 5419a86f18..c73a623bbd 100644 --- a/apps/backend/src/datasources/StatusActionsDataSource.ts +++ b/apps/backend/src/datasources/StatusActionsDataSource.ts @@ -13,6 +13,10 @@ export interface StatusActionsDataSource { workflowConnectionId: number, statusActionId: number ): Promise; + hasEmailTemplateIdConnectionStatusAction( + emailTemplateId: number + ): Promise; + updateConnectionStatusAction( data: ConnectionHasStatusAction ): Promise; diff --git a/apps/backend/src/datasources/mockups/EmailTemplateDataSource.ts b/apps/backend/src/datasources/mockups/EmailTemplateDataSource.ts new file mode 100644 index 0000000000..15c4b71f10 --- /dev/null +++ b/apps/backend/src/datasources/mockups/EmailTemplateDataSource.ts @@ -0,0 +1,149 @@ +import { EmailTemplateId } from '../../eventHandlers/email/emailTemplateId'; +import { EmailTemplate } from '../../models/EmailTemplate'; +import { EmailTemplatesFilter } from '../../resolvers/queries/EmailTemplatesQuery'; +import { EmailTemplateDataSource } from '../EmailTemplateDataSource'; + +export const dummyEmailTemplate = { + id: 1, + createdByUserId: 1, + name: 'Dummy Email Template', + description: 'This is a dummy email template for testing purposes.', + subject: 'Welcome to Our Service', + body: 'Hello, thank you for signing up for our service. We are excited to have you on board!', + createdAt: '', + useTemplateFile: false, +}; + +export class EmailTemplateDataSourceMock implements EmailTemplateDataSource { + emailTemplates: EmailTemplate[]; + constructor() { + this.init(); + } + + public init() { + this.emailTemplates = [ + new EmailTemplate( + 1, + 1, + 'Dummy Email Template', + 'This is a dummy email template for testing purposes.', + false, + 'Welcome to Our Service', + 'Hello, thank you for signing up for our service. We are excited to have you on board!' + ), + new EmailTemplate( + 2, + 1, + EmailTemplateId.PROPOSAL_CREATED, + 'This is a dummy email template for testing purposes.', + false, + 'Welcome to Our Service', + 'Hello, thank you for signing up for our service. We are excited to have you on board!' + ), + new EmailTemplate( + 3, + 1, + EmailTemplateId.ACCEPTED_PROPOSAL, + 'This is a dummy email template for testing purposes.', + false, + 'Welcome to Our Service', + 'Hello, thank you for signing up for our service. We are excited to have you on board!' + ), + new EmailTemplate( + 4, + 1, + EmailTemplateId.REJECTED_PROPOSAL, + 'This is a dummy email template for testing purposes.', + false, + 'Welcome to Our Service', + 'Hello, thank you for signing up for our service. We are excited to have you on board!' + ), + new EmailTemplate( + 5, + 1, + EmailTemplateId.RESERVED_PROPOSAL, + 'This is a dummy email template for testing purposes.', + false, + 'Welcome to Our Service', + 'Hello, thank you for signing up for our service. We are excited to have you on board!' + ), + new EmailTemplate( + 6, + 1, + EmailTemplateId.REVIEW_REMINDER, + 'This is a dummy email template for testing purposes.', + false, + 'Welcome to Our Service', + 'Hello, thank you for signing up for our service. We are excited to have you on board!' + ), + new EmailTemplate( + 7, + 1, + EmailTemplateId.INTERNAL_REVIEW_CREATED, + 'This is a dummy email template for testing purposes.', + false, + 'Welcome to Our Service', + 'Hello, thank you for signing up for our service. We are excited to have you on board!' + ), + new EmailTemplate( + 8, + 1, + EmailTemplateId.INTERNAL_REVIEW_DELETED, + 'This is a dummy email template for testing purposes.', + false, + 'Welcome to Our Service', + 'Hello, thank you for signing up for our service. We are excited to have you on board!' + ), + new EmailTemplate( + 9, + 1, + EmailTemplateId.INTERNAL_REVIEW_UPDATED, + 'This is a dummy email template for testing purposes.', + false, + 'Welcome to Our Service', + 'Hello, thank you for signing up for our service. We are excited to have you on board!' + ), + ]; + } + + async delete(id: number): Promise { + return dummyEmailTemplate; + } + async getEmailTemplates( + filter?: EmailTemplatesFilter + ): Promise<{ totalCount: number; emailTemplates: EmailTemplate[] }> { + return { totalCount: 1, emailTemplates: [dummyEmailTemplate] }; + } + + async getEmailTemplate(id: number): Promise { + return this.emailTemplates.find((e) => e.id == id) || dummyEmailTemplate; + } + + async getEmailTemplateByName(name: string): Promise { + return ( + this.emailTemplates.find((e) => e.name == name) || dummyEmailTemplate + ); + } + + async create( + createdByUserId: number, + name: string, + description: string, + use_template_file: boolean, + subject?: string, + body?: string + ): Promise { + return dummyEmailTemplate; + } + + async update( + id: number, + name: string, + description: string, + use_template_file: boolean, + subject?: string, + body?: string + ): Promise { + return dummyEmailTemplate; + } +} diff --git a/apps/backend/src/datasources/mockups/InviteDataSource.ts b/apps/backend/src/datasources/mockups/InviteDataSource.ts index 2d38115b47..dc42b8a866 100644 --- a/apps/backend/src/datasources/mockups/InviteDataSource.ts +++ b/apps/backend/src/datasources/mockups/InviteDataSource.ts @@ -1,7 +1,7 @@ import { inject, injectable } from 'tsyringe'; import { Tokens } from '../../config/Tokens'; -import { EmailTemplateId } from '../../eventHandlers/email/essEmailHandler'; +import { EmailTemplateId } from '../../eventHandlers/email/emailTemplateId'; import { CoProposerClaim } from '../../models/CoProposerClaim'; import { Invite } from '../../models/Invite'; import { CoProposerClaimDataSource } from '../CoProposerClaimDataSource'; diff --git a/apps/backend/src/datasources/mockups/StatusActionsDataSource.ts b/apps/backend/src/datasources/mockups/StatusActionsDataSource.ts index faa58b8c3e..7b78d90e92 100644 --- a/apps/backend/src/datasources/mockups/StatusActionsDataSource.ts +++ b/apps/backend/src/datasources/mockups/StatusActionsDataSource.ts @@ -47,6 +47,12 @@ export class StatusActionsDataSourceMock implements StatusActionsDataSource { return [dummyConnectionHasStatusAction]; } + async hasEmailTemplateIdConnectionStatusAction( + emailTemplateId: number + ): Promise { + return false; + } + async updateConnectionStatusAction( statusAction: ConnectionHasStatusAction ): Promise { diff --git a/apps/backend/src/datasources/postgres/EmailTemplateDataSource.ts b/apps/backend/src/datasources/postgres/EmailTemplateDataSource.ts new file mode 100644 index 0000000000..e51e7149fa --- /dev/null +++ b/apps/backend/src/datasources/postgres/EmailTemplateDataSource.ts @@ -0,0 +1,146 @@ +import { GraphQLError } from 'graphql'; +import { injectable } from 'tsyringe'; + +import { EmailTemplate } from '../../models/EmailTemplate'; +import { EmailTemplatesFilter } from '../../resolvers/queries/EmailTemplatesQuery'; +import { EmailTemplateDataSource } from '../EmailTemplateDataSource'; +import database from './database'; +import { createEmailTemplateObject, EmailTemplateRecord } from './records'; + +@injectable() +export default class PostgresEmailTemplateDataSource + implements EmailTemplateDataSource +{ + async getEmailTemplate(id: number): Promise { + return database + .select() + .from('email_templates') + .where('email_template_id', id) + .first() + .then((emailTemplate: EmailTemplateRecord) => { + return emailTemplate ? createEmailTemplateObject(emailTemplate) : null; + }); + } + + async getEmailTemplateByName(name: string): Promise { + return database + .select() + .from('email_templates') + .where('name', name) + .first() + .then((emailTemplate: EmailTemplateRecord) => { + return emailTemplate ? createEmailTemplateObject(emailTemplate) : null; + }); + } + + async getEmailTemplates( + filter?: EmailTemplatesFilter + ): Promise<{ totalCount: number; emailTemplates: EmailTemplate[] }> { + const query = database('email_templates').select(['*']); + + if (filter?.filter) { + query.whereILikeEscaped('name', '%?%', filter.filter); + } + + if (filter?.emailTemplateIds) { + query.whereIn('email_template_id', filter.emailTemplateIds); + } + + if (filter?.first) { + query.limit(filter?.first); + } + + if (filter?.offset) { + query.offset(filter?.offset); + } + + return database + .select() + .from('email_templates') + .then((emailTemplates: EmailTemplateRecord[]) => { + return { + totalCount: emailTemplates.length, + emailTemplates: emailTemplates.map(createEmailTemplateObject), + }; + }); + } + + async create( + createdByUserId: number, + name: string, + description: string, + useTemplateFile: boolean, + subject?: string, + body?: string + ): Promise { + return database + .insert( + { + created_by: createdByUserId, + name: name, + description: description, + subject: subject, + body: body, + use_template_file: useTemplateFile, + }, + ['*'] + ) + .from('email_templates') + .then((emailTemplates: EmailTemplateRecord[]) => { + if (emailTemplates?.length !== 1) { + throw new GraphQLError(`Failed to create email template '${name}'`); + } + + return createEmailTemplateObject(emailTemplates[0]); + }); + } + + async update( + emailTemplateId: number, + name: string, + description: string, + useTemplateFile: boolean, + subject?: string, + body?: string + ): Promise { + return database + .update( + { + name: name, + description: description, + subject: subject, + body: body, + use_template_file: useTemplateFile, + }, + ['*'] + ) + .from('email_templates') + .where('email_templates.email_template_id', emailTemplateId) + .then((emailTemplates: EmailTemplateRecord[]) => { + if (emailTemplates?.length !== 1) { + throw new GraphQLError( + `Failed to update email template with id '${emailTemplateId}'` + ); + } + + return createEmailTemplateObject(emailTemplates[0]); + }); + } + + async delete(id: number): Promise { + return database + .where('email_templates.email_template_id', id) + .del() + .from('email_templates') + .returning('*') + .then((emailTemplates: EmailTemplateRecord[]) => { + if (emailTemplates?.length !== 1) { + throw new GraphQLError( + `Could not delete emailTemplate with id:${id}` + ); + } + + return createEmailTemplateObject(emailTemplates[0]); + }); + } +} diff --git a/apps/backend/src/datasources/postgres/StatusActionsDataSource.ts b/apps/backend/src/datasources/postgres/StatusActionsDataSource.ts index d210940fea..8a95d8e2f4 100644 --- a/apps/backend/src/datasources/postgres/StatusActionsDataSource.ts +++ b/apps/backend/src/datasources/postgres/StatusActionsDataSource.ts @@ -1,3 +1,4 @@ +/* eslint-disable quotes */ import { logger } from '@user-office-software/duo-logger'; import { GraphQLError } from 'graphql'; import { inject, injectable } from 'tsyringe'; @@ -12,8 +13,8 @@ import { import { AddConnectionStatusActionsInput } from '../../resolvers/mutations/settings/AddConnectionStatusActionsMutation'; import { EmailActionConfig, - StatusActionConfig, RabbitMQActionConfig, + StatusActionConfig, } from '../../resolvers/types/StatusActionConfig'; import { StatusActionsDataSource } from '../StatusActionsDataSource'; import { WorkflowDataSource } from '../WorkflowDataSource'; @@ -130,6 +131,18 @@ export default class PostgresStatusActionsDataSource return this.createConnectionStatusActionObject(statusActionRecord); } + async hasEmailTemplateIdConnectionStatusAction( + emailTemplateId: number + ): Promise { + const fromClause = "config->'recipientsWithEmailTemplate'"; + const pattern = `\'[{"emailTemplate": {"id": "${emailTemplateId}"}}]\'`; + const countResult = await database.raw( + `select count(*) from workflow_connection_has_actions where ${fromClause} @> ${pattern}` + ); + + return Number(countResult.rows[0].count) > 0; + } + async updateConnectionStatusAction( statusAction: ConnectionHasStatusAction ): Promise { diff --git a/apps/backend/src/datasources/postgres/records.ts b/apps/backend/src/datasources/postgres/records.ts index 151dd47cb5..a79238a034 100644 --- a/apps/backend/src/datasources/postgres/records.ts +++ b/apps/backend/src/datasources/postgres/records.ts @@ -1,9 +1,9 @@ import { - ProposalPdfTemplateRecord, ExperimentSafetyPdfTemplateRecord, + ProposalPdfTemplateRecord, } from 'knex/types/tables'; -import { EmailTemplateId } from '../../eventHandlers/email/essEmailHandler'; +import { EmailTemplateId } from '../../eventHandlers/email/emailTemplateId'; import { Page } from '../../models/Admin'; import { FileMetadata } from '../../models/Blob'; import { AllocationTimeUnits, Call } from '../../models/Call'; @@ -14,6 +14,7 @@ import { } from '../../models/ConditionEvaluator'; import { CoProposerClaim } from '../../models/CoProposerClaim'; import { Country } from '../../models/Country'; +import { EmailTemplate } from '../../models/EmailTemplate'; import { Experiment, ExperimentStatus } from '../../models/Experiment'; import { ExperimentSafetyPdfTemplate } from '../../models/ExperimentSafetyPdfTemplate'; import { @@ -378,6 +379,16 @@ export interface CallRecord { readonly experiment_workflow_id: number; } +export interface EmailTemplateRecord { + readonly email_template_id: number; + readonly created_by: number; + readonly name: string; + readonly description: string; + readonly use_template_file: boolean; + readonly subject?: string | null; + readonly body?: string | null; +} + export interface PageTextRecord { readonly pagetext_id: number; readonly content: string; @@ -1067,6 +1078,20 @@ export const createCallObject = (call: CallRecord) => { ); }; +export const createEmailTemplateObject = ( + emailTemplate: EmailTemplateRecord +) => { + return new EmailTemplate( + emailTemplate.email_template_id, + emailTemplate.created_by, + emailTemplate.name, + emailTemplate.description, + emailTemplate.use_template_file, + emailTemplate.subject, + emailTemplate.body + ); +}; + export const createCallHasInstrumentObject = ( callHasInstrument: CallHasInstrumentRecord ) => { @@ -1424,7 +1449,7 @@ export interface InviteRecord { readonly claimed_at: Date | null; readonly is_email_sent: boolean; readonly expires_at: Date | null; - readonly template_id: EmailTemplateId | null; + readonly template_id: number | null; } export const createInviteObject = (invite: InviteRecord) => diff --git a/apps/backend/src/eventHandlers/MailService/EmailSettings.ts b/apps/backend/src/eventHandlers/MailService/EmailSettings.ts index c470772d92..4ec6d9cd34 100644 --- a/apps/backend/src/eventHandlers/MailService/EmailSettings.ts +++ b/apps/backend/src/eventHandlers/MailService/EmailSettings.ts @@ -148,14 +148,22 @@ export interface CreateTransmission { /** Content that will be used to construct a message */ content: | InlineContent - | { template_id: string; use_draft_template?: boolean } - | { email_rfc822: string }; + | { + template?: string; + email_rfc822?: string; + use_draft_template?: boolean; + }; } export default interface EmailSettings extends CreateTransmission { - content: { - template_id: string; - }; + content: + | { + template: string; + } + | { + template: string; + email_rfc822: string; + }; recipients: ( | { address: string; diff --git a/apps/backend/src/eventHandlers/MailService/MailService.ts b/apps/backend/src/eventHandlers/MailService/MailService.ts index ea01a662a0..63d24e93c8 100644 --- a/apps/backend/src/eventHandlers/MailService/MailService.ts +++ b/apps/backend/src/eventHandlers/MailService/MailService.ts @@ -5,9 +5,7 @@ export abstract class MailService { abstract sendMail(options: EmailSettings): ResultsPromise; abstract getEmailTemplates( includeDraft?: boolean - ): ResultsPromise< - (SparkPostTemplate | STFCEmailTemplate | ELIEmailTemplate)[] - >; + ): ResultsPromise<(SparkPostTemplate | SMTPEmailTemplate)[]>; } export type SparkPostTemplate = { @@ -22,12 +20,7 @@ export type SparkPostTemplate = { last_update_time: string; }; -export type STFCEmailTemplate = { - id: string; - name: string; -}; - -export type ELIEmailTemplate = { +export type SMTPEmailTemplate = { id: string; name: string; }; diff --git a/apps/backend/src/eventHandlers/MailService/SMTPMailService.spec.ts b/apps/backend/src/eventHandlers/MailService/SMTPMailService.spec.ts index 48f57ccd84..7543b0abc9 100644 --- a/apps/backend/src/eventHandlers/MailService/SMTPMailService.spec.ts +++ b/apps/backend/src/eventHandlers/MailService/SMTPMailService.spec.ts @@ -21,7 +21,7 @@ test('Return result should indicate all emails were successfully sent', async () const options: EmailSettings = { content: { - template_id: path.resolve('src', 'eventHandlers', 'emails', 'submit'), + template: path.resolve('src', 'eventHandlers', 'emails', 'submit'), }, substitution_data: { piPreferredname: 'John', @@ -75,7 +75,7 @@ test('All emails with bcc were successfully sent', async () => { const options: EmailSettings = { content: { - template_id: path.resolve('src', 'eventHandlers', 'emails', 'submit'), + template: path.resolve('src', 'eventHandlers', 'emails', 'submit'), }, substitution_data: substitutionData, recipients: [ @@ -89,12 +89,12 @@ test('All emails with bcc were successfully sent', async () => { const result = await smtpMailService.sendMail(options); expect(emailInfo).toHaveBeenCalledWith({ - template: expect.any(String), message: { to: process.env.SINK_EMAIL, bcc: bccEmail, + subject: '= ``', + html: '', }, - locals: substitutionData, }); return expect(result).toStrictEqual({ diff --git a/apps/backend/src/eventHandlers/MailService/SMTPMailService.ts b/apps/backend/src/eventHandlers/MailService/SMTPMailService.ts index 38fa36aed7..9c44d858cc 100644 --- a/apps/backend/src/eventHandlers/MailService/SMTPMailService.ts +++ b/apps/backend/src/eventHandlers/MailService/SMTPMailService.ts @@ -1,3 +1,4 @@ +import { existsSync, readFileSync } from 'node:fs'; import path from 'path'; import { logger } from '@user-office-software/duo-logger'; @@ -6,30 +7,45 @@ import * as nodemailer from 'nodemailer'; import { Transporter } from 'nodemailer'; import SMTPPool from 'nodemailer/lib/smtp-pool'; import SMTPTransport from 'nodemailer/lib/smtp-transport'; +import pug from 'pug'; import { container } from 'tsyringe'; import { Tokens } from '../../config/Tokens'; import { AdminDataSource } from '../../datasources/AdminDataSource'; +import { EmailTemplateDataSource } from '../../datasources/EmailTemplateDataSource'; import { SettingsId } from '../../models/Settings'; import { isProduction } from '../../utils/helperFunctions'; import EmailSettings from './EmailSettings'; -import { MailService, STFCEmailTemplate, SendMailResults } from './MailService'; +import { MailService, SendMailResults, SMTPEmailTemplate } from './MailService'; import { ResultsPromise } from './SparkPost'; export class SMTPMailService extends MailService { - private emailTemplates: EmailTemplates; + private emailTemplate: EmailTemplates; + private emailTemplateDataSource: EmailTemplateDataSource; constructor() { super(); + logger.logInfo('Initializing SMTPMailService', {}); + + this.emailTemplateDataSource = container.resolve( + Tokens.EmailTemplateDataSource + ); + const attachments = []; if (process.env.EMAIL_FOOTER_IMAGE_PATH !== undefined) { - attachments.push({ - filename: 'logo.png', - path: process.env.EMAIL_FOOTER_IMAGE_PATH, - cid: 'logo1', - }); + if (existsSync(process.env.EMAIL_FOOTER_IMAGE_PATH)) { + attachments.push({ + filename: 'logo.png', + path: process.env.EMAIL_FOOTER_IMAGE_PATH, + cid: 'logo1', + }); + } else { + logger.logWarn('Email footer image path does not exist', { + path: process.env.EMAIL_FOOTER_IMAGE_PATH, + }); + } } let smtpTransport: @@ -52,7 +68,7 @@ export class SMTPMailService extends MailService { }); } - this.emailTemplates = new EmailTemplates({ + this.emailTemplate = new EmailTemplates({ message: { from: process.env.EMAIL_SENDER, attachments, @@ -65,7 +81,22 @@ export class SMTPMailService extends MailService { relativeTo: path.resolve(process.env.EMAIL_TEMPLATE_PATH || ''), }, }, - getPath: this.getEmailTemplatePath, + render: (view: string, locals?: any) => { + return new Promise((resolve, reject) => { + const lastSlashIndex = view.lastIndexOf('/'); + const templateBody = + lastSlashIndex !== -1 ? view.substring(0, lastSlashIndex) : view; + + this.emailTemplate + .juiceResources(templateBody) + .then((html) => { + resolve(html); + }) + .catch((err) => { + reject(err); + }); + }); + }, }); } @@ -76,6 +107,72 @@ export class SMTPMailService extends MailService { ); } + private async compileEmailTemplate(options: EmailSettings): Promise<{ + subject: string; + body: string; + } | null> { + if (process.env.NODE_ENV === 'test') { + return { subject: '= ``', body: '' }; + } + + const emailTemplate = await this.emailTemplateDataSource.getEmailTemplate( + +options.content.template + ); + + if (!emailTemplate) { + logger.logError('Email template not found', { + template: options.content.template, + }); + + return null; + } + + let templateBody = ''; + let templateSubject = ''; + + if (emailTemplate.useTemplateFile) { + const templateBodyPath = + this.getEmailTemplatePath('html', emailTemplate.name) + '.pug'; + const templateSubjectPath = + this.getEmailTemplatePath('subject', emailTemplate.name) + '.pug'; + + try { + templateBody = readFileSync(templateBodyPath, 'utf-8'); + templateSubject = readFileSync(templateSubjectPath, 'utf-8'); + } catch (error) { + logger.logError('Email template file not found', { + error: error, + }); + + return null; + } + } else { + templateBody = emailTemplate.body || ''; + templateSubject = emailTemplate.subject || ''; + } + + try { + let compiledSubject = ''; + let compiledBody = ''; + compiledSubject = pug.render( + templateSubject, + options.substitution_data || {} + ); + compiledBody = pug.render(templateBody, options.substitution_data || {}); + + return { + subject: compiledSubject, + body: compiledBody, + }; + } catch (error) { + logger.logError('Error compiling email template', { + error: error, + }); + + return null; + } + } + private getSmtpAuthOptions() { if (process.env.EMAIL_AUTH_USERNAME && process.env.EMAIL_AUTH_PASSWORD) { return { @@ -115,15 +212,19 @@ export class SMTPMailService extends MailService { sendMailResults.id = 'test'; } - const template = - this.getEmailTemplatePath('html', options.content.template_id) + '.pug'; + const template = await this.compileEmailTemplate(options); - if ( - !(await (this.emailTemplates as any).templateExists(template)) && - process.env.NODE_ENV !== 'test' - ) { - logger.logError('Template does not exist', { - templateId: template, + if (!template) { + logger.logError('Email template not found', { + template: options.content.template, + }); + + return { results: sendMailResults }; + } + + if (process.env.SKIP_SMTP_EMAIL_SENDING === 'true') { + logger.logInfo('Skipping email sending', { + template: options.content.template, }); return { results: sendMailResults }; @@ -131,8 +232,7 @@ export class SMTPMailService extends MailService { options.recipients.forEach((participant) => { emailPromises.push( - this.emailTemplates.send({ - template: options.content.template_id, + this.emailTemplate.send({ message: { ...(typeof participant.address !== 'string' ? { @@ -143,15 +243,18 @@ export class SMTPMailService extends MailService { name: participant.address?.header_to, }, bcc: bccAddress, + subject: template.subject, + html: template.body, } : { to: isProduction ? participant.address : process.env.SINK_EMAIL, bcc: bccAddress, + subject: template.subject, + html: template.body, }), }, - locals: options.substitution_data, }) ); }); @@ -174,54 +277,15 @@ export class SMTPMailService extends MailService { }); } - async getEmailTemplates(): ResultsPromise { + async getEmailTemplates(): ResultsPromise { + const emailTemplates = + await this.emailTemplateDataSource.getEmailTemplates(); + return { - results: [ - { - id: 'clf-proposal-submitted-pi', - name: 'CLF PI Co-I Submission Email', - }, - { - id: 'isis-proposal-submitted-pi', - name: 'ISIS PI Co-I Submission Email', - }, - { - id: 'isis-rapid-proposal-submitted-pi', - name: 'ISIS Rapid PI Co-I Submission Email', - }, - { - id: 'isis-rapid-proposal-submitted-uo', - name: 'ISIS Rapid User Office Submission Email', - }, - { - id: 'xpress-proposal-submitted-pi', - name: 'ISIS Xpress PI Co-I Submission Email', - }, - { - id: 'xpress-proposal-submitted-sc', - name: 'ISIS Xpress Scientist Submission Email', - }, - { - id: 'xpress-proposal-under-review', - name: 'ISIS Xpress PI Co-I Under Review Email', - }, - { - id: 'xpress-proposal-approved', - name: 'ISIS Xpress PI Co-I Approval Email', - }, - { - id: 'xpress-proposal-sra', - name: 'ISIS Xpress SRA Request Email', - }, - { - id: 'xpress-proposal-unsuccessful', - name: 'ISIS Xpress PI Co-I Reject Email', - }, - { - id: 'xpress-proposal-finished', - name: 'ISIS Xpress PI Co-I Finish Email', - }, - ], + results: emailTemplates.emailTemplates.map((template) => ({ + id: template.id.toString(), + name: template.name || '', + })), }; } } diff --git a/apps/backend/src/eventHandlers/MailService/SkipSendMailService.ts b/apps/backend/src/eventHandlers/MailService/SkipSendMailService.ts index 184291a96a..4a74cc89d5 100644 --- a/apps/backend/src/eventHandlers/MailService/SkipSendMailService.ts +++ b/apps/backend/src/eventHandlers/MailService/SkipSendMailService.ts @@ -7,6 +7,8 @@ import { ResultsPromise } from './SparkPost'; export class SkipSendMailService extends MailService { constructor() { super(); + + logger.logInfo('Initializing SkipSendMailService', {}); } async sendMail(options: EmailSettings): ResultsPromise { @@ -31,10 +33,10 @@ export class SkipSendMailService extends MailService { { last_use: '2023-08-21T12:23:59+00:00', description: 'A test message from SparkPost.com', - id: 'my-first-email', + id: '1', has_draft: false, published: true, - name: 'My First Email', + name: 'test-name-1', shared_with_subaccounts: false, has_published: true, last_update_time: '2022-08-14T08:42:50+00:00', @@ -42,10 +44,10 @@ export class SkipSendMailService extends MailService { { last_use: '2023-08-22T12:23:59+00:00', description: 'A test message from SparkPost.com', - id: 'my-second-email', + id: '2', has_draft: false, published: true, - name: 'My Second Email', + name: 'test-name-2', shared_with_subaccounts: false, has_published: true, last_update_time: '2023-08-14T08:42:50+00:00', diff --git a/apps/backend/src/eventHandlers/email/eliEmailHandler.spec.ts b/apps/backend/src/eventHandlers/email/eliEmailHandler.spec.ts index 6aa5e083f5..65d38e80cb 100644 --- a/apps/backend/src/eventHandlers/email/eliEmailHandler.spec.ts +++ b/apps/backend/src/eventHandlers/email/eliEmailHandler.spec.ts @@ -1,13 +1,15 @@ -import 'reflect-metadata'; import { faker } from '@faker-js/faker'; +import 'reflect-metadata'; import { container } from 'tsyringe'; import { Tokens } from '../../config/Tokens'; import { CoProposerClaimDataSourceMock } from '../../datasources/mockups/CoProposerClaimDataSource'; +import { EmailTemplateDataSourceMock } from '../../datasources/mockups/EmailTemplateDataSource'; import { RoleClaimDataSourceMock } from '../../datasources/mockups/RoleClaimDataSource'; import { ApplicationEvent } from '../../events/applicationEvents'; import { Event } from '../../events/event.enum'; import { eliEmailHandler } from './eliEmailHandler'; +import { EmailTemplateId } from './emailTemplateId'; // Mock MailService const mockMailService = { @@ -17,6 +19,7 @@ const mockMailService = { describe('eliEmailHandler', () => { let coProposerDataSourceMock: CoProposerClaimDataSourceMock; let roleClaimDataSourceMock: RoleClaimDataSourceMock; + let emailTemplateDataSourceMock: EmailTemplateDataSourceMock; beforeAll(() => { container.registerInstance(Tokens.MailService, mockMailService); @@ -30,6 +33,10 @@ describe('eliEmailHandler', () => { roleClaimDataSourceMock = container.resolve( Tokens.RoleClaimDataSource ); + emailTemplateDataSourceMock = + container.resolve( + Tokens.EmailTemplateDataSource + ); coProposerDataSourceMock.init(); roleClaimDataSourceMock.init(); @@ -54,6 +61,11 @@ describe('eliEmailHandler', () => { isRejection: false, } as ApplicationEvent; + const expectedEmailTemplate = + await emailTemplateDataSourceMock.getEmailTemplateByName( + EmailTemplateId.PROPOSAL_CREATED + ); + // Call the eliEmailHandler with the mock event await eliEmailHandler(mockEvent); @@ -61,7 +73,7 @@ describe('eliEmailHandler', () => { expect(mockMailService.sendMail).toHaveBeenCalledWith( expect.objectContaining({ content: { - template_id: 'proposal-created', + template: expectedEmailTemplate?.id.toString(), }, }) ); @@ -83,6 +95,11 @@ describe('eliEmailHandler', () => { isRejection: false, } as ApplicationEvent; + const expectedEmailTemplate = + await emailTemplateDataSourceMock.getEmailTemplateByName( + EmailTemplateId.ACCEPTED_PROPOSAL + ); + // Call the eliEmailHandler with the mock event await eliEmailHandler(mockEvent); @@ -90,7 +107,7 @@ describe('eliEmailHandler', () => { expect(mockMailService.sendMail).toHaveBeenCalledWith( expect.objectContaining({ content: { - template_id: 'Accepted-Proposal', + template: expectedEmailTemplate?.id.toString(), }, }) ); @@ -112,6 +129,11 @@ describe('eliEmailHandler', () => { isRejection: false, } as ApplicationEvent; + const expectedEmailTemplate = + await emailTemplateDataSourceMock.getEmailTemplateByName( + EmailTemplateId.REJECTED_PROPOSAL + ); + // Call the eliEmailHandler with the mock event await eliEmailHandler(mockEvent); @@ -119,7 +141,7 @@ describe('eliEmailHandler', () => { expect(mockMailService.sendMail).toHaveBeenCalledWith( expect.objectContaining({ content: { - template_id: 'Rejected-Proposal', + template: expectedEmailTemplate?.id.toString(), }, }) ); @@ -141,6 +163,11 @@ describe('eliEmailHandler', () => { isRejection: false, } as ApplicationEvent; + const expectedEmailTemplate = + await emailTemplateDataSourceMock.getEmailTemplateByName( + EmailTemplateId.RESERVED_PROPOSAL + ); + // Call the eliEmailHandler with the mock event await eliEmailHandler(mockEvent); @@ -148,13 +175,13 @@ describe('eliEmailHandler', () => { expect(mockMailService.sendMail).toHaveBeenCalledWith( expect.objectContaining({ content: { - template_id: 'Reserved-Proposal', + template: expectedEmailTemplate?.id.toString(), }, }) ); }); - test('should use template review-reminder', async () => { + test('should use template reviewer-reminder', async () => { // Create a mock event for FAP_REVIEWER_NOTIFIED const mockEvent: ApplicationEvent = { type: Event.FAP_REVIEWER_NOTIFIED, @@ -166,6 +193,11 @@ describe('eliEmailHandler', () => { isRejection: false, } as ApplicationEvent; + const expectedEmailTemplate = + await emailTemplateDataSourceMock.getEmailTemplateByName( + EmailTemplateId.REVIEW_REMINDER + ); + // Call the eliEmailHandler with the mock event await eliEmailHandler(mockEvent); @@ -173,7 +205,7 @@ describe('eliEmailHandler', () => { expect(mockMailService.sendMail).toHaveBeenCalledWith( expect.objectContaining({ content: { - template_id: 'review-reminder', + template: expectedEmailTemplate?.id.toString(), }, }) ); @@ -199,6 +231,11 @@ describe('eliEmailHandler', () => { isRejection: false, } as ApplicationEvent; + const expectedEmailTemplate = + await emailTemplateDataSourceMock.getEmailTemplateByName( + EmailTemplateId.INTERNAL_REVIEW_CREATED + ); + // Call the eliEmailHandler with the mock event await eliEmailHandler(mockEvent); @@ -206,7 +243,7 @@ describe('eliEmailHandler', () => { expect(mockMailService.sendMail).toHaveBeenCalledWith( expect.objectContaining({ content: { - template_id: 'internal-review-created', + template: expectedEmailTemplate?.id.toString(), }, }) ); @@ -232,6 +269,11 @@ describe('eliEmailHandler', () => { isRejection: false, } as ApplicationEvent; + const expectedEmailTemplate = + await emailTemplateDataSourceMock.getEmailTemplateByName( + EmailTemplateId.INTERNAL_REVIEW_UPDATED + ); + // Call the eliEmailHandler with the mock event await eliEmailHandler(mockEvent); @@ -239,7 +281,7 @@ describe('eliEmailHandler', () => { expect(mockMailService.sendMail).toHaveBeenCalledWith( expect.objectContaining({ content: { - template_id: 'internal-review-updated', + template: expectedEmailTemplate?.id.toString(), }, }) ); @@ -265,6 +307,11 @@ describe('eliEmailHandler', () => { isRejection: false, } as ApplicationEvent; + const expectedEmailTemplate = + await emailTemplateDataSourceMock.getEmailTemplateByName( + EmailTemplateId.INTERNAL_REVIEW_DELETED + ); + // Call the eliEmailHandler with the mock event await eliEmailHandler(mockEvent); @@ -272,7 +319,7 @@ describe('eliEmailHandler', () => { expect(mockMailService.sendMail).toHaveBeenCalledWith( expect.objectContaining({ content: { - template_id: 'internal-review-deleted', + template: expectedEmailTemplate?.id.toString(), }, }) ); diff --git a/apps/backend/src/eventHandlers/email/eliEmailHandler.ts b/apps/backend/src/eventHandlers/email/eliEmailHandler.ts index 1c155c6dce..56884ae744 100644 --- a/apps/backend/src/eventHandlers/email/eliEmailHandler.ts +++ b/apps/backend/src/eventHandlers/email/eliEmailHandler.ts @@ -3,10 +3,12 @@ import { container } from 'tsyringe'; import { Tokens } from '../../config/Tokens'; import { CallDataSource } from '../../datasources/CallDataSource'; +import { EmailTemplateDataSource } from '../../datasources/EmailTemplateDataSource'; import { FapDataSource } from '../../datasources/FapDataSource'; import { InviteDataSource } from '../../datasources/InviteDataSource'; import { ProposalDataSource } from '../../datasources/ProposalDataSource'; 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'; @@ -16,6 +18,7 @@ import { ProposalEndStatus } from '../../models/Proposal'; import { BasicUserDetails } from '../../models/User'; import EmailSettings from '../MailService/EmailSettings'; import { MailService } from '../MailService/MailService'; +import { EmailTemplateId } from './emailTemplateId'; export async function eliEmailHandler(event: ApplicationEvent) { const mailService = container.resolve(Tokens.MailService); @@ -27,6 +30,14 @@ 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 ); @@ -38,6 +49,10 @@ export async function eliEmailHandler(event: ApplicationEvent) { Tokens.EventBus ); + const emailTemplateDataSource = container.resolve( + Tokens.EmailTemplateDataSource + ); + if (event.isRejection) { return; } @@ -75,6 +90,7 @@ export async function eliEmailHandler(event: ApplicationEvent) { }); }); } + break; } @@ -89,86 +105,43 @@ export async function eliEmailHandler(event: ApplicationEvent) { return; } - const options: EmailSettings = { - content: { - template_id: 'proposal-created', - }, - substitution_data: { - piPreferredname: principalInvestigator.preferredname, - piLastname: principalInvestigator.lastname, - proposalNumber: event.proposal.proposalId, - proposalTitle: event.proposal.title, - callShortCode: call.shortCode, - }, - recipients: [{ address: principalInvestigator.email }], - }; + const template = EmailTemplateId.PROPOSAL_CREATED; - mailService - .sendMail(options) - .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, - }); - }); + const emailTemplate = + await emailTemplateDataSource.getEmailTemplateByName(template); - return; - } + if (!emailTemplate) { + logger.logError('Email template not found', { + template, + }); - case Event.PROPOSAL_SUBMITTED: { - const principalInvestigator = await userDataSource.getUser( - event.proposal.proposerId - ); - const participants = await userDataSource.getProposalUsersFull( - event.proposal.primaryKey - ); - if (!principalInvestigator) { return; } const options: EmailSettings = { content: { - template_id: 'proposal-submitted', + template: emailTemplate.id.toString(), }, substitution_data: { - piPreferredname: principalInvestigator.preferredname, - piLastname: principalInvestigator.lastname, - proposalNumber: event.proposal.proposalId, - proposalTitle: event.proposal.title, - coProposers: participants.map( - (partipant) => `${partipant.preferredname} ${partipant.lastname} ` - ), - call: '', + preferredName: principalInvestigator.preferredname, + lastName: principalInvestigator.lastname, + firstName: principalInvestigator.firstname, + proposal: event.proposal, + call: call, }, - recipients: [ - { address: principalInvestigator.email }, - ...participants.map((partipant) => { - return { - address: { - email: partipant.email, - header_to: principalInvestigator.email, - }, - }; - }), - ], + recipients: [{ address: principalInvestigator.email }], }; mailService .sendMail(options) .then((res: any) => { - logger.logInfo('Emails sent on proposal submission:', { + logger.logInfo('Emails sent on proposal creation:', { result: res, event, }); }) .catch((err: string) => { - logger.logError('Could not send email(s) on proposal submission:', { + logger.logError('Could not send email(s) on proposal creation:', { error: err, event, }); @@ -185,30 +158,40 @@ export async function eliEmailHandler(event: ApplicationEvent) { return; } const { finalStatus } = event.proposal; - let templateId = ''; + let template = ''; if (finalStatus === ProposalEndStatus.ACCEPTED) { - templateId = 'Accepted-Proposal'; + template = EmailTemplateId.ACCEPTED_PROPOSAL; } else if (finalStatus === ProposalEndStatus.REJECTED) { - templateId = 'Rejected-Proposal'; + template = EmailTemplateId.REJECTED_PROPOSAL; } else if (finalStatus === ProposalEndStatus.RESERVED) { - templateId = 'Reserved-Proposal'; + template = EmailTemplateId.RESERVED_PROPOSAL; } else { logger.logError('Failed email notification', { event }); return; } + const emailTemplate = + await emailTemplateDataSource.getEmailTemplateByName(template); + + if (!emailTemplate) { + logger.logError('Email template not found', { + template, + }); + + return; + } + mailService .sendMail({ content: { - template_id: templateId, + template: emailTemplate.id.toString(), }, substitution_data: { - piPreferredname: principalInvestigator.preferredname, - piLastname: principalInvestigator.lastname, - proposalNumber: event.proposal.proposalId, - proposalTitle: event.proposal.title, - commentForUser: event.proposal.commentForUser, + preferredName: principalInvestigator.preferredname, + lastName: principalInvestigator.lastname, + firstName: principalInvestigator.firstname, + proposal: event.proposal, }, recipients: [{ address: principalInvestigator.email }], }) @@ -236,17 +219,27 @@ export async function eliEmailHandler(event: ApplicationEvent) { return; } + const template = EmailTemplateId.REVIEW_REMINDER; + const emailTemplate = + await emailTemplateDataSource.getEmailTemplateByName(template); + + if (!emailTemplate) { + logger.logError('Email template not found', { + template, + }); + + return; + } + mailService .sendMail({ content: { - template_id: 'review-reminder', + template: emailTemplate.id.toString(), }, substitution_data: { - fapReviewerPreferredName: fapReviewer.preferredname, - fapReviewerLastName: fapReviewer.lastname, - proposalNumber: proposal.proposalId, - proposalTitle: proposal.title, - commentForUser: proposal.commentForUser, + preferredName: fapReviewer.preferredname, + lastName: fapReviewer.lastname, + proposal: proposal, }, recipients: [{ address: fapReviewer.email }], }) @@ -315,18 +308,29 @@ export async function eliEmailHandler(event: ApplicationEvent) { } } - let templateId = 'internal-review-created'; + let template = EmailTemplateId.INTERNAL_REVIEW_CREATED; if (event.type === Event.INTERNAL_REVIEW_UPDATED) { - templateId = 'internal-review-updated'; + template = EmailTemplateId.INTERNAL_REVIEW_UPDATED; } else if (event.type === Event.INTERNAL_REVIEW_DELETED) { - templateId = 'internal-review-deleted'; + template = EmailTemplateId.INTERNAL_REVIEW_DELETED; + } + + const emailTemplate = + await emailTemplateDataSource.getEmailTemplateByName(template); + + if (!emailTemplate) { + logger.logError('Email template not found', { + template, + }); + + return; } mailService .sendMail({ content: { - template_id: templateId, + template: emailTemplate.id.toString(), }, substitution_data: { assignedByPreferredName: assignedBy.preferredname, @@ -335,8 +339,7 @@ export async function eliEmailHandler(event: ApplicationEvent) { reviewerLastname: reviewer.lastname, technicalReviewerPreferredName: technicalReviewerPreferredName, technicalReviewerLastname: technicalReviewerLastname, - proposalTitle: proposal.title, - proposalNumber: proposal.proposalId, + proposal: proposal, reviewTitle: event.internalreview.title, }, recipients: [{ address: reviewer.email }], @@ -360,17 +363,31 @@ export async function eliEmailHandler(event: ApplicationEvent) { async function sendInviteEmail( invite: Invite, inviter: BasicUserDetails, - templateId: string + template: string ) { const mailService = container.resolve(Tokens.MailService); const inviteDataSource = container.resolve( Tokens.InviteDataSource ); + const emailTemplateDataSource = container.resolve( + Tokens.EmailTemplateDataSource + ); + + const emailTemplate = + await emailTemplateDataSource.getEmailTemplateByName(template); + + if (!emailTemplate) { + logger.logError('Email template not found', { + template: template, + }); + + return; + } return mailService .sendMail({ content: { - template_id: templateId, + template: emailTemplate.id.toString(), }, substitution_data: { email: invite.email, @@ -385,7 +402,7 @@ async function sendInviteEmail( await inviteDataSource.update({ id: invite.id, isEmailSent: true, - templateId: templateId, + templateId: template, }); logger.logInfo('Successful email transmission', { res }); }) diff --git a/apps/backend/src/eventHandlers/email/emailTemplateId.ts b/apps/backend/src/eventHandlers/email/emailTemplateId.ts new file mode 100644 index 0000000000..225e319523 --- /dev/null +++ b/apps/backend/src/eventHandlers/email/emailTemplateId.ts @@ -0,0 +1,20 @@ +export enum EmailTemplateId { + CO_PROPOSER_INVITE_ACCEPTED = 'co-proposer-invite-accepted', + PROPOSAL_SUBMITTED = 'proposal-submitted', + PROPOSAL_CREATED = 'proposal-created', + 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', + INTERNAL_REVIEW_CREATED = 'internal-review-created', + INTERNAL_REVIEW_UPDATED = 'internal-review-updated', + INTERNAL_REVIEW_DELETED = 'internal-review-deleted', + CALL_CREATED_EMAIL = 'call-created-email', + FEEDBACK_REQUEST = 'feedback-request', +} diff --git a/apps/backend/src/eventHandlers/email/essEmailHandler.spec.ts b/apps/backend/src/eventHandlers/email/essEmailHandler.spec.ts index 1487554311..8d55b8bafa 100644 --- a/apps/backend/src/eventHandlers/email/essEmailHandler.spec.ts +++ b/apps/backend/src/eventHandlers/email/essEmailHandler.spec.ts @@ -1,14 +1,14 @@ -import 'reflect-metadata'; import { faker } from '@faker-js/faker'; import { logger } from '@user-office-software/duo-logger'; +import 'reflect-metadata'; import { container } from 'tsyringe'; import { Tokens } from '../../config/Tokens'; import { AdminDataSourceMock } from '../../datasources/mockups/AdminDataSource'; import { CoProposerClaimDataSourceMock } from '../../datasources/mockups/CoProposerClaimDataSource'; import { - ProposalDataSourceMock, dummyProposal, + ProposalDataSourceMock, } from '../../datasources/mockups/ProposalDataSource'; import { basicDummyUser, @@ -20,7 +20,8 @@ import { ApplicationEvent } from '../../events/applicationEvents'; import { Event } from '../../events/event.enum'; import { Invite } from '../../models/Invite'; import { Settings, SettingsId } from '../../models/Settings'; -import { EmailTemplateId, essEmailHandler } from './essEmailHandler'; +import { EmailTemplateId } from './emailTemplateId'; +import { essEmailHandler } from './essEmailHandler'; // Mock MailService const mockMailService = { @@ -87,7 +88,7 @@ describe('essEmailHandler co-proposer invites', () => { expect(mockMailService.sendMail).toHaveBeenCalledWith({ content: { - template_id: EmailTemplateId.CO_PROPOSER_INVITE_ACCEPTED, + template: EmailTemplateId.CO_PROPOSER_INVITE_ACCEPTED, }, substitution_data: { piPreferredname: expect.any(String), @@ -138,7 +139,7 @@ describe('essEmailHandler co-proposer invites', () => { expect(mockMailService.sendMail).toHaveBeenCalledWith({ content: { - template_id: + template: EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_VISIT_REGISTRATION, }, substitution_data: { @@ -361,7 +362,7 @@ describe('essEmailHandler co-proposer invites', () => { expect(mockMailService.sendMail).toHaveBeenCalledWith( expect.objectContaining({ content: { - template_id: + template: EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_CO_PROPOSER, }, substitution_data: expect.objectContaining({ @@ -433,7 +434,7 @@ describe('essEmailHandler co-proposer invites', () => { expect(sendMailsSpy).toHaveBeenCalledTimes(1); const arg = sendMailsSpy.mock.calls[0][0]; - expect(arg.content.template_id).toBe(EmailTemplateId.PROPOSAL_SUBMITTED); + expect(arg.content.template).toBe(EmailTemplateId.PROPOSAL_SUBMITTED); // Recipients: first is PI, rest are co-proposers with header_to pointing to PI expect(arg.recipients).toEqual([ @@ -490,7 +491,7 @@ describe('essEmailHandler co-proposer invites', () => { expect(mockMailService.sendMail).toHaveBeenCalledWith( expect.objectContaining({ - content: { template_id: EmailTemplateId.VISIT_REGISTRATION_APPROVED }, + content: { template: EmailTemplateId.VISIT_REGISTRATION_APPROVED }, substitution_data: expect.objectContaining({ preferredname: dummyUser.preferredname, startsAt: expect.stringMatching(FORMATTED_DATE_WITH_TZ), @@ -524,7 +525,7 @@ describe('essEmailHandler co-proposer invites', () => { expect(mockMailService.sendMail).toHaveBeenCalledWith( expect.objectContaining({ content: { - template_id: EmailTemplateId.VISIT_REGISTRATION_CANCELLED, + template: EmailTemplateId.VISIT_REGISTRATION_CANCELLED, }, substitution_data: expect.objectContaining({ preferredname: dummyUser.preferredname, diff --git a/apps/backend/src/eventHandlers/email/essEmailHandler.ts b/apps/backend/src/eventHandlers/email/essEmailHandler.ts index b4392ee4e9..2db0d186f9 100644 --- a/apps/backend/src/eventHandlers/email/essEmailHandler.ts +++ b/apps/backend/src/eventHandlers/email/essEmailHandler.ts @@ -20,21 +20,7 @@ import { SettingsId } from '../../models/Settings'; 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', -} +import { EmailTemplateId } from './emailTemplateId'; function formatEmailDate( value: Date | null, @@ -141,7 +127,7 @@ export async function essEmailHandler(event: ApplicationEvent) { mailService .sendMail({ content: { - template_id: EmailTemplateId.CO_PROPOSER_INVITE_ACCEPTED, + template: EmailTemplateId.CO_PROPOSER_INVITE_ACCEPTED, }, substitution_data: { piPreferredname: principalInvestigator.preferredname, @@ -185,7 +171,7 @@ export async function essEmailHandler(event: ApplicationEvent) { const options: EmailSettings = { content: { - template_id: EmailTemplateId.PROPOSAL_SUBMITTED, + template: EmailTemplateId.PROPOSAL_SUBMITTED, }, substitution_data: { piPreferredname: principalInvestigator.preferredname, @@ -253,7 +239,7 @@ export async function essEmailHandler(event: ApplicationEvent) { mailService .sendMail({ content: { - template_id: templateId, + template: templateId, }, substitution_data: { piPreferredname: principalInvestigator.preferredname, @@ -383,7 +369,7 @@ export async function essEmailHandler(event: ApplicationEvent) { mailService .sendMail({ content: { - template_id: EmailTemplateId.REVIEW_REMINDER, + template: EmailTemplateId.REVIEW_REMINDER, }, substitution_data: { fapReviewerPreferredName: fapReviewer.preferredname, @@ -468,7 +454,7 @@ export async function essEmailHandler(event: ApplicationEvent) { mailService .sendMail({ content: { - template_id: templateId, + template: templateId, }, substitution_data: { preferredname: user.preferredname, @@ -534,7 +520,7 @@ async function sendInviteEmail( return mailService .sendMail({ content: { - template_id: templateId, + template: templateId, }, substitution_data: { email: invite.email, diff --git a/apps/backend/src/eventHandlers/email/stfcEmailHandler.spec.ts b/apps/backend/src/eventHandlers/email/stfcEmailHandler.spec.ts index fbc8f3e739..a887ba306c 100644 --- a/apps/backend/src/eventHandlers/email/stfcEmailHandler.spec.ts +++ b/apps/backend/src/eventHandlers/email/stfcEmailHandler.spec.ts @@ -1,6 +1,6 @@ -import 'reflect-metadata'; import { faker } from '@faker-js/faker'; import * as Logger from '@user-office-software/duo-logger'; +import 'reflect-metadata'; import { container } from 'tsyringe'; import { Tokens } from '../../config/Tokens'; @@ -69,7 +69,7 @@ describe('stfcEmailHandler', () => { expect(process.env.FBS_EMAIL).toBe(inviteEmail); expect(mockMailService.sendMail).toHaveBeenCalledWith({ - content: { template_id: 'call-created-email' }, + content: { template: 'call-created-email' }, substitution_data: { shortCode: 'string', startCall: new Date(2000, 1, 1), @@ -109,7 +109,7 @@ describe('stfcEmailHandler', () => { await new Promise(setImmediate); expect(mockMailService.sendMail).toHaveBeenCalledWith({ - content: { template_id: 'call-created-email' }, + content: { template: 'call-created-email' }, substitution_data: { shortCode: 'error', }, diff --git a/apps/backend/src/eventHandlers/email/stfcEmailHandler.ts b/apps/backend/src/eventHandlers/email/stfcEmailHandler.ts index 3c4c253c5d..a4abf1c09b 100644 --- a/apps/backend/src/eventHandlers/email/stfcEmailHandler.ts +++ b/apps/backend/src/eventHandlers/email/stfcEmailHandler.ts @@ -6,6 +6,7 @@ import { ApplicationEvent } from '../../events/applicationEvents'; import { Event } from '../../events/event.enum'; import EmailSettings from '../MailService/EmailSettings'; import { MailService } from '../MailService/MailService'; +import { EmailTemplateId } from './emailTemplateId'; export async function stfcEmailHandler(event: ApplicationEvent) { //test for null @@ -26,7 +27,7 @@ export async function stfcEmailHandler(event: ApplicationEvent) { return; } - const templateID = 'call-created-email'; + const notificationEmailAddress = process.env.FBS_EMAIL; const eventCallPartial = (({ shortCode, startCall, endCall }) => ({ shortCode, @@ -35,7 +36,7 @@ export async function stfcEmailHandler(event: ApplicationEvent) { }))(event.call); const emailSettings = callCreationEmail( eventCallPartial, - templateID, + EmailTemplateId.CALL_CREATED_EMAIL, notificationEmailAddress ); @@ -67,7 +68,7 @@ const callCreationEmail = function createNotificationEmail( ): EmailSettings { const emailSettings: EmailSettings = { content: { - template_id: templateID, + template: templateID, }, substitution_data: { ...notificationInput, diff --git a/apps/backend/src/eventHandlers/messageBroker.ts b/apps/backend/src/eventHandlers/messageBroker.ts index edd4b88b0f..1d70cb352d 100644 --- a/apps/backend/src/eventHandlers/messageBroker.ts +++ b/apps/backend/src/eventHandlers/messageBroker.ts @@ -293,6 +293,18 @@ export async function createPostToRabbitMQHandler() { ); break; } + case Event.EMAIL_TEMPLATE_CREATED: + case Event.EMAIL_TEMPLATE_UPDATED: + case Event.EMAIL_TEMPLATE_DELETED: { + const jsonMessage = JSON.stringify(event.emailtemplate); + + await rabbitMQ.sendMessageToExchange( + EXCHANGE_NAME, + event.type, + jsonMessage + ); + break; + } case Event.TOPIC_ANSWERED: { const proposal = await proposalDataSource.getProposals({ questionaryIds: event.array.map((a) => a.questionaryId), diff --git a/apps/backend/src/events/applicationEvents.ts b/apps/backend/src/events/applicationEvents.ts index 0b737f385b..d43d97bef4 100644 --- a/apps/backend/src/events/applicationEvents.ts +++ b/apps/backend/src/events/applicationEvents.ts @@ -1,4 +1,5 @@ import { Call } from '../models/Call'; +import { EmailTemplate } from '../models/EmailTemplate'; import { ExperimentSafety } from '../models/Experiment'; import { Fap, FapProposal } from '../models/Fap'; import { FapMeetingDecision } from '../models/FapMeetingDecision'; @@ -451,6 +452,21 @@ interface ExperimentSafetyStatusChangedByUserEvent extends GeneralEvent { experimentsafety: ExperimentSafety; } +interface EmailTemplateCreatedEvent extends GeneralEvent { + type: Event.EMAIL_TEMPLATE_CREATED; + emailtemplate: EmailTemplate; +} + +interface EmailTemplateUpdatedEvent extends GeneralEvent { + type: Event.EMAIL_TEMPLATE_UPDATED; + emailtemplate: EmailTemplate; +} + +interface EmailTemplateDeletedEvent extends GeneralEvent { + type: Event.EMAIL_TEMPLATE_DELETED; + emailtemplate: EmailTemplate; +} + export type ApplicationEvent = | ProposalAcceptedEvent | ProposalUpdatedEvent @@ -535,4 +551,7 @@ export type ApplicationEvent = | ExperimentSafetyManagementDecisionSubmittedByISEvent | ExperimentSafetyManagementDecisionSubmittedByESREvent | ExperimentSafetyStatusChangedByWorkflowEvent - | ExperimentSafetyStatusChangedByUserEvent; + | ExperimentSafetyStatusChangedByUserEvent + | EmailTemplateCreatedEvent + | EmailTemplateUpdatedEvent + | EmailTemplateDeletedEvent; diff --git a/apps/backend/src/events/event.enum.ts b/apps/backend/src/events/event.enum.ts index 9ed57310d9..ae83c2e64e 100644 --- a/apps/backend/src/events/event.enum.ts +++ b/apps/backend/src/events/event.enum.ts @@ -96,6 +96,9 @@ 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', + EMAIL_TEMPLATE_CREATED = 'EMAIL_TEMPLATE_CREATED', + EMAIL_TEMPLATE_UPDATED = 'EMAIL_TEMPLATE_UPDATED', + EMAIL_TEMPLATE_DELETED = 'EMAIL_TEMPLATE_DELETED', VISIT_CREATED = 'VISIT_CREATED', } @@ -416,4 +419,7 @@ export const EventLabel = new Map([ Event.EXPERIMENT_SAFETY_STATUS_CHANGED_BY_WORKFLOW, 'Event occurs when experiment safety status is changed by workflow', ], + [Event.EMAIL_TEMPLATE_CREATED, 'Event occurs when email template is created'], + [Event.EMAIL_TEMPLATE_UPDATED, 'Event occurs when email template is updated'], + [Event.EMAIL_TEMPLATE_DELETED, 'Event occurs when email template is deleted'], ]); diff --git a/apps/backend/src/models/EmailTemplate.ts b/apps/backend/src/models/EmailTemplate.ts new file mode 100644 index 0000000000..421f7a015d --- /dev/null +++ b/apps/backend/src/models/EmailTemplate.ts @@ -0,0 +1,11 @@ +export class EmailTemplate { + constructor( + public id: number, + public createdByUserId: number, + public name: string, + public description: string, + public useTemplateFile: boolean, + public subject?: string | null, + public body?: string | null + ) {} +} diff --git a/apps/backend/src/models/Invite.ts b/apps/backend/src/models/Invite.ts index 449682dff2..e406287693 100644 --- a/apps/backend/src/models/Invite.ts +++ b/apps/backend/src/models/Invite.ts @@ -1,4 +1,4 @@ -import { EmailTemplateId } from '../eventHandlers/email/essEmailHandler'; +import { EmailTemplateId } from '../eventHandlers/email/emailTemplateId'; export class Invite { constructor( diff --git a/apps/backend/src/mutations/EmailTemplateMutation.spec.ts b/apps/backend/src/mutations/EmailTemplateMutation.spec.ts new file mode 100644 index 0000000000..d2e6b5ee6f --- /dev/null +++ b/apps/backend/src/mutations/EmailTemplateMutation.spec.ts @@ -0,0 +1,90 @@ +import { container } from 'tsyringe'; + +import { dummyEmailTemplate } from '../datasources/mockups/EmailTemplateDataSource'; +import { + dummyUserOfficerWithRole, + dummyUserWithRole, +} from '../datasources/mockups/UserDataSource'; +import EmailTemplateMutations from './EmailTemplateMutations'; + +const emailTemplateMutations = container.resolve(EmailTemplateMutations); + +beforeEach(() => {}); + +describe('Test Email Template Mutations', () => { + test('A user can not create an email template', () => { + return expect( + emailTemplateMutations.create(dummyUserWithRole, { + name: 'Dummy Email Template', + description: 'This is a dummy email template for testing purposes.', + useTemplateFile: false, + subject: 'Welcome to Our Service', + body: 'Hello, thank you for signing up for our service. We are excited to have you on board!', + }) + ).resolves.toHaveProperty('reason', 'INSUFFICIENT_PERMISSIONS'); + }); + + test('A not logged in user can not create an email template', () => { + return expect( + emailTemplateMutations.create(null, { + name: 'Dummy Email Template', + description: 'This is a dummy email template for testing purposes.', + useTemplateFile: false, + subject: 'Welcome to Our Service', + body: 'Hello, thank you for signing up for our service. We are excited to have you on board!', + }) + ).resolves.toHaveProperty('reason', 'NOT_LOGGED_IN'); + }); + + test('A logged in user officer can create an email template', () => { + const emailTemplateToCreate = { + name: 'Dummy Email Template', + description: 'This is a dummy email template for testing purposes.', + useTemplateFile: false, + subject: 'Welcome to Our Service', + body: 'Hello, thank you for signing up for our service. We are excited to have you on board!', + }; + + return expect( + emailTemplateMutations.create( + dummyUserOfficerWithRole, + emailTemplateToCreate + ) + ).resolves.toStrictEqual({ + id: 1, + createdAt: '', + createdByUserId: 1, + ...emailTemplateToCreate, + }); + }); + + test('A logged in user officer can update an email template', () => { + const emailTemplateToUpdate = { + id: 1, + name: 'Dummy Email Template', + description: 'This is a dummy email template for testing purposes.', + useTemplateFile: false, + subject: 'Welcome to Our Service', + body: 'Hello, thank you for signing up for our service. We are excited to have you on board!', + }; + + return expect( + emailTemplateMutations.update( + dummyUserOfficerWithRole, + emailTemplateToUpdate + ) + ).resolves.toStrictEqual({ + createdAt: '', + createdByUserId: 1, + ...emailTemplateToUpdate, + }); + }); + + test('A logged in user officer can delete email template', () => { + return expect( + emailTemplateMutations.delete(dummyUserOfficerWithRole, { + emailTemplateId: 1, + }) + ).resolves.toBe(dummyEmailTemplate); + }); +}); diff --git a/apps/backend/src/mutations/EmailTemplateMutations.ts b/apps/backend/src/mutations/EmailTemplateMutations.ts new file mode 100644 index 0000000000..695ff4d404 --- /dev/null +++ b/apps/backend/src/mutations/EmailTemplateMutations.ts @@ -0,0 +1,124 @@ +import { + createEmailTemplateValidationSchema, + updateEmailTemplateValidationSchema, +} from '@user-office-software/duo-validation'; +import { inject, injectable } from 'tsyringe'; + +import { Tokens } from '../config/Tokens'; +import { EmailTemplateDataSource } from '../datasources/EmailTemplateDataSource'; +import StatusActionsDataSource from '../datasources/postgres/StatusActionsDataSource'; +import { Authorized, EventBus, ValidateArgs } from '../decorators'; +import { Event } from '../events/event.enum'; +import { EmailTemplate } from '../models/EmailTemplate'; +import { Rejection, rejection } from '../models/Rejection'; +import { Roles } from '../models/Role'; +import { UserWithRole } from '../models/User'; +import { CreateEmailTemplateInput } from '../resolvers/mutations/CreateEmailTemplateMutation'; +import { UpdateEmailTemplateInput } from '../resolvers/mutations/UpdateEmailTemplateMutation'; + +@injectable() +export default class EmailTemplateMutations { + constructor( + @inject(Tokens.EmailTemplateDataSource) + private dataSource: EmailTemplateDataSource, + @inject(Tokens.StatusActionsDataSource) + private statusActionsDataSource: StatusActionsDataSource + ) {} + + @ValidateArgs(createEmailTemplateValidationSchema) + @EventBus(Event.EMAIL_TEMPLATE_CREATED) + @Authorized([Roles.USER_OFFICER]) + async create( + agent: UserWithRole | null, + args: CreateEmailTemplateInput + ): Promise { + if (!agent) { + return rejection('Unauthorized', { args }); + } + + try { + const createdEmailTemplate = await this.dataSource.create( + agent.id, + args.name, + args.description, + args.useTemplateFile, + args.subject, + args.body + ); + + return createdEmailTemplate; + } catch (error) { + return rejection('Could not create email template', { + agent, + name: args.name, + }); + } + } + + @ValidateArgs(updateEmailTemplateValidationSchema) + @Authorized([Roles.USER_OFFICER]) + @EventBus(Event.EMAIL_TEMPLATE_UPDATED) + async update( + agent: UserWithRole | null, + args: UpdateEmailTemplateInput + ): Promise { + try { + const updatedEmailTemplate = await this.dataSource.update( + args.id, + args.name, + args.description, + args.useTemplateFile, + args.subject, + args.body + ); + + return updatedEmailTemplate; + } catch (error) { + return rejection('Could not update email template', { + agent, + name: args.name, + }); + } + } + + @Authorized([Roles.USER_OFFICER]) + @EventBus(Event.EMAIL_TEMPLATE_DELETED) + async delete( + agent: UserWithRole | null, + { emailTemplateId }: { emailTemplateId: number } + ): Promise { + const emailTemplate = + await this.dataSource.getEmailTemplate(emailTemplateId); + + if (!emailTemplate) { + return rejection('Email template not found', { emailTemplateId }); + } + + const has = + await this.statusActionsDataSource.hasEmailTemplateIdConnectionStatusAction( + emailTemplate.id + ); + + if (has) { + return rejection( + 'Could not delete email template (used in status actions)', + { emailTemplateId } + ); + } + + try { + const result = await this.dataSource.delete(emailTemplateId); + + return result; + } catch (error) { + return rejection( + 'Failed to delete email template', + { + agent, + emailTemplateId, + }, + error + ); + } + } +} diff --git a/apps/backend/src/mutations/FeedbackMutations.ts b/apps/backend/src/mutations/FeedbackMutations.ts index 492ede0aaf..055d4a08d1 100644 --- a/apps/backend/src/mutations/FeedbackMutations.ts +++ b/apps/backend/src/mutations/FeedbackMutations.ts @@ -9,11 +9,11 @@ import { ExperimentDataSource } from '../datasources/ExperimentDataSource'; import { FeedbackDataSource } from '../datasources/FeedbackDataSource'; import { QuestionaryDataSource } from '../datasources/QuestionaryDataSource'; import { Authorized } from '../decorators'; +import { EmailTemplateId } from '../eventHandlers/email/emailTemplateId'; import { MailService } from '../eventHandlers/MailService/MailService'; import { ExperimentStatus } from '../models/Experiment'; import { Feedback, FeedbackStatus } from '../models/Feedback'; -import { rejection } from '../models/Rejection'; -import { Rejection } from '../models/Rejection'; +import { rejection, Rejection } from '../models/Rejection'; import { Roles } from '../models/Role'; import { SettingsId } from '../models/Settings'; import { TemplateGroupId } from '../models/Template'; @@ -327,7 +327,7 @@ export default class FeedbackMutations { try { const { results } = await this.mailService.sendMail({ content: { - template_id: 'feedback-request', + template: EmailTemplateId.FEEDBACK_REQUEST, }, substitution_data: { teamleadPreferredname: teamLead.preferredname, diff --git a/apps/backend/src/mutations/InviteMutations.spec.ts b/apps/backend/src/mutations/InviteMutations.spec.ts index d062362821..d623b0123f 100644 --- a/apps/backend/src/mutations/InviteMutations.spec.ts +++ b/apps/backend/src/mutations/InviteMutations.spec.ts @@ -1,5 +1,5 @@ -import 'reflect-metadata'; import { faker } from '@faker-js/faker'; +import 'reflect-metadata'; import { container } from 'tsyringe'; import { Tokens } from '../config/Tokens'; @@ -16,7 +16,7 @@ import { } from '../datasources/mockups/UserDataSource'; import { VisitDataSourceMock } from '../datasources/mockups/VisitDataSource'; import { VisitDataSource } from '../datasources/VisitDataSource'; -import { EmailTemplateId } from '../eventHandlers/email/essEmailHandler'; +import { EmailTemplateId } from '../eventHandlers/email/emailTemplateId'; import { MailService } from '../eventHandlers/MailService/MailService'; import { Event } from '../events/event.enum'; import { Invite } from '../models/Invite'; @@ -238,7 +238,7 @@ describe('Test Invite Mutations', () => { expect.objectContaining({ recipients: [{ address: email }], content: { - template_id: + template: EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_CO_PROPOSER, }, }) @@ -310,7 +310,7 @@ describe('Test Invite Mutations', () => { expect.objectContaining({ recipients: [{ address: email }], content: { - template_id: + template: EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_VISIT_REGISTRATION, }, }) diff --git a/apps/backend/src/queries/EmailTemplateQueries.ts b/apps/backend/src/queries/EmailTemplateQueries.ts new file mode 100644 index 0000000000..057e9246a0 --- /dev/null +++ b/apps/backend/src/queries/EmailTemplateQueries.ts @@ -0,0 +1,28 @@ +import { inject, injectable } from 'tsyringe'; + +import { UserAuthorization } from '../auth/UserAuthorization'; +import { Tokens } from '../config/Tokens'; +import { EmailTemplateDataSource } from '../datasources/EmailTemplateDataSource'; +import { Authorized } from '../decorators'; +import { Roles } from '../models/Role'; +import { UserWithRole } from '../models/User'; +import { EmailTemplatesFilter } from '../resolvers/queries/EmailTemplatesQuery'; + +@injectable() +export default class EmailTemplateQueries { + constructor( + @inject(Tokens.EmailTemplateDataSource) + public dataSource: EmailTemplateDataSource, + @inject(Tokens.UserAuthorization) private userAuth: UserAuthorization + ) {} + + @Authorized([Roles.USER_OFFICER]) + async get(agent: UserWithRole | null, id: number) { + return await this.dataSource.getEmailTemplate(id); + } + + @Authorized([Roles.USER_OFFICER]) + async getAll(agent: UserWithRole | null, filter: EmailTemplatesFilter) { + return this.dataSource.getEmailTemplates(filter); + } +} diff --git a/apps/backend/src/queries/StatusActionQueries.ts b/apps/backend/src/queries/StatusActionQueries.ts index 7bf1463966..7939773c46 100644 --- a/apps/backend/src/queries/StatusActionQueries.ts +++ b/apps/backend/src/queries/StatusActionQueries.ts @@ -1,6 +1,8 @@ +import { logger } from '@user-office-software/duo-logger'; import { inject, injectable } from 'tsyringe'; import { Tokens } from '../config/Tokens'; +import { EmailTemplateDataSource } from '../datasources/EmailTemplateDataSource'; import { StatusActionsDataSource } from '../datasources/StatusActionsDataSource'; import { Authorized } from '../decorators'; import { MailService } from '../eventHandlers/MailService/MailService'; @@ -21,7 +23,9 @@ export default class StatusActionQueries { @inject(Tokens.StatusActionsDataSource) public dataSource: StatusActionsDataSource, @inject(Tokens.MailService) - public emailService: MailService + public emailService: MailService, + @inject(Tokens.EmailTemplateDataSource) + public emailTemplateDataSource: EmailTemplateDataSource ) {} @Authorized([Roles.USER_OFFICER]) @@ -59,15 +63,18 @@ export default class StatusActionQueries { description: EmailStatusActionRecipientsWithDescription.get(item), })); - const sparkPostEmailTemplates = + const emailTemplatesResult = await this.emailService.getEmailTemplates(); - const emailTemplates = sparkPostEmailTemplates.results.map((item) => ({ - id: item.id, - name: item.name, - })); + logger.logInfo('Email templates', { emailTemplatesResult }); - return new EmailActionDefaultConfig(allEmailRecipients, emailTemplates); + return new EmailActionDefaultConfig( + allEmailRecipients, + emailTemplatesResult.results.map((e) => ({ + id: e.id, + name: e.name, + })) + ); case StatusActionType.RABBITMQ: // NOTE: For now we return just the default exchange. diff --git a/apps/backend/src/resolvers/mutations/CreateEmailTemplateMutation.ts b/apps/backend/src/resolvers/mutations/CreateEmailTemplateMutation.ts new file mode 100644 index 0000000000..65cfa16488 --- /dev/null +++ b/apps/backend/src/resolvers/mutations/CreateEmailTemplateMutation.ts @@ -0,0 +1,37 @@ +import { Arg, Ctx, Field, InputType, Mutation, Resolver } from 'type-graphql'; + +import { ResolverContext } from '../../context'; +import { EmailTemplate } from '../types/EmailTemplate'; + +@InputType() +export class CreateEmailTemplateInput { + @Field(() => String) + public name: string; + + @Field(() => String) + public description: string; + + @Field(() => Boolean) + public useTemplateFile: boolean; + + @Field(() => String, { nullable: true }) + public subject?: string; + + @Field(() => String, { nullable: true }) + public body?: string; +} + +@Resolver() +export class CreateEmailTemplateMutation { + @Mutation(() => EmailTemplate) + createEmailTemplate( + @Arg('createEmailTemplateInput') + createEmailTemplateInput: CreateEmailTemplateInput, + @Ctx() context: ResolverContext + ) { + return context.mutations.emailTemplate.create( + context.user, + createEmailTemplateInput + ); + } +} diff --git a/apps/backend/src/resolvers/mutations/DeleteEmailTemplateMutation.ts b/apps/backend/src/resolvers/mutations/DeleteEmailTemplateMutation.ts new file mode 100644 index 0000000000..aa6ce21328 --- /dev/null +++ b/apps/backend/src/resolvers/mutations/DeleteEmailTemplateMutation.ts @@ -0,0 +1,17 @@ +import { Arg, Ctx, Int, Mutation, Resolver } from 'type-graphql'; + +import { ResolverContext } from '../../context'; +import { EmailTemplate } from '../types/EmailTemplate'; + +@Resolver() +export class DeleteEmailTemplateMutation { + @Mutation(() => EmailTemplate) + deleteEmailTemplate( + @Arg('id', () => Int) id: number, + @Ctx() context: ResolverContext + ) { + return context.mutations.emailTemplate.delete(context.user, { + emailTemplateId: id, + }); + } +} diff --git a/apps/backend/src/resolvers/mutations/UpdateEmailTemplateMutation.ts b/apps/backend/src/resolvers/mutations/UpdateEmailTemplateMutation.ts new file mode 100644 index 0000000000..162ef0849f --- /dev/null +++ b/apps/backend/src/resolvers/mutations/UpdateEmailTemplateMutation.ts @@ -0,0 +1,48 @@ +import { + Arg, + Ctx, + Field, + InputType, + Int, + Mutation, + Resolver, +} from 'type-graphql'; + +import { ResolverContext } from '../../context'; +import { EmailTemplate } from '../types/EmailTemplate'; + +@InputType() +export class UpdateEmailTemplateInput { + @Field(() => Int) + public id: number; + + @Field(() => String) + public name: string; + + @Field(() => String) + public description: string; + + @Field(() => Boolean) + public useTemplateFile: boolean; + + @Field(() => String, { nullable: true }) + public subject?: string; + + @Field(() => String, { nullable: true }) + public body?: string; +} + +@Resolver() +export class UpdateEmailTemplateMutation { + @Mutation(() => EmailTemplate) + updateEmailTemplate( + @Arg('updateEmailTemplateInput') + updateEmailTemplateInput: UpdateEmailTemplateInput, + @Ctx() context: ResolverContext + ) { + return context.mutations.emailTemplate.update( + context.user, + updateEmailTemplateInput + ); + } +} diff --git a/apps/backend/src/resolvers/queries/EmailTemplateQuery.ts b/apps/backend/src/resolvers/queries/EmailTemplateQuery.ts new file mode 100644 index 0000000000..2d4fff7738 --- /dev/null +++ b/apps/backend/src/resolvers/queries/EmailTemplateQuery.ts @@ -0,0 +1,15 @@ +import { Arg, Ctx, Int, Query, Resolver } from 'type-graphql'; + +import { ResolverContext } from '../../context'; +import { EmailTemplate } from '../types/EmailTemplate'; + +@Resolver() +export class EmailTemplateQuery { + @Query(() => EmailTemplate, { nullable: true }) + emailTemplate( + @Arg('emailTemplateId', () => Int) emailTemplateId: number, + @Ctx() context: ResolverContext + ) { + return context.queries.emailTemplate.get(context.user, emailTemplateId); + } +} diff --git a/apps/backend/src/resolvers/queries/EmailTemplatesQuery.ts b/apps/backend/src/resolvers/queries/EmailTemplatesQuery.ts new file mode 100644 index 0000000000..28e39af79b --- /dev/null +++ b/apps/backend/src/resolvers/queries/EmailTemplatesQuery.ts @@ -0,0 +1,49 @@ +import { + Arg, + Ctx, + Field, + InputType, + Int, + ObjectType, + Query, + Resolver, +} from 'type-graphql'; + +import { ResolverContext } from '../../context'; +import { EmailTemplate } from '../types/EmailTemplate'; + +@InputType() +export class EmailTemplatesFilter { + @Field(() => String, { nullable: true }) + filter?: string; + + @Field(() => Int, { nullable: true }) + first?: number; + + @Field(() => Int, { nullable: true }) + offset?: number; + + @Field(() => [Int], { nullable: true }) + public emailTemplateIds?: number[]; +} + +@ObjectType() +class EmailTemplatesQueryResult { + @Field(() => Int) + public totalCount: number; + + @Field(() => [EmailTemplate]) + public emailTemplates: EmailTemplate[]; +} + +@Resolver() +export class EmailTemplatesQuery { + @Query(() => EmailTemplatesQueryResult, { nullable: true }) + emailTemplates( + @Ctx() context: ResolverContext, + @Arg('filter', () => EmailTemplatesFilter, { nullable: true }) + filter: EmailTemplatesFilter + ) { + return context.queries.emailTemplate.getAll(context.user, filter); + } +} diff --git a/apps/backend/src/resolvers/types/EmailTemplate.ts b/apps/backend/src/resolvers/types/EmailTemplate.ts new file mode 100644 index 0000000000..8af481944b --- /dev/null +++ b/apps/backend/src/resolvers/types/EmailTemplate.ts @@ -0,0 +1,27 @@ +import { Field, Int, ObjectType } from 'type-graphql'; + +import { EmailTemplate as EmailTemplateOrigin } from '../../models/EmailTemplate'; + +@ObjectType() +export class EmailTemplate implements EmailTemplateOrigin { + @Field(() => Int) + public id: number; + + @Field(() => Int) + public createdByUserId: number; + + @Field(() => String) + public name: string; + + @Field(() => String, { nullable: true }) + public description: string; + + @Field(() => Boolean) + public useTemplateFile: boolean; + + @Field(() => String, { nullable: true }) + public subject: string; + + @Field(() => String, { nullable: true }) + public body: string; +} diff --git a/apps/backend/src/resolvers/types/StatusAction.ts b/apps/backend/src/resolvers/types/StatusAction.ts index 95df315dd5..ca7d69bbad 100644 --- a/apps/backend/src/resolvers/types/StatusAction.ts +++ b/apps/backend/src/resolvers/types/StatusAction.ts @@ -15,8 +15,8 @@ import { } from '../../models/StatusAction'; import { EmailActionDefaultConfig, - StatusActionDefaultConfig, RabbitMQActionDefaultConfig, + StatusActionDefaultConfig, } from './StatusActionConfig'; @ObjectType() diff --git a/apps/backend/src/resolvers/types/StatusActionConfig.ts b/apps/backend/src/resolvers/types/StatusActionConfig.ts index 4e71445491..9f70884d99 100644 --- a/apps/backend/src/resolvers/types/StatusActionConfig.ts +++ b/apps/backend/src/resolvers/types/StatusActionConfig.ts @@ -57,9 +57,8 @@ export class EmailStatusActionRecipient { export class EmailStatusActionEmailTemplate { @Field(() => String) public id: string; - @Field(() => String) - public name?: string; + public name: string; } @ObjectType() diff --git a/apps/backend/src/statusActionEngine/emailActionHandler.ts b/apps/backend/src/statusActionEngine/emailActionHandler.ts index 9ecab4f1e4..22bde0bdd5 100644 --- a/apps/backend/src/statusActionEngine/emailActionHandler.ts +++ b/apps/backend/src/statusActionEngine/emailActionHandler.ts @@ -16,16 +16,16 @@ import { } from '../resolvers/types/StatusActionConfig'; import { WorkflowEngineProposalType } from '../workflowEngine/proposal'; import { + constructProposalStatusChangeEvent, EmailReadyType, getCoProposersAndFormatOutputForEmailSending, - getInstrumentScientistsAndFormatOutputForEmailSending, - getPIAndFormatOutputForEmailSending, - getFapReviewersAndFormatOutputForEmailSending, getFapChairSecretariesAndFormatOutputForEmailSending, - statusActionLogger, + getFapReviewersAndFormatOutputForEmailSending, + getInstrumentScientistsAndFormatOutputForEmailSending, getOtherAndFormatOutputForEmailSending, + getPIAndFormatOutputForEmailSending, getTechniqueScientistsAndFormatOutputForEmailSending, - constructProposalStatusChangeEvent, + statusActionLogger, } from './statusActionUtils'; export const emailActionHandler = async ( @@ -90,7 +90,7 @@ export const emailStatusActionRecipient = async ( loggedInUserId?: number | null ) => { const proposalPks = proposals.map((proposal) => proposal.primaryKey); - const templateMessage = recipientWithTemplate.emailTemplate.id; + const emailTemplateId = recipientWithTemplate.emailTemplate.id; const successfulMessage = !!statusActionsLogId ? 'Email successfully sent on status action replay' : 'Email successfully sent'; @@ -115,7 +115,7 @@ export const emailStatusActionRecipient = async ( }), successfulMessage, failMessage, - templateMessage, + emailTemplateId, loggedInUserId ); @@ -140,7 +140,7 @@ export const emailStatusActionRecipient = async ( }), successfulMessage, failMessage, - templateMessage, + emailTemplateId, loggedInUserId )); @@ -164,7 +164,7 @@ export const emailStatusActionRecipient = async ( }), successfulMessage, failMessage, - templateMessage, + emailTemplateId, loggedInUserId ); @@ -188,7 +188,7 @@ export const emailStatusActionRecipient = async ( }), successfulMessage, failMessage, - templateMessage, + emailTemplateId, loggedInUserId ); @@ -213,7 +213,7 @@ export const emailStatusActionRecipient = async ( }), successfulMessage, failMessage, - templateMessage, + emailTemplateId, loggedInUserId ); @@ -288,7 +288,7 @@ export const emailStatusActionRecipient = async ( }), successfulMessage, failMessage, - templateMessage, + emailTemplateId, loggedInUserId ); @@ -313,7 +313,7 @@ export const emailStatusActionRecipient = async ( }), successfulMessage, failMessage, - templateMessage, + emailTemplateId, loggedInUserId ); @@ -372,7 +372,7 @@ export const emailStatusActionRecipient = async ( }), successfulMessage, failMessage, - templateMessage, + emailTemplateId, loggedInUserId ); @@ -405,7 +405,7 @@ export const emailStatusActionRecipient = async ( }), successfulMessage, failMessage, - templateMessage, + emailTemplateId, loggedInUserId ); } @@ -425,7 +425,7 @@ const sendMail = async ( ) => Promise, successfulMessage: string, failMessage: string, - templateMessage: string, + emailTemplateId: string, loggedInUserId?: number | null ) => { const mailService = container.resolve(Tokens.MailService); @@ -443,13 +443,15 @@ const sendMail = async ( return; } + try { const mailServiceResponse = await Promise.all( recipientsWithData.map(async (recipientWithData) => { try { const res = await mailService.sendMail({ content: { - template_id: recipientWithData.template, + template: emailTemplateId, + email_rfc822: '', }, substitution_data: { proposals: recipientWithData.proposals, @@ -474,7 +476,7 @@ const sendMail = async ( const evt = constructProposalStatusChangeEvent( proposal, loggedInUserId || null, - `${successfulMessage} template: ${templateMessage} to: ${recipientWithData.email} recipient: ${recipientWithData.id}`, + `${successfulMessage} template: ${emailTemplateId} to: ${recipientWithData.email} recipient: ${recipientWithData.id}`, undefined ); emailEventHandler(evt); @@ -491,7 +493,7 @@ const sendMail = async ( const evt = constructProposalStatusChangeEvent( proposal, loggedInUserId || null, - `${failMessage} template: ${templateMessage} to: ${recipientWithData.email} recipient: ${recipientWithData.id}`, + `${failMessage} template: ${emailTemplateId} to: ${recipientWithData.email} recipient: ${recipientWithData.id}`, undefined ); emailEventHandler(evt); diff --git a/apps/e2e/cypress/e2e/statusActions.cy.ts b/apps/e2e/cypress/e2e/statusActions.cy.ts index 76f5936447..6a5c8e470b 100644 --- a/apps/e2e/cypress/e2e/statusActions.cy.ts +++ b/apps/e2e/cypress/e2e/statusActions.cy.ts @@ -1,11 +1,11 @@ import { faker } from '@faker-js/faker'; import { - Event as PROPOSAL_EVENTS, - EmailStatusActionRecipients, - StatusActionType, AllocationTimeUnits, - FeatureUpdateAction, + EmailStatusActionRecipients, FeatureId, + FeatureUpdateAction, + Event as PROPOSAL_EVENTS, + StatusActionType, } from '@user-office-software-libs/shared-types'; import { DateTime } from 'luxon'; @@ -35,12 +35,34 @@ const newCall = { let proposal1Id: string; let proposal2Id: string; +let testEmailTemplate1Id: string; +let testEmailTemplate2Id: string; context('Status actions tests', () => { beforeEach(function () { cy.resetDB(); cy.getAndStoreFeaturesEnabled(); + cy.createEmailTemplate({ + name: initialDBData.emailTemplates.template1.name, + description: initialDBData.emailTemplates.template1.description, + useTemplateFile: initialDBData.emailTemplates.template1.useTemplateFile, + subject: initialDBData.emailTemplates.template1.subject, + body: initialDBData.emailTemplates.template1.body, + }).then((result) => { + testEmailTemplate1Id = result.createEmailTemplate.id.toString(); + }); + + cy.createEmailTemplate({ + name: initialDBData.emailTemplates.template2.name, + description: initialDBData.emailTemplates.template2.description, + useTemplateFile: initialDBData.emailTemplates.template2.useTemplateFile, + subject: initialDBData.emailTemplates.template2.subject, + body: initialDBData.emailTemplates.template2.body, + }).then((result) => { + testEmailTemplate2Id = result.createEmailTemplate.id.toString(); + }); + cy.updateFeature({ action: FeatureUpdateAction.ENABLE, featureIds: [FeatureId.PREGENERATED_PROPOSAL_PDF], @@ -130,7 +152,10 @@ context('Status actions tests', () => { name: EmailStatusActionRecipients.PI, description: '', }, - emailTemplate: { id: 'pi-template', name: 'PI template' }, + emailTemplate: { + id: testEmailTemplate1Id, + name: initialDBData.emailTemplates.template1.name, + }, combineEmails: true, }, ], @@ -221,7 +246,10 @@ context('Status actions tests', () => { name: EmailStatusActionRecipients.PI, description: '', }, - emailTemplate: { id: 'pi-template', name: 'PI template' }, + emailTemplate: { + id: testEmailTemplate2Id, + name: initialDBData.emailTemplates.template2.name, + }, }, ], }; @@ -293,7 +321,10 @@ context('Status actions tests', () => { name: EmailStatusActionRecipients.PI, description: '', }, - emailTemplate: { id: 'pi-template', name: 'PI template' }, + emailTemplate: { + id: testEmailTemplate1Id, + name: initialDBData.emailTemplates.template1.name, + }, }, ], }; @@ -451,7 +482,10 @@ context('Status actions tests', () => { description: 'Other email recipients manually added by their email', }, - emailTemplate: { id: 'my-first-email', name: 'My First Email' }, + emailTemplate: { + id: testEmailTemplate1Id, + name: initialDBData.emailTemplates.template1.name, + }, otherRecipientEmails: [faker.internet.email()], }, ], @@ -570,8 +604,8 @@ context('Status actions tests', () => { 'Other email recipients manually added by their email', }, emailTemplate: { - id: 'status-actions-test-template', - name: 'Status actions test template', + id: testEmailTemplate1Id, + name: initialDBData.emailTemplates.template1.name, }, otherRecipientEmails: [statusActionEmail], }, @@ -683,7 +717,10 @@ context('Status actions tests', () => { name: EmailStatusActionRecipients.PI, description: '', }, - emailTemplate: { id: 'pi-template', name: 'PI template' }, + emailTemplate: { + id: testEmailTemplate1Id, + name: initialDBData.emailTemplates.template1.name, + }, }, { recipient: { @@ -692,8 +729,8 @@ context('Status actions tests', () => { 'Other email recipients manually added by their email', }, emailTemplate: { - id: 'status-actions-test-template', - name: 'Status actions test template', + id: testEmailTemplate2Id, + name: initialDBData.emailTemplates.template2.name, }, otherRecipientEmails: [faker.internet.email()], }, diff --git a/apps/e2e/cypress/e2e/templatesBasic.cy.ts b/apps/e2e/cypress/e2e/templatesBasic.cy.ts index 534d3937a2..a6aba08eae 100644 --- a/apps/e2e/cypress/e2e/templatesBasic.cy.ts +++ b/apps/e2e/cypress/e2e/templatesBasic.cy.ts @@ -1,37 +1,62 @@ import { faker } from '@faker-js/faker'; import { + EmailStatusActionRecipients, FeatureId, + StatusActionType, TemplateGroupId, } from '@user-office-software-libs/shared-types'; import { DateTime } from 'luxon'; import { booleanQuestion, - intervalQuestion, - numberQuestion, - textQuestion, - multipleChoiceQuestion, + createTopicWithQuestionsAndRelations, dateQuestion, - timeQuestion, + dynamicMultipleChoiceQuestion, fileQuestion, - richTextInputQuestion, + intervalQuestion, + multipleChoiceQuestion, + newCall, + numberQuestion, proposal, + richTextInputQuestion, templateDependencies, - createTopicWithQuestionsAndRelations, - dynamicMultipleChoiceQuestion, - newCall, templateSearch, + textQuestion, + timeQuestion, } from './templateContext'; import featureFlags from '../support/featureFlags'; import initialDBData from '../support/initialDBData'; const scientist1 = initialDBData.users.user1; +let testEmailTemplate1Id: string; context('Template Basic tests', () => { + const emailTemplateName1 = faker.lorem.words(3); + const emailTemplateDescription1 = faker.lorem.words(3); + const emailTemplateName2 = faker.lorem.words(3); + const emailTemplateDescription2 = faker.lorem.words(3); beforeEach(() => { cy.resetDB(true); cy.getAndStoreFeaturesEnabled(); cy.viewport(1920, 1680); + + cy.createEmailTemplate({ + name: initialDBData.emailTemplates.template1.name, + description: initialDBData.emailTemplates.template1.description, + useTemplateFile: initialDBData.emailTemplates.template1.useTemplateFile, + subject: initialDBData.emailTemplates.template1.subject, + body: initialDBData.emailTemplates.template1.body, + }).then((result) => { + testEmailTemplate1Id = result.createEmailTemplate.id.toString(); + }); + + cy.createEmailTemplate({ + name: initialDBData.emailTemplates.template2.name, + description: initialDBData.emailTemplates.template2.description, + useTemplateFile: initialDBData.emailTemplates.template2.useTemplateFile, + subject: initialDBData.emailTemplates.template2.subject, + body: initialDBData.emailTemplates.template2.body, + }); }); describe('Proposal templates basic tests', () => { @@ -1995,4 +2020,137 @@ context('Template Basic tests', () => { .should('exist'); }); }); + + describe('Email templates tests', () => { + it('User officer can create email template', () => { + cy.login('officer'); + cy.visit('/'); + + cy.navigateToTemplatesSubmenu('Email'); + + cy.get('[data-cy=create-new-entry]').click(); + cy.get('[data-cy="submit"]').click(); + cy.get('[data-cy="name"]').type(emailTemplateName1); + cy.get('[data-cy="description"]').first().type(emailTemplateDescription1); + cy.get('[data-cy=use-template-file]') + .first() + .find('input[type="checkbox"]') + .should('not.be.checked') + .click(); + cy.get('[data-cy="submit"]').click(); + cy.notification({ variant: 'success', text: 'created successfully' }); + cy.contains(emailTemplateName1); + cy.contains(emailTemplateDescription1); + + cy.get('[data-cy=create-new-entry]').click(); + cy.get('[data-cy="name"]').type(emailTemplateName1); + cy.get('[data-cy="description"]').first().type(emailTemplateDescription2); + cy.get('[data-cy=use-template-file]') + .first() + .find('input[type="checkbox"]') + .should('not.be.checked') + .click(); + cy.get('[data-cy="submit"]').click(); + cy.notification({ + variant: 'error', + text: 'Could not create email template', + }); + }); + + it('User officer can update email template', () => { + cy.login('officer'); + cy.visit('/'); + + cy.navigateToTemplatesSubmenu('Email'); + + cy.contains(initialDBData.emailTemplates.template1.name) + .parent() + .find('[aria-label="Edit"]') + .click(); + cy.get('[data-cy="name"]').type(emailTemplateName2); + cy.get('[data-cy="description"]').first().type(emailTemplateDescription2); + cy.get('[data-cy="submit"]').click(); + cy.notification({ variant: 'success', text: 'updated successfully' }); + cy.contains(emailTemplateName2); + cy.contains(emailTemplateDescription2); + }); + + it('User officer can delete email template', () => { + cy.login('officer'); + cy.visit('/'); + + cy.navigateToTemplatesSubmenu('Email'); + + cy.contains(initialDBData.emailTemplates.template1.name) + .parent() + .find('[aria-label="Delete"]') + .click(); + + cy.get('[aria-label="Save"]').click(); + + cy.notification({ variant: 'success', text: 'Email template deleted' }); + + cy.contains(initialDBData.emailTemplates.template1.name).should( + 'not.exist' + ); + }); + + it.only('User officer cannot delete referenced email template', () => { + const statusActionConfig = { + recipientsWithEmailTemplate: [ + { + recipient: { + name: EmailStatusActionRecipients.PI, + description: '', + }, + emailTemplate: { + id: testEmailTemplate1Id, + name: initialDBData.emailTemplates.template1.name, + }, + combineEmails: true, + }, + ], + }; + + cy.addWorkflowStatus({ + statusId: initialDBData.proposalStatuses.feasibilityReview.id, + workflowId: initialDBData.workflows.defaultWorkflow.id, + sortOrder: 1, + prevStatusId: initialDBData.proposalStatuses.draft.id, + posX: 0, + posY: 200, + prevConnectionId: 1, + }).then((result) => { + cy.reload(); + cy.addConnectionStatusActions({ + actions: [ + { + actionId: 1, + actionType: StatusActionType.EMAIL, + config: JSON.stringify(statusActionConfig), + }, + ], + connectionId: result.addWorkflowStatus.id, + workflowId: initialDBData.workflows.defaultWorkflow.id, + }); + }); + + cy.login('officer'); + cy.visit('/'); + + cy.navigateToTemplatesSubmenu('Email'); + + cy.contains(initialDBData.emailTemplates.template1.name) + .parent() + .find('[aria-label="Delete"]') + .click(); + + cy.get('[aria-label="Save"]').click(); + + cy.notification({ + variant: 'error', + text: 'Could not delete email template', + }); + }); + }); }); diff --git a/apps/e2e/cypress/support/e2e.ts b/apps/e2e/cypress/support/e2e.ts index 27f094350d..297973d0b3 100644 --- a/apps/e2e/cypress/support/e2e.ts +++ b/apps/e2e/cypress/support/e2e.ts @@ -16,18 +16,19 @@ // Import commands.js using ES2015 syntax: import './admin'; import './call'; +import './emailTemplate'; +import './fap'; import './instrument'; import './internalReview'; import './invite'; +import './predefinedMessages'; import './proposal'; +import './settings'; +import './statusActionLogs'; +import './tag'; +import './technique'; import './template'; import './user'; import './utils'; import './visits'; import './workflow'; -import './fap'; -import './predefinedMessages'; -import './settings'; -import './technique'; -import './statusActionLogs'; -import './tag'; diff --git a/apps/e2e/cypress/support/emailTemplate.ts b/apps/e2e/cypress/support/emailTemplate.ts new file mode 100644 index 0000000000..f913685eb4 --- /dev/null +++ b/apps/e2e/cypress/support/emailTemplate.ts @@ -0,0 +1,18 @@ +import { + CreateEmailTemplateMutation, + CreateEmailTemplateMutationVariables, +} from '@user-office-software-libs/shared-types'; + +import { getE2EApi } from './utils'; + +const api = getE2EApi(); + +function createEmailTemplate( + createEmailTemplateInput: CreateEmailTemplateMutationVariables +): Cypress.Chainable { + const request = api.createEmailTemplate(createEmailTemplateInput); + + return cy.wrap(request); +} + +Cypress.Commands.add('createEmailTemplate', createEmailTemplate); diff --git a/apps/e2e/cypress/support/initialDBData.ts b/apps/e2e/cypress/support/initialDBData.ts index 8c9434cf17..4f53e21a97 100644 --- a/apps/e2e/cypress/support/initialDBData.ts +++ b/apps/e2e/cypress/support/initialDBData.ts @@ -452,4 +452,24 @@ export default { proposalPk: 1, questionId: 'sample_declaration_question', }, + emailTemplates: { + template1: { + id: 1, + createdByUserId: 0, + name: 'template-name-1', + useTemplateFile: false, + description: 'template-description-1', + subject: 'template-subject-1', + body: 'template-body-1', + }, + template2: { + id: 2, + createdByUserId: 0, + name: 'template-name-2', + useTemplateFile: false, + description: 'template-description-2', + subject: 'template-subject-2', + body: 'template-body-2', + }, + }, }; diff --git a/apps/e2e/cypress/support/statusActionLogs.ts b/apps/e2e/cypress/support/statusActionLogs.ts index 76871487da..3f93c97023 100644 --- a/apps/e2e/cypress/support/statusActionLogs.ts +++ b/apps/e2e/cypress/support/statusActionLogs.ts @@ -1,3 +1,10 @@ +import { + GetEmailTemplateQuery, + GetEmailTemplateQueryVariables, +} from '@user-office-software-libs/shared-types'; + +import { getE2EApi } from './utils'; + const navigateToStatusActionLogsSubmenu = (submenuName: string) => { cy.get('body').then(($body) => { if ($body.find(`[aria-label='${submenuName}']`).length) { @@ -9,7 +16,18 @@ const navigateToStatusActionLogsSubmenu = (submenuName: string) => { }); }; +const getEmailTemplate = ( + getEmailTemplatesInput: GetEmailTemplateQueryVariables +): Cypress.Chainable => { + const api = getE2EApi(); + const request = api.getEmailTemplate(getEmailTemplatesInput); + + return cy.wrap(request); +}; + Cypress.Commands.add( 'navigateToStatusActionLogsSubmenu', navigateToStatusActionLogsSubmenu ); + +Cypress.Commands.add('getEmailTemplate', getEmailTemplate); diff --git a/apps/e2e/cypress/support/template.ts b/apps/e2e/cypress/support/template.ts index 905d816ecf..4edc2921ba 100644 --- a/apps/e2e/cypress/support/template.ts +++ b/apps/e2e/cypress/support/template.ts @@ -14,14 +14,14 @@ import { CreateTemplateMutation, CreateTemplateMutationVariables, CreateTopicMutation, - UpdateTopicMutation, CreateTopicMutationVariables, + SetActiveTemplateMutationVariables, UpdateQuestionMutation, UpdateQuestionMutationVariables, UpdateQuestionTemplateRelationSettingsMutation, UpdateQuestionTemplateRelationSettingsMutationVariables, + UpdateTopicMutation, UpdateTopicMutationVariables, - SetActiveTemplateMutationVariables, } from '@user-office-software-libs/shared-types'; import { getE2EApi } from './utils'; diff --git a/apps/e2e/cypress/types/emailTemplates.d.ts b/apps/e2e/cypress/types/emailTemplates.d.ts new file mode 100644 index 0000000000..b2174d30c3 --- /dev/null +++ b/apps/e2e/cypress/types/emailTemplates.d.ts @@ -0,0 +1,29 @@ +import { + CreateEmailTemplateMutation, + CreateEmailTemplateMutationVariables, +} from '@user-office-software-libs/shared-types'; + +declare global { + namespace Cypress { + interface Chainable { + /** + * Creates a new email template with the given values + * + * @returns {typeof createEmailTemplateMutation} + * @memberof Chainable + * @example + * cy.createEmailTemplate({ + * name: faker.random.words(2), + * shortCode: faker.random.alphaNumeric(15), + * description: faker.random.words(5), + * managerUserId: 1 + * }); + */ + createEmailTemplate( + createEmailTemplateInput: CreateEmailTemplateMutationVariables + ): Cypress.Chainable; + } + } +} + +export {}; diff --git a/apps/e2e/cypress/types/statusActionLog.d.ts b/apps/e2e/cypress/types/statusActionLog.d.ts index 21a505b8be..1ef293ec11 100644 --- a/apps/e2e/cypress/types/statusActionLog.d.ts +++ b/apps/e2e/cypress/types/statusActionLog.d.ts @@ -1,3 +1,5 @@ +import { GetEmailTemplateQuery } from '@user-office-software-libs/shared-types'; + declare global { namespace Cypress { interface Chainable { @@ -10,6 +12,18 @@ declare global { * cy.expandStatusActionLogsSubmenu() */ navigateToStatusActionLogsSubmenu: (submenuName: string) => void; + + /** + * Gets email template + * + * @returns {typeof getEmailTemplate} + * @memberof Chainable + * @example + * cy.getProposals(getProposalsInput: GetProposalsQueryVariables) + */ + getEmailTemplate: ( + getEmailTemplatesInput: GetEmailTemplateQueryVariables + ) => Cypress.Chainable; } } } diff --git a/apps/frontend/src/components/AppRoutes.tsx b/apps/frontend/src/components/AppRoutes.tsx index 1d70bc35bf..b4d168fcb9 100644 --- a/apps/frontend/src/components/AppRoutes.tsx +++ b/apps/frontend/src/components/AppRoutes.tsx @@ -1,6 +1,6 @@ import React, { lazy, useContext } from 'react'; import { useTranslation } from 'react-i18next'; -import { Routes, Route, Navigate, Outlet } from 'react-router-dom'; +import { Navigate, Outlet, Route, Routes } from 'react-router-dom'; import i18n from 'i18n'; @@ -118,6 +118,7 @@ const ImportUnitsPage = lazy(() => import('./unit/ImportUnitsPage')); const PeoplePage = lazy(() => import('./user/PeoplePage')); const ProfilePage = lazy(() => import('./user/ProfilePage')); const UserPage = lazy(() => import('./user/UserPage')); +const EmailTemplatePage = lazy(() => import('./template/EmailTemplatePage')); const PrivateOutlet = () => ( @@ -458,6 +459,15 @@ const AppRoutes = () => { /> } /> + } + /> + } + /> {isVisitManagementEnabled && ( + + + + + + + + + diff --git a/apps/frontend/src/components/settings/workflow/EmailActionConfig.tsx b/apps/frontend/src/components/settings/workflow/EmailActionConfig.tsx index 62cc91b619..2b572decc4 100644 --- a/apps/frontend/src/components/settings/workflow/EmailActionConfig.tsx +++ b/apps/frontend/src/components/settings/workflow/EmailActionConfig.tsx @@ -9,7 +9,7 @@ import TextField from '@mui/material/TextField'; import Tooltip from '@mui/material/Tooltip'; import Typography from '@mui/material/Typography'; import { FieldArray, FieldArrayRenderProps } from 'formik'; -import React, { useState, KeyboardEvent } from 'react'; +import React, { KeyboardEvent, useState } from 'react'; import * as Yup from 'yup'; import { @@ -171,6 +171,21 @@ const EmailActionConfig = ({ } }; + const getEmailTemplate = (foundRecipientWithEmailTemplateIndex: number) => { + if (foundRecipientWithEmailTemplateIndex !== -1) { + return ( + emailTemplates.find( + (template) => + template.id === + recipientsWithEmailTemplate[foundRecipientWithEmailTemplateIndex] + ?.emailTemplate?.id + ) || null + ); + } else { + return null; + } + }; + return ( <> @@ -261,11 +276,9 @@ const EmailActionConfig = ({ newTemplateValue ); }} - value={ - recipientsWithEmailTemplate[ - foundRecipientWithEmailTemplateIndex - ].emailTemplate || null - } + value={getEmailTemplate( + foundRecipientWithEmailTemplateIndex + )} data-cy={`${recipient.name}-email-template`} /> {recipient.name === EmailStatusActionRecipients.OTHER && ( diff --git a/apps/frontend/src/components/template/CreateUpdateEmailTemplate.tsx b/apps/frontend/src/components/template/CreateUpdateEmailTemplate.tsx new file mode 100644 index 0000000000..75b571e1e0 --- /dev/null +++ b/apps/frontend/src/components/template/CreateUpdateEmailTemplate.tsx @@ -0,0 +1,151 @@ +import Button from '@mui/material/Button'; +import Typography from '@mui/material/Typography'; +import { + createEmailTemplateValidationSchema, + updateEmailTemplateValidationSchema, +} from '@user-office-software/duo-validation'; +import { Field, Form, Formik } from 'formik'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import i18n from 'i18n'; + +import CheckboxWithLabel from 'components/common/FormikUICheckboxWithLabel'; +import TextField from 'components/common/FormikUITextField'; +import UOLoader from 'components/common/UOLoader'; +import { EmailTemplateFragment } from 'generated/sdk'; +import useDataApiWithFeedback from 'utils/useDataApiWithFeedback'; + +type CreateUpdateEmailTemplateProps = { + close: (emailTemplateAdded: EmailTemplateFragment | null) => void; + emailTemplate: EmailTemplateFragment | null; +}; + +const CreateUpdateEmailTemplate = ({ + close, + emailTemplate, +}: CreateUpdateEmailTemplateProps) => { + const { t } = useTranslation(); + const { api, isExecutingCall } = useDataApiWithFeedback(); + + const initialValues = { + id: emailTemplate?.id || 0, + name: emailTemplate?.name || '', + description: emailTemplate?.description || '', + useTemplateFile: emailTemplate?.useTemplateFile || false, + subject: emailTemplate?.subject || '', + body: emailTemplate?.body || '', + }; + + return ( + => { + if (emailTemplate) { + try { + const { updateEmailTemplate } = await api({ + toastSuccessMessage: 'Email template updated successfully!', + }).updateEmailTemplate(values); + + close(updateEmailTemplate); + } catch { + close(null); + } + } else { + try { + const { createEmailTemplate } = await api({ + toastSuccessMessage: 'Email template created successfully!', + }).createEmailTemplate(values); + + close(createEmailTemplate); + } catch { + close(null); + } + } + }} + validationSchema={ + !!emailTemplate + ? updateEmailTemplateValidationSchema + : createEmailTemplateValidationSchema + } + > + {({ isValid, values }) => ( +
+ + {(emailTemplate ? 'Update ' : 'Create new ') + + i18n.format(t('Email template'), 'lowercase')} + + + + + + + + + )} +
+ ); +}; + +export default CreateUpdateEmailTemplate; diff --git a/apps/frontend/src/components/template/EmailTemplatePage.tsx b/apps/frontend/src/components/template/EmailTemplatePage.tsx new file mode 100644 index 0000000000..9f7bc50c51 --- /dev/null +++ b/apps/frontend/src/components/template/EmailTemplatePage.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +import { StyledContainer, StyledPaper } from 'styles/StyledComponents'; + +import EmailTemplatesTable from './EmailTemplateTable'; + +const EmailTemplatesPage = () => { + return ( + + + + + + ); +}; + +export default EmailTemplatesPage; diff --git a/apps/frontend/src/components/template/EmailTemplateTable.tsx b/apps/frontend/src/components/template/EmailTemplateTable.tsx new file mode 100644 index 0000000000..81870135f6 --- /dev/null +++ b/apps/frontend/src/components/template/EmailTemplateTable.tsx @@ -0,0 +1,96 @@ +import { Column } from '@material-table/core'; +import { Typography } from '@mui/material'; +import React from 'react'; + +import SuperMaterialTable from 'components/common/SuperMaterialTable'; +import { EmailTemplateFragment, UserRole } from 'generated/sdk'; +import { useCheckAccess } from 'hooks/common/useCheckAccess'; +import { useEmailTemplatesData } from 'hooks/emailTemplate/useEmailTemplatesData'; +import useDataApiWithFeedback from 'utils/useDataApiWithFeedback'; +import { FunctionType } from 'utils/utilTypes'; + +import CreateUpdateEmailTemplate from './CreateUpdateEmailTemplate'; + +const EmailTemplatesTable = () => { + const { + loadingEmailTemplates, + emailTemplates, + setEmailTemplatesWithLoading: setEmailTemplates, + } = useEmailTemplatesData(); + + const isUserOfficer = useCheckAccess([UserRole.USER_OFFICER]); + const { api } = useDataApiWithFeedback(); + const columns: Column[] = [ + { + title: 'Name', + field: 'name', + }, + { + title: 'Description', + field: 'description', + }, + { + title: 'Subject', + field: 'subject', + }, + { + title: 'Body', + field: 'body', + }, + ]; + + const onEmailTemplateDelete = async (emailTemplateId: number | string) => { + try { + await api({ + toastSuccessMessage: 'Email template deleted successfully', + }).deleteEmailTemplate({ id: emailTemplateId as number }); + + return true; + } catch { + return false; + } + }; + + const createModal = ( + onUpdate: FunctionType, + onCreate: FunctionType, + editEmailTemplate: EmailTemplateFragment | null + ) => ( + + !!editEmailTemplate ? onUpdate(emailTemplate) : onCreate(emailTemplate) + } + /> + ); + + return ( +
+ + Email Templates + + } + hasAccess={{ + create: isUserOfficer, + update: isUserOfficer, + remove: isUserOfficer, + }} + columns={columns} + data={emailTemplates} + isLoading={loadingEmailTemplates} + options={{ + search: true, + debounceInterval: 400, + }} + createModal={createModal} + persistUrlQueryParams={true} + > +
+ ); +}; + +export default EmailTemplatesTable; diff --git a/apps/frontend/src/graphql/emailTemplate/createEmailTemplate.graphql b/apps/frontend/src/graphql/emailTemplate/createEmailTemplate.graphql new file mode 100644 index 0000000000..3122bf13ca --- /dev/null +++ b/apps/frontend/src/graphql/emailTemplate/createEmailTemplate.graphql @@ -0,0 +1,19 @@ +mutation createEmailTemplate( + $name: String! + $description: String! + $useTemplateFile: Boolean! + $subject: String + $body: String +) { + createEmailTemplate( + createEmailTemplateInput: { + name: $name + description: $description + useTemplateFile: $useTemplateFile + subject: $subject + body: $body + } + ) { + ...emailTemplate + } +} diff --git a/apps/frontend/src/graphql/emailTemplate/deleteEmailTemplate.graphql b/apps/frontend/src/graphql/emailTemplate/deleteEmailTemplate.graphql new file mode 100644 index 0000000000..a37b19f714 --- /dev/null +++ b/apps/frontend/src/graphql/emailTemplate/deleteEmailTemplate.graphql @@ -0,0 +1,5 @@ +mutation deleteEmailTemplate($id: Int!) { + deleteEmailTemplate(id: $id) { + id + } +} diff --git a/apps/frontend/src/graphql/emailTemplate/fragment.emailTemplate.graphql b/apps/frontend/src/graphql/emailTemplate/fragment.emailTemplate.graphql new file mode 100644 index 0000000000..1578f27272 --- /dev/null +++ b/apps/frontend/src/graphql/emailTemplate/fragment.emailTemplate.graphql @@ -0,0 +1,9 @@ +fragment emailTemplate on EmailTemplate { + id + createdByUserId + name + description + useTemplateFile + subject + body +} diff --git a/apps/frontend/src/graphql/emailTemplate/getEmailTemplate.graphql b/apps/frontend/src/graphql/emailTemplate/getEmailTemplate.graphql new file mode 100644 index 0000000000..3de666ac7b --- /dev/null +++ b/apps/frontend/src/graphql/emailTemplate/getEmailTemplate.graphql @@ -0,0 +1,5 @@ +query getEmailTemplate($emailTemplateId: Int!) { + emailTemplate(emailTemplateId: $emailTemplateId) { + ...emailTemplate + } +} diff --git a/apps/frontend/src/graphql/emailTemplate/getEmailTemplates.graphql b/apps/frontend/src/graphql/emailTemplate/getEmailTemplates.graphql new file mode 100644 index 0000000000..b6c007ad6d --- /dev/null +++ b/apps/frontend/src/graphql/emailTemplate/getEmailTemplates.graphql @@ -0,0 +1,8 @@ +query getEmailTemplates($filter: EmailTemplatesFilter) { + emailTemplates(filter: $filter) { + emailTemplates { + ...emailTemplate + } + totalCount + } +} diff --git a/apps/frontend/src/graphql/emailTemplate/updateEmailTemplate.graphql b/apps/frontend/src/graphql/emailTemplate/updateEmailTemplate.graphql new file mode 100644 index 0000000000..ff0ebbdaf3 --- /dev/null +++ b/apps/frontend/src/graphql/emailTemplate/updateEmailTemplate.graphql @@ -0,0 +1,21 @@ +mutation updateEmailTemplate( + $id: Int! + $name: String! + $description: String! + $useTemplateFile: Boolean! + $subject: String + $body: String +) { + updateEmailTemplate( + updateEmailTemplateInput: { + id: $id + name: $name + description: $description + useTemplateFile: $useTemplateFile + subject: $subject + body: $body + } + ) { + ...emailTemplate + } +} diff --git a/apps/frontend/src/graphql/settings/statusActions/fragment.connectionStatusActionConfig.graphql b/apps/frontend/src/graphql/settings/statusActions/fragment.connectionStatusActionConfig.graphql index e2cb0e9669..108af42263 100644 --- a/apps/frontend/src/graphql/settings/statusActions/fragment.connectionStatusActionConfig.graphql +++ b/apps/frontend/src/graphql/settings/statusActions/fragment.connectionStatusActionConfig.graphql @@ -7,7 +7,6 @@ fragment connectionStatusActionConfig on StatusActionConfig { } emailTemplate { id - name } otherRecipientEmails combineEmails diff --git a/apps/frontend/src/graphql/settings/statusActions/fragment.statusActionConfig.graphql b/apps/frontend/src/graphql/settings/statusActions/fragment.statusActionConfig.graphql index 66649b0fb6..bc7f58679f 100644 --- a/apps/frontend/src/graphql/settings/statusActions/fragment.statusActionConfig.graphql +++ b/apps/frontend/src/graphql/settings/statusActions/fragment.statusActionConfig.graphql @@ -4,6 +4,7 @@ fragment statusActionDefaultConfig on StatusActionDefaultConfig { name description } + emailTemplates { id name diff --git a/apps/frontend/src/hooks/emailTemplate/useEmailTemplatesData.ts b/apps/frontend/src/hooks/emailTemplate/useEmailTemplatesData.ts new file mode 100644 index 0000000000..691cc65c58 --- /dev/null +++ b/apps/frontend/src/hooks/emailTemplate/useEmailTemplatesData.ts @@ -0,0 +1,69 @@ +import { + Dispatch, + SetStateAction, + useContext, + useEffect, + useState, +} from 'react'; + +import { UserContext } from 'context/UserContextProvider'; +import { EmailTemplateFragment, UserRole } from 'generated/sdk'; +import { useDataApi } from 'hooks/common/useDataApi'; + +export function useEmailTemplatesData(): { + loadingEmailTemplates: boolean; + emailTemplates: EmailTemplateFragment[]; + setEmailTemplatesWithLoading: Dispatch< + SetStateAction + >; +} { + const [emailTemplates, setEmailTemplates] = useState( + [] + ); + + const [loadingEmailTemplates, setLoadingEmailTemplates] = useState(true); + + const { currentRole } = useContext(UserContext); + + const api = useDataApi(); + + const setEmailTemplatesWithLoading = ( + data: SetStateAction + ) => { + setLoadingEmailTemplates(true); + setEmailTemplates(data); + setLoadingEmailTemplates(false); + }; + + useEffect(() => { + let unmounted = false; + + setLoadingEmailTemplates(true); + + if (currentRole && currentRole === UserRole.USER_OFFICER) { + api() + .getEmailTemplates() + .then((data) => { + if (unmounted) { + return; + } + + if (data.emailTemplates) { + setEmailTemplates(data.emailTemplates.emailTemplates); + } + setLoadingEmailTemplates(false); + }); + } + + return () => { + // used to avoid unmounted component state update error + unmounted = true; + }; + }, [api, currentRole]); + + return { + loadingEmailTemplates, + emailTemplates, + setEmailTemplatesWithLoading, + }; +} diff --git a/apps/frontend/src/hooks/settings/useStatusActionsData.ts b/apps/frontend/src/hooks/settings/useStatusActionsData.ts index 05d2220502..e0481d51a3 100644 --- a/apps/frontend/src/hooks/settings/useStatusActionsData.ts +++ b/apps/frontend/src/hooks/settings/useStatusActionsData.ts @@ -1,9 +1,9 @@ import { - useEffect, - useState, - SetStateAction, Dispatch, + SetStateAction, useContext, + useEffect, + useState, } from 'react'; import { FeatureContext } from 'context/FeatureContextProvider'; diff --git a/package-lock.json b/package-lock.json index 30e34025fd..8e34d517aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "wait-on": "^9.0.1" }, "devDependencies": { + "@types/react-dom": "^19.2.2", "husky": "^9.0.10", "lint-staged": "^16.0.0" } @@ -65,6 +66,27 @@ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==" }, + "node_modules/@types/react": { + "version": "19.2.2", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", + "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.2", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.2.tgz", + "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, "node_modules/ansi-escapes": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.1.1.tgz", @@ -307,6 +329,14 @@ "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" } }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -1192,6 +1222,23 @@ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==" }, + "@types/react": { + "version": "19.2.2", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", + "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", + "dev": true, + "peer": true, + "requires": { + "csstype": "^3.0.2" + } + }, + "@types/react-dom": { + "version": "19.2.2", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.2.tgz", + "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", + "dev": true, + "requires": {} + }, "ansi-escapes": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.1.1.tgz", @@ -1359,6 +1406,13 @@ "yargs": "17.7.2" } }, + "csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "dev": true, + "peer": true + }, "delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", diff --git a/package.json b/package.json index a8c7d096e7..64a685b61a 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,8 @@ "wait-on": "^9.0.1" }, "devDependencies": { + "@types/react-dom": "^19.2.2", "husky": "^9.0.10", "lint-staged": "^16.0.0" } -} +} \ No newline at end of file diff --git a/validation/src/Template/index.ts b/validation/src/Template/index.ts index f7499293f2..6bd8525494 100644 --- a/validation/src/Template/index.ts +++ b/validation/src/Template/index.ts @@ -82,3 +82,20 @@ export const createQuestionTemplateRelationValidationSchema = sortOrder: Yup.number().required(), topicId: Yup.number().required(), }); + +export const createEmailTemplateValidationSchema = Yup.object().shape({ + name: Yup.string().required(), + description: Yup.string().required(), + useTemplateFile: Yup.bool().required(), + subject: Yup.string().notRequired(), + body: Yup.string().notRequired(), +}); + +export const updateEmailTemplateValidationSchema = Yup.object().shape({ + id: Yup.number().required(), + name: Yup.string().required(), + description: Yup.string().required(), + useTemplateFile: Yup.bool().required(), + subject: Yup.string().notRequired(), + body: Yup.string().notRequired(), +}); \ No newline at end of file