From da5615a5963d3351e2071d7433a9c291e668f37b Mon Sep 17 00:00:00 2001 From: Catalin Oancea Date: Thu, 28 Nov 2024 11:36:23 +0200 Subject: [PATCH] Add endpoint for fetching projects scorecards (#122) --- .../projects/projects-map.repository.ts | 13 +- .../projects/projects-scorecard.repository.ts | 143 ++++++++++++++++++ .../modules/projects/projects.controller.ts | 32 +++- api/src/modules/projects/projects.module.ts | 7 +- .../integration/projects/projects.spec.ts | 68 +++++++++ api/test/utils/test-manager.ts | 10 +- shared/contracts/projects.contract.ts | 15 ++ shared/dtos/projects/projects-map.dto.ts | 4 +- .../dtos/projects/projects-scorecard.dto.ts | 26 ++++ shared/entities/project-score.enum.ts | 5 + shared/entities/project-scorecard.entity.ts | 73 +++++++++ shared/lib/db-entities.ts | 2 + shared/lib/entity-mocks.ts | 29 ++++ 13 files changed, 412 insertions(+), 15 deletions(-) create mode 100644 api/src/modules/projects/projects-scorecard.repository.ts create mode 100644 shared/dtos/projects/projects-scorecard.dto.ts create mode 100644 shared/entities/project-score.enum.ts create mode 100644 shared/entities/project-scorecard.entity.ts diff --git a/api/src/modules/projects/projects-map.repository.ts b/api/src/modules/projects/projects-map.repository.ts index 8df6465d..a268710e 100644 --- a/api/src/modules/projects/projects-map.repository.ts +++ b/api/src/modules/projects/projects-map.repository.ts @@ -3,9 +3,9 @@ import { Repository, SelectQueryBuilder } from 'typeorm'; import { Project } from '@shared/entities/projects.entity'; import { InjectRepository } from '@nestjs/typeorm'; import { - OtherMapFilters, + OtherProjectFilters, ProjectMap, - ProjectMapFilters, + ProjectFilters, } from '@shared/dtos/projects/projects-map.dto'; @Injectable() @@ -18,8 +18,8 @@ export class ProjectsMapRepository extends Repository { } async getProjectsMap( - filters?: ProjectMapFilters, - otherFilters?: OtherMapFilters, + filters?: ProjectFilters, + otherFilters?: OtherProjectFilters, ): Promise { const geoQueryBuilder = this.manager.createQueryBuilder(); geoQueryBuilder @@ -67,8 +67,8 @@ export class ProjectsMapRepository extends Repository { private applyFilters( queryBuilder: SelectQueryBuilder, - filters: ProjectMapFilters = {}, - otherFilters: OtherMapFilters = {}, + filters: ProjectFilters = {}, + otherFilters: OtherProjectFilters = {}, ) { const { countryCode, @@ -153,7 +153,6 @@ export class ProjectsMapRepository extends Repository { ); } - // TODO: Pending to apply "parameter" filters (size, price type, NPV vs non-NPV)... return queryBuilder; } } diff --git a/api/src/modules/projects/projects-scorecard.repository.ts b/api/src/modules/projects/projects-scorecard.repository.ts new file mode 100644 index 00000000..04577168 --- /dev/null +++ b/api/src/modules/projects/projects-scorecard.repository.ts @@ -0,0 +1,143 @@ +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.controller.ts b/api/src/modules/projects/projects.controller.ts index 70f151ab..9db99337 100644 --- a/api/src/modules/projects/projects.controller.ts +++ b/api/src/modules/projects/projects.controller.ts @@ -6,9 +6,10 @@ 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 { - OtherMapFilters, - ProjectMapFilters, + OtherProjectFilters, + ProjectFilters, } from '@shared/dtos/projects/projects-map.dto'; @Controller() @@ -17,6 +18,7 @@ export class ProjectsController { private readonly projectsService: ProjectsService, private readonly countryService: CountriesService, private readonly projectMapRepository: ProjectsMapRepository, + private readonly projectsScorecardRepository: ProjectsScorecardRepository, ) {} @TsRestHandler(projectsContract.getProjects) @@ -27,6 +29,28 @@ export class ProjectsController { }); } + @TsRestHandler(projectsContract.getProjectsScorecard) + async getProjectsScorecard(): ControllerResponse { + 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; + }, + ); + } + @TsRestHandler(projectsContract.getProjectCountries) async getProjectCountries(): ControllerResponse { return tsRestHandler(projectsContract.getProjectCountries, async () => { @@ -50,13 +74,13 @@ export class ProjectsController { async getProjectsMap(): ControllerResponse { return tsRestHandler(projectsContract.getProjectsMap, async ({ query }) => { const { filter } = query; - const otherFilters: OtherMapFilters = { + const otherFilters: OtherProjectFilters = { costRange: query.costRange, abatementPotentialRange: query.abatementPotentialRange, costRangeSelector: query.costRangeSelector, }; const data = await this.projectMapRepository.getProjectsMap( - filter as unknown as ProjectMapFilters, + filter as unknown as ProjectFilters, otherFilters, ); return { body: data, status: HttpStatus.OK } as any; diff --git a/api/src/modules/projects/projects.module.ts b/api/src/modules/projects/projects.module.ts index 44cde03b..4b0f107f 100644 --- a/api/src/modules/projects/projects.module.ts +++ b/api/src/modules/projects/projects.module.ts @@ -5,10 +5,15 @@ 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'; @Module({ imports: [TypeOrmModule.forFeature([Project]), CountriesModule], controllers: [ProjectsController], - providers: [ProjectsService, ProjectsMapRepository], + providers: [ + ProjectsService, + ProjectsMapRepository, + ProjectsScorecardRepository, + ], }) export class ProjectsModule {} diff --git a/api/test/integration/projects/projects.spec.ts b/api/test/integration/projects/projects.spec.ts index d580783b..5f3ea632 100644 --- a/api/test/integration/projects/projects.spec.ts +++ b/api/test/integration/projects/projects.spec.ts @@ -2,6 +2,7 @@ import { TestManager } from '../../utils/test-manager'; import { HttpStatus } from '@nestjs/common'; import { projectsContract } from '@shared/contracts/projects.contract'; import { Country } from '@shared/entities/country.entity'; +import { ProjectScorecard } from '@shared/entities/project-scorecard.entity'; import { Project } from '@shared/entities/projects.entity'; describe('Projects', () => { @@ -19,6 +20,10 @@ describe('Projects', () => { afterEach(async () => { await testManager.getDataSource().getRepository(Project).delete({}); + await testManager + .getDataSource() + .getRepository(ProjectScorecard) + .delete({}); }); afterAll(async () => { @@ -26,6 +31,69 @@ describe('Projects', () => { await testManager.close(); }); + describe('Get Projects Scorecards', () => { + test('Should return a list of Projects Scorecards', async () => { + const projects: Project[] = []; + for (const country of countriesInDb.slice(0, 5)) { + projects.push( + await testManager + .mocks() + .createProject({ countryCode: country.code }), + ); + } + + 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({ disablePagination: true }); + + expect(response.status).toBe(HttpStatus.OK); + expect(response.body.length).toBe(projects.length); + }); + + test('Should return a filtered list of Projects Scorecards', async () => { + const numProjects = 5; + const projects: Project[] = []; + const countryCodes: string[] = countriesInDb + .slice(0, numProjects) + .map((country) => country.code); + const totalCostNPVs = [25, 15, 45, 10, 30]; + + for (let i = 0; i < numProjects; i++) { + projects.push( + await testManager.mocks().createProject({ + countryCode: countryCodes[i], + totalCostNPV: totalCostNPVs[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: 'npv', + costRange: [12, 26], + }); + expect(response.status).toBe(HttpStatus.OK); + expect(response.body.length).toBe(2); + }); + }); + describe('Get Projects', () => { test('Should return a list of Projects', async () => { const projects: Project[] = []; diff --git a/api/test/utils/test-manager.ts b/api/test/utils/test-manager.ts index cb3ae5f5..b3452b7f 100644 --- a/api/test/utils/test-manager.ts +++ b/api/test/utils/test-manager.ts @@ -13,7 +13,11 @@ import { IEmailServiceToken } from '@api/modules/notifications/email/email-servi import { MockEmailService } from './mocks/mock-email.service'; import { ROLES } from '@shared/entities/users/roles.enum'; -import { createProject, createUser } from '@shared/lib/entity-mocks'; +import { + createProject, + createUser, + createProjectScorecard, +} from '@shared/lib/entity-mocks'; import { clearTablesByEntities, clearTestDataFromDatabase, @@ -24,6 +28,7 @@ import * as fs from 'fs'; import { Project } from '@shared/entities/projects.entity'; import { adminContract } from '@shared/contracts/admin.contract'; import { Country } from '@shared/entities/country.entity'; +import { ProjectScorecard } from '@shared/entities/project-scorecard.entity'; /** * @description: Abstraction for NestJS testing workflow. For now its a basic implementation to create a test app, but can be extended to encapsulate * common testing utilities @@ -139,6 +144,9 @@ export class TestManager { createUser(this.getDataSource(), additionalData), createProject: async (additionalData?: Partial) => createProject(this.getDataSource(), additionalData), + createProjectScorecard: async ( + additionalData?: Partial, + ) => createProjectScorecard(this.getDataSource(), additionalData), }; } } diff --git a/shared/contracts/projects.contract.ts b/shared/contracts/projects.contract.ts index fdcabde7..2c174809 100644 --- a/shared/contracts/projects.contract.ts +++ b/shared/contracts/projects.contract.ts @@ -1,3 +1,4 @@ +import { ProjectScorecard } from "./../entities/project-scorecard.entity"; import { initContract } from "@ts-rest/core"; import { z } from "zod"; import { @@ -13,6 +14,7 @@ import { BaseEntity } from "typeorm"; const contract = initContract(); export type ProjectType = Omit; +export type ProjectScorecardType = Omit; export const otherFilters = z.object({ costRange: z.coerce.number().array().optional(), @@ -49,6 +51,19 @@ 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-map.dto.ts b/shared/dtos/projects/projects-map.dto.ts index f9bd8e44..f015b3e4 100644 --- a/shared/dtos/projects/projects-map.dto.ts +++ b/shared/dtos/projects/projects-map.dto.ts @@ -12,7 +12,7 @@ export type ProjectGeoProperties = z.infer; export type ProjectMap = FeatureCollection; -export type ProjectMapFilters = { +export type ProjectFilters = { countryCode?: string[]; totalCost?: number[]; abatementPotential?: number[]; @@ -23,7 +23,7 @@ export type ProjectMapFilters = { priceType?: PROJECT_PRICE_TYPE; }; -export type OtherMapFilters = { +export type OtherProjectFilters = { costRange?: number[]; abatementPotentialRange?: number[]; costRangeSelector?: "total" | "npv"; diff --git a/shared/dtos/projects/projects-scorecard.dto.ts b/shared/dtos/projects/projects-scorecard.dto.ts new file mode 100644 index 00000000..4802f08a --- /dev/null +++ b/shared/dtos/projects/projects-scorecard.dto.ts @@ -0,0 +1,26 @@ +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-score.enum.ts b/shared/entities/project-score.enum.ts new file mode 100644 index 00000000..1bc65d6e --- /dev/null +++ b/shared/entities/project-score.enum.ts @@ -0,0 +1,5 @@ +export enum PROJECT_SCORE { + LOW = "low", + MEDIUM = "medium", + HIGH = "high", +} diff --git a/shared/entities/project-scorecard.entity.ts b/shared/entities/project-scorecard.entity.ts new file mode 100644 index 00000000..43552aa8 --- /dev/null +++ b/shared/entities/project-scorecard.entity.ts @@ -0,0 +1,73 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + BaseEntity, + ManyToOne, + Unique, + JoinColumn, +} from "typeorm"; +import { Country } from "@shared/entities/country.entity"; +import { ECOSYSTEM } from "@shared/entities/ecosystem.enum"; +import { PROJECT_SCORE } from "@shared/entities/project-score.enum"; + +@Entity("project_scorecard") +@Unique(["country", "ecosystem"]) +export class ProjectScorecard extends BaseEntity { + @PrimaryGeneratedColumn("uuid") + id: string; + + @Column({ name: "country_code", length: 3, nullable: true, type: "char" }) + countryCode: string; + + //Unidirectional relation + @ManyToOne(() => Country) + @JoinColumn({ name: "country_code" }) + country: Country; + + @Column({ name: "ecosystem", enum: ECOSYSTEM, type: "enum" }) + ecosystem: ECOSYSTEM; + + @Column({ name: "financial_feasibility", enum: PROJECT_SCORE, type: "enum" }) + financialFeasibility: PROJECT_SCORE; + + @Column({ name: "legal_feasibility", enum: PROJECT_SCORE, type: "enum" }) + legalFeasibility: PROJECT_SCORE; + + @Column({ + name: "implementation_feasibility", + enum: PROJECT_SCORE, + type: "enum", + }) + implementationFeasibility: PROJECT_SCORE; + + @Column({ name: "social_feasibility", enum: PROJECT_SCORE, type: "enum" }) + socialFeasibility: PROJECT_SCORE; + + @Column({ name: "security_rating", enum: PROJECT_SCORE, type: "enum" }) + securityRating: PROJECT_SCORE; + + @Column({ + name: "availability_of_experienced_labor", + enum: PROJECT_SCORE, + type: "enum", + }) + availabilityOfExperiencedLabor: PROJECT_SCORE; + + @Column({ + name: "availability_of_alternating_funding", + enum: PROJECT_SCORE, + type: "enum", + }) + availabilityOfAlternatingFunding: PROJECT_SCORE; + + @Column({ + name: "coastal_protection_benefits", + enum: PROJECT_SCORE, + type: "enum", + }) + coastalProtectionBenefits: PROJECT_SCORE; + + @Column({ name: "biodiversity_benefit", enum: PROJECT_SCORE, type: "enum" }) + biodiversityBenefit: PROJECT_SCORE; +} diff --git a/shared/lib/db-entities.ts b/shared/lib/db-entities.ts index dbc802fc..c1e963e0 100644 --- a/shared/lib/db-entities.ts +++ b/shared/lib/db-entities.ts @@ -33,6 +33,7 @@ import { CustomProject } from "@shared/entities/custom-project.entity"; import { UserUploadCostInputs } from "@shared/entities/users/user-upload-cost-inputs.entity"; 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"; export const COMMON_DATABASE_ENTITIES = [ User, @@ -70,4 +71,5 @@ export const COMMON_DATABASE_ENTITIES = [ UserUploadCostInputs, UserUploadRestorationInputs, UserUploadConservationInputs, + ProjectScorecard, ]; diff --git a/shared/lib/entity-mocks.ts b/shared/lib/entity-mocks.ts index fb534da6..2e7b0805 100644 --- a/shared/lib/entity-mocks.ts +++ b/shared/lib/entity-mocks.ts @@ -12,6 +12,8 @@ import { RESTORATION_ACTIVITY_SUBTYPE, } from "@shared/entities/activity.enum"; import { ECOSYSTEM } from "@shared/entities/ecosystem.enum"; +import { ProjectScorecard } from "@shared/entities/project-scorecard.entity"; +import { PROJECT_SCORE } from "@shared/entities/project-score.enum"; export const createUser = async ( dataSource: DataSource, @@ -59,3 +61,30 @@ export const createProject = async ( .getRepository(Project) .save({ ...defaultProjectData, ...additionalData }); }; + +export const createProjectScorecard = async ( + dataSource: DataSource, + additionalData?: DeepPartial, +): Promise => { + const countries = await dataSource.getRepository(Country).find(); + if (!countries.length) { + throw new Error("No countries in the database"); + } + const defaultProjectScorecardData: Partial = { + countryCode: countries[0].code, + ecosystem: ECOSYSTEM.MANGROVE, + financialFeasibility: PROJECT_SCORE.HIGH, + legalFeasibility: PROJECT_SCORE.LOW, + implementationFeasibility: PROJECT_SCORE.MEDIUM, + socialFeasibility: PROJECT_SCORE.HIGH, + securityRating: PROJECT_SCORE.LOW, + availabilityOfExperiencedLabor: PROJECT_SCORE.MEDIUM, + availabilityOfAlternatingFunding: PROJECT_SCORE.HIGH, + coastalProtectionBenefits: PROJECT_SCORE.LOW, + biodiversityBenefit: PROJECT_SCORE.HIGH, + }; + + return dataSource + .getRepository(ProjectScorecard) + .save({ ...defaultProjectScorecardData, ...additionalData }); +};