# Design Document: Ticket Linking ## Overview The ticket-linking feature introduces a generic many-to-many association system between the three entity types in the CVE Dashboard: Archer tickets (EXC-XXXX), Jira tickets (PROJECT-XXXX), and CVEs (CVE-YYYY-NNNNN). A new `ticket_links` PostgreSQL table stores directional link records, but the system treats them as bidirectional — querying from either side returns the link. The existing `cve_id`/`vendor` foreign key columns on `archer_tickets` and `jira_tickets` remain unchanged; this feature is purely additive. The implementation spans three layers: 1. **Database** — A `ticket_links` table with CHECK constraints, unique index, and bidirectional query indexes. 2. **Backend API** — A new Express router (`routes/links.js`) exposing POST/GET/DELETE endpoints behind `requireAuth()`. 3. **Frontend** — A reusable `LinkedItems` React component rendered on Archer and Jira detail views. ## Architecture ```mermaid flowchart TD subgraph Frontend A[ArcherTicketDetail] --> C[LinkedItems Component] B[JiraPage Detail View] --> C C -->|POST /api/links| D[Links Router] C -->|GET /api/links?type=&id=| D C -->|DELETE /api/links/:id| D end subgraph Backend D --> E[Validation Layer] E --> F[PostgreSQL Pool] end subgraph Database F --> G[ticket_links table] F --> H[audit_logs table] end ``` ### Design Decisions | Decision | Rationale | |----------|-----------| | TEXT columns for source/target types and IDs | Avoids polymorphic FK complexity; entity types have well-defined ID patterns validated at the application layer | | Bidirectional queries via OR conditions | Single query returns links regardless of which side was the "source" at creation time | | Duplicate prevention checks both (A→B) and (B→A) | Treats links as undirected edges — prevents logical duplicates stored in different directions | | Separate `ticket_links` table (not modifying existing FKs) | Purely additive; zero risk to existing Archer/Jira/CVE workflows | | Relationship types as TEXT with CHECK constraint | Extensible without migrations; initial set: `related`, `spawned_by`, `blocks` | | Auto-detect entity type from key format | Reduces user friction — no need to manually select a type dropdown | ## Components and Interfaces ### Backend: `routes/links.js` ```javascript // Express router factory — follows existing project pattern function createLinksRouter() { const router = express.Router(); // POST /api/links — Create a new link router.post('/', requireAuth(), async (req, res) => { /* ... */ }); // GET /api/links?type=archer&id=EXC-6056 — Query links for an entity router.get('/', requireAuth(), async (req, res) => { /* ... */ }); // DELETE /api/links/:id — Remove a link by ID router.delete('/:id', requireAuth(), async (req, res) => { /* ... */ }); return router; } ``` **Validation helpers:** ```javascript const ENTITY_PATTERNS = { archer: /^EXC-\d{4,}$/, jira: /^[A-Z][A-Z0-9_]+-\d+$/, cve: /^CVE-\d{4}-\d{4,}$/ }; const VALID_ENTITY_TYPES = ['archer', 'jira', 'cve']; const VALID_RELATIONSHIPS = ['related', 'spawned_by', 'blocks']; function detectEntityType(key) { if (ENTITY_PATTERNS.cve.test(key)) return 'cve'; if (ENTITY_PATTERNS.archer.test(key)) return 'archer'; if (ENTITY_PATTERNS.jira.test(key)) return 'jira'; return null; } ``` ### Frontend: `LinkedItems` Component ``` Props: - entityType: 'archer' | 'jira' | 'cve' - entityId: string (e.g., 'EXC-6056') State: - links: Array - loading: boolean - showAddForm: boolean - error: string | null ``` The component fetches links on mount via `GET /api/links?type={entityType}&id={entityId}`, renders a list of linked items with type badges and relationship labels, and provides "Add Link" / "Remove" controls. ### API Contract **POST /api/links** ```json // Request { "source_type": "archer", "source_id": "EXC-6056", "target_type": "jira", "target_id": "STEAM-1234", "relationship": "related" } // Response 201 { "id": 42, "source_type": "archer", "source_id": "EXC-6056", "target_type": "jira", "target_id": "STEAM-1234", "relationship": "related", "created_by": 7, "created_at": "2025-01-15T10:30:00Z" } ``` **GET /api/links?type=archer&id=EXC-6056** ```json // Response 200 [ { "id": 42, "linked_type": "jira", "linked_id": "STEAM-1234", "relationship": "related", "created_by_username": "jsmith", "created_at": "2025-01-15T10:30:00Z" } ] ``` **DELETE /api/links/:id** ```json // Response 200 { "message": "Link deleted successfully" } ``` ## Data Models ### `ticket_links` Table ```sql CREATE TABLE IF NOT EXISTS ticket_links ( id SERIAL PRIMARY KEY, source_type TEXT NOT NULL CHECK (source_type IN ('archer', 'jira', 'cve')), source_id TEXT NOT NULL, target_type TEXT NOT NULL CHECK (target_type IN ('archer', 'jira', 'cve')), target_id TEXT NOT NULL, relationship TEXT NOT NULL DEFAULT 'related' CHECK (relationship IN ('related', 'spawned_by', 'blocks')), created_by INTEGER REFERENCES users(id), created_at TIMESTAMPTZ DEFAULT NOW(), UNIQUE (source_type, source_id, target_type, target_id) ); CREATE INDEX IF NOT EXISTS idx_ticket_links_source ON ticket_links(source_type, source_id); CREATE INDEX IF NOT EXISTS idx_ticket_links_target ON ticket_links(target_type, target_id); ``` ### Bidirectional Query Pattern ```sql -- Find all links involving entity (type=?, id=?) SELECT * FROM ticket_links WHERE (source_type = $1 AND source_id = $2) OR (target_type = $1 AND target_id = $2); ``` ### Duplicate Prevention Query Before inserting a new link (A→B), check that neither (A→B) nor (B→A) already exists: ```sql SELECT id FROM ticket_links WHERE (source_type = $1 AND source_id = $2 AND target_type = $3 AND target_id = $4) OR (source_type = $3 AND source_id = $4 AND target_type = $1 AND target_id = $2); ``` ### Migration File A new migration `add_ticket_links_table.js` will be added to `backend/migrations/` following the existing pattern (idempotent DDL via `CREATE TABLE IF NOT EXISTS`). ## 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: Entity Type Detection from Key Format *For any* valid entity key matching one of the known patterns (EXC-XXXX for Archer, PROJECT-XXXX for Jira, CVE-YYYY-NNNNN for CVE), the `detectEntityType` function SHALL return the correct entity type; and *for any* string not matching any known pattern, it SHALL return null. **Validates: Requirements 6.4, 1.3, 2.4** ### Property 2: Entity ID Format Validation *For any* (entity_type, entity_id) pair where the entity_id does not match the expected regex pattern for that type, the Link_Service validation SHALL reject the input; and *for any* pair where the entity_id does match, validation SHALL accept it. **Validates: Requirements 2.5** ### Property 3: Self-Link Rejection *For any* valid entity (type, id), attempting to create a link where source and target are identical SHALL be rejected with a validation error, and the ticket_links table SHALL remain unchanged. **Validates: Requirements 2.2** ### Property 4: Bidirectional Duplicate Prevention *For any* two distinct entities A and B, after successfully creating a link from A to B, attempting to create either A→B again or B→A SHALL be rejected with a conflict error. **Validates: Requirements 2.3, 8.3** ### Property 5: Bidirectional Query Completeness *For any* entity E that appears in the ticket_links table (as either source or target), querying links for E SHALL return every link record where E appears on either side, and SHALL NOT return any link record where E does not appear. **Validates: Requirements 3.1, 8.1, 8.2** ### Property 6: Link Creation Round-Trip *For any* valid (source_type, source_id, target_type, target_id, relationship) tuple where source ≠ target and no duplicate exists, creating the link and then querying for it SHALL return a record with matching source_type, source_id, target_type, target_id, and relationship values. **Validates: Requirements 2.1, 3.2** ### Property 7: Delete Removes Link from Queries *For any* existing link record, after deleting it by ID, querying links for either the source or target entity SHALL no longer include that link in the results. **Validates: Requirements 4.1** ## Error Handling ### Backend Error Responses | Scenario | HTTP Status | Response Body | |----------|-------------|---------------| | Missing/invalid auth | 401 | `{ "error": "Authentication required" }` | | Invalid entity type | 400 | `{ "error": "Invalid entity type. Must be one of: archer, jira, cve" }` | | Invalid entity ID format | 400 | `{ "error": "Invalid ID format for type '{type}'. Expected: {pattern}" }` | | Self-link attempt | 400 | `{ "error": "Cannot link an entity to itself" }` | | Duplicate link (either direction) | 409 | `{ "error": "A link between these entities already exists", "existing_id": N }` | | Link not found on delete | 404 | `{ "error": "Link not found" }` | | Database error | 500 | `{ "error": "Internal server error" }` | | Missing required fields | 400 | `{ "error": "source_type, source_id, target_type, target_id are required" }` | ### Frontend Error Handling - API errors are caught and displayed inline in the LinkedItems component (toast or inline message). - Network failures show a generic "Unable to reach server" message with a retry option. - The form is NOT cleared on error so the user can correct and resubmit. - Optimistic UI is NOT used — the list only refreshes after confirmed server success. ### Edge Cases - **Concurrent duplicate creation**: The UNIQUE constraint on the table acts as a final safety net if two requests pass the application-layer check simultaneously. The second insert will fail with a Postgres 23505 error, which the backend maps to a 409 response. - **Deleted entities**: Links are not cascade-deleted when an entity is removed from its source table. Orphaned links are acceptable — the UI will show the ID but may indicate "entity not found" if navigation fails. - **Very long entity IDs**: Entity IDs are TEXT columns with no length limit at the DB level, but the regex patterns enforce reasonable lengths (Archer: 4+ digits, Jira: standard key format, CVE: standard format). ## Testing Strategy ### Unit Tests (Example-Based) - Validation function tests: specific valid/invalid inputs for each entity type - Default relationship value when omitted - Auth rejection (401) for unauthenticated requests - Empty state rendering in LinkedItems component - Form display/hide toggle on "Add Link" button click - Error message display on API failure - Confirmation prompt on remove action ### Property-Based Tests **Library**: [fast-check](https://github.com/dubzzz/fast-check) (already compatible with the project's Jest test runner) **Configuration**: Minimum 100 iterations per property test. Each property test will be tagged with a comment referencing the design property: ``` // Feature: ticket-linking, Property N: {property_text} ``` Properties to implement: 1. **Entity type detection** — Generate random strings matching/not-matching patterns, verify `detectEntityType` correctness 2. **Entity ID format validation** — Generate (type, id) pairs with valid/invalid formats, verify acceptance/rejection 3. **Self-link rejection** — Generate random valid entities, verify self-link is always rejected 4. **Bidirectional duplicate prevention** — Generate entity pairs, create link, verify both directions are blocked 5. **Bidirectional query completeness** — Generate a set of links, verify query returns exactly the correct subset 6. **Link creation round-trip** — Generate valid link inputs, create and query, verify data integrity 7. **Delete removes from queries** — Generate links, delete one, verify it disappears from both sides ### Integration Tests - Full POST → GET → DELETE lifecycle against a test database - Audit log entries created on link creation and deletion - UNIQUE constraint fires on concurrent duplicate inserts - Migration is idempotent (can run multiple times without error) - Existing archer_tickets and jira_tickets schemas unchanged after migration