// routes/ivantiTodoQueue.js const express = require('express'); const pool = require('../db'); const { requireAuth, requireGroup } = require('../middleware/auth'); const logAudit = require('../helpers/auditLog'); const VALID_WORKFLOW_TYPES = ['FP', 'Archer', 'CARD', 'GRANITE', 'DECOM', 'Remediate']; const INVENTORY_TYPES = ['CARD', 'GRANITE', 'DECOM']; 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() { const router = express.Router(); /** * GET /api/ivanti/todo-queue * * Returns all todo queue items belonging to the authenticated user. * * @query None * @returns {Array} Array of queue items with parsed `cves` array * - id {number} * - user_id {number} * - finding_id {string} * - finding_title {string|null} * - cves {Array} * - ip_address {string|null} * - hostname {string|null} * - vendor {string} * - workflow_type {string} One of: FP, Archer, CARD, GRANITE, DECOM, Remediate * - status {string} pending | complete * - remediation_notes_count {number} * - created_at {string} * - updated_at {string} */ router.get('/', requireAuth(), async (req, res) => { try { const { rows } = await pool.query( `SELECT q.*, COALESCE(nc.note_count, 0) AS remediation_notes_count FROM ivanti_todo_queue q LEFT JOIN ( SELECT queue_item_id, COUNT(*) AS note_count FROM queue_remediation_notes GROUP BY queue_item_id ) nc ON nc.queue_item_id = q.id WHERE q.user_id = $1 ORDER BY q.vendor ASC, q.created_at ASC`, [req.user.id] ); const parsed = rows.map((r) => { let cves = []; if (r.cves_json) { try { cves = JSON.parse(r.cves_json); } catch (e) { cves = []; } } return { ...r, remediation_notes_count: parseInt(r.remediation_notes_count, 10), cves }; }); res.json(parsed); } catch (err) { console.error('Error fetching todo queue:', err); res.status(500).json({ error: 'Internal server error.' }); } }); /** * POST /api/ivanti/todo-queue/batch * * Adds multiple findings to the authenticated user's todo queue in a single transaction. * Requires Admin or Standard_User group. * * @body {Object} * - findings {Array} 1–200 items, each with: * - finding_id {string} Required, non-empty * - finding_title {string} Optional, max 500 chars * - cves {Array} Optional * - ip_address {string} Optional, max 64 chars * - hostname {string} Optional, max 255 chars * - workflow_type {string} Required. One of: FP, Archer, CARD, GRANITE, DECOM, Remediate * - vendor {string} Required for FP, Archer, and Remediate workflows; max 200 chars * @returns {Object} { items: Array } — inserted queue items with parsed `cves` array * @error 400 Invalid input * @error 500 Internal server error */ router.post('/batch', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { const { findings, workflow_type, vendor } = req.body; 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, CARD, GRANITE, DECOM, or Remediate.' }); } if (!INVENTORY_TYPES.includes(workflow_type)) { if (!isValidVendor(vendor)) { return res.status(400).json({ error: 'vendor is required for FP, Archer, and Remediate 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 = INVENTORY_TYPES.includes(workflow_type) ? '' : vendor.trim(); const userId = req.user.id; const client = await pool.connect(); try { await client.query('BEGIN'); const insertedIds = []; for (const f of findings) { 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; const { rows } = await client.query( `INSERT INTO ivanti_todo_queue (user_id, finding_id, finding_title, cves_json, ip_address, hostname, vendor, workflow_type) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id`, [userId, findingId, title, cvesJson, ipVal, hostVal, vendorVal, workflow_type] ); insertedIds.push(rows[0].id); } await client.query('COMMIT'); // Fetch all inserted rows const { rows: fetchedRows } = await pool.query( `SELECT * FROM ivanti_todo_queue WHERE id = ANY($1)`, [insertedIds] ); const items = fetchedRows.map((r) => ({ ...r, cves: r.cves_json ? JSON.parse(r.cves_json) : [], })); logAudit({ 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 }); } catch (err) { await client.query('ROLLBACK'); console.error('Batch insert error:', err); return res.status(500).json({ error: 'Internal server error.' }); } finally { client.release(); } }); /** * POST /api/ivanti/todo-queue * * Adds a single finding to the authenticated user's todo queue. * Requires Admin or Standard_User group. * * @body {Object} * - finding_id {string} Required, non-empty * - finding_title {string} Optional, max 500 chars * - cves {Array} Optional * - ip_address {string} Optional, max 64 chars * - hostname {string} Optional, max 255 chars * - vendor {string} Required for FP, Archer, and Remediate workflows; max 200 chars * - workflow_type {string} Required. One of: FP, Archer, CARD, GRANITE, DECOM, Remediate * @returns {Object} The created queue item with parsed `cves` array * @error 400 Invalid input * @error 500 Internal server error */ router.post('/', requireAuth(), requireGroup('Admin', 'Standard_User'), async (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, CARD, GRANITE, DECOM, or Remediate.' }); } if (!INVENTORY_TYPES.includes(workflow_type) && !isValidVendor(vendor)) { return res.status(400).json({ error: 'vendor is required for FP, Archer, and Remediate workflows.' }); } if (vendor !== undefined && vendor !== '' && !isValidVendor(vendor)) { return res.status(400).json({ error: 'vendor must be under 200 chars.' }); } const vendorVal = INVENTORY_TYPES.includes(workflow_type) ? '' : 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; try { const { rows } = await pool.query( `INSERT INTO ivanti_todo_queue (user_id, finding_id, finding_title, cves_json, ip_address, hostname, vendor, workflow_type) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`, [req.user.id, finding_id.trim(), title, cvesJson, ipVal, hostVal, vendorVal, workflow_type] ); const result = { ...rows[0], cves: rows[0].cves_json ? JSON.parse(rows[0].cves_json) : [], }; res.status(201).json(result); } catch (err) { console.error('Error adding to queue:', err); res.status(500).json({ error: 'Internal server error.' }); } }); /** * PUT /api/ivanti/todo-queue/:id * * Updates an existing queue item owned by the authenticated user. * Requires Admin or Standard_User group. * * @param {string} id — Queue item ID (URL parameter) * @body {Object} At least one field required: * - vendor {string} Optional, non-empty, max 200 chars * - workflow_type {string} Optional. One of: FP, Archer, CARD, GRANITE, DECOM, Remediate * - status {string} Optional. One of: pending, complete * @returns {Object} The updated queue item with parsed `cves` array * @error 400 Invalid input or no fields to update * @error 404 Queue item not found * @error 500 Internal server error */ router.put('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (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, Archer, CARD, GRANITE, DECOM, or Remediate.' }); } if (status !== undefined && !VALID_STATUSES.includes(status)) { return res.status(400).json({ error: 'status must be pending or complete.' }); } try { const { rows: existingRows } = await pool.query( 'SELECT * FROM ivanti_todo_queue WHERE id = $1 AND user_id = $2', [id, req.user.id] ); if (!existingRows[0]) { return res.status(404).json({ error: 'Queue item not found.' }); } const updates = []; const params = []; let paramIndex = 1; if (vendor !== undefined) { updates.push(`vendor = $${paramIndex++}`); params.push(vendor.trim()); } if (workflow_type !== undefined) { updates.push(`workflow_type = $${paramIndex++}`); params.push(workflow_type); } if (status !== undefined) { updates.push(`status = $${paramIndex++}`); params.push(status); } if (updates.length === 0) { return res.status(400).json({ error: 'No fields to update.' }); } updates.push('updated_at = NOW()'); params.push(id, req.user.id); await pool.query( `UPDATE ivanti_todo_queue SET ${updates.join(', ')} WHERE id = $${paramIndex++} AND user_id = $${paramIndex}`, params ); const { rows } = await pool.query( 'SELECT * FROM ivanti_todo_queue WHERE id = $1', [id] ); const result = { ...rows[0], cves: rows[0].cves_json ? JSON.parse(rows[0].cves_json) : [], }; res.json(result); } catch (err) { console.error(err); res.status(500).json({ error: 'Internal server error.' }); } }); /** * POST /api/ivanti/todo-queue/:id/redirect * * Redirects a queue item to a different workflow type. If the item is pending, * updates workflow_type in place. If the item is complete, creates a new pending * queue item with the same finding data but a new workflow type/vendor. * Requires Admin or Standard_User group. * * @param {string} id — Queue item ID (URL parameter) * @body {Object} * - workflow_type {string} Required. One of: FP, Archer, CARD, GRANITE, DECOM, Remediate * - vendor {string} Required for FP, Archer, and Remediate workflows; max 200 chars * @returns {Object} The updated or newly created queue item with parsed `cves` array * @error 400 Invalid input * @error 404 Queue item not found * @error 500 Internal server error */ router.post('/:id/redirect', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { const { id } = req.params; const { workflow_type, vendor } = req.body; if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) { return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, GRANITE, DECOM, or Remediate.' }); } if (!INVENTORY_TYPES.includes(workflow_type)) { if (!isValidVendor(vendor)) { return res.status(400).json({ error: 'vendor is required for FP, Archer, and Remediate 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 = INVENTORY_TYPES.includes(workflow_type) ? '' : vendor.trim(); try { const { rows: origRows } = await pool.query( 'SELECT * FROM ivanti_todo_queue WHERE id = $1 AND user_id = $2', [id, req.user.id] ); const original = origRows[0]; if (!original) { return res.status(404).json({ error: 'Queue item not found.' }); } // If the item is still pending, update workflow_type in place (no duplication) if (original.status === 'pending') { const { rows } = await pool.query( `UPDATE ivanti_todo_queue SET workflow_type = $1, vendor = $2, updated_at = NOW() WHERE id = $3 AND user_id = $4 RETURNING *`, [workflow_type, vendorVal, id, req.user.id] ); logAudit({ userId: req.user.id, username: req.user.username, action: 'queue_item_redirected', entityType: 'ivanti_todo_queue', entityId: String(original.id), details: { original_workflow_type: original.workflow_type, target_workflow_type: workflow_type, method: 'in_place_update', vendor: vendorVal, }, ipAddress: req.ip, }); const result = { ...rows[0], cves: rows[0].cves_json ? JSON.parse(rows[0].cves_json) : [], }; return res.json(result); } // If the item is complete, create a new pending item (legacy behavior) const { rows } = await pool.query( `INSERT INTO ivanti_todo_queue (user_id, finding_id, finding_title, cves_json, ip_address, hostname, vendor, workflow_type) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`, [req.user.id, original.finding_id, original.finding_title, original.cves_json, original.ip_address, original.hostname, vendorVal, workflow_type] ); logAudit({ userId: req.user.id, username: req.user.username, action: 'queue_item_redirected', entityType: 'ivanti_todo_queue', entityId: String(original.id), details: { original_workflow_type: original.workflow_type, target_workflow_type: workflow_type, method: 'new_item_from_complete', new_item_id: rows[0].id, vendor: vendorVal, }, ipAddress: req.ip, }); const result = { ...rows[0], cves: rows[0].cves_json ? JSON.parse(rows[0].cves_json) : [], }; return res.status(201).json(result); } catch (err) { console.error('Error redirecting queue item:', err); res.status(500).json({ error: 'Internal server error.' }); } }); /** * GET /api/ivanti/todo-queue/ticket-links * * Returns Jira ticket associations for the current user's queue items. * Joins jira_ticket_queue_items with jira_tickets to get ticket_key and url. * * @returns {Object} { links: { [queue_item_id]: { ticket_key, jira_url } } } * @error 500 Internal server error */ router.get('/ticket-links', requireAuth(), async (req, res) => { try { const { rows } = await pool.query( `SELECT jtqi.queue_item_id, jt.ticket_key, jt.url AS jira_url FROM jira_ticket_queue_items jtqi JOIN jira_tickets jt ON jt.id = jtqi.jira_ticket_id JOIN ivanti_todo_queue q ON q.id = jtqi.queue_item_id WHERE q.user_id = $1`, [req.user.id] ); const links = {}; for (const row of rows) { links[row.queue_item_id] = { ticket_key: row.ticket_key, jira_url: row.jira_url }; } res.json({ links }); } catch (err) { console.error('Error fetching ticket links:', err); res.status(500).json({ error: 'Internal server error.' }); } }); /** * DELETE /api/ivanti/todo-queue/completed * * Deletes all completed queue items belonging to the authenticated user. * Requires Admin or Standard_User group. * * @returns {Object} { message: string, deleted: number } * @error 500 Internal server error */ router.delete('/completed', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { const client = await pool.connect(); try { await client.query('BEGIN'); // Select completed item IDs for this user const { rows: completedRows } = await client.query( "SELECT id FROM ivanti_todo_queue WHERE user_id = $1 AND status = 'complete'", [req.user.id] ); if (completedRows.length === 0) { await client.query('COMMIT'); return res.json({ message: 'Completed items cleared.', deleted: 0 }); } const ids = completedRows.map(r => r.id); // Delete junction table references first await client.query( 'DELETE FROM jira_ticket_queue_items WHERE queue_item_id = ANY($1::int[])', [ids] ); // Delete the completed queue items const deleteResult = await client.query( 'DELETE FROM ivanti_todo_queue WHERE id = ANY($1::int[])', [ids] ); await client.query('COMMIT'); res.json({ message: 'Completed items cleared.', deleted: deleteResult.rowCount }); } catch (err) { await client.query('ROLLBACK'); console.error('Error clearing completed queue items:', err); res.status(500).json({ error: 'Internal server error.' }); } finally { client.release(); } }); /** * DELETE /api/ivanti/todo-queue/:id * * Deletes a single queue item owned by the authenticated user. * Requires Admin or Standard_User group. * * @param {string} id — Queue item ID (URL parameter) * @returns {Object} { message: string } * @error 404 Queue item not found * @error 500 Internal server error */ router.delete('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { const { id } = req.params; try { const { rows } = await pool.query( 'SELECT id FROM ivanti_todo_queue WHERE id = $1 AND user_id = $2', [id, req.user.id] ); if (!rows[0]) { return res.status(404).json({ error: 'Queue item not found.' }); } await pool.query( 'DELETE FROM ivanti_todo_queue WHERE id = $1 AND user_id = $2', [id, req.user.id] ); res.json({ message: 'Queue item deleted.' }); } catch (err) { console.error(err); res.status(500).json({ error: 'Internal server error.' }); } }); // ========================================================================= // Remediation Notes Routes // ========================================================================= /** * POST /api/ivanti/todo-queue/:id/notes * * Creates a remediation note for a queue item owned by the authenticated user. * Requires Admin or Standard_User group. * * @param {string} id — Queue item ID (URL parameter) * @body {Object} * - note_text {string} Required, 1–5000 characters, non-whitespace-only * @returns {Object} The created note with id, queue_item_id, user_id, username, note_text, created_at * @error 400 Invalid note_text * @error 404 Queue item not found or not owned * @error 500 Internal server error */ router.post('/:id/notes', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { const { id } = req.params; const { note_text } = req.body; // Validate queue item exists and belongs to user try { const { rows: itemRows } = await pool.query( 'SELECT id FROM ivanti_todo_queue WHERE id = $1 AND user_id = $2', [id, req.user.id] ); if (!itemRows[0]) { return res.status(404).json({ error: 'Queue item not found.' }); } } catch (err) { console.error('Error checking queue item ownership:', err); return res.status(500).json({ error: 'Internal server error.' }); } // Validate note_text if (!note_text || typeof note_text !== 'string' || note_text.trim().length === 0) { return res.status(400).json({ error: 'Note text is required.' }); } if (note_text.length > 5000) { return res.status(400).json({ error: 'Note text must not exceed 5000 characters.' }); } try { const { rows } = await pool.query( `INSERT INTO queue_remediation_notes (queue_item_id, user_id, username, note_text) VALUES ($1, $2, $3, $4) RETURNING id, queue_item_id, user_id, username, note_text, created_at`, [id, req.user.id, req.user.username, note_text] ); logAudit({ userId: req.user.id, username: req.user.username, action: 'create_remediation_note', entityType: 'queue_remediation_notes', entityId: String(rows[0].id), details: { queue_item_id: parseInt(id, 10) }, ipAddress: req.ip, }); return res.status(201).json(rows[0]); } catch (err) { console.error('Error creating remediation note:', err); return res.status(500).json({ error: 'Internal server error.' }); } }); /** * GET /api/ivanti/todo-queue/:id/notes * * Returns all remediation notes for a queue item owned by the authenticated user. * Notes are ordered by created_at descending (most recent first). * * @param {string} id — Queue item ID (URL parameter) * @returns {Array} Array of note objects (empty array if none) * @error 404 Queue item not found or not owned * @error 500 Internal server error */ router.get('/:id/notes', requireAuth(), async (req, res) => { const { id } = req.params; // Validate queue item exists and belongs to user try { const { rows: itemRows } = await pool.query( 'SELECT id FROM ivanti_todo_queue WHERE id = $1 AND user_id = $2', [id, req.user.id] ); if (!itemRows[0]) { return res.status(404).json({ error: 'Queue item not found.' }); } } catch (err) { console.error('Error checking queue item ownership:', err); return res.status(500).json({ error: 'Internal server error.' }); } try { const { rows } = await pool.query( `SELECT id, queue_item_id, user_id, username, note_text, created_at FROM queue_remediation_notes WHERE queue_item_id = $1 ORDER BY created_at DESC`, [id] ); return res.json(rows); } catch (err) { console.error('Error fetching remediation notes:', err); return res.status(500).json({ error: 'Internal server error.' }); } }); return router; } module.exports = createIvantiTodoQueueRouter;