Skip to content

Commit a1b75d8

Browse files
committed
fix(V2): guard project deletes for AEF references
1 parent e445c91 commit a1b75d8

4 files changed

Lines changed: 198 additions & 21 deletions

File tree

src/controllers/v2/project-v2.controller.js

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,32 @@ import { loggerV2 } from '../../config/logger.js';
3535
import { projectV2Schema } from '../../validations/v2/project-v2.validations.js';
3636
import { formatModelAssociationName } from '../../utils/model-utils.js';
3737
import { resolveOrgUid } from '../../utils/owner-utils.js';
38-
import { stageProjectChildDeletes } from '../../utils/v2-cascade-delete.js';
38+
import { getProjectCascadeUnits, stageProjectChildDeletes } from '../../utils/v2-cascade-delete.js';
3939
import { checkReferences, buildReferenceConflictBody } from '../../utils/v2-reference-guards.js';
4040

41+
const checkProjectCascadeUnitReferences = async (projectId) => {
42+
const units = await getProjectCascadeUnits(projectId);
43+
const referencesByTable = new Map();
44+
45+
for (const unit of units) {
46+
const unitRefResult = await checkReferences('unit', unit.cadTrustUnitId);
47+
for (const reference of unitRefResult.references) {
48+
const existing = referencesByTable.get(reference.table);
49+
if (existing) {
50+
existing.count += reference.count;
51+
} else {
52+
referencesByTable.set(reference.table, { ...reference });
53+
}
54+
}
55+
}
56+
57+
const references = [...referencesByTable.values()];
58+
return {
59+
hasReferences: references.length > 0,
60+
references,
61+
};
62+
};
63+
4164
export const create = async (req, res) => {
4265
try {
4366
await assertV2IfReadOnlyMode();
@@ -825,6 +848,11 @@ export const destroy = async (req, res) => {
825848
return res.status(409).json(buildReferenceConflictBody('project', refResult));
826849
}
827850

851+
const cascadeRefResult = await checkProjectCascadeUnitReferences(id);
852+
if (cascadeRefResult.hasReferences) {
853+
return res.status(409).json(buildReferenceConflictBody('project', cascadeRefResult));
854+
}
855+
828856
const releaseTransactionMutex =
829857
await processingSyncRegistriesTransactionMutexV2.acquire();
830858
let stagedChildDeletes;

src/utils/v2-cascade-delete.js

Lines changed: 47 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,44 @@ export const stageUnitChildDeletes = async (unitId, options = {}) => {
188188
return rows.length;
189189
};
190190

191+
export const getProjectCascadeUnits = async (projectId, options = {}) => {
192+
const { transaction } = options;
193+
194+
const verifications = await VerificationV2.findAll({
195+
where: { cadTrustProjectId: projectId },
196+
raw: true,
197+
transaction,
198+
});
199+
200+
const verificationIds = verifications
201+
.map((verification) => verification.cadTrustVerificationId)
202+
.filter(Boolean);
203+
204+
if (verificationIds.length === 0) {
205+
return [];
206+
}
207+
208+
const issuances = await IssuanceV2.findAll({
209+
where: { cadTrustVerificationId: verificationIds },
210+
raw: true,
211+
transaction,
212+
});
213+
214+
const issuanceIds = issuances
215+
.map((issuance) => issuance.cadTrustIssuanceId)
216+
.filter(Boolean);
217+
218+
if (issuanceIds.length === 0) {
219+
return [];
220+
}
221+
222+
return UnitV2.findAll({
223+
where: { cadTrustIssuanceId: issuanceIds },
224+
raw: true,
225+
transaction,
226+
});
227+
};
228+
191229
export const stageProjectChildDeletes = async (projectId, options = {}) => {
192230
const { transaction } = options;
193231
const rows = [];
@@ -230,30 +268,20 @@ export const stageProjectChildDeletes = async (projectId, options = {}) => {
230268
});
231269
pushRowsForRecords(rows, issuances, 'issuance', 'cad_trust_issuance_id');
232270

233-
const issuanceIds = issuances
234-
.map((issuance) => issuance.cadTrustIssuanceId)
271+
const units = await getProjectCascadeUnits(projectId, { transaction });
272+
pushRowsForRecords(rows, units, 'unit', 'cad_trust_unit_id');
273+
274+
const unitIds = units
275+
.map((unit) => unit.cadTrustUnitId)
235276
.filter(Boolean);
236277

237-
if (issuanceIds.length > 0) {
238-
const units = await UnitV2.findAll({
239-
where: { cadTrustIssuanceId: issuanceIds },
278+
if (unitIds.length > 0) {
279+
const unitLabels = await UnitLabelV2.findAll({
280+
where: { cadTrustUnitId: unitIds },
240281
raw: true,
241282
transaction,
242283
});
243-
pushRowsForRecords(rows, units, 'unit', 'cad_trust_unit_id');
244-
245-
const unitIds = units
246-
.map((unit) => unit.cadTrustUnitId)
247-
.filter(Boolean);
248-
249-
if (unitIds.length > 0) {
250-
const unitLabels = await UnitLabelV2.findAll({
251-
where: { cadTrustUnitId: unitIds },
252-
raw: true,
253-
transaction,
254-
});
255-
pushRowsForRecords(rows, unitLabels, 'unit_label', 'cad_trust_unit_label_id');
256-
}
284+
pushRowsForRecords(rows, unitLabels, 'unit_label', 'cad_trust_unit_label_id');
257285
}
258286
}
259287

src/utils/v2-reference-guards.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,30 @@ const REFERENCE_MAP = {
112112
&& record?.cad_trust_project_id !== recordId
113113
),
114114
},
115+
{
116+
model: AefT5AuthorizedEntitiesV2,
117+
fkField: 'cadTrustProjectId',
118+
table: 'aef_t5_authorized_entities',
119+
label: 'AEF-T5 authorized entity records',
120+
},
121+
{
122+
model: AefT2AuthorizationsV2,
123+
fkField: 'cadTrustProjectId',
124+
table: 'aef_t2_authorizations',
125+
label: 'AEF-T2 authorization records',
126+
},
127+
{
128+
model: AefT3ActionsV2,
129+
fkField: 'cadTrustProjectId',
130+
table: 'aef_t3_actions',
131+
label: 'AEF-T3 action records',
132+
},
133+
{
134+
model: AefT4HoldingsV2,
135+
fkField: 'cadTrustProjectId',
136+
table: 'aef_t4_holdings',
137+
label: 'AEF-T4 holding records',
138+
},
115139
],
116140
unit: [
117141
{

tests/v2/integration/project-v2.spec.js

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { expect } from 'chai';
22
import supertest from 'supertest';
33
import app from '../../../src/server.js';
44
import { prepareV2Db } from '../../../src/database/v2/index.js';
5-
import { StagingV2, ProjectV2, ProgramV2, LocationV2, EstimationV2, RatingV2, CoBenefitV2, ValidationV2, VerificationV2, MethodologyV2, ProjectMethodologyV2, StakeholderV2, StakeholderProjectV2, IssuanceV2, UnitV2, LabelV2, UnitLabelV2 } from '../../../src/models/v2/index.js';
5+
import { StagingV2, ProjectV2, ProgramV2, LocationV2, EstimationV2, RatingV2, CoBenefitV2, ValidationV2, VerificationV2, MethodologyV2, ProjectMethodologyV2, StakeholderV2, StakeholderProjectV2, IssuanceV2, UnitV2, LabelV2, UnitLabelV2, AefT1SubmissionV2, AefT5AuthorizedEntitiesV2 } from '../../../src/models/v2/index.js';
66
import { v4 as uuidv4 } from 'uuid';
77

88
import {
@@ -1244,6 +1244,103 @@ describe('V2 Project API - Basic CRUD Tests', function () {
12441244
}
12451245
});
12461246

1247+
it('should return 409 when AEF records reference this project', async function () {
1248+
const chain = await createV2TestProgramChain({ testId: `PROJ-AEF-PROJ-${uuidv4().slice(0, 8)}` });
1249+
const t1 = await AefT1SubmissionV2.create({
1250+
cadTrustAefT1SubmissionId: uuidv4(),
1251+
aefT1SubmissionParty: 'Project reference guard party',
1252+
aefT1SubmissionVersion: '1.0',
1253+
aefT1SubmissionReportYear: 2024,
1254+
aefT1SubmissionSubmissionDate: '2024-01-15',
1255+
});
1256+
await AefT5AuthorizedEntitiesV2.create({
1257+
cadTrustAefT5AuthorizedEntitiesId: uuidv4(),
1258+
aefT5AuthorizedEntitiesAuthorizationDate: '2024-01-15',
1259+
aefT5AuthorizedEntitiesName: 'Project reference guard entity',
1260+
aefT5AuthorizedEntitiesId: `PROJ-REF-AE-${uuidv4().slice(0, 6)}`,
1261+
aefT5AuthorizedEntitiesCooperativeApproachId: `PROJ-REF-CA-${uuidv4().slice(0, 6)}`,
1262+
cadTrustAefT1SubmissionId: t1.cadTrustAefT1SubmissionId,
1263+
cadTrustProjectId: chain.project.cadTrustProjectId,
1264+
});
1265+
1266+
const response = await supertest(app)
1267+
.delete(`/v2/project/${chain.project.cadTrustProjectId}`)
1268+
.expect(409);
1269+
1270+
expect(response.body.success).to.be.false;
1271+
expect(response.body.error).to.equal('Referenced records must be removed before deletion');
1272+
expect(response.body.references).to.deep.include({ table: 'aef_t5_authorized_entities', count: 1 });
1273+
1274+
const stagingDeletes = await StagingV2.findAll({
1275+
where: { table: 'project', action: 'DELETE' },
1276+
raw: true,
1277+
});
1278+
const projectDelete = stagingDeletes.find((row) => {
1279+
const data = JSON.parse(row.data);
1280+
return data[0]?.cad_trust_project_id === chain.project.cadTrustProjectId;
1281+
});
1282+
expect(projectDelete).to.be.undefined;
1283+
});
1284+
1285+
it('should return 409 when cascade unit has AEF references', async function () {
1286+
const homeOrgId = await getV2HomeOrgId();
1287+
const chain = await createV2TestProgramChain({ testId: `PROJ-AEF-GUARD-${uuidv4().slice(0, 8)}` });
1288+
const unit = await UnitV2.create(addUuidIfNeeded('UnitV2', {
1289+
unitSerialId: `PROJ-AEF-UNIT-${uuidv4().slice(0, 8)}`,
1290+
unitStartBlock: '1',
1291+
unitEndBlock: '100',
1292+
unitCount: 50,
1293+
unitType: 'Avoidance - nature',
1294+
unitVintageYear: 2024,
1295+
unitStatus: 'Issued',
1296+
unitMetric: 'tCO2e',
1297+
cadTrustIssuanceId: chain.issuance.cadTrustIssuanceId,
1298+
orgUid: homeOrgId,
1299+
}));
1300+
const t1 = await AefT1SubmissionV2.create({
1301+
cadTrustAefT1SubmissionId: uuidv4(),
1302+
aefT1SubmissionParty: 'Project cascade guard party',
1303+
aefT1SubmissionVersion: '1.0',
1304+
aefT1SubmissionReportYear: 2024,
1305+
aefT1SubmissionSubmissionDate: '2024-01-15',
1306+
});
1307+
await AefT5AuthorizedEntitiesV2.create({
1308+
cadTrustAefT5AuthorizedEntitiesId: uuidv4(),
1309+
aefT5AuthorizedEntitiesAuthorizationDate: '2024-01-15',
1310+
aefT5AuthorizedEntitiesName: 'Project cascade guard entity',
1311+
aefT5AuthorizedEntitiesId: `PROJ-AE-${uuidv4().slice(0, 6)}`,
1312+
aefT5AuthorizedEntitiesCooperativeApproachId: `PROJ-CA-${uuidv4().slice(0, 6)}`,
1313+
cadTrustAefT1SubmissionId: t1.cadTrustAefT1SubmissionId,
1314+
cadTrustUnitId: unit.cadTrustUnitId,
1315+
cadTrustProjectId: null,
1316+
});
1317+
1318+
const response = await supertest(app)
1319+
.delete(`/v2/project/${chain.project.cadTrustProjectId}`)
1320+
.expect(409);
1321+
1322+
expect(response.body.success).to.be.false;
1323+
expect(response.body.error).to.equal('Referenced records must be removed before deletion');
1324+
expect(response.body.references).to.deep.include({ table: 'aef_t5_authorized_entities', count: 1 });
1325+
1326+
const stagingDeletes = await StagingV2.findAll({
1327+
where: { action: 'DELETE' },
1328+
raw: true,
1329+
});
1330+
const projectDelete = stagingDeletes.find((row) => {
1331+
if (row.table !== 'project') return false;
1332+
const data = JSON.parse(row.data);
1333+
return data[0]?.cad_trust_project_id === chain.project.cadTrustProjectId;
1334+
});
1335+
const unitDelete = stagingDeletes.find((row) => {
1336+
if (row.table !== 'unit') return false;
1337+
const data = JSON.parse(row.data);
1338+
return data[0]?.cad_trust_unit_id === unit.cadTrustUnitId;
1339+
});
1340+
expect(projectDelete).to.be.undefined;
1341+
expect(unitDelete).to.be.undefined;
1342+
});
1343+
12471344
it('should return 409 when another project references this project', async function () {
12481345
const homeOrgId = await getV2HomeOrgId();
12491346
const program = await ProgramV2.create({

0 commit comments

Comments
 (0)