Add DECOM workflow type, auto-note/hide on decom, show CVEs on CARD queue items, auto-run migrations in pipeline
- Add DECOM to queue workflow types (red badge, inventory-style display) - When findings are added as DECOM, auto-set note to 'DECOM' and hide row - Hidden rows are excluded from donut charts (removes from pending count) - Show CVEs on CARD/GRANITE/DECOM queue items (was previously omitted) - Add backend/migrations/run-all.js for CI/CD auto-migration execution - Pipeline now runs migrations before service restart on both staging and prod - Add add_decom_workflow_type.js migration (updates CHECK constraint)
This commit is contained in:
@@ -319,7 +319,7 @@ CREATE TABLE IF NOT EXISTS ivanti_todo_queue (
|
||||
ip_address TEXT,
|
||||
hostname TEXT,
|
||||
vendor TEXT NOT NULL,
|
||||
workflow_type TEXT NOT NULL CHECK (workflow_type IN ('FP', 'Archer', 'CARD', 'GRANITE')),
|
||||
workflow_type TEXT NOT NULL CHECK (workflow_type IN ('FP', 'Archer', 'CARD', 'GRANITE', 'DECOM')),
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'complete')),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
|
||||
33
backend/migrations/add_decom_workflow_type.js
Normal file
33
backend/migrations/add_decom_workflow_type.js
Normal file
@@ -0,0 +1,33 @@
|
||||
// Migration: Add DECOM to workflow_type CHECK constraint on ivanti_todo_queue
|
||||
// Run from backend/: node migrations/add_decom_workflow_type.js
|
||||
|
||||
const pool = require('../db');
|
||||
|
||||
async function migrate() {
|
||||
console.log('Starting add_decom_workflow_type migration...');
|
||||
|
||||
try {
|
||||
// Drop the existing constraint and add the updated one
|
||||
await pool.query(`
|
||||
ALTER TABLE ivanti_todo_queue
|
||||
DROP CONSTRAINT IF EXISTS ivanti_todo_queue_workflow_type_check
|
||||
`);
|
||||
console.log('✓ Dropped old workflow_type constraint');
|
||||
|
||||
await pool.query(`
|
||||
ALTER TABLE ivanti_todo_queue
|
||||
ADD CONSTRAINT ivanti_todo_queue_workflow_type_check
|
||||
CHECK (workflow_type IN ('FP', 'Archer', 'CARD', 'GRANITE', 'DECOM'))
|
||||
`);
|
||||
console.log('✓ Added updated workflow_type constraint (includes DECOM)');
|
||||
|
||||
console.log('Migration complete!');
|
||||
} catch (err) {
|
||||
console.error('Migration failed:', err.message);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
migrate();
|
||||
51
backend/migrations/run-all.js
Normal file
51
backend/migrations/run-all.js
Normal file
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env node
|
||||
// Run all Postgres-compatible migrations in order.
|
||||
// Each migration is idempotent (safe to re-run).
|
||||
// Used by CI/CD pipeline during deploy to ensure schema is up to date.
|
||||
//
|
||||
// Usage: cd backend && node migrations/run-all.js
|
||||
|
||||
const { execSync } = require('child_process');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const MIGRATIONS_DIR = __dirname;
|
||||
|
||||
// Only run migrations that use the Postgres pool (not legacy SQLite ones).
|
||||
// Add new migrations to this list as they're created.
|
||||
const POSTGRES_MIGRATIONS = [
|
||||
'add_decom_workflow_type.js',
|
||||
];
|
||||
|
||||
async function runAll() {
|
||||
console.log(`[Migrations] Running ${POSTGRES_MIGRATIONS.length} Postgres migration(s)...`);
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const file of POSTGRES_MIGRATIONS) {
|
||||
const fullPath = path.join(MIGRATIONS_DIR, file);
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
console.error(` [FAIL] ${file}: file not found`);
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(` [run] ${file}`);
|
||||
execSync(`node ${fullPath}`, {
|
||||
cwd: path.join(MIGRATIONS_DIR, '..'),
|
||||
stdio: 'inherit',
|
||||
timeout: 30000,
|
||||
});
|
||||
succeeded++;
|
||||
} catch (err) {
|
||||
console.error(` [FAIL] ${file}: exit code ${err.status}`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Migrations] Done: ${succeeded} applied, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
}
|
||||
|
||||
runAll();
|
||||
@@ -4,7 +4,8 @@ const pool = require('../db');
|
||||
const { requireAuth, requireGroup } = require('../middleware/auth');
|
||||
const logAudit = require('../helpers/auditLog');
|
||||
|
||||
const VALID_WORKFLOW_TYPES = ['FP', 'Archer', 'CARD', 'GRANITE'];
|
||||
const VALID_WORKFLOW_TYPES = ['FP', 'Archer', 'CARD', 'GRANITE', 'DECOM'];
|
||||
const INVENTORY_TYPES = ['CARD', 'GRANITE', 'DECOM'];
|
||||
const VALID_STATUSES = ['pending', 'complete'];
|
||||
|
||||
function isValidVendor(vendor) {
|
||||
@@ -16,7 +17,26 @@ function isValidVendor(vendor) {
|
||||
function createIvantiTodoQueueRouter() {
|
||||
const router = express.Router();
|
||||
|
||||
// GET /api/ivanti/todo-queue
|
||||
/**
|
||||
* GET /api/ivanti/todo-queue
|
||||
*
|
||||
* Returns all todo queue items belonging to the authenticated user.
|
||||
*
|
||||
* @query None
|
||||
* @returns {Array<Object>} Array of queue items with parsed `cves` array
|
||||
* - id {number}
|
||||
* - user_id {number}
|
||||
* - finding_id {string}
|
||||
* - finding_title {string|null}
|
||||
* - cves {Array<string>}
|
||||
* - ip_address {string|null}
|
||||
* - hostname {string|null}
|
||||
* - vendor {string}
|
||||
* - workflow_type {string} One of: FP, Archer, CARD, GRANITE, DECOM
|
||||
* - status {string} pending | complete
|
||||
* - created_at {string}
|
||||
* - updated_at {string}
|
||||
*/
|
||||
router.get('/', requireAuth(), async (req, res) => {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
@@ -37,7 +57,25 @@ function createIvantiTodoQueueRouter() {
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/ivanti/todo-queue/batch
|
||||
/**
|
||||
* 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<Object>} 1–200 items, each with:
|
||||
* - finding_id {string} Required, non-empty
|
||||
* - finding_title {string} Optional, max 500 chars
|
||||
* - 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
|
||||
* @returns {Object} { items: Array<Object> } — 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;
|
||||
|
||||
@@ -53,10 +91,10 @@ function createIvantiTodoQueueRouter() {
|
||||
}
|
||||
|
||||
if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) {
|
||||
return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, or GRANITE.' });
|
||||
return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, GRANITE, or DECOM.' });
|
||||
}
|
||||
|
||||
if (!['CARD', 'GRANITE'].includes(workflow_type)) {
|
||||
if (!INVENTORY_TYPES.includes(workflow_type)) {
|
||||
if (!isValidVendor(vendor)) {
|
||||
return res.status(400).json({ error: 'vendor is required for FP and Archer workflows.' });
|
||||
}
|
||||
@@ -66,7 +104,7 @@ function createIvantiTodoQueueRouter() {
|
||||
return res.status(400).json({ error: 'vendor must be under 200 chars.' });
|
||||
}
|
||||
|
||||
const vendorVal = ['CARD', 'GRANITE'].includes(workflow_type) ? '' : vendor.trim();
|
||||
const vendorVal = INVENTORY_TYPES.includes(workflow_type) ? '' : vendor.trim();
|
||||
const userId = req.user.id;
|
||||
|
||||
const client = await pool.connect();
|
||||
@@ -131,7 +169,24 @@ function createIvantiTodoQueueRouter() {
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/ivanti/todo-queue
|
||||
/**
|
||||
* 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<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
|
||||
* @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;
|
||||
|
||||
@@ -139,16 +194,16 @@ 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, or GRANITE.' });
|
||||
return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, GRANITE, or DECOM.' });
|
||||
}
|
||||
if (!['CARD', 'GRANITE'].includes(workflow_type) && !isValidVendor(vendor)) {
|
||||
if (!INVENTORY_TYPES.includes(workflow_type) && !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 = ['CARD', 'GRANITE'].includes(workflow_type) ? '' : vendor.trim();
|
||||
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;
|
||||
@@ -175,7 +230,22 @@ function createIvantiTodoQueueRouter() {
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/ivanti/todo-queue/:id
|
||||
/**
|
||||
* 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
|
||||
* - 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;
|
||||
@@ -184,7 +254,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, or GRANITE.' });
|
||||
return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, GRANITE, or DECOM.' });
|
||||
}
|
||||
if (status !== undefined && !VALID_STATUSES.includes(status)) {
|
||||
return res.status(400).json({ error: 'status must be pending or complete.' });
|
||||
@@ -242,15 +312,30 @@ function createIvantiTodoQueueRouter() {
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/ivanti/todo-queue/:id/redirect
|
||||
/**
|
||||
* POST /api/ivanti/todo-queue/:id/redirect
|
||||
*
|
||||
* Redirects a completed queue item to a different workflow by creating 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 of the completed item (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
|
||||
* @returns {Object} The newly created queue item with parsed `cves` array
|
||||
* @error 400 Invalid input or item not in complete status
|
||||
* @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, or GRANITE.' });
|
||||
return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, GRANITE, or DECOM.' });
|
||||
}
|
||||
if (!['CARD', 'GRANITE'].includes(workflow_type)) {
|
||||
if (!INVENTORY_TYPES.includes(workflow_type)) {
|
||||
if (!isValidVendor(vendor)) {
|
||||
return res.status(400).json({ error: 'vendor is required for FP and Archer workflows.' });
|
||||
}
|
||||
@@ -259,7 +344,7 @@ function createIvantiTodoQueueRouter() {
|
||||
return res.status(400).json({ error: 'vendor must be under 200 chars.' });
|
||||
}
|
||||
|
||||
const vendorVal = ['CARD', 'GRANITE'].includes(workflow_type) ? '' : vendor.trim();
|
||||
const vendorVal = INVENTORY_TYPES.includes(workflow_type) ? '' : vendor.trim();
|
||||
|
||||
try {
|
||||
const { rows: origRows } = await pool.query(
|
||||
@@ -308,7 +393,15 @@ function createIvantiTodoQueueRouter() {
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/ivanti/todo-queue/completed
|
||||
/**
|
||||
* 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) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
@@ -322,7 +415,17 @@ function createIvantiTodoQueueRouter() {
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/ivanti/todo-queue/:id
|
||||
/**
|
||||
* 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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user