diff --git a/.env.example b/.env.example index 40b4dc2..cb5c032 100644 --- a/.env.example +++ b/.env.example @@ -32,6 +32,7 @@ CASE_IDIR_FIELD='idir field here' CASE_SEARCHSPEC_IDIR_FIELD='idir field here' CASE_SINCE_FIELD='since field here' CASE_RESTRICTED_FIELD='restricted field here' +CASE_TYPE_FIELD='type field here' INCIDENT_IDIR_FIELD='idir field here' INCIDENT_SEARCHSPEC_IDIR_FIELD='idir field here' INCIDENT_SINCE_FIELD='since field here' diff --git a/helm/templates/deployment.yaml b/helm/templates/deployment.yaml index 3d5b608..f1e59c8 100644 --- a/helm/templates/deployment.yaml +++ b/helm/templates/deployment.yaml @@ -214,6 +214,11 @@ spec: secretKeyRef: name: visitz-api key: INCIDENT_RESTRICTED_FIELD + - name: CASE_TYPE_FIELD + valueFrom: + secretKeyRef: + name: visitz-api + key: CASE_TYPE_FIELD - name: VPI_APP_LABEL value: {{ .Values.vpiAppBuildLabel.version }} - name: VPI_APP_ENV diff --git a/src/common/constants/error-constants.ts b/src/common/constants/error-constants.ts new file mode 100644 index 0000000..a05749c --- /dev/null +++ b/src/common/constants/error-constants.ts @@ -0,0 +1,5 @@ +export const childServicesTypeError = + 'Given case is not a Child Services case and cannot have Child/Youth visits.'; + +export const dateFormatError = + 'Date / time must met the ISO-8601 standard, and cannot be in the future'; diff --git a/src/common/constants/upstream-constants.ts b/src/common/constants/upstream-constants.ts index ebbf67c..d9eb7b6 100644 --- a/src/common/constants/upstream-constants.ts +++ b/src/common/constants/upstream-constants.ts @@ -5,6 +5,7 @@ const postInPersonVisitsEndpointEnvVarName = 'IN_PERSON_VISITS_POST_ENDPOINT'; const attachmentsEndpointEnvVarName = 'ATTACHMENTS_ENDPOINT'; const idirUsernameHeaderField = 'x-idir-username'; const upstreamDateFormat = 'MM/dd/yyyy HH:mm:ss'; +const caseChildServices = 'Child Services'; const childVisitType = 'In Person Child Youth'; const childVisitIdirFieldName = 'Login Name'; const childVisitEntityIdFieldName = 'Parent Id'; @@ -34,6 +35,7 @@ export { attachmentsEndpointEnvVarName, idirUsernameHeaderField, upstreamDateFormat, + caseChildServices, childVisitType, childVisitIdirFieldName, childVisitEntityIdFieldName, diff --git a/src/configuration/configuration.ts b/src/configuration/configuration.ts index a8822ec..2227fdc 100644 --- a/src/configuration/configuration.ts +++ b/src/configuration/configuration.ts @@ -11,6 +11,7 @@ export default () => ({ idirField: process.env.CASE_IDIR_FIELD ?? undefined, searchspecIdirField: process.env.CASE_SEARCHSPEC_IDIR_FIELD ?? undefined, restrictedField: process.env.CASE_RESTRICTED_FIELD ?? undefined, + typeField: process.env.CASE_TYPE_FIELD ?? undefined, }, incident: { endpoint: encodeURI((process.env.INCIDENT_ENDPOINT ?? ' ').trim()), diff --git a/src/external-api/request-preparer/request-preparer.service.ts b/src/external-api/request-preparer/request-preparer.service.ts index e9f5c1f..3162bfe 100644 --- a/src/external-api/request-preparer/request-preparer.service.ts +++ b/src/external-api/request-preparer/request-preparer.service.ts @@ -102,7 +102,7 @@ export class RequestPreparerService { return [headers, params]; } - async sendGetRequest(url: string, headers, res: Response, params?) { + async sendGetRequest(url: string, headers, res?: Response, params?) { let response; try { const token = @@ -141,7 +141,10 @@ export class RequestPreparerService { { cause: error }, ); } - if (response.headers.hasOwnProperty(recordCountHeaderName)) { + if ( + res !== undefined && + response.headers.hasOwnProperty(recordCountHeaderName) + ) { res.setHeader( recordCountHeaderName, response.headers[recordCountHeaderName], diff --git a/src/helpers/in-person-visits/in-person-visits.service.spec.ts b/src/helpers/in-person-visits/in-person-visits.service.spec.ts index 67ccd8d..37e341f 100644 --- a/src/helpers/in-person-visits/in-person-visits.service.spec.ts +++ b/src/helpers/in-person-visits/in-person-visits.service.spec.ts @@ -26,10 +26,14 @@ import { PostInPersonVisitDtoUpstream } from '../../dto/post-in-person-visit.dto import { getMockRes } from '@jest-mock/express'; import configuration from '../../configuration/configuration'; import { JwtService } from '@nestjs/jwt'; +import { caseChildServices } from '../../common/constants/upstream-constants'; +import { BadRequestException, HttpException, HttpStatus } from '@nestjs/common'; describe('InPersonVisitsService', () => { let service: InPersonVisitsService; let requestPreparerService: RequestPreparerService; + let configService: ConfigService; + let typeFieldName: string | undefined; const { res, mockClear } = getMockRes(); beforeEach(async () => { @@ -57,6 +61,8 @@ describe('InPersonVisitsService', () => { requestPreparerService = module.get( RequestPreparerService, ); + configService = module.get(ConfigService); + typeFieldName = configService.get('upstreamAuth.case.typeField'); mockClear(); }); @@ -83,6 +89,12 @@ describe('InPersonVisitsService', () => { async (data, recordType, idPathParams, filterQueryParams) => { const spy = jest .spyOn(requestPreparerService, 'sendGetRequest') + .mockResolvedValueOnce({ + data: { items: [{ [`${typeFieldName}`]: caseChildServices }] }, + headers: {}, + status: 200, + statusText: 'OK', + } as AxiosResponse) .mockResolvedValueOnce({ data: data, headers: {}, @@ -97,10 +109,41 @@ describe('InPersonVisitsService', () => { 'idir', filterQueryParams, ); - expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledTimes(2); expect(result).toEqual(new NestedInPersonVisitsEntity(data)); }, ); + + it.each([ + [ + RecordType.Case, + { [idName]: 'test' } as IdPathParams, + { [sinceParamName]: '2020-12-24' } as FilterQueryParams, + ], + ])( + 'should return bad request exception on non-child services case', + async (recordType, idPathParams, filterQueryParams) => { + const caseTypeSpy = jest + .spyOn(requestPreparerService, 'sendGetRequest') + .mockResolvedValueOnce({ + data: { items: [{ [`${typeFieldName}`]: 'randomCaseType' }] }, + headers: {}, + status: 200, + statusText: 'OK', + } as AxiosResponse); + + await expect( + service.getListInPersonVisitRecord( + recordType, + idPathParams, + res, + 'idir', + filterQueryParams, + ), + ).rejects.toThrow(BadRequestException); + expect(caseTypeSpy).toHaveBeenCalledTimes(1); + }, + ); }); describe('getSingleInPersonVisitRecord tests', () => { @@ -115,6 +158,12 @@ describe('InPersonVisitsService', () => { async (data, recordType, idPathParams) => { const spy = jest .spyOn(requestPreparerService, 'sendGetRequest') + .mockResolvedValueOnce({ + data: { items: [{ [`${typeFieldName}`]: caseChildServices }] }, + headers: {}, + status: 200, + statusText: 'OK', + } as AxiosResponse) .mockResolvedValueOnce({ data: data, headers: {}, @@ -128,10 +177,39 @@ describe('InPersonVisitsService', () => { res, 'idir', ); - expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledTimes(2); expect(result).toEqual(new InPersonVisitsEntity(data)); }, ); + + it.each([ + [ + RecordType.Case, + { [idName]: 'test', [visitIdName]: 'test2' } as VisitIdPathParams, + ], + ])( + 'should return bad request exception on non-child services case', + async (recordType, idPathParams) => { + const caseTypeSpy = jest + .spyOn(requestPreparerService, 'sendGetRequest') + .mockResolvedValueOnce({ + data: { items: [{ [`${typeFieldName}`]: 'randomCaseType' }] }, + headers: {}, + status: 200, + statusText: 'OK', + } as AxiosResponse); + + await expect( + service.getSingleInPersonVisitRecord( + recordType, + idPathParams, + res, + 'idir', + ), + ).rejects.toThrow(BadRequestException); + expect(caseTypeSpy).toHaveBeenCalledTimes(1); + }, + ); }); describe('postSingleInPersonVisitRecord tests', () => { @@ -156,6 +234,14 @@ describe('InPersonVisitsService', () => { status: 200, statusText: 'OK', } as AxiosResponse); + const caseTypeSpy = jest + .spyOn(requestPreparerService, 'sendGetRequest') + .mockResolvedValueOnce({ + data: { items: [{ [`${typeFieldName}`]: caseChildServices }] }, + headers: {}, + status: 200, + statusText: 'OK', + } as AxiosResponse); const result = await service.postSingleInPersonVisitRecord( recordType, @@ -163,8 +249,94 @@ describe('InPersonVisitsService', () => { 'idir', ); expect(spy).toHaveBeenCalledTimes(1); + expect(caseTypeSpy).toHaveBeenCalledTimes(1); expect(result).toEqual(new NestedInPersonVisitsEntity(data)); }, ); + + it.each([ + [ + RecordType.Case, + new PostInPersonVisitDtoUpstream({ + 'Date of visit': '11/08/2024 08:24:11', + 'Visit Details Value': VisitDetails.NotPrivateInHome, + 'Visit Description': 'comment', + }), + ], + ])( + 'should return bad request exception on non-child services case', + async (recordType, body) => { + const caseTypeSpy = jest + .spyOn(requestPreparerService, 'sendGetRequest') + .mockResolvedValueOnce({ + data: { items: [{ [`${typeFieldName}`]: 'randomCaseType' }] }, + headers: {}, + status: 200, + statusText: 'OK', + } as AxiosResponse); + + await expect( + service.postSingleInPersonVisitRecord(recordType, body, 'idir'), + ).rejects.toThrow(BadRequestException); + expect(caseTypeSpy).toHaveBeenCalledTimes(1); + }, + ); + }); + + describe('isChildCaseType tests', () => { + it('should return true when type field is a child services case', async () => { + const spy = jest + .spyOn(requestPreparerService, 'sendGetRequest') + .mockResolvedValueOnce({ + data: { items: [{ [`${typeFieldName}`]: caseChildServices }] }, + headers: {}, + status: 200, + statusText: 'OK', + } as AxiosResponse); + + const isChildCase = await service.isChildCaseType('parentId', 'idir'); + expect(spy).toHaveBeenCalledTimes(1); + expect(isChildCase).toBe(true); + }); + + it('should return false when type field is not a child services case', async () => { + const spy = jest + .spyOn(requestPreparerService, 'sendGetRequest') + .mockResolvedValueOnce({ + data: { items: [{ [`${typeFieldName}`]: 'randomCaseType' }] }, + headers: {}, + status: 200, + statusText: 'OK', + } as AxiosResponse); + + const isChildCase = await service.isChildCaseType('parentId', 'idir'); + expect(spy).toHaveBeenCalledTimes(1); + expect(isChildCase).toBe(false); + }); + + it('should return false when type field does not exist on case entity', async () => { + const spy = jest + .spyOn(requestPreparerService, 'sendGetRequest') + .mockResolvedValueOnce({ + data: { items: [{ [`${typeFieldName}sss`]: 'randomCaseType' }] }, + headers: {}, + status: 200, + statusText: 'OK', + } as AxiosResponse); + + const isChildCase = await service.isChildCaseType('parentId', 'idir'); + expect(spy).toHaveBeenCalledTimes(1); + expect(isChildCase).toBe(false); + }); + + it('should return false on upstream error', async () => { + const spy = jest + .spyOn(requestPreparerService, 'sendGetRequest') + .mockRejectedValueOnce(new HttpException({}, HttpStatus.NO_CONTENT)); + + const isChildCase = await service.isChildCaseType('parentId', 'idir'); + expect(spy).toHaveBeenCalledTimes(1); + expect(isChildCase).toBe(false); + }); }); }); diff --git a/src/helpers/in-person-visits/in-person-visits.service.ts b/src/helpers/in-person-visits/in-person-visits.service.ts index 452eadf..a0fe456 100644 --- a/src/helpers/in-person-visits/in-person-visits.service.ts +++ b/src/helpers/in-person-visits/in-person-visits.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { RecordType } from '../../common/constants/enumerations'; import { IdPathParams, VisitIdPathParams } from '../../dto/id-path-params.dto'; import { FilterQueryParams } from '../../dto/filter-query-params.dto'; @@ -17,15 +17,26 @@ import { } from '../../common/constants/parameter-constants'; import { PostInPersonVisitDtoUpstream } from '../../dto/post-in-person-visit.dto'; import { Response } from 'express'; -import { trustedIdirHeaderName } from '../../common/constants/upstream-constants'; +import { + caseChildServices, + childVisitEntityIdFieldName, + trustedIdirHeaderName, +} from '../../common/constants/upstream-constants'; +import { childServicesTypeError } from '../../common/constants/error-constants'; @Injectable() export class InPersonVisitsService { url: string; postUrl: string; + caseUrl: string; workspace: string | undefined; postWorkspace: string | undefined; + caseWorkspace: string | undefined; sinceFieldName: string | undefined; + typeFieldName: string | undefined; + + private readonly logger = new Logger(InPersonVisitsService.name); + constructor( private readonly configService: ConfigService, private readonly requestPreparerService: RequestPreparerService, @@ -38,13 +49,19 @@ export class InPersonVisitsService { this.configService.get('endpointUrls.baseUrl') + this.configService.get('endpointUrls.postInPersonVisits'), ); + this.caseUrl = encodeURI( + this.configService.get('endpointUrls.baseUrl') + + this.configService.get('upstreamAuth.case.endpoint'), + ); this.workspace = this.configService.get('workspaces.inPersonVisits'); this.postWorkspace = this.configService.get( 'workspaces.postInPersonVisits', ); + this.caseWorkspace = this.configService.get('upstreamAuth.case.workspace'); this.sinceFieldName = this.configService.get( 'sinceFieldName.inPersonVisits', ); + this.typeFieldName = this.configService.get('upstreamAuth.case.typeField'); } async getSingleInPersonVisitRecord( @@ -53,7 +70,12 @@ export class InPersonVisitsService { res: Response, idir: string, ): Promise { - const baseSearchSpec = `([Parent Id]="${id[idName]}" AND [Id]="${id[visitIdName]}"`; + const parentId = id[idName]; + const isValidChildCase = await this.isChildCaseType(parentId, idir); + if (!isValidChildCase) { + throw new BadRequestException([childServicesTypeError]); + } + const baseSearchSpec = `([Parent Id]="${parentId}" AND [Id]="${id[visitIdName]}"`; const [headers, params] = this.requestPreparerService.prepareHeadersAndParams( baseSearchSpec, @@ -78,7 +100,12 @@ export class InPersonVisitsService { idir: string, filter?: FilterQueryParams, ): Promise { - const baseSearchSpec = `([Parent Id]="${id[idName]}"`; + const parentId = id[idName]; + const isValidChildCase = await this.isChildCaseType(parentId, idir); + if (!isValidChildCase) { + throw new BadRequestException([childServicesTypeError]); + } + const baseSearchSpec = `([Parent Id]="${parentId}"`; const [headers, params] = this.requestPreparerService.prepareHeadersAndParams( baseSearchSpec, @@ -102,6 +129,11 @@ export class InPersonVisitsService { body: PostInPersonVisitDtoUpstream, idir: string, ): Promise { + const parentId = body[childVisitEntityIdFieldName]; + const isValidChildCase = await this.isChildCaseType(parentId, idir); + if (!isValidChildCase) { + throw new BadRequestException([childServicesTypeError]); + } const headers = { Accept: CONTENT_TYPE, 'Content-Type': CONTENT_TYPE, @@ -122,4 +154,33 @@ export class InPersonVisitsService { ); return new NestedInPersonVisitsEntity(response.data); } + + async isChildCaseType(parentId: string, idir: string): Promise { + const baseSearchSpec = `([Id]="${parentId}"`; + const [headers, params] = + this.requestPreparerService.prepareHeadersAndParams( + baseSearchSpec, + this.caseWorkspace, + undefined, + true, + idir, + ); + let response; + try { + response = await this.requestPreparerService.sendGetRequest( + this.caseUrl, + headers, + undefined, + params, + ); + } catch { + return false; + } + const type = response.data['items'][0][`${this.typeFieldName}`]; + if (type === undefined) { + this.logger.error(`${this.typeFieldName} field not found in request`); + return false; + } + return type === caseChildServices; + } } diff --git a/src/helpers/utilities/utilities.service.ts b/src/helpers/utilities/utilities.service.ts index 70c1b2e..7f5b3c3 100644 --- a/src/helpers/utilities/utilities.service.ts +++ b/src/helpers/utilities/utilities.service.ts @@ -6,6 +6,7 @@ import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import { Request } from 'express'; import { RecordType } from '../../common/constants/enumerations'; +import { dateFormatError } from '../../common/constants/error-constants'; @Injectable() export class UtilitiesService { @@ -89,7 +90,5 @@ export function isPastISO8601Date(date: string): string { return dateObject.toFormat(upstreamDateFormat); } } - throw new BadRequestException([ - 'Date / time must met the ISO-8601 standard, and cannot be in the future', - ]); + throw new BadRequestException([dateFormatError]); }