diff --git a/app/src/controllers/ats.ts b/app/src/controllers/ats.ts index 0db33867..585aa2ea 100644 --- a/app/src/controllers/ats.ts +++ b/app/src/controllers/ats.ts @@ -1,7 +1,9 @@ import { atsService } from '../services'; +import { getCurrentUsername } from '../utils/utils'; + import type { NextFunction, Request, Response } from 'express'; -import type { ATSUserSearchParameters } from '../types'; +import type { ATSClientResource, ATSUserSearchParameters } from '../types'; const controller = { searchATSUsers: async ( @@ -12,6 +14,19 @@ const controller = { try { const response = await atsService.searchATSUsers(req.query); + res.status(response.status).json(response.data); + } catch (e: unknown) { + next(e); + } + }, + + createATSClient: async (req: Request, res: Response, next: NextFunction) => { + try { + const identityProvider = req.currentContext?.tokenPayload?.identity_provider.toUpperCase(); + const atsClient = req.body; + // Set the createdBy field to current user with \\ as the separator for the domain and username to match ATS DB + atsClient.createdBy = `${identityProvider}\\${getCurrentUsername(req.currentContext)}`; + const response = await atsService.createATSClient(atsClient); res.status(response.status).json(response.data); } catch (e: unknown) { next(e); diff --git a/app/src/routes/v1/ats.ts b/app/src/routes/v1/ats.ts index c97d635e..43ea5788 100644 --- a/app/src/routes/v1/ats.ts +++ b/app/src/routes/v1/ats.ts @@ -5,9 +5,10 @@ import { hasAuthorization } from '../../middleware/authorization'; import { requireSomeAuth } from '../../middleware/requireSomeAuth'; import { requireSomeGroup } from '../../middleware/requireSomeGroup'; import { Action, Resource } from '../../utils/enums/application'; +import { atsValidator } from '../../validators'; import type { NextFunction, Request, Response } from 'express'; -import type { ATSUserSearchParameters } from '../../types'; +import type { ATSClientResource, ATSUserSearchParameters } from '../../types'; const router = express.Router(); router.use(requireSomeAuth); @@ -21,4 +22,14 @@ router.get( } ); +/** Creates a client in ATS */ +router.post( + '/client', + hasAuthorization(Resource.ATS, Action.CREATE), + atsValidator.createATSClient, + (req: Request, res: Response, next: NextFunction): void => { + atsController.createATSClient(req, res, next); + } +); + export default router; diff --git a/app/src/services/ats.ts b/app/src/services/ats.ts index 77ebd38f..54c4dd52 100644 --- a/app/src/services/ats.ts +++ b/app/src/services/ats.ts @@ -2,14 +2,13 @@ import axios from 'axios'; import config from 'config'; import type { AxiosInstance } from 'axios'; -import type { ATSUserSearchParameters } from '../types'; +import type { ATSClientResource, ATSUserSearchParameters } from '../types'; /** * @function getToken * Gets Auth token using ATS client credentials * @returns */ - async function getToken() { const response = await axios({ method: 'GET', @@ -33,7 +32,6 @@ async function getToken() { * @param {AxiosRequestConfig} options Axios request config options * @returns {AxiosInstance} An axios instance */ - function atsAxios(): AxiosInstance { // Create axios instance const atsAxios = axios.create({ @@ -57,7 +55,6 @@ const service = { * @param {ATSUserSearchParameters} data The search parameters * @returns {Promise} The result of calling the search api */ - searchATSUsers: async (params?: ATSUserSearchParameters) => { try { const { data, status } = await atsAxios().get('/clients', { params: params }); @@ -75,6 +72,31 @@ const service = { }; } } + }, + + /** + * @function createATSClient + * Creates a client in ATS + * @param {ATSClientResource} data The client data + * @returns {Promise} The result of calling the post api + */ + createATSClient: async (atsClient: ATSClientResource) => { + try { + const { data, status } = await atsAxios().post('/clients', atsClient); + return { data: data, status }; + } catch (e: unknown) { + if (axios.isAxiosError(e)) { + return { + data: e.response?.data.message, + status: e.response ? e.response.status : 500 + }; + } else { + return { + data: 'Error', + status: 500 + }; + } + } } }; diff --git a/app/src/types/ATSClientResource.ts b/app/src/types/ATSClientResource.ts new file mode 100644 index 00000000..e861deb5 --- /dev/null +++ b/app/src/types/ATSClientResource.ts @@ -0,0 +1,21 @@ +type AddressResource = { + '@type': string; + addressLine1: string; + addressLine2: string | null; + city: string; + provinceCode: string; + countryCode: string; + postalCode: string | null; + primaryPhone: string; + email: string; +}; + +export type ATSClientResource = { + '@type': string; + address: AddressResource; + firstName: string; + surName: string; + regionName: string; + optOutOfBCStatSurveyInd: string; + createdBy: string; +}; diff --git a/app/src/types/index.ts b/app/src/types/index.ts index 68ad06d6..844669b1 100644 --- a/app/src/types/index.ts +++ b/app/src/types/index.ts @@ -1,5 +1,6 @@ export type { Activity } from './Activity'; export type { AccessRequest } from './AccessRequest'; +export type { ATSClientResource } from './ATSClientResource'; export type { ATSUserSearchParameters } from './ATSUserSearchParameters'; export type { BceidSearchParameters } from './BceidSearchParameters'; export type { BringForward } from './BringForward'; diff --git a/app/src/validators/ats.ts b/app/src/validators/ats.ts new file mode 100644 index 00000000..33b2242d --- /dev/null +++ b/app/src/validators/ats.ts @@ -0,0 +1,33 @@ +import Joi from 'joi'; + +import { validate } from '../middleware/validation'; + +import { BasicResponse } from '../utils/enums/application'; + +const addressBody = { + '@type': Joi.string().valid('AddressResource'), + addressLine1: Joi.string().max(255).allow(null), + city: Joi.string().max(255).allow(null), + provinceCode: Joi.string().max(255).allow(null), + primaryPhone: Joi.string().max(255).allow(null), + email: Joi.string().max(255).allow(null) +}; + +const clientBody = { + '@type': Joi.string().valid('ClientResource'), + firstName: Joi.string().max(255).required(), + surName: Joi.string().max(255).required(), + regionName: Joi.string().max(255).required(), + optOutOfBCStatSurveyInd: Joi.string().valid(BasicResponse.NO.toUpperCase()), + address: Joi.object(addressBody).allow(null) +}; + +const schema = { + createATSClient: { + body: Joi.object(clientBody) + } +}; + +export default { + createATSClient: validate(schema.createATSClient) +}; diff --git a/app/src/validators/index.ts b/app/src/validators/index.ts index f7f533c2..ffb9b0e5 100644 --- a/app/src/validators/index.ts +++ b/app/src/validators/index.ts @@ -1,4 +1,5 @@ export { default as accessRequestValidator } from './accessRequest'; +export { default as atsValidator } from './ats'; export { default as documentValidator } from './document'; export { default as enquiryValidator } from './enquiry'; export { default as noteValidator } from './note'; diff --git a/app/tests/unit/controllers/ats.spec.ts b/app/tests/unit/controllers/ats.spec.ts new file mode 100644 index 00000000..12b8e26b --- /dev/null +++ b/app/tests/unit/controllers/ats.spec.ts @@ -0,0 +1,228 @@ +import { atsController } from '../../../src/controllers'; +import { atsService } 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 = mockResponse(); +beforeEach(() => { + res = mockResponse(); +}); + +afterEach(() => { + jest.resetAllMocks(); +}); + +const CURRENT_CONTEXT = { authType: 'BEARER', tokenPayload: null, userId: 'abc-123' }; + +describe('createATSClient', () => { + const next = jest.fn(); + + // Mock service calls + const createSpy = jest.spyOn(atsService, 'createATSClient'); + + it('should return 201 if all good', async () => { + const req = { + body: { + '@type': 'ClientResource', + address: { + '@type': 'AddressResource', + addressLine1: null, + city: null, + provinceCode: null, + primaryPhone: '(213) 213-2132', + email: 's@s.com' + }, + firstName: 'Gill', + surName: 'Bates', + regionName: 'HOUSING', + optOutOfBCStatSurveyInd: 'NO' + }, + currentContext: CURRENT_CONTEXT + }; + + const created = { + data: { + '@type': 'ClientResource', + address: { + '@type': 'AddressResource', + addressLine1: null, + city: null, + provinceCode: null, + primaryPhone: '(213) 213-2132', + email: 's@s.com' + }, + firstName: 'Gill', + surName: 'Bates', + regionName: 'HOUSING', + optOutOfBCStatSurveyInd: 'NO' + }, + status: 201 + }; + + createSpy.mockResolvedValue(created); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await atsController.createATSClient(req as any, res as any, next); + + expect(createSpy).toHaveBeenCalledTimes(1); + expect(createSpy).toHaveBeenCalledWith({ + ...req.body + }); + expect(res.status).toHaveBeenCalledWith(201); + }); + + it('calls next if the ats service fails to create', async () => { + const req = { + body: { + '@type': 'ClientResource', + address: { + '@type': 'AddressResource', + addressLine1: null, + city: null, + provinceCode: null, + primaryPhone: '(213) 213-2132', + email: 's@s.com' + }, + firstName: 'Gill', + surName: 'Bates', + optOutOfBCStatSurveyInd: 'NO' + }, + currentContext: CURRENT_CONTEXT + }; + + createSpy.mockImplementationOnce(() => { + throw new Error(); + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await atsController.createATSClient(req as any, res as any, next); + + expect(createSpy).toHaveBeenCalledTimes(1); + expect(createSpy).toHaveBeenCalledWith({ + ...req.body + }); + expect(res.status).toHaveBeenCalledTimes(0); + expect(next).toHaveBeenCalledTimes(1); + }); +}); + +describe('searchATSUsers', () => { + const next = jest.fn(); + + // Mock service calls + const searchATSUsersSpy = jest.spyOn(atsService, 'searchATSUsers'); + + it('should return 200 if all good', async () => { + const req = { + query: { firstName: 'John' }, + currentContext: CURRENT_CONTEXT + }; + + const atsUsers = { + data: { + '@type': 'ClientsResource', + links: [ + { + '@type': 'RelLink', + rel: 'self', + href: 'https://t1api.nrs.gov.bc.ca/ats-api/clients?firstName=John', + method: 'GET' + }, + { + '@type': 'RelLink', + rel: 'next', + href: 'https://t1api.nrs.gov.bc.ca/ats-api/clients?firstName=John', + method: 'GET' + } + ], + pageNumber: 0, + pageRowCount: 956, + totalRowCount: 956, + totalPageCount: 1, + clients: [ + { + '@type': 'ClientResource', + links: [ + { + '@type': 'RelLink', + rel: 'self', + href: 'https://t1api.nrs.gov.bc.ca/ats-api/clients', + method: 'GET' + } + ], + clientId: 96, + address: { + '@type': 'AddressResource', + links: [], + addressId: 443, + addressLine1: null, + addressLine2: null, + city: 'Fqmrpml', + provinceCode: 'Alberta', + countryCode: 'Canada', + postalCode: null, + primaryPhone: null, + secondaryPhone: null, + fax: null, + email: null, + createdBy: null, + createdDateTime: null, + updatedBy: null, + updatedDateTime: null + }, + businessOrgCode: null, + firstName: 'John', + surName: 'Nike', + companyName: null, + organizationNumber: null, + confirmedIndicator: false, + createdBy: 'IDIR\\JNNIKE', + createdDateTime: 1166734440000, + updatedBy: 'ATS', + updatedDateTime: 1166734440000, + regionName: 'Skeena', + optOutOfBCStatSurveyInd: 'NO' + } + ] + }, + status: 200 + }; + + searchATSUsersSpy.mockResolvedValue(atsUsers); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await atsController.searchATSUsers(req as any, res as any, next); + + expect(searchATSUsersSpy).toHaveBeenCalledTimes(1); + expect(searchATSUsersSpy).toHaveBeenCalledWith({ firstName: 'John' }); + expect(res.status).toHaveBeenCalledWith(200); + }); + + it('calls next if the ats service fails to get ats users', async () => { + const req = { + query: { firstName: 'John' }, + currentContext: CURRENT_CONTEXT + }; + + searchATSUsersSpy.mockImplementationOnce(() => { + throw new Error(); + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await atsController.searchATSUsers(req as any, res as any, next); + + expect(searchATSUsersSpy).toHaveBeenCalledTimes(1); + expect(searchATSUsersSpy).toHaveBeenCalledWith({ firstName: 'John' }); + expect(res.status).toHaveBeenCalledTimes(0); + expect(next).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/components/housing/submission/SubmissionForm.vue b/frontend/src/components/housing/submission/SubmissionForm.vue index e7811dcb..391556ec 100644 --- a/frontend/src/components/housing/submission/SubmissionForm.vue +++ b/frontend/src/components/housing/submission/SubmissionForm.vue @@ -38,7 +38,7 @@ import { applicantValidator, assignedToValidator, latitudeValidator, longitudeVa import type { Ref } from 'vue'; import type { IInputEvent } from '@/interfaces'; -import type { ATSUser, Submission, User } from '@/types'; +import type { ATSClientResource, Submission, User } from '@/types'; import { omit, setEmptyStringsToNull } from '@/utils/utils'; // Interfaces @@ -58,7 +58,6 @@ const submissionStore = useSubmissionStore(); // State const assigneeOptions: Ref> = ref([]); -const atsClientNumber: Ref = ref(undefined); const atsUserLinkModalVisible: Ref = ref(false); const atsUserDetailsModalVisible: Ref = ref(false); const atsUserCreateModalVisible: Ref = ref(false); @@ -197,7 +196,6 @@ const onSubmit = async (values: any) => { const submitData: Submission = omit(setEmptyStringsToNull(values) as SubmissionForm, ['locationAddress', 'user']); submitData.assignedUserId = values.user?.userId ?? undefined; submitData.consentToFeedback = values.consentToFeedback === BasicResponse.YES; - submitData.atsClientNumber = atsClientNumber.value?.toString() ?? null; const result = await submissionService.updateSubmission(values.submissionId, submitData); submissionStore.setSubmission(result.data); formRef.value?.resetForm({ @@ -283,7 +281,6 @@ onMounted(async () => { applicationStatus: submission.applicationStatus, waitingOn: submission.waitingOn }; - atsClientNumber.value = submission.atsClientNumber ?? undefined; }); @@ -617,7 +614,7 @@ onMounted(async () => {
Client #
@@ -625,11 +622,15 @@ onMounted(async () => { class="atsclass" @click="atsUserDetailsModalVisible = true" > - {{ atsClientNumber }} + {{ values.atsClientNumber }}
+
- diff --git a/frontend/src/services/atsService.ts b/frontend/src/services/atsService.ts index adfdddb5..76368cac 100644 --- a/frontend/src/services/atsService.ts +++ b/frontend/src/services/atsService.ts @@ -1,5 +1,7 @@ import { appAxios } from './interceptors'; +import type { ATSClientResource } from '@/types'; + export default { /** * @function searchATSUsers @@ -16,7 +18,7 @@ export default { * @function createATSClient * @returns {Promise} An axios response */ - createATSClient(data?: any) { + createATSClient(data?: ATSClientResource) { return appAxios().post('ats/client', data); } }; diff --git a/frontend/src/types/ATSClientResource.ts b/frontend/src/types/ATSClientResource.ts new file mode 100644 index 00000000..d5111d26 --- /dev/null +++ b/frontend/src/types/ATSClientResource.ts @@ -0,0 +1,46 @@ +export type RelLink = { + '@type': string; + rel: string; + href: string; + method: string; +}; + +export type AddressResource = { + '@type': string; + links: RelLink[]; + addressId: number; + addressLine1: string; + addressLine2: string | null; + city: string; + provinceCode: string; + countryCode: string; + postalCode: string | null; + primaryPhone: string | null; + secondaryPhone: string | null; + fax: string | null; + email: string | null; + createdBy: string | null; + createdDateTime: number | null; + updatedBy: string | null; + updatedDateTime: number | null; +}; + +export type ATSClientResource = { + '@type': string | undefined; + links: RelLink[] | undefined; + clientId: number | undefined; + address: AddressResource; + businessOrgCode: string | null; + firstName: string; + formattedAddress: string; + surName: string; + companyName: string | null; + organizationNumber: string | null; + confirmedIndicator: boolean; + createdBy: string | undefined; + createdDateTime: number | undefined; + updatedBy: string | undefined; + updatedDateTime: number | undefined; + regionName: string | undefined; + optOutOfBCStatSurveyInd: string | undefined; +}; diff --git a/frontend/src/types/ATSUser.ts b/frontend/src/types/ATSUser.ts deleted file mode 100644 index 7dbd7cb8..00000000 --- a/frontend/src/types/ATSUser.ts +++ /dev/null @@ -1,8 +0,0 @@ -export type ATSUser = { - address: string; - atsClientNumber?: string | undefined; - email: string; - firstName: string; - phone: string; - lastName: string; -}; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index f5d2a2b0..e6d3bdb7 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1,5 +1,5 @@ export type { AccessRequest } from './AccessRequest'; -export type { ATSUser } from './ATSUser'; +export type { ATSClientResource } from './ATSClientResource'; export type { BasicBCeIDAttribute } from './BasicBCeIDAttribute'; export type { BringForward } from './BringForward'; export type { BusinessBCeIDAttribute } from './BusinessBCeIDAttribute';