From 7e47e50b7baf0a925ea40397e3f9b25b86917e73 Mon Sep 17 00:00:00 2001 From: Catalin Oancea Date: Thu, 5 Dec 2024 10:33:02 +0200 Subject: [PATCH] Use scorecard view instead of repo (#129) --- .../projects/projects-scorecard.repository.ts | 143 ------------------ .../projects/projects-scorecard.service.ts | 75 +++++++++ .../modules/projects/projects.controller.ts | 18 +-- api/src/modules/projects/projects.module.ts | 15 +- .../integration/projects/projects.spec.ts | 96 +++++++++++- shared/contracts/projects.contract.ts | 28 ++-- .../dtos/projects/projects-scorecard.dto.ts | 26 ---- shared/entities/project-scorecard.view.ts | 89 +++++++++++ shared/lib/db-entities.ts | 4 +- 9 files changed, 286 insertions(+), 208 deletions(-) delete mode 100644 api/src/modules/projects/projects-scorecard.repository.ts create mode 100644 api/src/modules/projects/projects-scorecard.service.ts delete mode 100644 shared/dtos/projects/projects-scorecard.dto.ts create mode 100644 shared/entities/project-scorecard.view.ts diff --git a/api/src/modules/projects/projects-scorecard.repository.ts b/api/src/modules/projects/projects-scorecard.repository.ts deleted file mode 100644 index 04577168..00000000 --- a/api/src/modules/projects/projects-scorecard.repository.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { Repository, SelectQueryBuilder } from 'typeorm'; -import { Project } from '@shared/entities/projects.entity'; -import { InjectRepository } from '@nestjs/typeorm'; -import { - OtherProjectFilters, - ProjectFilters, -} from '@shared/dtos/projects/projects-map.dto'; - -import { ProjectScorecardDto } from '@shared/dtos/projects/projects-scorecard.dto'; - -@Injectable() -export class ProjectsScorecardRepository extends Repository { - constructor( - @InjectRepository(Project) - private readonly projectRepo: Repository, - ) { - super(projectRepo.target, projectRepo.manager, projectRepo.queryRunner); - } - - async getProjectsScorecard( - filters?: ProjectFilters, - otherFilters?: OtherProjectFilters, - ): Promise { - const queryBuilder = this.manager.createQueryBuilder(); - queryBuilder - .select( - `p.country_code AS countryCode, - p.ecosystem AS ecosystem, - p.activity AS activity, - p.restoration_activity AS activitySubtype, - p.project_name AS projectName, - ps.financial_feasibility AS financialFeasibility, - ps.legal_feasibility AS legalFeasibility, - ps.implementation_feasibility AS implementationFeasibility, - ps.social_feasibility AS socialFeasibility, - ps.security_rating AS securityRating, - ps.availability_of_experienced_labor AS availabilityOfExperiencedLabor, - ps.availability_of_alternating_funding AS availabilityOfAlternatingFunding, - ps.coastal_protection_benefits AS coastalProtectionBenefits, - ps.biodiversity_benefit AS biodiversityBenefit, - p.abatement_potential AS abatementPotential, - p.total_cost AS totalCost, - p.total_cost_npv AS totalCostNPV`, - ) - .from('projects', 'p') - .leftJoin( - 'project_scorecard', - 'ps', - 'p.country_code = ps.country_code and ps."ecosystem"::VARCHAR = p."ecosystem"::VARCHAR', - ); - - const projectScorecards = await this.applyScorecardFilters( - queryBuilder, - filters, - otherFilters, - ).getRawMany(); - - return projectScorecards; - } - - private applyScorecardFilters( - queryBuilder: SelectQueryBuilder, - filters: ProjectFilters = {}, - otherFilters: OtherProjectFilters = {}, - ) { - const { - countryCode, - totalCost, - abatementPotential, - activity, - activitySubtype, - ecosystem, - } = filters; - const { costRange, abatementPotentialRange, costRangeSelector } = - otherFilters; - if (countryCode?.length) { - queryBuilder.andWhere('countryCode IN (:...countryCodes)', { - countryCodes: countryCode, - }); - } - if (totalCost?.length) { - const maxTotalCost = Math.max(...totalCost); - queryBuilder.andWhere('totalCost <= :maxTotalCost', { - maxTotalCost, - }); - } - if (abatementPotential?.length) { - const maxAbatementPotential = Math.max(...abatementPotential); - queryBuilder.andWhere('p.abatement_potential <= :maxAbatementPotential', { - maxAbatementPotential, - }); - } - if (activity) { - queryBuilder.andWhere('activity IN (:...activity)', { - activity, - }); - } - if (activitySubtype?.length) { - queryBuilder.andWhere('p.restoration_activity IN (:...activitySubtype)', { - activitySubtype, - }); - } - - if (ecosystem) { - queryBuilder.andWhere('ecosystem IN (:...ecosystem)', { - ecosystem, - }); - } - if (abatementPotentialRange) { - queryBuilder.andWhere( - 'p.abatemen_potential >= :minAP AND p.abatement_potential <= :maxAP', - { - minAP: Math.min(...abatementPotentialRange), - maxAP: Math.max(...abatementPotentialRange), - }, - ); - } - - if (costRange && costRangeSelector) { - let filteredCostColumn: string; - switch (costRangeSelector) { - case 'npv': - filteredCostColumn = 'p.total_cost_npv'; - break; - case 'total': - default: - filteredCostColumn = 'p.total_cost'; - break; - } - - queryBuilder.andWhere( - `${filteredCostColumn} >= :minCost AND ${filteredCostColumn} <= :maxCost`, - { - minCost: Math.min(...costRange), - maxCost: Math.max(...costRange), - }, - ); - } - - return queryBuilder; - } -} diff --git a/api/src/modules/projects/projects-scorecard.service.ts b/api/src/modules/projects/projects-scorecard.service.ts new file mode 100644 index 00000000..c1bc2fe0 --- /dev/null +++ b/api/src/modules/projects/projects-scorecard.service.ts @@ -0,0 +1,75 @@ +import { Injectable } from '@nestjs/common'; +import { AppBaseService } from '@api/utils/app-base.service'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, SelectQueryBuilder } from 'typeorm'; +import { z } from 'zod'; +import { getProjectsQuerySchema } from '@shared/contracts/projects.contract'; +import { ProjectScorecardView } from '@shared/entities/project-scorecard.view'; +import { + OtherProjectFilters, + ProjectFilters, +} from '@shared/dtos/projects/projects-map.dto'; + +export type ProjectFetchSpecificacion = z.infer; + +@Injectable() +export class ProjectsScorecardService extends AppBaseService< + ProjectScorecardView, + unknown, + unknown, + unknown +> { + constructor( + @InjectRepository(ProjectScorecardView) + private readonly projectScorecardRepo: Repository, + ) { + super(projectScorecardRepo, 'project_scorecard', 'project_scorecards'); + } + + async extendFindAllQuery( + query: SelectQueryBuilder, + fetchSpecification: ProjectFetchSpecificacion, + ): Promise> { + // Filter by project name + if (fetchSpecification.partialProjectName) { + query = query.andWhere('project_name ILIKE :projectName', { + projectName: `%${fetchSpecification.partialProjectName}%`, + }); + } + + // Filter by abatement potential + if (fetchSpecification.abatementPotentialRange) { + query = query.andWhere( + 'abatement_potential >= :minAP AND abatement_potential <= :maxAP', + { + minAP: Math.min(...fetchSpecification.abatementPotentialRange), + maxAP: Math.max(...fetchSpecification.abatementPotentialRange), + }, + ); + } + + // Filter by cost (total or NPV) + if (fetchSpecification.costRange && fetchSpecification.costRangeSelector) { + let filteredCostColumn: string; + switch (fetchSpecification.costRangeSelector) { + case 'npv': + filteredCostColumn = 'total_cost_npv'; + break; + case 'total': + default: + filteredCostColumn = 'total_cost'; + break; + } + + query = query.andWhere( + `${filteredCostColumn} >= :minCost AND ${filteredCostColumn} <= :maxCost`, + { + minCost: Math.min(...fetchSpecification.costRange), + maxCost: Math.max(...fetchSpecification.costRange), + }, + ); + } + + return query; + } +} diff --git a/api/src/modules/projects/projects.controller.ts b/api/src/modules/projects/projects.controller.ts index 9db99337..89dd8067 100644 --- a/api/src/modules/projects/projects.controller.ts +++ b/api/src/modules/projects/projects.controller.ts @@ -1,3 +1,4 @@ +import { ProjectsScorecardService } from './projects-scorecard.service'; import { Controller, HttpStatus } from '@nestjs/common'; import { tsRestHandler, TsRestHandler } from '@ts-rest/nest'; import { ControllerResponse } from '@api/types/controller-response.type'; @@ -6,7 +7,6 @@ import { ProjectsService } from '@api/modules/projects/projects.service'; import { CountriesService } from '@api/modules/countries/countries.service'; import { CountryWithNoGeometry } from '@shared/entities/country.entity'; import { ProjectsMapRepository } from '@api/modules/projects/projects-map.repository'; -import { ProjectsScorecardRepository } from '@api/modules/projects/projects-scorecard.repository'; import { OtherProjectFilters, ProjectFilters, @@ -17,8 +17,8 @@ export class ProjectsController { constructor( private readonly projectsService: ProjectsService, private readonly countryService: CountriesService, + private readonly projectsScorecardService: ProjectsScorecardService, private readonly projectMapRepository: ProjectsMapRepository, - private readonly projectsScorecardRepository: ProjectsScorecardRepository, ) {} @TsRestHandler(projectsContract.getProjects) @@ -34,19 +34,9 @@ export class ProjectsController { return tsRestHandler( projectsContract.getProjectsScorecard, async ({ query }) => { - const { filter } = query; - const otherFilters: OtherProjectFilters = { - costRange: query.costRange, - abatementPotentialRange: query.abatementPotentialRange, - costRangeSelector: query.costRangeSelector, - }; - const data = - await this.projectsScorecardRepository.getProjectsScorecard( - filter as unknown as ProjectFilters, - otherFilters, - ); - return { body: data, status: HttpStatus.OK } as any; + await this.projectsScorecardService.findAllPaginated(query); + return { body: data, status: HttpStatus.OK }; }, ); } diff --git a/api/src/modules/projects/projects.module.ts b/api/src/modules/projects/projects.module.ts index 4b0f107f..e920eb5c 100644 --- a/api/src/modules/projects/projects.module.ts +++ b/api/src/modules/projects/projects.module.ts @@ -5,15 +5,14 @@ import { ProjectsController } from './projects.controller'; import { ProjectsService } from './projects.service'; import { CountriesModule } from '@api/modules/countries/countries.module'; import { ProjectsMapRepository } from '@api/modules/projects/projects-map.repository'; -import { ProjectsScorecardRepository } from '@api/modules/projects/projects-scorecard.repository'; - +import { ProjectScorecardView } from '@shared/entities/project-scorecard.view'; +import { ProjectsScorecardService } from './projects-scorecard.service'; @Module({ - imports: [TypeOrmModule.forFeature([Project]), CountriesModule], - controllers: [ProjectsController], - providers: [ - ProjectsService, - ProjectsMapRepository, - ProjectsScorecardRepository, + imports: [ + TypeOrmModule.forFeature([Project, ProjectScorecardView]), + CountriesModule, ], + controllers: [ProjectsController], + providers: [ProjectsService, ProjectsMapRepository, ProjectsScorecardService], }) export class ProjectsModule {} diff --git a/api/test/integration/projects/projects.spec.ts b/api/test/integration/projects/projects.spec.ts index 5f3ea632..42ddc6c1 100644 --- a/api/test/integration/projects/projects.spec.ts +++ b/api/test/integration/projects/projects.spec.ts @@ -55,10 +55,12 @@ describe('Projects', () => { .query({ disablePagination: true }); expect(response.status).toBe(HttpStatus.OK); - expect(response.body.length).toBe(projects.length); + expect(response.body.data.length).toBe(projects.length); }); + }); - test('Should return a filtered list of Projects Scorecards', async () => { + describe('Filters for Projects Scorecards', () => { + test('Projects Scorecards filtered by NPV', async () => { const numProjects = 5; const projects: Project[] = []; const countryCodes: string[] = countriesInDb @@ -89,8 +91,96 @@ describe('Projects', () => { costRangeSelector: 'npv', costRange: [12, 26], }); + + expect(response.status).toBe(HttpStatus.OK); + expect(response.body.data.length).toBe(2); + + // check pagination + expect(response.body.metadata.totalItems).toBe(2); + expect(response.body.metadata.totalPages).toBe(1); + expect(response.body.metadata.page).toBe(1); + }); + + test('Projects Scorecards filtered by totalCost', async () => { + const numProjects = 5; + const projects: Project[] = []; + const countryCodes: string[] = countriesInDb + .slice(0, numProjects) + .map((country) => country.code); + const totalCosts = [25, 15, 45, 10, 30]; + + for (let i = 0; i < numProjects; i++) { + projects.push( + await testManager.mocks().createProject({ + countryCode: countryCodes[i], + totalCost: totalCosts[i], + }), + ); + } + + for (const project of projects) { + await testManager.mocks().createProjectScorecard({ + countryCode: project.countryCode, + ecosystem: project.ecosystem, + }); + } + + const response = await testManager + .request() + .get(projectsContract.getProjectsScorecard.path) + .query({ + costRangeSelector: 'total', + costRange: [12, 26], + }); + + expect(response.status).toBe(HttpStatus.OK); + expect(response.body.data.length).toBe(2); + + // check pagination + expect(response.body.metadata.totalItems).toBe(2); + expect(response.body.metadata.totalPages).toBe(1); + expect(response.body.metadata.page).toBe(1); + }); + + test('Projects Scorecards filtered by partia name', async () => { + const numProjects = 5; + const projects: Project[] = []; + const countryCodes: string[] = countriesInDb + .slice(0, numProjects) + .map((country) => country.code); + const projectNames = [ + 'Project aab', + 'Project aaaabbbb', + 'Project abb', + 'Project cdef', + 'Project xyz', + ]; + + for (let i = 0; i < numProjects; i++) { + projects.push( + await testManager.mocks().createProject({ + countryCode: countryCodes[i], + projectName: projectNames[i], + }), + ); + } + + for (const project of projects) { + await testManager.mocks().createProjectScorecard({ + countryCode: project.countryCode, + ecosystem: project.ecosystem, + }); + } + + const response = await testManager + .request() + .get(projectsContract.getProjectsScorecard.path) + .query({ + partialProjectName: 'aab', + }); + expect(response.status).toBe(HttpStatus.OK); - expect(response.body.length).toBe(2); + expect(response.body.data.length).toBe(2); }); }); diff --git a/shared/contracts/projects.contract.ts b/shared/contracts/projects.contract.ts index 2c174809..26a485c8 100644 --- a/shared/contracts/projects.contract.ts +++ b/shared/contracts/projects.contract.ts @@ -10,6 +10,7 @@ import { CountryWithNoGeometry } from "@shared/entities/country.entity"; import { ProjectMap } from "@shared/dtos/projects/projects-map.dto"; import { generateEntityQuerySchema } from "@shared/schemas/query-param.schema"; import { BaseEntity } from "typeorm"; +import { ProjectScorecardView } from "@shared/entities/project-scorecard.view"; const contract = initContract(); @@ -23,7 +24,13 @@ export const otherFilters = z.object({ partialProjectName: z.string().optional(), }); export const projectsQuerySchema = generateEntityQuerySchema(Project); +export const projectScorecardQuerySchema = + generateEntityQuerySchema(ProjectScorecard); + export const getProjectsQuerySchema = projectsQuerySchema.merge(otherFilters); +export const getProjectScorecardQuerySchema = + projectScorecardQuerySchema.merge(otherFilters); + export const projectsContract = contract.router({ getProjects: { method: "GET", @@ -33,6 +40,14 @@ export const projectsContract = contract.router({ }, query: getProjectsQuerySchema, }, + getProjectsScorecard: { + method: "GET", + path: "/projects/scorecard", + responses: { + 200: contract.type>(), + }, + query: getProjectScorecardQuerySchema, + }, getProject: { method: "GET", path: "/projects/:id", @@ -51,19 +66,6 @@ export const projectsContract = contract.router({ 200: contract.type>(), }, }, - getProjectsScorecard: { - method: "GET", - path: "/projects/scorecard", - responses: { - 200: contract.type>(), - }, - query: getProjectsQuerySchema.pick({ - filter: true, - costRange: true, - abatementPotentialRange: true, - costRangeSelector: true, - }), - }, getProjectsMap: { method: "GET", path: "/projects/map", diff --git a/shared/dtos/projects/projects-scorecard.dto.ts b/shared/dtos/projects/projects-scorecard.dto.ts deleted file mode 100644 index 4802f08a..00000000 --- a/shared/dtos/projects/projects-scorecard.dto.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { - ACTIVITY, - RESTORATION_ACTIVITY_SUBTYPE, -} from "@shared/entities/activity.enum"; -import { ECOSYSTEM } from "@shared/entities/ecosystem.enum"; -import { PROJECT_SCORE } from "@shared/entities/project-score.enum"; - -export type ProjectScorecardDto = { - countryCode: string; - ecosystem: ECOSYSTEM; - activity: ACTIVITY; - activitySubtype: RESTORATION_ACTIVITY_SUBTYPE; - project_name: string; - financialFeasibility: PROJECT_SCORE; - legalFeasibility: PROJECT_SCORE; - implementationFeasibility: PROJECT_SCORE; - socialFeasibility: PROJECT_SCORE; - securityRating: PROJECT_SCORE; - availabilityOfExperiencedLabor: PROJECT_SCORE; - availabilityOfAlternatingFunding: PROJECT_SCORE; - coastalProtectionBenefits: PROJECT_SCORE; - biodiversityBenefit: PROJECT_SCORE; - abatementPotential: number; - totalCost: number; - totalCostNPV: number; -}; diff --git a/shared/entities/project-scorecard.view.ts b/shared/entities/project-scorecard.view.ts new file mode 100644 index 00000000..3eb1175d --- /dev/null +++ b/shared/entities/project-scorecard.view.ts @@ -0,0 +1,89 @@ +import { ValueTransformer, ViewColumn, ViewEntity } from "typeorm"; + +export const decimalTransformer: ValueTransformer = { + to: (value: number | null) => value, + from: (value: string | null): number | null => + value !== null ? parseFloat(value) : null, +}; + +@ViewEntity({ + name: "project_scorecard_view", + expression: ` +SELECT + p.country_code AS country_code, + p.ecosystem AS ecosystem, + p.activity AS activity, + p.restoration_activity AS activity_subtype, + p.project_name AS project_name, + ps.financial_feasibility AS financial_feasibility, + ps.legal_feasibility AS legal_feasibility, + ps.implementation_feasibility AS implementation_feasibility, + ps.social_feasibility AS social_feasibility, + ps.security_rating AS security_rating, + ps.availability_of_experienced_labor AS availability_of_experienced_labor, + ps.availability_of_alternating_funding AS availability_of_alternating_funding, + ps.coastal_protection_benefits AS coastal_protection_benefits, + ps.biodiversity_benefit AS biodiversity_benefit, + p.abatement_potential AS abatement_potential, + p.total_cost AS total_cost, + p.total_cost_npv AS total_cost_npv +FROM + projects p +LEFT JOIN + project_scorecard ps +ON + p.country_code = ps.country_code and + ps."ecosystem"::VARCHAR = p."ecosystem"::VARCHAR`, +}) +export class ProjectScorecardView { + @ViewColumn({ name: "country_code" }) + countryCode: string; + + @ViewColumn({ name: "ecosystem" }) + ecosystem: string; + + @ViewColumn({ name: "activity" }) + activity: string; + + @ViewColumn({ name: "activity_subtype" }) + activitySubtype: string; + + @ViewColumn({ name: "project_name" }) + projectName: string; + + @ViewColumn({ name: "financial_feasibility" }) + financialFeasibility: string; + + @ViewColumn({ name: "legal_feasibility" }) + legalFeasibility: string; + + @ViewColumn({ name: "implementation_feasibility" }) + implementationFeasibility: string; + + @ViewColumn({ name: "social_feasibility" }) + socialFeasibility: string; + + @ViewColumn({ name: "security_rating" }) + securityRating: string; + + @ViewColumn({ name: "availability_of_experienced_labor" }) + availabilityOfExperiencedLabor: string; + + @ViewColumn({ name: "availability_of_alternating_funding" }) + availabilityOfAlternatingFunding: string; + + @ViewColumn({ name: "coastal_protection_benefits" }) + coastalProtectionBenefits: string; + + @ViewColumn({ name: "biodiversity_benefit" }) + biodiversityBenefit: string; + + @ViewColumn({ name: "abatement_potential", transformer: decimalTransformer }) + abatementPotential: number; + + @ViewColumn({ name: "total_cost", transformer: decimalTransformer }) + totalCost: number; + + @ViewColumn({ name: "total_cost_npv", transformer: decimalTransformer }) + totalCostNPV: number; +} diff --git a/shared/lib/db-entities.ts b/shared/lib/db-entities.ts index eb75b6af..01dadcaf 100644 --- a/shared/lib/db-entities.ts +++ b/shared/lib/db-entities.ts @@ -34,6 +34,7 @@ import { UserUploadCostInputs } from "@shared/entities/users/user-upload-cost-in import { UserUploadRestorationInputs } from "@shared/entities/users/user-upload-restoration-inputs.entity"; import { UserUploadConservationInputs } from "@shared/entities/users/user-upload-conservation-inputs.entity"; import { ProjectScorecard } from "@shared/entities/project-scorecard.entity"; +import { ProjectScorecardView } from "@shared/entities/project-scorecard.view"; import { BackOfficeSession } from "@shared/entities/users/backoffice-session"; export const COMMON_DATABASE_ENTITIES = [ @@ -73,5 +74,6 @@ export const COMMON_DATABASE_ENTITIES = [ UserUploadRestorationInputs, UserUploadConservationInputs, ProjectScorecard, - BackOfficeSession + ProjectScorecardView, + BackOfficeSession, ];