diff --git a/apps/entity-service/src/entities/dto/disturbance-report.dto.ts b/apps/entity-service/src/entities/dto/disturbance-report.dto.ts index 93298950..6c48e367 100644 --- a/apps/entity-service/src/entities/dto/disturbance-report.dto.ts +++ b/apps/entity-service/src/entities/dto/disturbance-report.dto.ts @@ -5,6 +5,7 @@ import { populateDto } from "@terramatch-microservices/common/dto/json-api-attri import { ApiProperty } from "@nestjs/swagger"; import { HybridSupportProps } from "@terramatch-microservices/common/dto/hybrid-support.dto"; import { DisturbanceReportEntryDto } from "./disturbance-report-entry.dto"; +import { MediaDto } from "./media.dto"; @JsonApiDto({ type: "disturbanceReports" }) export class DisturbanceReportLightDto extends EntityDto { @@ -50,8 +51,13 @@ export class DisturbanceReportLightDto extends EntityDto { @ApiProperty({ nullable: true, type: DisturbanceReportEntryDto, isArray: true }) entries: DisturbanceReportEntryDto[] | null; + + @ApiProperty({ type: Number }) + reportId: number; } +export type DisturbanceReportMedia = Pick; + export class DisturbanceReportFullDto extends DisturbanceReportLightDto { constructor( disturbanceReport: DisturbanceReport, @@ -101,4 +107,7 @@ export class DisturbanceReportFullDto extends DisturbanceReportLightDto { @ApiProperty({ nullable: true, type: String }) actionDescription: string | null; + + @ApiProperty({ type: () => MediaDto, isArray: true }) + media: MediaDto[]; } diff --git a/apps/entity-service/src/entities/processors/disturbance-report.processor.spec.ts b/apps/entity-service/src/entities/processors/disturbance-report.processor.spec.ts index 14e282b5..a89aad64 100644 --- a/apps/entity-service/src/entities/processors/disturbance-report.processor.spec.ts +++ b/apps/entity-service/src/entities/processors/disturbance-report.processor.spec.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ -import { DisturbanceReport } from "@terramatch-microservices/database/entities"; +import { DisturbanceReport, Disturbance, SitePolygon } from "@terramatch-microservices/database/entities"; import { Test } from "@nestjs/testing"; import { MediaService } from "@terramatch-microservices/common/media/media.service"; import { createMock, DeepMocked } from "@golevelup/ts-jest"; @@ -11,7 +11,8 @@ import { DisturbanceReportEntryFactory, ProjectFactory, ProjectUserFactory, - UserFactory + UserFactory, + SitePolygonFactory } from "@terramatch-microservices/database/factories"; import { BadRequestException } from "@nestjs/common/exceptions/bad-request.exception"; import { DisturbanceReportProcessor } from "./disturbance-report.processor"; @@ -323,4 +324,260 @@ describe("DisturbanceReportProcessor", () => { expect(entries).toHaveLength(0); }); }); + + describe("processReportSpecificLogic", () => { + it("upserts disturbance and sets disturbanceId on polygons parsed from JSON", async () => { + const project = await ProjectFactory.create(); + const disturbanceReport = await DisturbanceReportFactory.create({ + projectId: project.id, + description: "desc", + actionDescription: "act", + status: "awaiting-approval" + }); + + const poly1 = await SitePolygonFactory.create(); + const poly2 = await SitePolygonFactory.create(); + + await DisturbanceReportEntryFactory.create({ + disturbanceReportId: disturbanceReport.id, + name: "polygon-affected", + value: JSON.stringify([{ polyUuid: poly1.uuid }, { polyUuid: poly2.uuid }]), + inputType: "text" + }); + + await DisturbanceReportEntryFactory.create({ + disturbanceReportId: disturbanceReport.id, + name: "intensity", + value: "high", + inputType: "select" + }); + await DisturbanceReportEntryFactory.create({ + disturbanceReportId: disturbanceReport.id, + name: "extent", + value: "large", + inputType: "text" + }); + await DisturbanceReportEntryFactory.create({ + disturbanceReportId: disturbanceReport.id, + name: "disturbance-type", + value: "fire", + inputType: "text" + }); + await DisturbanceReportEntryFactory.create({ + disturbanceReportId: disturbanceReport.id, + name: "disturbance-subtype", + value: JSON.stringify({ code: "wild" }), + inputType: "text" + }); + await DisturbanceReportEntryFactory.create({ + disturbanceReportId: disturbanceReport.id, + name: "people-affected", + value: "12", + inputType: "number" + }); + await DisturbanceReportEntryFactory.create({ + disturbanceReportId: disturbanceReport.id, + name: "monetary-damage", + value: "345.6", + inputType: "number" + }); + await DisturbanceReportEntryFactory.create({ + disturbanceReportId: disturbanceReport.id, + name: "property-affected", + value: JSON.stringify({ houses: 3 }), + inputType: "text" + }); + await DisturbanceReportEntryFactory.create({ + disturbanceReportId: disturbanceReport.id, + name: "date-of-disturbance", + value: "2024-01-05", + inputType: "date" + }); + + await processor.update(disturbanceReport, { status: "approved" }); + + const disturbance = await Disturbance.findOne({ + where: { disturbanceableType: DisturbanceReport.LARAVEL_TYPE, disturbanceableId: disturbanceReport.id } + }); + expect(disturbance).toBeTruthy(); + expect(disturbance?.intensity).toBe("high"); + expect(disturbance?.extent).toBe("large"); + expect(disturbance?.type).toBe("fire"); + expect(disturbance?.peopleAffected).toBe(12); + expect(disturbance?.monetaryDamage).toBe(345.6); + expect(disturbance?.description).toBe("desc"); + expect(disturbance?.actionDescription).toBe("act"); + expect(disturbance?.disturbanceDate?.toISOString()).toBe(new Date("2024-01-05").toISOString()); + + const updatedPoly1 = await SitePolygon.findOne({ where: { uuid: poly1.uuid } }); + const updatedPoly2 = await SitePolygon.findOne({ where: { uuid: poly2.uuid } }); + expect(updatedPoly1?.disturbanceId).toBe(disturbance?.id); + expect(updatedPoly2?.disturbanceId).toBe(disturbance?.id); + }); + + it("does not overwrite existing polygon disturbanceId and updates only null ones", async () => { + const project = await ProjectFactory.create(); + const disturbanceReport = await DisturbanceReportFactory.create({ + projectId: project.id, + status: "awaiting-approval" + }); + + const preExistingDisturbance = await Disturbance.create({ + disturbanceableType: DisturbanceReport.LARAVEL_TYPE, + disturbanceableId: disturbanceReport.id, + hidden: 0 + } as Disturbance); + + const polyWithDist = await SitePolygonFactory.create(); + await polyWithDist.update({ disturbanceId: preExistingDisturbance.id }); + const polyWithoutDist = await SitePolygonFactory.create(); + + await DisturbanceReportEntryFactory.create({ + disturbanceReportId: disturbanceReport.id, + name: "polygon-affected", + value: JSON.stringify([{ polyUuid: polyWithDist.uuid }, { polyUuid: polyWithoutDist.uuid }]), + inputType: "text" + }); + + await processor.update(disturbanceReport, { status: "approved" }); + + const refreshedWith = await SitePolygon.findOne({ where: { uuid: polyWithDist.uuid } }); + const refreshedWithout = await SitePolygon.findOne({ where: { uuid: polyWithoutDist.uuid } }); + + expect(refreshedWith?.disturbanceId).toBe(preExistingDisturbance.id); + expect(refreshedWithout?.disturbanceId).toBeTruthy(); + }); + + it("parses CSV fallback for polygon-affected when JSON fails", async () => { + const project = await ProjectFactory.create(); + const disturbanceReport = await DisturbanceReportFactory.create({ + projectId: project.id, + status: "awaiting-approval" + }); + + const p1 = await SitePolygonFactory.create(); + const p2 = await SitePolygonFactory.create(); + + await DisturbanceReportEntryFactory.create({ + disturbanceReportId: disturbanceReport.id, + name: "polygon-affected", + value: `${p1.uuid}, ${p2.uuid}`, + inputType: "text" + }); + + await processor.update(disturbanceReport, { status: "approved" }); + + const disturbance = await Disturbance.findOne({ + where: { disturbanceableType: DisturbanceReport.LARAVEL_TYPE, disturbanceableId: disturbanceReport.id } + }); + expect(disturbance).toBeTruthy(); + + const up1 = await SitePolygon.findOne({ where: { uuid: p1.uuid } }); + const up2 = await SitePolygon.findOne({ where: { uuid: p2.uuid } }); + expect(up1?.disturbanceId).toBe(disturbance?.id); + expect(up2?.disturbanceId).toBe(disturbance?.id); + }); + + it("returns early and does not create disturbance when no polygons provided", async () => { + const project = await ProjectFactory.create(); + const disturbanceReport = await DisturbanceReportFactory.create({ + projectId: project.id, + status: "awaiting-approval" + }); + + await DisturbanceReportEntryFactory.create({ + disturbanceReportId: disturbanceReport.id, + name: "intensity", + value: "low", + inputType: "select" + }); + + await processor.update(disturbanceReport, { status: "approved" }); + + const disturbance = await Disturbance.findOne({ + where: { disturbanceableType: DisturbanceReport.LARAVEL_TYPE, disturbanceableId: disturbanceReport.id } + }); + expect(disturbance).toBeNull(); + }); + + it("handles nested arrays in polygon-affected JSON", async () => { + const project = await ProjectFactory.create(); + const disturbanceReport = await DisturbanceReportFactory.create({ + projectId: project.id, + status: "awaiting-approval" + }); + + const p1 = await SitePolygonFactory.create(); + const p2 = await SitePolygonFactory.create(); + + const nested = [[{ polyUuid: p1.uuid }], [{ polyUuid: p2.uuid }]]; + await DisturbanceReportEntryFactory.create({ + disturbanceReportId: disturbanceReport.id, + name: "polygon-affected", + value: JSON.stringify(nested), + inputType: "text" + }); + + await processor.update(disturbanceReport, { status: "approved" }); + + const disturbance = await Disturbance.findOne({ + where: { disturbanceableType: DisturbanceReport.LARAVEL_TYPE, disturbanceableId: disturbanceReport.id } + }); + expect(disturbance).toBeTruthy(); + + const up1 = await SitePolygon.findOne({ where: { uuid: p1.uuid } }); + const up2 = await SitePolygon.findOne({ where: { uuid: p2.uuid } }); + expect(up1?.disturbanceId).toBe(disturbance?.id); + expect(up2?.disturbanceId).toBe(disturbance?.id); + }); + + it("warns on invalid JSON for subtype and property-affected and ignores invalid date", async () => { + const project = await ProjectFactory.create(); + const disturbanceReport = await DisturbanceReportFactory.create({ + projectId: project.id, + status: "awaiting-approval" + }); + + const p = await SitePolygonFactory.create(); + await DisturbanceReportEntryFactory.create({ + disturbanceReportId: disturbanceReport.id, + name: "polygon-affected", + value: JSON.stringify([{ polyUuid: p.uuid }]), + inputType: "text" + }); + await DisturbanceReportEntryFactory.create({ + disturbanceReportId: disturbanceReport.id, + name: "disturbance-subtype", + value: "{invalid}", + inputType: "text" + }); + await DisturbanceReportEntryFactory.create({ + disturbanceReportId: disturbanceReport.id, + name: "property-affected", + value: "{invalid}", + inputType: "text" + }); + await DisturbanceReportEntryFactory.create({ + disturbanceReportId: disturbanceReport.id, + name: "date-of-disturbance", + value: "not-a-date", + inputType: "date" + }); + + const warnSpy = jest.spyOn((processor as unknown as { logger: { warn: (m: string) => void } }).logger, "warn"); + + await processor.update(disturbanceReport, { status: "approved" }); + + const disturbance = await Disturbance.findOne({ + where: { disturbanceableType: DisturbanceReport.LARAVEL_TYPE, disturbanceableId: disturbanceReport.id } + }); + expect(disturbance).toBeTruthy(); + expect(disturbance?.subtype).toBeNull(); + expect(disturbance?.propertyAffected).toBeNull(); + expect(disturbance?.disturbanceDate).toBeNull(); + + expect(warnSpy).toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + }); }); diff --git a/apps/entity-service/src/entities/processors/disturbance-report.processor.ts b/apps/entity-service/src/entities/processors/disturbance-report.processor.ts index 907e5e85..7029b720 100644 --- a/apps/entity-service/src/entities/processors/disturbance-report.processor.ts +++ b/apps/entity-service/src/entities/processors/disturbance-report.processor.ts @@ -2,16 +2,24 @@ import { DisturbanceReport, DisturbanceReportEntry, Project, - ProjectUser + ProjectUser, + SitePolygon, + Disturbance, + Media } from "@terramatch-microservices/database/entities"; import { ReportProcessor } from "./entity-processor"; import { EntityQueryDto } from "../dto/entity-query.dto"; import { BadRequestException } from "@nestjs/common"; import { Op, Includeable } from "sequelize"; import { ReportUpdateAttributes } from "../dto/entity-update.dto"; -import { DisturbanceReportFullDto, DisturbanceReportLightDto } from "../dto/disturbance-report.dto"; +import { + DisturbanceReportFullDto, + DisturbanceReportLightDto, + DisturbanceReportMedia +} from "../dto/disturbance-report.dto"; import { DisturbanceReportEntryDto } from "../dto/disturbance-report-entry.dto"; import { FrameworkKey } from "@terramatch-microservices/database/constants/framework"; +import { TMLogger } from "@terramatch-microservices/common/util/tm-logger"; const SIMPLE_FILTERS: (keyof EntityQueryDto)[] = [ "status", @@ -36,6 +44,186 @@ export class DisturbanceReportProcessor extends ReportProcessor< > { readonly LIGHT_DTO = DisturbanceReportLightDto; readonly FULL_DTO = DisturbanceReportFullDto; + private logger = new TMLogger(DisturbanceReportProcessor.name); + + async update(model: DisturbanceReport, update: ReportUpdateAttributes) { + await super.update(model, update); + + if (update.status === "approved") { + await this.processReportSpecificLogic(model); + } + } + + /** + * Specific method for DisturbanceReport custom logic. This is called automatically when the report is approved. + * + * This method implements the workflow described in the task: + * - When a polygon is identified as affected in a disturbance report, the site_polygon.disturbance_id field + * is populated with the v2_disturbances.id + * - All new versions of the polygon will carry the disturbance_id with it, as occurs with other attributes + * - Each polygon should only be identified in 1 disturbance + * - Extracts disturbance details (intensity, extent, type, subtype, peopleAffected, monetaryDamage, propertyAffected) + * from disturbance report entries and populates the disturbance record + */ + private async processReportSpecificLogic(model: DisturbanceReport): Promise { + const entries = await DisturbanceReportEntry.report(model.id).findAll(); + + const affectedPolygonUuids = new Set(); + const disturbanceData: Partial = {}; + + this.processPolygonEntry(entries, affectedPolygonUuids, disturbanceData); + + if (affectedPolygonUuids.size === 0) { + return; + } + + // Upsert disturbance for this report (align with PHP logic) + const disturbanceUpsertData = { + disturbanceableType: DisturbanceReport.LARAVEL_TYPE, + disturbanceableId: model.id, + disturbanceDate: disturbanceData.disturbanceDate, + type: disturbanceData.type, + subtype: disturbanceData.subtype, + intensity: disturbanceData.intensity, + extent: disturbanceData.extent, + peopleAffected: disturbanceData.peopleAffected, + monetaryDamage: disturbanceData.monetaryDamage, + propertyAffected: disturbanceData.propertyAffected, + description: model.description, + actionDescription: model.actionDescription, + hidden: 0 + } as Disturbance; + + let disturbance = await Disturbance.findOne({ + where: { disturbanceableType: DisturbanceReport.LARAVEL_TYPE, disturbanceableId: model.id } + }); + if (disturbance != null) { + await disturbance.update(disturbanceUpsertData); + } else { + disturbance = await Disturbance.create(disturbanceUpsertData); + } + + // Find all affected site polygons and validate they're not already affected by another disturbance + const affectedPolygons = await SitePolygon.active().forUuids(Array.from(affectedPolygonUuids)).findAll(); + + // Check for polygons that are already affected by another disturbance + const alreadyAffectedPolygons = affectedPolygons.filter(polygon => polygon.disturbanceId != null); + if (alreadyAffectedPolygons.length > 0) { + this.logger.warn( + `The following polygons are already affected by another disturbance: ${alreadyAffectedPolygons + .map(p => p.uuid) + .join(", ")}` + ); + } + + await SitePolygon.update( + { disturbanceId: disturbance.id }, + { + where: { + uuid: { [Op.in]: Array.from(affectedPolygonUuids) }, + disturbanceId: null + } + } + ); + } + + private processPolygonEntry( + entries: DisturbanceReportEntry[], + affectedPolygonUuids: Set, + disturbanceData: Partial + ): void { + for (const entry of entries) { + // Look for entries that contain affected polygon UUIDs + // Based on the task requirements, this should identify which polygons have been impacted + if (entry.name === "polygon-affected" && entry.value != null) { + try { + const parsedValue = JSON.parse(entry.value); + if (Array.isArray(parsedValue)) { + parsedValue.forEach(polygonGroup => { + if (Array.isArray(polygonGroup)) { + polygonGroup.forEach(polygonObj => { + if (polygonObj != null && typeof polygonObj === "object" && polygonObj.polyUuid != null) { + affectedPolygonUuids.add(polygonObj.polyUuid); + } + }); + } else if (polygonGroup != null && typeof polygonGroup === "object" && polygonGroup.polyUuid != null) { + affectedPolygonUuids.add(polygonGroup.polyUuid); + } + }); + } + } catch (error) { + this.logger.warn(`Failed to parse polygon JSON: ${error.message}, trying comma-separated values`); + const uuids = entry.value + .split(",") + .map(uuid => uuid.trim()) + .filter(uuid => uuid != null && uuid !== ""); + uuids.forEach(uuid => affectedPolygonUuids.add(uuid)); + } + } + + this.processDisturbanceDataEntry(entry, disturbanceData); + } + } + + private processDisturbanceDataEntry(entry: DisturbanceReportEntry, disturbanceData: Partial): void { + if (entry.value == null) return; + + switch (entry.name) { + case "intensity": + disturbanceData.intensity = entry.value; + break; + case "extent": + disturbanceData.extent = entry.value; + break; + case "disturbance-type": + disturbanceData.type = entry.value; + break; + case "disturbance-subtype": + try { + const parsed = JSON.parse(entry.value); + if (parsed != null) { + disturbanceData.subtype = parsed; + } + } catch { + this.logger.warn(`Failed to parse subtype JSON: ${entry.value}`); + } + break; + case "people-affected": { + const peopleAffected = Number(entry.value); + if (!isNaN(peopleAffected)) { + disturbanceData.peopleAffected = peopleAffected; + } + break; + } + case "monetary-damage": { + const monetaryDamage = Number(entry.value); + if (!isNaN(monetaryDamage)) { + disturbanceData.monetaryDamage = monetaryDamage; + } + break; + } + case "property-affected": + try { + const parsed = JSON.parse(entry.value); + if (parsed != null) { + disturbanceData.propertyAffected = parsed; + } + } catch { + this.logger.warn(`Failed to parse propertyAffected JSON: ${entry.value}`); + } + break; + case "date-of-disturbance": { + const date = new Date(entry.value); + if (!isNaN(date.getTime()) && date.getTime() > 0) { + disturbanceData.disturbanceDate = date; + } + break; + } + default: + this.logger.error(`Unknown disturbance report entry name: ${entry.name}`); + break; + } + } async findOne(uuid: string) { return await DisturbanceReport.findOne({ @@ -116,10 +304,18 @@ export class DisturbanceReportProcessor extends ReportProcessor< const entries = await this.getDisturbanceReportEntries(disturbanceReport); const intensity = entries.find(entry => entry.name === "intensity")?.value ?? null; const dateOfDisturbance = entries.find(entry => entry.name === "date-of-disturbance")?.value; + const mediaCollection = await Media.for(disturbanceReport).findAll(); const dto = new DisturbanceReportFullDto(disturbanceReport, { + reportId: disturbanceReport.id, entries, intensity, - dateOfDisturbance: dateOfDisturbance != null ? new Date(dateOfDisturbance) : null + dateOfDisturbance: dateOfDisturbance != null ? new Date(dateOfDisturbance) : null, + ...(this.entitiesService.mapMediaCollection( + mediaCollection, + DisturbanceReport.MEDIA, + "disturbanceReports", + disturbanceReport.uuid + ) as DisturbanceReportMedia) }); return { id: disturbanceReport.uuid, dto }; @@ -146,6 +342,7 @@ export class DisturbanceReportProcessor extends ReportProcessor< return { id: disturbanceReport.uuid, dto: new DisturbanceReportLightDto(disturbanceReport, { + reportId: disturbanceReport.id, entries, intensity, dateOfDisturbance: dateOfDisturbance != null ? new Date(dateOfDisturbance) : null diff --git a/apps/entity-service/src/entities/processors/entity-processor.ts b/apps/entity-service/src/entities/processors/entity-processor.ts index 1f62f59b..e7a10354 100644 --- a/apps/entity-service/src/entities/processors/entity-processor.ts +++ b/apps/entity-service/src/entities/processors/entity-processor.ts @@ -13,7 +13,7 @@ import { NEEDS_MORE_INFORMATION, RESTORATION_IN_PROGRESS } from "@terramatch-microservices/database/constants/status"; -import { FinancialReport, ProjectReport } from "@terramatch-microservices/database/entities"; +import { ProjectReport } from "@terramatch-microservices/database/entities"; export type Aggregate> = { func: string; @@ -204,10 +204,6 @@ export abstract class ReportProcessor< } } - if (model instanceof FinancialReport && update.status === "approved") { - await this.processFinancialReportSpecificLogic(model); - } - await super.update(model, update); } @@ -218,13 +214,4 @@ export abstract class ReportProcessor< return { [Op.or]: [null, false] }; } }; - - /** - * Override this method in specific report processors to add custom logic - * @param model The report model being processed - */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - protected async processFinancialReportSpecificLogic(model: FinancialReport): Promise { - // This method will be overridden in FinancialReportProcessor - } } diff --git a/apps/entity-service/src/entities/processors/financial-report.processor.spec.ts b/apps/entity-service/src/entities/processors/financial-report.processor.spec.ts index 7687cc94..0af0ed56 100644 --- a/apps/entity-service/src/entities/processors/financial-report.processor.spec.ts +++ b/apps/entity-service/src/entities/processors/financial-report.processor.spec.ts @@ -246,7 +246,7 @@ describe("FinancialReportProcessor", () => { }); }); - describe("processFinancialReportSpecificLogic", () => { + describe("processReportSpecificLogic", () => { it("should update organisation fields when financial report is approved", async () => { const organisation = await OrganisationFactory.create({ finStartMonth: null, @@ -255,46 +255,22 @@ describe("FinancialReportProcessor", () => { const financialReport = await FinancialReportFactory.create({ organisationId: organisation.id, finStartMonth: 3, - currency: "USD" + currency: "USD", + status: "awaiting-approval" }); - await ( - processor as unknown as { - processFinancialReportSpecificLogic: (report: FinancialReport) => Promise; - } - ).processFinancialReportSpecificLogic(financialReport); + await processor.update(financialReport, { status: "approved" }); await organisation.reload(); expect(organisation.finStartMonth).toBe(3); expect(organisation.currency).toBe("USD"); }); - it("should log warning and return early when organisation is not found", async () => { - const organisation = await OrganisationFactory.create(); - const financialReport = await FinancialReportFactory.create({ - organisationId: organisation.id - }); - - financialReport.organisationId = 99999; - - const mockWarn = jest.fn(); - (processor as unknown as { logger: { warn: jest.Mock } }).logger = { - warn: mockWarn - }; - - await ( - processor as unknown as { - processFinancialReportSpecificLogic: (report: FinancialReport) => Promise; - } - ).processFinancialReportSpecificLogic(financialReport); - - expect(mockWarn).toHaveBeenCalledWith(`Organisation not found for FinancialReport ${financialReport.uuid}`); - }); - it("should create new financial indicators when none exist in organisation", async () => { const organisation = await OrganisationFactory.create(); const financialReport = await FinancialReportFactory.create({ - organisationId: organisation.id + organisationId: organisation.id, + status: "awaiting-approval" }); await FinancialIndicatorFactory.create({ @@ -320,11 +296,7 @@ describe("FinancialReportProcessor", () => { const mockBulkCreate = jest.spyOn(FinancialIndicator, "bulkCreate").mockResolvedValue([]); const mockUpdate = jest.spyOn(FinancialIndicator, "update").mockResolvedValue([1]); - await ( - processor as unknown as { - processFinancialReportSpecificLogic: (report: FinancialReport) => Promise; - } - ).processFinancialReportSpecificLogic(financialReport); + await processor.update(financialReport, { status: "approved" }); expect(mockBulkCreate).toHaveBeenCalledWith([ { @@ -354,7 +326,8 @@ describe("FinancialReportProcessor", () => { it("should update existing financial indicators when they exist in organisation", async () => { const organisation = await OrganisationFactory.create(); const financialReport = await FinancialReportFactory.create({ - organisationId: organisation.id + organisationId: organisation.id, + status: "awaiting-approval" }); const existingIndicator1 = await FinancialIndicatorFactory.create({ @@ -398,11 +371,7 @@ describe("FinancialReportProcessor", () => { const mockBulkCreate = jest.spyOn(FinancialIndicator, "bulkCreate").mockResolvedValue([]); const mockUpdate = jest.spyOn(FinancialIndicator, "update").mockResolvedValue([1]); - await ( - processor as unknown as { - processFinancialReportSpecificLogic: (report: FinancialReport) => Promise; - } - ).processFinancialReportSpecificLogic(financialReport); + await processor.update(financialReport, { status: "approved" }); expect(mockUpdate).toHaveBeenCalledTimes(2); expect(mockUpdate).toHaveBeenCalledWith( @@ -431,7 +400,8 @@ describe("FinancialReportProcessor", () => { it("should handle mixed scenario with both creating and updating indicators", async () => { const organisation = await OrganisationFactory.create(); const financialReport = await FinancialReportFactory.create({ - organisationId: organisation.id + organisationId: organisation.id, + status: "awaiting-approval" }); const existingIndicator = await FinancialIndicatorFactory.create({ @@ -466,11 +436,7 @@ describe("FinancialReportProcessor", () => { const mockBulkCreate = jest.spyOn(FinancialIndicator, "bulkCreate").mockResolvedValue([]); const mockUpdate = jest.spyOn(FinancialIndicator, "update").mockResolvedValue([1]); - await ( - processor as unknown as { - processFinancialReportSpecificLogic: (report: FinancialReport) => Promise; - } - ).processFinancialReportSpecificLogic(financialReport); + await processor.update(financialReport, { status: "approved" }); expect(mockBulkCreate).toHaveBeenCalledWith([ { @@ -500,17 +466,14 @@ describe("FinancialReportProcessor", () => { it("should handle case when no financial indicators exist in report", async () => { const organisation = await OrganisationFactory.create(); const financialReport = await FinancialReportFactory.create({ - organisationId: organisation.id + organisationId: organisation.id, + status: "awaiting-approval" }); const mockBulkCreate = jest.spyOn(FinancialIndicator, "bulkCreate").mockResolvedValue([]); const mockUpdate = jest.spyOn(FinancialIndicator, "update").mockResolvedValue([1]); - await ( - processor as unknown as { - processFinancialReportSpecificLogic: (report: FinancialReport) => Promise; - } - ).processFinancialReportSpecificLogic(financialReport); + await processor.update(financialReport, { status: "approved" }); expect(mockBulkCreate).not.toHaveBeenCalled(); expect(mockUpdate).not.toHaveBeenCalled(); diff --git a/apps/entity-service/src/entities/processors/financial-report.processor.ts b/apps/entity-service/src/entities/processors/financial-report.processor.ts index 452d3b56..79eb7121 100644 --- a/apps/entity-service/src/entities/processors/financial-report.processor.ts +++ b/apps/entity-service/src/entities/processors/financial-report.processor.ts @@ -31,10 +31,18 @@ export class FinancialReportProcessor extends ReportProcessor< readonly FULL_DTO = FinancialReportFullDto; private logger = new TMLogger(FinancialReportProcessor.name); + async update(model: FinancialReport, update: ReportUpdateAttributes) { + await super.update(model, update); + + if (update.status === "approved") { + await this.processReportSpecificLogic(model); + } + } + /** - * Specific method for FinancialReport custom logic. This is called automatically when nothingToReport is updated + * Specific method for FinancialReport custom logic. This is called automatically when the report is approved */ - protected async processFinancialReportSpecificLogic(model: FinancialReport): Promise { + private async processReportSpecificLogic(model: FinancialReport): Promise { const organisation = await Organisation.findByPk(model.organisationId); if (organisation == null) { this.logger.warn(`Organisation not found for FinancialReport ${model.uuid}`); diff --git a/apps/research-service/src/site-polygons/dto/site-polygon.dto.ts b/apps/research-service/src/site-polygons/dto/site-polygon.dto.ts index ab4a537a..8267c64b 100644 --- a/apps/research-service/src/site-polygons/dto/site-polygon.dto.ts +++ b/apps/research-service/src/site-polygons/dto/site-polygon.dto.ts @@ -57,6 +57,7 @@ export class SitePolygonLightDto extends HybridSupportDto { projectShortName: sitePolygon.site?.project?.shortName, indicators: indicators ?? [], siteName: sitePolygon.site?.name, + disturbanceableId: sitePolygon?.disturbance?.disturbanceableId ?? null, lightResource: true }); } @@ -169,6 +170,12 @@ export class SitePolygonLightDto extends HybridSupportDto { description: "UUID of the site polygon" }) uuid: string; + + @ApiProperty({ + type: Number, + nullable: true + }) + disturbanceableId: number | null; } @JsonApiDto({ type: "sitePolygons" }) @@ -191,6 +198,7 @@ export class SitePolygonFullDto extends SitePolygonLightDto { geometry: sitePolygon.polygon?.polygon, establishmentTreeSpecies: establishmentTreeSpecies ?? [], reportingPeriods: reportingPeriods ?? [], + disturbanceableId: sitePolygon.disturbance?.disturbanceableId ?? null, lightResource: false }); } 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 index 2a0eb376..3403cedb 100644 --- a/apps/research-service/src/site-polygons/site-polygon-query.builder.ts +++ b/apps/research-service/src/site-polygons/site-polygon-query.builder.ts @@ -10,7 +10,8 @@ import { PolygonGeometry, Project, Site, - SitePolygon + SitePolygon, + Disturbance } from "@terramatch-microservices/database/entities"; import { IndicatorSlug, PolygonStatus } from "@terramatch-microservices/database/constants"; import { uniq } from "lodash"; @@ -63,6 +64,11 @@ export class SitePolygonQueryBuilder extends PaginatedQueryBuilder model: PolygonGeometry, attributes: ["polygon"] }, + { + model: Disturbance, + attributes: ["disturbanceableId"], + required: false + }, this.siteJoin ]; diff --git a/libs/database/src/lib/entities/audit-status.entity.ts b/libs/database/src/lib/entities/audit-status.entity.ts index 339118ab..df461147 100644 --- a/libs/database/src/lib/entities/audit-status.entity.ts +++ b/libs/database/src/lib/entities/audit-status.entity.ts @@ -10,6 +10,7 @@ import { ProjectReport } from "./project-report.entity"; import { SiteReport } from "./site-report.entity"; import { NurseryReport } from "./nursery-report.entity"; import { SitePolygon } from "./site-polygon.entity"; +import { DisturbanceReport } from "./disturbance-report.entity"; const TYPES = ["change-request", "status", "submission", "comment", "change-request-updated", "reminder-sent"] as const; type AuditStatusType = (typeof TYPES)[number]; @@ -42,7 +43,8 @@ export class AuditStatus extends Model { ProjectReport.LARAVEL_TYPE, SiteReport.LARAVEL_TYPE, NurseryReport.LARAVEL_TYPE, - SitePolygon.LARAVEL_TYPE + SitePolygon.LARAVEL_TYPE, + DisturbanceReport.LARAVEL_TYPE ]; static for(auditable: LaravelModel) { diff --git a/libs/database/src/lib/entities/disturbance-report-entry.entity.ts b/libs/database/src/lib/entities/disturbance-report-entry.entity.ts index b4d711e1..fde73062 100644 --- a/libs/database/src/lib/entities/disturbance-report-entry.entity.ts +++ b/libs/database/src/lib/entities/disturbance-report-entry.entity.ts @@ -7,19 +7,28 @@ import { ForeignKey, BelongsTo, Model, - Index + Index, + Scopes } from "sequelize-typescript"; import { BIGINT, STRING, TEXT, UUID, UUIDV4 } from "sequelize"; import { DisturbanceReport } from "./disturbance-report.entity"; +import { chainScope } from "../util/chain-scope"; @Table({ tableName: "disturbance_report_entries", underscored: true, paranoid: true }) +@Scopes(() => ({ + report: (id: number) => ({ where: { disturbanceReportId: id } }) +})) export class DisturbanceReportEntry extends Model { static readonly LARAVEL_TYPE = "App\\Models\\V2\\DisturbanceReportEntry"; + static report(id: number) { + return chainScope(this, "report", id) as typeof DisturbanceReportEntry; + } + @PrimaryKey @AutoIncrement @Column(BIGINT.UNSIGNED) diff --git a/libs/database/src/lib/entities/disturbance-report.entity.ts b/libs/database/src/lib/entities/disturbance-report.entity.ts index 5fbd8ec5..d53ab421 100644 --- a/libs/database/src/lib/entities/disturbance-report.entity.ts +++ b/libs/database/src/lib/entities/disturbance-report.entity.ts @@ -30,6 +30,9 @@ import { Project } from "./project.entity"; }) export class DisturbanceReport extends Model { static readonly LARAVEL_TYPE = "App\\Models\\V2\\DisturbanceReport"; + static readonly MEDIA = { + media: { dbCollection: "media", multiple: true, validation: "general-documents" } + } as const; static project(id: number) { return chainScope(this, "project", id) as typeof DisturbanceReport; diff --git a/libs/database/src/lib/entities/site-polygon.entity.ts b/libs/database/src/lib/entities/site-polygon.entity.ts index c41cb5bd..b3442b95 100644 --- a/libs/database/src/lib/entities/site-polygon.entity.ts +++ b/libs/database/src/lib/entities/site-polygon.entity.ts @@ -41,7 +41,8 @@ export type Indicator = @Scopes(() => ({ active: { where: { isActive: true } }, approved: { where: { status: "approved" } }, - sites: (uuids: string[] | Literal) => ({ where: { siteUuid: { [Op.in]: uuids } } }) + sites: (uuids: string[] | Literal) => ({ where: { siteUuid: { [Op.in]: uuids } } }), + forUuids: (uuids: string[] | Literal) => ({ where: { uuid: { [Op.in]: uuids } } }) })) @Table({ tableName: "site_polygon", @@ -66,6 +67,10 @@ export class SitePolygon extends Model { return chainScope(this, "sites", uuids) as typeof SitePolygon; } + static forUuids(uuids: string[] | Literal) { + return chainScope(this, "forUuids", uuids) as typeof SitePolygon; + } + @PrimaryKey @AutoIncrement @Column(BIGINT.UNSIGNED)