• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

opengovsg / FormSG / 18550061762

16 Oct 2025 04:13AM UTC coverage: 71.77% (-0.03%) from 71.796%
18550061762

push

github

web-flow
Merge pull request #8820 from opengovsg/release-al2

build: merge release v6.259.0 to develop

3086 of 5221 branches covered (59.11%)

Branch coverage included in aggregate %.

10986 of 14386 relevant lines covered (76.37%)

50.92 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

60.42
/src/app/modules/submission/encrypt-submission/encrypt-submission.service.ts
1
import { GrowthBook } from '@growthbook/growthbook'
2
import mongoose from 'mongoose'
58✔
3
import { err, ok, okAsync, Result, ResultAsync } from 'neverthrow'
58✔
4
import Mail from 'nodemailer/lib/mailer'
5

6
import { AutoReplyMailData } from 'src/app/services/mail/mail.types'
7

8
import { featureFlags } from '../../../../../shared/constants'
58✔
9
import {
58✔
10
  DateString,
11
  FormResponseMode,
12
  SubmissionType,
13
} from '../../../../../shared/types'
14
import {
15
  FieldResponse,
16
  IEncryptedSubmissionSchema,
17
  IPopulatedEncryptedForm,
18
  IPopulatedForm,
19
} from '../../../../types'
20
import { createLoggerWithLabel } from '../../../config/logger'
58✔
21
import { getEncryptSubmissionModel } from '../../../models/submission.server.model'
58✔
22
import { createQueryWithDateParam } from '../../../utils/date'
58✔
23
import { getMongoErrorMessage } from '../../../utils/handle-mongo-error'
58✔
24
import { DatabaseError, PossibleDatabaseError } from '../../core/core.errors'
58✔
25
import { FormNotFoundError } from '../../form/form.errors'
26
import * as FormService from '../../form/form.service'
58✔
27
import { isFormEncryptMode } from '../../form/form.utils'
58✔
28
import * as UserService from '../../user/user.service'
58✔
29
import {
30
  WebhookPushToQueueError,
31
  WebhookValidationError,
32
} from '../../webhook/webhook.errors'
33
import { WebhookFactory } from '../../webhook/webhook.factory'
58✔
34
import { SubmissionEmailObj } from '../email-submission/email-submission.util'
35
import {
58✔
36
  ResponseModeError,
37
  SendEmailConfirmationError,
38
  SubmissionNotFoundError,
39
  UnsupportedSettingsError,
40
} from '../submission.errors'
41
import { sendEmailConfirmations } from '../submission.service'
58✔
42
import { extractEmailConfirmationData } from '../submission.utils'
58✔
43

44
import { CHARTS_MAX_SUBMISSION_RESULTS } from './encrypt-submission.constants'
58✔
45
import { SaveEncryptSubmissionParams } from './encrypt-submission.types'
46

47
const logger = createLoggerWithLabel(module)
58✔
48
const EncryptSubmissionModel = getEncryptSubmissionModel(mongoose)
58✔
49

50
/**
51
 * Retrieves all encrypted submission data from the database
52
 * - up to the 1000th submission, sorted in reverse chronological order
53
 * - this query uses 'form_1_submissionType_1_created_-1' index
54
 * @param formId the id of the form to filter submissions for
55
 * @returns ok(SubmissionData)
56
 * @returns err(DatabaseError) when error occurs during query
57
 */
58
export const getAllEncryptedSubmissionData = (
58✔
59
  formId: string,
60
  startDate?: DateString,
61
  endDate?: DateString,
62
) => {
63
  const findQuery = {
×
64
    form: formId,
65
    submissionType: SubmissionType.Encrypt,
66
    ...createQueryWithDateParam(startDate, endDate),
67
  }
68
  return ResultAsync.fromPromise(
×
69
    EncryptSubmissionModel.find(findQuery)
70
      .limit(CHARTS_MAX_SUBMISSION_RESULTS)
71
      .sort({ created: -1 }),
72
    (error) => {
73
      logger.error({
×
74
        message: 'Failure retrieving encrypted submission from database',
75
        meta: {
76
          action: 'getEncryptedSubmissionData',
77
          formId,
78
        },
79
        error,
80
      })
81

82
      return new DatabaseError(getMongoErrorMessage(error))
×
83
    },
84
  )
85
}
86

87
export const checkFormIsEncryptMode = (
58✔
88
  form: IPopulatedForm,
89
): Result<IPopulatedEncryptedForm, ResponseModeError> => {
90
  return isFormEncryptMode(form)
24!
91
    ? ok(form)
92
    : err(new ResponseModeError(FormResponseMode.Encrypt, form.responseMode))
93
}
94

95
export const assertFormIsSingleSubmissionDisabled = (
58✔
96
  form: IPopulatedForm,
97
): Result<IPopulatedForm, UnsupportedSettingsError> => {
98
  return !form.isSingleSubmission
×
99
    ? ok(form)
100
    : err(
101
        new UnsupportedSettingsError(
102
          'isSingleSubmission cannot be enabled for payment forms as it is not currently supported',
103
        ),
104
      )
105
}
106

107
/**
108
 * Creates an encrypted submission without saving it to the database.
109
 * @param form Document of the form being submitted
110
 * @param encryptedContent Encrypted content of submission
111
 * @param version Encryption version
112
 * @param attachmentMetadata
113
 * @param verifiedContent Verified content included in submission, e.g. SingPass ID
114
 * @returns Encrypted submission document which has not been saved to database
115
 */
116
export const createEncryptSubmissionWithoutSave = ({
58✔
117
  form,
118
  encryptedContent,
119
  version,
120
  attachmentMetadata,
121
  verifiedContent,
122
}: SaveEncryptSubmissionParams): IEncryptedSubmissionSchema => {
123
  return new EncryptSubmissionModel({
1✔
124
    form: form._id,
125
    authType: form.authType,
126
    myInfoFields: form.getUniqueMyInfoAttrs(),
127
    encryptedContent,
128
    verifiedContent,
129
    attachmentMetadata,
130
    version,
131
  })
132
}
133

134
/**
135
 * Performs the post-submission actions for encrypt submissions. This is to be
136
 * called when the submission is completed
137
 * @param submission the completed submission
138
 * @param responses the verified field responses sent with the original submission request
139
 * @returns ok(true) if all actions were completed successfully
140
 * @returns err(FormNotFoundError) if the form or form admin does not exist
141
 * @returns err(ResponseModeError) if the form is not encrypt mode
142
 * @returns err(WebhookValidationError) if the webhook URL failed validation
143
 * @returns err(WebhookPushToQueueError) if the webhook was failed to be pushed to SQS
144
 * @returns err(SubmissionNotFoundError) if there was an error updating the submission with the webhook record
145
 * @returns err(SendEmailConfirmationError) if any email failed to be sent
146
 * @returns err(PossibleDatabaseError) if error occurs whilst querying the database
147
 */
148
export const performEncryptPostSubmissionActions = ({
58✔
149
  submission,
150
  responses,
151
  growthbook,
152
  emailData,
153
  attachments,
154
  respondentEmails,
155
}: {
156
  submission: IEncryptedSubmissionSchema
157
  responses: FieldResponse[]
158
  growthbook?: GrowthBook
159
  emailData?: SubmissionEmailObj
160
  attachments?: Mail.Attachment[]
161
  respondentEmails?: string[]
162
}): ResultAsync<
163
  true,
164
  | FormNotFoundError
165
  | ResponseModeError
166
  | WebhookValidationError
167
  | WebhookPushToQueueError
168
  | SendEmailConfirmationError
169
  | SubmissionNotFoundError
170
  | PossibleDatabaseError
171
> => {
172
  const logMeta = {
21✔
173
    action: 'performEncryptPostSubmissionActions',
174
    submissionId: submission.id,
175
  }
176

177
  return (
21✔
178
    FormService.retrieveFullFormById(submission.form)
179
      .andThen(checkFormIsEncryptMode)
180
      .andThen((form) => {
181
        // Fire webhooks if available
182
        // To avoid being coupled to latency of receiving system,
183
        // do not await on webhook
184
        const webhookUrl = form.webhook?.url
7!
185
        if (!webhookUrl) return okAsync(form)
7✔
186

187
        return WebhookFactory.sendInitialWebhook(
×
188
          submission,
189
          webhookUrl,
190
          !!form.webhook?.isRetryEnabled,
×
191
        ).andThen(() => okAsync(form))
×
192
      })
193
      // TODO [PDF-LAMBDA-GENERATION]: Remove setting of Growthbook targetting once pdf generation rollout is complete
194
      .map(async (form) => {
7✔
195
        await UserService.getPopulatedUserById(form.admin).map(
7✔
196
          async (admin) => {
×
197
            await growthbook?.setAttributes({
×
198
              ...growthbook?.getAttributes(),
×
199
              formId: submission.form.toString(),
200
              adminEmail: admin.email,
201
              adminAgency: admin.agency.shortName,
202
            })
203
          },
204
        )
205
        return form
7✔
206
      })
207
      .andThen((form) => {
208
        const respondentCopyEmailData: AutoReplyMailData[] = respondentEmails
7!
209
          ? respondentEmails?.map((val) => {
×
210
              return {
×
211
                email: val,
212
                includeFormSummary: true,
213
              }
214
            })
215
          : []
216

217
        // TODO [PDF-LAMBDA-GENERATION]: Remove setting of Growthbook targetting once pdf generation rollout is complete
218
        const isUseLambdaOutput =
219
          growthbook?.isOn(featureFlags.lambdaPdfGeneration) ?? false
7!
220
        logger.info({
7✔
221
          message: 'Growthbook flag for lambda pdf generation',
222
          meta: {
223
            ...logMeta,
224
            isUseLambdaOutput,
225
            growthbookAttributes: growthbook?.getAttributes(),
21!
226
            lambdaPdfGenerationGrowthbookValue: growthbook?.getFeatureValue(
21!
227
              featureFlags.lambdaPdfGeneration,
228
              undefined,
229
            ),
230
          },
231
        })
232

233
        return sendEmailConfirmations({
7✔
234
          form,
235
          submission,
236
          attachments,
237
          responsesData: emailData?.autoReplyData,
21!
238
          recipientData: [
239
            ...extractEmailConfirmationData(responses, form.form_fields),
240
            ...respondentCopyEmailData,
241
          ],
242
          isUseLambdaOutput,
243
        }).mapErr((error) => {
244
          logger.error({
×
245
            message: 'Error while sending email confirmations',
246
            meta: {
247
              action: 'sendEmailAutoReplies',
248
            },
249
            error,
250
          })
251
          return error
×
252
        })
253
      })
254
  )
255
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc