From 901b6b45dc74eed46ec42835b5ab3ab3318630a7 Mon Sep 17 00:00:00 2001 From: Ismael Martinez Date: Wed, 30 Apr 2025 06:42:34 -0600 Subject: [PATCH 01/25] [TM-1995] Add TM-1995-migrate-project-pitches-be-v3 --- apps/entity-service/src/app.module.ts | 7 +++-- .../src/entities/dto/project-pitch.dto.ts | 27 +++++++++++++++++ .../src/entities/project-pitch.service.ts | 10 +++++++ .../entities/project-pitches.controller.ts | 29 +++++++++++++++++++ 4 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 apps/entity-service/src/entities/dto/project-pitch.dto.ts create mode 100644 apps/entity-service/src/entities/project-pitch.service.ts create mode 100644 apps/entity-service/src/entities/project-pitches.controller.ts diff --git a/apps/entity-service/src/app.module.ts b/apps/entity-service/src/app.module.ts index 006c28ac..e251bce6 100644 --- a/apps/entity-service/src/app.module.ts +++ b/apps/entity-service/src/app.module.ts @@ -8,17 +8,20 @@ import { EntitiesService } from "./entities/entities.service"; import { EntitiesController } from "./entities/entities.controller"; import { EntityAssociationsController } from "./entities/entity-associations.controller"; import { HealthModule } from "@terramatch-microservices/common/health/health.module"; +import { ProjectPitchesController } from "./entities/project-pitches.controller"; +import { ProjectPitchService } from "./entities/project-pitch.service"; @Module({ imports: [SentryModule.forRoot(), CommonModule, HealthModule], - controllers: [EntitiesController, EntityAssociationsController, TreesController], + controllers: [ProjectPitchesController, EntitiesController, EntityAssociationsController, TreesController], providers: [ { provide: APP_FILTER, useClass: SentryGlobalFilter }, EntitiesService, - TreeService + TreeService, + ProjectPitchService ] }) export class AppModule {} diff --git a/apps/entity-service/src/entities/dto/project-pitch.dto.ts b/apps/entity-service/src/entities/dto/project-pitch.dto.ts new file mode 100644 index 00000000..9af2d5b2 --- /dev/null +++ b/apps/entity-service/src/entities/dto/project-pitch.dto.ts @@ -0,0 +1,27 @@ +import { JsonApiDto } from "@terramatch-microservices/common/decorators"; +import { pickApiProperties } from "@terramatch-microservices/common/dto/json-api-attributes"; +import { ApiProperty } from "@nestjs/swagger"; +import { AssociationDto, AssociationDtoAdditionalProps } from "./association.dto"; +import { ProjectPitch } from "@terramatch-microservices/database/entities"; + +@JsonApiDto({ type: "projectPitches" }) +export class ProjectPitchDto extends AssociationDto { + constructor(projectPitch: ProjectPitch, additional: AssociationDtoAdditionalProps) { + super({ + ...pickApiProperties(projectPitch, ProjectPitchDto), + ...additional + }); + } + + @ApiProperty() + uuid: string; + + @ApiProperty({ nullable: true }) + capacityBuildingNeeds: string[] | null; + + @ApiProperty({ nullable: true }) + totalTrees: number | null; + + @ApiProperty({ nullable: true }) + totalHectares: number | null; +} diff --git a/apps/entity-service/src/entities/project-pitch.service.ts b/apps/entity-service/src/entities/project-pitch.service.ts new file mode 100644 index 00000000..fd3f0eea --- /dev/null +++ b/apps/entity-service/src/entities/project-pitch.service.ts @@ -0,0 +1,10 @@ +import { Injectable } from "@nestjs/common"; +import { ProjectPitchDto } from "./dto/project-pitch.dto"; + +@Injectable() +export class ProjectPitchService { + getProjectPitch(uuid: string): Promise { + // Mock implementation, replace with actual database call + return Promise.resolve(new ProjectPitchDto(null, null)); + } +} diff --git a/apps/entity-service/src/entities/project-pitches.controller.ts b/apps/entity-service/src/entities/project-pitches.controller.ts new file mode 100644 index 00000000..fa012fdb --- /dev/null +++ b/apps/entity-service/src/entities/project-pitches.controller.ts @@ -0,0 +1,29 @@ +import { BadRequestException, Controller, Get, NotFoundException, Param } from "@nestjs/common"; +import { EntityAssociationIndexParamsDto } from "./dto/entity-association-index-params.dto"; +import { PolicyService } from "@terramatch-microservices/common"; +import { buildJsonApi } from "@terramatch-microservices/common/util"; +import { ApiOperation } from "@nestjs/swagger"; +import { ExceptionResponse } from "@terramatch-microservices/common/decorators"; +import { ProjectPitchService } from "./project-pitch.service"; +import { ProjectPitchDto } from "./dto/project-pitch.dto"; + +@Controller("entities/v3/projectPitches") +export class ProjectPitchesController { + constructor( + private readonly projectPitchService: ProjectPitchService, + private readonly policyService: PolicyService + ) {} + + @Get(":uuid") + @ApiOperation({ + operationId: "ProjectPitchesAssociationIndex", + summary: "Get an project pitch by uuid." + }) + @ExceptionResponse(BadRequestException, { description: "Param types invalid" }) + @ExceptionResponse(NotFoundException, { description: "Base entity not found" }) + async getByUUID(@Param() { uuid }: EntityAssociationIndexParamsDto) { + const result = await this.projectPitchService.getProjectPitch(uuid); + + return buildJsonApi(ProjectPitchDto).addData(uuid, result).document.serialize(); + } +} From 611558a01627d3c5bf008409ab2cbcb916393dda Mon Sep 17 00:00:00 2001 From: Ismael Martinez Date: Thu, 1 May 2025 11:05:15 -0600 Subject: [PATCH 02/25] [TM-1995] feat: enhance ProjectPitchDto and service with additional properties and methods --- .../entities/dto/project-pitch-param.dto.ts | 6 + .../src/entities/dto/project-pitch.dto.ts | 459 +++++++++++++++++- .../src/entities/project-pitch.service.ts | 11 +- .../entities/project-pitches.controller.ts | 20 +- 4 files changed, 476 insertions(+), 20 deletions(-) create mode 100644 apps/entity-service/src/entities/dto/project-pitch-param.dto.ts diff --git a/apps/entity-service/src/entities/dto/project-pitch-param.dto.ts b/apps/entity-service/src/entities/dto/project-pitch-param.dto.ts new file mode 100644 index 00000000..75afcda5 --- /dev/null +++ b/apps/entity-service/src/entities/dto/project-pitch-param.dto.ts @@ -0,0 +1,6 @@ +import { ApiProperty } from "@nestjs/swagger"; + +export class ProjectPitchParamDto { + @ApiProperty({ description: "Entity UUID for association" }) + uuid: string; +} diff --git a/apps/entity-service/src/entities/dto/project-pitch.dto.ts b/apps/entity-service/src/entities/dto/project-pitch.dto.ts index 9af2d5b2..b74aaaeb 100644 --- a/apps/entity-service/src/entities/dto/project-pitch.dto.ts +++ b/apps/entity-service/src/entities/dto/project-pitch.dto.ts @@ -1,27 +1,462 @@ import { JsonApiDto } from "@terramatch-microservices/common/decorators"; -import { pickApiProperties } from "@terramatch-microservices/common/dto/json-api-attributes"; import { ApiProperty } from "@nestjs/swagger"; -import { AssociationDto, AssociationDtoAdditionalProps } from "./association.dto"; -import { ProjectPitch } from "@terramatch-microservices/database/entities"; +import { IsArray, IsDate, IsNumber, IsOptional, IsString, IsUUID, Max, Min } from "class-validator"; @JsonApiDto({ type: "projectPitches" }) -export class ProjectPitchDto extends AssociationDto { - constructor(projectPitch: ProjectPitch, additional: AssociationDtoAdditionalProps) { - super({ - ...pickApiProperties(projectPitch, ProjectPitchDto), - ...additional - }); +export class ProjectPitchDto { + constructor(data: Partial) { + Object.assign(this, data); } @ApiProperty() + @IsUUID() uuid: string; - @ApiProperty({ nullable: true }) + @ApiProperty({ required: false }) + @IsOptional() + @IsArray() + @IsString({ each: true }) capacityBuildingNeeds: string[] | null; - @ApiProperty({ nullable: true }) + @ApiProperty({ required: false }) + @IsOptional() + @IsNumber() + @Min(0) totalTrees: number | null; - @ApiProperty({ nullable: true }) + @ApiProperty({ required: false }) + @IsOptional() + @IsNumber() + @Min(0) totalHectares: number | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + restorationInterventionTypes: string[] | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + landUseTypes: string[] | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + restorationStrategy: string[] | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + projectCountyDistrict: string | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + projectCountry: string | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + projectObjectives: string | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + projectName: string | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsUUID() + organisationId: string | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsUUID() + fundingProgrammeId: string | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsNumber() + @Min(0) + projectBudget: number | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + howDiscovered: string[] | null; + + @ApiProperty() + @IsString() + status: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsDate() + expectedActiveRestorationStartDate: Date | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsDate() + expectedActiveRestorationEndDate: Date | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + descriptionOfProjectTimeline: string | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + projPartnerInfo: string | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + landTenureProjArea: string[] | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + landholderCommEngage: string | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + projSuccessRisks: string | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + monitorEvalPlan: string | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + projBoundary: string | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + sustainableDevGoals: string[] | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + projAreaDescription: string | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + environmentalGoals: string | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsNumber() + @Min(0) + proposedNumSites: number | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsNumber() + @Min(0) + proposedNumNurseries: number | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + currLandDegradation: string | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + mainDegradationCauses: string | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + seedlingsSource: string | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + projImpactSocieconom: string | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + projImpactFoodsec: string | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + projImpactWatersec: string | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + projImpactJobtypes: string | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsNumber() + @Min(0) + numJobsCreated: number | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsNumber() + @Min(0) + @Max(100) + pctEmployeesMen: number | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsNumber() + @Min(0) + @Max(100) + pctEmployeesWomen: number | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsNumber() + @Min(0) + @Max(100) + pctEmployees18To35: number | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsNumber() + @Min(0) + @Max(100) + pctEmployeesOlder35: number | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsNumber() + @Min(0) + projBeneficiaries: number | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsNumber() + @Min(0) + @Max(100) + pctBeneficiariesWomen: number | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsNumber() + @Min(0) + @Max(100) + pctBeneficiariesSmall: number | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsNumber() + @Min(0) + @Max(100) + pctBeneficiariesLarge: number | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsNumber() + @Min(0) + @Max(100) + pctBeneficiariesYouth: number | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + mainCausesOfDegradation: string | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + states: string[] | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsNumber() + @Min(0) + hectaresFirstYr: number | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsNumber() + @Min(0) + totalTreesFirstYr: number | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsNumber() + @Min(0) + pctBeneficiariesBackwardClass: number | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + landSystems: string[] | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + treeRestorationPractices: string[] | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + detailedInterventionTypes: string[] | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + monitoringEvaluationPlan: string | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsNumber() + @Min(0) + pctBeneficiariesScheduledClasses: number | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsNumber() + @Min(0) + pctBeneficiariesScheduledTribes: number | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + theoryOfChange: string | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + proposedGovPartners: string | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + pctSchTribe: string | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + sustainabilityPlan: string | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + replicationPlan: string | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + replicationChallenges: string | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + solutionMarketSite: string | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + affordabilityOfSolution: string | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + growthTrendsBusiness: string | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + limitationsOnScope: string | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + businessModelReplicationPlan: string | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + biodiversityImpact: string | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + waterSource: string | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + climateResilience: string | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + soilHealth: string | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsNumber() + @Min(0) + @Max(100) + pctEmployeesMarginalised: number | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsNumber() + @Min(0) + @Max(100) + pctBeneficiariesMarginalised: number | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsNumber() + @Min(0) + @Max(100) + pctBeneficiariesMen: number | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + baselineBiodiversity: string | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsNumber() + @Min(0) + goalTreesRestoredPlanting: number | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsNumber() + @Min(0) + goalTreesRestoredAnr: number | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsNumber() + @Min(0) + goalTreesRestoredDirectSeeding: number | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsNumber() + @Min(0) + directSeedingSurvivalRate: number | null; } diff --git a/apps/entity-service/src/entities/project-pitch.service.ts b/apps/entity-service/src/entities/project-pitch.service.ts index fd3f0eea..dd5f95ec 100644 --- a/apps/entity-service/src/entities/project-pitch.service.ts +++ b/apps/entity-service/src/entities/project-pitch.service.ts @@ -1,10 +1,13 @@ import { Injectable } from "@nestjs/common"; -import { ProjectPitchDto } from "./dto/project-pitch.dto"; +import { ProjectPitch } from "@terramatch-microservices/database/entities"; @Injectable() export class ProjectPitchService { - getProjectPitch(uuid: string): Promise { - // Mock implementation, replace with actual database call - return Promise.resolve(new ProjectPitchDto(null, null)); + async getProjectPitch(uuid: string): Promise { + return await ProjectPitch.findOne({ where: { uuid } }); + } + + async getProjectPitches(): Promise { + return await ProjectPitch.findAll({ limit: 10 }); } } diff --git a/apps/entity-service/src/entities/project-pitches.controller.ts b/apps/entity-service/src/entities/project-pitches.controller.ts index fa012fdb..7a6bf8c0 100644 --- a/apps/entity-service/src/entities/project-pitches.controller.ts +++ b/apps/entity-service/src/entities/project-pitches.controller.ts @@ -6,6 +6,7 @@ import { ApiOperation } from "@nestjs/swagger"; import { ExceptionResponse } from "@terramatch-microservices/common/decorators"; import { ProjectPitchService } from "./project-pitch.service"; import { ProjectPitchDto } from "./dto/project-pitch.dto"; +import { ProjectPitchParamDto } from "./dto/project-pitch-param.dto"; @Controller("entities/v3/projectPitches") export class ProjectPitchesController { @@ -16,14 +17,25 @@ export class ProjectPitchesController { @Get(":uuid") @ApiOperation({ - operationId: "ProjectPitchesAssociationIndex", + operationId: "ProjectPitchesGetUUIDIndex", summary: "Get an project pitch by uuid." }) @ExceptionResponse(BadRequestException, { description: "Param types invalid" }) - @ExceptionResponse(NotFoundException, { description: "Base entity not found" }) - async getByUUID(@Param() { uuid }: EntityAssociationIndexParamsDto) { + @ExceptionResponse(NotFoundException, { description: "Project pitch not found" }) + async getByUUID(@Param() { uuid }: ProjectPitchParamDto) { const result = await this.projectPitchService.getProjectPitch(uuid); + return buildJsonApi(ProjectPitchDto).addData(uuid, new ProjectPitchDto(result)).document.serialize(); + } - return buildJsonApi(ProjectPitchDto).addData(uuid, result).document.serialize(); + @Get() + @ApiOperation({ + operationId: "ProjectPitchesIndex", + summary: "Get projects pitches." + }) + @ExceptionResponse(BadRequestException, { description: "Param types invalid" }) + @ExceptionResponse(NotFoundException, { description: "Records not found" }) + async getPitches() { + const result = await this.projectPitchService.getProjectPitches(); + return buildJsonApi(ProjectPitchDto, { forceDataArray: true }).addData("dummy", result).document.serialize(); } } From 0cb9a661e88d6d6b9467fbbc89a0d1b4b556bbb8 Mon Sep 17 00:00:00 2001 From: Ismael Martinez Date: Thu, 1 May 2025 12:12:01 -0600 Subject: [PATCH 03/25] [TM-1995] feat: implement user-specific project pitch retrieval and enhance DTO structure --- .../src/entities/dto/project-pitch.dto.ts | 11 +++-- .../src/entities/project-pitch.service.ts | 24 +++++++++- .../entities/project-pitches.controller.ts | 44 +++++++++++++------ 3 files changed, 60 insertions(+), 19 deletions(-) diff --git a/apps/entity-service/src/entities/dto/project-pitch.dto.ts b/apps/entity-service/src/entities/dto/project-pitch.dto.ts index b74aaaeb..7451eab2 100644 --- a/apps/entity-service/src/entities/dto/project-pitch.dto.ts +++ b/apps/entity-service/src/entities/dto/project-pitch.dto.ts @@ -1,11 +1,16 @@ import { JsonApiDto } from "@terramatch-microservices/common/decorators"; import { ApiProperty } from "@nestjs/swagger"; import { IsArray, IsDate, IsNumber, IsOptional, IsString, IsUUID, Max, Min } from "class-validator"; +import { JsonApiAttributes, pickApiProperties } from "@terramatch-microservices/common/dto/json-api-attributes"; +import { ProjectPitch } from "@terramatch-microservices/database/entities"; @JsonApiDto({ type: "projectPitches" }) -export class ProjectPitchDto { - constructor(data: Partial) { - Object.assign(this, data); +export class ProjectPitchDto extends JsonApiAttributes { + constructor(data: ProjectPitch) { + pickApiProperties(data, ProjectPitchDto); + super({ + ...pickApiProperties(data, ProjectPitchDto) + }); } @ApiProperty() diff --git a/apps/entity-service/src/entities/project-pitch.service.ts b/apps/entity-service/src/entities/project-pitch.service.ts index dd5f95ec..f7da42ee 100644 --- a/apps/entity-service/src/entities/project-pitch.service.ts +++ b/apps/entity-service/src/entities/project-pitch.service.ts @@ -1,5 +1,7 @@ import { Injectable } from "@nestjs/common"; -import { ProjectPitch } from "@terramatch-microservices/database/entities"; +import { ProjectPitch, User } from "@terramatch-microservices/database/entities"; +import { Op } from "sequelize"; +import * as console from "node:console"; @Injectable() export class ProjectPitchService { @@ -7,7 +9,25 @@ export class ProjectPitchService { return await ProjectPitch.findOne({ where: { uuid } }); } - async getProjectPitches(): Promise { + async getProjectPitches(userId: string): Promise { + const user = await User.findOne({ + include: ["roles", "organisation", "frameworks"], + where: { id: userId } + }); + await user.loadOrganisation(); + console.log("user organization", user.organisations); + return await ProjectPitch.findAll({ + where: { + /*organisationId: { + [Op.in]: user.organisations.map(org => org.id) + },*/ + organisationId: user.organisation.id + }, + limit: 10 + }); + } + + async getAdminProjectPitches(): Promise { return await ProjectPitch.findAll({ limit: 10 }); } } diff --git a/apps/entity-service/src/entities/project-pitches.controller.ts b/apps/entity-service/src/entities/project-pitches.controller.ts index 7a6bf8c0..661f359d 100644 --- a/apps/entity-service/src/entities/project-pitches.controller.ts +++ b/apps/entity-service/src/entities/project-pitches.controller.ts @@ -1,5 +1,4 @@ -import { BadRequestException, Controller, Get, NotFoundException, Param } from "@nestjs/common"; -import { EntityAssociationIndexParamsDto } from "./dto/entity-association-index-params.dto"; +import { BadRequestException, Controller, Get, NotFoundException, Param, Request } from "@nestjs/common"; import { PolicyService } from "@terramatch-microservices/common"; import { buildJsonApi } from "@terramatch-microservices/common/util"; import { ApiOperation } from "@nestjs/swagger"; @@ -15,27 +14,44 @@ export class ProjectPitchesController { private readonly policyService: PolicyService ) {} - @Get(":uuid") + @Get() @ApiOperation({ - operationId: "ProjectPitchesGetUUIDIndex", - summary: "Get an project pitch by uuid." + operationId: "ProjectPitchesIndex", + summary: "Get projects pitches." }) @ExceptionResponse(BadRequestException, { description: "Param types invalid" }) - @ExceptionResponse(NotFoundException, { description: "Project pitch not found" }) - async getByUUID(@Param() { uuid }: ProjectPitchParamDto) { - const result = await this.projectPitchService.getProjectPitch(uuid); - return buildJsonApi(ProjectPitchDto).addData(uuid, new ProjectPitchDto(result)).document.serialize(); + @ExceptionResponse(NotFoundException, { description: "Records not found" }) + async getPitches(@Request() { authenticatedUserId }) { + const result = await this.projectPitchService.getProjectPitches(authenticatedUserId); + const document = buildJsonApi(ProjectPitchDto, { forceDataArray: true }); + for (const pitch of result) { + const pitchDto = new ProjectPitchDto(pitch); + document.addData(pitchDto.uuid, pitchDto); + } + return document.serialize(); } - @Get() + @Get("/admin") @ApiOperation({ - operationId: "ProjectPitchesIndex", - summary: "Get projects pitches." + operationId: "AdminProjectPitchesIndex", + summary: "Get admin projects pitches." }) @ExceptionResponse(BadRequestException, { description: "Param types invalid" }) @ExceptionResponse(NotFoundException, { description: "Records not found" }) - async getPitches() { - const result = await this.projectPitchService.getProjectPitches(); + async getAdminPitches() { + const result = await this.projectPitchService.getAdminProjectPitches(); return buildJsonApi(ProjectPitchDto, { forceDataArray: true }).addData("dummy", result).document.serialize(); } + + @Get(":uuid") + @ApiOperation({ + operationId: "ProjectPitchesGetUUIDIndex", + summary: "Get an project pitch by uuid." + }) + @ExceptionResponse(BadRequestException, { description: "Param types invalid" }) + @ExceptionResponse(NotFoundException, { description: "Project pitch not found" }) + async getByUUID(@Param() { uuid }: ProjectPitchParamDto) { + const result = await this.projectPitchService.getProjectPitch(uuid); + return buildJsonApi(ProjectPitchDto).addData(uuid, new ProjectPitchDto(result)).document.serialize(); + } } From 941898a13ad22f00fe1ae89f05fa9ecf9e607e7c Mon Sep 17 00:00:00 2001 From: Ismael Martinez Date: Fri, 2 May 2025 14:04:47 -0600 Subject: [PATCH 04/25] [TM-1995] feat: add id property to ProjectPitchDto and enhance controller response with JsonApiResponse --- apps/entity-service/src/entities/dto/project-pitch.dto.ts | 3 +++ .../src/entities/project-pitches.controller.ts | 5 +++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/entity-service/src/entities/dto/project-pitch.dto.ts b/apps/entity-service/src/entities/dto/project-pitch.dto.ts index 7451eab2..dc8d09f7 100644 --- a/apps/entity-service/src/entities/dto/project-pitch.dto.ts +++ b/apps/entity-service/src/entities/dto/project-pitch.dto.ts @@ -13,6 +13,9 @@ export class ProjectPitchDto extends JsonApiAttributes { }); } + @ApiProperty() + id: number; + @ApiProperty() @IsUUID() uuid: string; diff --git a/apps/entity-service/src/entities/project-pitches.controller.ts b/apps/entity-service/src/entities/project-pitches.controller.ts index 661f359d..9bb5bae8 100644 --- a/apps/entity-service/src/entities/project-pitches.controller.ts +++ b/apps/entity-service/src/entities/project-pitches.controller.ts @@ -1,8 +1,8 @@ -import { BadRequestException, Controller, Get, NotFoundException, Param, Request } from "@nestjs/common"; +import { BadRequestException, Controller, Get, HttpStatus, NotFoundException, Param, Request } from "@nestjs/common"; import { PolicyService } from "@terramatch-microservices/common"; import { buildJsonApi } from "@terramatch-microservices/common/util"; import { ApiOperation } from "@nestjs/swagger"; -import { ExceptionResponse } from "@terramatch-microservices/common/decorators"; +import { ExceptionResponse, JsonApiResponse } from "@terramatch-microservices/common/decorators"; import { ProjectPitchService } from "./project-pitch.service"; import { ProjectPitchDto } from "./dto/project-pitch.dto"; import { ProjectPitchParamDto } from "./dto/project-pitch-param.dto"; @@ -48,6 +48,7 @@ export class ProjectPitchesController { operationId: "ProjectPitchesGetUUIDIndex", summary: "Get an project pitch by uuid." }) + @JsonApiResponse(ProjectPitchDto, { status: HttpStatus.OK }) @ExceptionResponse(BadRequestException, { description: "Param types invalid" }) @ExceptionResponse(NotFoundException, { description: "Project pitch not found" }) async getByUUID(@Param() { uuid }: ProjectPitchParamDto) { From 9b867266a13c3187a7b56ad68942a7e2728be37a Mon Sep 17 00:00:00 2001 From: Ismael Martinez Date: Fri, 2 May 2025 15:39:14 -0600 Subject: [PATCH 05/25] [TM-1995] feat: enhance getPitches method to accept pagination and search parameters --- .../src/entities/dto/projects-pitches-param.dto.ts | 9 +++++++++ .../src/entities/project-pitches.controller.ts | 10 ++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 apps/entity-service/src/entities/dto/projects-pitches-param.dto.ts diff --git a/apps/entity-service/src/entities/dto/projects-pitches-param.dto.ts b/apps/entity-service/src/entities/dto/projects-pitches-param.dto.ts new file mode 100644 index 00000000..90c97756 --- /dev/null +++ b/apps/entity-service/src/entities/dto/projects-pitches-param.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from "@nestjs/swagger"; + +export class ProjectsPitchesParamDto { + @ApiProperty({ description: "pagination page" }) + perPage?: number; + + @ApiProperty({ description: "uuids array to search" }) + search: string[]; +} diff --git a/apps/entity-service/src/entities/project-pitches.controller.ts b/apps/entity-service/src/entities/project-pitches.controller.ts index 9bb5bae8..c8238572 100644 --- a/apps/entity-service/src/entities/project-pitches.controller.ts +++ b/apps/entity-service/src/entities/project-pitches.controller.ts @@ -6,6 +6,7 @@ import { ExceptionResponse, JsonApiResponse } from "@terramatch-microservices/co import { ProjectPitchService } from "./project-pitch.service"; import { ProjectPitchDto } from "./dto/project-pitch.dto"; import { ProjectPitchParamDto } from "./dto/project-pitch-param.dto"; +import { ProjectsPitchesParamDto } from "./dto/projects-pitches-param.dto"; @Controller("entities/v3/projectPitches") export class ProjectPitchesController { @@ -21,7 +22,7 @@ export class ProjectPitchesController { }) @ExceptionResponse(BadRequestException, { description: "Param types invalid" }) @ExceptionResponse(NotFoundException, { description: "Records not found" }) - async getPitches(@Request() { authenticatedUserId }) { + async getPitches(@Request() { authenticatedUserId }, @Param() { perPage, search }: ProjectsPitchesParamDto) { const result = await this.projectPitchService.getProjectPitches(authenticatedUserId); const document = buildJsonApi(ProjectPitchDto, { forceDataArray: true }); for (const pitch of result) { @@ -40,7 +41,12 @@ export class ProjectPitchesController { @ExceptionResponse(NotFoundException, { description: "Records not found" }) async getAdminPitches() { const result = await this.projectPitchService.getAdminProjectPitches(); - return buildJsonApi(ProjectPitchDto, { forceDataArray: true }).addData("dummy", result).document.serialize(); + const document = buildJsonApi(ProjectPitchDto, { forceDataArray: true }); + for (const pitch of result) { + const pitchDto = new ProjectPitchDto(pitch); + document.addData(pitchDto.uuid, pitchDto); + } + return document.serialize(); } @Get(":uuid") From 06d63c557862ae8e51ad5c607783e86ae531deac Mon Sep 17 00:00:00 2001 From: Ismael Martinez Date: Mon, 5 May 2025 07:33:42 -0600 Subject: [PATCH 06/25] [TM-1995] feat: enhance project pitch retrieval with pagination and search capabilities --- .../dto/projects-pitches-param.dto.ts | 2 +- .../src/entities/project-pitch.service.ts | 66 ++++++++++++++----- .../entities/project-pitches.controller.ts | 40 +++++++++-- .../src/lib/entities/project-pitch.entity.ts | 6 +- 4 files changed, 89 insertions(+), 25 deletions(-) diff --git a/apps/entity-service/src/entities/dto/projects-pitches-param.dto.ts b/apps/entity-service/src/entities/dto/projects-pitches-param.dto.ts index 90c97756..78724302 100644 --- a/apps/entity-service/src/entities/dto/projects-pitches-param.dto.ts +++ b/apps/entity-service/src/entities/dto/projects-pitches-param.dto.ts @@ -2,7 +2,7 @@ import { ApiProperty } from "@nestjs/swagger"; export class ProjectsPitchesParamDto { @ApiProperty({ description: "pagination page" }) - perPage?: number; + page?: number; @ApiProperty({ description: "uuids array to search" }) search: string[]; diff --git a/apps/entity-service/src/entities/project-pitch.service.ts b/apps/entity-service/src/entities/project-pitch.service.ts index f7da42ee..4e20119f 100644 --- a/apps/entity-service/src/entities/project-pitch.service.ts +++ b/apps/entity-service/src/entities/project-pitch.service.ts @@ -1,7 +1,8 @@ import { Injectable } from "@nestjs/common"; import { ProjectPitch, User } from "@terramatch-microservices/database/entities"; -import { Op } from "sequelize"; -import * as console from "node:console"; +import { Includeable, Op } from "sequelize"; +import { ProjectsPitchesParamDto } from "./dto/projects-pitches-param.dto"; +import { PaginatedQueryBuilder } from "@terramatch-microservices/database/util/paginated-query.builder"; @Injectable() export class ProjectPitchService { @@ -9,25 +10,58 @@ export class ProjectPitchService { return await ProjectPitch.findOne({ where: { uuid } }); } - async getProjectPitches(userId: string): Promise { + async getProjectPitches(userId: string, params: ProjectsPitchesParamDto) { const user = await User.findOne({ - include: ["roles", "organisation", "frameworks"], + include: ["roles", "organisations", "frameworks"], where: { id: userId } }); - await user.loadOrganisation(); - console.log("user organization", user.organisations); - return await ProjectPitch.findAll({ - where: { - /*organisationId: { - [Op.in]: user.organisations.map(org => org.id) - },*/ - organisationId: user.organisation.id - }, - limit: 10 + const pageNumber = params.page ?? 1; + const organisationAssociation: Includeable = { + association: "organisation", + attributes: ["uuid", "name"] + }; + const builder = new PaginatedQueryBuilder(ProjectPitch, pageNumber, [organisationAssociation]); + if (pageNumber > 1) { + builder.pageNumber(pageNumber); + } + + if (params.search) { + builder.where({ + [Op.or]: [ + { projectName: { [Op.like]: `%${params.search}%` } }, + { "$organisation.name$": { [Op.like]: `%${params.search}%` } } + ] + }); + } + + builder.where({ + organisationId: { + [Op.in]: user.organisations.map(org => org.uuid) + } }); + + return { data: await builder.execute(), paginationTotal: await builder.paginationTotal() }; } - async getAdminProjectPitches(): Promise { - return await ProjectPitch.findAll({ limit: 10 }); + async getAdminProjectPitches(params: ProjectsPitchesParamDto) { + const pageNumber = params.page ?? 1; + const organisationAssociation: Includeable = { + association: "organisation", + attributes: ["uuid", "name"] + }; + const builder = new PaginatedQueryBuilder(ProjectPitch, pageNumber, [organisationAssociation]); + if (pageNumber > 1) { + builder.pageNumber(pageNumber); + } + + if (params.search) { + builder.where({ + [Op.or]: [ + { projectName: { [Op.like]: `%${params.search}%` } }, + { "$organisation.name$": { [Op.like]: `%${params.search}%` } } + ] + }); + } + return { data: await builder.execute(), paginationTotal: await builder.paginationTotal() }; } } diff --git a/apps/entity-service/src/entities/project-pitches.controller.ts b/apps/entity-service/src/entities/project-pitches.controller.ts index c8238572..ffc632e5 100644 --- a/apps/entity-service/src/entities/project-pitches.controller.ts +++ b/apps/entity-service/src/entities/project-pitches.controller.ts @@ -1,4 +1,13 @@ -import { BadRequestException, Controller, Get, HttpStatus, NotFoundException, Param, Request } from "@nestjs/common"; +import { + BadRequestException, + Controller, + Get, + HttpStatus, + NotFoundException, + Param, + Query, + Request +} from "@nestjs/common"; import { PolicyService } from "@terramatch-microservices/common"; import { buildJsonApi } from "@terramatch-microservices/common/util"; import { ApiOperation } from "@nestjs/swagger"; @@ -22,13 +31,22 @@ export class ProjectPitchesController { }) @ExceptionResponse(BadRequestException, { description: "Param types invalid" }) @ExceptionResponse(NotFoundException, { description: "Records not found" }) - async getPitches(@Request() { authenticatedUserId }, @Param() { perPage, search }: ProjectsPitchesParamDto) { - const result = await this.projectPitchService.getProjectPitches(authenticatedUserId); + async getPitches(@Request() { authenticatedUserId }, @Query() params: ProjectsPitchesParamDto) { + const { data, paginationTotal } = await this.projectPitchService.getProjectPitches(authenticatedUserId, params); const document = buildJsonApi(ProjectPitchDto, { forceDataArray: true }); - for (const pitch of result) { + const indexIds: string[] = []; + for (const pitch of data) { + indexIds.push(pitch.uuid); const pitchDto = new ProjectPitchDto(pitch); document.addData(pitchDto.uuid, pitchDto); } + + document.addIndexData({ + resource: "projectPitches", + requestPath: `/entities/v3/projectPitches`, + ids: indexIds, + total: paginationTotal + }); return document.serialize(); } @@ -39,13 +57,21 @@ export class ProjectPitchesController { }) @ExceptionResponse(BadRequestException, { description: "Param types invalid" }) @ExceptionResponse(NotFoundException, { description: "Records not found" }) - async getAdminPitches() { - const result = await this.projectPitchService.getAdminProjectPitches(); + async getAdminPitches(@Query() params: ProjectsPitchesParamDto) { + const { data, paginationTotal } = await this.projectPitchService.getAdminProjectPitches(params); const document = buildJsonApi(ProjectPitchDto, { forceDataArray: true }); - for (const pitch of result) { + const indexIds: string[] = []; + for (const pitch of data) { + indexIds.push(pitch.uuid); const pitchDto = new ProjectPitchDto(pitch); document.addData(pitchDto.uuid, pitchDto); } + document.addIndexData({ + resource: "projectPitches", + requestPath: `/entities/v3/projectPitches`, + ids: indexIds, + total: paginationTotal + }); return document.serialize(); } diff --git a/libs/database/src/lib/entities/project-pitch.entity.ts b/libs/database/src/lib/entities/project-pitch.entity.ts index 784cfefe..976e5194 100644 --- a/libs/database/src/lib/entities/project-pitch.entity.ts +++ b/libs/database/src/lib/entities/project-pitch.entity.ts @@ -1,6 +1,7 @@ -import { AllowNull, AutoIncrement, Column, Index, Model, PrimaryKey, Table } from "sequelize-typescript"; +import { AllowNull, AutoIncrement, BelongsTo, Column, Index, Model, PrimaryKey, Table } from "sequelize-typescript"; import { BIGINT, DATE, INTEGER, STRING, TEXT, TINYINT, UUID, UUIDV4 } from "sequelize"; import { JsonColumn } from "../decorators/json-column.decorator"; +import { Organisation } from "@terramatch-microservices/database/entities/organisation.entity"; @Table({ tableName: "project_pitches", underscored: true, paranoid: true }) export class ProjectPitch extends Model { @@ -333,4 +334,7 @@ export class ProjectPitch extends Model { @AllowNull @Column(INTEGER.UNSIGNED) directSeedingSurvivalRate: number | null; + + @BelongsTo(() => Organisation, { foreignKey: "organisationId", constraints: false }) + organisation: Organisation | null; } From 2cbffc09b66e760567549b5e2d90547c89ed3be5 Mon Sep 17 00:00:00 2001 From: Ismael Martinez Date: Mon, 5 May 2025 09:17:16 -0600 Subject: [PATCH 07/25] [TM-1995] feat: update pagination parameters in project pitch service and controller --- .../entities/dto/projects-pitches-param.dto.ts | 9 ++++++--- .../src/entities/project-pitch.service.ts | 15 +++++++++------ .../src/entities/project-pitches.controller.ts | 15 ++++++++++----- 3 files changed, 25 insertions(+), 14 deletions(-) diff --git a/apps/entity-service/src/entities/dto/projects-pitches-param.dto.ts b/apps/entity-service/src/entities/dto/projects-pitches-param.dto.ts index 78724302..ef180212 100644 --- a/apps/entity-service/src/entities/dto/projects-pitches-param.dto.ts +++ b/apps/entity-service/src/entities/dto/projects-pitches-param.dto.ts @@ -2,8 +2,11 @@ import { ApiProperty } from "@nestjs/swagger"; export class ProjectsPitchesParamDto { @ApiProperty({ description: "pagination page" }) - page?: number; + pageNumber?: number; - @ApiProperty({ description: "uuids array to search" }) - search: string[]; + @ApiProperty({ description: "pagination page" }) + pageSize?: number; + + @ApiProperty({ description: "text to search" }) + search: string; } diff --git a/apps/entity-service/src/entities/project-pitch.service.ts b/apps/entity-service/src/entities/project-pitch.service.ts index 4e20119f..11229ae5 100644 --- a/apps/entity-service/src/entities/project-pitch.service.ts +++ b/apps/entity-service/src/entities/project-pitch.service.ts @@ -3,6 +3,7 @@ import { ProjectPitch, User } from "@terramatch-microservices/database/entities" import { Includeable, Op } from "sequelize"; import { ProjectsPitchesParamDto } from "./dto/projects-pitches-param.dto"; import { PaginatedQueryBuilder } from "@terramatch-microservices/database/util/paginated-query.builder"; +import { MAX_PAGE_SIZE } from "./entities.service"; @Injectable() export class ProjectPitchService { @@ -15,12 +16,13 @@ export class ProjectPitchService { include: ["roles", "organisations", "frameworks"], where: { id: userId } }); - const pageNumber = params.page ?? 1; + const pageNumber = params.pageNumber ?? 1; + const pageSize = params.pageSize ?? MAX_PAGE_SIZE; const organisationAssociation: Includeable = { association: "organisation", attributes: ["uuid", "name"] }; - const builder = new PaginatedQueryBuilder(ProjectPitch, pageNumber, [organisationAssociation]); + const builder = new PaginatedQueryBuilder(ProjectPitch, pageSize, [organisationAssociation]); if (pageNumber > 1) { builder.pageNumber(pageNumber); } @@ -40,16 +42,17 @@ export class ProjectPitchService { } }); - return { data: await builder.execute(), paginationTotal: await builder.paginationTotal() }; + return { data: await builder.execute(), paginationTotal: await builder.paginationTotal(), pageNumber }; } async getAdminProjectPitches(params: ProjectsPitchesParamDto) { - const pageNumber = params.page ?? 1; + const pageNumber = params.pageNumber ?? 1; + const pageSize = params.pageSize ?? MAX_PAGE_SIZE; const organisationAssociation: Includeable = { association: "organisation", attributes: ["uuid", "name"] }; - const builder = new PaginatedQueryBuilder(ProjectPitch, pageNumber, [organisationAssociation]); + const builder = new PaginatedQueryBuilder(ProjectPitch, pageSize, [organisationAssociation]); if (pageNumber > 1) { builder.pageNumber(pageNumber); } @@ -62,6 +65,6 @@ export class ProjectPitchService { ] }); } - return { data: await builder.execute(), paginationTotal: await builder.paginationTotal() }; + return { data: await builder.execute(), paginationTotal: await builder.paginationTotal(), pageNumber }; } } diff --git a/apps/entity-service/src/entities/project-pitches.controller.ts b/apps/entity-service/src/entities/project-pitches.controller.ts index ffc632e5..ef6088e0 100644 --- a/apps/entity-service/src/entities/project-pitches.controller.ts +++ b/apps/entity-service/src/entities/project-pitches.controller.ts @@ -32,7 +32,10 @@ export class ProjectPitchesController { @ExceptionResponse(BadRequestException, { description: "Param types invalid" }) @ExceptionResponse(NotFoundException, { description: "Records not found" }) async getPitches(@Request() { authenticatedUserId }, @Query() params: ProjectsPitchesParamDto) { - const { data, paginationTotal } = await this.projectPitchService.getProjectPitches(authenticatedUserId, params); + const { data, paginationTotal, pageNumber } = await this.projectPitchService.getProjectPitches( + authenticatedUserId, + params + ); const document = buildJsonApi(ProjectPitchDto, { forceDataArray: true }); const indexIds: string[] = []; for (const pitch of data) { @@ -45,7 +48,8 @@ export class ProjectPitchesController { resource: "projectPitches", requestPath: `/entities/v3/projectPitches`, ids: indexIds, - total: paginationTotal + total: paginationTotal, + pageNumber: pageNumber }); return document.serialize(); } @@ -58,7 +62,7 @@ export class ProjectPitchesController { @ExceptionResponse(BadRequestException, { description: "Param types invalid" }) @ExceptionResponse(NotFoundException, { description: "Records not found" }) async getAdminPitches(@Query() params: ProjectsPitchesParamDto) { - const { data, paginationTotal } = await this.projectPitchService.getAdminProjectPitches(params); + const { data, paginationTotal, pageNumber } = await this.projectPitchService.getAdminProjectPitches(params); const document = buildJsonApi(ProjectPitchDto, { forceDataArray: true }); const indexIds: string[] = []; for (const pitch of data) { @@ -68,9 +72,10 @@ export class ProjectPitchesController { } document.addIndexData({ resource: "projectPitches", - requestPath: `/entities/v3/projectPitches`, + requestPath: `/entities/v3/projectPitches/admin`, ids: indexIds, - total: paginationTotal + total: paginationTotal, + pageNumber: pageNumber }); return document.serialize(); } From fed6f6575c03f1c42a8add9e88cd2ef4788ba015 Mon Sep 17 00:00:00 2001 From: Ismael Martinez Date: Tue, 6 May 2025 09:07:09 -0600 Subject: [PATCH 08/25] [TM-1995] add project-pitches.controller.spec.ts --- .../project-pitches.controller.spec.ts | 111 ++++++++++++++++++ .../entities/project-pitches.controller.ts | 6 +- 2 files changed, 112 insertions(+), 5 deletions(-) create mode 100644 apps/entity-service/src/entities/project-pitches.controller.spec.ts diff --git a/apps/entity-service/src/entities/project-pitches.controller.spec.ts b/apps/entity-service/src/entities/project-pitches.controller.spec.ts new file mode 100644 index 00000000..4cfa182d --- /dev/null +++ b/apps/entity-service/src/entities/project-pitches.controller.spec.ts @@ -0,0 +1,111 @@ +import { ProjectPitch } from "@terramatch-microservices/database/entities"; +import { createMock, DeepMocked } from "@golevelup/ts-jest"; +import { Test } from "@nestjs/testing"; +import { ProjectPitchesController } from "./project-pitches.controller"; +import { ProjectPitchService } from "./project-pitch.service"; +import { ProjectsPitchesParamDto } from "./dto/projects-pitches-param.dto"; +import { BadRequestException, NotFoundException } from "@nestjs/common"; + +describe("ProjectPitchesController", () => { + let controller: ProjectPitchesController; + let projectPitchService: DeepMocked; + + beforeEach(async () => { + const module = await Test.createTestingModule({ + controllers: [ProjectPitchesController], + providers: [{ provide: ProjectPitchService, useValue: (projectPitchService = createMock()) }] + }).compile(); + + controller = module.get(ProjectPitchesController); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe("Project Pitches Index", () => { + it("should return project pitches successfully", async () => { + const mockResponse = { + data: [], + paginationTotal: 0, + pageNumber: 1 + }; + projectPitchService.getProjectPitches.mockResolvedValue(mockResponse); + + const result = await controller.getPitches({ authenticatedUserId: 1 }, new ProjectsPitchesParamDto()); + expect(result).toBeDefined(); + expect(projectPitchService.getProjectPitches).toHaveBeenCalledWith(1, expect.any(ProjectsPitchesParamDto)); + }); + + it("should return an array of 3 project pitches successfully", async () => { + const mockResponse = { + data: [ + new ProjectPitch({ uuid: "1", projectName: "Pitch 1", totalHectares: 200 }), + new ProjectPitch({ uuid: "2", projectName: "Pitch 2", totalHectares: 300 }) + ], + paginationTotal: 3, + pageNumber: 1 + }; + projectPitchService.getProjectPitches.mockResolvedValue(mockResponse); + + const result = await controller.getPitches({ authenticatedUserId: 1 }, new ProjectsPitchesParamDto()); + expect(result).toBeDefined(); + expect(Array.isArray(result.data) ? result.data.length : 0).toBe(2); + expect(projectPitchService.getProjectPitches).toHaveBeenCalledWith(1, expect.any(ProjectsPitchesParamDto)); + }); + + it("should throw BadRequestException for invalid parameters", async () => { + projectPitchService.getProjectPitches.mockRejectedValue(new BadRequestException("Invalid parameters")); + + await expect(controller.getPitches({ authenticatedUserId: 1 }, new ProjectsPitchesParamDto())).rejects.toThrow( + BadRequestException + ); + }); + + it("should handle unexpected errors gracefully", async () => { + projectPitchService.getProjectPitches.mockRejectedValue(new Error("Unexpected error")); + + await expect(controller.getPitches({ authenticatedUserId: 1 }, new ProjectsPitchesParamDto())).rejects.toThrow( + Error + ); + }); + }); + + describe("Admin Project Pitches Index", () => { + it("should call getBaseEntity", async () => { + await controller.getAdminPitches(new ProjectsPitchesParamDto()); + }); + }); + + describe("Project Pitch UUID", () => { + describe("Project Pitch UUID", () => { + it("should return a project pitch successfully", async () => { + const mockProjectPitch = new ProjectPitch({ uuid: "1", projectName: "Pitch 1", totalHectares: 200 }); + projectPitchService.getProjectPitch.mockResolvedValue(mockProjectPitch); + + const result = await controller.getByUUID({ uuid: "1" }); + expect(result).toBeDefined(); + expect(result.data["id"]).toBe("1"); + expect(projectPitchService.getProjectPitch).toHaveBeenCalledWith("1"); + }); + + it("should throw NotFoundException if project pitch is not found", async () => { + projectPitchService.getProjectPitch.mockRejectedValue(new NotFoundException("Project pitch not found")); + + await expect(controller.getByUUID({ uuid: "non-existent-uuid" })).rejects.toThrow(NotFoundException); + }); + + it("should throw BadRequestException for invalid UUID", async () => { + projectPitchService.getProjectPitch.mockRejectedValue(new BadRequestException("Invalid UUID")); + + await expect(controller.getByUUID({ uuid: "invalid-uuid" })).rejects.toThrow(BadRequestException); + }); + + it("should handle unexpected errors gracefully", async () => { + projectPitchService.getProjectPitch.mockRejectedValue(new Error("Unexpected error")); + + await expect(controller.getByUUID({ uuid: "1" })).rejects.toThrow(Error); + }); + }); + }); +}); diff --git a/apps/entity-service/src/entities/project-pitches.controller.ts b/apps/entity-service/src/entities/project-pitches.controller.ts index ef6088e0..8e46f436 100644 --- a/apps/entity-service/src/entities/project-pitches.controller.ts +++ b/apps/entity-service/src/entities/project-pitches.controller.ts @@ -8,7 +8,6 @@ import { Query, Request } from "@nestjs/common"; -import { PolicyService } from "@terramatch-microservices/common"; import { buildJsonApi } from "@terramatch-microservices/common/util"; import { ApiOperation } from "@nestjs/swagger"; import { ExceptionResponse, JsonApiResponse } from "@terramatch-microservices/common/decorators"; @@ -19,10 +18,7 @@ import { ProjectsPitchesParamDto } from "./dto/projects-pitches-param.dto"; @Controller("entities/v3/projectPitches") export class ProjectPitchesController { - constructor( - private readonly projectPitchService: ProjectPitchService, - private readonly policyService: PolicyService - ) {} + constructor(private readonly projectPitchService: ProjectPitchService) {} @Get() @ApiOperation({ From 07c2af55ae7f6b58bb3af95afa98dba0ba85c320 Mon Sep 17 00:00:00 2001 From: Ismael Martinez Date: Tue, 6 May 2025 09:46:29 -0600 Subject: [PATCH 09/25] [TM-1995] feat: update getProjectPitches method to validate user existence and change userId type to number --- .../entities/project-pitch.service.spec.ts | 72 +++++++++++++++++++ .../src/entities/project-pitch.service.ts | 7 +- 2 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 apps/entity-service/src/entities/project-pitch.service.spec.ts diff --git a/apps/entity-service/src/entities/project-pitch.service.spec.ts b/apps/entity-service/src/entities/project-pitch.service.spec.ts new file mode 100644 index 00000000..4174776e --- /dev/null +++ b/apps/entity-service/src/entities/project-pitch.service.spec.ts @@ -0,0 +1,72 @@ +import { Test } from "@nestjs/testing"; +import { ProjectPitchService } from "./project-pitch.service"; +import { ProjectsPitchesParamDto } from "./dto/projects-pitches-param.dto"; +import { Organisation, OrganisationUser, ProjectPitch, User } from "@terramatch-microservices/database/entities"; +import { OrganisationFactory, UserFactory } from "@terramatch-microservices/database/factories"; + +describe("ProjectPitchService", () => { + let service: ProjectPitchService; + + beforeEach(async () => { + const module = await Test.createTestingModule({ + providers: [ProjectPitchService] + }).compile(); + + service = module.get(ProjectPitchService); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe("Get ProjectsPitches", () => { + it("throws an error if the user is not found", async () => { + jest.spyOn(User, "findOne").mockImplementation(() => Promise.resolve(null)); + + await expect(service.getProjectPitches(-1, new ProjectsPitchesParamDto())).rejects.toThrow("User not found"); + }); + + it("returns paginated project pitches for a valid user", async () => { + const org = await OrganisationFactory.create(); + const user = await UserFactory.create({ organisationId: org.id }); + jest.spyOn(User, "findOne").mockImplementation(() => Promise.resolve(user)); + const projectPitches = [ + new ProjectPitch({ uuid: "pitch1", projectName: "Project 1" }), + new ProjectPitch({ uuid: "pitch2", projectName: "Project 2" }) + ]; + jest.spyOn(ProjectPitch, "findAll").mockImplementation(() => Promise.resolve(projectPitches)); + + const params = new ProjectsPitchesParamDto(); + params.pageNumber = 1; + params.pageSize = 2; + + const result = await service.getProjectPitches(user.id, params); + + expect(result.data).toHaveLength(2); + expect(result.data[0].uuid).toBe("pitch1"); + expect(result.data[1].uuid).toBe("pitch2"); + expect(result.pageNumber).toBe(1); + }); + + /* + it("applies search filters correctly", async () => { + const userModel = module.get("UserModel"); + const projectPitchModel = module.get("ProjectPitchModel"); + + userModel.findOne.mockResolvedValue({ + id: "validUserId", + organisations: [{ uuid: "org1" }] + }); + + projectPitchModel.findAll.mockResolvedValue([{ uuid: "pitch1", projectName: "Filtered Project" }]); + + const params = new ProjectsPitchesParamDto(); + params.search = "Filtered"; + + const result = await service.getProjectPitches("validUserId", params); + + expect(result.data).toHaveLength(1); + expect(result.data[0].projectName).toContain("Filtered"); + });*/ + }); +}); diff --git a/apps/entity-service/src/entities/project-pitch.service.ts b/apps/entity-service/src/entities/project-pitch.service.ts index 11229ae5..413a5f51 100644 --- a/apps/entity-service/src/entities/project-pitch.service.ts +++ b/apps/entity-service/src/entities/project-pitch.service.ts @@ -11,11 +11,16 @@ export class ProjectPitchService { return await ProjectPitch.findOne({ where: { uuid } }); } - async getProjectPitches(userId: string, params: ProjectsPitchesParamDto) { + async getProjectPitches(userId: number, params: ProjectsPitchesParamDto) { const user = await User.findOne({ include: ["roles", "organisations", "frameworks"], where: { id: userId } }); + + if (!user) { + throw new Error("User not found"); + } + const pageNumber = params.pageNumber ?? 1; const pageSize = params.pageSize ?? MAX_PAGE_SIZE; const organisationAssociation: Includeable = { From 518898a3306605a84f11f17383f278599d4f40c5 Mon Sep 17 00:00:00 2001 From: Ismael Martinez Date: Tue, 6 May 2025 13:35:05 -0600 Subject: [PATCH 10/25] [TM-1995] feat: update getPitches and getAdminPitches methods to include pagination type in JSON API response --- .../src/entities/project-pitches.controller.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/entity-service/src/entities/project-pitches.controller.ts b/apps/entity-service/src/entities/project-pitches.controller.ts index 8e46f436..ef738469 100644 --- a/apps/entity-service/src/entities/project-pitches.controller.ts +++ b/apps/entity-service/src/entities/project-pitches.controller.ts @@ -25,6 +25,7 @@ export class ProjectPitchesController { operationId: "ProjectPitchesIndex", summary: "Get projects pitches." }) + @JsonApiResponse([{ data: ProjectPitchDto, pagination: "number" }]) @ExceptionResponse(BadRequestException, { description: "Param types invalid" }) @ExceptionResponse(NotFoundException, { description: "Records not found" }) async getPitches(@Request() { authenticatedUserId }, @Query() params: ProjectsPitchesParamDto) { @@ -32,7 +33,7 @@ export class ProjectPitchesController { authenticatedUserId, params ); - const document = buildJsonApi(ProjectPitchDto, { forceDataArray: true }); + const document = buildJsonApi(ProjectPitchDto, { pagination: "number" }); const indexIds: string[] = []; for (const pitch of data) { indexIds.push(pitch.uuid); @@ -59,7 +60,7 @@ export class ProjectPitchesController { @ExceptionResponse(NotFoundException, { description: "Records not found" }) async getAdminPitches(@Query() params: ProjectsPitchesParamDto) { const { data, paginationTotal, pageNumber } = await this.projectPitchService.getAdminProjectPitches(params); - const document = buildJsonApi(ProjectPitchDto, { forceDataArray: true }); + const document = buildJsonApi(ProjectPitchDto, { pagination: "number" }); const indexIds: string[] = []; for (const pitch of data) { indexIds.push(pitch.uuid); From 233f29ce688b32f04d19965cc773cd7c9c38cf1d Mon Sep 17 00:00:00 2001 From: Ismael Martinez Date: Wed, 7 May 2025 05:33:10 -0600 Subject: [PATCH 11/25] [TM-1995] feat: refactor project pitch service and controller to use EntityQueryDto for pagination and search parameters --- .../entities/dto/projects-pitches-param.dto.ts | 1 + .../src/entities/project-pitch.service.spec.ts | 2 +- .../src/entities/project-pitch.service.ts | 16 ++++++++-------- .../src/entities/project-pitches.controller.ts | 11 +++++++---- 4 files changed, 17 insertions(+), 13 deletions(-) diff --git a/apps/entity-service/src/entities/dto/projects-pitches-param.dto.ts b/apps/entity-service/src/entities/dto/projects-pitches-param.dto.ts index ef180212..a6b6b83c 100644 --- a/apps/entity-service/src/entities/dto/projects-pitches-param.dto.ts +++ b/apps/entity-service/src/entities/dto/projects-pitches-param.dto.ts @@ -1,6 +1,7 @@ import { ApiProperty } from "@nestjs/swagger"; export class ProjectsPitchesParamDto { + // TODO delete @ApiProperty({ description: "pagination page" }) pageNumber?: number; diff --git a/apps/entity-service/src/entities/project-pitch.service.spec.ts b/apps/entity-service/src/entities/project-pitch.service.spec.ts index 4174776e..fe99ab2d 100644 --- a/apps/entity-service/src/entities/project-pitch.service.spec.ts +++ b/apps/entity-service/src/entities/project-pitch.service.spec.ts @@ -1,7 +1,7 @@ import { Test } from "@nestjs/testing"; import { ProjectPitchService } from "./project-pitch.service"; import { ProjectsPitchesParamDto } from "./dto/projects-pitches-param.dto"; -import { Organisation, OrganisationUser, ProjectPitch, User } from "@terramatch-microservices/database/entities"; +import { ProjectPitch, User } from "@terramatch-microservices/database/entities"; import { OrganisationFactory, UserFactory } from "@terramatch-microservices/database/factories"; describe("ProjectPitchService", () => { diff --git a/apps/entity-service/src/entities/project-pitch.service.ts b/apps/entity-service/src/entities/project-pitch.service.ts index 413a5f51..fd4c2968 100644 --- a/apps/entity-service/src/entities/project-pitch.service.ts +++ b/apps/entity-service/src/entities/project-pitch.service.ts @@ -1,9 +1,9 @@ -import { Injectable } from "@nestjs/common"; +import { Injectable, Param } from "@nestjs/common"; import { ProjectPitch, User } from "@terramatch-microservices/database/entities"; import { Includeable, Op } from "sequelize"; -import { ProjectsPitchesParamDto } from "./dto/projects-pitches-param.dto"; import { PaginatedQueryBuilder } from "@terramatch-microservices/database/util/paginated-query.builder"; import { MAX_PAGE_SIZE } from "./entities.service"; +import { EntityQueryDto } from "./dto/entity-query.dto"; @Injectable() export class ProjectPitchService { @@ -11,7 +11,7 @@ export class ProjectPitchService { return await ProjectPitch.findOne({ where: { uuid } }); } - async getProjectPitches(userId: number, params: ProjectsPitchesParamDto) { + async getProjectPitches(userId: number, params: EntityQueryDto) { const user = await User.findOne({ include: ["roles", "organisations", "frameworks"], where: { id: userId } @@ -21,8 +21,8 @@ export class ProjectPitchService { throw new Error("User not found"); } - const pageNumber = params.pageNumber ?? 1; - const pageSize = params.pageSize ?? MAX_PAGE_SIZE; + const pageNumber = params.page ? params.page.number : 1; + const pageSize = params.page ? params.page.size : MAX_PAGE_SIZE; const organisationAssociation: Includeable = { association: "organisation", attributes: ["uuid", "name"] @@ -50,9 +50,9 @@ export class ProjectPitchService { return { data: await builder.execute(), paginationTotal: await builder.paginationTotal(), pageNumber }; } - async getAdminProjectPitches(params: ProjectsPitchesParamDto) { - const pageNumber = params.pageNumber ?? 1; - const pageSize = params.pageSize ?? MAX_PAGE_SIZE; + async getAdminProjectPitches(params: EntityQueryDto) { + const pageNumber = params.page ? params.page.number : 1; + const pageSize = params.page ? params.page.size : MAX_PAGE_SIZE; const organisationAssociation: Includeable = { association: "organisation", attributes: ["uuid", "name"] diff --git a/apps/entity-service/src/entities/project-pitches.controller.ts b/apps/entity-service/src/entities/project-pitches.controller.ts index ef738469..fcb53bda 100644 --- a/apps/entity-service/src/entities/project-pitches.controller.ts +++ b/apps/entity-service/src/entities/project-pitches.controller.ts @@ -8,13 +8,15 @@ import { Query, Request } from "@nestjs/common"; -import { buildJsonApi } from "@terramatch-microservices/common/util"; +import { buildJsonApi, getStableRequestQuery } from "@terramatch-microservices/common/util"; import { ApiOperation } from "@nestjs/swagger"; import { ExceptionResponse, JsonApiResponse } from "@terramatch-microservices/common/decorators"; import { ProjectPitchService } from "./project-pitch.service"; import { ProjectPitchDto } from "./dto/project-pitch.dto"; import { ProjectPitchParamDto } from "./dto/project-pitch-param.dto"; import { ProjectsPitchesParamDto } from "./dto/projects-pitches-param.dto"; +import { EntityIndexParamsDto } from "./dto/entity-index-params.dto"; +import { EntityQueryDto } from "./dto/entity-query.dto"; @Controller("entities/v3/projectPitches") export class ProjectPitchesController { @@ -28,7 +30,7 @@ export class ProjectPitchesController { @JsonApiResponse([{ data: ProjectPitchDto, pagination: "number" }]) @ExceptionResponse(BadRequestException, { description: "Param types invalid" }) @ExceptionResponse(NotFoundException, { description: "Records not found" }) - async getPitches(@Request() { authenticatedUserId }, @Query() params: ProjectsPitchesParamDto) { + async getPitches(@Request() { authenticatedUserId }, @Query() params: EntityQueryDto) { const { data, paginationTotal, pageNumber } = await this.projectPitchService.getProjectPitches( authenticatedUserId, params @@ -56,9 +58,10 @@ export class ProjectPitchesController { operationId: "AdminProjectPitchesIndex", summary: "Get admin projects pitches." }) + @JsonApiResponse([{ data: ProjectPitchDto, pagination: "number" }]) @ExceptionResponse(BadRequestException, { description: "Param types invalid" }) @ExceptionResponse(NotFoundException, { description: "Records not found" }) - async getAdminPitches(@Query() params: ProjectsPitchesParamDto) { + async getAdminPitches(@Query() params: EntityQueryDto) { const { data, paginationTotal, pageNumber } = await this.projectPitchService.getAdminProjectPitches(params); const document = buildJsonApi(ProjectPitchDto, { pagination: "number" }); const indexIds: string[] = []; @@ -69,7 +72,7 @@ export class ProjectPitchesController { } document.addIndexData({ resource: "projectPitches", - requestPath: `/entities/v3/projectPitches/admin`, + requestPath: `/entities/v3/projectPitches/admin${getStableRequestQuery(params)}`, ids: indexIds, total: paginationTotal, pageNumber: pageNumber From f116c5d1e9c7a4734c79cb7514986fcab0e1f79c Mon Sep 17 00:00:00 2001 From: Ismael Martinez Date: Wed, 7 May 2025 10:15:14 -0600 Subject: [PATCH 12/25] [TM-1995] feat: update project pitch service and controller to use EntityQueryDto for pagination and search, and add error handling for not found cases --- .../dto/projects-pitches-param.dto.ts | 13 -- .../entities/project-pitch.service.spec.ts | 114 +++++++++++++++--- .../src/entities/project-pitch.service.ts | 14 ++- .../project-pitches.controller.spec.ts | 18 ++- .../entities/project-pitches.controller.ts | 4 +- .../factories/organisation-user.factory.ts | 8 ++ 6 files changed, 120 insertions(+), 51 deletions(-) delete mode 100644 apps/entity-service/src/entities/dto/projects-pitches-param.dto.ts create mode 100644 libs/database/src/lib/factories/organisation-user.factory.ts diff --git a/apps/entity-service/src/entities/dto/projects-pitches-param.dto.ts b/apps/entity-service/src/entities/dto/projects-pitches-param.dto.ts deleted file mode 100644 index a6b6b83c..00000000 --- a/apps/entity-service/src/entities/dto/projects-pitches-param.dto.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { ApiProperty } from "@nestjs/swagger"; - -export class ProjectsPitchesParamDto { - // TODO delete - @ApiProperty({ description: "pagination page" }) - pageNumber?: number; - - @ApiProperty({ description: "pagination page" }) - pageSize?: number; - - @ApiProperty({ description: "text to search" }) - search: string; -} diff --git a/apps/entity-service/src/entities/project-pitch.service.spec.ts b/apps/entity-service/src/entities/project-pitch.service.spec.ts index fe99ab2d..a4843964 100644 --- a/apps/entity-service/src/entities/project-pitch.service.spec.ts +++ b/apps/entity-service/src/entities/project-pitch.service.spec.ts @@ -1,8 +1,23 @@ import { Test } from "@nestjs/testing"; import { ProjectPitchService } from "./project-pitch.service"; -import { ProjectsPitchesParamDto } from "./dto/projects-pitches-param.dto"; -import { ProjectPitch, User } from "@terramatch-microservices/database/entities"; +import { Organisation, ProjectPitch, User } from "@terramatch-microservices/database/entities"; import { OrganisationFactory, UserFactory } from "@terramatch-microservices/database/factories"; +import { EntityQueryDto } from "./dto/entity-query.dto"; +import { NumberPage } from "@terramatch-microservices/common/dto/page.dto"; +import { OrganisationUserFactory } from "@terramatch-microservices/database/factories/organisation-user.factory"; +import { NotFoundException } from "@nestjs/common"; + +async function getUserWithOrganisation() { + const user = await UserFactory.create(); + const org = await OrganisationFactory.create(); + const orgUser = await OrganisationUserFactory.create({ + organisationId: org.id, + userId: user.id, + status: "approved" + }); + user.organisations = [{ ...orgUser, ...org } as unknown as Organisation & { OrganisationUser: typeof orgUser }]; + return user; +} describe("ProjectPitchService", () => { let service: ProjectPitchService; @@ -19,16 +34,35 @@ describe("ProjectPitchService", () => { jest.restoreAllMocks(); }); + describe("Get ProjectPitch by UUID", () => { + it("returns a project pitch for a valid UUID", async () => { + const mockProjectPitch = new ProjectPitch({ uuid: "uuid", projectName: "Test Project" }); + jest.spyOn(ProjectPitch, "findOne").mockImplementation(() => Promise.resolve(mockProjectPitch)); + + const result = await service.getProjectPitch("uuid"); + + expect(result).toBeDefined(); + expect(result.uuid).toBe("uuid"); + expect(result.projectName).toBe("Test Project"); + }); + + it("throws an error if no project pitch not found for the given UUID", async () => { + jest.spyOn(ProjectPitch, "findOne").mockImplementation(() => Promise.resolve(null)); + await expect(service.getProjectPitch("invalid-uuid")).rejects.toThrow( + new NotFoundException("ProjectPitch not found") + ); + }); + }); + describe("Get ProjectsPitches", () => { it("throws an error if the user is not found", async () => { jest.spyOn(User, "findOne").mockImplementation(() => Promise.resolve(null)); - await expect(service.getProjectPitches(-1, new ProjectsPitchesParamDto())).rejects.toThrow("User not found"); + await expect(service.getProjectPitches(-1, new EntityQueryDto())).rejects.toThrow("User not found"); }); it("returns paginated project pitches for a valid user", async () => { - const org = await OrganisationFactory.create(); - const user = await UserFactory.create({ organisationId: org.id }); + const user = await getUserWithOrganisation(); jest.spyOn(User, "findOne").mockImplementation(() => Promise.resolve(user)); const projectPitches = [ new ProjectPitch({ uuid: "pitch1", projectName: "Project 1" }), @@ -36,9 +70,10 @@ describe("ProjectPitchService", () => { ]; jest.spyOn(ProjectPitch, "findAll").mockImplementation(() => Promise.resolve(projectPitches)); - const params = new ProjectsPitchesParamDto(); - params.pageNumber = 1; - params.pageSize = 2; + const params = new EntityQueryDto(); + params.page = new NumberPage(); + params.page.number = 1; + params.page.size = 10; const result = await service.getProjectPitches(user.id, params); @@ -48,25 +83,64 @@ describe("ProjectPitchService", () => { expect(result.pageNumber).toBe(1); }); - /* it("applies search filters correctly", async () => { - const userModel = module.get("UserModel"); - const projectPitchModel = module.get("ProjectPitchModel"); + const user = await getUserWithOrganisation(); + jest.spyOn(User, "findOne").mockImplementation(() => Promise.resolve(user)); + const projectPitches = [new ProjectPitch({ uuid: "pitch2", projectName: "Filtered" })]; + jest.spyOn(ProjectPitch, "findAll").mockImplementation(() => Promise.resolve(projectPitches)); + + const params = new EntityQueryDto(); + params.page = new NumberPage(); + params.page.number = 1; + params.page.size = 10; + params.search = "filtered"; + + const result = await service.getProjectPitches(user.id, params); + + expect(result.data).toHaveLength(1); + expect(result.data[0].projectName).toContain("Filtered"); + }); + }); + + describe("Get Admin ProjectsPitches", () => { + it("returns paginated admin project pitches", async () => { + const user = await getUserWithOrganisation(); + jest.spyOn(User, "findOne").mockImplementation(() => Promise.resolve(user)); + const projectPitches = [ + new ProjectPitch({ uuid: "pitch y", projectName: "Project y" }), + new ProjectPitch({ uuid: "pitch x", projectName: "Project x" }) + ]; + jest.spyOn(ProjectPitch, "findAll").mockImplementation(() => Promise.resolve(projectPitches)); + + const params = new EntityQueryDto(); + params.page = new NumberPage(); + params.page.number = 1; + params.page.size = 10; - userModel.findOne.mockResolvedValue({ - id: "validUserId", - organisations: [{ uuid: "org1" }] - }); + const result = await service.getAdminProjectPitches(params); - projectPitchModel.findAll.mockResolvedValue([{ uuid: "pitch1", projectName: "Filtered Project" }]); + expect(result.data).toHaveLength(2); + expect(result.data[0].uuid).toBe("pitch y"); + expect(result.data[1].uuid).toBe("pitch x"); + expect(result.pageNumber).toBe(1); + }); + + it("applies search filters correctly", async () => { + const user = await getUserWithOrganisation(); + jest.spyOn(User, "findOne").mockImplementation(() => Promise.resolve(user)); + const projectPitches = [new ProjectPitch({ uuid: "pitch x", projectName: "Filtered" })]; + jest.spyOn(ProjectPitch, "findAll").mockImplementation(() => Promise.resolve(projectPitches)); - const params = new ProjectsPitchesParamDto(); - params.search = "Filtered"; + const params = new EntityQueryDto(); + params.page = new NumberPage(); + params.page.number = 1; + params.page.size = 10; + params.search = "filtered"; - const result = await service.getProjectPitches("validUserId", params); + const result = await service.getAdminProjectPitches(params); expect(result.data).toHaveLength(1); expect(result.data[0].projectName).toContain("Filtered"); - });*/ + }); }); }); diff --git a/apps/entity-service/src/entities/project-pitch.service.ts b/apps/entity-service/src/entities/project-pitch.service.ts index fd4c2968..c99cf05f 100644 --- a/apps/entity-service/src/entities/project-pitch.service.ts +++ b/apps/entity-service/src/entities/project-pitch.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Param } from "@nestjs/common"; +import { Injectable, NotFoundException, Param } from "@nestjs/common"; import { ProjectPitch, User } from "@terramatch-microservices/database/entities"; import { Includeable, Op } from "sequelize"; import { PaginatedQueryBuilder } from "@terramatch-microservices/database/util/paginated-query.builder"; @@ -7,18 +7,22 @@ import { EntityQueryDto } from "./dto/entity-query.dto"; @Injectable() export class ProjectPitchService { - async getProjectPitch(uuid: string): Promise { - return await ProjectPitch.findOne({ where: { uuid } }); + async getProjectPitch(uuid: string) { + const projectPitch = ProjectPitch.findOne({ where: { uuid } }); + if (!projectPitch) { + throw new NotFoundException("ProjectPitch not found"); + } + return projectPitch; } async getProjectPitches(userId: number, params: EntityQueryDto) { const user = await User.findOne({ - include: ["roles", "organisations", "frameworks"], + include: ["organisations"], where: { id: userId } }); if (!user) { - throw new Error("User not found"); + throw new NotFoundException("User not found"); } const pageNumber = params.page ? params.page.number : 1; diff --git a/apps/entity-service/src/entities/project-pitches.controller.spec.ts b/apps/entity-service/src/entities/project-pitches.controller.spec.ts index 4cfa182d..412d0887 100644 --- a/apps/entity-service/src/entities/project-pitches.controller.spec.ts +++ b/apps/entity-service/src/entities/project-pitches.controller.spec.ts @@ -3,8 +3,8 @@ import { createMock, DeepMocked } from "@golevelup/ts-jest"; import { Test } from "@nestjs/testing"; import { ProjectPitchesController } from "./project-pitches.controller"; import { ProjectPitchService } from "./project-pitch.service"; -import { ProjectsPitchesParamDto } from "./dto/projects-pitches-param.dto"; import { BadRequestException, NotFoundException } from "@nestjs/common"; +import { EntityQueryDto } from "./dto/entity-query.dto"; describe("ProjectPitchesController", () => { let controller: ProjectPitchesController; @@ -32,9 +32,9 @@ describe("ProjectPitchesController", () => { }; projectPitchService.getProjectPitches.mockResolvedValue(mockResponse); - const result = await controller.getPitches({ authenticatedUserId: 1 }, new ProjectsPitchesParamDto()); + const result = await controller.getPitches({ authenticatedUserId: 1 }, new EntityQueryDto()); expect(result).toBeDefined(); - expect(projectPitchService.getProjectPitches).toHaveBeenCalledWith(1, expect.any(ProjectsPitchesParamDto)); + expect(projectPitchService.getProjectPitches).toHaveBeenCalledWith(1, expect.any(EntityQueryDto)); }); it("should return an array of 3 project pitches successfully", async () => { @@ -48,16 +48,16 @@ describe("ProjectPitchesController", () => { }; projectPitchService.getProjectPitches.mockResolvedValue(mockResponse); - const result = await controller.getPitches({ authenticatedUserId: 1 }, new ProjectsPitchesParamDto()); + const result = await controller.getPitches({ authenticatedUserId: 1 }, new EntityQueryDto()); expect(result).toBeDefined(); expect(Array.isArray(result.data) ? result.data.length : 0).toBe(2); - expect(projectPitchService.getProjectPitches).toHaveBeenCalledWith(1, expect.any(ProjectsPitchesParamDto)); + expect(projectPitchService.getProjectPitches).toHaveBeenCalledWith(1, expect.any(EntityQueryDto)); }); it("should throw BadRequestException for invalid parameters", async () => { projectPitchService.getProjectPitches.mockRejectedValue(new BadRequestException("Invalid parameters")); - await expect(controller.getPitches({ authenticatedUserId: 1 }, new ProjectsPitchesParamDto())).rejects.toThrow( + await expect(controller.getPitches({ authenticatedUserId: 1 }, new EntityQueryDto())).rejects.toThrow( BadRequestException ); }); @@ -65,15 +65,13 @@ describe("ProjectPitchesController", () => { it("should handle unexpected errors gracefully", async () => { projectPitchService.getProjectPitches.mockRejectedValue(new Error("Unexpected error")); - await expect(controller.getPitches({ authenticatedUserId: 1 }, new ProjectsPitchesParamDto())).rejects.toThrow( - Error - ); + await expect(controller.getPitches({ authenticatedUserId: 1 }, new EntityQueryDto())).rejects.toThrow(Error); }); }); describe("Admin Project Pitches Index", () => { it("should call getBaseEntity", async () => { - await controller.getAdminPitches(new ProjectsPitchesParamDto()); + await controller.getAdminPitches(new EntityQueryDto()); }); }); diff --git a/apps/entity-service/src/entities/project-pitches.controller.ts b/apps/entity-service/src/entities/project-pitches.controller.ts index fcb53bda..14ac4ab7 100644 --- a/apps/entity-service/src/entities/project-pitches.controller.ts +++ b/apps/entity-service/src/entities/project-pitches.controller.ts @@ -14,8 +14,6 @@ import { ExceptionResponse, JsonApiResponse } from "@terramatch-microservices/co import { ProjectPitchService } from "./project-pitch.service"; import { ProjectPitchDto } from "./dto/project-pitch.dto"; import { ProjectPitchParamDto } from "./dto/project-pitch-param.dto"; -import { ProjectsPitchesParamDto } from "./dto/projects-pitches-param.dto"; -import { EntityIndexParamsDto } from "./dto/entity-index-params.dto"; import { EntityQueryDto } from "./dto/entity-query.dto"; @Controller("entities/v3/projectPitches") @@ -45,7 +43,7 @@ export class ProjectPitchesController { document.addIndexData({ resource: "projectPitches", - requestPath: `/entities/v3/projectPitches`, + requestPath: `/entities/v3/projectPitches/admin${getStableRequestQuery(params)}`, ids: indexIds, total: paginationTotal, pageNumber: pageNumber diff --git a/libs/database/src/lib/factories/organisation-user.factory.ts b/libs/database/src/lib/factories/organisation-user.factory.ts new file mode 100644 index 00000000..774743a2 --- /dev/null +++ b/libs/database/src/lib/factories/organisation-user.factory.ts @@ -0,0 +1,8 @@ +import { OrganisationUser } from "../entities"; +import { FactoryGirl } from "factory-girl-ts"; +import { faker } from "@faker-js/faker"; +import { ORGANISATION_STATUSES } from "../constants/status"; + +export const OrganisationUserFactory = FactoryGirl.define(OrganisationUser, async () => ({ + status: faker.helpers.arrayElement(ORGANISATION_STATUSES) +})); From 371a1937b40c46077e7d5100996522ac4fe32683 Mon Sep 17 00:00:00 2001 From: Ismael Martinez Date: Wed, 7 May 2025 10:23:45 -0600 Subject: [PATCH 13/25] [TM-1995] test: enhance admin project pitches controller tests for success and error scenarios --- .../project-pitches.controller.spec.ts | 42 ++++++++++++++++++- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/apps/entity-service/src/entities/project-pitches.controller.spec.ts b/apps/entity-service/src/entities/project-pitches.controller.spec.ts index 412d0887..47969ef4 100644 --- a/apps/entity-service/src/entities/project-pitches.controller.spec.ts +++ b/apps/entity-service/src/entities/project-pitches.controller.spec.ts @@ -70,8 +70,46 @@ describe("ProjectPitchesController", () => { }); describe("Admin Project Pitches Index", () => { - it("should call getBaseEntity", async () => { - await controller.getAdminPitches(new EntityQueryDto()); + it("should return project pitches successfully", async () => { + const mockResponse = { + data: [], + paginationTotal: 0, + pageNumber: 1 + }; + projectPitchService.getAdminProjectPitches.mockResolvedValue(mockResponse); + + const result = await controller.getAdminPitches(new EntityQueryDto()); + expect(projectPitchService.getAdminProjectPitches).toHaveBeenCalledTimes(1); + expect(result).toBeDefined(); + }); + + it("should return an array of 3 project pitches successfully", async () => { + const mockResponse = { + data: [ + new ProjectPitch({ uuid: "1", projectName: "Pitch 1", totalHectares: 200 }), + new ProjectPitch({ uuid: "2", projectName: "Pitch 2", totalHectares: 300 }) + ], + paginationTotal: 3, + pageNumber: 1 + }; + projectPitchService.getAdminProjectPitches.mockResolvedValue(mockResponse); + + const result = await controller.getAdminPitches(new EntityQueryDto()); + expect(result).toBeDefined(); + expect(Array.isArray(result.data) ? result.data.length : 0).toBe(2); + expect(projectPitchService.getAdminProjectPitches).toHaveBeenCalledTimes(1); + }); + + it("should throw BadRequestException for invalid parameters", async () => { + projectPitchService.getAdminProjectPitches.mockRejectedValue(new BadRequestException("Invalid parameters")); + + await expect(controller.getAdminPitches(new EntityQueryDto())).rejects.toThrow(BadRequestException); + }); + + it("should handle unexpected errors gracefully", async () => { + projectPitchService.getAdminProjectPitches.mockRejectedValue(new Error("Unexpected error")); + + await expect(controller.getAdminPitches(new EntityQueryDto())).rejects.toThrow(Error); }); }); From 2bf1ad675e40a064e609499e3b32694170bdcebe Mon Sep 17 00:00:00 2001 From: Ismael Martinez Date: Thu, 8 May 2025 07:10:28 -0600 Subject: [PATCH 14/25] [TM-1995] feat: simplify pagination parameter setup in project pitch service tests and handle not found error more succinctly --- .../entities/project-pitch.service.spec.ts | 34 +++++++------------ .../src/entities/project-pitch.service.ts | 2 +- 2 files changed, 14 insertions(+), 22 deletions(-) diff --git a/apps/entity-service/src/entities/project-pitch.service.spec.ts b/apps/entity-service/src/entities/project-pitch.service.spec.ts index a4843964..5b11241e 100644 --- a/apps/entity-service/src/entities/project-pitch.service.spec.ts +++ b/apps/entity-service/src/entities/project-pitch.service.spec.ts @@ -5,7 +5,6 @@ import { OrganisationFactory, UserFactory } from "@terramatch-microservices/data import { EntityQueryDto } from "./dto/entity-query.dto"; import { NumberPage } from "@terramatch-microservices/common/dto/page.dto"; import { OrganisationUserFactory } from "@terramatch-microservices/database/factories/organisation-user.factory"; -import { NotFoundException } from "@nestjs/common"; async function getUserWithOrganisation() { const user = await UserFactory.create(); @@ -19,6 +18,14 @@ async function getUserWithOrganisation() { return user; } +function getDefaultPagination() { + const params = new EntityQueryDto(); + params.page = new NumberPage(); + params.page.number = 1; + params.page.size = 10; + return params; +} + describe("ProjectPitchService", () => { let service: ProjectPitchService; @@ -48,9 +55,7 @@ describe("ProjectPitchService", () => { it("throws an error if no project pitch not found for the given UUID", async () => { jest.spyOn(ProjectPitch, "findOne").mockImplementation(() => Promise.resolve(null)); - await expect(service.getProjectPitch("invalid-uuid")).rejects.toThrow( - new NotFoundException("ProjectPitch not found") - ); + await expect(service.getProjectPitch("invalid-uuid")).rejects.toThrow("ProjectPitch not found"); }); }); @@ -70,11 +75,7 @@ describe("ProjectPitchService", () => { ]; jest.spyOn(ProjectPitch, "findAll").mockImplementation(() => Promise.resolve(projectPitches)); - const params = new EntityQueryDto(); - params.page = new NumberPage(); - params.page.number = 1; - params.page.size = 10; - + const params = getDefaultPagination(); const result = await service.getProjectPitches(user.id, params); expect(result.data).toHaveLength(2); @@ -89,10 +90,7 @@ describe("ProjectPitchService", () => { const projectPitches = [new ProjectPitch({ uuid: "pitch2", projectName: "Filtered" })]; jest.spyOn(ProjectPitch, "findAll").mockImplementation(() => Promise.resolve(projectPitches)); - const params = new EntityQueryDto(); - params.page = new NumberPage(); - params.page.number = 1; - params.page.size = 10; + const params = getDefaultPagination(); params.search = "filtered"; const result = await service.getProjectPitches(user.id, params); @@ -112,10 +110,7 @@ describe("ProjectPitchService", () => { ]; jest.spyOn(ProjectPitch, "findAll").mockImplementation(() => Promise.resolve(projectPitches)); - const params = new EntityQueryDto(); - params.page = new NumberPage(); - params.page.number = 1; - params.page.size = 10; + const params = getDefaultPagination(); const result = await service.getAdminProjectPitches(params); @@ -131,10 +126,7 @@ describe("ProjectPitchService", () => { const projectPitches = [new ProjectPitch({ uuid: "pitch x", projectName: "Filtered" })]; jest.spyOn(ProjectPitch, "findAll").mockImplementation(() => Promise.resolve(projectPitches)); - const params = new EntityQueryDto(); - params.page = new NumberPage(); - params.page.number = 1; - params.page.size = 10; + const params = getDefaultPagination(); params.search = "filtered"; const result = await service.getAdminProjectPitches(params); diff --git a/apps/entity-service/src/entities/project-pitch.service.ts b/apps/entity-service/src/entities/project-pitch.service.ts index c99cf05f..15c1d8ea 100644 --- a/apps/entity-service/src/entities/project-pitch.service.ts +++ b/apps/entity-service/src/entities/project-pitch.service.ts @@ -8,7 +8,7 @@ import { EntityQueryDto } from "./dto/entity-query.dto"; @Injectable() export class ProjectPitchService { async getProjectPitch(uuid: string) { - const projectPitch = ProjectPitch.findOne({ where: { uuid } }); + const projectPitch = await ProjectPitch.findOne({ where: { uuid } }); if (!projectPitch) { throw new NotFoundException("ProjectPitch not found"); } From f61666caf60e16499998f8fb5b0cc0ba9daf768f Mon Sep 17 00:00:00 2001 From: Ismael Martinez Date: Thu, 8 May 2025 16:06:10 -0600 Subject: [PATCH 15/25] [TM-1995] refactor: remove unused import from project pitch service --- apps/entity-service/src/entities/project-pitch.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/entity-service/src/entities/project-pitch.service.ts b/apps/entity-service/src/entities/project-pitch.service.ts index 15c1d8ea..d5bc0731 100644 --- a/apps/entity-service/src/entities/project-pitch.service.ts +++ b/apps/entity-service/src/entities/project-pitch.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException, Param } from "@nestjs/common"; +import { Injectable, NotFoundException } from "@nestjs/common"; import { ProjectPitch, User } from "@terramatch-microservices/database/entities"; import { Includeable, Op } from "sequelize"; import { PaginatedQueryBuilder } from "@terramatch-microservices/database/util/paginated-query.builder"; From 2862f3b3585573e33617a4b5e562f2e40b074ee4 Mon Sep 17 00:00:00 2001 From: Ismael Martinez Date: Thu, 8 May 2025 16:09:01 -0600 Subject: [PATCH 16/25] [TM-1995] refactor: update import path for Organisation entity in project pitch model --- libs/database/src/lib/entities/project-pitch.entity.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/database/src/lib/entities/project-pitch.entity.ts b/libs/database/src/lib/entities/project-pitch.entity.ts index 880406df..8e80b806 100644 --- a/libs/database/src/lib/entities/project-pitch.entity.ts +++ b/libs/database/src/lib/entities/project-pitch.entity.ts @@ -1,7 +1,7 @@ import { AllowNull, AutoIncrement, BelongsTo, Column, Index, Model, PrimaryKey, Table } from "sequelize-typescript"; import { BIGINT, DATE, DECIMAL, INTEGER, STRING, TEXT, TINYINT, UUID, UUIDV4 } from "sequelize"; import { JsonColumn } from "../decorators/json-column.decorator"; -import { Organisation } from "@terramatch-microservices/database/entities/organisation.entity"; +import { Organisation } from "./organisation.entity"; @Table({ tableName: "project_pitches", underscored: true, paranoid: true }) export class ProjectPitch extends Model { From ac8c69a0b2bd2b2db221296695adc2cd28539b39 Mon Sep 17 00:00:00 2001 From: Ismael Martinez Date: Mon, 12 May 2025 16:22:01 -0600 Subject: [PATCH 17/25] [TM-1995] refactor: migrate project pitch service to use new query DTO and improve error handling --- .../entities/dto/project-pitch-query.dto.ts | 39 +++++++++ .../src/entities/dto/project-pitch.dto.ts | 4 - .../entities/project-pitch.service.spec.ts | 75 +++++++++------- .../src/entities/project-pitch.service.ts | 86 +++++++++---------- .../project-pitches.controller.spec.ts | 78 +++++------------ .../entities/project-pitches.controller.ts | 62 ++++--------- 6 files changed, 156 insertions(+), 188 deletions(-) create mode 100644 apps/entity-service/src/entities/dto/project-pitch-query.dto.ts diff --git a/apps/entity-service/src/entities/dto/project-pitch-query.dto.ts b/apps/entity-service/src/entities/dto/project-pitch-query.dto.ts new file mode 100644 index 00000000..7373fe7d --- /dev/null +++ b/apps/entity-service/src/entities/dto/project-pitch-query.dto.ts @@ -0,0 +1,39 @@ +import { ApiProperty, IntersectionType } from "@nestjs/swagger"; +import { IsEnum, IsOptional, ValidateNested } from "class-validator"; +import { NumberPage } from "@terramatch-microservices/common/dto/page.dto"; + +class QuerySort { + @ApiProperty({ name: "sort[field]", required: false }) + @IsOptional() + field?: string; + + @ApiProperty({ name: "sort[direction]", required: false, enum: ["ASC", "DESC"], default: "ASC" }) + @IsEnum(["ASC", "DESC"]) + @IsOptional() + direction?: "ASC" | "DESC"; +} + +class FilterItem { + [key: string]: string | undefined; +} + +export class ProjectPitchQueryDto extends IntersectionType(QuerySort, NumberPage) { + @ValidateNested() + @IsOptional() + page?: NumberPage; + + @ValidateNested() + @IsOptional() + sort?: QuerySort; + + @ApiProperty({ required: false }) + @IsOptional() + search?: string; + + @ApiProperty({ + required: false, + description: "Search query used for filtering selectable options in autocomplete fields." + }) + @IsOptional() + filter?: FilterItem; +} diff --git a/apps/entity-service/src/entities/dto/project-pitch.dto.ts b/apps/entity-service/src/entities/dto/project-pitch.dto.ts index dc8d09f7..fe409eb2 100644 --- a/apps/entity-service/src/entities/dto/project-pitch.dto.ts +++ b/apps/entity-service/src/entities/dto/project-pitch.dto.ts @@ -7,15 +7,11 @@ import { ProjectPitch } from "@terramatch-microservices/database/entities"; @JsonApiDto({ type: "projectPitches" }) export class ProjectPitchDto extends JsonApiAttributes { constructor(data: ProjectPitch) { - pickApiProperties(data, ProjectPitchDto); super({ ...pickApiProperties(data, ProjectPitchDto) }); } - @ApiProperty() - id: number; - @ApiProperty() @IsUUID() uuid: string; diff --git a/apps/entity-service/src/entities/project-pitch.service.spec.ts b/apps/entity-service/src/entities/project-pitch.service.spec.ts index 5b11241e..ca7d9ad7 100644 --- a/apps/entity-service/src/entities/project-pitch.service.spec.ts +++ b/apps/entity-service/src/entities/project-pitch.service.spec.ts @@ -2,9 +2,9 @@ import { Test } from "@nestjs/testing"; import { ProjectPitchService } from "./project-pitch.service"; import { Organisation, ProjectPitch, User } from "@terramatch-microservices/database/entities"; import { OrganisationFactory, UserFactory } from "@terramatch-microservices/database/factories"; -import { EntityQueryDto } from "./dto/entity-query.dto"; import { NumberPage } from "@terramatch-microservices/common/dto/page.dto"; import { OrganisationUserFactory } from "@terramatch-microservices/database/factories/organisation-user.factory"; +import { ProjectPitchQueryDto } from "./dto/project-pitch-query.dto"; async function getUserWithOrganisation() { const user = await UserFactory.create(); @@ -19,7 +19,7 @@ async function getUserWithOrganisation() { } function getDefaultPagination() { - const params = new EntityQueryDto(); + const params = new ProjectPitchQueryDto(); params.page = new NumberPage(); params.page.number = 1; params.page.size = 10; @@ -60,79 +60,88 @@ describe("ProjectPitchService", () => { }); describe("Get ProjectsPitches", () => { - it("throws an error if the user is not found", async () => { - jest.spyOn(User, "findOne").mockImplementation(() => Promise.resolve(null)); - - await expect(service.getProjectPitches(-1, new EntityQueryDto())).rejects.toThrow("User not found"); - }); - - it("returns paginated project pitches for a valid user", async () => { + it("returns paginated project pitches", async () => { const user = await getUserWithOrganisation(); jest.spyOn(User, "findOne").mockImplementation(() => Promise.resolve(user)); const projectPitches = [ - new ProjectPitch({ uuid: "pitch1", projectName: "Project 1" }), - new ProjectPitch({ uuid: "pitch2", projectName: "Project 2" }) + new ProjectPitch({ uuid: "pitch y", projectName: "Project y" }), + new ProjectPitch({ uuid: "pitch x", projectName: "Project x" }) ]; jest.spyOn(ProjectPitch, "findAll").mockImplementation(() => Promise.resolve(projectPitches)); const params = getDefaultPagination(); - const result = await service.getProjectPitches(user.id, params); + + const result = await service.getProjectPitches(params); expect(result.data).toHaveLength(2); - expect(result.data[0].uuid).toBe("pitch1"); - expect(result.data[1].uuid).toBe("pitch2"); + expect(result.data[0].uuid).toBe("pitch y"); + expect(result.data[1].uuid).toBe("pitch x"); expect(result.pageNumber).toBe(1); }); - it("applies search filters correctly", async () => { + it("applies search correctly", async () => { const user = await getUserWithOrganisation(); jest.spyOn(User, "findOne").mockImplementation(() => Promise.resolve(user)); - const projectPitches = [new ProjectPitch({ uuid: "pitch2", projectName: "Filtered" })]; + const projectPitches = [new ProjectPitch({ uuid: "pitch x", projectName: "Filtered" })]; jest.spyOn(ProjectPitch, "findAll").mockImplementation(() => Promise.resolve(projectPitches)); const params = getDefaultPagination(); params.search = "filtered"; - const result = await service.getProjectPitches(user.id, params); + const result = await service.getProjectPitches(params); expect(result.data).toHaveLength(1); expect(result.data[0].projectName).toContain("Filtered"); }); - }); - describe("Get Admin ProjectsPitches", () => { - it("returns paginated admin project pitches", async () => { + it("deny filters", async () => { const user = await getUserWithOrganisation(); jest.spyOn(User, "findOne").mockImplementation(() => Promise.resolve(user)); - const projectPitches = [ - new ProjectPitch({ uuid: "pitch y", projectName: "Project y" }), - new ProjectPitch({ uuid: "pitch x", projectName: "Project x" }) - ]; + const projectPitches = [new ProjectPitch({ uuid: "pitch x", projectName: "Filtered" })]; jest.spyOn(ProjectPitch, "findAll").mockImplementation(() => Promise.resolve(projectPitches)); const params = getDefaultPagination(); + params.filter = { invalid_filter: "foo" }; - const result = await service.getAdminProjectPitches(params); + await expect(service.getProjectPitches(params)).rejects.toThrow("Invalid filter key: invalid_filter"); + }); - expect(result.data).toHaveLength(2); - expect(result.data[0].uuid).toBe("pitch y"); - expect(result.data[1].uuid).toBe("pitch x"); - expect(result.pageNumber).toBe(1); + it("applies filters correctly", async () => { + const user = await getUserWithOrganisation(); + jest.spyOn(User, "findOne").mockImplementation(() => Promise.resolve(user)); + const projectPitches = [new ProjectPitch({ uuid: "pitch x", projectName: "Filtered" })]; + jest.spyOn(ProjectPitch, "findAll").mockImplementation(() => Promise.resolve(projectPitches)); + + const params = getDefaultPagination(); + params.filter = { restoration_intervention_types: "foo" }; + + const result = await service.getProjectPitches(params); + expect(result.data).toHaveLength(1); }); - it("applies search filters correctly", async () => { + it("deny orders fields", async () => { const user = await getUserWithOrganisation(); jest.spyOn(User, "findOne").mockImplementation(() => Promise.resolve(user)); const projectPitches = [new ProjectPitch({ uuid: "pitch x", projectName: "Filtered" })]; jest.spyOn(ProjectPitch, "findAll").mockImplementation(() => Promise.resolve(projectPitches)); const params = getDefaultPagination(); - params.search = "filtered"; + params.sort = { field: "no_exist_column", direction: "ASC" }; - const result = await service.getAdminProjectPitches(params); + await expect(service.getProjectPitches(params)).rejects.toThrow("Invalid sort field: no_exist_column"); + }); + it("applies order correctly", async () => { + const user = await getUserWithOrganisation(); + jest.spyOn(User, "findOne").mockImplementation(() => Promise.resolve(user)); + const projectPitches = [new ProjectPitch({ uuid: "pitch x", projectName: "Filtered" })]; + jest.spyOn(ProjectPitch, "findAll").mockImplementation(() => Promise.resolve(projectPitches)); + + const params = getDefaultPagination(); + params.sort = { field: "organisation_id", direction: "ASC" }; + + const result = await service.getProjectPitches(params); expect(result.data).toHaveLength(1); - expect(result.data[0].projectName).toContain("Filtered"); }); }); }); diff --git a/apps/entity-service/src/entities/project-pitch.service.ts b/apps/entity-service/src/entities/project-pitch.service.ts index d5bc0731..41d6f95c 100644 --- a/apps/entity-service/src/entities/project-pitch.service.ts +++ b/apps/entity-service/src/entities/project-pitch.service.ts @@ -1,9 +1,9 @@ -import { Injectable, NotFoundException } from "@nestjs/common"; -import { ProjectPitch, User } from "@terramatch-microservices/database/entities"; +import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; +import { ProjectPitch } from "@terramatch-microservices/database/entities"; import { Includeable, Op } from "sequelize"; import { PaginatedQueryBuilder } from "@terramatch-microservices/database/util/paginated-query.builder"; import { MAX_PAGE_SIZE } from "./entities.service"; -import { EntityQueryDto } from "./dto/entity-query.dto"; +import { ProjectPitchQueryDto } from "./dto/project-pitch-query.dto"; @Injectable() export class ProjectPitchService { @@ -15,18 +15,9 @@ export class ProjectPitchService { return projectPitch; } - async getProjectPitches(userId: number, params: EntityQueryDto) { - const user = await User.findOne({ - include: ["organisations"], - where: { id: userId } - }); - - if (!user) { - throw new NotFoundException("User not found"); - } - - const pageNumber = params.page ? params.page.number : 1; - const pageSize = params.page ? params.page.size : MAX_PAGE_SIZE; + async getProjectPitches(query: ProjectPitchQueryDto) { + const pageNumber = query.page ? query.page.number : 1; + const pageSize = query.page ? query.page.size : MAX_PAGE_SIZE; const organisationAssociation: Includeable = { association: "organisation", attributes: ["uuid", "name"] @@ -36,44 +27,47 @@ export class ProjectPitchService { builder.pageNumber(pageNumber); } - if (params.search) { + if (query.search) { builder.where({ [Op.or]: [ - { projectName: { [Op.like]: `%${params.search}%` } }, - { "$organisation.name$": { [Op.like]: `%${params.search}%` } } + { projectName: { [Op.like]: `%${query.search}%` } }, + { "$organisation.name$": { [Op.like]: `%${query.search}%` } } ] }); } - - builder.where({ - organisationId: { - [Op.in]: user.organisations.map(org => org.uuid) - } - }); - - return { data: await builder.execute(), paginationTotal: await builder.paginationTotal(), pageNumber }; - } - - async getAdminProjectPitches(params: EntityQueryDto) { - const pageNumber = params.page ? params.page.number : 1; - const pageSize = params.page ? params.page.size : MAX_PAGE_SIZE; - const organisationAssociation: Includeable = { - association: "organisation", - attributes: ["uuid", "name"] - }; - const builder = new PaginatedQueryBuilder(ProjectPitch, pageSize, [organisationAssociation]); - if (pageNumber > 1) { - builder.pageNumber(pageNumber); - } - - if (params.search) { - builder.where({ - [Op.or]: [ - { projectName: { [Op.like]: `%${params.search}%` } }, - { "$organisation.name$": { [Op.like]: `%${params.search}%` } } - ] + if (query.filter) { + Object.keys(query.filter).forEach(key => { + if (!["restoration_intervention_types", "project_country"].includes(key)) { + throw new BadRequestException(`Invalid filter key: ${key}`); + } + const value = query.filter[key]; + builder.where({ + [key]: { [Op.like]: `%${value}%` } + }); }); } + if (query.sort != null) { + if ( + [ + "organisation_id", + "project_name", + "project_objectives", + "project_country", + "project_county_district", + "restoration_intervention_types", + "total_hectares", + "total_trees", + "capacity_building_needs", + "created_at", + "updated_at", + "deleted_at" + ].includes(query.sort.field) + ) { + builder.order([query.sort.field, query.sort.direction ?? "ASC"]); + } else { + throw new BadRequestException(`Invalid sort field: ${query.sort.field}`); + } + } return { data: await builder.execute(), paginationTotal: await builder.paginationTotal(), pageNumber }; } } diff --git a/apps/entity-service/src/entities/project-pitches.controller.spec.ts b/apps/entity-service/src/entities/project-pitches.controller.spec.ts index 47969ef4..e802b873 100644 --- a/apps/entity-service/src/entities/project-pitches.controller.spec.ts +++ b/apps/entity-service/src/entities/project-pitches.controller.spec.ts @@ -4,16 +4,21 @@ import { Test } from "@nestjs/testing"; import { ProjectPitchesController } from "./project-pitches.controller"; import { ProjectPitchService } from "./project-pitch.service"; import { BadRequestException, NotFoundException } from "@nestjs/common"; -import { EntityQueryDto } from "./dto/entity-query.dto"; +import { PolicyService } from "@terramatch-microservices/common"; +import { ProjectPitchQueryDto } from "./dto/project-pitch-query.dto"; describe("ProjectPitchesController", () => { let controller: ProjectPitchesController; let projectPitchService: DeepMocked; + let policyService: DeepMocked; beforeEach(async () => { const module = await Test.createTestingModule({ controllers: [ProjectPitchesController], - providers: [{ provide: ProjectPitchService, useValue: (projectPitchService = createMock()) }] + providers: [ + { provide: ProjectPitchService, useValue: (projectPitchService = createMock()) }, + { provide: PolicyService, useValue: (policyService = createMock()) } + ] }).compile(); controller = module.get(ProjectPitchesController); @@ -30,11 +35,13 @@ describe("ProjectPitchesController", () => { paginationTotal: 0, pageNumber: 1 }; + policyService.getPermissions.mockResolvedValue(["framework-ppc"]); + projectPitchService.getProjectPitches.mockResolvedValue(mockResponse); - const result = await controller.getPitches({ authenticatedUserId: 1 }, new EntityQueryDto()); + const result = await controller.projectPitchIndex(new ProjectPitchQueryDto()); + expect(projectPitchService.getProjectPitches).toHaveBeenCalledTimes(1); expect(result).toBeDefined(); - expect(projectPitchService.getProjectPitches).toHaveBeenCalledWith(1, expect.any(EntityQueryDto)); }); it("should return an array of 3 project pitches successfully", async () => { @@ -46,70 +53,25 @@ describe("ProjectPitchesController", () => { paginationTotal: 3, pageNumber: 1 }; + policyService.getPermissions.mockResolvedValue(["framework-ppc"]); projectPitchService.getProjectPitches.mockResolvedValue(mockResponse); - const result = await controller.getPitches({ authenticatedUserId: 1 }, new EntityQueryDto()); + const result = await controller.projectPitchIndex(new ProjectPitchQueryDto()); expect(result).toBeDefined(); expect(Array.isArray(result.data) ? result.data.length : 0).toBe(2); - expect(projectPitchService.getProjectPitches).toHaveBeenCalledWith(1, expect.any(EntityQueryDto)); + expect(projectPitchService.getProjectPitches).toHaveBeenCalledTimes(1); }); it("should throw BadRequestException for invalid parameters", async () => { projectPitchService.getProjectPitches.mockRejectedValue(new BadRequestException("Invalid parameters")); - await expect(controller.getPitches({ authenticatedUserId: 1 }, new EntityQueryDto())).rejects.toThrow( - BadRequestException - ); + await expect(controller.projectPitchIndex(new ProjectPitchQueryDto())).rejects.toThrow(BadRequestException); }); it("should handle unexpected errors gracefully", async () => { projectPitchService.getProjectPitches.mockRejectedValue(new Error("Unexpected error")); - await expect(controller.getPitches({ authenticatedUserId: 1 }, new EntityQueryDto())).rejects.toThrow(Error); - }); - }); - - describe("Admin Project Pitches Index", () => { - it("should return project pitches successfully", async () => { - const mockResponse = { - data: [], - paginationTotal: 0, - pageNumber: 1 - }; - projectPitchService.getAdminProjectPitches.mockResolvedValue(mockResponse); - - const result = await controller.getAdminPitches(new EntityQueryDto()); - expect(projectPitchService.getAdminProjectPitches).toHaveBeenCalledTimes(1); - expect(result).toBeDefined(); - }); - - it("should return an array of 3 project pitches successfully", async () => { - const mockResponse = { - data: [ - new ProjectPitch({ uuid: "1", projectName: "Pitch 1", totalHectares: 200 }), - new ProjectPitch({ uuid: "2", projectName: "Pitch 2", totalHectares: 300 }) - ], - paginationTotal: 3, - pageNumber: 1 - }; - projectPitchService.getAdminProjectPitches.mockResolvedValue(mockResponse); - - const result = await controller.getAdminPitches(new EntityQueryDto()); - expect(result).toBeDefined(); - expect(Array.isArray(result.data) ? result.data.length : 0).toBe(2); - expect(projectPitchService.getAdminProjectPitches).toHaveBeenCalledTimes(1); - }); - - it("should throw BadRequestException for invalid parameters", async () => { - projectPitchService.getAdminProjectPitches.mockRejectedValue(new BadRequestException("Invalid parameters")); - - await expect(controller.getAdminPitches(new EntityQueryDto())).rejects.toThrow(BadRequestException); - }); - - it("should handle unexpected errors gracefully", async () => { - projectPitchService.getAdminProjectPitches.mockRejectedValue(new Error("Unexpected error")); - - await expect(controller.getAdminPitches(new EntityQueryDto())).rejects.toThrow(Error); + await expect(controller.projectPitchIndex(new ProjectPitchQueryDto())).rejects.toThrow(Error); }); }); @@ -119,7 +81,7 @@ describe("ProjectPitchesController", () => { const mockProjectPitch = new ProjectPitch({ uuid: "1", projectName: "Pitch 1", totalHectares: 200 }); projectPitchService.getProjectPitch.mockResolvedValue(mockProjectPitch); - const result = await controller.getByUUID({ uuid: "1" }); + const result = await controller.projectPitchGet({ uuid: "1" }); expect(result).toBeDefined(); expect(result.data["id"]).toBe("1"); expect(projectPitchService.getProjectPitch).toHaveBeenCalledWith("1"); @@ -128,19 +90,19 @@ describe("ProjectPitchesController", () => { it("should throw NotFoundException if project pitch is not found", async () => { projectPitchService.getProjectPitch.mockRejectedValue(new NotFoundException("Project pitch not found")); - await expect(controller.getByUUID({ uuid: "non-existent-uuid" })).rejects.toThrow(NotFoundException); + await expect(controller.projectPitchGet({ uuid: "non-existent-uuid" })).rejects.toThrow(NotFoundException); }); it("should throw BadRequestException for invalid UUID", async () => { projectPitchService.getProjectPitch.mockRejectedValue(new BadRequestException("Invalid UUID")); - await expect(controller.getByUUID({ uuid: "invalid-uuid" })).rejects.toThrow(BadRequestException); + await expect(controller.projectPitchGet({ uuid: "invalid-uuid" })).rejects.toThrow(BadRequestException); }); it("should handle unexpected errors gracefully", async () => { projectPitchService.getProjectPitch.mockRejectedValue(new Error("Unexpected error")); - await expect(controller.getByUUID({ uuid: "1" })).rejects.toThrow(Error); + await expect(controller.projectPitchGet({ uuid: "1" })).rejects.toThrow(Error); }); }); }); diff --git a/apps/entity-service/src/entities/project-pitches.controller.ts b/apps/entity-service/src/entities/project-pitches.controller.ts index 14ac4ab7..ffd9a78a 100644 --- a/apps/entity-service/src/entities/project-pitches.controller.ts +++ b/apps/entity-service/src/entities/project-pitches.controller.ts @@ -1,66 +1,34 @@ -import { - BadRequestException, - Controller, - Get, - HttpStatus, - NotFoundException, - Param, - Query, - Request -} from "@nestjs/common"; +import { BadRequestException, Controller, Get, HttpStatus, NotFoundException, Param, Query } from "@nestjs/common"; import { buildJsonApi, getStableRequestQuery } from "@terramatch-microservices/common/util"; import { ApiOperation } from "@nestjs/swagger"; import { ExceptionResponse, JsonApiResponse } from "@terramatch-microservices/common/decorators"; import { ProjectPitchService } from "./project-pitch.service"; import { ProjectPitchDto } from "./dto/project-pitch.dto"; import { ProjectPitchParamDto } from "./dto/project-pitch-param.dto"; -import { EntityQueryDto } from "./dto/entity-query.dto"; +import { PolicyService } from "@terramatch-microservices/common"; +import { ProjectPitchQueryDto } from "./dto/project-pitch-query.dto"; @Controller("entities/v3/projectPitches") export class ProjectPitchesController { - constructor(private readonly projectPitchService: ProjectPitchService) {} + constructor( + private readonly projectPitchService: ProjectPitchService, + private readonly policyService: PolicyService + ) {} @Get() @ApiOperation({ - operationId: "ProjectPitchesIndex", + operationId: "projectPitchIndex", summary: "Get projects pitches." }) @JsonApiResponse([{ data: ProjectPitchDto, pagination: "number" }]) @ExceptionResponse(BadRequestException, { description: "Param types invalid" }) @ExceptionResponse(NotFoundException, { description: "Records not found" }) - async getPitches(@Request() { authenticatedUserId }, @Query() params: EntityQueryDto) { - const { data, paginationTotal, pageNumber } = await this.projectPitchService.getProjectPitches( - authenticatedUserId, - params - ); - const document = buildJsonApi(ProjectPitchDto, { pagination: "number" }); - const indexIds: string[] = []; - for (const pitch of data) { - indexIds.push(pitch.uuid); - const pitchDto = new ProjectPitchDto(pitch); - document.addData(pitchDto.uuid, pitchDto); + async projectPitchIndex(@Query() params: ProjectPitchQueryDto) { + const permissions = await this.policyService.getPermissions(); + if (!permissions.some(permission => permission.startsWith("framework-"))) { + throw new BadRequestException("User does not have permission to access this resource"); } - - document.addIndexData({ - resource: "projectPitches", - requestPath: `/entities/v3/projectPitches/admin${getStableRequestQuery(params)}`, - ids: indexIds, - total: paginationTotal, - pageNumber: pageNumber - }); - return document.serialize(); - } - - @Get("/admin") - @ApiOperation({ - operationId: "AdminProjectPitchesIndex", - summary: "Get admin projects pitches." - }) - @JsonApiResponse([{ data: ProjectPitchDto, pagination: "number" }]) - @ExceptionResponse(BadRequestException, { description: "Param types invalid" }) - @ExceptionResponse(NotFoundException, { description: "Records not found" }) - async getAdminPitches(@Query() params: EntityQueryDto) { - const { data, paginationTotal, pageNumber } = await this.projectPitchService.getAdminProjectPitches(params); + const { data, paginationTotal, pageNumber } = await this.projectPitchService.getProjectPitches(params); const document = buildJsonApi(ProjectPitchDto, { pagination: "number" }); const indexIds: string[] = []; for (const pitch of data) { @@ -80,13 +48,13 @@ export class ProjectPitchesController { @Get(":uuid") @ApiOperation({ - operationId: "ProjectPitchesGetUUIDIndex", + operationId: "projectPitchGet", summary: "Get an project pitch by uuid." }) @JsonApiResponse(ProjectPitchDto, { status: HttpStatus.OK }) @ExceptionResponse(BadRequestException, { description: "Param types invalid" }) @ExceptionResponse(NotFoundException, { description: "Project pitch not found" }) - async getByUUID(@Param() { uuid }: ProjectPitchParamDto) { + async projectPitchGet(@Param() { uuid }: ProjectPitchParamDto) { const result = await this.projectPitchService.getProjectPitch(uuid); return buildJsonApi(ProjectPitchDto).addData(uuid, new ProjectPitchDto(result)).document.serialize(); } From e4d3275cc2857b2ab1d8c966f9fb8c6b48c0052a Mon Sep 17 00:00:00 2001 From: Ismael Martinez Date: Mon, 12 May 2025 17:37:22 -0600 Subject: [PATCH 18/25] [TM-1995] feat: add optional createdAt field to project pitch DTO and update request path in controller --- apps/entity-service/src/entities/dto/project-pitch.dto.ts | 5 +++++ apps/entity-service/src/entities/project-pitch.service.ts | 1 + .../src/entities/project-pitches.controller.ts | 2 +- 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/entity-service/src/entities/dto/project-pitch.dto.ts b/apps/entity-service/src/entities/dto/project-pitch.dto.ts index fe409eb2..f6814526 100644 --- a/apps/entity-service/src/entities/dto/project-pitch.dto.ts +++ b/apps/entity-service/src/entities/dto/project-pitch.dto.ts @@ -463,4 +463,9 @@ export class ProjectPitchDto extends JsonApiAttributes { @IsNumber() @Min(0) directSeedingSurvivalRate: number | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsDate() + createdAt?: Date | null; } diff --git a/apps/entity-service/src/entities/project-pitch.service.ts b/apps/entity-service/src/entities/project-pitch.service.ts index 41d6f95c..7ac345c6 100644 --- a/apps/entity-service/src/entities/project-pitch.service.ts +++ b/apps/entity-service/src/entities/project-pitch.service.ts @@ -49,6 +49,7 @@ export class ProjectPitchService { if (query.sort != null) { if ( [ + "id", "organisation_id", "project_name", "project_objectives", diff --git a/apps/entity-service/src/entities/project-pitches.controller.ts b/apps/entity-service/src/entities/project-pitches.controller.ts index ffd9a78a..c8fd7ccc 100644 --- a/apps/entity-service/src/entities/project-pitches.controller.ts +++ b/apps/entity-service/src/entities/project-pitches.controller.ts @@ -38,7 +38,7 @@ export class ProjectPitchesController { } document.addIndexData({ resource: "projectPitches", - requestPath: `/entities/v3/projectPitches/admin${getStableRequestQuery(params)}`, + requestPath: `/entities/v3/projectPitches${getStableRequestQuery(params)}`, ids: indexIds, total: paginationTotal, pageNumber: pageNumber From 31f8197bf620d0af6be335d6549281599b908a0f Mon Sep 17 00:00:00 2001 From: Ismael Martinez Date: Tue, 13 May 2025 08:27:38 -0600 Subject: [PATCH 19/25] [TM-1995] refactor: update filter and sort keys in project pitch service and controller --- .../src/entities/project-pitch.service.ts | 20 ++++--------------- .../entities/project-pitches.controller.ts | 15 +++++++------- 2 files changed, 11 insertions(+), 24 deletions(-) diff --git a/apps/entity-service/src/entities/project-pitch.service.ts b/apps/entity-service/src/entities/project-pitch.service.ts index 7ac345c6..a3649b67 100644 --- a/apps/entity-service/src/entities/project-pitch.service.ts +++ b/apps/entity-service/src/entities/project-pitch.service.ts @@ -37,7 +37,7 @@ export class ProjectPitchService { } if (query.filter) { Object.keys(query.filter).forEach(key => { - if (!["restoration_intervention_types", "project_country"].includes(key)) { + if (!["restorationInterventionTypes", "projectCountry"].includes(key)) { throw new BadRequestException(`Invalid filter key: ${key}`); } const value = query.filter[key]; @@ -48,21 +48,9 @@ export class ProjectPitchService { } if (query.sort != null) { if ( - [ - "id", - "organisation_id", - "project_name", - "project_objectives", - "project_country", - "project_county_district", - "restoration_intervention_types", - "total_hectares", - "total_trees", - "capacity_building_needs", - "created_at", - "updated_at", - "deleted_at" - ].includes(query.sort.field) + ["id", "organisationId", "projectName", "projectCountry", "restorationInterventionTypes", "createdAt"].includes( + query.sort.field + ) ) { builder.order([query.sort.field, query.sort.direction ?? "ASC"]); } else { diff --git a/apps/entity-service/src/entities/project-pitches.controller.ts b/apps/entity-service/src/entities/project-pitches.controller.ts index c8fd7ccc..2a50fede 100644 --- a/apps/entity-service/src/entities/project-pitches.controller.ts +++ b/apps/entity-service/src/entities/project-pitches.controller.ts @@ -24,17 +24,16 @@ export class ProjectPitchesController { @ExceptionResponse(BadRequestException, { description: "Param types invalid" }) @ExceptionResponse(NotFoundException, { description: "Records not found" }) async projectPitchIndex(@Query() params: ProjectPitchQueryDto) { - const permissions = await this.policyService.getPermissions(); - if (!permissions.some(permission => permission.startsWith("framework-"))) { - throw new BadRequestException("User does not have permission to access this resource"); - } const { data, paginationTotal, pageNumber } = await this.projectPitchService.getProjectPitches(params); const document = buildJsonApi(ProjectPitchDto, { pagination: "number" }); const indexIds: string[] = []; - for (const pitch of data) { - indexIds.push(pitch.uuid); - const pitchDto = new ProjectPitchDto(pitch); - document.addData(pitchDto.uuid, pitchDto); + if (data.length !== 0) { + // await this.policyService.authorize("read", data); + for (const pitch of data) { + indexIds.push(pitch.uuid); + const pitchDto = new ProjectPitchDto(pitch); + document.addData(pitchDto.uuid, pitchDto); + } } document.addIndexData({ resource: "projectPitches", From 8a708c9a60b05e1b56e19ba5814e28b111f9c8e2 Mon Sep 17 00:00:00 2001 From: Ismael Martinez Date: Tue, 13 May 2025 14:13:52 -0600 Subject: [PATCH 20/25] [TM-1995] refactor: update ProjectPitch policy integration and fix filter/sort key naming in project pitch service tests --- .../src/entities/project-pitch.service.spec.ts | 4 ++-- .../src/entities/project-pitches.controller.ts | 2 +- libs/common/src/lib/policies/policy.service.ts | 6 +++++- libs/common/src/lib/policies/project-pitch.policy.ts | 11 +++++++++++ 4 files changed, 19 insertions(+), 4 deletions(-) create mode 100644 libs/common/src/lib/policies/project-pitch.policy.ts diff --git a/apps/entity-service/src/entities/project-pitch.service.spec.ts b/apps/entity-service/src/entities/project-pitch.service.spec.ts index ca7d9ad7..46c56f75 100644 --- a/apps/entity-service/src/entities/project-pitch.service.spec.ts +++ b/apps/entity-service/src/entities/project-pitch.service.spec.ts @@ -113,7 +113,7 @@ describe("ProjectPitchService", () => { jest.spyOn(ProjectPitch, "findAll").mockImplementation(() => Promise.resolve(projectPitches)); const params = getDefaultPagination(); - params.filter = { restoration_intervention_types: "foo" }; + params.filter = { restorationInterventionTypes: "foo" }; const result = await service.getProjectPitches(params); expect(result.data).toHaveLength(1); @@ -138,7 +138,7 @@ describe("ProjectPitchService", () => { jest.spyOn(ProjectPitch, "findAll").mockImplementation(() => Promise.resolve(projectPitches)); const params = getDefaultPagination(); - params.sort = { field: "organisation_id", direction: "ASC" }; + params.sort = { field: "organisationId", direction: "ASC" }; const result = await service.getProjectPitches(params); expect(result.data).toHaveLength(1); diff --git a/apps/entity-service/src/entities/project-pitches.controller.ts b/apps/entity-service/src/entities/project-pitches.controller.ts index 2a50fede..0141a5e0 100644 --- a/apps/entity-service/src/entities/project-pitches.controller.ts +++ b/apps/entity-service/src/entities/project-pitches.controller.ts @@ -28,7 +28,7 @@ export class ProjectPitchesController { const document = buildJsonApi(ProjectPitchDto, { pagination: "number" }); const indexIds: string[] = []; if (data.length !== 0) { - // await this.policyService.authorize("read", data); + await this.policyService.authorize("read", data); for (const pitch of data) { indexIds.push(pitch.uuid); const pitchDto = new ProjectPitchDto(pitch); diff --git a/libs/common/src/lib/policies/policy.service.ts b/libs/common/src/lib/policies/policy.service.ts index c823ee97..dcbfaf87 100644 --- a/libs/common/src/lib/policies/policy.service.ts +++ b/libs/common/src/lib/policies/policy.service.ts @@ -6,6 +6,7 @@ import { NurseryReport, Permission, Project, + ProjectPitch, ProjectReport, Site, SitePolygon, @@ -24,6 +25,8 @@ import { SitePolicy } from "./site.policy"; import { NurseryReportPolicy } from "./nursery-report.policy"; import { NurseryPolicy } from "./nursery.policy"; import { TMLogger } from "../util/tm-logger"; +import { ProjectPitchPolicy } from "@terramatch-microservices/common/policies/project-pitch.policy"; +import * as console from "node:console"; type EntityClass = { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -43,7 +46,8 @@ const POLICIES: [EntityClass, PolicyClass][] = [ [Site, SitePolicy], [SitePolygon, SitePolygonPolicy], [SiteReport, SiteReportPolicy], - [User, UserPolicy] + [User, UserPolicy], + [ProjectPitch, ProjectPitchPolicy] ]; /** diff --git a/libs/common/src/lib/policies/project-pitch.policy.ts b/libs/common/src/lib/policies/project-pitch.policy.ts new file mode 100644 index 00000000..e63f85f6 --- /dev/null +++ b/libs/common/src/lib/policies/project-pitch.policy.ts @@ -0,0 +1,11 @@ +import { ProjectPitch } from "@terramatch-microservices/database/entities"; +import { UserPermissionsPolicy } from "./user-permissions.policy"; + +export class ProjectPitchPolicy extends UserPermissionsPolicy { + async addRules() { + if (this.permissions.some(permission => permission.startsWith("framework-"))) { + this.builder.can("read", ProjectPitch); + return; + } + } +} From 6fdb82cac73c102d3f05459cd6d11ee102cd8b16 Mon Sep 17 00:00:00 2001 From: Ismael Martinez Date: Tue, 13 May 2025 14:16:46 -0600 Subject: [PATCH 21/25] [TM-1995] refactor: update import path for ProjectPitchPolicy in policy service --- libs/common/src/lib/policies/policy.service.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/libs/common/src/lib/policies/policy.service.ts b/libs/common/src/lib/policies/policy.service.ts index dcbfaf87..3e315753 100644 --- a/libs/common/src/lib/policies/policy.service.ts +++ b/libs/common/src/lib/policies/policy.service.ts @@ -25,8 +25,7 @@ import { SitePolicy } from "./site.policy"; import { NurseryReportPolicy } from "./nursery-report.policy"; import { NurseryPolicy } from "./nursery.policy"; import { TMLogger } from "../util/tm-logger"; -import { ProjectPitchPolicy } from "@terramatch-microservices/common/policies/project-pitch.policy"; -import * as console from "node:console"; +import { ProjectPitchPolicy } from "./project-pitch.policy"; type EntityClass = { // eslint-disable-next-line @typescript-eslint/no-explicit-any From 84a780097a9d5fad288a934ae393c01cd01f7186 Mon Sep 17 00:00:00 2001 From: Ismael Martinez Date: Wed, 14 May 2025 08:57:51 -0600 Subject: [PATCH 22/25] [TM-1995] refactor: improve project pitch service error handling and policy checks --- .../src/entities/project-pitch.service.ts | 15 ++++++++++----- .../src/entities/project-pitches.controller.ts | 1 + .../src/lib/policies/project-pitch.policy.ts | 2 +- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/apps/entity-service/src/entities/project-pitch.service.ts b/apps/entity-service/src/entities/project-pitch.service.ts index a3649b67..96d772ce 100644 --- a/apps/entity-service/src/entities/project-pitch.service.ts +++ b/apps/entity-service/src/entities/project-pitch.service.ts @@ -9,15 +9,20 @@ import { ProjectPitchQueryDto } from "./dto/project-pitch-query.dto"; export class ProjectPitchService { async getProjectPitch(uuid: string) { const projectPitch = await ProjectPitch.findOne({ where: { uuid } }); - if (!projectPitch) { + if (projectPitch == null) { throw new NotFoundException("ProjectPitch not found"); } return projectPitch; } async getProjectPitches(query: ProjectPitchQueryDto) { - const pageNumber = query.page ? query.page.number : 1; - const pageSize = query.page ? query.page.size : MAX_PAGE_SIZE; + const { size: pageSize = MAX_PAGE_SIZE, number: pageNumber = 1 } = query.page ?? {}; + if (pageSize > MAX_PAGE_SIZE || pageSize < 1) { + throw new BadRequestException(`Invalid page size: ${pageSize}`); + } + if (pageNumber < 1) { + throw new BadRequestException(`Invalid page number: ${pageNumber}`); + } const organisationAssociation: Includeable = { association: "organisation", attributes: ["uuid", "name"] @@ -27,7 +32,7 @@ export class ProjectPitchService { builder.pageNumber(pageNumber); } - if (query.search) { + if (query.search != null) { builder.where({ [Op.or]: [ { projectName: { [Op.like]: `%${query.search}%` } }, @@ -35,7 +40,7 @@ export class ProjectPitchService { ] }); } - if (query.filter) { + if (query.filter != null) { Object.keys(query.filter).forEach(key => { if (!["restorationInterventionTypes", "projectCountry"].includes(key)) { throw new BadRequestException(`Invalid filter key: ${key}`); diff --git a/apps/entity-service/src/entities/project-pitches.controller.ts b/apps/entity-service/src/entities/project-pitches.controller.ts index 0141a5e0..6630b0f3 100644 --- a/apps/entity-service/src/entities/project-pitches.controller.ts +++ b/apps/entity-service/src/entities/project-pitches.controller.ts @@ -55,6 +55,7 @@ export class ProjectPitchesController { @ExceptionResponse(NotFoundException, { description: "Project pitch not found" }) async projectPitchGet(@Param() { uuid }: ProjectPitchParamDto) { const result = await this.projectPitchService.getProjectPitch(uuid); + await this.policyService.authorize("read", result); return buildJsonApi(ProjectPitchDto).addData(uuid, new ProjectPitchDto(result)).document.serialize(); } } diff --git a/libs/common/src/lib/policies/project-pitch.policy.ts b/libs/common/src/lib/policies/project-pitch.policy.ts index e63f85f6..f2f47300 100644 --- a/libs/common/src/lib/policies/project-pitch.policy.ts +++ b/libs/common/src/lib/policies/project-pitch.policy.ts @@ -3,7 +3,7 @@ import { UserPermissionsPolicy } from "./user-permissions.policy"; export class ProjectPitchPolicy extends UserPermissionsPolicy { async addRules() { - if (this.permissions.some(permission => permission.startsWith("framework-"))) { + if (this.frameworks.length > 0) { this.builder.can("read", ProjectPitch); return; } From aa065bd0551db506b0c2e7f09f2fb4fd4192ff47 Mon Sep 17 00:00:00 2001 From: Jose Carlos Laura Ramirez Date: Thu, 15 May 2025 08:46:00 -0400 Subject: [PATCH 23/25] [TM-2050] use values or localization keys for mail --- libs/common/src/lib/email/email.service.ts | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/libs/common/src/lib/email/email.service.ts b/libs/common/src/lib/email/email.service.ts index 054ca6bc..f0a882f2 100644 --- a/libs/common/src/lib/email/email.service.ts +++ b/libs/common/src/lib/email/email.service.ts @@ -84,13 +84,16 @@ export class EmailService { ) { if (!this.hasSubject(i18nKeys)) throw new InternalServerErrorException("Email subject is required"); - const subjectKey = this.getSubjectKey(i18nKeys); + const subjectKey = this.getPropertyKey("subject", i18nKeys); + const titleKey = this.getPropertyKey("title", i18nKeys); + const ctaKey = this.getPropertyKey("cta", i18nKeys); - const { [subjectKey]: subject, ...translated } = await this.localizationService.translateKeys( - i18nKeys, - locale, - i18nReplacements ?? {} - ); + const { + [subjectKey]: subject, + [titleKey]: title, + [ctaKey]: cta, + ...translated + } = await this.localizationService.translateKeys(i18nKeys, locale, i18nReplacements ?? {}); const data: Dictionary = { backendUrl: this.configService.get("EMAIL_IMAGE_BASE_URL"), @@ -100,6 +103,8 @@ export class EmailService { transactional: null, year: new Date().getFullYear(), ...translated, + title, + cta, ...(additionalValues ?? {}) }; if (isString(data["link"]) && data["link"].substring(0, 4).toLowerCase() !== "http") { @@ -116,7 +121,7 @@ export class EmailService { return i18nKeys["subject"] != null || i18nKeys["subjectKey"] != null; } - private getSubjectKey(i18nKeys: Dictionary) { - return i18nKeys["subject"] == null ? "subjectKey" : "subject"; + private getPropertyKey(property: string, i18nKeys: Dictionary) { + return i18nKeys[property] == null ? `${property}Key` : property; } } From 15bb00b95f8f81c056c02c0ab5fa1b867f13873d Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Mon, 19 May 2025 09:12:35 -0700 Subject: [PATCH 24/25] [TM-1995] Fix name of column on project pitches. --- libs/database/src/lib/entities/project-pitch.entity.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/database/src/lib/entities/project-pitch.entity.ts b/libs/database/src/lib/entities/project-pitch.entity.ts index 8e80b806..f739a47a 100644 --- a/libs/database/src/lib/entities/project-pitch.entity.ts +++ b/libs/database/src/lib/entities/project-pitch.entity.ts @@ -269,7 +269,7 @@ export class ProjectPitch extends Model { @AllowNull @Column(TEXT) - solutionMarketSite: string | null; + solutionMarketSize: string | null; @AllowNull @Column(TEXT) From bf38519a6544797899a687c0916c6ee898b54a4e Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Mon, 19 May 2025 09:19:49 -0700 Subject: [PATCH 25/25] [TM-1995] Fix name of column on project pitches. --- apps/entity-service/src/entities/dto/project-pitch.dto.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/entity-service/src/entities/dto/project-pitch.dto.ts b/apps/entity-service/src/entities/dto/project-pitch.dto.ts index f6814526..3edd38f9 100644 --- a/apps/entity-service/src/entities/dto/project-pitch.dto.ts +++ b/apps/entity-service/src/entities/dto/project-pitch.dto.ts @@ -372,7 +372,7 @@ export class ProjectPitchDto extends JsonApiAttributes { @ApiProperty({ required: false }) @IsOptional() @IsString() - solutionMarketSite: string | null; + solutionMarketSize: string | null; @ApiProperty({ required: false }) @IsOptional()