diff --git a/.env.example b/.env.example index 97463ce..8b6d5b8 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,7 @@ CLIENT_ID='id here' CLIENT_SECRET='secret here' UPSTREAM_BASE_URL=http://www.google.com SUPPORT_NETWORK_ENDPOINT=/endpoint/path/here +IN_PERSON_VISITS_ENDPOINT=/endpoint/path/here CASE_ENDPOINT=/endpoint/path/here INCIDENT_ENDPOINT=/endpoint/path/here SR_ENDPOINT=/endpoint/path/here diff --git a/.github/workflows/build-and-push-ghcr.yaml b/.github/workflows/build-and-push-ghcr.yaml index 3447dfc..a534c39 100644 --- a/.github/workflows/build-and-push-ghcr.yaml +++ b/.github/workflows/build-and-push-ghcr.yaml @@ -77,6 +77,14 @@ jobs: value: ${{ secrets.APS_NAMESPACE }} commitChange: false + - name: 'YAML poke: Set build number' + uses: fjogeleit/yaml-update-action@v0.15.0 + with: + valueFile: 'helm/values.yaml' + propertyPath: 'vpiAppBuildLabel.version' + value: "${{ github.ref_name }}-${{ github.run_number }}" + commitChange: false + - name: Authenticate with OpenShift uses: redhat-actions/oc-login@v1 with: diff --git a/helm/templates/deployment.yaml b/helm/templates/deployment.yaml index a134fde..505a54e 100644 --- a/helm/templates/deployment.yaml +++ b/helm/templates/deployment.yaml @@ -82,4 +82,11 @@ spec: secretKeyRef: name: visitz-api key: SKIP_AUTH_GUARD + - name: IN_PERSON_VISITS_ENDPOINT + valueFrom: + secretKeyRef: + name: visitz-api + key: IN_PERSON_VISITS_ENDPOINT + - name: VPI_APP_LABEL + value: {{ .Values.vpiAppBuildLabel.version }} restartPolicy: Always diff --git a/helm/values.yaml b/helm/values.yaml index 39e2d31..ab576c5 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -20,3 +20,6 @@ affinity: {} aps: namespace: '' + +vpiAppBuildLabel: + version: '' diff --git a/src/app.module.ts b/src/app.module.ts index 7b410df..d91f5f7 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,5 +1,5 @@ import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; +import { ConfigModule, ConfigService } from '@nestjs/config'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import configuration from './configuration/configuration'; @@ -8,17 +8,38 @@ import { ControllersModule } from './controllers/controllers.module'; import { HelpersModule } from './helpers/helpers.module'; import { LoggerModule } from 'nestjs-pino'; import { CacheModule } from '@nestjs/cache-manager'; +import { ExternalApiModule } from './external-api/external-api.module'; @Module({ imports: [ ConfigModule.forRoot({ load: [configuration], }), - LoggerModule.forRoot(), + LoggerModule.forRootAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (configService: ConfigService) => ({ + pinoHttp: { + customSuccessObject: (req, res, loggableObject) => { + return { + ...loggableObject, + buildNumber: configService.get('buildInfo.buildNumber'), + }; + }, + customErrorObject: (req, res, loggableObject) => { + return { + ...loggableObject, + buildNumber: configService.get('buildInfo.buildNumber'), + }; + }, + }, + }), + }), CacheModule.register({ isGlobal: true }), CommonModule, ControllersModule, HelpersModule, + ExternalApiModule, ], controllers: [AppController], providers: [AppService], diff --git a/src/common/common.module.ts b/src/common/common.module.ts index 14c7bc3..216e48a 100644 --- a/src/common/common.module.ts +++ b/src/common/common.module.ts @@ -5,7 +5,7 @@ import { UtilitiesModule } from '../helpers/utilities/utilities.module'; import { UtilitiesService } from '../helpers/utilities/utilities.service'; import { AuthService } from './guards/auth/auth.service'; import { AuthModule } from './guards/auth/auth.module'; -import { TokenRefresherModule } from '../helpers/token-refresher/token-refresher.module'; +import { TokenRefresherModule } from '../external-api/token-refresher/token-refresher.module'; @Module({ providers: [UtilitiesService, AuthService, UtilitiesService, ConfigService], diff --git a/src/common/constants/parameter-constants.ts b/src/common/constants/parameter-constants.ts index 7105a4a..f60067b 100644 --- a/src/common/constants/parameter-constants.ts +++ b/src/common/constants/parameter-constants.ts @@ -1,7 +1,8 @@ const VIEW_MODE = 'Catalog'; const CHILD_LINKS = 'None'; const CONTENT_TYPE = 'application/json'; +const PAGINATION = 'N'; const idRegex = /[0-9\-A-Za-z]+/; -export { VIEW_MODE, CHILD_LINKS, CONTENT_TYPE, idRegex }; +export { VIEW_MODE, CHILD_LINKS, CONTENT_TYPE, PAGINATION, idRegex }; diff --git a/src/common/constants/upstream-constants.ts b/src/common/constants/upstream-constants.ts new file mode 100644 index 0000000..fa6b755 --- /dev/null +++ b/src/common/constants/upstream-constants.ts @@ -0,0 +1,9 @@ +const baseUrlEnvVarName = 'UPSTREAM_BASE_URL'; +const supportNetworkEndpointEnvVarName = 'SUPPORT_NETWORK_ENDPOINT'; +const inPersonVisitsEndpointEnvVarName = 'IN_PERSON_VISITS_ENDPOINT'; + +export { + baseUrlEnvVarName, + supportNetworkEndpointEnvVarName, + inPersonVisitsEndpointEnvVarName, +}; diff --git a/src/common/guards/auth/auth.guard.spec.ts b/src/common/guards/auth/auth.guard.spec.ts index 1d0cb61..6b04a80 100644 --- a/src/common/guards/auth/auth.guard.spec.ts +++ b/src/common/guards/auth/auth.guard.spec.ts @@ -7,7 +7,7 @@ import { UtilitiesService } from '../../../helpers/utilities/utilities.service'; import { AuthGuard } from './auth.guard'; import { AuthService } from './auth.service'; import { getMockReq } from '@jest-mock/express'; -import { TokenRefresherService } from '../../../helpers/token-refresher/token-refresher.service'; +import { TokenRefresherService } from '../../../external-api/token-refresher/token-refresher.service'; describe('AuthGuard', () => { let service: AuthService; diff --git a/src/common/guards/auth/auth.module.ts b/src/common/guards/auth/auth.module.ts index a859bfb..a29caa1 100644 --- a/src/common/guards/auth/auth.module.ts +++ b/src/common/guards/auth/auth.module.ts @@ -4,8 +4,8 @@ import { HttpModule } from '@nestjs/axios'; import { AuthService } from './auth.service'; import { UtilitiesModule } from '../../../helpers/utilities/utilities.module'; import { UtilitiesService } from '../../../helpers/utilities/utilities.service'; -import { TokenRefresherService } from '../../../helpers/token-refresher/token-refresher.service'; -import { TokenRefresherModule } from '../../../helpers/token-refresher/token-refresher.module'; +import { TokenRefresherService } from '../../../external-api/token-refresher/token-refresher.service'; +import { TokenRefresherModule } from '../../../external-api/token-refresher/token-refresher.module'; @Module({ providers: [ diff --git a/src/common/guards/auth/auth.service.spec.ts b/src/common/guards/auth/auth.service.spec.ts index f620d23..8190e7e 100644 --- a/src/common/guards/auth/auth.service.spec.ts +++ b/src/common/guards/auth/auth.service.spec.ts @@ -15,7 +15,7 @@ import { AuthService } from './auth.service'; import { RecordType } from '../../../common/constants/enumerations'; import { EnumTypeError } from '../../../common/errors/errors'; import { UtilitiesService } from '../../../helpers/utilities/utilities.service'; -import { TokenRefresherService } from '../../../helpers/token-refresher/token-refresher.service'; +import { TokenRefresherService } from '../../../external-api/token-refresher/token-refresher.service'; describe('AuthService', () => { let service: AuthService; diff --git a/src/common/guards/auth/auth.service.ts b/src/common/guards/auth/auth.service.ts index 182ec42..bd3109f 100644 --- a/src/common/guards/auth/auth.service.ts +++ b/src/common/guards/auth/auth.service.ts @@ -14,12 +14,14 @@ import { } from '../../../common/constants/parameter-constants'; import { firstValueFrom } from 'rxjs'; import { AxiosError } from 'axios'; -import { TokenRefresherService } from '../../../helpers/token-refresher/token-refresher.service'; +import { TokenRefresherService } from '../../../external-api/token-refresher/token-refresher.service'; +import { baseUrlEnvVarName } from '../../../common/constants/upstream-constants'; @Injectable() export class AuthService { cacheTime: number; baseUrl: string; + buildNumber: string; private readonly logger = new Logger(AuthService.name); constructor( @@ -30,7 +32,8 @@ export class AuthService { private readonly tokenRefresherService: TokenRefresherService, ) { this.cacheTime = this.configService.get('recordCache.cacheTtlMs'); - this.baseUrl = this.configService.get('UPSTREAM_BASE_URL'); + this.baseUrl = this.configService.get(baseUrlEnvVarName); + this.buildNumber = this.configService.get('buildInfo.buildNumber'); } async getRecordAndValidate(req: Request): Promise { @@ -45,8 +48,6 @@ export class AuthService { const key = `${id}|${recordType}`; let upstreamResult: string | null | undefined = await this.cacheManager.get(key); - // TODO: Remove this console log once guard is verified working - this.logger.log(`Cache result: ${upstreamResult}`); if (upstreamResult === undefined) { upstreamResult = await this.getAssignedIdirUpstream(id, recordType); @@ -114,9 +115,14 @@ export class AuthService { return idir; } catch (error) { if (error instanceof AxiosError) { - this.logger.error(error.message, error.stack, error.cause); + this.logger.error({ + msg: error.message, + stack: error.stack, + cause: error.cause, + buildNumber: this.buildNumber, + }); } else { - this.logger.error(error); + this.logger.error({ error, buildNumber: this.buildNumber }); } } return null; diff --git a/src/configuration/configuration.ts b/src/configuration/configuration.ts index 22d6b6e..36311b1 100644 --- a/src/configuration/configuration.ts +++ b/src/configuration/configuration.ts @@ -27,5 +27,19 @@ export default () => ({ clientId: process.env.CLIENT_ID, clientSecret: process.env.CLIENT_SECRET, }, + workspaces: { + supportNetwork: undefined, + inPersonVisits: undefined, + }, + sinceFieldName: { + supportNetwork: 'Updated', + inPersonVisits: undefined, + }, skipAuthGuard: process.env.SKIP_AUTH_GUARD === 'true', + buildInfo: { + buildNumber: + process.env.VPI_APP_LABEL === undefined + ? 'localBuild' + : process.env.VPI_APP_LABEL, + }, }); diff --git a/src/controllers/cases/cases.controller.spec.ts b/src/controllers/cases/cases.controller.spec.ts index 10cf81d..c1e8661 100644 --- a/src/controllers/cases/cases.controller.spec.ts +++ b/src/controllers/cases/cases.controller.spec.ts @@ -11,9 +11,15 @@ import { import { SinceQueryParams } from '../../dto/since-query-params.dto'; import { IdPathParams } from '../../dto/id-path-params.dto'; import { AuthService } from '../../common/guards/auth/auth.service'; -import { TokenRefresherService } from '../../helpers/token-refresher/token-refresher.service'; +import { TokenRefresherService } from '../../external-api/token-refresher/token-refresher.service'; import { SupportNetworkService } from '../../helpers/support-network/support-network.service'; import { UtilitiesService } from '../../helpers/utilities/utilities.service'; +import { InPersonVisitsService } from '../../helpers/in-person-visits/in-person-visits.service'; +import { RequestPreparerService } from '../../external-api/request-preparer/request-preparer.service'; +import { + InPersonVisitsEntity, + InPersonVisitsSingleResponseCaseExample, +} from '../../entities/in-person-visits.entity'; describe('CasesController', () => { let controller: CasesController; @@ -27,6 +33,8 @@ describe('CasesController', () => { AuthService, SupportNetworkService, TokenRefresherService, + InPersonVisitsService, + RequestPreparerService, { provide: CACHE_MANAGER, useValue: {} }, ConfigService, UtilitiesService, @@ -70,4 +78,31 @@ describe('CasesController', () => { }, ); }); + + describe('getSingleCaseInPersonVisitRecord tests', () => { + it.each([ + [ + InPersonVisitsSingleResponseCaseExample, + { id: 'test' } as IdPathParams, + { since: '2020-02-02' } as SinceQueryParams, + ], + ])( + 'should return single values given good input', + async (data, idPathParams, sinceQueryParams) => { + const casesServiceSpy = jest + .spyOn(casesService, 'getSingleCaseInPersonVisitRecord') + .mockReturnValueOnce(Promise.resolve(new InPersonVisitsEntity(data))); + + const result = await controller.getSingleCaseInPersonVisitRecord( + idPathParams, + sinceQueryParams, + ); + expect(casesServiceSpy).toHaveBeenCalledWith( + idPathParams, + sinceQueryParams, + ); + expect(result).toEqual(new InPersonVisitsEntity(data)); + }, + ); + }); }); diff --git a/src/controllers/cases/cases.controller.ts b/src/controllers/cases/cases.controller.ts index 0140b34..1a325ef 100644 --- a/src/controllers/cases/cases.controller.ts +++ b/src/controllers/cases/cases.controller.ts @@ -30,6 +30,12 @@ import { ApiNotFoundEntity } from '../../entities/api-not-found.entity'; import { CONTENT_TYPE } from '../../common/constants/parameter-constants'; import { ApiInternalServerErrorEntity } from '../../entities/api-internal-server-error.entity'; import { AuthGuard } from '../../common/guards/auth/auth.guard'; +import { + InPersonVisitsEntity, + InPersonVisitsListResponseCaseExample, + InPersonVisitsSingleResponseCaseExample, + NestedInPersonVisitsEntity, +} from '../../entities/in-person-visits.entity'; @Controller('case') @UseGuards(AuthGuard) @@ -90,4 +96,54 @@ export class CasesController { since, ); } + + @UseInterceptors(ClassSerializerInterceptor) + @Get(':id/visits') + @ApiOperation({ + description: + 'Find all In Person Child / Youth Visits related to a given Case entity by Case id.', + }) + @ApiQuery({ name: 'since', required: false }) + @ApiExtraModels(InPersonVisitsEntity, NestedInPersonVisitsEntity) + @ApiOkResponse({ + content: { + [CONTENT_TYPE]: { + schema: { + oneOf: [ + { $ref: getSchemaPath(InPersonVisitsEntity) }, + { $ref: getSchemaPath(NestedInPersonVisitsEntity) }, + ], + }, + examples: { + InPersonVisitsSingleResponse: { + value: InPersonVisitsSingleResponseCaseExample, + }, + InPersonVisitsListResponse: { + value: InPersonVisitsListResponseCaseExample, + }, + }, + }, + }, + }) + async getSingleCaseInPersonVisitRecord( + @Param( + new ValidationPipe({ + transform: true, + transformOptions: { enableImplicitConversion: true }, + forbidNonWhitelisted: true, + }), + ) + id: IdPathParams, + @Query( + new ValidationPipe({ + transform: true, + transformOptions: { enableImplicitConversion: true }, + forbidNonWhitelisted: true, + skipMissingProperties: true, + }), + ) + since?: SinceQueryParams, + ): Promise { + return await this.casesService.getSingleCaseInPersonVisitRecord(id, since); + } } diff --git a/src/controllers/cases/cases.module.ts b/src/controllers/cases/cases.module.ts index 7f4c815..3d6b084 100644 --- a/src/controllers/cases/cases.module.ts +++ b/src/controllers/cases/cases.module.ts @@ -7,10 +7,18 @@ import { AuthService } from '../../common/guards/auth/auth.service'; import { ConfigService } from '@nestjs/config'; import { UtilitiesService } from '../../helpers/utilities/utilities.service'; import { HttpModule } from '@nestjs/axios'; +import { TokenRefresherService } from '../../external-api/token-refresher/token-refresher.service'; +import { ExternalApiModule } from '../../external-api/external-api.module'; @Module({ - providers: [CasesService, AuthService, ConfigService, UtilitiesService], + providers: [ + CasesService, + AuthService, + ConfigService, + UtilitiesService, + TokenRefresherService, + ], controllers: [CasesController], - imports: [HelpersModule, AuthModule, HttpModule], + imports: [HelpersModule, AuthModule, HttpModule, ExternalApiModule], }) export class CasesModule {} diff --git a/src/controllers/cases/cases.service.spec.ts b/src/controllers/cases/cases.service.spec.ts index d0eb883..bda6a6f 100644 --- a/src/controllers/cases/cases.service.spec.ts +++ b/src/controllers/cases/cases.service.spec.ts @@ -12,11 +12,18 @@ import { import { IdPathParams } from '../../dto/id-path-params.dto'; import { SinceQueryParams } from '../../dto/since-query-params.dto'; import { RecordType } from '../../common/constants/enumerations'; -import { TokenRefresherService } from '../../helpers/token-refresher/token-refresher.service'; +import { TokenRefresherService } from '../../external-api/token-refresher/token-refresher.service'; +import { InPersonVisitsService } from '../../helpers/in-person-visits/in-person-visits.service'; +import { RequestPreparerService } from '../../external-api/request-preparer/request-preparer.service'; +import { + InPersonVisitsEntity, + InPersonVisitsSingleResponseCaseExample, +} from '../../entities/in-person-visits.entity'; describe('CasesService', () => { let service: CasesService; let supportNetworkService: SupportNetworkService; + let inPersonVisitsService: InPersonVisitsService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -26,6 +33,8 @@ describe('CasesService', () => { SupportNetworkService, UtilitiesService, TokenRefresherService, + InPersonVisitsService, + RequestPreparerService, { provide: CACHE_MANAGER, useValue: { @@ -40,6 +49,9 @@ describe('CasesService', () => { supportNetworkService = module.get( SupportNetworkService, ); + inPersonVisitsService = module.get( + InPersonVisitsService, + ); }); it('should be defined', () => { @@ -77,4 +89,32 @@ describe('CasesService', () => { }, ); }); + + describe('getSingleCaseInPersonVisitRecord tests', () => { + it.each([ + [ + InPersonVisitsSingleResponseCaseExample, + { id: 'test' } as IdPathParams, + { since: '2024-12-01' } as SinceQueryParams, + ], + ])( + 'should return single values given good input', + async (data, idPathParams, sinceQueryParams) => { + const InPersonVisitsSpy = jest + .spyOn(inPersonVisitsService, 'getSingleInPersonVisitRecord') + .mockReturnValueOnce(Promise.resolve(new InPersonVisitsEntity(data))); + + const result = await service.getSingleCaseInPersonVisitRecord( + idPathParams, + sinceQueryParams, + ); + expect(InPersonVisitsSpy).toHaveBeenCalledWith( + RecordType.Case, + idPathParams, + sinceQueryParams, + ); + expect(result).toEqual(new InPersonVisitsEntity(data)); + }, + ); + }); }); diff --git a/src/controllers/cases/cases.service.ts b/src/controllers/cases/cases.service.ts index d43ab70..8f011bb 100644 --- a/src/controllers/cases/cases.service.ts +++ b/src/controllers/cases/cases.service.ts @@ -7,10 +7,18 @@ import { } from '../../entities/support-network.entity'; import { IdPathParams } from '../../dto/id-path-params.dto'; import { SinceQueryParams } from '../../dto/since-query-params.dto'; +import { InPersonVisitsService } from '../../helpers/in-person-visits/in-person-visits.service'; +import { + InPersonVisitsEntity, + NestedInPersonVisitsEntity, +} from '../../entities/in-person-visits.entity'; @Injectable() export class CasesService { - constructor(private readonly supportNetworkService: SupportNetworkService) {} + constructor( + private readonly supportNetworkService: SupportNetworkService, + private readonly inPersonVisitsService: InPersonVisitsService, + ) {} async getSingleCaseSupportNetworkInformationRecord( id: IdPathParams, @@ -22,4 +30,15 @@ export class CasesService { since, ); } + + async getSingleCaseInPersonVisitRecord( + id: IdPathParams, + since?: SinceQueryParams, + ): Promise { + return await this.inPersonVisitsService.getSingleInPersonVisitRecord( + RecordType.Case, + id, + since, + ); + } } diff --git a/src/controllers/incidents/incidents.controller.spec.ts b/src/controllers/incidents/incidents.controller.spec.ts index d07f795..2047e1e 100644 --- a/src/controllers/incidents/incidents.controller.spec.ts +++ b/src/controllers/incidents/incidents.controller.spec.ts @@ -12,8 +12,9 @@ import { SinceQueryParams } from '../../dto/since-query-params.dto'; import { IdPathParams } from '../../dto/id-path-params.dto'; import { AuthService } from '../../common/guards/auth/auth.service'; import { SupportNetworkService } from '../../helpers/support-network/support-network.service'; -import { TokenRefresherService } from '../../helpers/token-refresher/token-refresher.service'; +import { TokenRefresherService } from '../../external-api/token-refresher/token-refresher.service'; import { UtilitiesService } from '../../helpers/utilities/utilities.service'; +import { RequestPreparerService } from '../../external-api/request-preparer/request-preparer.service'; describe('IncidentsController', () => { let controller: IncidentsController; @@ -27,6 +28,7 @@ describe('IncidentsController', () => { SupportNetworkService, AuthService, TokenRefresherService, + RequestPreparerService, { provide: CACHE_MANAGER, useValue: {} }, ConfigService, UtilitiesService, diff --git a/src/controllers/incidents/incidents.module.ts b/src/controllers/incidents/incidents.module.ts index 01d541e..3ac6330 100644 --- a/src/controllers/incidents/incidents.module.ts +++ b/src/controllers/incidents/incidents.module.ts @@ -7,9 +7,16 @@ import { HelpersModule } from '../../helpers/helpers.module'; import { AuthModule } from '../../common/guards/auth/auth.module'; import { AuthService } from '../../common/guards/auth/auth.service'; import { UtilitiesService } from '../../helpers/utilities/utilities.service'; +import { TokenRefresherService } from '../../external-api/token-refresher/token-refresher.service'; @Module({ - providers: [IncidentsService, AuthService, ConfigService, UtilitiesService], + providers: [ + IncidentsService, + AuthService, + ConfigService, + UtilitiesService, + TokenRefresherService, + ], controllers: [IncidentsController], imports: [HelpersModule, AuthModule, HttpModule], }) diff --git a/src/controllers/incidents/incidents.service.spec.ts b/src/controllers/incidents/incidents.service.spec.ts index 34d2a05..fe5175d 100644 --- a/src/controllers/incidents/incidents.service.spec.ts +++ b/src/controllers/incidents/incidents.service.spec.ts @@ -12,7 +12,8 @@ import { import { SinceQueryParams } from '../../dto/since-query-params.dto'; import { IdPathParams } from '../../dto/id-path-params.dto'; import { RecordType } from '../../common/constants/enumerations'; -import { TokenRefresherService } from '../../helpers/token-refresher/token-refresher.service'; +import { TokenRefresherService } from '../../external-api/token-refresher/token-refresher.service'; +import { RequestPreparerService } from '../../external-api/request-preparer/request-preparer.service'; describe('IncidentsService', () => { let service: IncidentsService; @@ -26,6 +27,7 @@ describe('IncidentsService', () => { SupportNetworkService, UtilitiesService, TokenRefresherService, + RequestPreparerService, { provide: CACHE_MANAGER, useValue: { diff --git a/src/controllers/service-requests/service-requests.controller.spec.ts b/src/controllers/service-requests/service-requests.controller.spec.ts index b5ae841..c17710f 100644 --- a/src/controllers/service-requests/service-requests.controller.spec.ts +++ b/src/controllers/service-requests/service-requests.controller.spec.ts @@ -11,8 +11,9 @@ import { import { IdPathParams } from '../../dto/id-path-params.dto'; import { SinceQueryParams } from '../../dto/since-query-params.dto'; import { SupportNetworkService } from '../../helpers/support-network/support-network.service'; -import { TokenRefresherService } from '../../helpers/token-refresher/token-refresher.service'; +import { TokenRefresherService } from '../../external-api/token-refresher/token-refresher.service'; import { UtilitiesService } from '../../helpers/utilities/utilities.service'; +import { RequestPreparerService } from '../../external-api/request-preparer/request-preparer.service'; describe('ServiceRequestsController', () => { let controller: ServiceRequestsController; @@ -25,6 +26,7 @@ describe('ServiceRequestsController', () => { ServiceRequestsService, SupportNetworkService, TokenRefresherService, + RequestPreparerService, { provide: CACHE_MANAGER, useValue: {} }, ConfigService, UtilitiesService, diff --git a/src/controllers/service-requests/service-requests.module.ts b/src/controllers/service-requests/service-requests.module.ts index fda72fc..eba7f12 100644 --- a/src/controllers/service-requests/service-requests.module.ts +++ b/src/controllers/service-requests/service-requests.module.ts @@ -7,6 +7,7 @@ import { HelpersModule } from '../../helpers/helpers.module'; import { AuthModule } from '../../common/guards/auth/auth.module'; import { AuthService } from '../../common/guards/auth/auth.service'; import { UtilitiesService } from '../../helpers/utilities/utilities.service'; +import { TokenRefresherService } from '../../external-api/token-refresher/token-refresher.service'; @Module({ providers: [ @@ -14,6 +15,7 @@ import { UtilitiesService } from '../../helpers/utilities/utilities.service'; AuthService, ConfigService, UtilitiesService, + TokenRefresherService, ], controllers: [ServiceRequestsController], imports: [HelpersModule, AuthModule, HttpModule], diff --git a/src/controllers/service-requests/service-requests.service.spec.ts b/src/controllers/service-requests/service-requests.service.spec.ts index b0419fe..3061e5b 100644 --- a/src/controllers/service-requests/service-requests.service.spec.ts +++ b/src/controllers/service-requests/service-requests.service.spec.ts @@ -12,7 +12,8 @@ import { import { RecordType } from '../../common/constants/enumerations'; import { IdPathParams } from '../../dto/id-path-params.dto'; import { SinceQueryParams } from '../../dto/since-query-params.dto'; -import { TokenRefresherService } from '../../helpers/token-refresher/token-refresher.service'; +import { TokenRefresherService } from '../../external-api/token-refresher/token-refresher.service'; +import { RequestPreparerService } from '../../external-api/request-preparer/request-preparer.service'; describe('ServiceRequestsService', () => { let service: ServiceRequestsService; @@ -26,6 +27,7 @@ describe('ServiceRequestsService', () => { SupportNetworkService, UtilitiesService, TokenRefresherService, + RequestPreparerService, { provide: HttpService, useValue: { get: jest.fn() } }, { provide: CACHE_MANAGER, diff --git a/src/entities/in-person-visits.entity.ts b/src/entities/in-person-visits.entity.ts new file mode 100644 index 0000000..432d230 --- /dev/null +++ b/src/entities/in-person-visits.entity.ts @@ -0,0 +1,98 @@ +import { ApiProperty, ApiSchema } from '@nestjs/swagger'; +import { Exclude, Expose, Type } from 'class-transformer'; + +/* + * Examples + */ +export const InPersonVisitsSingleResponseCaseExample = { + Name: 'Id-here', + 'Visit Description': 'description', + Id: 'Id-here', + Type: 'In Person Child Youth', + 'Date of visit': '11/09/2024 09:33:23', + 'Visit Details Value': 'comment', + 'Parent Id': 'Entity-Id-here', + 'Login Name': 'Idir-here', +}; + +export const InPersonVisitsListResponseCaseExample = { + items: [ + { + ...InPersonVisitsSingleResponseCaseExample, + 'Date of visit': '11/09/2024 10:36:25', + }, + InPersonVisitsSingleResponseCaseExample, + ], +}; + +/* + * Model definitions + */ +@Exclude() +@ApiSchema({ name: 'InPersonVisitsSingleResponse' }) +export class InPersonVisitsEntity { + @ApiProperty({ + example: InPersonVisitsSingleResponseCaseExample['Name'], + }) + @Expose() + Name: string; + + @ApiProperty({ + example: InPersonVisitsSingleResponseCaseExample['Visit Description'], + }) + @Expose() + 'Visit Description': string; + + @ApiProperty({ + example: InPersonVisitsSingleResponseCaseExample['Id'], + }) + @Expose() + Id: string; + + @ApiProperty({ + example: InPersonVisitsSingleResponseCaseExample['Type'], + }) + @Expose() + Type: string; + + @ApiProperty({ + example: InPersonVisitsSingleResponseCaseExample['Date of visit'], + }) + @Expose() + 'Date of visit': string; + + @ApiProperty({ + example: InPersonVisitsSingleResponseCaseExample['Visit Details Value'], + }) + @Expose() + 'Visit Details Value': string; + + @ApiProperty({ + example: InPersonVisitsSingleResponseCaseExample['Parent Id'], + }) + @Expose() + 'Parent Id': string; + + @ApiProperty({ + example: InPersonVisitsSingleResponseCaseExample['Login Name'], + }) + @Expose() + 'Login Name': string; + + constructor(partial: Partial) { + Object.assign(this, partial); + } +} + +@Exclude() +@ApiSchema({ name: 'InPersonVisitsListResponse' }) +export class NestedInPersonVisitsEntity { + @Expose() + @ApiProperty({ type: InPersonVisitsEntity, isArray: true }) + @Type(() => InPersonVisitsEntity) + items: Array; + + constructor(object) { + Object.assign(this, object); + } +} diff --git a/src/external-api/external-api.module.ts b/src/external-api/external-api.module.ts new file mode 100644 index 0000000..c85d4dd --- /dev/null +++ b/src/external-api/external-api.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { RequestPreparerService } from './request-preparer/request-preparer.service'; +import { TokenRefresherService } from './token-refresher/token-refresher.service'; +import { UtilitiesModule } from '../helpers/utilities/utilities.module'; +import { HttpModule } from '@nestjs/axios'; +import { TokenRefresherModule } from './token-refresher/token-refresher.module'; +import { ConfigModule } from '@nestjs/config'; + +@Module({ + imports: [UtilitiesModule, HttpModule, TokenRefresherModule, ConfigModule], + providers: [RequestPreparerService, TokenRefresherService], + exports: [RequestPreparerService, TokenRefresherService], +}) +export class ExternalApiModule {} diff --git a/src/external-api/request-preparer/request-preparer.module.ts b/src/external-api/request-preparer/request-preparer.module.ts new file mode 100644 index 0000000..6e27f5f --- /dev/null +++ b/src/external-api/request-preparer/request-preparer.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { RequestPreparerService } from './request-preparer.service'; +import { UtilitiesService } from '../../helpers/utilities/utilities.service'; +import { UtilitiesModule } from '../../helpers/utilities/utilities.module'; +import { TokenRefresherService } from '../token-refresher/token-refresher.service'; +import { HttpModule } from '@nestjs/axios'; +import { ConfigService } from '@nestjs/config'; + +@Module({ + imports: [UtilitiesModule, HttpModule], + providers: [ + RequestPreparerService, + UtilitiesService, + TokenRefresherService, + ConfigService, + ], + exports: [RequestPreparerService], +}) +export class RequestPreparerModule {} diff --git a/src/external-api/request-preparer/request-preparer.service.spec.ts b/src/external-api/request-preparer/request-preparer.service.spec.ts new file mode 100644 index 0000000..bb57f97 --- /dev/null +++ b/src/external-api/request-preparer/request-preparer.service.spec.ts @@ -0,0 +1,177 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { RequestPreparerService } from './request-preparer.service'; +import { UtilitiesService } from '../../helpers/utilities/utilities.service'; +import { + CHILD_LINKS, + CONTENT_TYPE, + PAGINATION, + VIEW_MODE, +} from '../../common/constants/parameter-constants'; +import { TokenRefresherService } from '../token-refresher/token-refresher.service'; +import { HttpService } from '@nestjs/axios'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { of } from 'rxjs'; +import { + AxiosError, + AxiosResponse, + InternalAxiosRequestConfig, + RawAxiosRequestHeaders, +} from 'axios'; + +describe('RequestPreparerService', () => { + let service: RequestPreparerService; + let httpService: HttpService; + let tokenRefresherService: TokenRefresherService; + const validId = '1234ab'; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ConfigModule.forRoot()], + providers: [ + RequestPreparerService, + UtilitiesService, + TokenRefresherService, + { + provide: HttpService, + useValue: { get: () => jest.fn(), post: () => jest.fn() }, + }, + { + provide: CACHE_MANAGER, + useValue: { + set: () => jest.fn(), + get: () => 'Bearer token', + }, + }, + ConfigService, + ], + }).compile(); + + service = module.get(RequestPreparerService); + httpService = module.get(HttpService); + tokenRefresherService = module.get( + TokenRefresherService, + ); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('prepareHeadersAndParams tests', () => { + it.each([ + ['spec', undefined], + ['spec', { id: validId }, 'workspace'], + ])( + 'correctly prepares headers and params with no date parameter', + (baseSearchSpec, workspace) => { + const [headers, params] = service.prepareHeadersAndParams( + baseSearchSpec, + workspace, + '', + ); + expect(headers).toEqual({ Accept: CONTENT_TYPE }); + expect(params).toEqual({ + ViewMode: VIEW_MODE, + ChildLinks: CHILD_LINKS, + searchspec: baseSearchSpec + ')', + workspace: workspace, + pagination: PAGINATION, + }); + }, + ); + + it.each([['spec', { since: '2024-02-20' }, '02/20/2024 00:00:00']])( + 'correctly prepares headers and params with a date parameter', + (baseSearchSpec, since, expectedDate) => { + const [headers, params] = service.prepareHeadersAndParams( + baseSearchSpec, + undefined, + 'Updated', + since, + ); + expect(headers).toEqual({ Accept: CONTENT_TYPE }); + expect(params).toEqual({ + ViewMode: VIEW_MODE, + ChildLinks: CHILD_LINKS, + searchspec: `${baseSearchSpec} AND [Updated] > "${expectedDate}")`, + pagination: PAGINATION, + }); + }, + ); + }); + + describe('sendGetRequest tests', () => { + it('provides a response on sucessful http service call', async () => { + const spy = jest.spyOn(httpService, 'get').mockReturnValueOnce( + of({ + data: {}, + headers: {}, + status: 200, + statusText: 'OK', + } as AxiosResponse), + ); + const result = await service.sendGetRequest('url', {}); + expect(spy).toHaveBeenCalledTimes(1); + expect(result.data).toEqual({}); + }); + + it.each([[500]])( + `Should return HttpException with matching status on axios error`, + async (status) => { + const spy = jest.spyOn(httpService, 'get').mockImplementation(() => { + throw new AxiosError( + 'Axios Error', + status.toString(), + {} as InternalAxiosRequestConfig, + {}, + { + data: {}, + status: status, + statusText: '', + headers: {} as RawAxiosRequestHeaders, + config: {} as InternalAxiosRequestConfig, + }, + ); + }); + + await expect( + service.sendGetRequest('url', {}, {}), + ).rejects.toHaveProperty('status', status); + expect(spy).toHaveBeenCalledTimes(1); + }, + ); + + it('Should return HttpException with status 204 on 404 from upstream', async () => { + const spy = jest.spyOn(httpService, 'get').mockImplementation(() => { + throw new AxiosError( + 'Axios Error', + '404', + {} as InternalAxiosRequestConfig, + {}, + { + data: {}, + status: 404, + statusText: '', + headers: {} as RawAxiosRequestHeaders, + config: {} as InternalAxiosRequestConfig, + }, + ); + }); + await expect( + service.sendGetRequest('url', {}, {}), + ).rejects.toHaveProperty('status', 204); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('Should return HttpException with status 500 on bearer token undefined', async () => { + const spy = jest + .spyOn(tokenRefresherService, 'refreshUpstreamBearerToken') + .mockResolvedValueOnce(undefined); + await expect( + service.sendGetRequest('url', {}, {}), + ).rejects.toHaveProperty('status', 500); + expect(spy).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/external-api/request-preparer/request-preparer.service.ts b/src/external-api/request-preparer/request-preparer.service.ts new file mode 100644 index 0000000..8200f41 --- /dev/null +++ b/src/external-api/request-preparer/request-preparer.service.ts @@ -0,0 +1,102 @@ +import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common'; +import { + VIEW_MODE, + CHILD_LINKS, + CONTENT_TYPE, + PAGINATION, +} from '../../common/constants/parameter-constants'; +import { SinceQueryParams } from '../../dto/since-query-params.dto'; +import { UtilitiesService } from '../../helpers/utilities/utilities.service'; +import { TokenRefresherService } from '../token-refresher/token-refresher.service'; +import { HttpService } from '@nestjs/axios'; +import { firstValueFrom } from 'rxjs'; +import { AxiosError } from 'axios'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class RequestPreparerService { + buildNumber: string; + private readonly logger = new Logger(RequestPreparerService.name); + constructor( + private readonly utilitiesService: UtilitiesService, + private readonly tokenRefresherService: TokenRefresherService, + private readonly httpService: HttpService, + private readonly configService: ConfigService, + ) { + this.buildNumber = this.configService.get('buildInfo.buildNumber'); + } + + prepareHeadersAndParams( + baseSearchSpec: string, + workspace: string | undefined, + sinceFieldName: string | undefined, + since?: SinceQueryParams, + ) { + let searchSpec = baseSearchSpec; + let formattedDate: string | undefined; + if ( + sinceFieldName === undefined || + since === undefined || + typeof since.since !== 'string' || + (formattedDate = this.utilitiesService.convertISODateToUpstreamFormat( + since.since, + )) === undefined + ) { + searchSpec = searchSpec + `)`; + } else { + searchSpec = + searchSpec + ` AND [${sinceFieldName}] > "${formattedDate}")`; + } + const params = { + ViewMode: VIEW_MODE, + ChildLinks: CHILD_LINKS, + searchspec: searchSpec, + pagination: PAGINATION, + }; + if (typeof workspace !== 'undefined') { + params['workspace'] = workspace; + } + const headers = { + Accept: CONTENT_TYPE, + }; + return [headers, params]; + } + + async sendGetRequest(url: string, headers, params?) { + let response; + try { + const token = + await this.tokenRefresherService.refreshUpstreamBearerToken(); + if (token === undefined) { + throw new Error('Upstream auth failed'); + } + headers['Authorization'] = token; + response = await firstValueFrom( + this.httpService.get(url, { params, headers }), + ); + } catch (error) { + if (error instanceof AxiosError) { + this.logger.error({ + msg: error.message, + stack: error.stack, + cause: error.cause, + buildNumber: this.buildNumber, + }); + if (error.status === 404) { + throw new HttpException({}, HttpStatus.NO_CONTENT, { cause: error }); + } + } else { + this.logger.error({ error, buildNumber: this.buildNumber }); + } + throw new HttpException( + { + status: HttpStatus.INTERNAL_SERVER_ERROR, + error: error.message, + }, + HttpStatus.INTERNAL_SERVER_ERROR, + { cause: error }, + ); + } + return response; + } +} diff --git a/src/helpers/token-refresher/token-refresher-constants.ts b/src/external-api/token-refresher/token-refresher-constants.ts similarity index 100% rename from src/helpers/token-refresher/token-refresher-constants.ts rename to src/external-api/token-refresher/token-refresher-constants.ts diff --git a/src/helpers/token-refresher/token-refresher.module.ts b/src/external-api/token-refresher/token-refresher.module.ts similarity index 100% rename from src/helpers/token-refresher/token-refresher.module.ts rename to src/external-api/token-refresher/token-refresher.module.ts diff --git a/src/helpers/token-refresher/token-refresher.service.spec.ts b/src/external-api/token-refresher/token-refresher.service.spec.ts similarity index 100% rename from src/helpers/token-refresher/token-refresher.service.spec.ts rename to src/external-api/token-refresher/token-refresher.service.spec.ts diff --git a/src/helpers/token-refresher/token-refresher.service.ts b/src/external-api/token-refresher/token-refresher.service.ts similarity index 91% rename from src/helpers/token-refresher/token-refresher.service.ts rename to src/external-api/token-refresher/token-refresher.service.ts index 925007b..332a79c 100644 --- a/src/helpers/token-refresher/token-refresher.service.ts +++ b/src/external-api/token-refresher/token-refresher.service.ts @@ -16,6 +16,7 @@ export class TokenRefresherService { accessTokenUrl: string; clientId: string; clientSecret: string; + buildNumber: string; private readonly logger = new Logger(TokenRefresherService.name); constructor( @@ -26,6 +27,7 @@ export class TokenRefresherService { this.accessTokenUrl = this.configService.get('oauth.accessTokenUrl'); this.clientId = this.configService.get('oauth.clientId'); this.clientSecret = this.configService.get('oauth.clientSecret'); + this.buildNumber = this.configService.get('buildInfo.buildNumber'); } async refreshUpstreamBearerToken(): Promise { @@ -78,7 +80,12 @@ export class TokenRefresherService { return [bearer_token, expiryMs]; } catch (error) { if (error instanceof AxiosError) { - this.logger.error(error.message, error.stack, error.cause); + this.logger.error({ + msg: error.message, + stack: error.stack, + cause: error.cause, + buildNumber: this.buildNumber, + }); } return [undefined, undefined]; } diff --git a/src/helpers/helpers.module.ts b/src/helpers/helpers.module.ts index 82a8f58..74665be 100644 --- a/src/helpers/helpers.module.ts +++ b/src/helpers/helpers.module.ts @@ -5,8 +5,12 @@ import { HttpModule } from '@nestjs/axios'; import { ConfigModule } from '@nestjs/config'; import { UtilitiesModule } from './utilities/utilities.module'; import { UtilitiesService } from './utilities/utilities.service'; -import { TokenRefresherModule } from './token-refresher/token-refresher.module'; -import { TokenRefresherService } from './token-refresher/token-refresher.service'; +import { TokenRefresherModule } from '../external-api/token-refresher/token-refresher.module'; +import { TokenRefresherService } from '../external-api/token-refresher/token-refresher.service'; +import { InPersonVisitsModule } from './in-person-visits/in-person-visits.module'; +import { InPersonVisitsService } from './in-person-visits/in-person-visits.service'; +import { RequestPreparerModule } from '../external-api/request-preparer/request-preparer.module'; +import { RequestPreparerService } from '../external-api/request-preparer/request-preparer.service'; @Module({ imports: [ @@ -15,8 +19,16 @@ import { TokenRefresherService } from './token-refresher/token-refresher.service ConfigModule, UtilitiesModule, TokenRefresherModule, + InPersonVisitsModule, + RequestPreparerModule, ], - providers: [SupportNetworkService, UtilitiesService, TokenRefresherService], - exports: [SupportNetworkService, UtilitiesService, TokenRefresherService], + providers: [ + SupportNetworkService, + UtilitiesService, + TokenRefresherService, + InPersonVisitsService, + RequestPreparerService, + ], + exports: [SupportNetworkService, UtilitiesService, InPersonVisitsService], }) export class HelpersModule {} diff --git a/src/helpers/in-person-visits/in-person-visits.module.ts b/src/helpers/in-person-visits/in-person-visits.module.ts new file mode 100644 index 0000000..05e4838 --- /dev/null +++ b/src/helpers/in-person-visits/in-person-visits.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { InPersonVisitsService } from './in-person-visits.service'; +import { ConfigService } from '@nestjs/config'; +import { RequestPreparerService } from '../../external-api/request-preparer/request-preparer.service'; +import { UtilitiesService } from '../utilities/utilities.service'; +import { TokenRefresherService } from '../../external-api/token-refresher/token-refresher.service'; +import { HttpModule } from '@nestjs/axios'; + +@Module({ + imports: [HttpModule], + providers: [ + InPersonVisitsService, + ConfigService, + RequestPreparerService, + UtilitiesService, + TokenRefresherService, + ], + exports: [InPersonVisitsService], +}) +export class InPersonVisitsModule {} 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 new file mode 100644 index 0000000..6b70b9e --- /dev/null +++ b/src/helpers/in-person-visits/in-person-visits.service.spec.ts @@ -0,0 +1,119 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { InPersonVisitsService } from './in-person-visits.service'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { RequestPreparerService } from '../../external-api/request-preparer/request-preparer.service'; +import { UtilitiesService } from '../utilities/utilities.service'; +import { TokenRefresherService } from '../../external-api/token-refresher/token-refresher.service'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import { HttpService } from '@nestjs/axios'; +import { AxiosResponse } from 'axios'; +import { IdPathParams } from '../../dto/id-path-params.dto'; +import { RecordType } from '../../common/constants/enumerations'; +import { SinceQueryParams } from '../../dto/since-query-params.dto'; +import { + InPersonVisitsEntity, + InPersonVisitsListResponseCaseExample, + InPersonVisitsSingleResponseCaseExample, + NestedInPersonVisitsEntity, +} from '../../entities/in-person-visits.entity'; + +describe('InPersonVisitsService', () => { + let service: InPersonVisitsService; + let requestPreparerService: RequestPreparerService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ConfigModule.forRoot()], + providers: [ + InPersonVisitsService, + UtilitiesService, + ConfigService, + TokenRefresherService, + RequestPreparerService, + { provide: HttpService, useValue: { get: jest.fn() } }, + { + provide: CACHE_MANAGER, + useValue: { + set: () => jest.fn(), + get: () => 'Bearer token', + }, + }, + ], + }).compile(); + + service = module.get(InPersonVisitsService); + requestPreparerService = module.get( + RequestPreparerService, + ); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('getSingleInPersonVisitRecord tests', () => { + it.each([ + [ + InPersonVisitsSingleResponseCaseExample, + RecordType.Case, + { id: 'test' } as IdPathParams, + undefined, + ], + [ + InPersonVisitsListResponseCaseExample.items[0], + RecordType.Case, + { id: 'test' } as IdPathParams, + { since: '2024-12-24' } as SinceQueryParams, + ], + ])( + 'should return single values given good input', + async (data, recordType, idPathParams, sinceQueryParams) => { + const spy = jest + .spyOn(requestPreparerService, 'sendGetRequest') + .mockResolvedValueOnce({ + data: data, + headers: {}, + status: 200, + statusText: 'OK', + } as AxiosResponse); + + const result = await service.getSingleInPersonVisitRecord( + recordType, + idPathParams, + sinceQueryParams, + ); + expect(spy).toHaveBeenCalledTimes(1); + expect(result).toEqual(new InPersonVisitsEntity(data)); + }, + ); + + it.each([ + [ + InPersonVisitsListResponseCaseExample, + RecordType.Case, + { id: 'test' } as IdPathParams, + undefined, + ], + ])( + 'should return list values given good input', + async (data, recordType, idPathParams, sinceQueryParams) => { + const spy = jest + .spyOn(requestPreparerService, 'sendGetRequest') + .mockResolvedValueOnce({ + data: data, + headers: {}, + status: 200, + statusText: 'OK', + } as AxiosResponse); + + const result = await service.getSingleInPersonVisitRecord( + recordType, + idPathParams, + sinceQueryParams, + ); + expect(spy).toHaveBeenCalledTimes(1); + expect(result).toEqual(new NestedInPersonVisitsEntity(data)); + }, + ); + }); +}); diff --git a/src/helpers/in-person-visits/in-person-visits.service.ts b/src/helpers/in-person-visits/in-person-visits.service.ts new file mode 100644 index 0000000..1266124 --- /dev/null +++ b/src/helpers/in-person-visits/in-person-visits.service.ts @@ -0,0 +1,58 @@ +import { Injectable } from '@nestjs/common'; +import { RecordType } from '../../common/constants/enumerations'; +import { IdPathParams } from '../../dto/id-path-params.dto'; +import { SinceQueryParams } from '../../dto/since-query-params.dto'; +import { ConfigService } from '@nestjs/config'; +import { RequestPreparerService } from '../../external-api/request-preparer/request-preparer.service'; +import { + NestedInPersonVisitsEntity, + InPersonVisitsEntity, +} from '../../entities/in-person-visits.entity'; +import { + baseUrlEnvVarName, + inPersonVisitsEndpointEnvVarName, +} from '../../common/constants/upstream-constants'; + +@Injectable() +export class InPersonVisitsService { + url: string; + workspace: string | undefined; + sinceFieldName: string | undefined; + constructor( + private readonly configService: ConfigService, + private readonly requestPreparerService: RequestPreparerService, + ) { + this.url = ( + this.configService.get(baseUrlEnvVarName) + + this.configService.get(inPersonVisitsEndpointEnvVarName) + ).replace(/\s/g, '%20'); + this.workspace = this.configService.get('workspaces.inPersonVisits'); + this.sinceFieldName = this.configService.get( + 'sinceFieldName.supportNetwork', + ); + } + + async getSingleInPersonVisitRecord( + _type: RecordType, + id: IdPathParams, + since?: SinceQueryParams, + ): Promise { + const baseSearchSpec = `([Parent Id]="${id.id}"`; + const [headers, params] = + this.requestPreparerService.prepareHeadersAndParams( + baseSearchSpec, + this.workspace, + this.sinceFieldName, + since, + ); + const response = await this.requestPreparerService.sendGetRequest( + this.url, + headers, + params, + ); + if ((response.data as object).hasOwnProperty('items')) { + return new NestedInPersonVisitsEntity(response.data); + } + return new InPersonVisitsEntity(response.data); + } +} diff --git a/src/helpers/support-network/support-network.module.ts b/src/helpers/support-network/support-network.module.ts index 875d2ab..f0343b6 100644 --- a/src/helpers/support-network/support-network.module.ts +++ b/src/helpers/support-network/support-network.module.ts @@ -1,15 +1,21 @@ import { Module } from '@nestjs/common'; import { HttpModule } from '@nestjs/axios'; import { SupportNetworkService } from './support-network.service'; -import { ConfigModule } from '@nestjs/config'; -import { UtilitiesModule } from '../../helpers/utilities/utilities.module'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { RequestPreparerService } from '../../external-api/request-preparer/request-preparer.service'; +import { RequestPreparerModule } from '../../external-api/request-preparer/request-preparer.module'; import { UtilitiesService } from '../utilities/utilities.service'; -import { TokenRefresherModule } from '../token-refresher/token-refresher.module'; -import { TokenRefresherService } from '../token-refresher/token-refresher.service'; +import { TokenRefresherService } from '../../external-api/token-refresher/token-refresher.service'; @Module({ - imports: [HttpModule, ConfigModule, UtilitiesModule, TokenRefresherModule], - providers: [SupportNetworkService, UtilitiesService, TokenRefresherService], + imports: [HttpModule, ConfigModule, RequestPreparerModule], + providers: [ + SupportNetworkService, + RequestPreparerService, + UtilitiesService, + TokenRefresherService, + ConfigService, + ], exports: [SupportNetworkService], }) export class SupportNetworkModule {} diff --git a/src/helpers/support-network/support-network.service.spec.ts b/src/helpers/support-network/support-network.service.spec.ts index d014d1a..7520387 100644 --- a/src/helpers/support-network/support-network.service.spec.ts +++ b/src/helpers/support-network/support-network.service.spec.ts @@ -2,13 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { HttpService } from '@nestjs/axios'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { ConfigModule, ConfigService } from '@nestjs/config'; -import { - AxiosError, - AxiosResponse, - InternalAxiosRequestConfig, - RawAxiosRequestHeaders, -} from 'axios'; -import { of } from 'rxjs'; +import { AxiosResponse } from 'axios'; import { UtilitiesService } from '../utilities/utilities.service'; import { RecordType } from '../../common/constants/enumerations'; import { SupportNetworkService } from './support-network.service'; @@ -21,13 +15,12 @@ import { } from '../../entities/support-network.entity'; import { IdPathParams } from '../../dto/id-path-params.dto'; import { SinceQueryParams } from '../../dto/since-query-params.dto'; -import { TokenRefresherService } from '../token-refresher/token-refresher.service'; +import { TokenRefresherService } from '../../external-api/token-refresher/token-refresher.service'; +import { RequestPreparerService } from '../../external-api/request-preparer/request-preparer.service'; describe('SupportNetworkService', () => { let service: SupportNetworkService; - let configService: ConfigService; - let httpService: HttpService; - let tokenRefresherService: TokenRefresherService; + let requestPreparerService: RequestPreparerService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -37,6 +30,7 @@ describe('SupportNetworkService', () => { UtilitiesService, ConfigService, TokenRefresherService, + RequestPreparerService, { provide: HttpService, useValue: { get: jest.fn() } }, { provide: CACHE_MANAGER, @@ -49,10 +43,8 @@ describe('SupportNetworkService', () => { }).compile(); service = module.get(SupportNetworkService); - configService = module.get(ConfigService); - httpService = module.get(HttpService); - tokenRefresherService = module.get( - TokenRefresherService, + requestPreparerService = module.get( + RequestPreparerService, ); }); @@ -77,22 +69,14 @@ describe('SupportNetworkService', () => { ])( 'should return single values given good input', async (data, recordType, idPathParams, sinceQueryParams) => { - const spy = jest.spyOn(httpService, 'get').mockReturnValueOnce( - of({ + const spy = jest + .spyOn(requestPreparerService, 'sendGetRequest') + .mockResolvedValueOnce({ data: data, headers: {}, - config: { - url: - configService.get('UPSTREAM_BASE_URL') + - configService - .get('SUPPORT_NETWORK_ENDPOINT') - .replace(/\s/g, '%20'), - headers: {} as RawAxiosRequestHeaders, - }, status: 200, statusText: 'OK', - } as AxiosResponse), - ); + } as AxiosResponse); const result = await service.getSingleSupportNetworkInformationRecord( recordType, @@ -114,22 +98,14 @@ describe('SupportNetworkService', () => { ])( 'should return list values given good input', async (data, recordType, idPathParams, sinceQueryParams) => { - const spy = jest.spyOn(httpService, 'get').mockReturnValueOnce( - of({ + const spy = jest + .spyOn(requestPreparerService, 'sendGetRequest') + .mockResolvedValueOnce({ data: data, headers: {}, - config: { - url: - configService.get('UPSTREAM_BASE_URL') + - configService - .get('SUPPORT_NETWORK_ENDPOINT') - .replace(/\s/g, '%20'), - headers: {} as RawAxiosRequestHeaders, - }, status: 200, statusText: 'OK', - } as AxiosResponse), - ); + } as AxiosResponse); const result = await service.getSingleSupportNetworkInformationRecord( recordType, @@ -140,45 +116,5 @@ describe('SupportNetworkService', () => { expect(result).toEqual(new NestedSupportNetworkEntity(data)); }, ); - - it.each([[404], [500]])( - `Should return HttpException with matching status on axios error`, - async (status) => { - const spy = jest.spyOn(httpService, 'get').mockImplementation(() => { - throw new AxiosError( - 'Axios Error', - status.toString(), - {} as InternalAxiosRequestConfig, - {}, - { - data: {}, - status: status, - statusText: '', - headers: {} as RawAxiosRequestHeaders, - config: {} as InternalAxiosRequestConfig, - }, - ); - }); - - await expect( - service.getSingleSupportNetworkInformationRecord(RecordType.Case, { - id: 'doesNotExist', - } as IdPathParams), - ).rejects.toHaveProperty('status', status); - expect(spy).toHaveBeenCalledTimes(1); - }, - ); - - it('Should return HttpException with status 500 on bearer token undefined', async () => { - const spy = jest - .spyOn(tokenRefresherService, 'refreshUpstreamBearerToken') - .mockResolvedValueOnce(undefined); - await expect( - service.getSingleSupportNetworkInformationRecord(RecordType.Case, { - id: 'doesNotExist', - } as IdPathParams), - ).rejects.toHaveProperty('status', 500); - expect(spy).toHaveBeenCalledTimes(1); - }); }); }); diff --git a/src/helpers/support-network/support-network.service.ts b/src/helpers/support-network/support-network.service.ts index aa798e7..71eb45c 100644 --- a/src/helpers/support-network/support-network.service.ts +++ b/src/helpers/support-network/support-network.service.ts @@ -1,40 +1,38 @@ -import { HttpService } from '@nestjs/axios'; -import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { firstValueFrom } from 'rxjs'; import { RecordEntityMap, RecordType, } from '../../common/constants/enumerations'; -import { - CHILD_LINKS, - VIEW_MODE, - CONTENT_TYPE, -} from '../../common/constants/parameter-constants'; -import { UtilitiesService } from '../utilities/utilities.service'; import { SupportNetworkEntity, NestedSupportNetworkEntity, } from '../../entities/support-network.entity'; -import { AxiosError } from 'axios'; import { IdPathParams } from '../../dto/id-path-params.dto'; import { SinceQueryParams } from '../../dto/since-query-params.dto'; -import { TokenRefresherService } from '../token-refresher/token-refresher.service'; +import { RequestPreparerService } from '../../external-api/request-preparer/request-preparer.service'; +import { + baseUrlEnvVarName, + supportNetworkEndpointEnvVarName, +} from '../../common/constants/upstream-constants'; @Injectable() export class SupportNetworkService { url: string; - private readonly logger = new Logger(SupportNetworkService.name); + workspace: string | undefined; + sinceFieldName: string | undefined; constructor( - private readonly httpService: HttpService, private readonly configService: ConfigService, - private readonly utilitiesService: UtilitiesService, - private readonly tokenRefresherService: TokenRefresherService, + private readonly requestPreparerService: RequestPreparerService, ) { this.url = ( - this.configService.get('UPSTREAM_BASE_URL') + - this.configService.get('SUPPORT_NETWORK_ENDPOINT') + this.configService.get(baseUrlEnvVarName) + + this.configService.get(supportNetworkEndpointEnvVarName) ).replace(/\s/g, '%20'); + this.workspace = this.configService.get('workspaces.supportNetwork'); + this.sinceFieldName = this.configService.get( + 'sinceFieldName.supportNetwork', + ); } async getSingleSupportNetworkInformationRecord( @@ -42,63 +40,19 @@ export class SupportNetworkService { id: IdPathParams, since?: SinceQueryParams, ) { - let searchSpec = `([Entity Id]="${id.id}" AND [Entity Name]="${RecordEntityMap[type]}"`; - let formattedDate: string | undefined; - if ( - since === undefined || - typeof since.since !== 'string' || - (formattedDate = this.utilitiesService.convertISODateToUpstreamFormat( - since.since, - )) === undefined - ) { - searchSpec = searchSpec + `)`; - } else { - searchSpec = searchSpec + ` AND [Updated] > "${formattedDate}")`; - } - const params = { - ViewMode: VIEW_MODE, - ChildLinks: CHILD_LINKS, - searchspec: searchSpec, - }; - const headers = { - Accept: CONTENT_TYPE, - }; - let response; - try { - const token = - await this.tokenRefresherService.refreshUpstreamBearerToken(); - if (token === undefined) { - throw new Error('Upstream auth failed'); - } - headers['Authorization'] = token; - response = await firstValueFrom( - this.httpService.get(this.url, { params, headers }), + const baseSearchSpec = `([Entity Id]="${id.id}" AND [Entity Name]="${RecordEntityMap[type]}"`; + const [headers, params] = + this.requestPreparerService.prepareHeadersAndParams( + baseSearchSpec, + this.workspace, + this.sinceFieldName, + since, ); - } catch (error) { - if (error instanceof AxiosError) { - this.logger.error(error.message, error.stack, error.cause); - if (error.status === 404) { - throw new HttpException( - { - status: HttpStatus.NOT_FOUND, - error: 'There is no data for the requested resource', - }, - HttpStatus.NOT_FOUND, - { cause: error }, - ); - } - } else { - this.logger.error(error); - } - throw new HttpException( - { - status: HttpStatus.INTERNAL_SERVER_ERROR, - error: error.message, - }, - HttpStatus.INTERNAL_SERVER_ERROR, - { cause: error }, - ); - } + const response = await this.requestPreparerService.sendGetRequest( + this.url, + headers, + params, + ); if ((response.data as object).hasOwnProperty('items')) { return new NestedSupportNetworkEntity(response.data); }