/** * Transactional, provider-generic ticket creation for RMM alerts. Adapted from * the Huntress incident creator (which verified the live tickets schema): * tickets have no description/source_reference columns — the body and * provenance live in the attributes JSONB — and entered_at is the creation * timestamp. */ import type { Knex } from 'knex'; import { TicketModel } from '../../models/ticketModel'; import type { NormalizedRmmAlertEvent, NormalizedRmmAlertSeverity, RmmAlertRuleActions, } from './contracts'; export interface CreateAlertTicketParams { event: NormalizedRmmAlertEvent; actions: RmmAlertRuleActions; clientId: string; assetId?: string | null; organizationName?: string | null; } export interface CreatedAlertTicket { ticket_id: string; ticket_number: string; } export async function createTicketForAlert( trx: Knex.Transaction, params: CreateAlertTicketParams ): Promise { const { event, actions } = params; const tenantId = event.tenantId; const boardId = await resolveBoardId(trx, tenantId, actions.boardId); if (!boardId) { throw new Error('No board available for alert ticket (no rule boardId and no default board)'); } // Status resolution is board-scoped (statuses.status_type/board_id) — reuse // the canonical lookup so alert tickets land in the same opening status as // manually created ones. const defaultStatusId = await TicketModel.getDefaultStatusId(tenantId, trx, boardId); if (!defaultStatusId) { throw new Error('No default ticket status configured for tenant'); } const priorityId = actions.priorityOverride ?? (await resolvePriorityForSeverity(trx, tenantId, event.severity)); const title = renderTemplate(actions.ticketTemplate?.titleTemplate, params) ?? defaultTitle(event); const description = renderTemplate(actions.ticketTemplate?.descriptionTemplate, params) ?? defaultDescription(event); const ticketNumber = await generateTicketNumber(trx, tenantId); const now = new Date().toISOString(); const [ticket] = await trx('tickets') .insert({ tenant: tenantId, ticket_number: ticketNumber, title, client_id: params.clientId, status_id: defaultStatusId, priority_id: priorityId ?? null, board_id: boardId, assigned_to: actions.assignToUserId ?? null, attributes: JSON.stringify({ description, source_reference: event.externalAlertId, }), source: event.provider, entered_at: now, updated_at: now, }) .returning(['ticket_id', 'ticket_number']); if (params.assetId) { await associateAsset(trx, tenantId, params.assetId, ticket.ticket_id, now); } await addAlertInternalNote(trx, tenantId, ticket.ticket_id, initialNote(event)); return ticket as CreatedAlertTicket; } /** System-authored internal note (comment_threads row first; thread_id is NOT NULL). */ export async function addAlertInternalNote( trx: Knex.Transaction, tenantId: string, ticketId: string, note: string ): Promise { const now = new Date().toISOString(); const generated = await trx.raw('SELECT gen_random_uuid() AS comment_id, gen_random_uuid() AS thread_id'); const ids = generated.rows?.[0] as { comment_id: string; thread_id: string } | undefined; if (!ids?.comment_id || !ids?.thread_id) { throw new Error('Failed to generate comment/thread identifiers'); } await trx('comment_threads').insert({ tenant: tenantId, thread_id: ids.thread_id, ticket_id: ticketId, project_task_id: null, root_comment_id: ids.comment_id, is_internal: true, reply_count: 0, last_activity_at: now, created_at: now, created_by: null, }); await trx('comments').insert({ tenant: tenantId, comment_id: ids.comment_id, thread_id: ids.thread_id, ticket_id: ticketId, user_id: null, note, is_internal: true, is_resolution: false, is_system_generated: true, created_at: now, }); } async function associateAsset( trx: Knex.Transaction, tenantId: string, assetId: string, ticketId: string, now: string ): Promise { // asset_associations.created_by is NOT NULL with an FK to users; attribute // system-created links to the tenant's earliest user (Huntress convention). const auditUser = await trx('users').where({ tenant: tenantId }).orderBy('created_at', 'asc').first('user_id'); if (!auditUser) return; await trx('asset_associations').insert({ tenant: tenantId, asset_id: assetId, entity_id: ticketId, entity_type: 'ticket', relationship_type: 'related', created_by: auditUser.user_id, created_at: now, }); } async function resolveBoardId( trx: Knex.Transaction, tenantId: string, ruleBoardId?: string ): Promise { if (ruleBoardId) return ruleBoardId; const defaultBoard = await trx('boards') .where({ tenant: tenantId, is_default: true }) .andWhere((qb) => qb.where('is_inactive', false).orWhereNull('is_inactive')) .first('board_id'); return defaultBoard?.board_id ?? null; } const SEVERITY_PRIORITY_NAMES: Record = { critical: ['urgent', 'critical'], major: ['high'], moderate: ['medium', 'normal'], minor: ['low'], none: ['low'], }; async function resolvePriorityForSeverity( trx: Knex.Transaction, tenantId: string, severity: NormalizedRmmAlertSeverity ): Promise { const candidates = SEVERITY_PRIORITY_NAMES[severity] ?? []; // Tenants rarely use the bare names ("P1 - Critical" is typical), so fall // back to a substring match after the exact pass. for (const exact of [true, false]) { for (const name of candidates) { const priority = await trx('priorities') .where({ tenant: tenantId }) .whereRaw( exact ? 'LOWER(priority_name) = ?' : 'LOWER(priority_name) LIKE ?', [exact ? name : `%${name}%`] ) .orderBy('order_number', 'asc') .first('priority_id'); if (priority) return priority.priority_id; } } return null; } const TEMPLATE_PLACEHOLDERS: Record string> = { device: ({ event }) => event.deviceName ?? event.externalDeviceId ?? 'Unknown device', message: ({ event }) => event.message ?? '', severity: ({ event }) => event.severity, organization: ({ organizationName, event }) => organizationName ?? event.externalOrganizationId ?? '', }; function renderTemplate(template: string | undefined, params: CreateAlertTicketParams): string | null { if (!template) return null; return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (match, key: string) => { const resolve = TEMPLATE_PLACEHOLDERS[key.toLowerCase()]; return resolve ? resolve(params) : match; }); } function defaultTitle(event: NormalizedRmmAlertEvent): string { const condition = event.alertClass ?? event.activityType ?? 'Alert'; const device = event.deviceName ?? event.externalDeviceId ?? 'unknown device'; const suffix = event.message && event.message.length < 60 ? `: ${event.message}` : ''; return `[${providerLabel(event.provider)} Alert] ${condition} on ${device}${suffix}`; } function defaultDescription(event: NormalizedRmmAlertEvent): string { return [ `Alert from ${providerLabel(event.provider)}.`, '', `Severity: ${event.severity}`, `Device: ${event.deviceName ?? event.externalDeviceId ?? 'unknown'}`, event.activityType ? `Activity type: ${event.activityType}` : null, event.alertClass ? `Alert class: ${event.alertClass}` : null, `Triggered at: ${event.occurredAt}`, '', event.message ?? '', ] .filter((line): line is string => line !== null) .join('\n'); } function initialNote(event: NormalizedRmmAlertEvent): string { return [ `Ticket created automatically from a ${providerLabel(event.provider)} alert.`, `External alert ID: ${event.externalAlertId}`, event.externalDeviceId ? `External device ID: ${event.externalDeviceId}` : null, `Severity: ${event.severity}`, ] .filter((line): line is string => line !== null) .join('\n'); } const PROVIDER_LABELS: Record = { ninjaone: 'NinjaOne', tacticalrmm: 'Tactical RMM', levelio: 'Level', huntress: 'Huntress', tanium: 'Tanium', }; export function providerLabel(provider: string): string { return PROVIDER_LABELS[provider] ?? provider; } /** Max ticket_number + 1 with the tenant's configured prefix (Huntress/NinjaOne pattern). */ async function generateTicketNumber(trx: Knex.Transaction, tenantId: string): Promise { const result = await trx('tickets').where({ tenant: tenantId }).max('ticket_number as max_number').first(); let nextNumber = 1; if (result?.max_number) { const match = String(result.max_number).match(/(\d+)$/); if (match) nextNumber = parseInt(match[1], 10) + 1; } const settingsRow = await trx('tenant_settings').where({ tenant: tenantId }).first(); const prefix = settingsRow?.settings?.ticket_number_prefix || 'TKT-'; return `${prefix}${String(nextNumber).padStart(6, '0')}`; }