feat: add FP submission editing with lifecycle tracking, clickable workflow badges, and edit modal

- Add migration for lifecycle_status, batch UUID, updated_at columns and submission history table
- Add backend endpoints: GET/PUT/POST/PATCH for viewing, editing, adding findings/attachments, and status changes
- Add pure helpers: validateLifecycleTransition, mergeFindings, buildSubmissionHistoryEntry
- Add FpEditModal with tabbed UI (Details, Findings, Attachments, History)
- Make workflow badges clickable for Reworked/Rejected/Expired states with pencil icon
- Add submissions list section to QueuePanel with lifecycle status badges
- Wire state and data flow in ReportingPage for submissions fetch and edit callbacks
This commit is contained in:
jramos
2026-04-13 12:27:56 -06:00
parent 57f11c362b
commit df30430956
7 changed files with 2092 additions and 7 deletions

View File

@@ -3,7 +3,7 @@ const express = require('express');
const multer = require('multer');
const path = require('path');
const { requireGroup } = require('../middleware/auth');
const { ivantiFormPost } = require('../helpers/ivantiApi');
const { ivantiFormPost, ivantiPost, ivantiMultipartPost } = require('../helpers/ivantiApi');
const logAudit = require('../helpers/auditLog');
// ---------------------------------------------------------------------------
@@ -112,6 +112,48 @@ function buildIvantiFormFields(formData, findingIds) {
];
}
const LIFECYCLE_STATUSES = new Set([
'submitted', 'approved', 'rejected', 'rework', 'resubmitted'
]);
/**
* Validates whether a lifecycle status transition is allowed.
* Returns { valid: true } or { valid: false, error: string }.
*/
function validateLifecycleTransition(currentStatus, newStatus) {
if (currentStatus === 'approved') {
return { valid: false, error: 'This submission is finalized and cannot be edited.' };
}
if (!LIFECYCLE_STATUSES.has(newStatus)) {
return { valid: false, error: 'Invalid lifecycle status.' };
}
return { valid: true };
}
/**
* Merges existing finding IDs (JSON string) with new IDs (array), deduplicates,
* and returns the merged array as a JSON string.
*/
function mergeFindings(existingJson, newIds) {
const existing = JSON.parse(existingJson || '[]');
const merged = [...new Set([...existing, ...newIds])];
return JSON.stringify(merged);
}
/**
* Builds a submission history entry object.
* The caller is responsible for setting submission_id on the returned object.
*/
function buildSubmissionHistoryEntry(changeType, details, userId, username) {
return {
user_id: userId,
username: username,
change_type: changeType,
change_details_json: JSON.stringify(details),
created_at: new Date().toISOString()
};
}
// ---------------------------------------------------------------------------
// Multer configuration
// ---------------------------------------------------------------------------
@@ -385,6 +427,586 @@ function createIvantiFpWorkflowRouter(db, requireAuth) {
});
});
// -----------------------------------------------------------------------
// GET /api/ivanti/fp-submissions
// Returns the authenticated user's FP submission records with history.
// -----------------------------------------------------------------------
router.get('/submissions', requireAuth(db), (req, res) => {
(async () => {
try {
const submissions = await new Promise((resolve, reject) => {
db.all(
`SELECT * FROM ivanti_fp_submissions WHERE user_id = ? ORDER BY created_at DESC`,
[req.user.id],
(err, rows) => { if (err) reject(err); else resolve(rows || []); }
);
});
// Fetch history for all submissions in one query if there are any
if (submissions.length > 0) {
const submissionIds = submissions.map(s => s.id);
const placeholders = submissionIds.map(() => '?').join(',');
const historyRows = await new Promise((resolve, reject) => {
db.all(
`SELECT * FROM ivanti_fp_submission_history WHERE submission_id IN (${placeholders}) ORDER BY created_at ASC`,
submissionIds,
(err, rows) => { if (err) reject(err); else resolve(rows || []); }
);
});
// Group history by submission_id
const historyMap = {};
for (const row of historyRows) {
if (!historyMap[row.submission_id]) historyMap[row.submission_id] = [];
historyMap[row.submission_id].push(row);
}
for (const sub of submissions) {
sub.history = historyMap[sub.id] || [];
}
} else {
// No submissions, nothing to do
}
res.json(submissions);
} catch (err) {
console.error('Error fetching FP submissions:', err);
res.status(500).json({ error: 'Internal server error.' });
}
})().catch((unexpectedErr) => {
console.error('Unexpected error in GET /submissions:', unexpectedErr);
res.status(500).json({ error: 'Internal server error.' });
});
});
// -----------------------------------------------------------------------
// PUT /api/ivanti/fp-submissions/:id
// Updates form fields and proxies to Ivanti update endpoint.
// -----------------------------------------------------------------------
router.put('/submissions/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
(async () => {
const submissionId = req.params.id;
// 1. Fetch submission and verify ownership
const submission = await new Promise((resolve, reject) => {
db.get(`SELECT * FROM ivanti_fp_submissions WHERE id = ?`, [submissionId], (err, row) => {
if (err) reject(err); else resolve(row);
});
});
if (!submission) {
return res.status(404).json({ error: 'Submission not found.' });
}
if (submission.user_id !== req.user.id) {
return res.status(403).json({ error: 'You can only edit your own submissions.' });
}
// 2. Lifecycle guard
if (submission.lifecycle_status === 'approved') {
return res.status(400).json({ error: 'This submission is finalized and cannot be edited.' });
}
// 3. Validate body
const validationErrors = validateFpWorkflowForm(req.body);
if (Object.keys(validationErrors).length > 0) {
return res.status(400).json({ success: false, errors: validationErrors });
}
// 4. Proxy to Ivanti
const apiKey = process.env.IVANTI_API_KEY;
const clientId = process.env.IVANTI_CLIENT_ID || '1550';
const skipTls = process.env.IVANTI_SKIP_TLS === 'true';
if (!apiKey) {
return res.status(500).json({ success: false, error: 'Ivanti API key is not configured.' });
}
const scopeMap = { 'Authorized': 'AUTHORIZED', 'None': 'NONE', 'Automated': 'AUTOMATED' };
const updateUrl = `/client/${encodeURIComponent(clientId)}/workflowBatch/falsePositive/update`;
const updateBody = {
id: submission.ivanti_workflow_batch_id,
name: req.body.name,
reason: req.body.reason,
description: req.body.description || '',
expirationDate: req.body.expirationDate,
overrideControl: scopeMap[req.body.scopeOverride] || 'AUTHORIZED'
};
let ivantiResult;
try {
ivantiResult = await ivantiPost(updateUrl, updateBody, apiKey, skipTls);
} catch (networkErr) {
logAudit(db, {
userId: req.user.id, username: req.user.username,
action: 'ivanti_fp_submission_edit_failed', entityType: 'ivanti_workflow',
details: { error: networkErr.message, submissionId },
ipAddress: req.ip
});
return res.status(502).json({ success: false, error: 'Failed to connect to Ivanti API.', details: networkErr.message });
}
if (ivantiResult.status !== 200 && ivantiResult.status !== 201 && ivantiResult.status !== 202) {
const errorMap = {
401: 'Ivanti API key is invalid or missing. Contact your administrator.',
419: 'API key lacks permissions for this operation.',
429: 'Ivanti API rate limit reached. Please try again in a few minutes.'
};
const errorMsg = ivantiResult.status >= 500
? 'Ivanti API is temporarily unavailable. Please try again later.'
: (errorMap[ivantiResult.status] || `Operation failed: ${ivantiResult.status}`);
return res.status(ivantiResult.status === 429 ? 429 : 502).json({ success: false, error: errorMsg });
}
// 5. Determine new lifecycle_status
let newLifecycleStatus = submission.lifecycle_status;
if (submission.lifecycle_status === 'rejected' || submission.lifecycle_status === 'rework') {
newLifecycleStatus = 'resubmitted';
}
// 6. Update local record
const now = new Date().toISOString();
try {
await new Promise((resolve, reject) => {
db.run(
`UPDATE ivanti_fp_submissions
SET workflow_name = ?, reason = ?, description = ?, expiration_date = ?, scope_override = ?, lifecycle_status = ?, updated_at = ?
WHERE id = ?`,
[
req.body.name,
req.body.reason,
req.body.description || null,
req.body.expirationDate,
req.body.scopeOverride || 'Authorized',
newLifecycleStatus,
now,
submissionId
],
(err) => { if (err) reject(err); else resolve(); }
);
});
} catch (dbErr) {
console.error('Failed to update submission record:', dbErr);
return res.status(500).json({ success: false, error: 'Failed to update local record.' });
}
// 7. Insert history row
const historyEntry = buildSubmissionHistoryEntry('fields_updated', {
changed: {
name: { from: submission.workflow_name, to: req.body.name },
reason: { from: submission.reason, to: req.body.reason },
description: { from: submission.description, to: req.body.description || '' },
expirationDate: { from: submission.expiration_date, to: req.body.expirationDate },
scopeOverride: { from: submission.scope_override, to: req.body.scopeOverride || 'Authorized' }
}
}, req.user.id, req.user.username);
try {
await new Promise((resolve, reject) => {
db.run(
`INSERT INTO ivanti_fp_submission_history (submission_id, user_id, username, change_type, change_details_json, created_at)
VALUES (?, ?, ?, ?, ?, ?)`,
[submissionId, historyEntry.user_id, historyEntry.username, historyEntry.change_type, historyEntry.change_details_json, historyEntry.created_at],
(err) => { if (err) reject(err); else resolve(); }
);
});
} catch (histErr) {
console.error('Failed to insert history row:', histErr);
}
// 8. Audit log
logAudit(db, {
userId: req.user.id, username: req.user.username,
action: 'ivanti_fp_submission_edited', entityType: 'ivanti_workflow',
entityId: String(submission.ivanti_workflow_batch_id),
details: { submissionId, workflowName: req.body.name },
ipAddress: req.ip
});
// 9. Return updated record
const updatedRecord = await new Promise((resolve, reject) => {
db.get(`SELECT * FROM ivanti_fp_submissions WHERE id = ?`, [submissionId], (err, row) => {
if (err) reject(err); else resolve(row);
});
});
res.json({ success: true, submission: updatedRecord });
})().catch((unexpectedErr) => {
console.error('Unexpected error in PUT /submissions/:id:', unexpectedErr);
res.status(500).json({ success: false, error: 'Internal server error.' });
});
});
// -----------------------------------------------------------------------
// POST /api/ivanti/fp-submissions/:id/findings
// Maps additional findings to the existing workflow batch.
// -----------------------------------------------------------------------
router.post('/submissions/:id/findings', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
(async () => {
const submissionId = req.params.id;
const { findingIds, queueItemIds } = req.body;
// Validate arrays
if (!Array.isArray(findingIds) || findingIds.length === 0) {
return res.status(400).json({ error: 'At least one finding ID is required.' });
}
if (!Array.isArray(queueItemIds) || queueItemIds.length === 0) {
return res.status(400).json({ error: 'At least one queue item ID is required.' });
}
// 1. Fetch submission and verify ownership
const submission = await new Promise((resolve, reject) => {
db.get(`SELECT * FROM ivanti_fp_submissions WHERE id = ?`, [submissionId], (err, row) => {
if (err) reject(err); else resolve(row);
});
});
if (!submission) {
return res.status(404).json({ error: 'Submission not found.' });
}
if (submission.user_id !== req.user.id) {
return res.status(403).json({ error: 'You can only edit your own submissions.' });
}
// 2. Lifecycle guard
if (submission.lifecycle_status === 'approved') {
return res.status(400).json({ error: 'This submission is finalized and cannot be edited.' });
}
// 3. Verify queue items belong to user, are FP type, and pending
const placeholders = queueItemIds.map(() => '?').join(',');
const queueRows = await new Promise((resolve, reject) => {
db.all(
`SELECT id, workflow_type, status, user_id FROM ivanti_todo_queue WHERE id IN (${placeholders})`,
queueItemIds,
(err, rows) => { if (err) reject(err); else resolve(rows || []); }
);
});
if (queueRows.length !== queueItemIds.length) {
return res.status(400).json({ error: 'One or more queue items not found.' });
}
for (const row of queueRows) {
if (row.user_id !== req.user.id) {
return res.status(403).json({ error: 'You can only submit your own queue items.' });
}
if (row.workflow_type !== 'FP') {
return res.status(400).json({ error: `Queue item ${row.id} is not an FP workflow type.` });
}
if (row.status !== 'pending') {
return res.status(400).json({ error: `Queue item ${row.id} is not in pending status.` });
}
}
// 4. Proxy to Ivanti map endpoint
const apiKey = process.env.IVANTI_API_KEY;
const clientId = process.env.IVANTI_CLIENT_ID || '1550';
const skipTls = process.env.IVANTI_SKIP_TLS === 'true';
if (!apiKey) {
return res.status(500).json({ success: false, error: 'Ivanti API key is not configured.' });
}
const mapUrl = `/client/${encodeURIComponent(clientId)}/workflowBatch/falsePositive/${encodeURIComponent(submission.ivanti_workflow_batch_uuid)}/map`;
const formFields = [{ name: 'subjectFilterRequest', value: buildSubjectFilterRequest(findingIds) }];
let mapResult;
try {
mapResult = await ivantiFormPost(mapUrl, formFields, [], apiKey, skipTls);
} catch (networkErr) {
logAudit(db, {
userId: req.user.id, username: req.user.username,
action: 'ivanti_fp_findings_add_failed', entityType: 'ivanti_workflow',
details: { error: networkErr.message, submissionId, findingIds },
ipAddress: req.ip
});
return res.status(502).json({ success: false, error: 'Failed to connect to Ivanti API.', details: networkErr.message });
}
if (mapResult.status !== 200 && mapResult.status !== 201 && mapResult.status !== 202) {
const errorMap = {
401: 'Ivanti API key is invalid or missing. Contact your administrator.',
419: 'API key lacks permissions for this operation.',
429: 'Ivanti API rate limit reached. Please try again in a few minutes.'
};
const errorMsg = mapResult.status >= 500
? 'Ivanti API is temporarily unavailable. Please try again later.'
: (errorMap[mapResult.status] || `Operation failed: ${mapResult.status}`);
return res.status(mapResult.status === 429 ? 429 : 502).json({ success: false, error: errorMsg });
}
// 5. Merge finding IDs
const mergedJson = mergeFindings(submission.finding_ids_json, findingIds);
const now = new Date().toISOString();
try {
await new Promise((resolve, reject) => {
db.run(
`UPDATE ivanti_fp_submissions SET finding_ids_json = ?, updated_at = ? WHERE id = ?`,
[mergedJson, now, submissionId],
(err) => { if (err) reject(err); else resolve(); }
);
});
} catch (dbErr) {
console.error('Failed to update finding_ids_json:', dbErr);
}
// 6. Mark queue items complete
let queueItemsUpdated = 0;
try {
const queuePlaceholders = queueItemIds.map(() => '?').join(',');
queueItemsUpdated = await new Promise((resolve, reject) => {
db.run(
`UPDATE ivanti_todo_queue SET status='complete', updated_at=CURRENT_TIMESTAMP WHERE id IN (${queuePlaceholders}) AND user_id=?`,
[...queueItemIds, req.user.id],
function (err) { if (err) reject(err); else resolve(this.changes); }
);
});
} catch (queueErr) {
console.error('Failed to update queue items:', queueErr);
}
// 7. Insert history row
const historyEntry = buildSubmissionHistoryEntry('findings_added', {
addedFindingIds: findingIds,
queueItemIds: queueItemIds
}, req.user.id, req.user.username);
try {
await new Promise((resolve, reject) => {
db.run(
`INSERT INTO ivanti_fp_submission_history (submission_id, user_id, username, change_type, change_details_json, created_at)
VALUES (?, ?, ?, ?, ?, ?)`,
[submissionId, historyEntry.user_id, historyEntry.username, historyEntry.change_type, historyEntry.change_details_json, historyEntry.created_at],
(err) => { if (err) reject(err); else resolve(); }
);
});
} catch (histErr) {
console.error('Failed to insert history row:', histErr);
}
// 8. Audit log
logAudit(db, {
userId: req.user.id, username: req.user.username,
action: 'ivanti_fp_findings_added', entityType: 'ivanti_workflow',
entityId: String(submission.ivanti_workflow_batch_id),
details: { submissionId, addedFindingIds: findingIds, queueItemsUpdated },
ipAddress: req.ip
});
res.json({ success: true, addedFindings: findingIds, queueItemsUpdated });
})().catch((unexpectedErr) => {
console.error('Unexpected error in POST /submissions/:id/findings:', unexpectedErr);
res.status(500).json({ success: false, error: 'Internal server error.' });
});
});
// -----------------------------------------------------------------------
// POST /api/ivanti/fp-submissions/:id/attachments
// Uploads additional files to the existing workflow batch.
// -----------------------------------------------------------------------
router.post('/submissions/:id/attachments', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
fpUpload(req, res, (multerErr) => {
if (multerErr) {
if (multerErr.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({ error: 'File exceeds the 10 MB size limit.' });
}
return res.status(400).json({ error: multerErr.message });
}
const files = req.files || [];
if (files.length === 0) {
return res.status(400).json({ error: 'At least one file is required.' });
}
// Validate extensions (belt-and-suspenders)
for (const file of files) {
if (!isAllowedFileExtension(file.originalname)) {
return res.status(400).json({
error: `File type not allowed: ${file.originalname}. Allowed: ${[...ALLOWED_EXTENSIONS].join(', ')}`
});
}
}
(async () => {
const submissionId = req.params.id;
// 1. Fetch submission and verify ownership
const submission = await new Promise((resolve, reject) => {
db.get(`SELECT * FROM ivanti_fp_submissions WHERE id = ?`, [submissionId], (err, row) => {
if (err) reject(err); else resolve(row);
});
});
if (!submission) {
return res.status(404).json({ error: 'Submission not found.' });
}
if (submission.user_id !== req.user.id) {
return res.status(403).json({ error: 'You can only edit your own submissions.' });
}
// 2. Lifecycle guard
if (submission.lifecycle_status === 'approved') {
return res.status(400).json({ error: 'This submission is finalized and cannot be edited.' });
}
// 3. Upload each file to Ivanti
const apiKey = process.env.IVANTI_API_KEY;
const clientId = process.env.IVANTI_CLIENT_ID || '1550';
const skipTls = process.env.IVANTI_SKIP_TLS === 'true';
if (!apiKey) {
return res.status(500).json({ success: false, error: 'Ivanti API key is not configured.' });
}
const attachUrl = `/client/${encodeURIComponent(clientId)}/workflowBatch/falsePositive/${encodeURIComponent(submission.ivanti_workflow_batch_uuid)}/attach`;
const attachmentResults = [];
for (const f of files) {
try {
const result = await ivantiMultipartPost(attachUrl, f.buffer, f.originalname, apiKey, skipTls);
const success = result.status === 200 || result.status === 201 || result.status === 202;
attachmentResults.push({ filename: f.originalname, success, ...(success ? {} : { error: `Upload failed: ${result.status}` }) });
} catch (uploadErr) {
attachmentResults.push({ filename: f.originalname, success: false, error: uploadErr.message });
}
}
// 4. Update attachment_count and attachment_results_json
const existingResults = JSON.parse(submission.attachment_results_json || '[]');
const allResults = [...existingResults, ...attachmentResults];
const successCount = attachmentResults.filter(r => r.success).length;
const newAttachmentCount = (submission.attachment_count || 0) + successCount;
const now = new Date().toISOString();
try {
await new Promise((resolve, reject) => {
db.run(
`UPDATE ivanti_fp_submissions SET attachment_count = ?, attachment_results_json = ?, updated_at = ? WHERE id = ?`,
[newAttachmentCount, JSON.stringify(allResults), now, submissionId],
(err) => { if (err) reject(err); else resolve(); }
);
});
} catch (dbErr) {
console.error('Failed to update attachment records:', dbErr);
}
// 5. Insert history row
const historyEntry = buildSubmissionHistoryEntry('attachments_added', {
files: attachmentResults
}, req.user.id, req.user.username);
try {
await new Promise((resolve, reject) => {
db.run(
`INSERT INTO ivanti_fp_submission_history (submission_id, user_id, username, change_type, change_details_json, created_at)
VALUES (?, ?, ?, ?, ?, ?)`,
[submissionId, historyEntry.user_id, historyEntry.username, historyEntry.change_type, historyEntry.change_details_json, historyEntry.created_at],
(err) => { if (err) reject(err); else resolve(); }
);
});
} catch (histErr) {
console.error('Failed to insert history row:', histErr);
}
// 6. Audit log
logAudit(db, {
userId: req.user.id, username: req.user.username,
action: 'ivanti_fp_attachments_added', entityType: 'ivanti_workflow',
entityId: String(submission.ivanti_workflow_batch_id),
details: { submissionId, attachmentResults },
ipAddress: req.ip
});
const allSucceeded = attachmentResults.every(r => r.success);
res.json({ success: true, attachmentResults, status: allSucceeded ? 'success' : 'partial' });
})().catch((unexpectedErr) => {
console.error('Unexpected error in POST /submissions/:id/attachments:', unexpectedErr);
res.status(500).json({ success: false, error: 'Internal server error.' });
});
});
});
// -----------------------------------------------------------------------
// PATCH /api/ivanti/fp-submissions/:id/status
// Updates the lifecycle status of a submission.
// -----------------------------------------------------------------------
router.patch('/submissions/:id/status', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
(async () => {
const submissionId = req.params.id;
const newStatus = req.body.lifecycle_status;
// 1. Fetch submission and verify ownership
const submission = await new Promise((resolve, reject) => {
db.get(`SELECT * FROM ivanti_fp_submissions WHERE id = ?`, [submissionId], (err, row) => {
if (err) reject(err); else resolve(row);
});
});
if (!submission) {
return res.status(404).json({ error: 'Submission not found.' });
}
if (submission.user_id !== req.user.id) {
return res.status(403).json({ error: 'You can only edit your own submissions.' });
}
// 2. Validate transition
const transition = validateLifecycleTransition(submission.lifecycle_status, newStatus);
if (!transition.valid) {
return res.status(400).json({ error: transition.error });
}
// 3. Update lifecycle_status and updated_at
const now = new Date().toISOString();
const previousStatus = submission.lifecycle_status;
try {
await new Promise((resolve, reject) => {
db.run(
`UPDATE ivanti_fp_submissions SET lifecycle_status = ?, updated_at = ? WHERE id = ?`,
[newStatus, now, submissionId],
(err) => { if (err) reject(err); else resolve(); }
);
});
} catch (dbErr) {
console.error('Failed to update lifecycle status:', dbErr);
return res.status(500).json({ success: false, error: 'Failed to update status.' });
}
// 4. Insert history row
const historyEntry = buildSubmissionHistoryEntry('status_changed', {
from: previousStatus,
to: newStatus
}, req.user.id, req.user.username);
try {
await new Promise((resolve, reject) => {
db.run(
`INSERT INTO ivanti_fp_submission_history (submission_id, user_id, username, change_type, change_details_json, created_at)
VALUES (?, ?, ?, ?, ?, ?)`,
[submissionId, historyEntry.user_id, historyEntry.username, historyEntry.change_type, historyEntry.change_details_json, historyEntry.created_at],
(err) => { if (err) reject(err); else resolve(); }
);
});
} catch (histErr) {
console.error('Failed to insert history row:', histErr);
}
// 5. Audit log
logAudit(db, {
userId: req.user.id, username: req.user.username,
action: 'ivanti_fp_status_changed', entityType: 'ivanti_workflow',
entityId: String(submission.ivanti_workflow_batch_id),
details: { submissionId, from: previousStatus, to: newStatus },
ipAddress: req.ip
});
res.json({ success: true, previousStatus, newStatus });
})().catch((unexpectedErr) => {
console.error('Unexpected error in PATCH /submissions/:id/status:', unexpectedErr);
res.status(500).json({ success: false, error: 'Internal server error.' });
});
});
return router;
}
@@ -393,3 +1015,6 @@ module.exports.validateFpWorkflowForm = validateFpWorkflowForm;
module.exports.buildIvantiFormFields = buildIvantiFormFields;
module.exports.buildSubjectFilterRequest = buildSubjectFilterRequest;
module.exports.isAllowedFileExtension = isAllowedFileExtension;
module.exports.validateLifecycleTransition = validateLifecycleTransition;
module.exports.mergeFindings = mergeFindings;
module.exports.buildSubmissionHistoryEntry = buildSubmissionHistoryEntry;