- 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
782 lines
35 KiB
TypeScript
782 lines
35 KiB
TypeScript
/**
|
||
* 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;
|
||
}
|