/** * 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; 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 = { '-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 = { 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 { const r = await postgresClient.query('SELECT * FROM ticket_digest_config WHERE id = 1'); return r.rows[0] as DigestConfig; } async updateConfig(updates: Partial): Promise { 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 { 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 { 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 { 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 => { 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 = { '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 = {}; 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> { 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; }