diff --git a/app/src/controllers/activity.ts b/app/src/controllers/activity.ts new file mode 100644 index 00000000..bd1572da --- /dev/null +++ b/app/src/controllers/activity.ts @@ -0,0 +1,25 @@ +import { activityService } from '../services'; + +import { ACTIVITY_ID_LENGTH } from '../utils/constants/application'; + +import type { NextFunction, Request, Response } from '../interfaces/IExpress'; + +const controller = { + validateActivityId: async (req: Request<{ activityId: string }>, res: Response, next: NextFunction) => { + try { + const { activityId } = req.params; + const hexidecimal = parseInt(activityId, 16); + + if (activityId.length !== ACTIVITY_ID_LENGTH || !hexidecimal) { + return res.status(400).json({ message: 'Invalid activity Id format' }); + } + const activity = await activityService.getActivity(activityId); + + res.status(200).json({ valid: !!activity && !activity.isDeleted }); + } catch (e: unknown) { + next(e); + } + } +}; + +export default controller; diff --git a/app/src/controllers/index.ts b/app/src/controllers/index.ts index e70dcde7..04192109 100644 --- a/app/src/controllers/index.ts +++ b/app/src/controllers/index.ts @@ -1,3 +1,4 @@ +export { default as activityController } from './activity'; export { default as documentController } from './document'; export { default as enquiryController } from './enquiry'; export { default as noteController } from './note'; diff --git a/app/src/routes/v1/activity.ts b/app/src/routes/v1/activity.ts new file mode 100644 index 00000000..376fa9e8 --- /dev/null +++ b/app/src/routes/v1/activity.ts @@ -0,0 +1,15 @@ +import express from 'express'; +import { activityController } from '../../controllers'; +import { requireSomeAuth } from '../../middleware/requireSomeAuth'; + +import type { NextFunction, Request, Response } from '../../interfaces/IExpress'; + +const router = express.Router(); +router.use(requireSomeAuth); + +//** Validates an Activity Id */ +router.get('/validate/:activityId', (req: Request, res: Response, next: NextFunction): void => { + activityController.validateActivityId(req, res, next); +}); + +export default router; diff --git a/app/src/routes/v1/index.ts b/app/src/routes/v1/index.ts index 082fe123..62f95c5a 100644 --- a/app/src/routes/v1/index.ts +++ b/app/src/routes/v1/index.ts @@ -2,6 +2,7 @@ import { currentUser } from '../../middleware/authentication'; import express from 'express'; +import activity from './activity'; import document from './document'; import enquiry from './enquiry'; import note from './note'; @@ -17,10 +18,11 @@ router.use(currentUser); // Base v1 Responder router.get('/', (_req, res) => { res.status(200).json({ - endpoints: ['/document', '/enquiry', '/note', '/permit', '/roadmap', '/sso', '/submission', '/user'] + endpoints: ['/activity', '/document', '/enquiry', '/note', '/permit', '/roadmap', '/sso', '/submission', '/user'] }); }); +router.use('/activity', activity); router.use('/document', document); router.use('/enquiry', enquiry); router.use('/note', note); diff --git a/app/src/utils/constants/application.ts b/app/src/utils/constants/application.ts index 9e69bb35..bf8bf829 100644 --- a/app/src/utils/constants/application.ts +++ b/app/src/utils/constants/application.ts @@ -19,3 +19,5 @@ export const DEFAULTCORS = Object.freeze({ export const YES_NO_LIST = [BasicResponse.YES, BasicResponse.NO]; export const YES_NO_UNSURE_LIST = [BasicResponse.YES, BasicResponse.NO, BasicResponse.UNSURE]; + +export const ACTIVITY_ID_LENGTH = 8; diff --git a/app/tests/unit/controllers/activity.spec.ts b/app/tests/unit/controllers/activity.spec.ts new file mode 100644 index 00000000..53b192a7 --- /dev/null +++ b/app/tests/unit/controllers/activity.spec.ts @@ -0,0 +1,146 @@ +import { activityController } from '../../../src/controllers'; +import { activityService } from '../../../src/services'; + +// Mock config library - @see {@link https://stackoverflow.com/a/64819698} +jest.mock('config'); + +const mockResponse = () => { + const res: { status?: jest.Mock; json?: jest.Mock; end?: jest.Mock } = {}; + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + + return res; +}; + +let res: { status?: jest.Mock; json?: jest.Mock; end?: jest.Mock }; +beforeEach(() => { + res = mockResponse(); +}); + +afterEach(() => { + /* + * Must use clearAllMocks when using the mocked config + * resetAllMocks seems to cause strange issues such as + * functions not calling as expected + */ + jest.clearAllMocks(); +}); + +const CURRENT_USER = { authType: 'BEARER', tokenPayload: null }; + +const ACTIVITY = { + activityId: '12345678', + initiativeId: '59cd9e86-7cce-4791-b071-69002c731315', + isDeleted: false +}; + +const DELETED_ACTIVITY = { + activityId: '87654321', + initiativeId: '59cd9e86-7cce-4791-b071-69002c731315', + isDeleted: true +}; + +describe('validateActivityId', () => { + const next = jest.fn(); + + // Mock service calls + const activityServiceSpy = jest.spyOn(activityService, 'getActivity'); + + it('shoulld return status 200 and valid true if activityId exists and isDeleted is false', async () => { + const req = { + params: { activityId: '12345678' }, + currentUser: CURRENT_USER + }; + + activityServiceSpy.mockResolvedValue(ACTIVITY); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await activityController.validateActivityId(req as any, res as any, next); + + expect(activityServiceSpy).toHaveBeenCalledTimes(1); + expect(activityServiceSpy).toHaveBeenCalledWith(req.params.activityId); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ valid: true }); + }); + + it('shoulld return status 200 and valid false if activityId exists but isDeleted is true', async () => { + const req = { + params: { activityId: '87654321' }, + currentUser: CURRENT_USER + }; + + activityServiceSpy.mockResolvedValue(DELETED_ACTIVITY); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await activityController.validateActivityId(req as any, res as any, next); + + expect(activityServiceSpy).toHaveBeenCalledTimes(1); + expect(activityServiceSpy).toHaveBeenCalledWith(req.params.activityId); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ valid: false }); + }); + + it('shoulld return status 200 and valid false if activityId does not exist', async () => { + const req = { + params: { activityId: 'FFFFFFFF' }, + currentUser: CURRENT_USER + }; + + activityServiceSpy.mockResolvedValue(null); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await activityController.validateActivityId(req as any, res as any, next); + + expect(activityServiceSpy).toHaveBeenCalledTimes(1); + expect(activityServiceSpy).toHaveBeenCalledWith(req.params.activityId); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ valid: false }); + }); + + it('Should return 400 and error message if activityId does not have correct length', async () => { + const req = { + params: { activityId: '12345' }, + currentUser: CURRENT_USER + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await activityController.validateActivityId(req as any, res as any, next); + + expect(activityServiceSpy).toHaveBeenCalledTimes(0); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ message: 'Invalid activity Id format' }); + }); + + it('Should return 400 and error message if activityId is not hexidecimal value', async () => { + const req = { + params: { activityId: 'GGGGGGGG' }, + currentUser: CURRENT_USER + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await activityController.validateActivityId(req as any, res as any, next); + + expect(activityServiceSpy).toHaveBeenCalledTimes(0); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ message: 'Invalid activity Id format' }); + }); + + it('Should call next with error if getActivity fails', async () => { + const req = { + params: { activityId: '12345678' }, + currentUser: CURRENT_USER + }; + + const error = new Error(); + + activityServiceSpy.mockRejectedValue(error); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await activityController.validateActivityId(req as any, res as any, next); + + expect(activityServiceSpy).toHaveBeenCalledTimes(1); + expect(activityServiceSpy).toHaveBeenCalledWith(req.params.activityId); + expect(next).toHaveBeenCalledTimes(1); + expect(next).toHaveBeenCalledWith(error); + }); +}); diff --git a/frontend/src/components/housing/enquiry/EnquiryIntakeForm.vue b/frontend/src/components/housing/enquiry/EnquiryIntakeForm.vue index 85ce7498..8f1943cb 100644 --- a/frontend/src/components/housing/enquiry/EnquiryIntakeForm.vue +++ b/frontend/src/components/housing/enquiry/EnquiryIntakeForm.vue @@ -9,9 +9,9 @@ import { Dropdown, InputMask, RadioList, InputText, StepperNavigation, TextArea import CollectionDisclaimer from '@/components/housing/CollectionDisclaimer.vue'; import EnquiryIntakeConfirmation from '@/components/housing/enquiry/EnquiryIntakeConfirmation.vue'; import { Button, Card, Divider, useConfirm, useToast } from '@/lib/primevue'; -import { enquiryService, submissionService } from '@/services'; +import { activityService, enquiryService, submissionService } from '@/services'; import { useConfigStore } from '@/store'; -import { YES_NO_LIST } from '@/utils/constants/application'; +import { ACTIVITY_ID_LENGTH, YES_NO_LIST } from '@/utils/constants/application'; import { CONTACT_PREFERENCE_LIST, PROJECT_RELATIONSHIP_LIST } from '@/utils/constants/housing'; import { BasicResponse, Regex, RouteName } from '@/utils/enums/application'; import { IntakeFormCategory, IntakeStatus } from '@/utils/enums/housing'; @@ -234,6 +234,23 @@ async function emailConfirmation(activityId: string) { }; await submissionService.emailConfirmation(emailData); } + +async function checkActivityIdValidity(event: Event) { + const target = event.target as HTMLInputElement; + const activityId = target.value; + const hexidecimal = parseInt(activityId, 16); + + if (activityId) { + if (activityId.length === ACTIVITY_ID_LENGTH && hexidecimal) { + const valid = (await activityService.checkActivityIdValidity(activityId)).data.valid; + if (!valid) { + toast.warn(`Confirmation ID ${activityId} does not exist, please check again.`); + } + } else { + toast.warn('Confirmation ID is not the right format, please check again.'); + } + } +} diff --git a/frontend/src/services/activityService.ts b/frontend/src/services/activityService.ts new file mode 100644 index 00000000..6a1b9ef4 --- /dev/null +++ b/frontend/src/services/activityService.ts @@ -0,0 +1,12 @@ +import { appAxios } from './interceptors'; + +export default { + /** + * @function checkActivityIdValidity + * Checks if an activity ID is valid + * @returns {Promise} An axios response + */ + checkActivityIdValidity(activityId: string) { + return appAxios().get(`activity/validate/${activityId}`); + } +}; diff --git a/frontend/src/services/index.ts b/frontend/src/services/index.ts index 2af85a1f..c38c272c 100644 --- a/frontend/src/services/index.ts +++ b/frontend/src/services/index.ts @@ -1,6 +1,7 @@ export { default as AuthService } from './authService'; export { default as ConfigService } from './configService'; export { default as PermissionService } from './permissionService'; +export { default as activityService } from './activityService'; export { default as comsService } from './comsService'; export { default as documentService } from './documentService'; export { default as enquiryService } from './enquiryService'; diff --git a/frontend/src/utils/constants/application.ts b/frontend/src/utils/constants/application.ts index 09e73932..9d2d9467 100644 --- a/frontend/src/utils/constants/application.ts +++ b/frontend/src/utils/constants/application.ts @@ -24,3 +24,5 @@ export const SYSTEM_USER = NIL; export const YES_NO_LIST = [BasicResponse.YES, BasicResponse.NO]; export const YES_NO_UNSURE_LIST = [BasicResponse.YES, BasicResponse.NO, BasicResponse.UNSURE]; + +export const ACTIVITY_ID_LENGTH = 8; diff --git a/frontend/src/views/DeveloperView.vue b/frontend/src/views/DeveloperView.vue index 848cc71c..bb93698c 100644 --- a/frontend/src/views/DeveloperView.vue +++ b/frontend/src/views/DeveloperView.vue @@ -17,10 +17,6 @@ const { getConfig } = storeToRefs(useConfigStore()); const permissionService = new PermissionService(); const router = useRouter(); -async function ssoRequestBasicAccess() { - await permissionService.requestBasicAccess(); -} - async function searchIdirUsers() { await permissionService.searchIdirUsers({ firstName: 'Kyle' }); }