Add data management panel with delete vertical, rollback upload, and reset all

Backend:
- DELETE /api/compliance/vcl-multi/vertical/:code — wipe a single vertical
- DELETE /api/compliance/vcl-multi/upload/:uploadId — rollback most recent upload
- DELETE /api/compliance/vcl-multi/all — nuclear reset of all multi-vertical data
- All delete operations are Admin-only and audit-logged

Frontend:
- Manage button (red, Admin-only) in CCP Metrics header
- DataManagementPanel modal showing upload history grouped by vertical
- Per-vertical delete button
- Per-upload rollback button (most recent only)
- Reset All button with confirmation dialog
- Success/error messaging
This commit is contained in:
Jordan Ramos
2026-05-14 11:54:58 -06:00
parent 1eb8eab76f
commit 408aaa7012
2 changed files with 461 additions and 21 deletions

View File

@@ -874,6 +874,253 @@ function createVCLMultiVerticalRouter(upload) {
}
});
// -----------------------------------------------------------------------
// Data Management — Delete / Rollback
// -----------------------------------------------------------------------
/**
* DELETE /vertical/:code
* Deletes all data for a single vertical — items, uploads, summary, and snapshots.
* Admin only.
*
* @method DELETE
* @route /vertical/:code
* @group Admin
* @param {string} code — vertical code to delete (e.g., "NTS_AEO")
*
* @response 200
* {
* message: string,
* deleted: { items: number, uploads: number }
* }
* @response 400 { error: string } — invalid vertical code
* @response 500 { error: string }
*/
router.delete('/vertical/:code', requireGroup('Admin'), async (req, res) => {
const vertical = req.params.code;
if (!vertical || vertical.length > 100) return res.status(400).json({ error: 'Invalid vertical code' });
const client = await pool.connect();
try {
await client.query('BEGIN');
const { rows: uploadRows } = await client.query(
`SELECT id FROM compliance_uploads WHERE vertical = $1`, [vertical]
);
const uploadIds = uploadRows.map(r => r.id);
if (uploadIds.length > 0) {
await client.query(
`DELETE FROM vcl_multi_vertical_summary WHERE upload_id = ANY($1)`, [uploadIds]
);
}
const itemResult = await client.query(
`DELETE FROM compliance_items WHERE vertical = $1`, [vertical]
);
const uploadResult = await client.query(
`DELETE FROM compliance_uploads WHERE vertical = $1`, [vertical]
);
await client.query(
`DELETE FROM compliance_snapshots WHERE vertical = $1`, [vertical]
);
await client.query('COMMIT');
logAudit({
userId: req.user.id,
username: req.user.username,
action: 'vcl_multi_vertical_delete',
entityType: 'compliance_vertical',
entityId: vertical,
details: { items_deleted: itemResult.rowCount, uploads_deleted: uploadResult.rowCount },
ipAddress: req.ip,
});
res.json({
message: `Deleted all data for vertical "${vertical}"`,
deleted: { items: itemResult.rowCount, uploads: uploadResult.rowCount },
});
} catch (err) {
await client.query('ROLLBACK');
console.error('[VCL Multi] DELETE /vertical/:code error:', err.message);
res.status(500).json({ error: 'Failed to delete vertical data' });
} finally {
client.release();
}
});
/**
* DELETE /all
* Deletes ALL multi-vertical data — items, uploads, summary, and snapshots.
* Admin only. Nuclear reset.
*
* @method DELETE
* @route /all
* @group Admin
*
* @response 200
* {
* message: string,
* deleted: { items: number, uploads: number }
* }
* @response 500 { error: string }
*/
router.delete('/all', requireGroup('Admin'), async (req, res) => {
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query(`DELETE FROM vcl_multi_vertical_summary`);
const itemResult = await client.query(`DELETE FROM compliance_items WHERE vertical IS NOT NULL`);
const uploadResult = await client.query(`DELETE FROM compliance_uploads WHERE vertical IS NOT NULL`);
await client.query(`DELETE FROM compliance_snapshots WHERE vertical IS NOT NULL AND vertical != ''`);
await client.query('COMMIT');
logAudit({
userId: req.user.id,
username: req.user.username,
action: 'vcl_multi_vertical_reset',
entityType: 'compliance_vertical',
entityId: 'ALL',
details: { items_deleted: itemResult.rowCount, uploads_deleted: uploadResult.rowCount },
ipAddress: req.ip,
});
res.json({
message: 'All multi-vertical data has been deleted',
deleted: { items: itemResult.rowCount, uploads: uploadResult.rowCount },
});
} catch (err) {
await client.query('ROLLBACK');
console.error('[VCL Multi] DELETE /all error:', err.message);
res.status(500).json({ error: 'Failed to reset data' });
} finally {
client.release();
}
});
/**
* DELETE /upload/:uploadId
* Rolls back a specific upload — deletes items introduced by it, reactivates items it resolved.
* Admin only. Must be the most recent upload for that vertical.
*
* @method DELETE
* @route /upload/:uploadId
* @group Admin
* @param {number} uploadId — numeric ID of the upload to roll back
*
* @response 200
* {
* message: string,
* rolled_back: {
* upload_id: number,
* vertical: string,
* filename: string,
* items_deleted: number,
* items_reactivated: number
* }
* }
* @response 400 { error: string } — invalid upload ID, not a multi-vertical upload, or not the most recent upload
* @response 404 { error: string } — upload not found
* @response 500 { error: string }
*/
router.delete('/upload/:uploadId', requireGroup('Admin'), async (req, res) => {
const uploadId = parseInt(req.params.uploadId, 10);
if (isNaN(uploadId)) return res.status(400).json({ error: 'Invalid upload ID' });
try {
const { rows: uploadRows } = await pool.query(
`SELECT id, filename, report_date, vertical, new_count, resolved_count FROM compliance_uploads WHERE id = $1`,
[uploadId]
);
const upload = uploadRows[0];
if (!upload) return res.status(404).json({ error: 'Upload not found' });
if (!upload.vertical) return res.status(400).json({ error: 'This upload is not a multi-vertical upload' });
const { rows: latestRows } = await pool.query(
`SELECT id FROM compliance_uploads WHERE vertical = $1 ORDER BY id DESC LIMIT 1`,
[upload.vertical]
);
if (latestRows[0].id !== uploadId) {
return res.status(400).json({
error: `Only the most recent upload for "${upload.vertical}" can be rolled back`,
latest_upload_id: latestRows[0].id,
});
}
const client = await pool.connect();
try {
await client.query('BEGIN');
const deleteNew = await client.query(
`DELETE FROM compliance_items WHERE first_seen_upload_id = $1 AND upload_id = $1`, [uploadId]
);
const reactivate = await client.query(
`UPDATE compliance_items SET status = 'active', resolved_upload_id = NULL WHERE resolved_upload_id = $1`, [uploadId]
);
const { rows: prevRows } = await pool.query(
`SELECT id FROM compliance_uploads WHERE vertical = $1 AND id < $2 ORDER BY id DESC LIMIT 1`,
[upload.vertical, uploadId]
);
if (prevRows.length > 0) {
await client.query(
`UPDATE compliance_items SET upload_id = $1, seen_count = GREATEST(seen_count - 1, 1)
WHERE upload_id = $2 AND first_seen_upload_id != $2`,
[prevRows[0].id, uploadId]
);
}
await client.query(`DELETE FROM vcl_multi_vertical_summary WHERE upload_id = $1`, [uploadId]);
await client.query(`DELETE FROM compliance_uploads WHERE id = $1`, [uploadId]);
const currentMonth = new Date().toISOString().slice(0, 7);
await client.query(
`DELETE FROM compliance_snapshots WHERE vertical = $1 AND snapshot_month = $2`,
[upload.vertical, currentMonth]
);
await client.query('COMMIT');
logAudit({
userId: req.user.id,
username: req.user.username,
action: 'vcl_multi_upload_rollback',
entityType: 'compliance_upload',
entityId: String(uploadId),
details: {
vertical: upload.vertical,
filename: upload.filename,
items_deleted: deleteNew.rowCount,
items_reactivated: reactivate.rowCount,
},
ipAddress: req.ip,
});
res.json({
message: `Rolled back upload "${upload.filename}" for ${upload.vertical}`,
rolled_back: {
upload_id: uploadId,
vertical: upload.vertical,
filename: upload.filename,
items_deleted: deleteNew.rowCount,
items_reactivated: reactivate.rowCount,
},
});
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
} catch (err) {
console.error('[VCL Multi] DELETE /upload/:uploadId error:', err.message);
res.status(500).json({ error: 'Failed to rollback upload: ' + err.message });
}
});
return router;
}