diff --git a/apps/entity-service/src/entities/dto/entity-query.dto.ts b/apps/entity-service/src/entities/dto/entity-query.dto.ts index 000843bed..720e1a22c 100644 --- a/apps/entity-service/src/entities/dto/entity-query.dto.ts +++ b/apps/entity-service/src/entities/dto/entity-query.dto.ts @@ -48,4 +48,8 @@ export class EntityQueryDto extends IntersectionType(QuerySort, NumberPage) { @ApiProperty({ required: false }) @IsOptional() projectUuid?: string; + + @ApiProperty({ required: false }) + @IsOptional() + nurseryUuid?: string; } diff --git a/apps/entity-service/src/entities/dto/nursery-report.dto.ts b/apps/entity-service/src/entities/dto/nursery-report.dto.ts new file mode 100644 index 000000000..082689701 --- /dev/null +++ b/apps/entity-service/src/entities/dto/nursery-report.dto.ts @@ -0,0 +1,233 @@ +import { NurseryReport } from "@terramatch-microservices/database/entities"; +import { EntityDto, AdditionalProps } from "./entity.dto"; +import { pickApiProperties } from "@terramatch-microservices/common/dto/json-api-attributes"; +import { JsonApiDto } from "@terramatch-microservices/common/decorators/json-api-dto.decorator"; +import { ApiProperty } from "@nestjs/swagger"; +import { MediaDto } from "./media.dto"; + +@JsonApiDto({ type: "nurseryReports" }) +export class NurseryReportLightDto extends EntityDto { + constructor(nurseryReport?: NurseryReport, props?: AdditionalNurseryReportLightProps) { + super(); + if (nurseryReport != null) { + this.populate(NurseryReportLightDto, { + ...pickApiProperties(nurseryReport, NurseryReportLightDto), + lightResource: true, + // these two are untyped and marked optional in the base model. + createdAt: nurseryReport.createdAt as Date, + updatedAt: nurseryReport.updatedAt as Date, + ...props + }); + } + } + + @ApiProperty({ + nullable: true, + description: "The associated nursery name" + }) + nurseryName: string | null; + + @ApiProperty({ + nullable: true, + description: "The associated nursery uuid" + }) + nurseryUuid: string | null; + + @ApiProperty() + frameworkKey: string | null; + + @ApiProperty() + frameworkUuid: string | null; + + @ApiProperty() + status: string; + + @ApiProperty() + updateRequestStatus: string; + + @ApiProperty({ + nullable: true, + description: "The associated project name" + }) + projectName: string | null; + + @ApiProperty({ + nullable: true, + description: "The associated project uuid" + }) + projectUuid: string | null; + + @ApiProperty({ + nullable: true, + description: "The associated organisation name" + }) + organisationName: string | null; + + @ApiProperty({ + nullable: true, + description: "The associated organisation uuid" + }) + organisationUuid: string | null; + + @ApiProperty() + updatedAt: Date; + + @ApiProperty({ nullable: true }) + submittedAt: Date | null; + + @ApiProperty({ nullable: true }) + taskUuid: string | null; + + @ApiProperty() + dueAt: Date | null; + + @ApiProperty({ nullable: true }) + title: string | null; + + @ApiProperty({ nullable: true }) + reportTitle: string | null; + + @ApiProperty() + createdAt: Date; +} + +export type AdditionalNurseryReportLightProps = Pick; +export type AdditionalNurseryReportFullProps = AdditionalNurseryReportLightProps & + AdditionalProps>; +export type NurseryReportMedia = Pick; + +export class NurseryReportFullDto extends NurseryReportLightDto { + constructor(nurseryReport: NurseryReport, props?: AdditionalNurseryReportFullProps) { + super(); + if (nurseryReport != null) { + this.populate(NurseryReportFullDto, { + ...pickApiProperties(nurseryReport, NurseryReportFullDto), + lightResource: false, + // these two are untyped and marked optional in the base model. + createdAt: nurseryReport.createdAt as Date, + updatedAt: nurseryReport.updatedAt as Date, + ...props + }); + } + } + + @ApiProperty({ nullable: true }) + reportTitle: string | null; + + @ApiProperty({ nullable: true }) + projectReportTitle: string | null; + + @ApiProperty({ + nullable: true, + description: "The associated nursery name" + }) + nurseryName: string | null; + + @ApiProperty({ + nullable: true, + description: "The associated nursery uuid" + }) + nurseryUuid: string | null; + + @ApiProperty({ + nullable: true, + description: "The associated organisation name" + }) + organisationName: string | null; + + @ApiProperty({ + nullable: true, + description: "The associated organisation uuid" + }) + organisationUuid: string | null; + + @ApiProperty() + dueAt: Date | null; + + @ApiProperty() + status: string; + + @ApiProperty() + updateRequestStatus: string; + + @ApiProperty({ nullable: true }) + feedback: string | null; + + @ApiProperty({ nullable: true }) + feedbackFields: string[] | null; + + @ApiProperty() + nothingToReport: boolean; + + @ApiProperty({ nullable: true }) + completion: number | null; + + @ApiProperty({ nullable: true }) + title: string | null; + + @ApiProperty({ nullable: true }) + seedlingsYoungTrees: number | null; + + @ApiProperty({ nullable: true }) + interestingFacts: string | null; + + @ApiProperty({ nullable: true }) + sitePrep: string | null; + + @ApiProperty({ nullable: true }) + sharedDriveLink: string | null; + + @ApiProperty({ nullable: true }) + createdByFirstName: string | null; + + @ApiProperty({ nullable: true }) + createdByLastName: string | null; + + @ApiProperty({ nullable: true }) + approvedByFirstName: string | null; + + @ApiProperty({ nullable: true }) + approvedByLastName: string | null; + + @ApiProperty({ + nullable: true, + description: "The associated project name" + }) + projectName: string | null; + + @ApiProperty({ + nullable: true, + description: "The associated project uuid" + }) + projectUuid: string | null; + + @ApiProperty({ + nullable: true, + description: "The associated task uuid" + }) + taskUuid: string | null; + + @ApiProperty({ nullable: true }) + submittedAt: Date | null; + + @ApiProperty() + migrated: boolean; + + @ApiProperty() + createdAt: Date; + + @ApiProperty() + updatedAt: Date; + + @ApiProperty({ type: () => MediaDto, isArray: true }) + file: MediaDto[]; + + @ApiProperty({ type: () => MediaDto, isArray: true }) + otherAdditionalDocuments: MediaDto[]; + + @ApiProperty({ type: () => MediaDto, isArray: true }) + treeSeedlingContributions: MediaDto[]; + + @ApiProperty({ type: () => MediaDto, isArray: true }) + photos: MediaDto[]; +} diff --git a/apps/entity-service/src/entities/entities.controller.ts b/apps/entity-service/src/entities/entities.controller.ts index cd33db7ac..789520b81 100644 --- a/apps/entity-service/src/entities/entities.controller.ts +++ b/apps/entity-service/src/entities/entities.controller.ts @@ -23,6 +23,7 @@ import { ProjectReportFullDto, ProjectReportLightDto } from "./dto/project-repor import { NurseryFullDto, NurseryLightDto } from "./dto/nursery.dto"; import { EntityModel } from "@terramatch-microservices/database/constants/entities"; import { JsonApiDeletedResponse } from "@terramatch-microservices/common/decorators/json-api-response.decorator"; +import { NurseryReportFullDto, NurseryReportLightDto } from "./dto/nursery-report.dto"; @Controller("entities/v3") @ApiExtraModels(ANRDto, ProjectApplicationDto, MediaDto) @@ -38,7 +39,8 @@ export class EntitiesController { { data: ProjectLightDto, pagination: "number" }, { data: SiteLightDto, pagination: "number" }, { data: NurseryLightDto, pagination: "number" }, - { data: ProjectReportLightDto, pagination: "number" } + { data: ProjectReportLightDto, pagination: "number" }, + { data: NurseryReportLightDto, pagination: "number" } ]) @ExceptionResponse(BadRequestException, { description: "Query params invalid" }) async entityIndex(@Param() { entity }: EntityIndexParamsDto, @Query() query: EntityQueryDto) { @@ -69,7 +71,14 @@ export class EntitiesController { operationId: "entityGet", summary: "Get a single full entity resource by UUID" }) - @JsonApiResponse([ProjectFullDto, SiteFullDto, NurseryFullDto, ProjectReportFullDto]) + @JsonApiResponse([ + ProjectFullDto, + SiteFullDto, + NurseryFullDto, + NurseryFullDto, + ProjectReportFullDto, + NurseryReportFullDto + ]) @ExceptionResponse(UnauthorizedException, { description: "Authentication failed, or resource unavailable to current user." }) diff --git a/apps/entity-service/src/entities/entities.service.ts b/apps/entity-service/src/entities/entities.service.ts index 8d3cbcf8e..1aeeaeeb3 100644 --- a/apps/entity-service/src/entities/entities.service.ts +++ b/apps/entity-service/src/entities/entities.service.ts @@ -20,13 +20,15 @@ import { UuidModel } from "@terramatch-microservices/database/types/util"; import { SeedingDto } from "./dto/seeding.dto"; import { TreeSpeciesDto } from "./dto/tree-species.dto"; import { DemographicDto } from "./dto/demographic.dto"; +import { NurseryReportProcessor } from "./processors/nursery-report.processor"; // The keys of this array must match the type in the resulting DTO. const ENTITY_PROCESSORS = { projects: ProjectProcessor, sites: SiteProcessor, nurseries: NurseryProcessor, - projectReports: ProjectReportProcessor + projectReports: ProjectReportProcessor, + nurseryReports: NurseryReportProcessor }; export type ProcessableEntity = keyof typeof ENTITY_PROCESSORS; diff --git a/apps/entity-service/src/entities/processors/nursery-report.processor.spec.ts b/apps/entity-service/src/entities/processors/nursery-report.processor.spec.ts new file mode 100644 index 000000000..d7454d9d9 --- /dev/null +++ b/apps/entity-service/src/entities/processors/nursery-report.processor.spec.ts @@ -0,0 +1,405 @@ +import { NurseryReport } from "@terramatch-microservices/database/entities"; +import { Test } from "@nestjs/testing"; +import { MediaService } from "@terramatch-microservices/common/media/media.service"; +import { createMock } from "@golevelup/ts-jest"; +import { EntitiesService } from "../entities.service"; +import { reverse, sortBy } from "lodash"; +import { EntityQueryDto } from "../dto/entity-query.dto"; +import { + NurseryFactory, + NurseryReportFactory, + OrganisationFactory, + ProjectFactory, + ProjectUserFactory, + UserFactory +} from "@terramatch-microservices/database/factories"; +import { buildJsonApi } from "@terramatch-microservices/common/util"; +import { BadRequestException } from "@nestjs/common/exceptions/bad-request.exception"; +import { DateTime } from "luxon"; +import { NurseryReportProcessor } from "./nursery-report.processor"; +import { NurseryReportFullDto, NurseryReportLightDto } from "../dto/nursery-report.dto"; + +describe("NurseryReportProcessor", () => { + let processor: NurseryReportProcessor; + let userId: number; + + beforeAll(async () => { + userId = (await UserFactory.create()).id; + }); + + beforeEach(async () => { + await NurseryReport.truncate(); + + const module = await Test.createTestingModule({ + providers: [{ provide: MediaService, useValue: createMock() }, EntitiesService] + }).compile(); + + processor = module.get(EntitiesService).createEntityProcessor("nurseryReports") as NurseryReportProcessor; + }); + + describe("should return a list of nursery reports when findMany is called with valid parameters", () => { + async function expectNurseryReports( + expected: NurseryReport[], + query: Omit, + { + permissions = [], + sortField = "id", + sortUp = true, + total = expected.length + }: { permissions?: string[]; sortField?: string; sortUp?: boolean; total?: number } = {} + ) { + const { models, paginationTotal } = await processor.findMany(query as EntityQueryDto, userId, permissions); + expect(models.length).toBe(expected.length); + expect(paginationTotal).toBe(total); + + const sorted = sortBy(expected, sortField); + if (!sortUp) reverse(sorted); + expect(models.map(({ id }) => id)).toEqual(sorted.map(({ id }) => id)); + } + + it("should returns nursery reports", async () => { + const project = await ProjectFactory.create(); + const nursery = await NurseryFactory.create({ projectId: project.id }); + await ProjectUserFactory.create({ userId, projectId: project.id }); + const managedNurseryReports = await NurseryReportFactory.createMany(3, { nurseryId: nursery.id }); + await NurseryReportFactory.createMany(5); + await expectNurseryReports(managedNurseryReports, {}, { permissions: ["manage-own"] }); + }); + + it("should returns managed nursery reports", async () => { + const project = await ProjectFactory.create(); + await ProjectUserFactory.create({ userId, projectId: project.id, isMonitoring: false, isManaging: true }); + await ProjectFactory.create(); + const nursery = await NurseryFactory.create({ projectId: project.id }); + const nurseryReports = await NurseryReportFactory.createMany(3, { nurseryId: nursery.id }); + await NurseryReportFactory.createMany(5); + await expectNurseryReports(nurseryReports, {}, { permissions: ["projects-manage"] }); + }); + + it("should returns framework nursery reports", async () => { + const nurseryReports = await NurseryReportFactory.createMany(3, { frameworkKey: "hbf" }); + await NurseryReportFactory.createMany(3, { frameworkKey: "ppc" }); + for (const p of await NurseryReportFactory.createMany(3, { frameworkKey: "terrafund" })) { + nurseryReports.push(p); + } + + await expectNurseryReports(nurseryReports, {}, { permissions: ["framework-hbf", "framework-terrafund"] }); + }); + + it("should return nursery reports that match the search term", async () => { + const org1 = await OrganisationFactory.create({ name: "A Org" }); + const org2 = await OrganisationFactory.create({ name: "B Org" }); + const project1 = await ProjectFactory.create({ name: "Foo Bar", organisationId: org1.id }); + const project2 = await ProjectFactory.create({ name: "Baz Foo", organisationId: org2.id }); + const nursery1 = await NurseryFactory.create({ projectId: project1.id }); + const nursery2 = await NurseryFactory.create({ projectId: project2.id }); + nursery1.project = await nursery1.$get("project"); + nursery2.project = await nursery2.$get("project"); + project1.organisation = await project1.$get("organisation"); + project2.organisation = await project2.$get("organisation"); + const nurseryReport1 = await NurseryReportFactory.create({ nurseryId: nursery1.id }); + const nurseryReport2 = await NurseryReportFactory.create({ nurseryId: nursery2.id }); + nurseryReport1.nursery = await nurseryReport1.$get("nursery"); + nurseryReport2.nursery = await nurseryReport2.$get("nursery"); + await NurseryReportFactory.createMany(3); + + await expectNurseryReports([nurseryReport1, nurseryReport2], { search: "foo" }); + + await expectNurseryReports([nurseryReport1, nurseryReport2], { search: "org" }); + }); + + it("should return nursery reports filtered by the status, update request status, nursery, country, organisation", async () => { + const org1 = await OrganisationFactory.create(); + const org2 = await OrganisationFactory.create(); + const p1 = await ProjectFactory.create({ country: "MX", organisationId: org1.id }); + const p2 = await ProjectFactory.create({ country: "CA", organisationId: org2.id }); + await ProjectUserFactory.create({ userId, projectId: p1.id }); + await ProjectUserFactory.create({ userId, projectId: p2.id }); + const n1 = await NurseryFactory.create({ projectId: p1.id }); + const n2 = await NurseryFactory.create({ projectId: p2.id }); + const first = await NurseryReportFactory.create({ + title: "first nursery report", + status: "approved", + updateRequestStatus: "awaiting-approval", + frameworkKey: "ppc", + nurseryId: n1.id + }); + const second = await NurseryReportFactory.create({ + title: "second project report", + status: "started", + updateRequestStatus: "awaiting-approval", + frameworkKey: "ppc", + nurseryId: n1.id + }); + const third = await NurseryReportFactory.create({ + title: "third project report", + status: "approved", + updateRequestStatus: "awaiting-approval", + frameworkKey: "ppc", + nurseryId: n1.id + }); + const fourth = await NurseryReportFactory.create({ + title: "fourth project report", + status: "approved", + updateRequestStatus: "approved", + frameworkKey: "terrafund", + nurseryId: n2.id + }); + + first.nursery = await first.$get("nursery"); + second.nursery = await second.$get("nursery"); + third.nursery = await third.$get("nursery"); + fourth.nursery = await fourth.$get("nursery"); + + await expectNurseryReports([first, second, third], { updateRequestStatus: "awaiting-approval" }); + + await expectNurseryReports([first, third, fourth], { status: "approved" }); + + await expectNurseryReports([fourth], { projectUuid: p2.uuid }); + + await expectNurseryReports([first, second, third], { country: "MX" }); + + await expectNurseryReports([first, second, third], { nurseryUuid: n1.uuid }); + + await expectNurseryReports([second], { status: "started", updateRequestStatus: "awaiting-approval" }); + + await expectNurseryReports([first, second, third], { projectUuid: p1.uuid }); + + await expectNurseryReports([], { projectUuid: "123" }); + }); + + it("should throw an error if the nursery uuid is not found", async () => { + await expect(processor.findMany({ nurseryUuid: "123" })).rejects.toThrow(BadRequestException); + }); + + it("should sort nursery reports by project name", async () => { + const projectA = await ProjectFactory.create({ name: "A Project" }); + const projectB = await ProjectFactory.create({ name: "B Project" }); + const projectC = await ProjectFactory.create({ name: "C Project" }); + const nurseryA = await NurseryFactory.create({ projectId: projectA.id }); + const nurseryB = await NurseryFactory.create({ projectId: projectB.id }); + const nurseryC = await NurseryFactory.create({ projectId: projectC.id }); + const nurseryReportA = await NurseryReportFactory.create({ nurseryId: nurseryA.id }); + const nurseryReportB = await NurseryReportFactory.create({ nurseryId: nurseryB.id }); + const nurseryReportC = await NurseryReportFactory.create({ nurseryId: nurseryC.id }); + await expectNurseryReports( + [nurseryReportA, nurseryReportB, nurseryReportC], + { sort: { field: "projectName" } }, + { sortField: "projectName" } + ); + await expectNurseryReports( + [nurseryReportC, nurseryReportB, nurseryReportA], + { sort: { field: "projectName", direction: "DESC" } }, + { sortField: "projectName" } + ); + }); + + it("should throw an error if the sort field is not valid", async () => { + await expect(processor.findMany({ sort: { field: "invalid" } })).rejects.toThrow(BadRequestException); + }); + + it("should sort nursery reports by organisation name", async () => { + const org1 = await OrganisationFactory.create({ name: "A Org" }); + const org2 = await OrganisationFactory.create({ name: "B Org" }); + const org3 = await OrganisationFactory.create({ name: "C Org" }); + const projectA = await ProjectFactory.create({ organisationId: org1.id }); + const projectB = await ProjectFactory.create({ organisationId: org2.id }); + const projectC = await ProjectFactory.create({ organisationId: org3.id }); + const nurseryA = await NurseryFactory.create({ projectId: projectA.id }); + const nurseryB = await NurseryFactory.create({ projectId: projectB.id }); + const nurseryC = await NurseryFactory.create({ projectId: projectC.id }); + projectA.organisation = await projectA.$get("organisation"); + projectB.organisation = await projectB.$get("organisation"); + projectC.organisation = await projectC.$get("organisation"); + + const nurseryReports = [ + await NurseryReportFactory.create({ + nurseryId: nurseryA.id + }) + ]; + nurseryReports.push( + await NurseryReportFactory.create({ + nurseryId: nurseryB.id + }) + ); + nurseryReports.push( + await NurseryReportFactory.create({ + nurseryId: nurseryC.id + }) + ); + for (const n of nurseryReports) { + n.nursery = await n.$get("nursery"); + } + + await expectNurseryReports( + nurseryReports, + { sort: { field: "organisationName" } }, + { sortField: "organisationName" } + ); + await expectNurseryReports( + nurseryReports, + { sort: { field: "organisationName", direction: "DESC" } }, + { sortField: "organisationName", sortUp: false } + ); + }); + + it("should sort nursery reports by due date", async () => { + const nurseryReportA = await NurseryReportFactory.create({ dueAt: DateTime.now().minus({ days: 1 }).toJSDate() }); + const nurseryReportB = await NurseryReportFactory.create({ + dueAt: DateTime.now().minus({ days: 10 }).toJSDate() + }); + const nurseryReportC = await NurseryReportFactory.create({ dueAt: DateTime.now().minus({ days: 5 }).toJSDate() }); + await expectNurseryReports( + [nurseryReportA, nurseryReportC, nurseryReportB], + { sort: { field: "dueAt", direction: "DESC" } }, + { sortField: "dueAt", sortUp: false } + ); + await expectNurseryReports( + [nurseryReportB, nurseryReportC, nurseryReportA], + { sort: { field: "dueAt", direction: "ASC" } }, + { sortField: "dueAt", sortUp: true } + ); + }); + + it("should sort nursery reports by submitted at", async () => { + const now = DateTime.now(); + const nurseryReportA = await NurseryReportFactory.create({ + submittedAt: now.minus({ minutes: 1 }).toJSDate() + }); + const nurseryReportB = await NurseryReportFactory.create({ + submittedAt: now.minus({ minutes: 10 }).toJSDate() + }); + const nurseryReportC = await NurseryReportFactory.create({ + submittedAt: now.minus({ minutes: 5 }).toJSDate() + }); + await expectNurseryReports( + [nurseryReportA, nurseryReportC, nurseryReportB], + { sort: { field: "submittedAt", direction: "DESC" } }, + { sortField: "submittedAt", sortUp: false } + ); + await expectNurseryReports( + [nurseryReportB, nurseryReportC, nurseryReportA], + { sort: { field: "submittedAt", direction: "ASC" } }, + { sortField: "submittedAt", sortUp: true } + ); + }); + + it("should sort nursery reports by updated at", async () => { + const nurseryReportA = await NurseryReportFactory.create(); + nurseryReportA.updatedAt = DateTime.now().minus({ days: 1 }).toJSDate(); + + await expectNurseryReports([nurseryReportA], { sort: { field: "updatedAt" } }, { sortField: "updatedAt" }); + await expectNurseryReports( + [nurseryReportA], + { sort: { field: "updatedAt", direction: "DESC" } }, + { sortField: "updatedAt", sortUp: false } + ); + }); + + it("should sort nursery reports by update request status", async () => { + const nurseryReportA = await NurseryReportFactory.create({ updateRequestStatus: "awaiting-approval" }); + const nurseryReportB = await NurseryReportFactory.create({ updateRequestStatus: "awaiting-approval" }); + const nurseryReportC = await NurseryReportFactory.create({ updateRequestStatus: "awaiting-approval" }); + await expectNurseryReports( + [nurseryReportA, nurseryReportB, nurseryReportC], + { sort: { field: "updateRequestStatus" } }, + { sortField: "updateRequestStatus" } + ); + await expectNurseryReports( + [nurseryReportA, nurseryReportB, nurseryReportC], + { sort: { field: "updateRequestStatus", direction: "ASC" } }, + { sortField: "updateRequestStatus" } + ); + await expectNurseryReports( + [nurseryReportC, nurseryReportB, nurseryReportA], + { sort: { field: "updateRequestStatus", direction: "DESC" } }, + { sortField: "updateRequestStatus", sortUp: false } + ); + }); + + it("should sort nursery reports by status", async () => { + const nurseryReportA = await NurseryReportFactory.create({ status: "started" }); + const nurseryReportB = await NurseryReportFactory.create({ status: "approved" }); + const nurseryReportC = await NurseryReportFactory.create({ status: "approved" }); + await expectNurseryReports( + [nurseryReportA, nurseryReportB, nurseryReportC], + { sort: { field: "status" } }, + { sortField: "status" } + ); + await expectNurseryReports( + [nurseryReportA, nurseryReportB, nurseryReportC], + { sort: { field: "status", direction: "ASC" } }, + { sortField: "status" } + ); + await expectNurseryReports( + [nurseryReportC, nurseryReportB, nurseryReportA], + { sort: { field: "status", direction: "DESC" } }, + { sortField: "status", sortUp: false } + ); + }); + + it("should return an empty list when there are no matches in the search", async () => { + await NurseryReportFactory.createMany(3, { title: "foo" }); + await expectNurseryReports([], { search: "test" }); + }); + + it("should paginate nursery reports", async () => { + const nurseryReports = sortBy(await NurseryReportFactory.createMany(25), "id"); + await expectNurseryReports(nurseryReports.slice(0, 10), { page: { size: 10 } }, { total: nurseryReports.length }); + await expectNurseryReports( + nurseryReports.slice(10, 20), + { page: { size: 10, number: 2 } }, + { total: nurseryReports.length } + ); + await expectNurseryReports( + nurseryReports.slice(20), + { page: { size: 10, number: 3 } }, + { total: nurseryReports.length } + ); + }); + }); + + describe("should return a requested nursery report when findOne is called with a valid uuid", () => { + it("should return a requested nursery report", async () => { + const nurseryReport = await NurseryReportFactory.create(); + const result = await processor.findOne(nurseryReport.uuid); + expect(result.id).toBe(nurseryReport.id); + }); + }); + + describe("should properly map the nursery report data into its respective DTOs", () => { + it("should serialize a Nursery Report as a light resource (NurseryReportLightDto)", async () => { + const { uuid } = await NurseryReportFactory.create(); + const nurseryReport = await processor.findOne(uuid); + const document = buildJsonApi(NurseryReportLightDto, { forceDataArray: true }); + await processor.addLightDto(document, nurseryReport); + const attributes = document.serialize().data[0].attributes as NurseryReportLightDto; + expect(attributes).toMatchObject({ + uuid, + lightResource: true + }); + }); + + it("should include calculated fields in NurseryReportFullDto", async () => { + const project = await ProjectFactory.create(); + const nursery = await NurseryFactory.create({ projectId: project.id }); + + const { uuid } = await NurseryReportFactory.create({ + nurseryId: nursery.id, + dueAt: null + }); + + const nurseryReport = await processor.findOne(uuid); + const document = buildJsonApi(NurseryReportFullDto, { forceDataArray: true }); + await processor.addFullDto(document, nurseryReport); + const attributes = document.serialize().data[0].attributes as NurseryReportFullDto; + expect(attributes).toMatchObject({ + uuid, + lightResource: false, + projectUuid: project.uuid, + nurseryUuid: nursery.uuid, + dueAt: null + }); + }); + }); +}); diff --git a/apps/entity-service/src/entities/processors/nursery-report.processor.ts b/apps/entity-service/src/entities/processors/nursery-report.processor.ts new file mode 100644 index 000000000..653df4050 --- /dev/null +++ b/apps/entity-service/src/entities/processors/nursery-report.processor.ts @@ -0,0 +1,174 @@ +import { Media, Nursery, NurseryReport, ProjectReport, ProjectUser } from "@terramatch-microservices/database/entities"; +import { EntityProcessor } from "./entity-processor"; +import { EntityQueryDto } from "../dto/entity-query.dto"; +import { DocumentBuilder } from "@terramatch-microservices/common/util"; +import { Includeable, Op } from "sequelize"; +import { BadRequestException } from "@nestjs/common"; +import { FrameworkKey } from "@terramatch-microservices/database/constants/framework"; +import { AdditionalNurseryReportFullProps, NurseryReportFullDto, NurseryReportMedia } from "../dto/nursery-report.dto"; +import { NurseryReportLightDto } from "../dto/nursery-report.dto"; + +export class NurseryReportProcessor extends EntityProcessor< + NurseryReport, + NurseryReportLightDto, + NurseryReportFullDto +> { + readonly LIGHT_DTO = NurseryReportLightDto; + readonly FULL_DTO = NurseryReportFullDto; + + async findOne(uuid: string): Promise { + return await NurseryReport.findOne({ + where: { uuid }, + include: [ + { + association: "nursery", + attributes: ["id", "uuid", "name"], + include: [ + { + association: "project", + attributes: ["id", "uuid", "name"], + include: [{ association: "organisation", attributes: ["uuid", "name"] }] + } + ] + }, + { + association: "task", + attributes: ["uuid"] + }, + { + association: "createdByUser", + attributes: ["id", "uuid", "firstName", "lastName"] + }, + { + association: "approvedByUser", + attributes: ["id", "uuid", "firstName", "lastName"] + } + ] + }); + } + + async findMany(query: EntityQueryDto, userId?: number, permissions?: string[]) { + const nurseryAssociation: Includeable = { + association: "nursery", + attributes: ["id", "uuid", "name"], + include: [ + { + association: "project", + attributes: ["id", "uuid", "name"], + include: [{ association: "organisation", attributes: ["id", "uuid", "name"] }] + } + ] + }; + + const builder = await this.entitiesService.buildQuery(NurseryReport, query, [nurseryAssociation]); + if (query.sort != null) { + if (["dueAt", "submittedAt", "updatedAt", "status", "updateRequestStatus"].includes(query.sort.field)) { + builder.order([query.sort.field, query.sort.direction ?? "ASC"]); + } else if (query.sort.field === "organisationName") { + builder.order(["nursery", "project", "organisation", "name", query.sort.direction ?? "ASC"]); + } else if (query.sort.field === "projectName") { + builder.order(["nursery", "project", "name", query.sort.direction ?? "ASC"]); + } else if (query.sort.field !== "id") { + throw new BadRequestException(`Invalid sort field: ${query.sort.field}`); + } + } + + const frameworkPermissions = permissions + ?.filter(name => name.startsWith("framework-")) + .map(name => name.substring("framework-".length) as FrameworkKey); + if (frameworkPermissions?.length > 0) { + builder.where({ frameworkKey: { [Op.in]: frameworkPermissions } }); + } else if (permissions?.includes("manage-own")) { + builder.where({ "$nursery.project.id$": { [Op.in]: ProjectUser.userProjectsSubquery(userId) } }); + } else if (permissions?.includes("projects-manage")) { + builder.where({ "$nursery.project.id$": { [Op.in]: ProjectUser.projectsManageSubquery(userId) } }); + } + + const associationFieldMap = { + nurseryUuid: "$nursery.uuid$", + organisationUuid: "$nursery.project.organisation.uuid$", + country: "$nursery.project.country$", + projectUuid: "$nursery.project.uuid$" + }; + + const termsToFilter = [ + "status", + "updateRequestStatus", + "frameworkKey", + "nurseryUuid", + "organisationUuid", + "country", + "projectUuid" + ]; + + termsToFilter.forEach(term => { + const field = associationFieldMap[term] ?? term; + if (query[term] != null) { + builder.where({ [field]: query[term] }); + } + }); + + if (query.search != null) { + builder.where({ + [Op.or]: [ + { "$nursery.project.name$": { [Op.like]: `%${query.search}%` } }, + { "$nursery.project.organisation.name$": { [Op.like]: `%${query.search}%` } } + ] + }); + } + + if (query.nurseryUuid != null) { + const nursery = await Nursery.findOne({ where: { uuid: query.nurseryUuid }, attributes: ["id"] }); + if (nursery == null) { + throw new BadRequestException(`Nursery with uuid ${query.nurseryUuid} not found`); + } + builder.where({ nurseryId: nursery.id }); + } + + return { models: await builder.execute(), paginationTotal: await builder.paginationTotal() }; + } + + async addFullDto(document: DocumentBuilder, nurseryReport: NurseryReport): Promise { + const nurseryReportId = nurseryReport.id; + const mediaCollection = await Media.nurseryReport(nurseryReportId).findAll(); + const reportTitle = await this.getReportTitle(nurseryReport); + const projectReportTitle = await this.getProjectReportTitle(nurseryReport); + const migrated = nurseryReport.oldModel != null; + const props: AdditionalNurseryReportFullProps = { + reportTitle, + projectReportTitle, + migrated, + ...(this.entitiesService.mapMediaCollection(mediaCollection, NurseryReport.MEDIA) as NurseryReportMedia) + }; + + document.addData(nurseryReport.uuid, new NurseryReportFullDto(nurseryReport, props)); + } + + async addLightDto(document: DocumentBuilder, nurseryReport: NurseryReport): Promise { + const reportTitle = await this.getReportTitle(nurseryReport); + document.addData(nurseryReport.uuid, new NurseryReportLightDto(nurseryReport, { reportTitle })); + } + + protected async getReportTitleBase(dueAt: Date | null, title: string | null, locale: string | null) { + if (dueAt == null) return title ?? ""; + + const adjustedDate = new Date(dueAt); + adjustedDate.setMonth(adjustedDate.getMonth() - 1); + const wEnd = adjustedDate.toLocaleString(locale, { month: "long", year: "numeric" }); + + adjustedDate.setMonth(adjustedDate.getMonth() - 5); + const wStart = adjustedDate.toLocaleString(locale, { month: "long" }); + + return `Nursery Report for ${wStart} - ${wEnd}`; + } + + protected async getReportTitle(nurseryReport: NurseryReport) { + return this.getReportTitleBase(nurseryReport.dueAt, nurseryReport.title, nurseryReport.user?.locale ?? "en-GB"); + } + + protected async getProjectReportTitle(nurseryReport: NurseryReport) { + const projectReport = await ProjectReport.findOne({ where: { taskId: nurseryReport.taskId } }); + + return this.getReportTitleBase(projectReport.dueAt, projectReport.title, projectReport.user?.locale ?? "en-GB"); + } +} diff --git a/apps/entity-service/src/entities/processors/nursery.processor.ts b/apps/entity-service/src/entities/processors/nursery.processor.ts index 9eb81fd0e..ada799527 100644 --- a/apps/entity-service/src/entities/processors/nursery.processor.ts +++ b/apps/entity-service/src/entities/processors/nursery.processor.ts @@ -75,10 +75,10 @@ export class NurseryProcessor extends EntityProcessor ({ order: ["orderColumn"] })) @@ -40,6 +41,12 @@ import { ProjectReport } from "./project-report.entity"; modelId: id } }), + nurseryReport: (id: number) => ({ + where: { + modelType: NurseryReport.LARAVEL_TYPE, + modelId: id + } + }), projectReport: (id: number) => ({ where: { modelType: ProjectReport.LARAVEL_TYPE, @@ -73,6 +80,10 @@ export class Media extends Model { return chainScope(this, "nursery", id) as typeof Media; } + static nurseryReport(id: number) { + return chainScope(this, "nurseryReport", id) as typeof Media; + } + static projectReport(id: number) { return chainScope(this, "projectReport", id) as typeof Media; } diff --git a/libs/database/src/lib/entities/nursery-report.entity.ts b/libs/database/src/lib/entities/nursery-report.entity.ts index 323aa4e31..3288100ff 100644 --- a/libs/database/src/lib/entities/nursery-report.entity.ts +++ b/libs/database/src/lib/entities/nursery-report.entity.ts @@ -11,7 +11,7 @@ import { Scopes, Table } from "sequelize-typescript"; -import { BIGINT, DATE, INTEGER, Op, STRING, UUID } from "sequelize"; +import { BIGINT, DATE, INTEGER, Op, STRING, TEXT, TINYINT, UUID } from "sequelize"; import { Nursery } from "./nursery.entity"; import { TreeSpecies } from "./tree-species.entity"; import { COMPLETE_REPORT_STATUSES, ReportStatus, UpdateRequestStatus } from "../constants/status"; @@ -19,6 +19,9 @@ import { FrameworkKey } from "../constants/framework"; import { Literal } from "sequelize/types/utils"; import { chainScope } from "../util/chain-scope"; import { Subquery } from "../util/subquery.builder"; +import { User } from "./user.entity"; +import { JsonColumn } from "../decorators/json-column.decorator"; +import { Task } from "./task.entity"; // Incomplete stub @Scopes(() => ({ @@ -34,6 +37,13 @@ export class NurseryReport extends Model { static readonly PARENT_ID = "nurseryId"; static readonly LARAVEL_TYPE = "App\\Models\\V2\\Nurseries\\NurseryReport"; + static readonly MEDIA = { + file: { dbCollection: "file", multiple: true }, + otherAdditionalDocuments: { dbCollection: "other_additional_documents", multiple: true }, + treeSeedlingContributions: { dbCollection: "tree_seedling_contributions", multiple: true }, + photos: { dbCollection: "photos", multiple: true } + } as const; + static incomplete() { return chainScope(this, "incomplete") as typeof NurseryReport; } @@ -73,10 +83,74 @@ export class NurseryReport extends Model { @Column(BIGINT.UNSIGNED) nurseryId: number; + @ForeignKey(() => User) + @Column(BIGINT.UNSIGNED) + createdBy: number; + + @ForeignKey(() => User) + @Column(BIGINT.UNSIGNED) + approvedBy: number; + @BelongsTo(() => Nursery) nursery: Nursery | null; - // TODO foreign key for task + @BelongsTo(() => User) + user: User | null; + + @BelongsTo(() => User, { foreignKey: "createdBy", as: "createdByUser" }) + createdByUser: User | null; + + @BelongsTo(() => User, { foreignKey: "approvedBy", as: "approvedByUser" }) + approvedByUser: User | null; + + @BelongsTo(() => Task) + task: Task | null; + + get projectName() { + return this.nursery?.project?.name; + } + + get projectUuid() { + return this.nursery?.project?.uuid; + } + + get organisationName() { + return this.nursery?.project?.organisationName; + } + + get organisationUuid() { + return this.nursery?.project?.organisationUuid; + } + + get nurseryName() { + return this.nursery?.name; + } + + get nurseryUuid() { + return this.nursery?.uuid; + } + + get taskUuid() { + return this.task?.uuid; + } + + get createdByFirstName() { + return this.createdByUser?.firstName; + } + + get createdByLastName() { + return this.createdByUser?.lastName; + } + + get approvedByFirstName() { + return this.approvedByUser?.firstName; + } + + get approvedByLastName() { + return this.approvedByUser?.lastName; + } + + @ForeignKey(() => Task) @AllowNull @Column(BIGINT.UNSIGNED) taskId: number; @@ -96,6 +170,54 @@ export class NurseryReport extends Model { @Column(INTEGER.UNSIGNED) seedlingsYoungTrees: number | null; + @AllowNull + @Column(TINYINT) + nothingToReport: boolean; + + @AllowNull + @Column(TEXT) + feedback: string | null; + + @AllowNull + @JsonColumn() + feedbackFields: string[] | null; + + @AllowNull + @Column(DATE) + submittedAt: Date | null; + + @AllowNull + @Column(INTEGER) + completion: number | null; + + @AllowNull + @Column(STRING) + title: string | null; + + @AllowNull + @Column(TEXT) + interestingFacts: string | null; + + @AllowNull + @Column(TEXT) + sitePrep: string | null; + + @AllowNull + @Column(TEXT) + sharedDriveLink: string | null; + + @AllowNull + @Column(STRING) + oldModel: string | null; + + @AllowNull + @Column(INTEGER.UNSIGNED) + oldId: number | null; + + @AllowNull + @Column(TEXT("long")) + answers: string | null; + @HasMany(() => TreeSpecies, { foreignKey: "speciesableId", constraints: false, diff --git a/libs/database/src/lib/entities/project.entity.ts b/libs/database/src/lib/entities/project.entity.ts index 6410b7216..3f15eea27 100644 --- a/libs/database/src/lib/entities/project.entity.ts +++ b/libs/database/src/lib/entities/project.entity.ts @@ -367,6 +367,10 @@ export class Project extends Model { return this.organisation?.name; } + get organisationUuid() { + return this.organisation?.uuid; + } + @BelongsTo(() => Application) application: Application | null;