Skip to content

Commit 1e82c90

Browse files
committed
fix(V2): enforce response contract and required-field validation for CSV batch
Return stagedCount/errorCount from batchUpload models. Controllers now branch response: full success (200), partial success (200 with errors), or total failure (400 with success:false) when zero rows are staged. Add required-field validation for INSERT rows — checks allowNull:false fields from the Sequelize model definition, excluding auto-managed fields (PK, orgUid, timestamps) and derivable fields (unitSerialId). UPDATE rows skip this check since missing fields merge from the DB. Addresses review findings: - FINDING 1: success:true when 0 rows staged (misleading contract) - FINDING 2: missing required-field validation for CSV INSERT rows
1 parent f97f7d7 commit 1e82c90

8 files changed

Lines changed: 353 additions & 46 deletions

File tree

docs/cadt_rpc_api_v2.md

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2422,7 +2422,7 @@ CSV headers may use either camelCase attribute names (e.g., `cadTrustProjectId`)
24222422

24232423
**Update behavior**: When a row includes a `cadTrustProjectId` that matches an existing project, the CSV row is **merged** with the existing DB record — fields present in the CSV overwrite the existing values; fields absent from the CSV retain their current values. This differs from the REST `PUT` endpoint, which requires all fields.
24242424

2425-
**Validation**: Rows are validated for foreign key existence (`cadTrustProgramId` must reference an existing program or a staged program record). Rows that fail validation are skipped and their errors are returned in the response. The CSV validation is intentionally more lenient than the REST API — fields like `projectLink` and `projectStatusDate` that are required in the REST schema are optional in CSV batch upload.
2425+
**Validation**: INSERT rows are validated for required fields (`projectRegistryName`, `projectId`, `projectName`) and foreign key existence (`cadTrustProgramId` must reference an existing or staged program). UPDATE rows skip required-field checks since missing fields are merged from the existing record. Rows that fail validation are skipped and their errors are returned in the response with row numbers. The CSV validation is intentionally more lenient than the REST API — fields like `projectLink` and `projectStatusDate` that are required in the REST schema are optional in CSV batch upload. If **all** rows fail validation, the response returns HTTP 400 with `success: false`.
24262426

24272427
**Ownership**: UPDATE rows are verified to belong to the home organization. Attempting to update a project owned by another organization will produce an error for that row.
24282428

@@ -2444,20 +2444,38 @@ Request
24442444
curl --location --request POST 'http://localhost:31310/v2/project/batch' --form 'csv=@"./createProject.csv"'
24452445
```
24462446

2447-
Response (success, no row-level errors)
2447+
Response (full success — all rows staged)
24482448
```json
24492449
{
24502450
"message": "CSV processing complete, your records have been added to the staging table.",
2451-
"success": true
2451+
"success": true,
2452+
"stagedCount": 3,
2453+
"errorCount": 0
24522454
}
24532455
```
24542456

2455-
Response (success with row-level errors — valid rows are still staged)
2457+
Response (partial success — some rows staged, some failed)
24562458
```json
24572459
{
2458-
"message": "CSV processing complete, your records have been added to the staging table.",
2460+
"message": "CSV processing complete. 2 row(s) staged, 1 row(s) skipped due to errors.",
24592461
"success": true,
2462+
"stagedCount": 2,
2463+
"errorCount": 1,
2464+
"errors": [
2465+
{ "row": 3, "error": "cadTrustProgramId 'invalid-id' does not exist" }
2466+
]
2467+
}
2468+
```
2469+
2470+
Response (failure — no rows staged, HTTP 400)
2471+
```json
2472+
{
2473+
"message": "No rows were staged. All rows failed validation.",
2474+
"success": false,
2475+
"stagedCount": 0,
2476+
"errorCount": 2,
24602477
"errors": [
2478+
{ "row": 2, "error": "Missing required field(s): projectName" },
24612479
{ "row": 3, "error": "cadTrustProgramId 'invalid-id' does not exist" }
24622480
]
24632481
}
@@ -3718,7 +3736,7 @@ CSV headers may use either camelCase attribute names (e.g., `cadTrustUnitId`) or
37183736

37193737
**Serial ID derivation**: If `unitSerialId` is not provided but `unitStartBlock` and `unitEndBlock` are, the serial ID is automatically derived as `<unitStartBlock>-<unitEndBlock>`.
37203738

3721-
**Validation**: Rows are validated for foreign key existence (`cadTrustIssuanceId` must reference an existing issuance or a staged issuance record). Rows that fail validation are skipped and their errors are returned in the response.
3739+
**Validation**: INSERT rows are validated for required fields (`unitStartBlock`, `unitEndBlock`, `unitVintageYear`, `cadTrustIssuanceId`) and foreign key existence (`cadTrustIssuanceId` must reference an existing or staged issuance). `unitSerialId` is not required if `unitStartBlock` and `unitEndBlock` are provided (it is derived automatically). UPDATE rows skip required-field checks since missing fields are merged from the existing record. Rows that fail validation are skipped and their errors are returned in the response with row numbers. If **all** rows fail validation, the response returns HTTP 400 with `success: false`.
37223740

37233741
**Ownership**: UPDATE rows are verified to belong to the home organization. Attempting to update a unit owned by another organization will produce an error for that row.
37243742

@@ -3727,25 +3745,42 @@ Request
37273745
curl --location --request POST 'http://localhost:31310/v2/unit/batch' --form 'csv=@"./createUnit.csv"'
37283746
```
37293747

3730-
Response (success, no row-level errors)
3748+
Response (full success — all rows staged)
37313749
```json
37323750
{
37333751
"message": "CSV processing complete, your records have been added to the staging table.",
3734-
"success": true
3752+
"success": true,
3753+
"stagedCount": 3,
3754+
"errorCount": 0
37353755
}
37363756
```
37373757

3738-
Response (success with row-level errors — valid rows are still staged)
3758+
Response (partial success — some rows staged, some failed)
37393759
```json
37403760
{
3741-
"message": "CSV processing complete, your records have been added to the staging table.",
3761+
"message": "CSV processing complete. 2 row(s) staged, 1 row(s) skipped due to errors.",
37423762
"success": true,
3763+
"stagedCount": 2,
3764+
"errorCount": 1,
37433765
"errors": [
37443766
{ "row": 4, "error": "cadTrustIssuanceId 'invalid-id' does not exist" }
37453767
]
37463768
}
37473769
```
37483770

3771+
Response (failure — no rows staged, HTTP 400)
3772+
```json
3773+
{
3774+
"message": "No rows were staged. All rows failed validation.",
3775+
"success": false,
3776+
"stagedCount": 0,
3777+
"errorCount": 1,
3778+
"errors": [
3779+
{ "row": 2, "error": "Missing required field(s): unitStartBlock, unitEndBlock" }
3780+
]
3781+
}
3782+
```
3783+
37493784
---
37503785

37513786
<a id="unit-put-examples"></a>

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

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -961,15 +961,27 @@ export const batchUpload = async (req, res) => {
961961
const result = await ProjectV2.batchUpload({ data: req.file.buffer });
962962

963963
const response = {
964-
message:
965-
'CSV processing complete, your records have been added to the staging table.',
966-
success: true,
964+
stagedCount: result.stagedCount,
965+
errorCount: result.errorCount,
967966
};
968967

969-
if (result.errors && result.errors.length > 0) {
968+
if (result.errorCount > 0) {
970969
response.errors = result.errors;
971970
}
972971

972+
if (result.stagedCount === 0) {
973+
response.success = false;
974+
response.message = 'No rows were staged. All rows failed validation.';
975+
return res.status(400).json(response);
976+
}
977+
978+
response.success = true;
979+
if (result.errorCount > 0) {
980+
response.message = `CSV processing complete. ${result.stagedCount} row(s) staged, ${result.errorCount} row(s) skipped due to errors.`;
981+
} else {
982+
response.message = 'CSV processing complete, your records have been added to the staging table.';
983+
}
984+
973985
res.json(response);
974986
} catch (error) {
975987
loggerV2.error('[v2]: Batch Upload Failed.', error);

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

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -941,15 +941,27 @@ export const batchUpload = async (req, res) => {
941941
const result = await UnitV2.batchUpload({ data: req.file.buffer });
942942

943943
const response = {
944-
message:
945-
'CSV processing complete, your records have been added to the staging table.',
946-
success: true,
944+
stagedCount: result.stagedCount,
945+
errorCount: result.errorCount,
947946
};
948947

949-
if (result.errors && result.errors.length > 0) {
948+
if (result.errorCount > 0) {
950949
response.errors = result.errors;
951950
}
952951

952+
if (result.stagedCount === 0) {
953+
response.success = false;
954+
response.message = 'No rows were staged. All rows failed validation.';
955+
return res.status(400).json(response);
956+
}
957+
958+
response.success = true;
959+
if (result.errorCount > 0) {
960+
response.message = `CSV processing complete. ${result.stagedCount} row(s) staged, ${result.errorCount} row(s) skipped due to errors.`;
961+
} else {
962+
response.message = 'CSV processing complete, your records have been added to the staging table.';
963+
}
964+
953965
res.json(response);
954966
} catch (error) {
955967
loggerV2.error('[v2]: Batch Upload Failed.', error);

src/models/v2/project-v2.model.js

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
normalizeCsvHeaders,
1717
toDbFieldNames,
1818
stripUnknownDbFields,
19+
validateRequiredFields,
1920
} from '../../utils/v2-xls.js';
2021
import { assertRecordExistanceOrStaged } from '../../utils/v2-data-assertions.js';
2122
import { ProgramV2 } from './program-v2.model.js';
@@ -373,7 +374,7 @@ class ProjectV2 extends Model {
373374
* 6. Upsert into StagingV2
374375
*
375376
* @param {Object} csvFile - CSV file object with data buffer
376-
* @returns {Promise<Object>} Result with errors array (may be empty)
377+
* @returns {Promise<Object>} { stagedCount, errorCount, errors }
377378
*/
378379
static async batchUpload(csvFile) {
379380
const buffer = csvFile.data;
@@ -402,6 +403,7 @@ class ProjectV2 extends Model {
402403
const orgUid = homeOrg.org_uid;
403404

404405
const errors = [];
406+
let stagedCount = 0;
405407

406408
await sequelizeV2.transaction(async (transaction) => {
407409
for (let i = 0; i < rawRows.length; i++) {
@@ -433,6 +435,15 @@ class ProjectV2 extends Model {
433435
mergedRecord = { ...row };
434436
}
435437

438+
// Required-field validation for INSERT rows
439+
if (action === 'INSERT') {
440+
const missing = validateRequiredFields(mergedRecord, ProjectV2);
441+
if (missing.length > 0) {
442+
errors.push({ row: rowNum, error: `Missing required field(s): ${missing.join(', ')}` });
443+
continue;
444+
}
445+
}
446+
436447
// FK existence check for cadTrustProgramId
437448
if (mergedRecord.cadTrustProgramId) {
438449
try {
@@ -467,13 +478,14 @@ class ProjectV2 extends Model {
467478
},
468479
{ transaction },
469480
);
481+
stagedCount++;
470482
} catch (err) {
471483
errors.push({ row: rowNum, error: err.message });
472484
}
473485
}
474486
});
475487

476-
return { errors };
488+
return { stagedCount, errorCount: errors.length, errors };
477489
}
478490

479491
/**

src/models/v2/unit-v2.model.js

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
normalizeCsvHeaders,
2121
toDbFieldNames,
2222
stripUnknownDbFields,
23+
validateRequiredFields,
2324
} from '../../utils/v2-xls.js';
2425
import { assertRecordExistanceOrStaged } from '../../utils/v2-data-assertions.js';
2526
import { IssuanceV2 } from './issuance-v2.model.js';
@@ -434,7 +435,7 @@ class UnitV2 extends Model {
434435
* 6. Upsert into StagingV2
435436
*
436437
* @param {Object} csvFile - CSV file object with data buffer
437-
* @returns {Promise<Object>} Result with errors array (may be empty)
438+
* @returns {Promise<Object>} { stagedCount, errorCount, errors }
438439
*/
439440
static async batchUpload(csvFile) {
440441
const buffer = csvFile.data;
@@ -463,6 +464,10 @@ class UnitV2 extends Model {
463464
const orgUid = homeOrg.org_uid;
464465

465466
const errors = [];
467+
let stagedCount = 0;
468+
469+
// unitSerialId is derivable from blocks — don't require it if blocks are present
470+
const unitSkipFields = new Set(['unitSerialId']);
466471

467472
await sequelizeV2.transaction(async (transaction) => {
468473
for (let i = 0; i < rawRows.length; i++) {
@@ -495,6 +500,15 @@ class UnitV2 extends Model {
495500
mergedRecord = { ...row };
496501
}
497502

503+
// Required-field validation for INSERT rows
504+
if (action === 'INSERT') {
505+
const missing = validateRequiredFields(mergedRecord, UnitV2, unitSkipFields);
506+
if (missing.length > 0) {
507+
errors.push({ row: rowNum, error: `Missing required field(s): ${missing.join(', ')}` });
508+
continue;
509+
}
510+
}
511+
498512
// FK existence check for cadTrustIssuanceId
499513
if (mergedRecord.cadTrustIssuanceId) {
500514
try {
@@ -529,13 +543,14 @@ class UnitV2 extends Model {
529543
},
530544
{ transaction },
531545
);
546+
stagedCount++;
532547
} catch (err) {
533548
errors.push({ row: rowNum, error: err.message });
534549
}
535550
}
536551
});
537552

538-
return { errors };
553+
return { stagedCount, errorCount: errors.length, errors };
539554
}
540555

541556

src/utils/v2-xls.js

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,42 @@ export function stripUnknownDbFields(dbRow, modelClass, logger = null) {
256256
return cleaned;
257257
}
258258

259+
/**
260+
* Validate that a camelCase row destined for INSERT contains all fields
261+
* marked `allowNull: false` in the model definition. Skips fields that
262+
* are auto-managed (primary key, orgUid, timestamps) and any fields listed
263+
* in the optional `skipFields` set (e.g. `unitSerialId` when it can be
264+
* derived from other fields).
265+
*
266+
* @param {Object} row - camelCase row to validate
267+
* @param {import('sequelize').Model} modelClass
268+
* @param {Set<string>} [skipFields] - attribute names to skip
269+
* @returns {string[]} array of missing field names (empty if valid)
270+
*/
271+
export function validateRequiredFields(row, modelClass, skipFields = new Set()) {
272+
const autoManaged = new Set([
273+
modelClass.primaryKeyAttribute,
274+
'orgUid',
275+
'createdAt',
276+
'updatedAt',
277+
// Models with underscored:true and custom timestamp mappings use
278+
// snake_case attribute names in rawAttributes
279+
'created_at',
280+
'updated_at',
281+
]);
282+
283+
const missing = [];
284+
for (const [attrName, meta] of Object.entries(modelClass.rawAttributes)) {
285+
if (meta.allowNull === false && !autoManaged.has(attrName) && !skipFields.has(attrName)) {
286+
const val = row[attrName];
287+
if (val === undefined || val === null || val === '') {
288+
missing.push(attrName);
289+
}
290+
}
291+
}
292+
return missing;
293+
}
294+
259295
/**
260296
* Create StagingV2 records from parsed XLSX data.
261297
* Parent rows and child rows each get their own staging entry, matching the

0 commit comments

Comments
 (0)