Skip to content

Commit

Permalink
Add endpoint for fetching projects scorecards (#122)
Browse files Browse the repository at this point in the history
  • Loading branch information
catalin-oancea authored Nov 28, 2024
1 parent 3a1146b commit da5615a
Show file tree
Hide file tree
Showing 13 changed files with 412 additions and 15 deletions.
13 changes: 6 additions & 7 deletions api/src/modules/projects/projects-map.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -18,8 +18,8 @@ export class ProjectsMapRepository extends Repository<Project> {
}

async getProjectsMap(
filters?: ProjectMapFilters,
otherFilters?: OtherMapFilters,
filters?: ProjectFilters,
otherFilters?: OtherProjectFilters,
): Promise<ProjectMap> {
const geoQueryBuilder = this.manager.createQueryBuilder();
geoQueryBuilder
Expand Down Expand Up @@ -67,8 +67,8 @@ export class ProjectsMapRepository extends Repository<Project> {

private applyFilters(
queryBuilder: SelectQueryBuilder<Project>,
filters: ProjectMapFilters = {},
otherFilters: OtherMapFilters = {},
filters: ProjectFilters = {},
otherFilters: OtherProjectFilters = {},
) {
const {
countryCode,
Expand Down Expand Up @@ -153,7 +153,6 @@ export class ProjectsMapRepository extends Repository<Project> {
);
}

// TODO: Pending to apply "parameter" filters (size, price type, NPV vs non-NPV)...
return queryBuilder;
}
}
143 changes: 143 additions & 0 deletions api/src/modules/projects/projects-scorecard.repository.ts
Original file line number Diff line number Diff line change
@@ -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<Project> {
constructor(
@InjectRepository(Project)
private readonly projectRepo: Repository<Project>,
) {
super(projectRepo.target, projectRepo.manager, projectRepo.queryRunner);
}

async getProjectsScorecard(
filters?: ProjectFilters,
otherFilters?: OtherProjectFilters,
): Promise<ProjectScorecardDto[]> {
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<Project>,
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;
}
}
32 changes: 28 additions & 4 deletions api/src/modules/projects/projects.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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)
Expand All @@ -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 () => {
Expand All @@ -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;
Expand Down
7 changes: 6 additions & 1 deletion api/src/modules/projects/projects.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
68 changes: 68 additions & 0 deletions api/test/integration/projects/projects.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -19,13 +20,80 @@ describe('Projects', () => {

afterEach(async () => {
await testManager.getDataSource().getRepository(Project).delete({});
await testManager
.getDataSource()
.getRepository(ProjectScorecard)
.delete({});
});

afterAll(async () => {
await testManager.clearDatabase();
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[] = [];
Expand Down
10 changes: 9 additions & 1 deletion api/test/utils/test-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -139,6 +144,9 @@ export class TestManager {
createUser(this.getDataSource(), additionalData),
createProject: async (additionalData?: Partial<Project>) =>
createProject(this.getDataSource(), additionalData),
createProjectScorecard: async (
additionalData?: Partial<ProjectScorecard>,
) => createProjectScorecard(this.getDataSource(), additionalData),
};
}
}
Loading

0 comments on commit da5615a

Please sign in to comment.