diff --git a/scripts/20210628_create-isOnboardedAcc/create-isOnboardedAcc.js b/scripts/20210628_create-isOnboardedAcc/create-isOnboardedAcc.js new file mode 100644 index 0000000000..f1660b83ba --- /dev/null +++ b/scripts/20210628_create-isOnboardedAcc/create-isOnboardedAcc.js @@ -0,0 +1,63 @@ +/* eslint-disable */ + +/* +This script creates a new key, isOnboardedAccount, on the smscounts db which indexes the msgSrvcId variable +*/ + +let formTwilioId = 'insert the form twilio id here' + +// == PRE-UPDATE CHECKS == +// Count the number of verifications (A) +db.getCollection('smscounts').count({ smsType: 'VERIFICATION' }) + +// Count of forms using our twilio acc (B) +db.getCollection('smscounts').count({ + smsType: 'VERIFICATION', +msgSrvcSid: {$eq: formTwilioId}, +}) + +// Count of forms using their own twilio acc (C) +// A === B + C +db.getCollection('smscounts').count({ + smsType: 'VERIFICATION', + msgSrvcSid: { $ne: formTwilioId }, +}) + + +// == UPDATE == +// Update verifications which have message service id equal to form twilio id +db.getCollection('smscounts').updateMany( + { smsType: 'VERIFICATION', msgSrvcSid: { $eq: formTwilioId } }, + { + $set: { + isOnboardedAccount: false, + }, + } +) + +// Update verifications whose message service id is not equal to form twilio id +db.getCollection('smscounts').updateMany( + { smsType: 'VERIFICATION', msgSrvcSid: { $ne: formTwilioId } }, + { + $set: { + isOnboardedAccount: true, + }, + } +) + +// == POST-UPDATE CHECKS == +// Check number of verifications updated +// Sum of these two should be equal to the initial count +// Count of forms using our twilio acc +db.getCollection('smscounts').count({ + smsType: 'VERIFICATION', + msgSrvcSid: { $eq: formTwilioId }, + isOnboardedAccount: false +}) + +// Count of forms using their own twilio acc +db.getCollection('smscounts').count({ + smsType: 'VERIFICATION', + msgSrvcSid: { $ne: formTwilioId }, + isOnboardedAccount: true +}) diff --git a/scripts/20210628_create-isOnboardedAcc/delete-isOnboardedAcc.js b/scripts/20210628_create-isOnboardedAcc/delete-isOnboardedAcc.js new file mode 100644 index 0000000000..9b2492ca01 --- /dev/null +++ b/scripts/20210628_create-isOnboardedAcc/delete-isOnboardedAcc.js @@ -0,0 +1,30 @@ +/* eslint-disable */ + +/* +This script creates a new key, isOnboardedAccount, on the smscounts db which indexes the msgSrvcId variable +*/ + +// == PRE-UPDATE CHECKS == +// Count the number of verifications we have to update +db.getCollection('smscounts').count({ smsType: 'VERIFICATION' }) + +// == UPDATE == +// Update verified smses +db.getCollection('smscounts').updateMany( + { smsType: 'VERIFICATION' }, + { + $unset: { + isOnboardedAccount: false, + }, + } +) + +// == POST-UPDATE CHECKS == +// Check number of verifications updated +// SHOULD BE 0 +db.getCollection('smscounts').count({ + smsType: 'VERIFICATION', + isOnboardedAccount: { + $exists: true + } +}) \ No newline at end of file diff --git a/shared/utils/verification.ts b/shared/utils/verification.ts index a3467c70ba..e9d280dca1 100644 --- a/shared/utils/verification.ts +++ b/shared/utils/verification.ts @@ -21,3 +21,23 @@ export enum VfnErrors { TransactionNotFound = 'TRANSACTION_NOT_FOUND', InvalidMobileNumber = 'INVALID_MOBILE_NUMBER', } + +export enum ADMIN_VERIFIED_SMS_STATES { + limitExceeded = 'LIMIT_EXCEEDED', + belowLimit = 'BELOW_LIMIT', + hasMessageServiceId = 'MESSAGE_SERVICE_ID_OBTAINED', +} + +export enum SMS_WARNING_TIERS { + LOW = 2500, + MED = 5000, + HIGH = 7500, +} + +export const stringifiedSmsWarningTiers: { + [K in keyof typeof SMS_WARNING_TIERS]: string +} = { + LOW: '2.5K', + MED: '5K', + HIGH: '7.5K', +} diff --git a/src/app/models/__tests__/form.server.model.spec.ts b/src/app/models/__tests__/form.server.model.spec.ts index e6d4b118d5..49e0b508b4 100644 --- a/src/app/models/__tests__/form.server.model.spec.ts +++ b/src/app/models/__tests__/form.server.model.spec.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/ban-ts-comment */ import { ObjectId } from 'bson-ext' -import { cloneDeep, map, merge, omit, orderBy, pick } from 'lodash' +import { cloneDeep, map, merge, omit, orderBy, pick, range } from 'lodash' import mongoose, { Types } from 'mongoose' import { EMAIL_PUBLIC_FORM_FIELDS, @@ -1802,6 +1802,205 @@ describe('Form Model', () => { await expect(Form.countDocuments()).resolves.toEqual(0) }) }) + + describe('disableSmsVerificationsForUser', () => { + const MOCK_MSG_SRVC_NAME = 'mockTwilioId' + it('should disable sms verifications for all forms belonging to a user that are not onboarded successfully', async () => { + // Arrange + const mockFormPromises = range(3).map((_, idx) => { + const isOnboarded = !!(idx % 2) + return Form.create({ + admin: populatedAdmin._id, + responseMode: ResponseMode.Email, + title: 'mock mobile form', + emails: [populatedAdmin.email], + ...(isOnboarded && { msgSrvcName: MOCK_MSG_SRVC_NAME }), + form_fields: [ + generateDefaultField(BasicField.Mobile, { isVerifiable: true }), + ], + }) + }) + await Promise.all(mockFormPromises) + + // Act + await Form.disableSmsVerificationsForUser(populatedAdmin._id) + + // Assert + // Find all forms that match admin id + // All forms with msgSrvcName have been using their own credentials + // They should not have verifications disabled. + const onboardedForms = await Form.find({ + admin: populatedAdmin._id, + msgSrvcName: { + $exists: true, + }, + }) + onboardedForms.map(({ form_fields }) => + form_fields!.map((field) => { + expect(field.isVerifiable).toBe(true) + }), + ) + + // Conversely, forms without msgSrvcName are using our credentials + // And should have their verifications disabled. + const notOnboardedForms = await Form.find({ + admin: populatedAdmin._id, + msgSrvcName: { + $exists: false, + }, + }) + + notOnboardedForms.map(({ form_fields }) => + form_fields!.map((field) => { + expect(field.isVerifiable).toBe(false) + }), + ) + }) + + it('should not disable non mobile fields for a user', async () => { + // Arrange + const mockFormPromises = range(3).map(() => { + return Form.create({ + admin: populatedAdmin._id, + responseMode: ResponseMode.Email, + title: 'mock email form', + emails: [populatedAdmin.email], + form_fields: [ + generateDefaultField(BasicField.Email, { isVerifiable: true }), + ], + }) + }) + await Promise.all(mockFormPromises) + + // Act + await Form.disableSmsVerificationsForUser(populatedAdmin._id) + + // Assert + // Find all forms that match admin id + const updatedForms = await Form.find({ admin: populatedAdmin._id }) + updatedForms.map(({ form_fields }) => + form_fields!.map((field) => { + expect(field.isVerifiable).toBe(true) + }), + ) + }) + + it('should only disable sms verifications for a particular user', async () => { + // Arrange + const MOCK_USER_ID = new ObjectId() + await dbHandler.insertFormCollectionReqs({ + userId: MOCK_USER_ID, + mailDomain: 'something.com', + }) + await Form.create({ + admin: populatedAdmin._id, + responseMode: ResponseMode.Email, + title: 'mock email form', + emails: [populatedAdmin.email], + form_fields: [ + generateDefaultField(BasicField.Mobile, { isVerifiable: true }), + ], + }) + await Form.create({ + admin: MOCK_USER_ID, + responseMode: ResponseMode.Email, + title: 'mock email form', + emails: [populatedAdmin.email], + form_fields: [ + generateDefaultField(BasicField.Email, { isVerifiable: true }), + ], + }) + + // Act + await Form.disableSmsVerificationsForUser(populatedAdmin._id) + + // Assert + // Find all forms that match admin id + const updatedMobileForm = await Form.find({ admin: populatedAdmin._id }) + expect(updatedMobileForm[0]!.form_fields[0].isVerifiable).toBe(false) + const updatedEmailForm = await Form.find({ admin: MOCK_USER_ID }) + expect(updatedEmailForm[0]!.form_fields[0].isVerifiable).toBe(true) + }) + + it('should not update when a db error occurs', async () => { + // Arrange + const disableSpy = jest.spyOn(Form, 'disableSmsVerificationsForUser') + disableSpy.mockResolvedValueOnce(new Error('tee hee db crashed')) + const mockFormPromises = range(3).map(() => { + return Form.create({ + admin: populatedAdmin._id, + responseMode: ResponseMode.Email, + title: 'mock mobile form', + emails: [populatedAdmin.email], + form_fields: [ + generateDefaultField(BasicField.Mobile, { isVerifiable: true }), + ], + }) + }) + await Promise.all(mockFormPromises) + + // Act + await Form.disableSmsVerificationsForUser(populatedAdmin._id) + + // Assert + // Find all forms that match admin id + const updatedForms = await Form.find({ admin: populatedAdmin._id }) + updatedForms.map(({ form_fields }) => + form_fields!.map((field) => { + expect(field.isVerifiable).toBe(true) + }), + ) + }) + }) + + describe('retrievePublicFormsWithSmsVerification', () => { + const MOCK_MSG_SRVC_NAME = 'mockTwilioName' + it('should retrieve only public forms with verifiable mobile fields that are not onboarded', async () => { + // Arrange + const mockFormPromises = range(8).map((_, idx) => { + // Extract bits and use them to represent state + const isPublic = !!(idx % 2) + const isVerifiable = !!((idx >> 1) % 2) + const isOnboarded = !!((idx >> 2) % 2) + return Form.create({ + admin: populatedAdmin._id, + responseMode: ResponseMode.Email, + title: 'mock mobile form', + emails: [populatedAdmin.email], + status: isPublic ? Status.Public : Status.Private, + ...(isOnboarded && { msgSrvcName: MOCK_MSG_SRVC_NAME }), + form_fields: [ + generateDefaultField(BasicField.Mobile, { isVerifiable }), + ], + }) + }) + await Promise.all(mockFormPromises) + + // Act + const forms = await Form.retrievePublicFormsWithSmsVerification( + populatedAdmin._id, + ) + + // Assert + expect(forms.length).toBe(1) + expect(forms[0].form_fields[0].isVerifiable).toBe(true) + expect(forms[0].status).toBe(Status.Public) + expect(forms[0].msgSrvcName).toBeUndefined() + }) + + it('should return an empty array when there are no forms', async () => { + // NOTE: This is an edge case and should never happen in prod as this method is called when + // a public form has a certain amount of verifications + + // Act + const forms = await Form.retrievePublicFormsWithSmsVerification( + populatedAdmin._id, + ) + + // Assert + expect(forms.length).toBe(0) + }) + }) }) describe('Methods', () => { diff --git a/src/app/models/form.server.model.ts b/src/app/models/form.server.model.ts index 1a40818e77..07dd28a682 100644 --- a/src/app/models/form.server.model.ts +++ b/src/app/models/form.server.model.ts @@ -808,6 +808,53 @@ const compileFormModel = (db: Mongoose): IFormModel => { ).exec() } + FormSchema.statics.disableSmsVerificationsForUser = async function ( + userId: IUserSchema['_id'], + ) { + return this.updateMany( + // Filter the collection so that only specified user is selected + // Only update forms without message service name + // As it implies that those forms are using default (our) credentials + { + admin: userId, + msgSrvcName: { + $exists: false, + }, + }, + // Next, set the isVerifiable property for each field in form_fields + // Refer here for $[identifier] syntax: https://docs.mongodb.com/manual/reference/operator/update/positional-filtered/ + { $set: { 'form_fields.$[field].isVerifiable': false } }, + { + // Only set if the field has fieldType equal to mobile + arrayFilters: [{ 'field.fieldType': 'mobile' }], + // NOTE: Not updating the timestamp because we should preserve ordering due to user-level modifications + timestamps: false, + }, + ).exec() + } + + /** + * Retrieves all the public forms for a user which has sms verifications enabled + * This only retrieves forms that are using FormSG credentials + * @param userId The userId to retrieve the forms for + * @returns All public forms that have sms verifications enabled + */ + FormSchema.statics.retrievePublicFormsWithSmsVerification = async function ( + userId: IUserSchema['_id'], + ) { + return this.find({ + admin: userId, + 'form_fields.fieldType': BasicField.Mobile, + 'form_fields.isVerifiable': true, + status: Status.Public, + msgSrvcName: { + $exists: false, + }, + }) + .read('secondary') + .exec() + } + // Hooks FormSchema.pre('validate', function (next) { // Reject save if form document is too large diff --git a/src/app/modules/form/__tests__/form.service.spec.ts b/src/app/modules/form/__tests__/form.service.spec.ts index 9ba77c679a..c249d90fa3 100644 --- a/src/app/modules/form/__tests__/form.service.spec.ts +++ b/src/app/modules/form/__tests__/form.service.spec.ts @@ -459,4 +459,42 @@ describe('FormService', () => { expect(actual._unsafeUnwrapErr()).toEqual(new ApplicationError()) }) }) + + describe('retrievePublicFormsWithSmsVerification', () => { + it('should call the db method successfully', async () => { + // Arrange + const retrieveFormSpy = jest + .spyOn(Form, 'retrievePublicFormsWithSmsVerification') + .mockResolvedValueOnce([]) + const MOCK_ADMIN_ID = MOCK_ADMIN_OBJ_ID.toString() + const expected: IFormSchema[] = [] + + // Act + const actual = await FormService.retrievePublicFormsWithSmsVerification( + MOCK_ADMIN_ID, + ) + + // Assert + expect(actual._unsafeUnwrap()).toEqual(expected) + expect(retrieveFormSpy).toBeCalledWith(MOCK_ADMIN_ID) + }) + + it('should propagate the error received when error occurs while querying', async () => { + // Arrange + const expected = new DatabaseError('whoops') + const retrieveFormSpy = jest + .spyOn(Form, 'retrievePublicFormsWithSmsVerification') + .mockRejectedValueOnce(expected) + const MOCK_ADMIN_ID = MOCK_ADMIN_OBJ_ID.toString() + + // Act + const actual = await FormService.retrievePublicFormsWithSmsVerification( + MOCK_ADMIN_ID, + ) + + // Assert + expect(actual._unsafeUnwrapErr()).toBeInstanceOf(DatabaseError) + expect(retrieveFormSpy).toBeCalledWith(MOCK_ADMIN_ID) + }) + }) }) diff --git a/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts b/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts index 97b34ddb31..eb4355bdb1 100644 --- a/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts +++ b/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts @@ -34,6 +34,7 @@ import { import * as SubmissionService from 'src/app/modules/submission/submission.service' import * as SubmissionUtils from 'src/app/modules/submission/submission.utils' import { MissingUserError } from 'src/app/modules/user/user.errors' +import { SmsLimitExceededError } from 'src/app/modules/verification/verification.errors' import { MailGenerationError, MailSendError, @@ -6754,10 +6755,10 @@ describe('admin-form.controller', () => { _id: MOCK_USER_ID, email: 'somerandom@example.com', } as IPopulatedUser - const MOCK_FIELD = generateDefaultField(BasicField.Rating) + const MOCK_FIELD = generateDefaultField(BasicField.Mobile) const MOCK_UPDATED_FIELD = { ...MOCK_FIELD, - title: 'some new title', + isVerifiable: true, } as FieldUpdateDto const MOCK_FORM = { @@ -6785,7 +6786,9 @@ describe('admin-form.controller', () => { MockAuthService.getFormAfterPermissionChecks.mockReturnValue( okAsync(MOCK_FORM), ) - + MockAdminFormService.shouldUpdateFormField.mockReturnValue( + okAsync(MOCK_FORM), + ) MockAdminFormService.updateFormField.mockReturnValue( okAsync(MOCK_UPDATED_FIELD as IFieldSchema), ) @@ -6884,6 +6887,29 @@ describe('admin-form.controller', () => { expect(MockAdminFormService.updateFormField).not.toHaveBeenCalled() }) + it('should return 409 when the field could not be updated due to a sms limit exceeded error', async () => { + // Arrange + MockAdminFormService.shouldUpdateFormField.mockReturnValueOnce( + errAsync(new SmsLimitExceededError()), + ) + const expected = { + message: + 'You have exceeded the free sms limit. Please refresh and try again.', + } + const mockRes = expressHandler.mockResponse() + + // Act + await AdminFormController._handleUpdateFormField( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toBeCalledWith(409) + expect(mockRes.json).toBeCalledWith(expected) + }) + it('should return 410 when form to update form field for is already archived', async () => { // Arrange const mockRes = expressHandler.mockResponse() @@ -7033,10 +7059,12 @@ describe('admin-form.controller', () => { title: 'mock title', } as IPopulatedForm - const MOCK_RETURNED_FIELD = generateDefaultField(BasicField.Nric) + const MOCK_RETURNED_FIELD = generateDefaultField(BasicField.Mobile, { + isVerifiable: true, + }) const MOCK_CREATE_FIELD_BODY = pick(MOCK_RETURNED_FIELD, [ 'fieldType', - 'title', + 'isVerifiable', ]) as FieldCreateDto const MOCK_REQ = expressHandler.mockRequest({ session: { @@ -7054,6 +7082,9 @@ describe('admin-form.controller', () => { MockAuthService.getFormAfterPermissionChecks.mockReturnValue( okAsync(MOCK_FORM), ) + MockAdminFormService.shouldUpdateFormField.mockReturnValue( + okAsync(MOCK_FORM), + ) MockAdminFormService.createFormField.mockReturnValue( okAsync(MOCK_RETURNED_FIELD), ) @@ -7125,6 +7156,29 @@ describe('admin-form.controller', () => { expect(MockAdminFormService.createFormField).not.toHaveBeenCalled() }) + it('should return 409 when the field could not be created due to a sms limit exceeded error', async () => { + // Arrange + MockAdminFormService.shouldUpdateFormField.mockReturnValueOnce( + errAsync(new SmsLimitExceededError()), + ) + const expected = { + message: + 'You have exceeded the free sms limit. Please refresh and try again.', + } + const mockRes = expressHandler.mockResponse() + + // Act + await AdminFormController._handleCreateFormField( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toBeCalledWith(409) + expect(mockRes.json).toBeCalledWith(expected) + }) + it('should return 410 when attempting to create a form field for an archived form', async () => { // Arrange const expectedErrorString = 'form gone pls' @@ -7314,11 +7368,14 @@ describe('admin-form.controller', () => { _id: MOCK_USER_ID, email: 'somerandom@example.com', } as IPopulatedUser - const MOCK_FIELDS = [generateDefaultField(BasicField.Rating)] + const MOCK_FIELDS = [ + generateDefaultField(BasicField.Mobile, { isVerifiable: true }), + ] const MOCK_FIELD_ID = String(MOCK_FIELDS[0]._id) - const MOCK_DUPLICATED_FIELD = generateDefaultField(BasicField.Rating) - + const MOCK_DUPLICATED_FIELD = generateDefaultField(BasicField.Mobile, { + isVerifiable: true, + }) const MOCK_FORM = { admin: MOCK_USER, _id: MOCK_FORM_ID, @@ -7344,6 +7401,10 @@ describe('admin-form.controller', () => { MockAuthService.getFormAfterPermissionChecks.mockReturnValue( okAsync(MOCK_FORM), ) + MockAdminFormService.getFormField.mockReturnValue(ok(MOCK_FIELDS[0])) + MockAdminFormService.shouldUpdateFormField.mockReturnValue( + okAsync(MOCK_FORM), + ) MockAdminFormService.duplicateFormField.mockReturnValue( okAsync(MOCK_DUPLICATED_FIELD), ) @@ -7481,6 +7542,28 @@ describe('admin-form.controller', () => { ) }) + it('should return 409 when the field could not be duplicated due to a sms limit exceeded error', async () => { + // Arrange + MockAdminFormService.shouldUpdateFormField.mockReturnValueOnce( + errAsync(new SmsLimitExceededError()), + ) + const expected = { + message: + 'You have exceeded the free sms limit. Please refresh and try again.', + } + const mockRes = expressHandler.mockResponse() + + // Act + await AdminFormController.handleDuplicateFormField( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toBeCalledWith(409) + expect(mockRes.json).toBeCalledWith(expected) + }) it('should return 410 when form to duplicate form field for is already archived', async () => { // Arrange const mockRes = expressHandler.mockResponse() @@ -7491,7 +7574,7 @@ describe('admin-form.controller', () => { ) // Act - await AdminFormController.handleDeleteFormField( + await AdminFormController.handleDuplicateFormField( MOCK_REQ, mockRes, jest.fn(), diff --git a/src/app/modules/form/admin-form/__tests__/admin-form.service.spec.ts b/src/app/modules/form/admin-form/__tests__/admin-form.service.spec.ts index f21bd90796..11abcb1881 100644 --- a/src/app/modules/form/admin-form/__tests__/admin-form.service.spec.ts +++ b/src/app/modules/form/admin-form/__tests__/admin-form.service.spec.ts @@ -20,6 +20,7 @@ import { } from 'src/app/modules/core/core.errors' import { MissingUserError } from 'src/app/modules/user/user.errors' import * as UserService from 'src/app/modules/user/user.service' +import { SmsLimitExceededError } from 'src/app/modules/verification/verification.errors' import { formatErrorRecoveryMessage } from 'src/app/utils/handle-mongo-error' import { EditFieldActions } from 'src/shared/constants' import { @@ -55,6 +56,8 @@ import { import { generateDefaultField } from 'tests/unit/backend/helpers/generate-form-data' import { VALID_UPLOAD_FILE_TYPES } from '../../../../../../shared/constants/file' +import { smsConfig } from '../../../../config/features/sms.config' +import * as SmsService from '../../../../services/sms/sms.service' import { FormNotFoundError, LogicNotFoundError, @@ -66,30 +69,7 @@ import { FieldNotFoundError, InvalidFileTypeError, } from '../admin-form.errors' -import { - archiveForm, - createForm, - createFormField, - createFormLogic, - createPresignedPostUrlForImages, - createPresignedPostUrlForLogos, - deleteFormField, - deleteFormLogic, - duplicateForm, - duplicateFormField, - editFormFields, - getDashboardForms, - getFormField, - reorderFormField, - transferFormOwnership, - updateEndPage, - updateForm, - updateFormCollaborators, - updateFormField, - updateFormLogic, - updateFormSettings, - updateStartPage, -} from '../admin-form.service' +import * as AdminFormService from '../admin-form.service' import { OverrideProps } from '../admin-form.types' import * as AdminFormUtils from '../admin-form.utils' @@ -134,7 +114,7 @@ describe('admin-form.service', () => { .mockResolvedValueOnce(mockDashboardForms) // Act - const actualResult = await getDashboardForms(mockUserId) + const actualResult = await AdminFormService.getDashboardForms(mockUserId) // Assert expect(getSpy).toHaveBeenCalledWith(mockUserId, mockUser.email) @@ -148,7 +128,7 @@ describe('admin-form.service', () => { MockUserService.findUserById.mockReturnValueOnce(errAsync(expectedError)) // Act - const actualResult = await getDashboardForms('any') + const actualResult = await AdminFormService.getDashboardForms('any') // Assert expect(actualResult.isErr()).toEqual(true) @@ -172,7 +152,7 @@ describe('admin-form.service', () => { .mockRejectedValueOnce(new Error('some error')) // Act - const actualResult = await getDashboardForms(mockUserId) + const actualResult = await AdminFormService.getDashboardForms(mockUserId) // Assert expect(getSpy).toHaveBeenCalledWith(mockUserId, mockUser.email) @@ -201,11 +181,12 @@ describe('admin-form.service', () => { }) // Act - const actualResult = await createPresignedPostUrlForImages({ - fileId: 'any id', - fileMd5Hash: 'any hash', - fileType: VALID_UPLOAD_FILE_TYPES[0], - }) + const actualResult = + await AdminFormService.createPresignedPostUrlForImages({ + fileId: 'any id', + fileMd5Hash: 'any hash', + fileType: VALID_UPLOAD_FILE_TYPES[0], + }) // Assert // Check that the correct bucket was used. @@ -225,11 +206,12 @@ describe('admin-form.service', () => { expect(VALID_UPLOAD_FILE_TYPES.includes(invalidFileType)).toEqual(false) // Act - const actualResult = await createPresignedPostUrlForImages({ - fileId: 'any id', - fileMd5Hash: 'any hash', - fileType: invalidFileType, - }) + const actualResult = + await AdminFormService.createPresignedPostUrlForImages({ + fileId: 'any id', + fileMd5Hash: 'any hash', + fileType: invalidFileType, + }) // Assert expect(actualResult.isErr()).toEqual(true) @@ -250,11 +232,12 @@ describe('admin-form.service', () => { }) // Act - const actualResult = await createPresignedPostUrlForImages({ - fileId: 'any id', - fileMd5Hash: 'any hash', - fileType: VALID_UPLOAD_FILE_TYPES[0], - }) + const actualResult = + await AdminFormService.createPresignedPostUrlForImages({ + fileId: 'any id', + fileMd5Hash: 'any hash', + fileType: VALID_UPLOAD_FILE_TYPES[0], + }) // Assert // Check that the correct bucket was used. @@ -291,11 +274,12 @@ describe('admin-form.service', () => { }) // Act - const actualResult = await createPresignedPostUrlForLogos({ - fileId: 'any id', - fileMd5Hash: 'any hash', - fileType: VALID_UPLOAD_FILE_TYPES[0], - }) + const actualResult = + await AdminFormService.createPresignedPostUrlForLogos({ + fileId: 'any id', + fileMd5Hash: 'any hash', + fileType: VALID_UPLOAD_FILE_TYPES[0], + }) // Assert // Check that the correct bucket was used. @@ -315,11 +299,12 @@ describe('admin-form.service', () => { expect(VALID_UPLOAD_FILE_TYPES.includes(invalidFileType)).toEqual(false) // Act - const actualResult = await createPresignedPostUrlForLogos({ - fileId: 'any id', - fileMd5Hash: 'any hash', - fileType: invalidFileType, - }) + const actualResult = + await AdminFormService.createPresignedPostUrlForLogos({ + fileId: 'any id', + fileMd5Hash: 'any hash', + fileType: invalidFileType, + }) // Assert expect(actualResult.isErr()).toEqual(true) @@ -340,11 +325,12 @@ describe('admin-form.service', () => { }) // Act - const actualResult = await createPresignedPostUrlForLogos({ - fileId: 'any id', - fileMd5Hash: 'any hash', - fileType: VALID_UPLOAD_FILE_TYPES[0], - }) + const actualResult = + await AdminFormService.createPresignedPostUrlForLogos({ + fileId: 'any id', + fileMd5Hash: 'any hash', + fileType: VALID_UPLOAD_FILE_TYPES[0], + }) // Assert // Check that the correct bucket was used. @@ -375,7 +361,7 @@ describe('admin-form.service', () => { } as unknown as IPopulatedForm // Act - const actual = await archiveForm(mockInitialForm) + const actual = await AdminFormService.archiveForm(mockInitialForm) // Assert expect(actual.isOk()).toEqual(true) @@ -393,7 +379,7 @@ describe('admin-form.service', () => { } as unknown as IPopulatedForm // Act - const actual = await archiveForm(mockInitialForm) + const actual = await AdminFormService.archiveForm(mockInitialForm) // Assert expect(actual.isErr()).toEqual(true) @@ -456,7 +442,7 @@ describe('admin-form.service', () => { .mockResolvedValueOnce(expectedForm as never) // Act - const actualResult = await duplicateForm( + const actualResult = await AdminFormService.duplicateForm( mockForm, mockNewAdminId, MOCK_EMAIL_OVERRIDE_PARAMS, @@ -505,7 +491,7 @@ describe('admin-form.service', () => { .mockResolvedValueOnce(expectedForm as never) // Act - const actualResult = await duplicateForm( + const actualResult = await AdminFormService.duplicateForm( mockForm, mockNewAdminId, MOCK_EMAIL_OVERRIDE_PARAMS, @@ -546,7 +532,7 @@ describe('admin-form.service', () => { .mockReturnValueOnce(expectedOverrideProps) // Act - const actualResult = await duplicateForm( + const actualResult = await AdminFormService.duplicateForm( mockForm, mockNewAdminId, MOCK_EMAIL_OVERRIDE_PARAMS, @@ -612,7 +598,7 @@ describe('admin-form.service', () => { ) // Act - const actualResult = await transferFormOwnership( + const actualResult = await AdminFormService.transferFormOwnership( mockValidForm, MOCK_NEW_OWNER_EMAIL, ) @@ -643,7 +629,7 @@ describe('admin-form.service', () => { } as unknown as IPopulatedForm // Act - const actualResult = await transferFormOwnership( + const actualResult = await AdminFormService.transferFormOwnership( mockValidForm, MOCK_NEW_OWNER_EMAIL, ) @@ -671,7 +657,7 @@ describe('admin-form.service', () => { ) // Act - const actualResult = await transferFormOwnership( + const actualResult = await AdminFormService.transferFormOwnership( mockValidForm, MOCK_NEW_OWNER_EMAIL, ) @@ -695,7 +681,7 @@ describe('admin-form.service', () => { ) // Act - const actualResult = await transferFormOwnership( + const actualResult = await AdminFormService.transferFormOwnership( mockValidForm, MOCK_NEW_OWNER_EMAIL, ) @@ -722,7 +708,7 @@ describe('admin-form.service', () => { } as unknown as IPopulatedForm // Act - const actualResult = await transferFormOwnership( + const actualResult = await AdminFormService.transferFormOwnership( mockValidForm, MOCK_NEW_OWNER_EMAIL, ) @@ -746,7 +732,7 @@ describe('admin-form.service', () => { } as unknown as IPopulatedForm // Act - const actualResult = await transferFormOwnership( + const actualResult = await AdminFormService.transferFormOwnership( mockValidForm, // Should trigger error since new owner email is the same as current. MOCK_CURRENT_OWNER.email, @@ -792,7 +778,7 @@ describe('admin-form.service', () => { ) // Act - const actualResult = await transferFormOwnership( + const actualResult = await AdminFormService.transferFormOwnership( mockValidForm, MOCK_NEW_OWNER_EMAIL, ) @@ -813,7 +799,7 @@ describe('admin-form.service', () => { describe('createForm', () => { it('should successfully create form', async () => { // Arrange - const formParams: Parameters[0] = { + const formParams: Parameters[0] = { title: 'create form title', admin: new ObjectId().toHexString(), responseMode: ResponseMode.Email, @@ -828,7 +814,7 @@ describe('admin-form.service', () => { .mockResolvedValueOnce(expectedForm as never) // Act - const actualResult = await createForm(formParams) + const actualResult = await AdminFormService.createForm(formParams) // Assert expect(actualResult._unsafeUnwrap()).toEqual(expectedForm) @@ -837,7 +823,7 @@ describe('admin-form.service', () => { it('should return DatabaseValidationError on invalid form params whilst creating form', async () => { // Arrange - const formParams: Parameters[0] = { + const formParams: Parameters[0] = { title: 'create form title', admin: new ObjectId().toHexString(), responseMode: ResponseMode.Encrypt, @@ -850,7 +836,7 @@ describe('admin-form.service', () => { .mockRejectedValueOnce(new mongoose.Error.ValidationError() as never) // Act - const actualResult = await createForm(formParams) + const actualResult = await AdminFormService.createForm(formParams) // Assert expect(actualResult._unsafeUnwrapErr()).toBeInstanceOf( @@ -861,7 +847,7 @@ describe('admin-form.service', () => { it('should return DatabaseConflictError on mongoose version error', async () => { // Arrange - const formParams: Parameters[0] = { + const formParams: Parameters[0] = { title: 'create form title', admin: new ObjectId().toHexString(), responseMode: ResponseMode.Encrypt, @@ -873,7 +859,7 @@ describe('admin-form.service', () => { ) // Act - const actualResult = await createForm(formParams) + const actualResult = await AdminFormService.createForm(formParams) // Assert expect(actualResult._unsafeUnwrapErr()).toBeInstanceOf( @@ -884,7 +870,7 @@ describe('admin-form.service', () => { it('should return DatabasePayloadError on form size error', async () => { // Arrange - const formParams: Parameters[0] = { + const formParams: Parameters[0] = { title: 'create form title', admin: new ObjectId().toHexString(), responseMode: ResponseMode.Encrypt, @@ -899,7 +885,7 @@ describe('admin-form.service', () => { .mockRejectedValueOnce(expectedError as never) // Act - const actualResult = await createForm(formParams) + const actualResult = await AdminFormService.createForm(formParams) // Assert expect(actualResult._unsafeUnwrapErr()).toEqual( @@ -912,7 +898,7 @@ describe('admin-form.service', () => { it('should return DatabaseError on database error whilst creating form', async () => { // Arrange - const formParams: Parameters[0] = { + const formParams: Parameters[0] = { title: 'create form title', admin: new ObjectId().toHexString(), responseMode: ResponseMode.Encrypt, @@ -924,7 +910,7 @@ describe('admin-form.service', () => { .mockRejectedValueOnce(new Error(mockErrorString) as never) // Act - const actualResult = await createForm(formParams) + const actualResult = await AdminFormService.createForm(formParams) // Assert expect(actualResult._unsafeUnwrapErr()).toEqual( @@ -969,7 +955,10 @@ describe('admin-form.service', () => { } // Act - const actualResult = await editFormFields(MOCK_INTIAL_FORM, createParams) + const actualResult = await AdminFormService.editFormFields( + MOCK_INTIAL_FORM, + createParams, + ) // Assert expect(actualResult._unsafeUnwrap()).toEqual(MOCK_UPDATED_FORM) @@ -991,7 +980,10 @@ describe('admin-form.service', () => { } // Act - const actualResult = await editFormFields(MOCK_INTIAL_FORM, reorderParams) + const actualResult = await AdminFormService.editFormFields( + MOCK_INTIAL_FORM, + reorderParams, + ) // Assert expect(actualResult._unsafeUnwrapErr()).toEqual(mockError) @@ -1017,7 +1009,10 @@ describe('admin-form.service', () => { } // Act - const actualResult = await editFormFields(MOCK_INTIAL_FORM, deleteParams) + const actualResult = await AdminFormService.editFormFields( + MOCK_INTIAL_FORM, + deleteParams, + ) // Assert expect(actualResult._unsafeUnwrapErr()).toBeInstanceOf( @@ -1049,12 +1044,17 @@ describe('admin-form.service', () => { it('should successfully update given form keys', async () => { // Arrange - const formUpdateParams: Parameters[1] = { + const formUpdateParams: Parameters< + typeof AdminFormService.updateForm + >[1] = { status: Status.Private, } // Act - const actualResult = await updateForm(MOCK_INITIAL_FORM, formUpdateParams) + const actualResult = await AdminFormService.updateForm( + MOCK_INITIAL_FORM, + formUpdateParams, + ) // Assert expect(actualResult._unsafeUnwrap()).toEqual(MOCK_UPDATED_FORM) @@ -1067,7 +1067,9 @@ describe('admin-form.service', () => { it('should return DatabaseError when error occurs whilst updating form', async () => { // Arrange - const formUpdateParams: Parameters[1] = { + const formUpdateParams: Parameters< + typeof AdminFormService.updateForm + >[1] = { esrvcId: 'MOCK-ESRVCID', } // Mock database failure. @@ -1076,7 +1078,10 @@ describe('admin-form.service', () => { ) // Act - const actualResult = await updateForm(MOCK_INITIAL_FORM, formUpdateParams) + const actualResult = await AdminFormService.updateForm( + MOCK_INITIAL_FORM, + formUpdateParams, + ) // Assert expect(actualResult._unsafeUnwrapErr()).toBeInstanceOf(DatabaseError) @@ -1145,7 +1150,7 @@ describe('admin-form.service', () => { } // Act - const actualResult = await updateFormSettings( + const actualResult = await AdminFormService.updateFormSettings( MOCK_EMAIL_FORM, settingsToUpdate, ) @@ -1168,7 +1173,7 @@ describe('admin-form.service', () => { }, } // Act - const actualResult = await updateFormSettings( + const actualResult = await AdminFormService.updateFormSettings( MOCK_ENCRYPT_FORM, settingsToUpdate, ) @@ -1199,7 +1204,7 @@ describe('admin-form.service', () => { }) // Act - const actualResult = await updateFormSettings( + const actualResult = await AdminFormService.updateFormSettings( MOCK_ENCRYPT_FORM, settingsToUpdate, ) @@ -1239,7 +1244,7 @@ describe('admin-form.service', () => { } as unknown as IPopulatedForm // Act - const actual = await updateFormField( + const actual = await AdminFormService.updateFormField( mockForm, fieldToUpdate._id, mockNewField, @@ -1267,7 +1272,7 @@ describe('admin-form.service', () => { ) as FieldUpdateDto // Act - const actual = await updateFormField( + const actual = await AdminFormService.updateFormField( mockForm, invalidFieldId, mockNewField, @@ -1294,7 +1299,7 @@ describe('admin-form.service', () => { ) as FieldUpdateDto // Act - const actual = await updateFormField( + const actual = await AdminFormService.updateFormField( mockForm, invalidFieldId, mockNewField, @@ -1331,7 +1336,10 @@ describe('admin-form.service', () => { ]) as FieldCreateDto // Act - const actual = await createFormField(mockForm, formCreateParams) + const actual = await AdminFormService.createFormField( + mockForm, + formCreateParams, + ) // Assert // Should return last element in form_field @@ -1358,7 +1366,10 @@ describe('admin-form.service', () => { } as FieldCreateDto // Act - const actual = await createFormField(mockForm, formCreateParams) + const actual = await AdminFormService.createFormField( + mockForm, + formCreateParams, + ) // Assert expect(actual._unsafeUnwrapErr()).toBeInstanceOf(DatabaseValidationError) @@ -1406,7 +1417,10 @@ describe('admin-form.service', () => { }) // Act - const actualResult = await deleteFormLogic(mockEmailForm, logicId) + const actualResult = await AdminFormService.deleteFormLogic( + mockEmailForm, + logicId, + ) // Assert expect(actualResult.isOk()).toEqual(true) @@ -1440,7 +1454,10 @@ describe('admin-form.service', () => { }) // Act - const actualResult = await deleteFormLogic(mockEncryptForm, logicId) + const actualResult = await AdminFormService.deleteFormLogic( + mockEncryptForm, + logicId, + ) // Assert expect(actualResult.isOk()).toEqual(true) @@ -1466,7 +1483,10 @@ describe('admin-form.service', () => { it('should return LogicNotFoundError if logic does not exist on form', async () => { // Act const wrongLogicId = new ObjectId().toHexString() - const actualResult = await deleteFormLogic(mockEmailForm, wrongLogicId) + const actualResult = await AdminFormService.deleteFormLogic( + mockEmailForm, + wrongLogicId, + ) // Assert expect(actualResult.isErr()).toEqual(true) @@ -1493,7 +1513,7 @@ describe('admin-form.service', () => { } as unknown as IPopulatedForm // Act - const actual = await duplicateFormField( + const actual = await AdminFormService.duplicateFormField( mockForm, String(fieldToDuplicate._id), ) @@ -1523,7 +1543,10 @@ describe('admin-form.service', () => { } as unknown as IPopulatedForm // Act - const actual = await duplicateFormField(mockForm, fieldToDuplicate._id) + const actual = await AdminFormService.duplicateFormField( + mockForm, + fieldToDuplicate._id, + ) // Assert expect(actual._unsafeUnwrapErr()).toEqual(new FormNotFoundError()) @@ -1542,7 +1565,10 @@ describe('admin-form.service', () => { } as unknown as IPopulatedForm // Act - const actual = await duplicateFormField(mockForm, initialFields[0]._id) + const actual = await AdminFormService.duplicateFormField( + mockForm, + initialFields[0]._id, + ) // Assert expect(actual._unsafeUnwrapErr()).toBeInstanceOf(DatabaseValidationError) @@ -1567,7 +1593,7 @@ describe('admin-form.service', () => { const newPosition = 1 // Act - const actual = await reorderFormField( + const actual = await AdminFormService.reorderFormField( mockForm, fieldToReorder, newPosition, @@ -1591,7 +1617,7 @@ describe('admin-form.service', () => { const newPosition = 2 // Act - const actual = await reorderFormField( + const actual = await AdminFormService.reorderFormField( mockForm, fieldToReorder, newPosition, @@ -1618,7 +1644,7 @@ describe('admin-form.service', () => { const newPosition = 2 // Act - const actual = await reorderFormField( + const actual = await AdminFormService.reorderFormField( mockForm, fieldToReorder, newPosition, @@ -1652,7 +1678,10 @@ describe('admin-form.service', () => { } as unknown as IPopulatedForm // Act - const actual = await updateFormCollaborators(mockForm, newCollaborators) + const actual = await AdminFormService.updateFormCollaborators( + mockForm, + newCollaborators, + ) // Assert expect(mockForm.updateFormCollaborators).toHaveBeenCalledWith( @@ -1677,7 +1706,10 @@ describe('admin-form.service', () => { } as unknown as IPopulatedForm // Act - const actual = await updateFormCollaborators(mockForm, newCollaborators) + const actual = await AdminFormService.updateFormCollaborators( + mockForm, + newCollaborators, + ) // Assert expect(mockForm.updateFormCollaborators).toHaveBeenCalledWith( @@ -1763,7 +1795,10 @@ describe('admin-form.service', () => { CREATE_SPY.mockResolvedValue(mockEmailFormUpdated as IFormDocument) // Act - const actualResult = await createFormLogic(mockEmailForm, createLogicBody) + const actualResult = await AdminFormService.createFormLogic( + mockEmailForm, + createLogicBody, + ) // Assert expect(actualResult.isOk()).toEqual(true) @@ -1782,7 +1817,7 @@ describe('admin-form.service', () => { CREATE_SPY.mockResolvedValue(mockEncryptFormUpdated as IFormDocument) // Act - const actualResult = await createFormLogic( + const actualResult = await AdminFormService.createFormLogic( mockEncryptForm, createLogicBody, ) @@ -1804,7 +1839,7 @@ describe('admin-form.service', () => { CREATE_SPY.mockResolvedValue(undefined as unknown as IFormDocument) // Act - const actualResult = await createFormLogic( + const actualResult = await AdminFormService.createFormLogic( mockEncryptForm, createLogicBody, ) @@ -1828,7 +1863,7 @@ describe('admin-form.service', () => { CREATE_SPY.mockResolvedValue(updatedFormWithoutLogic as IFormDocument) // Act - const actualResult = await createFormLogic( + const actualResult = await AdminFormService.createFormLogic( mockEncryptForm, createLogicBody, ) @@ -1852,7 +1887,7 @@ describe('admin-form.service', () => { CREATE_SPY.mockResolvedValue(updatedFormWithEmptyLogic as IFormDocument) // Act - const actualResult = await createFormLogic( + const actualResult = await AdminFormService.createFormLogic( mockEncryptForm, createLogicBody, ) @@ -1894,7 +1929,10 @@ describe('admin-form.service', () => { deleteSpy.mockResolvedValueOnce(mockUpdatedForm) // Act - const actual = await deleteFormField(mockForm, String(fieldToDelete._id)) + const actual = await AdminFormService.deleteFormField( + mockForm, + String(fieldToDelete._id), + ) // Assert expect(actual._unsafeUnwrap()).toEqual(mockUpdatedForm) @@ -1913,7 +1951,7 @@ describe('admin-form.service', () => { } as unknown as IPopulatedForm // Act - const actual = await deleteFormField( + const actual = await AdminFormService.deleteFormField( mockForm, new ObjectId().toHexString(), ) @@ -1934,7 +1972,10 @@ describe('admin-form.service', () => { deleteSpy.mockResolvedValueOnce(null) // Act - const actual = await deleteFormField(mockForm, fieldToDelete._id) + const actual = await AdminFormService.deleteFormField( + mockForm, + fieldToDelete._id, + ) // Assert expect(actual._unsafeUnwrapErr()).toEqual(new FormNotFoundError()) @@ -1963,7 +2004,10 @@ describe('admin-form.service', () => { updateSpy.mockResolvedValueOnce(mockUpdatedForm) // Act - const actual = await updateEndPage(MOCK_FORM_ID, MOCK_NEW_END_PAGE) + const actual = await AdminFormService.updateEndPage( + MOCK_FORM_ID, + MOCK_NEW_END_PAGE, + ) // Assert expect(actual._unsafeUnwrap()).toEqual(MOCK_NEW_END_PAGE) @@ -1974,7 +2018,10 @@ describe('admin-form.service', () => { updateSpy.mockResolvedValueOnce(null) // Act - const actual = await updateEndPage(MOCK_FORM_ID, MOCK_NEW_END_PAGE) + const actual = await AdminFormService.updateEndPage( + MOCK_FORM_ID, + MOCK_NEW_END_PAGE, + ) // Assert expect(actual._unsafeUnwrapErr()).toEqual(new FormNotFoundError()) @@ -1986,7 +2033,10 @@ describe('admin-form.service', () => { updateSpy.mockRejectedValueOnce(new Error(expectedErrorMsg)) // Act - const actual = await updateEndPage(MOCK_FORM_ID, MOCK_NEW_END_PAGE) + const actual = await AdminFormService.updateEndPage( + MOCK_FORM_ID, + MOCK_NEW_END_PAGE, + ) // Assert const actualError = actual._unsafeUnwrapErr() @@ -2018,7 +2068,10 @@ describe('admin-form.service', () => { updateSpy.mockResolvedValueOnce(mockUpdatedForm) // Act - const actual = await updateStartPage(MOCK_FORM_ID, MOCK_NEW_START_PAGE) + const actual = await AdminFormService.updateStartPage( + MOCK_FORM_ID, + MOCK_NEW_START_PAGE, + ) // Assert expect(actual._unsafeUnwrap()).toEqual(MOCK_NEW_START_PAGE) @@ -2029,7 +2082,10 @@ describe('admin-form.service', () => { updateSpy.mockResolvedValueOnce(null) // Act - const actual = await updateStartPage(MOCK_FORM_ID, MOCK_NEW_START_PAGE) + const actual = await AdminFormService.updateStartPage( + MOCK_FORM_ID, + MOCK_NEW_START_PAGE, + ) // Assert expect(actual._unsafeUnwrapErr()).toEqual(new FormNotFoundError()) @@ -2041,7 +2097,10 @@ describe('admin-form.service', () => { updateSpy.mockRejectedValueOnce(new Error(expectedErrorMsg)) // Act - const actual = await updateStartPage(MOCK_FORM_ID, MOCK_NEW_START_PAGE) + const actual = await AdminFormService.updateStartPage( + MOCK_FORM_ID, + MOCK_NEW_START_PAGE, + ) // Assert const actualError = actual._unsafeUnwrapErr() @@ -2130,7 +2189,7 @@ describe('admin-form.service', () => { UPDATE_SPY.mockResolvedValue(mockEmailFormUpdated as IFormSchema) // Act - const actualResult = await updateFormLogic( + const actualResult = await AdminFormService.updateFormLogic( mockEmailForm, logicId1.toHexString(), updateLogicBody, @@ -2152,7 +2211,7 @@ describe('admin-form.service', () => { UPDATE_SPY.mockResolvedValue(mockEncryptFormUpdated as IFormSchema) // Act - const actualResult = await updateFormLogic( + const actualResult = await AdminFormService.updateFormLogic( mockEncryptForm, logicId1.toHexString(), updateLogicBody, @@ -2172,7 +2231,7 @@ describe('admin-form.service', () => { it('should return LogicNotFoundError if logic does not exist on form', async () => { // Act const wrongLogicId = new ObjectId().toHexString() - const actualResult = await updateFormLogic( + const actualResult = await AdminFormService.updateFormLogic( mockEmailForm, wrongLogicId, updateLogicBody, @@ -2194,10 +2253,13 @@ describe('admin-form.service', () => { // Append created field to end of form_fields. form_fields: [MOCK_FIELD], _id: new ObjectId(), - } as IFormDocument + } as IPopulatedForm // Act - const actual = await getFormField(MOCK_FORM, String(MOCK_FIELD._id)) + const actual = await AdminFormService.getFormField( + MOCK_FORM, + String(MOCK_FIELD._id), + ) // Assert expect(actual._unsafeUnwrap()).toEqual(MOCK_FIELD) @@ -2211,16 +2273,158 @@ describe('admin-form.service', () => { // Append created field to end of form_fields. form_fields: [], _id: new ObjectId(), - } as unknown as IFormDocument + } as unknown as IPopulatedForm const expectedError = new FieldNotFoundError( `Attempted to retrieve field ${MOCK_ID} from ${MOCK_FORM._id} but field was not present`, ) // Act - const actual = await getFormField(MOCK_FORM, MOCK_ID) + const actual = await AdminFormService.getFormField(MOCK_FORM, MOCK_ID) // Assert expect(actual._unsafeUnwrapErr()).toEqual(expectedError) }) }) + + describe('disableSmsVerificationsForUser', () => { + it('should return true when the forms are updated successfully', async () => { + // Arrange + const MOCK_ADMIN_ID = new ObjectId().toHexString() + const disableSpy = jest.spyOn(FormModel, 'disableSmsVerificationsForUser') + disableSpy.mockResolvedValueOnce({ n: 0, nModified: 0, ok: 0 }) + + // Act + const expected = await AdminFormService.disableSmsVerificationsForUser( + MOCK_ADMIN_ID, + ) + + // Assert + expect(disableSpy).toHaveBeenCalledWith(MOCK_ADMIN_ID) + expect(expected._unsafeUnwrap()).toEqual(true) + }) + + it('should return a database error when the operation fails', async () => { + // Arrange + const MOCK_ADMIN_ID = new ObjectId().toHexString() + const disableSpy = jest.spyOn(FormModel, 'disableSmsVerificationsForUser') + disableSpy.mockRejectedValueOnce('whoops') + + // Act + const expected = await AdminFormService.disableSmsVerificationsForUser( + MOCK_ADMIN_ID, + ) + + // Assert + expect(disableSpy).toHaveBeenCalledWith(MOCK_ADMIN_ID) + expect(expected._unsafeUnwrapErr()).toBeInstanceOf(DatabaseError) + }) + }) + + describe('shouldUpdateFormField', () => { + const MOCK_FORM = { + admin: { + _id: new ObjectId(), + }, + } as unknown as IPopulatedForm + + const countSpy = jest.spyOn(SmsService, 'retrieveFreeSmsCounts') + + describe('when update form field is not a BasicField.Mobile type', () => { + const MOCK_RATING_FIELD = generateDefaultField(BasicField.Rating) + it('should return the form without doing anything', async () => { + // Act + const actual = await AdminFormService.shouldUpdateFormField( + MOCK_FORM, + MOCK_RATING_FIELD, + ) + + // Assert + expect(actual._unsafeUnwrap()).toEqual(MOCK_FORM) + expect(countSpy).not.toHaveBeenCalled() + }) + }) + + describe('when update form field is a BasicField.Mobile type', () => { + const MOCK_UNVERIFIABLE_MOBILE_FIELD = generateDefaultField( + BasicField.Mobile, + ) + const MOCK_VERIFIABLE_MOBILE_FIELD = generateDefaultField( + BasicField.Mobile, + { isVerifiable: true }, + ) + + it('should return the given form when the admin is under the free sms limit', async () => { + // Arrange + countSpy.mockReturnValueOnce(okAsync(smsConfig.smsVerificationLimit)) + + // Act + const actual = await AdminFormService.shouldUpdateFormField( + MOCK_FORM, + MOCK_VERIFIABLE_MOBILE_FIELD, + ) + + // Assert + expect(actual._unsafeUnwrap()).toBe(MOCK_FORM) + }) + + it('should return the given form when the form is onboarded with its own credentials', async () => { + // Arrange + const MOCK_ONBOARDED_FORM = { ...MOCK_FORM, msgSrvcName: 'form a form' } + + // Act + const actual = await AdminFormService.shouldUpdateFormField( + MOCK_ONBOARDED_FORM, + MOCK_VERIFIABLE_MOBILE_FIELD, + ) + + // Assert + expect(countSpy).not.toHaveBeenCalled() + expect(actual._unsafeUnwrap()).toEqual(MOCK_ONBOARDED_FORM) + }) + + it('should return the given form when mobile field is not verifiable', async () => { + // Act + const actual = await AdminFormService.shouldUpdateFormField( + MOCK_FORM, + MOCK_UNVERIFIABLE_MOBILE_FIELD, + ) + + // Assert + expect(countSpy).not.toHaveBeenCalled() + expect(actual._unsafeUnwrap()).toEqual(MOCK_FORM) + }) + + it('should return sms retrieval error when sms limit exceeded and the given form has not been onboarded', async () => { + // Arrange + countSpy.mockReturnValueOnce( + okAsync(smsConfig.smsVerificationLimit + 1), + ) + + // Act + const actual = await AdminFormService.shouldUpdateFormField( + MOCK_FORM, + MOCK_VERIFIABLE_MOBILE_FIELD, + ) + + // Assert + expect(actual._unsafeUnwrapErr()).toEqual(new SmsLimitExceededError()) + }) + + it('should propagate any database errors encountered during retrieval', async () => { + // Arrange + const MOCK_ERROR_STRING = 'something went oopsie' + const expectedError = new DatabaseError(MOCK_ERROR_STRING) + countSpy.mockReturnValueOnce(errAsync(expectedError)) + + // Act + const actual = await AdminFormService.shouldUpdateFormField( + MOCK_FORM, + MOCK_VERIFIABLE_MOBILE_FIELD, + ) + + // Assert + expect(actual._unsafeUnwrapErr()).toBe(expectedError) + }) + }) + }) }) diff --git a/src/app/modules/form/admin-form/admin-form.controller.ts b/src/app/modules/form/admin-form/admin-form.controller.ts index 7b4dc4e992..6e809ef9aa 100644 --- a/src/app/modules/form/admin-form/admin-form.controller.ts +++ b/src/app/modules/form/admin-form/admin-form.controller.ts @@ -1198,6 +1198,7 @@ export const handleUpdateForm: ControllerHandler< * @returns 400 when form field has invalid updates to be performed * @returns 403 when current user does not have permissions to update form * @returns 404 when form or field to duplicate cannot be found + * @returns 409 when saving updated form field causes sms limit to be exceeded * @returns 409 when saving updated form incurs a conflict in the database * @returns 410 when form to update is archived * @returns 413 when updated form is too large to be saved in the database @@ -1221,6 +1222,12 @@ export const handleDuplicateFormField: ControllerHandler< level: PermissionLevel.Write, }), ) + .andThen((form) => { + return AdminFormService.getFormField(form, fieldId).asyncAndThen( + (formFieldToDuplicate) => + AdminFormService.shouldUpdateFormField(form, formFieldToDuplicate), + ) + }) .andThen((form) => AdminFormService.duplicateFormField(form, fieldId)) .map((duplicatedField) => res.status(StatusCodes.OK).json(duplicatedField as FormFieldDto), @@ -1311,6 +1318,7 @@ export const _handleUpdateFormField: ControllerHandler< FieldUpdateDto > = (req, res) => { const { formId, fieldId } = req.params + const updatedFormField = req.body const sessionUserId = (req.session as AuthedSessionData).user._id // Step 1: Retrieve currently logged in user. @@ -1324,9 +1332,13 @@ export const _handleUpdateFormField: ControllerHandler< level: PermissionLevel.Write, }), ) - // Step 3: User has permissions, update form field of retrieved form. + // Step 3: Check if the user has exceeded the allowable limit for sms if the fieldType is mobile + .andThen((form) => + AdminFormService.shouldUpdateFormField(form, updatedFormField), + ) + // Step 4: User has permissions, update form field of retrieved form. .andThen((form) => - AdminFormService.updateFormField(form, fieldId, req.body), + AdminFormService.updateFormField(form, fieldId, updatedFormField), ) .map((updatedFormField) => res.status(StatusCodes.OK).json(updatedFormField as FormFieldDto), @@ -1340,7 +1352,7 @@ export const _handleUpdateFormField: ControllerHandler< userId: sessionUserId, formId, fieldId, - updateFieldBody: req.body, + updateFieldBody: updatedFormField, }, error, }) @@ -1647,6 +1659,7 @@ export const handleEmailPreviewSubmission = [ * @returns 403 when current user does not have permissions to update form field * @returns 404 when form cannot be found * @returns 404 when form field cannot be found + * @returns 409 when form field update conflicts with database state * @returns 410 when updating form field of an archived form * @returns 413 when updating form field causes form to be too large to be saved in the database * @returns 422 when an invalid form field update is attempted on the form @@ -1687,6 +1700,7 @@ export const handleUpdateFormField = [ * @returns 200 with created form field * @returns 403 when current user does not have permissions to create a form field * @returns 404 when form cannot be found + * @returns 409 when form field update conflicts with database state * @returns 410 when creating form field for an archived form * @returns 413 when creating form field causes form to be too large to be saved in the database * @returns 422 when an invalid form field creation is attempted on the form @@ -1699,6 +1713,7 @@ export const _handleCreateFormField: ControllerHandler< FieldCreateDto > = (req, res) => { const { formId } = req.params + const formFieldToCreate = req.body const sessionUserId = (req.session as AuthedSessionData).user._id // Step 1: Retrieve currently logged in user. @@ -1712,8 +1727,14 @@ export const _handleCreateFormField: ControllerHandler< level: PermissionLevel.Write, }), ) - // Step 3: User has permissions, proceed to create form field with provided body. - .andThen((form) => AdminFormService.createFormField(form, req.body)) + // Step 3: Check if the user has exceeded the allowable limit for sms if the fieldType is mobile + .andThen((form) => + AdminFormService.shouldUpdateFormField(form, formFieldToCreate), + ) + // Step 4: User has permissions, proceed to create form field with provided body. + .andThen((form) => + AdminFormService.createFormField(form, formFieldToCreate), + ) .map((createdFormField) => res.status(StatusCodes.OK).json(createdFormField as FormFieldWithId), ) @@ -1725,7 +1746,7 @@ export const _handleCreateFormField: ControllerHandler< ...createReqMeta(req), userId: sessionUserId, formId, - createFieldBody: req.body, + createFieldBody: formFieldToCreate, }, error, }) diff --git a/src/app/modules/form/admin-form/admin-form.service.ts b/src/app/modules/form/admin-form/admin-form.service.ts index 724dbbbe45..816309d001 100644 --- a/src/app/modules/form/admin-form/admin-form.service.ts +++ b/src/app/modules/form/admin-form/admin-form.service.ts @@ -10,13 +10,16 @@ import { } from '../../../../../shared/constants/file' import { EditFieldActions } from '../../../../shared/constants' import { + BasicField, FormFieldSchema, FormLogicSchema, FormLogoState, FormSettings, + IField, IForm, IFormDocument, IFormSchema, + IMobileField, IPopulatedForm, IUserSchema, LogicDto, @@ -36,7 +39,9 @@ import { import { aws as AwsConfig } from '../../../config/config' import { createLoggerWithLabel } from '../../../config/logger' import getFormModel from '../../../models/form.server.model' +import * as SmsService from '../../../services/sms/sms.service' import { dotifyObject } from '../../../utils/dotify-object' +import { isVerifiableMobileField } from '../../../utils/field-validation/field-validation.guards' import { getMongoErrorMessage, transformMongoError, @@ -50,13 +55,15 @@ import { } from '../../core/core.errors' import { MissingUserError } from '../../user/user.errors' import * as UserService from '../../user/user.service' +import { SmsLimitExceededError } from '../../verification/verification.errors' +import { hasAdminExceededFreeSmsLimit } from '../../verification/verification.util' import { FormNotFoundError, LogicNotFoundError, TransferOwnershipError, } from '../form.errors' import { getFormModelByResponseMode } from '../form.service' -import { getFormFieldById, getLogicById } from '../form.utils' +import { getFormFieldById, getLogicById, isFormOnboarded } from '../form.utils' import { PRESIGNED_POST_EXPIRY_SECS } from './admin-form.constants' import { @@ -1058,3 +1065,89 @@ export const updateStartPage = ( return okAsync(updatedForm.startPage) }) } + +/** + * Disables sms verifications for all forms belonging to the specified user + * @param userId the id of the user whose sms verifications should be disabled + * @returns ok(true) when the forms have been successfully disabled + * @returns err(PossibleDatabaseError) when an error occurred while attempting to disable sms verifications + */ +export const disableSmsVerificationsForUser = ( + userId: string, +): ResultAsync => + ResultAsync.fromPromise( + FormModel.disableSmsVerificationsForUser(userId), + (error) => { + logger.error({ + message: + 'Error occurred when attempting to disable sms verifications for user', + meta: { + action: 'disableSmsVerificationsForUser', + userId, + }, + error, + }) + return transformMongoError(error) + }, + ).map(() => true) + +/** + * Checks if the given form field should be updated. + * This currently checks if the admin has exceeded their free sms limit. + * @param form The form which the specified field belongs to + * @param formField The field which we should perform the update for + * @returns ok(form) If the field can be updated + * @return err(PossibleDatabaseError) if an error occurred while performing the required checks + * @return err(SmsLimitExceededError) if the form admin went over the free sms limit + * while attempting to toggle verification on for a mobile form field + */ +export const shouldUpdateFormField = ( + form: IPopulatedForm, + formField: IField, +): ResultAsync< + IPopulatedForm, + PossibleDatabaseError | SmsLimitExceededError +> => { + switch (formField.fieldType) { + case BasicField.Mobile: { + // NOTE: This casting is safe and we require this casting because we do not declare explicit discriminants on the extended type + return isMobileFieldUpdateAllowed(formField as IMobileField, form) + } + default: + return okAsync(form) + } +} + +/** + * Checks whether the mobile update should be allowed based on whether the mobile field is verified + * and (if verified), the admin's free sms counts + * @param mobileField The mobile field to check + * @param form The form that the field belongs to + * @returns ok(form) if the update is valid + * @returns err(PossibleDatabaseError) if an error occurred while retrieving counts from database + * @returns err(SmsLimitExceededError) if the admin of the form has exceeded their free sms quota + */ +const isMobileFieldUpdateAllowed = ( + mobileField: IMobileField, + form: IPopulatedForm, +): ResultAsync< + IPopulatedForm, + PossibleDatabaseError | SmsLimitExceededError +> => { + // Field can always update if it's not a verifiable field or if the form has been onboarded + if (!isVerifiableMobileField(mobileField) || isFormOnboarded(form)) { + return okAsync(form) + } + + const formAdminId = String(form.admin._id) + + // If the form admin has exceeded the sms limit + // And the form is not onboarded, refuse to update the field + return SmsService.retrieveFreeSmsCounts(formAdminId).andThen( + (freeSmsSent) => { + return hasAdminExceededFreeSmsLimit(freeSmsSent) + ? errAsync(new SmsLimitExceededError()) + : okAsync(form) + }, + ) +} diff --git a/src/app/modules/form/admin-form/admin-form.utils.ts b/src/app/modules/form/admin-form/admin-form.utils.ts index 3caa9168bc..0e183a238b 100644 --- a/src/app/modules/form/admin-form/admin-form.utils.ts +++ b/src/app/modules/form/admin-form/admin-form.utils.ts @@ -28,6 +28,7 @@ import { } from '../../core/core.errors' import { ErrorResponseData } from '../../core/core.types' import { MissingUserError } from '../../user/user.errors' +import { SmsLimitExceededError } from '../../verification/verification.errors' import { ForbiddenFormError, FormDeletedError, @@ -63,6 +64,11 @@ export const mapRouteError = ( coreErrorMessage?: string, ): ErrorResponseData => { switch (error.constructor) { + case SmsLimitExceededError: + return { + statusCode: StatusCodes.CONFLICT, + errorMessage: error.message, + } case InvalidFileTypeError: case CreatePresignedUrlError: return { diff --git a/src/app/modules/form/form.service.ts b/src/app/modules/form/form.service.ts index cebbd598cc..5c2fdbabcc 100644 --- a/src/app/modules/form/form.service.ts +++ b/src/app/modules/form/form.service.ts @@ -5,6 +5,7 @@ import { AuthType, IEmailFormModel, IEncryptedFormModel, + IFormDocument, IFormSchema, IPopulatedForm, ResponseMode, @@ -303,3 +304,38 @@ export const checkIsIntranetFormAccess = ( } return isIntranetUser } + +export const retrievePublicFormsWithSmsVerification = ( + userId: string, +): ResultAsync => { + return ResultAsync.fromPromise( + FormModel.retrievePublicFormsWithSmsVerification(userId), + (error) => { + logger.error({ + message: 'Error retrieving public forms with sms verifications', + meta: { + action: 'retrievePublicFormsWithSmsVerification', + userId: userId, + }, + error, + }) + + return transformMongoError(error) + }, + ).andThen((forms) => { + if (!forms.length) { + // NOTE: Warn here because this is supposed to be called to generate a list of form titles + // When the admin has used up their sms verification limit. + // It is not an error because there are potential cases where the admins privatize their form after. + logger.warn({ + message: + 'Attempted to retrieve public forms with sms verifications but none was found', + meta: { + action: 'retrievePublicFormsWithSmsVerification', + userId: userId, + }, + }) + } + return okAsync(forms) + }) +} diff --git a/src/app/modules/form/form.utils.ts b/src/app/modules/form/form.utils.ts index 1b23ff6b24..095f97dc4d 100644 --- a/src/app/modules/form/form.utils.ts +++ b/src/app/modules/form/form.utils.ts @@ -1,13 +1,18 @@ import { FormFieldSchema, + FormLinkView, FormLogicSchema, IEncryptedFormSchema, + IForm, + IFormDocument, IFormSchema, + IOnboardedForm, IPopulatedEmailForm, IPopulatedForm, Permission, ResponseMode, } from '../../../types' +import { smsConfig } from '../../config/features/sms.config' import { isMongooseDocumentArray } from '../../utils/mongoose' // Converts 'test@hotmail.com, test@gmail.com' to ['test@hotmail.com', 'test@gmail.com'] @@ -121,3 +126,27 @@ export const getLogicById = ( return form_logics.find((logic) => logicId === String(logic._id)) ?? null } + +/** + * Checks if a given form is onboarded (the form's message service name is defined and different from the default) + * @param form The form to check + * @returns boolean indicating if the form is/is not onboarded + */ +export const isFormOnboarded = ( + form: Pick, +): form is IOnboardedForm => { + return form.msgSrvcName + ? !(form.msgSrvcName === smsConfig.twilioMsgSrvcSid) + : false +} + +export const extractFormLinkView = ( + form: Pick, + appUrl: string, +): FormLinkView => { + const { title, _id } = form + return { + title, + link: `${appUrl}/${_id}`, + } +} diff --git a/src/app/modules/verification/__tests__/verification.controller.spec.ts b/src/app/modules/verification/__tests__/verification.controller.spec.ts index 5b27d647da..f2e8528396 100644 --- a/src/app/modules/verification/__tests__/verification.controller.spec.ts +++ b/src/app/modules/verification/__tests__/verification.controller.spec.ts @@ -3,6 +3,7 @@ import { Request, Response } from 'express' import { StatusCodes } from 'http-status-codes' import mongoose from 'mongoose' import { errAsync, okAsync } from 'neverthrow' +import { WAIT_FOR_OTP_SECONDS } from 'shared/utils/verification' import { mocked } from 'ts-jest/utils' import { MailSendError } from 'src/app/services/mail/mail.errors' @@ -12,11 +13,10 @@ import { } from 'src/app/services/sms/sms.errors' import { HashingError } from 'src/app/utils/hash' import * as OtpUtils from 'src/app/utils/otp' -import { IFormSchema, IVerificationSchema } from 'src/types' +import { IFormSchema, IPopulatedForm, IVerificationSchema } from 'src/types' import dbHandler from 'tests/unit/backend/helpers/jest-db' -import { WAIT_FOR_OTP_SECONDS } from '../../../../../shared/utils/verification' import expressHandler from '../../../../../tests/unit/backend/helpers/jest-express' import { DatabaseError, MalformedParametersError } from '../../core/core.errors' import { FormNotFoundError } from '../../form/form.errors' @@ -614,10 +614,22 @@ describe('Verification controller', () => { }, }) + const MOCK_FORM = { + admin: { + _id: new ObjectId(), + }, + title: 'i am a form', + _id: new ObjectId(), + permissionList: [{ email: 'former@forms.sg' }], + } as IPopulatedForm + beforeEach(() => { MockFormService.retrieveFormById.mockReturnValue( okAsync({} as IFormSchema), ) + MockFormService.retrieveFullFormById.mockReturnValueOnce( + okAsync(MOCK_FORM), + ) MockOtpUtils.generateOtpWithHash.mockReturnValue( okAsync({ @@ -631,6 +643,11 @@ describe('Verification controller', () => { }) it('should return 201 when params are valid', async () => { + // Arrange + MockVerificationService.processAdminSmsCounts.mockReturnValueOnce( + okAsync(true), + ) + // Act await VerificationController._handleGenerateOtp( MOCK_REQ, @@ -650,6 +667,9 @@ describe('Verification controller', () => { hashedOtp: MOCK_HASHED_OTP, recipient: MOCK_ANSWER, }) + expect( + MockVerificationService.processAdminSmsCounts, + ).toHaveBeenCalledWith(MOCK_FORM) expect(mockRes.sendStatus).toHaveBeenCalledWith(StatusCodes.CREATED) }) diff --git a/src/app/modules/verification/__tests__/verification.service.spec.ts b/src/app/modules/verification/__tests__/verification.service.spec.ts index 8a2cf5b428..74818f2bec 100644 --- a/src/app/modules/verification/__tests__/verification.service.spec.ts +++ b/src/app/modules/verification/__tests__/verification.service.spec.ts @@ -4,16 +4,22 @@ import mongoose from 'mongoose' import { errAsync, okAsync } from 'neverthrow' import { mocked } from 'ts-jest/utils' +import { smsConfig } from 'src/app/config/features/sms.config' import formsgSdk from 'src/app/config/formsg-sdk' import * as FormService from 'src/app/modules/form/form.service' -import { MailSendError } from 'src/app/services/mail/mail.errors' +import { + MailGenerationError, + MailSendError, +} from 'src/app/services/mail/mail.errors' import MailService from 'src/app/services/mail/mail.service' import { SmsSendError } from 'src/app/services/sms/sms.errors' import { SmsFactory } from 'src/app/services/sms/sms.factory' +import * as SmsService from 'src/app/services/sms/sms.service' import * as HashUtils from 'src/app/utils/hash' import { BasicField, IFormSchema, + IPopulatedForm, IVerificationSchema, PublicTransaction, UpdateFieldData, @@ -21,8 +27,11 @@ import { import dbHandler from 'tests/unit/backend/helpers/jest-db' +import { SMS_WARNING_TIERS } from '../../../../../shared/utils/verification' import { DatabaseError } from '../../core/core.errors' +import * as AdminFormService from '../../form/admin-form/admin-form.service' import { FormNotFoundError } from '../../form/form.errors' +import * as FormUtils from '../../form/form.utils' import { FieldNotFoundInTransactionError, MissingHashDataError, @@ -682,4 +691,152 @@ describe('Verification service', () => { expect(result._unsafeUnwrapErr()).toEqual(new WrongOtpError()) }) }) + + describe('processAdminSmsCounts', () => { + const MOCK_FORM = { + title: 'some mock form', + _id: new ObjectId(), + admin: { + _id: new ObjectId(), + }, + permissionList: [{ email: 'some@user.gov.sg' }], + } as IPopulatedForm + const onboardSpy = jest.spyOn(FormUtils, 'isFormOnboarded') + const retrievalSpy = jest.spyOn(SmsService, 'retrieveFreeSmsCounts') + const disableSpy = jest.spyOn( + AdminFormService, + 'disableSmsVerificationsForUser', + ) + + it('should not do anything when the form is onboarded', async () => { + // Arrange + onboardSpy.mockReturnValueOnce(true) + + // Act + const actual = await VerificationService.processAdminSmsCounts(MOCK_FORM) + + // Assert + expect(actual._unsafeUnwrap()).toBe(true) + expect(retrievalSpy).not.toHaveBeenCalled() + }) + + it('should disable sms verifications and send email when sms limit is exceeded', async () => { + // Arrange + + MockMailService.sendSmsVerificationDisabledEmail.mockReturnValueOnce( + okAsync(true), + ) + + disableSpy.mockReturnValueOnce(okAsync(true)) + retrievalSpy.mockReturnValueOnce( + okAsync(smsConfig.smsVerificationLimit + 1), + ) + + // Act + const actual = await VerificationService.processAdminSmsCounts(MOCK_FORM) + + // Assert + expect(actual._unsafeUnwrap()).toBe(true) + expect( + MockMailService.sendSmsVerificationDisabledEmail, + ).toHaveBeenCalledWith(MOCK_FORM) + // NOTE: String casting is required so that the test recognises them as equal + expect(disableSpy).toHaveBeenCalledWith(String(MOCK_FORM.admin._id)) + }) + + it('should send a warning when the admin has sent out a certain number of sms', async () => { + // Arrange + + MockMailService.sendSmsVerificationWarningEmail.mockReturnValueOnce( + okAsync(true), + ) + retrievalSpy.mockReturnValueOnce(okAsync(SMS_WARNING_TIERS.LOW)) + + // Act + const actual = await VerificationService.processAdminSmsCounts(MOCK_FORM) + + // Assert + expect(actual._unsafeUnwrap()).toBe(true) + expect(disableSpy).not.toHaveBeenCalled() + expect( + MockMailService.sendSmsVerificationWarningEmail, + ).toHaveBeenCalledWith(MOCK_FORM, SMS_WARNING_TIERS.LOW) + }) + + it('should not do anything when the sms sent by admin is not at any limit', async () => { + // Arrange + + retrievalSpy.mockReturnValueOnce(okAsync(SMS_WARNING_TIERS.LOW - 1)) + + // Act + const actual = await VerificationService.processAdminSmsCounts(MOCK_FORM) + + // Assert + expect(actual._unsafeUnwrap()).toBe(true) + expect( + MockMailService.sendSmsVerificationDisabledEmail, + ).not.toHaveBeenCalled() + expect( + MockMailService.sendSmsVerificationWarningEmail, + ).not.toHaveBeenCalled() + expect(disableSpy).not.toHaveBeenCalled() + }) + + it('should propagate any errors encountered during warning mail sending', async () => { + // Arrange + const expected = new MailGenerationError('big ded') + MockMailService.sendSmsVerificationWarningEmail.mockReturnValueOnce( + errAsync(expected), + ) + retrievalSpy.mockReturnValueOnce(okAsync(SMS_WARNING_TIERS.LOW)) + + // Act + const actual = await VerificationService.processAdminSmsCounts(MOCK_FORM) + + // Assert + expect(actual._unsafeUnwrapErr()).toBe(expected) + expect(disableSpy).not.toHaveBeenCalled() + expect( + MockMailService.sendSmsVerificationWarningEmail, + ).toHaveBeenCalledWith(MOCK_FORM, SMS_WARNING_TIERS.LOW) + }) + + it('should propagate any errors encountered during disabled mail sending', async () => { + // Arrange + const expected = new MailGenerationError('big ded') + MockMailService.sendSmsVerificationDisabledEmail.mockReturnValueOnce( + errAsync(expected), + ) + retrievalSpy.mockReturnValueOnce( + okAsync(smsConfig.smsVerificationLimit + 1), + ) + + // Act + const actual = await VerificationService.processAdminSmsCounts(MOCK_FORM) + + // Assert + expect(disableSpy).not.toHaveBeenCalled() + expect( + MockMailService.sendSmsVerificationWarningEmail, + ).not.toHaveBeenCalled() + expect(actual._unsafeUnwrapErr()).toBe(expected) + expect( + MockMailService.sendSmsVerificationDisabledEmail, + ).toHaveBeenCalledWith(MOCK_FORM) + }) + + it('should return the error received when retrieval of sms counts fails', async () => { + // Arrange + const expected = new DatabaseError() + onboardSpy.mockReturnValueOnce(false) + retrievalSpy.mockReturnValueOnce(errAsync(expected)) + + // Act + const actual = await VerificationService.processAdminSmsCounts(MOCK_FORM) + + // Assert + expect(actual._unsafeUnwrapErr()).toBe(expected) + expect(retrievalSpy).toBeCalledWith(String(MOCK_FORM.admin._id)) + }) + }) }) diff --git a/src/app/modules/verification/verification.controller.ts b/src/app/modules/verification/verification.controller.ts index 1b134d165c..0741fd4a4f 100644 --- a/src/app/modules/verification/verification.controller.ts +++ b/src/app/modules/verification/verification.controller.ts @@ -221,7 +221,21 @@ export const _handleGenerateOtp: ControllerHandler< transactionId, }), ) - .map(() => res.sendStatus(StatusCodes.CREATED)) + .map(() => { + res.sendStatus(StatusCodes.CREATED) + // NOTE: This is returned because tests require this to avoid async mocks interfering with each other. + // However, this is not an issue in reality because express does not require awaiting on the sendStatus call. + return FormService.retrieveFullFormById(formId) + .andThen((form) => VerificationService.processAdminSmsCounts(form)) + .mapErr((error) => { + logger.error({ + message: + 'Error checking sms counts or deactivating OTP verification for admin', + meta: logMeta, + error, + }) + }) + }) .mapErr((error) => { logger.error({ message: 'Error creating new OTP', diff --git a/src/app/modules/verification/verification.errors.ts b/src/app/modules/verification/verification.errors.ts index f7464064ea..555add4134 100644 --- a/src/app/modules/verification/verification.errors.ts +++ b/src/app/modules/verification/verification.errors.ts @@ -82,3 +82,14 @@ export class NonVerifiedFieldTypeError extends ApplicationError { ) } } + +/** + * Agency user has sent too many SMSes using default Twilio credentials + */ +export class SmsLimitExceededError extends ApplicationError { + constructor( + message = 'You have exceeded the free sms limit. Please refresh and try again.', + ) { + super(message) + } +} diff --git a/src/app/modules/verification/verification.service.ts b/src/app/modules/verification/verification.service.ts index 1ae5f98324..c34383d0f9 100644 --- a/src/app/modules/verification/verification.service.ts +++ b/src/app/modules/verification/verification.service.ts @@ -1,19 +1,28 @@ import mongoose from 'mongoose' import { err, errAsync, ok, okAsync, Result, ResultAsync } from 'neverthrow' -import { NUM_OTP_RETRIES } from '../../../../shared/utils/verification' +import { + NUM_OTP_RETRIES, + SMS_WARNING_TIERS, +} from '../../../../shared/utils/verification' import { BasicField, + IPopulatedForm, IVerificationFieldSchema, IVerificationSchema, PublicTransaction, } from '../../../types' import formsgSdk from '../../config/formsg-sdk' import { createLoggerWithLabel } from '../../config/logger' -import { MailSendError } from '../../services/mail/mail.errors' +import * as AdminFormService from '../../modules/form/admin-form/admin-form.service' +import { + MailGenerationError, + MailSendError, +} from '../../services/mail/mail.errors' import MailService from '../../services/mail/mail.service' import { InvalidNumberError, SmsSendError } from '../../services/sms/sms.errors' import { SmsFactory } from '../../services/sms/sms.factory' +import * as SmsService from '../../services/sms/sms.service' import { transformMongoError } from '../../utils/handle-mongo-error' import { compareHash, HashingError } from '../../utils/hash' import { @@ -23,6 +32,7 @@ import { } from '../core/core.errors' import { FormNotFoundError } from '../form/form.errors' import * as FormService from '../form/form.service' +import { isFormOnboarded } from '../form/form.utils' import { FieldNotFoundInTransactionError, @@ -38,6 +48,7 @@ import { import getVerificationModel from './verification.model' import { SendOtpParams } from './verification.types' import { + hasAdminExceededFreeSmsLimit, isOtpExpired, isOtpWaitTimeElapsed, isTransactionExpired, @@ -476,3 +487,62 @@ const sendOtpForField = ( return errAsync(new NonVerifiedFieldTypeError(fieldType)) } } + +/** + * Checks the number of free smses sent by the admin of the form and deactivates verification or sends mail as required + * @param form The form whose admin's sms counts needs to be checked + * @returns ok(true) when the verification has been deactivated successfully or no action is required + * @returns err(MailGenerationError) when an error occurred on creating the HTML template for the email + * @returns err(MailSendError) when an error occurred on sending the email + * @returns err(PossibleDatabaseError) when an error occurred while retrieving the counts from the database + */ +export const processAdminSmsCounts = ( + form: IPopulatedForm, +): ResultAsync< + true, + MailGenerationError | MailSendError | PossibleDatabaseError +> => { + if (isFormOnboarded(form)) { + return okAsync(true) + } + + // Convert to string because it's typed as any + const formAdminId = String(form.admin._id) + + return SmsService.retrieveFreeSmsCounts(formAdminId).andThen((freeSmsSent) => + checkSmsCountAndPerformAction(form, freeSmsSent), + ) +} + +/** + * Checks the number of free smses sent by the admin of a form and performs the appropriate action + * @param form The form whose admin's sms counts needs to be checked + * @returns ok(true) when the action has been performed successfully + * @returns err(MailGenerationError) when an error occurred on creating the HTML template for the email + * @returns err(MailSendError) when an error occurred on sending the email + * @returns err(PossibleDatabaseError) when an error occurred while retrieving the counts from the database + */ +const checkSmsCountAndPerformAction = ( + form: Pick, + freeSmsSent: number, +): ResultAsync< + true, + MailGenerationError | MailSendError | PossibleDatabaseError +> => { + // Convert to string because it's typed as any + const formAdminId = String(form.admin._id) + + // NOTE: Because the admin has exceeded their allowable limit of free sms, + // the sms verifications for their forms also need to be disabled. + if (hasAdminExceededFreeSmsLimit(freeSmsSent)) { + return MailService.sendSmsVerificationDisabledEmail(form).andThen(() => + AdminFormService.disableSmsVerificationsForUser(formAdminId), + ) + } + + if (freeSmsSent in SMS_WARNING_TIERS) { + return MailService.sendSmsVerificationWarningEmail(form, freeSmsSent) + } + + return okAsync(true) +} diff --git a/src/app/modules/verification/verification.util.ts b/src/app/modules/verification/verification.util.ts index 2e179dfc75..b47478817c 100644 --- a/src/app/modules/verification/verification.util.ts +++ b/src/app/modules/verification/verification.util.ts @@ -11,6 +11,7 @@ import { IVerificationSchema, MapRouteError, } from '../../../types' +import { smsConfig } from '../../config/features/sms.config' import { createLoggerWithLabel } from '../../config/logger' import { MailSendError } from '../../services/mail/mail.errors' import { InvalidNumberError, SmsSendError } from '../../services/sms/sms.errors' @@ -210,3 +211,7 @@ export const mapRouteError: MapRouteError = ( } } } + +export const hasAdminExceededFreeSmsLimit = (smsCount: number): boolean => { + return smsCount > smsConfig.smsVerificationLimit +} diff --git a/src/app/services/mail/__tests__/mail.service.spec.ts b/src/app/services/mail/__tests__/mail.service.spec.ts index 2ad3be7949..e0f46f1e0d 100644 --- a/src/app/services/mail/__tests__/mail.service.spec.ts +++ b/src/app/services/mail/__tests__/mail.service.spec.ts @@ -1,9 +1,14 @@ +import ejs from 'ejs' import { cloneDeep } from 'lodash' import moment from 'moment-timezone' import { err, ok, okAsync } from 'neverthrow' import Mail, { Attachment } from 'nodemailer/lib/mailer' -import { MailSendError } from 'src/app/services/mail/mail.errors' +import { extractFormLinkView } from 'src/app/modules/form/form.utils' +import { + MailGenerationError, + MailSendError, +} from 'src/app/services/mail/mail.errors' import { MailService } from 'src/app/services/mail/mail.service' import { AutoreplySummaryRenderData, @@ -13,7 +18,12 @@ import { import * as MailUtils from 'src/app/services/mail/mail.utils' import { BounceType, IPopulatedForm, ISubmissionSchema } from 'src/types' -import { HASH_EXPIRE_AFTER_SECONDS } from '../../../../../shared/utils/verification' +import { + HASH_EXPIRE_AFTER_SECONDS, + stringifiedSmsWarningTiers, +} from '../../../../../shared/utils/verification' +import { smsConfig } from '../../../config/features/sms.config' +import * as FormService from '../../../modules/form/form.service' const MOCK_VALID_EMAIL = 'to@example.com' const MOCK_VALID_EMAIL_2 = 'to2@example.com' @@ -1244,4 +1254,267 @@ describe('mail.service', () => { expect(sendMailSpy).toHaveBeenCalledWith(expectedArgs) }) }) + + describe('sendSmsVerificationDisabledEmail', () => { + const MOCK_FORM_ID = 'mockFormId' + const MOCK_FORM_TITLE = 'You are all individuals!' + const MOCK_INVALID_EMAIL = 'something wrong@a' + + const MOCK_FORM: IPopulatedForm = { + permissionList: [ + { email: MOCK_VALID_EMAIL_2 }, + { email: MOCK_VALID_EMAIL_3 }, + ], + admin: { + email: MOCK_VALID_EMAIL, + }, + title: MOCK_FORM_TITLE, + _id: MOCK_FORM_ID, + } as unknown as IPopulatedForm + + const MOCK_INVALID_EMAIL_FORM: IPopulatedForm = { + permissionList: [], + admin: { + email: MOCK_INVALID_EMAIL, + }, + title: MOCK_FORM_TITLE, + _id: MOCK_FORM_ID, + } as unknown as IPopulatedForm + + const generateAdminExpectedMailOptions = async (admin: string) => { + const result = + await MailUtils.generateSmsVerificationDisabledHtmlForAdmin({ + forms: [extractFormLinkView(MOCK_FORM, MOCK_APP_URL)], + smsVerificationLimit: + smsConfig.smsVerificationLimit.toLocaleString('en-US'), + smsWarningTiers: stringifiedSmsWarningTiers, + }).map((emailHtml) => { + return { + to: admin, + from: MOCK_SENDER_STRING, + html: emailHtml, + subject: '[FormSG] SMS Verification - Free Tier Limit Reached', + replyTo: MOCK_SENDER_EMAIL, + bcc: MOCK_SENDER_EMAIL, + } + }) + return result._unsafeUnwrap() + } + + const generateCollabExpectedMailOptions = async ( + admin: string, + collab: string[], + ) => { + const result = + await MailUtils.generateSmsVerificationDisabledHtmlForCollab({ + form: extractFormLinkView(MOCK_FORM, MOCK_APP_URL), + smsVerificationLimit: + smsConfig.smsVerificationLimit.toLocaleString('en-US'), + smsWarningTiers: stringifiedSmsWarningTiers, + }).map((emailHtml) => { + return { + to: admin, + cc: collab, + from: MOCK_SENDER_STRING, + html: emailHtml, + subject: '[FormSG] SMS Verification - Free Tier Limit Reached', + replyTo: MOCK_SENDER_EMAIL, + bcc: MOCK_SENDER_EMAIL, + } + }) + return result._unsafeUnwrap() + } + it('should send verified sms disabled emails successfully', async () => { + // Arrange + // sendMail should return mocked success response + sendMailSpy.mockResolvedValueOnce('mockedSuccessResponse') + sendMailSpy.mockResolvedValueOnce('mockedSuccessResponse') + jest + .spyOn(FormService, 'retrievePublicFormsWithSmsVerification') + .mockReturnValueOnce(okAsync([MOCK_FORM])) + const expectedAdminMailOptions = await generateAdminExpectedMailOptions( + MOCK_VALID_EMAIL, + ) + const expectedCollabMailOptions = await generateCollabExpectedMailOptions( + MOCK_VALID_EMAIL, + [MOCK_VALID_EMAIL_2, MOCK_VALID_EMAIL_3], + ) + + // Act + const actualResult = await mailService.sendSmsVerificationDisabledEmail( + MOCK_FORM, + ) + + // Assert + expect(actualResult._unsafeUnwrap()).toEqual(true) + // Check arguments passed to sendNodeMail + expect(sendMailSpy).toHaveBeenCalledTimes(2) + expect(sendMailSpy).toHaveBeenCalledWith(expectedAdminMailOptions) + expect(sendMailSpy).toHaveBeenCalledWith(expectedCollabMailOptions) + }) + + it('should return MailSendError when the provided email is invalid', async () => { + // Arrange + jest + .spyOn(FormService, 'retrievePublicFormsWithSmsVerification') + .mockReturnValueOnce(okAsync([MOCK_INVALID_EMAIL_FORM])) + + // Act + const actualResult = await mailService.sendSmsVerificationDisabledEmail( + MOCK_INVALID_EMAIL_FORM, + ) + + // Assert + expect(actualResult).toEqual( + err(new MailSendError('Invalid email error')), + ) + // Check arguments passed to sendNodeMail + expect(sendMailSpy).toHaveBeenCalledTimes(0) + }) + + it('should return MailGenerationError when the html template could not be created', async () => { + // Arrange + jest + .spyOn(FormService, 'retrievePublicFormsWithSmsVerification') + .mockReturnValueOnce(okAsync([MOCK_INVALID_EMAIL_FORM])) + jest.spyOn(ejs, 'renderFile').mockRejectedValueOnce('no.') + + // Act + const actualResult = await mailService.sendSmsVerificationDisabledEmail( + MOCK_INVALID_EMAIL_FORM, + ) + + // Assert + expect(actualResult).toEqual( + err( + new MailGenerationError( + 'Error occurred whilst rendering mail template', + ), + ), + ) + // Check arguments passed to sendNodeMail + expect(sendMailSpy).toHaveBeenCalledTimes(0) + }) + }) + + describe('sendSmsVerificationWarningEmail', () => { + const MOCK_FORM_ID = 'mockFormId' + const MOCK_FORM_TITLE = 'You are all individuals!' + const MOCK_INVALID_EMAIL = 'something wrong@a' + + const MOCK_FORM: IPopulatedForm = { + permissionList: [ + { email: MOCK_VALID_EMAIL_2 }, + { email: MOCK_VALID_EMAIL_3 }, + ], + admin: { + email: MOCK_VALID_EMAIL, + }, + title: MOCK_FORM_TITLE, + _id: MOCK_FORM_ID, + } as unknown as IPopulatedForm + + const MOCK_INVALID_EMAIL_FORM: IPopulatedForm = { + permissionList: [], + admin: { + email: MOCK_INVALID_EMAIL, + }, + title: MOCK_FORM_TITLE, + _id: MOCK_FORM_ID, + } as unknown as IPopulatedForm + + const generateExpectedMailOptions = async ( + count: number, + admin: string, + ) => { + const result = await MailUtils.generateSmsVerificationWarningHtml({ + forms: [extractFormLinkView(MOCK_FORM, MOCK_APP_URL)], + numAvailable: (smsConfig.smsVerificationLimit - count).toLocaleString( + 'en-US', + ), + smsVerificationLimit: + smsConfig.smsVerificationLimit.toLocaleString('en-US'), + }).map((emailHtml) => { + return { + to: admin, + from: MOCK_SENDER_STRING, + html: emailHtml, + subject: '[FormSG] SMS Verification - Free Tier Limit Alert', + replyTo: MOCK_SENDER_EMAIL, + bcc: MOCK_SENDER_EMAIL, + } + }) + return result._unsafeUnwrap() + } + + it('should send verified sms warning emails successfully', async () => { + // Arrange + jest + .spyOn(FormService, 'retrievePublicFormsWithSmsVerification') + .mockReturnValueOnce(okAsync([MOCK_FORM])) + // sendMail should return mocked success response + sendMailSpy.mockResolvedValueOnce('mockedSuccessResponse') + + // Act + const actualResult = await mailService.sendSmsVerificationWarningEmail( + MOCK_FORM, + 1000, + ) + const expectedMailOptions = await generateExpectedMailOptions( + 1000, + MOCK_VALID_EMAIL, + ) + + // Assert + expect(actualResult._unsafeUnwrap()).toEqual(true) + // Check arguments passed to sendNodeMail + expect(sendMailSpy).toHaveBeenCalledTimes(1) + expect(sendMailSpy).toHaveBeenCalledWith(expectedMailOptions) + }) + + it('should return MailSendError when the provided email is invalid', async () => { + // Arrange + jest + .spyOn(FormService, 'retrievePublicFormsWithSmsVerification') + .mockReturnValueOnce(okAsync([MOCK_INVALID_EMAIL_FORM])) + + // Act + const actualResult = await mailService.sendSmsVerificationWarningEmail( + MOCK_INVALID_EMAIL_FORM, + 1000, + ) + + // Assert + expect(actualResult).toEqual( + err(new MailSendError('Invalid email error')), + ) + // Check arguments passed to sendNodeMail + expect(sendMailSpy).toHaveBeenCalledTimes(0) + }) + + it('should return MailGenerationError when the html template could not be created', async () => { + // Arrange + jest + .spyOn(FormService, 'retrievePublicFormsWithSmsVerification') + .mockReturnValueOnce(okAsync([MOCK_FORM])) + jest.spyOn(ejs, 'renderFile').mockRejectedValueOnce('no.') + + // Act + const actualResult = await mailService.sendSmsVerificationWarningEmail( + MOCK_INVALID_EMAIL_FORM, + 1000, + ) + + // Assert + expect(actualResult).toEqual( + err( + new MailGenerationError( + 'Error occurred whilst rendering mail template', + ), + ), + ) + // Check arguments passed to sendNodeMail + expect(sendMailSpy).toHaveBeenCalledTimes(0) + }) + }) }) diff --git a/src/app/services/mail/mail.service.ts b/src/app/services/mail/mail.service.ts index 4729b08b95..7839380ff8 100644 --- a/src/app/services/mail/mail.service.ts +++ b/src/app/services/mail/mail.service.ts @@ -1,36 +1,57 @@ import { get, inRange, isEmpty } from 'lodash' import moment from 'moment-timezone' -import { err, errAsync, Result, ResultAsync } from 'neverthrow' +import { + combine, + err, + errAsync, + okAsync, + Result, + ResultAsync, +} from 'neverthrow' import Mail from 'nodemailer/lib/mailer' import promiseRetry from 'promise-retry' import validator from 'validator' -import { HASH_EXPIRE_AFTER_SECONDS } from '../../../../shared/utils/verification' +import { + HASH_EXPIRE_AFTER_SECONDS, + stringifiedSmsWarningTiers, +} from '../../../../shared/utils/verification' import { BounceType, EmailAdminDataField, IEmailFormSchema, + IPopulatedForm, + IPopulatedUser, ISubmissionSchema, } from '../../../types' import config from '../../config/config' +import { smsConfig } from '../../config/features/sms.config' import { createLoggerWithLabel } from '../../config/logger' +import * as FormService from '../../modules/form/form.service' +import { extractFormLinkView } from '../../modules/form/form.utils' import { EMAIL_HEADERS, EmailType } from './mail.constants' import { MailGenerationError, MailSendError } from './mail.errors' import { + AdminSmsDisabledData, AutoreplySummaryRenderData, BounceNotificationHtmlData, + CollabSmsDisabledData, MailOptions, MailServiceParams, SendAutoReplyEmailsArgs, SendMailOptions, SendSingleAutoreplyMailArgs, + SmsVerificationWarningData, } from './mail.types' import { generateAutoreplyHtml, generateAutoreplyPdf, generateBounceNotificationHtml, generateLoginOtpHtml, + generateSmsVerificationDisabledHtmlForAdmin, + generateSmsVerificationDisabledHtmlForCollab, + generateSmsVerificationWarningHtml, generateSubmissionToAdminHtml, generateVerificationOtpHtml, isToFieldValid, @@ -572,6 +593,162 @@ export class MailService { }), ) } + + /** + * Sends a email to the admin and collaborators of the form when the verified sms feature will be disabled. + * This happens only when the admin has hit a certain limit of sms verifications on his account. + * + * Note that the email sent to the admin and collaborators will differ. + * This is because the admin will see all of their forms that are affected but collaborators + * only see forms which they are a part of. + * + * @param form The form whose admin and collaborators will be issued the email + * @returns ok(true) when mail sending is successful + * @returns err(MailGenerationError) when there was an error in generating the html data for the mail + * @returns err(MailSendError) when there was an error in sending the mail + */ + sendSmsVerificationDisabledEmail = ( + form: Pick, + ): ResultAsync => { + // Step 1: Retrieve all public forms of admin that have sms verification enabled + return FormService.retrievePublicFormsWithSmsVerification(form.admin._id) + .andThen((forms) => { + // Step 2: Send the mail containing all the active forms to the admin + return this.sendDisabledMailForAdmin(forms, form.admin).map(() => forms) + }) + .andThen((forms) => { + // Step 3: Send to each individual form + return combine( + forms.map((f) => + // If there are no collaborators, do not send out the email. + // Admin would already have received a summary email from Step 2. + f.permissionList.length + ? this.sendDisabledMailForCollab(f, form.admin) + : okAsync(true), + ), + ) + }) + .map(() => true) + } + + // Helper method to send an email to all the collaborators of a given form that would be affected by + // Sms verifications being disabled for the form. + // Note that this method also emails the admin to notify them that the collaborators have been informed. + sendDisabledMailForCollab = ( + form: IPopulatedForm, + admin: IPopulatedUser, + ): ResultAsync => { + const htmlData: CollabSmsDisabledData = { + form: extractFormLinkView(form, this.#appUrl), + smsVerificationLimit: + // Formatted using localeString so that the displayed number has commas + smsConfig.smsVerificationLimit.toLocaleString('en-US'), + smsWarningTiers: stringifiedSmsWarningTiers, + } + const collaborators = form.permissionList.map(({ email }) => email) + + return generateSmsVerificationDisabledHtmlForCollab(htmlData).andThen( + (mailHtml) => { + const mailOptions: MailOptions = { + to: admin.email, + cc: collaborators, + from: this.#senderFromString, + html: mailHtml, + subject: '[FormSG] SMS Verification - Free Tier Limit Reached', + replyTo: this.#senderMail, + bcc: this.#senderMail, + } + + return this.#sendNodeMail(mailOptions, { + formId: form._id, + mailId: 'sendDisabledMailForCollab', + }) + }, + ) + } + + // Helper method to send an email to a form admin which contains a summary of + // which forms would be impacted by sms verifications being removed. + sendDisabledMailForAdmin = ( + forms: IPopulatedForm[], + admin: IPopulatedUser, + ): ResultAsync => { + const htmlData: AdminSmsDisabledData = { + forms: forms.map((f) => extractFormLinkView(f, this.#appUrl)), + smsVerificationLimit: + // Formatted using localeString so that the displayed number has commas + smsConfig.smsVerificationLimit.toLocaleString('en-US'), + smsWarningTiers: stringifiedSmsWarningTiers, + } + + return ( + // Step 1: Generate HTML data for admin + generateSmsVerificationDisabledHtmlForAdmin(htmlData).andThen( + (mailHtml) => { + const mailOptions: MailOptions = { + to: admin.email, + from: this.#senderFromString, + html: mailHtml, + subject: '[FormSG] SMS Verification - Free Tier Limit Reached', + replyTo: this.#senderMail, + bcc: this.#senderMail, + } + + // Step 2: Send mail out to admin ONLY + return this.#sendNodeMail(mailOptions, { + mailId: 'sendDisabledMailForAdmin', + }) + }, + ) + ) + } + + /** + * Sends a warning email to the admin of the form when their current verified sms counts hits a limit + * @param form The form whose admin will be issued a warning + * @param smsVerifications The current total sms verifications for the form + * @returns ok(true) when mail sending is successful + * @returns err(MailGenerationError) when there was an error in generating the html data for the mail + * @returns err(MailSendError) when there was an error in sending the mail + */ + sendSmsVerificationWarningEmail = ( + form: Pick, + smsVerifications: number, + ): ResultAsync => { + // Step 1: Retrieve all public forms of admin that have sms verification enabled + return FormService.retrievePublicFormsWithSmsVerification( + form.admin._id, + ).andThen((forms) => { + const htmlData: SmsVerificationWarningData = { + forms: forms.map((f) => extractFormLinkView(f, this.#appUrl)), + // Formatted using localeString so that the displayed number has commas + numAvailable: ( + smsConfig.smsVerificationLimit - smsVerifications + ).toLocaleString('en-US'), + smsVerificationLimit: + smsConfig.smsVerificationLimit.toLocaleString('en-US'), + } + + // Step 2: Generate HTML from template + return generateSmsVerificationWarningHtml(htmlData).andThen( + (mailHtml) => { + const mailOptions: MailOptions = { + to: form.admin.email, + from: this.#senderFromString, + html: mailHtml, + subject: '[FormSG] SMS Verification - Free Tier Limit Alert', + replyTo: this.#senderMail, + bcc: this.#senderMail, + } + + // Step 3: Send mail out + return this.#sendNodeMail(mailOptions, { + mailId: 'sendSmsVerificationWarningEmail', + }) + }, + ) + }) + } } export default new MailService() diff --git a/src/app/services/mail/mail.types.ts b/src/app/services/mail/mail.types.ts index 5d1a62d392..e30aec0ad3 100644 --- a/src/app/services/mail/mail.types.ts +++ b/src/app/services/mail/mail.types.ts @@ -1,9 +1,11 @@ import Mail from 'nodemailer/lib/mailer' import { OperationOptions } from 'retry' +import { SMS_WARNING_TIERS } from 'shared/utils/verification' import { AutoReplyOptions, EmailAdminDataField, + FormLinkView, IFormSchema, IPopulatedForm, ISubmissionSchema, @@ -86,3 +88,23 @@ export type BounceNotificationHtmlData = { bouncedRecipients: string appName: string } + +export type AdminSmsDisabledData = { + forms: FormLinkView[] +} & SmsVerificationTiers + +export type CollabSmsDisabledData = { + form: FormLinkView +} & SmsVerificationTiers + +type SmsVerificationTiers = { + smsVerificationLimit: string + // Ensure that all tiers are covered + smsWarningTiers: { [K in keyof typeof SMS_WARNING_TIERS]: string } +} + +export type SmsVerificationWarningData = { + forms: FormLinkView[] + numAvailable: string + smsVerificationLimit: string +} diff --git a/src/app/services/mail/mail.utils.ts b/src/app/services/mail/mail.utils.ts index d6df270e53..bd94b194f7 100644 --- a/src/app/services/mail/mail.utils.ts +++ b/src/app/services/mail/mail.utils.ts @@ -11,9 +11,12 @@ import { createLoggerWithLabel } from '../../config/logger' import { MailGenerationError, MailSendError } from './mail.errors' import { + AdminSmsDisabledData, AutoreplyHtmlData, AutoreplySummaryRenderData, BounceNotificationHtmlData, + CollabSmsDisabledData, + SmsVerificationWarningData, SubmissionToAdminHtmlData, } from './mail.types' @@ -169,3 +172,24 @@ export const isToFieldValid = (addresses: string | string[]): boolean => { // Every address must be an email to be valid. return mails.every((addr) => validator.isEmail(addr)) } + +export const generateSmsVerificationDisabledHtmlForAdmin = ( + htmlData: AdminSmsDisabledData, +): ResultAsync => { + const pathToTemplate = `${process.cwd()}/src/app/views/templates/sms-verification-disabled-admin.server.view.html` + return safeRenderFile(pathToTemplate, htmlData) +} + +export const generateSmsVerificationDisabledHtmlForCollab = ( + htmlData: CollabSmsDisabledData, +): ResultAsync => { + const pathToTemplate = `${process.cwd()}/src/app/views/templates/sms-verification-disabled-collab.server.view.html` + return safeRenderFile(pathToTemplate, htmlData) +} + +export const generateSmsVerificationWarningHtml = ( + htmlData: SmsVerificationWarningData, +): ResultAsync => { + const pathToTemplate = `${process.cwd()}/src/app/views/templates/sms-verification-warning.view.html` + return safeRenderFile(pathToTemplate, htmlData) +} diff --git a/src/app/services/sms/sms_count.server.model.ts b/src/app/services/sms/sms_count.server.model.ts index c509168b1a..234431b70c 100644 --- a/src/app/services/sms/sms_count.server.model.ts +++ b/src/app/services/sms/sms_count.server.model.ts @@ -145,7 +145,9 @@ const compileSmsCountModel = (db: Mongoose) => { 'formAdmin.userId': userId, smsType: SmsType.Verification, isOnboardedAccount: false, - }).exec() + }) + .read('secondary') + .exec() } const SmsCountModel = db.model( diff --git a/src/app/utils/field-validation/field-validation.guards.ts b/src/app/utils/field-validation/field-validation.guards.ts index dba136ce53..93d4390ef6 100644 --- a/src/app/utils/field-validation/field-validation.guards.ts +++ b/src/app/utils/field-validation/field-validation.guards.ts @@ -1,7 +1,14 @@ import { get } from 'lodash' import { types as basicTypes } from '../../../../shared/constants/field/basic' -import { BasicField, IEmailFieldSchema, ITableRow } from '../../../types' +import { + BasicField, + IEmailFieldSchema, + IField, + IMobileField, + ITableRow, + IVerifiableMobileField, +} from '../../../types' import { ColumnResponse, ProcessedAttachmentResponse, @@ -88,3 +95,13 @@ export const isPossibleEmailFieldSchema = ( ): field is Partial => { return get(field, 'fieldType') === BasicField.Email } + +const isMobileNumberField = (formField: IField): formField is IMobileField => { + return formField.fieldType === BasicField.Mobile +} + +export const isVerifiableMobileField = ( + formField: IField, +): formField is IVerifiableMobileField => { + return isMobileNumberField(formField) && formField.isVerifiable +} diff --git a/src/app/views/templates/sms-verification-disabled-admin.server.view.html b/src/app/views/templates/sms-verification-disabled-admin.server.view.html new file mode 100644 index 0000000000..4e160ff25d --- /dev/null +++ b/src/app/views/templates/sms-verification-disabled-admin.server.view.html @@ -0,0 +1,38 @@ + + + + +

Dear form admin,

+

+ You are receiving this email as you own forms with SMS OTP verification + enabled: +

+
    + <% forms.forEach(function({title, link}) { %> +
  1. <%= title %>
  2. + <% }) %> +
+

+ As you have reached the free tier limit of <%= smsVerificationLimit %> + verifications sent, SMS OTP verification has been + automatically disabled on all your form(s). Respondents will still + be able to submit to your form(s) but will not be prompted to verify their + mobile numbers. +

+ +

+ We would have previously notified all form admins upon your account + reaching <%= smsWarningTiers.LOW %>, <%= smsWarningTiers.MED %> and <%= + smsWarningTiers.HIGH %> free verifications. +

+

+ If you need to send more SMS OTP verifications, please + + arrange advance billing with us. + +

+

FormSG Team

+ + diff --git a/src/app/views/templates/sms-verification-disabled-collab.server.view.html b/src/app/views/templates/sms-verification-disabled-collab.server.view.html new file mode 100644 index 0000000000..5c87148fef --- /dev/null +++ b/src/app/views/templates/sms-verification-disabled-collab.server.view.html @@ -0,0 +1,34 @@ + + + + +

Dear form admin,

+

+ You are receiving this email as you are a collaborator on a form with SMS + OTP verification enabled: + <%= form.title %>. +

+

+ As the owner has reached the free tier limit of <%= smsVerificationLimit + %> verifications sent, SMS OTP verification has been automatically + disabled on this form. Respondents will still be able to submit to this + form but will not be prompted to verify their mobile numbers. +

+ +

+ We would have previously notified all form admins upon your account + reaching <%= smsWarningTiers.LOW %>, <%= smsWarningTiers.MED %> and <%= + smsWarningTiers.HIGH %> responses while free SMS OTP verification was + enabled. +

+

+ If you need to send more SMS OTP verifications, please + + arrange advance billing with us. + +

+

FormSG Team

+ + diff --git a/src/app/views/templates/sms-verification-warning.view.html b/src/app/views/templates/sms-verification-warning.view.html new file mode 100644 index 0000000000..e7b6fdc0d7 --- /dev/null +++ b/src/app/views/templates/sms-verification-warning.view.html @@ -0,0 +1,29 @@ + + + + +

Dear form admin,

+

+ You are receiving this email as you own forms with SMS OTP verification + enabled: +

+
    + <% forms.forEach(function({title, link}) { %> +
  1. <%= title %>
  2. + <% }) %> +
+

+ Your account can use <%= numAvailable %> more free verifications + until free SMS OTP verification is automatically disabled for all owned + forms. +

+

+ If you need to send more than 10,000 SMS OTP verifications, please + arrange advance billing with us. + +

+

FormSG Team

+ + diff --git a/src/public/modules/forms/admin/css/edit-form.css b/src/public/modules/forms/admin/css/edit-form.css index 38f1a362ef..69bc3323a0 100644 --- a/src/public/modules/forms/admin/css/edit-form.css +++ b/src/public/modules/forms/admin/css/edit-form.css @@ -1426,3 +1426,18 @@ a.modal-cancel-btn:hover { .pull-right { cursor: pointer; } + +.inline-padding { + /* margin is set so that the error message appears to be in the same block as the toggle options */ + margin: 0 15px; +} + +/* Spinner */ +.loading-spinner { + display: flex; + justify-content: flex-end; +} + +.sms-counts { + color: #484848; +} diff --git a/src/public/modules/forms/admin/directiveViews/configure-mobile.client.view.html b/src/public/modules/forms/admin/directiveViews/configure-mobile.client.view.html index 44b68cf7fc..2194c39105 100644 --- a/src/public/modules/forms/admin/directiveViews/configure-mobile.client.view.html +++ b/src/public/modules/forms/admin/directiveViews/configure-mobile.client.view.html @@ -1,17 +1,31 @@
-
-
- OTP verification - +
+
+
+ OTP verification + +
+
+ {{verifiedSmsCount}}/{{smsVerificationLimit}} SMSes used +
+
+ +

+
+ + + You have reached the free tier limit for SMS verification. To continue + using SMS verification, + + please arrange for billing with us + +
diff --git a/src/public/modules/forms/admin/directives/configure-mobile.client.directive.js b/src/public/modules/forms/admin/directives/configure-mobile.client.directive.js index ee0bcec6a0..a48c052073 100644 --- a/src/public/modules/forms/admin/directives/configure-mobile.client.directive.js +++ b/src/public/modules/forms/admin/directives/configure-mobile.client.directive.js @@ -1,4 +1,11 @@ 'use strict' +const { get } = require('lodash') + +const { + ADMIN_VERIFIED_SMS_STATES, +} = require('../../../../../../shared/utils/verification') + +const AdminMetaService = require('../../../../services/AdminMetaService') angular .module('forms') @@ -10,24 +17,96 @@ function configureMobileDirective() { 'modules/forms/admin/directiveViews/configure-mobile.client.view.html', restrict: 'E', scope: { - field: '=', + field: '<', + form: '<', name: '=', characterLimit: '=', + smsVerificationLimit: '=', + verifiedSmsCount: '=', + isLoading: '<', }, controller: [ + '$q', '$uibModal', '$scope', '$translate', - function ($uibModal, $scope, $translate) { - // Get support form link from translation json. - $translate('LINKS.SUPPORT_FORM_LINK').then((supportFormLink) => { - $scope.supportFormLink = supportFormLink - }) + 'Toastr', + function ($q, $uibModal, $scope, $translate, Toastr) { + // Get the link for onboarding the form from the translation json + $translate('LINKS.VERIFIED_SMS_SETUP_LINK').then( + (verifiedSmsSetupLink) => { + $scope.verifiedSmsSetupLink = verifiedSmsSetupLink + }, + ) + + // Formats a given string as a number by setting it to US locale. + // Concretely, this adds commas between every thousand. + const formatStringAsNumber = (num) => + Number(num).toLocaleString('en-US') + + // NOTE: This is set on scope as it is used by the UI to determine if the toggle is loading + $scope.isLoading = true + $scope.field.hasRetrievalError = false + + const getAdminVerifiedSmsState = ( + verifiedSmsCount, + msgSrvcId, + freeSmsQuota, + ) => { + if (msgSrvcId) { + return ADMIN_VERIFIED_SMS_STATES.hasMessageServiceId + } + + if (verifiedSmsCount <= freeSmsQuota) { + return ADMIN_VERIFIED_SMS_STATES.belowLimit + } + + return ADMIN_VERIFIED_SMS_STATES.limitExceeded + } + + $q.when( + AdminMetaService.getFreeSmsCountsUsedByFormAdmin($scope.form._id), + ) + .then(({ quota, freeSmsCounts }) => { + $scope.verifiedSmsCount = formatStringAsNumber(freeSmsCounts) + $scope.adminVerifiedSmsState = getAdminVerifiedSmsState( + freeSmsCounts, + $scope.form.msgSrvcName, + quota, + ) + $scope.smsVerificationLimit = formatStringAsNumber(quota) + // NOTE: This links into the verifiable field component and hence, is used by both email and mobile + $scope.field.hasAdminExceededSmsLimit = + $scope.adminVerifiedSmsState === + ADMIN_VERIFIED_SMS_STATES.limitExceeded + }) + .catch((error) => { + $scope.field.hasRetrievalError = true + Toastr.error( + get( + error, + 'response.data.message', + 'Sorry, an error occurred. Please refresh the page to toggle OTP verification.', + ), + ) + }) + .finally(() => ($scope.isLoading = false)) + + // Only open if the admin has sms counts below the limit. + // If the admin has counts above limit without a message id, the toggle should be disabled anyway. + // Otherwise, if the admin has a message id, just enable it without the modal $scope.openVerifiedSMSModal = function () { const isTogglingOnVerifiedSms = !$scope.field.isVerifiable - $scope.verifiedSMSModal = + const isAdminBelowLimit = + $scope.adminVerifiedSmsState === + ADMIN_VERIFIED_SMS_STATES.belowLimit + const shouldShowModal = isTogglingOnVerifiedSms && + isAdminBelowLimit && + !$scope.field.hasRetrievalError + $scope.verifiedSMSModal = + shouldShowModal && $uibModal.open({ animation: true, backdrop: 'static', @@ -39,13 +118,23 @@ function configureMobileDirective() { resolve: { externalScope: function () { return { - title: 'Verified SMS charges', - confirmButtonText: 'OK, Noted', + title: `OTP verification will be disabled at ${$scope.smsVerificationLimit} responses`, + confirmButtonText: 'I understand', description: ` - Under 10,000 form responses: Free verified SMS -

- Above 10,000 form responses: ~US$0.0395 per SMS - contact us - for billing. Forms exceeding the free tier without billing will be deactivated. + We provide ${$scope.smsVerificationLimit} free SMS OTP verifications per account, only counting owned forms. + + Once this limit is reached, SMS OTP verification will be automatically disabled for all owned forms. + +

+ + If you are a collaborator, ensure the form's owner has enough free verifications. + +

+ + If you require more than ${$scope.smsVerificationLimit} verifications, please arrange advance billing with us. + +

+ Current response count: ${$scope.verifiedSmsCount}/${$scope.smsVerificationLimit} `, } }, diff --git a/src/public/modules/forms/admin/views/edit-fields.client.modal.html b/src/public/modules/forms/admin/views/edit-fields.client.modal.html index 98f1c99808..6dfd863430 100644 --- a/src/public/modules/forms/admin/views/edit-fields.client.modal.html +++ b/src/public/modules/forms/admin/views/edit-fields.client.modal.html @@ -849,6 +849,7 @@