From 1f3fe56c238cffd2d1a1d4a601980c94488c7587 Mon Sep 17 00:00:00 2001 From: Kevin Foong <55353265+kevin9foong@users.noreply.github.com> Date: Fri, 5 Dec 2025 21:10:14 +0800 Subject: [PATCH 1/3] feat: add feature flag for admin email pdf --- shared/constants/feature-flags.ts | 7 +++++ .../modules/submission/submission.utils.ts | 30 ++++++++++++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/shared/constants/feature-flags.ts b/shared/constants/feature-flags.ts index 00b841b445..88417ae696 100644 --- a/shared/constants/feature-flags.ts +++ b/shared/constants/feature-flags.ts @@ -28,7 +28,14 @@ export const featureFlags = { singpassMrf: 'singpass-mrf' as const, enableSaveDraftButtonFloating: 'enable-save-draft-button-floating' as const, enableSaveDraftButtonHeader: 'enable-save-draft-button-header' as const, + adminEmailPdf: 'admin-email-pdf' as const, ogpHeader: 'enable-ogp-header' as const, ogpAwareness: 'ogp-awareness' as const, ogpSpinner: 'ogp-spinner' as const, } + +export enum AdminEmailPdfFeatureValue { + OFF = 'OFF', + SIGNATURES_ONLY = 'SIGNATURES_ONLY', + ON = 'ON', +} diff --git a/src/app/modules/submission/submission.utils.ts b/src/app/modules/submission/submission.utils.ts index 7634c758f8..e36296332e 100644 --- a/src/app/modules/submission/submission.utils.ts +++ b/src/app/modules/submission/submission.utils.ts @@ -1,3 +1,4 @@ +import { GrowthBook } from '@growthbook/growthbook' import { encode as encodeBase64 } from '@stablelib/base64' import crypto from 'crypto' import StatusCodes from 'http-status-codes' @@ -14,7 +15,11 @@ import { import mongoose from 'mongoose' import { err, ok, Result } from 'neverthrow' -import { MULTIRESPONDENT_FORM_SUBMISSION_VERSION } from '../../../../shared/constants' +import { + AdminEmailPdfFeatureValue, + featureFlags, + MULTIRESPONDENT_FORM_SUBMISSION_VERSION, +} from '../../../../shared/constants' import { FIELDS_TO_REJECT } from '../../../../shared/constants/field/basic' import { MYINFO_ATTRIBUTE_MAP } from '../../../../shared/constants/field/myinfo' import { @@ -941,3 +946,26 @@ export const buildMrfMetadata = ({ hasNextStepRecipientEmails, } } + +export const isAdminEmailPdfEnabled = ({ + growthbook, + formFields, +}: { + growthbook?: GrowthBook + formFields: FormFieldSchema[] +}) => { + if (!growthbook) { + return false + } + const adminEmailPdfFeatureValue = growthbook.getFeatureValue( + featureFlags.adminEmailPdf, + AdminEmailPdfFeatureValue.OFF, + ) + if (adminEmailPdfFeatureValue === AdminEmailPdfFeatureValue.ON) { + return true + } + if (adminEmailPdfFeatureValue === AdminEmailPdfFeatureValue.SIGNATURES_ONLY) { + return formFields.some((field) => field.fieldType === BasicField.Signature) + } + return false +} From f72a00f814a617cf51cf24ef9116af4bf3b5bb40 Mon Sep 17 00:00:00 2001 From: Kevin Foong <55353265+kevin9foong@users.noreply.github.com> Date: Fri, 5 Dec 2025 21:10:41 +0800 Subject: [PATCH 2/3] feat: add admin pdf copy for mrf --- .../multirespondent-submission.controller.ts | 2 + .../multirespondent-submission.service.ts | 625 +++++++++++++----- .../multirespondent-submission.utils.ts | 246 +++---- 3 files changed, 589 insertions(+), 284 deletions(-) diff --git a/src/app/modules/submission/multirespondent-submission/multirespondent-submission.controller.ts b/src/app/modules/submission/multirespondent-submission/multirespondent-submission.controller.ts index 978739e258..e4424ec48e 100644 --- a/src/app/modules/submission/multirespondent-submission/multirespondent-submission.controller.ts +++ b/src/app/modules/submission/multirespondent-submission/multirespondent-submission.controller.ts @@ -139,6 +139,7 @@ const submitMultirespondentForm = async ( encryptedPayload, logMeta, attachments: req.formsg.unencryptedAttachments, + growthbook: req.growthbook, }) } @@ -234,6 +235,7 @@ const updateMultirespondentSubmission = async ( encryptedPayload, logMeta, attachments: req.formsg.unencryptedAttachments, + growthbook: req.growthbook, }) } diff --git a/src/app/modules/submission/multirespondent-submission/multirespondent-submission.service.ts b/src/app/modules/submission/multirespondent-submission/multirespondent-submission.service.ts index a09693fbd9..6d6ebfa6b1 100644 --- a/src/app/modules/submission/multirespondent-submission/multirespondent-submission.service.ts +++ b/src/app/modules/submission/multirespondent-submission/multirespondent-submission.service.ts @@ -1,4 +1,4 @@ -import { flatten, uniq } from 'lodash' +import { GrowthBook } from '@growthbook/growthbook' import mongoose from 'mongoose' import { err, errAsync, ok, okAsync, Result, ResultAsync } from 'neverthrow' import Mail from 'nodemailer/lib/mailer' @@ -27,7 +27,7 @@ import { MultirespondentSubmissionDto, SnapshottedFormDef, } from '../../../../types/api' -import config from '../../../config/config' +import config, { isTest } from '../../../config/config' import { createLoggerWithLabel, CustomLoggerParams, @@ -38,11 +38,7 @@ import { MailSendError, } from '../../../services/mail/mail.errors' import MailService from '../../../services/mail/mail.service' -import { AutoReplyMailData } from '../../../services/mail/mail.types' -import { - AutoReplyData, - generateAutoreplyPdf, -} from '../../../services/mail/mail.utils' +import { generateAutoreplyPdf } from '../../../services/mail/mail.utils' import { transformMongoError } from '../../../utils/handle-mongo-error' import { DatabaseError } from '../../core/core.errors' import { isFormMultirespondent } from '../../form/form.utils' @@ -60,15 +56,18 @@ import { } from '../submission.errors' import { uploadAttachments } from '../submission.service' import { AttachmentMetadata } from '../submission.types' -import { getMrfSubmissionWorkflowStatus } from '../submission.utils' +import { + getMrfSubmissionWorkflowStatus, + isAdminEmailPdfEnabled, +} from '../submission.utils' import { reportSubmissionResponseTime } from '../submissions.statsd-client' import { MultirespondentSubmissionContent } from './multirespondent-submission.types' import { - extractRespondentCopyEmails, + extractRespondentCopyEmailDatas, getEmailFromResponses, - getPdfResponsesData, - getQuestionTitleAnswerString, + getQuestionAnswerPairsForMultipleFields, + getResponsesDataFromMrfResponses, retrieveWorkflowStepEmailAddresses, } from './multirespondent-submission.utils' @@ -315,20 +314,15 @@ export const sendNextStepReminderEmail = ({ }) } -const sendMrfOutcomeEmails = ({ - currentStepNumber, +const getEmailsToNotifyAboutMrfOutcome = ({ form, responses, + currentStepNumber, submissionId, - isApproval = false, - isRejected = false, - attachments, }: { - currentStepNumber: number form: Pick< IPopulatedMultirespondentForm, | '_id' - | 'title' | 'emails' | 'stepOneEmailNotificationFieldId' | 'stepsToNotify' @@ -337,30 +331,34 @@ const sendMrfOutcomeEmails = ({ form_fields: FormFieldSchema[] | FormFieldDto[] } responses: FieldResponsesV3 + currentStepNumber: number submissionId: string - isApproval?: boolean - isRejected?: boolean - attachments?: IAttachmentInfo[] -}): ResultAsync => { +}): Result => { const logMeta = { - action: 'sendMrfOutcomeEmails', + action: 'getMrfOutcomeEmailsToNotify', formId: form._id?.toString(), submissionId, } - const emailsToNotify = + + // Emails to notify under the 'others' setting + const othersEmailsToNotify = form.emails && Array.isArray(form.emails) ? form.emails : [] + // Emails to notify under the 'Respondent in step 1' setting const stepOneEmailNotificationFieldId = form.stepOneEmailNotificationFieldId - const stepOneEmailToNotify = stepOneEmailNotificationFieldId + const respondentInStepOneToNotify = stepOneEmailNotificationFieldId ? getEmailFromResponses(stepOneEmailNotificationFieldId, responses) : null - if (stepOneEmailToNotify) emailsToNotify.push(stepOneEmailToNotify) + const respondentInStepOneEmailToNotify = respondentInStepOneToNotify + ? [respondentInStepOneToNotify] + : [] + + // Emails to notify under the 'Other respondents in your workflow' setting const stepsToNotifyUpToCurrentStep = form.workflow.slice( 1, // exclude first step since notification is indicated by `stepOneEmailNotificationFieldId` currentStepNumber + 1, ) - const validWorkflowStepsToNotify = (form.stepsToNotify ?? []) .map((stepId) => stepsToNotifyUpToCurrentStep.find( @@ -371,52 +369,135 @@ const sendMrfOutcomeEmails = ({ (workflowStep) => workflowStep !== undefined, ) as FormWorkflowStepDto[] + const otherRespondentsInYourWorkflowEmailsToNotifyResult = Result.combine( + validWorkflowStepsToNotify.flatMap((workflowStep) => { + return retrieveWorkflowStepEmailAddresses(form, workflowStep, responses) + }), + ).map((emails) => emails.flat()) + + if (otherRespondentsInYourWorkflowEmailsToNotifyResult.isErr()) { + logger.error({ + message: + 'Failed to retrieve workflow step email addresses from non-step 1 workflow steps', + meta: logMeta, + error: otherRespondentsInYourWorkflowEmailsToNotifyResult.error, + }) + return err(otherRespondentsInYourWorkflowEmailsToNotifyResult.error) + } + + const otherRespondentsInYourWorkflowEmailsToNotify = + otherRespondentsInYourWorkflowEmailsToNotifyResult.value + return ok([ + ...othersEmailsToNotify, + ...respondentInStepOneEmailToNotify, + ...otherRespondentsInYourWorkflowEmailsToNotify, + ]) +} + +const checkIsWorkflowCompleted = ({ + currentStepNumber, + form, + isRejected, +}: { + currentStepNumber: number + form: Pick + isRejected: boolean +}) => { + const lastStepNumber = form.workflow.length - 1 + const isLastStepSubmitted = currentStepNumber === lastStepNumber + + return isRejected || isLastStepSubmitted +} + +const sendMrfOutcomeEmails = ({ + currentStepNumber, + form, + responses, + submissionId, + isApproval = false, + isRejected = false, + attachments, + pdfResult, +}: { + currentStepNumber: number + form: Pick< + IPopulatedMultirespondentForm, + | '_id' + | 'title' + | 'emails' + | 'stepOneEmailNotificationFieldId' + | 'stepsToNotify' + | 'workflow' + > & { + form_fields: FormFieldSchema[] | FormFieldDto[] + } + responses: FieldResponsesV3 + submissionId: string + isApproval?: boolean + isRejected?: boolean + attachments?: IAttachmentInfo[] + pdfResult: ResultAsync< + Mail.Attachment | undefined, + AutoreplyPdfGenerationError + > +}): ResultAsync< + true, + InvalidWorkflowTypeError | MailSendError | AutoreplyPdfGenerationError +> => { + const logMeta = { + action: 'sendMrfOutcomeEmails', + formId: form._id?.toString(), + submissionId, + } + return ( // Step 1: Fetch email address from all workflow steps that are selected to notify - Result.combine( - validWorkflowStepsToNotify.map((workflowStep) => - retrieveWorkflowStepEmailAddresses(form, workflowStep, responses), - ), - ) - .mapErr((error) => { - logger.error({ - message: 'Failed to retrieve workflow step email addresses', - meta: logMeta, - error, - }) - return error - }) - .map((workflowStepEmailsToNotifyList) => { - return flatten(workflowStepEmailsToNotifyList) - }) - // Step 2: Combine static emails and workflow step emails that are selected to notify - .map((workflowStepEmailsToNotify) => { - return uniq([...workflowStepEmailsToNotify, ...emailsToNotify]) + getEmailsToNotifyAboutMrfOutcome({ + form, + responses, + currentStepNumber, + submissionId, + }) + .asyncAndThen((destinationEmails) => { + return pdfResult + .orElse(() => okAsync(undefined)) + .map((responsePdf) => { + return { + destinationEmails, + responsePdf, + } + }) }) // Step 3: Send outcome emails based on type - .asyncAndThen((destinationEmails) => { + .andThen(({ destinationEmails, responsePdf }) => { if (!destinationEmails || destinationEmails.length <= 0) { logger.info({ message: 'No destination email found for MRF outcome email', meta: logMeta, }) - return okAsync(true) + return okAsync(true as const) } - const lastStepNumber = form.workflow.length - 1 - const isLastStep = currentStepNumber === lastStepNumber - const isWorkflowCompleted = isLastStep + const isWorkflowCompleted = checkIsWorkflowCompleted({ + currentStepNumber, + form, + isRejected, + }) - if (!isWorkflowCompleted && !isRejected) { - return okAsync(true) + if (!isWorkflowCompleted) { + return okAsync(true as const) } - const formQuestionAnswers = getQuestionTitleAnswerString({ + const formQuestionAnswers = getQuestionAnswerPairsForMultipleFields({ formFields: form.form_fields, responses, }) - const emailAttachments = [...(attachments ?? [])] + const emailAttachments = [] + emailAttachments.push(...(attachments ?? [])) + if (responsePdf) { + emailAttachments.push(responsePdf) + } if (isApproval) { return MailService.sendMrfApprovalEmail({ @@ -440,6 +521,7 @@ const sendMrfOutcomeEmails = ({ return errAsync(error) }) } + return MailService.sendMrfWorkflowCompletionEmail({ emails: destinationEmails, formId: form._id, @@ -468,7 +550,9 @@ const sendMrfRespondentCopyEmails = ({ responses, submission, attachments, - respondentCopyRecipientData, + formFields, + currentStepActiveFields, + pdfResult, }: { form: Pick< IPopulatedMultirespondentForm | SnapshottedFormDef, @@ -479,81 +563,73 @@ const sendMrfRespondentCopyEmails = ({ responses: FieldResponsesV3 submission: IMultirespondentSubmissionSchema attachments?: IAttachmentInfo[] - respondentCopyRecipientData: AutoReplyMailData[] + formFields: FormFieldSchema[] | FormFieldDto[] + currentStepActiveFields: string[] + pdfResult: ResultAsync< + Mail.Attachment | undefined, + AutoreplyPdfGenerationError + > }): ResultAsync< true, InvalidWorkflowTypeError | MailSendError | AutoreplyPdfGenerationError > => { - const submissionId: string = submission.id - const formQuestionAnswers = getQuestionTitleAnswerString({ - formFields: form.form_fields, + const respondentCopyEmailDatas = extractRespondentCopyEmailDatas({ responses, + formFields, + currentStepActiveFields, }) + // if no respondent copy email data, continue without sending any emails + if (!respondentCopyEmailDatas) { + return okAsync(true) + } + + const submissionId: string = submission.id - // Prepare repsonses for pdf html - const pdfFormData = getPdfResponsesData({ + const formQuestionAnswers = getQuestionAnswerPairsForMultipleFields({ formFields: form.form_fields, responses, }) - const hasFormSummary = respondentCopyRecipientData.some( - (autoReplyMailData) => autoReplyMailData.includeFormSummary, - ) - const autoReplyData: AutoReplyData = { - refNo: submissionId, - formTitle: form.title, - submissionDateTime: submission.created || new Date(), - responsesData: pdfFormData, - formUrl: `${config.app.appUrl}/${form._id}`, - } - - // Step 1: generate PDF if needed - const pdfResult: ResultAsync< - Mail.Attachment | undefined, - AutoreplyPdfGenerationError - > = hasFormSummary - ? generateAutoreplyPdf(autoReplyData, true).map((pdfBuffer) => ({ - filename: 'response.pdf', - content: Buffer.copyBytesFrom(pdfBuffer), - })) - : okAsync(undefined) - - return pdfResult.andThen((responsePdf) => { - const recipientAttachments = [ - ...(attachments ?? []), - ...(responsePdf ? [responsePdf] : []), - ] - return ResultAsync.combine( - respondentCopyRecipientData.map((autoReplyMailData) => { - return MailService.sendMrfRespondentCopyEmail({ - formId: form._id, - formTitle: form.title, - responseId: submissionId, - attachments: autoReplyMailData.includeFormSummary - ? recipientAttachments - : [], - autoReplyMailData, - agencyName: form.admin.agency.fullName, - ...(autoReplyMailData.includeFormSummary && { formQuestionAnswers }), - }).orElse((error) => { - logger.error({ - message: 'Failed to send respondent copy email', - meta: { - action: 'sendMrfRespondentCopyEmail', - formId: form._id, - submissionId, - autoReplyMailData, - }, - error, + return pdfResult + .orElse(() => okAsync(undefined)) + .andThen((responsePdf) => { + const recipientAttachments = [ + ...(attachments ?? []), + ...(responsePdf ? [responsePdf] : []), + ] + return ResultAsync.combine( + respondentCopyEmailDatas.map((autoReplyMailData) => { + return MailService.sendMrfRespondentCopyEmail({ + formId: form._id, + formTitle: form.title, + responseId: submissionId, + attachments: autoReplyMailData.includeFormSummary + ? recipientAttachments + : [], + autoReplyMailData, + agencyName: form.admin.agency.fullName, + ...(autoReplyMailData.includeFormSummary && { + formQuestionAnswers, + }), + }).orElse((error) => { + logger.error({ + message: 'Failed to send respondent copy email', + meta: { + action: 'sendMrfRespondentCopyEmail', + formId: form._id, + submissionId, + autoReplyMailData, + }, + error, + }) + return okAsync(true) //continue even if one email fails }) - return okAsync(true) //continue even if one email fails - }) - }), - ).map(() => true) as ResultAsync< - true, - InvalidWorkflowTypeError | MailSendError | AutoreplyPdfGenerationError - > - }) + }), + ).map(() => true) as ResultAsync< + true, + InvalidWorkflowTypeError | MailSendError | AutoreplyPdfGenerationError + > + }) } const saveAttachmentsToDbIfExists = ({ @@ -689,6 +765,172 @@ export const createMultiRespondentFormSubmission = ({ }) } +interface CheckIfRespondentFormSummaryIsRequiredArgs { + responses: FieldResponsesV3 + formFields: FormFieldSchema[] | FormFieldDto[] + currentStepActiveFields: string[] +} + +const checkIfRespondentFormSummaryIsRequired = ({ + responses, + formFields, + currentStepActiveFields, +}: CheckIfRespondentFormSummaryIsRequiredArgs): boolean => { + const respondentCopyEmailDatas = extractRespondentCopyEmailDatas({ + responses, + formFields, + currentStepActiveFields, + }) + return ( + respondentCopyEmailDatas && + respondentCopyEmailDatas.some((emailData) => emailData.includeFormSummary) + ) +} + +interface CheckIsWorkflowCompletionEmailPdfRequiredArgs { + currentStepNumber: number + form: Pick< + IPopulatedMultirespondentForm, + | '_id' + | 'workflow' + | 'emails' + | 'stepsToNotify' + | 'stepOneEmailNotificationFieldId' + > & { + form_fields: FormFieldSchema[] | FormFieldDto[] + } + responses: FieldResponsesV3 + isRejected: boolean + submissionId: string + growthbook?: GrowthBook +} + +const checkIsWorkflowCompletionEmailPdfRequired = ({ + currentStepNumber, + form, + responses, + isRejected, + submissionId, + growthbook, +}: CheckIsWorkflowCompletionEmailPdfRequiredArgs) => { + const isGbFlagEnabled = + isAdminEmailPdfEnabled({ + growthbook, + formFields: form.form_fields as FormFieldSchema[], + }) || isTest + + if (!isGbFlagEnabled) { + return false + } + const isWorkflowCompleted = checkIsWorkflowCompleted({ + currentStepNumber, + form, + isRejected, + }) + + const hasEmailsToSendMrfOutcomeNotification = + getEmailsToNotifyAboutMrfOutcome({ + form, + responses, + currentStepNumber, + submissionId, + }) + + return ( + isWorkflowCompleted && + hasEmailsToSendMrfOutcomeNotification.isOk() && + hasEmailsToSendMrfOutcomeNotification.value.length > 0 + ) +} + +type CheckIsPdfGenerationRequiredArgs = Omit< + CheckIfRespondentFormSummaryIsRequiredArgs, + 'formFields' +> & + CheckIsWorkflowCompletionEmailPdfRequiredArgs + +const generatePdfAttachmentIfRequired = ({ + submission, + form, + responses, + currentStepActiveFields, + currentStepNumber, + isRejected, + growthbook, +}: CheckIsPdfGenerationRequiredArgs & { + submission: IMultirespondentSubmissionSchema + form: Pick< + IPopulatedMultirespondentForm, + | '_id' + | 'title' + | 'workflow' + | 'emails' + | 'stepsToNotify' + | 'stepOneEmailNotificationFieldId' + > & { + form_fields: FormFieldSchema[] | FormFieldDto[] + } +}): ResultAsync => { + const submissionId = submission.id + + const isRespondentCopyPdfRequired = checkIfRespondentFormSummaryIsRequired({ + responses, + formFields: form.form_fields, + currentStepActiveFields, + }) + const isWorkflowCompletionEmailPdfRequired = + checkIsWorkflowCompletionEmailPdfRequired({ + currentStepNumber, + form, + responses, + isRejected, + submissionId, + growthbook, + }) + + if (!isRespondentCopyPdfRequired && !isWorkflowCompletionEmailPdfRequired) { + return okAsync(undefined) + } + + const responsesData = getResponsesDataFromMrfResponses({ + formFields: form.form_fields, + responses, + }) + + const autoReplyData = { + refNo: submissionId, + formTitle: form.title, + submissionDateTime: submission.created ?? new Date(), + responsesData, + formUrl: `${config.app.appUrl}/${form._id}`, + } + + const DEFAULT_RESPONSE_PDF_FILENAME = 'response.pdf' + const pdfResult = 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, + formId: form._id, + formResponseMode: FormResponseMode.Multirespondent, + isRespondentCopyPdfRequired, + isWorkflowCompletionEmailPdfRequired, + }, + error, + }) + return error + }) + + return pdfResult +} + export const performMultiRespondentPostSubmissionCreateActions = ({ submission, submissionId, @@ -696,6 +938,7 @@ export const performMultiRespondentPostSubmissionCreateActions = ({ encryptedPayload, logMeta, attachments, + growthbook, }: { submission: IMultirespondentSubmissionSchema submissionId: string @@ -703,6 +946,7 @@ export const performMultiRespondentPostSubmissionCreateActions = ({ encryptedPayload: MultirespondentSubmissionDto logMeta: CustomLoggerParams['meta'] attachments?: IAttachmentInfo[] + growthbook?: GrowthBook }): ResultAsync => { const { submissionSecretKey, responses } = encryptedPayload const currentStepNumber = 0 @@ -715,33 +959,53 @@ export const performMultiRespondentPostSubmissionCreateActions = ({ submissionId, } - // Find active fields for current step - const activeFields = form.workflow[currentStepNumber] - ? form.workflow[currentStepNumber].edit - : [] - - // Find respondent copy recipient data - const respondentCopyRecipientData = extractRespondentCopyEmails({ - responses: encryptedPayload.responses, - formFields: form.form_fields, - activeFields, + const pdfResult = generatePdfAttachmentIfRequired({ + submission, + form, + responses, + currentStepActiveFields: form.workflow[currentStepNumber]?.edit ?? [], + currentStepNumber, + isRejected: false, // first step cannot be an approval step and thus cannot be rejected. + submissionId, + growthbook, }) - if (respondentCopyRecipientData && respondentCopyRecipientData.length > 0) { - sendMrfRespondentCopyEmails({ + const sendMrfRespondentCopyEmailsPdfResult = + checkIfRespondentFormSummaryIsRequired({ + responses, + formFields: form.form_fields, + currentStepActiveFields: form.workflow[currentStepNumber]?.edit ?? [], + }) + ? pdfResult + : okAsync(undefined) + + const sendMrfOutcomeEmailsPdfResult = + checkIsWorkflowCompletionEmailPdfRequired({ + currentStepNumber, form, responses, - submission, - attachments, - respondentCopyRecipientData, - }).mapErr((error) => { - logger.error({ - message: 'Send multirespondent respondent copy email error', - meta: logMeta, - error, - }) // return nothing; since successful submission does not depend on respondent copy emails being sent + isRejected: false, + submissionId, + growthbook, }) - } + ? pdfResult + : okAsync(undefined) + + sendMrfRespondentCopyEmails({ + form, + responses, + submission, + attachments, + formFields: form.form_fields, + currentStepActiveFields: form.workflow[currentStepNumber]?.edit ?? [], + pdfResult: sendMrfRespondentCopyEmailsPdfResult, + }).mapErr((error) => { + logger.error({ + message: 'Send multirespondent respondent copy email error', + meta: logMeta, + error, + }) + }) const webhookUrl = form.webhook?.url if (webhookUrl) { @@ -793,6 +1057,7 @@ export const performMultiRespondentPostSubmissionCreateActions = ({ responses, submissionId, attachments, + pdfResult: sendMrfOutcomeEmailsPdfResult, }) }) .mapErr((error) => { @@ -960,6 +1225,7 @@ export const performMultiRespondentPostSubmissionUpdateActions = ({ encryptedPayload, logMeta, attachments, + growthbook, }: { submission: IMultirespondentSubmissionSchema submissionId: string @@ -968,6 +1234,7 @@ export const performMultiRespondentPostSubmissionUpdateActions = ({ encryptedPayload: MultirespondentSubmissionDto logMeta: CustomLoggerParams['meta'] attachments?: IAttachmentInfo[] + growthbook?: GrowthBook }): ResultAsync< boolean, | InvalidWorkflowTypeError @@ -985,29 +1252,6 @@ export const performMultiRespondentPostSubmissionUpdateActions = ({ submissionId, } - // Find respondent copy recipient data - const respondentCopyRecipientData = extractRespondentCopyEmails({ - responses: responses, - formFields: snapshottedFormDef.form_fields, - activeFields: snapshottedFormDef.workflow[currentStepNumber].edit, - }) - - if (respondentCopyRecipientData && respondentCopyRecipientData.length > 0) { - sendMrfRespondentCopyEmails({ - form: snapshottedFormDef, - responses, - submission, - attachments, - respondentCopyRecipientData, - }).mapErr((error) => { - logger.error({ - message: 'Send multirespondent respondent copy email error', - meta: logMeta, - error, - }) // return nothing; since successful submission does not depend on this respondent copy emails sent - }) - } - const isStepRejectedResult = checkIsStepRejected({ zeroIndexedStepNumber: currentStepNumber, form: snapshottedFormDef, @@ -1054,6 +1298,57 @@ export const performMultiRespondentPostSubmissionUpdateActions = ({ }) } + const pdfResult = generatePdfAttachmentIfRequired({ + submission, + form: snapshottedFormDef, + responses, + currentStepNumber, + isRejected: isStepRejected, + submissionId, + currentStepActiveFields: + snapshottedFormDef.workflow[currentStepNumber]?.edit ?? [], + growthbook, + }) + + const sendMrfRespondentCopyEmailsPdfResult = + checkIfRespondentFormSummaryIsRequired({ + responses, + formFields: snapshottedFormDef.form_fields, + currentStepActiveFields: + snapshottedFormDef.workflow[currentStepNumber]?.edit ?? [], + }) + ? pdfResult + : okAsync(undefined) + + const sendMrfOutcomeEmailsPdfResult = + checkIsWorkflowCompletionEmailPdfRequired({ + currentStepNumber, + form: snapshottedFormDef, + responses, + isRejected: isStepRejected, + submissionId, + growthbook, + }) + ? pdfResult + : okAsync(undefined) + + sendMrfRespondentCopyEmails({ + form: snapshottedFormDef, + responses, + submission, + attachments, + formFields: snapshottedFormDef.form_fields, + currentStepActiveFields: + snapshottedFormDef.workflow[currentStepNumber]?.edit ?? [], + pdfResult: sendMrfRespondentCopyEmailsPdfResult, + }).mapErr((error) => { + logger.error({ + message: 'Send multirespondent respondent copy email error', + meta: logMeta, + error, + }) + }) + if (isStepRejected) { return sendMrfOutcomeEmails({ currentStepNumber, @@ -1063,6 +1358,7 @@ export const performMultiRespondentPostSubmissionUpdateActions = ({ isApproval: true, isRejected: true, attachments: attachments, + pdfResult: sendMrfOutcomeEmailsPdfResult, }).mapErr((error) => { logger.error({ message: 'Send mrf outcome email error', @@ -1079,6 +1375,7 @@ export const performMultiRespondentPostSubmissionUpdateActions = ({ submissionId, isApproval: checkIsFormApproval(snapshottedFormDef), attachments: attachments, + pdfResult: sendMrfOutcomeEmailsPdfResult, }) .mapErr((error) => { logger.error({ diff --git a/src/app/modules/submission/multirespondent-submission/multirespondent-submission.utils.ts b/src/app/modules/submission/multirespondent-submission/multirespondent-submission.utils.ts index e7447d0fa2..8759ae24fe 100644 --- a/src/app/modules/submission/multirespondent-submission/multirespondent-submission.utils.ts +++ b/src/app/modules/submission/multirespondent-submission/multirespondent-submission.utils.ts @@ -1,7 +1,7 @@ import moment from 'moment' import { err, ok, Result } from 'neverthrow' -import { CLIENT_CHECKBOX_OTHERS_INPUT_VALUE } from '../../../../../shared/constants/form' +import { CLIENT_CHECKBOX_OTHERS_INPUT_VALUE } from '../../../../../shared/constants' import { BasicField, FieldResponsesV3, @@ -28,7 +28,6 @@ import { AutoReplyMailData } from '../../../services/mail/mail.types' import { convertToSignaturePngDataUri } from '../../../utils/convert-vector-array-to-png' import { validateFieldV3 } from '../../../utils/field-validation' import { FieldIdSet } from '../../../utils/logic-adaptor' -import { QuestionAnswer } from '../../../views/templates/MrfWorkflowCompletionEmail' import { startsWithSPCPFieldTitle } from '../../spcp/spcp.util' import { InvalidWorkflowTypeError, @@ -228,25 +227,87 @@ export const validateMrfFieldResponses = ({ } /** - * Extracts question-answer pairs from a form field and a response. + * Extracts email data to be sent respondent copies to from a multirespondent submission. + * @param responses - The multirespondent submission's field responses. + * @param formFields - The schema of the form fields present in the form. + * @param currentStepActiveFields - The active field Ids assigned in the current step. + * @returns AutoReplyMailData[] - list of email data to be sent respondent copies to. + */ +export const extractRespondentCopyEmailDatas = ({ + responses, + formFields, + currentStepActiveFields, +}: { + responses: FieldResponsesV3 + formFields: FormFieldSchema[] | FormFieldDto[] + currentStepActiveFields: string[] +}): AutoReplyMailData[] => { + return currentStepActiveFields.flatMap((fieldId) => { + const fieldIdString = fieldId.toString() + const field = formFields.find((f) => f._id.toString() === fieldIdString) + const response = responses[fieldIdString] + + if ( + // checks if field is an email field + field && + field.fieldType === BasicField.Email && + field.autoReplyOptions?.hasAutoReply && + response && + // checks if response has an answer (email) + typeof response.answer === 'object' && + 'value' in response.answer && + typeof response.answer.value === 'string' + ) { + const { + autoReplyMessage, + autoReplySubject, + autoReplySender, + includeFormSummary, + } = field.autoReplyOptions + return [ + { + email: response.answer.value, + subject: autoReplySubject, + sender: autoReplySender, + body: autoReplyMessage, + includeFormSummary, + }, + ] + } + return [] // no respondent copy emails found + }) +} + +export type QuestionAnswerPair = { + question: string + answer: string + signatureDataPngDataUri?: string + fieldType: BasicField +} + +/** + * Given a single form field and its response, extracts question-answer pairs. * Used for email body/pdf outputs and individualResponsePage displays * Returns an array since some fields (e.g. table, children) will have - * multiple questionAnswerPairs per response - * @param formField - form field schema - * @param response - response of the form field - * @returns An array of QuestionAnswer objects + * multiple question-answer pairs per response + * @param formField - Single form field schema. Does not include Ndi responses, @see getQuestionAnswerPairsForMultipleFields on how to include ndi responses. + * @param response - Response for the given form field + * @returns An array of QuestionAnswer objects representing the extracted question-answer pairs for the given form field. */ -export const getQuestionTitleAnswerStringSingleField = ({ +const getQuestionAnswerPairsForOneField = ({ formField, response, + includeSignatureDataPngDataUri, }: { formField: FormFieldSchema | FormFieldDto response: FieldResponseV3 -}): QuestionAnswer[] => { + includeSignatureDataPngDataUri: boolean +}): QuestionAnswerPair[] => { let questionTitle = formField.title let answer = '' let answerArray: string[] = [] - const questionAnswerPair: QuestionAnswer[] = [] + const questionAnswerPairs: QuestionAnswerPair[] = [] + switch (response.fieldType) { case BasicField.Attachment: questionTitle = `[Attachment] ${questionTitle}` @@ -308,12 +369,13 @@ export const getQuestionTitleAnswerStringSingleField = ({ const question = `[Table] ${formField.title} (${delimitedColumnTitles})` const answer = delimitedColumnAnswers - questionAnswerPair.push({ + questionAnswerPairs.push({ question, answer, + fieldType: response.fieldType, }) } - return questionAnswerPair + return questionAnswerPairs case BasicField.Radio: answer = 'value' in response.answer @@ -332,71 +394,85 @@ export const getQuestionTitleAnswerStringSingleField = ({ answer = selectedAnswers.toString() break - case BasicField.Signature: - questionTitle = `[signature] ${questionTitle}` - answer = SIGNATURE_CAPTURED_STRING - break + case BasicField.Signature: { + const signatureQuestionAnswer = { + question: `[signature] ${questionTitle}`, + answer: SIGNATURE_CAPTURED_STRING, + signatureDataPngDataUri: includeSignatureDataPngDataUri + ? convertToSignaturePngDataUri(response.answer.value) + : undefined, + fieldType: response.fieldType, + } + return [signatureQuestionAnswer] + } case BasicField.Children: if (!response.answer.childFields || !response.answer.child) { break } for (const [index, child] of response.answer.child.entries()) { - questionAnswerPair.push({ + questionAnswerPairs.push({ question: `Child ${index + 1}: ${response.answer.childFields.toString()}`, answer: child ? child.toString() : response.answer.childFields.map(() => '').toString(), + fieldType: response.fieldType, }) } - return questionAnswerPair + return questionAnswerPairs default: answer = response.answer } - questionAnswerPair.push({ + questionAnswerPairs.push({ question: questionTitle, answer, + fieldType: response.fieldType, }) - return questionAnswerPair + return questionAnswerPairs } -/* - * Extracts question-answer pairs from form fields and responses. - * @param formFields - The form fields schema - * @param responses - The responses to the form fields - * @returns An array of QuestionAnswer objects +/** + * Given multiple form fields and their responses, extracts question-answer pairs. + * @param formFields - List of form fields schemas + * @param responses - Corresponding list of responses to the given form fields + * @returns An array of QuestionAnswer pairs representing the extracted question-answer pairs for the all the given form fields. */ -export const getQuestionTitleAnswerString = ({ +export const getQuestionAnswerPairsForMultipleFields = ({ formFields, responses, + includeSignatureDataPngDataUri = false, }: { formFields: FormFieldSchema[] | FormFieldDto[] responses: FieldResponsesV3 -}): QuestionAnswer[] => { - let questionAnswerPairs: QuestionAnswer[] = [] + includeSignatureDataPngDataUri?: boolean +}): QuestionAnswerPair[] => { + const questionAnswerPairs: QuestionAnswerPair[] = [] if (!formFields || !responses) { return [] } - for (const formField of formFields) { - const questionTitle = formField.title - const response = responses[formField._id] + for (const currentFormField of formFields) { + const questionTitle = currentFormField.title + const response = responses[currentFormField._id] if (!response || !questionTitle) continue - const questionAnswerPair = getQuestionTitleAnswerStringSingleField({ - formField, - response, - }) + const questionAnswerPairsForCurrentFormField = + getQuestionAnswerPairsForOneField({ + formField: currentFormField, + response, + includeSignatureDataPngDataUri, + }) - questionAnswerPairs = [...questionAnswerPairs, ...questionAnswerPair] + questionAnswerPairs.push(...questionAnswerPairsForCurrentFormField) } // Add Ndi responses if they exist for (const key in responses) { if (startsWithSPCPFieldTitle(key)) { - const value = responses[key] as NdiResponseV3 + const { answer, fieldType } = responses[key] as NdiResponseV3 questionAnswerPairs.push({ question: key, - answer: value.answer, + answer, + fieldType, }) } } @@ -409,97 +485,27 @@ export const getQuestionTitleAnswerString = ({ * @param responses - The mrf responses to the form fields * @returns list of EmailRespondentConfirmationField used for email & pdf generation */ -export const getPdfResponsesData = ({ +export const getResponsesDataFromMrfResponses = ({ formFields, responses, }: { formFields: FormFieldSchema[] | FormFieldDto[] responses: FieldResponsesV3 }): EmailRespondentConfirmationField[] => { - let pdfResponseData: EmailRespondentConfirmationField[] = [] if (!formFields || !responses) return [] - for (const formField of formFields) { - const questionTitle = formField.title - const response = responses[formField._id] - - if (!response || !questionTitle) continue - const emailFieldForPdf: EmailRespondentConfirmationField[] = - getQuestionTitleAnswerStringSingleField({ - formField, - response, - }).map((questionAnswerPair) => { - return { - question: questionAnswerPair.question, - fieldType: response.fieldType, - answerTemplate: [questionAnswerPair.answer], - ...(response.fieldType === BasicField.Signature && { - answer: convertToSignaturePngDataUri(response.answer.value), - }), - } - }) - - pdfResponseData = [...pdfResponseData, ...emailFieldForPdf] - } - - // Add Ndi responses if they exist - for (const key in responses) { - if (startsWithSPCPFieldTitle(key)) { - const value = responses[key] as NdiResponseV3 - pdfResponseData.push({ - question: key, - answerTemplate: [value.answer], - fieldType: value.fieldType, - }) - } - } - return pdfResponseData -} - -/** - * Extracts email data to be sent respondent copies to from a multirespondent submission. - * Email inputs from email confirmation-enabled email fields that are assigned in the current step are extracted - * @param responses - The multirespondent submission's field responses - * @param formFields - The form fields schema - * @param activeFields - The active field Ids assigned in the current step - * @returns AutoReplyMailData[] - list of email data to be sent respondent copies to - */ -export const extractRespondentCopyEmails = ({ - responses, - formFields, - activeFields, -}: { - responses: FieldResponsesV3 - formFields: FormFieldSchema[] | FormFieldDto[] - activeFields: string[] -}): AutoReplyMailData[] => { - return activeFields.flatMap((fieldId) => { - const fieldIdString = fieldId.toString() - const field = formFields.find((f) => f._id.toString() === fieldIdString) - const response = responses[fieldIdString] + const questionAnswerPairs = getQuestionAnswerPairsForMultipleFields({ + formFields, + responses, + includeSignatureDataPngDataUri: true, + }) - if ( - // checks if field is an email field - field && - field.fieldType === BasicField.Email && - field.autoReplyOptions?.hasAutoReply && - response && - // checks if response has an answer (email) - typeof response.answer === 'object' && - 'value' in response.answer && - typeof response.answer.value === 'string' - ) { - const options = field.autoReplyOptions - return [ - { - email: response.answer.value, - subject: options.autoReplySubject, - sender: options.autoReplySender, - body: options.autoReplyMessage, - includeFormSummary: options.includeFormSummary, - }, - ] + return questionAnswerPairs.map((questionAnswerPair) => { + return { + question: questionAnswerPair.question, + answerTemplate: [questionAnswerPair.answer], + answer: questionAnswerPair.signatureDataPngDataUri, + fieldType: questionAnswerPair.fieldType, } - return [] // no respondent copy emails found }) } From da9bcc11d9adce4ed86ef77e35237bf4cc1db64b Mon Sep 17 00:00:00 2001 From: Kevin Foong <55353265+kevin9foong@users.noreply.github.com> Date: Fri, 5 Dec 2025 21:10:56 +0800 Subject: [PATCH 3/3] feat: add tc for admin pdf copy for mrf --- ...multirespondent-submission.service.spec.ts | 1240 ++++++++++++++++- .../multirespondent-submission.utils.spec.ts | 215 ++- 2 files changed, 1435 insertions(+), 20 deletions(-) diff --git a/src/app/modules/submission/multirespondent-submission/__tests__/multirespondent-submission.service.spec.ts b/src/app/modules/submission/multirespondent-submission/__tests__/multirespondent-submission.service.spec.ts index b4a5482bed..efe4ecd082 100644 --- a/src/app/modules/submission/multirespondent-submission/__tests__/multirespondent-submission.service.spec.ts +++ b/src/app/modules/submission/multirespondent-submission/__tests__/multirespondent-submission.service.spec.ts @@ -1,6 +1,6 @@ import dbHandler from '__tests__/unit/backend/helpers/jest-db' import { ObjectId } from 'bson' -import { okAsync } from 'neverthrow' +import { errAsync, okAsync } from 'neverthrow' import { BasicField, FieldResponsesV3, @@ -10,7 +10,9 @@ import { WorkflowType, } from 'shared/types' +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 { IMultirespondentSubmissionSchema, IPopulatedMultirespondentForm, @@ -21,21 +23,42 @@ import { MrfReminderInvalidWorkflowStepError, MrfReminderRecipientEmailsEmptyError, } from '../../submission.errors' +import * as MultirespondentSubmissionService from '../multirespondent-submission.service' import { getPendingStepRecipientEmailsFromSubmittedStepsMeta, performMultiRespondentPostSubmissionCreateActions, performMultiRespondentPostSubmissionUpdateActions, sendNextStepReminderEmail, } from '../multirespondent-submission.service' -import * as MultirespondentSubmissionService from '../multirespondent-submission.service' jest.mock('src/app/modules/datadog/datadog.utils') +jest.mock('src/app/services/mail/mail.utils') + +const MockMailUtils = jest.mocked(MailUtils) +const MOCK_PDF_ATTACHMENT_BUFFER = Buffer.from('mock pdf buffer') +const EXPECTED_MOCK_PDF_ATTACHMENT = { + filename: 'response.pdf', + content: MOCK_PDF_ATTACHMENT_BUFFER, +} +const MOCK_SUBMISSION_ATTACHMENTS = [ + { + filename: 'attachment_1.pdf', + content: Buffer.from('mock pdf buffer'), + fieldId: new ObjectId().toHexString(), + }, +] describe('multirespondent-submission.service', () => { beforeAll(async () => { await dbHandler.connect() }) + beforeEach(() => { + MockMailUtils.generateAutoreplyPdf.mockReturnValue( + okAsync(Buffer.from('mock pdf buffer')), + ) + }) + afterEach(async () => { jest.clearAllMocks() await dbHandler.clearDatabase() @@ -48,6 +71,1081 @@ describe('multirespondent-submission.service', () => { const mockFormId = new ObjectId().toHexString() const mockSubmissionId = new ObjectId().toHexString() + describe('pdf attachment', () => { + describe('pdf attachment is not generated when not needed', () => { + describe('first step', () => { + it('should not generate pdf when there is no active form summary included email field and workflow is incomplete', async () => { + // Arrange + const emailFieldWithoutFormSummaryStep1 = { + _id: new ObjectId().toHexString(), + fieldType: BasicField.Email, + title: 'Step 1 Email Field', + autoReplyOptions: { + hasAutoReply: true, + includeFormSummary: false, + autoReplySubject: 'Test Subject', + autoReplyMessage: 'Test Message', + autoReplySender: 'Test Sender', + }, + } + const emailFieldWithFormSummaryStep2 = { + _id: new ObjectId().toHexString(), + fieldType: BasicField.Email, + title: 'Step 2 Email Field', + autoReplyOptions: { + hasAutoReply: true, + includeFormSummary: true, + autoReplySubject: 'Test Subject', + autoReplyMessage: 'Test Message', + autoReplySender: 'Test Sender', + }, + } + + const workflow = [ + { + _id: new ObjectId().toHexString(), + workflow_type: WorkflowType.Static, + emails: [], + edit: [emailFieldWithoutFormSummaryStep1._id], + }, + { + _id: new ObjectId().toHexString(), + workflow_type: WorkflowType.Static, + emails: ['step2_respondent_email@example.com'], + edit: [emailFieldWithFormSummaryStep2._id], + }, + ] + + // Act + await performMultiRespondentPostSubmissionCreateActions({ + submission: { + _id: mockSubmissionId, + } as unknown as IMultirespondentSubmissionSchema, + submissionId: mockSubmissionId, + form: { + _id: mockFormId, + title: 'Test Form', + form_fields: [ + emailFieldWithoutFormSummaryStep1, + emailFieldWithFormSummaryStep2, + ], + stepsToNotify: [workflow[0]._id, workflow[1]._id], + workflow, + admin: { + agency: { + fullName: 'Government Technology Agency', + }, + }, + } as unknown as IPopulatedMultirespondentForm, + encryptedPayload: { + encryptedContent: 'encryptedContent', + version: 1, + submissionPublicKey: 'submissionPublicKey', + encryptedSubmissionSecretKey: 'encryptedSubmissionSecretKey', + responses: { + [emailFieldWithoutFormSummaryStep1._id]: { + fieldType: BasicField.Email, + answer: { + value: 'expected1@example.com', + }, + }, + }, + } as MultirespondentSubmissionDto, + logMeta: {} as any, + attachments: MOCK_SUBMISSION_ATTACHMENTS, + }) + + // Assert + expect(MockMailUtils.generateAutoreplyPdf).not.toHaveBeenCalled() + }) + it('should not generate pdf when there is no active form summary included email field and workflow is complete but has no emails to notify for outcome', async () => { + // Arrange + const emailFieldWithoutFormSummaryStep1 = { + _id: new ObjectId().toHexString(), + fieldType: BasicField.Email, + title: 'Step 1 Email Field', + autoReplyOptions: { + hasAutoReply: true, + includeFormSummary: false, + autoReplySubject: 'Test Subject', + autoReplyMessage: 'Test Message', + autoReplySender: 'Test Sender', + }, + } + + const workflow = [ + { + _id: new ObjectId().toHexString(), + workflow_type: WorkflowType.Static, + emails: [], // step 1 has no emails to notify for outcome + edit: [emailFieldWithoutFormSummaryStep1._id], + }, + ] + + const step1Id = new ObjectId().toHexString() + + // Act + await performMultiRespondentPostSubmissionCreateActions({ + submission: { + _id: mockSubmissionId, + } as unknown as IMultirespondentSubmissionSchema, + submissionId: mockSubmissionId, + form: { + _id: mockFormId, + title: 'Test Form', + form_fields: [emailFieldWithoutFormSummaryStep1], + stepsToNotify: [step1Id], + workflow, + admin: { + agency: { + fullName: 'Government Technology Agency', + }, + }, + } as unknown as IPopulatedMultirespondentForm, + encryptedPayload: { + encryptedContent: 'encryptedContent', + version: 1, + submissionPublicKey: 'submissionPublicKey', + encryptedSubmissionSecretKey: 'encryptedSubmissionSecretKey', + responses: { + [emailFieldWithoutFormSummaryStep1._id]: { + fieldType: BasicField.Email, + answer: { + value: 'expected1@example.com', + }, + }, + }, + } as MultirespondentSubmissionDto, + logMeta: {} as any, + attachments: MOCK_SUBMISSION_ATTACHMENTS, + }) + + // Assert + expect(MockMailUtils.generateAutoreplyPdf).not.toHaveBeenCalled() + }) + }) + + describe('subsequent steps', () => { + it('should not generate pdf when there is no active form summary included email field and workflow is incomplete', async () => { + // Arrange + const emailFieldWithFormSummaryStep1 = { + _id: new ObjectId().toHexString(), + fieldType: BasicField.Email, + title: 'Step 1 Email Field', + autoReplyOptions: { + hasAutoReply: true, + includeFormSummary: true, + autoReplySubject: 'Test Subject', + autoReplyMessage: 'Test Message', + autoReplySender: 'Test Sender', + }, + } + const emailFieldWithoutFormSummaryStep2 = { + _id: new ObjectId().toHexString(), + fieldType: BasicField.Email, + title: 'Step 2 Email Field', + autoReplyOptions: { + hasAutoReply: true, + includeFormSummary: false, + autoReplySubject: 'Test Subject', + autoReplyMessage: 'Test Message', + autoReplySender: 'Test Sender', + }, + } + + const workflow = [ + { + _id: new ObjectId().toHexString(), + workflow_type: WorkflowType.Static, + emails: [], + edit: [emailFieldWithFormSummaryStep1._id], + }, + { + _id: new ObjectId().toHexString(), + workflow_type: WorkflowType.Static, + emails: ['step2_respondent_email@example.com'], + edit: [emailFieldWithoutFormSummaryStep2._id], + }, + { + _id: new ObjectId().toHexString(), + workflow_type: WorkflowType.Static, + emails: ['step3_respondent_email@example.com'], + edit: [emailFieldWithFormSummaryStep1._id], + }, + ] + + // Act + await performMultiRespondentPostSubmissionUpdateActions({ + submission: { + _id: mockSubmissionId, + } as unknown as IMultirespondentSubmissionSchema, + submissionId: mockSubmissionId, + snapshottedFormDef: { + _id: mockFormId, + title: 'Test Form', + form_fields: [ + emailFieldWithFormSummaryStep1, + emailFieldWithoutFormSummaryStep2, + ], + stepsToNotify: [workflow[1]._id], + workflow, + admin: { + agency: { + fullName: 'Government Technology Agency', + }, + }, + } as unknown as SnapshottedFormDef, + currentStepNumber: 1, // submitted step 2 + encryptedPayload: { + encryptedContent: 'encryptedContent', + version: 1, + submissionPublicKey: 'submissionPublicKey', + encryptedSubmissionSecretKey: 'encryptedSubmissionSecretKey', + responses: { + [emailFieldWithFormSummaryStep1._id]: { + fieldType: BasicField.Email, + answer: { + value: 'expected1@example.com', + }, + }, + [emailFieldWithoutFormSummaryStep2._id]: { + fieldType: BasicField.Email, + answer: { + value: 'expected2@example.com', + }, + }, + }, + } as MultirespondentSubmissionDto, + logMeta: {} as any, + attachments: MOCK_SUBMISSION_ATTACHMENTS, + }) + + // Assert + expect(MockMailUtils.generateAutoreplyPdf).not.toHaveBeenCalled() + }) + + it('should not generate pdf when there is no active form summary included email field and workflow is complete but has no emails to notify for outcome', async () => { + // Arrange + const emailFieldWithFormSummaryStep1 = { + _id: new ObjectId().toHexString(), + fieldType: BasicField.Email, + title: 'Step 1 Email Field', + autoReplyOptions: { + hasAutoReply: true, + includeFormSummary: true, + autoReplySubject: 'Test Subject', + autoReplyMessage: 'Test Message', + autoReplySender: 'Test Sender', + }, + } + const emailFieldWithoutFormSummaryStep2 = { + _id: new ObjectId().toHexString(), + fieldType: BasicField.Email, + title: 'Step 2 Email Field', + autoReplyOptions: { + hasAutoReply: true, + includeFormSummary: false, + autoReplySubject: 'Test Subject', + autoReplyMessage: 'Test Message', + autoReplySender: 'Test Sender', + }, + } + + const workflow = [ + { + _id: new ObjectId().toHexString(), + workflow_type: WorkflowType.Static, + emails: [], + edit: [emailFieldWithFormSummaryStep1._id], + }, + { + _id: new ObjectId().toHexString(), + workflow_type: WorkflowType.Static, + emails: ['step2_respondent_email@example.com'], + edit: [emailFieldWithoutFormSummaryStep2._id], + }, + ] + + // Act + await performMultiRespondentPostSubmissionUpdateActions({ + submission: { + _id: mockSubmissionId, + } as unknown as IMultirespondentSubmissionSchema, + submissionId: mockSubmissionId, + snapshottedFormDef: { + _id: mockFormId, + title: 'Test Form', + form_fields: [ + emailFieldWithFormSummaryStep1, + emailFieldWithoutFormSummaryStep2, + ], + stepsToNotify: [], + emails: [], + workflow, + admin: { + agency: { + fullName: 'Government Technology Agency', + }, + }, + } as unknown as SnapshottedFormDef, + currentStepNumber: 1, // submitted step 2 + encryptedPayload: { + encryptedContent: 'encryptedContent', + version: 1, + submissionPublicKey: 'submissionPublicKey', + encryptedSubmissionSecretKey: 'encryptedSubmissionSecretKey', + responses: { + [emailFieldWithFormSummaryStep1._id]: { + fieldType: BasicField.Email, + answer: { + value: 'expected1@example.com', + }, + }, + [emailFieldWithoutFormSummaryStep2._id]: { + fieldType: BasicField.Email, + answer: { + value: 'expected2@example.com', + }, + }, + }, + } as MultirespondentSubmissionDto, + logMeta: {} as any, + attachments: MOCK_SUBMISSION_ATTACHMENTS, + }) + + // Assert + expect(MockMailUtils.generateAutoreplyPdf).not.toHaveBeenCalled() + }) + }) + }) + }) + + describe('respondent copy emails are sent', () => { + describe('first step', () => { + it('sends respondent copy without pdf when email field auto reply enabled but form summary is not included', async () => { + // Arrange + const sendMrfRespondentCopyEmailSpy = jest.spyOn( + MailService, + 'sendMrfRespondentCopyEmail', + ) + + const emailFieldWithoutFormSummaryStep1 = { + _id: new ObjectId().toHexString(), + fieldType: BasicField.Email, + title: 'Step 1 Email Field', + autoReplyOptions: { + hasAutoReply: true, + includeFormSummary: false, + autoReplySubject: 'Test Subject', + autoReplyMessage: 'Test Message', + autoReplySender: 'Test Sender', + }, + } + const emailFieldWithFormSummaryStep2 = { + _id: new ObjectId().toHexString(), + fieldType: BasicField.Email, + title: 'Step 2 Email Field', + autoReplyOptions: { + hasAutoReply: true, + includeFormSummary: true, + autoReplySubject: 'Test Subject', + autoReplyMessage: 'Test Message', + autoReplySender: 'Test Sender', + }, + } + + const workflow = [ + { + _id: new ObjectId().toHexString(), + workflow_type: WorkflowType.Static, + emails: [], + edit: [emailFieldWithoutFormSummaryStep1._id], + }, + { + _id: new ObjectId().toHexString(), + workflow_type: WorkflowType.Static, + emails: ['step2_respondent_email@example.com'], + edit: [emailFieldWithFormSummaryStep2._id], + }, + ] + + // Act + await performMultiRespondentPostSubmissionCreateActions({ + submission: { + _id: mockSubmissionId, + } as unknown as IMultirespondentSubmissionSchema, + submissionId: mockSubmissionId, + form: { + _id: mockFormId, + title: 'Test Form', + form_fields: [ + emailFieldWithoutFormSummaryStep1, + emailFieldWithFormSummaryStep2, + ], + stepsToNotify: [workflow[0]._id, workflow[1]._id], + workflow, + admin: { + agency: { + fullName: 'Government Technology Agency', + }, + }, + } as unknown as IPopulatedMultirespondentForm, + encryptedPayload: { + encryptedContent: 'encryptedContent', + version: 1, + submissionPublicKey: 'submissionPublicKey', + encryptedSubmissionSecretKey: 'encryptedSubmissionSecretKey', + responses: { + [emailFieldWithoutFormSummaryStep1._id]: { + fieldType: BasicField.Email, + answer: { + value: 'expected1@example.com', + }, + }, + }, + } as MultirespondentSubmissionDto, + logMeta: {} as any, + attachments: MOCK_SUBMISSION_ATTACHMENTS, + }) + + // Assert + // that sent to correct destination emails + expect(sendMrfRespondentCopyEmailSpy).toHaveBeenCalledTimes(1) + expect( + sendMrfRespondentCopyEmailSpy.mock.calls[0][0].autoReplyMailData + .email, + ).toEqual('expected1@example.com') + // does not attach pdf and submission attachments since form summary is not included for active respondent copy email field + expect( + sendMrfRespondentCopyEmailSpy.mock.calls[0][0].attachments, + ).toEqual([]) + }) + it('sends respondent copy emails with pdf when email field auto reply enabled and form summary is included', async () => { + // Arrange + const sendMrfRespondentCopyEmailSpy = jest.spyOn( + MailService, + 'sendMrfRespondentCopyEmail', + ) + + const emailFieldWithFormSummaryStep1 = { + _id: new ObjectId().toHexString(), + fieldType: BasicField.Email, + title: 'Step 1 Email Field', + autoReplyOptions: { + hasAutoReply: true, + includeFormSummary: true, + autoReplySubject: 'Test Subject', + autoReplyMessage: 'Test Message', + autoReplySender: 'Test Sender', + }, + } + const emailFieldWithFormSummaryStep2 = { + _id: new ObjectId().toHexString(), + fieldType: BasicField.Email, + title: 'Step 2 Email Field', + autoReplyOptions: { + hasAutoReply: true, + includeFormSummary: true, + autoReplySubject: 'Test Subject', + autoReplyMessage: 'Test Message', + autoReplySender: 'Test Sender', + }, + } + + const workflow = [ + { + _id: new ObjectId().toHexString(), + workflow_type: WorkflowType.Static, + emails: [], + edit: [emailFieldWithFormSummaryStep1._id], + }, + { + _id: new ObjectId().toHexString(), + workflow_type: WorkflowType.Static, + emails: ['step2_respondent_email@example.com'], + edit: [emailFieldWithFormSummaryStep2._id], + }, + ] + + // Act + await performMultiRespondentPostSubmissionCreateActions({ + submission: { + _id: mockSubmissionId, + } as unknown as IMultirespondentSubmissionSchema, + submissionId: mockSubmissionId, + form: { + _id: mockFormId, + title: 'Test Form', + form_fields: [ + emailFieldWithFormSummaryStep1, + emailFieldWithFormSummaryStep2, + ], + stepsToNotify: [workflow[0]._id, workflow[1]._id], + workflow, + admin: { + agency: { + fullName: 'Government Technology Agency', + }, + }, + } as unknown as IPopulatedMultirespondentForm, + encryptedPayload: { + encryptedContent: 'encryptedContent', + version: 1, + submissionPublicKey: 'submissionPublicKey', + encryptedSubmissionSecretKey: 'encryptedSubmissionSecretKey', + responses: { + [emailFieldWithFormSummaryStep1._id]: { + fieldType: BasicField.Email, + answer: { + value: 'expected1@example.com', + }, + }, + }, + } as MultirespondentSubmissionDto, + logMeta: {} as any, + attachments: MOCK_SUBMISSION_ATTACHMENTS, + }) + + // Assert + // that sent to correct destination emails + expect(sendMrfRespondentCopyEmailSpy).toHaveBeenCalledTimes(1) + expect( + sendMrfRespondentCopyEmailSpy.mock.calls[0][0].autoReplyMailData + .email, + ).toEqual('expected1@example.com') + // attaches pdf and submission attachments + expect( + sendMrfRespondentCopyEmailSpy.mock.calls[0][0].attachments, + ).toEqual([ + ...MOCK_SUBMISSION_ATTACHMENTS, + EXPECTED_MOCK_PDF_ATTACHMENT, + ]) + }) + it('does not send respondent copy emails when email field auto reply is not enabled', async () => { + // Arrange + const sendMrfRespondentCopyEmailSpy = jest.spyOn( + MailService, + 'sendMrfRespondentCopyEmail', + ) + + const emailFieldWithoutAutoReplyStep1 = { + _id: new ObjectId().toHexString(), + fieldType: BasicField.Email, + title: 'Step 1 Email Field', + autoReplyOptions: { + hasAutoReply: false, + includeFormSummary: false, + autoReplySubject: 'Test Subject', + autoReplyMessage: 'Test Message', + autoReplySender: 'Test Sender', + }, + } + const emailFieldWithFormSummaryStep2 = { + _id: new ObjectId().toHexString(), + fieldType: BasicField.Email, + title: 'Step 2 Email Field', + autoReplyOptions: { + hasAutoReply: true, + includeFormSummary: true, + autoReplySubject: 'Test Subject', + autoReplyMessage: 'Test Message', + autoReplySender: 'Test Sender', + }, + } + + const workflow = [ + { + _id: new ObjectId().toHexString(), + workflow_type: WorkflowType.Static, + emails: [], + edit: [emailFieldWithoutAutoReplyStep1._id], + }, + { + _id: new ObjectId().toHexString(), + workflow_type: WorkflowType.Static, + emails: ['step2_respondent_email@example.com'], + edit: [emailFieldWithFormSummaryStep2._id], + }, + ] + + // Act + await performMultiRespondentPostSubmissionCreateActions({ + submission: { + _id: mockSubmissionId, + } as unknown as IMultirespondentSubmissionSchema, + submissionId: mockSubmissionId, + form: { + _id: mockFormId, + title: 'Test Form', + form_fields: [ + emailFieldWithoutAutoReplyStep1, + emailFieldWithFormSummaryStep2, + ], + stepsToNotify: [workflow[0]._id, workflow[1]._id], + workflow, + admin: { + agency: { + fullName: 'Government Technology Agency', + }, + }, + } as unknown as IPopulatedMultirespondentForm, + encryptedPayload: { + encryptedContent: 'encryptedContent', + version: 1, + submissionPublicKey: 'submissionPublicKey', + encryptedSubmissionSecretKey: 'encryptedSubmissionSecretKey', + responses: { + [emailFieldWithoutAutoReplyStep1._id]: { + fieldType: BasicField.Email, + answer: { + value: 'expected1@example.com', + }, + }, + }, + } as MultirespondentSubmissionDto, + logMeta: {} as any, + attachments: MOCK_SUBMISSION_ATTACHMENTS, + }) + + // Assert + expect(sendMrfRespondentCopyEmailSpy).not.toHaveBeenCalled() + }) + + it('sends respondent copy despite pdf generation error', async () => { + // Arrange + MockMailUtils.generateAutoreplyPdf.mockReturnValue( + errAsync(new AutoreplyPdfGenerationError()), + ) + const sendMrfRespondentCopyEmailSpy = jest.spyOn( + MailService, + 'sendMrfRespondentCopyEmail', + ) + const emailFieldWithFormSummaryStep1 = { + _id: new ObjectId().toHexString(), + fieldType: BasicField.Email, + title: 'Step 1 Email Field', + autoReplyOptions: { + hasAutoReply: true, + includeFormSummary: true, + autoReplySubject: 'Test Subject', + autoReplyMessage: 'Test Message', + autoReplySender: 'Test Sender', + }, + } + + const workflow = [ + { + _id: new ObjectId().toHexString(), + workflow_type: WorkflowType.Static, + emails: [], + edit: [emailFieldWithFormSummaryStep1._id], + }, + ] + + // Act + await performMultiRespondentPostSubmissionCreateActions({ + submission: { + _id: mockSubmissionId, + } as unknown as IMultirespondentSubmissionSchema, + submissionId: mockSubmissionId, + form: { + _id: mockFormId, + title: 'Test Form', + form_fields: [emailFieldWithFormSummaryStep1], + stepsToNotify: [workflow[0]._id], + workflow, + admin: { + agency: { + fullName: 'Government Technology Agency', + }, + }, + } as unknown as IPopulatedMultirespondentForm, + encryptedPayload: { + encryptedContent: 'encryptedContent', + version: 1, + submissionPublicKey: 'submissionPublicKey', + encryptedSubmissionSecretKey: 'encryptedSubmissionSecretKey', + responses: { + [emailFieldWithFormSummaryStep1._id]: { + fieldType: BasicField.Email, + answer: { + value: 'expected1@example.com', + }, + }, + }, + } as MultirespondentSubmissionDto, + logMeta: {} as any, + attachments: MOCK_SUBMISSION_ATTACHMENTS, + }) + + // Assert + // that sent to correct destination emails + expect(sendMrfRespondentCopyEmailSpy).toHaveBeenCalledTimes(1) + expect( + sendMrfRespondentCopyEmailSpy.mock.calls[0][0].autoReplyMailData + .email, + ).toEqual('expected1@example.com') + // still sends without pdf attachment + expect( + sendMrfRespondentCopyEmailSpy.mock.calls[0][0].attachments, + ).toEqual([...MOCK_SUBMISSION_ATTACHMENTS]) + }) + }) + + describe('subsequent steps', () => { + it('sends respondent copy without pdf when email field auto reply enabled but form summary is not included', async () => { + // Arrange + const sendMrfRespondentCopyEmailSpy = jest.spyOn( + MailService, + 'sendMrfRespondentCopyEmail', + ) + + const emailFieldWithFormSummaryStep1 = { + _id: new ObjectId().toHexString(), + fieldType: BasicField.Email, + title: 'Step 1 Email Field', + autoReplyOptions: { + hasAutoReply: true, + includeFormSummary: true, + autoReplySubject: 'Test Subject', + autoReplyMessage: 'Test Message', + autoReplySender: 'Test Sender', + }, + } + const emailFieldWithoutFormSummaryStep2 = { + _id: new ObjectId().toHexString(), + fieldType: BasicField.Email, + title: 'Step 2 Email Field', + autoReplyOptions: { + hasAutoReply: true, + includeFormSummary: false, + autoReplySubject: 'Test Subject', + autoReplyMessage: 'Test Message', + autoReplySender: 'Test Sender', + }, + } + + const workflow = [ + { + _id: new ObjectId().toHexString(), + workflow_type: WorkflowType.Static, + emails: [], + edit: [emailFieldWithFormSummaryStep1._id], + }, + { + _id: new ObjectId().toHexString(), + workflow_type: WorkflowType.Static, + emails: ['step2_respondent_email@example.com'], + edit: [emailFieldWithoutFormSummaryStep2._id], + }, + ] + + // Act + await performMultiRespondentPostSubmissionUpdateActions({ + submission: { + _id: mockSubmissionId, + } as unknown as IMultirespondentSubmissionSchema, + submissionId: mockSubmissionId, + snapshottedFormDef: { + _id: mockFormId, + title: 'Test Form', + form_fields: [ + emailFieldWithFormSummaryStep1, + emailFieldWithoutFormSummaryStep2, + ], + stepsToNotify: [workflow[0]._id, workflow[1]._id], + workflow, + admin: { + agency: { + fullName: 'Government Technology Agency', + }, + }, + } as unknown as SnapshottedFormDef, + currentStepNumber: 1, // step 2 + encryptedPayload: { + encryptedContent: 'encryptedContent', + version: 1, + submissionPublicKey: 'submissionPublicKey', + encryptedSubmissionSecretKey: 'encryptedSubmissionSecretKey', + responses: { + [emailFieldWithFormSummaryStep1._id]: { + fieldType: BasicField.Email, + answer: { + value: 'expected1@example.com', + }, + }, + [emailFieldWithoutFormSummaryStep2._id]: { + fieldType: BasicField.Email, + answer: { + value: 'expected2@example.com', + }, + }, + }, + } as MultirespondentSubmissionDto, + logMeta: {} as any, + attachments: MOCK_SUBMISSION_ATTACHMENTS, + }) + + // Assert + // that sent to correct destination emails + expect(sendMrfRespondentCopyEmailSpy).toHaveBeenCalledTimes(1) + expect( + sendMrfRespondentCopyEmailSpy.mock.calls[0][0].autoReplyMailData + .email, + ).toEqual('expected2@example.com') + // does not attach pdf and submission attachments since form summary is not included for active respondent copy email field + expect( + sendMrfRespondentCopyEmailSpy.mock.calls[0][0].attachments, + ).toEqual([]) + }) + it('sends respondent copy emails with pdf when email field auto reply enabled and form summary is included', async () => { + // Arrange + const sendMrfRespondentCopyEmailSpy = jest.spyOn( + MailService, + 'sendMrfRespondentCopyEmail', + ) + + const emailFieldWithFormSummaryStep1 = { + _id: new ObjectId().toHexString(), + fieldType: BasicField.Email, + title: 'Step 1 Email Field', + autoReplyOptions: { + hasAutoReply: true, + includeFormSummary: true, + autoReplySubject: 'Test Subject', + autoReplyMessage: 'Test Message', + autoReplySender: 'Test Sender', + }, + } + const emailFieldWithFormSummaryStep2 = { + _id: new ObjectId().toHexString(), + fieldType: BasicField.Email, + title: 'Step 2 Email Field', + autoReplyOptions: { + hasAutoReply: true, + includeFormSummary: true, + autoReplySubject: 'Test Subject', + autoReplyMessage: 'Test Message', + autoReplySender: 'Test Sender', + }, + } + + const emailField2WithFormSummaryStep2 = { + _id: new ObjectId().toHexString(), + fieldType: BasicField.Email, + title: 'Step 2 Email Field', + autoReplyOptions: { + hasAutoReply: true, + includeFormSummary: true, + autoReplySubject: 'Test Subject', + autoReplyMessage: 'Test Message', + autoReplySender: 'Test Sender', + }, + } + + const workflow = [ + { + _id: new ObjectId().toHexString(), + workflow_type: WorkflowType.Static, + emails: [], + edit: [emailFieldWithFormSummaryStep1._id], + }, + { + _id: new ObjectId().toHexString(), + workflow_type: WorkflowType.Static, + emails: ['step2_respondent_email@example.com'], + edit: [ + emailField2WithFormSummaryStep2._id, + emailFieldWithFormSummaryStep2._id, + ], + }, + ] + + // Act + await performMultiRespondentPostSubmissionUpdateActions({ + submission: { + _id: mockSubmissionId, + } as unknown as IMultirespondentSubmissionSchema, + submissionId: mockSubmissionId, + snapshottedFormDef: { + _id: mockFormId, + title: 'Test Form', + form_fields: [ + emailFieldWithFormSummaryStep1, + emailField2WithFormSummaryStep2, + emailFieldWithFormSummaryStep2, + ], + stepsToNotify: [workflow[0]._id, workflow[1]._id], + workflow, + admin: { + agency: { + fullName: 'Government Technology Agency', + }, + }, + } as unknown as SnapshottedFormDef, + currentStepNumber: 1, // step 2 + encryptedPayload: { + encryptedContent: 'encryptedContent', + version: 1, + submissionPublicKey: 'submissionPublicKey', + encryptedSubmissionSecretKey: 'encryptedSubmissionSecretKey', + responses: { + [emailFieldWithFormSummaryStep1._id]: { + fieldType: BasicField.Email, + answer: { + value: 'expected1@example.com', + }, + }, + [emailField2WithFormSummaryStep2._id]: { + fieldType: BasicField.Email, + answer: { + value: 'expected2@example.com', + }, + }, + [emailFieldWithFormSummaryStep2._id]: { + fieldType: BasicField.Email, + answer: { + value: 'expected3@example.com', + }, + }, + }, + } as MultirespondentSubmissionDto, + logMeta: {} as any, + attachments: MOCK_SUBMISSION_ATTACHMENTS, + }) + + // Assert + // that sent to correct destination emails + expect(sendMrfRespondentCopyEmailSpy).toHaveBeenCalledTimes(2) + expect( + sendMrfRespondentCopyEmailSpy.mock.calls.map( + (call) => call[0].autoReplyMailData.email, + ), + ).toContainValues(['expected2@example.com', 'expected3@example.com']) + // attaches pdf and submission attachments + expect( + sendMrfRespondentCopyEmailSpy.mock.calls[0][0].attachments, + ).toEqual([ + ...MOCK_SUBMISSION_ATTACHMENTS, + EXPECTED_MOCK_PDF_ATTACHMENT, + ]) + expect( + sendMrfRespondentCopyEmailSpy.mock.calls[1][0].attachments, + ).toEqual([ + ...MOCK_SUBMISSION_ATTACHMENTS, + EXPECTED_MOCK_PDF_ATTACHMENT, + ]) + }) + it('does not send respondent copy emails when email field auto reply is not enabled', async () => { + // Arrange + const sendMrfRespondentCopyEmailSpy = jest.spyOn( + MailService, + 'sendMrfRespondentCopyEmail', + ) + + const emailFieldWithFormSummaryStep1 = { + _id: new ObjectId().toHexString(), + fieldType: BasicField.Email, + title: 'Step 1 Email Field', + autoReplyOptions: { + hasAutoReply: true, + includeFormSummary: true, + autoReplySubject: 'Test Subject', + autoReplyMessage: 'Test Message', + autoReplySender: 'Test Sender', + }, + } + const emailFieldNoAutoReplyStep2 = { + _id: new ObjectId().toHexString(), + fieldType: BasicField.Email, + title: 'Step 2 Email Field', + } + + const emailFieldWithFormSummaryStep2 = { + _id: new ObjectId().toHexString(), + fieldType: BasicField.Email, + title: 'Step 2 Email Field', + autoReplyOptions: { + hasAutoReply: true, + includeFormSummary: true, + autoReplySubject: 'Test Subject', + autoReplyMessage: 'Test Message', + autoReplySender: 'Test Sender', + }, + required: false, + } + + const workflow = [ + { + _id: new ObjectId().toHexString(), + workflow_type: WorkflowType.Static, + emails: [], + edit: [emailFieldWithFormSummaryStep1._id], + }, + { + _id: new ObjectId().toHexString(), + workflow_type: WorkflowType.Static, + emails: ['step2_respondent_email@example.com'], + edit: [ + emailFieldWithFormSummaryStep2._id, + emailFieldNoAutoReplyStep2._id, + ], + }, + ] + + // Act + await performMultiRespondentPostSubmissionUpdateActions({ + submission: { + _id: mockSubmissionId, + } as unknown as IMultirespondentSubmissionSchema, + submissionId: mockSubmissionId, + snapshottedFormDef: { + _id: mockFormId, + title: 'Test Form', + form_fields: [ + emailFieldWithFormSummaryStep1, + emailFieldNoAutoReplyStep2, + emailFieldWithFormSummaryStep2, + ], + stepsToNotify: [workflow[0]._id, workflow[1]._id], + workflow, + admin: { + agency: { + fullName: 'Government Technology Agency', + }, + }, + } as unknown as SnapshottedFormDef, + currentStepNumber: 1, // step 2 + encryptedPayload: { + encryptedContent: 'encryptedContent', + version: 1, + submissionPublicKey: 'submissionPublicKey', + encryptedSubmissionSecretKey: 'encryptedSubmissionSecretKey', + responses: { + [emailFieldWithFormSummaryStep1._id]: { + fieldType: BasicField.Email, + answer: { + value: 'expected1@example.com', + }, + }, + [emailFieldNoAutoReplyStep2._id]: { + fieldType: BasicField.Email, + answer: { + value: 'expected2@example.com', + }, + }, + }, + } as MultirespondentSubmissionDto, + logMeta: {} as any, + attachments: MOCK_SUBMISSION_ATTACHMENTS, + }) + + // Assert + expect(sendMrfRespondentCopyEmailSpy).not.toHaveBeenCalled() + }) + }) + }) + describe('mrf approval email notification when approval step exists', () => { it('workflow continues and does not send approved outcome email when mrf is approved for mid step of multiple step MRF', async () => { // Arrange @@ -303,6 +1401,7 @@ describe('multirespondent-submission.service', () => { ] as FormFieldDto[], } as SnapshottedFormDef, currentStepNumber: currentWorkflowStep, + attachments: MOCK_SUBMISSION_ATTACHMENTS, encryptedPayload: { encryptedContent: 'encryptedContent', version: 1, @@ -318,6 +1417,13 @@ describe('multirespondent-submission.service', () => { expect(sendMrfApprovalEmailSpy).toHaveBeenCalledTimes(1) expect(sendMrfWorkflowCompletionEmailSpy).not.toHaveBeenCalled() expect(sendMRFWorkflowStepEmailSpy).not.toHaveBeenCalled() + // pdf generation is invoked + expect(MockMailUtils.generateAutoreplyPdf).toHaveBeenCalledTimes(1) + // pdf attachment is included and submission attachments are correct + expect(sendMrfApprovalEmailSpy.mock.calls[0][0].attachments).toEqual([ + ...MOCK_SUBMISSION_ATTACHMENTS, + EXPECTED_MOCK_PDF_ATTACHMENT, + ]) // is approve email and destination emails are correct expect(sendMrfApprovalEmailSpy.mock.calls[0][0].isRejected).toBeFalse() expect(sendMrfApprovalEmailSpy.mock.calls[0][0].emails).toContainValues( @@ -464,6 +1570,12 @@ describe('multirespondent-submission.service', () => { expect(sendMrfApprovalEmailSpy).toHaveBeenCalledTimes(1) expect(sendMrfWorkflowCompletionEmailSpy).not.toHaveBeenCalled() expect(sendMRFWorkflowStepEmailSpy).not.toHaveBeenCalled() + // pdf generation is invoked + expect(MockMailUtils.generateAutoreplyPdf).toHaveBeenCalledTimes(1) + // pdf attachment is included and submission attachments are correct + expect(sendMrfApprovalEmailSpy.mock.calls[0][0].attachments).toEqual([ + EXPECTED_MOCK_PDF_ATTACHMENT, + ]) // is approve email and destination emails are correct expect(sendMrfApprovalEmailSpy.mock.calls[0][0].isRejected).toBeFalse() expect(sendMrfApprovalEmailSpy.mock.calls[0][0].emails).toContainValues( @@ -604,6 +1716,12 @@ describe('multirespondent-submission.service', () => { expect(sendMrfApprovalEmailSpy).toHaveBeenCalledTimes(1) expect(sendMrfWorkflowCompletionEmailSpy).not.toHaveBeenCalled() expect(sendMRFWorkflowStepEmailSpy).not.toHaveBeenCalled() + // pdf generation is invoked + expect(MockMailUtils.generateAutoreplyPdf).toHaveBeenCalledTimes(1) + // pdf attachment is included and submission attachments are correct + expect(sendMrfApprovalEmailSpy.mock.calls[0][0].attachments).toEqual([ + EXPECTED_MOCK_PDF_ATTACHMENT, + ]) // is approve email and destination emails are correct expect(sendMrfApprovalEmailSpy.mock.calls[0][0].isRejected).toBeFalse() expect(sendMrfApprovalEmailSpy.mock.calls[0][0].emails).toContainValues( @@ -725,6 +1843,7 @@ describe('multirespondent-submission.service', () => { ] as FormFieldDto[], } as SnapshottedFormDef, currentStepNumber: currentStepNumber, + attachments: MOCK_SUBMISSION_ATTACHMENTS, encryptedPayload: { encryptedContent: 'encryptedContent', version: 1, @@ -740,6 +1859,13 @@ describe('multirespondent-submission.service', () => { expect(sendMrfApprovalEmailSpy).toHaveBeenCalledTimes(1) expect(sendMrfWorkflowCompletionEmailSpy).not.toHaveBeenCalled() expect(sendMRFWorkflowStepEmailSpy).not.toHaveBeenCalled() + // pdf generation is invoked + expect(MockMailUtils.generateAutoreplyPdf).toHaveBeenCalledTimes(1) + // pdf attachment is included and submission attachments are correct + expect(sendMrfApprovalEmailSpy.mock.calls[0][0].attachments).toEqual([ + ...MOCK_SUBMISSION_ATTACHMENTS, + EXPECTED_MOCK_PDF_ATTACHMENT, + ]) // is rejected email and destination emails are correct expect(sendMrfApprovalEmailSpy.mock.calls[0][0].isRejected).toBeTrue() expect(sendMrfApprovalEmailSpy.mock.calls[0][0].emails).toContainValues( @@ -895,6 +2021,12 @@ describe('multirespondent-submission.service', () => { expect(sendMrfApprovalEmailSpy).toHaveBeenCalledTimes(1) expect(sendMrfWorkflowCompletionEmailSpy).not.toHaveBeenCalled() expect(sendMRFWorkflowStepEmailSpy).not.toHaveBeenCalled() + // pdf generation is invoked + expect(MockMailUtils.generateAutoreplyPdf).toHaveBeenCalledTimes(1) + // pdf attachment is included and submission attachments are correct + expect(sendMrfApprovalEmailSpy.mock.calls[0][0].attachments).toEqual([ + EXPECTED_MOCK_PDF_ATTACHMENT, + ]) // is rejected email and destination emails are correct expect(sendMrfApprovalEmailSpy.mock.calls[0][0].isRejected).toBeTrue() expect(sendMrfApprovalEmailSpy.mock.calls[0][0].emails).toContainValues( @@ -1035,6 +2167,12 @@ describe('multirespondent-submission.service', () => { expect(sendMrfApprovalEmailSpy).toHaveBeenCalledTimes(1) expect(sendMrfWorkflowCompletionEmailSpy).not.toHaveBeenCalled() expect(sendMRFWorkflowStepEmailSpy).not.toHaveBeenCalled() + // pdf generation is invoked + expect(MockMailUtils.generateAutoreplyPdf).toHaveBeenCalledTimes(1) + // pdf attachment is included and submission attachments are correct + expect(sendMrfApprovalEmailSpy.mock.calls[0][0].attachments).toEqual([ + EXPECTED_MOCK_PDF_ATTACHMENT, + ]) // is rejected email and destination emails are correct expect(sendMrfApprovalEmailSpy.mock.calls[0][0].isRejected).toBeTrue() expect(sendMrfApprovalEmailSpy.mock.calls[0][0].emails).toContainValues( @@ -1047,7 +2185,63 @@ describe('multirespondent-submission.service', () => { }) describe('mrf completion email notification when no approval step exists', () => { - it('sends completion email when single step mrf is completed', async () => { + it('sends completion email without pdf attachment when pdf generation fails', async () => { + // Arrange + MockMailUtils.generateAutoreplyPdf.mockReturnValue( + errAsync(new AutoreplyPdfGenerationError()), + ) + const sendMrfWorkflowCompletionEmailSpy = jest.spyOn( + MailService, + 'sendMrfWorkflowCompletionEmail', + ) + const singleStepWorkflow: FormWorkflowStepDto[] = [ + { + _id: new ObjectId().toHexString(), + workflow_type: WorkflowType.Static, + emails: [], + edit: [], + }, + ] + + // Act + await performMultiRespondentPostSubmissionCreateActions({ + submission: { + _id: mockSubmissionId, + } as unknown as IMultirespondentSubmissionSchema, + submissionId: mockSubmissionId, + form: { + _id: mockFormId, + workflow: singleStepWorkflow, + emails: ['email1@example.com'], + } as IPopulatedMultirespondentForm, + encryptedPayload: { + encryptedContent: 'encryptedContent', + version: 1, + submissionPublicKey: 'submissionPublicKey', + encryptedSubmissionSecretKey: 'encryptedSubmissionSecretKey', + } as MultirespondentSubmissionDto, + attachments: MOCK_SUBMISSION_ATTACHMENTS, + logMeta: {} as any, + }) + + // Assert + expect(sendMrfWorkflowCompletionEmailSpy).toHaveBeenCalledTimes(1) + // pdf generation is invoked + expect(MockMailUtils.generateAutoreplyPdf).toHaveBeenCalledTimes(1) + // submission attachments is sent without pdf attachment + expect( + sendMrfWorkflowCompletionEmailSpy.mock.calls[0][0].attachments, + ).toEqual([...MOCK_SUBMISSION_ATTACHMENTS]) + // the correct destination emails are included + expect( + sendMrfWorkflowCompletionEmailSpy.mock.calls[0][0].emails, + ).toContainValues(['email1@example.com']) + expect( + sendMrfWorkflowCompletionEmailSpy.mock.calls[0][0].emails.length, + ).toBe(1) + }) + + it('sends completion email with pdf attachment when single step mrf is completed', async () => { // Arrange const sendMrfWorkflowCompletionEmailSpy = jest.spyOn( MailService, @@ -1080,11 +2274,19 @@ describe('multirespondent-submission.service', () => { submissionPublicKey: 'submissionPublicKey', encryptedSubmissionSecretKey: 'encryptedSubmissionSecretKey', } as MultirespondentSubmissionDto, + attachments: MOCK_SUBMISSION_ATTACHMENTS, logMeta: {} as any, }) // Assert expect(sendMrfWorkflowCompletionEmailSpy).toHaveBeenCalledTimes(1) + // pdf generation is invoked + expect(MockMailUtils.generateAutoreplyPdf).toHaveBeenCalledTimes(1) + // pdf attachment is included and submission attachments are correct + expect( + sendMrfWorkflowCompletionEmailSpy.mock.calls[0][0].attachments, + ).toEqual([...MOCK_SUBMISSION_ATTACHMENTS, EXPECTED_MOCK_PDF_ATTACHMENT]) + // the correct destination emails are included expect( sendMrfWorkflowCompletionEmailSpy.mock.calls[0][0].emails, ).toContainValues(['email1@example.com']) @@ -1186,6 +2388,12 @@ describe('multirespondent-submission.service', () => { // Assert expect(sendMrfWorkflowCompletionEmailSpy).toHaveBeenCalledTimes(1) + // pdf generation is invoked + expect(MockMailUtils.generateAutoreplyPdf).toHaveBeenCalledTimes(1) + // pdf attachment is included and submission attachments are correct + expect( + sendMrfWorkflowCompletionEmailSpy.mock.calls[0][0].attachments, + ).toEqual([EXPECTED_MOCK_PDF_ATTACHMENT]) // The emails sent to should only be the expected emails exactly expect( sendMrfWorkflowCompletionEmailSpy.mock.calls[0][0].emails, @@ -1377,6 +2585,12 @@ describe('multirespondent-submission.service', () => { // Assert expect(sendMrfWorkflowCompletionEmailSpy).toHaveBeenCalledTimes(1) + // pdf generation is invoked + expect(MockMailUtils.generateAutoreplyPdf).toHaveBeenCalledTimes(1) + // pdf attachment is included and submission attachments are correct + expect( + sendMrfWorkflowCompletionEmailSpy.mock.calls[0][0].attachments, + ).toEqual([EXPECTED_MOCK_PDF_ATTACHMENT]) expect( sendMrfWorkflowCompletionEmailSpy.mock.calls[0][0].emails, ).toContainValues(expectedEmails) @@ -1441,10 +2655,17 @@ describe('multirespondent-submission.service', () => { workflowStep: workflow.length - 1, } as MultirespondentSubmissionDto, logMeta: {} as any, + attachments: MOCK_SUBMISSION_ATTACHMENTS, }) // Assert expect(sendMrfWorkflowCompletionEmailSpy).toHaveBeenCalledTimes(1) + // pdf generation is invoked + expect(MockMailUtils.generateAutoreplyPdf).toHaveBeenCalledTimes(1) + // pdf attachment is included and submission attachments are correct + expect( + sendMrfWorkflowCompletionEmailSpy.mock.calls[0][0].attachments, + ).toEqual([...MOCK_SUBMISSION_ATTACHMENTS, EXPECTED_MOCK_PDF_ATTACHMENT]) expect( sendMrfWorkflowCompletionEmailSpy.mock.calls[0][0].emails, ).toContainValues(expectedEmails) @@ -1515,6 +2736,12 @@ describe('multirespondent-submission.service', () => { // Assert expect(sendMrfWorkflowCompletionEmailSpy).toHaveBeenCalledTimes(1) + // pdf generation is invoked + expect(MockMailUtils.generateAutoreplyPdf).toHaveBeenCalledTimes(1) + // pdf attachment is included and submission attachments are correct + expect( + sendMrfWorkflowCompletionEmailSpy.mock.calls[0][0].attachments, + ).toEqual([EXPECTED_MOCK_PDF_ATTACHMENT]) expect( sendMrfWorkflowCompletionEmailSpy.mock.calls[0][0].emails, ).toContainValues(expectedEmails) @@ -1605,6 +2832,13 @@ describe('multirespondent-submission.service', () => { // Assert expect(sendMrfWorkflowCompletionEmailSpy).toHaveBeenCalledTimes(1) + // pdf generation is invoked + expect(MockMailUtils.generateAutoreplyPdf).toHaveBeenCalledTimes(1) + // pdf attachment is included and submission attachments are correct + expect( + sendMrfWorkflowCompletionEmailSpy.mock.calls[0][0].attachments, + ).toEqual([EXPECTED_MOCK_PDF_ATTACHMENT]) + // the correct destination emails are included expect( sendMrfWorkflowCompletionEmailSpy.mock.calls[0][0].emails, ).toContainValues(expectedEmails) diff --git a/src/app/modules/submission/multirespondent-submission/__tests__/multirespondent-submission.utils.spec.ts b/src/app/modules/submission/multirespondent-submission/__tests__/multirespondent-submission.utils.spec.ts index 20f932f901..623fd09cd7 100644 --- a/src/app/modules/submission/multirespondent-submission/__tests__/multirespondent-submission.utils.spec.ts +++ b/src/app/modules/submission/multirespondent-submission/__tests__/multirespondent-submission.utils.spec.ts @@ -20,12 +20,14 @@ import { NumberResponseV3, ShortTextResponseV3, SignatureFieldResponseV3, + SignatureVectorArray, SubmissionType, TableResponseV3, WorkflowStatus, WorkflowType, } from 'shared/types' +import { convertToSignaturePngDataUri } from 'src/app/utils/convert-vector-array-to-png' import { FormFieldSchema, IAddressCompoundFieldSchema, @@ -45,7 +47,8 @@ import { ValidateFieldErrorV3 } from '../../submission.errors' import { createMultirespondentSubmissionDto, createPublicMultirespondentSubmissionDto, - getQuestionTitleAnswerString, + extractRespondentCopyEmailDatas, + getQuestionAnswerPairsForMultipleFields, retrieveWorkflowStepEmailAddresses, validateMrfFieldResponses, } from '../multirespondent-submission.utils' @@ -58,6 +61,93 @@ describe('multirespondent-submission.utils', () => { edit: [], } + describe('extractRespondentCopyEmailDatas', () => { + it('should return email data for only current step email fields with auto reply enabled and has answer', () => { + // Arrange + const inactiveEmailField = new ObjectId().toHexString() + const activeEmailField = new ObjectId().toHexString() + const activeEmailFieldNoAnswer = new ObjectId().toHexString() + const activeEmailFieldNoAutoReply = new ObjectId().toHexString() + const shortTextFieldId = new ObjectId().toHexString() + const autoReplyOptionDefaults = { + autoReplySubject: 'Test Subject', + autoReplySender: 'Test Sender', + autoReplyMessage: 'Test Body', + includeFormSummary: true, + hasAutoReply: true, + } + const formFields = [ + generateDefaultField(BasicField.Email, { + _id: inactiveEmailField, + autoReplyOptions: autoReplyOptionDefaults, + }), + generateDefaultField(BasicField.Email, { + _id: activeEmailField, + autoReplyOptions: autoReplyOptionDefaults, + }), + generateDefaultField(BasicField.Email, { + _id: activeEmailFieldNoAnswer, + autoReplyOptions: autoReplyOptionDefaults, + }), + generateDefaultField(BasicField.ShortText, { + _id: shortTextFieldId, + autoReplyOptions: autoReplyOptionDefaults, + }), + generateDefaultField(BasicField.Email, { + _id: activeEmailFieldNoAutoReply, + autoReplyOptions: { + ...autoReplyOptionDefaults, + hasAutoReply: false, + }, + }), + ] + + // Act + const result = extractRespondentCopyEmailDatas({ + responses: { + [inactiveEmailField]: { + fieldType: BasicField.Email, + answer: { + value: 'notexpectedsinceinactive@email.com', + }, + }, + [activeEmailField]: { + fieldType: BasicField.Email, + answer: { + value: 'expected@email.com', + }, + }, + [shortTextFieldId]: { + fieldType: BasicField.ShortText, + answer: 'short text answer', + }, + [activeEmailFieldNoAutoReply]: { + fieldType: BasicField.Email, + answer: { value: 'notexpectedsincenoautoReply@email.com' }, + }, + }, + formFields, + currentStepActiveFields: [ + activeEmailField, + shortTextFieldId, + activeEmailFieldNoAnswer, + activeEmailFieldNoAutoReply, + ], + }) + + // Assert + expect(result).toEqual([ + { + email: 'expected@email.com', + subject: 'Test Subject', + sender: 'Test Sender', + body: 'Test Body', + includeFormSummary: true, + }, + ]) + }) + }) + describe('createPublicMultirespondentSubmissionDto', () => { const getAllTypesFormFieldsWithDropdownOptionsToRecipientsMap = () => { return Object.values(BasicField).map((fieldType) => { @@ -430,12 +520,23 @@ describe('multirespondent-submission.utils', () => { } as EmailResponseV3, } - const result = getQuestionTitleAnswerString({ formFields, responses }) + const result = getQuestionAnswerPairsForMultipleFields({ + formFields, + responses, + }) expect(result).toEqual([ - { question: 'Short Text', answer: 'Test answer' }, - { question: 'Number', answer: '42' }, - { question: 'Email', answer: 'test@example.com' }, + { + question: 'Short Text', + answer: 'Test answer', + fieldType: BasicField.ShortText, + }, + { question: 'Number', answer: '42', fieldType: BasicField.Number }, + { + question: 'Email', + answer: 'test@example.com', + fieldType: BasicField.Email, + }, ]) }) @@ -454,10 +555,17 @@ describe('multirespondent-submission.utils', () => { } as AttachmentResponseV3, } - const result = getQuestionTitleAnswerString({ formFields, responses }) + const result = getQuestionAnswerPairsForMultipleFields({ + formFields, + responses, + }) expect(result).toEqual([ - { question: '[Attachment] File Upload', answer: 'file.pdf' }, + { + question: '[Attachment] File Upload', + answer: 'file.pdf', + fieldType: BasicField.Attachment, + }, ]) }) @@ -499,24 +607,31 @@ describe('multirespondent-submission.utils', () => { } as TableResponseV3, } - const result = getQuestionTitleAnswerString({ formFields, responses }) + const result = getQuestionAnswerPairsForMultipleFields({ + formFields, + responses, + }) expect(result).toEqual([ { question: '[Table] Table of Name and Age (Name; Age)', answer: 'Alice; 30', + fieldType: BasicField.Table, }, { question: '[Table] Table of Name and Age (Name; Age)', answer: 'Bob; 25', + fieldType: BasicField.Table, }, { question: '[Table] Table of Hobbies (Hobby; Years)', answer: 'Swimming; 5', + fieldType: BasicField.Table, }, { question: '[Table] Table of Hobbies (Hobby; Years)', answer: 'Reading; 10', + fieldType: BasicField.Table, }, ]) }) @@ -539,10 +654,17 @@ describe('multirespondent-submission.utils', () => { } as CheckboxResponseV3, } - const result = getQuestionTitleAnswerString({ formFields, responses }) + const result = getQuestionAnswerPairsForMultipleFields({ + formFields, + responses, + }) expect(result).toEqual([ - { question: 'Checkbox', answer: 'Option 1,Option 2,Custom Option' }, + { + question: 'Checkbox', + answer: 'Option 1,Option 2,Custom Option', + fieldType: BasicField.Checkbox, + }, ]) }) @@ -570,17 +692,64 @@ describe('multirespondent-submission.utils', () => { } as AddressResponseV3, } - const result = getQuestionTitleAnswerString({ formFields, responses }) + const result = getQuestionAnswerPairsForMultipleFields({ + formFields, + responses, + }) expect(result).toEqual([ { question: 'Address', answer: '161, BUKIT BATOK STREET 11, #1-1, SINGAPORE 650161', + fieldType: BasicField.Address, }, ]) }) - it('should handle signature fields correctly', () => { + it('should handle signature fields correctly when includeSignatureDataPngUri is true', () => { + const formFields: FormFieldSchema[] = [ + { + _id: '1', + title: 'Signature', + fieldType: BasicField.Signature, + } as ISignatureFieldSchema, + ] + + const MOCK_SIGNATURE_VALUE: SignatureVectorArray = [ + [[10, 20, 0.5]], + [[40, 40, 0.5]], + ] + + const responses: FieldResponsesV3 = { + '1': { + fieldType: BasicField.Signature, + answer: { + type: 'draw', + value: MOCK_SIGNATURE_VALUE, + } as SignatureFieldResponseV3, + }, + } + + const result = getQuestionAnswerPairsForMultipleFields({ + formFields, + responses, + includeSignatureDataPngDataUri: true, + }) + + const expectedSignatureDataPngDataUri = + convertToSignaturePngDataUri(MOCK_SIGNATURE_VALUE) + + expect(result).toEqual([ + { + question: '[signature] Signature', + answer: 'Signature captured', + fieldType: BasicField.Signature, + signatureDataPngDataUri: expectedSignatureDataPngDataUri, + }, + ]) + }) + + it('should handle signature fields correctly when includeSignatureDataPngUri is false', () => { const formFields: FormFieldSchema[] = [ { _id: '1', @@ -599,12 +768,17 @@ describe('multirespondent-submission.utils', () => { }, } - const result = getQuestionTitleAnswerString({ formFields, responses }) + const result = getQuestionAnswerPairsForMultipleFields({ + formFields, + responses, + }) expect(result).toEqual([ { question: '[signature] Signature', answer: 'Signature captured', + fieldType: BasicField.Signature, + signatureDataPngDataUri: undefined, }, ]) }) @@ -618,12 +792,16 @@ describe('multirespondent-submission.utils', () => { }, } - const result = getQuestionTitleAnswerString({ formFields, responses }) + const result = getQuestionAnswerPairsForMultipleFields({ + formFields, + responses, + }) expect(result).toEqual([ { question: 'SingPass Validated NRIC (Step 1)', answer: 'S1234567A', + fieldType: BasicField.Nric, }, ]) }) @@ -845,7 +1023,10 @@ describe('multirespondent-submission.utils', () => { } as ShortTextResponseV3, } - const result = getQuestionTitleAnswerString({ formFields, responses }) + const result = getQuestionAnswerPairsForMultipleFields({ + formFields, + responses, + }) expect(result).toEqual([]) }) @@ -859,13 +1040,13 @@ describe('multirespondent-submission.utils', () => { } as IShortTextFieldSchema, ] - const undefinedResult = getQuestionTitleAnswerString({ + const undefinedResult = getQuestionAnswerPairsForMultipleFields({ formFields, responses: undefined as unknown as FieldResponsesV3, }) expect(undefinedResult).toEqual([]) - const nullResult = getQuestionTitleAnswerString({ + const nullResult = getQuestionAnswerPairsForMultipleFields({ formFields, responses: null as unknown as FieldResponsesV3, })