diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 27823cd073..4510130811 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -412,6 +412,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..31447b41cd --- /dev/null +++ b/apps/backend/db_patches/0202_CreateEmailTemplatesTable.sql @@ -0,0 +1,100 @@ +DO +$$ +DECLARE + email_template_id_var int; + connection_loop_var record; + array_size_var int; + array_loop_var int; + template_name varchar(200); +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, + subject varchar(160) NOT NULL, + body text + ); + + INSERT INTO email_templates (created_by, name, description, subject, body) + VALUES + (0, 'CLF PI Co-I Submission Email', 'CLF PI Co-I Submission Email', '= `Proposal submitted`', 'Proposal submitted'); + + INSERT INTO email_templates (created_by, name, description, subject, body) + VALUES + (0, 'ISIS PI Co-I Submission Email', 'ISIS PI Co-I Submission Email', '= `Proposal submitted`', 'Proposal submitted'); + + INSERT INTO email_templates (created_by, name, description, subject, body) + VALUES + (0, 'ISIS Rapid PI Co-I Submission Email', 'ISIS Rapid PI Co-I Submission Email', '= `Proposal submitted`', 'Proposal submitted'); + + INSERT INTO email_templates (created_by, name, description, subject, body) + VALUES + (0, 'ISIS Rapid User Office Submission Email', 'ISIS Rapid User Office Submission Email', '= `Proposal submitted`', 'Proposal submitted'); + + INSERT INTO email_templates (created_by, name, description, subject, body) + VALUES + (0, 'ISIS Xpress PI Co-I Submission Email', 'ISIS Xpress PI Co-I Submission Email', '= `Proposal submitted`', 'Proposal submitted'); + + INSERT INTO email_templates (created_by, name, description, subject, body) + VALUES + (0, 'ISIS Xpress Scientist Submission Email', 'ISIS Xpress Scientist Submission Email', '= `Proposal submitted`', 'Proposal submitted'); + + INSERT INTO email_templates (created_by, name, description, subject, body) + VALUES + (0, 'ISIS Xpress PI Co-I Under Review Email', 'ISIS Xpress PI Co-I Under Review Email', '= `Proposal under review`', 'Proposal under review'); + + INSERT INTO email_templates (created_by, name, description, subject, body) + VALUES + (0, 'ISIS Xpress PI Co-I Approval Email', 'ISIS Xpress PI Co-I Approval Email', '= `Proposal approved`', 'Proposal approved'); + + INSERT INTO email_templates (created_by, name, description, subject, body) + VALUES + (0, 'ISIS Xpress SRA Request Email', 'ISIS Xpress SRA Request Email', '= `Proposal SRA request`', 'Proposal SRA request'); + + INSERT INTO email_templates (created_by, name, description, subject, body) + VALUES + (0, 'ISIS Xpress PI Co-I Reject Email', 'ISIS Xpress PI Co-I Reject Email', '= `Proposal unsuccessful`', 'Proposal unsuccessful'); + + INSERT INTO email_templates (created_by, name, description, subject, body) + VALUES + (0, 'ISIS Xpress PI Co-I Finish Email', 'ISIS Xpress PI Co-I Finish Email', '= `Proposal finished`', 'Proposal finished'); + + FOR connection_loop_var IN + SELECT * FROM workflow_connection_has_actions + LOOP + IF connection_loop_var.config IS NOT NULL THEN + SELECT jsonb_array_length(connection_loop_var.config->'recipientsWithEmailTemplate') INTO array_size_var; + + IF array_size_var IS NULL THEN + array_size_var := 0; + END IF; + + FOR array_loop_var IN 0..array_size_var-1 LOOP + SELECT connection_loop_var.config->'recipientsWithEmailTemplate'->array_loop_var->'emailTemplate'->>'name' INTO template_name; + + IF template_name IS NOT NULL THEN + -- RAISE NOTICE 'Processing connection % % with template % to %', array_loop_var, connection_loop_var.connection_id, template_name, update_config; + + SELECT email_templates.email_template_id INTO email_template_id_var FROM email_templates WHERE name = template_name; + + IF email_template_id_var IS NOT NULL THEN + UPDATE workflow_connection_has_actions + SET config = jsonb_set(config::jsonb, ('{recipientsWithEmailTemplate, ' || array_loop_var || ', emailTemplate, id}')::text[], to_jsonb(email_template_id_var), false) + WHERE connection_id = connection_loop_var.connection_id; + -- ELSE + -- RAISE WARNING 'No email template found for name %', template_name; + END IF; + -- ELSE + -- RAISE WARNING 'No email template found for connection %', template_name; + END IF; + END LOOP; + END IF; + END LOOP; + END IF; +END; +$$ +LANGUAGE plpgsql; \ No newline at end of file diff --git a/apps/backend/db_patches/db_seeds/0007_EmailTemplates.sql b/apps/backend/db_patches/db_seeds/0007_EmailTemplates.sql new file mode 100644 index 0000000000..fef4bb421a --- /dev/null +++ b/apps/backend/db_patches/db_seeds/0007_EmailTemplates.sql @@ -0,0 +1,15 @@ +DO +$DO$ +BEGIN + + INSERT INTO email_templates (created_by, name, description, subject, body) + VALUES + (0, 'template-name-1', 'template-description-1', 'template-subject-1', 'template-body-1'); + + INSERT INTO email_templates (created_by, name, description, subject, body) + VALUES + (0, 'template-name-2', 'template-description-2', 'template-subject-2', 'template-body-2'); + +END; +$DO$ +LANGUAGE plpgsql; diff --git a/apps/backend/package-lock.json b/apps/backend/package-lock.json index 81c999140a..77738ac2d3 100644 --- a/apps/backend/package-lock.json +++ b/apps/backend/package-lock.json @@ -70,6 +70,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", @@ -82,6 +83,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", @@ -3180,6 +3182,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", @@ -3482,6 +3491,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 a4e155571e..fa99a11389 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -92,6 +92,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", @@ -104,6 +105,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", @@ -129,4 +131,4 @@ "npm": ">=10.9.2", "node": ">=22.0.0" } -} \ No newline at end of file +} 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 295cc67524..788370fd24 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'; @@ -36,6 +37,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'; @@ -104,6 +106,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), @@ -137,6 +140,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 44cbf95bc4..ba8e35453c 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 f271d12ed8..8f2a73cabf 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'; @@ -124,6 +125,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 f99f9948cd..4336289145 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'; @@ -114,6 +115,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 6a158f0b8c..03a5ccf3a0 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'; @@ -117,6 +118,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/dependencyConfigESS.ts b/apps/backend/src/config/dependencyConfigESS.ts index ddf4cdaa05..71f3bc319f 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'; @@ -118,6 +119,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 bcc7995ff9..9010a99850 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'; @@ -114,6 +115,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 b3f0c39c0a..31868e6ad4 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'; @@ -106,6 +107,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 2ae3032b37..3d1fc8e1ed 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'; @@ -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'; @@ -100,6 +102,7 @@ interface ResolverContextQueries { statusAction: StatusActionQueries; tag: TagQueries; experiment: ExperimentQueries; + emailTemplate: EmailTemplateQueries; } interface ResolverContextMutations { @@ -132,6 +135,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..96f5c58f4d --- /dev/null +++ b/apps/backend/src/datasources/EmailTemplateDataSource.ts @@ -0,0 +1,28 @@ +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, + subject: string, + body: string + ): Promise; + + update( + id: number, + name: string, + description: string, + 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..245d7e7091 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( + emailTemplateName: string + ): 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..ff00dcc0f5 --- /dev/null +++ b/apps/backend/src/datasources/mockups/EmailTemplateDataSource.ts @@ -0,0 +1,137 @@ +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: '', +}; + +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.', + 'Welcome to Our Service', + 'Hello, thank you for signing up for our service. We are excited to have you on board!' + ), + new EmailTemplate( + 1, + 2, + EmailTemplateId.PROPOSAL_CREATED, + 'This is a dummy email template for testing purposes.', + 'Welcome to Our Service', + 'Hello, thank you for signing up for our service. We are excited to have you on board!' + ), + new EmailTemplate( + 1, + 2, + EmailTemplateId.ACCEPTED_PROPOSAL, + 'This is a dummy email template for testing purposes.', + 'Welcome to Our Service', + 'Hello, thank you for signing up for our service. We are excited to have you on board!' + ), + new EmailTemplate( + 1, + 2, + EmailTemplateId.REJECTED_PROPOSAL, + 'This is a dummy email template for testing purposes.', + 'Welcome to Our Service', + 'Hello, thank you for signing up for our service. We are excited to have you on board!' + ), + new EmailTemplate( + 1, + 2, + EmailTemplateId.RESERVED_PROPOSAL, + 'This is a dummy email template for testing purposes.', + 'Welcome to Our Service', + 'Hello, thank you for signing up for our service. We are excited to have you on board!' + ), + new EmailTemplate( + 1, + 2, + EmailTemplateId.REVIEW_REMINDER, + 'This is a dummy email template for testing purposes.', + 'Welcome to Our Service', + 'Hello, thank you for signing up for our service. We are excited to have you on board!' + ), + new EmailTemplate( + 1, + 2, + EmailTemplateId.INTERNAL_REVIEW_CREATED, + 'This is a dummy email template for testing purposes.', + 'Welcome to Our Service', + 'Hello, thank you for signing up for our service. We are excited to have you on board!' + ), + new EmailTemplate( + 1, + 2, + EmailTemplateId.INTERNAL_REVIEW_DELETED, + 'This is a dummy email template for testing purposes.', + 'Welcome to Our Service', + 'Hello, thank you for signing up for our service. We are excited to have you on board!' + ), + new EmailTemplate( + 1, + 2, + EmailTemplateId.INTERNAL_REVIEW_UPDATED, + 'This is a dummy email template for testing purposes.', + '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, + subject: string, + body: string + ): Promise { + return dummyEmailTemplate; + } + + async update( + id: number, + name: string, + description: string, + 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 5447b1ed66..09520b0978 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 { Invite } from '../../models/Invite'; import { CoProposerClaimDataSource } from '../CoProposerClaimDataSource'; import { GetInvitesFilter, InviteDataSource } from '../InviteDataSource'; diff --git a/apps/backend/src/datasources/mockups/StatusActionsDataSource.ts b/apps/backend/src/datasources/mockups/StatusActionsDataSource.ts index faa58b8c3e..d9d18f03c7 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( + emailTemplateName: string + ): 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..95f7142d53 --- /dev/null +++ b/apps/backend/src/datasources/postgres/EmailTemplateDataSource.ts @@ -0,0 +1,142 @@ +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, + subject: string, + body: string + ): Promise { + return database + .insert( + { + created_by: createdByUserId, + name: name, + description: description, + subject: subject, + body: body, + }, + ['*'] + ) + .from('email_templates') + .then((emailTemplates: EmailTemplateRecord[]) => { + if (emailTemplates?.length === 0) { + throw new GraphQLError(`Failed to create email template '${name}'`); + } + + return createEmailTemplateObject(emailTemplates[0]); + }); + } + + async update( + emailTemplateId: number, + name: string, + description: string, + subject: string, + body: string + ): Promise { + return database + .update( + { + name: name, + description: description, + subject: subject, + body: body, + }, + ['*'] + ) + .from('email_templates') + .where('email_templates.email_template_id', emailTemplateId) + .then((emailTemplates: EmailTemplateRecord[]) => { + if (emailTemplates === undefined || 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 === undefined || 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..c80b264def 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( + emailTemplateName: string + ): Promise { + const fromClause = "config->'recipientsWithEmailTemplate'"; + const pattern = `\'[{"emailTemplate": {"name": "${emailTemplateName}"}}]\'`; + 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 d3abb964b8..b148522400 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 { Fap, FapAssignment, FapProposal, FapReviewer } from '../../models/Fap'; @@ -369,6 +370,15 @@ 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 subject: string; + readonly body: string; +} + export interface PageTextRecord { readonly pagetext_id: number; readonly content: string; @@ -1060,6 +1070,19 @@ 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.subject, + emailTemplate.body + ); +}; + export const createCallHasInstrumentObject = ( callHasInstrument: CallHasInstrumentRecord ) => { 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..cab1bdbb26 100644 --- a/apps/backend/src/eventHandlers/MailService/MailService.ts +++ b/apps/backend/src/eventHandlers/MailService/MailService.ts @@ -28,7 +28,7 @@ export type STFCEmailTemplate = { }; export type ELIEmailTemplate = { - id: string; + id: number; name: string; }; diff --git a/apps/backend/src/eventHandlers/MailService/SMTPMailService.spec.ts b/apps/backend/src/eventHandlers/MailService/SMTPMailService.spec.ts index 48f57ccd84..56d1d88a08 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: [ @@ -93,6 +93,7 @@ test('All emails with bcc were successfully sent', async () => { message: { to: process.env.SINK_EMAIL, bcc: bccEmail, + subject: '= ``', }, locals: substitutionData, }); diff --git a/apps/backend/src/eventHandlers/MailService/SMTPMailService.ts b/apps/backend/src/eventHandlers/MailService/SMTPMailService.ts index 38fa36aed7..f6ad053503 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 { ELIEmailTemplate, MailService, SendMailResults } 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,49 @@ export class SMTPMailService extends MailService { ); } + private async getEmailTemplate(options: EmailSettings): Promise<{ + subject: string; + body: string; + } | null> { + if (process.env.NODE_ENV === 'test') { + return { subject: '= ``', body: '' }; + } + + let templateBody = ''; + let templateSubject = ''; + + const emailTemplate = + await this.emailTemplateDataSource.getEmailTemplateByName( + options.content.template + ); + + if (emailTemplate) { + templateBody = emailTemplate.body; + templateSubject = emailTemplate.subject; + } else { + const templateBodyPath = + this.getEmailTemplatePath('html', options.content.template) + '.pug'; + const templateSubjectPath = + this.getEmailTemplatePath('subject', options.content.template) + '.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; + } + } + + return { + subject: pug.render(templateSubject, {}), + body: pug.render(templateBody, options.substitution_data || {}), + }; + } + private getSmtpAuthOptions() { if (process.env.EMAIL_AUTH_USERNAME && process.env.EMAIL_AUTH_PASSWORD) { return { @@ -115,45 +189,51 @@ export class SMTPMailService extends MailService { sendMailResults.id = 'test'; } - const template = - this.getEmailTemplatePath('html', options.content.template_id) + '.pug'; + const template = await this.getEmailTemplate(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 }; } options.recipients.forEach((participant) => { - emailPromises.push( - this.emailTemplates.send({ - template: options.content.template_id, - message: { - ...(typeof participant.address !== 'string' - ? { - to: { - address: isProduction - ? participant.address?.email - : process.env.SINK_EMAIL, - name: participant.address?.header_to, - }, - bcc: bccAddress, - } - : { - to: isProduction - ? participant.address + const emailOptions = { + message: { + ...(typeof participant.address !== 'string' + ? { + to: { + address: isProduction + ? participant.address?.email : process.env.SINK_EMAIL, - bcc: bccAddress, - }), - }, - locals: options.substitution_data, - }) - ); + name: participant.address?.header_to, + }, + bcc: bccAddress, + subject: template.subject, + } + : { + to: isProduction + ? participant.address + : process.env.SINK_EMAIL, + bcc: bccAddress, + subject: template.subject, + }), + }, + locals: options.substitution_data, + template: template.body, + }; + + emailPromises.push(this.emailTemplate.send(emailOptions)); }); return Promise.allSettled(emailPromises).then((results) => { @@ -174,54 +254,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, + name: template.name || '', + })), }; } } diff --git a/apps/backend/src/eventHandlers/MailService/SkipSendMailService.ts b/apps/backend/src/eventHandlers/MailService/SkipSendMailService.ts index 184291a96a..a07950a699 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 { diff --git a/apps/backend/src/eventHandlers/email/eliEmailHandler.spec.ts b/apps/backend/src/eventHandlers/email/eliEmailHandler.spec.ts index 6aa5e083f5..626f02a6d8 100644 --- a/apps/backend/src/eventHandlers/email/eliEmailHandler.spec.ts +++ b/apps/backend/src/eventHandlers/email/eliEmailHandler.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'; @@ -8,6 +8,7 @@ import { RoleClaimDataSourceMock } from '../../datasources/mockups/RoleClaimData import { ApplicationEvent } from '../../events/applicationEvents'; import { Event } from '../../events/event.enum'; import { eliEmailHandler } from './eliEmailHandler'; +import { EmailTemplateId } from './emailTemplateId'; // Mock MailService const mockMailService = { @@ -61,7 +62,7 @@ describe('eliEmailHandler', () => { expect(mockMailService.sendMail).toHaveBeenCalledWith( expect.objectContaining({ content: { - template_id: 'proposal-created', + template: EmailTemplateId.PROPOSAL_CREATED, }, }) ); @@ -90,7 +91,7 @@ describe('eliEmailHandler', () => { expect(mockMailService.sendMail).toHaveBeenCalledWith( expect.objectContaining({ content: { - template_id: 'Accepted-Proposal', + template: EmailTemplateId.ACCEPTED_PROPOSAL, }, }) ); @@ -119,7 +120,7 @@ describe('eliEmailHandler', () => { expect(mockMailService.sendMail).toHaveBeenCalledWith( expect.objectContaining({ content: { - template_id: 'Rejected-Proposal', + template: EmailTemplateId.REJECTED_PROPOSAL, }, }) ); @@ -148,13 +149,13 @@ describe('eliEmailHandler', () => { expect(mockMailService.sendMail).toHaveBeenCalledWith( expect.objectContaining({ content: { - template_id: 'Reserved-Proposal', + template: EmailTemplateId.RESERVED_PROPOSAL, }, }) ); }); - 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, @@ -173,7 +174,7 @@ describe('eliEmailHandler', () => { expect(mockMailService.sendMail).toHaveBeenCalledWith( expect.objectContaining({ content: { - template_id: 'review-reminder', + template: EmailTemplateId.REVIEW_REMINDER, }, }) ); @@ -206,7 +207,7 @@ describe('eliEmailHandler', () => { expect(mockMailService.sendMail).toHaveBeenCalledWith( expect.objectContaining({ content: { - template_id: 'internal-review-created', + template: EmailTemplateId.INTERNAL_REVIEW_CREATED, }, }) ); @@ -239,7 +240,7 @@ describe('eliEmailHandler', () => { expect(mockMailService.sendMail).toHaveBeenCalledWith( expect.objectContaining({ content: { - template_id: 'internal-review-updated', + template: EmailTemplateId.INTERNAL_REVIEW_UPDATED, }, }) ); @@ -272,7 +273,7 @@ describe('eliEmailHandler', () => { expect(mockMailService.sendMail).toHaveBeenCalledWith( expect.objectContaining({ content: { - template_id: 'internal-review-deleted', + template: EmailTemplateId.INTERNAL_REVIEW_DELETED, }, }) ); diff --git a/apps/backend/src/eventHandlers/email/eliEmailHandler.ts b/apps/backend/src/eventHandlers/email/eliEmailHandler.ts index f5f93dd1a8..152ff8587b 100644 --- a/apps/backend/src/eventHandlers/email/eliEmailHandler.ts +++ b/apps/backend/src/eventHandlers/email/eliEmailHandler.ts @@ -3,11 +3,13 @@ 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 { RedeemCodesDataSource } from '../../datasources/RedeemCodesDataSource'; import { ReviewDataSource } from '../../datasources/ReviewDataSource'; +import { RoleClaimDataSource } from '../../datasources/RoleClaimDataSource'; import { UserDataSource } from '../../datasources/UserDataSource'; import { ApplicationEvent } from '../../events/applicationEvents'; import { Event } from '../../events/event.enum'; @@ -17,6 +19,7 @@ import { ProposalEndStatus } from '../../models/Proposal'; import { BasicUserDetails, UserRole } 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); @@ -28,6 +31,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 ); @@ -43,6 +54,10 @@ export async function eliEmailHandler(event: ApplicationEvent) { Tokens.EventBus ); + const emailTemplateDataSource = container.resolve( + Tokens.EmailTemplateDataSource + ); + if (event.isRejection) { return; } @@ -76,13 +91,18 @@ export async function eliEmailHandler(event: ApplicationEvent) { return; } + const templateId = + event.emailinviteresponse.role === UserRole.USER + ? EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_USER + : EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_REVIEWER; + + const emailTemplate = + await emailTemplateDataSource.getEmailTemplateByName(templateId); + mailService .sendMail({ content: { - template_id: - event.emailinviteresponse.role === UserRole.USER - ? 'user-office-registration-invitation' - : 'user-office-registration-invitation-reviewer', + template: emailTemplate?.name || templateId || '', }, substitution_data: { firstname: user.preferredname, @@ -136,6 +156,7 @@ export async function eliEmailHandler(event: ApplicationEvent) { }); }); } + break; } @@ -150,86 +171,35 @@ 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 }], - }; - - 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, - }); - }); - - return; - } + const templateId = EmailTemplateId.PROPOSAL_CREATED; - case Event.PROPOSAL_SUBMITTED: { - const principalInvestigator = await userDataSource.getUser( - event.proposal.proposerId - ); - const participants = await userDataSource.getProposalUsersFull( - event.proposal.primaryKey - ); - if (!principalInvestigator) { - return; - } + const emailTemplate = + await emailTemplateDataSource.getEmailTemplateByName(templateId); const options: EmailSettings = { content: { - template_id: 'proposal-submitted', + template: emailTemplate?.name || templateId || '', }, 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, }); @@ -248,28 +218,30 @@ export async function eliEmailHandler(event: ApplicationEvent) { const { finalStatus } = event.proposal; let templateId = ''; if (finalStatus === ProposalEndStatus.ACCEPTED) { - templateId = 'Accepted-Proposal'; + templateId = EmailTemplateId.ACCEPTED_PROPOSAL; } else if (finalStatus === ProposalEndStatus.REJECTED) { - templateId = 'Rejected-Proposal'; + templateId = EmailTemplateId.REJECTED_PROPOSAL; } else if (finalStatus === ProposalEndStatus.RESERVED) { - templateId = 'Reserved-Proposal'; + templateId = EmailTemplateId.RESERVED_PROPOSAL; } else { logger.logError('Failed email notification', { event }); return; } + const emailTemplate = + await emailTemplateDataSource.getEmailTemplateByName(templateId); + mailService .sendMail({ content: { - template_id: templateId, + template: emailTemplate?.name || templateId || '', }, 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 }], }) @@ -297,17 +269,19 @@ export async function eliEmailHandler(event: ApplicationEvent) { return; } + const templateId = EmailTemplateId.REVIEW_REMINDER; + const emailTemplate = + await emailTemplateDataSource.getEmailTemplateByName(templateId); + mailService .sendMail({ content: { - template_id: 'review-reminder', + template: emailTemplate?.name || templateId || '', }, 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 }], }) @@ -376,18 +350,21 @@ export async function eliEmailHandler(event: ApplicationEvent) { } } - let templateId = 'internal-review-created'; + let templateId = EmailTemplateId.INTERNAL_REVIEW_CREATED; if (event.type === Event.INTERNAL_REVIEW_UPDATED) { - templateId = 'internal-review-updated'; + templateId = EmailTemplateId.INTERNAL_REVIEW_UPDATED; } else if (event.type === Event.INTERNAL_REVIEW_DELETED) { - templateId = 'internal-review-deleted'; + templateId = EmailTemplateId.INTERNAL_REVIEW_DELETED; } + const emailTemplate = + await emailTemplateDataSource.getEmailTemplateByName(templateId); + mailService .sendMail({ content: { - template_id: templateId, + template: emailTemplate?.name || templateId || '', }, substitution_data: { assignedByPreferredName: assignedBy.preferredname, @@ -396,8 +373,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 }], @@ -418,6 +394,17 @@ export async function eliEmailHandler(event: ApplicationEvent) { } } +function getTemplateIdForRole(role: UserRole): string { + switch (role) { + case UserRole.USER: + return EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_USER; + case UserRole.INTERNAL_REVIEWER: + return EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_REVIEWER; + default: + throw new Error('No valid user role set for email invitation'); + } +} + async function sendInviteEmail( invite: Invite, inviter: BasicUserDetails, @@ -427,11 +414,17 @@ async function sendInviteEmail( const inviteDataSource = container.resolve( Tokens.InviteDataSource ); + const emailTemplateDataSource = container.resolve( + Tokens.EmailTemplateDataSource + ); + + const emailTemplate = + await emailTemplateDataSource.getEmailTemplateByName(templateId); return mailService .sendMail({ content: { - template_id: templateId, + template: emailTemplate?.name || templateId || '', }, substitution_data: { email: invite.email, 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 84d5df2edf..080c7ebdc3 100644 --- a/apps/backend/src/eventHandlers/email/essEmailHandler.spec.ts +++ b/apps/backend/src/eventHandlers/email/essEmailHandler.spec.ts @@ -1,13 +1,13 @@ -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 { CoProposerClaimDataSourceMock } from '../../datasources/mockups/CoProposerClaimDataSource'; import { - ProposalDataSourceMock, dummyProposal, + ProposalDataSourceMock, } from '../../datasources/mockups/ProposalDataSource'; import { basicDummyUser, @@ -17,7 +17,8 @@ import { import { ApplicationEvent } from '../../events/applicationEvents'; import { Event } from '../../events/event.enum'; import { Invite } from '../../models/Invite'; -import { EmailTemplateId, essEmailHandler } from './essEmailHandler'; +import { EmailTemplateId } from './emailTemplateId'; +import { essEmailHandler } from './essEmailHandler'; // Mock MailService const mockMailService = { @@ -83,7 +84,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), @@ -134,7 +135,7 @@ describe('essEmailHandler co-proposer invites', () => { expect(mockMailService.sendMail).toHaveBeenCalledWith({ content: { - template_id: + template: EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_VISIT_REGISTRATION, }, substitution_data: { @@ -329,7 +330,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([ diff --git a/apps/backend/src/eventHandlers/email/essEmailHandler.ts b/apps/backend/src/eventHandlers/email/essEmailHandler.ts index addff8f45e..8fc9cb41a8 100644 --- a/apps/backend/src/eventHandlers/email/essEmailHandler.ts +++ b/apps/backend/src/eventHandlers/email/essEmailHandler.ts @@ -17,22 +17,7 @@ import { ProposalEndStatus } from '../../models/Proposal'; import { BasicUserDetails } from '../../models/User'; import EmailSettings from '../MailService/EmailSettings'; import { MailService } from '../MailService/MailService'; - -export enum EmailTemplateId { - CO_PROPOSER_INVITE_ACCEPTED = 'co-proposer-invite-accepted', - PROPOSAL_SUBMITTED = 'proposal-submitted', - 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'; export async function essEmailHandler(event: ApplicationEvent) { const mailService = container.resolve(Tokens.MailService); const proposalDataSource = container.resolve( @@ -119,7 +104,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, @@ -163,7 +148,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, @@ -231,7 +216,7 @@ export async function essEmailHandler(event: ApplicationEvent) { mailService .sendMail({ content: { - template_id: templateId, + template: templateId, }, substitution_data: { piPreferredname: principalInvestigator.preferredname, @@ -352,7 +337,7 @@ export async function essEmailHandler(event: ApplicationEvent) { mailService .sendMail({ content: { - template_id: EmailTemplateId.REVIEW_REMINDER, + template: EmailTemplateId.REVIEW_REMINDER, }, substitution_data: { fapReviewerPreferredName: fapReviewer.preferredname, @@ -433,7 +418,7 @@ export async function essEmailHandler(event: ApplicationEvent) { mailService .sendMail({ content: { - template_id: templateId, + template: templateId, }, substitution_data: { preferredname: user.preferredname, @@ -489,7 +474,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.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 0db6464179..995c878a49 100644 --- a/apps/backend/src/eventHandlers/messageBroker.ts +++ b/apps/backend/src/eventHandlers/messageBroker.ts @@ -294,6 +294,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 2bff444905..80400cc340 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'; @@ -460,6 +461,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 @@ -545,4 +561,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 e98a24165e..8f9352042b 100644 --- a/apps/backend/src/events/event.enum.ts +++ b/apps/backend/src/events/event.enum.ts @@ -97,6 +97,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', } @@ -421,4 +424,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..bbf71d7fad --- /dev/null +++ b/apps/backend/src/models/EmailTemplate.ts @@ -0,0 +1,10 @@ +export class EmailTemplate { + constructor( + public id: number, + public createdByUserId: number, + public name: string, + public description: string, + public subject: string, + public body: string + ) {} +} 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..168aa7e5da --- /dev/null +++ b/apps/backend/src/mutations/EmailTemplateMutation.spec.ts @@ -0,0 +1,88 @@ +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.', + subject: 'Welcome to Our Service', + body: 'Hello, thank you for signing up for our service. We are excited to have you on board!', + createdByUserId: 1, + }) + ).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.', + subject: 'Welcome to Our Service', + body: 'Hello, thank you for signing up for our service. We are excited to have you on board!', + createdByUserId: 1, + }) + ).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.', + subject: 'Welcome to Our Service', + body: 'Hello, thank you for signing up for our service. We are excited to have you on board!', + createdByUserId: 1, + }; + + return expect( + emailTemplateMutations.create( + dummyUserOfficerWithRole, + emailTemplateToCreate + ) + ).resolves.toStrictEqual({ + id: 1, + createdAt: '', + ...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.', + subject: 'Welcome to Our Service', + body: 'Hello, thank you for signing up for our service. We are excited to have you on board!', + createdByUserId: 1, + }; + + return expect( + emailTemplateMutations.update( + dummyUserOfficerWithRole, + emailTemplateToUpdate + ) + ).resolves.toStrictEqual({ + createdAt: '', + ...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..41f352b9ac --- /dev/null +++ b/apps/backend/src/mutations/EmailTemplateMutations.ts @@ -0,0 +1,121 @@ +import { inject, injectable } from 'tsyringe'; + +import { Tokens } from '../config/Tokens'; +import { EmailTemplateDataSource } from '../datasources/EmailTemplateDataSource'; +import StatusActionsDataSource from '../datasources/postgres/StatusActionsDataSource'; +import { Authorized, EventBus } 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 + ) {} + + @EventBus(Event.EMAIL_TEMPLATE_CREATED) + @Authorized([Roles.USER_OFFICER]) + async create( + agent: UserWithRole | null, + args: CreateEmailTemplateInput + ): Promise { + try { + const createdEmailTemplate = await this.dataSource.create( + args.createdByUserId, + args.name, + args.description, + args.subject, + args.body + ); + + return createdEmailTemplate; + } catch (error) { + return rejection('Could not create email template', { + agent, + name: args.name, + }); + } + } + + @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.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.name + ); + + 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) { + // NOTE: We are explicitly casting error to { code: string } type because it is the easiest solution for now and because it's type is a bit difficult to determine because of knexjs not returning typed error message. + if ((error as { code: string }).code === '23503') { + return rejection( + 'Failed to delete call, it has dependencies which need to be deleted first', + { emailTemplateId }, + 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 e1f8a1e907..5255dfd28f 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..98fce5a9cb --- /dev/null +++ b/apps/backend/src/queries/EmailTemplateQueries.ts @@ -0,0 +1,44 @@ +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) { + const emailTemplate = await this.dataSource.getEmailTemplate(id); + + if (!emailTemplate) { + return null; + } + + if (this.userAuth.isApiToken(agent) || this.userAuth.isUserOfficer(agent)) { + return emailTemplate; + } else { + return null; + } + } + + @Authorized([Roles.USER_OFFICER]) + async getAll(agent: UserWithRole | null, filter: EmailTemplatesFilter) { + const emailTemplates = this.dataSource.getEmailTemplates(filter); + + if (this.userAuth.isApiToken(agent) || this.userAuth.isUserOfficer(agent)) { + return emailTemplates; + } else { + return null; + } + } +} diff --git a/apps/backend/src/queries/StatusActionQueries.ts b/apps/backend/src/queries/StatusActionQueries.ts index 7bf1463966..7ba6490ffc 100644 --- a/apps/backend/src/queries/StatusActionQueries.ts +++ b/apps/backend/src/queries/StatusActionQueries.ts @@ -1,6 +1,7 @@ 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 +22,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 +62,16 @@ 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, - })); - - return new EmailActionDefaultConfig(allEmailRecipients, emailTemplates); + return new EmailActionDefaultConfig( + allEmailRecipients, + emailTemplatesResult.results.map((e) => ({ + id: e.name, + 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..edc82b954e --- /dev/null +++ b/apps/backend/src/resolvers/mutations/CreateEmailTemplateMutation.ts @@ -0,0 +1,45 @@ +import { + Arg, + Ctx, + Field, + InputType, + Int, + Mutation, + Resolver, +} from 'type-graphql'; + +import { ResolverContext } from '../../context'; +import { EmailTemplate } from '../types/EmailTemplate'; + +@InputType() +export class CreateEmailTemplateInput { + @Field(() => Int) + public createdByUserId: number; + + @Field(() => String) + public name: string; + + @Field(() => String) + public description: string; + + @Field(() => String) + public subject: string; + + @Field(() => String) + 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..85429560a4 --- /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(() => Int) + public createdByUserId: number; + + @Field(() => String) + public name: string; + + @Field(() => String) + public description: string; + + @Field(() => String) + public subject: string; + + @Field(() => String) + 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..fd13dbd875 --- /dev/null +++ b/apps/backend/src/resolvers/types/EmailTemplate.ts @@ -0,0 +1,24 @@ +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(() => String) + public subject: string; + + @Field(() => String) + 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 ccf5968b61..7360f72183 100644 --- a/apps/backend/src/statusActionEngine/emailActionHandler.ts +++ b/apps/backend/src/statusActionEngine/emailActionHandler.ts @@ -3,6 +3,7 @@ import { container } from 'tsyringe'; import { Tokens } from '../config/Tokens'; import { AdminDataSource } from '../datasources/AdminDataSource'; +import { EmailTemplateDataSource } from '../datasources/EmailTemplateDataSource'; import { InstrumentDataSource } from '../datasources/InstrumentDataSource'; import { UserDataSource } from '../datasources/UserDataSource'; import { MailService } from '../eventHandlers/MailService/MailService'; @@ -16,16 +17,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 +91,7 @@ export const emailStatusActionRecipient = async ( loggedInUserId?: number | null ) => { const proposalPks = proposals.map((proposal) => proposal.primaryKey); - const templateMessage = recipientWithTemplate.emailTemplate.id; + const emailTemplateName = recipientWithTemplate.emailTemplate.name; const successfulMessage = !!statusActionsLogId ? 'Email successfully sent on status action replay' : 'Email successfully sent'; @@ -115,7 +116,7 @@ export const emailStatusActionRecipient = async ( }), successfulMessage, failMessage, - templateMessage, + emailTemplateName, loggedInUserId ); @@ -140,7 +141,7 @@ export const emailStatusActionRecipient = async ( }), successfulMessage, failMessage, - templateMessage, + emailTemplateName, loggedInUserId )); @@ -164,7 +165,7 @@ export const emailStatusActionRecipient = async ( }), successfulMessage, failMessage, - templateMessage, + emailTemplateName, loggedInUserId ); @@ -188,7 +189,7 @@ export const emailStatusActionRecipient = async ( }), successfulMessage, failMessage, - templateMessage, + emailTemplateName, loggedInUserId ); @@ -213,7 +214,7 @@ export const emailStatusActionRecipient = async ( }), successfulMessage, failMessage, - templateMessage, + emailTemplateName, loggedInUserId ); @@ -250,7 +251,7 @@ export const emailStatusActionRecipient = async ( id: recipientWithTemplate.recipient.name, email: userOfficeEmail, proposals: proposals, - template: recipientWithTemplate.emailTemplate.id, + template: recipientWithTemplate.emailTemplate.name, }, ]; } else { @@ -262,7 +263,8 @@ export const emailStatusActionRecipient = async ( id: recipientWithTemplate.recipient.name, email: userOfficeEmail, proposals: [proposal], - template: recipientWithTemplate.emailTemplate.id, + template: recipientWithTemplate.emailTemplate.name, + templateId: recipientWithTemplate.emailTemplate.id, instruments: await instrumentDataSource.getInstrumentsByProposalPk( proposal.primaryKey ), @@ -288,7 +290,7 @@ export const emailStatusActionRecipient = async ( }), successfulMessage, failMessage, - templateMessage, + emailTemplateName, loggedInUserId ); @@ -313,7 +315,7 @@ export const emailStatusActionRecipient = async ( }), successfulMessage, failMessage, - templateMessage, + emailTemplateName, loggedInUserId ); @@ -348,7 +350,7 @@ export const emailStatusActionRecipient = async ( id: recipientWithTemplate.recipient.name, email: experimentSafetyEmail, proposals: proposals, - template: recipientWithTemplate.emailTemplate.id, + template: recipientWithTemplate.emailTemplate.name, }, ]; } else { @@ -372,7 +374,7 @@ export const emailStatusActionRecipient = async ( }), successfulMessage, failMessage, - templateMessage, + emailTemplateName, loggedInUserId ); @@ -405,7 +407,7 @@ export const emailStatusActionRecipient = async ( }), successfulMessage, failMessage, - templateMessage, + emailTemplateName, loggedInUserId ); } @@ -425,10 +427,13 @@ const sendMail = async ( ) => Promise, successfulMessage: string, failMessage: string, - templateMessage: string, + emailTemplateName: string, loggedInUserId?: number | null ) => { const mailService = container.resolve(Tokens.MailService); + const emailTemplateDataSource = container.resolve( + Tokens.EmailTemplateDataSource + ); const loggingHandler = container.resolve< (event: ApplicationEvent) => Promise >(Tokens.LoggingHandler); @@ -443,13 +448,30 @@ const sendMail = async ( return; } + + const emailTemplate = + await emailTemplateDataSource.getEmailTemplateByName(emailTemplateName); + + if (!emailTemplate) { + logger.logInfo( + `Could not send email(s) because the email template with id ${emailTemplateName} does not exist.`, + { recipientsWithData } + ); + } + + logger.logInfo( + `Preparing to send email(s) using template: ${emailTemplate?.name} (${emailTemplateName}) to ${recipientsWithData.length} recipient(s).`, + { recipientsWithData } + ); + try { const mailServiceResponse = await Promise.all( recipientsWithData.map(async (recipientWithData) => { try { const res = await mailService.sendMail({ content: { - template_id: recipientWithData.template, + template: emailTemplate?.name || '', + email_rfc822: '', }, substitution_data: { proposals: recipientWithData.proposals, @@ -473,7 +495,7 @@ const sendMail = async ( const evt = constructProposalStatusChangeEvent( proposal, loggedInUserId || null, - `${successfulMessage} template: ${templateMessage} to: ${recipientWithData.email} recipient: ${recipientWithData.id}`, + `${successfulMessage} template: ${emailTemplate?.name} to: ${recipientWithData.email} recipient: ${recipientWithData.id}`, undefined ); emailEventHandler(evt); @@ -490,7 +512,7 @@ const sendMail = async ( const evt = constructProposalStatusChangeEvent( proposal, loggedInUserId || null, - `${failMessage} template: ${templateMessage} to: ${recipientWithData.email} recipient: ${recipientWithData.id}`, + `${failMessage} template: ${emailTemplate?.name} to: ${recipientWithData.email} recipient: ${recipientWithData.id}`, undefined ); emailEventHandler(evt); diff --git a/apps/backend/src/statusActionEngine/statusActionUtils.ts b/apps/backend/src/statusActionEngine/statusActionUtils.ts index 1fa7af8e38..eb1a7352ef 100644 --- a/apps/backend/src/statusActionEngine/statusActionUtils.ts +++ b/apps/backend/src/statusActionEngine/statusActionUtils.ts @@ -214,7 +214,7 @@ export const getEmailReadyArrayOfUsersAndProposals = async ( emailReadyUsersWithProposals.push({ id: recipientsWithEmailTemplate.recipient.name, proposals: [proposal], - template: recipientsWithEmailTemplate.emailTemplate.id, + template: recipientsWithEmailTemplate.emailTemplate.name, email: recipient.email, firstName: recipient.firstname, lastName: recipient.lastname, diff --git a/apps/e2e/cypress/e2e/statusActions.cy.ts b/apps/e2e/cypress/e2e/statusActions.cy.ts index 685e6ea897..14688e3ba8 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'; @@ -131,7 +131,10 @@ context('Status actions tests', () => { name: EmailStatusActionRecipients.PI, description: '', }, - emailTemplate: { id: 'pi-template', name: 'PI template' }, + emailTemplate: { + id: 'My First Email', + name: 'My First Email', + }, combineEmails: true, }, ], @@ -222,7 +225,10 @@ context('Status actions tests', () => { name: EmailStatusActionRecipients.PI, description: '', }, - emailTemplate: { id: 'pi-template', name: 'PI template' }, + emailTemplate: { + id: 'My Second Email', + name: 'My Second Email', + }, }, ], }; @@ -294,7 +300,10 @@ context('Status actions tests', () => { name: EmailStatusActionRecipients.PI, description: '', }, - emailTemplate: { id: 'pi-template', name: 'PI template' }, + emailTemplate: { + id: 'My First Email', + name: 'My First Email', + }, }, ], }; @@ -452,7 +461,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: 'My First Email', + name: 'My First Email', + }, otherRecipientEmails: [faker.internet.email()], }, ], @@ -571,8 +583,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: 'My First Email', + name: 'My First Email', }, otherRecipientEmails: [statusActionEmail], }, @@ -684,7 +696,10 @@ context('Status actions tests', () => { name: EmailStatusActionRecipients.PI, description: '', }, - emailTemplate: { id: 'pi-template', name: 'PI template' }, + emailTemplate: { + id: 'My First Email', + name: 'My First Email', + }, }, { recipient: { @@ -693,8 +708,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: 'My First Email', + name: 'My First Email', }, otherRecipientEmails: [faker.internet.email()], }, diff --git a/apps/e2e/cypress/support/initialDBData.ts b/apps/e2e/cypress/support/initialDBData.ts index e85b5a381a..2455bc554c 100644 --- a/apps/e2e/cypress/support/initialDBData.ts +++ b/apps/e2e/cypress/support/initialDBData.ts @@ -459,4 +459,22 @@ export default { proposalPk: 1, questionId: 'sample_declaration_question', }, + emailTemplates: { + template1: { + id: 1, + createdByUserId: 0, + name: 'template-name-1', + description: 'template-description-1', + subject: 'template-subject-1', + body: 'template-body-1', + }, + template2: { + id: 2, + createdByUserId: 0, + name: 'template-name-2', + 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/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 72efde899c..4185d6bd9f 100644 --- a/apps/frontend/src/components/AppRoutes.tsx +++ b/apps/frontend/src/components/AppRoutes.tsx @@ -1,7 +1,7 @@ import i18n from 'i18n'; 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 { FeatureContext } from 'context/FeatureContextProvider'; import { UserContext } from 'context/UserContextProvider'; @@ -117,6 +117,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 = () => ( @@ -457,6 +458,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..23e06ea946 100644 --- a/apps/frontend/src/components/settings/workflow/EmailActionConfig.tsx +++ b/apps/frontend/src/components/settings/workflow/EmailActionConfig.tsx @@ -9,7 +9,8 @@ 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 { KeyboardEvent, useState } from 'react'; +import React from 'react'; import * as Yup from 'yup'; import { @@ -171,6 +172,21 @@ const EmailActionConfig = ({ } }; + const getEmailTemplate = (foundRecipientWithEmailTemplateIndex: number) => { + if (foundRecipientWithEmailTemplateIndex !== -1) { + return ( + emailTemplates.find( + (template) => + template.name === + recipientsWithEmailTemplate[foundRecipientWithEmailTemplateIndex] + ?.emailTemplate?.name + ) || null + ); + } else { + return null; + } + }; + return ( <> @@ -238,7 +254,7 @@ const EmailActionConfig = ({ options={emailTemplates || []} getOptionLabel={(option) => option.name} isOptionEqualToValue={(option, value) => - option.id === value.id + option.name === value.name } renderInput={(params) => ( {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..07ab7607fe --- /dev/null +++ b/apps/frontend/src/components/template/CreateUpdateEmailTemplate.tsx @@ -0,0 +1,134 @@ +import Button from '@mui/material/Button'; +import Typography from '@mui/material/Typography'; +import { Field, Form, Formik } from 'formik'; +import i18n from 'i18n'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import TextField from 'components/common/FormikUITextField'; +import UOLoader from 'components/common/UOLoader'; +import { getCurrentUser } from 'context/UserContextProvider'; +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 || '', + subject: emailTemplate?.subject || '', + body: emailTemplate?.body || '', + createdByUserId: + emailTemplate?.createdByUserId || + (getCurrentUser()?.user.id as number) || + 0, + }; + + return ( + => { + if (emailTemplate) { + try { + const { updateEmailTemplate } = await api({ + toastSuccessMessage: 'Email template updated successfully!', + }).updateEmailTemplate(values); + + close(updateEmailTemplate); + } catch (error) { + close(null); + } + } else { + try { + const { createEmailTemplate } = await api({ + toastSuccessMessage: 'Email template created successfully!', + }).createEmailTemplate(values); + + close(createEmailTemplate); + } catch (error) { + close(null); + } + } + }} + > + {({ isValid }) => ( +
+ + {(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..ec0a71aae3 --- /dev/null +++ b/apps/frontend/src/components/template/EmailTemplateTable.tsx @@ -0,0 +1,98 @@ +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 (error) { + 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..8301b3ec68 --- /dev/null +++ b/apps/frontend/src/graphql/emailTemplate/createEmailTemplate.graphql @@ -0,0 +1,19 @@ +mutation createEmailTemplate( + $createdByUserId: Int! + $name: String! + $description: String! + $subject: String! + $body: String! +) { + createEmailTemplate( + createEmailTemplateInput: { + createdByUserId: $createdByUserId + name: $name + description: $description + 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..3fa38ffb03 --- /dev/null +++ b/apps/frontend/src/graphql/emailTemplate/fragment.emailTemplate.graphql @@ -0,0 +1,8 @@ +fragment emailTemplate on EmailTemplate { + id + createdByUserId + name + description + 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..01693de5b3 --- /dev/null +++ b/apps/frontend/src/graphql/emailTemplate/updateEmailTemplate.graphql @@ -0,0 +1,21 @@ +mutation updateEmailTemplate( + $id: Int! + $createdByUserId: Int! + $name: String! + $description: String! + $subject: String! + $body: String! +) { + updateEmailTemplate( + updateEmailTemplateInput: { + id: $id + createdByUserId: $createdByUserId + name: $name + description: $description + subject: $subject + body: $body + } + ) { + ...emailTemplate + } +} 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 fdc9fbc609..61a131e34f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,9 +11,11 @@ "license": "ISC", "dependencies": { "concurrently": "^9.0.1", + "ejs": "^3.1.10", "wait-on": "^9.0.1" }, "devDependencies": { + "@types/react-dom": "^19.2.2", "husky": "^9.0.10", "lint-staged": "^16.0.0" } @@ -65,6 +67,27 @@ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==" }, + "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", @@ -102,6 +125,12 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -117,6 +146,21 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -319,6 +363,13 @@ "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" + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -340,6 +391,21 @@ "node": ">= 0.4" } }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -412,6 +478,15 @@ "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", "dev": true }, + "node_modules/filelist": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", + "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -608,6 +683,23 @@ "node": ">=0.12.0" } }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/joi": { "version": "18.0.1", "resolved": "https://registry.npmjs.org/joi/-/joi-18.0.1.tgz", @@ -900,6 +992,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", @@ -935,6 +1039,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, "node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", @@ -1263,6 +1373,23 @@ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==" }, + "@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", @@ -1285,6 +1412,11 @@ "color-convert": "^2.0.1" } }, + "async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==" + }, "asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -1300,6 +1432,19 @@ "proxy-from-env": "^1.1.0" } }, + "balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, + "brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "requires": { + "balanced-match": "^1.0.0" + } + }, "braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -1439,6 +1584,12 @@ "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 + }, "delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -1454,6 +1605,14 @@ "gopd": "^1.2.0" } }, + "ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "requires": { + "jake": "^10.8.5" + } + }, "emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -1505,6 +1664,14 @@ "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", "dev": true }, + "filelist": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", + "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", + "requires": { + "minimatch": "^5.0.1" + } + }, "fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -1621,6 +1788,16 @@ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "dev": true }, + "jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "requires": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + } + }, "joi": { "version": "18.0.1", "resolved": "https://registry.npmjs.org/joi/-/joi-18.0.1.tgz", @@ -1818,6 +1995,14 @@ "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", "dev": true }, + "minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "requires": { + "brace-expansion": "^2.0.1" + } + }, "minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", @@ -1838,6 +2023,11 @@ "mimic-function": "^5.0.0" } }, + "picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, "picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", diff --git a/package.json b/package.json index 29d72608e5..56146f8949 100644 --- a/package.json +++ b/package.json @@ -50,9 +50,11 @@ ], "dependencies": { "concurrently": "^9.0.1", + "ejs": "^3.1.10", "wait-on": "^9.0.1" }, "devDependencies": { + "@types/react-dom": "^19.2.2", "husky": "^9.0.10", "lint-staged": "^16.0.0" }