Skip to content
Merged
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
Expand Up @@ -4,7 +4,7 @@ import { SitePolygonsService } from "./site-polygons.service";
import { createMock, DeepMocked } from "@golevelup/ts-jest";
import { Test } from "@nestjs/testing";
import { PolicyService } from "@terramatch-microservices/common";
import { BadRequestException, UnauthorizedException } from "@nestjs/common";
import { BadRequestException, NotFoundException, 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";
Expand Down Expand Up @@ -387,4 +387,42 @@ describe("SitePolygonsController", () => {
expect(sitePolygonService.updateIndicator).toHaveBeenNthCalledWith(3, "2345", indicator2, transaction);
});
});

describe("deleteOne", () => {
it("should throw NotFoundException when site polygon does not exist", async () => {
const uuid = "non-existent-uuid";
jest.spyOn(SitePolygon, "findOne").mockResolvedValue(null);

await expect(controller.deleteOne(uuid)).rejects.toThrow(
new NotFoundException(`Site polygon not found for uuid: ${uuid}`)
);
expect(policyService.authorize).not.toHaveBeenCalled();
expect(sitePolygonService.deleteSitePolygon).not.toHaveBeenCalled();
});

it("should throw UnauthorizedException when user is not authorized", async () => {
const sitePolygon = await SitePolygonFactory.build();
jest.spyOn(SitePolygon, "findOne").mockResolvedValue(sitePolygon);
policyService.authorize.mockRejectedValue(new UnauthorizedException());

await expect(controller.deleteOne(sitePolygon.uuid)).rejects.toThrow(UnauthorizedException);
expect(policyService.authorize).toHaveBeenCalledWith("delete", sitePolygon);
expect(sitePolygonService.deleteSitePolygon).not.toHaveBeenCalled();
});

it("should successfully delete a site polygon when authorized", async () => {
const sitePolygon = await SitePolygonFactory.build();
jest.spyOn(SitePolygon, "findOne").mockResolvedValue(sitePolygon);
policyService.authorize.mockResolvedValue(undefined);
sitePolygonService.deleteSitePolygon.mockResolvedValue(undefined);

const result = await controller.deleteOne(sitePolygon.uuid);

expect(policyService.authorize).toHaveBeenCalledWith("delete", sitePolygon);
expect(sitePolygonService.deleteSitePolygon).toHaveBeenCalledWith(sitePolygon.uuid);
expect(result).toHaveProperty("meta");
expect(result.meta).toHaveProperty("resourceType", "sitePolygons");
expect(result.meta).toHaveProperty("resourceId", sitePolygon.uuid);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,24 @@ import {
BadRequestException,
Body,
Controller,
Delete,
Get,
NotFoundException,
Param,
Patch,
Query,
UnauthorizedException
} from "@nestjs/common";
import { buildJsonApi, getStableRequestQuery, IndexData } from "@terramatch-microservices/common/util";
import {
buildDeletedResponse,
buildJsonApi,
getDtoType,
getStableRequestQuery,
IndexData
} from "@terramatch-microservices/common/util";
import { ApiExtraModels, ApiOkResponse, ApiOperation } from "@nestjs/swagger";
import { ExceptionResponse, JsonApiResponse } from "@terramatch-microservices/common/decorators";
import { JsonApiDeletedResponse } from "@terramatch-microservices/common/decorators/json-api-response.decorator";
import { SitePolygonFullDto, SitePolygonLightDto } from "./dto/site-polygon.dto";
import { SitePolygonQueryDto } from "./dto/site-polygon-query.dto";
import {
Expand Down Expand Up @@ -214,4 +223,31 @@ export class SitePolygonsController {
await Promise.all(updates);
});
}

@Delete(":uuid")
@ApiOperation({
operationId: "deleteSitePolygon",
summary: "Delete a site polygon and all associated records",
description: `Deletes a site polygon and all its associated records including indicators,
criteria site records, audit statuses, and geometry data. This operation soft deletes
ALL related site polygons by primaryUuid (version management) and deletes polygon
geometry for all related site polygons.`
})
@JsonApiDeletedResponse([getDtoType(SitePolygonFullDto), getDtoType(SitePolygonLightDto)], {
description: "Site polygon and all associated records were deleted"
})
@ExceptionResponse(UnauthorizedException, { description: "Authentication failed." })
@ExceptionResponse(NotFoundException, { description: "Site polygon not found." })
async deleteOne(@Param("uuid") uuid: string) {
const sitePolygon = await SitePolygon.findOne({ where: { uuid } });
if (sitePolygon == null) {
throw new NotFoundException(`Site polygon not found for uuid: ${uuid}`);
}

await this.policyService.authorize("delete", sitePolygon);

await this.sitePolygonService.deleteSitePolygon(uuid);

return buildDeletedResponse(getDtoType(SitePolygonFullDto), uuid);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
IndicatorOutputTreeCoverFactory,
IndicatorOutputTreeCoverLossFactory,
LandscapeGeometryFactory,
PolygonGeometryFactory,
ProjectFactory,
SiteFactory,
SitePolygonFactory,
Expand All @@ -17,6 +18,7 @@ import {
} from "@terramatch-microservices/database/factories";
import {
Indicator,
IndicatorOutputHectares,
PolygonGeometry,
Project,
Site,
Expand Down Expand Up @@ -839,4 +841,71 @@ describe("SitePolygonsService", () => {
treeSpecies: [{ name: "ReportMaple", amount: 100 }]
});
});

describe("deleteSitePolygon", () => {
it("should throw NotFoundException when site polygon does not exist", async () => {
const nonExistentUuid = "00000000-0000-0000-0000-000000000000";

await expect(service.deleteSitePolygon(nonExistentUuid)).rejects.toThrow(
new NotFoundException(`SitePolygon not found for uuid: ${nonExistentUuid}`)
);
});

it("should successfully delete a site polygon with all associated records", async () => {
const project = await ProjectFactory.create();
const site = await SiteFactory.create({ projectId: project.id });
const polygonGeometry = await PolygonGeometryFactory.create();

const sitePolygon = await SitePolygonFactory.create({
siteUuid: site.uuid,
polygonUuid: polygonGeometry.uuid,
isActive: true
});

const relatedSitePolygon = await SitePolygonFactory.create({
siteUuid: site.uuid,
primaryUuid: sitePolygon.primaryUuid,
polygonUuid: polygonGeometry.uuid,
isActive: false
});

const indicator = await IndicatorOutputHectaresFactory.create({
sitePolygonId: sitePolygon.id
});

await service.deleteSitePolygon(sitePolygon.uuid);

await sitePolygon.reload({ paranoid: false });
expect(sitePolygon.deletedAt).not.toBeNull();

await relatedSitePolygon.reload({ paranoid: false });
expect(relatedSitePolygon.deletedAt).not.toBeNull();

const deletedIndicator = await IndicatorOutputHectares.findByPk(indicator.id, { paranoid: false });
expect(deletedIndicator?.deletedAt).not.toBeNull();

const deletedPolygonGeometry = await PolygonGeometry.findByPk(polygonGeometry.id, { paranoid: false });
expect(deletedPolygonGeometry?.deletedAt).not.toBeNull();
});

it("should successfully delete a site polygon with minimal associations", async () => {
const project = await ProjectFactory.create();
const site = await SiteFactory.create({ projectId: project.id });
const polygonGeometry = await PolygonGeometryFactory.create();

const sitePolygon = await SitePolygonFactory.create({
siteUuid: site.uuid,
polygonUuid: polygonGeometry.uuid,
isActive: true
});

await service.deleteSitePolygon(sitePolygon.uuid);

await sitePolygon.reload({ paranoid: false });
expect(sitePolygon.deletedAt).not.toBeNull();

const deletedPolygonGeometry = await PolygonGeometry.findByPk(polygonGeometry.id, { paranoid: false });
expect(deletedPolygonGeometry?.deletedAt).not.toBeNull();
});
});
});
97 changes: 96 additions & 1 deletion apps/research-service/src/site-polygons/site-polygons.service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
import { BadRequestException, Injectable, NotFoundException, Type } from "@nestjs/common";
import { Site, SitePolygon, SiteReport, TreeSpecies } from "@terramatch-microservices/database/entities";
import {
AuditStatus,
CriteriaSite,
CriteriaSiteHistoric,
PointGeometry,
PolygonGeometry,
ProjectPolygon,
Site,
SitePolygon,
SiteReport,
TreeSpecies
} from "@terramatch-microservices/database/entities";
import {
IndicatorDto,
ReportingPeriodDto,
Expand Down Expand Up @@ -77,6 +88,90 @@ export class SitePolygonsService {
}
}

/**
* Deletes a site polygon and all its associated records.
* This method handles cascade deletion of all related entities.
*/
async deleteSitePolygon(uuid: string): Promise<void> {
await this.transaction(async transaction => {
const sitePolygon = await SitePolygon.findOne({
where: { uuid },
include: [
{ model: Site, attributes: ["id", "uuid", "projectId"] },
{ model: PolygonGeometry, attributes: ["id", "uuid"] },
{ model: PointGeometry, attributes: ["id", "uuid"] }
],
transaction
});

if (sitePolygon == null) {
throw new NotFoundException(`SitePolygon not found for uuid: ${uuid}`);
}

const relatedSitePolygons = await SitePolygon.findAll({
where: { primaryUuid: sitePolygon.primaryUuid },
attributes: ["id", "uuid", "polygonUuid", "pointUuid"],
transaction
});

const sitePolygonIds = relatedSitePolygons.map(sp => sp.id);
const polygonUuids = relatedSitePolygons.map(sp => sp.polygonUuid).filter((uuid): uuid is string => uuid != null);
const pointUuids = relatedSitePolygons.map(sp => sp.pointUuid).filter((uuid): uuid is string => uuid != null);

for (const IndicatorClass of Object.values(INDICATOR_MODEL_CLASSES)) {
await IndicatorClass.destroy({
where: { sitePolygonId: { [Op.in]: sitePolygonIds } },
transaction
});
}

if (polygonUuids.length > 0) {
await CriteriaSite.destroy({
where: { polygonId: { [Op.in]: polygonUuids } },
transaction
});
await CriteriaSiteHistoric.destroy({
where: { polygonId: { [Op.in]: polygonUuids } },
transaction
});
}

await AuditStatus.destroy({
where: {
auditableType: SitePolygon.LARAVEL_TYPE,
auditableId: { [Op.in]: sitePolygonIds }
},
transaction
});

if (polygonUuids.length > 0) {
await ProjectPolygon.destroy({
where: { polyUuid: { [Op.in]: polygonUuids } },
transaction
});
}

if (pointUuids.length > 0) {
await PointGeometry.destroy({
where: { uuid: { [Op.in]: pointUuids } },
transaction
});
}

if (polygonUuids.length > 0) {
await PolygonGeometry.destroy({
where: { uuid: { [Op.in]: polygonUuids } },
transaction
});
}

await SitePolygon.destroy({
where: { primaryUuid: sitePolygon.primaryUuid },
transaction
});
});
}

async loadAssociationDtos(sitePolygons: SitePolygon[], lightResource: boolean) {
const associationDtos: Record<number, AssociationDtos> = {};
for (const [sitePolygonId, indicators] of Object.entries(await this.getIndicators(sitePolygons))) {
Expand Down
50 changes: 50 additions & 0 deletions libs/common/src/lib/policies/site-polygon.policy.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,4 +88,54 @@ describe("SitePolygonPolicy", () => {
mockPermissions();
await expectCannot(service, "readAll", SitePolygon);
});

it("allows deleting polygons within frameworks", async () => {
const site = await SiteFactory.create({ frameworkKey: "ppc" });

mockUserId(123);
mockPermissions("framework-ppc");
const sitePolygon = new SitePolygon();
sitePolygon.siteUuid = site.uuid;

await expectCan(service, "delete", sitePolygon);
});

it("allows deleting polygons for own projects with manage-own", async () => {
const user = await UserFactory.create();
const project = await ProjectFactory.create();
await ProjectUserFactory.create({ userId: user.id, projectId: project.id });
const site = await SiteFactory.create({ projectId: project.id });

mockUserId(user.id);
mockPermissions("manage-own");
const sitePolygon = new SitePolygon();
sitePolygon.siteUuid = site.uuid;

await expectCan(service, "delete", sitePolygon);
});

it("allows deleting polygons for managed projects with projects-manage", async () => {
const user = await UserFactory.create();
const project = await ProjectFactory.create();
await ProjectUserFactory.create({ userId: user.id, projectId: project.id, isManaging: true });
const site = await SiteFactory.create({ projectId: project.id });

mockUserId(user.id);
mockPermissions("projects-manage");
const sitePolygon = new SitePolygon();
sitePolygon.siteUuid = site.uuid;

await expectCan(service, "delete", sitePolygon);
});

it("disallows deleting polygons without appropriate permissions", async () => {
const site = await SiteFactory.create({ frameworkKey: "ppc" });
const sitePolygon = new SitePolygon();
sitePolygon.siteUuid = site.uuid;

mockUserId(123);
mockPermissions();

await expectCannot(service, "delete", sitePolygon);
});
});
6 changes: 3 additions & 3 deletions libs/common/src/lib/policies/site-polygon.policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export class SitePolygonPolicy extends UserPermissionsPolicy {
attributes: ["uuid"]
});
const siteUuids = sites.map(site => site.uuid);
this.builder.can("manage", SitePolygon, { siteUuid: { $in: siteUuids } });
this.builder.can(["manage", "delete"], SitePolygon, { siteUuid: { $in: siteUuids } });
}
if (this.permissions.includes("manage-own")) {
const sites = await Site.findAll({
Expand All @@ -24,7 +24,7 @@ export class SitePolygonPolicy extends UserPermissionsPolicy {
});
const siteUuids = sites.map(site => site.uuid);
if (siteUuids.length > 0) {
this.builder.can("manage", SitePolygon, { siteUuid: { $in: siteUuids } });
this.builder.can(["manage", "delete"], SitePolygon, { siteUuid: { $in: siteUuids } });
}
}

Expand All @@ -39,7 +39,7 @@ export class SitePolygonPolicy extends UserPermissionsPolicy {
});
const siteUuids = sites.map(site => site.uuid);
if (siteUuids.length > 0) {
this.builder.can("manage", SitePolygon, { siteUuid: { $in: siteUuids } });
this.builder.can(["manage", "delete"], SitePolygon, { siteUuid: { $in: siteUuids } });
}
}
}
Expand Down