Skip to content

Commit

Permalink
project map filters
Browse files Browse the repository at this point in the history
  • Loading branch information
alexeh committed Oct 30, 2024
1 parent 2b0a701 commit c6834a1
Show file tree
Hide file tree
Showing 8 changed files with 225 additions and 17 deletions.
65 changes: 61 additions & 4 deletions api/src/modules/projects/projects-map.repository.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { Injectable } from '@nestjs/common';
import { Repository } from 'typeorm';
import { Repository, SelectQueryBuilder } from 'typeorm';
import { Project } from '@shared/entities/projects.entity';
import { InjectRepository } from '@nestjs/typeorm';
import { ProjectMap } from '@shared/dtos/projects/projects-map.dto';
import {
ProjectMap,
ProjectMapFilters,
} from '@shared/dtos/projects/projects-map.dto';

@Injectable()
export class ProjectsMapRepository extends Repository<Project> {
Expand All @@ -13,7 +16,7 @@ export class ProjectsMapRepository extends Repository<Project> {
super(projectRepo.target, projectRepo.manager, projectRepo.queryRunner);
}

async getProjectsMap(): Promise<ProjectMap> {
async getProjectsMap(filters?: ProjectMapFilters): Promise<ProjectMap> {
const geoQueryBuilder = this.manager.createQueryBuilder();
geoQueryBuilder
.select(
Expand All @@ -37,7 +40,7 @@ export class ProjectsMapRepository extends Repository<Project> {
.from('countries', 'country')
.innerJoin(
(subQuery) => {
return subQuery
subQuery
.select('p.country_code')
.from(Project, 'p')
.addSelect(
Expand All @@ -46,6 +49,8 @@ export class ProjectsMapRepository extends Repository<Project> {
)
.addSelect('SUM(p.total_cost)', 'total_cost')
.groupBy('p.country_code');

return this.applyFilters(subQuery, filters);
},
'filtered_projects',
'filtered_projects.country_code = country.code',
Expand All @@ -56,4 +61,56 @@ export class ProjectsMapRepository extends Repository<Project> {
}>();
return geojson;
}

private applyFilters(
queryBuilder: SelectQueryBuilder<Project>,
filters: ProjectMapFilters = {},
) {
const {
countryCode,
totalCost,
abatementPotential,
activity,
activitySubtype,
ecosystem,
projectSizeFilter,
priceType,
} = filters;
if (countryCode?.length) {
queryBuilder.andWhere('p.countryCode IN (:...countryCodes)', {
countryCodes: countryCode,
});
}
if (totalCost?.length) {
const maxTotalCost = Math.max(...totalCost);
queryBuilder.andWhere('p.totalCost <= :maxTotalCost', {
maxTotalCost,
});
}
if (abatementPotential?.length) {
const maxAbatementPotential = Math.max(...abatementPotential);
queryBuilder.andWhere('p.abatementPotential <= :maxAbatementPotential', {
maxAbatementPotential,
});
}
if (activity) {
queryBuilder.andWhere('p.activity IN (:...activity)', {
activity,
});
}
if (activitySubtype?.length) {
queryBuilder.andWhere('p.activitySubtype IN (:...activitySubtype)', {
activitySubtype,
});
}

if (ecosystem) {
queryBuilder.andWhere('p.ecosystem IN (:...ecosystem)', {
ecosystem,
});
}

// TODO: Pending to apply "parameter" filters (size, price type, NPV vs non-NPV)...
return queryBuilder;
}
}
13 changes: 11 additions & 2 deletions api/src/modules/projects/projects.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ 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 {
FetchSpecification,
ProcessFetchSpecification,
} from 'nestjs-base-service';
import { ProjectMapFilters } from '@shared/dtos/projects/projects-map.dto';

@Controller()
export class ProjectsController {
Expand Down Expand Up @@ -43,9 +48,13 @@ export class ProjectsController {
}

@TsRestHandler(projectsContract.getProjectsMap)
async getProjectsMap(): ControllerResponse {
async getProjectsMap(
@ProcessFetchSpecification() dto: FetchSpecification,
): ControllerResponse {
return tsRestHandler(projectsContract.getProjectsMap, async () => {
const data = await this.projectMapRepository.getProjectsMap();
const data = await this.projectMapRepository.getProjectsMap(
dto.filter as ProjectMapFilters,
);
return { body: data, status: HttpStatus.OK } as any;
});
}
Expand Down
101 changes: 101 additions & 0 deletions api/test/integration/project-map/project-map.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { TestManager } from '../../utils/test-manager';
import { HttpStatus } from '@nestjs/common';
import { Project } from '@shared/entities/projects.entity';
import { Country } from '@shared/entities/country.entity';
import { projectsContract } from '@shared/contracts/projects.contract';
import { ECOSYSTEM } from '@shared/entities/base-data.entity';

describe('Project Map', () => {
let testManager: TestManager;

beforeAll(async () => {
testManager = await TestManager.createTestManager();
await testManager.ingestCountries();
});

afterEach(async () => {
await testManager.clearTablesByEntities([Project]);
});

afterAll(async () => {
await testManager.clearDatabase();
await testManager.close();
});

test('Should return unique country geometries, with project cost aggregated values', async () => {
const countries = await testManager
.getDataSource()
.getRepository(Country)
.find({ take: 2 });

for (const [index, country] of Object.entries(countries)) {
await testManager.mocks().createProject({
countryCode: country.code,
totalCost: 1000 + parseInt(index),
abatementPotential: 2000 + parseInt(index),
});
await testManager.mocks().createProject({
countryCode: country.code,
totalCost: 1000 + parseInt(index),
abatementPotential: 2000 + parseInt(index),
});
}

const response = await testManager
.request()
.get(projectsContract.getProjectsMap.path);

expect(response.status).toBe(HttpStatus.OK);
expect(response.body.features.length).toBe(2);
expect(response.body.features[0].properties.cost).toBe(2000);
expect(response.body.features[0].properties.abatementPotential).toBe(4000);
expect(response.body.features[1].properties.cost).toBe(2002);
expect(response.body.features[1].properties.abatementPotential).toBe(4002);
});

test('Should return the aggregated values for the filtered projects', async () => {
const mangroveProjectInSpain1 = await testManager.mocks().createProject({
countryCode: 'ESP',
totalCost: 1111,
abatementPotential: 2222,
ecosystem: ECOSYSTEM.MANGROVE,
});
const mangroveProjectInSpain2 = await testManager.mocks().createProject({
countryCode: 'ESP',
totalCost: 3333,
abatementPotential: 4444,
ecosystem: ECOSYSTEM.MANGROVE,
});
const seagrassProjectInSpain = await testManager.mocks().createProject({
countryCode: 'ESP',
totalCost: 5555,
abatementPotential: 6666,
ecosystem: ECOSYSTEM.SEAGRASS,
});
const mangroveProjectInPortugal = await testManager.mocks().createProject({
countryCode: 'PRT',
totalCost: 7777,
abatementPotential: 8888,
ecosystem: ECOSYSTEM.MANGROVE,
});
const seagrassProjectInPortugal = await testManager.mocks().createProject({
countryCode: 'PRT',
totalCost: 9999,
abatementPotential: 10000,
ecosystem: ECOSYSTEM.SEAGRASS,
});

const response = await testManager
.request()
.get(projectsContract.getProjectsMap.path)
.query({ filter: { ecosystem: [ECOSYSTEM.MANGROVE] } });

expect(response.body.features).toHaveLength(2);
expect(response.body.features[0].properties.cost).toBe(
mangroveProjectInSpain1.totalCost + mangroveProjectInSpain2.totalCost,
);
expect(response.body.features[1].properties.abatementPotential).toBe(
mangroveProjectInPortugal.abatementPotential,
);
});
});
9 changes: 8 additions & 1 deletion api/test/utils/test-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ import {
createProject,
createUser,
} from '@shared/lib/entity-mocks';
import { clearTestDataFromDatabase } from '@shared/lib/db-helpers';
import {
clearTablesByEntities,
clearTestDataFromDatabase,
} from '@shared/lib/db-helpers';
import * as path from 'path';
import * as fs from 'fs';
import { BaseData } from '@shared/entities/base-data.entity';
Expand Down Expand Up @@ -60,6 +63,10 @@ export class TestManager {
await clearTestDataFromDatabase(this.dataSource);
}

async clearTablesByEntities(entities: any[]) {
return clearTablesByEntities(this.dataSource, entities);
}

getApp() {
return this.testApp;
}
Expand Down
12 changes: 6 additions & 6 deletions shared/contracts/projects.contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ import {
import { Project } from "@shared/entities/projects.entity";
import { FetchSpecification } from "nestjs-base-service";
import { CountryWithNoGeometry } from "@shared/entities/country.entity";
import { ProjectMap } from "@shared/dtos/projects/projects-map.dto";
import {
ProjectMap,
ProjectMapFilters,
} from "@shared/dtos/projects/projects-map.dto";

const contract = initContract();
export const projectsContract = contract.router({
Expand Down Expand Up @@ -45,10 +48,7 @@ export const projectsContract = contract.router({
},
// TODO: we need to define filters, they should probably match filters for Projects. Or we might want to pass only project ids, which
// would be already filtered
query: z.object({ countryCodes: z.string().array().optional() }).optional(),
//query: z.object({ countryCodes: z.string().array().optional() }).optional(),
query: contract.type<{ filter: ProjectMapFilters }>(),
},
});

export type ProjectMapFilters = z.infer<
typeof projectsContract.getProjectsMap.query
>;
16 changes: 16 additions & 0 deletions shared/dtos/projects/projects-map.dto.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,23 @@
import { z } from "zod";
import { FeatureCollection, Geometry } from "geojson";
import { ProjectGeoPropertiesSchema } from "@shared/schemas/geometries/projects";
import { ACTIVITY, ECOSYSTEM } from "@shared/entities/base-data.entity";
import {
PROJECT_PRICE_TYPE,
PROJECT_SIZE_FILTER,
} from "@shared/entities/projects.entity";

export type ProjectGeoProperties = z.infer<typeof ProjectGeoPropertiesSchema>;

export type ProjectMap = FeatureCollection<Geometry, ProjectGeoProperties>;

export type ProjectMapFilters = {
countryCode?: string[];
totalCost?: number[];
abatementPotential?: number[];
activity?: ACTIVITY;
activitySubtype?: string[];
ecosystem?: ECOSYSTEM;
projectSizeFilter?: PROJECT_SIZE_FILTER;
priceType?: PROJECT_PRICE_TYPE;
};
12 changes: 12 additions & 0 deletions shared/lib/db-helpers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { DataSource, EntityMetadata } from "typeorm";
import { difference } from "lodash";
import { EntityTarget } from "typeorm/common/EntityTarget";
import { ObjectLiteral } from "typeorm/common/ObjectLiteral";

export async function clearTestDataFromDatabase(
dataSource: DataSource,
Expand Down Expand Up @@ -51,3 +53,13 @@ export async function clearTestDataFromDatabase(
await queryRunner.release();
}
}

export async function clearTablesByEntities(
dataSource: DataSource,
entities: EntityTarget<ObjectLiteral>[],
): Promise<void> {
for (const entity of entities) {
const repo = dataSource.getRepository(entity);
await repo.delete({});
}
}
14 changes: 10 additions & 4 deletions shared/schemas/geometries/projects.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import { z } from "zod";
import { FeatureCollection, Geometry } from "geojson";

export const ProjectGeoPropertiesSchema = z.object({
abatementPotential: z.number(),
cost: z.number(),
country: z.string(),
});

export type ProjectGeoProperties = z.infer<typeof ProjectGeoPropertiesSchema>;

export type ProjectMap = FeatureCollection<Geometry, ProjectGeoProperties>;
export const ProjectMapQuerySchema = z.object({
countryCode: z.string().array(),
totalCost: z.number().array(),
abatementPotential: z.number().array(),
activity: z.string().array(),
activitySubtype: z.string().array(),
ecosystem: z.string().array(),
projectSizeFilter: z.number().array(),
priceType: z.string().array(),
});

0 comments on commit c6834a1

Please sign in to comment.