12 KiB
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:
- Database — A
ticket_linkstable with CHECK constraints, unique index, and bidirectional query indexes. - Backend API — A new Express router (
routes/links.js) exposing POST/GET/DELETE endpoints behindrequireAuth(). - Frontend — A reusable
LinkedItemsReact component rendered on Archer and Jira detail views.
Architecture
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
// 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:
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<LinkRecord>
- 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
// 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
// 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
// Response 200
{ "message": "Link deleted successfully" }
Data Models
ticket_links Table
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
-- 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:
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 (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:
- Entity type detection — Generate random strings matching/not-matching patterns, verify
detectEntityTypecorrectness - Entity ID format validation — Generate (type, id) pairs with valid/invalid formats, verify acceptance/rejection
- Self-link rejection — Generate random valid entities, verify self-link is always rejected
- Bidirectional duplicate prevention — Generate entity pairs, create link, verify both directions are blocked
- Bidirectional query completeness — Generate a set of links, verify query returns exactly the correct subset
- Link creation round-trip — Generate valid link inputs, create and query, verify data integrity
- 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