wulf-pulse/lib/services/ticket-digest-service.ts

783 lines
35 KiB
TypeScript
Raw Normal View History

/**
* 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;
}