diff --git a/src/app/modules/payments/payments.service.ts b/src/app/modules/payments/payments.service.ts index 5b3bf6127d..f3a44d1fcb 100644 --- a/src/app/modules/payments/payments.service.ts +++ b/src/app/modules/payments/payments.service.ts @@ -242,6 +242,7 @@ export const performPaymentPostSubmissionActions = ( performEncryptPostSubmissionActions({ submission, responses: payment.responses, + emailFields: [], // TODO [EMAIL-CONFIRMATION-BUG]: Email confirmation email to email fields does not work for payment forms, this is an existing issue to be fixed. }) .andThen(() => // If successfully sent email confirmations, delete response data from payment document. diff --git a/src/app/modules/submission/__tests__/submission.service.spec.ts b/src/app/modules/submission/__tests__/submission.service.spec.ts index ff983b3bbc..bb46cd43fc 100644 --- a/src/app/modules/submission/__tests__/submission.service.spec.ts +++ b/src/app/modules/submission/__tests__/submission.service.spec.ts @@ -455,7 +455,9 @@ describe('submission.service', () => { form: mockForm, recipientData, submission: MOCK_SUBMISSION, - attachments: MOCK_ATTACHMENTS, + submissionAttachments: MOCK_ATTACHMENTS, + pdfAttachment: undefined, + isPaymentEnabled: false, responsesData: MOCK_AUTOREPLY_DATA, }) @@ -467,7 +469,9 @@ describe('submission.service', () => { expect(MockMailService.sendAutoReplyEmails).toHaveBeenCalledWith({ form: mockForm, submission: MOCK_SUBMISSION, - attachments: MOCK_ATTACHMENTS, + submissionAttachments: MOCK_ATTACHMENTS, + pdfAttachment: undefined, + isPaymentEnabled: false, responsesData: MOCK_AUTOREPLY_DATA, autoReplyMailDatas: expectedAutoReplyData, }) @@ -496,7 +500,9 @@ describe('submission.service', () => { form: mockForm, recipientData, submission: MOCK_SUBMISSION, - attachments: MOCK_ATTACHMENTS, + submissionAttachments: MOCK_ATTACHMENTS, + pdfAttachment: undefined, + isPaymentEnabled: false, responsesData: MOCK_AUTOREPLY_DATA, }) @@ -541,7 +547,9 @@ describe('submission.service', () => { form: mockForm, recipientData, submission: MOCK_SUBMISSION, - attachments: MOCK_ATTACHMENTS, + submissionAttachments: MOCK_ATTACHMENTS, + pdfAttachment: undefined, + isPaymentEnabled: false, responsesData: MOCK_AUTOREPLY_DATA, }) @@ -592,7 +600,9 @@ describe('submission.service', () => { form: mockForm, recipientData, submission: MOCK_SUBMISSION, - attachments: MOCK_ATTACHMENTS, + submissionAttachments: MOCK_ATTACHMENTS, + pdfAttachment: undefined, + isPaymentEnabled: false, responsesData: MOCK_AUTOREPLY_DATA, }) @@ -601,7 +611,9 @@ describe('submission.service', () => { expect(MockMailService.sendAutoReplyEmails).toHaveBeenCalledWith({ form: mockForm, submission: MOCK_SUBMISSION, - attachments: MOCK_ATTACHMENTS, + submissionAttachments: MOCK_ATTACHMENTS, + pdfAttachment: undefined, + isPaymentEnabled: false, responsesData: MOCK_AUTOREPLY_DATA, autoReplyMailDatas: expectedAutoReplyData, }) @@ -655,8 +667,10 @@ describe('submission.service', () => { form: mockForm, recipientData, submission: MOCK_SUBMISSION, - attachments: MOCK_ATTACHMENTS, - responsesData: undefined, + submissionAttachments: MOCK_ATTACHMENTS, + pdfAttachment: undefined, + isPaymentEnabled: false, + responsesData: [], }) const expectedAutoReplyData = [ @@ -667,14 +681,16 @@ describe('submission.service', () => { expect(MockMailService.sendAutoReplyEmails).toHaveBeenCalledWith({ form: mockForm, submission: MOCK_SUBMISSION, - attachments: MOCK_ATTACHMENTS, + submissionAttachments: MOCK_ATTACHMENTS, responsesData: [], autoReplyMailDatas: expectedAutoReplyData, + pdfAttachment: undefined, + isPaymentEnabled: false, }) expect(result._unsafeUnwrap()).toBe(true) }) - it('should call mail service with attachments undefined when there are no attachments', async () => { + it('should call mail service with pdfAttachment when a pdfAttachment is provided', async () => { const mockForm = { _id: MOCK_FORM_ID, form_fields: [ @@ -717,12 +733,19 @@ describe('submission.service', () => { responses, mockForm.form_fields, ) + + const MOCK_PDF_ATTACHMENT = { + content: Buffer.from('mock pdf buffer'), + filename: 'response.pdf', + } const result = await SubmissionService.sendEmailConfirmations({ form: mockForm, recipientData, submission: MOCK_SUBMISSION, - attachments: undefined, + submissionAttachments: undefined, responsesData: MOCK_AUTOREPLY_DATA, + isPaymentEnabled: false, + pdfAttachment: MOCK_PDF_ATTACHMENT, }) const expectedAutoReplyData = [ @@ -733,7 +756,79 @@ describe('submission.service', () => { expect(MockMailService.sendAutoReplyEmails).toHaveBeenCalledWith({ form: mockForm, submission: MOCK_SUBMISSION, - attachments: undefined, + submissionAttachments: undefined, + pdfAttachment: MOCK_PDF_ATTACHMENT, + isPaymentEnabled: false, + responsesData: MOCK_AUTOREPLY_DATA, + autoReplyMailDatas: expectedAutoReplyData, + }) + expect(result._unsafeUnwrap()).toBe(true) + }) + + it('should call mail service with submissionAttachments undefined and pdfAttachment undefined when there are no submissionAttachments or pdfAttachment', async () => { + const mockForm = { + _id: MOCK_FORM_ID, + form_fields: [ + { + ...generateDefaultField(BasicField.Email), + autoReplyOptions: AUTOREPLY_OPTIONS_1, + }, + { + ...generateDefaultField(BasicField.Email), + autoReplyOptions: AUTOREPLY_OPTIONS_2, + }, + ], + } as unknown as IPopulatedForm + MockMailService.sendAutoReplyEmails.mockResolvedValueOnce([ + { + status: 'fulfilled', + value: ok(true), + }, + { + status: 'fulfilled', + value: ok(true), + }, + ]) + + const responses = [ + { + ...generateNewSingleAnswerResponse(BasicField.Email, { + _id: mockForm.form_fields![0]._id, + answer: MOCK_EMAIL_1, + }), + }, + { + ...generateNewSingleAnswerResponse(BasicField.Email, { + _id: mockForm.form_fields![1]._id, + answer: MOCK_EMAIL_2, + }), + }, + ] + const recipientData = extractEmailConfirmationData( + responses, + mockForm.form_fields, + ) + const result = await SubmissionService.sendEmailConfirmations({ + form: mockForm, + recipientData, + submission: MOCK_SUBMISSION, + submissionAttachments: undefined, + responsesData: MOCK_AUTOREPLY_DATA, + isPaymentEnabled: false, + pdfAttachment: undefined, + }) + + const expectedAutoReplyData = [ + EXPECTED_AUTOREPLY_DATA_1, + EXPECTED_AUTOREPLY_DATA_2, + ] + + expect(MockMailService.sendAutoReplyEmails).toHaveBeenCalledWith({ + form: mockForm, + submission: MOCK_SUBMISSION, + submissionAttachments: undefined, + pdfAttachment: undefined, + isPaymentEnabled: false, responsesData: MOCK_AUTOREPLY_DATA, autoReplyMailDatas: expectedAutoReplyData, }) @@ -780,8 +875,10 @@ describe('submission.service', () => { form: mockForm, recipientData, submission: MOCK_SUBMISSION, - attachments: MOCK_ATTACHMENTS, + submissionAttachments: MOCK_ATTACHMENTS, responsesData: MOCK_AUTOREPLY_DATA, + isPaymentEnabled: false, + pdfAttachment: undefined, }) const expectedAutoReplyData = [ @@ -792,7 +889,9 @@ describe('submission.service', () => { expect(MockMailService.sendAutoReplyEmails).toHaveBeenCalledWith({ form: mockForm, submission: MOCK_SUBMISSION, - attachments: MOCK_ATTACHMENTS, + submissionAttachments: MOCK_ATTACHMENTS, + pdfAttachment: undefined, + isPaymentEnabled: false, responsesData: MOCK_AUTOREPLY_DATA, autoReplyMailDatas: expectedAutoReplyData, }) @@ -849,8 +948,10 @@ describe('submission.service', () => { form: mockForm, recipientData, submission: MOCK_SUBMISSION, - attachments: MOCK_ATTACHMENTS, + submissionAttachments: MOCK_ATTACHMENTS, responsesData: MOCK_AUTOREPLY_DATA, + isPaymentEnabled: false, + pdfAttachment: undefined, }) const expectedAutoReplyData = [ @@ -861,9 +962,11 @@ describe('submission.service', () => { expect(MockMailService.sendAutoReplyEmails).toHaveBeenCalledWith({ form: mockForm, submission: MOCK_SUBMISSION, - attachments: MOCK_ATTACHMENTS, + submissionAttachments: MOCK_ATTACHMENTS, responsesData: MOCK_AUTOREPLY_DATA, autoReplyMailDatas: expectedAutoReplyData, + pdfAttachment: undefined, + isPaymentEnabled: false, }) expect(result._unsafeUnwrapErr()).toEqual( new SendEmailConfirmationError(), @@ -2747,7 +2850,11 @@ describe('submission.service', () => { // Act // empty string for version id to simulate failure - const actualResult = await downloadCleanFile('invalid-key', '') + const actualResult = await downloadCleanFile( + 'invalid-key', + '', + 'mock-bucket-name', + ) // Assert expect(awsSpy).not.toHaveBeenCalled() @@ -2761,7 +2868,11 @@ describe('submission.service', () => { // Act // empty string for version id to simulate failure - const actualResult = await downloadCleanFile(MOCK_VALID_UUID, '') + const actualResult = await downloadCleanFile( + MOCK_VALID_UUID, + '', + 'mock-bucket-name', + ) // Assert expect(awsSpy).toHaveBeenCalledOnce() @@ -2796,7 +2907,11 @@ describe('submission.service', () => { // Act // empty strings for invalid keys and version ids - const actualResult = await downloadCleanFile(MOCK_VALID_UUID, versionId) + const actualResult = await downloadCleanFile( + MOCK_VALID_UUID, + versionId, + 'mock-bucket-name', + ) // Assert expect(awsSpy).toHaveBeenCalledOnce() diff --git a/src/app/modules/submission/encrypt-submission/__tests__/encrypt-submission.controller.spec.ts b/src/app/modules/submission/encrypt-submission/__tests__/encrypt-submission.controller.spec.ts index 7c707945a0..64594c7f15 100644 --- a/src/app/modules/submission/encrypt-submission/__tests__/encrypt-submission.controller.spec.ts +++ b/src/app/modules/submission/encrypt-submission/__tests__/encrypt-submission.controller.spec.ts @@ -33,11 +33,10 @@ import { FormFieldSchema, IAttachmentInfo, IPopulatedEncryptedForm, + SgidFieldTitle, } from 'src/types' import { EncryptSubmissionDto, FormCompleteDto } from 'src/types/api' -import { SubmissionEmailObj } from '../../email-submission/email-submission.util' -import { ProcessedFieldResponse } from '../../submission.types' import { generateHashedSubmitterId, getCookieNameByAuthType, @@ -492,9 +491,6 @@ describe('encrypt-submission.controller', () => { checkHasSingleSubmissionValidationFailureSpy, ).not.toHaveBeenCalled() - // Assert email notification should be sent - expect(MockMailService.sendSubmissionToAdmin).toHaveBeenCalledTimes(1) - // Assert that status is not called, which defaults to intended 200 OK expect(mockRes.status).not.toHaveBeenCalled() // Assert that response does not any error codes @@ -502,6 +498,7 @@ describe('encrypt-submission.controller', () => { (mockRes.json as jest.Mock).mock.calls[0][0].errorCodes, ).not.toBeDefined() + // Assert that post submission actions are performed expect(performEncryptPostSubmissionActionsSpy).toHaveBeenCalledTimes(1) }) @@ -567,9 +564,6 @@ describe('encrypt-submission.controller', () => { ) expect(saveIfSubmitterIdIsUniqueSpy).toHaveBeenCalledTimes(1) - // Assert email notification should be sent - expect(MockMailService.sendSubmissionToAdmin).toHaveBeenCalledTimes(1) - // Assert that status is not called, which defaults to intended 200 OK expect(mockRes.status).not.toHaveBeenCalled() // Assert that response does not any error codes @@ -584,6 +578,7 @@ describe('encrypt-submission.controller', () => { ), ) + // Assert that post submission actions are performed expect(performEncryptPostSubmissionActionsSpy).toHaveBeenCalledTimes(1) }) @@ -731,9 +726,6 @@ describe('encrypt-submission.controller', () => { // Act await submitEncryptModeFormForTest(mockReq, mockRes) - // Assert email notification should be sent - expect(MockMailService.sendSubmissionToAdmin).toHaveBeenCalledTimes(1) - // Assert that status is not called, which defaults to intended 200 OK expect(mockRes.status).not.toHaveBeenCalled() // Assert that response does not have the single submission validation failure flag @@ -748,8 +740,10 @@ describe('encrypt-submission.controller', () => { ), ) + // Assert that post submission actions are performed expect(performEncryptPostSubmissionActionsSpy).toHaveBeenCalledTimes(1) }) + it('should return json response with single submission validation failure flag when submissionId is not unique and does not save submission', async () => { jest .spyOn(EncryptSubmission, 'saveIfSubmitterIdIsUnique') @@ -1129,7 +1123,7 @@ describe('encrypt-submission.controller', () => { expect(savedSubmission!.verifiedContent).toBeUndefined() }) - it('should not include nric in email notification and not store nric if form isSubmitterIdCollectionEnabled is false for MyInfo authType', async () => { + it('should not include nric field in email fields to be included in notification email and not store nric if form isSubmitterIdCollectionEnabled is false for MyInfo authType', async () => { // Arrange const mockFormId = new ObjectId() const mockMyInfoAuthTypeAndSubmitterIdCollectionDisabledForm = { @@ -1165,6 +1159,11 @@ describe('encrypt-submission.controller', () => { ) as unknown as SubmitEncryptModeFormHandlerRequest const mockRes = expressHandler.mockResponse() + const performEncryptPostSubmissionActionsSpy = jest.spyOn( + EncryptSubmissionService, + 'performEncryptPostSubmissionActions', + ) + // Act await submitEncryptModeFormForTest(MOCK_REQ, mockRes) @@ -1181,14 +1180,21 @@ describe('encrypt-submission.controller', () => { // Assert // email notification should be sent - expect(MockMailService.sendSubmissionToAdmin).toHaveBeenCalledTimes(1) + expect(performEncryptPostSubmissionActionsSpy).toHaveBeenCalledTimes(1) // Assert nric is not contained - formData empty array since no parsed responses to be included in email expect( - MockMailService.sendSubmissionToAdmin.mock.calls[0][0].formData, - ).toEqual([]) + performEncryptPostSubmissionActionsSpy.mock.calls[0][0].emailFields, + ).not.toContain( + expect.objectContaining({ + answer: MOCK_NRIC, + fieldType: BasicField.Nric, + isVisible: true, + question: SgidFieldTitle.SgidNric, + }), + ) }) - it('should include nric in email notification and store nric in verifiedContent if form isSubmitterIdCollectionEnabled is true for SgId authType', async () => { + it('should include nric in email fields to be included in notification email and store nric in verifiedContent if form isSubmitterIdCollectionEnabled is true for SgId authType', async () => { // Arrange const mockFormId = new ObjectId() const mockSgidAuthTypeAndSubmitterIdCollectionEnabledForm = { @@ -1230,6 +1236,11 @@ describe('encrypt-submission.controller', () => { } const expectedVerifiedContent = { sgidUinFin: MOCK_NRIC } + const performEncryptPostSubmissionActionsSpy = jest.spyOn( + EncryptSubmissionService, + 'performEncryptPostSubmissionActions', + ) + // Act await submitEncryptModeFormForTest(MOCK_REQ, mockRes) @@ -1252,17 +1263,23 @@ describe('encrypt-submission.controller', () => { JSON.stringify(expectedVerifiedContent), ) - // email notification should be sent with nric included - expect(MockMailService.sendSubmissionToAdmin).toHaveBeenCalledTimes(1) + // email fields for generating email notification include nric + expect(performEncryptPostSubmissionActionsSpy).toHaveBeenCalledTimes(1) expect( - MockMailService.sendSubmissionToAdmin.mock.calls[0][0].formData[0] - .answer, - ).toEqual(MOCK_NRIC) + performEncryptPostSubmissionActionsSpy.mock.calls[0][0].emailFields[0], + ).toEqual( + expect.objectContaining({ + answer: MOCK_NRIC, + fieldType: BasicField.Nric, + isVisible: true, + question: SgidFieldTitle.SgidNric, + }), + ) }) }) - describe('emailData', () => { - it('should have the isVisible field set to true for form fields', async () => { + describe('passes the required correct data used to generate content for post-submission notification emails', () => { + it('passes the required isVisible values in email fields, submission attachments, and respondent emails', async () => { // Arrange const performEncryptPostSubmissionActionsSpy = jest.spyOn( EncryptSubmissionService, @@ -1296,194 +1313,33 @@ describe('encrypt-submission.controller', () => { fieldType: 'textarea', isVisible: true, }, - ] - - const mockReq = merge( - expressHandler.mockRequest({ - params: { formId: 'some id' }, - body: { - responses: mockResponses, - }, - }), { - formsg: { - encryptedPayload: { - encryptedContent: 'encryptedContent', - version: 1, - }, - formDef: {}, - encryptedFormDef: mockEncryptForm, - } as unknown as EncryptSubmissionDto, - } as unknown as FormCompleteDto, - ) as unknown as SubmitEncryptModeFormHandlerRequest - const mockRes = expressHandler.mockResponse() - - // Setup the SubmissionEmailObj - const emailData = new SubmissionEmailObj( - mockResponses as any as ProcessedFieldResponse[], - new Set(), - FormAuthType.NIL, - ) - - // Act - await submitEncryptModeFormForTest(mockReq, mockRes) - - // Assert - expect( - performEncryptPostSubmissionActionsSpy.mock.calls[0][0].emailData, - ).toEqual(emailData) - }) - }) - - describe('submitEncryptModeForm', () => { - beforeEach(() => { - jest.clearAllMocks() - MockMailService.sendSubmissionToAdmin.mockReturnValue(okAsync(true)) - }) - - it('should call sendSubmissionToAdmin with no attachments', async () => { - // Arrange - jest - .spyOn(MailService, 'sendSubmissionToAdmin') - .mockReturnValue(okAsync(true)) - - const mockFormId = new ObjectId() - const mockForm = { - _id: mockFormId, - title: 'Test Form', - authType: FormAuthType.NIL, - form_fields: [] as FormFieldSchema[], - emails: ['test@example.com'], - getUniqueMyInfoAttrs: () => [] as MyInfoAttribute[], - } as IPopulatedEncryptedForm - - const mockAttachments: IAttachmentInfo[] = [] - - const mockReq = merge( - expressHandler.mockRequest({ - params: { formId: mockFormId.toHexString() }, - body: { - responses: [], - attachments: mockAttachments, - }, - }), - { - formsg: { - encryptedPayload: { - encryptedContent: 'mockEncryptedContent', - version: 1, - }, - formDef: {}, - encryptedFormDef: mockForm, - unencryptedAttachments: mockAttachments, - } as unknown as EncryptSubmissionDto, - } as unknown as FormCompleteDto, - ) as unknown as SubmitEncryptModeFormHandlerRequest - - const mockRes = expressHandler.mockResponse() - - // Act - await submitEncryptModeFormForTest(mockReq, mockRes) - - // Assert (done) - expect(MockMailService.sendSubmissionToAdmin).toHaveBeenCalledWith( - expect.objectContaining({ - attachments: mockAttachments, - }), - ) - }) - - it('should call sendSubmissionToAdmin with the attachments', async () => { - // Arrange - jest - .spyOn(MailService, 'sendSubmissionToAdmin') - .mockReturnValue(okAsync(true)) - - const mockFormId = new ObjectId() - const mockForm = { - _id: mockFormId, - title: 'Test Form', - authType: FormAuthType.NIL, - form_fields: [] as FormFieldSchema[], - emails: ['test@example.com'], - getUniqueMyInfoAttrs: () => [] as MyInfoAttribute[], - } as IPopulatedEncryptedForm + id: new ObjectId(), + question: 'Short answer', + answer: 'this is a short answer', + fieldType: 'textfield', + isVisible: false, + }, + ] - const mockAttachments: IAttachmentInfo[] = [ + const mockUnencryptedAttachments: IAttachmentInfo[] = [ { filename: 'test.pdf', content: Buffer.from('this is a test file'), fieldId: 'test-field-id', }, - ] - - const mockReq = merge( - expressHandler.mockRequest({ - params: { formId: mockFormId.toHexString() }, - body: { - responses: [], - attachments: mockAttachments, - }, - }), - { - formsg: { - encryptedPayload: { - encryptedContent: 'mockEncryptedContent', - version: 1, - }, - formDef: {}, - encryptedFormDef: mockForm, - unencryptedAttachments: mockAttachments, - } as unknown as EncryptSubmissionDto, - } as unknown as FormCompleteDto, - ) as unknown as SubmitEncryptModeFormHandlerRequest - - const mockRes = expressHandler.mockResponse() - - // Act - await submitEncryptModeFormForTest(mockReq, mockRes) - - // Assert (done) - expect(MockMailService.sendSubmissionToAdmin).toHaveBeenCalledWith( - expect.objectContaining({ - attachments: mockAttachments, - }), - ) - }) - - it('should pass expected dataCollationData to sendSubmissionToAdmin', async () => { - // Arrange - const mockFormId = new ObjectId() - const signatureFieldId = new ObjectId() - const mockForm = { - _id: mockFormId, - title: 'Test Form', - authType: FormAuthType.NIL, - form_fields: [] as FormFieldSchema[], - emails: ['test@example.com'], - getUniqueMyInfoAttrs: () => [] as MyInfoAttribute[], - } as IPopulatedEncryptedForm - - const mockResponses = [ - { - _id: new ObjectId(), - question: '[MyInfo] Test Question', - answer: 'Test Answer', - fieldType: 'text', - isVisible: true, - }, { - _id: signatureFieldId, - question: 'Signature Question', - answerArray: ['draw', '[[[10,20,0.5]],[[40,40,0.5]]]'], - fieldType: 'signature', - isVisible: true, + filename: 'test2.pdf', + content: Buffer.from('this is another test file'), + fieldId: 'test-field-id-2', }, ] + const mockRespondentEmails = ['test@example.com', 'test2@example.com'] + const mockReq = merge( expressHandler.mockRequest({ - params: { formId: mockFormId.toHexString() }, + params: { formId: 'some id' }, body: { responses: mockResponses, }, @@ -1491,94 +1347,34 @@ describe('encrypt-submission.controller', () => { { formsg: { encryptedPayload: { - encryptedContent: 'mockEncryptedContent', + encryptedContent: 'encryptedContent', version: 1, }, + unencryptedAttachments: mockUnencryptedAttachments, formDef: {}, - encryptedFormDef: mockForm, + encryptedFormDef: mockEncryptForm, + respondentEmails: mockRespondentEmails, } as unknown as EncryptSubmissionDto, } as unknown as FormCompleteDto, ) as unknown as SubmitEncryptModeFormHandlerRequest - const mockRes = expressHandler.mockResponse() - // Act await submitEncryptModeFormForTest(mockReq, mockRes) // Assert - expect(MockMailService.sendSubmissionToAdmin).toHaveBeenCalledWith( - expect.objectContaining({ - dataCollationData: expect.arrayContaining([ - expect.objectContaining({ - question: 'Test Question', - answer: 'Test Answer', - }), - expect.objectContaining({ - question: '[signature] Signature Question', - answer: `Signature captured`, - }), - ]), - }), - ) - }) - - it('should strip [MyInfo] prefix from expected dataCollationData to sendSubmissionToAdmin', async () => { - // Arrange - const mockFormId = new ObjectId() - const mockForm = { - _id: mockFormId, - title: 'Test Form', - authType: FormAuthType.NIL, - form_fields: [] as FormFieldSchema[], - emails: ['test@example.com'], - getUniqueMyInfoAttrs: () => [] as MyInfoAttribute[], - } as IPopulatedEncryptedForm - - const mockResponses = [ - { - _id: new ObjectId(), - question: '[MyInfo] Name', - answer: 'Test Answer', - fieldType: 'text', - isVisible: true, - }, - ] - - const mockReq = merge( - expressHandler.mockRequest({ - params: { formId: mockFormId.toHexString() }, - body: { - responses: mockResponses, - }, - }), - { - formsg: { - encryptedPayload: { - encryptedContent: 'mockEncryptedContent', - version: 1, - }, - formDef: {}, - encryptedFormDef: mockForm, - } as unknown as EncryptSubmissionDto, - } as unknown as FormCompleteDto, - ) as unknown as SubmitEncryptModeFormHandlerRequest - - const mockRes = expressHandler.mockResponse() + expect( + performEncryptPostSubmissionActionsSpy.mock.calls[0][0].emailFields, + ).toEqual(mockResponses) - // Act - await submitEncryptModeFormForTest(mockReq, mockRes) + expect( + performEncryptPostSubmissionActionsSpy.mock.calls[0][0] + .submissionAttachments, + ).toEqual(mockUnencryptedAttachments) - // Assert - expect(MockMailService.sendSubmissionToAdmin).toHaveBeenCalledWith( - expect.objectContaining({ - dataCollationData: expect.arrayContaining([ - expect.objectContaining({ - question: 'Name', - answer: 'Test Answer', - }), - ]), - }), - ) + expect( + performEncryptPostSubmissionActionsSpy.mock.calls[0][0] + .respondentEmails, + ).toEqual(mockRespondentEmails) }) }) }) diff --git a/src/app/modules/submission/encrypt-submission/__tests__/encrypt-submission.service.spec.ts b/src/app/modules/submission/encrypt-submission/__tests__/encrypt-submission.service.spec.ts index dffa1e9608..2dbca147ac 100644 --- a/src/app/modules/submission/encrypt-submission/__tests__/encrypt-submission.service.spec.ts +++ b/src/app/modules/submission/encrypt-submission/__tests__/encrypt-submission.service.spec.ts @@ -2,14 +2,44 @@ import dbHandler from '__tests__/unit/backend/helpers/jest-db' import { ObjectId } from 'bson' import mongoose from 'mongoose' +import { errAsync, ok, okAsync } from 'neverthrow' +import { + BasicField, + EmailResponse, + FormAuthType, + FormResponseMode, + MyInfoAttribute, + PaymentChannel, +} from 'shared/types' import { getEncryptSubmissionModel } from 'src/app/models/submission.server.model' -import { IPopulatedEncryptedForm } from 'src/types' +import * as FormService from 'src/app/modules/form/form.service' +import { AutoreplyPdfGenerationError } from 'src/app/services/mail/mail.errors' +import MailService from 'src/app/services/mail/mail.service' +import * as MailUtils from 'src/app/services/mail/mail.utils' +import { + FormFieldSchema, + IAttachmentInfo, + IEncryptedSubmissionSchema, + IPopulatedEncryptedForm, + SgidFieldTitle, +} from 'src/types' -import { createEncryptSubmissionWithoutSave } from '../encrypt-submission.service' +import { ProcessedFieldResponse } from '../../submission.types' +import { + createEncryptSubmissionWithoutSave, + performEncryptPostSubmissionActions, +} from '../encrypt-submission.service' const EncryptSubmission = getEncryptSubmissionModel(mongoose) +jest.mock('src/app/services/mail/mail.service') +jest.mock('src/app/modules/form/form.service') +jest.mock('src/app/services/mail/mail.utils') +const MockMailService = jest.mocked(MailService) +const MockFormService = jest.mocked(FormService) +const MockMailUtils = jest.mocked(MailUtils) + describe('encrypt-submission.service', () => { beforeAll(async () => await dbHandler.connect()) beforeEach(async () => { @@ -53,4 +83,657 @@ describe('encrypt-submission.service', () => { expect(foundInDatabase).toBeNull() }) }) + + describe('performEncryptPostSubmissionActions', () => { + const MOCK_NON_PAYMENT_ENCRYPT_FORM = { + _id: new ObjectId(), + title: 'Test Form', + responseMode: FormResponseMode.Encrypt, + authType: FormAuthType.NIL, + form_fields: [] as FormFieldSchema[], + emails: ['test@example.com'], + getUniqueMyInfoAttrs: () => [] as MyInfoAttribute[], + payments_channel: { + channel: PaymentChannel.Unconnected, + }, + payments_field: { + enabled: false, + }, + } as IPopulatedEncryptedForm + + const MOCK_PAYMENT_ENCRYPT_FORM = { + _id: new ObjectId(), + title: 'Test Form', + responseMode: FormResponseMode.Encrypt, + authType: FormAuthType.NIL, + form_fields: [] as FormFieldSchema[], + emails: ['test@example.com'], + getUniqueMyInfoAttrs: () => [] as MyInfoAttribute[], + payments_channel: { + channel: PaymentChannel.Stripe, + }, + payments_field: { + enabled: true, + }, + } as IPopulatedEncryptedForm + + const MOCK_SUBMISSION_ATTACHMENTS = [ + { + filename: 'test.pdf', + content: 'something', + }, + { + filename: 'test2.pdf', + content: 'something else', + }, + ] + const MOCK_NRIC = 'S1234567A' + + const MOCK_PDF_ATTACHMENT_BUFFER = Buffer.from('mock pdf buffer') + const MOCK_PDF_ATTACHMENT = { + filename: 'response.pdf', + content: MOCK_PDF_ATTACHMENT_BUFFER, + } + + describe('pdfAttachment generation and passing to sendSubmissionToAdmin and sendEmailConfirmations', () => { + beforeEach(() => { + jest.clearAllMocks() + MockMailService.sendSubmissionToAdmin.mockReturnValue(okAsync(true)) + MockMailService.sendAutoReplyEmails.mockResolvedValue([ + { + status: 'fulfilled', + value: ok(true), + }, + ]) + MockMailUtils.generateAutoreplyPdf.mockReturnValue( + okAsync(MOCK_PDF_ATTACHMENT_BUFFER), + ) + }) + + it('should send email confirmations and sendSubmissionToAdmin without pdf attachment when pdf generation fails', async () => { + // Arrange + MockMailUtils.generateAutoreplyPdf.mockReturnValue( + errAsync(new AutoreplyPdfGenerationError()), + ) + const mockSubmission = { + _id: new ObjectId(), + form: new ObjectId(), + created: new Date(), + } as IEncryptedSubmissionSchema + const mockResponses: ProcessedFieldResponse[] = [ + { + _id: new ObjectId().toHexString(), + question: SgidFieldTitle.SgidNric, + answer: MOCK_NRIC, + fieldType: BasicField.Nric, + }, + ] + MockFormService.retrieveFullFormById.mockReturnValue( + okAsync(MOCK_NON_PAYMENT_ENCRYPT_FORM), + ) + + // Act + await performEncryptPostSubmissionActions({ + submission: mockSubmission, + responses: mockResponses, + emailFields: mockResponses, + submissionAttachments: MOCK_SUBMISSION_ATTACHMENTS, + respondentEmails: ['email1@example.com', 'email2@example.com'], // presence of respondent emails means that form summary is enabled + }) + + // Assert + expect(MockMailUtils.generateAutoreplyPdf).toHaveBeenCalledOnce() + expect(MockMailService.sendSubmissionToAdmin).toHaveBeenCalledWith( + expect.objectContaining({ + submissionAttachments: MOCK_SUBMISSION_ATTACHMENTS, + pdfAttachment: undefined, + }), + ) + expect(MockMailService.sendAutoReplyEmails).toHaveBeenCalledWith( + expect.objectContaining({ + submissionAttachments: MOCK_SUBMISSION_ATTACHMENTS, + pdfAttachment: undefined, + }), + ) + }) + + it('should not generate pdf attachment if payment is enabled and not pass to either sendSubmissionToAdmin or sendEmailConfirmations', async () => { + // Arrange + const mockSubmission = { + _id: new ObjectId(), + form: new ObjectId(), + created: new Date(), + } as IEncryptedSubmissionSchema + const mockResponses: ProcessedFieldResponse[] = [ + { + _id: new ObjectId().toHexString(), + question: SgidFieldTitle.SgidNric, + answer: MOCK_NRIC, + fieldType: BasicField.Nric, + }, + ] + MockFormService.retrieveFullFormById.mockReturnValue( + okAsync(MOCK_PAYMENT_ENCRYPT_FORM), + ) + + // Act + await performEncryptPostSubmissionActions({ + submission: mockSubmission, + responses: mockResponses, + emailFields: mockResponses, + submissionAttachments: MOCK_SUBMISSION_ATTACHMENTS, + respondentEmails: ['email1@example.com', 'email2@example.com'], + }) + + // Assert + expect(MockMailUtils.generateAutoreplyPdf).not.toHaveBeenCalled() + expect(MockMailService.sendSubmissionToAdmin).toHaveBeenCalledWith( + expect.objectContaining({ + submissionAttachments: MOCK_SUBMISSION_ATTACHMENTS, + pdfAttachment: undefined, + }), + ) + expect(MockMailService.sendAutoReplyEmails).toHaveBeenCalledWith( + expect.objectContaining({ + submissionAttachments: MOCK_SUBMISSION_ATTACHMENTS, + pdfAttachment: undefined, + }), + ) + }) + + it('should generate pdf attachment and pass pdf attachment to both sendSubmissionToAdmin and sendEmailConfirmation if form summary is enabled and payment is not enabled', async () => { + // Arrange + const mockSubmission = { + _id: new ObjectId(), + form: new ObjectId(), + created: new Date(), + } as IEncryptedSubmissionSchema + const mockResponses: ProcessedFieldResponse[] = [ + { + _id: new ObjectId().toHexString(), + question: SgidFieldTitle.SgidNric, + answer: MOCK_NRIC, + fieldType: BasicField.Nric, + }, + ] + MockFormService.retrieveFullFormById.mockReturnValue( + okAsync(MOCK_NON_PAYMENT_ENCRYPT_FORM), + ) + + // Act + await performEncryptPostSubmissionActions({ + submission: mockSubmission, + responses: mockResponses, + emailFields: mockResponses, + submissionAttachments: MOCK_SUBMISSION_ATTACHMENTS, + respondentEmails: ['email1@example.com', 'email2@example.com'], // presence of respondent emails means that form summary is enabled + }) + + // Assert + expect(MockMailUtils.generateAutoreplyPdf).toHaveBeenCalledOnce() + expect(MockMailService.sendSubmissionToAdmin).toHaveBeenCalledWith( + expect.objectContaining({ + submissionAttachments: MOCK_SUBMISSION_ATTACHMENTS, + pdfAttachment: MOCK_PDF_ATTACHMENT, + }), + ) + expect(MockMailService.sendAutoReplyEmails).toHaveBeenCalledWith( + expect.objectContaining({ + submissionAttachments: MOCK_SUBMISSION_ATTACHMENTS, + pdfAttachment: MOCK_PDF_ATTACHMENT, + }), + ) + }) + + it('should generate pdf attachment and pass pdf attachment to only sendSubmissionToAdmin if form summary and payment both are not enabled', async () => { + // Arrange + const mockSubmission = { + _id: new ObjectId(), + form: new ObjectId(), + created: new Date(), + } as IEncryptedSubmissionSchema + const mockResponsesWithoutAutoReplyEmailFields: ProcessedFieldResponse[] = + [ + { + _id: new ObjectId().toHexString(), + question: SgidFieldTitle.SgidNric, + answer: MOCK_NRIC, + fieldType: BasicField.Nric, + }, + ] + MockFormService.retrieveFullFormById.mockReturnValue( + okAsync(MOCK_NON_PAYMENT_ENCRYPT_FORM), + ) + + // Act + await performEncryptPostSubmissionActions({ + submission: mockSubmission, + responses: mockResponsesWithoutAutoReplyEmailFields, + emailFields: mockResponsesWithoutAutoReplyEmailFields, + submissionAttachments: MOCK_SUBMISSION_ATTACHMENTS, + respondentEmails: [], + }) + + // Assert + expect(MockMailUtils.generateAutoreplyPdf).toHaveBeenCalledOnce() + expect(MockMailService.sendSubmissionToAdmin).toHaveBeenCalledWith( + expect.objectContaining({ + pdfAttachment: MOCK_PDF_ATTACHMENT, + submissionAttachments: MOCK_SUBMISSION_ATTACHMENTS, + }), + ) + expect(MockMailService.sendAutoReplyEmails).not.toHaveBeenCalled() + }) + }) + + describe('sendEmailConfirmations', () => { + beforeEach(() => { + jest.clearAllMocks() + MockMailService.sendSubmissionToAdmin.mockReturnValue(okAsync(true)) + MockMailService.sendAutoReplyEmails.mockResolvedValue([ + { + status: 'fulfilled', + value: ok(true), + }, + ]) + MockMailUtils.generateAutoreplyPdf.mockReturnValue( + okAsync(MOCK_PDF_ATTACHMENT_BUFFER), + ) + }) + + it('should sendEmailConfirmations if there are respondent emails', async () => { + // Arrange + const mockSubmission = { + _id: new ObjectId(), + form: new ObjectId(), + created: new Date(), + } as IEncryptedSubmissionSchema + const mockResponsesWithoutAutoReplyEmailFields: ProcessedFieldResponse[] = + [ + { + _id: new ObjectId().toHexString(), + question: SgidFieldTitle.SgidNric, + answer: MOCK_NRIC, + fieldType: BasicField.Nric, + }, + ] + MockFormService.retrieveFullFormById.mockReturnValue( + okAsync(MOCK_NON_PAYMENT_ENCRYPT_FORM), + ) + + // Act + await performEncryptPostSubmissionActions({ + submission: mockSubmission, + responses: mockResponsesWithoutAutoReplyEmailFields, + emailFields: mockResponsesWithoutAutoReplyEmailFields, + submissionAttachments: MOCK_SUBMISSION_ATTACHMENTS, + respondentEmails: ['email1@example.com', 'email2@example.com'], + }) + + // Assert + expect(MockMailUtils.generateAutoreplyPdf).toHaveBeenCalledOnce() + expect(MockMailService.sendAutoReplyEmails).toHaveBeenCalledWith( + expect.objectContaining({ + autoReplyMailDatas: [ + { email: 'email1@example.com', includeFormSummary: true }, + { email: 'email2@example.com', includeFormSummary: true }, + ], + }), + ) + }) + + it('should sendEmailConfirmations if there are email fields with auto reply enabled', async () => { + // Arrange + const mockSubmission = { + _id: new ObjectId(), + form: new ObjectId(), + created: new Date(), + } as IEncryptedSubmissionSchema + const emailFieldId = new ObjectId().toHexString() + const emailResponse = { + _id: emailFieldId, + question: 'Email of respondent', + answer: 'email1@example.com', + fieldType: BasicField.Email, + } as EmailResponse + const emailFieldId2 = new ObjectId().toHexString() + const emailResponse2 = { + _id: emailFieldId2, + question: 'Email of respondent', + answer: 'email2@example.com', + fieldType: BasicField.Email, + } as EmailResponse + const mockResponses: ProcessedFieldResponse[] = [ + { + _id: new ObjectId().toHexString(), + question: SgidFieldTitle.SgidNric, + answer: MOCK_NRIC, + fieldType: BasicField.Nric, + }, + emailResponse, + emailResponse2, + ] + + const emailField = { + _id: emailFieldId, + fieldType: BasicField.Email, + autoReplyOptions: { + hasAutoReply: true, + autoReplySubject: 'Test Subject', + autoReplySender: 'test@example.com', + autoReplyMessage: 'Test Message', + includeFormSummary: true, + }, + } + + const emailField2 = { + _id: emailFieldId2, + fieldType: BasicField.Email, + autoReplyOptions: { + hasAutoReply: true, + autoReplySubject: 'Test Subject', + autoReplySender: 'test@example.com', + autoReplyMessage: 'Test Message', + includeFormSummary: false, + }, + } + + const mockNonPaymentEncryptFormWithEmailField = { + ...MOCK_NON_PAYMENT_ENCRYPT_FORM, + form_fields: [ + ...MOCK_NON_PAYMENT_ENCRYPT_FORM.form_fields, + emailField, + emailField2, + ], + } as IPopulatedEncryptedForm + + MockFormService.retrieveFullFormById.mockReturnValue( + okAsync(mockNonPaymentEncryptFormWithEmailField), + ) + + // Act + await performEncryptPostSubmissionActions({ + submission: mockSubmission, + responses: mockResponses, + emailFields: mockResponses, + submissionAttachments: MOCK_SUBMISSION_ATTACHMENTS, + respondentEmails: [], + }) + + // Assert + expect(MockMailUtils.generateAutoreplyPdf).toHaveBeenCalledOnce() + expect(MockMailService.sendAutoReplyEmails).toHaveBeenCalledWith( + expect.objectContaining({ + submissionAttachments: MOCK_SUBMISSION_ATTACHMENTS, + pdfAttachment: MOCK_PDF_ATTACHMENT, + autoReplyMailDatas: [ + { + body: emailField.autoReplyOptions.autoReplyMessage, + email: emailResponse.answer, + includeFormSummary: + emailField.autoReplyOptions.includeFormSummary, + sender: emailField.autoReplyOptions.autoReplySender, + subject: emailField.autoReplyOptions.autoReplySubject, + }, + { + body: emailField2.autoReplyOptions.autoReplyMessage, + email: emailResponse2.answer, + includeFormSummary: + emailField2.autoReplyOptions.includeFormSummary, + sender: emailField2.autoReplyOptions.autoReplySender, + subject: emailField2.autoReplyOptions.autoReplySubject, + }, + ], + }), + ) + }) + + it('should not sendEmailConfirmations if there are no respondent emails and no email fields with auto reply enabled', async () => { + // Arrange + const mockSubmission = { + _id: new ObjectId(), + form: new ObjectId(), + created: new Date(), + } as IEncryptedSubmissionSchema + const mockResponsesWithoutAutoReplyEmailFields: ProcessedFieldResponse[] = + [ + { + _id: new ObjectId().toHexString(), + question: SgidFieldTitle.SgidNric, + answer: MOCK_NRIC, + fieldType: BasicField.Nric, + }, + ] + MockFormService.retrieveFullFormById.mockReturnValue( + okAsync(MOCK_NON_PAYMENT_ENCRYPT_FORM), + ) + + // Act + await performEncryptPostSubmissionActions({ + submission: mockSubmission, + responses: mockResponsesWithoutAutoReplyEmailFields, + emailFields: mockResponsesWithoutAutoReplyEmailFields, + submissionAttachments: MOCK_SUBMISSION_ATTACHMENTS, + respondentEmails: [], + }) + + // Assert + expect(MockMailService.sendAutoReplyEmails).not.toHaveBeenCalled() + }) + }) + + describe('sendSubmissionToAdmin', () => { + beforeEach(() => { + jest.clearAllMocks() + MockMailService.sendSubmissionToAdmin.mockReturnValue(okAsync(true)) + MockFormService.retrieveFullFormById.mockReturnValue( + okAsync(MOCK_NON_PAYMENT_ENCRYPT_FORM), + ) + MockMailUtils.generateAutoreplyPdf.mockReturnValue( + okAsync(Buffer.from('mock pdf buffer')), + ) + }) + + describe('emailFields', () => { + it('should include nric field in notification email if provided', async () => { + // Arrange + const MOCK_NRIC = 'S1234567A' + const mockSubmission = { + _id: new ObjectId(), + form: new ObjectId(), + created: new Date(), + } as IEncryptedSubmissionSchema + + const mockResponses: ProcessedFieldResponse[] = [ + { + _id: new ObjectId().toHexString(), + question: SgidFieldTitle.SgidNric, + answer: MOCK_NRIC, + fieldType: BasicField.Nric, + }, + ] + + // Act + const postSubmissionActionStatus = + await performEncryptPostSubmissionActions({ + submission: mockSubmission, + responses: mockResponses, + emailFields: mockResponses, + submissionAttachments: [], + respondentEmails: [], + }) + + // Assert + expect(postSubmissionActionStatus).toEqual(ok(true)) + + expect( + MockMailService.sendSubmissionToAdmin.mock.calls[0][0].formData, + ).toEqual([ + { + answer: MOCK_NRIC, + fieldType: BasicField.Nric, + answerTemplate: [MOCK_NRIC], + question: SgidFieldTitle.SgidNric, + }, + ]) + }) + }) + + describe('submissionAttachments', () => { + it('should call sendSubmissionToAdmin with no submission attachments submission has no attachments', async () => { + // Arrange + const noAttachments: IAttachmentInfo[] = [] + + const mockSubmission = { + _id: new ObjectId(), + form: new ObjectId(), + created: new Date(), + } as IEncryptedSubmissionSchema + + // Act + const postSubmissionActionStatus = + await performEncryptPostSubmissionActions({ + submission: mockSubmission, + responses: [], + emailFields: [], + submissionAttachments: noAttachments, + respondentEmails: [], + }) + + // Assert + expect(postSubmissionActionStatus).toEqual(ok(true)) + + expect(MockMailService.sendSubmissionToAdmin).toHaveBeenCalledWith( + expect.objectContaining({ + submissionAttachments: noAttachments, + }), + ) + }) + + it('should call sendSubmissionToAdmin with submmission attachments when submission has attachments', async () => { + // Arrange + const twoAttachments: IAttachmentInfo[] = [ + { + filename: 'test.pdf', + content: Buffer.from('this is a test file'), + fieldId: 'test-field-id', + }, + ] + + const mockSubmission = { + _id: new ObjectId(), + form: new ObjectId(), + created: new Date(), + } as IEncryptedSubmissionSchema + + // Act + await performEncryptPostSubmissionActions({ + submission: mockSubmission, + responses: [], + emailFields: [], + submissionAttachments: twoAttachments, + respondentEmails: [], + }) + + // Assert + expect(MockMailService.sendSubmissionToAdmin).toHaveBeenCalledWith( + expect.objectContaining({ + submissionAttachments: twoAttachments, + }), + ) + }) + }) + + describe('dataCollationData', () => { + it('should pass expected dataCollationData to sendSubmissionToAdmin', async () => { + // Arrange + const mockResponses: ProcessedFieldResponse[] = [ + { + _id: new ObjectId().toHexString(), + question: '[MyInfo] Test Question', + answer: 'Test Answer', + fieldType: BasicField.ShortText, + }, + { + _id: new ObjectId().toHexString(), + question: 'Signature Question', + answerArray: ['draw', '[[[10,20,0.5]],[[40,40,0.5]]]'], + fieldType: BasicField.Signature, + }, + ] + + const mockSubmission = { + _id: new ObjectId(), + form: new ObjectId(), + created: new Date(), + } as IEncryptedSubmissionSchema + + // Act + await performEncryptPostSubmissionActions({ + submission: mockSubmission, + responses: mockResponses, + emailFields: mockResponses, + submissionAttachments: [], + respondentEmails: [], + }) + + // Assert + expect(MockMailService.sendSubmissionToAdmin).toHaveBeenCalledWith( + expect.objectContaining({ + dataCollationData: expect.arrayContaining([ + expect.objectContaining({ + question: 'Test Question', + answer: 'Test Answer', + }), + expect.objectContaining({ + question: '[signature] Signature Question', + answer: `Signature captured`, + }), + ]), + }), + ) + }) + + it('should strip [MyInfo] prefix from expected dataCollationData to sendSubmissionToAdmin', async () => { + // Arrange + const mockResponses: ProcessedFieldResponse[] = [ + { + _id: new ObjectId().toHexString(), + question: '[MyInfo] Name', + answer: 'Test Answer', + fieldType: BasicField.ShortText, + isVisible: true, + }, + ] + + const mockSubmission = { + _id: new ObjectId(), + form: new ObjectId(), + created: new Date(), + } as IEncryptedSubmissionSchema + + // Act + await performEncryptPostSubmissionActions({ + submission: mockSubmission, + responses: mockResponses, + emailFields: mockResponses, + submissionAttachments: [], + respondentEmails: [], + }) + + // Assert + expect(MockMailService.sendSubmissionToAdmin).toHaveBeenCalledWith( + expect.objectContaining({ + dataCollationData: expect.arrayContaining([ + expect.objectContaining({ + question: 'Name', + answer: 'Test Answer', + }), + ]), + }), + ) + }) + }) + }) + }) }) diff --git a/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts b/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts index 53f24ed9c2..70e66b763e 100644 --- a/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts +++ b/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts @@ -17,7 +17,6 @@ import { } from '../../../../../shared/types' import { IAttachmentInfo, - IEncryptedForm, IEncryptedSubmissionSchema, IPopulatedEncryptedForm, StripePaymentMetadataDto, @@ -35,7 +34,6 @@ import getPaymentModel from '../../../models/payment.server.model' import { getEncryptPendingSubmissionModel } from '../../../models/pending_submission.server.model' import { getEncryptSubmissionModel } from '../../../models/submission.server.model' import * as CaptchaMiddleware from '../../../services/captcha/captcha.middleware' -import MailService from '../../../services/mail/mail.service' import * as TurnstileMiddleware from '../../../services/turnstile/turnstile.middleware' import { Pipeline } from '../../../utils/pipeline-middleware' import { createReqMeta } from '../../../utils/request' @@ -55,9 +53,6 @@ import { SgidService } from '../../sgid/sgid.service' import { getOidcService } from '../../spcp/spcp.oidc.service' import { getPopulatedUserById } from '../../user/user.service' import * as VerifiedContentService from '../../verified-content/verified-content.service' -import { MYINFO_PREFIX } from '../email-submission/email-submission.constants' -import * as EmailSubmissionService from '../email-submission/email-submission.service' -import { SubmissionEmailObj } from '../email-submission/email-submission.util' import * as EncryptSubmissionMiddleware from '../encrypt-submission/encrypt-submission.middleware' import ParsedResponsesObject from '../ParsedResponsesObject.class' import * as ReceiverMiddleware from '../receiver/receiver.middleware' @@ -694,6 +689,9 @@ const _createPaymentSubmission = async ({ }) } +/** + * @param emailFields fields and their responses that will be included in email notifications to admins and respondents. + */ const _createSubmission = async ({ req, res, @@ -778,50 +776,7 @@ const _createSubmission = async ({ }) const createdTime = submission.created || new Date() - - const logMetaWithSubmission = { - ...logMeta, - submissionId, - responseMetadata, - } - - const emailData = new SubmissionEmailObj( - emailFields, - new Set(), // the MyInfo prefixes are already inserted in middleware - form.authType, - ) - - // Since we insert the [MyInfo] prefix in `encrypt-submission.middleware.ts`:L434 - // we want to remove it for the dataCollationData - const dataCollationData = emailData.dataCollationData.map((item) => ({ - question: item.question.startsWith(MYINFO_PREFIX) - ? item.question.slice(MYINFO_PREFIX.length) - : item.question, - answer: item.answer, - })) - - const emailAttachments = [...(unencryptedAttachments ?? [])] - // We don't await for email submission, as the submission gets saved for encrypt - // submissions regardless, the email is more of a notification and shouldn't - // stop the storage of the data in the db - if (((form as IEncryptedForm)?.emails || []).length > 0) { - logger.info({ - message: 'Sending admin notification mail', - meta: logMetaWithSubmission, - }) - - void MailService.sendSubmissionToAdmin({ - replyToEmails: EmailSubmissionService.extractEmailAnswers(emailFields), - form, - submission: { - created: createdTime, - id: submission.id, - }, - attachments: emailAttachments, - formData: emailData.formData, - dataCollationData, - }) - } + const submissionAttachments = [...(unencryptedAttachments ?? [])] // TODO 6395 make responseMetadata mandatory if (responseMetadata) { @@ -846,10 +801,10 @@ const _createSubmission = async ({ return await performEncryptPostSubmissionActions({ submission, responses, - growthbook: req.growthbook, - emailData, - attachments: emailAttachments, + emailFields, + submissionAttachments, respondentEmails, + growthbook: req.growthbook, }) } diff --git a/src/app/modules/submission/encrypt-submission/encrypt-submission.service.ts b/src/app/modules/submission/encrypt-submission/encrypt-submission.service.ts index e125490cc3..058f55a94d 100644 --- a/src/app/modules/submission/encrypt-submission/encrypt-submission.service.ts +++ b/src/app/modules/submission/encrypt-submission/encrypt-submission.service.ts @@ -3,34 +3,40 @@ import mongoose from 'mongoose' import { err, ok, okAsync, Result, ResultAsync } from 'neverthrow' import Mail from 'nodemailer/lib/mailer' -import { AutoReplyMailData } from 'src/app/services/mail/mail.types' - -import { featureFlags } from '../../../../../shared/constants' import { DateString, FormResponseMode, + PaymentChannel, SubmissionType, } from '../../../../../shared/types' import { + EmailAdminDataField, FieldResponse, + FormFieldSchema, IEncryptedSubmissionSchema, IPopulatedEncryptedForm, IPopulatedForm, } from '../../../../types' +import config, { isTest } from '../../../config/config' import { createLoggerWithLabel } from '../../../config/logger' import { getEncryptSubmissionModel } from '../../../models/submission.server.model' +import { AutoreplyPdfGenerationError } from '../../../services/mail/mail.errors' +import MailService from '../../../services/mail/mail.service' +import { AutoReplyMailData } from '../../../services/mail/mail.types' +import { generateAutoreplyPdf } from '../../../services/mail/mail.utils' import { createQueryWithDateParam } from '../../../utils/date' import { getMongoErrorMessage } from '../../../utils/handle-mongo-error' import { DatabaseError, PossibleDatabaseError } from '../../core/core.errors' import { FormNotFoundError } from '../../form/form.errors' import * as FormService from '../../form/form.service' import { isFormEncryptMode } from '../../form/form.utils' -import * as UserService from '../../user/user.service' import { WebhookPushToQueueError, WebhookValidationError, } from '../../webhook/webhook.errors' import { WebhookFactory } from '../../webhook/webhook.factory' +import { MYINFO_PREFIX } from '../email-submission/email-submission.constants' +import * as EmailSubmissionService from '../email-submission/email-submission.service' import { SubmissionEmailObj } from '../email-submission/email-submission.util' import { ResponseModeError, @@ -39,7 +45,11 @@ import { UnsupportedSettingsError, } from '../submission.errors' import { sendEmailConfirmations } from '../submission.service' -import { extractEmailConfirmationData } from '../submission.utils' +import { ProcessedFieldResponse } from '../submission.types' +import { + extractEmailConfirmationData, + isAdminEmailPdfEnabled, +} from '../submission.utils' import { CHARTS_MAX_SUBMISSION_RESULTS } from './encrypt-submission.constants' import { SaveEncryptSubmissionParams } from './encrypt-submission.types' @@ -131,11 +141,100 @@ export const createEncryptSubmissionWithoutSave = ({ }) } +const checkIfAdminPdfIsRequired = ( + isPaymentEnabled: boolean, + formFields: FormFieldSchema[], + growthbook?: GrowthBook, +): boolean => { + const isGbFlagEnabled = + isAdminEmailPdfEnabled({ growthbook, formFields }) || isTest + + if (!isGbFlagEnabled) { + return false + } + return !isPaymentEnabled +} + +const checkIfRespondentFormSummaryIsRequired = ({ + autoReplyMailDatas, + isPaymentEnabled, +}: { + autoReplyMailDatas: AutoReplyMailData[] + isPaymentEnabled: boolean +}): boolean => { + return ( + !isPaymentEnabled && + autoReplyMailDatas.some((data) => data.includeFormSummary) + ) +} + +const generatePdfAttachmentIfRequired = ({ + isPaymentEnabled, + autoReplyMailDatas, + submission, + form, + responsesData, + growthbook, +}: { + isPaymentEnabled: boolean + autoReplyMailDatas: AutoReplyMailData[] + submission: IEncryptedSubmissionSchema + form: IPopulatedEncryptedForm + responsesData: EmailAdminDataField[] + growthbook?: GrowthBook +}): ResultAsync => { + const isAdminPdfRequired = checkIfAdminPdfIsRequired( + isPaymentEnabled, + form.form_fields, + growthbook, + ) + const isRespondentCopyPdfRequired = checkIfRespondentFormSummaryIsRequired({ + isPaymentEnabled, + autoReplyMailDatas, + }) + if (!isAdminPdfRequired && !isRespondentCopyPdfRequired) { + return okAsync(undefined) + } + + const autoReplyData = { + refNo: submission.id, + formTitle: form.title, + submissionDateTime: submission.created ?? new Date(), + responsesData, + formUrl: `${config.app.appUrl}/${form._id}`, + } + + const DEFAULT_RESPONSE_PDF_FILENAME = 'response.pdf' + return generateAutoreplyPdf(autoReplyData, true) + .map((pdfBuffer) => ({ + filename: DEFAULT_RESPONSE_PDF_FILENAME, + content: Buffer.copyBytesFrom(pdfBuffer), + })) + .mapErr((error) => { + logger.error({ + message: + 'Failed to include required PDF attachment for email notifications', + meta: { + action: 'generatePdfAttachmentIfRequired', + submissionId: submission.id, + formId: form._id, + formResponseMode: form.responseMode, + isAdminPdfRequired, + isRespondentCopyPdfRequired, + }, + error, + }) + return error + }) +} + /** * Performs the post-submission actions for encrypt submissions. This is to be * called when the submission is completed * @param submission the completed submission * @param responses the verified field responses sent with the original submission request + * @param emailFields fields and their responses that will be included in email notifications. May be undefined if the form is payment form. + * @param submissionAttachments files from attachment fields in the submission that will be included in email notifications. * @returns ok(true) if all actions were completed successfully * @returns err(FormNotFoundError) if the form or form admin does not exist * @returns err(ResponseModeError) if the form is not encrypt mode @@ -148,17 +247,17 @@ export const createEncryptSubmissionWithoutSave = ({ export const performEncryptPostSubmissionActions = ({ submission, responses, - growthbook, - emailData, - attachments, + emailFields, + submissionAttachments, respondentEmails, + growthbook, }: { submission: IEncryptedSubmissionSchema responses: FieldResponse[] - growthbook?: GrowthBook - emailData?: SubmissionEmailObj - attachments?: Mail.Attachment[] + emailFields: ProcessedFieldResponse[] + submissionAttachments?: Mail.Attachment[] respondentEmails?: string[] + growthbook?: GrowthBook }): ResultAsync< true, | FormNotFoundError @@ -174,82 +273,115 @@ export const performEncryptPostSubmissionActions = ({ submissionId: submission.id, } - return ( - FormService.retrieveFullFormById(submission.form) - .andThen(checkFormIsEncryptMode) - .andThen((form) => { - // Fire webhooks if available - // To avoid being coupled to latency of receiving system, - // do not await on webhook - const webhookUrl = form.webhook?.url - if (!webhookUrl) return okAsync(form) - - return WebhookFactory.sendInitialWebhook( - submission, - webhookUrl, - !!form.webhook?.isRetryEnabled, - ).andThen(() => okAsync(form)) - }) - // TODO [PDF-LAMBDA-GENERATION]: Remove setting of Growthbook targetting once pdf generation rollout is complete - .map(async (form) => { - await UserService.getPopulatedUserById(form.admin).map( - async (admin) => { - await growthbook?.setAttributes({ - ...growthbook?.getAttributes(), - formId: submission.form.toString(), - adminEmail: admin.email, - adminAgency: admin.agency.shortName, + return FormService.retrieveFullFormById(submission.form) + .andThen(checkFormIsEncryptMode) + .andThen((form) => { + // Fire webhooks if available + // To avoid being coupled to latency of receiving system, + // do not await on webhook + const webhookUrl = form.webhook?.url + if (!webhookUrl) return okAsync(form) + + return WebhookFactory.sendInitialWebhook( + submission, + webhookUrl, + !!form.webhook?.isRetryEnabled, + ).andThen(() => okAsync(form)) + }) + .andThen((form) => { + const respondentCopyEmailData: AutoReplyMailData[] = respondentEmails + ? respondentEmails?.map((val) => { + return { + email: val, + includeFormSummary: true, + } + }) + : [] + + const { formData, dataCollationData } = new SubmissionEmailObj( + emailFields, + new Set(), // the MyInfo prefixes are already inserted in middleware + form.authType, + ) + // Since we insert the [MyInfo] prefix in `encrypt-submission.middleware.ts`:L434 + // we want to remove it for the dataCollationData + const formattedDataCollationData = dataCollationData.map((item) => ({ + question: item.question.startsWith(MYINFO_PREFIX) + ? item.question.slice(MYINFO_PREFIX.length) + : item.question, + answer: item.answer, + })) + const recipientEmailDatas = [ + ...extractEmailConfirmationData(responses, form.form_fields), + ...respondentCopyEmailData, + ] + + const isPaymentEnabled = + form.responseMode === FormResponseMode.Encrypt && + form.payments_channel.channel !== PaymentChannel.Unconnected && + form.payments_field.enabled === true + + const pdfAttachmentResult = generatePdfAttachmentIfRequired({ + isPaymentEnabled, + autoReplyMailDatas: recipientEmailDatas, + submission, + form, + responsesData: formData, + growthbook, + }).orElse(() => okAsync(undefined)) + + return pdfAttachmentResult.andThen((pdfAttachment) => { + return ResultAsync.combine([ + MailService.sendSubmissionToAdmin({ + replyToEmails: + EmailSubmissionService.extractEmailAnswers(emailFields), + form, + submission: { + created: submission.created, + id: submission.id, + }, + submissionAttachments, + formData, + dataCollationData: formattedDataCollationData, + pdfAttachment: checkIfAdminPdfIsRequired( + isPaymentEnabled, + form.form_fields, + growthbook, + ) + ? pdfAttachment + : undefined, + }).mapErr((error) => { + logger.error({ + message: + 'Error while sending submission notification email to admin', + meta: logMeta, + error, }) - }, - ) - return form - }) - .andThen((form) => { - const respondentCopyEmailData: AutoReplyMailData[] = respondentEmails - ? respondentEmails?.map((val) => { - return { - email: val, - includeFormSummary: true, - } + return error + }), + sendEmailConfirmations({ + form, + submission, + submissionAttachments, + recipientData: recipientEmailDatas, + responsesData: formData, + pdfAttachment: checkIfRespondentFormSummaryIsRequired({ + isPaymentEnabled, + autoReplyMailDatas: recipientEmailDatas, }) - : [] - - // TODO [PDF-LAMBDA-GENERATION]: Remove setting of Growthbook targetting once pdf generation rollout is complete - const isUseLambdaOutput = - growthbook?.isOn(featureFlags.lambdaPdfGeneration) ?? false - logger.info({ - message: 'Growthbook flag for lambda pdf generation', - meta: { - ...logMeta, - isUseLambdaOutput, - growthbookAttributes: growthbook?.getAttributes(), - lambdaPdfGenerationGrowthbookValue: growthbook?.getFeatureValue( - featureFlags.lambdaPdfGeneration, - undefined, - ), - }, - }) - - return sendEmailConfirmations({ - form, - submission, - attachments, - responsesData: emailData?.autoReplyData, - recipientData: [ - ...extractEmailConfirmationData(responses, form.form_fields), - ...respondentCopyEmailData, - ], - isUseLambdaOutput, - }).mapErr((error) => { - logger.error({ - message: 'Error while sending email confirmations', - meta: { - action: 'sendEmailAutoReplies', - }, - error, - }) - return error - }) + ? pdfAttachment + : undefined, + isPaymentEnabled, + }).mapErr((error) => { + logger.error({ + message: 'Error while sending email confirmations to respondents', + meta: logMeta, + error, + }) + return error + }), + ]) }) - ) + }) + .map(() => true) } diff --git a/src/app/modules/submission/submission.service.ts b/src/app/modules/submission/submission.service.ts index 987023d92c..94ab4c29d1 100644 --- a/src/app/modules/submission/submission.service.ts +++ b/src/app/modules/submission/submission.service.ts @@ -450,17 +450,19 @@ export const uploadAttachments = ( export const sendEmailConfirmations = ({ form, submission, - responsesData = [], - attachments, + responsesData, + submissionAttachments, recipientData, - isUseLambdaOutput, + pdfAttachment, + isPaymentEnabled, }: { form: IPopulatedForm submission: S - responsesData?: EmailRespondentConfirmationField[] - attachments?: Mail.Attachment[] + responsesData: EmailRespondentConfirmationField[] + submissionAttachments?: Mail.Attachment[] recipientData: AutoReplyMailData[] - isUseLambdaOutput: boolean + pdfAttachment?: Mail.Attachment + isPaymentEnabled: boolean }): ResultAsync => { const logMeta = { action: 'sendEmailConfirmations', @@ -473,10 +475,11 @@ export const sendEmailConfirmations = ({ const sentEmailsPromise = MailService.sendAutoReplyEmails({ form, submission, - attachments, + submissionAttachments, responsesData, autoReplyMailDatas: recipientData, - isUseLambdaOutput, + pdfAttachment, + isPaymentEnabled, }) return ResultAsync.fromPromise(sentEmailsPromise, (error) => { logger.error({ diff --git a/src/app/services/mail/__tests__/mail.service.spec.ts b/src/app/services/mail/__tests__/mail.service.spec.ts index 361d00da9c..58a91c202c 100644 --- a/src/app/services/mail/__tests__/mail.service.spec.ts +++ b/src/app/services/mail/__tests__/mail.service.spec.ts @@ -2,7 +2,6 @@ import { cloneDeep } from 'lodash' import moment from 'moment-timezone' import { err, ok, okAsync } from 'neverthrow' import Mail, { Attachment } from 'nodemailer/lib/mailer' -import { FormResponseMode, PaymentChannel } from 'shared/types' import { MailSendError } from 'src/app/services/mail/mail.errors' import { MailService } from 'src/app/services/mail/mail.service' @@ -13,12 +12,7 @@ import { SendAutoReplyEmailsArgs, } from 'src/app/services/mail/mail.types' import * as MailUtils from 'src/app/services/mail/mail.utils' -import { - BounceType, - IPopulatedEncryptedForm, - IPopulatedForm, - ISubmissionSchema, -} from 'src/types' +import { BounceType, IPopulatedForm, ISubmissionSchema } from 'src/types' const MOCK_VALID_EMAIL = 'to@example.com' const MOCK_VALID_EMAIL_2 = 'to2@example.com' @@ -365,6 +359,12 @@ describe('mail.service', () => { }) }) + const MOCK_PDF_ATTACHMENT = { + filename: 'test.pdf', + content: Buffer.from('this is a test file'), + fieldId: 'test-field-id', + } + describe('sendSubmissionToAdmin', () => { let expectedHtml: string @@ -379,13 +379,8 @@ describe('mail.service', () => { id: 'mockSubmissionId', created: new Date(), }, - attachments: [ - { - filename: 'test.pdf', - content: Buffer.from('this is a test file'), - fieldId: 'test-field-id', - }, - ], + submissionAttachments: [], + pdfAttachment: MOCK_PDF_ATTACHMENT, dataCollationData: [ { question: 'some question', @@ -420,7 +415,12 @@ describe('mail.service', () => { from: MOCK_SENDER_STRING, subject: `formsg-auto: ${MOCK_VALID_SUBMISSION_PARAMS.form.title} (#${MOCK_VALID_SUBMISSION_PARAMS.submission.id})`, html: expectedHtml, - attachments: MOCK_VALID_SUBMISSION_PARAMS.attachments, + attachments: [ + ...MOCK_VALID_SUBMISSION_PARAMS.submissionAttachments, + ...(MOCK_VALID_SUBMISSION_PARAMS.pdfAttachment + ? [MOCK_VALID_SUBMISSION_PARAMS.pdfAttachment] + : []), + ], headers: { // Hardcode in tests in case something changes this. 'X-Formsg-Email-Type': 'Admin (response)', @@ -445,6 +445,50 @@ describe('mail.service', () => { )._unsafeUnwrap() }) + it('should include submission attachments and pdf if it is provided', async () => { + const mockValidSubmissionParamsWithPdf = cloneDeep( + MOCK_VALID_SUBMISSION_PARAMS, + ) + mockValidSubmissionParamsWithPdf.pdfAttachment = + cloneDeep(MOCK_PDF_ATTACHMENT) + + const submissionAttachmentsAndPdf = [ + ...MOCK_VALID_SUBMISSION_PARAMS.submissionAttachments, + MOCK_PDF_ATTACHMENT, + ] + // Act + await mailService.sendSubmissionToAdmin(mockValidSubmissionParamsWithPdf) + + // Assert + expect(sendMailSpy).toHaveBeenCalledOnce() + expect(sendMailSpy).toHaveBeenCalledWith( + expect.objectContaining({ + attachments: submissionAttachmentsAndPdf, + }), + ) + }) + + it('should include submission attachments but not include pdf attachment if it is not provided', async () => { + const mockValidSubmissionParamsWithPdf = { + ...cloneDeep(MOCK_VALID_SUBMISSION_PARAMS), + pdfAttachment: undefined, + } + + const submissionAttachmentsWithoutPdf = [ + ...MOCK_VALID_SUBMISSION_PARAMS.submissionAttachments, + ] + // Act + await mailService.sendSubmissionToAdmin(mockValidSubmissionParamsWithPdf) + + // Assert + expect(sendMailSpy).toHaveBeenCalledOnce() + expect(sendMailSpy).toHaveBeenCalledWith( + expect.objectContaining({ + attachments: submissionAttachmentsWithoutPdf, + }), + ) + }) + it('should send submission mail to admin successfully if form.emails is an array with a single string', async () => { // Arrange // sendMail should return mocked success response @@ -699,6 +743,7 @@ describe('mail.service', () => { const MOCK_SENDER_NAME = 'John Doe' const MOCK_AUTOREPLY_PARAMS: SendAutoReplyEmailsArgs = { + isPaymentEnabled: false, form: { title: 'Test form title', _id: 'mockFormId', @@ -718,13 +763,16 @@ describe('mail.service', () => { answerTemplate: ['some answer template'], }, ], - attachments: ['something'] as Attachment[], + submissionAttachments: ['something'] as Attachment[], autoReplyMailDatas: [ { email: MOCK_VALID_EMAIL_2, }, ], - isUseLambdaOutput: false, + } + const MOCK_AUTOREPLY_PARAMS_PAYMENT_ENABLED: SendAutoReplyEmailsArgs = { + ...MOCK_AUTOREPLY_PARAMS, + isPaymentEnabled: true, } const DEFAULT_AUTO_REPLY_BODY = `To whom it may concern,\n\nThank you for submitting this form.\n\nRegards,\n${MOCK_AUTOREPLY_PARAMS.form.admin.agency.fullName}`.split( @@ -896,7 +944,13 @@ describe('mail.service', () => { // Arrange sendMailSpy.mockResolvedValueOnce('mockedSuccessResponse') + const MOCK_PDF_ATTACHMENT = { + content: MOCK_PDF, + filename: 'response.pdf', + } + const customDataParams = cloneDeep(MOCK_AUTOREPLY_PARAMS) + customDataParams.pdfAttachment = MOCK_PDF_ATTACHMENT customDataParams.autoReplyMailDatas[0].includeFormSummary = true const expectedRenderData: AutoreplySummaryRenderData = { @@ -921,11 +975,8 @@ describe('mail.service', () => { html: expectedMailBody, // Attachments should be concatted with mock pdf response attachments: [ - ...(MOCK_AUTOREPLY_PARAMS.attachments ?? []), - { - content: MOCK_PDF, - filename: 'response.pdf', - }, + ...(MOCK_AUTOREPLY_PARAMS.submissionAttachments ?? []), + MOCK_PDF_ATTACHMENT, ], } const expectedResponse = await Promise.allSettled([ok(true)]) @@ -941,23 +992,18 @@ describe('mail.service', () => { expect(sendMailSpy).toHaveBeenCalledWith(expectedArg) }) - it('should send single autoreply mail with PDF if autoReply.includeFormSummary == true and no active payment field', async () => { + it('should send single autoreply mail with PDF if autoReply.includeFormSummary == true, pdfAttachment is provided and no active payment field', async () => { // Arrange sendMailSpy.mockResolvedValueOnce('mockedSuccessResponse') + const MOCK_PDF_ATTACHMENT = { + content: MOCK_PDF, + filename: 'response.pdf', + } + const customDataParams = cloneDeep(MOCK_AUTOREPLY_PARAMS) + customDataParams.pdfAttachment = MOCK_PDF_ATTACHMENT customDataParams.autoReplyMailDatas[0].includeFormSummary = true - const formDef = { - ...customDataParams.form, - responseMode: FormResponseMode.Encrypt, - payments_channel: { - channel: PaymentChannel.Stripe, - }, - payments_field: { - enabled: false, - }, - } as IPopulatedEncryptedForm as IPopulatedForm - customDataParams.form = formDef const expectedRenderData: AutoreplySummaryRenderData = { formData: MOCK_AUTOREPLY_PARAMS.responsesData, @@ -981,11 +1027,8 @@ describe('mail.service', () => { html: expectedMailBody, // Attachments should be concatted with mock pdf response attachments: [ - ...(MOCK_AUTOREPLY_PARAMS.attachments ?? []), - { - content: MOCK_PDF, - filename: 'response.pdf', - }, + ...(MOCK_AUTOREPLY_PARAMS.submissionAttachments ?? []), + MOCK_PDF_ATTACHMENT, ], } const expectedResponse = await Promise.allSettled([ok(true)]) @@ -1001,27 +1044,22 @@ describe('mail.service', () => { expect(sendMailSpy).toHaveBeenCalledWith(expectedArg) }) - it('should send single autoreply mail without PDF if autoReply.includeFormSummary == true and has active payment field', async () => { + it('should send single autoreply mail without PDF if autoReply.includeFormSummary == true and has active payment field, even if PDF attachment is generated', async () => { // Arrange sendMailSpy.mockResolvedValueOnce('mockedSuccessResponse') - const customDataParams = cloneDeep(MOCK_AUTOREPLY_PARAMS) + const MOCK_PDF_ATTACHMENT = { + content: MOCK_PDF, + filename: 'response.pdf', + } + + const customDataParams = cloneDeep(MOCK_AUTOREPLY_PARAMS_PAYMENT_ENABLED) + customDataParams.pdfAttachment = MOCK_PDF_ATTACHMENT customDataParams.autoReplyMailDatas[0].includeFormSummary = true - const formDef = { - ...customDataParams.form, - responseMode: FormResponseMode.Encrypt, - payments_channel: { - channel: PaymentChannel.Stripe, - }, - payments_field: { - enabled: true, - }, - } as IPopulatedEncryptedForm as IPopulatedForm - customDataParams.form = formDef const expectedMailBody = ( await MailUtils.generateAutoreplyHtml({ - submissionId: MOCK_AUTOREPLY_PARAMS.submission.id, + submissionId: MOCK_AUTOREPLY_PARAMS_PAYMENT_ENABLED.submission.id, autoReplyBody: DEFAULT_AUTO_REPLY_BODY, }) )._unsafeUnwrap() @@ -1030,7 +1068,10 @@ describe('mail.service', () => { ...defaultExpectedArg, html: expectedMailBody, // Attachments should not be concatted with mock pdf response - attachments: [...(MOCK_AUTOREPLY_PARAMS.attachments ?? [])], + attachments: [ + ...(MOCK_AUTOREPLY_PARAMS_PAYMENT_ENABLED.submissionAttachments ?? + []), + ], } const expectedResponse = await Promise.allSettled([ok(true)]) @@ -1152,6 +1193,144 @@ describe('mail.service', () => { expect(sendMailSpy).toHaveBeenCalledTimes(2) expect(sendMailSpy).toHaveBeenCalledWith(expectedArg) }) + + it('should include pdf attachment for mailData if includeFormSummary is true, isPaymentEnabled is false and pdfAttachment is provided', async () => { + // Arrange + const pdfAttachment = { + filename: 'test.pdf', + content: Buffer.from('pdfcontent'), + } + const customDataParams = { + ...cloneDeep(MOCK_AUTOREPLY_PARAMS), + isPaymentEnabled: false, + pdfAttachment, + } + + customDataParams.autoReplyMailDatas = [ + { + email: MOCK_VALID_EMAIL, + includeFormSummary: true, + }, + ] + + const submissionAttachments = customDataParams.submissionAttachments ?? [] + const expectedSubmissionAttachmentsAndPdf = [ + ...submissionAttachments, + pdfAttachment, + ] + + sendMailSpy.mockResolvedValueOnce('mockedSuccessResponse') + + // Act + const result = await mailService.sendAutoReplyEmails(customDataParams) + + // Assert + expect(result[0].status).toBe('fulfilled') + expect(sendMailSpy).toHaveBeenCalledTimes(1) + expect(sendMailSpy).toHaveBeenCalledWith( + expect.objectContaining({ + attachments: expectedSubmissionAttachmentsAndPdf, + }), + ) + }) + + it('should still send mail when pdf should be included but is not provided', async () => { + // Arrange + const customDataParams = { + ...cloneDeep(MOCK_AUTOREPLY_PARAMS), + isPaymentEnabled: false, + pdfAttachment: undefined, + } + + customDataParams.autoReplyMailDatas = [ + { + email: MOCK_VALID_EMAIL, + includeFormSummary: true, + }, + ] + + const expectedSubmissionAttachmentsWithoutPdf = + customDataParams.submissionAttachments ?? [] + + sendMailSpy.mockResolvedValueOnce('mockedSuccessResponse') + + // Act + const result = await mailService.sendAutoReplyEmails(customDataParams) + + // Assert + expect(result[0].status).toBe('fulfilled') + expect(sendMailSpy).toHaveBeenCalledTimes(1) + expect(sendMailSpy).toHaveBeenCalledWith( + expect.objectContaining({ + attachments: expectedSubmissionAttachmentsWithoutPdf, + }), + ) + }) + + it('should not include any attachment for mailData if includeFormSummary is false', async () => { + // Arrange + const customDataParams = { + ...cloneDeep(MOCK_AUTOREPLY_PARAMS), + isPaymentEnabled: false, + pdfAttachment: undefined, + } + + customDataParams.autoReplyMailDatas = [ + { + email: MOCK_VALID_EMAIL, + includeFormSummary: false, + }, + ] + + const expectedNoAttachments: [] = [] + + sendMailSpy.mockResolvedValueOnce('mockedSuccessResponse') + + // Act + const result = await mailService.sendAutoReplyEmails(customDataParams) + + // Assert + expect(result[0].status).toBe('fulfilled') + expect(sendMailSpy).toHaveBeenCalledTimes(1) + expect(sendMailSpy).toHaveBeenCalledWith( + expect.objectContaining({ + attachments: expectedNoAttachments, + }), + ) + }) + + it('should not include pdf attachment for mailData if isPaymentEnabled is true', async () => { + // Arrange + const customDataParams = { + ...cloneDeep(MOCK_AUTOREPLY_PARAMS), + isPaymentEnabled: true, + pdfAttachment: undefined, + } + + customDataParams.autoReplyMailDatas = [ + { + email: MOCK_VALID_EMAIL, + includeFormSummary: true, + }, + ] + + const expectedSubmissionAttachmentsWithoutPdf = + customDataParams.submissionAttachments ?? [] + + sendMailSpy.mockResolvedValueOnce('mockedSuccessResponse') + + // Act + const result = await mailService.sendAutoReplyEmails(customDataParams) + + // Assert + expect(result[0].status).toBe('fulfilled') + expect(sendMailSpy).toHaveBeenCalledTimes(1) + expect(sendMailSpy).toHaveBeenCalledWith( + expect.objectContaining({ + attachments: expectedSubmissionAttachmentsWithoutPdf, + }), + ) + }) }) describe('sendBounceNotification', () => { diff --git a/src/app/services/mail/mail.service.ts b/src/app/services/mail/mail.service.ts index f76d0727f9..ba8e40195b 100644 --- a/src/app/services/mail/mail.service.ts +++ b/src/app/services/mail/mail.service.ts @@ -2,13 +2,12 @@ import { render } from '@react-email/render' import tracer from 'dd-trace' import { get, inRange, isEmpty } from 'lodash' import moment from 'moment-timezone' -import { err, errAsync, fromPromise, Result, ResultAsync } from 'neverthrow' +import { errAsync, fromPromise, okAsync, Result, ResultAsync } from 'neverthrow' import Mail from 'nodemailer/lib/mailer' import promiseRetry from 'promise-retry' import validator from 'validator' import { DEFAULT_RESPONDENT_COPY_EMAIL } from '../../../../shared/constants/mail' -import { FormResponseMode, PaymentChannel } from '../../../../shared/types' import { centsToDollars } from '../../../../shared/utils/payments' import { getPaymentInvoiceDownloadUrlPath } from '../../../../shared/utils/urls' import { HASH_EXPIRE_AFTER_SECONDS } from '../../../../shared/utils/verification' @@ -16,7 +15,6 @@ import { BounceType, EmailAdminDataField, IFormHasEmailSchema, - IPopulatedEncryptedForm, IPopulatedForm, ISubmissionSchema, } from '../../../types' @@ -62,9 +60,7 @@ import { SubmissionToAdminHtmlData, } from './mail.types' import { - AutoReplyData, generateAutoreplyHtml, - generateAutoreplyPdf, generateIssueReportedNotificationHtml, generateLoginOtpHtml, generatePaymentConfirmationHtml, @@ -679,7 +675,8 @@ export class MailService { * @param args.replyToEmails emails to set replyTo, if any * @param args.form the form document to retrieve some email data from * @param args.submission the submission document to retrieve some email data from - * @param args.attachments attachments to append to the email, if any + * @param args.submissionAttachments files from attachment fields in the submission to be included in the email notifications. + * @param args.pdfAttachment response PDF attachment to be included in the email notifications. * @param args.dataCollationData the data to use in the data collation tool to be appended to the end of the email * @param args.formData the form data to display to in the body in table form */ @@ -687,20 +684,43 @@ export class MailService { replyToEmails, form, submission, - attachments, + submissionAttachments = [], dataCollationData, formData, + pdfAttachment, }: { replyToEmails?: string[] form: Pick submission: Pick - attachments?: Mail.Attachment[] + submissionAttachments?: Mail.Attachment[] formData: EmailAdminDataField[] dataCollationData?: { question: string answer: string | number }[] + pdfAttachment?: Mail.Attachment }): ResultAsync => { + const logMeta = { + action: 'sendSubmissionToAdmin', + formId: form._id, + submissionId: submission.id, + } + + const adminEmailsToNotify = form.emails + if (!adminEmailsToNotify) { + return okAsync(true) + } + + const attachmentsToInclude = [ + ...submissionAttachments, + ...(pdfAttachment ? [pdfAttachment] : []), + ] + + logger.info({ + message: 'Sending admin notification mail', + meta: logMeta, + }) + const refNo = String(submission.id) const formTitle = form.title const submissionTime = moment(submission.created) @@ -740,7 +760,7 @@ export class MailService { from: this.#senderFromString, subject: `formsg-auto: ${formTitle} (#${refNo})`, html: mailHtml, - attachments, + attachments: attachmentsToInclude, headers: { [EMAIL_HEADERS.formId]: String(form._id), [EMAIL_HEADERS.submissionId]: refNo, @@ -775,7 +795,8 @@ export class MailService { * @param args.attachments attachments to append to the email, if any * @param args.responsesData the array of response data to use in rendering * the mail body or summary pdf - * @param args.isUseLambdaOutput whether to use the lambda output for the pdf generation + * @param args.submissionAttachments files from attachment fields in the submission that will be included in email notifications. + * @param args.pdfAttachment response PDF attachment to be included in the email notifications. * @param args.autoReplyMailDatas array of objects that contains autoreply mail data to override with defaults * @param args.autoReplyMailDatas[].email contains the recipient of the mail * @param args.autoReplyMailDatas[].subject if available, sends the mail out with this subject instead of the default subject @@ -787,8 +808,9 @@ export class MailService { submission, responsesData, autoReplyMailDatas, - attachments = [], - isUseLambdaOutput, + submissionAttachments = [], + pdfAttachment, + isPaymentEnabled, }: SendAutoReplyEmailsArgs): Promise< PromiseSettledResult< Result< @@ -797,48 +819,7 @@ export class MailService { > >[] > => { - // Create a copy of attachments for attaching of autoreply pdf if needed. - const attachmentsWithAutoreplyPdf = [...attachments] - const isEncryptForm = form?.responseMode === FormResponseMode.Encrypt - const encryptFormDef = form as IPopulatedEncryptedForm - const isPaymentEnabled = - isEncryptForm && - encryptFormDef.payments_channel.channel !== PaymentChannel.Unconnected && - encryptFormDef.payments_field.enabled === true - - const autoReplyData: AutoReplyData = { - refNo: submission.id, - formTitle: form.title, - submissionDateTime: submission.created || new Date(), - responsesData, - formUrl: `${this.#appUrl}/${form._id}`, - } - // Generate autoreply pdf and append into attachments if any of the mail has - // to include a form summary. - if ( - autoReplyMailDatas.some((data) => data.includeFormSummary) && - !isPaymentEnabled - ) { - const pdfBufferResult = await generateAutoreplyPdf( - autoReplyData, - isUseLambdaOutput, - ) - if (pdfBufferResult.isErr()) { - return Promise.allSettled([err(pdfBufferResult.error)]) - } - attachmentsWithAutoreplyPdf.push({ - filename: 'response.pdf', - content: Buffer.copyBytesFrom(pdfBufferResult.value), - }) - } - - // strip answer from renderData to always use answerTemplate for email body responses - const strippedResponsesData = responsesData.map( - ({ question, answerTemplate }) => ({ - question, - answerTemplate, - }), - ) + // Data to render both the submission details mail HTML body and PDF. const strippedRenderData: AutoreplySummaryRenderData = { refNo: submission.id, @@ -846,19 +827,42 @@ export class MailService { submissionTime: moment(submission.created) .tz('Asia/Singapore') .format('ddd, DD MMM YYYY hh:mm:ss A'), - formData: strippedResponsesData, + // strip answer from renderData to always use answerTemplate for email body responses + formData: responsesData.map(({ question, answerTemplate }) => ({ + question, + answerTemplate, + })), formUrl: `${this.#appUrl}/${form._id}`, } + const getAttachmentsToInclude = (mailData: AutoReplyMailData) => { + const shouldPdfAttachmentBeIncluded = + !isPaymentEnabled && mailData.includeFormSummary + + if (shouldPdfAttachmentBeIncluded && !pdfAttachment) { + logger.error({ + message: + 'Could not find PDF attachment required for autoReply email. Continuing to send without PDF attachment.', + meta: { + action: 'sendAutoReplyEmails', + formId: String(form._id), + submissionId: String(submission.id), + }, + }) + } + + return pdfAttachment && shouldPdfAttachmentBeIncluded + ? [...submissionAttachments, pdfAttachment] + : [...submissionAttachments] + } + // Prepare mail sending for each autoreply mail. return Promise.allSettled( autoReplyMailDatas.map((mailData, index) => { return this.#sendSingleAutoreplyMail({ form, submission, - attachments: mailData.includeFormSummary - ? attachmentsWithAutoreplyPdf - : attachments, + attachments: getAttachmentsToInclude(mailData), autoReplyMailData: mailData, formSummaryRenderData: strippedRenderData, index, @@ -1067,7 +1071,7 @@ export class MailService { responseId: string formQuestionAnswers: QuestionAnswer[] attachments?: Mail.Attachment[] - }) => { + }): ResultAsync => { const htmlData = { formTitle, responseId: responseId.toString(), @@ -1126,7 +1130,7 @@ export class MailService { isRejected: boolean formQuestionAnswers: QuestionAnswer[] attachments?: Mail.Attachment[] - }) => { + }): ResultAsync => { const outcome = isRejected ? WorkflowOutcome.NOT_APPROVED : WorkflowOutcome.APPROVED diff --git a/src/app/services/mail/mail.types.ts b/src/app/services/mail/mail.types.ts index 8689f0c01c..ce75e5d1c1 100644 --- a/src/app/services/mail/mail.types.ts +++ b/src/app/services/mail/mail.types.ts @@ -28,7 +28,7 @@ export type SendSingleAutoreplyMailArgs = { export type SendAutoReplyEmailsArgs = { form: IPopulatedForm submission: Pick - attachments?: Mail.Attachment[] + submissionAttachments?: Mail.Attachment[] responsesData: (Pick< EmailAdminDataField, 'question' | 'answerTemplate' | 'fieldType' @@ -36,7 +36,8 @@ export type SendAutoReplyEmailsArgs = { answer?: EmailAdminDataField['answer'] })[] autoReplyMailDatas: AutoReplyMailData[] - isUseLambdaOutput: boolean + pdfAttachment?: Mail.Attachment + isPaymentEnabled: boolean } export type MailServiceParams = {