diff --git a/api/src/app/app.module.ts b/api/src/app/app.module.ts index b03a287f2..9d772a5e4 100644 --- a/api/src/app/app.module.ts +++ b/api/src/app/app.module.ts @@ -1,23 +1,24 @@ import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; import { ScheduleModule } from '@nestjs/schedule'; +import { TypeOrmModule } from '@nestjs/typeorm'; // Core Modules import { AttachmentModule } from './modules/attachment/attachment.module'; -import { InteractionModule } from './modules/interaction/interaction.module'; import { DistrictModule } from './modules/district/district.module'; import { ForestClientModule } from './modules/forest-client/forest-client.module'; -import { ProjectModule } from './modules/project/project.module'; +import { InteractionModule } from './modules/interaction/interaction.module'; import { ProjectAuthModule } from './modules/project/project-auth.module'; +import { ProjectModule } from './modules/project/project.module'; import { PublicCommentModule } from './modules/public-comment/public-comment.module'; -import { SubmissionModule } from './modules/submission/submission.module'; import { SpatialFeatureModule } from './modules/spatial-feature/spatial-feature.module'; +import { SubmissionModule } from './modules/submission/submission.module'; // Other Modules +import { ExternalModule } from '@src/app/modules/external/external.module'; import { LoggerModule } from 'nestjs-pino'; +import { SecurityModule } from '../core/security/security.module'; import { AppConfigModule } from './modules/app-config/app-config.module'; import { AppConfigService } from './modules/app-config/app-config.provider'; -import { SecurityModule } from '../core/security/security.module' function getLogLevel():string { return process.env.LOG_LEVEL || 'info'; @@ -66,6 +67,7 @@ function getLogLevel():string { PublicCommentModule, SubmissionModule, SpatialFeatureModule, + ExternalModule ], }) diff --git a/api/src/app/modules/external/external.module.ts b/api/src/app/modules/external/external.module.ts new file mode 100644 index 000000000..ebc2ac5f8 --- /dev/null +++ b/api/src/app/modules/external/external.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { ProjectsByFspExternalModule } from '@src/app/modules/external/projects-by-fsp/projects-by-fsp.module'; + +/** + * "External" module provides external facing APIs for other system to + * interface with FOM. + */ +@Module({ + imports: [ + ProjectsByFspExternalModule + ] +}) +export class ExternalModule {} diff --git a/api/src/app/modules/external/projects-by-fsp/projects-by-fsp.controller.ts b/api/src/app/modules/external/projects-by-fsp/projects-by-fsp.controller.ts new file mode 100644 index 000000000..ba4726dc1 --- /dev/null +++ b/api/src/app/modules/external/projects-by-fsp/projects-by-fsp.controller.ts @@ -0,0 +1,39 @@ +import { ArgumentMetadata, BadRequestException, Controller, Get, HttpStatus, Injectable, PipeTransform, Query } from '@nestjs/common'; +import { ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { ProjectByFspResponse as ProjectsByFspResponse } from '@src/app/modules/external/projects-by-fsp/projects-by-fsp.dto'; +import { ProjectsByFspService } from '@src/app/modules/external/projects-by-fsp/projects-by-fsp.service'; +import { PinoLogger } from 'nestjs-pino'; + +// Custom pipe transformer for controller parameter conversion. +@Injectable() +export class PositiveIntPipe implements PipeTransform { + transform(value: any, metadata: ArgumentMetadata) { + + if(/^\d+$/.test(value)) { + const intValue = parseInt(value); + if ( intValue > 0) { + return value; + } + } + + throw new BadRequestException('Value must be positive integer.'); + } +} + +@ApiTags("external") +@Controller("external") +export class ProjectsByFspController { + constructor( + private readonly service: ProjectsByFspService, + private readonly _logger: PinoLogger) { + } + + @Get("fom-by-fsp") + @ApiQuery({ name: 'fspId', required: true}) + @ApiResponse({ status: HttpStatus.OK, type: [ProjectsByFspResponse] }) + async findByFsp( + @Query('fspId', PositiveIntPipe) fspId: number + ): Promise { + return this.service.findByFspId(fspId); + } +} \ No newline at end of file diff --git a/api/src/app/modules/external/projects-by-fsp/projects-by-fsp.dto.ts b/api/src/app/modules/external/projects-by-fsp/projects-by-fsp.dto.ts new file mode 100644 index 000000000..cd3074fb1 --- /dev/null +++ b/api/src/app/modules/external/projects-by-fsp/projects-by-fsp.dto.ts @@ -0,0 +1,17 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { ForestClientResponse } from "@src/app/modules/forest-client/forest-client.dto"; + +export class ProjectByFspResponse { + + @ApiProperty() + fomId: number; + + @ApiProperty() + name: string; + + @ApiProperty() + fspId: number; + + @ApiPropertyOptional() + forestClient: ForestClientResponse; + } diff --git a/api/src/app/modules/external/projects-by-fsp/projects-by-fsp.module.ts b/api/src/app/modules/external/projects-by-fsp/projects-by-fsp.module.ts new file mode 100644 index 000000000..c1fee1c11 --- /dev/null +++ b/api/src/app/modules/external/projects-by-fsp/projects-by-fsp.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ProjectsByFspController } from '@src/app/modules/external/projects-by-fsp/projects-by-fsp.controller'; +import { ProjectsByFspService } from '@src/app/modules/external/projects-by-fsp/projects-by-fsp.service'; +import { ForestClientModule } from '@src/app/modules/forest-client/forest-client.module'; +import { Project } from '@src/app/modules/project/project.entity'; +import { ProjectModule } from '@src/app/modules/project/project.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Project]), + ProjectModule, + ForestClientModule + ], + controllers: [ProjectsByFspController], + providers: [ProjectsByFspService] +}) +export class ProjectsByFspExternalModule {} diff --git a/api/src/app/modules/external/projects-by-fsp/projects-by-fsp.service.spec.ts b/api/src/app/modules/external/projects-by-fsp/projects-by-fsp.service.spec.ts new file mode 100644 index 000000000..5448ca8d6 --- /dev/null +++ b/api/src/app/modules/external/projects-by-fsp/projects-by-fsp.service.spec.ts @@ -0,0 +1,135 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { getRepositoryToken } from "@nestjs/typeorm"; +import { ProjectByFspResponse } from "@src/app/modules/external/projects-by-fsp/projects-by-fsp.dto"; +import { ProjectsByFspService } from "@src/app/modules/external/projects-by-fsp/projects-by-fsp.service"; +import { ForestClientService } from "@src/app/modules/forest-client/forest-client.service"; +import { Project } from "@src/app/modules/project/project.entity"; +import { RecursivePartial } from "@src/core/utils"; +import { PinoLogger } from "nestjs-pino"; +import { DataSource, Repository } from "typeorm"; + +describe('ProjectsByFspService', () => { + let service: ProjectsByFspService; + let forestClientService: ForestClientService; + let repository: Repository; + let createQueryBuilderMock: any; + + // service and dependencies setup. + beforeAll(async () => { + const moduleRef: TestingModule = await Test.createTestingModule({ + providers: provideDependencyMock() + }).compile(); + + forestClientService = moduleRef.get(ForestClientService); + service = moduleRef.get(ProjectsByFspService); + repository = moduleRef.get(getRepositoryToken(Project)); + + createQueryBuilderMock = { + leftJoinAndSelect: () => createQueryBuilderMock, + where: () => createQueryBuilderMock, + andWhere: () => createQueryBuilderMock, + orWhere: () => createQueryBuilderMock, + addOrderBy: () => createQueryBuilderMock, + limit: () => createQueryBuilderMock, + getMany: () => [], // default empty + }; + + }); + + // This is prerequisite to make sure services/repository are setup for testing. + it('Services/repository for testing should be defined', () => { + expect(service).toBeDefined(); + expect(forestClientService).toBeDefined(); + expect(repository).toBeDefined(); + }); + + describe('findByFspId', () => { + it('returns empty array when no fspId param', async () => { + expect(await service.findByFspId(null)).toEqual([]); + }); + + it('returns empty array when query builder result is empty.', async () => { + // use default createQueryBuilderMock + const createQueryBuilderSpy = jest.spyOn(repository, 'createQueryBuilder').mockImplementation(() => createQueryBuilderMock); + const fspIdWithNoFom = 999; + expect(await service.findByFspId(fspIdWithNoFom)).toEqual([]); + expect(createQueryBuilderSpy).toHaveBeenCalled(); + }); + + it('returns correct result when query builder finds records.', async () => { + const foundProjects = getSimpleProjectResponseData(); + createQueryBuilderMock.getMany = jest.fn().mockReturnValue(foundProjects); + const createQueryBuilderSpy = jest.spyOn(repository, 'createQueryBuilder').mockImplementation(() => createQueryBuilderMock); + const forestClientServiceConvertEntitySpy = jest.spyOn(forestClientService, 'convertEntity'); + const fspIdWithFom = 11; + const result = await service.findByFspId(fspIdWithFom); + expect(result.length).toEqual(getSimpleProjectResponseData().length); + expect(result[0]).toBeInstanceOf(ProjectByFspResponse) + expect(createQueryBuilderSpy).toHaveBeenCalled(); + expect(forestClientServiceConvertEntitySpy).toHaveBeenCalled(); + expect(result[0].fspId).toEqual(foundProjects[0].fspId) + }); + }); + +}); + +export class ProjectRepositoryFake { + public createQueryBuilder(): void { + // This is intentional for empty body. + } +} + +function provideDependencyMock(): Array { + const dependencyMock = + [ ProjectsByFspService, + { + provide: getRepositoryToken(Project), + useClass: ProjectRepositoryFake + }, + { + provide: PinoLogger, + useValue: { + info: jest.fn((x) => x), + debug: jest.fn((x) => x), + setContext: jest.fn((x) => x), + } + }, + { + provide: ForestClientService, + useValue: { + convertEntity: jest.fn() + } + }, + { + provide: DataSource, + useValue: { + getRepository: jest.fn() + } + } + ]; + return dependencyMock; +} + +function getSimpleProjectResponseData(): RecursivePartial[] { + const data = [ + { + "id": 1, + "name": "Project #1", + "fspId": 11, + "forestClient": { + "id": "00001012", + "name": "CLIENT #1" + } + }, + { + "id": 2, + "name": "Project #2", + "fspId": 11, + "forestClient": { + "id": "00001015", + "name": "CLIENT #2" + } + } + ] + return data; +} diff --git a/api/src/app/modules/external/projects-by-fsp/projects-by-fsp.service.ts b/api/src/app/modules/external/projects-by-fsp/projects-by-fsp.service.ts new file mode 100644 index 000000000..6447c6e70 --- /dev/null +++ b/api/src/app/modules/external/projects-by-fsp/projects-by-fsp.service.ts @@ -0,0 +1,58 @@ +import { ProjectByFspResponse } from "@api-modules/external/projects-by-fsp/projects-by-fsp.dto"; +import { Project } from "@api-modules/project/project.entity"; +import { DataService } from "@core"; +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { ForestClientService } from "@src/app/modules/forest-client/forest-client.service"; +import { ProjectFindCriteria } from "@src/app/modules/project/project.service"; +import { PinoLogger } from "nestjs-pino"; +import { Repository } from "typeorm"; +import _ = require('lodash'); + +@Injectable() +export class ProjectsByFspService extends DataService, ProjectByFspResponse> { + + constructor( + @InjectRepository(Project) + repository: Repository, + private forestClientService: ForestClientService, + logger: PinoLogger + ) { + super(repository, new Project(), logger); + } + + async findByFspId(fspId: number):Promise { + if (_.isNil(fspId)) { + return [] + } + const findCriteria: ProjectFindCriteria = new ProjectFindCriteria(); + findCriteria.fspId = fspId; + this.logger.debug('Find criteria: %o', findCriteria); + + const query = this.repository.createQueryBuilder("p") + .leftJoinAndSelect("p.forestClient", "forestClient") + .addOrderBy('p.project_id', 'DESC'); + findCriteria.applyFindCriteria(query); + query.limit(2500); // Can't use take(). Limit # of results to avoid system strain. + + const queryResults:Project[] = await query.getMany(); + if (queryResults && queryResults.length > 0) { + this.logger.debug(`${queryResults.length} project(s) found.`); + return queryResults.map(project => this.convertEntity(project)); + } + this.logger.debug('No result found.'); + return []; + } + + convertEntity(entity: Project): ProjectByFspResponse { + const response = new ProjectByFspResponse(); + response.fomId = entity.id + response.name = entity.name + response.fspId = entity.fspId; + if (entity.forestClient != null) { + response.forestClient = this.forestClientService.convertEntity(entity.forestClient); + } + return response; + } + +} \ No newline at end of file diff --git a/api/src/core/utils.ts b/api/src/core/utils.ts index 2bfdac7b6..f2881a926 100644 --- a/api/src/core/utils.ts +++ b/api/src/core/utils.ts @@ -66,3 +66,8 @@ export const flatDeep = (arr, d = 1) => { return d > 0 ? arr.reduce((acc, val) => acc.concat(Array.isArray(val) ? flatDeep(val, d - 1) : val), []) : arr.slice(); }; + +// Partial type with nested properties also as Partial. +export type RecursivePartial = { + [P in keyof T]?: RecursivePartial; +}; \ No newline at end of file