// routes/ivantiTodoQueue.js const express = require('express'); const { requireGroup } = require('../middleware/auth'); const logAudit = require('../helpers/auditLog'); const VALID_WORKFLOW_TYPES = ['FP', 'Archer', 'CARD']; const VALID_STATUSES = ['pending', 'complete']; function isValidVendor(vendor) { if (typeof vendor !== 'string') return false; const trimmed = vendor.trim(); return trimmed.length > 0 && trimmed.length <= 200; } function createIvantiTodoQueueRouter(db, requireAuth) { const router = express.Router(); /** * GET /api/ivanti/todo-queue * * Fetch the current user's queue items, ordered by vendor then created_at. * * @returns {Array} 200 - Array of queue items, each with: * id, user_id, finding_id, finding_title, cves_json, ip_address, * vendor, workflow_type, status, created_at, updated_at, cves (parsed array) * @returns {Object} 500 - { error: string } on database error */ router.get('/', requireAuth(db), (req, res) => { db.all( `SELECT * FROM ivanti_todo_queue WHERE user_id = ? ORDER BY vendor ASC, created_at ASC`, [req.user.id], (err, rows) => { if (err) { console.error('Error fetching todo queue:', err); return res.status(500).json({ error: 'Internal server error.' }); } // Parse cves_json back to array for each row const parsed = rows.map((r) => ({ ...r, cves: r.cves_json ? JSON.parse(r.cves_json) : [], })); res.json(parsed); } ); }); /** * POST /api/ivanti/todo-queue/batch * * Add multiple findings to the current user's queue in a single transaction. * * @body {Object[]} findings - Required array of 1–200 finding objects * @body {string} findings[].finding_id - Required, non-empty finding identifier * @body {string} [findings[].finding_title] - Optional finding title (max 500 chars) * @body {string[]} [findings[].cves] - Optional array of CVE identifiers * @body {string} [findings[].ip_address] - Optional IP address (max 64 chars) * @body {string} [findings[].hostname] - Optional hostname (max 255 chars) * @body {string} workflow_type - One of 'FP', 'Archer', 'CARD' * @body {string} vendor - Required for FP/Archer (max 200 chars); optional for CARD * * @returns {Object} 201 - { items: Array } array of created queue items, * each with: id, user_id, finding_id, finding_title, cves_json, ip_address, * vendor, workflow_type, status, created_at, updated_at, cves (parsed array) * @returns {Object} 400 - { error: string } on validation failure * @returns {Object} 500 - { error: string } on database/transaction error (all inserts rolled back) */ router.post('/batch', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => { const { findings, workflow_type, vendor } = req.body; // --- Validation --- if (!Array.isArray(findings) || findings.length < 1 || findings.length > 200) { return res.status(400).json({ error: 'findings array must contain 1-200 items.' }); } for (let i = 0; i < findings.length; i++) { const f = findings[i]; if (!f || typeof f.finding_id !== 'string' || f.finding_id.trim().length === 0) { return res.status(400).json({ error: 'Each finding must have a non-empty finding_id string.' }); } } if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) { return res.status(400).json({ error: 'workflow_type must be FP, Archer, or CARD.' }); } if (workflow_type !== 'CARD') { if (!isValidVendor(vendor)) { return res.status(400).json({ error: 'vendor is required for FP and Archer workflows.' }); } } if (vendor !== undefined && vendor !== '' && typeof vendor === 'string' && vendor.trim().length > 200) { return res.status(400).json({ error: 'vendor must be under 200 chars.' }); } const vendorVal = workflow_type === 'CARD' ? '' : vendor.trim(); const userId = req.user.id; // --- Transactional batch insert --- // Prepare all row values upfront const rows = findings.map((f) => { const findingId = f.finding_id.trim(); const title = f.finding_title && typeof f.finding_title === 'string' ? f.finding_title.slice(0, 500) : null; const cvesJson = Array.isArray(f.cves) ? JSON.stringify(f.cves) : null; const ipVal = f.ip_address && typeof f.ip_address === 'string' ? f.ip_address.trim().slice(0, 64) : null; const hostVal = f.hostname && typeof f.hostname === 'string' ? f.hostname.trim().slice(0, 255) : null; return [userId, findingId, title, cvesJson, ipVal, hostVal, vendorVal, workflow_type]; }); const insertedIds = []; let insertError = null; let remaining = rows.length; db.serialize(() => { db.run('BEGIN TRANSACTION'); rows.forEach((params) => { db.run( `INSERT INTO ivanti_todo_queue (user_id, finding_id, finding_title, cves_json, ip_address, hostname, vendor, workflow_type) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, params, function (err) { if (err && !insertError) { insertError = err; } else if (!err) { insertedIds.push(this.lastID); } remaining--; // After all insert callbacks have fired, commit or rollback if (remaining === 0) { if (insertError) { db.run('ROLLBACK', () => { console.error('Batch insert error:', insertError); return res.status(500).json({ error: 'Internal server error.' }); }); } else { db.run('COMMIT', (commitErr) => { if (commitErr) { console.error('Batch commit error:', commitErr); db.run('ROLLBACK', () => {}); return res.status(500).json({ error: 'Internal server error.' }); } // Fetch all inserted rows const placeholders = insertedIds.map(() => '?').join(','); db.all( `SELECT * FROM ivanti_todo_queue WHERE id IN (${placeholders})`, insertedIds, (fetchErr, fetchedRows) => { if (fetchErr) { console.error('Error fetching inserted batch rows:', fetchErr); return res.status(500).json({ error: 'Internal server error.' }); } const items = (fetchedRows || []).map((r) => ({ ...r, cves: r.cves_json ? JSON.parse(r.cves_json) : [], })); // Audit log (fire-and-forget) logAudit(db, { userId: req.user.id, username: req.user.username, action: 'batch_add_to_queue', entityType: 'ivanti_todo_queue', entityId: null, details: { count: insertedIds.length, workflow_type: workflow_type, finding_ids: findings.map((f) => f.finding_id.trim()), }, ipAddress: req.ip, }); return res.status(201).json({ items }); } ); }); } } } ); }); }); }); /** * POST /api/ivanti/todo-queue * * Add a single finding to the current user's queue. * * @body {string} finding_id - Required, non-empty finding identifier * @body {string} [finding_title] - Optional finding title (max 500 chars) * @body {string[]} [cves] - Optional array of CVE identifiers * @body {string} [ip_address] - Optional IP address (max 64 chars) * @body {string} [hostname] - Optional hostname (max 255 chars) * @body {string} vendor - Required for FP/Archer (max 200 chars); optional for CARD * @body {string} workflow_type - One of 'FP', 'Archer', 'CARD' * * @returns {Object} 201 - Created queue item with parsed cves array: * id, user_id, finding_id, finding_title, cves_json, ip_address, * vendor, workflow_type, status, created_at, updated_at, cves * @returns {Object} 400 - { error: string } on validation failure * @returns {Object} 500 - { error: string } on database error */ router.post('/', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => { const { finding_id, finding_title, cves, ip_address, hostname, vendor, workflow_type } = req.body; if (!finding_id || typeof finding_id !== 'string' || finding_id.trim().length === 0) { return res.status(400).json({ error: 'finding_id is required.' }); } if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) { return res.status(400).json({ error: 'workflow_type must be FP, Archer, or CARD.' }); } // Vendor is required for FP and Archer, optional for CARD if (workflow_type !== 'CARD' && !isValidVendor(vendor)) { return res.status(400).json({ error: 'vendor is required for FP and Archer workflows.' }); } if (vendor !== undefined && vendor !== '' && !isValidVendor(vendor)) { return res.status(400).json({ error: 'vendor must be under 200 chars.' }); } const vendorVal = workflow_type === 'CARD' ? '' : vendor.trim(); const cvesJson = Array.isArray(cves) ? JSON.stringify(cves) : null; const ipVal = ip_address && typeof ip_address === 'string' ? ip_address.trim().slice(0, 64) : null; const hostVal = hostname && typeof hostname === 'string' ? hostname.trim().slice(0, 255) : null; const title = finding_title && typeof finding_title === 'string' ? finding_title.slice(0, 500) : null; db.run( `INSERT INTO ivanti_todo_queue (user_id, finding_id, finding_title, cves_json, ip_address, hostname, vendor, workflow_type) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [req.user.id, finding_id.trim(), title, cvesJson, ipVal, hostVal, vendorVal, workflow_type], function (err) { if (err) { console.error('Error adding to queue:', err); return res.status(500).json({ error: 'Internal server error.' }); } db.get( 'SELECT * FROM ivanti_todo_queue WHERE id = ?', [this.lastID], (err2, row) => { if (err2 || !row) { return res.status(201).json({ id: this.lastID, message: 'Added to queue.' }); } res.status(201).json({ ...row, cves: row.cves_json ? JSON.parse(row.cves_json) : [] }); } ); } ); }); /** * PUT /api/ivanti/todo-queue/:id * * Update vendor, workflow_type, or status on a queue item — scoped to current user. * * @param {string} id - Queue item ID (URL parameter) * @body {string} [vendor] - New vendor string (max 200 chars) * @body {string} [workflow_type] - One of 'FP', 'Archer', 'CARD' * @body {string} [status] - One of 'pending', 'complete' * * @returns {Object} 200 - Updated queue item with parsed cves array: * id, user_id, finding_id, finding_title, cves_json, ip_address, * vendor, workflow_type, status, created_at, updated_at, cves * @returns {Object} 400 - { error: string } on validation failure or no fields to update * @returns {Object} 404 - { error: string } if item not found for current user * @returns {Object} 500 - { error: string } on database error */ router.put('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => { const { id } = req.params; const { vendor, workflow_type, status } = req.body; if (vendor !== undefined && !isValidVendor(vendor)) { return res.status(400).json({ error: 'vendor must be a non-empty string (max 200 chars).' }); } if (workflow_type !== undefined && !VALID_WORKFLOW_TYPES.includes(workflow_type)) { return res.status(400).json({ error: 'workflow_type must be FP or Archer.' }); } if (status !== undefined && !VALID_STATUSES.includes(status)) { return res.status(400).json({ error: 'status must be pending or complete.' }); } db.get( 'SELECT * FROM ivanti_todo_queue WHERE id = ? AND user_id = ?', [id, req.user.id], (err, existing) => { if (err) { console.error(err); return res.status(500).json({ error: 'Internal server error.' }); } if (!existing) { return res.status(404).json({ error: 'Queue item not found.' }); } const updates = []; const params = []; if (vendor !== undefined) { updates.push('vendor = ?'); params.push(vendor.trim()); } if (workflow_type !== undefined) { updates.push('workflow_type = ?'); params.push(workflow_type); } if (status !== undefined) { updates.push('status = ?'); params.push(status); } if (updates.length === 0) { return res.status(400).json({ error: 'No fields to update.' }); } updates.push('updated_at = CURRENT_TIMESTAMP'); params.push(id, req.user.id); db.run( `UPDATE ivanti_todo_queue SET ${updates.join(', ')} WHERE id = ? AND user_id = ?`, params, function (err2) { if (err2) { console.error(err2); return res.status(500).json({ error: 'Internal server error.' }); } db.get( 'SELECT * FROM ivanti_todo_queue WHERE id = ?', [id], (err3, row) => { if (err3 || !row) { return res.json({ message: 'Queue item updated.' }); } res.json({ ...row, cves: row.cves_json ? JSON.parse(row.cves_json) : [] }); } ); } ); } ); }); /** * DELETE /api/ivanti/todo-queue/completed * * Bulk-delete all completed items for the current user. * IMPORTANT: This route must be registered BEFORE DELETE /:id. * * @returns {Object} 200 - { message: string, deleted: number } * @returns {Object} 500 - { error: string } on database error */ router.delete('/completed', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => { db.run( "DELETE FROM ivanti_todo_queue WHERE user_id = ? AND status = 'complete'", [req.user.id], function (err) { if (err) { console.error('Error clearing completed queue items:', err); return res.status(500).json({ error: 'Internal server error.' }); } res.json({ message: 'Completed items cleared.', deleted: this.changes }); } ); }); /** * DELETE /api/ivanti/todo-queue/:id * * Delete a single queue item — scoped to current user. * * @param {string} id - Queue item ID (URL parameter) * * @returns {Object} 200 - { message: string } * @returns {Object} 404 - { error: string } if item not found for current user * @returns {Object} 500 - { error: string } on database error */ router.delete('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => { const { id } = req.params; db.get( 'SELECT id FROM ivanti_todo_queue WHERE id = ? AND user_id = ?', [id, req.user.id], (err, row) => { if (err) { console.error(err); return res.status(500).json({ error: 'Internal server error.' }); } if (!row) { return res.status(404).json({ error: 'Queue item not found.' }); } db.run( 'DELETE FROM ivanti_todo_queue WHERE id = ? AND user_id = ?', [id, req.user.id], function (err2) { if (err2) { console.error(err2); return res.status(500).json({ error: 'Internal server error.' }); } res.json({ message: 'Queue item deleted.' }); } ); } ); }); return router; } module.exports = createIvantiTodoQueueRouter;