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-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-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 new file mode 100644 index 00000000..3edd38f9 --- /dev/null +++ b/apps/entity-service/src/entities/dto/project-pitch.dto.ts @@ -0,0 +1,471 @@ +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 extends JsonApiAttributes { + constructor(data: ProjectPitch) { + super({ + ...pickApiProperties(data, ProjectPitchDto) + }); + } + + @ApiProperty() + @IsUUID() + uuid: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + capacityBuildingNeeds: string[] | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsNumber() + @Min(0) + totalTrees: number | null; + + @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() + solutionMarketSize: 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; + + @ApiProperty({ required: false }) + @IsOptional() + @IsDate() + createdAt?: Date | null; +} 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..46c56f75 --- /dev/null +++ b/apps/entity-service/src/entities/project-pitch.service.spec.ts @@ -0,0 +1,147 @@ +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 { 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(); + 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; +} + +function getDefaultPagination() { + const params = new ProjectPitchQueryDto(); + params.page = new NumberPage(); + params.page.number = 1; + params.page.size = 10; + return params; +} + +describe("ProjectPitchService", () => { + let service: ProjectPitchService; + + beforeEach(async () => { + const module = await Test.createTestingModule({ + providers: [ProjectPitchService] + }).compile(); + + service = module.get(ProjectPitchService); + }); + + afterEach(() => { + 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("ProjectPitch not found"); + }); + }); + + describe("Get ProjectsPitches", () => { + it("returns paginated 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 = getDefaultPagination(); + + const result = await service.getProjectPitches(params); + + 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 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.search = "filtered"; + + const result = await service.getProjectPitches(params); + + expect(result.data).toHaveLength(1); + expect(result.data[0].projectName).toContain("Filtered"); + }); + + it("deny filters", 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 = { invalid_filter: "foo" }; + + await expect(service.getProjectPitches(params)).rejects.toThrow("Invalid filter key: invalid_filter"); + }); + + 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 = { restorationInterventionTypes: "foo" }; + + const result = await service.getProjectPitches(params); + expect(result.data).toHaveLength(1); + }); + + 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.sort = { field: "no_exist_column", direction: "ASC" }; + + 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: "organisationId", direction: "ASC" }; + + const result = await service.getProjectPitches(params); + expect(result.data).toHaveLength(1); + }); + }); +}); 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..96d772ce --- /dev/null +++ b/apps/entity-service/src/entities/project-pitch.service.ts @@ -0,0 +1,67 @@ +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 { ProjectPitchQueryDto } from "./dto/project-pitch-query.dto"; + +@Injectable() +export class ProjectPitchService { + async getProjectPitch(uuid: string) { + const projectPitch = await ProjectPitch.findOne({ where: { uuid } }); + if (projectPitch == null) { + throw new NotFoundException("ProjectPitch not found"); + } + return projectPitch; + } + + async getProjectPitches(query: ProjectPitchQueryDto) { + 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"] + }; + const builder = new PaginatedQueryBuilder(ProjectPitch, pageSize, [organisationAssociation]); + if (pageNumber > 1) { + builder.pageNumber(pageNumber); + } + + if (query.search != null) { + builder.where({ + [Op.or]: [ + { projectName: { [Op.like]: `%${query.search}%` } }, + { "$organisation.name$": { [Op.like]: `%${query.search}%` } } + ] + }); + } + if (query.filter != null) { + Object.keys(query.filter).forEach(key => { + if (!["restorationInterventionTypes", "projectCountry"].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 ( + ["id", "organisationId", "projectName", "projectCountry", "restorationInterventionTypes", "createdAt"].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 new file mode 100644 index 00000000..e802b873 --- /dev/null +++ b/apps/entity-service/src/entities/project-pitches.controller.spec.ts @@ -0,0 +1,109 @@ +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 { BadRequestException, NotFoundException } from "@nestjs/common"; +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()) }, + { provide: PolicyService, useValue: (policyService = 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 + }; + policyService.getPermissions.mockResolvedValue(["framework-ppc"]); + + projectPitchService.getProjectPitches.mockResolvedValue(mockResponse); + + const result = await controller.projectPitchIndex(new ProjectPitchQueryDto()); + expect(projectPitchService.getProjectPitches).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 + }; + policyService.getPermissions.mockResolvedValue(["framework-ppc"]); + projectPitchService.getProjectPitches.mockResolvedValue(mockResponse); + + const result = await controller.projectPitchIndex(new ProjectPitchQueryDto()); + expect(result).toBeDefined(); + expect(Array.isArray(result.data) ? result.data.length : 0).toBe(2); + expect(projectPitchService.getProjectPitches).toHaveBeenCalledTimes(1); + }); + + it("should throw BadRequestException for invalid parameters", async () => { + projectPitchService.getProjectPitches.mockRejectedValue(new BadRequestException("Invalid parameters")); + + 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.projectPitchIndex(new ProjectPitchQueryDto())).rejects.toThrow(Error); + }); + }); + + 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.projectPitchGet({ 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.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.projectPitchGet({ uuid: "invalid-uuid" })).rejects.toThrow(BadRequestException); + }); + + it("should handle unexpected errors gracefully", async () => { + projectPitchService.getProjectPitch.mockRejectedValue(new Error("Unexpected 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 new file mode 100644 index 00000000..6630b0f3 --- /dev/null +++ b/apps/entity-service/src/entities/project-pitches.controller.ts @@ -0,0 +1,61 @@ +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 { 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, + private readonly policyService: PolicyService + ) {} + + @Get() + @ApiOperation({ + operationId: "projectPitchIndex", + summary: "Get projects pitches." + }) + @JsonApiResponse([{ data: ProjectPitchDto, pagination: "number" }]) + @ExceptionResponse(BadRequestException, { description: "Param types invalid" }) + @ExceptionResponse(NotFoundException, { description: "Records not found" }) + async projectPitchIndex(@Query() params: ProjectPitchQueryDto) { + const { data, paginationTotal, pageNumber } = await this.projectPitchService.getProjectPitches(params); + const document = buildJsonApi(ProjectPitchDto, { pagination: "number" }); + const indexIds: string[] = []; + 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", + requestPath: `/entities/v3/projectPitches${getStableRequestQuery(params)}`, + ids: indexIds, + total: paginationTotal, + pageNumber: pageNumber + }); + return document.serialize(); + } + + @Get(":uuid") + @ApiOperation({ + 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 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/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; } } diff --git a/libs/common/src/lib/policies/policy.service.ts b/libs/common/src/lib/policies/policy.service.ts index c823ee97..3e315753 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,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 "./project-pitch.policy"; type EntityClass = { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -43,7 +45,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..f2f47300 --- /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.frameworks.length > 0) { + this.builder.can("read", ProjectPitch); + return; + } + } +} diff --git a/libs/database/src/lib/entities/project-pitch.entity.ts b/libs/database/src/lib/entities/project-pitch.entity.ts index 66aa87a7..f739a47a 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, DECIMAL, INTEGER, STRING, TEXT, TINYINT, UUID, UUIDV4 } from "sequelize"; import { JsonColumn } from "../decorators/json-column.decorator"; +import { Organisation } from "./organisation.entity"; @Table({ tableName: "project_pitches", underscored: true, paranoid: true }) export class ProjectPitch extends Model { @@ -268,7 +269,7 @@ export class ProjectPitch extends Model { @AllowNull @Column(TEXT) - solutionMarketSite: string | null; + solutionMarketSize: string | null; @AllowNull @Column(TEXT) @@ -334,6 +335,9 @@ export class ProjectPitch extends Model { @Column(INTEGER.UNSIGNED) directSeedingSurvivalRate: number | null; + @BelongsTo(() => Organisation, { foreignKey: "organisationId", constraints: false }) + organisation: Organisation | null; + @AllowNull @JsonColumn({ field: "level_0_proposed" }) level0Proposed: string[] | null; 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) +}));