# Design Document: FP Submissions Cleanup ## Overview This feature addresses three usability improvements to the FP Submissions section in the Queue Panel: 1. **Auto-clear approved submissions** — filter out approved FP submissions from the visible list so finalized items do not clutter the panel. 2. **Dismiss rejected submissions** — add an X button on rejected submissions allowing users to permanently hide them from view, persisted in the database. 3. **Collapsible Submissions section** — add a collapse/expand toggle on the SUBMISSIONS header, with state persisted in localStorage. The design builds on the existing `ivantiFpWorkflow.js` route, the `QueuePanel` component's submissions section in `ReportingPage.js`, and the `ivanti_fp_submissions` table. Changes are minimal: one new database column (`dismissed_at`), one new API endpoint (`PATCH /submissions/:id/dismiss`), and frontend filtering/UI logic. ## Architecture ```mermaid sequenceDiagram participant U as User participant FE as React Frontend participant BE as Express Backend participant DB as PostgreSQL Note over FE,DB: Auto-Clear Approved (frontend filter only) FE->>BE: GET /api/ivanti/fp-workflow/submissions BE->>DB: SELECT * FROM ivanti_fp_submissions WHERE user_id = $1 DB-->>BE: All submissions (including approved) BE-->>FE: JSON array of submissions FE->>FE: Filter out approved + dismissed submissions for display Note over U,DB: Dismiss Rejected Submission U->>FE: Click X button on rejected submission FE->>BE: PATCH /api/ivanti/fp-workflow/submissions/:id/dismiss BE->>DB: UPDATE ivanti_fp_submissions SET dismissed_at = NOW() WHERE id = $1 BE->>DB: INSERT audit_logs (action: ivanti_fp_submission_dismissed) BE-->>FE: 200 OK FE->>FE: Remove submission from visible list Note over U,FE: Collapse/Expand Toggle U->>FE: Click collapse toggle on SUBMISSIONS header FE->>FE: Toggle collapsed state FE->>FE: Persist to localStorage (key: steam_submissions_collapsed) ``` ## Components and Interfaces ### Backend #### Extended Route: `backend/routes/ivantiFpWorkflow.js` One new endpoint added to the existing `createIvantiFpWorkflowRouter`. **Endpoint: `PATCH /api/ivanti/fp-workflow/submissions/:id/dismiss`** Marks a rejected submission as dismissed. - Auth: `requireAuth()`, `requireGroup('Admin', 'Standard_User')` - Ownership: verified via `user_id` match (returns 403 otherwise) - Lifecycle guard: only submissions with `lifecycle_status === 'rejected'` can be dismissed (returns 400 otherwise) - On success: sets `dismissed_at = NOW()` on the submission record, logs audit entry - Response: `{ success: true }` Request: no body required. Response: ```json { "success": true } ``` Error responses: - 404: `{ "error": "Submission not found." }` - 403: `{ "error": "You can only dismiss your own submissions." }` - 400: `{ "error": "Only rejected submissions can be dismissed." }` #### Modified Endpoint: `GET /api/ivanti/fp-workflow/submissions` No changes to the backend query — it continues to return all submissions for the user. The frontend handles filtering. This preserves the ability to open approved submissions from workflow badges in read-only mode. #### Pure Helper Function (exported for testing) - `filterVisibleSubmissions(submissions)` — new, filters an array of submission objects to exclude those with `lifecycle_status === 'approved'` or `dismissed_at !== null`. Returns the filtered array. ### Frontend #### QueuePanel Submissions Section Modifications **Filtering logic:** The `fpSubmissions` array passed to `QueuePanel` is filtered before rendering: - Exclude submissions where `lifecycle_status === 'approved'` - Exclude submissions where `dismissed_at` is not null This filtering is applied in the `useMemo` that already enriches `fpSubmissionsRaw` with Ivanti workflow state. **Dismiss button (X icon):** For submissions with `lifecycle_status === 'rejected'`, render an X button (lucide `X` icon, 12px) on the right side of the submission row. On click: 1. Call `PATCH /api/ivanti/fp-workflow/submissions/:id/dismiss` 2. On success, re-fetch submissions (or optimistically remove from local state) The X button uses `e.stopPropagation()` to prevent triggering the row's click-to-edit handler. **Collapse/expand toggle:** The SUBMISSIONS header row gets a clickable chevron icon: - Expanded state: `ChevronDown` icon (already imported from lucide-react) - Collapsed state: `ChevronUp` icon State management: - `const [submissionsCollapsed, setSubmissionsCollapsed] = useState(() => localStorage.getItem('steam_submissions_collapsed') === 'true')` - On toggle: flip state and persist to localStorage - When collapsed: hide the submission list, keep the header visible with the count badge - Default: expanded (when localStorage has no stored value) localStorage key: `steam_submissions_collapsed` #### FpEditModal — No Changes The `FpEditModal` already handles approved submissions in read-only mode. Since the modal can be opened from workflow badges in the Reporting Table (which bypass the Queue Panel filter), no changes are needed. ## Data Models ### Schema Change to `ivanti_fp_submissions` One new column: ```sql ALTER TABLE ivanti_fp_submissions ADD COLUMN dismissed_at TIMESTAMPTZ DEFAULT NULL; ``` - `NULL` means the submission is not dismissed (visible, subject to other filters) - A timestamp value means the submission was dismissed at that time (hidden from the Submissions section) ### Migration Script: `backend/migrations/add_fp_submissions_dismissed.js` Applies the schema change idempotently. Uses the same pattern as `add_fp_submission_editing.js`: - PostgreSQL: `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` - Wrapped in try/catch for safety ```javascript 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(); ``` ## Correctness Properties *A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.* ### Property 1: Submission Visibility Filter *For any* array of FP submission objects with arbitrary `lifecycle_status` values (from the set {submitted, approved, rejected, rework, resubmitted}) and arbitrary `dismissed_at` values (null or a timestamp string), `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 less than or equal to the input length. **Validates: Requirements 1.1, 2.2, 2.3** ### 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** ## Error Handling ### Dismiss Endpoint Errors | Condition | HTTP Status | Response | Behavior | |-----------|-------------|----------|----------| | Submission not found | 404 | `{ "error": "Submission not found." }` | No state change | | User does not own submission | 403 | `{ "error": "You can only dismiss your own submissions." }` | No state change | | Submission not in "rejected" status | 400 | `{ "error": "Only rejected submissions can be dismissed." }` | No state change | | Database error | 500 | `{ "error": "Internal server error." }` | Log error, no state change | ### Frontend Error Handling - If the dismiss API call fails, show a brief error toast/message and leave the submission in the list - If `localStorage` is unavailable (private browsing), the collapse state defaults to expanded and is not persisted (graceful degradation) ## Testing Strategy ### Property-Based Testing Use `fast-check` as the property-based testing library. Each correctness property maps to a single property-based test with a minimum of 100 iterations. Property tests focus on the pure filtering function: - `filterVisibleSubmissions(submissions)` — Property 1 - Dismiss button visibility predicate — Property 2 Tag format: **Feature: fp-submissions-cleanup, Property {number}: {title}** Test file: `backend/__tests__/fp-submissions-cleanup.property.test.js` ### Unit Testing Unit tests cover specific examples, edge cases, and integration points: - **Filter with all approved**: verify empty result when all submissions are approved - **Filter with all dismissed**: verify empty result when all submissions are dismissed - **Filter with mixed statuses**: verify correct subset returned - **Dismiss endpoint — happy path**: verify `dismissed_at` is set and audit log created - **Dismiss endpoint — wrong status**: verify 400 when attempting to dismiss a non-rejected submission - **Dismiss endpoint — ownership check**: verify 403 when non-owner attempts dismiss - **Collapse state persistence**: verify localStorage read/write with the correct key - **Collapse default**: verify expanded state when localStorage has no value Test file: `backend/__tests__/fp-submissions-cleanup.test.js` ### Integration Testing - Dismiss a rejected submission via API, re-fetch submissions, verify it's excluded from the response when filtered - Verify audit log entry is created with correct action and entity_id - Verify approved submissions are still accessible via direct ID lookup (for the edit modal)