From 24b5d9b705e75cb1c32d83d6191623077f64eec7 Mon Sep 17 00:00:00 2001 From: Alejandro Peralta Date: Mon, 16 Dec 2024 10:46:45 +0100 Subject: [PATCH] feat(api): Search projects with maximum values --- .../modules/projects/projects.controller.ts | 4 +- api/src/modules/projects/projects.service.ts | 46 +++++++++++++++---- .../integration/projects/projects.spec.ts | 32 +++++++++++++ shared/contracts/projects.contract.ts | 12 +++-- shared/dtos/projects/projects.dto.ts | 11 +++++ 5 files changed, 92 insertions(+), 13 deletions(-) create mode 100644 shared/dtos/projects/projects.dto.ts diff --git a/api/src/modules/projects/projects.controller.ts b/api/src/modules/projects/projects.controller.ts index 6ca825c6..dfd5af4a 100644 --- a/api/src/modules/projects/projects.controller.ts +++ b/api/src/modules/projects/projects.controller.ts @@ -24,7 +24,9 @@ export class ProjectsController { @TsRestHandler(projectsContract.getProjects) async getProjects(): ControllerResponse { return tsRestHandler(projectsContract.getProjects, async ({ query }) => { - const data = await this.projectsService.findAllPaginated(query); + const data = query.withMaximums + ? await this.projectsService.findAllProjectsWithMaximums(query) + : await this.projectsService.findAllPaginated(query); return { body: data, status: HttpStatus.OK }; }); } diff --git a/api/src/modules/projects/projects.service.ts b/api/src/modules/projects/projects.service.ts index 92538f33..af471e51 100644 --- a/api/src/modules/projects/projects.service.ts +++ b/api/src/modules/projects/projects.service.ts @@ -1,10 +1,11 @@ import { Injectable } from '@nestjs/common'; import { AppBaseService } from '@api/utils/app-base.service'; import { Project } from '@shared/entities/projects.entity'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, SelectQueryBuilder } from 'typeorm'; +import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; +import { DataSource, Repository, SelectQueryBuilder } from 'typeorm'; import { z } from 'zod'; import { getProjectsQuerySchema } from '@shared/contracts/projects.contract'; +import { PaginatedProjectsWithMaximums } from '@shared/dtos/projects/projects.dto'; export type ProjectFetchSpecificacion = z.infer; @@ -16,26 +17,49 @@ export class ProjectsService extends AppBaseService< unknown > { constructor( + @InjectDataSource() + private readonly dataSource: DataSource, @InjectRepository(Project) public readonly projectRepository: Repository, ) { super(projectRepository, 'project', 'projects'); } - async extendFindAllQuery( + public async findAllProjectsWithMaximums( + query: ProjectFetchSpecificacion, + ): Promise { + const qb = this.dataSource + .createQueryBuilder() + .select('MAX(abatement_potential)::integer', 'maxAbatementPotential') + .addSelect('MAX(total_cost + total_cost_npv)::integer', 'maxTotalCost') + .from(Project, 'project'); + const totalsQuery = this.applySearchFiltersToQueryBuilder(qb, query); + + const [maximums, { metadata, data }] = await Promise.all([ + totalsQuery.getRawOne(), + this.findAllPaginated(query), + ]); + return { + metadata, + maximums, + data, + }; + } + + private applySearchFiltersToQueryBuilder( query: SelectQueryBuilder, fetchSpecification: ProjectFetchSpecificacion, - ): Promise> { + ): SelectQueryBuilder { // Filter by project name if (fetchSpecification.partialProjectName) { - query = query.andWhere('project_name ILIKE :projectName', { + query.andWhere('project_name ILIKE :projectName', { projectName: `%${fetchSpecification.partialProjectName}%`, }); } // Filter by abatement potential if (fetchSpecification.abatementPotentialRange) { - query = query.andWhere( + query.andWhere( 'abatement_potential >= :minAP AND abatement_potential <= :maxAP', { minAP: Math.min(...fetchSpecification.abatementPotentialRange), @@ -57,7 +81,7 @@ export class ProjectsService extends AppBaseService< break; } - query = query.andWhere( + query.andWhere( `${filteredCostColumn} >= :minCost AND ${filteredCostColumn} <= :maxCost`, { minCost: Math.min(...fetchSpecification.costRange), @@ -65,7 +89,13 @@ export class ProjectsService extends AppBaseService< }, ); } - return query; } + + async extendFindAllQuery( + query: SelectQueryBuilder, + fetchSpecification: ProjectFetchSpecificacion, + ): Promise> { + return this.applySearchFiltersToQueryBuilder(query, fetchSpecification); + } } diff --git a/api/test/integration/projects/projects.spec.ts b/api/test/integration/projects/projects.spec.ts index 42ddc6c1..9a0bd379 100644 --- a/api/test/integration/projects/projects.spec.ts +++ b/api/test/integration/projects/projects.spec.ts @@ -324,6 +324,38 @@ describe('Projects', () => { }); }); + test('Should return a list of filtered projects with maximum values', async () => { + await testManager.mocks().createProject({ + id: 'e934e9fe-a79c-40a5-8254-8817851764ad', + projectName: 'PROJ_ABC', + totalCost: 100, + totalCostNPV: 50, + abatementPotential: 10, + }); + await testManager.mocks().createProject({ + id: 'e934e9fe-a79c-40a5-8254-8817851764ae', + projectName: 'PROJ_DEF', + totalCost: 200, + totalCostNPV: 100, + abatementPotential: 20, + }); + + const response = await testManager + .request() + .get(projectsContract.getProjects.path) + .query({ + withMaximums: true, + partialProjectName: 'PROJ', + }); + + expect(response.status).toBe(HttpStatus.OK); + expect(response.body.data).toHaveLength(2); + expect(response.body.maximums).toEqual({ + maxAbatementPotential: 20, + maxTotalCost: 300, + }); + }); + describe('Filters for Projects', () => { test('Should get a list of countries there are projects in', async () => { const fiveCountriesWithNoGeometry = countriesInDb diff --git a/shared/contracts/projects.contract.ts b/shared/contracts/projects.contract.ts index 7f0d9773..40df6bc7 100644 --- a/shared/contracts/projects.contract.ts +++ b/shared/contracts/projects.contract.ts @@ -11,32 +11,36 @@ import { ProjectMap } from "@shared/dtos/projects/projects-map.dto"; import { generateEntityQuerySchema } from "@shared/schemas/query-param.schema"; import { BaseEntity } from "typeorm"; import { ProjectScorecardView } from "@shared/entities/project-scorecard.view"; +import { PaginatedProjectsWithMaximums } from "@shared/dtos/projects/projects.dto"; const contract = initContract(); export type ProjectType = Omit; export type ProjectScorecardType = Omit; -export const otherFilters = z.object({ +export const otherParams = z.object({ costRange: z.coerce.number().array().optional(), abatementPotentialRange: z.coerce.number().array().optional(), costRangeSelector: z.nativeEnum(COST_TYPE_SELECTOR).optional(), partialProjectName: z.string().optional(), + withMaximums: z.coerce.boolean().optional(), }); export const projectsQuerySchema = generateEntityQuerySchema(Project); export const projectScorecardQuerySchema = generateEntityQuerySchema(ProjectScorecard); -export const getProjectsQuerySchema = projectsQuerySchema.merge(otherFilters); +export const getProjectsQuerySchema = projectsQuerySchema.merge(otherParams); export const getProjectScorecardQuerySchema = - projectScorecardQuerySchema.merge(otherFilters); + projectScorecardQuerySchema.merge(otherParams); export const projectsContract = contract.router({ getProjects: { method: "GET", path: "/projects", responses: { - 200: contract.type>(), + 200: contract.type< + ApiPaginationResponse | PaginatedProjectsWithMaximums + >(), }, query: getProjectsQuerySchema, }, diff --git a/shared/dtos/projects/projects.dto.ts b/shared/dtos/projects/projects.dto.ts new file mode 100644 index 00000000..1e8f9daf --- /dev/null +++ b/shared/dtos/projects/projects.dto.ts @@ -0,0 +1,11 @@ +import { PaginationMeta } from "@shared/dtos/global/api-response.dto"; +import { Project } from "@shared/entities/projects.entity"; + +export class PaginatedProjectsWithMaximums { + metadata: PaginationMeta; + data: Partial[]; + maximums: { + maxAbatementPotential: number; + maxCost: number; + }; +}