diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 425ea18..e968eb1 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -46,7 +46,7 @@ jobs: # in a clean way. For some reason, the `run-many` is necessary here. If this line simply uses # nx test database, the connection to the DB gets cut off before the sync is complete. - name: Sync DB Schema - run: NX_CLOUD_DISTRIBUTED_EXECUTION=false npx nx test database + run: NX_CLOUD_DISTRIBUTED_EXECUTION=false npx nx test database --no-cache - name: Test all run: NX_CLOUD_DISTRIBUTED_EXECUTION=false npx nx run-many -t test --coverage --passWithNoTests diff --git a/apps/research-service/src/site-polygons/dto/indicators.dto.ts b/apps/research-service/src/site-polygons/dto/indicators.dto.ts index f6be989..e161379 100644 --- a/apps/research-service/src/site-polygons/dto/indicators.dto.ts +++ b/apps/research-service/src/site-polygons/dto/indicators.dto.ts @@ -1,13 +1,16 @@ import { ApiProperty } from "@nestjs/swagger"; import { INDICATORS } from "@terramatch-microservices/database/constants"; +import { IsInt, IsNotEmpty, IsNumber, IsOptional, IsString } from "class-validator"; export class IndicatorTreeCoverLossDto { @ApiProperty({ enum: [INDICATORS[2], INDICATORS[3]] }) indicatorSlug: (typeof INDICATORS)[2] | (typeof INDICATORS)[3]; - @ApiProperty({ example: "2024" }) + @IsInt() + @ApiProperty({ example: 2024 }) yearOfAnalysis: number; + @IsNotEmpty() @ApiProperty({ type: "object", description: "Mapping of year of analysis to value.", @@ -20,9 +23,11 @@ export class IndicatorHectaresDto { @ApiProperty({ enum: [INDICATORS[4], INDICATORS[5], INDICATORS[6]] }) indicatorSlug: (typeof INDICATORS)[4] | (typeof INDICATORS)[5] | (typeof INDICATORS)[6]; + @IsInt() @ApiProperty({ example: "2024" }) yearOfAnalysis: number; + @IsNotEmpty() @ApiProperty({ type: "object", description: "Mapping of area type (eco region, land use, etc) to hectares", @@ -30,35 +35,51 @@ export class IndicatorHectaresDto { }) value: Record; } - export class IndicatorTreeCountDto { @ApiProperty({ enum: [INDICATORS[7], INDICATORS[8]] }) indicatorSlug: (typeof INDICATORS)[7] | (typeof INDICATORS)[8]; + @IsInt() @ApiProperty({ example: "2024" }) yearOfAnalysis: number; + @IsString() + @IsOptional() @ApiProperty() surveyType: string | null; + @IsNumber() + @IsOptional() @ApiProperty() surveyId: number | null; + @IsNumber() + @IsOptional() @ApiProperty() treeCount: number | null; + @IsString() + @IsOptional() @ApiProperty({ example: "types TBD" }) uncertaintyType: string | null; + @IsString() + @IsOptional() @ApiProperty() imagerySource: string | null; + @IsString() + @IsOptional() @ApiProperty({ type: "url" }) imageryId: string | null; + @IsString() + @IsOptional() @ApiProperty() projectPhase: string | null; + @IsNumber() + @IsOptional() @ApiProperty() confidence: number | null; } @@ -67,15 +88,22 @@ export class IndicatorTreeCoverDto { @ApiProperty({ enum: [INDICATORS[1]] }) indicatorSlug: (typeof INDICATORS)[1]; + @IsInt() @ApiProperty({ example: "2024" }) yearOfAnalysis: number; + @IsString() + @IsOptional() @ApiProperty({ example: "2024" }) projectPhase: string | null; + @IsNumber() + @IsOptional() @ApiProperty() percentCover: number | null; + @IsNumber() + @IsOptional() @ApiProperty() plusMinusPercent: number | null; } @@ -84,18 +112,27 @@ export class IndicatorFieldMonitoringDto { @ApiProperty({ enum: [INDICATORS[9]] }) indicatorSlug: (typeof INDICATORS)[9]; + @IsInt() @ApiProperty({ example: "2024" }) yearOfAnalysis: number; + @IsNumber() + @IsOptional() @ApiProperty() treeCount: number | null; + @IsString() + @IsOptional() @ApiProperty() projectPhase: string | null; + @IsString() + @IsOptional() @ApiProperty() species: string | null; + @IsNumber() + @IsOptional() @ApiProperty() survivalRate: number | null; } @@ -104,28 +141,35 @@ export class IndicatorMsuCarbonDto { @ApiProperty({ enum: [INDICATORS[10]] }) indicatorSlug: (typeof INDICATORS)[10]; + @IsInt() @ApiProperty({ example: "2024" }) yearOfAnalysis: number; + @IsNumber() + @IsOptional() @ApiProperty() carbonOutput: number | null; + @IsString() + @IsOptional() @ApiProperty() projectPhase: string | null; + @IsNumber() + @IsOptional() @ApiProperty() confidence: number | null; } export const INDICATOR_DTOS = { - [INDICATORS[1]]: IndicatorTreeCoverDto.prototype, - [INDICATORS[2]]: IndicatorTreeCoverLossDto.prototype, - [INDICATORS[3]]: IndicatorTreeCoverLossDto.prototype, - [INDICATORS[4]]: IndicatorHectaresDto.prototype, - [INDICATORS[5]]: IndicatorHectaresDto.prototype, - [INDICATORS[6]]: IndicatorHectaresDto.prototype, - [INDICATORS[7]]: IndicatorTreeCountDto.prototype, - [INDICATORS[8]]: IndicatorTreeCountDto.prototype, - [INDICATORS[9]]: IndicatorFieldMonitoringDto.prototype, - [INDICATORS[10]]: IndicatorMsuCarbonDto.prototype + [INDICATORS[1]]: IndicatorTreeCoverDto, + [INDICATORS[2]]: IndicatorTreeCoverLossDto, + [INDICATORS[3]]: IndicatorTreeCoverLossDto, + [INDICATORS[4]]: IndicatorHectaresDto, + [INDICATORS[5]]: IndicatorHectaresDto, + [INDICATORS[6]]: IndicatorHectaresDto, + [INDICATORS[7]]: IndicatorTreeCountDto, + [INDICATORS[8]]: IndicatorTreeCountDto, + [INDICATORS[9]]: IndicatorFieldMonitoringDto, + [INDICATORS[10]]: IndicatorMsuCarbonDto }; diff --git a/apps/research-service/src/site-polygons/dto/site-polygon-update.dto.ts b/apps/research-service/src/site-polygons/dto/site-polygon-update.dto.ts index 6e23a0d..2b2a0e2 100644 --- a/apps/research-service/src/site-polygons/dto/site-polygon-update.dto.ts +++ b/apps/research-service/src/site-polygons/dto/site-polygon-update.dto.ts @@ -1,48 +1,55 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { - IndicatorFieldMonitoringDto, - IndicatorHectaresDto, IndicatorMsuCarbonDto, - IndicatorTreeCountDto, IndicatorTreeCoverDto, - IndicatorTreeCoverLossDto -} from './indicators.dto'; +import { ApiProperty } from "@nestjs/swagger"; +import { IndicatorDto } from "./site-polygon.dto"; +import { Equals, IsArray, IsUUID, ValidateNested } from "class-validator"; +import { Type } from "class-transformer"; +import { INDICATOR_DTOS } from "./indicators.dto"; class SitePolygonUpdateAttributes { + @IsArray() + @ValidateNested() + @Type(() => Object, { + keepDiscriminatorProperty: true, + discriminator: { + property: "indicatorSlug", + subTypes: Object.entries(INDICATOR_DTOS).map(([name, value]) => ({ name, value })) + } + }) @ApiProperty({ - type: 'array', + type: "array", items: { oneOf: [ - { $ref: '#/components/schemas/IndicatorTreeCoverLossDto' }, - { $ref: '#/components/schemas/IndicatorHectaresDto' }, - { $ref: '#/components/schemas/IndicatorTreeCountDto' }, - { $ref: '#/components/schemas/IndicatorTreeCoverDto' }, - { $ref: '#/components/schemas/IndicatorFieldMonitoringDto' }, - { $ref: '#/components/schemas/IndicatorMsuCarbonDto' }, + { $ref: "#/components/schemas/IndicatorTreeCoverLossDto" }, + { $ref: "#/components/schemas/IndicatorHectaresDto" }, + { $ref: "#/components/schemas/IndicatorTreeCountDto" }, + { $ref: "#/components/schemas/IndicatorTreeCoverDto" }, + { $ref: "#/components/schemas/IndicatorFieldMonitoringDto" }, + { $ref: "#/components/schemas/IndicatorMsuCarbonDto" } ] }, - description: 'All indicators to update for this polygon' + description: "All indicators to update for this polygon" }) - indicators: ( - IndicatorTreeCoverLossDto | - IndicatorHectaresDto | - IndicatorTreeCountDto | - IndicatorTreeCoverDto | - IndicatorFieldMonitoringDto | - IndicatorMsuCarbonDto - )[]; + indicators: IndicatorDto[]; } class SitePolygonUpdate { - @ApiProperty({ enum: ['sitePolygons'] }) - type: 'sitePolygons'; + @Equals("sitePolygons") + @ApiProperty({ enum: ["sitePolygons"] }) + type: string; - @ApiProperty({ format: 'uuid' }) + @IsUUID() + @ApiProperty({ format: "uuid" }) id: string; + @ValidateNested() + @Type(() => SitePolygonUpdateAttributes) @ApiProperty({ type: () => SitePolygonUpdateAttributes }) attributes: SitePolygonUpdateAttributes; } export class SitePolygonBulkUpdateBodyDto { + @IsArray() + @ValidateNested() + @Type(() => SitePolygonUpdate) @ApiProperty({ isArray: true, type: () => SitePolygonUpdate }) data: SitePolygonUpdate[]; } diff --git a/apps/research-service/src/site-polygons/site-polygon-query.builder.ts b/apps/research-service/src/site-polygons/site-polygon-query.builder.ts new file mode 100644 index 0000000..bcf3fcf --- /dev/null +++ b/apps/research-service/src/site-polygons/site-polygon-query.builder.ts @@ -0,0 +1,161 @@ +import { Attributes, Filterable, FindOptions, IncludeOptions, literal, Op, WhereOptions } from "sequelize"; +import { + IndicatorOutputFieldMonitoring, + IndicatorOutputHectares, + IndicatorOutputMsuCarbon, + IndicatorOutputTreeCount, + IndicatorOutputTreeCover, + IndicatorOutputTreeCoverLoss, + PolygonGeometry, + Project, + Site, + SitePolygon, + SiteReport, + TreeSpecies +} from "@terramatch-microservices/database/entities"; +import { IndicatorSlug, PolygonStatus } from "@terramatch-microservices/database/constants"; +import { uniq } from "lodash"; +import { BadRequestException } from "@nestjs/common"; + +type IndicatorModelClass = + | typeof IndicatorOutputTreeCover + | typeof IndicatorOutputTreeCoverLoss + | typeof IndicatorOutputHectares + | typeof IndicatorOutputTreeCount + | typeof IndicatorOutputFieldMonitoring + | typeof IndicatorOutputMsuCarbon; + +export const INDICATOR_MODEL_CLASSES: { [Slug in IndicatorSlug]: IndicatorModelClass } = { + treeCover: IndicatorOutputTreeCover, + treeCoverLoss: IndicatorOutputTreeCoverLoss, + treeCoverLossFires: IndicatorOutputTreeCoverLoss, + restorationByEcoRegion: IndicatorOutputHectares, + restorationByStrategy: IndicatorOutputHectares, + restorationByLandUse: IndicatorOutputHectares, + treeCount: IndicatorOutputTreeCount, + earlyTreeVerification: IndicatorOutputTreeCount, + fieldMonitoring: IndicatorOutputFieldMonitoring, + msuCarbon: IndicatorOutputMsuCarbon +}; + +const INDICATOR_EXCLUDE_COLUMNS = ["id", "sitePolygonId", "createdAt", "updatedAt", "deletedAt"]; + +export class SitePolygonQueryBuilder { + private siteJoin: IncludeOptions = { + model: Site, + include: [ + { model: TreeSpecies, attributes: ["name", "amount"] }, + { + model: SiteReport, + include: [{ model: TreeSpecies, attributes: ["name", "amount"] }], + attributes: ["dueAt", "submittedAt"] + } + ], + attributes: ["projectId"], + required: true + }; + + private findOptions: FindOptions> = { + include: [ + { model: IndicatorOutputFieldMonitoring, attributes: { exclude: INDICATOR_EXCLUDE_COLUMNS } }, + { model: IndicatorOutputHectares, attributes: { exclude: INDICATOR_EXCLUDE_COLUMNS } }, + { model: IndicatorOutputMsuCarbon, attributes: { exclude: INDICATOR_EXCLUDE_COLUMNS } }, + { model: IndicatorOutputTreeCount, attributes: { exclude: INDICATOR_EXCLUDE_COLUMNS } }, + { model: IndicatorOutputTreeCover, attributes: { exclude: INDICATOR_EXCLUDE_COLUMNS } }, + { model: IndicatorOutputTreeCoverLoss, attributes: { exclude: INDICATOR_EXCLUDE_COLUMNS } }, + { model: PolygonGeometry, attributes: ["polygon"], required: true }, + this.siteJoin + ] + }; + + constructor(pageSize: number) { + this.findOptions.limit = pageSize; + } + + async excludeTestProjects() { + // avoid joining against the entire project table by doing a quick query first. The number of test projects is small + const testProjects = await Project.findAll({ where: { isTest: true }, attributes: ["id"] }); + this.where({ projectId: { [Op.notIn]: testProjects.map(({ id }) => id) } }, this.siteJoin); + return this; + } + + async filterProjectUuids(projectUuids: string[]) { + const filterProjects = await Project.findAll({ + where: { uuid: { [Op.in]: projectUuids } }, + attributes: ["id"] + }); + this.where({ projectId: { [Op.in]: filterProjects.map(({ id }) => id) } }, this.siteJoin); + return this; + } + + hasStatuses(polygonStatuses?: PolygonStatus[]) { + if (polygonStatuses != null) this.where({ status: { [Op.in]: polygonStatuses } }); + return this; + } + + modifiedSince(date?: Date) { + if (date != null) this.where({ updatedAt: { [Op.gte]: date } }); + return this; + } + + isMissingIndicators(indicatorSlugs?: IndicatorSlug[]) { + if (indicatorSlugs != null) { + const literals = uniq(indicatorSlugs).map(slug => { + const table = INDICATOR_MODEL_CLASSES[slug]?.tableName; + if (table == null) throw new BadRequestException(`Unrecognized indicator slug: ${slug}`); + + return literal( + `(SELECT COUNT(*) = 0 from ${table} WHERE indicator_slug = "${slug}" AND site_polygon_id = SitePolygon.id)` + ); + }); + this.where({ [Op.and]: literals }); + } + return this; + } + + async touchesBoundary(polygonUuid?: string) { + if (polygonUuid != null) { + // This check isn't strictly necessary for constructing the query, but we do want to throw a useful + // error to the caller if the polygonUuid doesn't exist, and simply mixing it into the query won't + // do it + if ((await PolygonGeometry.count({ where: { uuid: polygonUuid } })) === 0) { + throw new BadRequestException(`Unrecognized polygon UUID: ${polygonUuid}`); + } + + this.where({ + [Op.and]: [ + literal( + `(SELECT ST_INTERSECTS(polygon.geom, (SELECT geom FROM polygon_geometry WHERE uuid = "${polygonUuid}")))` + ) + ] + }); + } + return this; + } + + async pageAfter(pageAfter: string) { + const sitePolygon = await SitePolygon.findOne({ + where: { uuid: pageAfter }, + attributes: ["id"] + }); + if (sitePolygon == null) throw new BadRequestException("pageAfter polygon not found"); + this.where({ id: { [Op.gt]: sitePolygon.id } }); + return this; + } + + async execute(): Promise { + return await SitePolygon.findAll(this.findOptions); + } + + private where(options: WhereOptions, filterable: Filterable = this.findOptions) { + if (filterable.where == null) filterable.where = {}; + + const clauses = { ...options }; + if (clauses[Op.and] != null && filterable.where[Op.and] != null) { + // For this builder, we only use arrays of literals with Op.and, so we can simply merge the arrays + clauses[Op.and] = [...filterable.where[Op.and], ...clauses[Op.and]]; + } + + Object.assign(filterable.where, clauses); + } +} diff --git a/apps/research-service/src/site-polygons/site-polygons.controller.spec.ts b/apps/research-service/src/site-polygons/site-polygons.controller.spec.ts index 6d0005b..ac47aaa 100644 --- a/apps/research-service/src/site-polygons/site-polygons.controller.spec.ts +++ b/apps/research-service/src/site-polygons/site-polygons.controller.spec.ts @@ -3,10 +3,12 @@ import { SitePolygonsService } from "./site-polygons.service"; import { createMock, DeepMocked } from "@golevelup/ts-jest"; import { Test, TestingModule } from "@nestjs/testing"; import { PolicyService } from "@terramatch-microservices/common"; -import { BadRequestException, NotImplementedException, UnauthorizedException } from "@nestjs/common"; +import { BadRequestException, UnauthorizedException } from "@nestjs/common"; import { Resource } from "@terramatch-microservices/common/util"; import { SitePolygon } from "@terramatch-microservices/database/entities"; import { SitePolygonFactory } from "@terramatch-microservices/database/factories"; +import { SitePolygonBulkUpdateBodyDto } from "./dto/site-polygon-update.dto"; +import { Transaction } from "sequelize"; describe("SitePolygonsController", () => { let controller: SitePolygonsController; @@ -108,8 +110,60 @@ describe("SitePolygonsController", () => { }); describe("bulkUpdate", () => { - it("Should throw", async () => { - await expect(controller.bulkUpdate(null)).rejects.toThrow(NotImplementedException); + it("Should authorize", async () => { + policyService.authorize.mockRejectedValue(new UnauthorizedException()); + await expect(controller.bulkUpdate(null)).rejects.toThrow(UnauthorizedException); + }); + + it("should use a transaction for updates", async () => { + const transaction = {} as Transaction; + sitePolygonService.updateIndicator.mockResolvedValue(); + sitePolygonService.transaction.mockImplementation(callback => callback(transaction)); + const indicator = { + indicatorSlug: "restorationByLandUse", + yearOfAnalysis: 2025, + value: { + "Northern Acacia-Commiphora bushlands and thickets": 0.114 + } + }; + const payload = { + data: [{ type: "sitePolygons", id: "1234", attributes: { indicators: [indicator] } }] + } as SitePolygonBulkUpdateBodyDto; + await controller.bulkUpdate(payload); + expect(sitePolygonService.updateIndicator).toHaveBeenCalledWith("1234", indicator, transaction); + }); + + it("should call update for each indicator in the payload", async () => { + const transaction = {} as Transaction; + sitePolygonService.updateIndicator.mockResolvedValue(); + sitePolygonService.transaction.mockImplementation(callback => callback(transaction)); + const indicator1 = { + indicatorSlug: "restorationByLandUse", + yearOfAnalysis: 2025, + value: { + "Northern Acacia-Commiphora bushlands and thickets": 0.114 + } + }; + const indicator2 = { + indicatorSlug: "treeCoverLoss", + yearOfAnalysis: 2025, + value: { + "2023": 0.45, + "2024": 0.6, + "2025": 0.8 + } + }; + const payload = { + data: [ + { type: "sitePolygons", id: "1234", attributes: { indicators: [indicator1, indicator2] } }, + { type: "sitePolygons", id: "2345", attributes: { indicators: [indicator2] } } + ] + } as SitePolygonBulkUpdateBodyDto; + await controller.bulkUpdate(payload); + expect(sitePolygonService.updateIndicator).toHaveBeenCalledTimes(3); + expect(sitePolygonService.updateIndicator).toHaveBeenNthCalledWith(1, "1234", indicator1, transaction); + expect(sitePolygonService.updateIndicator).toHaveBeenNthCalledWith(2, "1234", indicator2, transaction); + expect(sitePolygonService.updateIndicator).toHaveBeenNthCalledWith(3, "2345", indicator2, transaction); }); }); }); diff --git a/apps/research-service/src/site-polygons/site-polygons.controller.ts b/apps/research-service/src/site-polygons/site-polygons.controller.ts index 8e79651..a5e6138 100644 --- a/apps/research-service/src/site-polygons/site-polygons.controller.ts +++ b/apps/research-service/src/site-polygons/site-polygons.controller.ts @@ -3,7 +3,7 @@ import { Body, Controller, Get, - NotImplementedException, + NotFoundException, Patch, Query, UnauthorizedException @@ -96,7 +96,20 @@ export class SitePolygonsController { }) @ApiOkResponse() @ApiException(() => UnauthorizedException, { description: "Authentication failed." }) + @ApiException(() => BadRequestException, { description: "One or more of the data payload members has a problem." }) + @ApiException(() => NotFoundException, { description: "A site polygon specified in the data was not found." }) async bulkUpdate(@Body() updatePayload: SitePolygonBulkUpdateBodyDto): Promise { - throw new NotImplementedException(); + await this.policyService.authorize("updateAll", SitePolygon); + + await this.sitePolygonService.transaction(async transaction => { + const updates: Promise[] = []; + for (const update of updatePayload.data) { + for (const indicator of update.attributes.indicators) { + updates.push(this.sitePolygonService.updateIndicator(update.id, indicator, transaction)); + } + } + + await Promise.all(updates); + }); } } diff --git a/apps/research-service/src/site-polygons/site-polygons.service.spec.ts b/apps/research-service/src/site-polygons/site-polygons.service.spec.ts index 7721ec2..a000ea3 100644 --- a/apps/research-service/src/site-polygons/site-polygons.service.spec.ts +++ b/apps/research-service/src/site-polygons/site-polygons.service.spec.ts @@ -17,10 +17,11 @@ import { TreeSpeciesFactory } from "@terramatch-microservices/database/factories"; import { Indicator, PolygonGeometry, SitePolygon, TreeSpecies } from "@terramatch-microservices/database/entities"; -import { BadRequestException } from "@nestjs/common"; +import { BadRequestException, NotFoundException } from "@nestjs/common"; import { faker } from "@faker-js/faker"; import { DateTime } from "luxon"; import { IndicatorSlug } from "@terramatch-microservices/database/constants"; +import { IndicatorHectaresDto, IndicatorTreeCountDto, IndicatorTreeCoverLossDto } from "./dto/indicators.dto"; describe("SitePolygonsService", () => { let service: SitePolygonsService; @@ -329,4 +330,102 @@ describe("SitePolygonsService", () => { expect(result.length).toBe(1); expect(result[0].id).toBe(draftPoly2.id); }); + + it("should commit a transaction", async () => { + const commit = jest.fn(); + // @ts-expect-error incomplete mock. + jest.spyOn(SitePolygon.sequelize, "transaction").mockResolvedValue({ commit }); + + const result = await service.transaction(async () => "result"); + expect(result).toBe("result"); + expect(commit).toHaveBeenCalled(); + }); + + it("should roll back a transaction", async () => { + const rollback = jest.fn(); + // @ts-expect-error incomplete mock + jest.spyOn(SitePolygon.sequelize, "transaction").mockResolvedValue({ rollback }); + + await expect( + service.transaction(async () => { + throw new Error("Test Exception"); + }) + ).rejects.toThrow("Test Exception"); + expect(rollback).toHaveBeenCalled(); + }); + + it("should throw if the site polygon is not found", async () => { + await expect(service.updateIndicator("asdfasdf", null)).rejects.toThrow(NotFoundException); + }); + + it("should throw if the indicator slug is invalid", async () => { + const { uuid } = await SitePolygonFactory.create(); + // @ts-expect-error incomplete DTO object + await expect(service.updateIndicator(uuid, { indicatorSlug: "foobar" as IndicatorSlug })).rejects.toThrow( + BadRequestException + ); + }); + + it("should create a new indicator row if none exists", async () => { + const sitePolygon = await SitePolygonFactory.create(); + const dto = { + indicatorSlug: "treeCoverLoss", + yearOfAnalysis: 2025, + value: { + "2023": 0.45, + "2024": 0.6, + "2025": 0.8 + } + } as IndicatorTreeCoverLossDto; + await service.updateIndicator(sitePolygon.uuid, dto); + const treeCoverLoss = await sitePolygon.$get("indicatorsTreeCoverLoss"); + expect(treeCoverLoss.length).toBe(1); + expect(treeCoverLoss[0]).toMatchObject(dto); + }); + + it("should create a new indicator row if the yearOfAnalysis does not match", async () => { + const sitePolygon = await SitePolygonFactory.create(); + const dto = { + indicatorSlug: "restorationByLandUse", + yearOfAnalysis: 2025, + value: { + "Northern Acacia-Commiphora bushlands and thickets": 0.114 + } + } as IndicatorHectaresDto; + await IndicatorOutputHectaresFactory.create({ + ...dto, + yearOfAnalysis: 2024, + sitePolygonId: sitePolygon.id + }); + await service.updateIndicator(sitePolygon.uuid, dto); + const hectares = await sitePolygon.$get("indicatorsHectares"); + expect(hectares.length).toBe(2); + expect(hectares[0]).toMatchObject({ ...dto, yearOfAnalysis: 2024 }); + expect(hectares[1]).toMatchObject(dto); + }); + + it("should update an indicator if it already exists", async () => { + const sitePolygon = await SitePolygonFactory.create(); + const dto = { + indicatorSlug: "treeCount", + yearOfAnalysis: 2024, + surveyType: "string", + surveyId: 1000, + treeCount: 5432, + uncertaintyType: "types TBD", + imagerySource: "maxar", + imageryId: "https://foo.bar/image", + projectPhase: "establishment", + confidence: 70 + } as IndicatorTreeCountDto; + await IndicatorOutputTreeCountFactory.create({ + ...dto, + sitePolygonId: sitePolygon.id, + confidence: 20 + }); + await service.updateIndicator(sitePolygon.uuid, dto); + const treeCount = await sitePolygon.$get("indicatorsTreeCount"); + expect(treeCount.length).toBe(1); + expect(treeCount[0]).toMatchObject(dto); + }); }); diff --git a/apps/research-service/src/site-polygons/site-polygons.service.ts b/apps/research-service/src/site-polygons/site-polygons.service.ts index 954fb64..6a50f95 100644 --- a/apps/research-service/src/site-polygons/site-polygons.service.ts +++ b/apps/research-service/src/site-polygons/site-polygons.service.ts @@ -1,152 +1,11 @@ -import { BadRequestException, Injectable, Type } from "@nestjs/common"; -import { - IndicatorOutputFieldMonitoring, - IndicatorOutputHectares, - IndicatorOutputMsuCarbon, - IndicatorOutputTreeCount, - IndicatorOutputTreeCover, - IndicatorOutputTreeCoverLoss, - PolygonGeometry, - Project, - Site, - SitePolygon, - SiteReport, - TreeSpecies -} from "@terramatch-microservices/database/entities"; -import { Attributes, Filterable, FindOptions, IncludeOptions, literal, Op, WhereOptions } from "sequelize"; +import { BadRequestException, Injectable, NotFoundException, Type } from "@nestjs/common"; +import { SitePolygon } from "@terramatch-microservices/database/entities"; import { IndicatorDto, ReportingPeriodDto, TreeSpeciesDto } from "./dto/site-polygon.dto"; import { INDICATOR_DTOS } from "./dto/indicators.dto"; import { ModelPropertiesAccessor } from "@nestjs/swagger/dist/services/model-properties-accessor"; -import { pick, uniq } from "lodash"; -import { IndicatorSlug, PolygonStatus } from "@terramatch-microservices/database/constants"; - -const INDICATOR_TABLES: { [Slug in IndicatorSlug]: string } = { - treeCover: "indicator_output_tree_cover", - treeCoverLoss: "indicator_output_tree_cover_loss", - treeCoverLossFires: "indicator_output_tree_cover_loss", - restorationByEcoRegion: "indicator_output_hectares", - restorationByStrategy: "indicator_output_hectares", - restorationByLandUse: "indicator_output_hectares", - treeCount: "indicator_output_tree_count", - earlyTreeVerification: "indicator_output_tree_count", - fieldMonitoring: "indicator_output_field_monitoring", - msuCarbon: "indicator_output_msu_carbon" -}; - -const INDICATOR_EXCLUDE_COLUMNS = ["id", "sitePolygonId", "createdAt", "updatedAt", "deletedAt"]; - -export class SitePolygonQueryBuilder { - private siteJoin: IncludeOptions = { - model: Site, - include: [ - { model: TreeSpecies, attributes: ["name", "amount"] }, - { - model: SiteReport, - include: [{ model: TreeSpecies, attributes: ["name", "amount"] }], - attributes: ["dueAt", "submittedAt"] - } - ], - attributes: ["projectId"], - required: true - }; - private findOptions: FindOptions> = { - include: [ - { model: IndicatorOutputFieldMonitoring, attributes: { exclude: INDICATOR_EXCLUDE_COLUMNS } }, - { model: IndicatorOutputHectares, attributes: { exclude: INDICATOR_EXCLUDE_COLUMNS } }, - { model: IndicatorOutputMsuCarbon, attributes: { exclude: INDICATOR_EXCLUDE_COLUMNS } }, - { model: IndicatorOutputTreeCount, attributes: { exclude: INDICATOR_EXCLUDE_COLUMNS } }, - { model: IndicatorOutputTreeCover, attributes: { exclude: INDICATOR_EXCLUDE_COLUMNS } }, - { model: IndicatorOutputTreeCoverLoss, attributes: { exclude: INDICATOR_EXCLUDE_COLUMNS } }, - { model: PolygonGeometry, attributes: ["polygon"], required: true }, - this.siteJoin - ] - }; - - constructor(pageSize: number) { - this.findOptions.limit = pageSize; - } - - async excludeTestProjects() { - // avoid joining against the entire project table by doing a quick query first. The number of test projects is small - const testProjects = await Project.findAll({ where: { isTest: true }, attributes: ["id"] }); - this.where({ projectId: { [Op.notIn]: testProjects.map(({ id }) => id) } }, this.siteJoin); - return this; - } - - async filterProjectUuids(projectUuids: string[]) { - const filterProjects = await Project.findAll({ where: { uuid: { [Op.in]: projectUuids } }, attributes: ["id"] }); - this.where({ projectId: { [Op.in]: filterProjects.map(({ id }) => id) } }, this.siteJoin); - return this; - } - - hasStatuses(polygonStatuses?: PolygonStatus[]) { - if (polygonStatuses != null) this.where({ status: { [Op.in]: polygonStatuses } }); - return this; - } - - modifiedSince(date?: Date) { - if (date != null) this.where({ updatedAt: { [Op.gte]: date } }); - return this; - } - - isMissingIndicators(indicatorSlugs?: IndicatorSlug[]) { - if (indicatorSlugs != null) { - const literals = uniq(indicatorSlugs).map(slug => { - const table = INDICATOR_TABLES[slug]; - if (table == null) throw new BadRequestException(`Unrecognized indicator slug: ${slug}`); - - return literal( - `(SELECT COUNT(*) = 0 from ${table} WHERE indicator_slug = "${slug}" AND site_polygon_id = SitePolygon.id)` - ); - }); - this.where({ [Op.and]: literals }); - } - return this; - } - - async touchesBoundary(polygonUuid?: string) { - if (polygonUuid != null) { - // This check isn't strictly necessary for constructing the query, but we do want to throw a useful - // error to the caller if the polygonUuid doesn't exist, and simply mixing it into the query won't - // do it - if ((await PolygonGeometry.count({ where: { uuid: polygonUuid } })) === 0) { - throw new BadRequestException(`Unrecognized polygon UUID: ${polygonUuid}`); - } - - this.where({ - [Op.and]: [ - literal( - `(SELECT ST_INTERSECTS(polygon.geom, (SELECT geom FROM polygon_geometry WHERE uuid = "${polygonUuid}")))` - ) - ] - }); - } - return this; - } - - async pageAfter(pageAfter: string) { - const sitePolygon = await SitePolygon.findOne({ where: { uuid: pageAfter }, attributes: ["id"] }); - if (sitePolygon == null) throw new BadRequestException("pageAfter polygon not found"); - this.where({ id: { [Op.gt]: sitePolygon.id } }); - return this; - } - - async execute(): Promise { - return await SitePolygon.findAll(this.findOptions); - } - - private where(options: WhereOptions, filterable: Filterable = this.findOptions) { - if (filterable.where == null) filterable.where = {}; - - const clauses = { ...options }; - if (clauses[Op.and] != null && filterable.where[Op.and] != null) { - // For this builder, we only use arrays of literals with Op.and, so we can simply merge the arrays - clauses[Op.and] = [...filterable.where[Op.and], ...clauses[Op.and]]; - } - - Object.assign(filterable.where, clauses); - } -} +import { pick } from "lodash"; +import { INDICATOR_MODEL_CLASSES, SitePolygonQueryBuilder } from "./site-polygon-query.builder"; +import { Transaction } from "sequelize"; @Injectable() export class SitePolygonsService { @@ -161,8 +20,8 @@ export class SitePolygonsService { const indicators: IndicatorDto[] = []; for (const indicator of await sitePolygon.getIndicators()) { const DtoPrototype = INDICATOR_DTOS[indicator.indicatorSlug]; - const fields = accessor.getModelProperties(DtoPrototype as unknown as Type); - indicators.push(pick(indicator, fields) as typeof DtoPrototype); + const fields = accessor.getModelProperties(DtoPrototype.prototype as unknown as Type); + indicators.push(pick(indicator, fields) as typeof DtoPrototype.prototype); } return indicators; @@ -194,4 +53,42 @@ export class SitePolygonsService { return reportingPeriods; } + + async updateIndicator(sitePolygonUuid: string, indicator: IndicatorDto, transaction?: Transaction): Promise { + const accessor = new ModelPropertiesAccessor(); + const { id: sitePolygonId } = (await SitePolygon.findOne({ where: { uuid: sitePolygonUuid } })) ?? {}; + if (sitePolygonId == null) { + throw new NotFoundException(`SitePolygon not found for id: ${sitePolygonUuid}`); + } + + const { indicatorSlug, yearOfAnalysis } = indicator; + const IndicatorClass = INDICATOR_MODEL_CLASSES[indicatorSlug]; + if (IndicatorClass == null) { + throw new BadRequestException(`Model not found for indicator: ${indicatorSlug}`); + } + + const model = + // @ts-expect-error The compiler is getting confused here; this is legal. + (await IndicatorClass.findOne({ + where: { sitePolygonId, indicatorSlug, yearOfAnalysis } + })) ?? new IndicatorClass(); + if (model.sitePolygonId == null) model.sitePolygonId = sitePolygonId; + + const DtoPrototype = INDICATOR_DTOS[indicatorSlug]; + const fields = accessor.getModelProperties(DtoPrototype.prototype as unknown as Type); + Object.assign(model, pick(indicator, fields)); + await model.save({ transaction }); + } + + async transaction(callback: (transaction: Transaction) => Promise) { + const transaction = await SitePolygon.sequelize.transaction(); + try { + const result = await callback(transaction); + await transaction.commit(); + return result; + } catch (e) { + await transaction.rollback(); + throw e; + } + } }