From 408aaa701267f41704c4d4d65607a04e404e6a90 Mon Sep 17 00:00:00 2001 From: Jordan Ramos Date: Thu, 14 May 2026 11:54:58 -0600 Subject: [PATCH] Add data management panel with delete vertical, rollback upload, and reset all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/routes/vclMultiVertical.js | 247 ++++++++++++++++++ .../src/components/pages/CCPMetricsPage.js | 235 +++++++++++++++-- 2 files changed, 461 insertions(+), 21 deletions(-) diff --git a/backend/routes/vclMultiVertical.js b/backend/routes/vclMultiVertical.js index 8f719d5..b62ab94 100644 --- a/backend/routes/vclMultiVertical.js +++ b/backend/routes/vclMultiVertical.js @@ -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; } diff --git a/frontend/src/components/pages/CCPMetricsPage.js b/frontend/src/components/pages/CCPMetricsPage.js index 651a338..9103a21 100644 --- a/frontend/src/components/pages/CCPMetricsPage.js +++ b/frontend/src/components/pages/CCPMetricsPage.js @@ -1,5 +1,5 @@ import React, { useState, useEffect, useCallback } from 'react'; -import { Upload, Building2, ChevronLeft, Loader, AlertCircle, BarChart3 } from 'lucide-react'; +import { Upload, Building2, ChevronLeft, Loader, AlertCircle, BarChart3, Settings, Trash2, RotateCcw } from 'lucide-react'; import { useAuth } from '../../contexts/AuthContext'; import { PieChart, Pie, Cell, ComposedChart, Bar, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ReferenceLine, ResponsiveContainer } from 'recharts'; import MultiVerticalUploadModal from './MultiVerticalUploadModal'; @@ -421,6 +421,166 @@ function MetricDeviceList({ vertical, metricId, onBack }) { } +// --------------------------------------------------------------------------- +// Data Management Panel +// --------------------------------------------------------------------------- +function DataManagementPanel({ onClose, onDataChanged }) { + const [uploads, setUploads] = useState([]); + const [loading, setLoading] = useState(true); + const [actionLoading, setActionLoading] = useState(null); + const [confirmAction, setConfirmAction] = useState(null); // { type, label, action } + const [message, setMessage] = useState(null); + + useEffect(() => { + fetchUploads(); + }, []); + + const fetchUploads = () => { + setLoading(true); + fetch(`${API_BASE}/compliance/vcl-multi/uploads`, { credentials: 'include' }) + .then(r => r.json()) + .then(data => { setUploads(data.uploads || []); setLoading(false); }) + .catch(() => setLoading(false)); + }; + + const handleDeleteVertical = async (vertical) => { + setActionLoading(vertical); + setMessage(null); + try { + const res = await fetch(`${API_BASE}/compliance/vcl-multi/vertical/${encodeURIComponent(vertical)}`, { + method: 'DELETE', credentials: 'include', + }); + const data = await res.json(); + if (!res.ok) { setMessage({ type: 'error', text: data.error }); } + else { setMessage({ type: 'success', text: data.message }); fetchUploads(); onDataChanged(); } + } catch (err) { setMessage({ type: 'error', text: err.message }); } + setActionLoading(null); + setConfirmAction(null); + }; + + const handleRollbackUpload = async (uploadId) => { + setActionLoading(uploadId); + setMessage(null); + try { + const res = await fetch(`${API_BASE}/compliance/vcl-multi/upload/${uploadId}`, { + method: 'DELETE', credentials: 'include', + }); + const data = await res.json(); + if (!res.ok) { setMessage({ type: 'error', text: data.error }); } + else { setMessage({ type: 'success', text: data.message }); fetchUploads(); onDataChanged(); } + } catch (err) { setMessage({ type: 'error', text: err.message }); } + setActionLoading(null); + setConfirmAction(null); + }; + + const handleDeleteAll = async () => { + setActionLoading('all'); + setMessage(null); + try { + const res = await fetch(`${API_BASE}/compliance/vcl-multi/all`, { + method: 'DELETE', credentials: 'include', + }); + const data = await res.json(); + if (!res.ok) { setMessage({ type: 'error', text: data.error }); } + else { setMessage({ type: 'success', text: data.message }); fetchUploads(); onDataChanged(); } + } catch (err) { setMessage({ type: 'error', text: err.message }); } + setActionLoading(null); + setConfirmAction(null); + }; + + // Group uploads by vertical + const verticalGroups = {}; + for (const u of uploads) { + if (!verticalGroups[u.vertical]) verticalGroups[u.vertical] = []; + verticalGroups[u.vertical].push(u); + } + + return ( +
+
e.stopPropagation()}> +
+

Manage Data

+ {/* ⚠️ CONVENTION: Use lucide-react icon instead of raw Unicode character */} + +
+ + {/* Message */} + {message && ( +
+ {message.text} +
+ )} + + {/* Confirm dialog */} + {confirmAction && ( +
+
+ {confirmAction.label} +
+
+ + +
+
+ )} + + {/* Delete All button */} +
+ +
+ + {loading &&
Loading uploads...
} + + {!loading && uploads.length === 0 && ( +
No uploads yet.
+ )} + + {/* Per-vertical sections */} + {!loading && Object.entries(verticalGroups).map(([vertical, vUploads]) => ( +
+
+ {vertical} + +
+ {vUploads.slice(0, 5).map((u, i) => ( +
+ {u.filename} + {u.report_date || '—'} + +{u.new_count || 0} / -{u.resolved_count || 0} + {i === 0 && ( + + )} +
+ ))} + {vUploads.length > 5 && ( +
...and {vUploads.length - 5} more
+ )} +
+ ))} +
+
+ ); +} + // --------------------------------------------------------------------------- // Main Page Component // --------------------------------------------------------------------------- @@ -431,6 +591,7 @@ export default function CCPMetricsPage() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [showUpload, setShowUpload] = useState(false); + const [showManage, setShowManage] = useState(false); // Drill-down state const [selectedVertical, setSelectedVertical] = useState(null); @@ -499,26 +660,50 @@ export default function CCPMetricsPage() {

{(isAdmin() || isEditor()) && ( - +
+ {isAdmin() && ( + + )} + +
)} @@ -597,6 +782,14 @@ export default function CCPMetricsPage() { onUploadComplete={handleUploadComplete} /> )} + + {/* Data Management Panel */} + {showManage && ( + setShowManage(false)} + onDataChanged={fetchData} + /> + )} ); }