# Design Document: Flexible Jira Ticket Creation ## Overview This feature decouples Jira ticket creation from the CVE-only workflow by making CVE ID and Vendor optional, adding a `source_context` field to track ticket origin, and exposing "Create Jira Ticket" actions from the Ivanti queue and Archer detail views. The changes span a database migration, backend validation updates, and frontend modal/list enhancements. The design preserves backward compatibility — existing tickets retain their CVE ID and Vendor values, and the new `source_context` column defaults to `manual` for legacy rows. ## Architecture ```mermaid flowchart TD subgraph Frontend JiraPage[JiraPage.js
Creation Modal + Ticket List] IvantiQueue[Ivanti Queue Page
Create Jira Ticket action] ArcherView[App.js Archer Detail
Create Jira Ticket action] end subgraph Backend Route[POST /api/jira-tickets/create-in-jira] Validation[Input Validation Layer] JiraAPI[jiraApi.js → Jira REST API] DB[(PostgreSQL
jira_tickets table)] end IvantiQueue -->|opens modal with pre-populated fields| JiraPage ArcherView -->|opens modal with pre-populated fields| JiraPage JiraPage -->|POST payload| Route Route --> Validation Validation -->|valid| JiraAPI JiraAPI -->|issue created| DB Validation -->|invalid| Route Route -->|400/201| JiraPage ``` The modal component is reused across all three entry points (Jira page, Ivanti queue, Archer detail). Each entry point passes pre-populated field values and a locked `source_context` value to the modal. ## Components and Interfaces ### Backend: `POST /api/jira-tickets/create-in-jira` (Updated) **Request Body:** | Field | Type | Required | Validation | |---|---|---|---| | `summary` | string | Yes | Non-empty after trim, max 255 chars | | `cve_id` | string \| null | No | If present and non-empty, must match `CVE-YYYY-NNNN+`. Empty string treated as null. | | `vendor` | string \| null | No | If present and non-empty, 1–200 chars after trim. Empty string treated as null. | | `source_context` | string \| null | No | Must be one of: `cve`, `archer`, `ivanti_queue`, `email`, `manual`. Defaults to `manual` if absent. | | `description` | string \| null | No | Free text, passed to Jira issue body | | `project_key` | string \| null | No | Falls back to `JIRA_PROJECT_KEY` env var | | `issue_type` | string \| null | No | Falls back to `JIRA_ISSUE_TYPE` env var | **Response (201):** ```json { "id": 42, "ticket_key": "VULN-789", "jira_url": "https://jira.example.com/browse/VULN-789", "source_context": "ivanti_queue", "message": "Jira issue created and linked successfully" } ``` **Validation Logic (pseudocode):** ``` if cve_id is empty string → treat as null if cve_id is non-null and non-empty: if not matching /^CVE-\d{4}-\d{4,}$/ → 400 if vendor is empty string or whitespace-only → treat as null if vendor is non-null and non-empty: trim whitespace if length > 200 → 400 if source_context is present: if not in ALLOWED_SET → 400 else: source_context = 'manual' if summary is empty or > 255 chars → 400 ``` ### Backend: `PUT /api/jira-tickets/:id` (Updated) The update endpoint rejects any attempt to change `source_context`: ``` if request body contains source_context field → 400 "source_context is immutable after creation" ``` ### Backend: `GET /api/jira-tickets` (Updated) Response now includes `source_context` on each ticket object. No other changes to the GET endpoint. ### Frontend: Creation Modal (Updated) The existing `showCreateJira` modal in `JiraPage.js` is updated: - CVE ID label: `"CVE ID (optional)"` with placeholder `"e.g. CVE-2024-12345"` - Vendor label: `"Vendor (optional)"` with placeholder `"e.g. Microsoft"` - New Source Context dropdown: options map display labels to API values - Summary remains required with inline validation error on empty submit - Form state extended with `source_context` field **Source Context Options Mapping:** | Display Label | API Value | |---|---| | CVE | `cve` | | Archer Request | `archer` | | Ivanti Queue | `ivanti_queue` | | Email | `email` | | Manual | `manual` | When opened from Ivanti queue or Archer context, the source_context dropdown is pre-selected and read-only. ### Frontend: Ivanti Queue Integration A "Create Jira Ticket" button is added to each queue item row (or action menu). When activated: 1. Opens the Creation Modal 2. Pre-populates `summary` with `finding_title` (truncated to 255 chars) 3. Pre-populates `cve_id` with first element of `cves_json` array (if non-empty) 4. Pre-populates `vendor` with queue item's `vendor` value (if present) 5. Sets `source_context` to `ivanti_queue` (locked) ### Frontend: Archer Detail Integration A "Create Jira Ticket" button is added to the Archer ticket detail view (visible to editor/admin roles). When activated: 1. Opens the Creation Modal 2. Pre-populates `summary` with `exc_number` (e.g., "EXC-1234") 3. Pre-populates `cve_id` with Archer ticket's `cve_id` (if present) 4. Pre-populates `vendor` with Archer ticket's `vendor` (if present) 5. Sets `source_context` to `archer` (locked) ### Frontend: Ticket List Updates The ticket list table gains: - A new "Source" column between Vendor and Summary, displaying a color-coded badge - A source_context dropdown filter (matching the existing status filter pattern) - Search includes `source_context` in the filterable fields **Badge Color Mapping:** | Source Context | Badge Color | |---|---| | `cve` | `#0EA5E9` (blue) | | `archer` | `#8B5CF6` (purple) | | `ivanti_queue` | `#F59E0B` (amber) | | `email` | `#10B981` (green) | | `manual` | `#94A3B8` (gray) | Null/empty source_context displays as "CVE" badge (blue) for backward compatibility with legacy tickets. ## Data Models ### `jira_tickets` Table (Updated Schema) ```sql CREATE TABLE IF NOT EXISTS jira_tickets ( id SERIAL PRIMARY KEY, cve_id TEXT, -- Changed: NULL allowed vendor TEXT, -- Changed: NULL allowed ticket_key TEXT NOT NULL, url TEXT, summary TEXT, status TEXT DEFAULT 'Open' CHECK (status IN ('Open', 'In Progress', 'Closed')), source_context TEXT DEFAULT 'manual', -- New column jira_id TEXT, jira_status TEXT, last_synced_at TIMESTAMPTZ, created_by INTEGER REFERENCES users(id), created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); ``` **Constraints:** - `source_context` has a CHECK constraint limiting values to `('cve', 'archer', 'ivanti_queue', 'email', 'manual')` - `cve_id` allows NULL (NOT NULL constraint dropped) - `vendor` allows NULL (NOT NULL constraint dropped) ### Migration: `add_flexible_jira_ticket_creation.js` ```javascript const pool = require('../db'); async function run() { console.log('Starting flexible Jira ticket creation migration...'); // Verify table exists const { rows } = await pool.query(` SELECT 1 FROM information_schema.tables WHERE table_name = 'jira_tickets' `); if (rows.length === 0) { console.error('✗ jira_tickets table does not exist. Cannot proceed.'); process.exit(1); } // Drop NOT NULL on cve_id await pool.query(`ALTER TABLE jira_tickets ALTER COLUMN cve_id DROP NOT NULL`); console.log('✓ cve_id NOT NULL constraint dropped (or was already nullable)'); // Drop NOT NULL on vendor await pool.query(`ALTER TABLE jira_tickets ALTER COLUMN vendor DROP NOT NULL`); console.log('✓ vendor NOT NULL constraint dropped (or was already nullable)'); // Add source_context column with default await pool.query(` ALTER TABLE jira_tickets ADD COLUMN IF NOT EXISTS source_context TEXT DEFAULT 'manual' `); console.log('✓ source_context column added (or already exists)'); // Add CHECK constraint (idempotent via IF NOT EXISTS pattern) await pool.query(` DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM pg_constraint WHERE conname = 'jira_tickets_source_context_check' ) THEN ALTER TABLE jira_tickets ADD CONSTRAINT jira_tickets_source_context_check CHECK (source_context IN ('cve', 'archer', 'ivanti_queue', 'email', 'manual')); END IF; END $$; `); console.log('✓ source_context CHECK constraint added (or already exists)'); // Backfill existing rows (DEFAULT handles this, but explicit for clarity) await pool.query(` UPDATE jira_tickets SET source_context = 'manual' WHERE source_context IS NULL `); console.log('✓ Existing rows backfilled with source_context = manual'); // Index for filtering await pool.query(` CREATE INDEX IF NOT EXISTS idx_jira_tickets_source_context ON jira_tickets(source_context) `); console.log('✓ source_context index created (or already exists)'); console.log('Migration complete.'); process.exit(0); } run().catch(err => { console.error('Migration failed:', err.message); process.exit(1); }); ``` **Idempotency:** `ALTER COLUMN DROP NOT NULL` is safe to run multiple times (no-op if already nullable). `ADD COLUMN IF NOT EXISTS` and `CREATE INDEX IF NOT EXISTS` are inherently idempotent. The CHECK constraint uses a `DO $$ ... IF NOT EXISTS` guard. ## 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: CVE ID validation and storage *For any* create-ticket request payload, if `cve_id` is absent, null, or an empty string, the service SHALL accept the request and store NULL for `cve_id`; if `cve_id` is a non-empty string matching `CVE-YYYY-NNNN+`, the service SHALL store that exact value. **Validates: Requirements 1.1, 1.2, 1.4** ### Property 2: Invalid CVE ID rejection *For any* non-empty string that does not match the pattern `CVE-YYYY-NNNN+` (four-digit year, four or more digit sequence), the service SHALL reject the create-ticket request with HTTP 400. **Validates: Requirements 1.3** ### Property 3: Vendor validation and storage *For any* create-ticket request payload, if `vendor` is absent, null, empty, or whitespace-only, the service SHALL store NULL; if `vendor` is a non-empty string of 1–200 characters after trimming, the service SHALL store the trimmed value. **Validates: Requirements 2.1, 2.2** ### Property 4: Over-length vendor rejection *For any* string that, after trimming whitespace, exceeds 200 characters, the service SHALL reject the create-ticket request with a validation error. **Validates: Requirements 2.3** ### Property 5: Invalid source_context rejection *For any* string not in the set `{cve, archer, ivanti_queue, email, manual}`, the service SHALL reject the create-ticket request with HTTP 400 when that string is provided as `source_context`. **Validates: Requirements 3.3** ### Property 6: source_context round-trip persistence *For any* valid `source_context` value in `{cve, archer, ivanti_queue, email, manual}`, creating a ticket with that value and then fetching the ticket via GET SHALL return the same `source_context` value. **Validates: Requirements 3.4** ### Property 7: source_context immutability *For any* existing ticket and any `source_context` value (valid or invalid), an update request that includes a `source_context` field SHALL be rejected with HTTP 400. **Validates: Requirements 3.6** ### Property 8: Summary pre-population truncation from Ivanti queue *For any* Ivanti queue item with a `finding_title` of arbitrary length, the pre-populated summary in the Creation Modal SHALL be at most 255 characters and SHALL equal the first 255 characters of `finding_title`. **Validates: Requirements 5.2** ### Property 9: Search includes source_context *For any* ticket whose `source_context` value contains the search term as a substring, that ticket SHALL appear in the filtered results when the user searches by that term. **Validates: Requirements 8.4** ## Error Handling | Scenario | HTTP Status | Error Message | Client Behavior | |---|---|---|---| | Invalid CVE ID format | 400 | "CVE ID format is invalid. Expected CVE-YYYY-NNNN+." | Display inline error, preserve form | | Vendor exceeds 200 chars | 400 | "Vendor exceeds maximum length of 200 characters." | Display inline error, preserve form | | Invalid source_context | 400 | "source_context must be one of: cve, archer, ivanti_queue, email, manual." | Display inline error, preserve form | | source_context update attempt | 400 | "source_context is immutable after creation." | Display inline error | | Empty summary | 400 | "Summary is required (max 255 chars)." | Prevent submission, show inline error | | Jira API unavailable | 502 | "Failed to create Jira issue." | Display error banner, preserve form values for retry | | Jira rate limit exceeded | 429 | "Jira rate limit exceeded. Try again later." | Display error banner, preserve form values | | Jira API not configured | 503 | "Jira API is not configured." | Display error banner | | DB insert fails after Jira creation | 207 | Warning with Jira key/URL | Display warning with Jira link | | jira_tickets table missing (migration) | Exit code 1 | Console error | Migration aborts cleanly | **Error preservation:** When the Jira API call fails or returns an error, the frontend modal retains all user-entered form data so the user can retry without re-entering information. This applies to all three entry points (Jira page, Ivanti queue, Archer detail). ## Testing Strategy ### Unit Tests (Example-Based) - Modal renders with "(optional)" labels and correct placeholders - Modal allows submission with empty CVE ID and Vendor - Source Context dropdown contains all five options with no default selection - Source Context dropdown sends correct API value for each display label - Summary validation prevents empty submission with inline error - Ivanti queue "Create Jira Ticket" button is visible on queue items - Archer detail "Create Jira Ticket" button visible for editor/admin, hidden for viewer - Archer pre-populates summary with `exc_number` - Ticket list displays source_context badge between Vendor and Summary columns - Null source_context displays "CVE" badge text - Source context filter dropdown includes "All" and all distinct values - API error preserves form field values in modal ### Property-Based Tests Property-based testing is appropriate for this feature because the backend validation logic operates on a wide input space (arbitrary strings for CVE IDs, vendors, and source_context values) where universal properties must hold. **Library:** fast-check (already available in the project's test infrastructure via Jest) **Configuration:** - Minimum 100 iterations per property test - Each test tagged with: `Feature: flexible-jira-ticket-creation, Property {N}: {title}` Properties 1–7 test backend validation logic (pure input → output behavior, mockable Jira API). Property 8 tests frontend pre-population logic (pure string truncation). Property 9 tests frontend filtering logic (pure array filter). ### Integration Tests - Migration runs successfully on a database with existing `jira_tickets` rows, preserving all data - Migration is idempotent (running twice produces same result) - Migration fails gracefully when `jira_tickets` table doesn't exist - End-to-end: create ticket from Ivanti queue context → verify stored with `source_context = 'ivanti_queue'` - End-to-end: create ticket from Archer context → verify stored with `source_context = 'archer'`