diff --git a/admin/datasource.ts b/admin/datasource.ts index fa59605c..161ef3d4 100644 --- a/admin/datasource.ts +++ b/admin/datasource.ts @@ -29,10 +29,16 @@ import { ImplementationLaborCost } from "@shared/entities/cost-inputs/implementa import { BaseSize } from "@shared/entities/base-size.entity.js"; import { BaseIncrease } from "@shared/entities/base-increase.entity.js"; import { ModelAssumptions } from "@shared/entities/model-assumptions.entity.js"; +import { UserUploadCostInputs } from "@shared/entities/users/user-upload-cost-inputs.entity.js"; +import { UserUploadRestorationInputs } from "@shared/entities/users/user-upload-restoration-inputs.entity.js"; +import { UserUploadConservationInputs } from "@shared/entities/users/user-upload-conservation-inputs.entity.js"; // TODO: If we import the COMMON_DATABASE_ENTITIES from shared, we get an error where DataSouce is not set for a given entity export const ADMINJS_ENTITIES = [ User, + UserUploadCostInputs, + UserUploadRestorationInputs, + UserUploadConservationInputs, ApiEventsEntity, Country, ProjectSize, diff --git a/admin/index.ts b/admin/index.ts index 5e0e41a0..25a33b38 100644 --- a/admin/index.ts +++ b/admin/index.ts @@ -33,6 +33,9 @@ import { ImplementationLaborCostResource } from "./resources/implementation-labo import { BaseSizeResource } from "./resources/base-size/base-size.resource.js"; import { BaseIncreaseResource } from "./resources/base-increase/base-increase.resource.js"; import { ModelAssumptionResource } from "./resources/model-assumptions/model-assumptions.resource.js"; +import { UserUploadCostInputs } from "@shared/entities/users/user-upload-cost-inputs.entity.js"; +import { UserUploadConservationInputs } from "@shared/entities/users/user-upload-conservation-inputs.entity.js"; +import { UserUploadRestorationInputs } from "@shared/entities/users/user-upload-restoration-inputs.entity.js"; AdminJS.registerAdapter({ Database: AdminJSTypeorm.Database, @@ -59,6 +62,36 @@ const start = async () => { componentLoader, resources: [ UserResource, + { + resource: UserUploadCostInputs, + name: "UserUploadCostInputs", + options: { + navigation: { + name: "User Data", + icon: "File", + }, + }, + }, + { + resource: UserUploadConservationInputs, + name: "UserUploadConservationInputs", + options: { + navigation: { + name: "User Data", + icon: "File", + }, + }, + }, + { + resource: UserUploadRestorationInputs, + name: "UserUploadRestorationInputs", + options: { + navigation: { + name: "User Data", + icon: "File", + }, + }, + }, ProjectSizeResource, FeasibilityAnalysisResource, ConservationAndPlanningAdminResource, @@ -119,7 +152,7 @@ const start = async () => { app.listen(PORT, () => { console.log( - `AdminJS started on http://localhost:${PORT}${admin.options.rootPath}` + `AdminJS started on http://localhost:${PORT}${admin.options.rootPath}`, ); }); }; diff --git a/api/src/modules/calculations/assumptions.repository.ts b/api/src/modules/calculations/assumptions.repository.ts new file mode 100644 index 00000000..655e5487 --- /dev/null +++ b/api/src/modules/calculations/assumptions.repository.ts @@ -0,0 +1,60 @@ +import { In, Repository } from 'typeorm'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Injectable } from '@nestjs/common'; +import { ModelAssumptions } from '@shared/entities/model-assumptions.entity'; +import { GetOverridableAssumptionsDTO } from '@shared/dtos/custom-projects/get-overridable-assumptions.dto'; +import { ACTIVITY } from '@shared/entities/activity.enum'; +import { ECOSYSTEM } from '@shared/entities/ecosystem.enum'; +import { + ACTIVITY_PROJECT_LENGTH_NAMES, + COMMON_OVERRIDABLE_ASSUMPTION_NAMES, + ECOSYSTEM_RESTORATION_RATE_NAMES, +} from '@shared/schemas/assumptions/assumptions.enums'; + +@Injectable() +export class AssumptionsRepository extends Repository { + map: Record< + ACTIVITY & ECOSYSTEM, + ECOSYSTEM_RESTORATION_RATE_NAMES & ACTIVITY_PROJECT_LENGTH_NAMES + > = { + [ACTIVITY.CONSERVATION]: ACTIVITY_PROJECT_LENGTH_NAMES.CONSERVATION, + [ACTIVITY.RESTORATION]: ACTIVITY_PROJECT_LENGTH_NAMES.RESTORATION, + [ECOSYSTEM.MANGROVE]: ECOSYSTEM_RESTORATION_RATE_NAMES.MANGROVE, + [ECOSYSTEM.SEAGRASS]: ECOSYSTEM_RESTORATION_RATE_NAMES.SEAGRASS, + [ECOSYSTEM.SALT_MARSH]: ECOSYSTEM_RESTORATION_RATE_NAMES.SALT_MARSH, + }; + constructor( + @InjectRepository(ModelAssumptions) + private repo: Repository, + ) { + super(repo.target, repo.manager, repo.queryRunner); + } + + async getOverridableModelAssumptions(dto: GetOverridableAssumptionsDTO) { + const assumptions = await this.createQueryBuilder('model_assumptions') + .select(['name', 'unit', 'value']) + .where({ + name: In(this.getAssumptionNamesByCountryAndEcosystem(dto)), + }) + .orderBy('name', 'ASC') + .getRawMany(); + if (assumptions.length !== 7) { + throw new Error('Not all required overridable assumptions were found'); + } + return assumptions; + } + + private getAssumptionNamesByCountryAndEcosystem( + dto: GetOverridableAssumptionsDTO, + ): string[] { + const { ecosystem, activity } = dto; + const assumptions = [...COMMON_OVERRIDABLE_ASSUMPTION_NAMES] as string[]; + assumptions.push(this.map[ecosystem]); + assumptions.push(this.map[activity]); + return assumptions; + } + + async getAllModelAssumptions() { + // TODO: To be implemented. We probably don't want to retrieve by find() as we would need to have a constant-like object for the calculations + } +} diff --git a/api/src/modules/calculations/calculations.module.ts b/api/src/modules/calculations/calculations.module.ts index 82002a40..12fd10ee 100644 --- a/api/src/modules/calculations/calculations.module.ts +++ b/api/src/modules/calculations/calculations.module.ts @@ -6,6 +6,7 @@ import { DataRepository } from '@api/modules/calculations/data.repository'; import { ModelAssumptions } from '@shared/entities/model-assumptions.entity'; import { BaseIncrease } from '@shared/entities/base-increase.entity'; import { BaseSize } from '@shared/entities/base-size.entity'; +import { AssumptionsRepository } from '@api/modules/calculations/assumptions.repository'; @Module({ imports: [ @@ -16,7 +17,7 @@ import { BaseSize } from '@shared/entities/base-size.entity'; BaseSize, ]), ], - providers: [CalculationEngine, DataRepository], - exports: [CalculationEngine, DataRepository], + providers: [CalculationEngine, DataRepository, AssumptionsRepository], + exports: [CalculationEngine, DataRepository, AssumptionsRepository], }) export class CalculationsModule {} diff --git a/api/src/modules/calculations/conservation-cost.calculator.ts b/api/src/modules/calculations/conservation-cost.calculator.ts index ad920495..11a79460 100644 --- a/api/src/modules/calculations/conservation-cost.calculator.ts +++ b/api/src/modules/calculations/conservation-cost.calculator.ts @@ -3,8 +3,10 @@ import { DEFAULT_STUFF } from '@api/modules/custom-projects/project-config.inter import { BaseIncrease } from '@shared/entities/base-increase.entity'; import { BaseSize } from '@shared/entities/base-size.entity'; import { SequestrationRatesCalculator } from '@api/modules/calculations/sequestration-rate.calculator'; -import { RESTORATION_ACTIVITY_SUBTYPE } from '@shared/entities/projects.entity'; -import { ACTIVITY } from '@shared/entities/activity.enum'; +import { + ACTIVITY, + RESTORATION_ACTIVITY_SUBTYPE, +} from '@shared/entities/activity.enum'; import { RevenueProfitCalculator } from '@api/modules/calculations/revenue-profit.calculators'; import { Finance } from 'financejs'; diff --git a/api/src/modules/calculations/cost.calculator.ts b/api/src/modules/calculations/cost.calculator.ts index d880d0c9..f954bb5d 100644 --- a/api/src/modules/calculations/cost.calculator.ts +++ b/api/src/modules/calculations/cost.calculator.ts @@ -6,7 +6,7 @@ import { RestorationProjectInput } from '@api/modules/custom-projects/input-fact import { BaseSize } from '@shared/entities/base-size.entity'; import { BaseIncrease } from '@shared/entities/base-increase.entity'; import { - CostInputs, + OverridableCostInputs, PROJECT_DEVELOPMENT_TYPE, } from '@api/modules/custom-projects/dto/project-cost-inputs.dto'; @@ -29,7 +29,7 @@ type CostPlanMap = { // ) {} // } -type CostPlans = Record; +type CostPlans = Record; export enum COST_KEYS { FEASIBILITY_ANALYSIS = 'feasibilityAnalysis', @@ -165,8 +165,9 @@ export class CostCalculator { const totalBaseCost = this.getTotalBaseCost( COST_KEYS.COMMUNITY_REPRESENTATION, ); - const projectDevelopmentType = - this.projectInput.costInputs.otherCommunityCashFlow; + // TODO: TO avoid type crash, fix after cost calculator has all required inputs + const projectDevelopmentType = 'Development'; + // this.projectInput.costInputs.otherCommunityCashFlow; const initialCost = projectDevelopmentType === PROJECT_DEVELOPMENT_TYPE.DEVELOPMENT ? 0 diff --git a/api/src/modules/calculations/data.repository.ts b/api/src/modules/calculations/data.repository.ts index e2622b56..4615a1e1 100644 --- a/api/src/modules/calculations/data.repository.ts +++ b/api/src/modules/calculations/data.repository.ts @@ -1,12 +1,14 @@ -import { Repository } from 'typeorm'; +import { Repository, SelectQueryBuilder } from 'typeorm'; import { BaseDataView } from '@shared/entities/base-data.view'; import { InjectRepository } from '@nestjs/typeorm'; import { Injectable, NotFoundException } from '@nestjs/common'; import { ECOSYSTEM } from '@shared/entities/ecosystem.enum'; -import { ACTIVITY } from '@shared/entities/activity.enum'; -import { GetDefaultCostInputsDto } from '@shared/dtos/custom-projects/get-default-cost-inputs.dto'; -import { CostInputs } from '@api/modules/custom-projects/dto/project-cost-inputs.dto'; -import { ModelAssumptions } from '@shared/entities/model-assumptions.entity'; +import { + ACTIVITY, + RESTORATION_ACTIVITY_SUBTYPE, +} from '@shared/entities/activity.enum'; +import { GetOverridableCostInputs } from '@shared/dtos/custom-projects/get-overridable-cost-inputs.dto'; +import { OverridableCostInputs } from '@api/modules/custom-projects/dto/project-cost-inputs.dto'; import { BaseSize } from '@shared/entities/base-size.entity'; import { BaseIncrease } from '@shared/entities/base-increase.entity'; @@ -17,6 +19,24 @@ export type CarbonInputs = { emissionFactorSoc: BaseDataView['emissionFactorSoc']; }; +const COMMON_OVERRIDABLE_COST_INPUTS = [ + 'feasibilityAnalysis', + 'conservationPlanningAndAdmin', + 'dataCollectionAndFieldCost', + 'communityRepresentation', + 'blueCarbonProjectPlanning', + 'establishingCarbonRights', + 'validation', + 'monitoring', + 'maintenance', + 'communityBenefitSharingFund', + 'carbonStandardFees', + 'baselineReassessment', + 'mrv', + 'longTermProjectOperatingCost', + 'financingCost', +]; + @Injectable() export class DataRepository extends Repository { constructor( @@ -70,41 +90,25 @@ export class DataRepository extends Repository { return defaultCarbonInputs; } - async getDefaultCostInputs( - dto: GetDefaultCostInputsDto, - ): Promise { + async getOverridableCostInputs( + dto: GetOverridableCostInputs, + ): Promise { const { countryCode, activity, ecosystem } = dto; - // The coming CostInput has a implementation labor property which does not exist in the BaseDataView entity, so we use a partial type to avoid the error - const costInputs: Partial = await this.findOne({ - where: { countryCode, activity, ecosystem }, - select: [ - 'feasibilityAnalysis', - 'conservationPlanningAndAdmin', - 'dataCollectionAndFieldCost', - 'communityRepresentation', - 'blueCarbonProjectPlanning', - 'establishingCarbonRights', - 'validation', - 'implementationLaborHybrid', - 'monitoring', - 'maintenance', - 'communityBenefitSharingFund', - 'carbonStandardFees', - 'baselineReassessment', - 'mrv', - 'longTermProjectOperatingCost', - 'financingCost', - 'implementationLaborPlanting', - 'implementationLaborHydrology', - 'otherCommunityCashFlow', - ], + const queryBuilder = this.createQueryBuilder().where({ + countryCode, + activity, + ecosystem, }); + + const selectQueryBuilder = this.buildSelect(queryBuilder, dto); + + const costInputs = await selectQueryBuilder.getRawOne(); if (!costInputs) { throw new NotFoundException( `Could not find default Cost Inputs for country ${countryCode}, activity ${activity} and ecosystem ${ecosystem}`, ); } - return costInputs as CostInputs; + return costInputs; } async getBaseIncreaseAndSize(params: { @@ -128,7 +132,45 @@ export class DataRepository extends Repository { return { baseSize, baseIncrease }; } - async getDefaultModelAssumptions() { - return this.repo.manager.getRepository(ModelAssumptions).find(); + /** + * As of now, only implementation labor has to be dynamically selected based on the restoration activity, if the activity is Restoration + * If the activity is Conservation, the implementation labor should be null or 0 + */ + private buildSelect( + queryBuilder: SelectQueryBuilder, + dto: GetOverridableCostInputs, + ) { + let implementationLaborToSelect: string; + if (dto.activity === ACTIVITY.RESTORATION) { + switch (dto.restorationActivity) { + case RESTORATION_ACTIVITY_SUBTYPE.HYBRID: + implementationLaborToSelect = 'implementationLaborHybrid'; + break; + case RESTORATION_ACTIVITY_SUBTYPE.PLANTING: + implementationLaborToSelect = 'implementationLaborPlanting'; + break; + case RESTORATION_ACTIVITY_SUBTYPE.HYDROLOGY: + implementationLaborToSelect = 'implementationLaborHydrology'; + break; + } + queryBuilder.select( + queryBuilder.alias + '.' + implementationLaborToSelect, + 'implementationLabor', + ); + } + // Set implementation labor to 0 if the activity is Conservation, since there is no implementation labor data for Conservation + if (dto.activity === ACTIVITY.CONSERVATION) { + queryBuilder.select('0', 'implementationLabor'); + } + // Since we are using aliases and selecting columns that are not in the entity, the transformer does not get triggered + // So we manually parse the values to float + for (const name of COMMON_OVERRIDABLE_COST_INPUTS) { + queryBuilder.addSelect( + queryBuilder.alias + '.' + name + ' :: float', + name, + ); + } + + return queryBuilder; } } diff --git a/api/src/modules/calculations/sequestration-rate.calculator.ts b/api/src/modules/calculations/sequestration-rate.calculator.ts index 08421f80..f8e09007 100644 --- a/api/src/modules/calculations/sequestration-rate.calculator.ts +++ b/api/src/modules/calculations/sequestration-rate.calculator.ts @@ -1,6 +1,9 @@ import { ConservationProject } from '@api/modules/custom-projects/conservation.project'; -import { ACTIVITY } from '@shared/entities/activity.enum'; -import { RESTORATION_ACTIVITY_SUBTYPE } from '@shared/entities/projects.entity'; +import { + ACTIVITY, + RESTORATION_ACTIVITY_SUBTYPE, +} from '@shared/entities/activity.enum'; + import { Injectable } from '@nestjs/common'; @Injectable() diff --git a/api/src/modules/custom-projects/custom-projects.controller.ts b/api/src/modules/custom-projects/custom-projects.controller.ts index 0edf0285..057348f9 100644 --- a/api/src/modules/custom-projects/custom-projects.controller.ts +++ b/api/src/modules/custom-projects/custom-projects.controller.ts @@ -31,8 +31,9 @@ export class CustomProjectsController { async getAssumptions(): Promise { return tsRestHandler( customProjectContract.getDefaultAssumptions, - async () => { - const data = await this.customProjects.getDefaultAssumptions(); + async ({ query }) => { + const data: any = + await this.customProjects.getDefaultAssumptions(query); return { body: { data }, status: HttpStatus.OK }; }, ); diff --git a/api/src/modules/custom-projects/custom-projects.service.ts b/api/src/modules/custom-projects/custom-projects.service.ts index df601125..c441b319 100644 --- a/api/src/modules/custom-projects/custom-projects.service.ts +++ b/api/src/modules/custom-projects/custom-projects.service.ts @@ -2,16 +2,17 @@ import { Injectable } from '@nestjs/common'; import { AppBaseService } from '@api/utils/app-base.service'; import { CreateCustomProjectDto } from '@api/modules/custom-projects/dto/create-custom-project-dto'; import { InjectRepository } from '@nestjs/typeorm'; -import { DataSource, Repository } from 'typeorm'; +import { Repository } from 'typeorm'; import { CustomProject } from '@shared/entities/custom-project.entity'; import { CalculationEngine } from '@api/modules/calculations/calculation.engine'; import { CustomProjectInputFactory } from '@api/modules/custom-projects/input-factory/custom-project-input.factory'; -import { GetDefaultCostInputsDto } from '@shared/dtos/custom-projects/get-default-cost-inputs.dto'; +import { GetOverridableCostInputs } from '@shared/dtos/custom-projects/get-overridable-cost-inputs.dto'; import { DataRepository } from '@api/modules/calculations/data.repository'; -import { CostInputs } from '@api/modules/custom-projects/dto/project-cost-inputs.dto'; -import { CustomProjectAssumptionsDto } from '@api/modules/custom-projects/dto/project-assumptions.dto'; +import { OverridableCostInputs } from '@api/modules/custom-projects/dto/project-cost-inputs.dto'; import { CostCalculator } from '@api/modules/calculations/cost.calculator'; import { CustomProjectSnapshotDto } from './dto/custom-project-snapshot.dto'; +import { GetOverridableAssumptionsDTO } from '@shared/dtos/custom-projects/get-overridable-assumptions.dto'; +import { AssumptionsRepository } from '@api/modules/calculations/assumptions.repository'; @Injectable() export class CustomProjectsService extends AppBaseService< @@ -25,8 +26,8 @@ export class CustomProjectsService extends AppBaseService< public readonly repo: Repository, public readonly calculationEngine: CalculationEngine, public readonly dataRepository: DataRepository, + public readonly assumptionsRepository: AssumptionsRepository, public readonly customProjectFactory: CustomProjectInputFactory, - public readonly dataSource: DataSource, ) { super(repo, 'customProject', 'customProjects'); } @@ -67,18 +68,12 @@ export class CustomProjectsService extends AppBaseService< } async getDefaultCostInputs( - dto: GetDefaultCostInputsDto, - ): Promise { - return this.dataRepository.getDefaultCostInputs(dto); + dto: GetOverridableCostInputs, + ): Promise { + return this.dataRepository.getOverridableCostInputs(dto); } - async getDefaultAssumptions(): Promise { - const modelAssumptions = - await this.dataRepository.getDefaultModelAssumptions(); - const projectAssumptions = new CustomProjectAssumptionsDto(); - modelAssumptions.forEach((assumption) => { - projectAssumptions[assumption.name] = assumption.value; - }); - return projectAssumptions; + async getDefaultAssumptions(dto: GetOverridableAssumptionsDTO) { + return this.assumptionsRepository.getOverridableModelAssumptions(dto); } } diff --git a/api/src/modules/custom-projects/dto/create-custom-project-dto.ts b/api/src/modules/custom-projects/dto/create-custom-project-dto.ts index 206072dc..a27928c5 100644 --- a/api/src/modules/custom-projects/dto/create-custom-project-dto.ts +++ b/api/src/modules/custom-projects/dto/create-custom-project-dto.ts @@ -11,8 +11,8 @@ import { ACTIVITY } from '@shared/entities/activity.enum'; import { ECOSYSTEM } from '@shared/entities/ecosystem.enum'; import { ConservationProjectParamDto } from '@api/modules/custom-projects/dto/conservation-project-params.dto'; import { RestorationProjectParamsDto } from '@api/modules/custom-projects/dto/restoration-project-params.dto'; -import { CustomProjectAssumptionsDto } from '@api/modules/custom-projects/dto/project-assumptions.dto'; -import { CostInputs } from '@api/modules/custom-projects/dto/project-cost-inputs.dto'; +import { OverridableAssumptions } from '@api/modules/custom-projects/dto/project-assumptions.dto'; +import { OverridableCostInputs } from '@api/modules/custom-projects/dto/project-cost-inputs.dto'; import { ProjectParamsValidator } from '@api/modules/custom-projects/validation/project-params.validator'; import { Transform, Type } from 'class-transformer'; @@ -45,18 +45,18 @@ export class CreateCustomProjectDto { carbonRevenuesToCover: CARBON_REVENUES_TO_COVER; @ValidateNested() - @Type(() => CustomProjectAssumptionsDto) + @Type(() => OverridableAssumptions) @IsNotEmpty({ message: 'Assumptions are required to create a custom project', }) - assumptions: CustomProjectAssumptionsDto; + assumptions: OverridableAssumptions; @IsNotEmpty({ message: 'Cost inputs are required to create a custom project', }) @ValidateNested() - @Type(() => CostInputs) - costInputs: CostInputs; + @Type(() => OverridableCostInputs) + costInputs: OverridableCostInputs; @IsNotEmpty() @Transform(injectEcosystemToParams) diff --git a/api/src/modules/custom-projects/dto/project-assumptions.dto.ts b/api/src/modules/custom-projects/dto/project-assumptions.dto.ts index 2df8333c..7dcb0531 100644 --- a/api/src/modules/custom-projects/dto/project-assumptions.dto.ts +++ b/api/src/modules/custom-projects/dto/project-assumptions.dto.ts @@ -1,6 +1,6 @@ import { IsNumber } from 'class-validator'; -export class CustomProjectAssumptionsDto { +export class OverridableAssumptions { @IsNumber() verificationFrequency: number; diff --git a/api/src/modules/custom-projects/dto/project-cost-inputs.dto.ts b/api/src/modules/custom-projects/dto/project-cost-inputs.dto.ts index 352e3534..c6ccbc31 100644 --- a/api/src/modules/custom-projects/dto/project-cost-inputs.dto.ts +++ b/api/src/modules/custom-projects/dto/project-cost-inputs.dto.ts @@ -1,11 +1,11 @@ -import { IsEnum, IsNumber } from 'class-validator'; +import { IsNumber } from 'class-validator'; export enum PROJECT_DEVELOPMENT_TYPE { DEVELOPMENT = 'Development', NON_DEVELOPMENT = 'Non-Development', } -export class CostInputs { +export class OverridableCostInputs { @IsNumber() financingCost: number; @@ -53,7 +53,4 @@ export class CostInputs { @IsNumber() implementationLabor: number; - - @IsEnum(PROJECT_DEVELOPMENT_TYPE) - otherCommunityCashFlow: PROJECT_DEVELOPMENT_TYPE | string; } diff --git a/api/src/modules/custom-projects/input-factory/conservation-project.input.ts b/api/src/modules/custom-projects/input-factory/conservation-project.input.ts index dc4369c3..68f64ebc 100644 --- a/api/src/modules/custom-projects/input-factory/conservation-project.input.ts +++ b/api/src/modules/custom-projects/input-factory/conservation-project.input.ts @@ -1,8 +1,8 @@ import { ACTIVITY } from '@shared/entities/activity.enum'; import { ECOSYSTEM } from '@shared/entities/ecosystem.enum'; import { CARBON_REVENUES_TO_COVER } from '@api/modules/custom-projects/dto/create-custom-project-dto'; -import { CostInputs } from '@api/modules/custom-projects/dto/project-cost-inputs.dto'; -import { CustomProjectAssumptionsDto } from '@api/modules/custom-projects/dto/project-assumptions.dto'; +import { OverridableCostInputs } from '@api/modules/custom-projects/dto/project-cost-inputs.dto'; +import { OverridableAssumptions } from '@api/modules/custom-projects/dto/project-assumptions.dto'; import { ConservationProjectParamDto, PROJECT_EMISSION_FACTORS, @@ -36,10 +36,9 @@ export class ConservationProjectInput { emissionFactorSoc: 0, }; - costInputs: CostInputs = new CostInputs(); + costInputs: OverridableCostInputs = new OverridableCostInputs(); - modelAssumptions: CustomProjectAssumptionsDto = - new CustomProjectAssumptionsDto(); + modelAssumptions: OverridableAssumptions = new OverridableAssumptions(); setLossRate( parameters: ConservationProjectParamDto, @@ -71,12 +70,12 @@ export class ConservationProjectInput { return this; } - setModelAssumptions(modelAssumptions: CustomProjectAssumptionsDto): this { + setModelAssumptions(modelAssumptions: OverridableAssumptions): this { this.modelAssumptions = modelAssumptions; return this; } - setCostInputs(costInputs: CostInputs): this { + setCostInputs(costInputs: OverridableCostInputs): this { this.costInputs = costInputs; return this; } diff --git a/api/src/modules/custom-projects/project-config.interface.ts b/api/src/modules/custom-projects/project-config.interface.ts index db27210f..6fd8acb4 100644 --- a/api/src/modules/custom-projects/project-config.interface.ts +++ b/api/src/modules/custom-projects/project-config.interface.ts @@ -1,6 +1,8 @@ import { BaseDataView } from '@shared/entities/base-data.view'; -import { ACTIVITY } from '@shared/entities/activity.enum'; -import { RESTORATION_ACTIVITY_SUBTYPE } from '@shared/entities/projects.entity'; +import { + ACTIVITY, + RESTORATION_ACTIVITY_SUBTYPE, +} from '@shared/entities/activity.enum'; // TODO: This seems to be a mix of assumptions, base sizes and increases. Check with Data export const DEFAULT_STUFF = { diff --git a/api/src/modules/custom-projects/restoration.project.ts b/api/src/modules/custom-projects/restoration.project.ts index cad04eaa..9e10f398 100644 --- a/api/src/modules/custom-projects/restoration.project.ts +++ b/api/src/modules/custom-projects/restoration.project.ts @@ -1,4 +1,7 @@ -import { ACTIVITY } from '@shared/entities/activity.enum'; +import { + ACTIVITY, + RESTORATION_ACTIVITY_SUBTYPE, +} from '@shared/entities/activity.enum'; import { RestorationProjectConfig, DEFAULT_STUFF, @@ -6,7 +9,6 @@ import { import { BaseDataView } from '@shared/entities/base-data.view'; import { CostInputsDeprecated } from '@api/modules/custom-projects/cost-inputs.interface'; import { ModelAssumptions } from '@shared/entities/model-assumptions.entity'; -import { RESTORATION_ACTIVITY_SUBTYPE } from '@shared/entities/projects.entity'; export class RestorationProject { name: string; diff --git a/api/src/modules/import/dtos/excel-projects.dto.ts b/api/src/modules/import/dtos/excel-projects.dto.ts index 437d389b..b1c4f354 100644 --- a/api/src/modules/import/dtos/excel-projects.dto.ts +++ b/api/src/modules/import/dtos/excel-projects.dto.ts @@ -1,9 +1,9 @@ -import { ACTIVITY } from '@shared/entities/activity.enum'; -import { ECOSYSTEM } from '@shared/entities/ecosystem.enum'; import { - PROJECT_PRICE_TYPE, + ACTIVITY, RESTORATION_ACTIVITY_SUBTYPE, -} from '@shared/entities/projects.entity'; +} from '@shared/entities/activity.enum'; +import { ECOSYSTEM } from '@shared/entities/ecosystem.enum'; +import { PROJECT_PRICE_TYPE } from '@shared/entities/projects.entity'; export type ExcelProjects = { project_name: string; diff --git a/api/src/modules/import/import.controller.ts b/api/src/modules/import/import.controller.ts index 9f205a7f..01f4bdc2 100644 --- a/api/src/modules/import/import.controller.ts +++ b/api/src/modules/import/import.controller.ts @@ -1,5 +1,11 @@ -import { Controller, UseGuards, UseInterceptors } from '@nestjs/common'; -import { FileInterceptor } from '@nestjs/platform-express'; +import { + Controller, + HttpStatus, + UploadedFiles, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express'; import { JwtAuthGuard } from '@api/modules/auth/guards/jwt-auth.guard'; import { RolesGuard } from '@api/modules/auth/guards/roles.guard'; import { RequiredRoles } from '@api/modules/auth/decorators/roles.decorator'; @@ -13,6 +19,7 @@ import { ControllerResponse } from '@api/types/controller-response.type'; import { Multer } from 'multer'; import { GetUser } from '@api/modules/auth/decorators/get-user.decorator'; import { User } from '@shared/entities/users/user.entity'; +import { usersContract } from '@shared/contracts/users.contract'; @Controller() @UseGuards(JwtAuthGuard, RolesGuard) @@ -36,4 +43,22 @@ export class ImportController { }; }); } + + @UseInterceptors(FilesInterceptor('files', 2)) + @RequiredRoles(ROLES.PARTNER, ROLES.ADMIN) + @TsRestHandler(usersContract.uploadData) + async uploadData( + @GetUser() user: User, + @UploadedFiles() files: Array, + ): Promise { + return tsRestHandler(usersContract.uploadData, async () => { + const [file1, file2] = files; + const [file1Buffer, file2Buffer] = [file1.buffer, file2.buffer]; + const data = await this.service.importDataProvidedByPartner( + [file1Buffer, file2Buffer], + user.id, + ); + return { body: data, status: HttpStatus.CREATED }; + }); + } } diff --git a/api/src/modules/import/import.service.ts b/api/src/modules/import/import.service.ts index da4de929..b4f5c57f 100644 --- a/api/src/modules/import/import.service.ts +++ b/api/src/modules/import/import.service.ts @@ -8,6 +8,15 @@ import { ImportRepository } from '@api/modules/import/import.repostiory'; import { EventBus } from '@nestjs/cqrs'; import { API_EVENT_TYPES } from '@api/modules/api-events/events.enum'; import { ImportEvent } from '@api/modules/import/events/import.event'; +import { DataSource } from 'typeorm'; +import { + userDataConservationInputMapJsonToEntity, + userDataCostInputsMapJsonToEntity, + userDataRestorationInputMapJsonToEntity, +} from '@api/modules/import/services/user-data-parser'; +import { UserUploadCostInputs } from '@shared/entities/users/user-upload-cost-inputs.entity'; +import { UserUploadRestorationInputs } from '@shared/entities/users/user-upload-restoration-inputs.entity'; +import { UserUploadConservationInputs } from '@shared/entities/users/user-upload-conservation-inputs.entity'; @Injectable() export class ImportService { @@ -24,6 +33,7 @@ export class ImportService { private readonly importRepo: ImportRepository, private readonly preprocessor: EntityPreprocessor, private readonly eventBus: EventBus, + private readonly dataSource: DataSource, ) {} async import(fileBuffer: Buffer, userId: string) { @@ -44,4 +54,36 @@ export class ImportService { registerImportEvent(userId: string, eventType: typeof this.eventMap) { this.eventBus.publish(new ImportEvent(eventType, userId, {})); } + + async importDataProvidedByPartner(fileBuffers: Buffer[], userId: string) { + // TODO: Debt, add event handling + const { costInputs, carbonInputs } = + await this.excelParser.parseUserExcels(fileBuffers); + const mappedCostInputs = userDataCostInputsMapJsonToEntity( + costInputs, + userId, + ); + const mappedRestorationInputs = userDataRestorationInputMapJsonToEntity( + carbonInputs.restoration, + userId, + ); + const mappedConservationInputs = userDataConservationInputMapJsonToEntity( + carbonInputs.conservation, + userId, + ); + await this.dataSource.transaction(async (manager) => { + const userCostInputsRepo = manager.getRepository(UserUploadCostInputs); + const userRestorationInputsRepo = manager.getRepository( + UserUploadRestorationInputs, + ); + const userConservationInputsRepo = manager.getRepository( + UserUploadConservationInputs, + ); + await userCostInputsRepo.save(mappedCostInputs); + await userRestorationInputsRepo.save(mappedRestorationInputs); + await userConservationInputsRepo.save(mappedConservationInputs); + }); + // + return carbonInputs; + } } diff --git a/api/src/modules/import/services/entity.preprocessor.ts b/api/src/modules/import/services/entity.preprocessor.ts index a73a5465..c44c9bd9 100644 --- a/api/src/modules/import/services/entity.preprocessor.ts +++ b/api/src/modules/import/services/entity.preprocessor.ts @@ -1107,7 +1107,7 @@ export class EntityPreprocessor { project.countryCode = row.country_code; project.ecosystem = row.ecosystem; project.activity = row.activity; - project.activitySubtype = row.activity_type; + project.restorationActivity = row.activity_type; project.projectSize = row.project_size_ha; project.projectSizeFilter = row.project_size_filter; project.abatementPotential = row.aAbatement_potential; diff --git a/api/src/modules/import/services/excel-parser.interface.ts b/api/src/modules/import/services/excel-parser.interface.ts index 0f9bb1d6..315b8e94 100644 --- a/api/src/modules/import/services/excel-parser.interface.ts +++ b/api/src/modules/import/services/excel-parser.interface.ts @@ -32,4 +32,6 @@ export const SHEETS_TO_PARSE = [ export interface ExcelParserInterface { parseExcel(data: Buffer): Promise; + + parseUserExcels(data: Buffer[]): Promise; } diff --git a/api/src/modules/import/services/user-data-parser.ts b/api/src/modules/import/services/user-data-parser.ts new file mode 100644 index 00000000..edc711fa --- /dev/null +++ b/api/src/modules/import/services/user-data-parser.ts @@ -0,0 +1,150 @@ +// I feel dirty doing this... + +import { UserUploadCostInputs } from '@shared/entities/users/user-upload-cost-inputs.entity'; +import { UserUploadRestorationInputs } from '@shared/entities/users/user-upload-restoration-inputs.entity'; +import { UserUploadConservationInputs } from '@shared/entities/users/user-upload-conservation-inputs.entity'; + +export function userDataCostInputsMapJsonToEntity( + inputJson: Record, + userId: string, +): Partial { + return { + programName: inputJson['Program name (if willing to share)'], + intendedLengthOfProject: inputJson['Intended length of project'], + country: inputJson['Country'], + currency: inputJson['Currency'], + projectStartYear: inputJson['Project start year'], + projectActivity: inputJson['Project activity'], + ecosystem: inputJson['Ecosystem'], + projectSize: inputJson['Project size'], + validationStandard: + inputJson['Validation standard / accrediting organization'], + numberOfLocalIndividuals: + inputJson[ + 'Number of local individuals who own, work, and/or live in the project site (e.g., land tenure)' + ], + cityOrRegion: inputJson['City / province / region / state of project'], + intendedAlternativeUseOfLand: inputJson['Intended alternative use of land'], + landOwnershipBeforeProject: inputJson['Land ownership before project'], + sdgsBenefitted: inputJson['SDGs benefitted / co-benefitted'], + projectEligibleForCarbonCredits: + inputJson['Project eligible for voluntary carbon credits?'] === 'yes', + willingToSpeakAboutPricing: + inputJson[ + 'Are you willing to speak with us about your carbon credit pricing?' + ] === 'yes', + ableToProvideDetailedCostDocumentation: + inputJson[ + 'Are you able to provide additional detailed cost documentation for the project?' + ] === 'yes', + costCategories: inputJson['Cost categories '], + establishingCommunityEngagement: + inputJson['Establishing community engagement / buy-in'], + conservationProjectPlanning: + inputJson['Conservation project planning & administration '], + carbonProjectPlanning: + inputJson['Carbon project planning & administration'], + landCost: inputJson['Land cost'], + financingCost: inputJson['Financing cost'], + materialsSeedsFertilizer: + inputJson['Materials (e.g., seeds, fertilizer, seedlings)'], + materialsMachineryEquipment: + inputJson['Materials (e.g., machinery, equipment, etc.)'], + projectLaborActivity: inputJson['Project labor / activity'], + engineeringIntervention: + inputJson['Engineering / construction intervention'], + ongoingCommunityEngagement: inputJson['Ongoing community engagement'], + otherProjectRunningCost: inputJson['Other project running cost'], + projectMonitoring: inputJson['Project monitoring'], + otherCost1: inputJson['1) Other cost (please specify activities)'], + otherCost2: inputJson['2) Other cost (please specify activities)'], + otherCost3: inputJson['3) Other cost (please specify activities)'], + projectCumulativeSequestration: + inputJson['Project site cumulative sequestration / carbon stock'], + detailedProjectActivity: + inputJson[ + 'Please describe in detail the project activity (e.g., planted mangrove seedlings, set up perimeter around conservation area)' + ], + communityEngagementSpending: + inputJson[ + 'When you kicked off the project, how did you spend to engage the community...' + ], + landRightsAndEasements: + inputJson[ + 'How did you acquire the rights to establish the project on the land?...' + ], + hourlyWageRate: + inputJson[ + 'What was the hourly wage rate paid for labor? How many hours worked for each activity?' + ], + ongoingCommunityCompensation: + inputJson[ + 'Please describe the ongoing community engagement for your project.' + ], + engineeringDetails: + inputJson[ + 'Did you undertake any engineering / construction interventions for your project?' + ], + user: { id: userId } as any, + }; +} + +export function userDataRestorationInputMapJsonToEntity( + data: Record, + userId: string, +): Partial { + return { + projectName: data['Project name'], + country: data['Country'], + cityOrRegion: data['City / province / region / state of project'], + projectStartYear: data['Project start year'], + mostRecentYearOfData: data['Most recent year of data collection'], + ecosystem: data['Ecosystem'], + projectActivity: data['Project activity']?.toString(), // Convertir a string si es numérico + projectSizeAreaStudied: data['Project size / area studied'], + categories: data['Categories'], + projectArea: data['Project area '], + abovegroundBiomassStock: data['Aboveground biomass stock'], + belowgroundBiomassStock: data['Belowground biomass stock '], + soilOrganicCarbonStock: data['Soil organic carbon stock '], + methaneEmissions: data['Methane emissions '], + nitrousOxideEmissions: data['Nitrous oxide emissions '], + abovegroundBiomassEmissionsFactor: + data['Aboveground biomass emissions factor'], + belowgroundBiomassEmissionsFactor: + data['Belowground biomass emissions factor'], + soilOrganicCarbonEmissionsFactor: + data['Soil organic carbon emissions factor '], + user: { id: userId } as any, + }; +} + +export function userDataConservationInputMapJsonToEntity( + data: Record, + userId: string, +): Partial { + return { + projectName: data['Project name'], + country: data['Country'], + cityOrRegion: data['City / province / region / state of project'], + projectStartYear: data['Project start year'], + mostRecentYearOfData: data['Most recent year of data collection'], + ecosystem: data['Ecosystem'], + projectActivity: data['Project activity']?.toString(), // Convertir a string si es numérico + projectSizeAreaStudied: data['Project size / area studied'], + categories: data['Categories'], + projectArea: data['Project area '], + abovegroundBiomassStock: data['Aboveground biomass stock'], + belowgroundBiomassStock: data['Belowground biomass stock '], + soilOrganicCarbonStock: data['Soil organic carbon stock '], + methaneEmissions: data['Methane emissions '], + nitrousOxideEmissions: data['Nitrous oxide emissions '], + abovegroundBiomassEmissionsFactor: + data['Aboveground biomass emissions factor'], + belowgroundBiomassEmissionsFactor: + data['Belowground biomass emissions factor'], + soilOrganicCarbonEmissionsFactor: + data['Soil organic carbon emissions factor '], + user: { id: userId } as any, + }; +} diff --git a/api/src/modules/import/services/xlsx.parser.ts b/api/src/modules/import/services/xlsx.parser.ts index d69ba019..caf2d698 100644 --- a/api/src/modules/import/services/xlsx.parser.ts +++ b/api/src/modules/import/services/xlsx.parser.ts @@ -21,4 +21,99 @@ export class XlsxParser implements ExcelParserInterface { return parsedData; } + + async parseUserExcels(data: Buffer[]) { + const carbonInputs: WorkBook = read(data[0]); + const costInputs: WorkBook = read(data[1]); + + const restorationSheet: WorkSheet = carbonInputs.Sheets['Restoration']; + const conservationSheet: WorkSheet = carbonInputs.Sheets['Conservation']; + const restoration = parseRestorationSheet(restorationSheet); + const costInputSheet = costInputs.Sheets['Input']; + const conservation = parseConservationSheet(conservationSheet); + + const costInput: Record = {}; + const keysToIgnore = [ + 'Input data into blue shade cells', + 'General information', + 'Project information', + ]; + + Object.keys(costInputSheet).forEach((cellKey) => { + if (!cellKey.startsWith('B')) return; // Ignore cells that are not in column B + + const questionCell = costInputSheet[cellKey]; + const question = questionCell?.v; + + if (question && !keysToIgnore.includes(question)) { + // Answer is in the column C or D of the same row + const rowIndex = cellKey.match(/\d+/)?.[0]; // extract row number from cell key + const answerCellKey = `C${rowIndex}`; + const answerCell = + costInputSheet[answerCellKey] || costInputSheet[`D${rowIndex}`]; + + const answer = answerCell?.v || 'No value provided'; + costInput[question] = answer; + } + }); + + return { + carbonInputs: { restoration, conservation }, + costInputs: costInput, + }; + } +} + +function parseRestorationSheet(sheet: WorkSheet): Record { + const result: Record = {}; + + const keysToIgnore = [ + 'Sub-national / project sequestration information', + 'General information', + ]; + + Object.keys(sheet).forEach((cellKey) => { + if (!cellKey.startsWith('B')) return; + + const questionCell = sheet[cellKey]; + const question = questionCell?.v; + + if (question && !keysToIgnore.includes(question)) { + const rowIndex = cellKey.match(/\d+/)?.[0]; + const answerCellKey = `C${rowIndex}`; + const answerCell = sheet[answerCellKey]; + + const answer = answerCell?.v || 'No value provided'; + result[question] = answer; + } + }); + + return result; +} + +function parseConservationSheet(sheet: WorkSheet): Record { + const result: Record = {}; + + const keysToIgnore = [ + 'Sub-national / project sequestration information', + 'General information', + ]; + + Object.keys(sheet).forEach((cellKey) => { + if (!cellKey.startsWith('B')) return; + + const questionCell = sheet[cellKey]; + const question = questionCell?.v; + + if (question && !keysToIgnore.includes(question)) { + const rowIndex = cellKey.match(/\d+/)?.[0]; + const answerCellKey = `C${rowIndex}`; + const answerCell = sheet[answerCellKey]; + + const answer = answerCell?.v || 'No value provided'; + result[question] = answer; + } + }); + + return result; } diff --git a/api/src/modules/users/users.module.ts b/api/src/modules/users/users.module.ts index 64a02288..9810333c 100644 --- a/api/src/modules/users/users.module.ts +++ b/api/src/modules/users/users.module.ts @@ -4,9 +4,20 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { User } from '@shared/entities/users/user.entity'; import { UsersController } from '@api/modules/users/users.controller'; import { AuthModule } from '@api/modules/auth/auth.module'; +import { UserUploadCostInputs } from '@shared/entities/users/user-upload-cost-inputs.entity'; +import { UserUploadRestorationInputs } from '@shared/entities/users/user-upload-restoration-inputs.entity'; +import { UserUploadConservationInputs } from '@shared/entities/users/user-upload-conservation-inputs.entity'; @Module({ - imports: [TypeOrmModule.forFeature([User]), forwardRef(() => AuthModule)], + imports: [ + TypeOrmModule.forFeature([ + User, + UserUploadCostInputs, + UserUploadRestorationInputs, + UserUploadConservationInputs, + ]), + forwardRef(() => AuthModule), + ], providers: [UsersService], exports: [UsersService], controllers: [UsersController], diff --git a/api/test/integration/custom-projects/custom-projects-create.spec.ts b/api/test/integration/custom-projects/custom-projects-create.spec.ts index ca07408a..b28b4347 100644 --- a/api/test/integration/custom-projects/custom-projects-create.spec.ts +++ b/api/test/integration/custom-projects/custom-projects-create.spec.ts @@ -16,7 +16,7 @@ describe('Create Custom Projects - Setup', () => { await testManager.close(); }); - describe('TEMPORAL, FOR REFERENCE', () => { + describe.skip('TEMPORAL, FOR REFERENCE', () => { test('Should generate a conservation project input object that will be used for calculations', async () => { const response = await testManager .request() diff --git a/api/test/integration/custom-projects/custom-projects-setup.spec.ts b/api/test/integration/custom-projects/custom-projects-setup.spec.ts index 6d2c0a94..07b359d9 100644 --- a/api/test/integration/custom-projects/custom-projects-setup.spec.ts +++ b/api/test/integration/custom-projects/custom-projects-setup.spec.ts @@ -20,6 +20,31 @@ describe('Create Custom Projects - Setup', () => { await testManager.close(); }); + describe('Get Overridable Model Assumptions', () => { + test('Should return overridable model assumptions based on ecosystem and activity', async () => { + const response = await testManager + .request() + .get(customProjectContract.getDefaultAssumptions.path) + .query({ + ecosystem: ECOSYSTEM.MANGROVE, + activity: ACTIVITY.CONSERVATION, + }); + + expect(response.body.data).toHaveLength(7); + expect(response.body.data.map((assumptions) => assumptions.name)).toEqual( + [ + 'Baseline reassessment frequency', + 'Buffer', + 'Carbon price increase', + 'Conservation project length', + 'Discount rate', + 'Mangrove restoration rate', + 'Verification frequency', + ], + ); + }); + }); + test('Should return the list of countries that are available to create a custom project', async () => { const response = await testManager .request() @@ -54,7 +79,7 @@ describe('Create Custom Projects - Setup', () => { establishingCarbonRights: 46666.666666666664, financingCost: 0.05, validation: 50000, - implementationLaborHybrid: null, + implementationLabor: 0, monitoring: 8400, maintenance: 0.0833, carbonStandardFees: 0.2, diff --git a/client/next.config.mjs b/client/next.config.mjs index 4678774e..b0c7f685 100644 --- a/client/next.config.mjs +++ b/client/next.config.mjs @@ -1,4 +1,32 @@ /** @type {import('next').NextConfig} */ -const nextConfig = {}; +const nextConfig = { + webpack(config) { + const warning = [ + ...(config.ignoreWarnings || []), + { + module: /typeorm/, + message: /the request of a dependency is an expression/, + }, + { + module: /typeorm/, + message: /Can't resolve 'react-native-sqlite-storage'/, + }, + { + module: /typeorm/, + message: /Can't resolve '@sap\/hana-client\/extension\/Stream'/, + }, + { + module: /typeorm/, + message: /Can't resolve 'mysql'/, + }, + { + module: /app-root-path/, + message: /the request of a dependency is an expression/, + }, + ]; + config.ignoreWarnings = warning; + return config; + }, +}; export default nextConfig; diff --git a/client/package.json b/client/package.json index 7ff418c1..ce79b8ee 100644 --- a/client/package.json +++ b/client/package.json @@ -13,6 +13,7 @@ "@hookform/resolvers": "3.9.0", "@lukemorales/query-key-factory": "1.3.4", "@radix-ui/react-alert-dialog": "1.1.2", + "@radix-ui/react-avatar": "^1.1.1", "@radix-ui/react-checkbox": "1.1.2", "@radix-ui/react-dialog": "1.1.2", "@radix-ui/react-dropdown-menu": "^2.1.2", @@ -44,6 +45,7 @@ "nuqs": "2.0.4", "react": "^18", "react-dom": "^18", + "react-dropzone": "^14.3.5", "react-map-gl": "7.1.7", "react-resizable-panels": "2.1.6", "rooks": "7.14.1", diff --git a/client/public/forms/carbon-input-template.xlsx b/client/public/forms/carbon-input-template.xlsx new file mode 100644 index 00000000..1f8fb432 Binary files /dev/null and b/client/public/forms/carbon-input-template.xlsx differ diff --git a/client/public/forms/cost-input-template.xlsx b/client/public/forms/cost-input-template.xlsx new file mode 100644 index 00000000..b47afea2 Binary files /dev/null and b/client/public/forms/cost-input-template.xlsx differ diff --git a/client/src/app/(overview)/url-store.ts b/client/src/app/(overview)/url-store.ts index 7f139643..44e6207e 100644 --- a/client/src/app/(overview)/url-store.ts +++ b/client/src/app/(overview)/url-store.ts @@ -8,10 +8,12 @@ import { PROJECT_PRICE_TYPE, PROJECT_SIZE_FILTER, } from "@shared/entities/projects.entity"; +import { useAtom } from "jotai"; import { parseAsJson, parseAsStringLiteral, useQueryState } from "nuqs"; import { z } from "zod"; import { FILTER_KEYS } from "@/app/(overview)/constants"; +import { popupAtom } from "@/app/(overview)/store"; import { INITIAL_COST_RANGE, @@ -48,10 +50,22 @@ export const INITIAL_FILTERS_STATE: z.infer = { }; export function useGlobalFilters() { - return useQueryState( + const [popup, setPopup] = useAtom(popupAtom); + const [filters, setFilters] = useQueryState( "filters", parseAsJson(filtersSchema.parse).withDefault(INITIAL_FILTERS_STATE), ); + + const updateFilters = async ( + updater: ( + prev: typeof INITIAL_FILTERS_STATE, + ) => typeof INITIAL_FILTERS_STATE, + ) => { + await setFilters(updater); + if (popup) setPopup(null); + }; + + return [filters, updateFilters] as const; } export function useTableView() { diff --git a/client/src/app/auth/(password)/forgot-password/layout.tsx b/client/src/app/auth/(password)/forgot-password/layout.tsx index 394aaa6f..16574b33 100644 --- a/client/src/app/auth/(password)/forgot-password/layout.tsx +++ b/client/src/app/auth/(password)/forgot-password/layout.tsx @@ -2,13 +2,13 @@ import { PropsWithChildren } from "react"; import type { Metadata } from "next"; +import AuthLayout from "@/containers/auth-layout"; + export const metadata: Metadata = { title: "Forgot password", description: "Forgot password | Blue Carbon Cost Tool", }; export default function ForgotPasswordLayout({ children }: PropsWithChildren) { - return ( -
{children}
- ); + return {children}; } diff --git a/client/src/app/auth/api/[...nextauth]/config.ts b/client/src/app/auth/api/[...nextauth]/config.ts index 3a606284..a7672551 100644 --- a/client/src/app/auth/api/[...nextauth]/config.ts +++ b/client/src/app/auth/api/[...nextauth]/config.ts @@ -28,8 +28,6 @@ declare module "next-auth/jwt" { } } -const PROJECT_NAME = "tnc-blue-carbon"; - export const config = { providers: [ Credentials({ @@ -83,22 +81,6 @@ export const config = { }; }, }, - cookies: { - sessionToken: { - name: `next-auth.session-token.${PROJECT_NAME}`, - options: { - secure: process.env.NODE_ENV === "production", - sameSite: "lax", - path: "/", - }, - }, - callbackUrl: { - name: `next-auth.callback-url.${PROJECT_NAME}`, - }, - csrfToken: { - name: `next-auth.csrf-token.${PROJECT_NAME}`, - }, - }, pages: { signIn: "/auth/signin", signOut: "/", diff --git a/client/src/app/auth/confirm-email/[token]/page.tsx b/client/src/app/auth/confirm-email/[token]/page.tsx index fbffb70b..32943fac 100644 --- a/client/src/app/auth/confirm-email/[token]/page.tsx +++ b/client/src/app/auth/confirm-email/[token]/page.tsx @@ -1,5 +1,10 @@ import ConfirmEmailForm from "@/containers/auth/confirm-email/form"; +import AuthLayout from "@/containers/auth-layout"; export default function ConfirmEmailPage() { - return ; + return ( + + + + ); } diff --git a/client/src/app/auth/signin/page.tsx b/client/src/app/auth/signin/page.tsx index 76c7db1e..2c7817dd 100644 --- a/client/src/app/auth/signin/page.tsx +++ b/client/src/app/auth/signin/page.tsx @@ -1,8 +1,16 @@ import { redirect } from "next/navigation"; +import { Metadata } from "next"; + import { auth } from "@/app/auth/api/[...nextauth]/config"; import SignIn from "@/containers/auth/signin"; +import AuthLayout from "@/containers/auth-layout"; + +export const metadata: Metadata = { + title: "Sign in", + description: "Sign in | Blue Carbon Cost Tool", +}; export default async function SignInPage() { const session = await auth(); @@ -11,5 +19,9 @@ export default async function SignInPage() { redirect("/profile"); } - return ; + return ( + + + + ); } diff --git a/client/src/app/auth/signup/[token]/page.tsx b/client/src/app/auth/signup/[token]/page.tsx index b9a5e649..a0bb4095 100644 --- a/client/src/app/auth/signup/[token]/page.tsx +++ b/client/src/app/auth/signup/[token]/page.tsx @@ -1,5 +1,10 @@ -import SignUp from "@/containers/auth/signup"; +import SetPassword from "@/containers/auth/set-password"; +import AuthLayout from "@/containers/auth-layout"; -export default async function SignUpPage() { - return ; +export default async function SetPasswordPage() { + return ( + + + + ); } diff --git a/client/src/app/auth/signup/page.tsx b/client/src/app/auth/signup/page.tsx new file mode 100644 index 00000000..b76f4dd4 --- /dev/null +++ b/client/src/app/auth/signup/page.tsx @@ -0,0 +1,27 @@ +import { redirect } from "next/navigation"; + +import { Metadata } from "next"; + +import { auth } from "@/app/auth/api/[...nextauth]/config"; + +import SignUp from "@/containers/auth/signup"; +import AuthLayout from "@/containers/auth-layout"; + +export const metadata: Metadata = { + title: "Create an account", + description: "Create an account | Blue Carbon Cost Tool", +}; + +export default async function SignInPage() { + const session = await auth(); + + if (session) { + redirect("/profile"); + } + + return ( + + + + ); +} diff --git a/client/src/components/ui/avatar.tsx b/client/src/components/ui/avatar.tsx new file mode 100644 index 00000000..6f2aacb2 --- /dev/null +++ b/client/src/components/ui/avatar.tsx @@ -0,0 +1,51 @@ +"use client"; + +import * as React from "react"; + +import * as AvatarPrimitive from "@radix-ui/react-avatar"; + +import { cn } from "@/lib/utils"; + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Avatar.displayName = AvatarPrimitive.Root.displayName; + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarImage.displayName = AvatarPrimitive.Image.displayName; + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; + +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/client/src/components/ui/button.tsx b/client/src/components/ui/button.tsx index 3ca4e8f2..89d0ace8 100644 --- a/client/src/components/ui/button.tsx +++ b/client/src/components/ui/button.tsx @@ -6,7 +6,7 @@ import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/lib/utils"; const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-blue-300/40 disabled:pointer-events-none disabled:opacity-50", + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full text-sm font-normal transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-blue-300/40 disabled:pointer-events-none disabled:opacity-50", { variants: { variant: { diff --git a/client/src/components/ui/card.tsx b/client/src/components/ui/card.tsx index d569cfec..78d0e11c 100644 --- a/client/src/components/ui/card.tsx +++ b/client/src/components/ui/card.tsx @@ -1,20 +1,34 @@ import * as React from "react"; +import { cva, VariantProps } from "class-variance-authority"; + import { cn } from "@/lib/utils"; -const Card = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => ( -
-)); +const cardVariants = cva("rounded-2xl text-card-foreground border shadow p-4", { + variants: { + variant: { + default: "bg-card", + secondary: "bg-transparent", + }, + }, + defaultVariants: { + variant: "default", + }, +}); + +export interface CardProps + extends React.HTMLAttributes, + VariantProps {} + +const Card = React.forwardRef( + ({ className, variant, ...props }, ref) => ( +
+ ), +); Card.displayName = "Card"; const CardHeader = React.forwardRef< diff --git a/client/src/components/ui/input.tsx b/client/src/components/ui/input.tsx index 41241808..ab2e2a8f 100644 --- a/client/src/components/ui/input.tsx +++ b/client/src/components/ui/input.tsx @@ -5,12 +5,12 @@ import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/lib/utils"; const inputVariants = cva( - "flex h-9 w-full rounded-full border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50", + "flex h-9 w-full rounded-full border border-input bg-transparent px-3 py-2 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50", { variants: { variant: { default: - "bg-big-stone-950 text-big-stone-900 shadow border-border focus-visible:border-primary focus-visible:ring-sky-blue-300/40", + "bg-big-stone-950 text-foreground shadow border-border focus-visible:border-primary focus-visible:ring-sky-blue-300/40", ghost: "text-foreground border-0 focus-visible:ring-sky-blue-300/40", }, }, diff --git a/client/src/components/ui/scroll-area.tsx b/client/src/components/ui/scroll-area.tsx index 5ea3aa6f..24c7b263 100644 --- a/client/src/components/ui/scroll-area.tsx +++ b/client/src/components/ui/scroll-area.tsx @@ -15,7 +15,7 @@ const ScrollArea = React.forwardRef< className={cn("relative overflow-hidden", className)} {...props} > - + {children} @@ -46,4 +46,4 @@ const ScrollBar = React.forwardRef< )); ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName; -export default { ScrollArea, ScrollBar }; +export { ScrollArea, ScrollBar }; diff --git a/client/src/containers/auth-layout/index.tsx b/client/src/containers/auth-layout/index.tsx new file mode 100644 index 00000000..9b3e4c59 --- /dev/null +++ b/client/src/containers/auth-layout/index.tsx @@ -0,0 +1,31 @@ +import { FC, PropsWithChildren } from "react"; + +import { cn } from "@/lib/utils"; + +import { SidebarTrigger } from "@/components/ui/sidebar"; + +type AuthLayout = PropsWithChildren<{ + className?: HTMLDivElement["className"]; +}>; + +const AuthLayout: FC = ({ className, children }) => { + return ( +
+
+ +

User area

+
+
+
+
+ {children} +
+
+
+
+ ); +}; + +export default AuthLayout; diff --git a/client/src/containers/auth/confirm-email/form/index.tsx b/client/src/containers/auth/confirm-email/form/index.tsx index 62a578ab..84289988 100644 --- a/client/src/containers/auth/confirm-email/form/index.tsx +++ b/client/src/containers/auth/confirm-email/form/index.tsx @@ -9,13 +9,20 @@ import { useParams, useRouter, useSearchParams } from "next/navigation"; import { zodResolver } from "@hookform/resolvers/zod"; import { TOKEN_TYPE_ENUM } from "@shared/schemas/auth/token-type.schema"; import { RequestEmailUpdateSchema } from "@shared/schemas/users/request-email-update.schema"; -import { useQuery } from "@tanstack/react-query"; +import { useSuspenseQuery } from "@tanstack/react-query"; import { z } from "zod"; import { client } from "@/lib/query-client"; import { queryKeys } from "@/lib/query-keys"; import { Button } from "@/components/ui/button"; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, +} from "@/components/ui/card"; import { Form, FormControl, @@ -45,7 +52,7 @@ const NewPasswordForm: FC = () => { data: isValidToken, isFetching, isError, - } = useQuery({ + } = useSuspenseQuery({ queryKey: queryKeys.auth.confirmEmailToken(params.token).queryKey, queryFn: () => { return client.auth.validateToken.query({ @@ -93,46 +100,47 @@ const NewPasswordForm: FC = () => { const isDisabled = isFetching || isError || !isValidToken; return ( -
-
-

Confirm email

+ + + + Confirm your email + {!isValidToken && ( -

- The token is invalid or has expired. -

+ +

+ The token is invalid or has expired. +

+
)} -
-
- - ( - - - - - - - )} - /> -
- -
- - -
+ + +
+ + ( + + + + + + + )} + /> +
+ +
+ + +
+ ); }; diff --git a/client/src/containers/auth/email-input/index.tsx b/client/src/containers/auth/email-input/index.tsx new file mode 100644 index 00000000..5de6562b --- /dev/null +++ b/client/src/containers/auth/email-input/index.tsx @@ -0,0 +1,47 @@ +import { InputHTMLAttributes, forwardRef } from "react"; + +import { MailIcon } from "lucide-react"; + +import { + FormControl, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; + +interface EmailInputProps extends InputHTMLAttributes { + label?: string; +} + +const EmailInput = forwardRef( + ( + { label = "Email", placeholder = "Enter your email address", ...props }, + ref, + ) => { + return ( + + {label} + +
+
+ +
+ +
+
+ +
+ ); + }, +); + +EmailInput.displayName = "EmailInput"; + +export default EmailInput; diff --git a/client/src/containers/auth/forgot-password/email-form/index.tsx b/client/src/containers/auth/forgot-password/email-form/index.tsx index 457b8516..08679657 100644 --- a/client/src/containers/auth/forgot-password/email-form/index.tsx +++ b/client/src/containers/auth/forgot-password/email-form/index.tsx @@ -4,22 +4,25 @@ import { FC, FormEvent, useCallback, useRef } from "react"; import { useForm } from "react-hook-form"; +import Link from "next/link"; + import { zodResolver } from "@hookform/resolvers/zod"; import { EmailSchema } from "@shared/schemas/auth/login.schema"; import { z } from "zod"; import { client } from "@/lib/query-client"; +import EmailInput from "@/containers/auth/email-input"; + import { Button } from "@/components/ui/button"; import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Form, FormField } from "@/components/ui/form"; import { useApiResponseToast } from "@/components/ui/toast/use-api-response-toast"; const ForgotPasswordEmailForm: FC = () => { @@ -62,41 +65,36 @@ const ForgotPasswordEmailForm: FC = () => { ); return ( -
-
-

Reset your password

-

+ + + Reset password + Enter your email address, and we'll send you a link to get back into your account. -

-
-
- - ( - - Email - - - - - - )} - /> -
- -
- - -
+ + + +
+ + } + /> +
+ + +
+ + +
+ ); }; diff --git a/client/src/containers/auth/forgot-password/new-password-form/index.tsx b/client/src/containers/auth/forgot-password/new-password-form/index.tsx index a5c074a4..9a47eab5 100644 --- a/client/src/containers/auth/forgot-password/new-password-form/index.tsx +++ b/client/src/containers/auth/forgot-password/new-password-form/index.tsx @@ -4,6 +4,7 @@ import { FC, FormEvent, useCallback, useRef } from "react"; import { useForm } from "react-hook-form"; +import Link from "next/link"; import { useParams, useRouter } from "next/navigation"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -16,6 +17,13 @@ import { client } from "@/lib/query-client"; import { queryKeys } from "@/lib/query-keys"; import { Button } from "@/components/ui/button"; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, +} from "@/components/ui/card"; import { Form, FormControl, @@ -103,86 +111,92 @@ const NewPasswordForm: FC = () => { const isDisabled = isFetching || isError || !isValidToken; return ( -
-
-

Create new password

- {!isValidToken && ( -

- The token is invalid or has expired. -

- )} -
-
- - ( - - New password - -
- -
-
- {!fieldState.invalid && ( - - Password must contain at least 8 characters. - - )} - -
- )} - /> - ( - - Repeat password - -
- -
-
- {!fieldState.invalid && ( - - Password must contain at least 8 characters. - - )} - -
- )} - /> -
- -
- - -
+ + + + Change your password + + + {!isValidToken ? ( +

+ The token is invalid or has expired. +

+ ) : ( +

Please set a new password to secure your account.

+ )} +
+
+ +
+ + ( + + New password + +
+ +
+
+ {!fieldState.invalid && ( + + Password must contain at least 8 characters. + + )} + +
+ )} + /> + ( + + Repeat password + +
+ +
+
+ {!fieldState.invalid && ( + + Password must contain at least 8 characters. + + )} + +
+ )} + /> +
+ + +
+ + +
+
); }; diff --git a/client/src/containers/auth/set-password/form/action.ts b/client/src/containers/auth/set-password/form/action.ts new file mode 100644 index 00000000..4e613bdb --- /dev/null +++ b/client/src/containers/auth/set-password/form/action.ts @@ -0,0 +1,57 @@ +"use server"; + +import { SignUpSchema } from "@shared/schemas/auth/sign-up.schema"; + +import { client } from "@/lib/query-client"; + +export type FormState = { + ok: boolean | undefined; + message: string | string[] | undefined; +}; + +export async function signUpAction( + prevState: FormState, + data: FormData, +): Promise { + const formData = Object.fromEntries(data); + const parsed = SignUpSchema.safeParse(formData); + + if (!parsed.success) { + return { + ok: false, + message: "Invalid update-email data", + }; + } + + try { + const response = await client.auth.signUp.mutation({ + extraHeaders: { + Authorization: `Bearer ${data.get("token")}`, + }, + body: { + oneTimePassword: parsed.data.oneTimePassword, + newPassword: parsed.data.newPassword, + }, + }); + + if (response.status === 401) { + return { + ok: false, + message: + response.body.errors?.map(({ title }) => title) ?? "unknown error", + }; + } + } catch (error: Error | unknown) { + if (error instanceof Error) { + return { + ok: false, + message: error.message, + }; + } + } + + return { + ok: true, + message: "account activated successfully", + }; +} diff --git a/client/src/containers/auth/set-password/form/index.tsx b/client/src/containers/auth/set-password/form/index.tsx new file mode 100644 index 00000000..7e56e50d --- /dev/null +++ b/client/src/containers/auth/set-password/form/index.tsx @@ -0,0 +1,204 @@ +"use client"; + +import { FC, useEffect, useRef } from "react"; + +import { useFormState } from "react-dom"; +import { useForm } from "react-hook-form"; + +import Link from "next/link"; +import { useParams, useRouter } from "next/navigation"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { TOKEN_TYPE_ENUM } from "@shared/schemas/auth/token-type.schema"; +import { useSuspenseQuery } from "@tanstack/react-query"; +import { z } from "zod"; + +import { client } from "@/lib/query-client"; +import { queryKeys } from "@/lib/query-keys"; + +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + FormDescription, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; + +import { signUpAction } from "./action"; +import { signUpSchemaForm } from "./schema"; + +const SignUpForm: FC = () => { + const { push } = useRouter(); + const [status, formAction] = useFormState(signUpAction, { + ok: undefined, + message: "", + }); + const params = useParams<{ token: string }>(); + + const formRef = useRef(null); + const form = useForm>({ + resolver: zodResolver(signUpSchemaForm), + defaultValues: { + oneTimePassword: "", + newPassword: "", + repeatPassword: "", + token: params.token, + }, + mode: "onSubmit", + }); + + const { + data: isValidToken, + isFetching, + isError, + } = useSuspenseQuery({ + queryKey: queryKeys.auth.resetPasswordToken(params.token).queryKey, + queryFn: () => { + return client.auth.validateToken.query({ + headers: { + authorization: `Bearer ${params.token}`, + }, + query: { + tokenType: TOKEN_TYPE_ENUM.ACCOUNT_CONFIRMATION, + }, + }); + }, + select: (data) => data.status === 200, + }); + + useEffect(() => { + if (status.ok) { + push("/auth/signin"); + } + }, [status, push]); + + const isDisabledByTokenValidation = !isValidToken || isFetching || isError; + + return ( + <> + {!isValidToken && !isFetching && ( +

+ The token is invalid or has expired. +

+ )} + {status.ok === false && ( +

{status.message}

+ )} +
+ { + evt.preventDefault(); + form.handleSubmit(() => { + formAction(new FormData(formRef.current!)); + })(evt); + }} + > + ( + + One-Time Password + + + + + + )} + /> + ( + + Password + +
+ +
+
+ {!fieldState.invalid && ( + + Password must contain at least 8 characters. + + )} + +
+ )} + /> + ( + + Repeat password + +
+ +
+
+ {!fieldState.invalid && ( + + Password must contain at least 8 characters. + + )} + +
+ )} + /> + + ( + + + + + + )} + /> + +
+ + +
+ + + + ); +}; + +export default SignUpForm; diff --git a/client/src/containers/auth/set-password/form/schema.ts b/client/src/containers/auth/set-password/form/schema.ts new file mode 100644 index 00000000..21bdf86e --- /dev/null +++ b/client/src/containers/auth/set-password/form/schema.ts @@ -0,0 +1,12 @@ +import { SignUpSchema } from "@shared/schemas/auth/sign-up.schema"; +import { z } from "zod"; + +export const signUpSchemaForm = SignUpSchema.and( + z.object({ + repeatPassword: SignUpSchema.shape.newPassword, + token: z.string().min(1), + }), +).refine((data) => data.newPassword === data.repeatPassword, { + message: "Passwords must match", + path: ["repeatPassword"], +}); diff --git a/client/src/containers/auth/set-password/index.tsx b/client/src/containers/auth/set-password/index.tsx new file mode 100644 index 00000000..09bd8d81 --- /dev/null +++ b/client/src/containers/auth/set-password/index.tsx @@ -0,0 +1,44 @@ +import { FC } from "react"; + +import Link from "next/link"; + +import { Button } from "@/components/ui/button"; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, +} from "@/components/ui/card"; + +import SignUpForm from "./form"; + +const SetPassword: FC = () => { + return ( + <> + + + Set password + + Please set a new password to secure your account. + + + + + + + +

+ + Already have an account? + + +

+
+ + ); +}; + +export default SetPassword; diff --git a/client/src/containers/auth/signin/form/index.tsx b/client/src/containers/auth/signin/form/index.tsx index 78feaa11..856f6996 100644 --- a/client/src/containers/auth/signin/form/index.tsx +++ b/client/src/containers/auth/signin/form/index.tsx @@ -4,6 +4,7 @@ import { FC, FormEvent, useCallback, useRef, useState } from "react"; import { useForm } from "react-hook-form"; +import Link from "next/link"; import { useSearchParams, useRouter } from "next/navigation"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -11,6 +12,8 @@ import { LogInSchema } from "@shared/schemas/auth/login.schema"; import { signIn } from "next-auth/react"; import { z } from "zod"; +import EmailInput from "@/containers/auth/email-input"; + import { Button } from "@/components/ui/button"; import { Form, @@ -66,26 +69,27 @@ const SignInForm: FC = () => { return (
- + ( - - Email - - - - - - )} + render={({ field }) => } /> ( - Password +
+ Password + +
{ {errorMessage && (
{errorMessage}
)} -
-
diff --git a/client/src/containers/auth/signin/index.tsx b/client/src/containers/auth/signin/index.tsx index 4038eb1d..fa82bec1 100644 --- a/client/src/containers/auth/signin/index.tsx +++ b/client/src/containers/auth/signin/index.tsx @@ -3,24 +3,43 @@ import { FC } from "react"; import Link from "next/link"; import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; import SignInForm from "./form"; const SignIn: FC = () => { return ( -
-
-
-

+ <> + + + Welcome to Blue Carbon Cost -

+ + + To sign in please enter your email and password below. + + + - -
-
-
+

+ + ); }; diff --git a/client/src/containers/auth/signup/form/action.ts b/client/src/containers/auth/signup/form/action.ts index 4e613bdb..65351ba6 100644 --- a/client/src/containers/auth/signup/form/action.ts +++ b/client/src/containers/auth/signup/form/action.ts @@ -1,6 +1,8 @@ "use server"; -import { SignUpSchema } from "@shared/schemas/auth/sign-up.schema"; +import { headers } from "next/headers"; + +import { CreateUserSchema } from "@shared/schemas/users/create-user.schema"; import { client } from "@/lib/query-client"; @@ -14,7 +16,7 @@ export async function signUpAction( data: FormData, ): Promise { const formData = Object.fromEntries(data); - const parsed = SignUpSchema.safeParse(formData); + const parsed = CreateUserSchema.safeParse(formData); if (!parsed.success) { return { @@ -24,21 +26,25 @@ export async function signUpAction( } try { - const response = await client.auth.signUp.mutation({ + const headersList = headers(); + const response = await client.auth.register.mutation({ extraHeaders: { Authorization: `Bearer ${data.get("token")}`, + origin: headersList.get("host") || undefined, }, body: { - oneTimePassword: parsed.data.oneTimePassword, - newPassword: parsed.data.newPassword, + name: parsed.data.name, + partnerName: parsed.data.partnerName, + email: parsed.data.email, }, }); - if (response.status === 401) { + if (response.status !== 201) { return { ok: false, message: - response.body.errors?.map(({ title }) => title) ?? "unknown error", + response.body.errors?.map(({ title }) => title).join("\n") ?? + "unknown error", }; } } catch (error: Error | unknown) { diff --git a/client/src/containers/auth/signup/form/index.tsx b/client/src/containers/auth/signup/form/index.tsx index e47fdbe4..fdccf5d4 100644 --- a/client/src/containers/auth/signup/form/index.tsx +++ b/client/src/containers/auth/signup/form/index.tsx @@ -5,17 +5,14 @@ import { FC, useEffect, useRef } from "react"; import { useFormState } from "react-dom"; import { useForm } from "react-hook-form"; -import { useParams, useRouter } from "next/navigation"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; import { zodResolver } from "@hookform/resolvers/zod"; -import { TOKEN_TYPE_ENUM } from "@shared/schemas/auth/token-type.schema"; -import { useQuery } from "@tanstack/react-query"; import { z } from "zod"; -import { client } from "@/lib/query-client"; -import { queryKeys } from "@/lib/query-keys"; - import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { Form, FormControl, @@ -23,75 +20,50 @@ import { FormItem, FormLabel, FormMessage, - FormDescription, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { useToast } from "@/components/ui/toast/use-toast"; import { signUpAction } from "./action"; import { signUpSchemaForm } from "./schema"; -const SignUpForm: FC = () => { +const TokenSignUpForm: FC = () => { const { push } = useRouter(); const [status, formAction] = useFormState(signUpAction, { ok: undefined, message: "", }); - const params = useParams<{ token: string }>(); + const { toast } = useToast(); const formRef = useRef(null); const form = useForm>({ resolver: zodResolver(signUpSchemaForm), defaultValues: { - oneTimePassword: "", - newPassword: "", - repeatPassword: "", - token: params.token, + name: "", + partnerName: "", + email: "", }, mode: "onSubmit", }); - const { - data: isValidToken, - isFetching, - isError, - } = useQuery({ - queryKey: queryKeys.auth.resetPasswordToken(params.token).queryKey, - queryFn: () => { - return client.auth.validateToken.query({ - headers: { - authorization: `Bearer ${params.token}`, - }, - query: { - tokenType: TOKEN_TYPE_ENUM.ACCOUNT_CONFIRMATION, - }, - }); - }, - select: (data) => data.status === 200, - }); - useEffect(() => { if (status.ok) { + toast({ + description: + "Sign up successful! Please check your email to verify your account.", + }); push("/auth/signin"); } - }, [status, push]); - - const isDisabledByTokenValidation = !isValidToken || isFetching || isError; + }, [status, push, toast]); return ( <> - {!isValidToken && !isFetching && ( -

- The token is invalid or has expired. -

- )} - {status.ok === false && ( -

{status.message}

- )} { evt.preventDefault(); form.handleSubmit(() => { @@ -101,17 +73,12 @@ const SignUpForm: FC = () => { > ( - One-Time Password + Name - + @@ -119,77 +86,74 @@ const SignUpForm: FC = () => { /> ( + name="partnerName" + render={({ field }) => ( - Password + Partner -
- -
+
- {!fieldState.invalid && ( - - Password must contain at least 8 characters. - - )}
)} /> ( + name="email" + render={({ field }) => ( - Repeat password + Email -
- -
+
- {!fieldState.invalid && ( - - Password must contain at least 8 characters. - - )}
)} /> - ( - +
+ + +
+
)} /> -
+ {!status.ok && status.message && ( +
{status.message}
+ )} +
+
@@ -198,4 +162,4 @@ const SignUpForm: FC = () => { ); }; -export default SignUpForm; +export default TokenSignUpForm; diff --git a/client/src/containers/auth/signup/form/schema.ts b/client/src/containers/auth/signup/form/schema.ts index 21bdf86e..b060b2dd 100644 --- a/client/src/containers/auth/signup/form/schema.ts +++ b/client/src/containers/auth/signup/form/schema.ts @@ -1,12 +1,10 @@ -import { SignUpSchema } from "@shared/schemas/auth/sign-up.schema"; +import { CreateUserSchema } from "@shared/schemas/users/create-user.schema"; import { z } from "zod"; -export const signUpSchemaForm = SignUpSchema.and( +export const signUpSchemaForm = CreateUserSchema.and( z.object({ - repeatPassword: SignUpSchema.shape.newPassword, - token: z.string().min(1), + privacyPolicy: z.boolean().refine((value) => value === true, { + message: "The terms and conditions and privacy policy must be accepted", + }), }), -).refine((data) => data.newPassword === data.repeatPassword, { - message: "Passwords must match", - path: ["repeatPassword"], -}); +); diff --git a/client/src/containers/auth/signup/index.tsx b/client/src/containers/auth/signup/index.tsx index 48adce2d..733fea60 100644 --- a/client/src/containers/auth/signup/index.tsx +++ b/client/src/containers/auth/signup/index.tsx @@ -3,25 +3,43 @@ import { FC } from "react"; import Link from "next/link"; import { Button } from "@/components/ui/button"; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, +} from "@/components/ui/card"; import SignUpForm from "./form"; const SignUp: FC = () => { return ( -
-
-

Sign up

- -
- + <> + + + + Create an account + + + To create your account please fill in the details bellow. + + + + + + + +

+ Already have an account? - -

-
-
+

+ + ); }; diff --git a/client/src/containers/overview/table/view/scorecard-prioritization/index.tsx b/client/src/containers/overview/table/view/scorecard-prioritization/index.tsx index fef14259..8367e529 100644 --- a/client/src/containers/overview/table/view/scorecard-prioritization/index.tsx +++ b/client/src/containers/overview/table/view/scorecard-prioritization/index.tsx @@ -64,7 +64,7 @@ export function ScoredCardPrioritizationTable() { ...filtersToQueryParams(filters), filter: { - activitySubtype: [""], + restorationActivity: [""], }, // fields: TABLE_COLUMNS.map((column) => column.accessorKey), // ...(sorting.length > 0 && { diff --git a/client/src/containers/profile/account-details/index.tsx b/client/src/containers/profile/account-details/index.tsx index ec7c2e32..a4618a18 100644 --- a/client/src/containers/profile/account-details/index.tsx +++ b/client/src/containers/profile/account-details/index.tsx @@ -26,7 +26,7 @@ import { useToast } from "@/components/ui/toast/use-toast"; import { accountDetailsSchema } from "./schema"; -const UpdateEmailForm: FC = () => { +const AccountDetailsForm: FC = () => { const queryClient = useQueryClient(); const { data: session, update: updateSession } = useSession(); const formRef = useRef(null); @@ -115,7 +115,7 @@ const UpdateEmailForm: FC = () => { name="name" render={({ field }) => ( - Name + Your name
{ )} /> - +
+ + +
); }; -export default UpdateEmailForm; +export default AccountDetailsForm; diff --git a/client/src/containers/profile/custom-projects/index.tsx b/client/src/containers/profile/custom-projects/index.tsx new file mode 100644 index 00000000..b52b9b19 --- /dev/null +++ b/client/src/containers/profile/custom-projects/index.tsx @@ -0,0 +1,44 @@ +import { FC } from "react"; + +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +const CustomProjects: FC = () => { + // TODO: Should fetch custom-projects from API when available + return ( + + + + + Project type + + + Number of projects + + + + + {Array.from({ length: 10 }, (_, index) => ({ + id: index + 1, + projectName: `Project ${index + 1}`, + numberOfProjects: Math.floor(Math.random() * 100) + 1, + })).map((row) => ( + + {row.projectName} + + {row.numberOfProjects} + + + ))} + +
+ ); +}; + +export default CustomProjects; diff --git a/client/src/containers/profile/delete-account/index.tsx b/client/src/containers/profile/delete-account/index.tsx index 7539303b..fa72719d 100644 --- a/client/src/containers/profile/delete-account/index.tsx +++ b/client/src/containers/profile/delete-account/index.tsx @@ -2,7 +2,6 @@ import { FC, useCallback } from "react"; -import { Trash2Icon } from "lucide-react"; import { signOut, useSession } from "next-auth/react"; import { client } from "@/lib/query-client"; @@ -52,44 +51,41 @@ const DeleteAccount: FC = () => { }, [session?.accessToken, toast]); return ( -
- + +
- - - - Account deletion - - This action cannot be undone. This will permanently delete your - account and remove your data from our servers. - - - - - - - - - - - - -
+
+ + + Delete my account + + This action cannot be undone. This will permanently delete your + account and remove your data from our servers. + + + + + + + + + + + + ); }; diff --git a/client/src/containers/profile/edit-password/form/index.tsx b/client/src/containers/profile/edit-password/form/index.tsx index db4fb397..aab9f53c 100644 --- a/client/src/containers/profile/edit-password/form/index.tsx +++ b/client/src/containers/profile/edit-password/form/index.tsx @@ -164,13 +164,8 @@ const SignUpForm: FC = () => { )} /> -
-
diff --git a/client/src/containers/profile/file-upload/index.tsx b/client/src/containers/profile/file-upload/index.tsx new file mode 100644 index 00000000..8b6a00f4 --- /dev/null +++ b/client/src/containers/profile/file-upload/index.tsx @@ -0,0 +1,167 @@ +import React, { FC, useCallback, useState } from "react"; + +import { useDropzone } from "react-dropzone"; + +import { FilePlusIcon, XIcon } from "lucide-react"; +import { useSession } from "next-auth/react"; + +import { client } from "@/lib/query-client"; +import { cn } from "@/lib/utils"; + +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { useToast } from "@/components/ui/toast/use-toast"; + +// Array should be in this order +const REQUIRED_FILES = [ + "carbon-input-template.xlsx", + "cost-input-template.xlsx", +]; +const EXCEL_EXTENSIONS = [".xlsx", ".xls"]; +const MAX_FILES = 2; + +const FileUpload: FC = () => { + const [files, setFiles] = useState([]); + const { data: session } = useSession(); + const { toast } = useToast(); + const onDropAccepted = useCallback( + (acceptedFiles: File[]) => { + const validFiles = acceptedFiles.filter((file) => + REQUIRED_FILES.includes(file.name), + ); + + if (validFiles.length !== acceptedFiles.length) { + return toast({ + variant: "destructive", + description: + "Only carbon-input-template.xlsx and cost-input-template.xlsx files are allowed", + }); + } + + setFiles((prevFiles) => { + const remainingSlots = MAX_FILES - prevFiles.length; + const filesToAdd = acceptedFiles.slice(0, remainingSlots); + return [...prevFiles, ...filesToAdd]; + }); + }, + [toast], + ); + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + onDropAccepted, + accept: { + "application/vnd.ms-excel": EXCEL_EXTENSIONS, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": + EXCEL_EXTENSIONS, + }, + maxFiles: MAX_FILES, + disabled: files.length >= MAX_FILES, + noClick: files.length >= MAX_FILES, + noDrag: files.length >= MAX_FILES, + }); + const removeFile = (fileToRemove: File) => { + setFiles((prevFiles) => prevFiles.filter((file) => file !== fileToRemove)); + }; + const handleUploadClick = async () => { + const fileNames = files.map((file) => file.name); + const missingFiles = REQUIRED_FILES.filter( + (name) => !fileNames.includes(name), + ); + + if (missingFiles.length > 0) { + return toast({ + variant: "destructive", + description: `Missing required file${missingFiles.length > 1 ? "s" : ""}: ${missingFiles.join(", ")}`, + }); + } + + const formData = new FormData(); + const sortedFiles = REQUIRED_FILES.map( + (name) => files.find((file) => file.name === name)!, + ); + + sortedFiles.forEach((file) => { + formData.append("files", file); + }); + + try { + const response = await client.user.uploadData.mutation({ + body: formData, + extraHeaders: { + authorization: `Bearer ${session?.accessToken as string}`, + }, + }); + + if (response.status === 201) { + toast({ description: "Your files has been uploaded successfully." }); + setFiles([]); + } else { + toast({ + variant: "destructive", + description: + response.body.errors?.[0].title || + "Something went wrong uploading your files", + }); + } + } catch (e) { + toast({ + variant: "destructive", + description: "Something went wrong uploading your files", + }); + } + }; + + return ( +
+ = MAX_FILES, + })} + > + +
+ +

+ {files.length < MAX_FILES + ? "Drop files, or click to upload" + : "You've attached the maximum of 2 files"} +

+
+
+ {files.length > 0 && ( +
+
    + {files.map((file: File) => ( +
  1. + +

    + {file.name} - {(file.size / 1024).toFixed(2)} KB +

    + +
    +
  2. + ))} +
+
+ +
+
+ )} +
+ ); +}; + +export default FileUpload; diff --git a/client/src/containers/profile/index.tsx b/client/src/containers/profile/index.tsx index f70c1f0c..e5e4e7eb 100644 --- a/client/src/containers/profile/index.tsx +++ b/client/src/containers/profile/index.tsx @@ -1,36 +1,140 @@ "use client"; -import { signOut } from "next-auth/react"; +import { useEffect, useRef } from "react"; -import AccountDetails from "@/containers/profile/account-details"; -import EditPassword from "@/containers/profile/edit-password"; -import UpdateEmail from "@/containers/profile/update-email"; +import Link from "next/link"; + +import { useSetAtom } from "jotai"; + +import CustomProjects from "@/containers/profile/custom-projects"; +import FileUpload from "@/containers/profile/file-upload"; +import ProfileSection from "@/containers/profile/profile-section"; +import ProfileSidebar from "@/containers/profile/profile-sidebar"; +import { intersectingAtom } from "@/containers/profile/store"; +import UserDetails from "@/containers/profile/user-details"; import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { SidebarTrigger } from "@/components/ui/sidebar"; import DeleteAccount from "src/containers/profile/delete-account"; +const sections = [ + { + id: "my-details", + title: "My details", + description: "This personal information is only visible to you.", + Component: UserDetails, + }, + { + id: "my-custom-projects", + title: "My custom projects", + description: ( + <> + You can see more detail and modify your custom projects in{" "} + + My Custom Projects + {" "} + page. + + ), + Component: CustomProjects, + }, + { + id: "data-upload", + title: "Data upload", + description: ( + <> +

+ Download the required templates, fill them in, and upload the + completed files below. +

+ +
    +
  1. + +
  2. +
  3. + +
  4. +
+ + ), + Component: FileUpload, + }, + { + id: "delete-account", + title: "Delete account", + description: + "This action will permanently delete your account. By doing this you will loose access to all your custom scenarios.", + Component: DeleteAccount, + }, +]; + export default function Profile() { + const ref = useRef(null); + const setIntersecting = useSetAtom(intersectingAtom); + + useEffect(() => { + if (!ref.current) return; + + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + const sectionSlug = entry.target.id; + + setIntersecting(sectionSlug); + } + }); + }, + { + root: ref.current, + threshold: 0.1, + /** + * This rootMargin creates a horizontal line vertically centered + * that will help trigger an intersection at that the very point. + */ + rootMargin: "-20% 0% -60% 0%", + }, + ); + + const sections = Array.from( + ref.current.querySelector("#profile-sections-container")?.children || [], + ); + sections.forEach((section) => observer.observe(section)); + + return () => observer.disconnect(); + }, [setIntersecting]); + return ( -
-
-
- - -
-
- - -
+
+
+ +

User area

- +
+ ({ id: s.id, name: s.title }))} + /> + +
+ {sections.map(({ Component, ...rest }) => ( + + + + ))} +
+
+
); } diff --git a/client/src/containers/profile/profile-section/index.tsx b/client/src/containers/profile/profile-section/index.tsx new file mode 100644 index 00000000..0746a652 --- /dev/null +++ b/client/src/containers/profile/profile-section/index.tsx @@ -0,0 +1,42 @@ +import { FC, PropsWithChildren } from "react"; + +import { getSidebarLinkId } from "@/containers/profile/profile-sidebar"; + +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, +} from "@/components/ui/card"; + +interface ProfileSectionProps extends PropsWithChildren { + id: string; + title: string; + description?: string | React.ReactNode; +} + +const ProfileSection: FC = ({ + id, + title, + description, + children, +}) => { + return ( +
+ + + {title} + {description && ( + + {description} + + )} + + {children} + +
+ ); +}; + +export default ProfileSection; diff --git a/client/src/containers/profile/profile-sidebar/index.tsx b/client/src/containers/profile/profile-sidebar/index.tsx new file mode 100644 index 00000000..41f4f21a --- /dev/null +++ b/client/src/containers/profile/profile-sidebar/index.tsx @@ -0,0 +1,101 @@ +import { FC } from "react"; + +import Link from "next/link"; + +import { useAtomValue } from "jotai"; +import { LogOutIcon } from "lucide-react"; +import { signOut, useSession } from "next-auth/react"; + +import { client } from "@/lib/query-client"; +import { queryKeys } from "@/lib/query-keys"; +import { cn } from "@/lib/utils"; + +import { intersectingAtom } from "@/containers/profile/store"; + +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; + +export const getSidebarLinkId = (slug?: string): string => + `profile-sidebar-${slug}-link`; + +const getInitials = (fullName?: string): string => { + if (!fullName) return ""; + const names = fullName.trim().split(" "); + + return names + .slice(0, 2) + .map((name) => name.charAt(0).toUpperCase()) + .join(""); +}; + +interface ProfileSidebarProps { + navItems: { name: string; id: string }[]; +} +const ProfileSidebar: FC = ({ navItems }) => { + const { data: session } = useSession(); + const { data: user } = client.user.findMe.useQuery( + queryKeys.user.me(session?.user?.id as string).queryKey, + { + extraHeaders: { + authorization: `Bearer ${session?.accessToken as string}`, + }, + }, + { + select: (data) => data.body.data, + queryKey: queryKeys.user.me(session?.user?.id as string).queryKey, + }, + ); + const intersecting = useAtomValue(intersectingAtom); + + return ( + + ); +}; + +export default ProfileSidebar; diff --git a/client/src/containers/profile/store.ts b/client/src/containers/profile/store.ts new file mode 100644 index 00000000..9eaac84f --- /dev/null +++ b/client/src/containers/profile/store.ts @@ -0,0 +1,3 @@ +import { atom } from "jotai"; + +export const intersectingAtom = atom(null); diff --git a/client/src/containers/profile/update-email/index.tsx b/client/src/containers/profile/update-email/index.tsx index 50f853bb..166f9d22 100644 --- a/client/src/containers/profile/update-email/index.tsx +++ b/client/src/containers/profile/update-email/index.tsx @@ -59,24 +59,36 @@ const UpdateEmailForm: FC = () => { const parsed = accountDetailsSchema.safeParse(formData); if (parsed.success) { - const response = await client.user.requestEmailUpdate.mutation({ - body: { - newEmail: parsed.data.email, - }, - extraHeaders: { - authorization: `Bearer ${session?.accessToken as string}`, - }, - }); - - if (response.status === 200) { - updateSession(response.body); - - queryClient.invalidateQueries({ - queryKey: queryKeys.user.me(session?.user?.id as string).queryKey, + try { + const response = await client.user.requestEmailUpdate.mutation({ + body: { + newEmail: parsed.data.email, + }, + extraHeaders: { + authorization: `Bearer ${session?.accessToken as string}`, + }, }); + if (response.status === 200) { + updateSession(response.body); + + queryClient.invalidateQueries({ + queryKey: queryKeys.user.me(session?.user?.id as string).queryKey, + }); + + toast({ + description: "You will receive an email in your inbox.", + }); + } else { + toast({ + variant: "destructive", + description: "Something went wrong updating the email", + }); + } + } catch (e) { toast({ - description: "Your email has been updated successfully.", + variant: "destructive", + description: "Something went wrong updating the email", }); } } @@ -99,7 +111,7 @@ const UpdateEmailForm: FC = () => {
{ form.handleSubmit(() => { onSubmit(new FormData(formRef.current!)); @@ -110,8 +122,8 @@ const UpdateEmailForm: FC = () => { control={form.control} name="email" render={({ field }) => ( - - Email + + Your email
{ )} /> - +
+ +
); diff --git a/client/src/containers/profile/user-details/index.tsx b/client/src/containers/profile/user-details/index.tsx new file mode 100644 index 00000000..4f4ff7cd --- /dev/null +++ b/client/src/containers/profile/user-details/index.tsx @@ -0,0 +1,25 @@ +import { FC } from "react"; + +import AccountDetails from "@/containers/profile/account-details"; +import EditPassword from "@/containers/profile/edit-password"; +import UpdateEmailForm from "@/containers/profile/update-email"; + +import { Card } from "@/components/ui/card"; + +const UserDetails: FC = () => { + return ( +
+ + + + + + + + + +
+ ); +}; + +export default UserDetails; diff --git a/client/src/public/forms/carbon-input-template.xlsx b/client/src/public/forms/carbon-input-template.xlsx new file mode 100644 index 00000000..1f8fb432 Binary files /dev/null and b/client/src/public/forms/carbon-input-template.xlsx differ diff --git a/client/src/public/forms/cost-input-template.xlsx b/client/src/public/forms/cost-input-template.xlsx new file mode 100644 index 00000000..b47afea2 Binary files /dev/null and b/client/src/public/forms/cost-input-template.xlsx differ diff --git a/e2e/.prettierrc b/e2e/.prettierrc new file mode 100644 index 00000000..04a13010 --- /dev/null +++ b/e2e/.prettierrc @@ -0,0 +1,4 @@ +{ + "trailingComma": "all", + "semi": true +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 2c383973..e918884a 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -27,8 +27,9 @@ export default defineConfig({ env: { NEXTAUTH_URL: APP_URL, NEXT_PUBLIC_API_URL: API_URL, - NEXTAUTH_SECRET: "WAzjpS46vFxp17TsRDU3FXo+TF0vrfy6uhCXwGMBUE8=" - } + NEXTAUTH_SECRET: "WAzjpS46vFxp17TsRDU3FXo+TF0vrfy6uhCXwGMBUE8=", + }, + timeout: 100000, }, ], testDir: "./tests", @@ -48,7 +49,6 @@ export default defineConfig({ baseURL: APP_URL, /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: "on-first-retry", - }, /* Configure projects for major browsers */ projects: [ diff --git a/e2e/tests/auth/delete-account.spec.ts b/e2e/tests/auth/delete-account.spec.ts index 1cc2221f..297573c0 100644 --- a/e2e/tests/auth/delete-account.spec.ts +++ b/e2e/tests/auth/delete-account.spec.ts @@ -44,7 +44,7 @@ test.describe("Auth - Delete Account", () => { await page.waitForURL('/auth/signin'); - await page.getByLabel("Email").fill(user.email); + await page.getByPlaceholder('Enter your email address').fill(user.email); await page.locator('input[type="password"]').fill(user.password); await page.getByRole("button", { name: /log in/i }).click(); diff --git a/e2e/tests/auth/sign-up.spec.ts b/e2e/tests/auth/sign-up.spec.ts index 8e374c24..754cf5e7 100644 --- a/e2e/tests/auth/sign-up.spec.ts +++ b/e2e/tests/auth/sign-up.spec.ts @@ -27,6 +27,45 @@ test.describe("Auth - Sign Up", () => { }); test("an user signs up successfully", async ({ page }) => { + const user: Pick = { + name: "John Doe", + partnerName: "Jane Doe", + email: "johndoe@test.com", + }; + + await page.goto(`/auth/signup`); + + await page.getByPlaceholder("Enter your name").fill(user.name); + await page.getByPlaceholder("Enter partner name").fill(user.partnerName); + await page.getByLabel("Email").fill(user.email); + await page.getByRole("checkbox").check(); + + await page.getByRole("button", { name: /Create account/i }).click(); + + await expect( + // Has to be a more specific selector targeting the notification list item + page.getByRole("list").getByRole("status").filter({ + hasText: + "Sign up successful! Please check your email to verify your account.", + }), + ).toBeVisible(); + + await page.waitForURL("/auth/signin"); + await expect( + page.getByText("Welcome to Blue Carbon Cost", { exact: true }), + ).toBeVisible(); + + const registeredUser = await testManager + .getDataSource() + .getRepository(User) + .findOne({ where: { email: user.email } }); + + expect(registeredUser?.isActive).toBe(false); + }); + + test("an user successfully finish signup process with OTP", async ({ + page, + }) => { const user: Pick = { email: "johndoe@test.com", password: "passwordpassword", @@ -54,12 +93,10 @@ test.describe("Auth - Sign Up", () => { await page.getByPlaceholder("Repeat the password").click(); await page.getByPlaceholder("Repeat the password").fill(newPassword); - await page.getByRole("button", { name: /sign up/i }).click(); + await page.getByRole("button", { name: /save/i }).click(); await page.waitForURL("/auth/signin"); - await expect( - page.getByRole("heading", { name: "Welcome to Blue Carbon Cost" }), - ).toBeVisible(); + await expect(page.getByText("Welcome to Blue Carbon Cost")).toBeVisible(); }); test("an user signs up with an invalid token", async ({ page }) => { @@ -75,6 +112,6 @@ test.describe("Auth - Sign Up", () => { ).toBeDisabled(); await expect(page.getByPlaceholder("Create a password")).toBeDisabled(); await expect(page.getByPlaceholder("Repeat the password")).toBeDisabled(); - await expect(page.getByRole("button", { name: /sign up/i })).toBeDisabled(); + await expect(page.getByRole("button", { name: /save/i })).toBeDisabled(); }); }); diff --git a/e2e/tests/auth/update-password.spec.ts b/e2e/tests/auth/update-password.spec.ts index e14327d2..28ae8a54 100644 --- a/e2e/tests/auth/update-password.spec.ts +++ b/e2e/tests/auth/update-password.spec.ts @@ -46,7 +46,7 @@ test.describe("Auth - Update Password", () => { await page.getByRole("button", { name: /update password/i }).click(); - await page.getByRole("button", { name: /sign out/i }).click(); + await page.getByRole("button", { name: /log out/i }).click(); await expect(page).toHaveURL(/auth\/signin/); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 339529f4..a77c2261 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -112,7 +112,7 @@ importers: version: 10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) '@nestjs/cqrs': specifier: ^10.2.7 - version: 10.2.7(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) + version: 10.2.7(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(reflect-metadata@0.2.2)(rxjs@7.8.1) '@nestjs/jwt': specifier: ^10.2.0 version: 10.2.0(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1)) @@ -124,10 +124,10 @@ importers: version: 10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.1) '@nestjs/typeorm': specifier: ^10.0.2 - version: 10.0.2(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.1)(reflect-metadata@0.2.2)(rxjs@7.8.1)(typeorm@0.3.20(pg@8.12.0)(ts-node@10.9.2(@types/node@20.14.2)(typescript@5.4.5))) + version: 10.0.2(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(reflect-metadata@0.2.2)(rxjs@7.8.1)(typeorm@0.3.20(pg@8.12.0)(ts-node@10.9.2(@types/node@20.14.2)(typescript@5.4.5))) '@ts-rest/nest': specifier: ^3.51.0 - version: 3.51.0(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.1)(@ts-rest/core@3.51.0(@types/node@20.14.2)(zod@3.23.8))(rxjs@7.8.1)(zod@3.23.8) + version: 3.51.0(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@ts-rest/core@3.51.0(@types/node@20.14.2)(zod@3.23.8))(rxjs@7.8.1)(zod@3.23.8) '@types/multer': specifier: 1.4.12 version: 1.4.12 @@ -194,7 +194,7 @@ importers: version: 10.1.4(chokidar@3.6.0)(typescript@5.4.5) '@nestjs/testing': specifier: ^10.0.0 - version: 10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.1)(@nestjs/platform-express@10.4.1) + version: 10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.1)) '@types/bcrypt': specifier: ^5.0.2 version: 5.0.2 @@ -285,6 +285,9 @@ importers: '@radix-ui/react-alert-dialog': specifier: 1.1.2 version: 1.1.2(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-avatar': + specifier: ^1.1.1 + version: 1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-checkbox': specifier: 1.1.2 version: 1.1.2(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -378,6 +381,9 @@ importers: react-dom: specifier: ^18 version: 18.3.1(react@18.3.1) + react-dropzone: + specifier: ^14.3.5 + version: 14.3.5(react@18.3.1) react-map-gl: specifier: 7.1.7 version: 7.1.7(mapbox-gl@3.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -2085,6 +2091,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-avatar@1.1.1': + resolution: {integrity: sha512-eoOtThOmxeoizxpX6RiEsQZ2wj5r4+zoeqAwO0cBaFQGjJwIH3dIX0OCxNrCyrrdxG+vBweMETh3VziQG7c1kw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-checkbox@1.1.2': resolution: {integrity: sha512-/i0fl686zaJbDQLNKrkCbMyDm6FQMt4jg323k7HuqitoANm9sE23Ql8yOK3Wusk34HSLKDChhMux05FnP6KUkw==} peerDependencies: @@ -3760,6 +3779,10 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + attr-accept@2.2.5: + resolution: {integrity: sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==} + engines: {node: '>=4'} + available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} @@ -4850,6 +4873,10 @@ packages: resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} engines: {node: ^10.12.0 || >=12.0.0} + file-selector@2.1.0: + resolution: {integrity: sha512-ZuXAqGePcSPz4JuerOY06Dzzq0hrmQ6VGoXVzGyFI1npeOfBgqGIKKpznfYWRkSLJlXutkqVC5WvGZtkFVhu9Q==} + engines: {node: '>= 12'} + filelist@1.0.4: resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==} @@ -6713,6 +6740,12 @@ packages: peerDependencies: react: ^18.3.1 + react-dropzone@14.3.5: + resolution: {integrity: sha512-9nDUaEEpqZLOz5v5SUcFA0CjM4vq8YbqO0WRls+EYT7+DvxUdzDPKNCPLqGfj3YL9MsniCLCD4RFA6M95V6KMQ==} + engines: {node: '>= 10.13'} + peerDependencies: + react: '>= 16.8 || 18.0.0' + react-fast-compare@3.2.2: resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==} @@ -9958,7 +9991,7 @@ snapshots: transitivePeerDependencies: - encoding - '@nestjs/cqrs@10.2.7(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.1)(reflect-metadata@0.2.2)(rxjs@7.8.1)': + '@nestjs/cqrs@10.2.7(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(reflect-metadata@0.2.2)(rxjs@7.8.1)': dependencies: '@nestjs/common': 10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) '@nestjs/core': 10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) @@ -10019,7 +10052,7 @@ snapshots: transitivePeerDependencies: - chokidar - '@nestjs/testing@10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.1)(@nestjs/platform-express@10.4.1)': + '@nestjs/testing@10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.1))': dependencies: '@nestjs/common': 10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) '@nestjs/core': 10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) @@ -10027,7 +10060,7 @@ snapshots: optionalDependencies: '@nestjs/platform-express': 10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.1) - '@nestjs/typeorm@10.0.2(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.1)(reflect-metadata@0.2.2)(rxjs@7.8.1)(typeorm@0.3.20(pg@8.12.0)(ts-node@10.9.2(@types/node@20.14.2)(typescript@5.4.5)))': + '@nestjs/typeorm@10.0.2(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(reflect-metadata@0.2.2)(rxjs@7.8.1)(typeorm@0.3.20(pg@8.12.0)(ts-node@10.9.2(@types/node@20.14.2)(typescript@5.4.5)))': dependencies: '@nestjs/common': 10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) '@nestjs/core': 10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) @@ -10131,6 +10164,18 @@ snapshots: '@types/react': 18.3.5 '@types/react-dom': 18.3.0 + '@radix-ui/react-avatar@1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-context': 1.1.1(@types/react@18.3.5)(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.5)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.5)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.5 + '@types/react-dom': 18.3.0 + '@radix-ui/react-checkbox@1.1.2(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.0 @@ -11280,7 +11325,7 @@ snapshots: '@types/node': 20.14.2 zod: 3.23.8 - '@ts-rest/nest@3.51.0(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.1)(@ts-rest/core@3.51.0(@types/node@20.14.2)(zod@3.23.8))(rxjs@7.8.1)(zod@3.23.8)': + '@ts-rest/nest@3.51.0(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@ts-rest/core@3.51.0(@types/node@20.14.2)(zod@3.23.8))(rxjs@7.8.1)(zod@3.23.8)': dependencies: '@nestjs/common': 10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) '@nestjs/core': 10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) @@ -12069,6 +12114,8 @@ snapshots: asynckit@0.4.0: {} + attr-accept@2.2.5: {} + available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.0.0 @@ -13105,8 +13152,8 @@ snapshots: '@typescript-eslint/parser': 7.18.0(eslint@8.57.0)(typescript@5.4.5) eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0))(eslint@8.57.0) - eslint-plugin-import: 2.30.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0) + eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0)(eslint@8.57.0) + eslint-plugin-import: 2.30.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.0) eslint-plugin-jsx-a11y: 6.10.0(eslint@8.57.0) eslint-plugin-react: 7.35.2(eslint@8.57.0) eslint-plugin-react-hooks: 4.6.2(eslint@8.57.0) @@ -13129,37 +13176,37 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0))(eslint@8.57.0): + eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0)(eslint@8.57.0): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.3.6(supports-color@5.5.0) enhanced-resolve: 5.17.1 eslint: 8.57.0 - eslint-module-utils: 2.9.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0) + eslint-module-utils: 2.9.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0)(eslint@8.57.0))(eslint@8.57.0) fast-glob: 3.3.2 get-tsconfig: 4.8.0 is-bun-module: 1.1.0 is-glob: 4.0.3 optionalDependencies: - eslint-plugin-import: 2.30.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0) + eslint-plugin-import: 2.30.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.0) transitivePeerDependencies: - '@typescript-eslint/parser' - eslint-import-resolver-node - eslint-import-resolver-webpack - supports-color - eslint-module-utils@2.9.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0): + eslint-module-utils@2.9.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0)(eslint@8.57.0))(eslint@8.57.0): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 7.18.0(eslint@8.57.0)(typescript@5.4.5) eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0))(eslint@8.57.0) + eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0)(eslint@8.57.0) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.30.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0): + eslint-plugin-import@2.30.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.0): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.8 @@ -13170,7 +13217,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.9.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0) + eslint-module-utils: 2.9.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0)(eslint@8.57.0))(eslint@8.57.0) hasown: 2.0.2 is-core-module: 2.15.1 is-glob: 4.0.3 @@ -13525,6 +13572,10 @@ snapshots: dependencies: flat-cache: 3.2.0 + file-selector@2.1.0: + dependencies: + tslib: 2.7.0 + filelist@1.0.4: dependencies: minimatch: 5.1.6 @@ -15584,6 +15635,13 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 + react-dropzone@14.3.5(react@18.3.1): + dependencies: + attr-accept: 2.2.5 + file-selector: 2.1.0 + prop-types: 15.8.1 + react: 18.3.1 + react-fast-compare@3.2.2: {} react-feather@2.0.10(react@18.3.1): diff --git a/shared/.prettierrc b/shared/.prettierrc new file mode 100644 index 00000000..04a13010 --- /dev/null +++ b/shared/.prettierrc @@ -0,0 +1,4 @@ +{ + "trailingComma": "all", + "semi": true +} diff --git a/shared/contracts/custom-projects.contract.ts b/shared/contracts/custom-projects.contract.ts index 6d6687be..7a080e5d 100644 --- a/shared/contracts/custom-projects.contract.ts +++ b/shared/contracts/custom-projects.contract.ts @@ -4,11 +4,12 @@ import { Country } from "@shared/entities/country.entity"; import { CustomProject } from "@shared/entities/custom-project.entity"; import { CreateCustomProjectDto } from "@api/modules/custom-projects/dto/create-custom-project-dto"; import { GetDefaultCostInputsSchema } from "@shared/schemas/custom-projects/get-cost-inputs.schema"; -import { CostInputs } from "@api/modules/custom-projects/dto/project-cost-inputs.dto"; -import { CustomProjectAssumptionsDto } from "@api/modules/custom-projects/dto/project-assumptions.dto"; import { CustomProjectSnapshotDto } from "@api/modules/custom-projects/dto/custom-project-snapshot.dto"; // TODO: This is a scaffold. We need to define types for responses, zod schemas for body and query param validation etc. +import { OverridableCostInputs } from "@api/modules/custom-projects/dto/project-cost-inputs.dto"; +import { GetAssumptionsSchema } from "@shared/schemas/assumptions/get-assumptions.schema"; +import { ModelAssumptions } from "@shared/entities/model-assumptions.entity"; const contract = initContract(); export const customProjectContract = contract.router({ @@ -25,15 +26,16 @@ export const customProjectContract = contract.router({ method: "GET", path: "/custom-projects/assumptions", responses: { - 200: contract.type>(), + 200: contract.type>>(), }, + query: GetAssumptionsSchema, summary: "Get default model assumptions", }, getDefaultCostInputs: { method: "GET", path: "/custom-projects/cost-inputs", responses: { - 200: contract.type>(), + 200: contract.type>(), }, query: GetDefaultCostInputsSchema, }, diff --git a/shared/contracts/users.contract.ts b/shared/contracts/users.contract.ts index f63d00f8..9b09dd33 100644 --- a/shared/contracts/users.contract.ts +++ b/shared/contracts/users.contract.ts @@ -3,18 +3,18 @@ import { generateEntityQuerySchema } from "@shared/schemas/query-param.schema"; import { User } from "@shared/entities/users/user.entity"; import { UserDto } from "@shared/dtos/users/user.dto"; import { z } from "zod"; -import { UpdateUserDto } from "@shared/dtos/users/update-user.dto"; -import { JSONAPIError } from '@shared/dtos/json-api.error'; +import { JSONAPIError } from "@shared/dtos/json-api.error"; import { ApiResponse } from "@shared/dtos/global/api-response.dto"; import { UpdateUserPasswordSchema } from "@shared/schemas/users/update-password.schema"; import { RequestEmailUpdateSchema } from "@shared/schemas/users/request-email-update.schema"; +import { UpdateUserSchema } from "@shared/schemas/users/update-user.schema"; const contract = initContract(); export const usersContract = contract.router({ findMe: { - method: 'GET', - path: '/users/me', + method: "GET", + path: "/users/me", responses: { 200: contract.type>(), 401: contract.type(), @@ -30,7 +30,7 @@ export const usersContract = contract.router({ responses: { 200: contract.type>(), }, - body: contract.type(), + body: UpdateUserSchema, summary: "Update an existing user", }, updatePassword: { @@ -58,4 +58,14 @@ export const usersContract = contract.router({ }, body: null, }, + + uploadData: { + method: "POST", + path: "/users/upload-data", + responses: { + 201: contract.type(), + }, + contentType: "multipart/form-data", + body: contract.type(), + }, }); diff --git a/shared/dtos/custom-projects/get-overridable-assumptions.dto.ts b/shared/dtos/custom-projects/get-overridable-assumptions.dto.ts new file mode 100644 index 00000000..248aca57 --- /dev/null +++ b/shared/dtos/custom-projects/get-overridable-assumptions.dto.ts @@ -0,0 +1,4 @@ +import { z } from "zod"; +import { GetAssumptionsSchema } from "@shared/schemas/assumptions/get-assumptions.schema"; + +export type GetOverridableAssumptionsDTO = z.infer; diff --git a/shared/dtos/custom-projects/get-default-cost-inputs.dto.ts b/shared/dtos/custom-projects/get-overridable-cost-inputs.dto.ts similarity index 77% rename from shared/dtos/custom-projects/get-default-cost-inputs.dto.ts rename to shared/dtos/custom-projects/get-overridable-cost-inputs.dto.ts index 1c199913..e3b3447d 100644 --- a/shared/dtos/custom-projects/get-default-cost-inputs.dto.ts +++ b/shared/dtos/custom-projects/get-overridable-cost-inputs.dto.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { GetDefaultCostInputsSchema } from "@shared/schemas/custom-projects/get-cost-inputs.schema"; -export type GetDefaultCostInputsDto = z.infer< +export type GetOverridableCostInputs = z.infer< typeof GetDefaultCostInputsSchema >; diff --git a/shared/entities/projects.entity.ts b/shared/entities/projects.entity.ts index ab5c789f..b9031dbc 100644 --- a/shared/entities/projects.entity.ts +++ b/shared/entities/projects.entity.ts @@ -9,7 +9,7 @@ import { import { Country } from "@shared/entities/country.entity"; import { ECOSYSTEM } from "./ecosystem.enum"; -import { ACTIVITY } from "./activity.enum"; +import { ACTIVITY, RESTORATION_ACTIVITY_SUBTYPE } from "./activity.enum"; export enum PROJECT_SIZE_FILTER { SMALL = "Small", @@ -27,12 +27,6 @@ export enum COST_TYPE_SELECTOR { NPV = "npv", } -export enum RESTORATION_ACTIVITY_SUBTYPE { - HYBRID = "Hybrid", - HYDROLOGY = "Hydrology", - PLANTING = "Planting", -} - @Entity("projects") export class Project extends BaseEntity { @PrimaryGeneratedColumn("uuid") @@ -57,12 +51,12 @@ export class Project extends BaseEntity { // TODO: We need to make this a somehow enum, as a subactivity of restoration, that can be null for conservation, and can represent all restoration activities @Column({ - name: "activity_subtype", + name: "restoration_activity", type: "varchar", length: 255, nullable: true, }) - activitySubtype: RESTORATION_ACTIVITY_SUBTYPE; + restorationActivity: RESTORATION_ACTIVITY_SUBTYPE; @Column({ name: "project_size", type: "decimal" }) projectSize: number; diff --git a/shared/entities/users/user-upload-conservation-inputs.entity.ts b/shared/entities/users/user-upload-conservation-inputs.entity.ts new file mode 100644 index 00000000..429065bd --- /dev/null +++ b/shared/entities/users/user-upload-conservation-inputs.entity.ts @@ -0,0 +1,73 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + BaseEntity, + ManyToOne, + JoinColumn, +} from "typeorm"; +import { User } from "@shared/entities/users/user.entity"; + +@Entity("user_upload_conservation_inputs") +export class UserUploadConservationInputs extends BaseEntity { + @PrimaryGeneratedColumn() + id: number; + + @ManyToOne(() => User, (user) => user.userUploadConservationInputs) + @JoinColumn({ name: "user_id" }) + user: User; + + @Column({ type: "varchar", length: 255, nullable: true }) + projectName: string; + + @Column({ type: "varchar", length: 255, nullable: true }) + country: string; + + @Column({ type: "varchar", length: 255, nullable: true }) + cityOrRegion: string; + + @Column({ type: "int", nullable: true }) + projectStartYear: number; + + @Column({ type: "int", nullable: true }) + mostRecentYearOfData: number; + + @Column({ type: "varchar", length: 255, nullable: true }) + ecosystem: string; + + @Column({ type: "varchar", length: 255, nullable: true }) + projectActivity: string; + + @Column({ type: "int", nullable: true }) + projectSizeAreaStudied: number; + + @Column({ type: "varchar", length: 255, nullable: true }) + categories: string; + + @Column({ type: "int", nullable: true }) + projectArea: number; + + @Column({ type: "int", nullable: true }) + abovegroundBiomassStock: number; + + @Column({ type: "int", nullable: true }) + belowgroundBiomassStock: number; + + @Column({ type: "int", nullable: true }) + soilOrganicCarbonStock: number; + + @Column({ type: "int", nullable: true }) + methaneEmissions: number; + + @Column({ type: "int", nullable: true }) + nitrousOxideEmissions: number; + + @Column({ type: "int", nullable: true }) + abovegroundBiomassEmissionsFactor: number; + + @Column({ type: "int", nullable: true }) + belowgroundBiomassEmissionsFactor: number; + + @Column({ type: "int", nullable: true }) + soilOrganicCarbonEmissionsFactor: number; +} diff --git a/shared/entities/users/user-upload-cost-inputs.entity.ts b/shared/entities/users/user-upload-cost-inputs.entity.ts new file mode 100644 index 00000000..16e97e66 --- /dev/null +++ b/shared/entities/users/user-upload-cost-inputs.entity.ts @@ -0,0 +1,139 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + BaseEntity, +} from "typeorm"; +import { User } from "@shared/entities/users/user.entity"; + +@Entity("user_upload_cost_inputs") +export class UserUploadCostInputs extends BaseEntity { + @PrimaryGeneratedColumn("uuid") + id: string; + + @ManyToOne(() => User, (user) => user.uploadedCostInputs) + @JoinColumn({ name: "user_id" }) + user: User; + + @Column({ type: "varchar", length: 255, nullable: true }) + programName: string; + + @Column({ type: "int", nullable: true }) + intendedLengthOfProject: number; + + @Column({ type: "varchar", length: 255, nullable: true }) + country: string; + + @Column({ type: "varchar", length: 255, nullable: true }) + currency: string; + + @Column({ type: "int", nullable: true }) + projectStartYear: number; + + @Column({ type: "varchar", length: 255, nullable: true }) + projectActivity: string; + + @Column({ type: "varchar", length: 255, nullable: true }) + ecosystem: string; + + @Column({ type: "varchar", length: 255, nullable: true }) + projectSize: string; + + @Column({ type: "varchar", length: 255, nullable: true }) + validationStandard: string; + + @Column({ type: "varchar", length: 255, nullable: true }) + numberOfLocalIndividuals: string; + + @Column({ type: "varchar", length: 255, nullable: true }) + cityOrRegion: string; + + @Column({ type: "varchar", length: 255, nullable: true }) + intendedAlternativeUseOfLand: string; + + @Column({ type: "varchar", length: 255, nullable: true }) + landOwnershipBeforeProject: string; + + @Column({ type: "varchar", length: 255, nullable: true }) + sdgsBenefitted: string; + + @Column({ type: "boolean", nullable: true }) + projectEligibleForCarbonCredits: boolean; + + @Column({ type: "boolean", nullable: true }) + willingToSpeakAboutPricing: boolean; + + @Column({ type: "boolean", nullable: true }) + ableToProvideDetailedCostDocumentation: boolean; + + @Column({ type: "text", nullable: true }) + costCategories: string; + + @Column({ type: "int", nullable: true }) + establishingCommunityEngagement: number; + + @Column({ type: "int", nullable: true }) + conservationProjectPlanning: number; + + @Column({ type: "int", nullable: true }) + carbonProjectPlanning: number; + + @Column({ type: "int", nullable: true }) + landCost: number; + + @Column({ type: "int", nullable: true }) + financingCost: number; + + @Column({ type: "int", nullable: true }) + materialsSeedsFertilizer: number; + + @Column({ type: "int", nullable: true }) + materialsMachineryEquipment: number; + + @Column({ type: "int", nullable: true }) + projectLaborActivity: number; + + @Column({ type: "int", nullable: true }) + engineeringIntervention: number; + + @Column({ type: "int", nullable: true }) + ongoingCommunityEngagement: number; + + @Column({ type: "int", nullable: true }) + otherProjectRunningCost: number; + + @Column({ type: "int", nullable: true }) + projectMonitoring: number; + + @Column({ type: "int", nullable: true }) + otherCost1: number; + + @Column({ type: "int", nullable: true }) + otherCost2: number; + + @Column({ type: "int", nullable: true }) + otherCost3: number; + + @Column({ type: "text", nullable: true }) + projectCumulativeSequestration: string; + + @Column({ type: "text", nullable: true }) + detailedProjectActivity: string; + + @Column({ type: "text", nullable: true }) + communityEngagementSpending: string; + + @Column({ type: "text", nullable: true }) + landRightsAndEasements: string; + + @Column({ type: "text", nullable: true }) + hourlyWageRate: string; + + @Column({ type: "text", nullable: true }) + ongoingCommunityCompensation: string; + + @Column({ type: "text", nullable: true }) + engineeringDetails: string; +} diff --git a/shared/entities/users/user-upload-restoration-inputs.entity.ts b/shared/entities/users/user-upload-restoration-inputs.entity.ts new file mode 100644 index 00000000..be43501a --- /dev/null +++ b/shared/entities/users/user-upload-restoration-inputs.entity.ts @@ -0,0 +1,73 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + BaseEntity, + ManyToOne, + JoinColumn, +} from "typeorm"; +import { User } from "@shared/entities/users/user.entity"; + +@Entity("user_upload_restoration_inputs") +export class UserUploadRestorationInputs extends BaseEntity { + @PrimaryGeneratedColumn() + id: number; + + @ManyToOne(() => User, (user) => user.userUploadRestorationInputs) + @JoinColumn({ name: "user_id" }) + user: User; + + @Column({ type: "varchar", length: 255, nullable: true }) + projectName: string; + + @Column({ type: "varchar", length: 255, nullable: true }) + country: string; + + @Column({ type: "varchar", length: 255, nullable: true }) + cityOrRegion: string; + + @Column({ type: "int", nullable: true }) + projectStartYear: number; + + @Column({ type: "int", nullable: true }) + mostRecentYearOfData: number; + + @Column({ type: "varchar", length: 255, nullable: true }) + ecosystem: string; + + @Column({ type: "varchar", length: 255, nullable: true }) + projectActivity: string; + + @Column({ type: "int", nullable: true }) + projectSizeAreaStudied: number; + + @Column({ type: "varchar", length: 255, nullable: true }) + categories: string; + + @Column({ type: "int", nullable: true }) + projectArea: number; + + @Column({ type: "int", nullable: true }) + abovegroundBiomassStock: number; + + @Column({ type: "int", nullable: true }) + belowgroundBiomassStock: number; + + @Column({ type: "int", nullable: true }) + soilOrganicCarbonStock: number; + + @Column({ type: "int", nullable: true }) + methaneEmissions: number; + + @Column({ type: "int", nullable: true }) + nitrousOxideEmissions: number; + + @Column({ type: "int", nullable: true }) + abovegroundBiomassEmissionsFactor: number; + + @Column({ type: "int", nullable: true }) + belowgroundBiomassEmissionsFactor: number; + + @Column({ type: "int", nullable: true }) + soilOrganicCarbonEmissionsFactor: number; +} diff --git a/shared/entities/users/user.entity.ts b/shared/entities/users/user.entity.ts index ec4ffcac..16727524 100644 --- a/shared/entities/users/user.entity.ts +++ b/shared/entities/users/user.entity.ts @@ -4,9 +4,13 @@ import { Entity, PrimaryGeneratedColumn, BaseEntity, + OneToMany, } from "typeorm"; import { Exclude } from "class-transformer"; import { ROLES } from "@shared/entities/users/roles.enum"; +import { UserUploadCostInputs } from "@shared/entities/users/user-upload-cost-inputs.entity"; +import { UserUploadRestorationInputs } from "@shared/entities/users/user-upload-restoration-inputs.entity"; +import { UserUploadConservationInputs } from "@shared/entities/users/user-upload-conservation-inputs.entity"; // TODO: For future reference: // https://github.com/typeorm/typeorm/issues/2897 @@ -42,4 +46,13 @@ export class User extends BaseEntity { @CreateDateColumn({ name: "created_at", type: "timestamp" }) createdAt: Date; + + @OneToMany("UserUploadCostInputs", "user") + uploadedCostInputs: UserUploadCostInputs[]; + + @OneToMany("UserUploadRestorationInputs", "user") + userUploadRestorationInputs: UserUploadRestorationInputs[]; + + @OneToMany("UserUploadConservationInputs", "user") + userUploadConservationInputs: UserUploadConservationInputs[]; } diff --git a/shared/lib/db-entities.ts b/shared/lib/db-entities.ts index a19a0af1..dbc802fc 100644 --- a/shared/lib/db-entities.ts +++ b/shared/lib/db-entities.ts @@ -30,6 +30,9 @@ import { BaseSize } from "@shared/entities/base-size.entity"; import { BaseIncrease } from "@shared/entities/base-increase.entity"; import { ModelAssumptions } from "@shared/entities/model-assumptions.entity"; import { CustomProject } from "@shared/entities/custom-project.entity"; +import { UserUploadCostInputs } from "@shared/entities/users/user-upload-cost-inputs.entity"; +import { UserUploadRestorationInputs } from "@shared/entities/users/user-upload-restoration-inputs.entity"; +import { UserUploadConservationInputs } from "@shared/entities/users/user-upload-conservation-inputs.entity"; export const COMMON_DATABASE_ENTITIES = [ User, @@ -64,4 +67,7 @@ export const COMMON_DATABASE_ENTITIES = [ BaseIncrease, ModelAssumptions, CustomProject, + UserUploadCostInputs, + UserUploadRestorationInputs, + UserUploadConservationInputs, ]; diff --git a/shared/lib/e2e-test-manager.ts b/shared/lib/e2e-test-manager.ts index b5f19f15..ab3c207d 100644 --- a/shared/lib/e2e-test-manager.ts +++ b/shared/lib/e2e-test-manager.ts @@ -67,7 +67,7 @@ export class E2eTestManager { user = await this.mocks().createUser(); } await this.page.goto("/auth/signin"); - await this.page.getByLabel("Email").fill(user.email); + await this.page.getByPlaceholder('Enter your email address').fill(user.email); await this.page.locator('input[type="password"]').fill(user.password); await this.page.getByRole("button", { name: /log in/i }).click(); await this.page.waitForURL("/profile"); diff --git a/shared/lib/entity-mocks.ts b/shared/lib/entity-mocks.ts index 95eb3562..fb534da6 100644 --- a/shared/lib/entity-mocks.ts +++ b/shared/lib/entity-mocks.ts @@ -5,15 +5,17 @@ import { Project, PROJECT_PRICE_TYPE, PROJECT_SIZE_FILTER, - RESTORATION_ACTIVITY_SUBTYPE, } from "@shared/entities/projects.entity"; import { Country } from "@shared/entities/country.entity"; -import { ACTIVITY } from "@shared/entities/activity.enum"; +import { + ACTIVITY, + RESTORATION_ACTIVITY_SUBTYPE, +} from "@shared/entities/activity.enum"; import { ECOSYSTEM } from "@shared/entities/ecosystem.enum"; export const createUser = async ( dataSource: DataSource, - additionalData?: Partial + additionalData?: Partial, ): Promise => { const salt = await genSalt(); const usedPassword = additionalData?.password ?? "12345678"; @@ -30,7 +32,7 @@ export const createUser = async ( export const createProject = async ( dataSource: DataSource, - additionalData?: DeepPartial + additionalData?: DeepPartial, ): Promise => { const countries = await dataSource.getRepository(Country).find(); if (!countries.length) { @@ -41,7 +43,7 @@ export const createProject = async ( countryCode: countries[0].code, activity: ACTIVITY.CONSERVATION, ecosystem: ECOSYSTEM.MANGROVE, - activitySubtype: RESTORATION_ACTIVITY_SUBTYPE.PLANTING, + restorationActivity: RESTORATION_ACTIVITY_SUBTYPE.PLANTING, projectSize: 100, projectSizeFilter: PROJECT_SIZE_FILTER.LARGE, abatementPotential: 100, diff --git a/shared/schemas/assumptions/assumptions.enums.ts b/shared/schemas/assumptions/assumptions.enums.ts new file mode 100644 index 00000000..1f7909bf --- /dev/null +++ b/shared/schemas/assumptions/assumptions.enums.ts @@ -0,0 +1,31 @@ +export enum ECOSYSTEM_RESTORATION_RATE_NAMES { + MANGROVE = "Mangrove restoration rate", + SEAGRASS = "Seagrass restoration rate", + SALT_MARSH = "Salt marsh restoration rate", +} + +export enum ACTIVITY_PROJECT_LENGTH_NAMES { + CONSERVATION = "Conservation project length", + RESTORATION = "Restoration project length", +} + +export const COMMON_OVERRIDABLE_ASSUMPTION_NAMES = [ + "Baseline reassessment frequency", + "Buffer", + "Carbon price increase", + "Discount rate", + "Verification frequency", +] as const; + +export const ASSUMPTIONS_NAME_TO_DTO_MAP = { + "Baseline reassessment frequency": "baselineReassessmentFrequency", + Buffer: "buffer", + "Carbon price increase": "carbonPriceIncrease", + "Discount rate": "discountRate", + "Verification frequency": "verificationFrequency", + "Mangrove restoration rate": "restorationRate", + "Seagrass restoration rate": "restorationRate", + "Salt marsh restoration rate": "restorationRate", + "Conservation project length": "projectLength", + "Restoration project length": "projectLength", +} as const; diff --git a/shared/schemas/assumptions/get-assumptions.schema.ts b/shared/schemas/assumptions/get-assumptions.schema.ts new file mode 100644 index 00000000..0f72bb16 --- /dev/null +++ b/shared/schemas/assumptions/get-assumptions.schema.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; +import { ECOSYSTEM } from "@shared/entities/ecosystem.enum"; +import { ACTIVITY } from "@shared/entities/activity.enum"; + +export const GetAssumptionsSchema = z.object({ + ecosystem: z.nativeEnum(ECOSYSTEM), + activity: z.nativeEnum(ACTIVITY), +}); diff --git a/shared/schemas/custom-projects/get-cost-inputs.schema.ts b/shared/schemas/custom-projects/get-cost-inputs.schema.ts index add88be8..a67538f9 100644 --- a/shared/schemas/custom-projects/get-cost-inputs.schema.ts +++ b/shared/schemas/custom-projects/get-cost-inputs.schema.ts @@ -1,9 +1,38 @@ import { z } from "zod"; import { ECOSYSTEM } from "@shared/entities/ecosystem.enum"; -import { ACTIVITY } from "@shared/entities/activity.enum"; +import { + ACTIVITY, + RESTORATION_ACTIVITY_SUBTYPE, +} from "@shared/entities/activity.enum"; -export const GetDefaultCostInputsSchema = z.object({ - countryCode: z.string().min(3).max(3), - ecosystem: z.nativeEnum(ECOSYSTEM), - activity: z.nativeEnum(ACTIVITY), -}); +export const GetDefaultCostInputsSchema = z + .object({ + countryCode: z.string().min(3).max(3), + ecosystem: z.nativeEnum(ECOSYSTEM), + activity: z.nativeEnum(ACTIVITY), + restorationActivity: z.nativeEnum(RESTORATION_ACTIVITY_SUBTYPE).optional(), + }) + .superRefine((data, ctx) => { + if ( + data.activity === ACTIVITY.CONSERVATION && + data.restorationActivity !== undefined + ) { + ctx.addIssue({ + path: ["restorationActivitySubtype"], + message: + "restorationActivitySubtype should not be defined when activity is CONSERVATION", + code: z.ZodIssueCode.custom, + }); + } + if ( + data.activity === ACTIVITY.RESTORATION && + data.restorationActivity === undefined + ) { + ctx.addIssue({ + path: ["restorationActivitySubtype"], + message: + "restorationActivitySubtype is required when activity is RESTORATION", + code: z.ZodIssueCode.custom, + }); + } + });