PSA/shared/rmm/alerts/ticketCreator.ts
Hermes 284313f908
Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

271 lines
8.9 KiB
TypeScript

/**
* 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<CreatedAlertTicket> {
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<void> {
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<void> {
// 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<string | null> {
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<NormalizedRmmAlertSeverity, string[]> = {
critical: ['urgent', 'critical'],
major: ['high'],
moderate: ['medium', 'normal'],
minor: ['low'],
none: ['low'],
};
async function resolvePriorityForSeverity(
trx: Knex.Transaction,
tenantId: string,
severity: NormalizedRmmAlertSeverity
): Promise<string | null> {
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, (params: CreateAlertTicketParams) => 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<string, string> = {
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<string> {
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')}`;
}