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

opengovsg / FormSG / 12667305727

08 Jan 2025 09:22AM UTC coverage: 72.402% (+0.005%) from 72.397%
12667305727

push

github

GitHub
fix(deps): bump cookie, cookie-parser, express-session and maildev

2725 of 4594 branches covered (59.32%)

Branch coverage included in aggregate %.

9962 of 12929 relevant lines covered (77.05%)

43.75 hits per line

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

85.53
/src/app/models/form.server.model.ts
1
import { calculateObjectSize, ObjectId } from 'bson'
139✔
2
import { compact, omit, pick, uniq } from 'lodash'
139✔
3
import mongoose, {
139✔
4
  ClientSession,
5
  Mongoose,
6
  Query,
7
  Schema,
8
  SchemaOptions,
9
  Types,
10
} from 'mongoose'
11
import validator from 'validator'
139✔
12
import isEmail from 'validator/lib/isEmail'
139✔
13

14
import {
139✔
15
  ADMIN_FORM_META_FIELDS,
16
  EMAIL_FORM_SETTINGS_FIELDS,
17
  EMAIL_PUBLIC_FORM_FIELDS,
18
  MB,
19
  MULTIRESPONDENT_FORM_SETTINGS_FIELDS,
20
  MULTIRESPONDENT_PUBLIC_FORM_FIELDS,
21
  STORAGE_FORM_SETTINGS_FIELDS,
22
  STORAGE_PUBLIC_FORM_FIELDS,
23
  WEBHOOK_SETTINGS_FIELDS,
24
} from '../../../shared/constants'
25
import {
139✔
26
  AdminDashboardFormMetaDto,
27
  BasicField,
28
  EmailFormSettings,
29
  FormAuthType,
30
  FormColorTheme,
31
  FormEndPage,
32
  FormField,
33
  FormFieldDto,
34
  FormLogoState,
35
  FormPaymentsChannel,
36
  FormPaymentsField,
37
  FormPermission,
38
  FormResponseMode,
39
  FormSettings,
40
  FormStartPage,
41
  FormStatus,
42
  FormWebhookResponseModeSettings,
43
  FormWebhookSettings,
44
  Language,
45
  LogicConditionState,
46
  LogicDto,
47
  LogicType,
48
  MultirespondentFormSettings,
49
  PaymentChannel,
50
  PaymentType,
51
  StorageFormSettings,
52
  WorkflowType,
53
} from '../../../shared/types'
54
import { reorder } from '../../../shared/utils/immutable-array-fns'
139✔
55
import { getApplicableIfStates } from '../../../shared/utils/logic'
139✔
56
import {
57
  FormFieldSchema,
58
  FormLogicSchema,
59
  FormOtpData,
60
  IEmailFormModel,
61
  IEmailFormSchema,
62
  IEncryptedFormDocument,
63
  IEncryptedFormModel,
64
  IEncryptedFormSchema,
65
  IFieldSchema,
66
  IFormDocument,
67
  IFormModel,
68
  IFormSchema,
69
  ILogicSchema,
70
  IMultirespondentFormModel,
71
  IMultirespondentFormSchema,
72
  IPopulatedForm,
73
  PickDuplicateForm,
74
  PublicForm,
75
} from '../../types'
76
import { IPopulatedUser, IUserSchema } from '../../types/user'
77
import { OverrideProps } from '../modules/form/admin-form/admin-form.types'
78
import { getFormFieldById, transformEmails } from '../modules/form/form.utils'
139✔
79
import { getMyInfoAttr } from '../modules/myinfo/myinfo.util'
139✔
80
import { validateWebhookUrl } from '../modules/webhook/webhook.validation'
139✔
81

82
import { ProductSchema } from './payments/productSchema'
139✔
83
import {
139✔
84
  BaseFieldSchema,
85
  createAttachmentFieldSchema,
86
  createCheckboxFieldSchema,
87
  createchildrenCompoundFieldSchema,
88
  createCountryRegionFieldSchema,
89
  createDateFieldSchema,
90
  createDecimalFieldSchema,
91
  createDropdownFieldSchema,
92
  createEmailFieldSchema,
93
  createHomenoFieldSchema,
94
  createImageFieldSchema,
95
  createLongTextFieldSchema,
96
  createMobileFieldSchema,
97
  createNricFieldSchema,
98
  createNumberFieldSchema,
99
  createRadioFieldSchema,
100
  createRatingFieldSchema,
101
  createSectionFieldSchema,
102
  createShortTextFieldSchema,
103
  createStatementFieldSchema,
104
  createTableFieldSchema,
105
  createUenFieldSchema,
106
  createYesNoFieldSchema,
107
} from './field'
108
import LogicSchema, {
139✔
109
  PreventSubmitLogicSchema,
110
  ShowFieldsLogicSchema,
111
} from './form_logic.server.schema'
112
import { CustomFormLogoSchema, FormLogoSchema } from './form_logo.server.schema'
139✔
113
import { FORM_WHITELISTED_SUBMITTER_IDS_ID } from './form_whitelist.server.model'
139✔
114
import WorkflowStepSchema, {
139✔
115
  WorkflowStepConditionalSchema,
116
  WorkflowStepDynamicSchema,
117
  WorkflowStepStaticSchema,
118
} from './form_workflow_step.server.schema'
119
import getUserModel from './user.server.model'
139✔
120
import { isPositiveInteger } from './utils'
139✔
121

122
export const FORM_SCHEMA_ID = 'Form'
139✔
123

124
const formSchemaOptions: SchemaOptions = {
139✔
125
  id: false,
126
  toJSON: {
127
    getters: true,
128
  },
129
  discriminatorKey: 'responseMode',
130
  read: 'nearest',
131
  timestamps: {
132
    createdAt: 'created',
133
    updatedAt: 'lastModified',
134
  },
135
}
136

137
export const formPaymentsFieldSchema = {
139✔
138
  enabled: {
139
    type: Boolean,
140
    default: false,
141
  },
142
  description: {
143
    type: String,
144
    trim: true,
145
    default: '',
146
  },
147
  name: {
148
    type: String,
149
    trim: true,
150
    default: '',
151
  },
152
  amount_cents: {
153
    type: Number,
154
    default: 0,
155
    validate: {
156
      validator: isPositiveInteger,
157
      message: 'amount_cents must be a non-negative integer.',
158
    },
159
  },
160
  products: [ProductSchema],
161
  products_meta: {
162
    multi_product: {
163
      type: Boolean,
164
      default: false,
165
    },
166
  },
167
  min_amount: {
168
    type: Number,
169
    default: 0,
170
    validate: {
171
      validator: isPositiveInteger,
172
      message: 'min_amount must be a non-negative integer.',
173
    },
174
  },
175
  max_amount: {
176
    type: Number,
177
    default: 0,
178
    validate: {
179
      validator: isPositiveInteger,
180
      message: 'max_amount must be a non-negative integer.',
181
    },
182
  },
183
  payment_type: {
184
    type: String,
185
    enum: Object.values(PaymentType),
186
    default: PaymentType.Products,
187
  },
188
  gst_enabled: {
189
    type: Boolean,
190
    default: true,
191
  },
192
  global_min_amount_override: {
193
    type: Number,
194
    default: 0,
195
  },
196
}
197

198
const whitelistedSubmitterIdNestedPath = {
139✔
199
  isWhitelistEnabled: {
200
    type: Boolean,
201
    required: true,
202
    default: false,
203
  },
204
  encryptedWhitelistedSubmitterIds: {
205
    type: Schema.Types.ObjectId,
206
    ref: FORM_WHITELISTED_SUBMITTER_IDS_ID,
207
    required: false,
208
    default: undefined,
209
  },
210
  _id: { id: false },
211
}
212

213
const EncryptedFormSchema = new Schema<IEncryptedFormSchema>({
139✔
214
  publicKey: {
215
    type: String,
216
    required: true,
217
  },
218
  emails: {
219
    type: [
220
      {
221
        type: String,
222
        trim: true,
223
      },
224
    ],
225
    set: transformEmails,
226
    validate: [
227
      (v: string[]) => {
228
        if (!Array.isArray(v)) return false
260!
229
        if (v.length === 0) return true
260✔
230
        return v.every((email) => validator.isEmail(email))
1✔
231
      },
232
      'Please provide valid email addresses',
233
    ],
234
    // Mongoose v6 only checks if the type is an array, not whether the array
235
    // is non-empty. We allow this field to not exist for backwards compatibility
236
    // TODO: Make this required after all forms have been migrated
237
    required: false,
238
  },
239
  whitelistedSubmitterIds: {
240
    type: whitelistedSubmitterIdNestedPath,
241
    get: (v: { isWhitelistEnabled: boolean }) => ({
26✔
242
      // remove the ObjectId link to whitelist collection's document by default unless asked for.
243
      isWhitelistEnabled: v.isWhitelistEnabled,
244
    }),
245
    default: () => ({
371✔
246
      isWhitelistEnabled: false,
247
    }),
248
  },
249
  payments_channel: {
250
    channel: {
251
      type: String,
252
      enum: Object.values(PaymentChannel),
253
      default: PaymentChannel.Unconnected,
254
    },
255
    target_account_id: {
256
      type: String,
257
      default: '',
258
      validate: [/^\S*$/i, 'target_account_id must not contain whitespace.'],
259
    },
260
    publishable_key: {
261
      type: String,
262
      default: '',
263
      validate: [/^\S*$/i, 'publishable_key must not contain whitespace.'],
264
    },
265
    payment_methods: {
266
      type: [String],
267
      default: [],
268
    },
269
  },
270

271
  payments_field: formPaymentsFieldSchema,
272

273
  business: {
274
    type: {
275
      address: { type: String, default: '', trim: true },
276
      gstRegNo: { type: String, default: '', trim: true },
277
    },
278
  },
279
})
280

281
const EncryptedFormDocumentSchema =
282
  EncryptedFormSchema as unknown as Schema<IEncryptedFormDocument>
139✔
283

284
EncryptedFormDocumentSchema.methods.getWhitelistedSubmitterIds = function () {
139✔
285
  return this.get('whitelistedSubmitterIds', null, {
×
286
    getters: false,
287
  })
288
}
289

290
EncryptedFormDocumentSchema.methods.addPaymentAccountId = function ({
139✔
291
  accountId,
292
  publishableKey,
293
}: {
294
  accountId: FormPaymentsChannel['target_account_id']
295
  publishableKey: FormPaymentsChannel['publishable_key']
296
}) {
297
  if (this.payments_channel?.channel === PaymentChannel.Unconnected) {
4!
298
    this.payments_channel = {
3✔
299
      // Definitely Stripe for now, may be different later on.
300
      channel: PaymentChannel.Stripe,
301
      target_account_id: accountId,
302
      publishable_key: publishableKey,
303
      payment_methods: [],
304
    }
305
  }
306
  return this.save()
4✔
307
}
308

309
EncryptedFormDocumentSchema.methods.removePaymentAccount = async function () {
139✔
310
  this.payments_channel = {
×
311
    channel: PaymentChannel.Unconnected,
312
    target_account_id: '',
313
    publishable_key: '',
314
    payment_methods: [],
315
  }
316
  if (this.payments_field) {
×
317
    this.payments_field.enabled = false
×
318
  }
319
  return this.save()
×
320
}
321

322
const EmailFormSchema = new Schema<IEmailFormSchema, IEmailFormModel>({
139✔
323
  emails: {
324
    type: [
325
      {
326
        type: String,
327
        trim: true,
328
      },
329
    ],
330
    set: transformEmails,
331
    validate: [
332
      (v: string[]) => {
333
        if (!Array.isArray(v)) return false
404!
334
        if (v.length === 0) return false
404✔
335
        return v.every((email) => validator.isEmail(email))
403✔
336
      },
337
      'Please provide valid email addresses',
338
    ],
339
    // Mongoose v5 only checks if the type is an array, not whether the array
340
    // is non-empty.
341
    required: [true, 'Emails field is required'],
342
  },
343
})
344

345
const MultirespondentFormSchema = new Schema<IMultirespondentFormSchema>({
139✔
346
  publicKey: {
347
    type: String,
348
    required: true,
349
  },
350
  workflow: {
351
    type: [WorkflowStepSchema],
352
  },
353
  emails: {
354
    type: [
355
      {
356
        type: String,
357
        trim: true,
358
      },
359
    ],
360
    set: transformEmails,
361
    validate: [
362
      (v: string[]) => {
363
        if (!Array.isArray(v)) return false
2!
364
        if (v.length === 0) return true
2✔
365
        return v.every((email) => validator.isEmail(email))
×
366
      },
367
      'Please provide valid email addresses',
368
    ],
369
    required: true,
370
  },
371
  stepsToNotify: {
372
    type: [{ type: String }],
373
    validate: [
374
      {
375
        validator: (v: string[]) => {
376
          if (!Array.isArray(v)) return false
2!
377
          if (v.length === 0) return true
2✔
378
          return v.every((fieldId) => ObjectId.isValid(fieldId))
×
379
        },
380
        message: 'Please provide valid form field ids',
381
      },
382
    ],
383
    required: true,
384
  },
385
  stepOneEmailNotificationFieldId: {
386
    type: String,
387
    default: '',
388
  },
389
})
390

391
const MultirespondentFormWorkflowPath = MultirespondentFormSchema.path(
139✔
392
  'workflow',
393
) as Schema.Types.DocumentArray
394

395
MultirespondentFormWorkflowPath.discriminator(
139✔
396
  WorkflowType.Static,
397
  WorkflowStepStaticSchema,
398
)
399
MultirespondentFormWorkflowPath.discriminator(
139✔
400
  WorkflowType.Dynamic,
401
  WorkflowStepDynamicSchema,
402
)
403
MultirespondentFormWorkflowPath.discriminator(
139✔
404
  WorkflowType.Conditional,
405
  WorkflowStepConditionalSchema,
406
)
407

408
const compileFormModel = (db: Mongoose): IFormModel => {
139✔
409
  const User = getUserModel(db)
75✔
410

411
  // Schema
412
  const FormSchema = new Schema<IFormSchema, IFormModel>(
75✔
413
    {
414
      title: {
415
        type: String,
416
        required: [true, 'Form name cannot be blank'],
417
        minlength: [4, 'Form name must be at least 4 characters'],
418
        maxlength: [200, 'Form name can have a maximum of 200 characters'],
419
        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
420
        // @ts-ignore
421
        trim: true,
422
      },
423

424
      form_fields: {
425
        type: [BaseFieldSchema],
426
        validate: {
427
          validator: function (this: IFormSchema) {
428
            const myInfoFieldCount = (this.form_fields ?? []).reduce(
667!
429
              (acc, field) => acc + (field.myInfo ? 1 : 0),
244!
430
              0,
431
            )
432
            return (
667✔
433
              myInfoFieldCount === 0 ||
667!
434
              ((this.authType === FormAuthType.MyInfo ||
435
                this.authType === FormAuthType.SGID_MyInfo) &&
436
                myInfoFieldCount <= 30)
437
            )
438
          },
439
          message:
440
            'Check that your form is MyInfo-authenticated and has 30 or fewer MyInfo fields.',
441
        },
442
      },
443
      form_logics: {
444
        type: [LogicSchema],
445
        validate: {
446
          validator(this: IFormSchema, v: ILogicSchema[]) {
447
            /**
448
             * A validatable condition is incomplete if there is a possibility
449
             * that its fieldType is null, which is a sign that a condition's
450
             * field property references a non-existent form_field.
451
             */
452
            type IncompleteValidatableCondition = {
453
              state: LogicConditionState
454
              fieldType?: BasicField
455
            }
456

457
            /**
458
             * A condition object is said to be validatable if it contains the two
459
             * necessary for validation: fieldType and state
460
             */
461
            type ValidatableCondition = IncompleteValidatableCondition & {
462
              fieldType: BasicField
463
            }
464

465
            const isConditionReferencesExistingField = (
660✔
466
              condition: IncompleteValidatableCondition,
467
            ): condition is ValidatableCondition => !!condition.fieldType
1✔
468

469
            const conditions = v.flatMap((logic) => {
660✔
470
              return logic.conditions.map<IncompleteValidatableCondition>(
33✔
471
                (condition) => {
472
                  const {
473
                    field,
474
                    state,
475
                  }: { field: ObjectId | string; state: LogicConditionState } =
476
                    condition
1✔
477
                  return {
1✔
478
                    state,
479
                    fieldType: this.form_fields?.find(
6!
480
                      (f: IFieldSchema) => String(f._id) === String(field),
×
481
                    )?.fieldType,
482
                  }
483
                },
484
              )
485
            })
486

487
            return conditions.every((condition) => {
660✔
488
              /**
489
               * Form fields can get deleted by form admins, which causes logic
490
               * conditions to reference invalid fields. Here we bypass validation
491
               * and allow these conditions to be saved, so we don't make life
492
               * difficult for form admins.
493
               */
494
              if (!isConditionReferencesExistingField(condition)) return true
1✔
495

496
              const { fieldType, state } = condition
×
497
              const applicableIfStates = getApplicableIfStates(fieldType)
×
498
              return applicableIfStates.includes(state)
×
499
            })
500
          },
501
          message: 'Form logic condition validation failed.',
502
        },
503
      },
504

505
      admin: {
506
        type: Schema.Types.ObjectId,
507
        ref: 'User',
508
        required: 'Form must have an Admin',
509
      },
510

511
      permissionList: {
512
        type: [
513
          {
514
            email: {
515
              type: String,
516
              trim: true,
517
              required: true,
518
              // Set email to lowercase for consistency
519
              set: (v: string) => v.toLowerCase(),
39✔
520
            },
521
            write: {
522
              type: Boolean,
523
              default: false,
524
            },
525
          },
526
        ],
527
        validate: {
528
          validator: (users: FormPermission[]) =>
529
            users.every((user) => !!user.email && isEmail(user.email)),
681✔
530
          message: 'Failed to update collaborators list.',
531
        },
532
      },
533

534
      startPage: {
535
        paragraph: String,
536
        estTimeTaken: Number,
537
        colorTheme: {
538
          type: String,
539
          enum: Object.values(FormColorTheme),
540
          default: FormColorTheme.Blue,
541
        },
542
        logo: {
543
          type: FormLogoSchema,
544
          default: () => ({}),
935✔
545
        },
546
        paragraphTranslations: {
547
          type: [
548
            {
549
              language: {
550
                type: String,
551
                enum: Object.values(Language),
552
              },
553
              translation: {
554
                type: String,
555
              },
556
            },
557
          ],
558
          default: [],
559
          _id: false,
560
        },
561
      },
562

563
      endPage: {
564
        title: {
565
          type: String,
566
          default: 'Thank you for filling out the form.',
567
        },
568
        paragraph: String,
569
        buttonLink: String,
570
        buttonText: {
571
          type: String,
572
          default: 'Submit another response',
573
        },
574
        paymentTitle: {
575
          type: String,
576
          default: 'Thank you, your payment has been made successfully.',
577
        },
578
        paymentParagraph: {
579
          type: String,
580
          default: 'Your form has been submitted and payment has been made.',
581
        },
582
        titleTranslations: {
583
          type: [
584
            {
585
              language: {
586
                type: String,
587
                enum: Object.values(Language),
588
              },
589
              translation: {
590
                type: String,
591
              },
592
            },
593
          ],
594
          default: [],
595
          _id: false,
596
        },
597
        paragraphTranslations: {
598
          type: [
599
            {
600
              language: {
601
                type: String,
602
                enum: Object.values(Language),
603
              },
604
              translation: {
605
                type: String,
606
              },
607
            },
608
          ],
609
          default: [],
610
          _id: false,
611
        },
612
      },
613

614
      hasCaptcha: {
615
        type: Boolean,
616
        default: true,
617
      },
618

619
      hasIssueNotification: {
620
        type: Boolean,
621
        default: true,
622
      },
623

624
      authType: {
625
        type: String,
626
        enum: Object.values(FormAuthType),
627
        default: FormAuthType.NIL,
628
        set: function (this: IFormSchema, v: FormAuthType) {
629
          // TODO (#1222): Convert to validator
630
          // Do not allow authType to be changed if form is published
631
          if (this.authType !== v && this.status === FormStatus.Public) {
1,015!
632
            return this.authType
×
633
          } else {
634
            return v
1,015✔
635
          }
636
        },
637
      },
638

639
      isSubmitterIdCollectionEnabled: {
640
        type: Boolean,
641
        default: false,
642
      },
643

644
      isSingleSubmission: {
645
        type: Boolean,
646
        default: false,
647
      },
648

649
      // This must be before `status` since `status` has setters reliant on
650
      // whether esrvcId is available, and mongoose@v6 now saves objects with keys
651
      // in the order the keys are specifified in the schema instead of the object.
652
      // See https://mongoosejs.com/docs/migrating_to_6.html#schema-defined-document-key-order.
653
      esrvcId: {
654
        type: String,
655
        required: false,
656
        validate: [/^\S*$/i, 'e-service ID must not contain whitespace'],
657
      },
658

659
      status: {
660
        type: String,
661
        enum: Object.values(FormStatus),
662
        default: FormStatus.Private,
663
        set: function (this: IFormSchema, v: FormStatus) {
664
          if (
1,095✔
665
            this.status === FormStatus.Private &&
1,384✔
666
            v === FormStatus.Public &&
667
            this.authType !== FormAuthType.NIL &&
668
            !this.esrvcId
669
          ) {
670
            return FormStatus.Private
1✔
671
          }
672

673
          return v
1,094✔
674
        },
675
      },
676

677
      // The subtext of the message shown on the error page if it is deactivated -
678
      // the header is "{{ title }} is not available."
679
      inactiveMessage: {
680
        type: String,
681
        default:
682
          'If you require further assistance, please contact the agency that gave you the form link.',
683
      },
684

685
      isListed: {
686
        type: Boolean,
687
        default: true,
688
      },
689

690
      webhook: {
691
        url: {
692
          type: String,
693
          default: '',
694
          validate: {
695
            validator: async (v: string) => !v || validateWebhookUrl(v),
663✔
696
            message:
697
              'Webhook must be a valid URL over HTTPS and point to a public IP.',
698
          },
699
        },
700
        isRetryEnabled: {
701
          type: Boolean,
702
          default: false,
703
        },
704
      },
705

706
      /**
707
       * LEGACY: Was previously used for sending with the correct Twilio.
708
       * @deprecated Twilio support is removed and replaced with postman-sms.
709
       * This is retained since DB records may still contain this field for backward compatibility.
710
       */
711
      msgSrvcName: {
712
        // Name of credentials for messaging service, stored in secrets manager
713
        type: String,
714
        required: false,
715
      },
716

717
      submissionLimit: {
718
        type: Number,
719
        default: null,
720
        min: 1,
721
      },
722

723
      goLinkSuffix: {
724
        // GoGov link suffix
725
        type: String,
726
        required: false,
727
        default: '',
728
      },
729

730
      metadata: {
731
        type: Object,
732
        required: false,
733
      },
734
      // boolean value to indicate if form supports multi
735
      // language
736
      hasMultiLang: {
737
        type: Boolean,
738
        required: false,
739
        default: false,
740
      },
741

742
      // languages that is supported by form for translations
743
      supportedLanguages: {
744
        type: [String],
745
        enum: Object.values(Language),
746
        require: false,
747
      },
748
    },
749
    formSchemaOptions,
750
  )
751

752
  // Add discriminators for the various field types.
753
  const FormFieldPath = FormSchema.path(
75✔
754
    'form_fields',
755
  ) as Schema.Types.DocumentArray
756

757
  const TableFieldSchema = createTableFieldSchema()
75✔
758

759
  FormFieldPath.discriminator(BasicField.Email, createEmailFieldSchema())
75✔
760
  FormFieldPath.discriminator(BasicField.Rating, createRatingFieldSchema())
75✔
761
  FormFieldPath.discriminator(
75✔
762
    BasicField.Attachment,
763
    createAttachmentFieldSchema(),
764
  )
765
  FormFieldPath.discriminator(BasicField.Dropdown, createDropdownFieldSchema())
75✔
766
  FormFieldPath.discriminator(
75✔
767
    BasicField.CountryRegion,
768
    createCountryRegionFieldSchema(),
769
  )
770
  FormFieldPath.discriminator(
75✔
771
    BasicField.Children,
772
    createchildrenCompoundFieldSchema(),
773
  )
774
  FormFieldPath.discriminator(BasicField.Radio, createRadioFieldSchema())
75✔
775
  FormFieldPath.discriminator(BasicField.Checkbox, createCheckboxFieldSchema())
75✔
776
  FormFieldPath.discriminator(
75✔
777
    BasicField.ShortText,
778
    createShortTextFieldSchema(),
779
  )
780
  FormFieldPath.discriminator(BasicField.HomeNo, createHomenoFieldSchema())
75✔
781
  FormFieldPath.discriminator(BasicField.Mobile, createMobileFieldSchema())
75✔
782
  FormFieldPath.discriminator(BasicField.LongText, createLongTextFieldSchema())
75✔
783
  FormFieldPath.discriminator(BasicField.Number, createNumberFieldSchema())
75✔
784
  FormFieldPath.discriminator(BasicField.Decimal, createDecimalFieldSchema())
75✔
785
  FormFieldPath.discriminator(BasicField.Image, createImageFieldSchema())
75✔
786
  FormFieldPath.discriminator(BasicField.Date, createDateFieldSchema())
75✔
787
  FormFieldPath.discriminator(BasicField.Nric, createNricFieldSchema())
75✔
788
  FormFieldPath.discriminator(BasicField.Uen, createUenFieldSchema())
75✔
789
  FormFieldPath.discriminator(BasicField.YesNo, createYesNoFieldSchema())
75✔
790
  FormFieldPath.discriminator(
75✔
791
    BasicField.Statement,
792
    createStatementFieldSchema(),
793
  )
794
  FormFieldPath.discriminator(BasicField.Section, createSectionFieldSchema())
75✔
795
  FormFieldPath.discriminator(BasicField.Table, TableFieldSchema)
75✔
796
  const TableColumnPath = TableFieldSchema.path(
75✔
797
    'columns',
798
  ) as Schema.Types.DocumentArray
799
  TableColumnPath.discriminator(
75✔
800
    BasicField.ShortText,
801
    createShortTextFieldSchema(),
802
  )
803
  TableColumnPath.discriminator(
75✔
804
    BasicField.Dropdown,
805
    createDropdownFieldSchema(),
806
  )
807

808
  // Discriminator defines all possible values of startPage.logo
809
  const StartPageLogoPath = FormSchema.path(
75✔
810
    'startPage.logo',
811
  ) as Schema.Types.DocumentArray
812
  StartPageLogoPath.discriminator(FormLogoState.Custom, CustomFormLogoSchema)
75✔
813

814
  // Discriminator defines different logic types
815
  const FormLogicPath = FormSchema.path(
75✔
816
    'form_logics',
817
  ) as Schema.Types.DocumentArray
818

819
  FormLogicPath.discriminator(LogicType.ShowFields, ShowFieldsLogicSchema)
75✔
820
  FormLogicPath.discriminator(LogicType.PreventSubmit, PreventSubmitLogicSchema)
75✔
821

822
  // Methods
823

824
  // Method to return myInfo attributes
825
  FormSchema.method<IFormSchema>(
75✔
826
    'getUniqueMyInfoAttrs',
827
    function getUniqueMyInfoAttrs() {
828
      if (
93✔
829
        this.authType !== FormAuthType.MyInfo &&
181✔
830
        this.authType !== FormAuthType.SGID_MyInfo
831
      ) {
832
        return []
88✔
833
      }
834

835
      // Compact is used to remove undefined from array
836
      return compact(
5✔
837
        uniq(
838
          this.form_fields?.flatMap((field) => {
15!
839
            return getMyInfoAttr(field)
×
840
          }),
841
        ),
842
      )
843
    },
844
  )
845

846
  // Return essential form creation parameters with the given properties
847
  FormSchema.methods.getDuplicateParams = function (
75✔
848
    overrideProps: OverrideProps,
849
  ) {
850
    const newForm = pick(this, [
5✔
851
      'form_fields',
852
      'form_logics',
853
      'startPage',
854
      'endPage',
855
      'authType',
856
      'isSubmitterIdCollectionEnabled',
857
      'isSingleSubmission',
858
      'inactiveMessage',
859
      'responseMode',
860
      'submissionLimit',
861
    ]) as PickDuplicateForm
862
    return { ...newForm, ...overrideProps }
5✔
863
  }
864

865
  // Archives form.
866
  FormSchema.methods.archive = function () {
75✔
867
    // Return instantly when form is already archived.
868
    if (this.status === FormStatus.Archived) {
6✔
869
      return Promise.resolve(this)
1✔
870
    }
871

872
    this.status = FormStatus.Archived
5✔
873
    return this.save()
5✔
874
  }
875

876
  const FormDocumentSchema = FormSchema as unknown as Schema<IFormDocument>
75✔
877

878
  FormDocumentSchema.methods.getDashboardView = function (
75✔
879
    admin: IPopulatedUser,
880
  ) {
881
    return {
5✔
882
      _id: this._id,
883
      title: this.title,
884
      status: this.status,
885
      lastModified: this.lastModified,
886
      responseMode: this.responseMode,
887
      admin,
888
    }
889
  }
890

891
  FormDocumentSchema.method<IFormDocument>(
75✔
892
    'getSettings',
893
    function (): FormSettings {
894
      switch (this.responseMode) {
6!
895
        case FormResponseMode.Email:
896
          return pick(this, EMAIL_FORM_SETTINGS_FIELDS) as EmailFormSettings
2✔
897
        case FormResponseMode.Encrypt:
898
          return pick(this, STORAGE_FORM_SETTINGS_FIELDS) as StorageFormSettings
4✔
899
        case FormResponseMode.Multirespondent:
900
          return pick(
×
901
            this,
902
            MULTIRESPONDENT_FORM_SETTINGS_FIELDS,
903
          ) as MultirespondentFormSettings
904
      }
905
    },
906
  )
907

908
  FormDocumentSchema.methods.getWebhookAndResponseModeSettings =
75✔
909
    function (): FormWebhookSettings {
910
      const formSettings = pick(
×
911
        this,
912
        WEBHOOK_SETTINGS_FIELDS,
913
      ) as FormWebhookResponseModeSettings
914
      return formSettings
×
915
    }
916

917
  FormDocumentSchema.method<IFormDocument>(
75✔
918
    'getPublicView',
919
    function (): PublicForm {
920
      let basePublicView
921
      switch (this.responseMode) {
18!
922
        case FormResponseMode.Encrypt:
923
          basePublicView = pick(this, STORAGE_PUBLIC_FORM_FIELDS) as PublicForm
4✔
924
          break
4✔
925
        case FormResponseMode.Email:
926
          basePublicView = pick(this, EMAIL_PUBLIC_FORM_FIELDS) as PublicForm
14✔
927
          break
14✔
928
        case FormResponseMode.Multirespondent:
929
          basePublicView = pick(
×
930
            this,
931
            MULTIRESPONDENT_PUBLIC_FORM_FIELDS,
932
          ) as PublicForm
933
          break
×
934
      }
935

936
      // Return non-populated public fields of form if not populated.
937
      if (!this.populated('admin')) {
18✔
938
        return basePublicView
2✔
939
      }
940

941
      // Populated, return public view with user's public view.
942
      return {
16✔
943
        ...basePublicView,
944
        admin: (this.admin as IUserSchema).getPublicView(),
945
      }
946
    },
947
  )
948

949
  // Transfer ownership of the form to another user
950
  FormDocumentSchema.method<IFormDocument>(
75✔
951
    'transferOwner',
952
    async function transferOwner(
953
      currentOwner: IUserSchema,
954
      newOwner: IUserSchema,
955
    ) {
956
      // Update form's admin to new owner's id.
957
      this.admin = newOwner._id
2✔
958

959
      // Remove new owner from perm list and include previous owner as an editor.
960
      this.permissionList = this.permissionList.filter(
2✔
961
        (item) => item.email !== newOwner.email,
×
962
      )
963
      this.permissionList.push({ email: currentOwner.email, write: true })
2✔
964

965
      return this.save()
2✔
966
    },
967
  )
968

969
  // Transfer ownership of multiple forms to another user
970
  FormSchema.statics.transferAllFormsToNewOwner = async function (
75✔
971
    currentOwner: IUserSchema,
972
    newOwner: IUserSchema,
973
  ) {
974
    return this.updateMany(
1✔
975
      {
976
        admin: currentOwner._id,
977
      },
978
      {
979
        $set: {
980
          admin: newOwner._id,
981
        },
982
        $addToSet: {
983
          permissionList: { email: currentOwner.email, write: true },
984
        },
985
      },
986
    ).exec()
987
  }
988

989
  // Add form collaborator
990
  FormSchema.statics.removeNewOwnerFromPermissionListForAllCurrentOwnerForms =
75✔
991
    async function (currentOwner: IUserSchema, newOwner: IUserSchema) {
992
      return this.updateMany(
2✔
993
        {
994
          admin: currentOwner._id,
995
        },
996
        {
997
          $pull: {
998
            permissionList: {
999
              email: { $in: [newOwner.email] },
1000
            },
1001
          },
1002
        },
1003
      ).exec()
1004
    }
1005

1006
  FormDocumentSchema.methods.updateFormCollaborators = async function (
75✔
1007
    updatedPermissions: FormPermission[],
1008
  ) {
1009
    this.permissionList = updatedPermissions
3✔
1010
    return this.save()
3✔
1011
  }
1012

1013
  FormDocumentSchema.methods.updateFormFieldById = function (
75✔
1014
    fieldId: string,
1015
    newField: FormFieldDto,
1016
  ) {
1017
    const fieldToUpdate = getFormFieldById(this.form_fields, fieldId)
6✔
1018
    if (!fieldToUpdate) return Promise.resolve(null)
6✔
1019

1020
    if (fieldToUpdate.fieldType !== newField.fieldType) {
5✔
1021
      this.invalidate('form_fields', 'Changing form field type is not allowed')
1✔
1022
    } else {
1023
      fieldToUpdate.set(newField)
4✔
1024
    }
1025

1026
    return this.save()
5✔
1027
  }
1028

1029
  FormDocumentSchema.methods.insertFormField = function (
75✔
1030
    newField: FormField,
1031
    to?: number,
1032
  ) {
1033
    const formFields = this.form_fields as Types.DocumentArray<IFieldSchema>
11✔
1034
    // Must use undefined check since number can be 0; i.e. falsey.
1035
    if (to !== undefined) {
11✔
1036
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
1037
      formFields.splice(to, 0, newField as any) // Typings are not complete for splice.
3✔
1038
    } else {
1039
      formFields.push(newField)
8✔
1040
    }
1041
    return this.save()
11✔
1042
  }
1043

1044
  FormDocumentSchema.methods.insertFormFields = function (
75✔
1045
    newFields: FormField[],
1046
    to?: number,
1047
  ) {
1048
    const formFields = this.form_fields as Types.DocumentArray<IFieldSchema>
×
1049
    // Must use undefined check since number can be 0; i.e. falsey.
1050
    if (to !== undefined) {
×
1051
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
1052
      formFields.splice(to, 0, ...(newFields as any[])) // Typings are not complete for splice.
×
1053
    } else {
1054
      formFields.push(...newFields)
×
1055
    }
1056
    return this.save()
×
1057
  }
1058

1059
  FormDocumentSchema.method<IFormDocument>(
75✔
1060
    'duplicateFormFieldByIdAndIndex',
1061
    function (fieldId: string, insertionIndex: number) {
1062
      const fieldToDuplicate = getFormFieldById(this.form_fields, fieldId)
6✔
1063
      if (!fieldToDuplicate) return Promise.resolve(null)
6✔
1064
      const duplicatedField = omit(fieldToDuplicate, [
4✔
1065
        '_id',
1066
        'globalId',
1067
      ]) as FormFieldSchema
1068

1069
      this.form_fields.splice(insertionIndex, 0, duplicatedField)
4✔
1070
      return this.save()
4✔
1071
    },
1072
  )
1073

1074
  FormDocumentSchema.method<IFormDocument>(
75✔
1075
    'reorderFormFieldById',
1076
    function reorderFormFieldById(
1077
      fieldId: string,
1078
      newPosition: number,
1079
    ): Promise<IFormDocument | null> {
1080
      const existingFieldPosition = this.form_fields.findIndex(
3✔
1081
        (f) => String(f._id) === fieldId,
7✔
1082
      )
1083

1084
      if (existingFieldPosition === -1) return Promise.resolve(null)
3✔
1085

1086
      // Exist, reorder form fields and save.
1087
      const updatedFormFields = reorder(
2✔
1088
        this.form_fields,
1089
        existingFieldPosition,
1090
        newPosition,
1091
      )
1092
      this.form_fields = updatedFormFields
2✔
1093
      return this.save()
2✔
1094
    },
1095
  )
1096

1097
  // Statics
1098
  // Method to retrieve data for OTP verification
1099
  FormSchema.statics.getOtpData = async function (formId: string) {
75✔
1100
    try {
3✔
1101
      const data = await this.findById(formId, 'admin').populate({
3✔
1102
        path: 'admin',
1103
        select: 'email',
1104
      })
1105
      return data
3✔
1106
        ? ({
1107
            form: data._id,
1108
            formAdmin: {
1109
              email: data.admin.email,
1110
              userId: data.admin._id,
1111
            },
1112
          } as FormOtpData)
1113
        : null
1114
    } catch {
1115
      return null
×
1116
    }
1117
  }
1118

1119
  // Returns the form with populated admin details
1120
  FormSchema.statics.getFullFormById = async function (
75✔
1121
    formId: string,
1122
    fields?: (keyof IPopulatedForm)[],
1123
  ): Promise<IPopulatedForm | null> {
1124
    return this.findById(formId, fields).populate({
259✔
1125
      path: 'admin',
1126
      populate: {
1127
        path: 'agency',
1128
      },
1129
    }) as Query<IPopulatedForm, IFormDocument>
1130
  }
1131

1132
  // Deactivate form by ID
1133
  FormSchema.statics.deactivateById = async function (
75✔
1134
    formId: string,
1135
  ): Promise<IFormSchema | null> {
1136
    const form = await this.findById(formId)
5✔
1137
    if (!form) return null
5✔
1138
    if (form.status === FormStatus.Public) {
3✔
1139
      form.status = FormStatus.Private
2✔
1140
    }
1141
    return form.save()
3✔
1142
  }
1143

1144
  FormDocumentSchema.statics.getMetaByUserIdOrEmail = async function (
75✔
1145
    userId: IUserSchema['_id'],
1146
    userEmail: IUserSchema['email'],
1147
  ): Promise<AdminDashboardFormMetaDto[]> {
1148
    return (
4✔
1149
      this.find()
1150
        // List forms when either the user is an admin or collaborator.
1151
        .or([
1152
          { 'permissionList.email': userEmail.toLowerCase() },
1153
          { admin: userId },
1154
        ])
1155
        // Filter out archived forms.
1156
        .where('status')
1157
        .ne(FormStatus.Archived)
1158
        // Project selected fields.
1159
        // `responseMode` is a discriminator key and is returned regardless,
1160
        // selection is made for explicitness.
1161
        // `_id` is also returned regardless and selection is made for
1162
        // explicitness.
1163
        .select(ADMIN_FORM_META_FIELDS.join(' '))
1164
        .sort('-lastModified')
1165
        .populate({
1166
          path: 'admin',
1167
          populate: {
1168
            path: 'agency',
1169
          },
1170
        })
1171
        .lean()
1172
        .exec()
1173
    )
1174
  }
1175

1176
  // Get all forms owned by the specified user ID.
1177
  FormDocumentSchema.statics.retrieveFormsOwnedByUserId = async function (
75✔
1178
    userId: IUserSchema['_id'],
1179
  ): Promise<AdminDashboardFormMetaDto[]> {
1180
    return (
×
1181
      this.find()
1182
        // List forms when either the user is an admin only.
1183
        .where('admin')
1184
        .eq(userId)
1185
        // Project selected fields.
1186
        // `responseMode` is a discriminator key and is returned regardless,
1187
        // selection is made for explicitness.
1188
        // `_id` is also returned regardless and selection is made for
1189
        // explicitness.
1190
        .select(ADMIN_FORM_META_FIELDS.join(' '))
1191
        .sort('-lastModified')
1192
        .populate({
1193
          path: 'admin',
1194
          populate: {
1195
            path: 'agency',
1196
          },
1197
        })
1198
        .lean()
1199
        .exec()
1200
    )
1201
  }
1202

1203
  // Deletes specified form logic.
1204
  FormSchema.statics.deleteFormLogic = async function (
75✔
1205
    formId: string,
1206
    logicId: string,
1207
  ): Promise<IFormSchema | null> {
1208
    return this.findByIdAndUpdate(
7✔
1209
      formId,
1210
      {
1211
        $pull: { form_logics: { _id: logicId } },
1212
      },
1213
      {
1214
        new: true,
1215
        runValidators: true,
1216
      },
1217
    ).exec()
1218
  }
1219

1220
  // Creates specified form logic.
1221
  FormSchema.statics.createFormLogic = async function (
75✔
1222
    formId: string,
1223
    createLogicBody: LogicDto,
1224
  ): Promise<IFormSchema | null> {
1225
    const form = await this.findById(formId).exec()
5✔
1226
    if (!form?.form_logics) return null
5✔
1227
    const newLogic = (
1228
      form.form_logics as Types.DocumentArray<FormLogicSchema>
4✔
1229
    ).create(createLogicBody)
1230
    form.form_logics.push(newLogic)
4✔
1231
    return form.save()
4✔
1232
  }
1233

1234
  // Deletes specified form field by id.
1235
  FormSchema.statics.deleteFormFieldById = async function (
75✔
1236
    formId: string,
1237
    fieldId: string,
1238
  ): Promise<IFormSchema | null> {
1239
    return this.findByIdAndUpdate(
2✔
1240
      formId,
1241
      { $pull: { form_fields: { _id: fieldId } } },
1242
      { new: true, runValidators: true },
1243
    ).exec()
1244
  }
1245

1246
  // Updates specified form logic.
1247
  FormSchema.statics.updateFormLogic = async function (
75✔
1248
    formId: string,
1249
    logicId: string,
1250
    updatedLogic: LogicDto,
1251
  ): Promise<IFormSchema | null> {
1252
    let form = await this.findById(formId).exec()
6✔
1253
    if (!form?.form_logics) return null
6✔
1254
    const index = form.form_logics.findIndex(
5✔
1255
      (logic) => String(logic._id) === logicId,
5✔
1256
    )
1257
    form = form.set(`form_logics.${index}`, updatedLogic, {
5✔
1258
      new: true,
1259
    })
1260
    return form.save()
5✔
1261
  }
1262

1263
  FormSchema.statics.updateEndPageById = async function (
75✔
1264
    formId: string,
1265
    newEndPage: FormEndPage,
1266
  ) {
1267
    return this.findByIdAndUpdate(
5✔
1268
      formId,
1269
      { endPage: newEndPage },
1270
      { new: true, runValidators: true },
1271
    ).exec()
1272
  }
1273

1274
  FormSchema.statics.updateStartPageById = async function (
75✔
1275
    formId: string,
1276
    newStartPage: FormStartPage,
1277
  ) {
1278
    return this.findByIdAndUpdate(
4✔
1279
      formId,
1280
      { startPage: newStartPage },
1281
      { new: true, runValidators: true },
1282
    ).exec()
1283
  }
1284

1285
  FormSchema.statics.updatePaymentsById = async function (
75✔
1286
    formId: string,
1287
    newPayments: FormPaymentsField,
1288
  ) {
1289
    return this.findByIdAndUpdate(
1✔
1290
      formId,
1291
      { payments_field: newPayments },
1292
      { new: true, runValidators: true },
1293
    ).exec()
1294
  }
1295

1296
  FormSchema.statics.updatePaymentsProductById = async function (
75✔
1297
    formId: string,
1298
    newProducts: FormPaymentsField['products'],
1299
  ) {
1300
    return this.findByIdAndUpdate(
×
1301
      formId,
1302
      { 'payments_field.products': newProducts },
1303
      { new: true, runValidators: true },
1304
    ).exec()
1305
  }
1306

1307
  FormSchema.statics.getGoLinkSuffix = async function (formId: string) {
75✔
1308
    return this.findById(formId, 'goLinkSuffix').exec()
×
1309
  }
1310

1311
  FormSchema.statics.setGoLinkSuffix = async function (
75✔
1312
    formId: string,
1313
    linkSuffix: string,
1314
  ) {
1315
    return this.findByIdAndUpdate(
×
1316
      formId,
1317
      { goLinkSuffix: linkSuffix },
1318
      { new: true, runValidators: true },
1319
    ).exec()
1320
  }
1321

1322
  FormSchema.statics.archiveForms = async function (
75✔
1323
    formIds: IFormSchema['_id'][],
1324
    session?: ClientSession,
1325
  ) {
1326
    return await this.updateMany(
1✔
1327
      { _id: { $in: formIds } },
1328
      { status: FormStatus.Archived },
1329
      { session },
1330
    ).read('primary')
1331
  }
1332

1333
  // Hooks
1334
  FormSchema.pre<IFormSchema>('validate', function (next) {
75✔
1335
    // Reject save if form document is too large
1336
    if (calculateObjectSize(this) > 10 * MB) {
688!
1337
      const err = new Error('Form size exceeded.')
×
1338
      err.name = 'FormSizeError'
×
1339
      return next(err)
×
1340
    }
1341

1342
    // Webhooks only allowed if encrypt mode
1343
    if (
688✔
1344
      this.responseMode !== FormResponseMode.Encrypt &&
1,110✔
1345
      (this.webhook?.url?.length ?? 0) > 0
3,798!
1346
    ) {
1347
      const validationError = this.invalidate(
1✔
1348
        'webhook',
1349
        'Webhook only allowed on storage mode form',
1350
      ) as mongoose.Error.ValidationError
1351
      return next(validationError)
1✔
1352
    }
1353

1354
    // Validate that admin exists before form is created.
1355
    return User.findById(this.admin).then((admin) => {
687✔
1356
      if (!admin) {
687✔
1357
        const validationError = this.invalidate(
6✔
1358
          'admin',
1359
          'Admin for this form is not found.',
1360
        ) as mongoose.Error.ValidationError
1361
        return next(validationError)
6✔
1362
      }
1363

1364
      // Remove admin from the permission list if they exist.
1365
      // This prevents the form owner from being both an admin and another role.
1366
      this.permissionList = this.permissionList?.filter(
681!
1367
        (item) => item.email !== admin.email,
30✔
1368
      )
1369

1370
      return next()
681✔
1371
    })
1372
  })
1373

1374
  // Indexes
1375
  // Provide an index to allow text search for form examples
1376
  FormSchema.index({
75✔
1377
    'startPage.paragraph': 'text',
1378
    title: 'text',
1379
  })
1380

1381
  FormSchema.index({
75✔
1382
    'permissionList.email': 1,
1383
    lastModified: -1,
1384
  })
1385

1386
  FormSchema.index({
75✔
1387
    admin: 1,
1388
    lastModified: -1,
1389
  })
1390

1391
  const FormModel = db.model<IFormSchema, IFormModel>(
75✔
1392
    FORM_SCHEMA_ID,
1393
    FormSchema,
1394
  )
1395

1396
  // Adding form discriminators
1397
  FormModel.discriminator(FormResponseMode.Email, EmailFormSchema)
75✔
1398
  FormModel.discriminator(FormResponseMode.Encrypt, EncryptedFormSchema)
75✔
1399
  FormModel.discriminator(
75✔
1400
    FormResponseMode.Multirespondent,
1401
    MultirespondentFormSchema,
1402
  )
1403

1404
  return FormModel
75✔
1405
}
1406

1407
const getFormModel = (db: Mongoose): IFormModel => {
139✔
1408
  try {
583✔
1409
    return db.model(FORM_SCHEMA_ID) as IFormModel
583✔
1410
  } catch {
1411
    return compileFormModel(db)
75✔
1412
  }
1413
}
1414

1415
export const getEmailFormModel = (db: Mongoose): IEmailFormModel => {
139✔
1416
  // Load or build base model first
1417
  getFormModel(db)
244✔
1418
  return db.model(FormResponseMode.Email) as IEmailFormModel
244✔
1419
}
1420

1421
export const getEncryptedFormModel = (db: Mongoose): IEncryptedFormModel => {
139✔
1422
  // Load or build base model first
1423
  getFormModel(db)
175✔
1424
  return db.model(FormResponseMode.Encrypt) as IEncryptedFormModel
175✔
1425
}
1426

1427
export const getMultirespondentFormModel = (
139✔
1428
  db: Mongoose,
1429
): IMultirespondentFormModel => {
1430
  // Load or build base model first
1431
  getFormModel(db)
56✔
1432
  return db.model(FormResponseMode.Multirespondent) as IMultirespondentFormModel
56✔
1433
}
1434

1435
export default getFormModel
139✔
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

© 2026 Coveralls, Inc