feat: QuickBooks Online integration

- Add QBO OAuth2 client with token refresh (lib/services/qbo-client.ts)
- Add QBO sync service for invoices, payments, deposits, purchases, journal entries, reports (lib/services/qbo-sync-service.ts)
- Add QBO types (lib/types/qbo.ts)
- Add API routes: /api/qbo/auth, /api/qbo/sync, /api/qbo/disconnect
- Add /admin/qbo status and sync management page
- Add legal pages: /legal/eula, /legal/privacy (Intuit app assessment)
- Add QBO nav link under Admin
- Fix reports: remove invalid summarize_column_by, add accounting_method from Preferences API, add showrows=all&showcols=all
- Add CashFlow report type alongside P&L and BalanceSheet
- Add NoReportData check to skip empty report months
- Add intuit_tid capture in error messages
- Add redirect: follow for cluster routing
- Migration 051: qbo_tokens, qbo_invoices, qbo_payments, qbo_deposits, qbo_transactions, qbo_reports tables

Also includes earlier work:
- Ping flap suppression pipeline step
- Ticket digest reports with LLM analysis
- Zabbix WAN monitor and gap analysis
- Kiosk is_deleted filter fixes
- Datto RMM ping target enrichment
- Entity sync soft-delete detection
This commit is contained in:
lorentz 2026-03-17 07:39:55 -04:00
parent c518eefdb2
commit b98c67482a
40 changed files with 6223 additions and 15 deletions

View file

@ -26,6 +26,10 @@ function getWebhookEntityName(entityType: WebhookEntityType): string {
ConfigurationItems: 'ConfigurationItemWebhooks',
Tickets: 'TicketWebhooks',
TicketNotes: 'TicketNoteWebhooks',
TimeEntries: 'TimeEntryWebhooks',
Tasks: 'TaskWebhooks',
Projects: 'ProjectWebhooks',
Contracts: 'ContractWebhooks',
};
return map[entityType] || `${entityType}Webhooks`;
}

View file

@ -551,4 +551,28 @@ export class DattoRMMClient {
return resp.json();
}
/**
* For a PING alert, fetch the ping target (instanceName) from alertContext.
* Returns null if not found or API call fails.
*/
async getPingAlertTarget(alertUid: string): Promise<string | null> {
const token = await this.getAccessToken();
const url = `${this.config.apiUrl}/api/v2/alert/${alertUid}`;
const resp = await fetch(url, {
headers: {
'Authorization': `Bearer ${token}`,
'Accept': 'application/json',
},
});
if (!resp.ok) return null;
const data = await resp.json();
const ctx = data?.alertContext;
if (ctx?.['@class'] === 'ping_ctx' && ctx?.instanceName) {
return ctx.instanceName as string;
}
return null;
}
}

View file

@ -386,13 +386,15 @@ export class EntitySyncService {
// For full sync, soft delete records not in the fetched set
// IMPORTANT: Skip soft deletes if ANY filters were applied (date, status, active, etc.)
// because we cannot know what records exist outside the filter criteria
// because we cannot know what records exist outside the filter criteria.
// EXCEPTION: TICKETS — we do a separate ID-only fetch from Autotask (no filters) to detect
// deletions even when date filters were applied to the main sync.
let deletedCount = 0;
if (!isIncremental && !hasAppliedFilters) {
entityLogger.phase(SyncPhase.DELETING, 'Checking for records to soft delete');
syncProgressTracker.updateProgress(trackingId, { phase: 'deleting' });
try {
const activeIds = mappedRecords.map(r => r.id);
deletedCount = await softDeleteMissingRecords(entity, activeIds);
@ -400,7 +402,43 @@ export class EntitySyncService {
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
entityLogger.warn('Soft delete failed, continuing sync', {}, err);
// Don't throw - soft delete failure shouldn't fail the entire sync
}
} else if (!isIncremental && hasAppliedFilters && entity === EntityType.TICKETS) {
entityLogger.phase(SyncPhase.DELETING, 'Fetching all Autotask ticket IDs for deletion diff');
syncProgressTracker.updateProgress(trackingId, { phase: 'deleting' });
try {
// Fetch all ticket IDs from Autotask with no filters for deletion diff
const allAutotaskTickets = await this.autotaskClient.queryEntityPaginated(
'Tickets',
{},
500
);
const liveIds = new Set(allAutotaskTickets.map((t: any) => String(t.id)));
// Find Pulse ticket IDs not present in Autotask → these were deleted
const pulseResult = await postgresClient.query(
`SELECT id FROM tickets WHERE is_deleted = false`
);
const toDelete = pulseResult.rows
.map((r: { id: number }) => r.id)
.filter((id: number) => !liveIds.has(String(id)));
if (toDelete.length > 0) {
const result = await postgresClient.query(
`UPDATE tickets
SET is_deleted = true, deleted_at = CURRENT_TIMESTAMP
WHERE id = ANY($1::int[])`,
[toDelete]
);
deletedCount = result.rowCount || 0;
entityLogger.info('Soft deleted tickets missing from Autotask', { deletedCount });
} else {
entityLogger.info('No deleted tickets detected');
}
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
entityLogger.warn('Ticket deletion diff failed, continuing sync', {}, err);
}
} else if (!isIncremental && hasAppliedFilters) {
entityLogger.info('Skipping soft-delete because filters were applied (would delete records outside filter criteria)');

View file

@ -20,3 +20,4 @@ import './rmm-quick-job';
import './enrich-vspc';
import './db-query';
import './fetch-b2-result';
import './ping-flap-suppress';

View file

@ -0,0 +1,142 @@
import { registerStepExecutor } from '../pipeline-engine';
import { PipelineStep, PipelineContext, StepExecutorResult } from '../../types/pipeline';
import { postgresClient } from '../postgres-client';
import { ZabbixClient } from '../zabbix-client';
import { ZabbixProblem } from '../../types/zabbix';
import { promises as dns } from 'dns';
/**
* Ping Flap Suppression Step
*
* Detects flapping PING alerts from Datto RMM and suppresses the corresponding
* Zabbix problem when a threshold is exceeded.
*
* Flap = same ping_target triggers >= FLAP_THRESHOLD times within FLAP_WINDOW_HOURS.
* When detected:
* - Suppresses the open Zabbix problem for the matching host for SUPPRESS_HOURS
* - Records the suppression in ping_flap_suppressions to avoid re-firing
*/
const FLAP_THRESHOLD = 5;
const FLAP_WINDOW_HOURS = 2;
const SUPPRESS_HOURS = 4;
function makeZabbix(): ZabbixClient {
if (!process.env.ZABBIX_API_URL || !process.env.ZABBIX_API_TOKEN) {
throw new Error('Zabbix not configured');
}
return new ZabbixClient({ apiUrl: process.env.ZABBIX_API_URL, apiToken: process.env.ZABBIX_API_TOKEN });
}
async function executePingFlapSuppress(
_step: PipelineStep,
context: PipelineContext,
_executionId: number
): Promise<StepExecutorResult> {
const payload = context.triggerData;
if (payload?.alert_type !== 'PING') {
return { success: true, output: { skipped: true } };
}
const pingTarget: string | null = payload?.ping_target ?? null;
if (!pingTarget) {
return { success: true, output: { skipped: true, reason: 'no ping_target' } };
}
// Check if already suppressed
const suppCheck = await postgresClient.query(
`SELECT suppressed_until FROM ping_flap_suppressions
WHERE ping_target = $1 AND suppressed_until > NOW()`,
[pingTarget]
);
if (suppCheck.rows.length > 0) {
return { success: true, output: { flap_suppressed: true, already_active: true } };
}
// Count triggers in the flap window
const countResult = await postgresClient.query(
`SELECT COUNT(*) as cnt FROM datto_rmm_alerts
WHERE alert_type = 'PING'
AND ping_target = $1
AND triggered = 'True'
AND timestamp >= NOW() - INTERVAL '${FLAP_WINDOW_HOURS} hours'`,
[pingTarget]
);
const triggerCount = parseInt(countResult.rows[0]?.cnt ?? '0', 10);
if (triggerCount < FLAP_THRESHOLD) {
return { success: true, output: { flap_detected: false, trigger_count: triggerCount } };
}
const suppressUntil = new Date(Date.now() + SUPPRESS_HOURS * 60 * 60 * 1000);
const message = `Auto-suppressed: flapping detected (${triggerCount} triggers in ${FLAP_WINDOW_HOURS}h). Awaiting manual resolution.`;
// Try to find and suppress the Zabbix problem
let zabbixSuppressed = false;
let zabbixHost: string | null = null;
try {
const zabbix = makeZabbix();
// Resolve DNS name to IP — Zabbix stores IPs in interfaces, not DNS names
const lookupTargets = [pingTarget];
try {
const resolved = await dns.lookup(pingTarget);
if (resolved.address && resolved.address !== pingTarget) {
lookupTargets.unshift(resolved.address);
}
} catch { /* not a resolvable hostname — may already be an IP */ }
let hosts: Array<{ hostid: string; name: string }> = [];
for (const target of lookupTargets) {
hosts = await zabbix.findHostsByInterface(target);
if (hosts.length > 0) break;
}
if (hosts.length > 0) {
const host = hosts[0];
zabbixHost = host.name;
const problems = await zabbix.getOpenProblemsForHost(host.hostid);
const unreachable = problems.find((p: ZabbixProblem) =>
p.name.toLowerCase().includes('unreachable') ||
p.name.toLowerCase().includes('unavailable')
);
if (unreachable) {
await zabbix.suppressProblem(unreachable.eventid, suppressUntil, message);
zabbixSuppressed = true;
}
}
} catch (err) {
console.error(`[ping-flap-suppress] Zabbix error for ${pingTarget}:`, err);
}
// Record suppression to prevent repeated attempts within the window
await postgresClient.query(
`INSERT INTO ping_flap_suppressions (ping_target, trigger_count, suppressed_until, zabbix_suppressed, notes)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (ping_target) DO UPDATE SET
trigger_count = EXCLUDED.trigger_count,
suppressed_until = EXCLUDED.suppressed_until,
zabbix_suppressed = EXCLUDED.zabbix_suppressed,
notes = EXCLUDED.notes,
updated_at = NOW()`,
[pingTarget, triggerCount, suppressUntil, zabbixSuppressed, message]
);
console.log(`[ping-flap-suppress] Flap detected: ${pingTarget} (${triggerCount} triggers) — zabbix_suppressed=${zabbixSuppressed} host=${zabbixHost}`);
return {
success: true,
output: {
flap_detected: true,
flap_suppressed: true,
trigger_count: triggerCount,
zabbix_suppressed: zabbixSuppressed,
zabbix_host: zabbixHost,
suppressed_until: suppressUntil.toISOString(),
},
};
}
registerStepExecutor('ping_flap_suppress', executePingFlapSuppress);

290
lib/services/qbo-client.ts Normal file
View file

@ -0,0 +1,290 @@
/**
* QuickBooks Online API Client
* Handles OAuth2 token management and all QBO REST API calls.
*/
import postgresClient from './postgres-client';
import {
QboTokenRecord,
QboTokenResponse,
QboInvoice,
QboPayment,
QboDeposit,
QboPurchase,
QboJournalEntry,
QboReport,
QboQueryResponse,
} from '@/lib/types/qbo';
const QBO_PRODUCTION_URL = 'https://quickbooks.api.intuit.com';
const QBO_SANDBOX_URL = 'https://sandbox-quickbooks.api.intuit.com';
const QBO_TOKEN_URL = 'https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer';
export class QboClient {
private clientId: string;
private clientSecret: string;
private realmId: string;
private baseUrl: string;
private _reportBasis: string | null = null;
constructor() {
this.clientId = process.env.QBO_CLIENT_ID || '';
this.clientSecret = process.env.QBO_CLIENT_SECRET || '';
this.realmId = process.env.QBO_REALM_ID || '';
this.baseUrl = process.env.QBO_SANDBOX === 'true' ? QBO_SANDBOX_URL : QBO_PRODUCTION_URL;
if (!this.clientId || !this.clientSecret || !this.realmId) {
throw new Error('QBO_CLIENT_ID, QBO_CLIENT_SECRET, and QBO_REALM_ID must be set');
}
console.log(`[QboClient] Using ${process.env.QBO_SANDBOX === 'true' ? 'SANDBOX' : 'PRODUCTION'} environment`);
}
// ─── Token Management ───────────────────────────────────────────────────────
async getValidAccessToken(): Promise<string> {
const token = await this.loadToken();
if (!token) {
throw new Error('No QBO token found. Complete OAuth2 authorization first via /api/qbo/auth');
}
if (new Date() < new Date(token.access_token_expires_at)) {
return token.access_token;
}
if (new Date() >= new Date(token.refresh_token_expires_at)) {
throw new Error('QBO refresh token has expired. Re-authorization required via /api/qbo/auth');
}
return this.refreshAccessToken(token.refresh_token);
}
async loadToken(): Promise<QboTokenRecord | null> {
const result = await postgresClient.query<QboTokenRecord>(
`SELECT * FROM qbo_tokens WHERE realm_id = $1 LIMIT 1`,
[this.realmId]
);
return result.rows[0] || null;
}
async saveToken(token: QboTokenResponse): Promise<void> {
const now = new Date();
const accessExpiry = new Date(now.getTime() + token.expires_in * 1000);
const refreshExpiry = new Date(now.getTime() + token.x_refresh_token_expires_in * 1000);
await postgresClient.query(
`INSERT INTO qbo_tokens (realm_id, access_token, refresh_token, access_token_expires_at, refresh_token_expires_at, updated_at)
VALUES ($1, $2, $3, $4, $5, NOW())
ON CONFLICT (realm_id) DO UPDATE SET
access_token = EXCLUDED.access_token,
refresh_token = EXCLUDED.refresh_token,
access_token_expires_at = EXCLUDED.access_token_expires_at,
refresh_token_expires_at = EXCLUDED.refresh_token_expires_at,
updated_at = NOW()`,
[this.realmId, token.access_token, token.refresh_token, accessExpiry, refreshExpiry]
);
}
async exchangeCodeForToken(authCode: string, redirectUri: string): Promise<QboTokenResponse> {
const credentials = Buffer.from(`${this.clientId}:${this.clientSecret}`).toString('base64');
const body = new URLSearchParams({
grant_type: 'authorization_code',
code: authCode,
redirect_uri: redirectUri,
});
const res = await fetch(QBO_TOKEN_URL, {
method: 'POST',
headers: {
Authorization: `Basic ${credentials}`,
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
},
body: body.toString(),
});
if (!res.ok) {
const err = await res.text();
throw new Error(`QBO token exchange failed: ${res.status} ${err}`);
}
const tokenData: QboTokenResponse = await res.json();
await this.saveToken(tokenData);
return tokenData;
}
private async refreshAccessToken(refreshToken: string): Promise<string> {
const credentials = Buffer.from(`${this.clientId}:${this.clientSecret}`).toString('base64');
const body = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
});
const res = await fetch(QBO_TOKEN_URL, {
method: 'POST',
headers: {
Authorization: `Basic ${credentials}`,
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
},
body: body.toString(),
});
if (!res.ok) {
const err = await res.text();
throw new Error(`QBO token refresh failed: ${res.status} ${err}`);
}
const tokenData: QboTokenResponse = await res.json();
await this.saveToken(tokenData);
console.log('[QboClient] Access token refreshed successfully');
return tokenData.access_token;
}
getAuthorizationUrl(redirectUri: string, state: string): string {
const params = new URLSearchParams({
client_id: this.clientId,
scope: 'com.intuit.quickbooks.accounting',
redirect_uri: redirectUri,
response_type: 'code',
state,
});
return `https://appcenter.intuit.com/connect/oauth2?${params.toString()}`;
}
// ─── Core API Request ────────────────────────────────────────────────────────
private async request<T>(path: string, options: RequestInit = {}): Promise<T> {
const accessToken = await this.getValidAccessToken();
const url = `${this.baseUrl}/v3/company/${this.realmId}${path}`;
const res = await fetch(url, {
...options,
redirect: 'follow',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
'Content-Type': 'application/json',
...options.headers,
},
});
const intuitTid = res.headers.get('intuit_tid') || res.headers.get('intuit-tid') || 'unknown';
if (!res.ok) {
const err = await res.text();
throw new Error(`QBO API error ${res.status} on ${path} [intuit_tid=${intuitTid}]: ${err}`);
}
return res.json() as Promise<T>;
}
// ─── Paginated Query ─────────────────────────────────────────────────────────
private async queryAll<T>(entity: string, extraWhere = ''): Promise<T[]> {
const all: T[] = [];
let startPos = 1;
const pageSize = 1000;
while (true) {
const where = extraWhere ? ` WHERE ${extraWhere}` : '';
const sql = encodeURIComponent(
`SELECT * FROM ${entity}${where} STARTPOSITION ${startPos} MAXRESULTS ${pageSize}`
);
const data = await this.request<QboQueryResponse<T>>(`/query?query=${sql}&minorversion=65`);
const items: T[] = (data.QueryResponse[entity] as T[]) || [];
all.push(...items);
if (items.length < pageSize) break;
startPos += pageSize;
}
return all;
}
// ─── Entity Fetchers ─────────────────────────────────────────────────────────
async getInvoices(updatedSince?: Date): Promise<QboInvoice[]> {
const where = updatedSince
? `MetaData.LastUpdatedTime > '${updatedSince.toISOString()}'`
: undefined;
return this.queryAll<QboInvoice>('Invoice', where);
}
async getPayments(updatedSince?: Date): Promise<QboPayment[]> {
const where = updatedSince
? `MetaData.LastUpdatedTime > '${updatedSince.toISOString()}'`
: undefined;
return this.queryAll<QboPayment>('Payment', where);
}
async getDeposits(updatedSince?: Date): Promise<QboDeposit[]> {
const where = updatedSince
? `MetaData.LastUpdatedTime > '${updatedSince.toISOString()}'`
: undefined;
return this.queryAll<QboDeposit>('Deposit', where);
}
async getPurchases(updatedSince?: Date): Promise<QboPurchase[]> {
const where = updatedSince
? `MetaData.LastUpdatedTime > '${updatedSince.toISOString()}'`
: undefined;
return this.queryAll<QboPurchase>('Purchase', where);
}
async getJournalEntries(updatedSince?: Date): Promise<QboJournalEntry[]> {
const where = updatedSince
? `MetaData.LastUpdatedTime > '${updatedSince.toISOString()}'`
: undefined;
return this.queryAll<QboJournalEntry>('JournalEntry', where);
}
async getPreferences(): Promise<{ reportBasis: string }> {
if (this._reportBasis) return { reportBasis: this._reportBasis };
const data = await this.request<{ Preferences?: { ReportPrefs?: { ReportBasis?: string } } }>('/preferences?minorversion=65');
this._reportBasis = data?.Preferences?.ReportPrefs?.ReportBasis ?? 'Accrual';
return { reportBasis: this._reportBasis };
}
async getProfitAndLoss(startDate: string, endDate: string, accountingMethod?: string): Promise<QboReport> {
const params = new URLSearchParams({
start_date: startDate,
end_date: endDate,
accounting_method: accountingMethod ?? 'Accrual',
showrows: 'all',
showcols: 'all',
minorversion: '65',
});
return this.request<QboReport>(`/reports/ProfitAndLoss?${params.toString()}`);
}
async getBalanceSheet(startDate: string, endDate: string, accountingMethod?: string): Promise<QboReport> {
const params = new URLSearchParams({
start_date: startDate,
end_date: endDate,
accounting_method: accountingMethod ?? 'Accrual',
showrows: 'all',
showcols: 'all',
minorversion: '65',
});
return this.request<QboReport>(`/reports/BalanceSheet?${params.toString()}`);
}
async getCashFlow(startDate: string, endDate: string): Promise<QboReport> {
const params = new URLSearchParams({
start_date: startDate,
end_date: endDate,
minorversion: '65',
});
return this.request<QboReport>(`/reports/CashFlow?${params.toString()}`);
}
}
let _instance: QboClient | null = null;
export function getQboClient(): QboClient {
if (!_instance) {
_instance = new QboClient();
}
return _instance;
}

View file

@ -0,0 +1,522 @@
/**
* QuickBooks Online Sync Service
* Fetches invoices, payments, deposits, transactions, and financial reports
* from QBO and upserts them into PostgreSQL.
*/
import postgresClient from './postgres-client';
import { QboClient, getQboClient } from './qbo-client';
import {
QboInvoice,
QboPayment,
QboDeposit,
QboPurchase,
QboJournalEntry,
QboReport,
QboSyncResult,
QboEntitySyncResult,
} from '@/lib/types/qbo';
export class QboSyncService {
private client: QboClient;
private isSyncing = false;
constructor(client?: QboClient) {
this.client = client || getQboClient();
}
isSyncInProgress(): boolean {
return this.isSyncing;
}
async fullSync(triggeredBy = 'system'): Promise<QboSyncResult> {
return this.executeSync('full', triggeredBy);
}
async incrementalSync(triggeredBy = 'system'): Promise<QboSyncResult> {
return this.executeSync('incremental', triggeredBy);
}
private async executeSync(
syncType: 'full' | 'incremental',
triggeredBy: string
): Promise<QboSyncResult> {
if (this.isSyncing) {
throw new Error('QBO sync already in progress');
}
this.isSyncing = true;
const syncId = `qbo_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const startedAt = new Date();
const entities: QboEntitySyncResult[] = [];
const errors: string[] = [];
const realmId = process.env.QBO_REALM_ID || '';
console.log(`[QboSync] Starting ${syncType} sync (${syncId}) triggered by ${triggeredBy}`);
try {
// Determine updatedSince for incremental
let updatedSince: Date | undefined;
if (syncType === 'incremental') {
updatedSince = await this.getLastSyncTime();
}
// Sync invoices
entities.push(await this.syncInvoices(realmId, updatedSince));
// Sync payments
entities.push(await this.syncPayments(realmId, updatedSince));
// Sync deposits
entities.push(await this.syncDeposits(realmId, updatedSince));
// Sync purchases (expenses/credit card charges)
entities.push(await this.syncPurchases(realmId, updatedSince));
// Sync journal entries
entities.push(await this.syncJournalEntries(realmId, updatedSince));
// Sync reports — always fetch current + last 12 months on full, current month on incremental
if (syncType === 'full') {
entities.push(await this.syncReports(realmId, 12));
} else {
entities.push(await this.syncReports(realmId, 1));
}
// Record sync history
await this.recordSyncHistory(syncId, syncType, triggeredBy, startedAt, 'completed', entities);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
errors.push(msg);
console.error(`[QboSync] Sync failed: ${msg}`);
await this.recordSyncHistory(syncId, syncType, triggeredBy, startedAt, 'failed', entities, msg);
} finally {
this.isSyncing = false;
}
const completedAt = new Date();
return {
syncId,
realmId,
status: errors.length === 0 ? 'completed' : 'failed',
startedAt,
completedAt,
duration: completedAt.getTime() - startedAt.getTime(),
entities,
errors,
};
}
// ─── Entity Sync Methods ───────────────────────────────────────────────────
private async syncInvoices(realmId: string, updatedSince?: Date): Promise<QboEntitySyncResult> {
const start = Date.now();
try {
const invoices = await this.client.getInvoices(updatedSince);
console.log(`[QboSync] Fetched ${invoices.length} invoices`);
let upserted = 0;
for (const inv of invoices) {
const status = this.deriveInvoiceStatus(inv);
await postgresClient.query(
`INSERT INTO qbo_invoices
(id, realm_id, doc_number, txn_date, due_date, customer_ref_id, customer_ref_name,
email_address, total_amt, balance, status, currency_code, line_items, linked_txns,
sync_token, qbo_created_at, qbo_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
doc_number = EXCLUDED.doc_number,
txn_date = EXCLUDED.txn_date,
due_date = EXCLUDED.due_date,
customer_ref_id = EXCLUDED.customer_ref_id,
customer_ref_name = EXCLUDED.customer_ref_name,
email_address = EXCLUDED.email_address,
total_amt = EXCLUDED.total_amt,
balance = EXCLUDED.balance,
status = EXCLUDED.status,
currency_code = EXCLUDED.currency_code,
line_items = EXCLUDED.line_items,
linked_txns = EXCLUDED.linked_txns,
sync_token = EXCLUDED.sync_token,
qbo_updated_at = EXCLUDED.qbo_updated_at,
synced_at = NOW()`,
[
inv.Id, realmId, inv.DocNumber ?? null,
inv.TxnDate ?? null, inv.DueDate ?? null,
inv.CustomerRef?.value ?? null, inv.CustomerRef?.name ?? null,
inv.BillEmail?.Address ?? null,
inv.TotalAmt ?? null, inv.Balance ?? null,
status, inv.CurrencyRef?.value ?? 'USD',
JSON.stringify(inv.Line ?? []),
JSON.stringify(inv.LinkedTxn ?? []),
inv.SyncToken,
inv.MetaData?.CreateTime ?? null,
inv.MetaData?.LastUpdatedTime ?? null,
]
);
upserted++;
}
return { entity: 'invoices', success: true, recordsUpserted: upserted, duration: Date.now() - start };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[QboSync] Invoice sync failed: ${msg}`);
return { entity: 'invoices', success: false, recordsUpserted: 0, duration: Date.now() - start, error: msg };
}
}
private async syncPayments(realmId: string, updatedSince?: Date): Promise<QboEntitySyncResult> {
const start = Date.now();
try {
const payments = await this.client.getPayments(updatedSince);
console.log(`[QboSync] Fetched ${payments.length} payments`);
let upserted = 0;
for (const pay of payments) {
await postgresClient.query(
`INSERT INTO qbo_payments
(id, realm_id, txn_date, customer_ref_id, customer_ref_name, total_amt, unapplied_amt,
currency_code, payment_method_ref, deposit_account_ref, linked_txns,
sync_token, qbo_created_at, qbo_updated_at, synced_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,NOW())
ON CONFLICT (id) DO UPDATE SET
txn_date = EXCLUDED.txn_date,
customer_ref_id = EXCLUDED.customer_ref_id,
customer_ref_name = EXCLUDED.customer_ref_name,
total_amt = EXCLUDED.total_amt,
unapplied_amt = EXCLUDED.unapplied_amt,
currency_code = EXCLUDED.currency_code,
payment_method_ref = EXCLUDED.payment_method_ref,
deposit_account_ref = EXCLUDED.deposit_account_ref,
linked_txns = EXCLUDED.linked_txns,
sync_token = EXCLUDED.sync_token,
qbo_updated_at = EXCLUDED.qbo_updated_at,
synced_at = NOW()`,
[
pay.Id, realmId, pay.TxnDate ?? null,
pay.CustomerRef?.value ?? null, pay.CustomerRef?.name ?? null,
pay.TotalAmt ?? null, pay.UnappliedAmt ?? null,
pay.CurrencyRef?.value ?? 'USD',
pay.PaymentMethodRef?.name ?? null,
pay.DepositToAccountRef?.name ?? null,
JSON.stringify(pay.Line ?? []),
pay.SyncToken,
pay.MetaData?.CreateTime ?? null,
pay.MetaData?.LastUpdatedTime ?? null,
]
);
upserted++;
}
return { entity: 'payments', success: true, recordsUpserted: upserted, duration: Date.now() - start };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[QboSync] Payment sync failed: ${msg}`);
return { entity: 'payments', success: false, recordsUpserted: 0, duration: Date.now() - start, error: msg };
}
}
private async syncDeposits(realmId: string, updatedSince?: Date): Promise<QboEntitySyncResult> {
const start = Date.now();
try {
const deposits = await this.client.getDeposits(updatedSince);
console.log(`[QboSync] Fetched ${deposits.length} deposits`);
let upserted = 0;
for (const dep of deposits) {
await postgresClient.query(
`INSERT INTO qbo_deposits
(id, realm_id, txn_date, deposit_to_account_ref_id, deposit_to_account_ref_name,
total_amt, line_items, sync_token, qbo_created_at, qbo_updated_at, synced_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,NOW())
ON CONFLICT (id) DO UPDATE SET
txn_date = EXCLUDED.txn_date,
deposit_to_account_ref_id = EXCLUDED.deposit_to_account_ref_id,
deposit_to_account_ref_name = EXCLUDED.deposit_to_account_ref_name,
total_amt = EXCLUDED.total_amt,
line_items = EXCLUDED.line_items,
sync_token = EXCLUDED.sync_token,
qbo_updated_at = EXCLUDED.qbo_updated_at,
synced_at = NOW()`,
[
dep.Id, realmId, dep.TxnDate ?? null,
dep.DepositToAccountRef?.value ?? null,
dep.DepositToAccountRef?.name ?? null,
dep.TotalAmt ?? null,
JSON.stringify(dep.Line ?? []),
dep.SyncToken,
dep.MetaData?.CreateTime ?? null,
dep.MetaData?.LastUpdatedTime ?? null,
]
);
upserted++;
}
return { entity: 'deposits', success: true, recordsUpserted: upserted, duration: Date.now() - start };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[QboSync] Deposit sync failed: ${msg}`);
return { entity: 'deposits', success: false, recordsUpserted: 0, duration: Date.now() - start, error: msg };
}
}
private async syncPurchases(realmId: string, updatedSince?: Date): Promise<QboEntitySyncResult> {
const start = Date.now();
try {
const purchases = await this.client.getPurchases(updatedSince);
console.log(`[QboSync] Fetched ${purchases.length} purchases`);
let upserted = 0;
for (const p of purchases) {
await postgresClient.query(
`INSERT INTO qbo_transactions
(id, txn_type, realm_id, txn_date, doc_number, entity_ref_id, entity_ref_name,
entity_type, account_ref_id, account_ref_name, total_amt, currency_code,
private_note, line_items, sync_token, qbo_created_at, qbo_updated_at, synced_at)
VALUES ($1,'Purchase',$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,NOW())
ON CONFLICT (id, txn_type) DO UPDATE SET
txn_date = EXCLUDED.txn_date,
doc_number = EXCLUDED.doc_number,
entity_ref_id = EXCLUDED.entity_ref_id,
entity_ref_name = EXCLUDED.entity_ref_name,
entity_type = EXCLUDED.entity_type,
account_ref_id = EXCLUDED.account_ref_id,
account_ref_name = EXCLUDED.account_ref_name,
total_amt = EXCLUDED.total_amt,
currency_code = EXCLUDED.currency_code,
private_note = EXCLUDED.private_note,
line_items = EXCLUDED.line_items,
sync_token = EXCLUDED.sync_token,
qbo_updated_at = EXCLUDED.qbo_updated_at,
synced_at = NOW()`,
[
p.Id, realmId, p.TxnDate ?? null, p.DocNumber ?? null,
p.EntityRef?.value ?? null, p.EntityRef?.name ?? null,
p.EntityRef?.type ?? null,
p.AccountRef?.value ?? null, p.AccountRef?.name ?? null,
p.TotalAmt ?? null, p.CurrencyRef?.value ?? 'USD',
p.PrivateNote ?? null,
JSON.stringify(p.Line ?? []),
p.SyncToken,
p.MetaData?.CreateTime ?? null,
p.MetaData?.LastUpdatedTime ?? null,
]
);
upserted++;
}
return { entity: 'purchases', success: true, recordsUpserted: upserted, duration: Date.now() - start };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[QboSync] Purchase sync failed: ${msg}`);
return { entity: 'purchases', success: false, recordsUpserted: 0, duration: Date.now() - start, error: msg };
}
}
private async syncJournalEntries(realmId: string, updatedSince?: Date): Promise<QboEntitySyncResult> {
const start = Date.now();
try {
const entries = await this.client.getJournalEntries(updatedSince);
console.log(`[QboSync] Fetched ${entries.length} journal entries`);
let upserted = 0;
for (const je of entries) {
await postgresClient.query(
`INSERT INTO qbo_transactions
(id, txn_type, realm_id, txn_date, doc_number, total_amt, currency_code,
private_note, line_items, sync_token, qbo_created_at, qbo_updated_at, synced_at)
VALUES ($1,'JournalEntry',$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,NOW())
ON CONFLICT (id, txn_type) DO UPDATE SET
txn_date = EXCLUDED.txn_date,
doc_number = EXCLUDED.doc_number,
total_amt = EXCLUDED.total_amt,
currency_code = EXCLUDED.currency_code,
private_note = EXCLUDED.private_note,
line_items = EXCLUDED.line_items,
sync_token = EXCLUDED.sync_token,
qbo_updated_at = EXCLUDED.qbo_updated_at,
synced_at = NOW()`,
[
je.Id, realmId, je.TxnDate ?? null, je.DocNumber ?? null,
je.TotalAmt ?? null, je.CurrencyRef?.value ?? 'USD',
je.PrivateNote ?? null,
JSON.stringify(je.Line ?? []),
je.SyncToken,
je.MetaData?.CreateTime ?? null,
je.MetaData?.LastUpdatedTime ?? null,
]
);
upserted++;
}
return { entity: 'journal_entries', success: true, recordsUpserted: upserted, duration: Date.now() - start };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[QboSync] Journal entry sync failed: ${msg}`);
return { entity: 'journal_entries', success: false, recordsUpserted: 0, duration: Date.now() - start, error: msg };
}
}
private async syncReports(realmId: string, monthsBack: number): Promise<QboEntitySyncResult> {
const start = Date.now();
let upserted = 0;
let skipped = 0;
const errors: string[] = [];
try {
// Fetch company accounting preference once
const { reportBasis } = await this.client.getPreferences();
console.log(`[QboSync] Report sync using accounting method: ${reportBasis}`);
const now = new Date();
for (let i = 0; i < monthsBack; i++) {
const periodEnd = new Date(now.getFullYear(), now.getMonth() - i + 1, 0);
const periodStart = new Date(now.getFullYear(), now.getMonth() - i, 1);
const startStr = this.formatDate(periodStart);
const endStr = this.formatDate(periodEnd);
// P&L
try {
const pl = await this.client.getProfitAndLoss(startStr, endStr, reportBasis);
if (pl?.Header?.NoReportData === 'true') {
console.log(`[QboSync] P&L ${startStr}: no data, skipping`);
skipped++;
} else {
await this.upsertReport(realmId, 'ProfitAndLoss', periodStart, periodEnd, pl);
console.log(`[QboSync] P&L ${startStr}: upserted`);
upserted++;
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
errors.push(`P&L ${startStr}: ${msg}`);
}
// Balance Sheet
try {
const bs = await this.client.getBalanceSheet(startStr, endStr, reportBasis);
if (bs?.Header?.NoReportData === 'true') {
console.log(`[QboSync] BalanceSheet ${startStr}: no data, skipping`);
skipped++;
} else {
await this.upsertReport(realmId, 'BalanceSheet', periodStart, periodEnd, bs);
console.log(`[QboSync] BalanceSheet ${startStr}: upserted`);
upserted++;
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
errors.push(`BalanceSheet ${startStr}: ${msg}`);
}
// Cash Flow
try {
const cf = await this.client.getCashFlow(startStr, endStr);
if (cf?.Header?.NoReportData === 'true') {
console.log(`[QboSync] CashFlow ${startStr}: no data, skipping`);
skipped++;
} else {
await this.upsertReport(realmId, 'CashFlow', periodStart, periodEnd, cf);
console.log(`[QboSync] CashFlow ${startStr}: upserted`);
upserted++;
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
errors.push(`CashFlow ${startStr}: ${msg}`);
}
}
console.log(`[QboSync] Reports: ${upserted} upserted, ${skipped} skipped, ${errors.length} errors`);
if (errors.length > 0) {
console.warn(`[QboSync] Report errors: ${errors.join('; ')}`);
}
return {
entity: 'reports',
success: errors.length === 0,
recordsUpserted: upserted,
duration: Date.now() - start,
error: errors.length > 0 ? errors.join('; ') : undefined,
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[QboSync] Report sync failed: ${msg}`);
return { entity: 'reports', success: false, recordsUpserted: upserted, duration: Date.now() - start, error: msg };
}
}
private async upsertReport(
realmId: string,
reportType: string,
periodStart: Date,
periodEnd: Date,
data: QboReport
): Promise<void> {
await postgresClient.query(
`INSERT INTO qbo_reports (realm_id, report_type, period_start, period_end, report_data, synced_at)
VALUES ($1, $2, $3, $4, $5, NOW())
ON CONFLICT (realm_id, report_type, period_start, period_end) DO UPDATE SET
report_data = EXCLUDED.report_data,
synced_at = NOW()`,
[realmId, reportType, periodStart, periodEnd, JSON.stringify(data)]
);
}
// ─── Helpers ────────────────────────────────────────────────────────────────
private deriveInvoiceStatus(inv: QboInvoice): string {
if ((inv.Balance ?? 0) === 0 && (inv.TotalAmt ?? 0) > 0) return 'Paid';
if ((inv.Balance ?? 0) > 0 && inv.DueDate && new Date(inv.DueDate) < new Date()) return 'Overdue';
if ((inv.Balance ?? 0) > 0) return 'Open';
return 'Unknown';
}
private formatDate(d: Date): string {
return d.toISOString().split('T')[0];
}
private async getLastSyncTime(): Promise<Date | undefined> {
try {
const result = await postgresClient.query<{ synced_at: Date }>(
`SELECT MAX(synced_at) as synced_at FROM qbo_invoices`
);
return result.rows[0]?.synced_at ?? undefined;
} catch {
return undefined;
}
}
private async recordSyncHistory(
_syncId: string,
syncType: string,
triggeredBy: string,
startedAt: Date,
status: string,
entities: QboEntitySyncResult[],
errorMessage?: string
): Promise<void> {
try {
const totalRecords = entities.reduce((sum, e) => sum + e.recordsUpserted, 0);
const normalizedType = syncType === 'incremental' ? 'incremental' : 'full';
await postgresClient.query(
`INSERT INTO sync_history
(entity_type, sync_type, status, records_added, started_at, completed_at, triggered_by, error_message)
VALUES ('qbo', $1, $2, $3, $4, NOW(), $5, $6)`,
[normalizedType, status, totalRecords, startedAt, triggeredBy, errorMessage ?? null]
);
} catch (err) {
console.warn('[QboSync] Failed to record sync history:', err);
}
}
}
let _instance: QboSyncService | null = null;
export function getQboSyncService(): QboSyncService {
if (!_instance) {
_instance = new QboSyncService();
}
return _instance;
}

View file

@ -15,13 +15,14 @@ import { isMsgraphConfigured } from './msgraph-factory';
import { ZoomSyncService } from './zoom-sync-service';
import { isZoomConfigured } from './zoom-factory';
import { MorningSummaryService } from './morning-summary-service';
import { TicketDigestService } from './ticket-digest-service';
export interface ScheduleConfig {
id: string;
name: string;
description: string;
cron_expression: string;
sync_type: 'incremental' | 'full' | 'veeam-incremental' | 'veeam-full' | 'veeam-rpo-check' | 'contract-services' | 'engagement-daily' | 'zoom-daily' | 'morning-summary';
sync_type: 'incremental' | 'full' | 'veeam-incremental' | 'veeam-full' | 'veeam-rpo-check' | 'contract-services' | 'engagement-daily' | 'zoom-daily' | 'morning-summary' | 'ticket-digest-daily' | 'ticket-digest-weekly' | 'ticket-digest-monthly';
years_back?: number;
is_enabled: boolean;
last_run?: Date;
@ -78,6 +79,14 @@ class SyncScheduler {
return this._morningSummaryService;
}
private _ticketDigestService?: TicketDigestService;
private getTicketDigestService(): TicketDigestService {
if (!this._ticketDigestService) {
this._ticketDigestService = new TicketDigestService();
}
return this._ticketDigestService;
}
private getZoomSyncService(): ZoomSyncService {
if (!this._zoomSyncService) {
this._zoomSyncService = new ZoomSyncService();
@ -242,6 +251,30 @@ class SyncScheduler {
sync_type: 'morning-summary',
is_enabled: false,
},
{
id: 'ticket-digest-daily',
name: 'Daily Ticket Digest',
description: 'LLM-analyzed ticket digest for the previous day, delivered to Teams at 7 AM MonFri',
cron_expression: '0 7 * * 1-5',
sync_type: 'ticket-digest-daily',
is_enabled: false,
},
{
id: 'ticket-digest-weekly',
name: 'Weekly Ticket Digest',
description: 'LLM-analyzed ticket digest for the previous week, delivered to Teams at 7 AM Monday',
cron_expression: '0 7 * * 1',
sync_type: 'ticket-digest-weekly',
is_enabled: false,
},
{
id: 'ticket-digest-monthly',
name: 'Monthly Ticket Digest',
description: 'LLM-analyzed ticket digest for the previous month, delivered to Teams at 7 AM on the 1st',
cron_expression: '0 7 1 * *',
sync_type: 'ticket-digest-monthly',
is_enabled: false,
},
];
for (const schedule of defaultSchedules) {
@ -367,6 +400,12 @@ class SyncScheduler {
}
} else if (config.sync_type === 'morning-summary') {
await this.getMorningSummaryService().run();
} else if (config.sync_type === 'ticket-digest-daily') {
await this.getTicketDigestService().run('daily');
} else if (config.sync_type === 'ticket-digest-weekly') {
await this.getTicketDigestService().run('weekly');
} else if (config.sync_type === 'ticket-digest-monthly') {
await this.getTicketDigestService().run('monthly');
} else if (config.sync_type === 'incremental') {
await this.syncService.incrementalSync('scheduled');
} else {

View file

@ -0,0 +1,782 @@
/**
* Ticket Digest Report Service
* Aggregates ticket data for daily/weekly/monthly periods, sends it to an LLM
* for noise analysis and insights, then delivers an Adaptive Card to Teams.
*/
import { postgresClient } from './postgres-client';
export type DigestPeriod = 'daily' | 'weekly' | 'monthly';
export interface DigestConfig {
daily_enabled: boolean;
weekly_enabled: boolean;
monthly_enabled: boolean;
daily_cron: string;
weekly_cron: string;
monthly_cron: string;
llm_provider: string;
llm_model: string;
include_noise_analysis: boolean;
include_sla_analysis: boolean;
include_resource_analysis: boolean;
include_client_analysis: boolean;
include_recommendations: boolean;
channel_ids: number[];
}
export interface NotificationChannel {
id: number;
name: string;
channel_type: 'teams' | 'telegram' | 'ntfy' | 'webhook';
config: Record<string, any>;
is_active: boolean;
}
export interface DeliveryResult {
channelId: number;
label: string;
success: boolean;
httpStatus?: number;
error?: string;
}
export interface TicketDigestStats {
period: { type: DigestPeriod; start: string; end: string; label: string };
overview: {
total_created: number;
total_resolved: number;
total_open_end: number;
avg_resolution_hours: number | null;
avg_first_response_hours: number | null;
total_hours_worked: number;
};
by_source: Array<{ source: number | null; source_label: string; count: number; pct: number }>;
by_queue: Array<{ queue_id: number | null; queue_label: string; count: number; resolved: number; avg_resolve_hrs: number | null }>;
by_priority: Array<{ priority: number | null; priority_label: string; count: number }>;
by_issue_type: Array<{ issue_type: number | null; issue_label: string; count: number }>;
top_clients: Array<{ company_id: number; company_name: string; ticket_count: number; hours_worked: number }>;
top_resources: Array<{ resource_id: number; resource_name: string; tickets_touched: number; hours_worked: number }>;
noise_candidates: Array<{ title: string; count: number; source: number | null; source_label: string; avg_resolve_min: number | null; sample_id: number }>;
monitor_tickets: { total: number; auto_resolved: number; pct_of_all: number };
sla: { first_response_met: number; first_response_missed: number; resolution_met: number; resolution_missed: number };
comparison: {
prev_total_created: number;
prev_total_resolved: number;
prev_avg_resolution_hours: number | null;
prev_total_hours_worked: number;
created_delta_pct: number | null;
resolved_delta_pct: number | null;
} | null;
}
const SOURCE_LABELS: Record<number, string> = {
'-2': 'RMM Alert (Resolved)',
'-1': 'RMM Alert',
1: 'Phone',
2: 'Chat/Portal',
4: 'Email',
6: 'Internal',
8: 'Monitoring Alert',
17: 'Auto-ticket',
21: 'Voice',
27: 'Feedback',
30: 'Web Portal',
35: 'Phish Alert',
38: 'Teams',
39: 'API',
};
const PRIORITY_LABELS: Record<number, string> = {
1: 'Critical',
2: 'High',
3: 'Medium',
4: 'Low',
6: 'Informational',
};
function getPeriodBounds(period: DigestPeriod, now: Date): { start: Date; end: Date; prevStart: Date; prevEnd: Date; label: string } {
const end = new Date(now);
end.setHours(0, 0, 0, 0);
if (period === 'daily') {
const start = new Date(end);
start.setDate(start.getDate() - 1);
const prevEnd = new Date(start);
const prevStart = new Date(prevEnd);
prevStart.setDate(prevStart.getDate() - 1);
return { start, end, prevStart, prevEnd, label: start.toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric' }) };
}
if (period === 'weekly') {
const start = new Date(end);
start.setDate(start.getDate() - 7);
const prevEnd = new Date(start);
const prevStart = new Date(prevEnd);
prevStart.setDate(prevStart.getDate() - 7);
const label = `${start.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} ${new Date(end.getTime() - 86400000).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}`;
return { start, end, prevStart, prevEnd, label };
}
// monthly
const start = new Date(end.getFullYear(), end.getMonth() - 1, 1);
const monthEnd = new Date(end.getFullYear(), end.getMonth(), 1);
const prevStart = new Date(start.getFullYear(), start.getMonth() - 1, 1);
const prevEnd = new Date(start);
const label = start.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
return { start, end: monthEnd, prevStart, prevEnd, label };
}
export class TicketDigestService {
// ──────────────────────────────────────────────────────────────
// Config & Webhook CRUD
// ──────────────────────────────────────────────────────────────
async getConfig(): Promise<DigestConfig> {
const r = await postgresClient.query('SELECT * FROM ticket_digest_config WHERE id = 1');
return r.rows[0] as DigestConfig;
}
async updateConfig(updates: Partial<DigestConfig>): Promise<DigestConfig> {
const fields: string[] = [];
const values: unknown[] = [];
let idx = 1;
for (const [key, val] of Object.entries(updates)) {
fields.push(`${key} = $${idx++}`);
values.push(val);
}
if (fields.length === 0) return this.getConfig();
fields.push('updated_at = NOW()');
values.push(1);
const r = await postgresClient.query(
`UPDATE ticket_digest_config SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`,
values
);
return r.rows[0] as DigestConfig;
}
async getAvailableChannels(): Promise<NotificationChannel[]> {
const r = await postgresClient.query(
'SELECT id, name, channel_type, config, is_active FROM notification_channels ORDER BY name'
);
return r.rows as NotificationChannel[];
}
// ──────────────────────────────────────────────────────────────
// Data Aggregation
// ──────────────────────────────────────────────────────────────
async aggregate(period: DigestPeriod, now?: Date): Promise<TicketDigestStats> {
const { start, end, prevStart, prevEnd, label } = getPeriodBounds(period, now ?? new Date());
const s = start.toISOString();
const e = end.toISOString();
const ps = prevStart.toISOString();
const pe = prevEnd.toISOString();
const [
overviewR,
bySourceR,
byQueueR,
byPriorityR,
byIssueTypeR,
topClientsR,
topResourcesR,
noiseR,
monitorR,
slaR,
prevOverviewR,
] = await Promise.all([
// Overview
postgresClient.query(`
SELECT
COUNT(*) FILTER (WHERE t.create_date >= $1 AND t.create_date < $2) as total_created,
COUNT(*) FILTER (WHERE t.resolved_date_time >= $1 AND t.resolved_date_time < $2) as total_resolved,
COUNT(*) FILTER (WHERE t.create_date < $2 AND (t.resolved_date_time IS NULL OR t.resolved_date_time >= $2) AND t.status NOT IN (5)) as total_open_end,
ROUND(AVG(EXTRACT(EPOCH FROM (t.resolved_date_time - t.create_date))/3600) FILTER (WHERE t.resolved_date_time >= $1 AND t.resolved_date_time < $2)::numeric, 1) as avg_resolution_hours,
ROUND(AVG(EXTRACT(EPOCH FROM (t.first_response_date_time - t.create_date))/3600) FILTER (WHERE t.first_response_date_time IS NOT NULL AND t.create_date >= $1 AND t.create_date < $2)::numeric, 1) as avg_first_response_hours,
COALESCE(SUM(te.hours_worked), 0) as total_hours_worked
FROM tickets t
LEFT JOIN time_entries te ON te.ticket_id = t.id AND (te.is_deleted = false) AND te.entry_date >= $1::date AND te.entry_date < $2::date
WHERE t.is_deleted = false AND (t.create_date >= $1 AND t.create_date < $2 OR t.resolved_date_time >= $1 AND t.resolved_date_time < $2)
`, [s, e]),
// By source
postgresClient.query(`
SELECT t.source, COUNT(*) as count
FROM tickets t WHERE t.is_deleted = false AND t.create_date >= $1 AND t.create_date < $2
GROUP BY t.source ORDER BY count DESC
`, [s, e]),
// By queue
postgresClient.query(`
SELECT t.queue_id, q.label as queue_label, COUNT(*) as count,
COUNT(*) FILTER (WHERE t.resolved_date_time >= $1 AND t.resolved_date_time < $2) as resolved,
ROUND(AVG(EXTRACT(EPOCH FROM (t.resolved_date_time - t.create_date))/3600) FILTER (WHERE t.resolved_date_time IS NOT NULL)::numeric, 1) as avg_resolve_hrs
FROM tickets t
LEFT JOIN queues q ON q.value = t.queue_id
WHERE t.is_deleted = false AND t.create_date >= $1 AND t.create_date < $2
GROUP BY t.queue_id, q.label ORDER BY count DESC LIMIT 15
`, [s, e]),
// By priority
postgresClient.query(`
SELECT t.priority, COUNT(*) as count
FROM tickets t WHERE t.is_deleted = false AND t.create_date >= $1 AND t.create_date < $2
GROUP BY t.priority ORDER BY t.priority
`, [s, e]),
// By issue type
postgresClient.query(`
SELECT t.issue_type, it.label as issue_label, COUNT(*) as count
FROM tickets t
LEFT JOIN issue_types it ON it.value = t.issue_type
WHERE t.is_deleted = false AND t.create_date >= $1 AND t.create_date < $2
GROUP BY t.issue_type, it.label ORDER BY count DESC LIMIT 15
`, [s, e]),
// Top clients
postgresClient.query(`
SELECT t.company_id, c.company_name, COUNT(DISTINCT t.id) as ticket_count,
COALESCE(SUM(te.hours_worked), 0)::float as hours_worked
FROM tickets t
JOIN companies c ON c.id = t.company_id
LEFT JOIN time_entries te ON te.ticket_id = t.id AND (te.is_deleted = false) AND te.entry_date >= $1::date AND te.entry_date < $2::date
WHERE t.is_deleted = false AND t.create_date >= $1 AND t.create_date < $2
GROUP BY t.company_id, c.company_name ORDER BY ticket_count DESC LIMIT 10
`, [s, e]),
// Top resources
postgresClient.query(`
SELECT te.resource_id, r.first_name || ' ' || r.last_name as resource_name,
COUNT(DISTINCT te.ticket_id) as tickets_touched,
COALESCE(SUM(te.hours_worked), 0)::float as hours_worked
FROM time_entries te
JOIN resources r ON r.id = te.resource_id
WHERE te.is_deleted = false AND te.entry_date >= $1::date AND te.entry_date < $2::date AND te.ticket_id IS NOT NULL
GROUP BY te.resource_id, r.first_name, r.last_name ORDER BY hours_worked DESC LIMIT 10
`, [s, e]),
// Noise candidates — repeated titles (grouping by first 60 chars of title)
postgresClient.query(`
SELECT LEFT(t.title, 60) as title, COUNT(*) as count, t.source,
ROUND(AVG(EXTRACT(EPOCH FROM (t.resolved_date_time - t.create_date))/60) FILTER (WHERE t.resolved_date_time IS NOT NULL)::numeric, 0) as avg_resolve_min,
MIN(t.id) as sample_id
FROM tickets t
WHERE t.is_deleted = false AND t.create_date >= $1 AND t.create_date < $2
GROUP BY LEFT(t.title, 60), t.source
HAVING COUNT(*) >= 3
ORDER BY count DESC LIMIT 20
`, [s, e]),
// Monitor-generated tickets
postgresClient.query(`
SELECT
COUNT(*) as total,
COUNT(*) FILTER (WHERE t.resolved_date_time IS NOT NULL AND EXTRACT(EPOCH FROM (t.resolved_date_time - t.create_date)) < 1800) as auto_resolved
FROM tickets t
WHERE t.is_deleted = false AND t.create_date >= $1 AND t.create_date < $2 AND t.monitor_id IS NOT NULL
`, [s, e]),
// SLA (using 1hr first response / 24hr resolution as baseline)
postgresClient.query(`
SELECT
COUNT(*) FILTER (WHERE t.first_response_date_time IS NOT NULL AND EXTRACT(EPOCH FROM (t.first_response_date_time - t.create_date))/3600 <= 1) as fr_met,
COUNT(*) FILTER (WHERE t.first_response_date_time IS NOT NULL AND EXTRACT(EPOCH FROM (t.first_response_date_time - t.create_date))/3600 > 1) as fr_missed,
COUNT(*) FILTER (WHERE t.resolved_date_time IS NOT NULL AND EXTRACT(EPOCH FROM (t.resolved_date_time - t.create_date))/3600 <= 24) as res_met,
COUNT(*) FILTER (WHERE t.resolved_date_time IS NOT NULL AND EXTRACT(EPOCH FROM (t.resolved_date_time - t.create_date))/3600 > 24) as res_missed
FROM tickets t
WHERE t.is_deleted = false AND t.create_date >= $1 AND t.create_date < $2
`, [s, e]),
// Previous period overview for comparison
postgresClient.query(`
SELECT
COUNT(*) FILTER (WHERE t.create_date >= $1 AND t.create_date < $2) as total_created,
COUNT(*) FILTER (WHERE t.resolved_date_time >= $1 AND t.resolved_date_time < $2) as total_resolved,
ROUND(AVG(EXTRACT(EPOCH FROM (t.resolved_date_time - t.create_date))/3600) FILTER (WHERE t.resolved_date_time >= $1 AND t.resolved_date_time < $2)::numeric, 1) as avg_resolution_hours,
COALESCE(SUM(te.hours_worked), 0) as total_hours_worked
FROM tickets t
LEFT JOIN time_entries te ON te.ticket_id = t.id AND (te.is_deleted = false) AND te.entry_date >= $1::date AND te.entry_date < $2::date
WHERE t.is_deleted = false AND (t.create_date >= $1 AND t.create_date < $2 OR t.resolved_date_time >= $1 AND t.resolved_date_time < $2)
`, [ps, pe]),
]);
const ov = overviewR.rows[0];
const prevOv = prevOverviewR.rows[0];
const monRow = monitorR.rows[0];
const slaRow = slaR.rows[0];
const totalCreated = parseInt(ov.total_created) || 0;
const prevCreated = parseInt(prevOv.total_created) || 0;
const prevResolved = parseInt(prevOv.total_resolved) || 0;
const deltaPct = (cur: number, prev: number): number | null => prev === 0 ? null : Math.round(((cur - prev) / prev) * 100);
return {
period: { type: period, start: s, end: e, label },
overview: {
total_created: totalCreated,
total_resolved: parseInt(ov.total_resolved) || 0,
total_open_end: parseInt(ov.total_open_end) || 0,
avg_resolution_hours: ov.avg_resolution_hours ? parseFloat(ov.avg_resolution_hours) : null,
avg_first_response_hours: ov.avg_first_response_hours ? parseFloat(ov.avg_first_response_hours) : null,
total_hours_worked: parseFloat(ov.total_hours_worked) || 0,
},
by_source: bySourceR.rows.map(r => ({
source: r.source,
source_label: SOURCE_LABELS[r.source] ?? `Source ${r.source ?? 'Unknown'}`,
count: parseInt(r.count),
pct: totalCreated > 0 ? Math.round((parseInt(r.count) / totalCreated) * 100) : 0,
})),
by_queue: byQueueR.rows.map(r => ({
queue_id: r.queue_id,
queue_label: r.queue_label || `Queue ${r.queue_id}`,
count: parseInt(r.count),
resolved: parseInt(r.resolved) || 0,
avg_resolve_hrs: r.avg_resolve_hrs ? parseFloat(r.avg_resolve_hrs) : null,
})),
by_priority: byPriorityR.rows.map(r => ({
priority: r.priority,
priority_label: PRIORITY_LABELS[r.priority] ?? `Priority ${r.priority ?? 'Unknown'}`,
count: parseInt(r.count),
})),
by_issue_type: byIssueTypeR.rows.map(r => ({
issue_type: r.issue_type,
issue_label: r.issue_label || `Type ${r.issue_type}`,
count: parseInt(r.count),
})),
top_clients: topClientsR.rows.map(r => ({
company_id: r.company_id,
company_name: r.company_name,
ticket_count: parseInt(r.ticket_count),
hours_worked: parseFloat(r.hours_worked) || 0,
})),
top_resources: topResourcesR.rows.map(r => ({
resource_id: r.resource_id,
resource_name: r.resource_name,
tickets_touched: parseInt(r.tickets_touched),
hours_worked: parseFloat(r.hours_worked) || 0,
})),
noise_candidates: noiseR.rows.map(r => ({
title: r.title,
count: parseInt(r.count),
source: r.source,
source_label: SOURCE_LABELS[r.source] ?? `Source ${r.source}`,
avg_resolve_min: r.avg_resolve_min ? parseFloat(r.avg_resolve_min) : null,
sample_id: parseInt(r.sample_id),
})),
monitor_tickets: {
total: parseInt(monRow.total) || 0,
auto_resolved: parseInt(monRow.auto_resolved) || 0,
pct_of_all: totalCreated > 0 ? Math.round((parseInt(monRow.total) / totalCreated) * 100) : 0,
},
sla: {
first_response_met: parseInt(slaRow.fr_met) || 0,
first_response_missed: parseInt(slaRow.fr_missed) || 0,
resolution_met: parseInt(slaRow.res_met) || 0,
resolution_missed: parseInt(slaRow.res_missed) || 0,
},
comparison: {
prev_total_created: prevCreated,
prev_total_resolved: prevResolved,
prev_avg_resolution_hours: prevOv.avg_resolution_hours ? parseFloat(prevOv.avg_resolution_hours) : null,
prev_total_hours_worked: parseFloat(prevOv.total_hours_worked) || 0,
created_delta_pct: deltaPct(totalCreated, prevCreated),
resolved_delta_pct: deltaPct(parseInt(ov.total_resolved) || 0, prevResolved),
},
};
}
// ──────────────────────────────────────────────────────────────
// LLM Analysis
// ──────────────────────────────────────────────────────────────
async analyzeWithLLM(stats: TicketDigestStats, config: DigestConfig): Promise<{ analysis: string; tokensUsed: number }> {
const apiKey = config.llm_provider === 'anthropic'
? process.env.ANTHROPIC_API_KEY || ''
: process.env.OPENAI_API_KEY || '';
if (!apiKey) {
// Also check workflow_settings table
const keyRow = await postgresClient.query(
`SELECT value FROM workflow_settings WHERE key = $1`,
[config.llm_provider === 'anthropic' ? 'anthropic_api_key' : 'openai_api_key']
);
const dbKey = keyRow.rows[0]?.value?.replace(/"/g, '') || '';
if (!dbKey) {
return { analysis: 'LLM API key not configured. Configure it in Admin → Workflow Settings.', tokensUsed: 0 };
}
return this.callLLM(stats, config, dbKey);
}
return this.callLLM(stats, config, apiKey);
}
private async callLLM(stats: TicketDigestStats, config: DigestConfig, apiKey: string): Promise<{ analysis: string; tokensUsed: number }> {
const systemPrompt = `You are an IT service desk analyst for a managed service provider (MSP). You produce concise, actionable digest reports for management.
Your analysis should be structured with these sections (use markdown headers):
${config.include_noise_analysis ? '- **Noise & Automation**: Identify repetitive/auto-generated tickets that could be suppressed or auto-resolved. Quantify the noise.' : ''}
${config.include_sla_analysis ? '- **SLA & Response Times**: Analyze first response and resolution times. Call out any concerning trends.' : ''}
${config.include_resource_analysis ? '- **Team Workload**: Analyze resource utilization. Flag overloaded or underutilized engineers.' : ''}
${config.include_client_analysis ? '- **Client Spotlight**: Highlight clients with unusual ticket volume or patterns worth attention.' : ''}
${config.include_recommendations ? '- **Recommendations**: 3-5 specific, actionable items to reduce noise, improve response times, or optimize workflows.' : ''}
Rules:
- Be direct and data-driven. Reference specific numbers from the data.
- Keep the total response under 800 words.
- Focus on anomalies and actionable findings, not restating obvious stats.
- If noise candidates repeat 10+ times, strongly recommend automation or suppression.
- Compare with previous period where relevant.`;
const dataPayload = JSON.stringify({
period: stats.period,
overview: stats.overview,
comparison: stats.comparison,
by_source: stats.by_source.slice(0, 8),
by_queue: stats.by_queue.slice(0, 10),
by_priority: stats.by_priority,
top_clients: stats.top_clients.slice(0, 8),
top_resources: stats.top_resources.slice(0, 8),
noise_candidates: stats.noise_candidates.slice(0, 15),
monitor_tickets: stats.monitor_tickets,
sla: stats.sla,
}, null, 2);
const userPrompt = `Analyze this ${stats.period.type} ticket digest for ${stats.period.label}:\n\n${dataPayload}`;
if (config.llm_provider === 'anthropic') {
const response = 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: config.llm_model || 'claude-sonnet-4-20250514',
max_tokens: 2000,
temperature: 0.3,
system: systemPrompt,
messages: [{ role: 'user', content: userPrompt }],
}),
});
if (!response.ok) {
const err = await response.text();
throw new Error(`Anthropic API error (${response.status}): ${err}`);
}
const data = await response.json();
const text = data.content?.find((b: any) => b.type === 'text')?.text || '';
const tokensUsed = (data.usage?.input_tokens || 0) + (data.usage?.output_tokens || 0);
return { analysis: text, tokensUsed };
} else {
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: config.llm_model || 'gpt-4o',
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
temperature: 0.3,
max_tokens: 2000,
}),
});
if (!response.ok) {
const err = await response.text();
throw new Error(`OpenAI API error (${response.status}): ${err}`);
}
const data = await response.json();
const text = data.choices?.[0]?.message?.content || '';
const tokensUsed = (data.usage?.total_tokens) || 0;
return { analysis: text, tokensUsed };
}
}
// ──────────────────────────────────────────────────────────────
// Adaptive Card Builder
// ──────────────────────────────────────────────────────────────
buildAdaptiveCard(stats: TicketDigestStats, analysis: string): object {
const ov = stats.overview;
const cmp = stats.comparison;
const periodTitle = stats.period.type.charAt(0).toUpperCase() + stats.period.type.slice(1);
const headerText = `📊 ${periodTitle} Ticket Digest — ${stats.period.label}`;
const delta = (cur: number, prev: number | null | undefined): string => {
if (prev == null || prev === 0) return '';
const pct = Math.round(((cur - prev) / prev) * 100);
return pct > 0 ? `${pct}%` : pct < 0 ? `${Math.abs(pct)}%` : '';
};
const bodyItems: object[] = [
{ type: 'TextBlock', text: headerText, weight: 'Bolder', size: 'Large', wrap: true },
{
type: 'ColumnSet',
columns: [
{ type: 'Column', width: 'stretch', items: [{ type: 'TextBlock', text: `**${ov.total_created}** Created${cmp ? delta(ov.total_created, cmp.prev_total_created) : ''}`, wrap: true }] },
{ type: 'Column', width: 'stretch', items: [{ type: 'TextBlock', text: `**${ov.total_resolved}** Resolved${cmp ? delta(ov.total_resolved, cmp.prev_total_resolved) : ''}`, wrap: true }] },
{ type: 'Column', width: 'stretch', items: [{ type: 'TextBlock', text: `**${ov.avg_resolution_hours ?? '—'}h** Avg Resolve`, wrap: true }] },
{ type: 'Column', width: 'stretch', items: [{ type: 'TextBlock', text: `**${ov.total_hours_worked.toFixed(1)}h** Worked`, wrap: true }] },
],
},
];
// Noise highlight
if (stats.noise_candidates.length > 0) {
const topNoise = stats.noise_candidates.slice(0, 5);
const totalNoise = topNoise.reduce((s, n) => s + n.count, 0);
const noiseFacts = topNoise.map(n => ({
title: `${n.count}×`,
value: `${n.title} (${n.source_label})`,
}));
bodyItems.push(
{ type: 'TextBlock', text: `🔁 Top Noise — ${totalNoise} repetitive tickets`, weight: 'Bolder', spacing: 'Medium', wrap: true },
{ type: 'FactSet', facts: noiseFacts },
);
}
// Monitor tickets
if (stats.monitor_tickets.total > 0) {
bodyItems.push({
type: 'TextBlock',
text: `🤖 Monitor-generated: **${stats.monitor_tickets.total}** (${stats.monitor_tickets.pct_of_all}% of all) · ${stats.monitor_tickets.auto_resolved} auto-resolved (<30m)`,
spacing: 'Medium', wrap: true,
});
}
// SLA summary
const totalFR = stats.sla.first_response_met + stats.sla.first_response_missed;
const totalRes = stats.sla.resolution_met + stats.sla.resolution_missed;
if (totalFR > 0 || totalRes > 0) {
const frPct = totalFR > 0 ? Math.round((stats.sla.first_response_met / totalFR) * 100) : 0;
const resPct = totalRes > 0 ? Math.round((stats.sla.resolution_met / totalRes) * 100) : 0;
bodyItems.push({
type: 'TextBlock',
text: `⏱️ SLA: First Response **${frPct}%** met (≤1h) · Resolution **${resPct}%** met (≤24h)`,
spacing: 'Small', wrap: true,
});
}
// Top clients
if (stats.top_clients.length > 0) {
const clientFacts = stats.top_clients.slice(0, 5).map(c => ({
title: `${c.ticket_count} tickets`,
value: `${c.company_name} (${c.hours_worked.toFixed(1)}h)`,
}));
bodyItems.push(
{ type: 'TextBlock', text: '🏢 Top Clients', weight: 'Bolder', spacing: 'Medium', wrap: true },
{ type: 'FactSet', facts: clientFacts },
);
}
// LLM analysis section (split into paragraphs for readability)
if (analysis && analysis.length > 20) {
bodyItems.push(
{ type: 'TextBlock', text: '🧠 AI Analysis', weight: 'Bolder', size: 'Medium', spacing: 'Large', wrap: true },
);
// Truncate for Adaptive Card limits (~28KB) and split on headers
const truncated = analysis.substring(0, 3500);
const sections = truncated.split(/(?=^##?\s)/m).filter(s => s.trim());
for (const section of sections.slice(0, 6)) {
bodyItems.push({ type: 'TextBlock', text: section.trim(), wrap: true, spacing: 'Small' });
}
}
return {
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
type: 'AdaptiveCard',
version: '1.4',
body: bodyItems,
actions: [
{ type: 'Action.OpenUrl', title: 'Open Pulse', url: 'https://pulse.wulfconsulting.cloud' },
],
};
}
// ──────────────────────────────────────────────────────────────
// Delivery
// ──────────────────────────────────────────────────────────────
async deliver(card: object, stats: TicketDigestStats, channelIds?: number[]): Promise<DeliveryResult[]> {
const config = await this.getConfig();
const ids = channelIds ?? config.channel_ids ?? [];
if (ids.length === 0) return [];
const channelRows = await postgresClient.query(
'SELECT id, name, channel_type, config, is_active FROM notification_channels WHERE id = ANY($1)',
[ids]
);
const channels = channelRows.rows as NotificationChannel[];
const teamsEnvelope = {
type: 'message',
attachments: [{
contentType: 'application/vnd.microsoft.card.adaptive',
contentUrl: null,
content: card,
}],
};
const plainText = this.buildPlainTextSummary(stats);
const results: DeliveryResult[] = await Promise.all(
channels.map(async (ch): Promise<DeliveryResult> => {
try {
let res: Response;
if (ch.channel_type === 'teams') {
const url = ch.config.webhook_url;
if (!url) throw new Error('Teams channel missing webhook_url');
res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(teamsEnvelope),
});
} else if (ch.channel_type === 'telegram') {
const { bot_token, chat_id, parse_mode } = ch.config;
if (!bot_token || !chat_id) throw new Error('Telegram missing bot_token or chat_id');
res = await fetch(`https://api.telegram.org/bot${bot_token}/sendMessage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chat_id, text: plainText, parse_mode: parse_mode || 'HTML' }),
});
} else if (ch.channel_type === 'ntfy') {
const server = ch.config.server_url || 'https://ntfy.sh';
const topic = ch.config.topic;
if (!topic) throw new Error('ntfy missing topic');
const headers: Record<string, string> = { 'Content-Type': 'text/plain', 'Title': `Ticket Digest — ${stats.period.label}` };
if (ch.config.auth_token) headers['Authorization'] = `Bearer ${ch.config.auth_token}`;
if (ch.config.default_priority) headers['Priority'] = ch.config.default_priority;
res = await fetch(`${server}/${topic}`, { method: 'POST', headers, body: plainText });
} else {
const url = ch.config.url;
if (!url) throw new Error('Webhook channel missing url');
res = await fetch(url, {
method: ch.config.method || 'POST',
headers: { 'Content-Type': 'application/json', ...(ch.config.headers || {}) },
body: JSON.stringify({ title: `Ticket Digest — ${stats.period.label}`, text: plainText, stats: stats.overview }),
});
}
return { channelId: ch.id, label: ch.name, success: res.ok, httpStatus: res.status };
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
return { channelId: ch.id, label: ch.name, success: false, error };
}
})
);
return results;
}
private buildPlainTextSummary(stats: TicketDigestStats): string {
const ov = stats.overview;
const lines = [
`📊 Ticket Digest — ${stats.period.label}`,
`Created: ${ov.total_created} | Resolved: ${ov.total_resolved} | Open: ${ov.total_open_end}`,
`Avg Resolution: ${ov.avg_resolution_hours ?? '—'}h | Hours Worked: ${ov.total_hours_worked.toFixed(1)}h`,
];
if (stats.monitor_tickets.total > 0) {
lines.push(`Monitor alerts: ${stats.monitor_tickets.total} (${stats.monitor_tickets.pct_of_all}% of all, ${stats.monitor_tickets.auto_resolved} auto-resolved)`);
}
if (stats.noise_candidates.length > 0) {
lines.push(`Top noise: ${stats.noise_candidates.slice(0, 3).map(n => `${n.title} (${n.count}×)`).join(', ')}`);
}
return lines.join('\n');
}
// ──────────────────────────────────────────────────────────────
// Full Run
// ──────────────────────────────────────────────────────────────
async run(period: DigestPeriod, channelIds?: number[]): Promise<{
stats: TicketDigestStats;
analysis: string;
deliveryResults: DeliveryResult[];
processingTimeMs: number;
}> {
const startTime = Date.now();
const config = await this.getConfig();
console.log(`[TICKET-DIGEST] Generating ${period} report...`);
// 1. Aggregate data
const stats = await this.aggregate(period);
console.log(`[TICKET-DIGEST] Aggregated: ${stats.overview.total_created} created, ${stats.overview.total_resolved} resolved`);
// 2. LLM analysis
let analysis = '';
let tokensUsed = 0;
try {
const llmResult = await this.analyzeWithLLM(stats, config);
analysis = llmResult.analysis;
tokensUsed = llmResult.tokensUsed;
console.log(`[TICKET-DIGEST] LLM analysis complete (${tokensUsed} tokens)`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[TICKET-DIGEST] LLM analysis failed: ${msg}`);
analysis = `LLM analysis unavailable: ${msg}`;
}
// 3. Build card
const card = this.buildAdaptiveCard(stats, analysis);
// 4. Persist
const processingTimeMs = Date.now() - startTime;
await postgresClient.query(
`INSERT INTO ticket_digest_reports (period_type, period_start, period_end, stats, llm_analysis, card_payload, tokens_used, processing_time_ms)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[period, stats.period.start, stats.period.end, JSON.stringify(stats), analysis, JSON.stringify(card), tokensUsed, processingTimeMs]
);
// 5. Deliver
const deliveryResults = await this.deliver(card, stats, channelIds);
console.log(`[TICKET-DIGEST] Delivered to ${deliveryResults.filter(r => r.success).length}/${deliveryResults.length} channels`);
// Update delivery status
const statusMap: Record<number, object> = {};
for (const r of deliveryResults) {
statusMap[r.channelId] = { success: r.success, httpStatus: r.httpStatus, error: r.error };
}
await postgresClient.query(
`UPDATE ticket_digest_reports SET delivery_status = $1
WHERE id = (SELECT id FROM ticket_digest_reports ORDER BY generated_at DESC LIMIT 1)`,
[JSON.stringify(statusMap)]
);
return { stats, analysis, deliveryResults, processingTimeMs };
}
// ──────────────────────────────────────────────────────────────
// History
// ──────────────────────────────────────────────────────────────
async getHistory(limit = 20): Promise<Array<{
id: number;
period_type: string;
period_start: string;
period_end: string;
generated_at: string;
stats: TicketDigestStats;
llm_analysis: string | null;
delivery_status: object;
tokens_used: number | null;
processing_time_ms: number | null;
}>> {
const r = await postgresClient.query(
'SELECT * FROM ticket_digest_reports ORDER BY generated_at DESC LIMIT $1',
[limit]
);
return r.rows;
}
}
let _instance: TicketDigestService | null = null;
export function getTicketDigestService(): TicketDigestService {
if (!_instance) _instance = new TicketDigestService();
return _instance;
}

View file

@ -132,6 +132,39 @@ export class ZabbixClient {
});
}
/**
* Fetch PROBLEM trigger events (value=1) for a set of hostids within a time range.
* Used to build the local zabbix_events correlation cache.
*/
async getEvents(params: {
hostIds: string[];
from: Date;
to: Date;
limit?: number;
}): Promise<Array<{
eventid: string;
objectid: string;
name: string;
severity: string;
clock: string;
r_eventid: string;
r_clock: string;
hosts: Array<{ hostid: string }>;
}>> {
// problem.get supports r_clock output; event.get does not
return this.rpc('problem.get', {
output: ['eventid', 'objectid', 'name', 'severity', 'clock', 'r_eventid', 'r_clock'],
time_from: Math.floor(params.from.getTime() / 1000),
time_till: Math.floor(params.to.getTime() / 1000),
hostids: params.hostIds,
selectHosts: ['hostid'],
recent: false,
sortfield: 'eventid',
sortorder: 'DESC',
limit: params.limit ?? 10000,
});
}
/**
* Delete one or more hosts by their hostids.
*/
@ -261,4 +294,42 @@ export class ZabbixClient {
await this.rpc<{ hostids: string[] }>('host.update', updateParams);
return { action: 'updated', hostid: existing.hostid };
}
/**
* Find hosts by DNS name or IP address across all interfaces.
* Used to correlate Datto RMM ping targets with Zabbix hosts.
*/
async findHostsByInterface(dnsOrIp: string): Promise<Array<{ hostid: string; name: string }>> {
return this.rpc<Array<{ hostid: string; name: string }>>('host.get', {
output: ['hostid', 'name'],
filter: { ip: [dnsOrIp], dns: [dnsOrIp] },
searchByAny: true,
});
}
/**
* Get open problems for a host.
*/
async getOpenProblemsForHost(hostid: string): Promise<ZabbixProblem[]> {
return this.rpc<ZabbixProblem[]>('problem.get', {
output: 'extend',
hostids: [hostid],
recent: true,
selectAcknowledges: 'count',
selectSuppressionData: 'extend',
});
}
/**
* Suppress a Zabbix problem event until a given timestamp.
* action=16 = suppress, action=4 = acknowledge with message.
* We combine both (action=20) to add a note and suppress.
*/
async suppressProblem(eventid: string, _suppressUntil: Date, message: string): Promise<void> {
await this.rpc<{ eventids: string[] }>('event.acknowledge', {
eventids: [eventid],
action: 20, // 4 (add message) + 16 (suppress)
message,
});
}
}

161
lib/types/qbo.ts Normal file
View file

@ -0,0 +1,161 @@
export interface QboTokenRecord {
id: number;
realm_id: string;
access_token: string;
refresh_token: string;
access_token_expires_at: Date;
refresh_token_expires_at: Date;
created_at: Date;
updated_at: Date;
}
export interface QboTokenResponse {
access_token: string;
refresh_token: string;
expires_in: number;
x_refresh_token_expires_in: number;
token_type: string;
}
export interface QboRef {
value: string;
name?: string;
}
export interface QboMetaData {
CreateTime: string;
LastUpdatedTime: string;
}
export interface QboLineItem {
Id?: string;
LineNum?: number;
Description?: string;
Amount: number;
DetailType: string;
[key: string]: any;
}
export interface QboLinkedTxn {
TxnId: string;
TxnType: string;
TxnLineId?: string;
}
// Invoice
export interface QboInvoice {
Id: string;
SyncToken: string;
DocNumber?: string;
TxnDate?: string;
DueDate?: string;
CustomerRef?: QboRef;
BillEmail?: { Address?: string };
TotalAmt?: number;
Balance?: number;
EmailStatus?: string;
PrintStatus?: string;
CurrencyRef?: QboRef;
Line?: QboLineItem[];
LinkedTxn?: QboLinkedTxn[];
MetaData?: QboMetaData;
}
// Payment
export interface QboPayment {
Id: string;
SyncToken: string;
TxnDate?: string;
CustomerRef?: QboRef;
TotalAmt?: number;
UnappliedAmt?: number;
CurrencyRef?: QboRef;
PaymentMethodRef?: QboRef;
DepositToAccountRef?: QboRef;
Line?: Array<{ Amount: number; LinkedTxn?: QboLinkedTxn[] }>;
MetaData?: QboMetaData;
}
// Deposit
export interface QboDeposit {
Id: string;
SyncToken: string;
TxnDate?: string;
DepositToAccountRef?: QboRef;
TotalAmt?: number;
Line?: QboLineItem[];
MetaData?: QboMetaData;
}
// Purchase (expense/credit card)
export interface QboPurchase {
Id: string;
SyncToken: string;
TxnDate?: string;
DocNumber?: string;
EntityRef?: QboRef & { type?: string };
AccountRef?: QboRef;
TotalAmt?: number;
CurrencyRef?: QboRef;
PrivateNote?: string;
Line?: QboLineItem[];
MetaData?: QboMetaData;
}
// Journal Entry
export interface QboJournalEntry {
Id: string;
SyncToken: string;
TxnDate?: string;
DocNumber?: string;
TotalAmt?: number;
CurrencyRef?: QboRef;
PrivateNote?: string;
Line?: QboLineItem[];
MetaData?: QboMetaData;
}
// Report (P&L, Balance Sheet)
export interface QboReport {
Header?: {
ReportName?: string;
StartPeriod?: string;
EndPeriod?: string;
Time?: string;
Currency?: string;
ReportBasis?: string;
NoReportData?: string;
[key: string]: any;
};
Columns?: any;
Rows?: any;
}
export interface QboQueryResponse<T> {
QueryResponse: {
[key: string]: T[] | number | undefined;
startPosition?: number;
maxResults?: number;
totalCount?: number;
};
time: string;
}
export interface QboSyncResult {
syncId: string;
realmId: string;
status: 'completed' | 'failed';
startedAt: Date;
completedAt: Date;
duration: number;
entities: QboEntitySyncResult[];
errors: string[];
}
export interface QboEntitySyncResult {
entity: string;
success: boolean;
recordsUpserted: number;
duration: number;
error?: string;
}

View file

@ -28,7 +28,8 @@ export enum WebhookEntityType {
}
/**
* Autotask entities that support webhooks via their REST API
* Entities Autotask actually exposes a webhook REST endpoint for.
* TimeEntries, Tasks, Projects, Contracts return 404 not supported by Autotask.
*/
export const WEBHOOK_SUPPORTED_ENTITIES: WebhookEntityType[] = [
WebhookEntityType.COMPANIES,
@ -115,6 +116,10 @@ const ENTITY_TYPE_MAP: Record<string, WebhookEntityType> = {
'InstalledProduct': WebhookEntityType.CONFIGURATION_ITEMS, // Autotask actual payload name
'Ticket': WebhookEntityType.TICKETS,
'TicketNote': WebhookEntityType.TICKET_NOTES,
'TimeEntry': WebhookEntityType.TIME_ENTRIES,
'Task': WebhookEntityType.TASKS,
'Project': WebhookEntityType.PROJECTS,
'Contract': WebhookEntityType.CONTRACTS,
};
/**