diff --git a/api/src/modules/projects/projects-map.repository.ts b/api/src/modules/projects/projects-map.repository.ts index 8df6465d..ea374893 100644 --- a/api/src/modules/projects/projects-map.repository.ts +++ b/api/src/modules/projects/projects-map.repository.ts @@ -3,10 +3,11 @@ 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'; +import { ProjectScorecardView } from '@shared/entities/project-scorecard.view'; @Injectable() export class ProjectsMapRepository extends Repository { @@ -18,8 +19,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 +68,8 @@ export class ProjectsMapRepository extends Repository { private applyFilters( queryBuilder: SelectQueryBuilder, - filters: ProjectMapFilters = {}, - otherFilters: OtherMapFilters = {}, + filters: ProjectFilters = {}, + otherFilters: OtherProjectFilters = {}, ) { const { countryCode, @@ -153,7 +154,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.controller.ts b/api/src/modules/projects/projects.controller.ts index 70f151ab..b9e15287 100644 --- a/api/src/modules/projects/projects.controller.ts +++ b/api/src/modules/projects/projects.controller.ts @@ -7,8 +7,8 @@ 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 { - OtherMapFilters, - ProjectMapFilters, + OtherProjectFilters, + ProjectFilters, } from '@shared/dtos/projects/projects-map.dto'; @Controller() @@ -27,6 +27,27 @@ 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.projectsService.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 +71,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..8150fd45 100644 --- a/api/src/modules/projects/projects.module.ts +++ b/api/src/modules/projects/projects.module.ts @@ -5,9 +5,13 @@ 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 { ProjectScorecardView } from '@shared/entities/project-scorecard.view'; @Module({ - imports: [TypeOrmModule.forFeature([Project]), CountriesModule], + imports: [ + TypeOrmModule.forFeature([Project, ProjectScorecardView]), + CountriesModule, + ], controllers: [ProjectsController], providers: [ProjectsService, ProjectsMapRepository], }) diff --git a/api/src/modules/projects/projects.service.ts b/api/src/modules/projects/projects.service.ts index 92538f33..7f5a2ea0 100644 --- a/api/src/modules/projects/projects.service.ts +++ b/api/src/modules/projects/projects.service.ts @@ -5,6 +5,11 @@ 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; @@ -18,10 +23,29 @@ export class ProjectsService extends AppBaseService< constructor( @InjectRepository(Project) public readonly projectRepository: Repository, + @InjectRepository(ProjectScorecardView) + private readonly projectScorecardRepo: Repository, ) { super(projectRepository, 'project', 'projects'); } + async getProjectsScorecard( + filters?: ProjectFilters, + otherFilters?: OtherProjectFilters, + ): Promise { + const queryBuilder = this.projectScorecardRepo + .createQueryBuilder() + .select(); + + const scorecards = this.applyScorecardFilters( + queryBuilder, + filters, + otherFilters, + ).getRawMany(); + + return scorecards; + } + async extendFindAllQuery( query: SelectQueryBuilder, fetchSpecification: ProjectFetchSpecificacion, @@ -68,4 +92,86 @@ export class ProjectsService extends AppBaseService< return query; } + + 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('country_code IN (:...countryCodes)', { + countryCodes: countryCode, + }); + } + if (totalCost?.length) { + const maxTotalCost = Math.max(...totalCost); + queryBuilder.andWhere('total_cost <= :maxTotalCost', { + maxTotalCost, + }); + } + if (abatementPotential?.length) { + const maxAbatementPotential = Math.max(...abatementPotential); + queryBuilder.andWhere('abatement_potential <= :maxAbatementPotential', { + maxAbatementPotential, + }); + } + if (activity) { + queryBuilder.andWhere('activity IN (:...activity)', { + activity, + }); + } + if (activitySubtype?.length) { + queryBuilder.andWhere('activity_subtype IN (:...activitySubtype)', { + activitySubtype, + }); + } + + if (ecosystem) { + queryBuilder.andWhere('ecosystem IN (:...ecosystem)', { + ecosystem, + }); + } + if (abatementPotentialRange) { + queryBuilder.andWhere( + 'abatement_potential >= :minAP AND abatement_potential <= :maxAP', + { + minAP: Math.min(...abatementPotentialRange), + maxAP: Math.max(...abatementPotentialRange), + }, + ); + } + + if (costRange && costRangeSelector) { + let filteredCostColumn: string; + switch (costRangeSelector) { + case 'npv': + filteredCostColumn = 'total_cost_npv'; + break; + case 'total': + default: + filteredCostColumn = 'total_cost'; + break; + } + + queryBuilder.andWhere( + `${filteredCostColumn} >= :minCost AND ${filteredCostColumn} <= :maxCost`, + { + minCost: Math.min(...costRange), + maxCost: Math.max(...costRange), + }, + ); + } + + return queryBuilder; + } } diff --git a/api/test/integration/projects/projects.spec.ts b/api/test/integration/projects/projects.spec.ts index d580783b..fc77ae36 100644 --- a/api/test/integration/projects/projects.spec.ts +++ b/api/test/integration/projects/projects.spec.ts @@ -26,6 +26,70 @@ 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.only('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..e69de29b 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/entities/project-scorecard.view.ts b/shared/entities/project-scorecard.view.ts new file mode 100644 index 00000000..21899dcd --- /dev/null +++ b/shared/entities/project-scorecard.view.ts @@ -0,0 +1,104 @@ +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", transformer: decimalTransformer }) + countryCode: string; + + @ViewColumn({ name: "ecosystem", transformer: decimalTransformer }) + ecosystem: string; + + @ViewColumn({ name: "activity", transformer: decimalTransformer }) + activity: string; + + @ViewColumn({ name: "activity_subtype", transformer: decimalTransformer }) + activitySubtype: string; + + @ViewColumn({ name: "project_name", transformer: decimalTransformer }) + projectName: string; + + @ViewColumn({ + name: "financial_feasibility", + transformer: decimalTransformer, + }) + financialFeasibility: string; + + @ViewColumn({ name: "legal_feasibility", transformer: decimalTransformer }) + legalFeasibility: string; + + @ViewColumn({ + name: "implementation_feasibility", + transformer: decimalTransformer, + }) + implementationFeasibility: string; + + @ViewColumn({ name: "social_feasibility", transformer: decimalTransformer }) + socialFeasibility: string; + + @ViewColumn({ name: "security_rating", transformer: decimalTransformer }) + securityRating: string; + + @ViewColumn({ + name: "availability_of_experienced_labor", + transformer: decimalTransformer, + }) + availabilityOfExperiencedLabor: string; + + @ViewColumn({ + name: "availability_of_alternating_funding", + transformer: decimalTransformer, + }) + availabilityOfAlternatingFunding: string; + + @ViewColumn({ + name: "coastal_protection_benefits", + transformer: decimalTransformer, + }) + coastalProtectionBenefits: string; + + @ViewColumn({ name: "biodiversity_benefit", transformer: decimalTransformer }) + 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 dbc802fc..e791725c 100644 --- a/shared/lib/db-entities.ts +++ b/shared/lib/db-entities.ts @@ -33,6 +33,8 @@ 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"; +import { ProjectScorecardView } from "@shared/entities/project-scorecard.view"; export const COMMON_DATABASE_ENTITIES = [ User, @@ -70,4 +72,6 @@ export const COMMON_DATABASE_ENTITIES = [ UserUploadCostInputs, UserUploadRestorationInputs, UserUploadConservationInputs, + ProjectScorecard, + ProjectScorecardView, ]; 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 }); +};