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
2026-03-17 07:39:55 -04:00
/ * *
* 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 (
2026-05-11 06:42:52 -04:00
'SELECT id, name, channel_type, config, is_active, owner_user_id FROM notification_channels ORDER BY name'
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
2026-03-17 07:39:55 -04:00
) ;
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 (
2026-05-11 06:42:52 -04:00
'SELECT id, name, channel_type, config, is_active, owner_user_id FROM notification_channels WHERE id = ANY($1)' ,
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
2026-03-17 07:39:55 -04:00
[ 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' ) {
2026-05-11 06:42:52 -04:00
// Personal channels forced to company server + token (UAT-FIX-01).
// Global rows retain config-driven behavior so admin-configured
// digest channels keep working.
const isPersonal = ! ! ( ch as NotificationChannel & { owner_user_id? : string | null } ) . owner_user_id ;
const server = isPersonal
? ( process . env . NTFY_BASE_URL || 'https://ntfy.wulfconsulting.cloud' )
: ( ch . config . server_url || 'https://ntfy.sh' ) ;
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
2026-03-17 07:39:55 -04:00
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 } ` } ;
2026-05-11 06:42:52 -04:00
if ( isPersonal ) {
const token = process . env . NTFY_PULSE_TOKEN ;
if ( token ) headers [ 'Authorization' ] = ` Bearer ${ token } ` ;
} else if ( ch . config . auth_token ) {
headers [ 'Authorization' ] = ` Bearer ${ ch . config . auth_token } ` ;
}
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
2026-03-17 07:39:55 -04:00
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 ;
}