Add FP submissions cleanup: auto-clear approved, dismiss rejected, collapsible section
This commit is contained in:
93
backend/__tests__/fp-submissions-cleanup.property.test.js
Normal file
93
backend/__tests__/fp-submissions-cleanup.property.test.js
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* Property-Based Tests: FP Submissions Cleanup
|
||||||
|
*
|
||||||
|
* Feature: fp-submissions-cleanup
|
||||||
|
*
|
||||||
|
* Tests the pure filtering functions used to determine which FP submissions
|
||||||
|
* are visible in the Queue Panel and which show the dismiss button.
|
||||||
|
*
|
||||||
|
* Validates: Requirements 1.1, 2.1, 2.2, 2.3
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fc = require('fast-check');
|
||||||
|
const { filterVisibleSubmissions, shouldShowDismissButton } = require('../routes/ivantiFpWorkflow');
|
||||||
|
|
||||||
|
// --- Generators ---
|
||||||
|
|
||||||
|
const lifecycleStatusArb = fc.constantFrom('submitted', 'approved', 'rejected', 'rework', 'resubmitted');
|
||||||
|
|
||||||
|
const dismissedAtArb = fc.oneof(
|
||||||
|
fc.constant(null),
|
||||||
|
fc.date({ min: new Date('2020-01-01'), max: new Date('2030-12-31') }).map(d => d.toISOString())
|
||||||
|
);
|
||||||
|
|
||||||
|
const submissionArb = fc.record({
|
||||||
|
id: fc.integer({ min: 1, max: 100000 }),
|
||||||
|
lifecycle_status: lifecycleStatusArb,
|
||||||
|
dismissed_at: dismissedAtArb,
|
||||||
|
user_id: fc.integer({ min: 1, max: 1000 }),
|
||||||
|
ivanti_workflow_batch_id: fc.string({ minLength: 1, maxLength: 20 })
|
||||||
|
});
|
||||||
|
|
||||||
|
const submissionsArrayArb = fc.array(submissionArb, { minLength: 0, maxLength: 50 });
|
||||||
|
|
||||||
|
// --- Property 1: Submission Visibility Filter ---
|
||||||
|
|
||||||
|
describe('Feature: fp-submissions-cleanup, Property 1: Submission Visibility Filter', () => {
|
||||||
|
/**
|
||||||
|
* For any array of FP submission objects with arbitrary lifecycle_status values
|
||||||
|
* and arbitrary dismissed_at values, filterVisibleSubmissions(submissions) should
|
||||||
|
* return only submissions where lifecycle_status is NOT "approved" AND dismissed_at
|
||||||
|
* is null. Additionally, every submission in the input that satisfies both conditions
|
||||||
|
* must appear in the output, and the output length must be <= input length.
|
||||||
|
*
|
||||||
|
* Validates: Requirements 1.1, 2.2, 2.3
|
||||||
|
*/
|
||||||
|
it('returns only non-approved and non-dismissed submissions', () => {
|
||||||
|
fc.assert(
|
||||||
|
fc.property(submissionsArrayArb, (submissions) => {
|
||||||
|
const result = filterVisibleSubmissions(submissions);
|
||||||
|
|
||||||
|
// Output length must be <= input length
|
||||||
|
expect(result.length).toBeLessThanOrEqual(submissions.length);
|
||||||
|
|
||||||
|
// Every item in the result must be non-approved and non-dismissed
|
||||||
|
for (const s of result) {
|
||||||
|
expect(s.lifecycle_status).not.toBe('approved');
|
||||||
|
expect(s.dismissed_at).toBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every input item that satisfies both conditions must appear in the output
|
||||||
|
const expected = submissions.filter(
|
||||||
|
s => s.lifecycle_status !== 'approved' && s.dismissed_at == null
|
||||||
|
);
|
||||||
|
expect(result).toEqual(expected);
|
||||||
|
}),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Property 2: Dismiss Button Visibility Predicate ---
|
||||||
|
|
||||||
|
describe('Feature: fp-submissions-cleanup, Property 2: Dismiss Button Visibility Predicate', () => {
|
||||||
|
/**
|
||||||
|
* For any FP submission object with a lifecycle_status value drawn from
|
||||||
|
* {submitted, approved, rejected, rework, resubmitted} and a dismissed_at value
|
||||||
|
* (null or timestamp), the dismiss button should be rendered if and only if
|
||||||
|
* lifecycle_status === 'rejected' AND dismissed_at is null.
|
||||||
|
*
|
||||||
|
* Validates: Requirements 2.1
|
||||||
|
*/
|
||||||
|
it('returns true iff status is rejected and dismissed_at is null', () => {
|
||||||
|
fc.assert(
|
||||||
|
fc.property(submissionArb, (submission) => {
|
||||||
|
const result = shouldShowDismissButton(submission);
|
||||||
|
const expected = submission.lifecycle_status === 'rejected' && submission.dismissed_at == null;
|
||||||
|
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
}),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
240
backend/__tests__/fp-submissions-cleanup.test.js
Normal file
240
backend/__tests__/fp-submissions-cleanup.test.js
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
/**
|
||||||
|
* Unit and Integration Tests: FP Submissions Cleanup
|
||||||
|
*
|
||||||
|
* Feature: fp-submissions-cleanup
|
||||||
|
*
|
||||||
|
* Tests cover:
|
||||||
|
* - Dismiss endpoint (happy path, wrong status, ownership check, not found)
|
||||||
|
* - Filter edge cases (all approved, all dismissed, mixed, empty array)
|
||||||
|
* - Integration: dismissed submissions remain in DB but are excluded from filtered list
|
||||||
|
*/
|
||||||
|
|
||||||
|
const http = require('http');
|
||||||
|
const express = require('express');
|
||||||
|
|
||||||
|
// Mock auth middleware to bypass real session checks
|
||||||
|
jest.mock('../middleware/auth', () => ({
|
||||||
|
requireAuth: () => (req, res, next) => {
|
||||||
|
req.user = { id: 1, username: 'testuser', group: 'Admin' };
|
||||||
|
next();
|
||||||
|
},
|
||||||
|
requireGroup: () => (req, res, next) => next(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock audit log as a no-op
|
||||||
|
jest.mock('../helpers/auditLog', () => jest.fn());
|
||||||
|
|
||||||
|
// Mock ivantiApi to avoid real network calls
|
||||||
|
jest.mock('../helpers/ivantiApi', () => ({
|
||||||
|
ivantiFormPost: jest.fn(),
|
||||||
|
ivantiPost: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the db pool
|
||||||
|
const mockPool = {
|
||||||
|
query: jest.fn(() => Promise.resolve({ rows: [], rowCount: 0 })),
|
||||||
|
};
|
||||||
|
jest.mock('../db', () => mockPool);
|
||||||
|
|
||||||
|
const createIvantiFpWorkflowRouter = require('../routes/ivantiFpWorkflow');
|
||||||
|
const { filterVisibleSubmissions, shouldShowDismissButton } = require('../routes/ivantiFpWorkflow');
|
||||||
|
|
||||||
|
// --- HTTP helper ---
|
||||||
|
|
||||||
|
function request(server, method, path, body) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const addr = server.address();
|
||||||
|
const options = {
|
||||||
|
hostname: '127.0.0.1',
|
||||||
|
port: addr.port,
|
||||||
|
path,
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = http.request(options, (res) => {
|
||||||
|
const chunks = [];
|
||||||
|
res.on('data', (chunk) => chunks.push(chunk));
|
||||||
|
res.on('end', () => {
|
||||||
|
const body = Buffer.concat(chunks).toString();
|
||||||
|
let json;
|
||||||
|
try { json = JSON.parse(body); } catch (e) { json = null; }
|
||||||
|
resolve({ statusCode: res.statusCode, body: json });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', reject);
|
||||||
|
if (body) req.write(JSON.stringify(body));
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Dismiss Endpoint Tests (Task 8.1) ---
|
||||||
|
|
||||||
|
describe('PATCH /submissions/:id/dismiss', () => {
|
||||||
|
let app, server;
|
||||||
|
|
||||||
|
beforeAll((done) => {
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use('/api/ivanti/fp-workflow', createIvantiFpWorkflowRouter());
|
||||||
|
server = app.listen(0, '127.0.0.1', done);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll((done) => {
|
||||||
|
server.close(done);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPool.query.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('happy path — dismisses a rejected submission owned by the user', async () => {
|
||||||
|
// First query: SELECT submission
|
||||||
|
mockPool.query.mockResolvedValueOnce({
|
||||||
|
rows: [{
|
||||||
|
id: 42,
|
||||||
|
user_id: 1,
|
||||||
|
lifecycle_status: 'rejected',
|
||||||
|
dismissed_at: null,
|
||||||
|
ivanti_workflow_batch_id: 'WF-100'
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
// Second query: UPDATE dismissed_at
|
||||||
|
mockPool.query.mockResolvedValueOnce({ rowCount: 1 });
|
||||||
|
|
||||||
|
const res = await request(server, 'PATCH', '/api/ivanti/fp-workflow/submissions/42/dismiss');
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.body).toEqual({ success: true });
|
||||||
|
// Verify the UPDATE was called with the correct SQL pattern
|
||||||
|
expect(mockPool.query).toHaveBeenCalledTimes(2);
|
||||||
|
const updateCall = mockPool.query.mock.calls[1];
|
||||||
|
expect(updateCall[0]).toContain('dismissed_at');
|
||||||
|
expect(updateCall[1]).toContain('42');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 when submission does not exist', async () => {
|
||||||
|
mockPool.query.mockResolvedValueOnce({ rows: [] });
|
||||||
|
|
||||||
|
const res = await request(server, 'PATCH', '/api/ivanti/fp-workflow/submissions/999/dismiss');
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
expect(res.body.error).toBe('Submission not found.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 403 when user does not own the submission', async () => {
|
||||||
|
mockPool.query.mockResolvedValueOnce({
|
||||||
|
rows: [{
|
||||||
|
id: 42,
|
||||||
|
user_id: 99, // different user
|
||||||
|
lifecycle_status: 'rejected',
|
||||||
|
dismissed_at: null,
|
||||||
|
ivanti_workflow_batch_id: 'WF-100'
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(server, 'PATCH', '/api/ivanti/fp-workflow/submissions/42/dismiss');
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
expect(res.body.error).toBe('You can only dismiss your own submissions.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 when submission is not in rejected status', async () => {
|
||||||
|
mockPool.query.mockResolvedValueOnce({
|
||||||
|
rows: [{
|
||||||
|
id: 42,
|
||||||
|
user_id: 1,
|
||||||
|
lifecycle_status: 'submitted',
|
||||||
|
dismissed_at: null,
|
||||||
|
ivanti_workflow_batch_id: 'WF-100'
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(server, 'PATCH', '/api/ivanti/fp-workflow/submissions/42/dismiss');
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(res.body.error).toBe('Only rejected submissions can be dismissed.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Filter Edge Cases (Task 8.2) ---
|
||||||
|
|
||||||
|
describe('filterVisibleSubmissions — edge cases', () => {
|
||||||
|
it('returns empty array when all submissions are approved', () => {
|
||||||
|
const submissions = [
|
||||||
|
{ id: 1, lifecycle_status: 'approved', dismissed_at: null },
|
||||||
|
{ id: 2, lifecycle_status: 'approved', dismissed_at: null },
|
||||||
|
{ id: 3, lifecycle_status: 'approved', dismissed_at: null },
|
||||||
|
];
|
||||||
|
expect(filterVisibleSubmissions(submissions)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array when all submissions are dismissed', () => {
|
||||||
|
const submissions = [
|
||||||
|
{ id: 1, lifecycle_status: 'rejected', dismissed_at: '2026-05-01T12:00:00Z' },
|
||||||
|
{ id: 2, lifecycle_status: 'submitted', dismissed_at: '2026-04-15T08:00:00Z' },
|
||||||
|
{ id: 3, lifecycle_status: 'rework', dismissed_at: '2026-03-20T10:00:00Z' },
|
||||||
|
];
|
||||||
|
expect(filterVisibleSubmissions(submissions)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns correct subset for mixed statuses', () => {
|
||||||
|
const submissions = [
|
||||||
|
{ id: 1, lifecycle_status: 'approved', dismissed_at: null },
|
||||||
|
{ id: 2, lifecycle_status: 'rejected', dismissed_at: null },
|
||||||
|
{ id: 3, lifecycle_status: 'submitted', dismissed_at: '2026-05-01T12:00:00Z' },
|
||||||
|
{ id: 4, lifecycle_status: 'rework', dismissed_at: null },
|
||||||
|
{ id: 5, lifecycle_status: 'resubmitted', dismissed_at: null },
|
||||||
|
];
|
||||||
|
const result = filterVisibleSubmissions(submissions);
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ id: 2, lifecycle_status: 'rejected', dismissed_at: null },
|
||||||
|
{ id: 4, lifecycle_status: 'rework', dismissed_at: null },
|
||||||
|
{ id: 5, lifecycle_status: 'resubmitted', dismissed_at: null },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array for empty input', () => {
|
||||||
|
expect(filterVisibleSubmissions([])).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Integration Test (Task 8.3) ---
|
||||||
|
|
||||||
|
describe('Integration: dismissed submissions remain in DB but are excluded from filtered list', () => {
|
||||||
|
it('dismissed submission is still in the database but excluded by filterVisibleSubmissions', async () => {
|
||||||
|
// Simulate the full database state after a dismiss operation:
|
||||||
|
// The submission record still exists with dismissed_at set
|
||||||
|
const allSubmissionsInDb = [
|
||||||
|
{ id: 1, lifecycle_status: 'submitted', dismissed_at: null, user_id: 1 },
|
||||||
|
{ id: 2, lifecycle_status: 'rejected', dismissed_at: '2026-05-01T12:00:00Z', user_id: 1 },
|
||||||
|
{ id: 3, lifecycle_status: 'approved', dismissed_at: null, user_id: 1 },
|
||||||
|
{ id: 4, lifecycle_status: 'rejected', dismissed_at: null, user_id: 1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// The dismissed submission (id: 2) is still in the database
|
||||||
|
const dismissedSubmission = allSubmissionsInDb.find(s => s.id === 2);
|
||||||
|
expect(dismissedSubmission).toBeDefined();
|
||||||
|
expect(dismissedSubmission.dismissed_at).not.toBeNull();
|
||||||
|
|
||||||
|
// But when we filter for visible submissions, it's excluded
|
||||||
|
const visibleSubmissions = filterVisibleSubmissions(allSubmissionsInDb);
|
||||||
|
|
||||||
|
// Dismissed submission (id: 2) is NOT in the visible list
|
||||||
|
expect(visibleSubmissions.find(s => s.id === 2)).toBeUndefined();
|
||||||
|
|
||||||
|
// Approved submission (id: 3) is also NOT in the visible list
|
||||||
|
expect(visibleSubmissions.find(s => s.id === 3)).toBeUndefined();
|
||||||
|
|
||||||
|
// Non-dismissed, non-approved submissions ARE in the visible list
|
||||||
|
expect(visibleSubmissions).toEqual([
|
||||||
|
{ id: 1, lifecycle_status: 'submitted', dismissed_at: null, user_id: 1 },
|
||||||
|
{ id: 4, lifecycle_status: 'rejected', dismissed_at: null, user_id: 1 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Verify the original array is unchanged (submissions remain in DB)
|
||||||
|
expect(allSubmissionsInDb.length).toBe(4);
|
||||||
|
expect(allSubmissionsInDb.find(s => s.id === 2)).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
17
backend/migrations/add_fp_submissions_dismissed.js
Normal file
17
backend/migrations/add_fp_submissions_dismissed.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
// Migration: Add dismissed_at column to ivanti_fp_submissions table
|
||||||
|
const pool = require('../db');
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
console.log('Starting FP submissions dismissed migration...');
|
||||||
|
try {
|
||||||
|
await pool.query(`ALTER TABLE ivanti_fp_submissions ADD COLUMN IF NOT EXISTS dismissed_at TIMESTAMPTZ DEFAULT NULL`);
|
||||||
|
console.log('✓ dismissed_at column added (or already exists)');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error adding dismissed_at column:', err.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('Migration complete.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
run();
|
||||||
@@ -15,6 +15,7 @@ const MIGRATIONS_DIR = __dirname;
|
|||||||
// Add new migrations to this list as they're created.
|
// Add new migrations to this list as they're created.
|
||||||
const POSTGRES_MIGRATIONS = [
|
const POSTGRES_MIGRATIONS = [
|
||||||
'add_decom_workflow_type.js',
|
'add_decom_workflow_type.js',
|
||||||
|
'add_fp_submissions_dismissed.js',
|
||||||
];
|
];
|
||||||
|
|
||||||
async function runAll() {
|
async function runAll() {
|
||||||
|
|||||||
@@ -88,6 +88,14 @@ 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() };
|
return { user_id: userId, username: username, change_type: changeType, change_details_json: JSON.stringify(details), created_at: new Date().toISOString() };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function filterVisibleSubmissions(submissions) {
|
||||||
|
return submissions.filter(s => s.lifecycle_status !== 'approved' && s.dismissed_at == null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldShowDismissButton(submission) {
|
||||||
|
return submission.lifecycle_status === 'rejected' && submission.dismissed_at == null;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Resolve workflow batch UUID
|
// Resolve workflow batch UUID
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -723,6 +731,45 @@ function createIvantiFpWorkflowRouter() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// PATCH /submissions/:id/dismiss — Dismiss a rejected submission
|
||||||
|
router.patch('/submissions/:id/dismiss', requireAuth(), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||||
|
(async () => {
|
||||||
|
const submissionId = req.params.id;
|
||||||
|
|
||||||
|
const { rows: subRows } = await pool.query(
|
||||||
|
`SELECT * FROM ivanti_fp_submissions WHERE id = $1`, [submissionId]
|
||||||
|
);
|
||||||
|
const submission = subRows[0];
|
||||||
|
|
||||||
|
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 dismiss your own submissions.' });
|
||||||
|
if (submission.lifecycle_status !== 'rejected') return res.status(400).json({ error: 'Only rejected submissions can be dismissed.' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE ivanti_fp_submissions SET dismissed_at = NOW() WHERE id = $1`,
|
||||||
|
[submissionId]
|
||||||
|
);
|
||||||
|
} catch (dbErr) {
|
||||||
|
console.error('Failed to set dismissed_at:', dbErr);
|
||||||
|
return res.status(500).json({ error: 'Internal server error.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
logAudit({
|
||||||
|
userId: req.user.id, username: req.user.username,
|
||||||
|
action: 'ivanti_fp_submission_dismissed', entityType: 'ivanti_workflow',
|
||||||
|
entityId: String(submission.ivanti_workflow_batch_id),
|
||||||
|
details: { submissionId },
|
||||||
|
ipAddress: req.ip
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
})().catch((unexpectedErr) => {
|
||||||
|
console.error('Unexpected error in PATCH /submissions/:id/dismiss:', unexpectedErr);
|
||||||
|
res.status(500).json({ error: 'Internal server error.' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// PATCH /submissions/:id/status — Update lifecycle status
|
// PATCH /submissions/:id/status — Update lifecycle status
|
||||||
router.patch('/submissions/:id/status', requireAuth(), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
router.patch('/submissions/:id/status', requireAuth(), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -787,3 +834,5 @@ module.exports.isAllowedFileExtension = isAllowedFileExtension;
|
|||||||
module.exports.validateLifecycleTransition = validateLifecycleTransition;
|
module.exports.validateLifecycleTransition = validateLifecycleTransition;
|
||||||
module.exports.mergeFindings = mergeFindings;
|
module.exports.mergeFindings = mergeFindings;
|
||||||
module.exports.buildSubmissionHistoryEntry = buildSubmissionHistoryEntry;
|
module.exports.buildSubmissionHistoryEntry = buildSubmissionHistoryEntry;
|
||||||
|
module.exports.filterVisibleSubmissions = filterVisibleSubmissions;
|
||||||
|
module.exports.shouldShowDismissButton = shouldShowDismissButton;
|
||||||
|
|||||||
@@ -1519,7 +1519,7 @@ function AddToQueuePopover({ finding, anchorRect, queueForm, setQueueForm, onAdd
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// QueuePanel — fixed slide-out panel showing the user's Ivanti queue
|
// QueuePanel — fixed slide-out panel showing the user's Ivanti queue
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, onClearCompleted, onCreateFpWorkflow, onRedirectComplete, canWrite, fpSubmissions, onEditSubmission, cardConfigured, cardTeams, onQueueRefresh }) {
|
function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, onClearCompleted, onCreateFpWorkflow, onRedirectComplete, canWrite, fpSubmissions, onEditSubmission, onDismissSubmission, cardConfigured, cardTeams, onQueueRefresh }) {
|
||||||
const pendingCount = items.filter((i) => i.status === 'pending').length;
|
const pendingCount = items.filter((i) => i.status === 'pending').length;
|
||||||
const completedCount = items.filter((i) => i.status === 'complete').length;
|
const completedCount = items.filter((i) => i.status === 'complete').length;
|
||||||
|
|
||||||
@@ -1568,6 +1568,37 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
|
|||||||
setSelectedIds(new Set());
|
setSelectedIds(new Set());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Submissions section — collapsible state (Task 6)
|
||||||
|
const [submissionsCollapsed, setSubmissionsCollapsed] = useState(() => localStorage.getItem('steam_submissions_collapsed') === 'true');
|
||||||
|
const [dismissError, setDismissError] = useState(null);
|
||||||
|
|
||||||
|
const toggleSubmissionsCollapsed = () => {
|
||||||
|
setSubmissionsCollapsed(prev => {
|
||||||
|
const next = !prev;
|
||||||
|
try { localStorage.setItem('steam_submissions_collapsed', String(next)); } catch { /* ignore */ }
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Dismiss handler (Task 5)
|
||||||
|
const handleDismiss = async (e, submissionId) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setDismissError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/ivanti/fp-workflow/submissions/${submissionId}/dismiss`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
setDismissError(data.error || 'Failed to dismiss submission');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (onDismissSubmission) onDismissSubmission(submissionId);
|
||||||
|
} catch (err) {
|
||||||
|
setDismissError('Network error — could not dismiss submission');
|
||||||
|
}
|
||||||
|
};
|
||||||
const handleRedirectSuccess = (newItem) => {
|
const handleRedirectSuccess = (newItem) => {
|
||||||
if (onRedirectComplete) onRedirectComplete(newItem);
|
if (onRedirectComplete) onRedirectComplete(newItem);
|
||||||
setRedirectItem(null);
|
setRedirectItem(null);
|
||||||
@@ -2475,24 +2506,40 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
|
|||||||
{/* Submissions section */}
|
{/* Submissions section */}
|
||||||
{fpSubmissions && fpSubmissions.length > 0 && (
|
{fpSubmissions && fpSubmissions.length > 0 && (
|
||||||
<div style={{ padding: '0 1.25rem 0.75rem' }}>
|
<div style={{ padding: '0 1.25rem 0.75rem' }}>
|
||||||
<div style={{
|
<div
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
onClick={toggleSubmissionsCollapsed}
|
||||||
padding: '0.3rem 0', marginBottom: '0.375rem',
|
style={{
|
||||||
borderBottom: '1px solid rgba(245,158,11,0.2)',
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
}}>
|
padding: '0.3rem 0', marginBottom: '0.375rem',
|
||||||
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '700', color: '#F59E0B', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
|
borderBottom: '1px solid rgba(245,158,11,0.2)',
|
||||||
Submissions
|
cursor: 'pointer', userSelect: 'none',
|
||||||
</span>
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||||
|
{submissionsCollapsed
|
||||||
|
? <ChevronUp style={{ width: '12px', height: '12px', color: '#F59E0B' }} />
|
||||||
|
: <ChevronDown style={{ width: '12px', height: '12px', color: '#F59E0B' }} />
|
||||||
|
}
|
||||||
|
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '700', color: '#F59E0B', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
|
||||||
|
Submissions
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: '#334155' }}>
|
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: '#334155' }}>
|
||||||
{fpSubmissions.length}
|
{fpSubmissions.length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{fpSubmissions.map((sub) => {
|
{dismissError && (
|
||||||
|
<div style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: '#EF4444', marginBottom: '0.375rem', padding: '0.25rem 0.5rem', background: 'rgba(239,68,68,0.08)', borderRadius: '0.25rem' }}>
|
||||||
|
{dismissError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!submissionsCollapsed && fpSubmissions.map((sub) => {
|
||||||
const lsBadge = lifecycleStatusBadge(sub.lifecycle_status);
|
const lsBadge = lifecycleStatusBadge(sub.lifecycle_status);
|
||||||
const findingCount = (() => {
|
const findingCount = (() => {
|
||||||
try { return JSON.parse(sub.finding_ids_json || '[]').length; } catch { return 0; }
|
try { return JSON.parse(sub.finding_ids_json || '[]').length; } catch { return 0; }
|
||||||
})();
|
})();
|
||||||
const clickable = canWrite && onEditSubmission;
|
const clickable = canWrite && onEditSubmission;
|
||||||
|
const showDismiss = sub.lifecycle_status === 'rejected' && !sub.dismissed_at;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={sub.id}
|
key={sub.id}
|
||||||
@@ -2548,6 +2595,32 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
|
|||||||
}}>
|
}}>
|
||||||
{sub.lifecycle_status || 'submitted'}
|
{sub.lifecycle_status || 'submitted'}
|
||||||
</span>
|
</span>
|
||||||
|
{showDismiss && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => handleDismiss(e, sub.id)}
|
||||||
|
title="Dismiss rejected submission"
|
||||||
|
style={{
|
||||||
|
flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
width: '20px', height: '20px',
|
||||||
|
background: 'rgba(239,68,68,0.08)',
|
||||||
|
border: '1px solid rgba(239,68,68,0.2)',
|
||||||
|
borderRadius: '0.2rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: 0,
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = 'rgba(239,68,68,0.2)';
|
||||||
|
e.currentTarget.style.borderColor = 'rgba(239,68,68,0.4)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = 'rgba(239,68,68,0.08)';
|
||||||
|
e.currentTarget.style.borderColor = 'rgba(239,68,68,0.2)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X style={{ width: '12px', height: '12px', color: '#EF4444' }} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -5376,6 +5449,11 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
|||||||
});
|
});
|
||||||
}, [fpSubmissionsRaw, findings]);
|
}, [fpSubmissionsRaw, findings]);
|
||||||
|
|
||||||
|
// Filtered submissions for QueuePanel display — hide approved and dismissed
|
||||||
|
const fpSubmissionsFiltered = useMemo(() => {
|
||||||
|
return fpSubmissions.filter(s => s.lifecycle_status !== 'approved' && !s.dismissed_at);
|
||||||
|
}, [fpSubmissions]);
|
||||||
|
|
||||||
// Queue API helpers
|
// Queue API helpers
|
||||||
const fetchQueue = useCallback(async () => {
|
const fetchQueue = useCallback(async () => {
|
||||||
setQueueLoading(true);
|
setQueueLoading(true);
|
||||||
@@ -5426,6 +5504,11 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
|||||||
fetchFindings();
|
fetchFindings();
|
||||||
}, [fetchFpSubmissions, fetchQueue]); // eslint-disable-line
|
}, [fetchFpSubmissions, fetchQueue]); // eslint-disable-line
|
||||||
|
|
||||||
|
const handleDismissSubmission = useCallback((submissionId) => {
|
||||||
|
// Optimistically remove the dismissed submission from local state
|
||||||
|
setFpSubmissions(prev => prev.filter(s => s.id !== submissionId));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const addToQueue = useCallback(async () => {
|
const addToQueue = useCallback(async () => {
|
||||||
if (!addPopover) return;
|
if (!addPopover) return;
|
||||||
const { finding } = addPopover;
|
const { finding } = addPopover;
|
||||||
@@ -6413,8 +6496,9 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
|||||||
));
|
));
|
||||||
}}
|
}}
|
||||||
canWrite={canWrite}
|
canWrite={canWrite}
|
||||||
fpSubmissions={fpSubmissions}
|
fpSubmissions={fpSubmissionsFiltered}
|
||||||
onEditSubmission={handleEditSubmission}
|
onEditSubmission={handleEditSubmission}
|
||||||
|
onDismissSubmission={handleDismissSubmission}
|
||||||
cardConfigured={cardConfigured}
|
cardConfigured={cardConfigured}
|
||||||
cardTeams={cardTeams}
|
cardTeams={cardTeams}
|
||||||
onQueueRefresh={fetchQueue}
|
onQueueRefresh={fetchQueue}
|
||||||
|
|||||||
Reference in New Issue
Block a user