Skip to content

Commit

Permalink
Add endpoint for fetching projects scorecards
Browse files Browse the repository at this point in the history
  • Loading branch information
catalin-oancea committed Nov 27, 2024
1 parent de5b2d8 commit 4c65e9c
Show file tree
Hide file tree
Showing 14 changed files with 448 additions and 15 deletions.
14 changes: 7 additions & 7 deletions api/src/modules/projects/projects-map.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Project> {
Expand All @@ -18,8 +19,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 +68,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 +154,6 @@ export class ProjectsMapRepository extends Repository<Project> {
);
}

// TODO: Pending to apply "parameter" filters (size, price type, NPV vs non-NPV)...
return queryBuilder;
}
}
29 changes: 25 additions & 4 deletions api/src/modules/projects/projects.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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 () => {
Expand All @@ -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;
Expand Down
6 changes: 5 additions & 1 deletion api/src/modules/projects/projects.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
})
Expand Down
106 changes: 106 additions & 0 deletions api/src/modules/projects/projects.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof getProjectsQuerySchema>;

Expand All @@ -18,10 +23,29 @@ export class ProjectsService extends AppBaseService<
constructor(
@InjectRepository(Project)
public readonly projectRepository: Repository<Project>,
@InjectRepository(ProjectScorecardView)
private readonly projectScorecardRepo: Repository<ProjectScorecardView>,
) {
super(projectRepository, 'project', 'projects');
}

async getProjectsScorecard(
filters?: ProjectFilters,
otherFilters?: OtherProjectFilters,
): Promise<ProjectScorecardView[]> {
const queryBuilder = this.projectScorecardRepo
.createQueryBuilder()
.select();

const scorecards = this.applyScorecardFilters(
queryBuilder,
filters,
otherFilters,
).getRawMany();

return scorecards;
}

async extendFindAllQuery(
query: SelectQueryBuilder<Project>,
fetchSpecification: ProjectFetchSpecificacion,
Expand Down Expand Up @@ -68,4 +92,86 @@ export class ProjectsService extends AppBaseService<

return query;
}

private applyScorecardFilters(
queryBuilder: SelectQueryBuilder<ProjectScorecardView>,
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;
}
}
64 changes: 64 additions & 0 deletions api/test/integration/projects/projects.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
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),
};
}
}
15 changes: 15 additions & 0 deletions shared/contracts/projects.contract.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ProjectScorecard } from "./../entities/project-scorecard.entity";
import { initContract } from "@ts-rest/core";
import { z } from "zod";
import {
Expand All @@ -13,6 +14,7 @@ import { BaseEntity } from "typeorm";
const contract = initContract();

export type ProjectType = Omit<Project, keyof BaseEntity>;
export type ProjectScorecardType = Omit<ProjectScorecard, keyof BaseEntity>;

export const otherFilters = z.object({
costRange: z.coerce.number().array().optional(),
Expand Down Expand Up @@ -49,6 +51,19 @@ export const projectsContract = contract.router({
200: contract.type<ApiResponse<CountryWithNoGeometry[]>>(),
},
},
getProjectsScorecard: {
method: "GET",
path: "/projects/scorecard",
responses: {
200: contract.type<ApiPaginationResponse<ProjectScorecardType>>(),
},
query: getProjectsQuerySchema.pick({
filter: true,
costRange: true,
abatementPotentialRange: true,
costRangeSelector: true,
}),
},
getProjectsMap: {
method: "GET",
path: "/projects/map",
Expand Down
4 changes: 2 additions & 2 deletions shared/dtos/projects/projects-map.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export type ProjectGeoProperties = z.infer<typeof ProjectGeoPropertiesSchema>;

export type ProjectMap = FeatureCollection<Geometry, ProjectGeoProperties>;

export type ProjectMapFilters = {
export type ProjectFilters = {
countryCode?: string[];
totalCost?: number[];
abatementPotential?: number[];
Expand All @@ -23,7 +23,7 @@ export type ProjectMapFilters = {
priceType?: PROJECT_PRICE_TYPE;
};

export type OtherMapFilters = {
export type OtherProjectFilters = {
costRange?: number[];
abatementPotentialRange?: number[];
costRangeSelector?: "total" | "npv";
Expand Down
Empty file.
Loading

0 comments on commit 4c65e9c

Please sign in to comment.