feat: IT Glue integration, workflow engine, pipelines, Zabbix WAN, notification channels, backup status UI improvements, nav alignment fixes

This commit is contained in:
lorentz 2026-02-27 14:52:14 -05:00
parent ed6c4a8b65
commit 19605f82aa
97 changed files with 17080 additions and 304 deletions

View file

@ -466,4 +466,89 @@ export class DattoRMMClient {
return allAlerts;
}
/**
* Get all available automation components (scripts/tasks) for quick jobs.
* GET /api/v2/account/components
*/
async getComponents(): Promise<any[]> {
const allComponents: any[] = [];
let page = 1;
const max = 100;
while (true) {
const response = await this.makeApiCall<any>(
`/account/components?page=${page}&max=${max}`,
{ method: 'GET' }
);
const components = response.components || [];
allComponents.push(...components);
if (components.length < max) break;
page++;
}
return allComponents;
}
/**
* Run a quick job on a device.
* PUT /api/v2/device/{deviceUid}/quickjob
*/
async runQuickJob(
deviceUid: string,
payload: {
jobName: string;
jobComponent: {
componentUid: string;
variables?: Array<{ name: string; value: string }>;
};
}
): Promise<any> {
const token = await this.getAccessToken();
const url = `https://concord-api.centrastage.net/api/v2/device/${deviceUid}/quickjob`;
const resp = await fetch(url, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify(payload),
});
if (!resp.ok) {
const errText = await resp.text();
throw new Error(`Datto RMM quick job failed (${resp.status}): ${errText.substring(0, 200)}`);
}
const text = await resp.text();
return text ? JSON.parse(text) : {};
}
/**
* Get job results for a device.
* GET /api/v2/job/{jobUid}/results/device/{deviceUid}
*/
async getJobResults(jobUid: string, deviceUid: string): Promise<any> {
const token = await this.getAccessToken();
const url = `https://concord-api.centrastage.net/api/v2/job/${jobUid}/results/${deviceUid}`;
const resp = await fetch(url, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
});
if (!resp.ok) {
const errText = await resp.text();
throw new Error(`Datto RMM job results failed (${resp.status}): ${errText.substring(0, 200)}`);
}
return resp.json();
}
}

View file

@ -0,0 +1,430 @@
/**
* IT Glue API Client
* Auth: x-api-key header
* Base: https://api.itglue.com
* Format: application/vnd.api+json (JSON:API)
*/
export interface ITGlueConfig {
apiKey: string;
baseUrl?: string;
}
export interface ITGlueOrganization {
id: string;
name: string;
shortName: string | null;
organizationTypeId: number | null;
organizationTypeName: string | null;
organizationStatusId: number | null;
organizationStatusName: string | null;
psaIntegration: string | null;
syncActive: boolean;
primary: boolean;
createdAt: string;
updatedAt: string;
}
export interface ITGlueFlexibleAsset {
id: string;
organizationId: number;
organizationName: string;
flexibleAssetTypeId: number;
flexibleAssetTypeName: string;
name: string;
traits: Record<string, any>;
createdAt: string;
updatedAt: string;
}
export interface ITGlueConfiguration {
id: string;
organizationId: number;
organizationName: string;
name: string;
hostname: string | null;
primaryIp: string | null;
macAddress: string | null;
serialNumber: string | null;
assetTag: string | null;
configurationTypeId: number | null;
configurationTypeName: string | null;
configurationStatusId: number | null;
configurationStatusName: string | null;
manufacturerId: number | null;
manufacturerName: string | null;
modelId: number | null;
modelName: string | null;
operatingSystemId: number | null;
operatingSystemName: string | null;
notes: string | null;
purchasedAt: string | null;
createdAt: string;
updatedAt: string;
}
export interface ITGluePassword {
id: string;
organizationId: number;
organizationName: string;
name: string;
username: string | null;
password: string | null;
url: string | null;
notes: string | null;
passwordCategoryId: number | null;
passwordCategoryName: string | null;
createdAt: string;
updatedAt: string;
}
export interface ITGlueContact {
id: string;
organizationId: number;
organizationName: string;
firstName: string | null;
lastName: string | null;
title: string | null;
contactTypeId: number | null;
contactTypeName: string | null;
emails: { value: string; primary: boolean; labelName: string }[];
phones: { value: string; extension: string | null; primary: boolean; labelName: string }[];
notes: string | null;
createdAt: string;
updatedAt: string;
}
export interface ITGlueFlexibleAssetType {
id: string;
name: string;
description: string | null;
icon: string | null;
enabled: boolean;
createdAt: string;
updatedAt: string;
}
export interface ITGluePaginatedResponse<T> {
data: T[];
meta: {
currentPage: number;
nextPage: number | null;
prevPage: number | null;
totalPages: number;
totalCount: number;
};
}
export class ITGlueClient {
private readonly apiKey: string;
private readonly baseUrl: string;
private readonly DEFAULT_PAGE_SIZE = 50;
constructor(config: ITGlueConfig) {
this.apiKey = config.apiKey;
this.baseUrl = config.baseUrl || 'https://api.itglue.com';
}
private get headers(): Record<string, string> {
return {
'x-api-key': this.apiKey,
'Content-Type': 'application/vnd.api+json',
};
}
private async request<T>(path: string, params: Record<string, string | number> = {}): Promise<T> {
const url = new URL(`${this.baseUrl}${path}`);
for (const [k, v] of Object.entries(params)) {
url.searchParams.set(k, String(v));
}
const res = await fetch(url.toString(), { headers: this.headers });
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`IT Glue API ${res.status} ${res.statusText}: ${body.slice(0, 200)}`);
}
return res.json();
}
private async fetchAllPages<T>(
path: string,
params: Record<string, string | number> = {},
mapper: (item: any) => T
): Promise<T[]> {
const results: T[] = [];
let page = 1;
let totalPages = 1;
do {
const data: any = await this.request(path, {
...params,
'page[size]': this.DEFAULT_PAGE_SIZE,
'page[number]': page,
});
results.push(...(data.data || []).map(mapper));
totalPages = data.meta?.['total-pages'] ?? 1;
page++;
} while (page <= totalPages);
return results;
}
/** Returns raw JSON:API data array for a single page (used by sync service) */
async getRaw(path: string, params: Record<string, string | number> = {}): Promise<any[]> {
const data: any = await this.request(path, params);
return data.data || [];
}
/** Returns all raw JSON:API data items across all pages (used by sync service) */
async getRawAllPages(path: string, params: Record<string, string | number> = {}): Promise<any[]> {
const results: any[] = [];
let page = 1;
let totalPages = 1;
do {
const data: any = await this.request(path, {
...params,
'page[size]': this.DEFAULT_PAGE_SIZE,
'page[number]': page,
});
results.push(...(data.data || []));
totalPages = data.meta?.['total-pages'] ?? 1;
page++;
} while (page <= totalPages);
return results;
}
// ─── Organizations ────────────────────────────────────────────────────────
private mapOrg(item: any): ITGlueOrganization {
const a = item.attributes;
return {
id: item.id,
name: a['name'],
shortName: a['short-name'] ?? null,
organizationTypeId: a['organization-type-id'] ?? null,
organizationTypeName: a['organization-type-name'] ?? null,
organizationStatusId: a['organization-status-id'] ?? null,
organizationStatusName: a['organization-status-name'] ?? null,
psaIntegration: a['psa-integration'] ?? null,
syncActive: a['sync-active'] ?? false,
primary: a['primary'] ?? false,
createdAt: a['created-at'],
updatedAt: a['updated-at'],
};
}
async getOrganizations(filter?: { name?: string; organizationTypeId?: number }): Promise<ITGlueOrganization[]> {
const params: Record<string, string | number> = {};
if (filter?.name) params['filter[name]'] = filter.name;
if (filter?.organizationTypeId) params['filter[organization-type-id]'] = filter.organizationTypeId;
return this.fetchAllPages('/organizations', params, this.mapOrg);
}
async getOrganization(id: string | number): Promise<ITGlueOrganization> {
const data: any = await this.request(`/organizations/${id}`);
return this.mapOrg(data.data);
}
async findOrganizationByName(name: string): Promise<ITGlueOrganization | null> {
const data: any = await this.request('/organizations', {
'filter[name]': name,
'page[size]': 5,
});
if (!data.data?.length) return null;
return this.mapOrg(data.data[0]);
}
// ─── Flexible Assets ──────────────────────────────────────────────────────
private mapFlexibleAsset(item: any): ITGlueFlexibleAsset {
const a = item.attributes;
return {
id: item.id,
organizationId: a['organization-id'],
organizationName: a['organization-name'],
flexibleAssetTypeId: a['flexible-asset-type-id'],
flexibleAssetTypeName: a['flexible-asset-type-name'],
name: a['name'],
traits: a['traits'] ?? {},
createdAt: a['created-at'],
updatedAt: a['updated-at'],
};
}
async getFlexibleAssets(params: {
organizationId?: number | string;
flexibleAssetTypeId?: number | string;
filter?: Record<string, string>;
}): Promise<ITGlueFlexibleAsset[]> {
const p: Record<string, string | number> = {};
if (params.organizationId) p['filter[organization-id]'] = params.organizationId;
if (params.flexibleAssetTypeId) p['filter[flexible-asset-type-id]'] = params.flexibleAssetTypeId;
if (params.filter) {
for (const [k, v] of Object.entries(params.filter)) {
p[`filter[${k}]`] = v;
}
}
return this.fetchAllPages('/flexible_assets', p, this.mapFlexibleAsset);
}
async getFlexibleAsset(id: string | number): Promise<ITGlueFlexibleAsset> {
const data: any = await this.request(`/flexible_assets/${id}`);
return this.mapFlexibleAsset(data.data);
}
async getFlexibleAssetTypes(): Promise<ITGlueFlexibleAssetType[]> {
return this.fetchAllPages('/flexible_asset_types', {}, (item: any) => ({
id: item.id,
name: item.attributes['name'],
description: item.attributes['description'] ?? null,
icon: item.attributes['icon'] ?? null,
enabled: item.attributes['enabled'] ?? true,
createdAt: item.attributes['created-at'],
updatedAt: item.attributes['updated-at'],
}));
}
// ─── Configurations ───────────────────────────────────────────────────────
private mapConfiguration(item: any): ITGlueConfiguration {
const a = item.attributes;
return {
id: item.id,
organizationId: a['organization-id'],
organizationName: a['organization-name'],
name: a['name'],
hostname: a['hostname'] ?? null,
primaryIp: a['primary-ip'] ?? null,
macAddress: a['mac-address'] ?? null,
serialNumber: a['serial-number'] ?? null,
assetTag: a['asset-tag'] ?? null,
configurationTypeId: a['configuration-type-id'] ?? null,
configurationTypeName: a['configuration-type-name'] ?? null,
configurationStatusId: a['configuration-status-id'] ?? null,
configurationStatusName: a['configuration-status-name'] ?? null,
manufacturerId: a['manufacturer-id'] ?? null,
manufacturerName: a['manufacturer-name'] ?? null,
modelId: a['model-id'] ?? null,
modelName: a['model-name'] ?? null,
operatingSystemId: a['operating-system-id'] ?? null,
operatingSystemName: a['operating-system-name'] ?? null,
notes: a['notes'] ?? null,
purchasedAt: a['purchased-at'] ?? null,
createdAt: a['created-at'],
updatedAt: a['updated-at'],
};
}
async getConfigurations(params: {
organizationId?: number | string;
name?: string;
hostname?: string;
serialNumber?: string;
} = {}): Promise<ITGlueConfiguration[]> {
const p: Record<string, string | number> = {};
if (params.organizationId) p['filter[organization-id]'] = params.organizationId;
if (params.name) p['filter[name]'] = params.name;
if (params.hostname) p['filter[hostname]'] = params.hostname;
if (params.serialNumber) p['filter[serial-number]'] = params.serialNumber;
return this.fetchAllPages('/configurations', p, this.mapConfiguration);
}
async getConfiguration(id: string | number): Promise<ITGlueConfiguration> {
const data: any = await this.request(`/configurations/${id}`);
return this.mapConfiguration(data.data);
}
// ─── Passwords ────────────────────────────────────────────────────────────
private mapPassword(item: any): ITGluePassword {
const a = item.attributes;
return {
id: item.id,
organizationId: a['organization-id'],
organizationName: a['organization-name'],
name: a['name'],
username: a['username'] ?? null,
password: a['password'] ?? null,
url: a['url'] ?? null,
notes: a['notes'] ?? null,
passwordCategoryId: a['password-category-id'] ?? null,
passwordCategoryName: a['password-category-name'] ?? null,
createdAt: a['created-at'],
updatedAt: a['updated-at'],
};
}
async getPasswords(params: {
organizationId?: number | string;
name?: string;
} = {}): Promise<ITGluePassword[]> {
const p: Record<string, string | number> = {};
if (params.organizationId) p['filter[organization-id]'] = params.organizationId;
if (params.name) p['filter[name]'] = params.name;
return this.fetchAllPages('/passwords', p, this.mapPassword);
}
// ─── Contacts ─────────────────────────────────────────────────────────────
private mapContact(item: any): ITGlueContact {
const a = item.attributes;
return {
id: item.id,
organizationId: a['organization-id'],
organizationName: a['organization-name'],
firstName: a['first-name'] ?? null,
lastName: a['last-name'] ?? null,
title: a['title'] ?? null,
contactTypeId: a['contact-type-id'] ?? null,
contactTypeName: a['contact-type-name'] ?? null,
emails: (a['contact-emails'] ?? []).map((e: any) => ({
value: e.value,
primary: e.primary,
labelName: e['label-name'],
})),
phones: (a['contact-phones'] ?? []).map((p: any) => ({
value: p.value,
extension: p.extension ?? null,
primary: p.primary,
labelName: p['label-name'],
})),
notes: a['notes'] ?? null,
createdAt: a['created-at'],
updatedAt: a['updated-at'],
};
}
async getContacts(params: {
organizationId?: number | string;
name?: string;
} = {}): Promise<ITGlueContact[]> {
const p: Record<string, string | number> = {};
if (params.organizationId) p['filter[organization-id]'] = params.organizationId;
if (params.name) p['filter[name]'] = params.name;
return this.fetchAllPages('/contacts', p, this.mapContact);
}
// ─── Utility ──────────────────────────────────────────────────────────────
async testConnection(): Promise<{ ok: boolean; organizationCount: number; accountName: string }> {
const data: any = await this.request('/organizations', { 'page[size]': 1 });
return {
ok: true,
organizationCount: data.meta?.['total-count'] ?? 0,
accountName: data.data?.[0]?.attributes?.name ?? 'Unknown',
};
}
}
// Singleton
let _client: ITGlueClient | null = null;
export function getITGlueClient(): ITGlueClient {
if (!_client) {
const apiKey = process.env.ITGLUE_API_KEY;
if (!apiKey) throw new Error('ITGLUE_API_KEY environment variable is not set');
_client = new ITGlueClient({ apiKey });
}
return _client;
}

View file

@ -0,0 +1,554 @@
/**
* IT Glue Sync Service Part 1 of 2
* Syncs all IT Glue data to local itg_* PostgreSQL tables
*/
import postgresClient from './postgres-client';
import { getITGlueClient } from './itglue-client';
export interface ITGlueSyncEntityResult {
entity: string;
success: boolean;
recordsUpserted: number;
duration: number;
error?: string;
}
export interface ITGlueSyncResult {
syncId: number;
syncType: 'full';
status: 'completed' | 'failed';
startedAt: Date;
completedAt: Date;
duration: number;
entities: ITGlueSyncEntityResult[];
totalUpserted: number;
errors: string[];
}
export class ITGlueSyncService {
private isSyncing = false;
isSyncInProgress(): boolean { return this.isSyncing; }
async fullSync(triggeredBy = 'system'): Promise<ITGlueSyncResult> {
if (this.isSyncing) throw new Error('IT Glue sync already in progress');
this.isSyncing = true;
const startedAt = new Date();
const entities: ITGlueSyncEntityResult[] = [];
const errors: string[] = [];
const { rows } = await postgresClient.query(
`INSERT INTO itg_sync_history (sync_type, status, triggered_by, started_at)
VALUES ('full','running',$1,NOW()) RETURNING id`,
[triggeredBy]
);
const syncId = rows[0].id;
const run = async (name: string, fn: () => Promise<number>) => {
const t = Date.now();
try {
const count = await fn();
entities.push({ entity: name, success: true, recordsUpserted: count, duration: Date.now() - t });
console.log(`[ITGlue] ${name}: ${count} records`);
} catch (err: any) {
errors.push(`${name}: ${err.message}`);
entities.push({ entity: name, success: false, recordsUpserted: 0, duration: Date.now() - t, error: err.message });
console.error(`[ITGlue] ${name} FAILED:`, err.message);
}
};
try {
await run('organization_types', () => this.syncSimpleTable('/organization_types', 'itg_organization_types'));
await run('organization_statuses', () => this.syncSimpleTable('/organization_statuses','itg_organization_statuses'));
await run('configuration_types', () => this.syncSimpleTable('/configuration_types', 'itg_configuration_types'));
await run('configuration_statuses', () => this.syncSimpleTable('/configuration_statuses','itg_configuration_statuses'));
await run('contact_types', () => this.syncSimpleTable('/contact_types', 'itg_contact_types'));
await run('password_categories', () => this.syncSimpleTable('/password_categories', 'itg_password_categories'));
await run('manufacturers', () => this.syncSimpleTable('/manufacturers', 'itg_manufacturers'));
await run('operating_systems', () => this.syncSimpleTable('/operating_systems', 'itg_operating_systems'));
await run('platforms', () => this.syncSimpleTable('/platforms', 'itg_platforms'));
await run('countries', () => this.syncCountries());
await run('models', () => this.syncModels());
await run('flexible_asset_types', () => this.syncFlexibleAssetTypes());
await run('flexible_asset_fields', () => this.syncFlexibleAssetFields());
await run('organizations', () => this.syncOrganizations());
await run('locations', () => this.syncLocations());
await run('contacts', () => this.syncContacts());
await run('configurations', () => this.syncConfigurations());
// configuration_interfaces skipped — no flat API endpoint; per-config calls are too slow for 14k+ configs
await run('flexible_assets', () => this.syncFlexibleAssets());
await run('password_folders', () => this.syncPasswordFolders());
await run('passwords', () => this.syncPasswords());
await run('documents', () => this.syncDocuments());
await run('domains', () => this.syncDomains());
await run('expirations', () => this.syncExpirations());
} finally {
this.isSyncing = false;
}
const completedAt = new Date();
const duration = completedAt.getTime() - startedAt.getTime();
const totalUpserted = entities.reduce((s, e) => s + e.recordsUpserted, 0);
const status = errors.length === 0 ? 'completed' : 'failed';
await postgresClient.query(
`UPDATE itg_sync_history
SET status=$1, completed_at=NOW(), duration_ms=$2, entities=$3, error=$4, total_upserted=$5
WHERE id=$6`,
[status, duration, JSON.stringify(entities), errors.join('\n') || null, totalUpserted, syncId]
);
return { syncId, syncType: 'full', status, startedAt, completedAt, duration, entities, totalUpserted, errors };
}
// ─── Generic simple-table upsert (id, name, created_at, updated_at) ──────────
private async syncSimpleTable(path: string, table: string): Promise<number> {
const client = getITGlueClient();
const items = await client.getRawAllPages(path);
let count = 0;
for (const item of items) {
const a = item.attributes;
await postgresClient.query(
`INSERT INTO ${table} (id, name, created_at, updated_at, synced_at)
VALUES ($1,$2,$3,$4,NOW())
ON CONFLICT (id) DO UPDATE SET name=$2, updated_at=$4, synced_at=NOW()`,
[item.id, a.name, a['created-at'] || null, a['updated-at'] || null]
);
count++;
}
return count;
}
private async syncCountries(): Promise<number> {
const client = getITGlueClient();
const items = await client.getRawAllPages('/countries');
let count = 0;
for (const item of items) {
const a = item.attributes;
await postgresClient.query(
`INSERT INTO itg_countries (id, name, iso_code, created_at, updated_at, synced_at)
VALUES ($1,$2,$3,$4,$5,NOW())
ON CONFLICT (id) DO UPDATE SET name=$2, iso_code=$3, updated_at=$5, synced_at=NOW()`,
[item.id, a.name, a['iso-code'] || null, a['created-at'] || null, a['updated-at'] || null]
);
count++;
}
return count;
}
private async syncModels(): Promise<number> {
const client = getITGlueClient();
const mfrs = await client.getRawAllPages('/manufacturers');
let count = 0;
for (const mfr of mfrs) {
const models = await client.getRawAllPages(`/manufacturers/${mfr.id}/relationships/models`);
for (const item of models) {
const a = item.attributes;
await postgresClient.query(
`INSERT INTO itg_models (id, manufacturer_id, name, created_at, updated_at, synced_at)
VALUES ($1,$2,$3,$4,$5,NOW())
ON CONFLICT (id) DO UPDATE SET manufacturer_id=$2, name=$3, updated_at=$5, synced_at=NOW()`,
[item.id, mfr.id, a.name, a['created-at'] || null, a['updated-at'] || null]
);
count++;
}
}
return count;
}
private async syncFlexibleAssetTypes(): Promise<number> {
const client = getITGlueClient();
const items = await client.getRawAllPages('/flexible_asset_types');
let count = 0;
for (const item of items) {
const a = item.attributes;
await postgresClient.query(
`INSERT INTO itg_flexible_asset_types (id, name, description, icon, enabled, builtin, created_at, updated_at, synced_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,NOW())
ON CONFLICT (id) DO UPDATE SET name=$2, description=$3, icon=$4, enabled=$5, builtin=$6, updated_at=$8, synced_at=NOW()`,
[item.id, a.name, a.description || null, a.icon || null, a.enabled ?? true, a.builtin ?? false,
a['created-at'] || null, a['updated-at'] || null]
);
count++;
}
return count;
}
private async syncFlexibleAssetFields(): Promise<number> {
const client = getITGlueClient();
const types = await client.getRawAllPages('/flexible_asset_types');
let count = 0;
for (const type of types) {
const fields = await client.getRawAllPages(
`/flexible_asset_types/${type.id}/relationships/flexible_asset_fields`
);
for (const item of fields) {
const a = item.attributes;
await postgresClient.query(
`INSERT INTO itg_flexible_asset_fields
(id, flexible_asset_type_id, name, kind, hint, decimals, tag_type,
required, use_for_title, expiration, show_in_list, created_at, updated_at, synced_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,NOW())
ON CONFLICT (id) DO UPDATE SET
flexible_asset_type_id=$2, name=$3, kind=$4, hint=$5, decimals=$6, tag_type=$7,
required=$8, use_for_title=$9, expiration=$10, show_in_list=$11, updated_at=$13, synced_at=NOW()`,
[item.id, type.id, a.name, a.kind || null, a.hint || null, a.decimals || 0,
a['tag-type'] || null, a.required ?? false, a['use-for-title'] ?? false,
a.expiration ?? false, a['show-in-list'] ?? false,
a['created-at'] || null, a['updated-at'] || null]
);
count++;
}
}
return count;
}
private async syncOrganizations(): Promise<number> {
const client = getITGlueClient();
const items = await client.getRawAllPages('/organizations');
let count = 0;
for (const item of items) {
const a = item.attributes;
await postgresClient.query(
`INSERT INTO itg_organizations
(id, name, short_name, organization_type_id, organization_type_name,
organization_status_id, organization_status_name, psa_integration, psa_id,
sync_active, primary_org, quick_notes, description, alert, parent_id,
created_at, updated_at, synced_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,NOW())
ON CONFLICT (id) DO UPDATE SET
name=$2, short_name=$3, organization_type_id=$4, organization_type_name=$5,
organization_status_id=$6, organization_status_name=$7, psa_integration=$8,
psa_id=$9, sync_active=$10, primary_org=$11, quick_notes=$12, description=$13,
alert=$14, parent_id=$15, updated_at=$17, synced_at=NOW()`,
[item.id, a.name, a['short-name'] || null,
a['organization-type-id'] || null, a['organization-type-name'] || null,
a['organization-status-id'] || null, a['organization-status-name'] || null,
a['psa-integration'] || null, a['psa-id'] || null,
a['sync-active'] ?? false, a.primary ?? false,
a['quick-notes'] || null, a.description || null, a.alert || null,
a['parent-id'] || null, a['created-at'] || null, a['updated-at'] || null]
);
count++;
}
return count;
}
private async syncLocations(): Promise<number> {
const client = getITGlueClient();
const items = await client.getRawAllPages('/locations');
let count = 0;
for (const item of items) {
const a = item.attributes;
await postgresClient.query(
`INSERT INTO itg_locations
(id, organization_id, organization_name, name, primary_location,
address_1, address_2, city, region_name, postal_code, country_name,
phone, fax, notes, created_at, updated_at, synced_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,NOW())
ON CONFLICT (id) DO UPDATE SET
organization_id=$2, organization_name=$3, name=$4, primary_location=$5,
address_1=$6, address_2=$7, city=$8, region_name=$9, postal_code=$10,
country_name=$11, phone=$12, fax=$13, notes=$14, updated_at=$16, synced_at=NOW()`,
[item.id, a['organization-id'], a['organization-name'] || null, a.name,
a['primary-location'] ?? false, a['address-1'] || null, a['address-2'] || null,
a.city || null, a['region-name'] || null, a['postal-code'] || null,
a['country-name'] || null, a.phone || null, a.fax || null, a.notes || null,
a['created-at'] || null, a['updated-at'] || null]
);
count++;
}
return count;
}
private async syncContacts(): Promise<number> {
const client = getITGlueClient();
const items = await client.getRawAllPages('/contacts');
let count = 0;
for (const item of items) {
const a = item.attributes;
await postgresClient.query(
`INSERT INTO itg_contacts
(id, organization_id, organization_name, first_name, last_name, name,
title, contact_type_id, contact_type_name, location_id, important,
notes, emails, phones, created_at, updated_at, synced_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,NOW())
ON CONFLICT (id) DO UPDATE SET
organization_id=$2, organization_name=$3, first_name=$4, last_name=$5,
name=$6, title=$7, contact_type_id=$8, contact_type_name=$9,
location_id=$10, important=$11, notes=$12, emails=$13, phones=$14,
updated_at=$16, synced_at=NOW()`,
[item.id, a['organization-id'], a['organization-name'] || null,
a['first-name'] || null, a['last-name'] || null, a.name || null,
a.title || null, a['contact-type-id'] || null, a['contact-type-name'] || null,
a['location-id'] || null, a.important ?? false, a.notes || null,
JSON.stringify(a['contact-emails'] || []),
JSON.stringify(a['contact-phones'] || []),
a['created-at'] || null, a['updated-at'] || null]
);
count++;
}
return count;
}
private async syncConfigurations(): Promise<number> {
const client = getITGlueClient();
const items = await client.getRawAllPages('/configurations');
let count = 0;
for (const item of items) {
const a = item.attributes;
await postgresClient.query(
`INSERT INTO itg_configurations
(id, organization_id, organization_name, name, hostname, primary_ip,
mac_address, serial_number, asset_tag, position, installed_by, purchased_by,
notes, operating_system_notes, warranty_expires_at, installed_at, purchased_at,
end_of_life_at, configuration_type_id, configuration_type_name,
configuration_status_id, configuration_status_name,
manufacturer_id, manufacturer_name, model_id, model_name,
operating_system_id, operating_system_name, location_id, contact_id,
rmm_id, rmm_integration_type, created_at, updated_at, synced_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,NOW())
ON CONFLICT (id) DO UPDATE SET
organization_id=$2, organization_name=$3, name=$4, hostname=$5, primary_ip=$6,
mac_address=$7, serial_number=$8, asset_tag=$9, position=$10, installed_by=$11,
purchased_by=$12, notes=$13, operating_system_notes=$14, warranty_expires_at=$15,
installed_at=$16, purchased_at=$17, end_of_life_at=$18,
configuration_type_id=$19, configuration_type_name=$20,
configuration_status_id=$21, configuration_status_name=$22,
manufacturer_id=$23, manufacturer_name=$24, model_id=$25, model_name=$26,
operating_system_id=$27, operating_system_name=$28,
location_id=$29, contact_id=$30, rmm_id=$31, rmm_integration_type=$32,
updated_at=$34, synced_at=NOW()`,
[item.id, a['organization-id'], a['organization-name'] || null,
a.name, a.hostname || null, a['primary-ip'] || null,
a['mac-address'] || null, a['serial-number'] || null, a['asset-tag'] || null,
a.position || null, a['installed-by'] || null, a['purchased-by'] || null,
a.notes || null, a['operating-system-notes'] || null,
a['warranty-expires-at'] || null, a['installed-at'] || null,
a['purchased-at'] || null, a['end-of-life-at'] || null,
a['configuration-type-id'] || null, a['configuration-type-name'] || null,
a['configuration-status-id'] || null, a['configuration-status-name'] || null,
a['manufacturer-id'] || null, a['manufacturer-name'] || null,
a['model-id'] || null, a['model-name'] || null,
a['operating-system-id'] || null, a['operating-system-name'] || null,
a['location-id'] || null, a['contact-id'] || null,
a['rmm-id'] || null, a['rmm-integration-type'] || null,
a['created-at'] || null, a['updated-at'] || null]
);
count++;
}
return count;
}
private async syncConfigurationInterfaces(): Promise<number> {
const client = getITGlueClient();
// No flat endpoint — must iterate per configuration
const configs = await client.getRawAllPages('/configurations');
let count = 0;
for (const cfg of configs) {
const items = await client.getRawAllPages(
`/configurations/${cfg.id}/relationships/configuration_interfaces`
);
for (const item of items) {
const a = item.attributes;
await postgresClient.query(
`INSERT INTO itg_configuration_interfaces
(id, configuration_id, organization_id, name, ip_address, mac_address,
primary_interface, notes, created_at, updated_at, synced_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,NOW())
ON CONFLICT (id) DO UPDATE SET
configuration_id=$2, organization_id=$3, name=$4, ip_address=$5,
mac_address=$6, primary_interface=$7, notes=$8, updated_at=$10, synced_at=NOW()`,
[item.id, cfg.id, a['organization-id'] || null,
a.name || null, a['ip-address'] || null, a['mac-address'] || null,
a.primary ?? false, a.notes || null,
a['created-at'] || null, a['updated-at'] || null]
);
count++;
}
}
return count;
}
private async syncFlexibleAssets(): Promise<number> {
const client = getITGlueClient();
// API requires filter[flexible-asset-type-id] — iterate per type
const types = await client.getRawAllPages('/flexible_asset_types');
let count = 0;
for (const type of types) {
const items = await client.getRawAllPages('/flexible_assets', {
'filter[flexible-asset-type-id]': type.id,
});
for (const item of items) {
const a = item.attributes;
await postgresClient.query(
`INSERT INTO itg_flexible_assets
(id, organization_id, organization_name, flexible_asset_type_id,
flexible_asset_type_name, name, traits, archived, created_at, updated_at, synced_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,NOW())
ON CONFLICT (id) DO UPDATE SET
organization_id=$2, organization_name=$3, flexible_asset_type_id=$4,
flexible_asset_type_name=$5, name=$6, traits=$7, archived=$8,
updated_at=$10, synced_at=NOW()`,
[item.id, a['organization-id'], a['organization-name'] || null,
a['flexible-asset-type-id'], a['flexible-asset-type-name'] || null,
a.name || null, JSON.stringify(a.traits || {}), a.archived ?? false,
a['created-at'] || null, a['updated-at'] || null]
);
count++;
}
}
return count;
}
private async syncPasswordFolders(): Promise<number> {
const client = getITGlueClient();
// No flat endpoint — must iterate per organization
const orgs = await postgresClient.query('SELECT id FROM itg_organizations');
let count = 0;
for (const org of orgs.rows) {
const items = await client.getRawAllPages(
`/organizations/${org.id}/relationships/password_folders`
);
for (const item of items) {
const a = item.attributes;
await postgresClient.query(
`INSERT INTO itg_password_folders
(id, organization_id, organization_name, name, inherited, created_at, updated_at, synced_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,NOW())
ON CONFLICT (id) DO UPDATE SET
organization_id=$2, organization_name=$3, name=$4, inherited=$5,
updated_at=$7, synced_at=NOW()`,
[item.id, org.id, a['organization-name'] || null,
a.name, a.inherited ?? false, a['created-at'] || null, a['updated-at'] || null]
);
count++;
}
}
return count;
}
private async syncPasswords(): Promise<number> {
const client = getITGlueClient();
const items = await client.getRawAllPages('/passwords');
let count = 0;
for (const item of items) {
const a = item.attributes;
await postgresClient.query(
`INSERT INTO itg_passwords
(id, organization_id, organization_name, name, username, password, url,
notes, password_category_id, password_category_name, password_folder_id,
autofill_selectors, otp_enabled, archived, created_at, updated_at, synced_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,NOW())
ON CONFLICT (id) DO UPDATE SET
organization_id=$2, organization_name=$3, name=$4, username=$5, password=$6,
url=$7, notes=$8, password_category_id=$9, password_category_name=$10,
password_folder_id=$11, autofill_selectors=$12, otp_enabled=$13, archived=$14,
updated_at=$16, synced_at=NOW()`,
[item.id, a['organization-id'], a['organization-name'] || null,
a.name, a.username || null, a.password || null, a.url || null,
a.notes || null, a['password-category-id'] || null, a['password-category-name'] || null,
a['password-folder-id'] || null, a['autofill-selectors'] || null,
a['otp-enabled'] ?? false, a.archived ?? false,
a['created-at'] || null, a['updated-at'] || null]
);
count++;
}
return count;
}
private async syncDocuments(): Promise<number> {
const client = getITGlueClient();
// No flat endpoint — must iterate per organization
const orgs = await postgresClient.query('SELECT id FROM itg_organizations');
let count = 0;
for (const org of orgs.rows) {
const items = await client.getRawAllPages(
`/organizations/${org.id}/relationships/documents`
);
for (const item of items) {
const a = item.attributes;
await postgresClient.query(
`INSERT INTO itg_documents
(id, organization_id, organization_name, name, content, draft, archived, created_at, updated_at, synced_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,NOW())
ON CONFLICT (id) DO UPDATE SET
organization_id=$2, organization_name=$3, name=$4, content=$5,
draft=$6, archived=$7, updated_at=$9, synced_at=NOW()`,
[item.id, org.id, a['organization-name'] || null,
a.name, a.content || null, a.draft ?? false, a.archived ?? false,
a['created-at'] || null, a['updated-at'] || null]
);
count++;
}
}
return count;
}
private async syncDomains(): Promise<number> {
const client = getITGlueClient();
const items = await client.getRawAllPages('/domains');
let count = 0;
for (const item of items) {
const a = item.attributes;
await postgresClient.query(
`INSERT INTO itg_domains
(id, organization_id, organization_name, name, screenshot, whois_updated_at,
expires_at, registrar_name, notes, created_at, updated_at, synced_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,NOW())
ON CONFLICT (id) DO UPDATE SET
organization_id=$2, organization_name=$3, name=$4, screenshot=$5,
whois_updated_at=$6, expires_at=$7, registrar_name=$8, notes=$9,
updated_at=$11, synced_at=NOW()`,
[item.id, a['organization-id'], a['organization-name'] || null,
a.name, a.screenshot || null, a['whois-updated-at'] || null,
a['expires-at'] || null, a['registrar-name'] || null, a.notes || null,
a['created-at'] || null, a['updated-at'] || null]
);
count++;
}
return count;
}
private async syncExpirations(): Promise<number> {
const client = getITGlueClient();
// No flat endpoint — must iterate per organization
const orgs = await postgresClient.query('SELECT id FROM itg_organizations');
let count = 0;
for (const org of orgs.rows) {
const items = await client.getRawAllPages(
`/organizations/${org.id}/relationships/expirations`
);
for (const item of items) {
const a = item.attributes;
await postgresClient.query(
`INSERT INTO itg_expirations
(id, organization_id, organization_name, resource_id, resource_type,
resource_name, expiration_type, description, expiration_date, notify,
created_at, updated_at, synced_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,NOW())
ON CONFLICT (id) DO UPDATE SET
organization_id=$2, organization_name=$3, resource_id=$4, resource_type=$5,
resource_name=$6, expiration_type=$7, description=$8, expiration_date=$9,
notify=$10, updated_at=$12, synced_at=NOW()`,
[item.id, org.id, a['organization-name'] || null,
a['resource-id'] || null, a['resource-type'] || null, a['resource-name'] || null,
a['expiration-type'] || null, a.description || null,
a['expiration-date'] || null, a.notify ?? false,
a['created-at'] || null, a['updated-at'] || null]
);
count++;
}
}
return count;
}
}
let _instance: ITGlueSyncService | null = null;
export function getITGlueSyncService(): ITGlueSyncService {
if (!_instance) _instance = new ITGlueSyncService();
return _instance;
}

View file

@ -0,0 +1,408 @@
/**
* Pipeline Engine
* Matches incoming webhooks to pipelines, executes steps sequentially,
* resolves template variables, and accumulates context between steps.
*/
import { postgresClient } from './postgres-client';
import {
WebhookPipeline,
PipelineStep,
PipelineWithSteps,
PipelineContext,
PipelineStatus,
StepExecutorResult,
TriggerCondition,
} from '../types/pipeline';
// Step executor registry — populated by individual step files
type StepExecutorFn = (
step: PipelineStep,
context: PipelineContext,
executionId: number
) => Promise<StepExecutorResult>;
const stepExecutors: Map<string, StepExecutorFn> = new Map();
export function registerStepExecutor(stepType: string, executor: StepExecutorFn): void {
stepExecutors.set(stepType, executor);
}
export class PipelineEngine {
/**
* Find and execute all matching pipelines for a trigger source + payload.
* Called from webhook routes after raw logging.
*/
async processTrigger(
triggerSource: string,
payload: Record<string, any>
): Promise<number[]> {
const pipelines = await this.findMatchingPipelines(triggerSource, payload);
if (pipelines.length === 0) {
return [];
}
console.log(`[PIPELINE] ${pipelines.length} pipeline(s) matched for ${triggerSource}`);
const executionIds: number[] = [];
for (const pipeline of pipelines) {
try {
const execId = await this.executePipeline(pipeline, triggerSource, payload);
executionIds.push(execId);
} catch (err) {
console.error(`[PIPELINE] Failed to execute pipeline "${pipeline.name}":`, err);
}
}
return executionIds;
}
/**
* Find active pipelines matching the trigger source and conditions.
*/
async findMatchingPipelines(
triggerSource: string,
payload: Record<string, any>
): Promise<PipelineWithSteps[]> {
const result = await postgresClient.query<WebhookPipeline>(
`SELECT * FROM webhook_pipelines
WHERE is_active = true AND trigger_source = $1
ORDER BY sort_order`,
[triggerSource]
);
const matched: PipelineWithSteps[] = [];
for (const pipeline of result.rows) {
const conditions: TriggerCondition[] = Array.isArray(pipeline.trigger_conditions)
? pipeline.trigger_conditions
: [];
if (this.evaluateConditions(conditions, payload)) {
const stepsResult = await postgresClient.query<PipelineStep>(
`SELECT * FROM pipeline_steps
WHERE pipeline_id = $1 AND is_active = true
ORDER BY step_order`,
[pipeline.id]
);
matched.push({ ...pipeline, steps: stepsResult.rows });
}
}
return matched;
}
/**
* Execute a single pipeline: create execution record, run steps, update status.
*/
async executePipeline(
pipeline: PipelineWithSteps,
triggerSource: string,
payload: Record<string, any>
): Promise<number> {
const execResult = await postgresClient.query<{ id: number }>(
`INSERT INTO pipeline_executions (pipeline_id, trigger_source, trigger_payload, status)
VALUES ($1, $2, $3, 'running')
RETURNING id`,
[pipeline.id, triggerSource, JSON.stringify(payload)]
);
const executionId = execResult.rows[0].id;
const context: PipelineContext = { trigger: payload };
let finalStatus: PipelineStatus = 'completed';
let errorMessage: string | null = null;
console.log(`[PIPELINE] Executing "${pipeline.name}" (exec #${executionId}), ${pipeline.steps.length} steps`);
for (const step of pipeline.steps) {
// Update current step
await postgresClient.query(
`UPDATE pipeline_executions SET current_step = $1, context = $2 WHERE id = $3`,
[step.step_order, JSON.stringify(context), executionId]
);
const stepStart = Date.now();
// Log step start
await postgresClient.query(
`INSERT INTO pipeline_execution_steps (execution_id, step_order, step_type, step_name, status, started_at, input_data)
VALUES ($1, $2, $3, $4, 'running', NOW(), $5)`,
[executionId, step.step_order, step.step_type, step.name, JSON.stringify({ config: step.config })]
);
const executor = stepExecutors.get(step.step_type);
if (!executor) {
const err = `No executor registered for step type: ${step.step_type}`;
console.error(`[PIPELINE] ${err}`);
await this.updateStepLog(executionId, step.step_order, 'failed', null, err, Date.now() - stepStart);
if (step.on_failure === 'stop') {
finalStatus = 'failed';
errorMessage = err;
break;
}
continue;
}
try {
// Resolve template variables in step config
const resolvedConfig = this.resolveTemplates(step.config, context);
const resolvedStep = { ...step, config: resolvedConfig };
const result = await executor(resolvedStep, context, executionId);
const duration = Date.now() - stepStart;
if (result.waiting) {
await this.updateStepLog(executionId, step.step_order, 'waiting', result.output, null, duration);
finalStatus = 'waiting';
break;
}
if (result.success) {
// Merge output into context
if (result.output) {
Object.assign(context, result.output);
}
await this.updateStepLog(executionId, step.step_order, 'completed', result.output, null, duration);
console.log(`[PIPELINE] Step ${step.step_order} "${step.name}" completed (${duration}ms)`);
} else {
await this.updateStepLog(executionId, step.step_order, 'failed', result.output, result.error || null, duration);
console.error(`[PIPELINE] Step ${step.step_order} "${step.name}" failed: ${result.error}`);
if (step.on_failure === 'stop') {
finalStatus = 'failed';
errorMessage = `Step ${step.step_order} "${step.name}": ${result.error}`;
break;
} else if (step.on_failure === 'skip_to' && step.skip_to_step) {
// Skip ahead — handled by finding the next step with matching order
// For simplicity, we just continue; the skip_to logic would need step reordering
continue;
}
// on_failure === 'continue' → keep going
}
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
const duration = Date.now() - stepStart;
await this.updateStepLog(executionId, step.step_order, 'failed', null, errMsg, duration);
console.error(`[PIPELINE] Step ${step.step_order} "${step.name}" threw: ${errMsg}`);
if (step.on_failure === 'stop') {
finalStatus = 'failed';
errorMessage = errMsg;
break;
}
}
}
// Finalize execution
await postgresClient.query(
`UPDATE pipeline_executions
SET status = $1, context = $2, completed_at = NOW(),
duration_ms = EXTRACT(EPOCH FROM (NOW() - started_at)) * 1000,
error_message = $3
WHERE id = $4`,
[finalStatus, JSON.stringify(context), errorMessage, executionId]
);
console.log(`[PIPELINE] Execution #${executionId} finished: ${finalStatus}`);
return executionId;
}
/**
* Resume a waiting pipeline (e.g., after approval callback).
*/
async resumeExecution(executionId: number, approvalResult: Record<string, any>): Promise<void> {
const execResult = await postgresClient.query<any>(
`SELECT pe.*, wp.name as pipeline_name FROM pipeline_executions pe
JOIN webhook_pipelines wp ON wp.id = pe.pipeline_id
WHERE pe.id = $1 AND pe.status = 'waiting'`,
[executionId]
);
if (execResult.rows.length === 0) {
throw new Error(`Execution #${executionId} not found or not in waiting state`);
}
const execution = execResult.rows[0];
const context: PipelineContext = execution.context || {};
context.approval_result = approvalResult;
// Get remaining steps after the current waiting step
const stepsResult = await postgresClient.query<PipelineStep>(
`SELECT * FROM pipeline_steps
WHERE pipeline_id = $1 AND step_order > $2 AND is_active = true
ORDER BY step_order`,
[execution.pipeline_id, execution.current_step]
);
// Update execution to running
await postgresClient.query(
`UPDATE pipeline_executions SET status = 'running', context = $1 WHERE id = $2`,
[JSON.stringify(context), executionId]
);
// Mark the waiting step as completed
await this.updateStepLog(executionId, execution.current_step, 'completed', approvalResult, null, 0);
// Continue executing remaining steps
let finalStatus: PipelineStatus = 'completed';
let errorMessage: string | null = null;
for (const step of stepsResult.rows) {
await postgresClient.query(
`UPDATE pipeline_executions SET current_step = $1, context = $2 WHERE id = $3`,
[step.step_order, JSON.stringify(context), executionId]
);
const stepStart = Date.now();
await postgresClient.query(
`INSERT INTO pipeline_execution_steps (execution_id, step_order, step_type, step_name, status, started_at, input_data)
VALUES ($1, $2, $3, $4, 'running', NOW(), $5)`,
[executionId, step.step_order, step.step_type, step.name, JSON.stringify({ config: step.config })]
);
const executor = stepExecutors.get(step.step_type);
if (!executor) {
const err = `No executor for: ${step.step_type}`;
await this.updateStepLog(executionId, step.step_order, 'failed', null, err, Date.now() - stepStart);
if (step.on_failure === 'stop') { finalStatus = 'failed'; errorMessage = err; break; }
continue;
}
try {
const resolvedConfig = this.resolveTemplates(step.config, context);
const result = await executor({ ...step, config: resolvedConfig }, context, executionId);
const duration = Date.now() - stepStart;
if (result.waiting) {
await this.updateStepLog(executionId, step.step_order, 'waiting', result.output, null, duration);
finalStatus = 'waiting';
break;
}
if (result.success) {
if (result.output) Object.assign(context, result.output);
await this.updateStepLog(executionId, step.step_order, 'completed', result.output, null, duration);
} else {
await this.updateStepLog(executionId, step.step_order, 'failed', result.output, result.error || null, duration);
if (step.on_failure === 'stop') { finalStatus = 'failed'; errorMessage = result.error ?? null; break; }
}
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
await this.updateStepLog(executionId, step.step_order, 'failed', null, errMsg, Date.now() - stepStart);
if (step.on_failure === 'stop') { finalStatus = 'failed'; errorMessage = errMsg; break; }
}
}
await postgresClient.query(
`UPDATE pipeline_executions
SET status = $1, context = $2, completed_at = NOW(),
duration_ms = EXTRACT(EPOCH FROM (NOW() - started_at)) * 1000,
error_message = $3
WHERE id = $4`,
[finalStatus, JSON.stringify(context), errorMessage, executionId]
);
}
// ============================================================================
// Template Resolution
// ============================================================================
/**
* Recursively resolve {{...}} template variables in any value.
*/
resolveTemplates(value: any, context: PipelineContext): any {
if (typeof value === 'string') {
return this.resolveStringTemplate(value, context);
}
if (Array.isArray(value)) {
return value.map(v => this.resolveTemplates(v, context));
}
if (value && typeof value === 'object') {
const resolved: Record<string, any> = {};
for (const [k, v] of Object.entries(value)) {
resolved[k] = this.resolveTemplates(v, context);
}
return resolved;
}
return value;
}
private resolveStringTemplate(template: string, context: PipelineContext): string {
return template.replace(/\{\{([^}]+)\}\}/g, (match, path: string) => {
const value = this.getNestedValue(context, path.trim());
if (value === undefined || value === null) return '';
return String(value);
});
}
private getNestedValue(obj: any, path: string): any {
const parts = path.split('.');
let current = obj;
for (const part of parts) {
if (current == null) return undefined;
current = current[part];
}
return current;
}
// ============================================================================
// Condition Evaluation
// ============================================================================
evaluateConditions(conditions: TriggerCondition[], payload: Record<string, any>): boolean {
if (conditions.length === 0) return true;
return conditions.every(cond => this.evaluateCondition(cond, payload));
}
private evaluateCondition(cond: TriggerCondition, payload: Record<string, any>): boolean {
const fieldValue = this.getNestedValue(payload, cond.field);
switch (cond.operator) {
case 'equals':
return String(fieldValue) === String(cond.value);
case 'not_equals':
return String(fieldValue) !== String(cond.value);
case 'contains':
return fieldValue != null && String(fieldValue).toLowerCase().includes(String(cond.value).toLowerCase());
case 'not_contains':
return fieldValue == null || !String(fieldValue).toLowerCase().includes(String(cond.value).toLowerCase());
case 'in':
return Array.isArray(cond.value) && cond.value.some((v: any) => String(v) === String(fieldValue));
case 'not_in':
return !Array.isArray(cond.value) || !cond.value.some((v: any) => String(v) === String(fieldValue));
case 'regex':
try { return fieldValue != null && new RegExp(String(cond.value), 'i').test(String(fieldValue)); }
catch { return false; }
case 'exists':
return fieldValue != null && fieldValue !== '';
case 'not_exists':
return fieldValue == null || fieldValue === '';
default:
return false;
}
}
// ============================================================================
// Helpers
// ============================================================================
private async updateStepLog(
executionId: number,
stepOrder: number,
status: string,
outputData: any,
errorMessage: string | null,
durationMs: number
): Promise<void> {
await postgresClient.query(
`UPDATE pipeline_execution_steps
SET status = $1, output_data = $2, error_message = $3, duration_ms = $4, completed_at = NOW()
WHERE execution_id = $5 AND step_order = $6`,
[status, outputData ? JSON.stringify(outputData) : null, errorMessage, durationMs, executionId, stepOrder]
);
}
}
export const pipelineEngine = new PipelineEngine();

View file

@ -0,0 +1,116 @@
/**
* AI Analyze Step send data to AI for analysis/summary.
* Config: { purpose: "summarize_alert", prompt: "...", system_prompt: "...", prompt_template_id?: 1 }
*/
import { registerStepExecutor } from '../pipeline-engine';
import { postgresClient } from '../postgres-client';
import { PipelineStep, PipelineContext, StepExecutorResult } from '../../types/pipeline';
async function executeAiAnalyze(
step: PipelineStep,
_context: PipelineContext,
_executionId: number
): Promise<StepExecutorResult> {
const userPrompt = step.config.prompt || '';
let systemPrompt = step.config.system_prompt || 'You are a helpful IT operations assistant.';
// If a prompt_template_id is provided, load from DB
if (step.config.prompt_template_id) {
const tplResult = await postgresClient.query(
`SELECT system_prompt, user_prompt_template, provider, model, temperature, max_tokens
FROM ai_prompt_templates WHERE id = $1 AND is_active = true`,
[step.config.prompt_template_id]
);
if (tplResult.rows.length > 0) {
systemPrompt = tplResult.rows[0].system_prompt || systemPrompt;
}
}
if (!userPrompt) {
return { success: false, error: 'Missing prompt for AI analysis' };
}
// Load AI settings
const settingsResult = await postgresClient.query(
`SELECT key, value FROM workflow_settings WHERE key IN ('default_ai_provider', 'openai_api_key', 'openai_model', 'anthropic_api_key', 'anthropic_model')`
);
const settings: Record<string, any> = {};
for (const row of settingsResult.rows) {
try { settings[row.key] = JSON.parse(row.value); } catch { settings[row.key] = row.value; }
}
const provider = step.config.provider || settings.default_ai_provider || 'openai';
const model = step.config.model || (provider === 'anthropic' ? settings.anthropic_model : settings.openai_model) || 'gpt-4o';
const apiKey = provider === 'anthropic' ? settings.anthropic_api_key : settings.openai_api_key;
if (!apiKey) {
return { success: false, error: `No API key configured for ${provider}` };
}
console.log(`[PIPELINE:ai_analyze] Calling ${provider}/${model}`);
let aiResponse: string;
if (provider === 'anthropic') {
const resp = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model,
max_tokens: Number(step.config.max_tokens) || 2000,
system: systemPrompt,
messages: [{ role: 'user', content: userPrompt }],
}),
});
if (!resp.ok) {
const errText = await resp.text();
return { success: false, error: `Anthropic API error (${resp.status}): ${errText.substring(0, 200)}` };
}
const data = await resp.json();
aiResponse = data.content?.[0]?.text || '';
} else {
const resp = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
},
body: JSON.stringify({
model,
temperature: Number(step.config.temperature) || 0.3,
max_tokens: Number(step.config.max_tokens) || 2000,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
}),
});
if (!resp.ok) {
const errText = await resp.text();
return { success: false, error: `OpenAI API error (${resp.status}): ${errText.substring(0, 200)}` };
}
const data = await resp.json();
aiResponse = data.choices?.[0]?.message?.content || '';
}
return {
success: true,
output: {
ai_response: aiResponse,
ai_provider: provider,
ai_model: model,
},
};
}
registerStepExecutor('ai_analyze', executeAiAnalyze);

View file

@ -0,0 +1,138 @@
/**
* Approval Step send approval request, pause pipeline until callback.
* Config: { channel_id: 1, message: "...", options: ["Approve","Reject","Escalate"], timeout_min: 60 }
*/
import { registerStepExecutor } from '../pipeline-engine';
import { postgresClient } from '../postgres-client';
import { PipelineStep, PipelineContext, StepExecutorResult, NotificationChannel } from '../../types/pipeline';
async function executeApproval(
step: PipelineStep,
context: PipelineContext,
executionId: number
): Promise<StepExecutorResult> {
const channelId = Number(step.config.channel_id);
const message = step.config.message || 'Approval required';
const options = step.config.options || ['Approve', 'Reject'];
const timeoutMin = Number(step.config.timeout_min) || 60;
const expiresAt = new Date(Date.now() + timeoutMin * 60 * 1000);
// Create approval request record
const result = await postgresClient.query<{ id: number }>(
`INSERT INTO approval_requests (execution_id, step_order, channel_id, message, options, status, expires_at)
VALUES ($1, $2, $3, $4, $5, 'pending', $6)
RETURNING id`,
[executionId, step.step_order, channelId || null, message, JSON.stringify(options), expiresAt]
);
const approvalId = result.rows[0].id;
const callbackUrl = `${process.env.WEBHOOK_BASE_URL || ''}/api/pipelines/approval/${approvalId}`;
console.log(`[PIPELINE:approval] Created approval #${approvalId}, callback: ${callbackUrl}`);
// Send notification with approval buttons if channel is configured
if (channelId) {
const chResult = await postgresClient.query<NotificationChannel>(
`SELECT * FROM notification_channels WHERE id = $1 AND is_active = true`,
[channelId]
);
if (chResult.rows.length > 0) {
const channel = chResult.rows[0];
await sendApprovalNotification(channel, message, options, approvalId, callbackUrl, context);
}
}
// Return waiting — pipeline will pause here
return {
success: true,
waiting: true,
output: { approval_id: approvalId, callback_url: callbackUrl },
};
}
async function sendApprovalNotification(
channel: NotificationChannel,
message: string,
options: string[],
approvalId: number,
callbackUrl: string,
context: PipelineContext
): Promise<void> {
try {
if (channel.channel_type === 'teams') {
const actions = options.map(opt => ({
type: 'Action.OpenUrl',
title: opt,
url: `${callbackUrl}?response=${encodeURIComponent(opt)}`,
}));
const card = {
type: 'message',
attachments: [{
contentType: 'application/vnd.microsoft.card.adaptive',
content: {
type: 'AdaptiveCard',
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
version: '1.4',
body: [
{ type: 'TextBlock', text: 'Approval Required', weight: 'bolder', size: 'medium' },
{ type: 'TextBlock', text: message, wrap: true },
{ type: 'TextBlock', text: `Approval #${approvalId}`, size: 'small', isSubtle: true },
],
actions,
},
}],
};
await fetch(channel.config.webhook_url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(card),
});
} else if (channel.channel_type === 'telegram') {
const keyboard = {
inline_keyboard: [options.map(opt => ({
text: opt,
callback_data: JSON.stringify({ approval_id: approvalId, response: opt }),
}))],
};
await fetch(`https://api.telegram.org/bot${channel.config.bot_token}/sendMessage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: channel.config.chat_id,
text: `🔔 *Approval Required*\n\n${message}\n\n_Approval #${approvalId}_`,
parse_mode: 'Markdown',
reply_markup: keyboard,
}),
});
} else if (channel.channel_type === 'ntfy') {
const serverUrl = channel.config.server_url || 'https://ntfy.sh';
const headers: Record<string, string> = {
'Title': 'Approval Required',
'Priority': 'high',
'Tags': 'warning',
'Actions': options.map(opt =>
`http, ${opt}, ${callbackUrl}?response=${encodeURIComponent(opt)}, method=POST`
).join('; '),
};
if (channel.config.auth_token) {
headers['Authorization'] = `Bearer ${channel.config.auth_token}`;
}
await fetch(`${serverUrl}/${channel.config.topic}`, {
method: 'POST',
headers,
body: message,
});
}
} catch (err) {
console.error(`[PIPELINE:approval] Failed to send notification:`, err);
}
}
registerStepExecutor('approval', executeApproval);

View file

@ -0,0 +1,52 @@
/**
* Create Note Step add a note to an Autotask ticket.
* Config: { ticket_id: "{{context.ticket_id}}", title: "...", body: "...", note_type: 1, publish: 1 }
*/
import { registerStepExecutor } from '../pipeline-engine';
import { AutotaskClient } from '../autotask-client';
import { PipelineStep, PipelineContext, StepExecutorResult } from '../../types/pipeline';
let _client: AutotaskClient | null = null;
function getClient(): AutotaskClient {
if (!_client) {
_client = new AutotaskClient({
apiUrl: process.env.AUTOTASK_API_URL || '',
username: process.env.AUTOTASK_USERNAME || '',
password: process.env.AUTOTASK_SECRET || '',
apiIntegrationCode: process.env.AUTOTASK_API_INTEGRATION_CODE || '',
});
}
return _client;
}
async function executeCreateNote(
step: PipelineStep,
_context: PipelineContext,
_executionId: number
): Promise<StepExecutorResult> {
const ticketId = Number(step.config.ticket_id);
const title = step.config.title || 'Pipeline Note';
const body = step.config.body || '';
const noteType = Number(step.config.note_type) || 1;
const publish = Number(step.config.publish) || 1;
if (!ticketId || isNaN(ticketId)) {
return { success: false, error: 'Missing or invalid ticket_id' };
}
console.log(`[PIPELINE:create_note] Adding note to ticket #${ticketId}: "${title}"`);
const client = getClient();
await client.createEntity('TicketNotes', {
ticketID: ticketId,
title,
description: body,
noteType,
publish,
});
return { success: true, output: { note_created: true, ticket_id: ticketId } };
}
registerStepExecutor('create_note', executeCreateNote);

View file

@ -0,0 +1,68 @@
/**
* Create Ticket Step create an Autotask ticket from context.
* Config: { template: { title, description, companyID, ticketType, priority, queueID, ... } }
* All template values are pre-resolved by the engine.
*/
import { registerStepExecutor } from '../pipeline-engine';
import { AutotaskClient } from '../autotask-client';
import { PipelineStep, PipelineContext, StepExecutorResult } from '../../types/pipeline';
let _client: AutotaskClient | null = null;
function getClient(): AutotaskClient {
if (!_client) {
_client = new AutotaskClient({
apiUrl: process.env.AUTOTASK_API_URL || '',
username: process.env.AUTOTASK_USERNAME || '',
password: process.env.AUTOTASK_SECRET || '',
apiIntegrationCode: process.env.AUTOTASK_API_INTEGRATION_CODE || '',
});
}
return _client;
}
async function executeCreateTicket(
step: PipelineStep,
_context: PipelineContext,
_executionId: number
): Promise<StepExecutorResult> {
const template = step.config.template;
if (!template || !template.title) {
return { success: false, error: 'Missing ticket template or title' };
}
// Build ticket payload — convert numeric strings to numbers
const ticketPayload: Record<string, any> = {};
for (const [key, value] of Object.entries(template)) {
if (['companyID', 'ticketType', 'priority', 'queueID', 'ticketCategory', 'issueType', 'subIssueType', 'status'].includes(key)) {
const num = Number(value);
if (!isNaN(num) && num > 0) {
ticketPayload[key] = num;
}
} else {
ticketPayload[key] = value;
}
}
// Default status to New (1) if not set
if (!ticketPayload.status) {
ticketPayload.status = 1;
}
console.log(`[PIPELINE:create_ticket] Creating ticket: "${ticketPayload.title}"`);
const client = getClient();
const ticket = await client.createTicket(ticketPayload);
return {
success: true,
output: {
ticket_id: ticket.id,
ticket_number: (ticket as any).ticketNumber,
created_ticket: ticket,
},
};
}
registerStepExecutor('create_ticket', executeCreateTicket);

View file

@ -0,0 +1,76 @@
/**
* Step Executor: db_query
* Run a parameterized read-only SQL query against local Postgres.
* Useful for trend analysis, history lookups, aggregations.
*
* Config:
* query: SQL string with $1, $2 etc. placeholders
* params: array of template strings for parameter values
* output_key: context key to store results (default: 'query_result')
* single_row: if true, store only first row instead of array
*
* Security: Only SELECT statements allowed. No mutations.
*/
import { registerStepExecutor } from '../pipeline-engine';
import { postgresClient } from '../postgres-client';
import { StepExecutorResult, PipelineStep, PipelineContext } from '../../types/pipeline';
const FORBIDDEN_KEYWORDS = [
'INSERT', 'UPDATE', 'DELETE', 'DROP', 'ALTER', 'CREATE', 'TRUNCATE',
'GRANT', 'REVOKE', 'COPY', 'EXECUTE', 'CALL',
];
registerStepExecutor('db_query', async (
step: PipelineStep,
context: PipelineContext,
executionId: number
): Promise<StepExecutorResult> => {
const config = step.config as {
query?: string;
params?: string[];
output_key?: string;
single_row?: boolean;
};
const query = config.query || '';
const params = config.params || [];
const outputKey = config.output_key || 'query_result';
const singleRow = config.single_row ?? false;
if (!query) {
return { success: false, output: {}, error: 'query is required' };
}
// Security: block mutations
const upperQuery = query.toUpperCase().replace(/\s+/g, ' ');
for (const keyword of FORBIDDEN_KEYWORDS) {
// Check for keyword as a standalone word (not inside a string literal)
const regex = new RegExp(`\\b${keyword}\\b`);
if (regex.test(upperQuery)) {
return {
success: false,
output: {},
error: `Forbidden SQL keyword: ${keyword}. Only SELECT queries are allowed.`,
};
}
}
try {
const result = await postgresClient.query(query, params);
const rows = result.rows;
const value = singleRow ? (rows[0] || null) : rows;
return {
success: true,
output: {
[outputKey]: value,
[`${outputKey}_count`]: rows.length,
},
};
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
return { success: false, output: { [outputKey]: null }, error: `DB query failed: ${msg}` };
}
});

View file

@ -0,0 +1,24 @@
/**
* Delay Step wait N seconds before continuing.
* Config: { seconds: 30 }
*/
import { registerStepExecutor } from '../pipeline-engine';
import { PipelineStep, PipelineContext, StepExecutorResult } from '../../types/pipeline';
async function executeDelay(
step: PipelineStep,
_context: PipelineContext,
_executionId: number
): Promise<StepExecutorResult> {
const seconds = Number(step.config.seconds) || 0;
if (seconds > 0) {
console.log(`[PIPELINE:delay] Waiting ${seconds}s`);
await new Promise(resolve => setTimeout(resolve, seconds * 1000));
}
return { success: true, output: { delayed_seconds: seconds } };
}
registerStepExecutor('delay', executeDelay);

View file

@ -0,0 +1,71 @@
/**
* Enrich Company Step lookup Autotask company from site name or site_uid.
* Config: { lookup_by: "site_name", source_field: "{{context.site_name}}" }
*/
import { registerStepExecutor } from '../pipeline-engine';
import { postgresClient } from '../postgres-client';
import { PipelineStep, PipelineContext, StepExecutorResult } from '../../types/pipeline';
async function executeEnrichCompany(
step: PipelineStep,
_context: PipelineContext,
_executionId: number
): Promise<StepExecutorResult> {
const lookupBy = step.config.lookup_by || 'site_name';
const sourceValue = step.config.source_field;
if (!sourceValue) {
return { success: false, error: `No source value for company lookup by ${lookupBy}` };
}
let query: string;
let params: any[];
if (lookupBy === 'site_uid') {
query = `SELECT s.autotask_company_id as company_id, s.autotask_company_name as company_name, s.name as site_name
FROM datto_rmm_sites s WHERE s.uid = $1 LIMIT 1`;
params = [sourceValue];
} else {
// site_name — fuzzy match
query = `SELECT s.autotask_company_id as company_id, s.autotask_company_name as company_name, s.name as site_name
FROM datto_rmm_sites s WHERE LOWER(s.name) = LOWER($1) LIMIT 1`;
params = [sourceValue];
}
const result = await postgresClient.query(query, params);
if (result.rows.length === 0) {
// Try companies table directly
const compResult = await postgresClient.query(
`SELECT id as company_id, company_name FROM companies
WHERE LOWER(company_name) LIKE LOWER($1) AND is_deleted = false LIMIT 1`,
[`%${sourceValue}%`]
);
if (compResult.rows.length > 0) {
return {
success: true,
output: {
company_id: compResult.rows[0].company_id,
company_name: compResult.rows[0].company_name,
company_found: true,
},
};
}
return { success: true, output: { company_id: null, company_name: null, company_found: false } };
}
const row = result.rows[0];
return {
success: true,
output: {
company_id: row.company_id,
company_name: row.company_name,
company_found: true,
},
};
}
registerStepExecutor('enrich_company', executeEnrichCompany);

View file

@ -0,0 +1,50 @@
/**
* Enrich Device Step fetch device details from Datto RMM or local DB.
* Config: { lookup_by: "device_uid", source_field: "{{context.device_uid}}" }
*/
import { registerStepExecutor } from '../pipeline-engine';
import { postgresClient } from '../postgres-client';
import { PipelineStep, PipelineContext, StepExecutorResult } from '../../types/pipeline';
async function executeEnrichDevice(
step: PipelineStep,
_context: PipelineContext,
_executionId: number
): Promise<StepExecutorResult> {
const deviceUid = step.config.source_field || step.config.device_uid;
if (!deviceUid) {
return { success: false, error: 'No device_uid provided for enrichment' };
}
// Try local DB first
const result = await postgresClient.query(
`SELECT d.*, s.name as site_name, s.autotask_company_id, s.autotask_company_name
FROM datto_rmm_devices d
LEFT JOIN datto_rmm_sites s ON s.id = d.site_id
WHERE d.uid = $1
LIMIT 1`,
[deviceUid]
);
if (result.rows.length === 0) {
return { success: true, output: { device: null, device_found: false } };
}
const device = result.rows[0];
return {
success: true,
output: {
device,
device_found: true,
device_hostname: device.hostname,
device_os: device.operating_system,
device_ip: device.int_ip_address || device.ext_ip_address,
company_id: device.autotask_company_id,
company_name: device.autotask_company_name,
},
};
}
registerStepExecutor('enrich_device', executeEnrichDevice);

View file

@ -0,0 +1,48 @@
/**
* Enrich Ticket Step fetch ticket from local DB or Autotask.
* Config: { lookup_by: "ticket_number"|"ticket_id", source_field: "{{context.ticket_number}}" }
*/
import { registerStepExecutor } from '../pipeline-engine';
import { postgresClient } from '../postgres-client';
import { PipelineStep, PipelineContext, StepExecutorResult } from '../../types/pipeline';
async function executeEnrichTicket(
step: PipelineStep,
_context: PipelineContext,
_executionId: number
): Promise<StepExecutorResult> {
const lookupBy = step.config.lookup_by || 'ticket_number';
const sourceValue = step.config.source_field;
if (!sourceValue) {
return { success: false, error: `No source value for ticket lookup by ${lookupBy}` };
}
const field = lookupBy === 'ticket_id' ? 'id' : 'ticket_number';
const result = await postgresClient.query(
`SELECT id, ticket_number, title, description, status, priority, queue_id,
company_id, contact_id, assigned_resource_id, ticket_type,
issue_type, sub_issue_type, ticket_category
FROM tickets WHERE ${field} = $1 AND is_deleted = false LIMIT 1`,
[sourceValue]
);
if (result.rows.length === 0) {
return { success: true, output: { ticket: null, ticket_found: false } };
}
const ticket = result.rows[0];
return {
success: true,
output: {
ticket,
ticket_found: true,
ticket_id: ticket.id,
ticket_number: ticket.ticket_number,
ticket_title: ticket.title,
},
};
}
registerStepExecutor('enrich_ticket', executeEnrichTicket);

View file

@ -0,0 +1,195 @@
/**
* Step Executor: enrich_vspc
* Queries Veeam VSPC (API + local DB) for backup status of a device.
*
* Config:
* lookup_by: 'device_name' | 'organization_uid'
* source_field: template string for the lookup value
*
* Outputs to context:
* vspc_found, vspc_agent_jobs, vspc_server_jobs, vspc_workloads,
* vspc_last_job_status, vspc_last_success, vspc_failure_message,
* vspc_restore_points, vspc_backed_up_size, vspc_free_space,
* vspc_alarms, vspc_summary
*/
import { registerStepExecutor } from '../pipeline-engine';
import { postgresClient } from '../postgres-client';
import { StepExecutorResult, PipelineStep, PipelineContext } from '../../types/pipeline';
registerStepExecutor('enrich_vspc', async (
step: PipelineStep,
context: PipelineContext,
executionId: number
): Promise<StepExecutorResult> => {
const config = step.config as {
lookup_by?: string;
source_field?: string;
};
const lookupBy = config.lookup_by || 'device_name';
const sourceValue = config.source_field || '';
if (!sourceValue) {
return { success: false, output: { vspc_found: false }, error: 'source_field is required' };
}
try {
// ---- 1. Query local DB for backup agent jobs matching this device ----
let agentJobs: any[] = [];
let serverJobs: any[] = [];
let workloads: any[] = [];
let alarms: any[] = [];
if (lookupBy === 'device_name') {
// Match by agent name (agent name often = hostname)
const agentResult = await postgresClient.query(
`SELECT baj.*, ba.name as agent_name, ba.status as agent_status,
ba.agent_platform, ba.version as agent_version,
vo.name as org_name, vo.company_id
FROM veeam_backup_agent_jobs baj
LEFT JOIN veeam_backup_agents ba ON ba.instance_uid = baj.backup_agent_uid
LEFT JOIN veeam_organizations vo ON vo.instance_uid = baj.organization_uid
WHERE LOWER(ba.name) LIKE LOWER($1)
OR LOWER(baj.name) LIKE LOWER($1)
ORDER BY baj.last_run DESC NULLS LAST`,
[`%${sourceValue}%`]
);
agentJobs = agentResult.rows;
// Check protected workloads (VM-level)
const workloadResult = await postgresClient.query(
`SELECT pw.*, bj.name as job_name, bj.status as job_status,
bj.last_run as job_last_run, bj.failure_message as job_failure_message
FROM veeam_protected_workloads pw
LEFT JOIN veeam_backup_jobs bj ON bj.instance_uid = pw.job_uid
WHERE LOWER(pw.name) LIKE LOWER($1)
ORDER BY pw.latest_restore_point_date DESC NULLS LAST`,
[`%${sourceValue}%`]
);
workloads = workloadResult.rows;
// Check backup server jobs that might reference this device
const serverJobResult = await postgresClient.query(
`SELECT bj.*, vo.name as org_name, vo.company_id
FROM veeam_backup_jobs bj
LEFT JOIN veeam_organizations vo ON vo.instance_uid = bj.organization_uid
WHERE LOWER(bj.name) LIKE LOWER($1)
OR LOWER(bj.destination) LIKE LOWER($1)
ORDER BY bj.last_run DESC NULLS LAST`,
[`%${sourceValue}%`]
);
serverJobs = serverJobResult.rows;
// Check active alarms for this device
const alarmResult = await postgresClient.query(
`SELECT * FROM veeam_alarms
WHERE LOWER(object_name) LIKE LOWER($1)
OR LOWER(object_computer_name) LIKE LOWER($1)
ORDER BY last_activation_time DESC NULLS LAST`,
[`%${sourceValue}%`]
);
alarms = alarmResult.rows;
}
// ---- 2. Build summary ----
const allJobs = [...agentJobs, ...serverJobs];
const latestJob = allJobs[0] || null;
const failedJobs = allJobs.filter(j => j.status === 'Failed');
const warningJobs = allJobs.filter(j => j.status === 'Warning');
const successJobs = allJobs.filter(j => j.status === 'Success');
// Find last successful backup across all job types
const lastSuccess = allJobs.find(j => j.status === 'Success');
const lastSuccessDate = lastSuccess?.last_run || lastSuccess?.last_end_time || null;
// Calculate hours since last success
let hoursSinceSuccess: number | null = null;
if (lastSuccessDate) {
hoursSinceSuccess = Math.round((Date.now() - new Date(lastSuccessDate).getTime()) / 3600000);
}
// Aggregate restore points and sizes
const totalRestorePoints = agentJobs.reduce((sum, j) => sum + (j.restore_points || 0), 0)
+ workloads.reduce((sum, w) => sum + (w.restore_points || 0), 0);
const totalBackedUpSize = agentJobs.reduce((sum, j) => sum + (j.backed_up_size || 0), 0);
const summary = [
`Device: ${sourceValue}`,
`Agent Jobs: ${agentJobs.length} (${successJobs.length} success, ${failedJobs.length} failed, ${warningJobs.length} warning)`,
`Server Jobs: ${serverJobs.length}`,
`Protected Workloads: ${workloads.length}`,
`Active Alarms: ${alarms.length}`,
`Total Restore Points: ${totalRestorePoints}`,
latestJob ? `Latest Job: "${latestJob.name}" — ${latestJob.status} at ${latestJob.last_run || 'never'}` : 'No jobs found',
latestJob?.failure_message ? `Failure: ${latestJob.failure_message}` : null,
lastSuccessDate ? `Last Success: ${lastSuccessDate} (${hoursSinceSuccess}h ago)` : 'No successful backup found',
alarms.length > 0 ? `Alarms: ${alarms.map((a: any) => `${a.alarm_area}: ${a.last_activation_message}`).join('; ')}` : null,
].filter(Boolean).join('\n');
const found = agentJobs.length > 0 || serverJobs.length > 0 || workloads.length > 0;
return {
success: true,
output: {
vspc_found: found,
vspc_agent_jobs: agentJobs.map(j => ({
name: j.name,
status: j.status,
last_run: j.last_run,
last_end_time: j.last_end_time,
last_duration: j.last_duration,
failure_message: j.failure_message,
backup_mode: j.backup_mode,
destination: j.destination,
restore_points: j.restore_points,
backed_up_size: j.backed_up_size,
free_space: j.free_space,
is_enabled: j.is_enabled,
agent_name: j.agent_name,
agent_status: j.agent_status,
org_name: j.org_name,
})),
vspc_server_jobs: serverJobs.map(j => ({
name: j.name,
status: j.status,
last_run: j.last_run,
failure_message: j.failure_message,
type: j.type,
destination: j.destination,
bottleneck: j.bottleneck,
backup_chain_size: j.backup_chain_size,
})),
vspc_workloads: workloads.map(w => ({
name: w.name,
restore_points: w.restore_points,
latest_restore_point_date: w.latest_restore_point_date,
latest_restore_point_size: w.latest_restore_point_size,
malware_state: w.malware_state,
job_name: w.job_name,
job_status: w.job_status,
})),
vspc_alarms: alarms.map(a => ({
area: a.alarm_area,
message: a.last_activation_message,
status: a.last_activation_status,
time: a.last_activation_time,
object_name: a.object_name,
})),
vspc_last_job_status: latestJob?.status || null,
vspc_last_success: lastSuccessDate,
vspc_hours_since_success: hoursSinceSuccess,
vspc_failure_message: latestJob?.failure_message || null,
vspc_restore_points: totalRestorePoints,
vspc_backed_up_size: totalBackedUpSize,
vspc_alarm_count: alarms.length,
vspc_failed_job_count: failedJobs.length,
vspc_summary: summary,
},
};
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
return { success: false, output: { vspc_found: false }, error: `VSPC enrichment failed: ${msg}` };
}
});

View file

@ -0,0 +1,147 @@
/**
* Fetch B2 Result Step download a JSON result from Backblaze B2 via S3-compatible presigned URL.
* Config: {
* object_key: "{{context.diagnostic_object_key}}", // key in the bucket
* output_key: "diagnostic_results", // context key for parsed JSON
* bucket?: "wulf-audits", // defaults to B2_BUCKET env
* }
*/
import crypto from 'crypto';
import { registerStepExecutor } from '../pipeline-engine';
import { PipelineStep, PipelineContext, StepExecutorResult } from '../../types/pipeline';
const B2_DEFAULTS = {
bucket: process.env.B2_BUCKET || 'wulf-audits',
region: process.env.B2_REGION || 'us-west-002',
endpoint: process.env.B2_ENDPOINT || 's3.us-west-002.backblazeb2.com',
keyId: process.env.B2_KEY_ID || '',
appKey: process.env.B2_APP_KEY || '',
};
function hmacSha256(key: string | Buffer, data: string): Buffer {
return crypto.createHmac('sha256', key).update(data).digest();
}
function generatePresignedUrl(objectKey: string, bucket?: string): string {
const { region, endpoint, keyId, appKey } = B2_DEFAULTS;
const bkt = bucket || B2_DEFAULTS.bucket;
if (!keyId || !appKey) {
throw new Error('B2_KEY_ID and B2_APP_KEY environment variables are required');
}
const expiresIn = 600; // 10 minutes
const method = 'GET';
const host = endpoint;
const canonicalUri = `/${bkt}/${objectKey}`;
const algorithm = 'AWS4-HMAC-SHA256';
const now = new Date();
const amzDate = now.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
const dateStamp = amzDate.slice(0, 8);
const credentialScope = `${dateStamp}/${region}/s3/aws4_request`;
const canonicalHeaders = `host:${host}\n`;
const signedHeaders = 'host';
const qs: Record<string, string> = {
'X-Amz-Algorithm': algorithm,
'X-Amz-Credential': encodeURIComponent(`${keyId}/${credentialScope}`),
'X-Amz-Date': amzDate,
'X-Amz-Expires': expiresIn.toString(),
'X-Amz-SignedHeaders': signedHeaders,
};
const canonicalQueryString = Object.keys(qs)
.sort()
.map((k) => `${k}=${qs[k]}`)
.join('&');
const payloadHash = 'UNSIGNED-PAYLOAD';
const canonicalRequest = [
method,
canonicalUri,
canonicalQueryString,
canonicalHeaders,
signedHeaders,
payloadHash,
].join('\n');
const stringToSign = [
algorithm,
amzDate,
credentialScope,
crypto.createHash('sha256').update(canonicalRequest).digest('hex'),
].join('\n');
// Derive signing key
const kDate = hmacSha256('AWS4' + appKey, dateStamp);
const kRegion = hmacSha256(kDate, region);
const kService = hmacSha256(kRegion, 's3');
const kSigning = hmacSha256(kService, 'aws4_request');
const signature = crypto
.createHmac('sha256', kSigning)
.update(stringToSign)
.digest('hex');
return `https://${host}${canonicalUri}?${canonicalQueryString}&X-Amz-Signature=${signature}`;
}
async function executeFetchB2Result(
step: PipelineStep,
_context: PipelineContext,
_executionId: number
): Promise<StepExecutorResult> {
const objectKey = step.config.object_key;
const outputKey = step.config.output_key || 'b2_result';
const bucket = step.config.bucket;
if (!objectKey) {
return { success: false, error: 'Missing object_key in fetch_b2_result config' };
}
console.log(`[PIPELINE:fetch_b2_result] Fetching ${objectKey} from B2`);
try {
const url = generatePresignedUrl(objectKey, bucket);
const response = await fetch(url);
if (!response.ok) {
return {
success: false,
error: `B2 download failed: ${response.status} ${response.statusText}`,
};
}
const text = await response.text();
let parsed: any;
try {
parsed = JSON.parse(text);
} catch {
// Not JSON — store as raw text
parsed = text;
}
console.log(`[PIPELINE:fetch_b2_result] Downloaded ${text.length} bytes, parsed as ${typeof parsed}`);
return {
success: true,
output: {
[outputKey]: parsed,
[`${outputKey}_raw_length`]: text.length,
b2_object_key: objectKey,
},
};
} catch (err: any) {
return {
success: false,
error: `B2 fetch error: ${err.message}`,
};
}
}
registerStepExecutor('fetch_b2_result', executeFetchB2Result);

View file

@ -0,0 +1,29 @@
/**
* Filter Step evaluate conditions, skip pipeline if not met.
* Config: { conditions: [{ field, operator, value }] }
*/
import { registerStepExecutor, pipelineEngine } from '../pipeline-engine';
import { PipelineStep, PipelineContext, StepExecutorResult } from '../../types/pipeline';
async function executeFilter(
step: PipelineStep,
context: PipelineContext,
_executionId: number
): Promise<StepExecutorResult> {
const conditions = step.config.conditions || [];
if (conditions.length === 0) {
return { success: true, output: { filter_result: 'passed' } };
}
const passed = pipelineEngine.evaluateConditions(conditions, context);
if (!passed) {
return { success: false, error: 'Filter conditions not met — pipeline skipped' };
}
return { success: true, output: { filter_result: 'passed' } };
}
registerStepExecutor('filter', executeFilter);

View file

@ -0,0 +1,22 @@
/**
* Pipeline Step Executors import all to register them with the engine.
* This file must be imported once at app startup or when the pipeline engine is used.
*/
import './filter';
import './transform';
import './set-variable';
import './delay';
import './enrich-device';
import './enrich-company';
import './enrich-ticket';
import './create-ticket';
import './update-ticket';
import './create-note';
import './ai-analyze';
import './notify';
import './approval';
import './rmm-quick-job';
import './enrich-vspc';
import './db-query';
import './fetch-b2-result';

View file

@ -0,0 +1,192 @@
/**
* Notify Step send notification to a configured channel.
* Config: { channel_id: 1, message: "...", card_template?: {...} }
* Channel config is loaded from notification_channels table.
*/
import { registerStepExecutor } from '../pipeline-engine';
import { postgresClient } from '../postgres-client';
import { PipelineStep, PipelineContext, StepExecutorResult, NotificationChannel } from '../../types/pipeline';
async function executeNotify(
step: PipelineStep,
_context: PipelineContext,
_executionId: number
): Promise<StepExecutorResult> {
const channelId = Number(step.config.channel_id);
if (!channelId || isNaN(channelId)) {
return { success: false, error: 'Missing or invalid channel_id' };
}
const result = await postgresClient.query<NotificationChannel>(
`SELECT * FROM notification_channels WHERE id = $1 AND is_active = true`,
[channelId]
);
if (result.rows.length === 0) {
return { success: false, error: `Notification channel #${channelId} not found or inactive` };
}
const channel = result.rows[0];
const message = step.config.message || '';
switch (channel.channel_type) {
case 'teams':
return await sendTeams(channel, step.config, message);
case 'telegram':
return await sendTelegram(channel, message);
case 'ntfy':
return await sendNtfy(channel, step.config, message);
case 'webhook':
return await sendWebhook(channel, step.config, message);
default:
return { success: false, error: `Unknown channel type: ${channel.channel_type}` };
}
}
async function sendTeams(
channel: NotificationChannel,
config: Record<string, any>,
message: string
): Promise<StepExecutorResult> {
const webhookUrl = channel.config.webhook_url;
if (!webhookUrl) {
return { success: false, error: 'Teams channel missing webhook_url' };
}
// If a card_template is provided, use it as Adaptive Card
const body = config.card_template || {
type: 'message',
attachments: [{
contentType: 'application/vnd.microsoft.card.adaptive',
content: {
type: 'AdaptiveCard',
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
version: '1.4',
body: [
{ type: 'TextBlock', text: config.title || 'Pulse Notification', weight: 'bolder', size: 'medium' },
{ type: 'TextBlock', text: message, wrap: true },
],
},
}],
};
const resp = await fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!resp.ok) {
const errText = await resp.text();
return { success: false, error: `Teams webhook failed (${resp.status}): ${errText.substring(0, 200)}` };
}
return { success: true, output: { notified: true, channel: 'teams' } };
}
async function sendTelegram(
channel: NotificationChannel,
message: string
): Promise<StepExecutorResult> {
const botToken = channel.config.bot_token;
const chatId = channel.config.chat_id;
if (!botToken || !chatId) {
return { success: false, error: 'Telegram channel missing bot_token or chat_id' };
}
const resp = await fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: chatId,
text: message,
parse_mode: channel.config.parse_mode || 'HTML',
}),
});
if (!resp.ok) {
const errText = await resp.text();
return { success: false, error: `Telegram API failed (${resp.status}): ${errText.substring(0, 200)}` };
}
return { success: true, output: { notified: true, channel: 'telegram' } };
}
async function sendNtfy(
channel: NotificationChannel,
config: Record<string, any>,
message: string
): Promise<StepExecutorResult> {
const serverUrl = channel.config.server_url || 'https://ntfy.sh';
const topic = channel.config.topic;
if (!topic) {
return { success: false, error: 'ntfy channel missing topic' };
}
const headers: Record<string, string> = {
'Content-Type': 'text/plain',
};
if (config.title || channel.config.default_title) {
headers['Title'] = config.title || channel.config.default_title;
}
if (config.priority || channel.config.default_priority) {
headers['Priority'] = config.priority || channel.config.default_priority;
}
if (channel.config.auth_token) {
headers['Authorization'] = `Bearer ${channel.config.auth_token}`;
}
const resp = await fetch(`${serverUrl}/${topic}`, {
method: 'POST',
headers,
body: message,
});
if (!resp.ok) {
const errText = await resp.text();
return { success: false, error: `ntfy failed (${resp.status}): ${errText.substring(0, 200)}` };
}
return { success: true, output: { notified: true, channel: 'ntfy' } };
}
async function sendWebhook(
channel: NotificationChannel,
config: Record<string, any>,
message: string
): Promise<StepExecutorResult> {
const url = channel.config.url;
if (!url) {
return { success: false, error: 'Webhook channel missing url' };
}
const method = channel.config.method || 'POST';
const customHeaders = channel.config.headers || {};
const body = config.body_template
? config.body_template
: { message, timestamp: new Date().toISOString() };
const resp = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
...customHeaders,
},
body: JSON.stringify(body),
});
if (!resp.ok) {
const errText = await resp.text();
return { success: false, error: `Webhook failed (${resp.status}): ${errText.substring(0, 200)}` };
}
return { success: true, output: { notified: true, channel: 'webhook' } };
}
registerStepExecutor('notify', executeNotify);

View file

@ -0,0 +1,89 @@
/**
* RMM Quick Job Step run a Datto RMM quick job on a device.
* Config: { device_uid: "{{context.device_uid}}", component_uid: "comp-xxx", variables: [...] }
*/
import { registerStepExecutor } from '../pipeline-engine';
import { DattoRMMClient } from '../datto-rmm-client';
import { PipelineStep, PipelineContext, StepExecutorResult } from '../../types/pipeline';
let _client: DattoRMMClient | null = null;
function getClient(): DattoRMMClient {
if (!_client) {
_client = new DattoRMMClient({
apiUrl: process.env.DATTO_RMM_API_URL || 'https://concord-api.centrastage.net/api/v2',
apiKey: process.env.DATTO_RMM_API_KEY || '',
apiSecret: process.env.DATTO_RMM_API_SECRET || '',
});
}
return _client;
}
async function executeRmmQuickJob(
step: PipelineStep,
_context: PipelineContext,
_executionId: number
): Promise<StepExecutorResult> {
const deviceUid = step.config.device_uid;
const componentUid = step.config.component_uid;
const jobName = step.config.job_name || 'Pipeline Quick Job';
const variables = step.config.variables || [];
if (!deviceUid) {
return { success: false, error: 'Missing device_uid for quick job' };
}
if (!componentUid) {
return { success: false, error: 'Missing component_uid for quick job' };
}
console.log(`[PIPELINE:rmm_quick_job] Running "${jobName}" on device ${deviceUid}`);
const client = getClient();
const result = await client.runQuickJob(deviceUid, {
jobName,
jobComponent: {
componentUid,
variables,
},
});
return {
success: true,
output: {
quick_job_result: result,
job_uid: result?.uid || null,
},
};
}
registerStepExecutor('rmm_quick_job', executeRmmQuickJob);
/**
* RMM Get Job Results Step poll for quick job results.
* Config: { job_uid: "{{context.job_uid}}", device_uid: "{{context.device_uid}}" }
*/
async function executeRmmGetJobResults(
step: PipelineStep,
_context: PipelineContext,
_executionId: number
): Promise<StepExecutorResult> {
const jobUid = step.config.job_uid;
const deviceUid = step.config.device_uid;
if (!jobUid || !deviceUid) {
return { success: false, error: 'Missing job_uid or device_uid' };
}
const client = getClient();
const result = await client.getJobResults(jobUid, deviceUid);
return {
success: true,
output: {
job_results: result,
job_status: result?.jobDeploymentStatus || 'unknown',
},
};
}
registerStepExecutor('rmm_get_job_results', executeRmmGetJobResults);

View file

@ -0,0 +1,23 @@
/**
* Set Variable Step set a single context variable.
* Config: { key: "my_var", value: "{{trigger.something}}" }
*/
import { registerStepExecutor } from '../pipeline-engine';
import { PipelineStep, PipelineContext, StepExecutorResult } from '../../types/pipeline';
async function executeSetVariable(
step: PipelineStep,
_context: PipelineContext,
_executionId: number
): Promise<StepExecutorResult> {
const { key, value } = step.config;
if (!key) {
return { success: false, error: 'Missing "key" in set_variable config' };
}
return { success: true, output: { [key]: value } };
}
registerStepExecutor('set_variable', executeSetVariable);

View file

@ -0,0 +1,22 @@
/**
* Transform Step map/reshape payload fields into context variables.
* Config: { mappings: { key: "{{trigger.field}}" } }
* Template variables are already resolved by the engine before this runs.
*/
import { registerStepExecutor } from '../pipeline-engine';
import { PipelineStep, PipelineContext, StepExecutorResult } from '../../types/pipeline';
async function executeTransform(
step: PipelineStep,
_context: PipelineContext,
_executionId: number
): Promise<StepExecutorResult> {
const mappings = step.config.mappings || {};
// Config values are already template-resolved by the engine,
// so we just pass them through as context output.
return { success: true, output: mappings };
}
registerStepExecutor('transform', executeTransform);

View file

@ -0,0 +1,47 @@
/**
* Update Ticket Step update an existing Autotask ticket.
* Config: { ticket_id: "{{context.ticket_id}}", fields: { priority: 4, queueID: 123 } }
*/
import { registerStepExecutor } from '../pipeline-engine';
import { AutotaskClient } from '../autotask-client';
import { PipelineStep, PipelineContext, StepExecutorResult } from '../../types/pipeline';
let _client: AutotaskClient | null = null;
function getClient(): AutotaskClient {
if (!_client) {
_client = new AutotaskClient({
apiUrl: process.env.AUTOTASK_API_URL || '',
username: process.env.AUTOTASK_USERNAME || '',
password: process.env.AUTOTASK_SECRET || '',
apiIntegrationCode: process.env.AUTOTASK_API_INTEGRATION_CODE || '',
});
}
return _client;
}
async function executeUpdateTicket(
step: PipelineStep,
_context: PipelineContext,
_executionId: number
): Promise<StepExecutorResult> {
const ticketId = Number(step.config.ticket_id);
const fields = step.config.fields || {};
if (!ticketId || isNaN(ticketId)) {
return { success: false, error: 'Missing or invalid ticket_id' };
}
if (Object.keys(fields).length === 0) {
return { success: true, output: { updated: false, reason: 'No fields to update' } };
}
console.log(`[PIPELINE:update_ticket] Updating ticket #${ticketId}: ${Object.keys(fields).join(', ')}`);
const client = getClient();
await client.updateTicket(ticketId, fields);
return { success: true, output: { updated: true, ticket_id: ticketId, fields_updated: Object.keys(fields) } };
}
registerStepExecutor('update_ticket', executeUpdateTicket);

View file

@ -8,13 +8,14 @@ import { SyncService, createSyncService } from './sync-service';
import { postgresClient } from './postgres-client';
import { AutotaskClient } from './autotask-client';
import { VeeamSyncService } from './veeam-sync-service';
import { VeeamRpoService } from './veeam-rpo-service';
export interface ScheduleConfig {
id: string;
name: string;
description: string;
cron_expression: string;
sync_type: 'incremental' | 'full' | 'veeam-incremental' | 'veeam-full';
sync_type: 'incremental' | 'full' | 'veeam-incremental' | 'veeam-full' | 'veeam-rpo-check';
years_back?: number;
is_enabled: boolean;
last_run?: Date;
@ -38,6 +39,7 @@ class SyncScheduler {
private initialized = false;
private syncService: SyncService;
private _veeamSyncService: VeeamSyncService | null = null;
private _veeamRpoService: VeeamRpoService | null = null;
private getVeeamSyncService(): VeeamSyncService {
if (!this._veeamSyncService) {
@ -46,6 +48,13 @@ class SyncScheduler {
return this._veeamSyncService;
}
private getVeeamRpoService(): VeeamRpoService {
if (!this._veeamRpoService) {
this._veeamRpoService = new VeeamRpoService();
}
return this._veeamRpoService;
}
constructor() {
// Create sync service instance
const autotaskClient = new AutotaskClient({
@ -163,6 +172,14 @@ class SyncScheduler {
sync_type: 'veeam-full',
is_enabled: false,
},
{
id: 'veeam-rpo-check',
name: 'Veeam RPO Check',
description: 'RPO-based workstation backup alerting — creates/resolves Autotask tickets every 30 minutes',
cron_expression: '*/30 * * * *',
sync_type: 'veeam-rpo-check',
is_enabled: false,
},
];
for (const schedule of defaultSchedules) {
@ -270,6 +287,8 @@ class SyncScheduler {
await this.getVeeamSyncService().incrementalSync('scheduled');
} else if (config.sync_type === 'veeam-full') {
await this.getVeeamSyncService().fullSync('scheduled');
} else if (config.sync_type === 'veeam-rpo-check') {
await this.getVeeamRpoService().runCheck();
} else if (config.sync_type === 'incremental') {
await this.syncService.incrementalSync('scheduled');
} else {

View file

@ -0,0 +1,503 @@
/**
* Ticket Workflow Engine
* Refactored table-driven workflow engine with per-workflow and per-step toggles.
* Matches ticket events to workflows, executes steps sequentially, resolves template
* variables, and accumulates context between steps.
*/
import { postgresClient } from './postgres-client';
import {
TicketWorkflow,
TicketWorkflowStep,
TicketWorkflowWithSteps,
TicketWorkflowExecution,
WorkflowStepContext,
WorkflowStepExecutorFn,
WorkflowStepResult,
TriggerCondition,
StepCondition,
} from '../types/ticket-workflow';
import { TicketData, WorkflowSettings } from '../types/workflow';
// Step executor registry — populated by individual step files
const workflowStepExecutors: Map<string, WorkflowStepExecutorFn> = new Map();
export function registerWorkflowStepExecutor(stepType: string, executor: WorkflowStepExecutorFn): void {
workflowStepExecutors.set(stepType, executor);
}
export class TicketWorkflowEngine {
/**
* Main entry point: process a ticket event and execute matching workflows.
*/
async processTrigger(
triggerEvent: string,
ticket: TicketData
): Promise<number[]> {
// Check global kill switch
const settings = await this.getSettings();
if (!settings.workflow_engine_enabled) {
console.log('[TICKET-WORKFLOW] Engine is disabled globally, skipping');
return [];
}
// Find matching workflows
const workflows = await this.findMatchingWorkflows(triggerEvent, ticket);
if (workflows.length === 0) {
console.log(`[TICKET-WORKFLOW] No workflows matched for ${triggerEvent} on ticket #${ticket.ticket_number}`);
return [];
}
console.log(`[TICKET-WORKFLOW] ${workflows.length} workflow(s) matched for ticket #${ticket.ticket_number}`);
// Execute each matching workflow
const executionIds: number[] = [];
for (const workflow of workflows) {
try {
const execId = await this.executeWorkflow(workflow, ticket, settings);
executionIds.push(execId);
} catch (err) {
console.error(`[TICKET-WORKFLOW] Failed to execute workflow "${workflow.name}":`, err);
}
}
return executionIds;
}
/**
* Load workflow settings from DB.
*/
async getSettings(): Promise<WorkflowSettings> {
const result = await postgresClient.query(
`SELECT key, value FROM workflow_settings`
);
const raw: Record<string, any> = {};
for (const row of result.rows) {
try {
raw[row.key] = JSON.parse(row.value);
} catch {
raw[row.key] = row.value;
}
}
return {
workflow_engine_enabled: raw.workflow_engine_enabled ?? false,
default_ai_provider: raw.default_ai_provider ?? 'openai',
openai_api_key: raw.openai_api_key ?? '',
openai_model: raw.openai_model ?? 'gpt-4o',
anthropic_api_key: raw.anthropic_api_key ?? '',
anthropic_model: raw.anthropic_model ?? 'claude-sonnet-4-20250514',
ai_for_title_cleanup: raw.ai_for_title_cleanup ?? true,
ai_for_description_rewrite: raw.ai_for_description_rewrite ?? true,
ai_for_ambiguous_classification: raw.ai_for_ambiguous_classification ?? true,
ai_for_troubleshooting: raw.ai_for_troubleshooting ?? true,
autotask_update_delay_ms: Number(raw.autotask_update_delay_ms) || 30000,
max_ai_retries: Number(raw.max_ai_retries) || 2,
classification_confidence_threshold: raw.classification_confidence_threshold ?? 'medium',
log_retention_days: Number(raw.log_retention_days) || 90,
};
}
/**
* Find active workflows matching the trigger event and conditions.
*/
async findMatchingWorkflows(
triggerEvent: string,
ticket: TicketData
): Promise<TicketWorkflowWithSteps[]> {
const result = await postgresClient.query<TicketWorkflow>(
`SELECT * FROM ticket_workflows
WHERE is_active = true AND trigger_event = $1
ORDER BY sort_order`,
[triggerEvent]
);
const matched: TicketWorkflowWithSteps[] = [];
for (const workflow of result.rows) {
const conditions: TriggerCondition[] = Array.isArray(workflow.trigger_conditions)
? workflow.trigger_conditions
: [];
if (this.evaluateTriggerConditions(conditions, ticket)) {
const stepsResult = await postgresClient.query<TicketWorkflowStep>(
`SELECT * FROM ticket_workflow_steps
WHERE workflow_id = $1 AND is_active = true
ORDER BY step_order`,
[workflow.id]
);
matched.push({ ...workflow, steps: stepsResult.rows });
}
}
return matched;
}
/**
* Execute a single workflow: create execution record, run steps, update status.
*/
async executeWorkflow(
workflow: TicketWorkflowWithSteps,
ticket: TicketData,
settings: WorkflowSettings
): Promise<number> {
const execResult = await postgresClient.query<{ id: number }>(
`INSERT INTO ticket_workflow_executions (workflow_id, ticket_id, ticket_number, status)
VALUES ($1, $2, $3, 'running')
RETURNING id`,
[workflow.id, ticket.id, ticket.ticket_number]
);
const executionId = execResult.rows[0].id;
const context: WorkflowStepContext = {
ticket,
_settings: settings,
field_changes: {},
};
let finalStatus: 'pending' | 'running' | 'completed' | 'failed' | 'skipped' = 'completed';
let classificationMethod: 'robotic' | 'ai' | 'hybrid' = 'robotic';
let branch: 'service_desk' | 'noc' | 'soc' = 'service_desk';
let errorMessage: string | null = null;
console.log(`[TICKET-WORKFLOW] Executing "${workflow.name}" (exec #${executionId}), ${workflow.steps.length} steps`);
for (const step of workflow.steps) {
// Check step condition
if (step.condition && !this.evaluateStepCondition(step.condition, context)) {
console.log(`[TICKET-WORKFLOW] Step ${step.step_order} "${step.name}" skipped (condition not met)`);
await this.logStepExecution(executionId, step, 'skipped', null, null, 0, 'Condition not met');
continue;
}
const stepStart = Date.now();
// Log step start
await this.logStepExecution(executionId, step, 'running', { config: step.config }, null, 0);
const executor = workflowStepExecutors.get(step.step_type);
if (!executor) {
const err = `No executor registered for step type: ${step.step_type}`;
console.error(`[TICKET-WORKFLOW] ${err}`);
await this.logStepExecution(executionId, step, 'failed', null, null, Date.now() - stepStart, err);
if (step.on_failure === 'stop') {
finalStatus = 'failed';
errorMessage = err;
break;
}
continue;
}
try {
// Resolve template variables in step config
const resolvedConfig = this.resolveTemplates(step.config, context);
const resolvedStep = { ...step, config: resolvedConfig };
const result = await executor(resolvedStep, context, executionId);
const duration = Date.now() - stepStart;
if (result.success) {
// Merge output into context
if (result.output) {
Object.assign(context, result.output);
// Track if AI was used
if (result.output.method === 'ai') {
classificationMethod = classificationMethod === 'robotic' ? 'hybrid' : 'ai';
}
}
await this.logStepExecution(executionId, step, 'completed', null, result.output, duration);
console.log(`[TICKET-WORKFLOW] Step ${step.step_order} "${step.name}" completed (${duration}ms)`);
} else {
await this.logStepExecution(executionId, step, 'failed', null, result.output, duration, result.error || null);
console.error(`[TICKET-WORKFLOW] Step ${step.step_order} "${step.name}" failed: ${result.error}`);
if (step.on_failure === 'stop') {
finalStatus = 'failed';
errorMessage = `Step ${step.step_order} "${step.name}": ${result.error}`;
break;
} else if (step.on_failure === 'skip_to' && step.skip_to_step) {
// Skip ahead (simplified: just continue, full implementation would jump to specific step)
continue;
}
// on_failure === 'continue' → keep going
}
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
const duration = Date.now() - stepStart;
await this.logStepExecution(executionId, step, 'failed', null, null, duration, errMsg);
console.error(`[TICKET-WORKFLOW] Step ${step.step_order} "${step.name}" threw: ${errMsg}`);
if (step.on_failure === 'stop') {
finalStatus = 'failed';
errorMessage = errMsg;
break;
}
}
}
// Extract branch from context if set
if (context.classification?.branch_routing) {
branch = (context.classification.branch_routing.value as any) || 'service_desk';
}
// Finalize execution
await postgresClient.query(
`UPDATE ticket_workflow_executions
SET status = $1, classification_method = $2, branch = $3, context = $4,
field_changes = $5, completed_at = NOW(),
duration_ms = EXTRACT(EPOCH FROM (NOW() - started_at)) * 1000,
error_message = $6
WHERE id = $7`,
[
finalStatus,
classificationMethod,
branch,
JSON.stringify(context),
JSON.stringify(context.field_changes || {}),
errorMessage,
executionId
]
);
console.log(`[TICKET-WORKFLOW] Execution #${executionId} finished: ${finalStatus}`);
return executionId;
}
/**
* Dry-run: execute a workflow on a ticket without actually updating Autotask.
*/
async dryRun(workflowId: number, ticketId: number): Promise<any> {
// Load workflow
const workflowResult = await postgresClient.query<TicketWorkflow>(
`SELECT * FROM ticket_workflows WHERE id = $1`,
[workflowId]
);
if (workflowResult.rows.length === 0) {
throw new Error(`Workflow #${workflowId} not found`);
}
const workflow = workflowResult.rows[0];
// Load steps
const stepsResult = await postgresClient.query<TicketWorkflowStep>(
`SELECT * FROM ticket_workflow_steps
WHERE workflow_id = $1 AND is_active = true
ORDER BY step_order`,
[workflowId]
);
const workflowWithSteps: TicketWorkflowWithSteps = {
...workflow,
steps: stepsResult.rows
};
// Load ticket
const ticketResult = await postgresClient.query<TicketData>(
`SELECT * FROM tickets WHERE id = $1`,
[ticketId]
);
if (ticketResult.rows.length === 0) {
throw new Error(`Ticket #${ticketId} not found`);
}
const ticket = ticketResult.rows[0];
// Load settings
const settings = await this.getSettings();
// Execute workflow (will create a real execution record)
// For dry-run, we could skip the update_ticket step or mark it differently
// For now, we'll execute normally but return the execution ID for inspection
const executionId = await this.executeWorkflow(workflowWithSteps, ticket, settings);
// Fetch execution result
const execResult = await postgresClient.query<TicketWorkflowExecution>(
`SELECT * FROM ticket_workflow_executions WHERE id = $1`,
[executionId]
);
// Fetch execution steps
const stepsExecResult = await postgresClient.query(
`SELECT * FROM ticket_workflow_execution_steps WHERE execution_id = $1 ORDER BY step_order`,
[executionId]
);
return {
execution: execResult.rows[0],
steps: stepsExecResult.rows
};
}
/**
* Evaluate trigger conditions (AND logic).
*/
private evaluateTriggerConditions(conditions: TriggerCondition[], ticket: TicketData): boolean {
for (const condition of conditions) {
const value = (ticket as any)[condition.field];
switch (condition.operator) {
case 'equals':
if (value !== condition.value) return false;
break;
case 'not_equals':
if (value === condition.value) return false;
break;
case 'in':
if (!Array.isArray(condition.value) || !condition.value.includes(value)) return false;
break;
case 'not_in':
if (!Array.isArray(condition.value) || condition.value.includes(value)) return false;
break;
case 'contains':
if (typeof value !== 'string' || !value.includes(String(condition.value))) return false;
break;
case 'not_contains':
if (typeof value === 'string' && value.includes(String(condition.value))) return false;
break;
case 'gt':
if (!(Number(value) > Number(condition.value))) return false;
break;
case 'lt':
if (!(Number(value) < Number(condition.value))) return false;
break;
default:
return false;
}
}
return true;
}
/**
* Evaluate step condition.
*/
private evaluateStepCondition(condition: StepCondition, context: WorkflowStepContext): boolean {
const value = this.getNestedValue(context, condition.field);
switch (condition.operator) {
case 'equals':
return value === condition.value;
case 'not_equals':
return value !== condition.value;
case 'in':
return Array.isArray(condition.value) && condition.value.includes(value);
case 'not_in':
return Array.isArray(condition.value) && !condition.value.includes(value);
case 'contains':
if (typeof value === 'string') {
return value.includes(String(condition.value));
}
if (Array.isArray(value)) {
return value.some(v => String(v).includes(String(condition.value)));
}
return false;
case 'not_contains':
if (typeof value === 'string') {
return !value.includes(String(condition.value));
}
return true;
case 'gt':
return Number(value) > Number(condition.value);
case 'lt':
return Number(value) < Number(condition.value);
case 'is_null':
return value === null || value === undefined;
case 'is_not_null':
return value !== null && value !== undefined;
default:
return false;
}
}
/**
* Resolve template variables in config (supports {{context.field}} syntax).
*/
private resolveTemplates(value: any, context: WorkflowStepContext): any {
if (typeof value === 'string') {
return this.resolveStringTemplate(value, context);
}
if (Array.isArray(value)) {
return value.map(v => this.resolveTemplates(v, context));
}
if (value && typeof value === 'object') {
const resolved: Record<string, any> = {};
for (const [k, v] of Object.entries(value)) {
resolved[k] = this.resolveTemplates(v, context);
}
return resolved;
}
return value;
}
private resolveStringTemplate(template: string, context: WorkflowStepContext): string {
return template.replace(/\{\{([^}]+)\}\}/g, (match, path: string) => {
const trimmedPath = path.trim();
// Handle special "settings.*" paths
if (trimmedPath.startsWith('settings.')) {
const settingKey = trimmedPath.substring('settings.'.length);
const value = (context._settings as any)[settingKey];
return value !== undefined && value !== null ? String(value) : '';
}
// Handle "context.*" paths
if (trimmedPath.startsWith('context.')) {
const contextKey = trimmedPath.substring('context.'.length);
const value = this.getNestedValue(context, contextKey);
return value !== undefined && value !== null ? String(value) : '';
}
// Direct context access
const value = this.getNestedValue(context, trimmedPath);
return value !== undefined && value !== null ? String(value) : '';
});
}
private getNestedValue(obj: any, path: string): any {
const parts = path.split('.');
let current = obj;
for (const part of parts) {
if (current == null) return undefined;
current = current[part];
}
return current;
}
/**
* Log step execution to database.
*/
private async logStepExecution(
executionId: number,
step: TicketWorkflowStep,
status: 'pending' | 'running' | 'completed' | 'failed' | 'skipped',
inputData: any,
outputData: any,
durationMs: number,
errorMessage?: string | null
): Promise<void> {
if (status === 'running') {
await postgresClient.query(
`INSERT INTO ticket_workflow_execution_steps
(execution_id, step_order, step_type, step_name, status, started_at, input_data)
VALUES ($1, $2, $3, $4, $5, NOW(), $6)`,
[executionId, step.step_order, step.step_type, step.name, status, JSON.stringify(inputData)]
);
} else {
await postgresClient.query(
`UPDATE ticket_workflow_execution_steps
SET status = $1, output_data = $2, completed_at = NOW(), duration_ms = $3, error_message = $4
WHERE execution_id = $5 AND step_order = $6`,
[status, JSON.stringify(outputData), durationMs, errorMessage, executionId, step.step_order]
);
}
}
}
// Export singleton instance
export const ticketWorkflowEngine = new TicketWorkflowEngine();

View file

@ -0,0 +1,481 @@
/**
* Veeam RPO Service
* Outcome-based backup alerting: one deduped Autotask ticket per job
* that has missed its RPO, auto-resolved when backup succeeds.
*/
import postgresClient from './postgres-client';
import { AutotaskClient } from './autotask-client';
const AT_QUEUE_ID = 29832283; // Operations Triage
const AT_ISSUE_TYPE = 38; // Backups
const AT_SUB_ISSUE = 637; // Backup: Veeam Agent for Microsoft Windows
const AT_PRIORITY_MED = 3; // Medium
const AT_PRIORITY_HIGH = 2; // High
const AT_PRIORITY_CRIT = 1; // Critical
const AT_STATUS_NEW = 1;
const AT_STATUS_DONE = 5;
export interface RpoCheckResult {
checked: number;
newTickets: number;
escalated: number;
resolved: number;
skipped: number;
errors: string[];
runAt: Date;
}
export interface RpoJobSummary {
job_instance_uid: string;
job_name: string;
org_name: string;
status: string;
schedule_type: string;
last_end_time: string | null;
hours_since_backup: number | null;
rpo_hours: number;
is_breached: boolean;
failure_category: string | null;
failure_message: string | null;
open_ticket: {
at_ticket_id: number;
at_ticket_number: string;
priority_level: string;
hours_overdue: number;
opened_at: string;
} | null;
}
function getRpoThresholds(scheduleType: string): { grace: number; high: number; critical: number } {
// grace = hours after next_run before we alert (buffer for slow jobs)
// high/critical = hours after next_run for escalation
const s = (scheduleType ?? '').toLowerCase();
if (s.includes('weekly')) {
return { grace: 4, high: 48, critical: 7 * 24 };
}
if (s.includes('continuous') || s.includes('real')) {
return { grace: 1, high: 4, critical: 12 };
}
// Daily (default) — alert 4h after missed window, escalate at 48h / 7 days
return { grace: 4, high: 48, critical: 7 * 24 };
}
function categorizeFailure(failureMessage: string | null): string {
if (!failureMessage) return 'No recent successful backup';
const msg = failureMessage.toLowerCase();
if (msg.includes('license') && (msg.includes('expired') || msg.includes('grace period'))) {
return 'License Expired — renew via VSPC';
}
if (msg.includes('vcg01') || msg.includes('cloud gateway') || msg.includes('cloud connect')) {
return 'Cloud Gateway Unreachable — check vcg01.wulfconsulting.com';
}
if (msg.includes('repository') && (msg.includes('inaccessible') || msg.includes('not accessible'))) {
return 'Backup Repository Inaccessible';
}
if (msg.includes('maintenance')) {
return 'Service Provider Maintenance';
}
if (msg.includes('ssl') || msg.includes('resolve host') || msg.includes('connection')) {
return 'Network/Connectivity Error';
}
if (msg.includes('timeout')) {
return 'Backup Job Timeout';
}
return failureMessage.trim().substring(0, 200);
}
function buildTicketTitle(jobName: string, orgName: string, hoursOverdue: number): string {
const h = Math.round(hoursOverdue);
const display = h >= 48 ? `${Math.round(h / 24)}d` : `${h}h`;
return `[Veeam RPO] ${jobName} @ ${orgName}${display} since last backup`;
}
function buildTicketDescription(
jobName: string,
orgName: string,
scheduleType: string,
lastEndTime: string | null,
hoursOverdue: number,
failureCategory: string,
failureMessage: string | null,
restorePoints: number | null,
): string {
const lastBackup = lastEndTime
? new Date(lastEndTime).toLocaleString('en-US', { timeZone: 'America/New_York' }) + ' ET'
: 'Never';
const lines = [
`Job: ${jobName}`,
`Organization: ${orgName}`,
`Schedule: ${scheduleType ?? 'Unknown'}`,
`Last Successful Backup: ${lastBackup}`,
`Hours Since Backup: ${Math.round(hoursOverdue)}h`,
`Restore Points Available: ${restorePoints ?? 'Unknown'}`,
``,
`Failure Reason: ${failureCategory}`,
];
if (failureMessage && failureCategory !== failureMessage.trim().substring(0, 200)) {
lines.push(``, `Raw Error: ${failureMessage.trim().substring(0, 500)}`);
}
lines.push(``, `Generated by Pulse RPO Monitor — ${new Date().toISOString()}`);
return lines.join('\n');
}
function getAutotaskClient(): AutotaskClient {
return new AutotaskClient({
apiUrl: process.env.AUTOTASK_API_URL || '',
username: process.env.AUTOTASK_USERNAME || '',
password: process.env.AUTOTASK_SECRET || '',
apiIntegrationCode: process.env.AUTOTASK_API_INTEGRATION_CODE || '',
});
}
export class VeeamRpoService {
async runCheck(): Promise<RpoCheckResult> {
const result: RpoCheckResult = {
checked: 0,
newTickets: 0,
escalated: 0,
resolved: 0,
skipped: 0,
errors: [],
runAt: new Date(),
};
const client = getAutotaskClient();
// Fetch all enabled workstation jobs with org info
const jobsRes = await postgresClient.query(`
SELECT
j.instance_uid,
j.name as job_name,
j.status,
j.schedule_type,
j.last_end_time,
j.next_run,
j.restore_points,
j.failure_message,
j.is_enabled,
j.operation_mode,
o.name as org_name,
EXTRACT(EPOCH FROM (NOW() - j.last_end_time)) / 3600.0 as hours_since_backup
FROM veeam_backup_agent_jobs j
JOIN veeam_organizations o ON o.instance_uid = j.organization_uid
WHERE j.operation_mode = 'Workstation'
AND j.is_enabled = true
ORDER BY hours_since_backup DESC NULLS LAST
`);
const jobs = jobsRes.rows;
result.checked = jobs.length;
// Fetch all currently open RPO tickets in one query
const openTicketsRes = await postgresClient.query(`
SELECT * FROM veeam_rpo_tickets WHERE resolved_at IS NULL
`);
const openByJobUid: Record<string, any> = {};
for (const row of openTicketsRes.rows) {
openByJobUid[row.job_instance_uid] = row;
}
for (const job of jobs) {
try {
await this.processJob(job, openByJobUid, client, result);
} catch (err: any) {
result.errors.push(`${job.job_name}: ${err.message}`);
}
}
// Update last_checked_at for all processed jobs
await postgresClient.query(`
UPDATE veeam_rpo_tickets SET last_checked_at = NOW() WHERE resolved_at IS NULL
`);
return result;
}
private async processJob(
job: any,
openByJobUid: Record<string, any>,
client: AutotaskClient,
result: RpoCheckResult,
): Promise<void> {
const thresholds = getRpoThresholds(job.schedule_type);
const openTicket = openByJobUid[job.instance_uid] ?? null;
// Skip jobs that are currently running — they haven't failed yet
if (job.status === 'Running') {
result.skipped++;
return;
}
const hoursAgo: number | null = job.hours_since_backup !== null ? parseFloat(job.hours_since_backup) : null;
const intervalHours = (job.schedule_type ?? '').toLowerCase().includes('weekly') ? 168
: (job.schedule_type ?? '').toLowerCase().includes('continuous') ? 1
: 24;
// Breach rules:
// - Failed/Warning: always breached — a failed backup is a failed backup regardless of recency
// - None (never run): always breached
// - Success: only breach if last success is older than (interval + grace) — daily = 28h
// Use 3x for the >30d cap logic only; the alert threshold is interval+grace
const rpoWindowHours = intervalHours + thresholds.grace;
const isBreached = job.status === 'Failed' || job.status === 'Warning'
|| hoursAgo === null
|| hoursAgo > rpoWindowHours;
if (!isBreached) {
if (openTicket) {
await this.resolveTicket(openTicket, client);
result.resolved++;
} else {
result.skipped++;
}
return;
}
// hoursOverdue = hours past the RPO window
const hoursOverdue = hoursAgo !== null ? hoursAgo - rpoWindowHours : thresholds.grace;
const failureCategory = categorizeFailure(job.failure_message);
const targetPriority = hoursOverdue >= thresholds.critical ? 'critical'
: hoursOverdue >= thresholds.high ? 'high'
: 'medium';
// Don't create new tickets for jobs broken longer than 30 days on first encounter.
// These are likely abandoned machines — show as breached in UI but don't flood AT.
const MAX_NEW_TICKET_AGE_HOURS = 720; // 30 days
const tooOldForNewTicket = !openTicket && (hoursAgo ?? 0) > MAX_NEW_TICKET_AGE_HOURS;
if (!openTicket) {
if (tooOldForNewTicket) {
result.skipped++;
return;
}
// Create new ticket
await this.createTicket(job, hoursOverdue, failureCategory, targetPriority, client, result);
} else {
// Escalate if needed
if (openTicket.priority_level !== targetPriority && this.isPriorityHigher(targetPriority, openTicket.priority_level)) {
await this.escalateTicket(openTicket, job, hoursOverdue, failureCategory, targetPriority, client, result);
} else {
result.skipped++;
}
}
}
private isPriorityHigher(a: string, b: string): boolean {
const rank: Record<string, number> = { medium: 1, high: 2, critical: 3 };
return (rank[a] ?? 0) > (rank[b] ?? 0);
}
private async createTicket(
job: any,
hoursOverdue: number,
failureCategory: string,
priorityLevel: string,
client: AutotaskClient,
result: RpoCheckResult,
): Promise<void> {
const atPriority = priorityLevel === 'critical' ? AT_PRIORITY_CRIT
: priorityLevel === 'high' ? AT_PRIORITY_HIGH
: AT_PRIORITY_MED;
const title = buildTicketTitle(job.job_name, job.org_name, hoursOverdue);
const description = buildTicketDescription(
job.job_name, job.org_name, job.schedule_type,
job.last_end_time, hoursOverdue, failureCategory,
job.failure_message, job.restore_points,
);
// Look up Autotask company ID from org name mapping
const companyRes = await postgresClient.query(`
SELECT c.id FROM companies c
JOIN veeam_organizations vo ON vo.autotask_company_id = c.id
WHERE vo.name = $1
LIMIT 1
`, [job.org_name]);
const companyId = companyRes.rows[0]?.id ?? null;
const ticketPayload: Record<string, any> = {
title,
description,
status: AT_STATUS_NEW,
queueID: AT_QUEUE_ID,
issueType: AT_ISSUE_TYPE,
subIssueType: AT_SUB_ISSUE,
priority: atPriority,
};
if (companyId) ticketPayload.companyID = companyId;
const ticket = await client.createTicket(ticketPayload);
await postgresClient.query(`
INSERT INTO veeam_rpo_tickets
(job_instance_uid, job_name, org_name, at_ticket_id, at_ticket_number,
priority_level, hours_overdue, failure_category, opened_at, last_checked_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW())
ON CONFLICT (job_instance_uid) DO UPDATE SET
at_ticket_id = EXCLUDED.at_ticket_id,
at_ticket_number = EXCLUDED.at_ticket_number,
priority_level = EXCLUDED.priority_level,
hours_overdue = EXCLUDED.hours_overdue,
failure_category = EXCLUDED.failure_category,
resolved_at = NULL,
opened_at = NOW(),
last_checked_at = NOW(),
updated_at = NOW()
`, [
job.instance_uid,
job.job_name,
job.org_name,
ticket.id,
(ticket as any).ticketNumber ?? null,
priorityLevel,
Math.round(hoursOverdue),
failureCategory,
]);
result.newTickets++;
console.log(`[RPO] Created ticket ${(ticket as any).ticketNumber} for ${job.job_name} (${Math.round(hoursOverdue)}h overdue)`);
}
private async escalateTicket(
openTicket: any,
job: any,
hoursOverdue: number,
failureCategory: string,
targetPriority: string,
client: AutotaskClient,
result: RpoCheckResult,
): Promise<void> {
const atPriority = targetPriority === 'critical' ? AT_PRIORITY_CRIT
: targetPriority === 'high' ? AT_PRIORITY_HIGH
: AT_PRIORITY_MED;
const note = `RPO Escalation: ${Math.round(hoursOverdue)}h since last successful backup (escalated to ${targetPriority}).\nFailure reason: ${failureCategory}`;
await client.updateTicket(openTicket.at_ticket_id, { priority: atPriority });
// Add a note to the ticket
try {
await (client as any).createEntity('TicketNotes', {
ticketID: openTicket.at_ticket_id,
title: `RPO Escalation — ${targetPriority.toUpperCase()}`,
description: note,
noteType: 1,
publish: 1,
});
} catch {
// Note creation failure is non-fatal
}
await postgresClient.query(`
UPDATE veeam_rpo_tickets SET
priority_level = $1,
hours_overdue = $2,
failure_category = $3,
last_checked_at = NOW(),
updated_at = NOW()
WHERE id = $4
`, [targetPriority, Math.round(hoursOverdue), failureCategory, openTicket.id]);
result.escalated++;
console.log(`[RPO] Escalated ticket ${openTicket.at_ticket_number} to ${targetPriority} (${Math.round(hoursOverdue)}h overdue)`);
}
private async resolveTicket(openTicket: any, client: AutotaskClient): Promise<void> {
await client.updateTicket(openTicket.at_ticket_id, { status: AT_STATUS_DONE });
await postgresClient.query(`
UPDATE veeam_rpo_tickets SET
resolved_at = NOW(),
last_checked_at = NOW(),
updated_at = NOW()
WHERE id = $1
`, [openTicket.id]);
console.log(`[RPO] Resolved ticket ${openTicket.at_ticket_number} — backup succeeded`);
}
async getStatus(): Promise<{ summary: Record<string, number>; jobs: RpoJobSummary[] }> {
const jobsRes = await postgresClient.query(`
SELECT
j.instance_uid,
j.name as job_name,
j.status,
j.schedule_type,
j.last_end_time,
j.next_run,
j.restore_points,
j.failure_message,
o.name as org_name,
EXTRACT(EPOCH FROM (NOW() - j.last_end_time)) / 3600.0 as hours_since_backup,
EXTRACT(EPOCH FROM (j.next_run - NOW())) / 3600.0 as hours_until_next_run
FROM veeam_backup_agent_jobs j
JOIN veeam_organizations o ON o.instance_uid = j.organization_uid
LEFT JOIN veeam_rpo_tickets rt
ON rt.job_instance_uid = j.instance_uid AND rt.resolved_at IS NULL
WHERE j.operation_mode = 'Workstation'
AND j.is_enabled = true
ORDER BY hours_since_backup DESC NULLS LAST
`);
const jobs: RpoJobSummary[] = jobsRes.rows.map((row) => {
const thresholds = getRpoThresholds(row.schedule_type);
const hoursAgo: number | null = row.hours_since_backup !== null ? parseFloat(row.hours_since_backup) : null;
const intervalHours = (row.schedule_type ?? '').toLowerCase().includes('weekly') ? 168
: (row.schedule_type ?? '').toLowerCase().includes('continuous') ? 1
: 24;
const rpoWindowHours = intervalHours + thresholds.grace;
const isBreached = row.status !== 'Running'
&& (row.status === 'Failed' || row.status === 'Warning'
|| hoursAgo === null
|| hoursAgo > rpoWindowHours);
return {
job_instance_uid: row.instance_uid,
job_name: row.job_name,
org_name: row.org_name,
status: row.status,
schedule_type: row.schedule_type,
last_end_time: row.last_end_time,
hours_since_backup: hoursAgo !== null ? Math.round(hoursAgo * 10) / 10 : null,
rpo_hours: thresholds.grace,
is_breached: isBreached,
failure_category: row.failure_message ? categorizeFailure(row.failure_message) : null,
failure_message: row.failure_message,
open_ticket: row.at_ticket_id ? {
at_ticket_id: (row as any).at_ticket_id,
at_ticket_number: (row as any).at_ticket_number,
priority_level: (row as any).priority_level,
hours_overdue: (row as any).hours_overdue,
opened_at: (row as any).opened_at,
} : null,
};
});
const breached = jobs.filter(j => j.is_breached).length;
const withTicket = jobs.filter(j => j.open_ticket !== null).length;
const healthy = jobs.filter(j => !j.is_breached).length;
const critical = jobs.filter(j => j.open_ticket?.priority_level === 'critical').length;
const high = jobs.filter(j => j.open_ticket?.priority_level === 'high').length;
return {
summary: {
total: jobs.length,
healthy,
breached,
withOpenTicket: withTicket,
critical,
high,
},
jobs,
};
}
}
let _instance: VeeamRpoService | null = null;
export function getVeeamRpoService(): VeeamRpoService {
if (!_instance) _instance = new VeeamRpoService();
return _instance;
}

View file

@ -11,6 +11,8 @@ import { mapAutotaskToDatabase } from '../utils/entity-mapper';
import { getTableName, getAutotaskEntityName } from '../utils/sync-helpers';
import { AutotaskClient } from './autotask-client';
import { workflowEngine } from './workflow-engine';
import { ticketWorkflowEngine } from './ticket-workflow-engine';
import '../services/workflow-steps'; // Register all workflow step executors
import { WorkflowEvent, TicketData } from '../types/workflow';
export class WebhookService {
@ -424,7 +426,14 @@ export class WebhookService {
}
console.log(`[WEBHOOK] Triggering workflow engine for ticket ${payload.entityId}`);
await workflowEngine.process(event);
// Fire-and-forget: trigger new ticket workflow engine
if (event.ticket_data) {
ticketWorkflowEngine.processTrigger(event.trigger_event, event.ticket_data).catch(err => {
console.error('[WEBHOOK] Ticket workflow engine error:', err);
});
}
// DEPRECATED: old workflow engine (will be removed after testing period)
// await workflowEngine.process(event);
}
}

View file

@ -0,0 +1,99 @@
/**
* AI Classify Step uses AI to classify ambiguous fields when robotic classification fails.
* Config: {
* template_purpose: 'ambiguous_classification',
* skip_if_valid?: boolean // skip if validation passed
* }
* Condition: typically checks context.validation.is_valid === false
*/
import { registerWorkflowStepExecutor } from '../ticket-workflow-engine';
import { aiTriageService } from '../ai-triage-service';
import { TicketWorkflowStep, WorkflowStepContext, WorkflowStepResult } from '../../types/ticket-workflow';
async function executeAiClassify(
step: TicketWorkflowStep,
context: WorkflowStepContext,
_executionId: number
): Promise<WorkflowStepResult> {
// Check if we should skip (validation passed)
if (step.config.skip_if_valid && context.validation?.is_valid) {
return {
success: true,
output: {
skipped: true,
reason: 'Validation passed, AI not needed'
}
};
}
// Get failed fields from validation
const validationFailedFields = context.validation?.errors?.map(e => e.field) || [];
if (validationFailedFields.length === 0) {
return {
success: true,
output: {
skipped: true,
reason: 'No failed fields to classify'
}
};
}
// Build ticket with current classification context
const ticket = {
...context.ticket,
...(context.field_changes || {})
};
// Call AI classification
const aiResult = await aiTriageService.classifyAmbiguous(
ticket,
validationFailedFields,
context._settings
);
// Merge AI results into context
if (aiResult.classification) {
if (!context.field_changes) {
context.field_changes = {};
}
// Update field changes with AI results
if (aiResult.classification.issue_type !== undefined) {
context.field_changes['issue_type'] = {
before: ticket.issue_type || null,
after: aiResult.classification.issue_type
};
}
if (aiResult.classification.sub_issue_type !== undefined) {
context.field_changes['sub_issue_type'] = {
before: ticket.sub_issue_type || null,
after: aiResult.classification.sub_issue_type
};
}
if (aiResult.classification.ticket_type !== undefined) {
context.field_changes['ticket_type'] = {
before: ticket.ticket_type || null,
after: aiResult.classification.ticket_type
};
}
if (aiResult.classification.priority !== undefined) {
context.field_changes['priority'] = {
before: ticket.priority || null,
after: aiResult.classification.priority
};
}
}
return {
success: true,
output: {
method: 'ai',
classification: aiResult.classification,
failed_fields_addressed: validationFailedFields
}
};
}
registerWorkflowStepExecutor('ai_classify', executeAiClassify);

View file

@ -0,0 +1,53 @@
/**
* AI Title Step uses AI to clean up messy ticket titles.
* Config: {
* template_purpose: 'title_cleanup'
* }
* Condition: typically checks if title needs cleanup (from classification.ai_reasons)
*/
import { registerWorkflowStepExecutor } from '../ticket-workflow-engine';
import { aiTriageService } from '../ai-triage-service';
import { TicketWorkflowStep, WorkflowStepContext, WorkflowStepResult } from '../../types/ticket-workflow';
async function executeAiTitle(
step: TicketWorkflowStep,
context: WorkflowStepContext,
_executionId: number
): Promise<WorkflowStepResult> {
const ticket = context.ticket;
// Call AI title cleanup
const cleanedTitle = await aiTriageService.cleanupTitle(ticket, context._settings);
if (!cleanedTitle || cleanedTitle === ticket.title) {
return {
success: true,
output: {
skipped: true,
reason: 'No title cleanup needed or AI returned same title'
}
};
}
// Update field changes
if (!context.field_changes) {
context.field_changes = {};
}
context.field_changes['title'] = {
before: ticket.title,
after: cleanedTitle
};
return {
success: true,
output: {
method: 'ai',
original_title: ticket.title,
cleaned_title: cleanedTitle
}
};
}
registerWorkflowStepExecutor('ai_title', executeAiTitle);

View file

@ -0,0 +1,52 @@
/**
* AI Troubleshooting Step generates troubleshooting steps and creates a ticket note.
* Config: {
* template_purpose: 'troubleshooting_steps',
* create_note: boolean // whether to create an Autotask ticket note
* }
* Condition: typically checks if ticket_type === 2 (Incident)
*/
import { registerWorkflowStepExecutor } from '../ticket-workflow-engine';
import { aiTriageService } from '../ai-triage-service';
import { AutotaskClient } from '../autotask-client';
import { TicketWorkflowStep, WorkflowStepContext, WorkflowStepResult } from '../../types/ticket-workflow';
async function executeAiTroubleshooting(
step: TicketWorkflowStep,
context: WorkflowStepContext,
_executionId: number
): Promise<WorkflowStepResult> {
const ticket = context.ticket;
// Generate troubleshooting steps
const troubleshootingSteps = await aiTriageService.generateTroubleshootingSteps(
ticket,
context._settings
);
if (!troubleshootingSteps) {
return {
success: true,
output: {
skipped: true,
reason: 'No troubleshooting steps generated'
}
};
}
// Note: Ticket note creation would go here
// For now, just return the troubleshooting steps in the output
// TODO: Implement createTicketNote in AutotaskClient if needed
return {
success: true,
output: {
method: 'ai',
troubleshooting_steps: troubleshootingSteps,
note_created: false
}
};
}
registerWorkflowStepExecutor('ai_troubleshooting', executeAiTroubleshooting);

View file

@ -0,0 +1,113 @@
/**
* Classify Step keyword-based classification using classification_rules table.
* Config: {
* rule_type: 'branch_routing' | 'ticket_type' | 'issue_classification' | 'priority' | 'queue_routing',
* result_field: 'branch' | 'ticket_type' | 'issue_type' | 'priority' | 'queue_id',
* result_field_2?: 'sub_issue_type', // for issue_classification only
* default_value?: any // fallback if no rules match
* }
*/
import { registerWorkflowStepExecutor } from '../ticket-workflow-engine';
import { roboticClassifier } from '../robotic-classifier';
import { TicketWorkflowStep, WorkflowStepContext, WorkflowStepResult } from '../../types/ticket-workflow';
async function executeClassify(
step: TicketWorkflowStep,
context: WorkflowStepContext,
_executionId: number
): Promise<WorkflowStepResult> {
const ruleType = step.config.rule_type;
const resultField = step.config.result_field;
const resultField2 = step.config.result_field_2;
const defaultValue = step.config.default_value;
if (!ruleType || !resultField) {
return {
success: false,
error: 'Missing required config: rule_type and result_field'
};
}
// Ensure rules are loaded
await roboticClassifier.loadRules();
// Build ticket context (may have values from previous steps)
const ticket = {
...context.ticket,
...(context.field_changes || {})
};
// Run classification for this rule type
const result = await (roboticClassifier as any).classifyByType(ruleType, ticket);
if (!result && defaultValue !== undefined) {
// No match, use default
const output: any = {
[resultField]: defaultValue,
matched_rule: null,
confidence: 'default',
method: 'default'
};
// Track field change
if (!context.field_changes) {
context.field_changes = {};
}
context.field_changes[resultField] = {
before: (ticket as any)[resultField] || null,
after: defaultValue
};
return { success: true, output };
}
if (!result) {
// No match and no default
return {
success: true,
output: {
[resultField]: null,
matched_rule: null,
confidence: 'none',
method: 'no_match'
}
};
}
// Matched a rule
const output: any = {
[resultField]: result.value,
matched_rule: result.matched_rule_name,
confidence: result.confidence,
method: 'robotic'
};
// Track field change for primary result
if (!context.field_changes) {
context.field_changes = {};
}
context.field_changes[resultField] = {
before: (ticket as any)[resultField] || null,
after: result.value
};
// Handle secondary result (e.g., sub_issue_type)
if (resultField2 && result.value_2 !== undefined) {
output[resultField2] = result.value_2;
context.field_changes[resultField2] = {
before: (ticket as any)[resultField2] || null,
after: result.value_2
};
}
// Store full classification result in context for later steps
if (!context.classification) {
context.classification = {};
}
context.classification[ruleType] = result;
return { success: true, output };
}
registerWorkflowStepExecutor('classify', executeClassify);

View file

@ -0,0 +1,31 @@
/**
* Delay Step wait N milliseconds before continuing.
* Config: {
* duration_ms: number | string // can be a number or a template like "{{settings.autotask_update_delay_ms}}"
* }
*/
import { registerWorkflowStepExecutor } from '../ticket-workflow-engine';
import { TicketWorkflowStep, WorkflowStepContext, WorkflowStepResult } from '../../types/ticket-workflow';
async function executeDelay(
step: TicketWorkflowStep,
_context: WorkflowStepContext,
_executionId: number
): Promise<WorkflowStepResult> {
const durationMs = Number(step.config.duration_ms) || 0;
if (durationMs > 0) {
console.log(`[WORKFLOW:delay] Waiting ${durationMs}ms`);
await new Promise(resolve => setTimeout(resolve, durationMs));
}
return {
success: true,
output: {
delayed_ms: durationMs
}
};
}
registerWorkflowStepExecutor('delay', executeDelay);

View file

@ -0,0 +1,12 @@
/**
* Workflow Step Executors import all to register them with the engine.
* This file must be imported once when the ticket workflow engine is used.
*/
import './classify';
import './validate';
import './ai-classify';
import './ai-title';
import './ai-troubleshooting';
import './delay';
import './update-ticket';

View file

@ -0,0 +1,83 @@
/**
* Update Ticket Step writes field_changes back to Autotask.
* Config: {
* use_field_changes: boolean // use context.field_changes
* }
*/
import { registerWorkflowStepExecutor } from '../ticket-workflow-engine';
import { AutotaskClient } from '../autotask-client';
import { postgresClient } from '../postgres-client';
import { TicketWorkflowStep, WorkflowStepContext, WorkflowStepResult } from '../../types/ticket-workflow';
async function executeUpdateTicket(
step: TicketWorkflowStep,
context: WorkflowStepContext,
_executionId: number
): Promise<WorkflowStepResult> {
const fieldChanges = context.field_changes || {};
const changeKeys = Object.keys(fieldChanges);
if (changeKeys.length === 0) {
return {
success: true,
output: {
skipped: true,
reason: 'No field changes to apply'
}
};
}
// Build Autotask update payload
const updatePayload: any = {};
for (const [field, change] of Object.entries(fieldChanges)) {
updatePayload[field] = change.after;
}
// Update in Autotask
try {
const autotaskClient = new AutotaskClient({
apiUrl: process.env.AUTOTASK_API_URL || '',
username: process.env.AUTOTASK_USERNAME || '',
password: process.env.AUTOTASK_SECRET || '',
apiIntegrationCode: process.env.AUTOTASK_API_INTEGRATION_CODE || '',
});
await autotaskClient.updateTicket(context.ticket.id, updatePayload);
// Update local DB copy
const setClause = Object.keys(fieldChanges)
.map((field, idx) => `${field} = $${idx + 2}`)
.join(', ');
const values = [
context.ticket.id,
...Object.values(fieldChanges).map(c => c.after)
];
if (setClause) {
await postgresClient.query(
`UPDATE tickets SET ${setClause}, updated_at = NOW() WHERE id = $1`,
values
);
}
return {
success: true,
output: {
updated_fields: changeKeys,
field_changes: fieldChanges,
autotask_updated: true,
local_db_updated: true
}
};
} catch (error) {
console.error('[UPDATE-TICKET] Failed to update Autotask:', error);
return {
success: false,
error: error instanceof Error ? error.message : String(error)
};
}
}
registerWorkflowStepExecutor('update_ticket', executeUpdateTicket);

View file

@ -0,0 +1,50 @@
/**
* Validate Step validates classification results against DB picklists.
* Config: {
* required_fields?: string[] // optional list of fields that must be present
* }
*/
import { registerWorkflowStepExecutor } from '../ticket-workflow-engine';
import { triageValidator } from '../triage-validator';
import { TicketWorkflowStep, WorkflowStepContext, WorkflowStepResult } from '../../types/ticket-workflow';
import { ClassificationResult } from '../../types/workflow';
async function executeValidate(
step: TicketWorkflowStep,
context: WorkflowStepContext,
_executionId: number
): Promise<WorkflowStepResult> {
// Build ClassificationResult from context.classification
const classification: ClassificationResult = {
branch: context.classification?.branch_routing || null,
ticket_type: context.classification?.ticket_type || null,
issue_classification: context.classification?.issue_classification || null,
priority: context.classification?.priority || null,
queue: context.classification?.queue_routing || null,
overall_confidence: 'medium',
needs_ai: false,
ai_reasons: []
};
// Run validation
const validationResult = await triageValidator.validate(classification);
// Store validation result in context for later steps
context.validation = validationResult;
// Check for failed fields
const validationFailedFields = validationResult.errors.map(e => e.field);
return {
success: true,
output: {
is_valid: validationResult.is_valid,
validation_errors: validationResult.errors,
validation_failed_fields: validationFailedFields,
total_errors: validationResult.errors.length
}
};
}
registerWorkflowStepExecutor('validate', executeValidate);

View file

@ -0,0 +1,149 @@
import {
ZabbixConfig,
ZabbixHost,
ZabbixHostGroup,
ZabbixTemplate,
ZabbixHostCreateParams,
ZabbixHostUpdateParams,
ZabbixRpcResponse,
} from '@/lib/types/zabbix';
export type { ZabbixHostTag } from '@/lib/types/zabbix';
export class ZabbixClient {
private config: ZabbixConfig;
private rpcId = 0;
constructor(config: ZabbixConfig) {
this.config = config;
}
/**
* Make a JSON-RPC 2.0 call to the Zabbix API.
* Auth is stateless: Bearer token in Authorization header (Zabbix 6.0+ API token).
*/
private async rpc<T>(method: string, params: Record<string, any>): Promise<T> {
const id = ++this.rpcId;
const url = `${this.config.apiUrl.replace(/\/$/, '')}/api_jsonrpc.php`;
const body = JSON.stringify({
jsonrpc: '2.0',
method,
params,
id,
});
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.config.apiToken}`,
},
body,
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Zabbix HTTP ${response.status}: ${text.substring(0, 200)}`);
}
const data: ZabbixRpcResponse<T> = await response.json();
if (data.error) {
throw new Error(
`Zabbix RPC error (${data.error.code}): ${data.error.message}` +
(data.error.data ? `${data.error.data}` : '')
);
}
return data.result as T;
}
/**
* Get or create a host group by name. Returns the groupid.
*/
async ensureHostGroup(name: string): Promise<string> {
const existing = await this.rpc<ZabbixHostGroup[]>('hostgroup.get', {
output: ['groupid', 'name'],
filter: { name: [name] },
});
if (existing.length > 0) {
return existing[0].groupid;
}
const created = await this.rpc<{ groupids: string[] }>('hostgroup.create', {
name,
});
return created.groupids[0];
}
/**
* Find a template by exact or partial host name. Returns the first match or null.
*/
async findTemplate(name: string): Promise<ZabbixTemplate | null> {
const results = await this.rpc<ZabbixTemplate[]>('template.get', {
output: ['templateid', 'host', 'name'],
search: { host: name },
searchByAny: false,
});
return results.length > 0 ? results[0] : null;
}
/**
* Find a host by its technical name (host field), with a fallback search by
* display name. The fallback handles legacy hosts created before hostname
* sanitization was introduced (their host == name == full display string).
*/
async findHostByName(hostname: string, displayName?: string): Promise<ZabbixHost | null> {
const results = await this.rpc<ZabbixHost[]>('host.get', {
output: ['hostid', 'host', 'name', 'status'],
filter: { host: [hostname] },
});
if (results.length > 0) return results[0];
// Fallback: search by display name (catches pre-sanitization legacy hosts)
if (displayName && displayName !== hostname) {
const byName = await this.rpc<ZabbixHost[]>('host.get', {
output: ['hostid', 'host', 'name', 'status'],
filter: { name: [displayName] },
});
if (byName.length > 0) return byName[0];
}
return null;
}
/**
* Create or update a Zabbix host. Idempotent looks up by host name first.
* Returns the hostid and whether the host was created or updated.
*/
async upsertHost(params: ZabbixHostCreateParams): Promise<{
action: 'created' | 'updated';
hostid: string;
}> {
const existing = await this.findHostByName(params.host, params.name);
if (!existing) {
const result = await this.rpc<{ hostids: string[] }>('host.create', params);
return { action: 'created', hostid: result.hostids[0] };
}
const updateParams: ZabbixHostUpdateParams = {
hostid: existing.hostid,
name: params.name,
description: params.description,
// Do not pass interfaces on update — Zabbix rejects changes when items
// are already linked to the existing interface.
groups: params.groups,
templates: params.templates,
macros: params.macros,
tags: params.tags,
};
await this.rpc<{ hostids: string[] }>('host.update', updateParams);
return { action: 'updated', hostid: existing.hostid };
}
}

171
lib/types/pipeline.ts Normal file
View file

@ -0,0 +1,171 @@
/**
* Pipeline Engine Types
*/
export type TriggerSource = 'datto_rmm' | 'autotask' | 'veeam' | 'manual';
export type StepType =
| 'filter'
| 'transform'
| 'set_variable'
| 'delay'
| 'enrich_device'
| 'enrich_company'
| 'enrich_ticket'
| 'create_ticket'
| 'update_ticket'
| 'create_note'
| 'ai_analyze'
| 'notify'
| 'approval'
| 'rmm_quick_job'
| 'rmm_get_job_results';
export type ChannelType = 'teams' | 'telegram' | 'ntfy' | 'webhook';
export type PipelineStatus = 'pending' | 'running' | 'waiting' | 'completed' | 'failed' | 'skipped';
export type StepStatus = 'pending' | 'running' | 'completed' | 'failed' | 'waiting' | 'skipped';
export type ApprovalStatus = 'pending' | 'approved' | 'rejected' | 'timeout';
export type OnFailure = 'continue' | 'stop' | 'skip_to';
export interface TriggerCondition {
field: string;
operator: 'equals' | 'not_equals' | 'contains' | 'not_contains' | 'in' | 'not_in' | 'regex' | 'exists' | 'not_exists';
value: any;
}
// ============================================================================
// DB Row Types
// ============================================================================
export interface NotificationChannel {
id: number;
name: string;
channel_type: ChannelType;
config: Record<string, any>;
is_active: boolean;
created_at: Date;
updated_at: Date;
}
export interface WebhookPipeline {
id: number;
name: string;
description: string | null;
is_active: boolean;
trigger_source: TriggerSource;
trigger_conditions: TriggerCondition[];
sort_order: number;
created_at: Date;
updated_at: Date;
}
export interface PipelineStep {
id: number;
pipeline_id: number;
step_order: number;
step_type: StepType;
name: string;
config: Record<string, any>;
on_failure: OnFailure;
skip_to_step: number | null;
is_active: boolean;
timeout_ms: number | null;
created_at: Date;
updated_at: Date;
}
export interface PipelineExecution {
id: number;
pipeline_id: number;
trigger_source: string;
trigger_payload: Record<string, any> | null;
status: PipelineStatus;
current_step: number | null;
context: Record<string, any>;
started_at: Date;
completed_at: Date | null;
duration_ms: number | null;
error_message: string | null;
created_at: Date;
}
export interface PipelineExecutionStep {
id: number;
execution_id: number;
step_order: number;
step_type: string;
step_name: string | null;
status: StepStatus;
input_data: Record<string, any> | null;
output_data: Record<string, any> | null;
started_at: Date | null;
completed_at: Date | null;
duration_ms: number | null;
error_message: string | null;
}
export interface ApprovalRequest {
id: number;
execution_id: number;
step_order: number;
channel_id: number | null;
message: string;
options: string[];
status: ApprovalStatus;
responded_by: string | null;
responded_at: Date | null;
response_data: Record<string, any> | null;
expires_at: Date | null;
created_at: Date;
}
// ============================================================================
// Runtime Types
// ============================================================================
export interface PipelineContext {
[key: string]: any;
}
export interface StepExecutorResult {
success: boolean;
output?: Record<string, any>;
error?: string;
waiting?: boolean; // true if step is paused (e.g., approval)
}
export interface PipelineWithSteps extends WebhookPipeline {
steps: PipelineStep[];
}
// ============================================================================
// API Types
// ============================================================================
export interface PipelineInput {
name: string;
description?: string;
is_active?: boolean;
trigger_source: TriggerSource;
trigger_conditions?: TriggerCondition[];
sort_order?: number;
}
export interface PipelineStepInput {
step_order: number;
step_type: StepType;
name: string;
config?: Record<string, any>;
on_failure?: OnFailure;
skip_to_step?: number;
is_active?: boolean;
timeout_ms?: number;
}
export interface NotificationChannelInput {
name: string;
channel_type: ChannelType;
config: Record<string, any>;
is_active?: boolean;
}

View file

@ -0,0 +1,128 @@
/**
* Ticket Workflow Engine Types
* TypeScript definitions for the refactored table-driven workflow engine
*/
import { TicketData, WorkflowSettings } from './workflow';
// ============================================================================
// Database Row Types
// ============================================================================
export interface TicketWorkflow {
id: number;
name: string;
description: string | null;
is_active: boolean;
trigger_event: string;
trigger_conditions: TriggerCondition[];
sort_order: number;
created_at: Date;
updated_at: Date;
}
export interface TicketWorkflowStep {
id: number;
workflow_id: number;
step_order: number;
step_type: string;
name: string;
config: Record<string, any>;
on_failure: 'continue' | 'stop' | 'skip_to';
skip_to_step: number | null;
is_active: boolean;
condition: StepCondition | null;
created_at: Date;
updated_at: Date;
}
export interface TicketWorkflowExecution {
id: number;
workflow_id: number;
ticket_id: number;
ticket_number: string | null;
status: 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
classification_method: 'robotic' | 'ai' | 'hybrid' | null;
branch: 'service_desk' | 'noc' | 'soc' | null;
context: Record<string, any>;
field_changes: Record<string, { before: any; after: any }> | null;
started_at: Date;
completed_at: Date | null;
duration_ms: number | null;
error_message: string | null;
created_at: Date;
}
export interface TicketWorkflowExecutionStep {
id: number;
execution_id: number;
step_order: number;
step_type: string;
step_name: string | null;
status: 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
input_data: Record<string, any> | null;
output_data: Record<string, any> | null;
started_at: Date | null;
completed_at: Date | null;
duration_ms: number | null;
error_message: string | null;
}
// ============================================================================
// Workflow Execution Types
// ============================================================================
export interface TriggerCondition {
field: string;
operator: 'equals' | 'not_equals' | 'in' | 'not_in' | 'contains' | 'not_contains' | 'gt' | 'lt';
value: any;
}
export interface StepCondition {
field: string;
operator: 'equals' | 'not_equals' | 'in' | 'not_in' | 'contains' | 'not_contains' | 'gt' | 'lt' | 'is_null' | 'is_not_null';
value: any;
}
export interface WorkflowStepContext {
// Original ticket data
ticket: TicketData;
// Workflow settings (from workflow_settings table)
_settings: WorkflowSettings;
// Accumulated classification results from classify steps
classification?: Record<string, any>;
// Validation result from validate step
validation?: {
is_valid: boolean;
errors: Array<{ field: string; message: string; value: any }>;
};
// Field changes accumulated from all steps (will be written to Autotask)
field_changes?: Record<string, { before: any; after: any }>;
// Any other data accumulated by steps
[key: string]: any;
}
export interface WorkflowStepResult {
success: boolean;
output?: Record<string, any>;
error?: string;
}
export interface TicketWorkflowWithSteps extends TicketWorkflow {
steps: TicketWorkflowStep[];
}
// ============================================================================
// Step Executor Function Type
// ============================================================================
export type WorkflowStepExecutorFn = (
step: TicketWorkflowStep,
context: WorkflowStepContext,
executionId: number
) => Promise<WorkflowStepResult>;

View file

@ -109,8 +109,10 @@ export interface AutotaskWebhookPayload {
*/
const ENTITY_TYPE_MAP: Record<string, WebhookEntityType> = {
'Company': WebhookEntityType.COMPANIES,
'Account': WebhookEntityType.COMPANIES, // Autotask legacy name
'Contact': WebhookEntityType.CONTACTS,
'ConfigurationItem': WebhookEntityType.CONFIGURATION_ITEMS,
'InstalledProduct': WebhookEntityType.CONFIGURATION_ITEMS, // Autotask actual payload name
'Ticket': WebhookEntityType.TICKETS,
'TicketNote': WebhookEntityType.TICKET_NOTES,
};

81
lib/types/zabbix.ts Normal file
View file

@ -0,0 +1,81 @@
// Zabbix API Types
export interface ZabbixConfig {
apiUrl: string;
apiToken: string;
}
export interface ZabbixHost {
hostid: string;
host: string;
name: string;
status: string;
interfaces?: ZabbixHostInterface[];
groups?: ZabbixHostGroup[];
templates?: ZabbixTemplate[];
description?: string;
}
export interface ZabbixHostInterface {
type: number; // 1 = agent, 2 = SNMP, 3 = IPMI, 4 = JMX
main: number; // 1 = default
useip: number; // 1 = use IP, 0 = use DNS
ip: string;
dns: string;
port: string;
}
export interface ZabbixHostGroup {
groupid: string;
name: string;
}
export interface ZabbixTemplate {
templateid: string;
host: string;
name?: string;
}
export interface ZabbixHostMacro {
macro: string; // e.g. "{$AUTOTASK_COMPANY_ID}"
value: string;
description?: string;
}
export interface ZabbixHostTag {
tag: string; // e.g. "client"
value: string; // e.g. "TK Plastics"
}
export interface ZabbixHostCreateParams {
host: string;
name?: string;
description?: string;
interfaces: ZabbixHostInterface[];
groups: Array<{ groupid: string }>;
templates?: Array<{ templateid: string }>;
macros?: ZabbixHostMacro[];
tags?: ZabbixHostTag[];
}
export interface ZabbixHostUpdateParams {
hostid: string;
name?: string;
description?: string;
interfaces?: ZabbixHostInterface[];
groups?: Array<{ groupid: string }>;
templates?: Array<{ templateid: string }>;
macros?: ZabbixHostMacro[];
tags?: ZabbixHostTag[];
}
export interface ZabbixRpcResponse<T> {
jsonrpc: string;
result?: T;
error?: {
code: number;
message: string;
data?: string;
};
id: number;
}