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 56af3e2c..1d50e4a3 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 @@ -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"; @@ -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); + }); + }); }); 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 7b4bbc92..48b99936 100644 --- a/apps/research-service/src/site-polygons/site-polygons.controller.ts +++ b/apps/research-service/src/site-polygons/site-polygons.controller.ts @@ -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 { @@ -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); + } } 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 32ae5915..d621e204 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 @@ -9,6 +9,7 @@ import { IndicatorOutputTreeCoverFactory, IndicatorOutputTreeCoverLossFactory, LandscapeGeometryFactory, + PolygonGeometryFactory, ProjectFactory, SiteFactory, SitePolygonFactory, @@ -17,6 +18,7 @@ import { } from "@terramatch-microservices/database/factories"; import { Indicator, + IndicatorOutputHectares, PolygonGeometry, Project, Site, @@ -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(); + }); + }); }); 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 e7264584..88ef27d1 100644 --- a/apps/research-service/src/site-polygons/site-polygons.service.ts +++ b/apps/research-service/src/site-polygons/site-polygons.service.ts @@ -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, @@ -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 { + 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 = {}; for (const [sitePolygonId, indicators] of Object.entries(await this.getIndicators(sitePolygons))) { diff --git a/libs/common/src/lib/policies/site-polygon.policy.spec.ts b/libs/common/src/lib/policies/site-polygon.policy.spec.ts index 52f56708..10563231 100644 --- a/libs/common/src/lib/policies/site-polygon.policy.spec.ts +++ b/libs/common/src/lib/policies/site-polygon.policy.spec.ts @@ -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); + }); }); diff --git a/libs/common/src/lib/policies/site-polygon.policy.ts b/libs/common/src/lib/policies/site-polygon.policy.ts index 3fb37b6d..3dd9d328 100644 --- a/libs/common/src/lib/policies/site-polygon.policy.ts +++ b/libs/common/src/lib/policies/site-polygon.policy.ts @@ -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({ @@ -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 } }); } } @@ -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 } }); } } }