diff --git a/src/app/models/__tests__/form.server.model.spec.ts b/src/app/models/__tests__/form.server.model.spec.ts index 36665d40a2..67d7650ea3 100644 --- a/src/app/models/__tests__/form.server.model.spec.ts +++ b/src/app/models/__tests__/form.server.model.spec.ts @@ -14,6 +14,7 @@ import { IEncryptedForm, IFieldSchema, IFormSchema, + ILogicSchema, IPopulatedUser, Permission, ResponseMode, @@ -384,7 +385,7 @@ describe('Form Model', () => { expect(actualSavedObject).toEqual(expectedObject) // Remove indeterministic id from actual permission list - const actualPermissionList = (saved.toObject() as IEncryptedForm).permissionList?.map( + const actualPermissionList = ((saved.toObject() as unknown) as IEncryptedForm).permissionList?.map( (permission) => omit(permission, '_id'), ) expect(actualPermissionList).toEqual(permissionList) @@ -1053,6 +1054,103 @@ describe('Form Model', () => { expect(actual).toEqual(expected) }) }) + + describe('deleteFormLogic', () => { + const logicId = new ObjectId().toHexString() + const mockFormLogic = { + form_logics: [ + { + _id: logicId, + id: logicId, + } as ILogicSchema, + ], + } + + it('should return form upon successful delete', async () => { + // arrange + const formParams = merge({}, MOCK_EMAIL_FORM_PARAMS, { + admin: populatedAdmin, + status: Status.Public, + responseMode: ResponseMode.Email, + ...mockFormLogic, + }) + const form = await Form.create(formParams) + + // act + const modifiedForm = await Form.deleteFormLogic(form._id, logicId) + + // assert + // Form should be returned + expect(modifiedForm).not.toBeNull() + + // Form should have correct status, responsemode + expect(modifiedForm?.responseMode).not.toBeNull() + expect(modifiedForm?.responseMode).toEqual(ResponseMode.Email) + expect(modifiedForm?.status).not.toBeNull() + expect(modifiedForm?.status).toEqual(Status.Public) + + // Check that form logic has been deleted + expect(modifiedForm?.form_logics).toBeEmpty() + }) + + it('should return form with remaining logic upon successful delete of one logic', async () => { + // arrange + + const logicId2 = new ObjectId().toHexString() + const mockFormLogicMultiple = { + form_logics: [ + { + _id: logicId, + id: logicId, + } as ILogicSchema, + { + _id: logicId2, + id: logicId2, + } as ILogicSchema, + ], + } + + const formParams = merge({}, MOCK_EMAIL_FORM_PARAMS, { + admin: populatedAdmin, + status: Status.Public, + responseMode: ResponseMode.Email, + ...mockFormLogicMultiple, + }) + const form = await Form.create(formParams) + + // act + const modifiedForm = await Form.deleteFormLogic(form._id, logicId) + + // assert + // Form should be returned + expect(modifiedForm).not.toBeNull() + + // Form should have correct status, responsemode + expect(modifiedForm?.responseMode).not.toBeNull() + expect(modifiedForm?.responseMode).toEqual(ResponseMode.Email) + expect(modifiedForm?.status).not.toBeNull() + expect(modifiedForm?.status).toEqual(Status.Public) + + // Check that correct form logic has been deleted + expect(modifiedForm?.form_logics).toBeDefined() + expect(modifiedForm?.form_logics).toHaveLength(1) + const logic = modifiedForm?.form_logics || ['some logic'] + expect((logic[0] as any)['_id'].toString()).toEqual(logicId2) + }) + + it('should return null if formId is invalid', async () => { + // arrange + + const invalidFormId = new ObjectId().toHexString() + + // act + const modifiedForm = await Form.deleteFormLogic(invalidFormId, logicId) + + // assert + // should return null + expect(modifiedForm).toBeNull() + }) + }) }) describe('Methods', () => { diff --git a/src/app/models/form.server.model.ts b/src/app/models/form.server.model.ts index 1f90eb7d06..a88bb0d1c5 100644 --- a/src/app/models/form.server.model.ts +++ b/src/app/models/form.server.model.ts @@ -613,6 +613,24 @@ const compileFormModel = (db: Mongoose): IFormModel => { ) } + // Deletes specified form logic. + FormSchema.statics.deleteFormLogic = async function ( + this: IFormModel, + formId: string, + logicId: string, + ): Promise { + return this.findByIdAndUpdate( + mongoose.Types.ObjectId(formId), + { + $pull: { form_logics: { _id: logicId } }, + }, + { + new: true, + runValidators: true, + }, + ).exec() + } + // Hooks FormSchema.pre('validate', function (next) { // Reject save if form document is too large 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 ec8fdc05a2..634674ff12 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 @@ -49,6 +49,7 @@ import { IFieldSchema, IForm, IFormSchema, + ILogicSchema, IPopulatedEmailForm, IPopulatedEncryptedForm, IPopulatedForm, @@ -76,6 +77,7 @@ import { ForbiddenFormError, FormDeletedError, FormNotFoundError, + LogicNotFoundError, PrivateFormError, TransferOwnershipError, } from '../../form.errors' @@ -6777,6 +6779,7 @@ describe('admin-form.controller', () => { MockAuthService.getFormAfterPermissionChecks.mockReturnValue( okAsync(MOCK_FORM), ) + MockAdminFormService.updateFormField.mockReturnValue( okAsync(MOCK_UPDATED_FIELD as IFieldSchema), ) @@ -7017,6 +7020,7 @@ describe('admin-form.controller', () => { _id: MOCK_USER_ID, email: 'somerandom@example.com', } as IPopulatedUser + const MOCK_FORM = { admin: MOCK_USER, _id: MOCK_FORM_ID, @@ -7039,7 +7043,6 @@ describe('admin-form.controller', () => { }, body: MOCK_CREATE_FIELD_BODY, }) - beforeEach(() => { MockUserService.getPopulatedUserById.mockReturnValue(okAsync(MOCK_USER)) MockAuthService.getFormAfterPermissionChecks.mockReturnValue( @@ -7296,4 +7299,201 @@ describe('admin-form.controller', () => { ) }) }) + + describe('handleDeleteLogic', () => { + const MOCK_USER_ID = new ObjectId().toHexString() + const MOCK_FORM_ID = new ObjectId().toHexString() + const MOCK_USER = { + _id: MOCK_USER_ID, + email: 'somerandom@example.com', + } as IPopulatedUser + + const logicId = new ObjectId().toHexString() + const mockFormLogic = { + form_logics: [ + { + _id: logicId, + id: logicId, + } as ILogicSchema, + ], + } + + const MOCK_FORM = { + admin: MOCK_USER, + _id: MOCK_FORM_ID, + title: 'mock title', + ...mockFormLogic, + } as IPopulatedForm + + const mockReq = expressHandler.mockRequest({ + params: { + formId: MOCK_FORM_ID, + logicId, + }, + session: { + user: { + _id: MOCK_USER_ID, + }, + }, + }) + const mockRes = expressHandler.mockResponse() + beforeEach(() => { + MockUserService.getPopulatedUserById.mockReturnValue(okAsync(MOCK_USER)) + MockAuthService.getFormAfterPermissionChecks.mockReturnValue( + okAsync(MOCK_FORM), + ) + MockAdminFormService.deleteFormLogic.mockReturnValue(okAsync(true)) + }) + + it('should call all services correctly when request is valid', async () => { + await AdminFormController.handleDeleteLogic(mockReq, mockRes, jest.fn()) + + expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( + MOCK_USER_ID, + ) + expect(MockAuthService.getFormAfterPermissionChecks).toHaveBeenCalledWith( + { + user: MOCK_USER, + formId: MOCK_FORM_ID, + level: PermissionLevel.Write, + }, + ) + expect(MockAdminFormService.deleteFormLogic).toHaveBeenCalledWith( + MOCK_FORM, + logicId, + ) + + expect(mockRes.sendStatus).toHaveBeenCalledWith(200) + }) + + it('should return 403 when user does not have permissions to delete logic', async () => { + MockAuthService.getFormAfterPermissionChecks.mockReturnValue( + errAsync( + new ForbiddenFormError('not authorized to perform write operation'), + ), + ) + + await AdminFormController.handleDeleteLogic(mockReq, mockRes, jest.fn()) + + expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( + MOCK_USER_ID, + ) + expect(MockAuthService.getFormAfterPermissionChecks).toHaveBeenCalledWith( + { + user: MOCK_USER, + formId: MOCK_FORM_ID, + level: PermissionLevel.Write, + }, + ) + expect(MockAdminFormService.deleteFormLogic).not.toHaveBeenCalled() + + expect(mockRes.status).toHaveBeenCalledWith(403) + + expect(mockRes.json).toHaveBeenCalledWith({ + message: 'not authorized to perform write operation', + }) + }) + + it('should return 404 when logicId cannot be found', async () => { + MockAdminFormService.deleteFormLogic.mockReturnValue( + errAsync(new LogicNotFoundError()), + ) + + await AdminFormController.handleDeleteLogic(mockReq, mockRes, jest.fn()) + + expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( + MOCK_USER_ID, + ) + expect(MockAuthService.getFormAfterPermissionChecks).toHaveBeenCalledWith( + { + user: MOCK_USER, + formId: MOCK_FORM_ID, + level: PermissionLevel.Write, + }, + ) + + expect(MockAdminFormService.deleteFormLogic).toHaveBeenCalledWith( + MOCK_FORM, + logicId, + ) + + expect(mockRes.status).toHaveBeenCalledWith(404) + + expect(mockRes.json).toHaveBeenCalledWith({ + message: 'logicId does not exist on form', + }) + }) + + it('should return 404 when form cannot be found', async () => { + MockAuthService.getFormAfterPermissionChecks.mockReturnValue( + errAsync(new FormNotFoundError()), + ) + + await AdminFormController.handleDeleteLogic(mockReq, mockRes, jest.fn()) + + expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( + MOCK_USER_ID, + ) + expect(MockAuthService.getFormAfterPermissionChecks).toHaveBeenCalledWith( + { + user: MOCK_USER, + formId: MOCK_FORM_ID, + level: PermissionLevel.Write, + }, + ) + expect(MockAdminFormService.deleteFormLogic).not.toHaveBeenCalled() + + expect(mockRes.status).toHaveBeenCalledWith(404) + + expect(mockRes.json).toHaveBeenCalledWith({ + message: 'Form not found', + }) + }) + + it('should return 422 when user in session cannot be retrieved from the database', async () => { + MockUserService.getPopulatedUserById.mockReturnValue( + errAsync(new MissingUserError()), + ) + + await AdminFormController.handleDeleteLogic(mockReq, mockRes, jest.fn()) + + expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( + MOCK_USER_ID, + ) + expect( + MockAuthService.getFormAfterPermissionChecks, + ).not.toHaveBeenCalled() + + expect(MockAdminFormService.deleteFormLogic).not.toHaveBeenCalled() + + expect(mockRes.status).toHaveBeenCalledWith(422) + + expect(mockRes.json).toHaveBeenCalledWith({ + message: 'User not found', + }) + }) + + it('should return 500 when database error occurs', async () => { + MockUserService.getPopulatedUserById.mockReturnValue( + errAsync(new DatabaseError()), + ) + + await AdminFormController.handleDeleteLogic(mockReq, mockRes, jest.fn()) + + expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( + MOCK_USER_ID, + ) + expect( + MockAuthService.getFormAfterPermissionChecks, + ).not.toHaveBeenCalled() + + expect(MockAdminFormService.deleteFormLogic).not.toHaveBeenCalled() + + expect(mockRes.status).toHaveBeenCalledWith(500) + + expect(mockRes.json).toHaveBeenCalledWith({ + message: 'Something went wrong. Please try again.', + }) + }) + }) }) 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 2c2cd1bc4d..f78b8aeb8e 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 @@ -31,6 +31,7 @@ import { IEmailFormSchema, IFormDocument, IFormSchema, + ILogicSchema, IPopulatedForm, IPopulatedUser, IUserSchema, @@ -46,7 +47,7 @@ import { import { generateDefaultField } from 'tests/unit/backend/helpers/generate-form-data' -import { TransferOwnershipError } from '../../form.errors' +import { LogicNotFoundError, TransferOwnershipError } from '../../form.errors' import { CreatePresignedUrlError, EditFieldError, @@ -59,6 +60,7 @@ import { createFormField, createPresignedPostUrlForImages, createPresignedPostUrlForLogos, + deleteFormLogic, duplicateForm, editFormFields, getDashboardForms, @@ -1344,4 +1346,113 @@ describe('admin-form.service', () => { expect(actual._unsafeUnwrapErr()).toBeInstanceOf(DatabaseValidationError) }) }) + describe('deleteFormLogic', () => { + const logicId = new ObjectId().toHexString() + const mockFormLogic = { + form_logics: [ + { + _id: logicId, + id: logicId, + } as ILogicSchema, + ], + } + + const DELETE_SPY = jest.spyOn(FormModel, 'deleteFormLogic') + + let mockEmailForm: IPopulatedForm, mockEncryptForm: IPopulatedForm + + beforeEach(() => { + mockEmailForm = ({ + _id: new ObjectId(), + status: Status.Public, + responseMode: ResponseMode.Email, + ...mockFormLogic, + } as unknown) as IPopulatedForm + mockEncryptForm = ({ + _id: new ObjectId(), + status: Status.Public, + responseMode: ResponseMode.Encrypt, + ...mockFormLogic, + } as unknown) as IPopulatedForm + }) + + it('should return ok(form) on successful form logic delete for email mode form', async () => { + // Arrange + const UPDATE_SPY = jest + .spyOn(FormModel, 'findByIdAndUpdate') + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + .mockReturnValue({ + exec: jest.fn().mockResolvedValue(mockEmailForm), + }) + + // Act + const actualResult = await deleteFormLogic(mockEmailForm, logicId) + + // Assert + expect(actualResult.isOk()).toEqual(true) + expect(actualResult._unsafeUnwrap()).toEqual(mockEmailForm) + + expect(UPDATE_SPY).toHaveBeenCalledWith( + mockEmailForm._id, + { + $pull: { form_logics: { _id: logicId } }, + }, + { + new: true, + runValidators: true, + }, + ) + + expect(DELETE_SPY).toHaveBeenCalledWith( + String(mockEmailForm._id), + logicId, + ) + }) + + it('should return ok(form) on successful form logic delete for encrypt mode form', async () => { + // Arrange + const UPDATE_SPY = jest + .spyOn(FormModel, 'findByIdAndUpdate') + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + .mockReturnValue({ + exec: jest.fn().mockResolvedValue(mockEncryptForm), + }) + + // Act + const actualResult = await deleteFormLogic(mockEncryptForm, logicId) + + // Assert + expect(actualResult.isOk()).toEqual(true) + expect(actualResult._unsafeUnwrap()).toEqual(mockEncryptForm) + + expect(UPDATE_SPY).toHaveBeenCalledWith( + mockEncryptForm._id, + { + $pull: { form_logics: { _id: logicId } }, + }, + { + new: true, + runValidators: true, + }, + ) + + expect(DELETE_SPY).toHaveBeenCalledWith( + String(mockEncryptForm._id), + logicId, + ) + }) + + it('should return LogicNotFoundError if logic does not exist on form', async () => { + // Act + const wrongLogicId = new ObjectId().toHexString() + const actualResult = await deleteFormLogic(mockEmailForm, wrongLogicId) + + // Assert + expect(actualResult.isErr()).toEqual(true) + expect(actualResult._unsafeUnwrapErr()).toEqual(new LogicNotFoundError()) + expect(DELETE_SPY).not.toHaveBeenCalled() + }) + }) }) 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 5b6238dbf7..c8d41b87c0 100644 --- a/src/app/modules/form/admin-form/admin-form.controller.ts +++ b/src/app/modules/form/admin-form/admin-form.controller.ts @@ -1565,6 +1565,54 @@ export const _handleCreateFormField: RequestHandler< }) ) } +/* + * Handler for DELETE /forms/:formId/logic/:logicId + * @security session + * + * @returns 200 with success message when successfully deleted + * @returns 403 when user does not have permissions to delete logic + * @returns 404 when form cannot be found + * @returns 422 when user in session cannot be retrieved from the database + * @returns 500 when database error occurs + */ +export const handleDeleteLogic: RequestHandler = (req, res) => { + const { formId, logicId } = req.params + const sessionUserId = (req.session as Express.AuthedSession).user._id + + // Step 1: Retrieve currently logged in user. + return ( + UserService.getPopulatedUserById(sessionUserId) + .andThen((user) => + // Step 2: Retrieve form with write permission check. + AuthService.getFormAfterPermissionChecks({ + user, + formId, + level: PermissionLevel.Write, + }), + ) + + // Step 3: Delete form logic + .andThen((retrievedForm) => + AdminFormService.deleteFormLogic(retrievedForm, logicId), + ) + .map(() => res.sendStatus(StatusCodes.OK)) + .mapErr((error) => { + logger.error({ + message: 'Error occurred when deleting form logic', + meta: { + action: 'handleDeleteLogic', + ...createReqMeta(req), + userId: sessionUserId, + formId, + logicId, + }, + error, + }) + const { errorMessage, statusCode } = mapRouteError(error) + return res.status(statusCode).json({ message: errorMessage }) + }) + ) +} /** * Handler for POST /forms/:formId/fields 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 5b20c17249..0fdb0e33f5 100644 --- a/src/app/modules/form/admin-form/admin-form.service.ts +++ b/src/app/modules/form/admin-form/admin-form.service.ts @@ -41,7 +41,11 @@ import { } from '../../core/core.errors' import { MissingUserError } from '../../user/user.errors' import * as UserService from '../../user/user.service' -import { FormNotFoundError, TransferOwnershipError } from '../form.errors' +import { + FormNotFoundError, + LogicNotFoundError, + TransferOwnershipError, +} from '../form.errors' import { getFormModelByResponseMode } from '../form.service' import { getFormFieldById } from '../form.utils' @@ -623,3 +627,52 @@ export const updateFormSettings = ( return okAsync(updatedForm.getSettings()) }) } + +/** + * Deletes form logic. + * @param form The original form to delete logic in + * @param logicId the logicId to delete + * @returns ok(true) on success + * @returns err(database errors) if db error is thrown during logic delete + * @returns err(LogicNotFoundError) if logicId does not exist on form + */ +export const deleteFormLogic = ( + form: IPopulatedForm, + logicId: string, +): ResultAsync => { + // First check if specified logic exists + if (!form.form_logics.some((logic) => logic.id === logicId)) { + logger.error({ + message: 'Error occurred - logicId to be deleted does not exist', + meta: { + action: 'deleteFormLogic', + formId: form._id, + logicId, + }, + }) + return errAsync(new LogicNotFoundError()) + } + + // Remove specified logic and then update form logic + return ResultAsync.fromPromise( + FormModel.deleteFormLogic(String(form._id), logicId), + (error) => { + logger.error({ + message: 'Error occurred when deleting form logic', + meta: { + action: 'deleteFormLogic', + formId: form._id, + logicId, + }, + error, + }) + return transformMongoError(error) + }, + // On success, return true + ).andThen((updatedForm) => { + if (!updatedForm) { + return errAsync(new FormNotFoundError()) + } + return okAsync(updatedForm) + }) +} 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 c800b1eed2..06593dcf67 100644 --- a/src/app/modules/form/admin-form/admin-form.utils.ts +++ b/src/app/modules/form/admin-form/admin-form.utils.ts @@ -24,6 +24,7 @@ import { ForbiddenFormError, FormDeletedError, FormNotFoundError, + LogicNotFoundError, PrivateFormError, TransferOwnershipError, } from '../form.errors' @@ -68,6 +69,11 @@ export const mapRouteError = ( statusCode: StatusCodes.NOT_FOUND, errorMessage: error.message, } + case LogicNotFoundError: + return { + statusCode: StatusCodes.NOT_FOUND, + errorMessage: error.message, + } case FormDeletedError: return { statusCode: StatusCodes.GONE, diff --git a/src/app/modules/form/form.errors.ts b/src/app/modules/form/form.errors.ts index 65ea70cfeb..50dacb5589 100644 --- a/src/app/modules/form/form.errors.ts +++ b/src/app/modules/form/form.errors.ts @@ -51,3 +51,12 @@ export class TransferOwnershipError extends ApplicationError { super(message) } } + +/** + * Error to be returned when form logic cannot be found + */ +export class LogicNotFoundError extends ApplicationError { + constructor(message = 'logicId does not exist on form') { + super(message) + } +} diff --git a/src/app/routes/api/v3/admin/forms/__tests__/admin-forms.logic.routes.spec.ts b/src/app/routes/api/v3/admin/forms/__tests__/admin-forms.logic.routes.spec.ts new file mode 100644 index 0000000000..6c5cdeadaa --- /dev/null +++ b/src/app/routes/api/v3/admin/forms/__tests__/admin-forms.logic.routes.spec.ts @@ -0,0 +1,199 @@ +import { ObjectId } from 'bson-ext' +import mongoose from 'mongoose' +import supertest, { Session } from 'supertest-session' + +import getUserModel from 'src/app/models/user.server.model' +import { ILogicSchema } from 'src/types' + +import { createAuthedSession } from 'tests/integration/helpers/express-auth' +import { setupApp } from 'tests/integration/helpers/express-setup' +import dbHandler from 'tests/unit/backend/helpers/jest-db' + +import { AdminFormsRouter } from '../admin-forms.routes' + +const UserModel = getUserModel(mongoose) + +const app = setupApp('/admin/forms', AdminFormsRouter, { + setupWithAuth: true, +}) + +describe('admin-form.logic.routes', () => { + let request: Session + + beforeAll(async () => await dbHandler.connect()) + beforeEach(async () => { + request = supertest(app) + }) + afterEach(async () => { + await dbHandler.clearDatabase() + jest.restoreAllMocks() + }) + afterAll(async () => await dbHandler.closeDatabase()) + + describe('DELETE /forms/:formId/logic/:logicId', () => { + it('should return 200 on successful form logic delete for email mode form', async () => { + // Arrange + const formLogicId = new ObjectId() + const { form: formToUpdate, user } = await dbHandler.insertEmailForm({ + formOptions: { + form_logics: [ + { + _id: formLogicId, + } as ILogicSchema, + ], + }, + }) + const session = await createAuthedSession(user.email, request) + + // Act + const response = await session + .delete(`/admin/forms/${formToUpdate._id}/logic/${formLogicId}`) + .send() + + // Assert + expect(response.status).toEqual(200) + }) + + it('should return 200 on successful form logic delete for encrypt mode form', async () => { + // Arrange + const formLogicId = new ObjectId() + const { form: formToUpdate, user } = await dbHandler.insertEncryptForm({ + formOptions: { + form_logics: [ + { + _id: formLogicId, + } as ILogicSchema, + ], + }, + }) + const session = await createAuthedSession(user.email, request) + + // Act + const response = await session + .delete(`/admin/forms/${formToUpdate._id}/logic/${formLogicId}`) + .send() + + // Assert + expect(response.status).toEqual(200) + }) + + it('should return 403 when current user does not have permissions to delete form logic', async () => { + // Arrange + const formLogicId = new ObjectId() + const { form: formToUpdate, agency } = await dbHandler.insertEncryptForm({ + formOptions: { + form_logics: [ + { + _id: formLogicId, + } as ILogicSchema, + ], + }, + }) + const diffUser = await dbHandler.insertUser({ + mailName: 'newUser', + agencyId: agency._id, + }) + // Log in as different user. + const session = await createAuthedSession(diffUser.email, request) + + // Act + const response = await session + .delete(`/admin/forms/${formToUpdate._id}/logic/${formLogicId}`) + .send() + + // Assert + expect(response.status).toEqual(403) + expect(response.body).toEqual({ + message: expect.stringContaining( + 'not authorized to perform write operation', + ), + }) + }) + + it('should return 404 with error message if logicId does not exist', async () => { + // Arrange + const formLogicId = new ObjectId() + const wrongLogicId = new ObjectId() + const { form: formToUpdate, user } = await dbHandler.insertEmailForm({ + formOptions: { + form_logics: [ + { + _id: formLogicId, + } as ILogicSchema, + ], + }, + }) + const session = await createAuthedSession(user.email, request) + + // Act + const response = await session + .delete(`/admin/forms/${formToUpdate._id}/logic/${wrongLogicId}`) + .send() + + // Assert + const expectedResponse = { + message: 'logicId does not exist on form', + } + expect(response.status).toEqual(404) + expect(response.body).toEqual(expectedResponse) + }) + + it('should return 404 with error message if form does not exist', async () => { + // Arrange + const formLogicId = new ObjectId() + const { user } = await dbHandler.insertEmailForm({ + formOptions: { + form_logics: [ + { + _id: formLogicId, + } as ILogicSchema, + ], + }, + }) + const session = await createAuthedSession(user.email, request) + + // Act + const wrongFormId = new ObjectId() + const response = await session + .delete(`/admin/forms/${wrongFormId}/logic/${formLogicId}`) + .send() + + // Assert + const expectedResponse = { + message: 'Form not found', + } + expect(response.status).toEqual(404) + expect(response.body).toEqual(expectedResponse) + }) + + it('should return 422 when userId cannot be found in the database', async () => { + // Arrange + const formLogicId = new ObjectId() + const { form: formToUpdate, user } = await dbHandler.insertEmailForm({ + formOptions: { + form_logics: [ + { + _id: formLogicId, + } as ILogicSchema, + ], + }, + }) + const session = await createAuthedSession(user.email, request) + + // Delete user after login. + await dbHandler.clearCollection(UserModel.collection.name) + + // Act + const response = await session + .delete(`/admin/forms/${formToUpdate._id}/logic/${formLogicId}`) + .send() + + // Assert + const expectedResponse = { + message: 'User not found', + } + expect(response.status).toEqual(422) + expect(response.body).toEqual(expectedResponse) + }) + }) +}) diff --git a/src/app/routes/api/v3/admin/forms/admin-forms.logic.routes.ts b/src/app/routes/api/v3/admin/forms/admin-forms.logic.routes.ts new file mode 100644 index 0000000000..595c89671f --- /dev/null +++ b/src/app/routes/api/v3/admin/forms/admin-forms.logic.routes.ts @@ -0,0 +1,21 @@ +import { Router } from 'express' + +import * as AdminFormController from '../../../../../modules/form/admin-form/admin-form.controller' + +export const AdminFormsLogicRouter = Router() + +/** + * Deletes a logic. + * @route DELETE /admin/forms/:formId/logic/:logicId + * @group admin + * @produces application/json + * @consumes application/json + * @returns 200 with success message when successfully deleted + * @returns 403 when user does not have permissions to delete logic + * @returns 404 when form cannot be found + * @returns 422 when user in session cannot be retrieved from the database + * @returns 500 when database error occurs + */ +AdminFormsLogicRouter.route( + '/:formId([a-fA-F0-9]{24})/logic/:logicId([a-fA-F0-9]{24})', +).delete(AdminFormController.handleDeleteLogic) diff --git a/src/app/routes/api/v3/admin/forms/admin-forms.routes.ts b/src/app/routes/api/v3/admin/forms/admin-forms.routes.ts index fb4287704d..0d7f4a6884 100644 --- a/src/app/routes/api/v3/admin/forms/admin-forms.routes.ts +++ b/src/app/routes/api/v3/admin/forms/admin-forms.routes.ts @@ -1,9 +1,11 @@ import { Router } from 'express' import { withUserAuthentication } from '../../../../../modules/auth/auth.middlewares' +import { handleDeleteLogic } from '../../../../../modules/form/admin-form/admin-form.controller' import { AdminFormsFeedbackRouter } from './admin-forms.feedback.routes' import { AdminFormsFormRouter } from './admin-forms.form.routes' +import { AdminFormsLogicRouter } from './admin-forms.logic.routes' import { AdminFormsPreviewRouter } from './admin-forms.preview.routes' import { AdminFormsSettingsRouter } from './admin-forms.settings.routes' import { AdminFormsSubmissionsRouter } from './admin-forms.submissions.routes' @@ -18,3 +20,20 @@ AdminFormsRouter.use(AdminFormsFeedbackRouter) AdminFormsRouter.use(AdminFormsFormRouter) AdminFormsRouter.use(AdminFormsSubmissionsRouter) AdminFormsRouter.use(AdminFormsPreviewRouter) +AdminFormsRouter.use(AdminFormsLogicRouter) + +/** + * Deletes a logic. + * @route DELETE /admin/forms/:formId/logic/:logicId + * @group admin + * @produces application/json + * @consumes application/json + * @returns 200 with success message when successfully deleted + * @returns 403 when user does not have permissions to delete logic + * @returns 404 when form cannot be found + * @returns 422 when user in session cannot be retrieved from the database + * @returns 500 when database error occurs + */ +AdminFormsRouter.route( + '/:formId([a-fA-F0-9]{24})/logic/:logicId([a-fA-F0-9]{24})', +).delete(handleDeleteLogic) diff --git a/src/public/modules/forms/admin/components/edit-logic.client.component.js b/src/public/modules/forms/admin/components/edit-logic.client.component.js index 79b752fb91..abef915000 100644 --- a/src/public/modules/forms/admin/components/edit-logic.client.component.js +++ b/src/public/modules/forms/admin/components/edit-logic.client.component.js @@ -1,6 +1,7 @@ 'use strict' const { LogicType } = require('../../../../../types') +const AdminFormService = require('../../../../services/AdminFormService') angular.module('forms').component('editLogicComponent', { templateUrl: 'modules/forms/admin/componentViews/edit-logic.client.view.html', @@ -9,11 +10,17 @@ angular.module('forms').component('editLogicComponent', { isLogicError: '<', updateForm: '&', }, - controller: ['$uibModal', 'FormFields', editLogicComponentController], + controller: [ + '$uibModal', + 'FormFields', + 'Toastr', + '$q', + editLogicComponentController, + ], controllerAs: 'vm', }) -function editLogicComponentController($uibModal, FormFields) { +function editLogicComponentController($uibModal, FormFields, Toastr, $q) { const vm = this vm.LogicType = LogicType const getNewCondition = function () { @@ -74,8 +81,15 @@ function editLogicComponentController($uibModal, FormFields) { } vm.deleteLogic = function (logicIndex) { - vm.myform.form_logics.splice(logicIndex, 1) - updateLogic({ form_logics: vm.myform.form_logics }) + const logicIdToDelete = vm.myform.form_logics[logicIndex]._id + $q.when(AdminFormService.deleteFormLogic(vm.myform._id, logicIdToDelete)) + .then(() => { + vm.myform.form_logics.splice(logicIndex, 1) + }) + .catch((logicDeleteError) => { + console.error(logicDeleteError) + Toastr.error('Failed to delete logic, please refresh and try again!') + }) } vm.openEditLogicModal = function (currLogic, isNew, logicIndex = -1) { diff --git a/src/public/services/AdminFormService.ts b/src/public/services/AdminFormService.ts index 2beed7cb06..ef6b115954 100644 --- a/src/public/services/AdminFormService.ts +++ b/src/public/services/AdminFormService.ts @@ -46,3 +46,12 @@ export const createSingleFormField = async ( ) .then(({ data }) => data) } + +export const deleteFormLogic = async ( + formId: string, + logicId: string, +): Promise => { + return axios + .delete(`${ADMIN_FORM_ENDPOINT}/${formId}/logic/${logicId}`) + .then(() => true) +} diff --git a/src/types/form.ts b/src/types/form.ts index 06549398ce..09fbbb6e14 100644 --- a/src/types/form.ts +++ b/src/types/form.ts @@ -282,6 +282,7 @@ export interface IFormModel extends Model { formId: string, fields?: (keyof IPopulatedForm)[], ): Promise + deleteFormLogic(formId: string, logicId: string): Promise deactivateById(formId: string): Promise getMetaByUserIdOrEmail( userId: IUserSchema['_id'],