Add Remediate workflow type to Ivanti Queue with remediation notes
- Add 'Remediate' as a valid workflow type (vendor-required, like FP/Archer) - Create queue_remediation_notes table with FK cascade and 5000 char limit - Add POST/GET /api/ivanti/todo-queue/:id/notes endpoints - Include remediation_notes_count in queue item GET response - Add RemediationModal component for viewing/adding notes - Add notes count badge on Remediate queue items (purple #A855F7 theme) - Add delete confirmation warning when removing items with notes - Append remediation notes to Jira ticket descriptions - Add property-based tests for all correctness properties
This commit is contained in:
@@ -4,7 +4,7 @@ const pool = require('../db');
|
||||
const { requireAuth, requireGroup } = require('../middleware/auth');
|
||||
const logAudit = require('../helpers/auditLog');
|
||||
|
||||
const VALID_WORKFLOW_TYPES = ['FP', 'Archer', 'CARD', 'GRANITE', 'DECOM'];
|
||||
const VALID_WORKFLOW_TYPES = ['FP', 'Archer', 'CARD', 'GRANITE', 'DECOM', 'Remediate'];
|
||||
const INVENTORY_TYPES = ['CARD', 'GRANITE', 'DECOM'];
|
||||
const VALID_STATUSES = ['pending', 'complete'];
|
||||
|
||||
@@ -32,16 +32,22 @@ function createIvantiTodoQueueRouter() {
|
||||
* - ip_address {string|null}
|
||||
* - hostname {string|null}
|
||||
* - vendor {string}
|
||||
* - workflow_type {string} One of: FP, Archer, CARD, GRANITE, DECOM
|
||||
* - 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.*
|
||||
`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]
|
||||
@@ -51,7 +57,7 @@ function createIvantiTodoQueueRouter() {
|
||||
if (r.cves_json) {
|
||||
try { cves = JSON.parse(r.cves_json); } catch (e) { cves = []; }
|
||||
}
|
||||
return { ...r, cves };
|
||||
return { ...r, remediation_notes_count: parseInt(r.remediation_notes_count, 10), cves };
|
||||
});
|
||||
res.json(parsed);
|
||||
} catch (err) {
|
||||
@@ -73,8 +79,8 @@ function createIvantiTodoQueueRouter() {
|
||||
* - cves {Array<string>} 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
|
||||
* - vendor {string} Required for FP, Archer, and DECOM workflows; max 200 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<Object> } — inserted queue items with parsed `cves` array
|
||||
* @error 400 Invalid input
|
||||
* @error 500 Internal server error
|
||||
@@ -94,12 +100,12 @@ function createIvantiTodoQueueRouter() {
|
||||
}
|
||||
|
||||
if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) {
|
||||
return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, GRANITE, or DECOM.' });
|
||||
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 and Archer workflows.' });
|
||||
return res.status(400).json({ error: 'vendor is required for FP, Archer, and Remediate workflows.' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,8 +190,8 @@ function createIvantiTodoQueueRouter() {
|
||||
* - cves {Array<string>} Optional
|
||||
* - ip_address {string} Optional, max 64 chars
|
||||
* - hostname {string} Optional, max 255 chars
|
||||
* - vendor {string} Required for FP, Archer, and DECOM workflows; max 200 chars
|
||||
* - workflow_type {string} Required. One of: FP, Archer, CARD, GRANITE, DECOM
|
||||
* - 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
|
||||
@@ -197,10 +203,10 @@ function createIvantiTodoQueueRouter() {
|
||||
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, or DECOM.' });
|
||||
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 and Archer workflows.' });
|
||||
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.' });
|
||||
@@ -242,7 +248,7 @@ function createIvantiTodoQueueRouter() {
|
||||
* @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
|
||||
* - 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
|
||||
@@ -257,7 +263,7 @@ function createIvantiTodoQueueRouter() {
|
||||
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, or DECOM.' });
|
||||
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.' });
|
||||
@@ -325,8 +331,8 @@ function createIvantiTodoQueueRouter() {
|
||||
*
|
||||
* @param {string} id — Queue item ID (URL parameter)
|
||||
* @body {Object}
|
||||
* - workflow_type {string} Required. One of: FP, Archer, CARD, GRANITE, DECOM
|
||||
* - vendor {string} Required for FP, Archer, and DECOM workflows; max 200 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} The updated or newly created queue item with parsed `cves` array
|
||||
* @error 400 Invalid input
|
||||
* @error 404 Queue item not found
|
||||
@@ -337,11 +343,11 @@ function createIvantiTodoQueueRouter() {
|
||||
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, or DECOM.' });
|
||||
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 and Archer workflows.' });
|
||||
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) {
|
||||
@@ -545,6 +551,118 @@ function createIvantiTodoQueueRouter() {
|
||||
}
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// 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<Object>} 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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user