Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { ApiProperty } from "@nestjs/swagger";
import { Equals, IsOptional, IsString, IsUUID } from "class-validator";
import { JsonApiBulkBodyDto } from "@terramatch-microservices/common/util/json-api-update-dto";

export class SitePolygonStatusUpdate {
@Equals("sitePolygons")
@ApiProperty({ enum: ["sitePolygons"] })
type: string;

@IsUUID()
@ApiProperty({ format: "uuid" })
id: string;
}

export class SitePolygonStatusBulkUpdateBodyDto extends JsonApiBulkBodyDto(SitePolygonStatusUpdate, {
description: "Array of site polygons to update",
minSize: 1,
minSizeMessage: "At least one site polygon must be provided",
example: [{ id: "123e4567-e89b-12d3-a456-426614174000" }, { id: "123e4567-e89b-12d3-a456-426614174001" }]
}) {
@IsOptional()
@IsString()
@ApiProperty({ description: "Comment for the status update", required: false, type: String })
comment: string | null = null;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For required: false, this should be comment?: string. If you really want to explicitly accept null, the ApiProperty should have nullable: true.

}
Original file line number Diff line number Diff line change
Expand Up @@ -682,6 +682,37 @@ describe("SitePolygonsController", () => {
});
});

describe("bulkStatusUpdate", () => {
it("should throw UnauthorizedException when user is not authorized", async () => {
policyService.authorize.mockRejectedValue(new UnauthorizedException());
await expect(
controller.updateBulkStatus("submitted", { comment: "comment", data: [{ type: "sitePolygons", id: "1234" }] })
).rejects.toThrow(UnauthorizedException);
});

it("should throw BadRequestException when userId is null", async () => {
policyService.authorize.mockResolvedValue(undefined);
Object.defineProperty(policyService, "userId", {
value: null,
writable: true,
configurable: true
});
await expect(
controller.updateBulkStatus("submitted", { comment: "comment", data: [{ type: "sitePolygons", id: "1234" }] })
).rejects.toThrow(UnauthorizedException);
});

it("should call updateBulkStatus on sitePolygonService", async () => {
policyService.authorize.mockResolvedValue(undefined);
const userParams = { id: 1, firstName: "Test", lastName: "User", emailAddress: "test@example.com" };
jest.spyOn(User, "findByPk").mockResolvedValue(userParams as User);
const data = [{ type: "sitePolygons", id: "1234" }];
Object.defineProperty(policyService, "userId", { value: userParams.id });
await controller.updateBulkStatus("submitted", { comment: "comment", data });
expect(sitePolygonService.updateBulkStatus).toHaveBeenCalledWith("submitted", data, "comment", userParams);
});
});

describe("bulkUpdate", () => {
it("Should authorize", async () => {
policyService.authorize.mockRejectedValue(new UnauthorizedException());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import { GeoJsonQueryDto } from "../geojson-export/dto/geojson-query.dto";
import { GeoJsonExportDto } from "../geojson-export/dto/geojson-export.dto";
import { GeometryUploadComparisonSummaryDto } from "./dto/geometry-upload-comparison-summary.dto";
import { GeometryUploadComparisonService } from "./geometry-upload-comparison.service";
import { SitePolygonStatusBulkUpdateBodyDto } from "./dto/site-polygon-status-update.dto";

const MAX_PAGE_SIZE = 100 as const;

Expand Down Expand Up @@ -365,6 +366,34 @@ export class SitePolygonsController {
return document.addIndex(indexData);
}

@Patch("status/:status")
@ApiOperation({
operationId: "updateSitePolygonStatus",
summary: "Update the status of a site polygon",
description: "Update the status of a site polygon"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These descriptions make it sound like this endpoint operates on a single polygon.

})
@JsonApiResponse({ data: SitePolygonLightDto, hasMany: true })
@ExceptionResponse(UnauthorizedException, { description: "Authentication failed." })
@ExceptionResponse(BadRequestException, { description: "Invalid request data." })
@ExceptionResponse(NotFoundException, { description: "Site polygon not found." })
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should also be something more like "At least one of the site polygons was not found".

async updateBulkStatus(@Param("status") status: string, @Body() request: SitePolygonStatusBulkUpdateBodyDto) {
await this.policyService.authorize("update", SitePolygon);
const userId = this.policyService.userId;
if (userId == null) {
throw new UnauthorizedException("User must be authenticated");
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For a normal endpoint (one that doesn't opt out of auth), the user id is guaranteed to be non null, so instead of this check, you can do:

const userId = this.policyService.userId as number;

const user = await User.findByPk(userId, {
attributes: ["id", "firstName", "lastName", "emailAddress"]
});
const { data, comment } = request;
const updatedUuids = await this.sitePolygonService.updateBulkStatus(status, data, comment, user);
const document = buildJsonApi(SitePolygonLightDto);
for (const sitePolygon of updatedUuids) {
document.addData(sitePolygon.uuid, await this.sitePolygonService.buildLightDto(sitePolygon, {}));
}
return document;
}

@Patch()
@ApiOperation({
operationId: "bulkUpdateSitePolygons",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import {
SiteFactory,
SitePolygonFactory,
SiteReportFactory,
TreeSpeciesFactory
TreeSpeciesFactory,
UserFactory
} from "@terramatch-microservices/database/factories";
import {
Indicator,
Expand All @@ -31,6 +32,7 @@ import { IndicatorHectaresDto, IndicatorTreeCountDto, IndicatorTreeCoverLossDto
import { IndicatorDto, SitePolygonFullDto, SitePolygonLightDto } from "./dto/site-polygon.dto";
import { LandscapeSlug } from "@terramatch-microservices/database/types/landscapeGeometry";
import { PolygonGeometryCreationService } from "./polygon-geometry-creation.service";
import { Op } from "sequelize";

describe("SitePolygonsService", () => {
let service: SitePolygonsService;
Expand Down Expand Up @@ -1156,4 +1158,19 @@ describe("SitePolygonsService", () => {
);
});
});

describe("updateBulkStatus", () => {
it("should update the status of multiple site polygons", async () => {
const data = [{ type: "sitePolygons", id: "1234" }];
const status = "submitted";
const comment = "comment";
const user = await UserFactory.create();
jest.spyOn(SitePolygon, "update").mockResolvedValue([1]);
await service.updateBulkStatus(status, data, comment, user);
expect(SitePolygon.update).toHaveBeenCalledWith(
{ status },
{ where: { uuid: { [Op.in]: data.map(d => d.id) } } }
);
});
});
});
45 changes: 42 additions & 3 deletions apps/research-service/src/site-polygons/site-polygons.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import {
SitePolygon,
SitePolygonData,
SiteReport,
TreeSpecies
TreeSpecies,
User
} from "@terramatch-microservices/database/entities";
import { PolygonGeometryCreationService } from "./polygon-geometry-creation.service";
import {
Expand All @@ -24,11 +25,12 @@ import { INDICATOR_DTOS } from "./dto/indicators.dto";
import { ModelPropertiesAccessor } from "@nestjs/swagger/dist/services/model-properties-accessor";
import { groupBy, pick, uniq } from "lodash";
import { INDICATOR_MODEL_CLASSES, SitePolygonQueryBuilder } from "./site-polygon-query.builder";
import { Op, Transaction } from "sequelize";
import { Attributes, Op, Transaction } from "sequelize";
import { CursorPage, isCursorPage, isNumberPage, NumberPage } from "@terramatch-microservices/common/dto/page.dto";
import { INDICATOR_SLUGS } from "@terramatch-microservices/database/constants";
import { INDICATOR_SLUGS, PolygonStatus } from "@terramatch-microservices/database/constants";
import { Subquery } from "@terramatch-microservices/database/util/subquery.builder";
import { isNotNull } from "@terramatch-microservices/database/types/array";
import { SitePolygonStatusUpdate } from "./dto/site-polygon-status-update.dto";

type AssociationDtos = {
indicators?: IndicatorDto[];
Expand Down Expand Up @@ -510,4 +512,41 @@ export class SitePolygonsService {
"siteId"
) as Record<number, SiteReport[]>;
}

async updateBulkStatus(
status: PolygonStatus,
sitePolygonsUpdate: SitePolygonStatusUpdate[],
comment: string | null,
user: User | null
) {
await SitePolygon.update({ status }, { where: { uuid: { [Op.in]: sitePolygonsUpdate.map(d => d.id) } } });
const sitePolygons = await SitePolygon.findAll({ where: { uuid: { [Op.in]: sitePolygonsUpdate.map(d => d.id) } } });

const auditStatusRecords = this.createAuditStatusRecords(sitePolygons, status, comment, user) as Array<
Attributes<AuditStatus>
>;
if (auditStatusRecords.length > 0) {
await AuditStatus.bulkCreate(auditStatusRecords);
}
return sitePolygons;
}

private createAuditStatusRecords(
sitePolygons: SitePolygon[],
status: PolygonStatus,
comment: string | null,
user: User | null
): Array<Partial<AuditStatus>> {
return sitePolygons.map(sitePolygon => ({
auditableType: SitePolygon.LARAVEL_TYPE,
auditableId: sitePolygon.id,
createdBy: user?.emailAddress ?? null,
firstName: user?.firstName ?? null,
lastName: user?.lastName ?? null,
comment: comment ?? null,
status: status as PolygonStatus,
type: "status",
isActive: null
}));
}
}