diff --git a/apps/entity-service/src/app.module.ts b/apps/entity-service/src/app.module.ts index 0f3c0ba7..d2baceb7 100644 --- a/apps/entity-service/src/app.module.ts +++ b/apps/entity-service/src/app.module.ts @@ -17,6 +17,8 @@ import { TasksService } from "./entities/tasks.service"; import { BoundingBoxController } from "./bounding-boxes/bounding-box.controller"; import { BoundingBoxService } from "./bounding-boxes/bounding-box.service"; import { DataApiModule } from "@terramatch-microservices/data-api"; +import { DemographicsController } from "./entities/demographics.controller"; +import { DemographicService } from "./entities/demographic.service"; import { ImpactStoriesController } from "./entities/impact-stories.controller"; import { ImpactStoryService } from "./entities/impact-story.service"; @@ -32,6 +34,7 @@ import { ImpactStoryService } from "./entities/impact-story.service"; FileUploadController, TreesController, BoundingBoxController, + DemographicsController, EntitiesController, EntityAssociationsController ], @@ -46,7 +49,8 @@ import { ImpactStoryService } from "./entities/impact-story.service"; ProjectPitchService, ImpactStoryService, BoundingBoxService, - TasksService + TasksService, + DemographicService ] }) export class AppModule {} diff --git a/apps/entity-service/src/entities/demographic.controller.spec.ts b/apps/entity-service/src/entities/demographic.controller.spec.ts new file mode 100644 index 00000000..99e588b7 --- /dev/null +++ b/apps/entity-service/src/entities/demographic.controller.spec.ts @@ -0,0 +1,90 @@ +import { Demographic, Project } from "@terramatch-microservices/database/entities"; +import { createMock, DeepMocked } from "@golevelup/ts-jest"; +import { Test } from "@nestjs/testing"; +import { BadRequestException } from "@nestjs/common"; +import { PolicyService } from "@terramatch-microservices/common"; +import { ProjectPitchQueryDto } from "./dto/project-pitch-query.dto"; +import { DemographicsController } from "./demographics.controller"; +import { DemographicService } from "./demographic.service"; +import { DemographicQueryDto } from "./dto/demographic-query.dto"; +import { ProjectFactory } from "@terramatch-microservices/database/factories"; + +describe("DemographicsController", () => { + let controller: DemographicsController; + let demographicService: DeepMocked; + let policyService: DeepMocked; + + beforeEach(async () => { + const module = await Test.createTestingModule({ + controllers: [DemographicsController], + providers: [ + { provide: DemographicService, useValue: (demographicService = createMock()) }, + { provide: PolicyService, useValue: (policyService = createMock()) } + ] + }).compile(); + + controller = module.get(DemographicsController); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe("Demographics Index", () => { + it("should return demographics successfully", async () => { + const mockResponse = { + data: [], + paginationTotal: 0, + pageNumber: 1 + }; + policyService.getPermissions.mockResolvedValue(["framework-ppc"]); + + demographicService.getDemographics.mockResolvedValue(mockResponse); + + const result = await controller.demographicsIndex(new DemographicQueryDto()); + expect(demographicService.getDemographics).toHaveBeenCalledTimes(1); + expect(result).toBeDefined(); + }); + + it("should return an array of 3 demographics successfully", async () => { + const project = await ProjectFactory.create(); + const mockResponse = { + data: [ + new Demographic({ + uuid: "1", + type: "type 1", + demographicalType: Project.LARAVEL_TYPE, + demographicalId: project.id + } as Demographic), + new Demographic({ + uuid: "2", + type: "type 2", + demographicalType: Project.LARAVEL_TYPE, + demographicalId: project.id + } as Demographic) + ], + paginationTotal: 3, + pageNumber: 1 + }; + policyService.getPermissions.mockResolvedValue(["framework-ppc"]); + demographicService.getDemographics.mockResolvedValue(mockResponse); + + const result = await controller.demographicsIndex(new DemographicQueryDto()); + expect(result).toBeDefined(); + expect(Array.isArray(result.data) ? result.data.length : 0).toBe(2); + expect(demographicService.getDemographics).toHaveBeenCalledTimes(1); + }); + + it("should throw BadRequestException for invalid parameters", async () => { + demographicService.getDemographics.mockRejectedValue(new BadRequestException("Invalid parameters")); + + await expect(controller.demographicsIndex(new ProjectPitchQueryDto())).rejects.toThrow(BadRequestException); + }); + + it("should handle unexpected errors gracefully", async () => { + demographicService.getDemographics.mockRejectedValue(new Error("Unexpected error")); + + await expect(controller.demographicsIndex(new ProjectPitchQueryDto())).rejects.toThrow(Error); + }); + }); +}); diff --git a/apps/entity-service/src/entities/demographic.service.spec.ts b/apps/entity-service/src/entities/demographic.service.spec.ts new file mode 100644 index 00000000..9bc0cede --- /dev/null +++ b/apps/entity-service/src/entities/demographic.service.spec.ts @@ -0,0 +1,112 @@ +import { Test } from "@nestjs/testing"; +import { Demographic } from "@terramatch-microservices/database/entities"; +import { NumberPage } from "@terramatch-microservices/common/dto/page.dto"; +import { DemographicService } from "./demographic.service"; +import { DemographicQueryDto } from "./dto/demographic-query.dto"; + +function getDefaultPagination() { + const params = new DemographicQueryDto(); + params.page = new NumberPage(); + params.page.number = 1; + params.page.size = 10; + return params; +} + +describe("DemographicService", () => { + let service: DemographicService; + + beforeEach(async () => { + const module = await Test.createTestingModule({ + providers: [DemographicService] + }).compile(); + + service = module.get(DemographicService); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe("Get Demographics", () => { + it("returns paginated demographics", async () => { + const demographics = [ + new Demographic({ uuid: "uuid y", type: "type 1" } as Demographic), + new Demographic({ uuid: "uuid x", type: "type 2" } as Demographic) + ]; + jest.spyOn(Demographic, "findAll").mockImplementation(() => Promise.resolve(demographics)); + + const params = getDefaultPagination(); + + const result = await service.getDemographics(params); + + expect(result.data).toHaveLength(2); + expect(result.data[0].uuid).toBe("uuid y"); + expect(result.data[1].uuid).toBe("uuid x"); + expect(result.pageNumber).toBe(1); + }); + + it("deny unexpected filter", async () => { + const demographics = [new Demographic({ uuid: "uuid10", type: "Filtered" } as Demographic)]; + jest.spyOn(Demographic, "findAll").mockImplementation(() => Promise.resolve(demographics)); + + const params = getDefaultPagination(); + params["unexpectedFilter"] = ["value1", "value2"]; + + await expect(service.getDemographics(params)).rejects.toThrow("Invalid filter key: unexpectedFilter"); + }); + + it("applies projectUuid filter", async () => { + const demographics = [new Demographic({ uuid: "uuid10", type: "Filtered" } as Demographic)]; + jest.spyOn(Demographic, "findAll").mockImplementation(() => Promise.resolve(demographics)); + + const params = getDefaultPagination(); + params.projectReportUuid = ["uuid10", "uid20"]; + + const result = await service.getDemographics(params); + expect(result.data).toHaveLength(1); + }); + + it("applies projectReportUuid filter", async () => { + const demographics = [new Demographic({ uuid: "uuid1", type: "Filtered" } as Demographic)]; + jest.spyOn(Demographic, "findAll").mockImplementation(() => Promise.resolve(demographics)); + + const params = getDefaultPagination(); + params.projectReportUuid = ["uuid1", "uid2"]; + + const result = await service.getDemographics(params); + expect(result.data).toHaveLength(1); + }); + + it("applies siteReportUuid filter", async () => { + const demographics = [new Demographic({ uuid: "uuid44", type: "Filtered" } as Demographic)]; + jest.spyOn(Demographic, "findAll").mockImplementation(() => Promise.resolve(demographics)); + + const params = getDefaultPagination(); + params.projectReportUuid = ["uuid44", "uid45"]; + + const result = await service.getDemographics(params); + expect(result.data).toHaveLength(1); + }); + + it("deny orders fields", async () => { + const demographics = [new Demographic({ uuid: "uuid1", type: "Filtered" } as Demographic)]; + jest.spyOn(Demographic, "findAll").mockImplementation(() => Promise.resolve(demographics)); + + const params = getDefaultPagination(); + params.sort = { field: "no_exist_column", direction: "ASC" }; + + await expect(service.getDemographics(params)).rejects.toThrow("Invalid sort field: no_exist_column"); + }); + + it("applies order correctly", async () => { + const demographics = [new Demographic({ uuid: "uuid1", type: "Filtered" } as Demographic)]; + jest.spyOn(Demographic, "findAll").mockImplementation(() => Promise.resolve(demographics)); + + const params = getDefaultPagination(); + params.sort = { field: "type", direction: "ASC" }; + + const result = await service.getDemographics(params); + expect(result.data).toHaveLength(1); + }); + }); +}); diff --git a/apps/entity-service/src/entities/demographic.service.ts b/apps/entity-service/src/entities/demographic.service.ts new file mode 100644 index 00000000..3d3eaf14 --- /dev/null +++ b/apps/entity-service/src/entities/demographic.service.ts @@ -0,0 +1,79 @@ +import { BadRequestException, Injectable } from "@nestjs/common"; +import { Demographic, Project, ProjectReport, SiteReport } from "@terramatch-microservices/database/entities"; +import { PaginatedQueryBuilder } from "@terramatch-microservices/common/util/paginated-query.builder"; +import { DemographicQueryDto } from "./dto/demographic-query.dto"; +import { Model, ModelStatic, Op } from "sequelize"; + +type DemographicFilter = { + uuidKey: string; + model: ModelStatic; + laravelType: string; +}; + +const DEMOGRAPHIC_FILTERS: DemographicFilter[] = [ + { + uuidKey: "projectUuid", + model: Project, + laravelType: Project.LARAVEL_TYPE + }, + { + uuidKey: "projectReportUuid", + model: ProjectReport, + laravelType: ProjectReport.LARAVEL_TYPE + }, + { + uuidKey: "siteReportUuid", + model: SiteReport, + laravelType: SiteReport.LARAVEL_TYPE + } +] as const; + +const VALID_FILTER_KEYS = DEMOGRAPHIC_FILTERS.map(({ uuidKey }) => uuidKey); + +@Injectable() +export class DemographicService { + async getDemographics(query: DemographicQueryDto) { + const builder = PaginatedQueryBuilder.forNumberPage(Demographic, query); + + Object.keys(query).forEach(key => { + if (key === "page" || key === "sort") return; + if (!VALID_FILTER_KEYS.includes(key)) { + throw new BadRequestException(`Invalid filter key: ${key}`); + } + }); + + for (const { uuidKey, model, laravelType } of DEMOGRAPHIC_FILTERS) { + const uuids = query[uuidKey]; + if (uuids != null && uuids.length > 0) { + const records = (await model.findAll({ + attributes: ["id"], + where: { uuid: { [Op.in]: uuids } } + })) as unknown as { id: number }[]; + + if (records.length > 0) { + const demographicIds = Demographic.idsSubquery( + records.map(record => record.id), + laravelType + ); + builder.where({ + id: { [Op.in]: demographicIds } + }); + } + } + } + + if (query.sort?.field != null) { + if (["id", "type"].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: query.page?.number ?? 1 + }; + } +} diff --git a/apps/entity-service/src/entities/demographics.controller.ts b/apps/entity-service/src/entities/demographics.controller.ts new file mode 100644 index 00000000..a9a57155 --- /dev/null +++ b/apps/entity-service/src/entities/demographics.controller.ts @@ -0,0 +1,67 @@ +import { + BadRequestException, + Controller, + Get, + InternalServerErrorException, + NotFoundException, + 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 { PolicyService } from "@terramatch-microservices/common"; +import { DemographicDto } from "./dto/demographic.dto"; +import { DemographicQueryDto } from "./dto/demographic-query.dto"; +import { DemographicService } from "./demographic.service"; +import { LARAVEL_MODEL_TYPES, LARAVEL_MODELS } from "@terramatch-microservices/database/constants/laravel-types"; +import { TMLogger } from "@terramatch-microservices/common/util/tm-logger"; + +@Controller("entities/v3/demographics") +export class DemographicsController { + private logger = new TMLogger(DemographicsController.name); + + constructor(private readonly demographicService: DemographicService, private readonly policyService: PolicyService) {} + + @Get() + @ApiOperation({ + operationId: "demographicsIndex", + summary: "Get demographics." + }) + @JsonApiResponse([{ data: DemographicDto, pagination: "number" }]) + @ExceptionResponse(BadRequestException, { description: "Param types invalid" }) + @ExceptionResponse(NotFoundException, { description: "Records not found" }) + async demographicsIndex(@Query() params: DemographicQueryDto) { + const { data, paginationTotal, pageNumber } = await this.demographicService.getDemographics(params); + const document = buildJsonApi(DemographicDto, { pagination: "number" }); + const indexIds: string[] = []; + if (data.length !== 0) { + await this.policyService.authorize("read", data); + for (const demographic of data) { + indexIds.push(demographic.uuid); + const { demographicalType: laravelType, demographicalId } = demographic; + const model = LARAVEL_MODELS[laravelType]; + if (model == null) { + this.logger.error("Unknown model type", model); + throw new InternalServerErrorException("Unexpected demographic association type"); + } + const entity = await model.findOne({ where: { id: demographicalId }, attributes: ["uuid"] }); + if (entity == null) { + this.logger.error("Demographic parent entity not found", { model, id: demographicalId }); + throw new NotFoundException(); + } + const entityType = LARAVEL_MODEL_TYPES[laravelType]; + const additionalProps = { entityType, entityUuid: entity.uuid }; + const demographicDto = new DemographicDto(demographic, additionalProps); + document.addData(demographicDto.uuid, demographicDto); + } + } + document.addIndexData({ + resource: "demographics", + requestPath: `/entities/v3/demographics${getStableRequestQuery(params)}`, + ids: indexIds, + total: paginationTotal, + pageNumber: pageNumber + }); + return document.serialize(); + } +} diff --git a/apps/entity-service/src/entities/dto/association.dto.ts b/apps/entity-service/src/entities/dto/association.dto.ts index a2e24ba5..6692244a 100644 --- a/apps/entity-service/src/entities/dto/association.dto.ts +++ b/apps/entity-service/src/entities/dto/association.dto.ts @@ -1,15 +1,23 @@ import { ApiProperty } from "@nestjs/swagger"; -import { ENTITY_TYPES } from "@terramatch-microservices/database/constants/entities"; -import { MediaOwnerType } from "@terramatch-microservices/database/constants/media-owners"; +import { ENTITY_TYPES, EntityType } from "@terramatch-microservices/database/constants/entities"; +import { MEDIA_OWNER_TYPES, MediaOwnerType } from "@terramatch-microservices/database/constants/media-owners"; +import { + DEMOGRAPHIC_ASSOCIATION_TYPES, + DemographicAssociationType +} from "@terramatch-microservices/database/types/demographic"; +import { uniq } from "lodash"; + +const ASSOCIATION_TYPES = uniq([...ENTITY_TYPES, ...MEDIA_OWNER_TYPES, DEMOGRAPHIC_ASSOCIATION_TYPES]); +type AssociationEntityType = EntityType | MediaOwnerType | DemographicAssociationType; export type AssociationDtoAdditionalProps = { - entityType: MediaOwnerType; + entityType: AssociationEntityType; entityUuid: string; }; export abstract class AssociationDto { - @ApiProperty({ enum: ENTITY_TYPES, description: "The entity type this resource is associated with." }) - entityType: MediaOwnerType; + @ApiProperty({ enum: ASSOCIATION_TYPES, description: "The entity type this resource is associated with." }) + entityType: AssociationEntityType; @ApiProperty({ description: "The entity UUID this resource is associated with." }) entityUuid: string; diff --git a/apps/entity-service/src/entities/dto/demographic-query.dto.ts b/apps/entity-service/src/entities/dto/demographic-query.dto.ts new file mode 100644 index 00000000..5710b0af --- /dev/null +++ b/apps/entity-service/src/entities/dto/demographic-query.dto.ts @@ -0,0 +1,20 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsArray, IsOptional } from "class-validator"; +import { IndexQueryDto } from "./index-query.dto"; + +export class DemographicQueryDto extends IndexQueryDto { + @ApiProperty({ required: false, isArray: true, description: "project uuid array" }) + @IsOptional() + @IsArray() + projectUuid?: string[]; + + @ApiProperty({ required: false, isArray: true, description: "projectReport uuid array" }) + @IsOptional() + @IsArray() + projectReportUuid?: string[]; + + @ApiProperty({ required: false, isArray: true, description: "siteReport uuid array" }) + @IsOptional() + @IsArray() + siteReportUuid?: string[]; +} diff --git a/libs/common/src/lib/policies/demographic.policy.ts b/libs/common/src/lib/policies/demographic.policy.ts new file mode 100644 index 00000000..96bdb4e6 --- /dev/null +++ b/libs/common/src/lib/policies/demographic.policy.ts @@ -0,0 +1,11 @@ +import { Demographic } from "@terramatch-microservices/database/entities"; +import { UserPermissionsPolicy } from "./user-permissions.policy"; + +export class DemographicPolicy extends UserPermissionsPolicy { + async addRules() { + if (this.frameworks.length > 0) { + this.builder.can("read", Demographic); + return; + } + } +} diff --git a/libs/common/src/lib/policies/policy.service.ts b/libs/common/src/lib/policies/policy.service.ts index d1b71554..9667dd93 100644 --- a/libs/common/src/lib/policies/policy.service.ts +++ b/libs/common/src/lib/policies/policy.service.ts @@ -2,6 +2,7 @@ import { Injectable, Scope, UnauthorizedException } from "@nestjs/common"; import { RequestContext } from "nestjs-request-context"; import { UserPolicy } from "./user.policy"; import { + Demographic, Nursery, NurseryReport, Permission, @@ -34,6 +35,7 @@ import { NurseryPolicy } from "./nursery.policy"; import { TMLogger } from "../util/tm-logger"; import { ProjectPitchPolicy } from "./project-pitch.policy"; import { TaskPolicy } from "./task.policy"; +import { DemographicPolicy } from "./demographic.policy"; import { AuditStatusPolicy } from "./audit-status.policy"; import { FinancialIndicatorPolicy } from "./financial-indicator.policy"; import { FormPolicy } from "./form.policy"; @@ -62,6 +64,7 @@ const POLICIES: [EntityClass, PolicyClass][] = [ [User, UserPolicy], [ProjectPitch, ProjectPitchPolicy], [Task, TaskPolicy], + [Demographic, DemographicPolicy], [AuditStatus, AuditStatusPolicy], [FinancialIndicator, FinancialIndicatorPolicy], [Form, FormPolicy], diff --git a/libs/database/src/lib/constants/laravel-types.ts b/libs/database/src/lib/constants/laravel-types.ts new file mode 100644 index 00000000..df6125af --- /dev/null +++ b/libs/database/src/lib/constants/laravel-types.ts @@ -0,0 +1,18 @@ +import { Organisation, Project, ProjectPitch, ProjectReport, SiteReport } from "../entities"; +import { DemographicAssociationType } from "../types/demographic"; + +export const LARAVEL_MODELS = { + [Organisation.LARAVEL_TYPE]: Organisation, + [Project.LARAVEL_TYPE]: Project, + [ProjectPitch.LARAVEL_TYPE]: ProjectPitch, + [ProjectReport.LARAVEL_TYPE]: ProjectReport, + [SiteReport.LARAVEL_TYPE]: SiteReport +}; + +export const LARAVEL_MODEL_TYPES: Record = { + [Organisation.LARAVEL_TYPE]: "organisations", + [Project.LARAVEL_TYPE]: "projects", + [ProjectPitch.LARAVEL_TYPE]: "projectPitches", + [ProjectReport.LARAVEL_TYPE]: "projectReports", + [SiteReport.LARAVEL_TYPE]: "siteReports" +} as const; diff --git a/libs/database/src/lib/entities/demographic.entity.ts b/libs/database/src/lib/entities/demographic.entity.ts index ea55fafc..aaa7cd03 100644 --- a/libs/database/src/lib/entities/demographic.entity.ts +++ b/libs/database/src/lib/entities/demographic.entity.ts @@ -38,12 +38,17 @@ export class Demographic extends Model { Demographic.ASSOCIATES_TYPES ] as const; - static idsSubquery(demographicalIds: Literal | number[], demographicalType: string, type: DemographicType) { - return Subquery.select(Demographic, "id") + static idsSubquery(demographicalIds: Literal | number[], demographicalType: string, type?: DemographicType) { + const query = Subquery.select(Demographic, "id") .eq("demographicalType", demographicalType) .in("demographicalId", demographicalIds) - .eq("hidden", false) - .eq("type", type).literal; + .eq("hidden", false); + + if (type != null) { + query.eq("type", type); + } + + return query.literal; } @PrimaryKey diff --git a/libs/database/src/lib/types/demographic.ts b/libs/database/src/lib/types/demographic.ts index 56def9c9..2166b308 100644 --- a/libs/database/src/lib/types/demographic.ts +++ b/libs/database/src/lib/types/demographic.ts @@ -1,3 +1,11 @@ import { Demographic } from "../entities"; export type DemographicType = (typeof Demographic.VALID_TYPES)[number]; +export const DEMOGRAPHIC_ASSOCIATION_TYPES = [ + "organisations", + "projectPitches", + "projects", + "projectReports", + "siteReports" +] as const; +export type DemographicAssociationType = (typeof DEMOGRAPHIC_ASSOCIATION_TYPES)[number];