Skip to content

Commit

Permalink
add general custom project validation
Browse files Browse the repository at this point in the history
  • Loading branch information
alexeh committed Nov 8, 2024
1 parent 1ff554b commit e2d080c
Show file tree
Hide file tree
Showing 11 changed files with 183 additions and 63 deletions.
12 changes: 8 additions & 4 deletions api/src/modules/custom-projects/custom-projects.controller.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { Body, Controller, HttpStatus } from '@nestjs/common';
import { Body, Controller, HttpStatus, ValidationPipe } from '@nestjs/common';
import { CountriesService } from '@api/modules/countries/countries.service';
import { DataSource } from 'typeorm';
import { ModelAssumptions } from '@shared/entities/model-assumptions.entity';
import { tsRestHandler, TsRestHandler } from '@ts-rest/nest';
import { ControllerResponse } from '@api/types/controller-response.type';
import { customProjectContract } from '@shared/contracts/custom-projects.contract';
import { CustomProjectsService } from '@api/modules/custom-projects/custom-projects.service';
import { CreateCustomProjectDto } from '@shared/dtos/custom-projects/create-custom-project.dto';
import {
CreateCustomProjectDtoDeprecated,
CreateCustomProjectDto,
} from '@shared/dtos/custom-projects/create-custom-project-dto.deprecated';

@Controller()
export class CustomProjectsController {
Expand Down Expand Up @@ -44,13 +47,14 @@ export class CustomProjectsController {

@TsRestHandler(customProjectContract.createCustomProject)
async create(
@Body()
@Body(new ValidationPipe({ transform: true }))
dto: CreateCustomProjectDto,
): Promise<ControllerResponse> {
return tsRestHandler(
customProjectContract.createCustomProject,
async ({ body }) => {
const customProject = await this.customProjects.create(dto);
console.log('dto', dto);
const customProject = await this.customProjects.create(dto as any);
return {
status: 201,
body: { data: customProject },
Expand Down
6 changes: 3 additions & 3 deletions api/src/modules/custom-projects/custom-projects.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { AppBaseService } from '@api/utils/app-base.service';
import { CreateCustomProjectDto } from '@shared/dtos/custom-projects/create-custom-project.dto';
import { CreateCustomProjectDtoDeprecated } from '@shared/dtos/custom-projects/create-custom-project-dto.deprecated';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, Repository } from 'typeorm';
import { CustomProject } from '@shared/entities/custom-project.entity';
Expand All @@ -12,7 +12,7 @@ import { ConservationCostCalculator } from '@api/modules/calculations/conservati
@Injectable()
export class CustomProjectsService extends AppBaseService<
CustomProject,
CreateCustomProjectDto,
CreateCustomProjectDtoDeprecated,
unknown,
unknown
> {
Expand All @@ -26,7 +26,7 @@ export class CustomProjectsService extends AppBaseService<
super(repo, 'customProject', 'customProjects');
}

async create(dto: CreateCustomProjectDto): Promise<any> {
async create(dto: CreateCustomProjectDtoDeprecated): Promise<any> {
const { countryCode, ecosystem, activity, projectName } = dto;
const { baseData, baseSize, baseIncrease, defaultAssumptions } =
await this.calculationEngine.getBaseData({
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CreateCustomProjectDto } from '@shared/dtos/custom-projects/create-custom-project.dto';
import { CreateCustomProjectDtoDeprecated } from '@shared/dtos/custom-projects/create-custom-project-dto.deprecated';
import { LOSS_RATE_USED } from '@shared/schemas/custom-projects/create-custom-project.schema';
import { BadRequestException } from '@nestjs/common';
import { BaseDataView } from '@shared/entities/base-data.view';
Expand All @@ -8,7 +8,9 @@ import { ECOSYSTEM } from '@shared/entities/ecosystem.enum';
export class ConservationProjectValidator {
validatedProject: Record<any, any>;
data: BaseDataView;
createDto: CreateCustomProjectDto & { projectSpecificLossRate: number };
createDto: CreateCustomProjectDtoDeprecated & {
projectSpecificLossRate: number;
};
constructor(dto, data: BaseDataView) {
this.createDto = dto;
this.data = data;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { CreateCustomProjectDto } from '@shared/dtos/custom-projects/create-custom-project.dto';
import { CreateCustomProjectDtoDeprecated } from '@shared/dtos/custom-projects/create-custom-project-dto.deprecated';
import { ACTIVITY } from '@shared/entities/activity.enum';
import { ConservationProjectValidator } from '@api/modules/custom-projects/validation/conservation-project.validator';
import { CalculationEngine } from '@api/modules/calculations/calculation.engine';
Expand All @@ -10,7 +9,7 @@ export class CreateCustomProjectValidator {
project: Record<any, any>;
constructor(private readonly calculationEngine: CalculationEngine) {}

async validate(dto: CreateCustomProjectDto) {
async validate(dto: CreateCustomProjectDtoDeprecated) {
const { countryCode, ecosystem, activity, projectName } = dto;
const { baseData } = await this.calculationEngine.getBaseData({
countryCode,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,53 +15,67 @@ describe('Create Custom Projects - Request Validations', () => {
await testManager.close();
});

test(`if lossRateUsed is ${LOSS_RATE_USED.NATIONAL_AVERAGE} then project specific loss rate should not be provided`, async () => {
const response = await testManager
.request()
.post(customProjectContract.createConservationCustomProject.path)
.send({
activity: ACTIVITY.CONSERVATION,
countryCode: 'USA',
ecosystem: ECOSYSTEM.MANGROVE,
lossRateUsed: LOSS_RATE_USED.NATIONAL_AVERAGE,
projectSpecificLossRate: 10,
});
describe('General Custom Project Validations', () => {
test('Should fail if common project parameters are not provided', async () => {
const response = await testManager
.request()
.post(customProjectContract.createCustomProject.path)
.send({});

expect(response.status).toBe(400);
expect(response.body.errors[0].title).toBe(
`Project Specific Loss Rate should not be provided when lossRateUsed is ${LOSS_RATE_USED.NATIONAL_AVERAGE}`,
);
});
test('If loss rate used is project specific, then project specific loss rate should be provided, and it should be a negative number', async () => {
const noProjectSpecificLossRateProvided = await testManager
.request()
.post(customProjectContract.createConservationCustomProject.path)
.send({
activity: ACTIVITY.CONSERVATION,
countryCode: 'USA',
ecosystem: ECOSYSTEM.MANGROVE,
lossRateUsed: LOSS_RATE_USED.PROJECT_SPECIFIC,
});

expect(noProjectSpecificLossRateProvided.status).toBe(400);
expect(noProjectSpecificLossRateProvided.body.title).toBe(
`Project Specific Loss Rate is required when lossRateUsed is ${LOSS_RATE_USED.PROJECT_SPECIFIC}`,
);

const positiveProjectSpecificLossRate = await testManager
.request()
.post(customProjectContract.createConservationCustomProject.path)
.send({
activity: ACTIVITY.CONSERVATION,
countryCode: 'USA',
ecosystem: ECOSYSTEM.MANGROVE,
lossRateUsed: LOSS_RATE_USED.PROJECT_SPECIFIC,
projectSpecificLossRate: 10,
});

expect(positiveProjectSpecificLossRate.status).toBe(400);
expect(positiveProjectSpecificLossRate.body.title).toBe(
'Project Specific Loss Rate should be negative',
);
expect(response.body.errors).toHaveLength(11);
expect(response.body.errors).toMatchObject(GENERAL_VALIDATION_ERRORS);
});
});
});

const GENERAL_VALIDATION_ERRORS = [
{
status: '400',
title: 'countryCode must not be greater than 3',
},
{
status: '400',
title: 'countryCode must not be less than 3',
},
{
status: '400',
title: 'countryCode must be a string',
},
{
status: '400',
title: 'projectName must be a string',
},
{
status: '400',
title:
'activity must be one of the following values: Restoration, Conservation',
},
{
status: '400',
title:
'ecosystem must be one of the following values: Mangrove, Seagrass, Salt marsh',
},
{
status: '400',
title:
'projectsSizeHa must be a number conforming to the specified constraints',
},
{
status: '400',
title:
'initialCarbonPriceAssumption must be a number conforming to the specified constraints',
},
{
status: '400',
title:
'carbonRevenuesToCover must be one of the following values: Opex, Capex and Opex',
},
{
status: '400',
title: 'Invalid parameters for the selected activity type.',
},
{
status: '400',
title: 'parameters should not be empty',
},
];
4 changes: 2 additions & 2 deletions shared/contracts/custom-projects.contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { ApiResponse } from "@shared/dtos/global/api-response.dto";
import { Country } from "@shared/entities/country.entity";
import { ModelAssumptions } from "@shared/entities/model-assumptions.entity";
import { CustomProject } from "@shared/entities/custom-project.entity";
import { CreateCustomProjectDto } from "@shared/dtos/custom-projects/create-custom-project.dto";
import { CreateCustomProjectDtoDeprecated } from "@shared/dtos/custom-projects/create-custom-project-dto.deprecated";

// TODO: This is a scaffold. We need to define types for responses, zod schemas for body and query param validation etc.

Expand Down Expand Up @@ -32,7 +32,7 @@ export const customProjectContract = contract.router({
responses: {
201: contract.type<ApiResponse<CustomProject>>(),
},
body: contract.type<CreateCustomProjectDto>(),
body: contract.type<CreateCustomProjectDtoDeprecated>(),
},
// createConservationCustomProject: {
// method: "POST",
Expand Down
53 changes: 53 additions & 0 deletions shared/dtos/custom-projects/conservation-project-params.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { LOSS_RATE_USED } from "@shared/schemas/custom-projects/create-custom-project.schema";
import {
IsEnum,
IsNegative,
IsNotEmpty,
IsNumber,
ValidateIf,
} from "class-validator";
import { EMISSION_FACTORS_TIER_TYPES } from "@shared/entities/carbon-inputs/emission-factors.entity";
import { CreateCustomProjectDto } from "@shared/dtos/custom-projects/create-custom-project-dto.deprecated";
import { ACTIVITY } from "@shared/entities/activity.enum";

export enum PROJECT_SPECIFIC_EMISSION {
ONE_EMISSION_FACTOR = "One emission factor",
TWO_EMISSION_FACTORS = "Two emission factors",
}
export enum TIER_3_EMISSION_FACTORS {
TIER_3 = "Tier 3 - Project specific emission factor",
}

export class ConservationProjectParamDto {
@ValidateIf((o) => o.acitvity === ACTIVITY.CONSERVATION)
@IsEnum(LOSS_RATE_USED)
lossRateUsed: LOSS_RATE_USED;

@ValidateIf((o) => o.lossRateUsed === LOSS_RATE_USED.PROJECT_SPECIFIC)
@IsNotEmpty({
message:
"Project Specific Loss Rate is required when lossRateUsed is projectSpecific",
})
@IsNumber()
@IsNegative({ message: "Project Specific Loss Rate must be negative" })
projectSpecificLossRate?: number;

@IsEnum(EMISSION_FACTORS_TIER_TYPES || TIER_3_EMISSION_FACTORS)
emissionFactorUsed: EMISSION_FACTORS_TIER_TYPES | TIER_3_EMISSION_FACTORS;

// This should be set at later stages for the calculations, but it is not required for the consumer
//emissionFactor: number;

@ValidateIf(
(o) => o.emissionFactorUsed === EMISSION_FACTORS_TIER_TYPES.TIER_2,
)
emissionFactorAGB: number;

emissionFactorSOC: number;

@ValidateIf((o) => o.emissionFactorUsed === TIER_3_EMISSION_FACTORS.TIER_3)
@IsEnum(PROJECT_SPECIFIC_EMISSION)
projectSpecificEmission: PROJECT_SPECIFIC_EMISSION;

projectSpecificEmissionFactor: number;
}
Original file line number Diff line number Diff line change
@@ -1,18 +1,24 @@
import {
IsEnum,
IsNegative,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
Max,
Min,
Validate,
ValidateIf,
} from "class-validator";
import { ACTIVITY } from "@shared/entities/activity.enum";
import { ECOSYSTEM } from "@shared/entities/ecosystem.enum";
import { LOSS_RATE_USED } from "@shared/schemas/custom-projects/create-custom-project.schema";
import { EMISSION_FACTORS_TIER_TYPES } from "@shared/entities/carbon-inputs/emission-factors.entity";
import { ConservationProjectParamDto } from "@shared/dtos/custom-projects/conservation-project-params.dto";
import { RestorationProjectParamsDto } from "@shared/dtos/custom-projects/restoration-project-params.dto";
import { ProjectParamsValidator } from "@shared/dtos/custom-projects/project-params.validator";

export class CreateCustomProjectDto {
export class CreateCustomProjectDtoDeprecated {
@IsString()
@Min(3)
@Max(3)
Expand Down Expand Up @@ -49,7 +55,7 @@ export enum CARBON_REVENUES_TO_COVER {
CAPEX_AND_OPEX = "Capex and Opex",
}

export class GeneralCustomProjectParam {
export class CreateCustomProjectDto {
@IsString()
@Min(3)
@Max(3)
Expand All @@ -72,4 +78,8 @@ export class GeneralCustomProjectParam {

@IsEnum(CARBON_REVENUES_TO_COVER)
carbonRevenuesToCover: CARBON_REVENUES_TO_COVER;

@IsNotEmpty()
@Validate(ProjectParamsValidator)
parameters: ConservationProjectParamDto | RestorationProjectParamsDto;
}
35 changes: 35 additions & 0 deletions shared/dtos/custom-projects/project-params.validator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import {
ValidatorConstraint,
ValidatorConstraintInterface,
ValidationArguments,
validateSync,
} from "class-validator";

import { ACTIVITY } from "@shared/entities/activity.enum";
import { ConservationProjectParamDto } from "@shared/dtos/custom-projects/conservation-project-params.dto";
import { RestorationProjectParamsDto } from "@shared/dtos/custom-projects/restoration-project-params.dto";
import { CreateCustomProjectDto } from "@shared/dtos/custom-projects/create-custom-project-dto.deprecated";

@ValidatorConstraint({ name: "ProjectParameterValidator", async: false })
export class ProjectParamsValidator implements ValidatorConstraintInterface {
validate(value: CreateCustomProjectDto, args: ValidationArguments): boolean {
const object = args.object as any;
const { activity } = object;

let dto;
if (activity === ACTIVITY.CONSERVATION) {
dto = new ConservationProjectParamDto();
} else if (activity === ACTIVITY.RESTORATION) {
dto = new RestorationProjectParamsDto();
} else {
return false;
}

const validationErrors = validateSync(Object.assign(dto, value));
return validationErrors.length === 0;
}

defaultMessage(args: ValidationArguments): string {
return `Invalid parameters for the selected activity type.`;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export class RestorationProjectParamsDto {}
2 changes: 2 additions & 0 deletions shared/entities/carbon-inputs/emission-factors.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import {
import { Country } from "@shared/entities/country.entity";
import { ECOSYSTEM } from "@shared/entities/ecosystem.enum";

// TODO: The calculations provide a third option Tier 3 which is provided by the user. Do we need to support this in the DB for this entity,
// or would be enough to save the user provided value as metadata for the custom project
export enum EMISSION_FACTORS_TIER_TYPES {
TIER_2 = "Tier 2 - Country-specific emission factor",
TIER_1 = "Tier 1 - Global emission factor",
Expand Down

0 comments on commit e2d080c

Please sign in to comment.